feat(document): 为文档添加下载与预览界面:
All checks were successful
deploy to server / build-and-deploy (push) Successful in 2m58s
All checks were successful
deploy to server / build-and-deploy (push) Successful in 2m58s
- 添加路由: download路由用于下载文件, preview路由用于文件预览 - 添加组件: FilePreviewer封装了若干格式文件的预览功能(目前支持pdf, image, text, video等) - 服务端API: 添加download和file分别用于处理文件下载请求与元数据获取请求 - 类型标注调整: 将原先位于app/types内的类型标注文件移至shared/types内,让app与server共享类型标注 - 国际化文本添加: 为相关页面添加国际化文本
This commit is contained in:
@ -4,6 +4,7 @@
|
||||
v-for="(doc, index) in documents"
|
||||
:key="index"
|
||||
class="document-card"
|
||||
@click="handleClick(doc.fileId)"
|
||||
>
|
||||
<div class="document-info">
|
||||
<h3>{{ doc.title }}</h3>
|
||||
@ -15,13 +16,6 @@
|
||||
>格式:
|
||||
{{ formatFileExtension(getFileExtension(doc.filename)) }}</span
|
||||
>
|
||||
<el-button
|
||||
class="download-button"
|
||||
type="primary"
|
||||
@click="handleDownload(doc.title, doc.url)"
|
||||
>
|
||||
下载
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
@ -36,18 +30,13 @@
|
||||
},
|
||||
});
|
||||
|
||||
const handleDownload = async (fileName: string, fileUrl: string) => {
|
||||
const response = await fetch(fileUrl);
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const localePath = useLocalePath();
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = fileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
const handleClick = (id: string) => {
|
||||
// 获取路由参数
|
||||
if (id) {
|
||||
navigateTo(localePath(`/download/${id}`));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
@ -59,6 +48,16 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.document-card {
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.document-card:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.document-meta {
|
||||
font-size: 0.8rem;
|
||||
color: var(--el-text-color-secondary);
|
||||
|
||||
177
app/components/FilePreviewer.vue
Normal file
177
app/components/FilePreviewer.vue
Normal file
@ -0,0 +1,177 @@
|
||||
<template>
|
||||
<section class="h-screen flex flex-col">
|
||||
<!-- 头部工具栏 -->
|
||||
<header
|
||||
v-if="showToolbar && fileMeta"
|
||||
class="p-3 border-b flex items-center justify-between"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<h2 class="truncate font-medium" :title="fileMeta.filename_download">
|
||||
{{ fileMeta.filename_download }}
|
||||
</h2>
|
||||
<p class="text-xs text-gray-500">
|
||||
{{ fileMeta.type }} · {{ formatedSize }} · {{ formatedDate }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="shrink-0 flex items-center gap-2">
|
||||
<button
|
||||
class="px-3 py-1.5 rounded border hover:bg-gray-50"
|
||||
type="button"
|
||||
:disabled="!fileMeta"
|
||||
@click="openInNewTab"
|
||||
>
|
||||
在新标签打开
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-1.5 rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
type="button"
|
||||
:disabled="!fileMeta"
|
||||
@click="download"
|
||||
>
|
||||
下载
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="pending" class="h-48 grid place-items-center border rounded">
|
||||
正在加载...
|
||||
</div>
|
||||
<div
|
||||
v-else-if="errorText"
|
||||
class="h-48 grid place-items-center border rounded text-red-600"
|
||||
>
|
||||
{{ errorText }}
|
||||
</div>
|
||||
<!-- 文件预览 -->
|
||||
<ClientOnly v-else>
|
||||
<div
|
||||
v-if="fileMeta && previewable"
|
||||
class="h-full w-full flex justify-center bg-gray-50"
|
||||
>
|
||||
<!-- 图片 -->
|
||||
<el-image
|
||||
v-if="isImage"
|
||||
fit="contain"
|
||||
class="max-w-full max-h-full select-none"
|
||||
:src="src"
|
||||
:alt="fileMeta.title || fileMeta.filename_download"
|
||||
/>
|
||||
|
||||
<!-- PDF -->
|
||||
<iframe
|
||||
v-else-if="isPdf"
|
||||
:src="src"
|
||||
title="PDF 预览"
|
||||
class="w-full h-full border-0"
|
||||
/>
|
||||
|
||||
<!-- 视频 -->
|
||||
<video
|
||||
v-else-if="isVideo"
|
||||
:src="src"
|
||||
controls
|
||||
class="w-full bg-black"
|
||||
/>
|
||||
|
||||
<!-- 文本(简单方式用 iframe;如需代码高亮可改为拉取文本并渲染 <pre>) -->
|
||||
<iframe
|
||||
v-else-if="isText"
|
||||
:src="src"
|
||||
title="文本预览"
|
||||
class="w-full h-full border-0"
|
||||
/>
|
||||
</div>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 预览的文件 ID */
|
||||
fileId?: string;
|
||||
file?: FileMeta;
|
||||
|
||||
/** 是否显示上方工具栏(文件名、大小、按钮) */
|
||||
showToolbar?: boolean;
|
||||
|
||||
/** 下载 API 基础路径(你的后端流接口),用于“下载”按钮 */
|
||||
downloadApiBase?: string;
|
||||
|
||||
/** 追加到 file.url 的查询(如临时 token),形如 { token: 'xxx' } */
|
||||
extraQuery?: Record<string, string | number | boolean>;
|
||||
}>(),
|
||||
{
|
||||
fileId: undefined,
|
||||
file: undefined,
|
||||
showToolbar: true,
|
||||
downloadApiBase: '/api/download',
|
||||
extraQuery: undefined,
|
||||
}
|
||||
);
|
||||
|
||||
const { data, pending, error } = await useFetch<FileMeta>(
|
||||
() => (props.file ? null : `/api/file/${props.fileId}`),
|
||||
{ server: true }
|
||||
);
|
||||
|
||||
const errorText = computed(() => error.value?.message ?? null);
|
||||
const fileMeta = computed(() => props.file ?? data.value ?? null);
|
||||
|
||||
/** 预览源地址:支持在 file.url 上追加额外 query(如临时 token、inline) */
|
||||
const src = computed<string>(() => {
|
||||
if (!fileMeta.value) return '';
|
||||
const url = new URL(fileMeta.value.url, window?.location?.origin);
|
||||
if (props.extraQuery) {
|
||||
Object.entries(props.extraQuery).forEach(([k, v]) =>
|
||||
url.searchParams.set(k, String(v))
|
||||
);
|
||||
}
|
||||
return url.toString();
|
||||
});
|
||||
|
||||
/** 类型判定 */
|
||||
const isImage = computed(
|
||||
() => fileMeta.value?.type.startsWith('image/') === true
|
||||
);
|
||||
const isPdf = computed(() => fileMeta.value?.type === 'application/pdf');
|
||||
const isVideo = computed(
|
||||
() => fileMeta.value?.type.startsWith('video/') === true
|
||||
);
|
||||
const isText = computed(
|
||||
() => fileMeta.value?.type.startsWith('text/') === true
|
||||
);
|
||||
const previewable = computed(() => fileMeta.value?.previewable === true);
|
||||
|
||||
const formatedSize = computed(() => {
|
||||
const size = fileMeta.value?.filesize ?? 0;
|
||||
return formatFileSize(size);
|
||||
});
|
||||
|
||||
const formatedDate = computed(() => {
|
||||
if (!fileMeta.value?.uploaded_on) return '';
|
||||
return new Date(fileMeta.value.uploaded_on).toLocaleDateString();
|
||||
});
|
||||
|
||||
/** 下载动作:走你自己的流式后端,避免直链暴露(便于权限与统计) */
|
||||
function download(): void {
|
||||
if (!fileMeta.value) return;
|
||||
const id = fileMeta.value.id;
|
||||
const a = document.createElement('a');
|
||||
a.href = `${props.downloadApiBase}/${encodeURIComponent(id)}`;
|
||||
a.download = fileMeta.value.filename_download;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
}
|
||||
|
||||
/** 新标签打开(直接访问直链,适合预览失败时的兜底体验) */
|
||||
function openInNewTab(): void {
|
||||
if (!src.value) return;
|
||||
window.open(src.value, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
</script>
|
||||
5
app/layouts/preview.vue
Normal file
5
app/layouts/preview.vue
Normal file
@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@ -23,6 +23,7 @@ export function toProductDocumentView(
|
||||
|
||||
return {
|
||||
id: raw.id,
|
||||
fileId: fileId,
|
||||
filename: file.filename_download,
|
||||
title: trans.title,
|
||||
url: url,
|
||||
@ -68,6 +69,7 @@ export function toDocumentListView(raw: ProductDocument): DocumentListView {
|
||||
|
||||
return {
|
||||
id: raw.id,
|
||||
fileId: fileId,
|
||||
filename: file.filename_download,
|
||||
title: trans.title,
|
||||
url: url,
|
||||
|
||||
@ -25,6 +25,9 @@ export interface DocumentListView {
|
||||
/** 唯一标识符 **/
|
||||
id: number;
|
||||
|
||||
/** 文件UUID **/
|
||||
fileId: string;
|
||||
|
||||
/** 文件名 **/
|
||||
filename: string;
|
||||
|
||||
|
||||
@ -6,6 +6,9 @@ export interface ProductDocumentView {
|
||||
/** 唯一标识符 **/
|
||||
id: number;
|
||||
|
||||
/** 文件UUID **/
|
||||
fileId: string;
|
||||
|
||||
/** 文件名 **/
|
||||
filename: string;
|
||||
|
||||
|
||||
133
app/pages/download/[id].vue
Normal file
133
app/pages/download/[id].vue
Normal file
@ -0,0 +1,133 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">文件下载</h1>
|
||||
<el-breadcrumb class="breadcrumb">
|
||||
<el-breadcrumb-item class="text-md opacity-50">
|
||||
<NuxtLink :to="$localePath('/')">{{
|
||||
$t('navigation.home')
|
||||
}}</NuxtLink>
|
||||
</el-breadcrumb-item>
|
||||
<el-breadcrumb-item class="text-md opacity-50">
|
||||
<NuxtLink :to="$localePath('/products')">{{
|
||||
$t('navigation.downloads')
|
||||
}}</NuxtLink>
|
||||
</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
</div>
|
||||
<div v-if="!pending" class="page-content">
|
||||
<el-card shadow="hover" class="p-4">
|
||||
<template #header>
|
||||
<div class="header-content">
|
||||
<el-icon class="header-icon"><ElIconDocument /></el-icon>
|
||||
<span class="truncate font-medium">{{
|
||||
file.filename_download
|
||||
}}</span>
|
||||
</div>
|
||||
</template>
|
||||
<dl class="text-gray-600 space-y-1 mb-6">
|
||||
<div>
|
||||
<dt class="font-semibold inline">类型:</dt>
|
||||
<dd class="inline">{{ file.type }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="font-semibold inline">大小:</dt>
|
||||
<dd class="inline">{{ formatFileSize(file.filesize) }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="font-semibold inline">上传时间:</dt>
|
||||
<dd class="inline">
|
||||
{{ new Date(file.uploaded_on).toLocaleDateString() }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<template #footer>
|
||||
<div class="button-group">
|
||||
<el-button type="primary" @click="handleDownload">下载</el-button>
|
||||
<el-button v-if="file.previewable" @click="handlePreview"
|
||||
>预览</el-button
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
</el-card>
|
||||
</div>
|
||||
<div v-else>
|
||||
<el-skeleton :rows="6" animated />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
// 获取路由参数
|
||||
const id = computed(() => route.params.id as string);
|
||||
|
||||
const {
|
||||
data: file,
|
||||
pending,
|
||||
error,
|
||||
} = await useFetch<FileMeta>(`/api/file/${id.value}`);
|
||||
|
||||
if (error.value || !file.value) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: '文件未找到',
|
||||
});
|
||||
}
|
||||
|
||||
function handleDownload() {
|
||||
const link = document.createElement('a');
|
||||
link.href = `/api/download/${id.value}`;
|
||||
link.download = file.value?.filename_download ?? 'download';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
function handlePreview() {
|
||||
router.push(`/preview/${id.value}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-container {
|
||||
padding: 2rem;
|
||||
margin: 0 auto;
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
color: var(--el-color-primary);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.header-icon {
|
||||
font-size: 1.5rem;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: baseline;
|
||||
gap: 1rem;
|
||||
}
|
||||
</style>
|
||||
13
app/pages/preview/[id].vue
Normal file
13
app/pages/preview/[id].vue
Normal file
@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<file-previewer :file-id="id" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: 'preview',
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const id = computed(() => route.params.id as string);
|
||||
</script>
|
||||
@ -34,6 +34,7 @@
|
||||
"support": "Support",
|
||||
"about-us": "About Us",
|
||||
"contact-info": "Contact Info",
|
||||
"downloads": "Downloads",
|
||||
"faq": "FAQ",
|
||||
"documents": "Documents",
|
||||
"calculator": "Calculator"
|
||||
|
||||
@ -34,6 +34,7 @@
|
||||
"support": "服务支持",
|
||||
"about-us": "关于我们",
|
||||
"contact-info": "联系信息",
|
||||
"downloads": "文件下载",
|
||||
"faq": "常见问题",
|
||||
"documents": "文档资料",
|
||||
"calculator": "纸管计算工具"
|
||||
|
||||
22
server/api/download/[id].ts
Normal file
22
server/api/download/[id].ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { getFileMeta } from '../../utils/file';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const id = getRouterParam(event, 'id');
|
||||
if (!id) {
|
||||
throw createError({ statusCode: 400, message: '缺少文件ID' });
|
||||
}
|
||||
const file = await getFileMeta(id);
|
||||
if (!file) {
|
||||
throw createError({ statusCode: 404, message: '文件不存在' });
|
||||
}
|
||||
|
||||
const res = await $fetch<ArrayBuffer>(file.url, {
|
||||
responseType: 'arrayBuffer',
|
||||
});
|
||||
return new Response(res, {
|
||||
headers: {
|
||||
'Content-Disposition': `attachment; filename="${encodeURIComponent(file.filename_download)}"`,
|
||||
'Content-Type': file.type,
|
||||
},
|
||||
});
|
||||
});
|
||||
24
server/api/file/[id].ts
Normal file
24
server/api/file/[id].ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { getFileMeta } from '../../utils/file';
|
||||
|
||||
/**
|
||||
* 用于处理文件相关的API请求
|
||||
* 返回指定ID的文件信息
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const id = getRouterParam(event, 'id');
|
||||
if (!id)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'File ID is required',
|
||||
});
|
||||
|
||||
const file = await getFileMeta(id);
|
||||
if (!file) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'File not found',
|
||||
});
|
||||
}
|
||||
|
||||
return file;
|
||||
});
|
||||
53
server/utils/file.ts
Normal file
53
server/utils/file.ts
Normal file
@ -0,0 +1,53 @@
|
||||
/**
|
||||
* 判断文件是否支持预览
|
||||
*/
|
||||
export function isPreviewable(mime: string | null | undefined): boolean {
|
||||
if (!mime || mime === undefined) return false;
|
||||
return (
|
||||
mime.startsWith('image/') ||
|
||||
mime.startsWith('video/') ||
|
||||
mime === 'application/pdf' ||
|
||||
mime.startsWith('text/')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Directus 获取文件元信息
|
||||
*/
|
||||
export async function getFileMeta(id: string): Promise<FileMeta | null> {
|
||||
const runtimeConfig = useRuntimeConfig();
|
||||
const baseUrl = runtimeConfig.public.directus.url;
|
||||
const access_token = runtimeConfig.public.directus.token;
|
||||
|
||||
try {
|
||||
const response = await $fetch<{ data: DirectusFile }>(
|
||||
`${baseUrl}/files/${id}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: access_token ? `Bearer ${access_token}` : '',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const file = response.data;
|
||||
if (!file) return null;
|
||||
|
||||
return {
|
||||
id: file.id,
|
||||
title: file.filename_disk ?? '',
|
||||
filename_download: file.filename_download ?? '',
|
||||
type: file.type ?? '',
|
||||
filesize: Number(file.filesize),
|
||||
width: file.width ?? undefined,
|
||||
height: file.height ?? undefined,
|
||||
uploaded_on: file.uploaded_on ?? undefined,
|
||||
url: `${baseUrl}/assets/${file.id}`,
|
||||
previewable: isPreviewable(file.type),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.error('Error fetching file metadata:', error.message);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
12
shared/types/file.ts
Normal file
12
shared/types/file.ts
Normal file
@ -0,0 +1,12 @@
|
||||
export interface FileMeta {
|
||||
id: string;
|
||||
title: string;
|
||||
filename_download: string;
|
||||
type: string;
|
||||
filesize: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
uploaded_on?: string;
|
||||
url: string;
|
||||
previewable: boolean;
|
||||
}
|
||||
2
shared/types/index.ts
Normal file
2
shared/types/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './directus';
|
||||
export * from './meilisearch';
|
||||
Reference in New Issue
Block a user