VUE3 实现了 PDF,HTML,md格式文件在线查看工具
在线体验地址: http://114.55.230.54/
实现了一款漂亮的PDF,HTML,md格式文件在线查看网页工具
1、PDF预览
1.1 实现代码
<script setup>
import { ref, watch, computed } from 'vue'
// 状态管理
const files = ref([]) // 存储上传的HTML文件列表
const activeFileIndex = ref(-1) // 当前选中的文件索引
const viewMode = ref('preview') // 预览模式:'preview'(渲染预览)或 'code'(源代码)
const showHelp = ref(false) // 帮助提示框显示状态
// 计算属性:当前选中的文件
const activeFile = computed(() => {
return activeFileIndex.value >= 0 ? files.value[activeFileIndex.value] : null
})
// 监听选中文件索引变化,自动滚动到可视区域
watch(activeFileIndex, (newIndex) => {
if (newIndex >= 0) {
const fileItems = document.querySelectorAll('.file-item')
if (fileItems[newIndex]) {
fileItems[newIndex].scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}
}
})
// 处理文件上传
const handleFileUpload = (e) => {
if (!e.target.files) return
for (let i = 0; i < e.target.files.length; i++) {
let file = e.target.files[i]
if (!file.name.endsWith('.pdf') && !file.name.endsWith('.PDF')) {
alert('请上传扩展名为.PDF的文件')
continue
}
// 避免重复上传同名文件
// const isDuplicate = files.value.some(item => item.name === file.name)
// if (isDuplicate) {
// alert(`文件 "${file.name}" 已存在,请选择其他文件或删除现有文件后重新上传`)
// return
// }
// 生成本地 URL
const blobUrl = URL.createObjectURL(file)
// 添加文件到列表
files.value.push({
name: file.name,
content: blobUrl,
size: formatFileSize(file.size)
})
activeFileIndex.value = files.value.length - 1
}
// 重置文件输入框
e.target.value = ''
}
// 移除指定索引的文件
const removeFile = (index) => {
const fileToDelete = files.value[index]
if (confirm(`确定要删除文件 "${fileToDelete.name}" 吗?删除后无法恢复`)) {
// 从列表中删除文件
files.value.splice(index, 1)
// 处理选中状态
if (index === activeFileIndex.value) {
activeFileIndex.value = files.value.length > 0 ? 0 : -1
} else if (index < activeFileIndex.value) {
activeFileIndex.value--
}
}
}
// 工具函数:格式化文件大小(B → KB/MB)
const formatFileSize = (bytes) => {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
</script>
<template>
<div class="app-container">
<!-- 顶部导航栏 -->
<header class="app-header">
<div class="header-inner">
<div class="logo-group">
<i class="fa fa-html5"></i>
<h1>PDF文件浏览器</h1>
</div>
<div class="header-actions">
<button class="help-btn" @click="showHelp = !showHelp">
<i class="fa fa-question-circle"></i>
<span class="help-text">帮助</span>
</button>
</div>
</div>
</header>
<!-- 帮助提示框 -->
<div class="help-container" v-if="showHelp">
<div class="help-inner">
<div class="help-text-group">
<h3>使用指南</h3>
<p>1. 点击"选择PDF文件"按钮选择文件</p>
<p>2. 在左侧文件列表中点击文件名查看内容</p>
<p>3. 可以删除已选择的文件</p>
</div>
<button class="close-help-btn" @click="showHelp = false">
<i class="fa fa-times"></i>
</button>
</div>
</div>
<!-- 主内容区 -->
<main class="app-main">
<!-- 左侧文件列表 -->
<div class="file-list-sidebar">
<div class="upload-section">
<label for="file-upload" class="upload-btn">
<i class="fa fa-upload"></i>选择PDF文件
</label>
<input
id="file-upload"
type="file"
multiple
accept=".pdf,.PDF"
@change="handleFileUpload"
class="file-input-hidden"
>
</div>
<div class="file-list-wrapper">
<div class="empty-file-state" v-if="files.length === 0">
<i class="fa fa-file-code-o"></i>
<p>没有选择的文件</p>
<p class="empty-tip">点击上方按钮选择PDF文件</p>
</div>
<ul class="file-list" v-else>
<li
v-for="(file, index) in files"
:key="index"
class="file-item"
:class="{ 'active': activeFileIndex === index }"
@click="activeFileIndex = index"
>
<div class="file-info">
<i class="fa fa-file-html-o"></i>
<span class="file-name">{{ file.name }}</span>
</div>
<button
class="delete-file-btn"
@click.stop="removeFile(index)"
title="删除文件"
>
<i class="fa fa-trash-o"></i>
</button>
</li>
</ul>
</div>
</div>
<!-- 右侧预览区 -->
<div class="preview-main">
<div class="preview-header" v-if="activeFile">
<h2 class="preview-file-name">{{ activeFile.name }}</h2>
<div class="view-mode-group">
</div>
</div>
<div class="preview-content">
<div class="empty-preview-state" v-if="!activeFile">
<i class="fa fa-eye"></i>
<p>请从左侧选择一个文件进行预览</p>
</div>
<!-- 预览模式 -->
<div class="html-preview" v-if="activeFile && viewMode === 'preview'">
<div class="preview-content-inner">
<iframe width="100%" style="height: calc(100vh - 250px)" scrolling="no"
:src="`/document-file/pdf/web/viewer.html?file=${activeFile.content}`"></iframe>
</div>
</div>
</div>
</div>
</main>
<!-- 底部状态栏 -->
<footer class="app-footer">
<div class="footer-inner">
<div class="current-file-info">
<span v-if="activeFile">
<i class="fa fa-file-text-o"></i>
{{ activeFile.name }}
</span>
<span v-else>未选择文件</span>
</div>
<div class="file-count-info">
<span>{{ files.length }} 个文件</span>
</div>
</div>
</footer>
</div>
</template>
<style scoped lang="scss">
// 基础变量定义
$color-primary: #3b82f6;
$color-primary-light: rgba(59, 130, 246, 0.1);
$color-primary-hover: rgba(59, 130, 246, 0.9);
$color-orange: #f97316;
$color-red: #ef4444;
$color-gray-50: #f9fafb;
$color-gray-100: #f3f4f6;
$color-gray-200: #e5e7eb;
$color-gray-400: #9ca3af;
$color-gray-500: #6b7280;
$color-gray-600: #4b5563;
$color-gray-700: #374151;
$color-dark: #1e293b;
$color-light: #f1f5f9;
$color-white: #ffffff;
$shadow-base: 0 4px 20px rgba(0, 0, 0, 0.08);
$shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
$border-radius: 0.5rem;
$transition-base: all 0.2s ease;
$container-max-width: 1280px;
$sidebar-width-mobile: 100%;
$sidebar-width-desktop: 20rem;
// 工具混合宏
@mixin flex-center {
display: flex;
align-items: center;
justify-content: center;
}
@mixin flex-between {
display: flex;
align-items: center;
justify-content: space-between;
}
@mixin text-ellipsis {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
// 基础样式重置
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
// 根容器样式
.app-container {
display: flex;
flex-direction: column;
height: calc(100vh - 90px);
overflow: hidden;
background-color: $color-gray-50;
font-family: 'Inter', system-ui, sans-serif;
color: #1f2937;
}
// 顶部导航栏样式
.app-header {
background-color: $color-white;
box-shadow: $shadow-sm;
z-index: 10;
padding: 0.75rem 1rem;
.header-inner {
@include flex-between;
max-width: $container-max-width;
margin: 0 auto;
}
.logo-group {
@include flex-center;
gap: 0.5rem;
i {
color: $color-orange;
font-size: 1.5rem;
}
h1 {
font-size: 1.25rem;
font-weight: 600;
color: $color-primary;
}
}
.header-actions {
.help-btn {
@include flex-center;
gap: 0.25rem;
background: transparent;
border: none;
color: $color-gray-600;
cursor: pointer;
font-size: 1rem;
transition: $transition-base;
&:hover {
color: $color-primary;
}
.help-text {
display: none;
@media (min-width: 768px) {
display: inline;
}
}
}
}
}
// 帮助提示框样式
.help-container {
background-color: rgba(59, 130, 246, 0.05);
border-left: 4px solid $color-primary;
padding: 1rem;
box-shadow: $shadow-sm;
transition: $transition-base;
.help-inner {
@include flex-between;
align-items: flex-start;
max-width: $container-max-width;
margin: 0 auto;
}
.help-text-group {
h3 {
font-size: 1rem;
font-weight: 600;
color: $color-primary;
margin-bottom: 0.5rem;
}
p {
font-size: 0.875rem;
color: $color-gray-600;
margin-bottom: 0.25rem;
}
}
.close-help-btn {
background: transparent;
border: none;
color: $color-gray-500;
cursor: pointer;
font-size: 1rem;
transition: $transition-base;
&:hover {
color: $color-gray-700;
}
}
}
// 主内容区样式
.app-main {
display: flex;
flex: 1;
overflow: hidden;
}
// 左侧文件列表侧边栏
.file-list-sidebar {
width: $sidebar-width-mobile;
background-color: $color-white;
border-right: 1px solid $color-gray-200;
display: flex;
flex-direction: column;
height: 100%;
@media (min-width: 768px) {
width: $sidebar-width-desktop;
}
// 上传区域
.upload-section {
padding: 1rem;
border-bottom: 1px solid $color-gray-200;
.upload-btn {
@include flex-center;
gap: 0.5rem;
display: block;
width: 100%;
padding: 0.5rem 1rem;
background-color: $color-primary;
color: $color-white;
text-align: center;
border-radius: $border-radius;
cursor: pointer;
transition: $transition-base;
&:hover {
background-color: $color-primary-hover;
}
}
.file-input-hidden {
display: none;
}
}
// 文件列表容器
.file-list-wrapper {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
// 空文件状态
.empty-file-state {
@include flex-center;
flex-direction: column;
height: 100%;
color: $color-gray-400;
i {
font-size: 3.5rem;
margin-bottom: 1rem;
}
p {
font-size: 1rem;
margin-bottom: 0.25rem;
}
.empty-tip {
font-size: 0.875rem;
}
}
// 文件列表
.file-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 0.25rem;
.file-item {
@include flex-between;
align-items: center;
padding: 0.5rem;
border-radius: $border-radius;
cursor: pointer;
transition: $transition-base;
&:hover {
background-color: $color-gray-100;
}
&.active {
background-color: $color-primary-light;
border-left: 4px solid $color-primary;
}
.file-info {
@include flex-center;
gap: 0.5rem;
flex: 1;
i {
color: $color-orange;
}
.file-name {
@include text-ellipsis;
max-width: 160px;
@media (min-width: 768px) {
max-width: 200px;
}
}
}
.delete-file-btn {
background: transparent;
border: none;
color: $color-gray-400;
cursor: pointer;
padding: 0.25rem;
transition: $transition-base;
&:hover {
color: $color-red;
}
}
}
}
}
}
// 右侧预览区
.preview-main {
flex: 1;
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
// 预览头部
.preview-header {
background-color: $color-gray-100;
border-bottom: 1px solid $color-gray-200;
padding: 0.75rem 1rem;
@include flex-between;
align-items: center;
.preview-file-name {
font-size: 1rem;
font-weight: 500;
@include text-ellipsis;
max-width: 70%;
}
.view-mode-group {
display: flex;
gap: 0.5rem;
.mode-btn {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.75rem;
font-size: 0.875rem;
border-radius: $border-radius;
background-color: $color-white;
border: 1px solid $color-gray-200;
box-shadow: $shadow-sm;
cursor: pointer;
transition: $transition-base;
&:hover {
background-color: $color-gray-100;
}
&.active {
background-color: $color-primary;
color: $color-white;
border-color: $color-primary;
}
}
}
}
// 预览内容区
.preview-content {
flex: 1;
overflow: auto;
padding: 1rem;
background-color: $color-gray-50;
// 空预览状态
.empty-preview-state {
@include flex-center;
flex-direction: column;
height: 100%;
color: $color-gray-400;
i {
font-size: 3.5rem;
margin-bottom: 1rem;
}
p {
font-size: 1rem;
}
}
// HTML预览模式
.html-preview {
background-color: $color-white;
border-radius: $border-radius;
box-shadow: $shadow-base;
padding: 1.5rem;
min-height: calc(100% - 2rem);
.preview-content-inner {
max-width: 100%;
}
}
// 代码预览模式
.code-preview {
background-color: $color-dark;
color: $color-light;
border-radius: $border-radius;
box-shadow: $shadow-base;
padding: 1rem;
min-height: calc(100% - 2rem);
overflow: auto;
.code-block {
white-space: pre-wrap;
word-break: break-all;
font-family: monospace;
font-size: 0.875rem;
line-height: 1.5;
}
}
}
}
// 底部状态栏
.app-footer {
background-color: $color-white;
border-top: 1px solid $color-gray-200;
padding: 0.5rem 1rem;
font-size: 0.875rem;
color: $color-gray-500;
.footer-inner {
@include flex-between;
max-width: $container-max-width;
margin: 0 auto;
}
.current-file-info,
.file-count-info {
display: flex;
align-items: center;
gap: 0.25rem;
}
}
</style>
2、HTML预览
2.2: 实现代码
<script setup>
import { ref, watch, computed } from 'vue'
// 状态管理
const files = ref([]) // 存储上传的HTML文件列表
const activeFileIndex = ref(-1) // 当前选中的文件索引
const viewMode = ref('preview') // 预览模式:'preview'(渲染预览)或 'code'(源代码)
const showHelp = ref(false) // 帮助提示框显示状态
const previewIframe = ref(null) // iframe元素引用
// 计算属性:当前选中的文件
const activeFile = computed(() => {
return activeFileIndex.value >= 0 ? files.value[activeFileIndex.value] : null
})
// 监听选中文件或视图模式变化,更新iframe内容
watch(
() => [activeFile.value?.content, viewMode.value],
([content, mode]) => {
if (mode === 'preview' && previewIframe.value && content) {
// 获取iframe文档对象
const iframeDoc = previewIframe.value.contentDocument
// 写入完整HTML结构(包含基础样式重置)
iframeDoc.open()
iframeDoc.write(`
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${activeFile.value.name} - 预览</title>
<style>
/* 基础样式重置,避免继承父页面样式 */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
line-height: 1.6;
padding: 1rem;
}
</style>
</head>
<body>
${content}
</body>
</html>
`)
iframeDoc.close()
}
},
{ immediate: true }
)
// 处理文件上传
const handleFileUpload = (e) => {
const file = e.target.files[0]
if (!file) return
// 验证文件类型(仅允许HTML)
if (!file.name.endsWith('.html')) {
alert('请上传扩展名为.html的文件')
return
}
// 避免重复上传同名文件
// const isDuplicate = files.value.some(item => item.name === file.name)
// if (isDuplicate) {
// alert(`文件 "${file.name}" 已存在,请选择其他文件或删除现有文件后重新上传`)
// return
// }
// 读取文件内容(文本格式)
const reader = new FileReader()
reader.onload = (event) => {
// 添加文件到列表
files.value.push({
name: file.name,
content: event.target.result,
size: formatFileSize(file.size)
})
// 自动选中新上传的文件
activeFileIndex.value = files.value.length - 1
}
// 处理读取错误
reader.onerror = () => {
alert('文件读取失败,请重试或选择其他文件')
}
// 以文本形式读取文件
reader.readAsText(file)
// 重置文件输入框
e.target.value = ''
}
// 移除指定索引的文件
const removeFile = (index) => {
const fileToDelete = files.value[index]
if (confirm(`确定要删除文件 "${fileToDelete.name}" 吗?删除后无法恢复`)) {
// 从列表中删除文件
files.value.splice(index, 1)
// 处理选中状态
if (index === activeFileIndex.value) {
activeFileIndex.value = files.value.length > 0 ? 0 : -1
} else if (index < activeFileIndex.value) {
activeFileIndex.value--
}
}
}
// 工具函数:格式化文件大小(B → KB/MB)
const formatFileSize = (bytes) => {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
</script>
<template>
<div class="app-container">
<!-- 顶部导航栏 -->
<header class="app-header">
<div class="header-inner">
<div class="logo-group">
<i class="fa fa-html5"></i>
<h1>HTML文件浏览器</h1>
</div>
<div class="header-actions">
<button class="help-btn" @click="showHelp = !showHelp">
<i class="fa fa-question-circle"></i>
<span class="help-text">帮助</span>
</button>
</div>
</div>
</header>
<!-- 帮助提示框 -->
<div class="help-container" v-if="showHelp">
<div class="help-inner">
<div class="help-text-group">
<h3>使用指南</h3>
<p>1. 点击"选择HTML文件"按钮选择文件</p>
<p>2. 在左侧文件列表中点击文件名查看内容</p>
<p>3. 可以删除已选择的文件</p>
</div>
<button class="close-help-btn" @click="showHelp = false">
<i class="fa fa-times"></i>
</button>
</div>
</div>
<!-- 主内容区 -->
<main class="app-main">
<!-- 左侧文件列表 -->
<div class="file-list-sidebar">
<div class="upload-section">
<label for="file-upload" class="upload-btn">
<i class="fa fa-upload"></i>选择HTML文件
</label>
<input
id="file-upload"
type="file"
accept=".html"
@change="handleFileUpload"
class="file-input-hidden"
>
</div>
<div class="file-list-wrapper">
<div class="empty-file-state" v-if="files.length === 0">
<i class="fa fa-file-code-o"></i>
<p>没有选择的文件</p>
<p class="empty-tip">点击上方按钮选择HTML文件</p>
</div>
<ul class="file-list" v-else>
<li
v-for="(file, index) in files"
:key="index"
class="file-item"
:class="{ 'active': activeFileIndex === index }"
@click="activeFileIndex = index"
>
<div class="file-info">
<i class="fa fa-file-html-o"></i>
<span class="file-name">{{ file.name }}</span>
</div>
<button
class="delete-file-btn"
@click.stop="removeFile(index)"
title="删除文件"
>
<i class="fa fa-trash-o"></i>
</button>
</li>
</ul>
</div>
</div>
<!-- 右侧预览区 -->
<div class="preview-main">
<div class="preview-header" v-if="activeFile">
<h2 class="preview-file-name">{{ activeFile.name }}</h2>
<div class="view-mode-group">
<button
class="mode-btn"
:class="{ 'active': viewMode === 'preview' }"
@click="viewMode = 'preview'"
>
<i class="fa fa-eye"></i>预览
</button>
<button
class="mode-btn"
:class="{ 'active': viewMode === 'code' }"
@click="viewMode = 'code'"
>
<i class="fa fa-code"></i>代码
</button>
</div>
</div>
<div class="preview-content">
<div class="empty-preview-state" v-if="!activeFile">
<i class="fa fa-eye"></i>
<p>请从左侧选择一个文件进行预览</p>
</div>
<!-- 预览模式 -->
<div class="html-preview" v-if="activeFile && viewMode === 'preview'">
<iframe
ref="previewIframe"
class="preview-content-inner"
frameborder="0"
title="HTML预览"
></iframe>
</div>
<!-- 代码模式 -->
<div class="code-preview" v-if="activeFile && viewMode === 'code'">
<pre class="code-block"><code>{{ activeFile.content }}</code></pre>
</div>
</div>
</div>
</main>
<!-- 底部状态栏 -->
<footer class="app-footer">
<div class="footer-inner">
<div class="current-file-info">
<span v-if="activeFile">
<i class="fa fa-file-text-o"></i>
{{ activeFile.name }}
</span>
<span v-else>未选择文件</span>
</div>
<div class="file-count-info">
<span>{{ files.length }} 个文件</span>
</div>
</div>
</footer>
</div>
</template>
<style scoped lang="scss">
// 基础变量定义
$color-primary: #3b82f6;
$color-primary-light: rgba(59, 130, 246, 0.1);
$color-primary-hover: rgba(59, 130, 246, 0.9);
$color-orange: #f97316;
$color-red: #ef4444;
$color-gray-50: #f9fafb;
$color-gray-100: #f3f4f6;
$color-gray-200: #e5e7eb;
$color-gray-400: #9ca3af;
$color-gray-500: #6b7280;
$color-gray-600: #4b5563;
$color-gray-700: #374151;
$color-dark: #1e293b;
$color-light: #f1f5f9;
$color-white: #ffffff;
$shadow-base: 0 4px 20px rgba(0, 0, 0, 0.08);
$shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
$border-radius: 0.5rem;
$transition-base: all 0.2s ease;
$container-max-width: 1280px;
$sidebar-width-mobile: 100%;
$sidebar-width-desktop: 20rem;
// 工具混合宏
@mixin flex-center {
display: flex;
align-items: center;
justify-content: center;
}
@mixin flex-between {
display: flex;
align-items: center;
justify-content: space-between;
}
@mixin text-ellipsis {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
// 基础样式重置
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
// 根容器样式
.app-container {
display: flex;
flex-direction: column;
height: calc(100vh - 90px);
overflow: hidden;
background-color: $color-gray-50;
font-family: 'Inter', system-ui, sans-serif;
color: #1f2937;
}
// 顶部导航栏样式
.app-header {
background-color: $color-white;
box-shadow: $shadow-sm;
z-index: 10;
padding: 0.75rem 1rem;
.header-inner {
@include flex-between;
max-width: $container-max-width;
margin: 0 auto;
}
.logo-group {
@include flex-center;
gap: 0.5rem;
i {
color: $color-orange;
font-size: 1.5rem;
}
h1 {
font-size: 1.25rem;
font-weight: 600;
color: $color-primary;
}
}
.header-actions {
.help-btn {
@include flex-center;
gap: 0.25rem;
background: transparent;
border: none;
color: $color-gray-600;
cursor: pointer;
font-size: 1rem;
transition: $transition-base;
&:hover {
color: $color-primary;
}
.help-text {
display: none;
@media (min-width: 768px) {
display: inline;
}
}
}
}
}
// 帮助提示框样式
.help-container {
background-color: rgba(59, 130, 246, 0.05);
border-left: 4px solid $color-primary;
padding: 1rem;
box-shadow: $shadow-sm;
transition: $transition-base;
.help-inner {
@include flex-between;
align-items: flex-start;
max-width: $container-max-width;
margin: 0 auto;
}
.help-text-group {
h3 {
font-size: 1rem;
font-weight: 600;
color: $color-primary;
margin-bottom: 0.5rem;
}
p {
font-size: 0.875rem;
color: $color-gray-600;
margin-bottom: 0.25rem;
}
}
.close-help-btn {
background: transparent;
border: none;
color: $color-gray-500;
cursor: pointer;
font-size: 1rem;
transition: $transition-base;
&:hover {
color: $color-gray-700;
}
}
}
// 主内容区样式
.app-main {
display: flex;
flex: 1;
overflow: hidden;
}
// 左侧文件列表侧边栏
.file-list-sidebar {
width: $sidebar-width-mobile;
background-color: $color-white;
border-right: 1px solid $color-gray-200;
display: flex;
flex-direction: column;
height: 100%;
@media (min-width: 768px) {
width: $sidebar-width-desktop;
}
// 上传区域
.upload-section {
padding: 1rem;
border-bottom: 1px solid $color-gray-200;
.upload-btn {
@include flex-center;
gap: 0.5rem;
display: block;
width: 100%;
padding: 0.5rem 1rem;
background-color: $color-primary;
color: $color-white;
text-align: center;
border-radius: $border-radius;
cursor: pointer;
transition: $transition-base;
&:hover {
background-color: $color-primary-hover;
}
}
.file-input-hidden {
display: none;
}
}
// 文件列表容器
.file-list-wrapper {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
// 空文件状态
.empty-file-state {
@include flex-center;
flex-direction: column;
height: 100%;
color: $color-gray-400;
i {
font-size: 3.5rem;
margin-bottom: 1rem;
}
p {
font-size: 1rem;
margin-bottom: 0.25rem;
}
.empty-tip {
font-size: 0.875rem;
}
}
// 文件列表
.file-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 0.25rem;
.file-item {
@include flex-between;
align-items: center;
padding: 0.5rem;
border-radius: $border-radius;
cursor: pointer;
transition: $transition-base;
&:hover {
background-color: $color-gray-100;
}
&.active {
background-color: $color-primary-light;
border-left: 4px solid $color-primary;
}
.file-info {
@include flex-center;
gap: 0.5rem;
flex: 1;
i {
color: $color-orange;
}
.file-name {
@include text-ellipsis;
max-width: 160px;
@media (min-width: 768px) {
max-width: 200px;
}
}
}
.delete-file-btn {
background: transparent;
border: none;
color: $color-gray-400;
cursor: pointer;
padding: 0.25rem;
transition: $transition-base;
&:hover {
color: $color-red;
}
}
}
}
}
}
// 右侧预览区
.preview-main {
flex: 1;
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
// 预览头部
.preview-header {
background-color: $color-gray-100;
border-bottom: 1px solid $color-gray-200;
padding: 0.75rem 1rem;
@include flex-between;
align-items: center;
.preview-file-name {
font-size: 1rem;
font-weight: 500;
@include text-ellipsis;
max-width: 70%;
}
.view-mode-group {
display: flex;
gap: 0.5rem;
.mode-btn {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.75rem;
font-size: 0.875rem;
border-radius: $border-radius;
background-color: $color-white;
border: 1px solid $color-gray-200;
box-shadow: $shadow-sm;
cursor: pointer;
transition: $transition-base;
&:hover {
background-color: $color-gray-100;
}
&.active {
background-color: $color-primary;
color: $color-white;
border-color: $color-primary;
}
}
}
}
// 预览内容区
.preview-content {
flex: 1;
overflow: auto;
padding: 1rem;
background-color: $color-gray-50;
// 空预览状态
.empty-preview-state {
@include flex-center;
flex-direction: column;
height: 100%;
color: $color-gray-400;
i {
font-size: 3.5rem;
margin-bottom: 1rem;
}
p {
font-size: 1rem;
}
}
// HTML预览模式
.html-preview {
background-color: $color-white;
border-radius: $border-radius;
box-shadow: $shadow-base;
padding: 1.5rem;
min-height: calc(100% - 2rem);
.preview-content-inner {
width: 100%;
height: calc(100vh - 270px);
border-radius: 6px;
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
background-color: white;
}
}
// 代码预览模式
.code-preview {
background-color: $color-dark;
color: $color-light;
border-radius: $border-radius;
box-shadow: $shadow-base;
padding: 1rem;
min-height: calc(100% - 2rem);
overflow: auto;
.code-block {
white-space: pre-wrap;
word-break: break-all;
font-family: monospace;
font-size: 0.875rem;
line-height: 1.5;
}
}
}
}
// 底部状态栏
.app-footer {
background-color: $color-white;
border-top: 1px solid $color-gray-200;
padding: 0.5rem 1rem;
font-size: 0.875rem;
color: $color-gray-500;
.footer-inner {
@include flex-between;
max-width: $container-max-width;
margin: 0 auto;
}
.current-file-info,
.file-count-info {
display: flex;
align-items: center;
gap: 0.25rem;
}
}
</style>
3、MD文件预览
3.1 实现代码
<script setup>
import { ref, watch, computed } from 'vue'
import { marked } from 'marked' // 引入Markdown解析库
import hljs from 'highlight.js'
import 'highlight.js/styles/github-dark.css' // 引入代码高亮样式
// 配置marked使用highlight.js进行代码高亮
marked.setOptions({
highlight: function(code, lang) {
// 如果指定了语言且hljs支持该语言
if (lang && hljs.getLanguage(lang)) {
return hljs.highlight(code, { language: lang }).value
}
// 未指定语言时尝试自动检测
return hljs.highlightAuto(code).value
},
breaks: true, // 支持换行
gfm: true // 支持GitHub Flavored Markdown
})
// 状态管理
const files = ref([]) // 存储选择的文件列表
const activeFileIndex = ref(-1) // 当前选中的文件索引
const viewMode = ref('preview') // 预览模式:'preview'(渲染预览)或 'code'(源代码)
const showHelp = ref(false) // 帮助提示框显示状态
// 计算属性:当前选中的文件
const activeFile = computed(() => {
return activeFileIndex.value >= 0 ? files.value[activeFileIndex.value] : null
})
// 计算属性:渲染后的Markdown内容
const renderedContent = computed(() => {
if (activeFile.value && viewMode.value === 'preview') {
return marked.parse(activeFile.value.content)
}
return ''
})
// 监听选中文件索引变化,自动滚动到可视区域
watch(activeFileIndex, (newIndex) => {
if (newIndex >= 0) {
const fileItems = document.querySelectorAll('.file-item')
if (fileItems[newIndex]) {
fileItems[newIndex].scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}
}
})
// 处理文件选择
const handleFileUpload = (e) => {
const file = e.target.files[0]
if (!file) return
// 验证文件类型(仅允许Markdown)
const isMarkdown = file.name.endsWith('.md') || file.name.endsWith('.markdown')
if (!isMarkdown) {
alert('请选择扩展名为.md或.markdown的文件')
return
}
// 避免重复选择同名文件
const isDuplicate = files.value.some(item => item.name === file.name)
if (isDuplicate) {
alert(`文件 "${file.name}" 已存在,请选择其他文件或删除现有文件后重新选择`)
return
}
// 读取文件内容(文本格式)
const reader = new FileReader()
reader.onload = (event) => {
// 添加文件到列表
files.value.push({
name: file.name,
content: event.target.result,
size: formatFileSize(file.size)
})
// 自动选中新选择的文件
activeFileIndex.value = files.value.length - 1
}
// 处理读取错误
reader.onerror = () => {
alert('文件读取失败,请重试或选择其他文件')
}
// 以文本形式读取文件
reader.readAsText(file)
// 重置文件输入框
e.target.value = ''
}
// 移除指定索引的文件
const removeFile = (index) => {
const fileToDelete = files.value[index]
if (confirm(`确定要删除文件 "${fileToDelete.name}" 吗?删除后无法恢复`)) {
// 从列表中删除文件
files.value.splice(index, 1)
// 处理选中状态
if (index === activeFileIndex.value) {
activeFileIndex.value = files.value.length > 0 ? 0 : -1
} else if (index < activeFileIndex.value) {
activeFileIndex.value--
}
}
}
// 工具函数:格式化文件大小(B → KB/MB)
const formatFileSize = (bytes) => {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
</script>
<template>
<div class="app-container">
<!-- 顶部导航栏 -->
<header class="app-header">
<div class="header-inner">
<div class="logo-group">
<i class="fa fa-markdown"></i>
<h1>Markdown文件浏览器</h1>
</div>
<div class="header-actions">
<button class="help-btn" @click="showHelp = !showHelp">
<i class="fa fa-question-circle"></i>
<span class="help-text">帮助</span>
</button>
</div>
</div>
</header>
<!-- 帮助提示框 -->
<div class="help-container" v-if="showHelp">
<div class="help-inner">
<div class="help-text-group">
<h3>使用指南</h3>
<p>1. 点击"选择Markdown文件"按钮选择文件</p>
<p>2. 在左侧文件列表中点击文件名查看内容</p>
<p>3. 可以切换预览模式和代码模式</p>
<p>4. 可以删除已选择的文件</p>
</div>
<button class="close-help-btn" @click="showHelp = false">
<i class="fa fa-times"></i>
</button>
</div>
</div>
<!-- 主内容区 -->
<main class="app-main">
<!-- 左侧文件列表 -->
<div class="file-list-sidebar">
<div class="upload-section">
<label for="file-upload" class="upload-btn">
<i class="fa fa-upload"></i>选择Markdown文件
</label>
<input
id="file-upload"
type="file"
accept=".md,.markdown"
@change="handleFileUpload"
class="file-input-hidden"
>
</div>
<div class="file-list-wrapper">
<div class="empty-file-state" v-if="files.length === 0">
<i class="fa fa-file-text-o"></i>
<p>没有选择的文件</p>
<p class="empty-tip">点击上方按钮选择Markdown文件</p>
</div>
<ul class="file-list" v-else>
<li
v-for="(file, index) in files"
:key="index"
class="file-item"
:class="{ 'active': activeFileIndex === index }"
@click="activeFileIndex = index"
>
<div class="file-info">
<i class="fa fa-file-text-o"></i>
<span class="file-name">{{ file.name }}</span>
</div>
<button
class="delete-file-btn"
@click.stop="removeFile(index)"
title="删除文件"
>
<i class="fa fa-trash-o"></i>
</button>
</li>
</ul>
</div>
</div>
<!-- 右侧预览区 -->
<div class="preview-main">
<div class="preview-header" v-if="activeFile">
<h2 class="preview-file-name">{{ activeFile.name }}</h2>
<div class="view-mode-group">
<button
class="mode-btn"
:class="{ 'active': viewMode === 'preview' }"
@click="viewMode = 'preview'"
>
<i class="fa fa-eye"></i>预览
</button>
<button
class="mode-btn"
:class="{ 'active': viewMode === 'code' }"
@click="viewMode = 'code'"
>
<i class="fa fa-code"></i>代码
</button>
</div>
</div>
<div class="preview-content">
<div class="empty-preview-state" v-if="!activeFile">
<i class="fa fa-eye"></i>
<p>请从左侧选择一个文件进行预览</p>
</div>
<!-- 预览模式 -->
<div class="markdown-preview" v-if="activeFile && viewMode === 'preview'">
<div class="preview-content-inner" v-html="renderedContent"></div>
</div>
<!-- 代码模式 -->
<div class="code-preview" v-if="activeFile && viewMode === 'code'">
<pre class="code-block"><code>{{ activeFile.content }}</code></pre>
</div>
</div>
</div>
</main>
<!-- 底部状态栏 -->
<footer class="app-footer">
<div class="footer-inner">
<div class="current-file-info">
<span v-if="activeFile">
<i class="fa fa-file-text-o"></i>
{{ activeFile.name }}
</span>
<span v-else>未选择文件</span>
</div>
<div class="file-count-info">
<span>{{ files.length }} 个文件</span>
</div>
</div>
</footer>
</div>
</template>
<style scoped lang="scss">
// 基础变量定义
$color-primary: #3b82f6;
$color-primary-light: rgba(59, 130, 246, 0.1);
$color-primary-hover: rgba(59, 130, 246, 0.9);
$color-purple: #9333ea; /* Markdown主色调 */
$color-red: #ef4444;
$color-gray-50: #f9fafb;
$color-gray-100: #f3f4f6;
$color-gray-200: #e5e7eb;
$color-gray-400: #9ca3af;
$color-gray-500: #6b7280;
$color-gray-600: #4b5563;
$color-gray-700: #374151;
$color-dark: #1e293b;
$color-light: #f1f5f9;
$color-white: #ffffff;
$shadow-base: 0 4px 20px rgba(0, 0, 0, 0.08);
$shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
$border-radius: 0.5rem;
$transition-base: all 0.2s ease;
$container-max-width: 1280px;
$sidebar-width-mobile: 100%;
$sidebar-width-desktop: 20rem;
// Markdown预览样式变量
$markdown-font-size: 1rem;
$markdown-line-height: 1.6;
$markdown-max-width: 800px;
// 工具混合宏
@mixin flex-center {
display: flex;
align-items: center;
justify-content: center;
}
@mixin flex-between {
display: flex;
align-items: center;
justify-content: space-between;
}
@mixin text-ellipsis {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
// 基础样式重置
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
// 根容器样式
.app-container {
display: flex;
flex-direction: column;
height: calc(100vh - 90px);
overflow: hidden;
background-color: $color-gray-50;
font-family: 'Inter', system-ui, sans-serif;
color: #1f2937;
}
// 顶部导航栏样式
.app-header {
background-color: $color-white;
box-shadow: $shadow-sm;
z-index: 10;
padding: 0.75rem 1rem;
.header-inner {
@include flex-between;
max-width: $container-max-width;
margin: 0 auto;
}
.logo-group {
@include flex-center;
gap: 0.5rem;
i {
color: $color-purple;
font-size: 1.5rem;
}
h1 {
font-size: 1.25rem;
font-weight: 600;
color: $color-primary;
}
}
.header-actions {
.help-btn {
@include flex-center;
gap: 0.25rem;
background: transparent;
border: none;
color: $color-gray-600;
cursor: pointer;
font-size: 1rem;
transition: $transition-base;
&:hover {
color: $color-primary;
}
.help-text {
display: none;
@media (min-width: 768px) {
display: inline;
}
}
}
}
}
// 帮助提示框样式
.help-container {
background-color: rgba(59, 130, 246, 0.05);
border-left: 4px solid $color-primary;
padding: 1rem;
box-shadow: $shadow-sm;
transition: $transition-base;
.help-inner {
@include flex-between;
align-items: flex-start;
max-width: $container-max-width;
margin: 0 auto;
}
.help-text-group {
h3 {
font-size: 1rem;
font-weight: 600;
color: $color-primary;
margin-bottom: 0.5rem;
}
p {
font-size: 0.875rem;
color: $color-gray-600;
margin-bottom: 0.25rem;
}
}
.close-help-btn {
background: transparent;
border: none;
color: $color-gray-500;
cursor: pointer;
font-size: 1rem;
transition: $transition-base;
&:hover {
color: $color-gray-700;
}
}
}
// 主内容区样式
.app-main {
display: flex;
flex: 1;
overflow: hidden;
}
// 左侧文件列表侧边栏
.file-list-sidebar {
width: $sidebar-width-mobile;
background-color: $color-white;
border-right: 1px solid $color-gray-200;
display: flex;
flex-direction: column;
height: 100%;
@media (min-width: 768px) {
width: $sidebar-width-desktop;
}
// 选择区域
.upload-section {
padding: 1rem;
border-bottom: 1px solid $color-gray-200;
.upload-btn {
@include flex-center;
gap: 0.5rem;
display: block;
width: 100%;
padding: 0.5rem 1rem;
background-color: $color-primary;
color: $color-white;
text-align: center;
border-radius: $border-radius;
cursor: pointer;
transition: $transition-base;
&:hover {
background-color: $color-primary-hover;
}
}
.file-input-hidden {
display: none;
}
}
// 文件列表容器
.file-list-wrapper {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
// 空文件状态
.empty-file-state {
@include flex-center;
flex-direction: column;
height: 100%;
color: $color-gray-400;
i {
font-size: 3.5rem;
margin-bottom: 1rem;
}
p {
font-size: 1rem;
margin-bottom: 0.25rem;
}
.empty-tip {
font-size: 0.875rem;
}
}
// 文件列表
.file-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 0.25rem;
.file-item {
@include flex-between;
align-items: center;
padding: 0.5rem;
border-radius: $border-radius;
cursor: pointer;
transition: $transition-base;
&:hover {
background-color: $color-gray-100;
}
&.active {
background-color: $color-primary-light;
border-left: 4px solid $color-primary;
}
.file-info {
@include flex-center;
gap: 0.5rem;
flex: 1;
i {
color: $color-purple;
}
.file-name {
@include text-ellipsis;
max-width: 160px;
@media (min-width: 768px) {
max-width: 200px;
}
}
}
.delete-file-btn {
background: transparent;
border: none;
color: $color-gray-400;
cursor: pointer;
padding: 0.25rem;
transition: $transition-base;
&:hover {
color: $color-red;
}
}
}
}
}
}
// 右侧预览区
.preview-main {
flex: 1;
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
// 预览头部
.preview-header {
background-color: $color-gray-100;
border-bottom: 1px solid $color-gray-200;
padding: 0.75rem 1rem;
@include flex-between;
align-items: center;
.preview-file-name {
font-size: 1rem;
font-weight: 500;
@include text-ellipsis;
max-width: 70%;
}
.view-mode-group {
display: flex;
gap: 0.5rem;
.mode-btn {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.75rem;
font-size: 0.875rem;
border-radius: $border-radius;
background-color: $color-white;
border: 1px solid $color-gray-200;
box-shadow: $shadow-sm;
cursor: pointer;
transition: $transition-base;
&:hover {
background-color: $color-gray-100;
}
&.active {
background-color: $color-primary;
color: $color-white;
border-color: $color-primary;
}
}
}
}
// 预览内容区
.preview-content {
flex: 1;
overflow: auto;
padding: 1rem;
background-color: $color-gray-50;
// 空预览状态
.empty-preview-state {
@include flex-center;
flex-direction: column;
height: 100%;
color: $color-gray-400;
i {
font-size: 3.5rem;
margin-bottom: 1rem;
}
p {
font-size: 1rem;
}
}
// Markdown预览模式
.markdown-preview {
background-color: $color-white;
border-radius: $border-radius;
box-shadow: $shadow-base;
padding: 2rem;
min-height: calc(100% - 2rem);
.preview-content-inner {
max-width: $markdown-max-width;
margin: 0 auto;
font-size: $markdown-font-size;
line-height: $markdown-line-height;
// Markdown基础样式
h1, h2, h3, h4, h5, h6 {
margin-top: 1.5em;
margin-bottom: 0.5em;
font-weight: 600;
color: $color-dark;
}
h1 {
font-size: 1.8rem;
border-bottom: 1px solid $color-gray-200;
padding-bottom: 0.5rem;
}
h2 {
font-size: 1.5rem;
border-bottom: 1px solid $color-gray-200;
padding-bottom: 0.5rem;
}
p {
margin-bottom: 1em;
}
ul, ol {
margin-left: 1.5rem;
margin-bottom: 1em;
}
ul {
list-style-type: disc;
}
ol {
list-style-type: decimal;
}
li {
margin-bottom: 0.5em;
}
a {
color: $color-primary;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
code {
background-color: $color-gray-100;
padding: 0.2em 0.4em;
border-radius: 0.25rem;
font-family: monospace;
font-size: 0.9em;
}
pre {
background-color: $color-dark;
color: $color-light;
padding: 1rem;
border-radius: $border-radius;
overflow-x: auto;
margin-bottom: 1em;
font-family: monospace;
}
pre code {
background-color: transparent;
padding: 0;
font-size: 0.9em;
}
blockquote {
border-left: 4px solid $color-gray-200;
padding-left: 1rem;
margin-left: 0;
margin-bottom: 1em;
color: $color-gray-600;
}
img {
max-width: 100%;
height: auto;
margin: 1em 0;
border-radius: $border-radius;
}
table {
border-collapse: collapse;
width: 100%;
margin-bottom: 1em;
}
th, td {
border: 1px solid $color-gray-200;
padding: 0.5rem 1rem;
text-align: left;
}
th {
background-color: $color-gray-50;
}
}
}
// 代码模式
.code-preview {
background-color: $color-dark;
color: $color-light;
border-radius: $border-radius;
box-shadow: $shadow-base;
padding: 1rem;
min-height: calc(100% - 2rem);
overflow: auto;
.code-block {
white-space: pre-wrap;
word-break: break-all;
font-family: monospace;
font-size: 0.875rem;
line-height: 1.5;
}
}
}
}
// 底部状态栏
.app-footer {
background-color: $color-white;
border-top: 1px solid $color-gray-200;
padding: 0.5rem 1rem;
font-size: 0.875rem;
color: $color-gray-500;
.footer-inner {
@include flex-between;
max-width: $container-max-width;
margin: 0 auto;
}
.current-file-info,
.file-count-info {
display: flex;
align-items: center;
gap: 0.25rem;
}
}
</style>