feat: 新增文件库功能,支持局域网文件浏览与媒体播放
后端: - 新增 ManagedLibraryRoot / ManagedFileRecord 数据模型及 SQLite 迁移 - 新增文件库服务、端点服务及定时扫描后台任务 - 新增 REST API: drives、directories、roots CRUD、files 分页搜索、文本预览 - 新增文件流端点支持视频/音频流式传输 - 数据库切换为 SQLite,Kestrel 绑定 0.0.0.0 支持局域网访问 前端: - 管理端:磁盘浏览、目录选择、根目录添加/启用/删除/扫描 - 客户端:根目录选择、文件搜索/筛选/分页、音视频播放、文本预览 - 全新响应式 UI(桌面+移动端),CSS 变量设计系统 - HTTP 客户端支持 Vite 开发代理与生产同源自动切换 - 移除 HTTPS 强制重定向以提升移动端视频流兼容性
This commit is contained in:
+427
-39
@@ -1,47 +1,435 @@
|
||||
<script setup lang="ts">
|
||||
import HelloWorld from './components/HelloWorld.vue'
|
||||
import TheWelcome from './components/TheWelcome.vue'
|
||||
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 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()])
|
||||
}
|
||||
|
||||
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>
|
||||
<header>
|
||||
<img alt="Vue logo" class="logo" src="./assets/logo.svg" width="125" height="125" />
|
||||
<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>
|
||||
|
||||
<div class="wrapper">
|
||||
<HelloWorld msg="You did it!" />
|
||||
</div>
|
||||
</header>
|
||||
<p v-if="errorMessage" class="error-banner">{{ errorMessage }}</p>
|
||||
|
||||
<main>
|
||||
<TheWelcome />
|
||||
<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>
|
||||
<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>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
header {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: block;
|
||||
margin: 0 auto 2rem;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
header {
|
||||
display: flex;
|
||||
place-items: center;
|
||||
padding-right: calc(var(--section-gap) / 2);
|
||||
}
|
||||
|
||||
.logo {
|
||||
margin: 0 2rem 0 0;
|
||||
}
|
||||
|
||||
header .wrapper {
|
||||
display: flex;
|
||||
place-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,8 +4,20 @@ import { isWebView2 } from './env'
|
||||
// WebView2 自定义协议前缀
|
||||
const WEBVIEW2_BASE = 'app://api/'
|
||||
|
||||
// 普通浏览器 HTTP API 地址,按需修改
|
||||
const HTTP_BASE = 'http://localhost:5000/api/'
|
||||
// Vite 开发页走 5206 API;API 托管前端时使用同源地址。
|
||||
const isViteDevServer = window.location.port === '51552'
|
||||
const HTTP_ORIGIN = isViteDevServer
|
||||
? `${window.location.protocol}//${window.location.hostname || 'localhost'}:5206`
|
||||
: window.location.origin
|
||||
const HTTP_BASE = `${HTTP_ORIGIN}/api/`
|
||||
|
||||
export const apiOrigin = (): string => HTTP_ORIGIN
|
||||
|
||||
export const apiUrl = (path: string): string => {
|
||||
if (/^https?:\/\//i.test(path)) return path
|
||||
const normalized = path.startsWith('/') ? path : `/${path}`
|
||||
return `${isWebView2() ? '' : HTTP_ORIGIN}${normalized}`
|
||||
}
|
||||
|
||||
// ─── axios 实例 ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -31,9 +43,9 @@ http.interceptors.request.use((config) => {
|
||||
// WebView2 桥接和 HTTP 两个环境返回结构相同,拦截器可统一处理
|
||||
http.interceptors.response.use(
|
||||
(response) => {
|
||||
const payload = response.data as { success: boolean; data?: unknown; error?: string }
|
||||
const payload = response.data as { success: boolean; data?: unknown; error?: string; message?: string }
|
||||
if (payload?.success === false) {
|
||||
return Promise.reject(new Error(payload.error ?? '请求失败'))
|
||||
return Promise.reject(new Error(payload.error ?? payload.message ?? '请求失败'))
|
||||
}
|
||||
return (payload?.data ?? payload) as never
|
||||
},
|
||||
@@ -65,11 +77,9 @@ export async function request<T = unknown>(endpoint: string, options: RequestOpt
|
||||
headers: { 'Content-Type': 'application/json', ...(options.headers ?? {}) },
|
||||
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
|
||||
})
|
||||
const payload = await res.json() as { success: boolean; data?: T; error?: string }
|
||||
// eslint-disable-next-line no-debugger
|
||||
debugger
|
||||
const payload = await res.json() as { success: boolean; data?: T; error?: string; message?: string }
|
||||
if (payload?.success === false) {
|
||||
throw new Error(payload.error ?? '请求失败')
|
||||
throw new Error(payload.error ?? payload.message ?? '请求失败')
|
||||
}
|
||||
return (payload?.data ?? payload) as T
|
||||
}
|
||||
|
||||
@@ -1,8 +1,93 @@
|
||||
import { request } from './http'
|
||||
import { apiUrl, request } from './http'
|
||||
|
||||
export type MediaType = 'all' | 'text' | 'video' | 'audio'
|
||||
|
||||
export interface DriveDto {
|
||||
name: string
|
||||
displayName: string
|
||||
rootDirectory: string
|
||||
driveType: string
|
||||
totalSize: number | null
|
||||
availableFreeSpace: number | null
|
||||
isReady: boolean
|
||||
}
|
||||
|
||||
export interface DirectoryDto {
|
||||
name: string
|
||||
fullPath: string
|
||||
}
|
||||
|
||||
export interface LibraryRootDto {
|
||||
id: number
|
||||
path: string
|
||||
displayName: string
|
||||
isEnabled: boolean
|
||||
isAvailable: boolean
|
||||
scanIntervalMinutes: number
|
||||
lastScanStartedAt: string | null
|
||||
lastScanCompletedAt: string | null
|
||||
lastScanError: string | null
|
||||
fileCount: number
|
||||
}
|
||||
|
||||
export interface FileRecordDto {
|
||||
id: number
|
||||
libraryRootId: number
|
||||
fileName: string
|
||||
relativePath: string
|
||||
extension: string
|
||||
sizeBytes: number
|
||||
lastWriteTimeUtc: string
|
||||
mediaType: 'text' | 'video' | 'audio'
|
||||
contentType: string
|
||||
streamUrl: string
|
||||
textUrl: string | null
|
||||
browserPlayable: boolean
|
||||
}
|
||||
|
||||
export interface TextPreviewDto {
|
||||
id: number
|
||||
fileName: string
|
||||
content: string
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
export interface PagedResponse<T> {
|
||||
items: T[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
const qs = (params: Record<string, string | number | undefined | null>) => {
|
||||
const search = new URLSearchParams()
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== undefined && value !== null && value !== '') search.set(key, String(value))
|
||||
}
|
||||
const value = search.toString()
|
||||
return value ? `?${value}` : ''
|
||||
}
|
||||
|
||||
// 业务接口定义,新增接口在此处添加一行即可
|
||||
export const api = {
|
||||
getUser: () => request('getUser'),
|
||||
getUser: () => request('getUser'),
|
||||
processData: (input: string) => request('processData', { method: 'POST', body: { input } }),
|
||||
wData: (input: string) => request('wData', { method: 'POST', body: { input } }),
|
||||
wData: (input: string) => request('wData', { method: 'POST', body: { input } }),
|
||||
getDrives: () => request<DriveDto[]>('library/drives'),
|
||||
getDirectories: (path: string) => request<DirectoryDto[]>(`library/directories${qs({ path })}`),
|
||||
getRoots: () => request<LibraryRootDto[]>('library/roots'),
|
||||
addRoot: (body: { path: string; displayName?: string; scanIntervalMinutes?: number }) =>
|
||||
request<LibraryRootDto>('library/roots', { method: 'POST', body }),
|
||||
setRootEnabled: (id: number, isEnabled: boolean) =>
|
||||
request<LibraryRootDto>('library/roots/enabled', { method: 'POST', body: { id, isEnabled } }),
|
||||
deleteRoot: (id: number) =>
|
||||
request('library/roots/delete', { method: 'POST', body: { id } }),
|
||||
scanRoot: (id: number) =>
|
||||
request<LibraryRootDto>('library/roots/scan', { method: 'POST', body: { id } }),
|
||||
searchFiles: (params: { page: number; pageSize: number; mediaType?: MediaType; keyword?: string; rootId?: number }) =>
|
||||
request<PagedResponse<FileRecordDto>>(`files${qs(params)}`),
|
||||
getTextPreview: (id: number) =>
|
||||
request<TextPreviewDto>(`files/text${qs({ id })}`),
|
||||
mediaUrl: (path: string) => apiUrl(path),
|
||||
}
|
||||
|
||||
@@ -1,35 +1,652 @@
|
||||
@import './base.css';
|
||||
|
||||
#app {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
font-weight: normal;
|
||||
:root {
|
||||
--page: #f5f7fb;
|
||||
--panel: #ffffff;
|
||||
--panel-strong: #101828;
|
||||
--line: #d9e1ea;
|
||||
--text: #17202c;
|
||||
--muted: #667085;
|
||||
--accent: #0f766e;
|
||||
--accent-strong: #115e59;
|
||||
--danger: #b42318;
|
||||
--danger-bg: #fff2f0;
|
||||
--shadow: 0 18px 45px rgba(16, 24, 40, 0.10);
|
||||
}
|
||||
|
||||
a,
|
||||
.green {
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
min-width: 320px;
|
||||
background: var(--page);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button,
|
||||
a {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
button {
|
||||
min-height: 38px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 0 13px;
|
||||
color: var(--text);
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 0 12px;
|
||||
color: var(--text);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
transition: 0.4s;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
a:hover {
|
||||
background-color: hsla(160, 100%, 37%, 0.2);
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
color: #fff;
|
||||
border-color: var(--accent);
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.secondary-button {
|
||||
color: var(--text);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.danger-button {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.text-button {
|
||||
min-height: 30px;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: var(--accent);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
border: 1px solid #f1b8b2;
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
color: var(--danger);
|
||||
background: var(--danger-bg);
|
||||
}
|
||||
|
||||
.empty-state,
|
||||
.hint,
|
||||
.unsupported {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.admin-shell {
|
||||
width: min(1480px, 100%);
|
||||
margin: 0 auto;
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.admin-hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 28px;
|
||||
align-items: end;
|
||||
border-radius: 18px;
|
||||
padding: 34px;
|
||||
color: #fff;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(15, 118, 110, 0.96), rgba(16, 24, 40, 0.98)),
|
||||
#101828;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 10px;
|
||||
color: #a7f3d0;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.admin-hero h1 {
|
||||
margin: 0;
|
||||
font-size: 36px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.admin-hero p {
|
||||
max-width: 720px;
|
||||
margin: 12px 0 0;
|
||||
color: #d9f5ef;
|
||||
}
|
||||
|
||||
.admin-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 120px);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.admin-metrics div {
|
||||
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||
border-radius: 14px;
|
||||
padding: 16px;
|
||||
background: rgba(255, 255, 255, 0.10);
|
||||
}
|
||||
|
||||
.admin-metrics strong {
|
||||
display: block;
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.admin-metrics span {
|
||||
color: #d9f5ef;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.admin-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(420px, 0.9fr) minmax(0, 1.1fr);
|
||||
gap: 18px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.admin-card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
padding: 22px;
|
||||
background: var(--panel);
|
||||
box-shadow: 0 10px 24px rgba(16, 24, 40, 0.05);
|
||||
}
|
||||
|
||||
.card-heading,
|
||||
.browser-header,
|
||||
.inline-form,
|
||||
.root-actions,
|
||||
.root-main,
|
||||
.root-meta,
|
||||
.mobile-header,
|
||||
.filter-row,
|
||||
.player-title,
|
||||
.mobile-pager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-heading,
|
||||
.browser-header,
|
||||
.mobile-header,
|
||||
.player-title {
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.card-heading h2,
|
||||
.drive-list h3,
|
||||
.directory-list h3 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.card-heading p {
|
||||
margin: 5px 0 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.client-link,
|
||||
.admin-link {
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 8px 14px;
|
||||
color: var(--accent-strong);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.field span {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.inline-form {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.inline-form button {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.admin-browser {
|
||||
display: grid;
|
||||
grid-template-columns: 0.8fr 1.2fr;
|
||||
gap: 14px;
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.drive-list,
|
||||
.directory-list {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 10px;
|
||||
min-height: 420px;
|
||||
}
|
||||
|
||||
.drive-row,
|
||||
.directory-row {
|
||||
display: grid;
|
||||
justify-items: start;
|
||||
width: 100%;
|
||||
min-height: 54px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.drive-row span,
|
||||
.directory-row {
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.drive-row small,
|
||||
.current-path {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.current-path {
|
||||
overflow-wrap: anywhere;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.root-table {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.root-item {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.root-main {
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.root-main strong {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.root-main p,
|
||||
.root-error {
|
||||
margin: 4px 0 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.root-error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.root-meta {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.root-meta span {
|
||||
border-radius: 999px;
|
||||
padding: 4px 9px;
|
||||
background: #f2f4f7;
|
||||
}
|
||||
|
||||
.root-actions {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
flex: 0 0 auto;
|
||||
border-radius: 999px;
|
||||
padding: 5px 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.status-pill.ok {
|
||||
color: #067647;
|
||||
background: #dcfae6;
|
||||
}
|
||||
|
||||
.status-pill.bad {
|
||||
color: var(--danger);
|
||||
background: var(--danger-bg);
|
||||
}
|
||||
|
||||
.client-shell {
|
||||
width: min(860px, 100%);
|
||||
margin: 0 auto;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.mobile-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
margin: -14px -14px 12px;
|
||||
padding: 16px 14px 12px;
|
||||
background: rgba(245, 247, 251, 0.94);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.mobile-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
border-radius: 999px;
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
|
||||
.mobile-header h1 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.mobile-header p,
|
||||
.player-title p,
|
||||
.mobile-file small {
|
||||
margin: 3px 0 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.mobile-filters,
|
||||
.player-panel,
|
||||
.mobile-list,
|
||||
.root-picker {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
padding: 12px;
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.root-picker {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.root-tile {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 4px 12px;
|
||||
width: 100%;
|
||||
min-height: 76px;
|
||||
padding: 13px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.root-tile span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.root-tile strong {
|
||||
grid-row: span 2;
|
||||
align-self: center;
|
||||
color: var(--accent-strong);
|
||||
font-size: 24px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.root-tile small {
|
||||
overflow: hidden;
|
||||
color: var(--muted);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.filter-row select {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.filter-row button {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.player-panel {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.player-title {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.player-title h2 {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.player-title span {
|
||||
flex: 0 0 auto;
|
||||
border-radius: 999px;
|
||||
padding: 4px 9px;
|
||||
color: var(--accent-strong);
|
||||
background: #ccfbef;
|
||||
font-size: 12px;
|
||||
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%;
|
||||
margin-top: 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
color: var(--accent-strong);
|
||||
background: #f0fdfa;
|
||||
text-align: center;
|
||||
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;
|
||||
margin-top: 12px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.mobile-file {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
min-height: 68px;
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.mobile-file.active {
|
||||
border-color: var(--accent);
|
||||
background: #ecfdf5;
|
||||
}
|
||||
|
||||
.mobile-file > span:last-child {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mobile-file strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
flex: 0 0 52px;
|
||||
border-radius: 999px;
|
||||
padding: 4px 8px;
|
||||
color: #fff;
|
||||
background: var(--accent-strong);
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.mobile-pager {
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 14px 0 4px;
|
||||
}
|
||||
|
||||
.empty-state,
|
||||
.unsupported {
|
||||
margin: 22px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.admin-layout,
|
||||
.admin-browser {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.admin-metrics {
|
||||
grid-template-columns: repeat(3, minmax(90px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
body {
|
||||
display: flex;
|
||||
place-items: center;
|
||||
@media (max-width: 780px) {
|
||||
.admin-shell {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
padding: 0 2rem;
|
||||
.admin-hero {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.admin-hero h1 {
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
.admin-metrics {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.inline-form,
|
||||
.filter-row {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.inline-form button,
|
||||
.filter-row button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.client-shell {
|
||||
padding-top: 24px;
|
||||
}
|
||||
|
||||
.mobile-header {
|
||||
position: static;
|
||||
margin: 0 0 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
padding: 18px;
|
||||
background: var(--panel);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import plugin from '@vitejs/plugin-vue';
|
||||
import { defineConfig } from 'vite'
|
||||
import plugin from '@vitejs/plugin-vue'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [plugin()],
|
||||
server: {
|
||||
port: 51552,
|
||||
}
|
||||
plugins: [plugin()],
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
port: 51552,
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user