feat(笔记预览): 实现大文件分块加载功能

添加分块加载API接口及前端实现,支持大文件(>500KB)的分页加载,提升大文件预览体验
后端实现分块逻辑并添加权限检查,前端添加加载提示和滚动加载功能
This commit is contained in:
ikmkj
2026-03-04 16:43:25 +08:00
parent 90626e73d9
commit 5ea9c776e7
7 changed files with 364 additions and 9 deletions

View File

@@ -3,6 +3,7 @@ package com.test.bijihoudaun.controller;
import cn.hutool.core.util.ObjectUtil;
import com.test.bijihoudaun.common.response.R;
import com.test.bijihoudaun.entity.MarkdownFile;
import com.test.bijihoudaun.entity.MarkdownFileChunk;
import com.test.bijihoudaun.entity.MarkdownFileVO;
import com.test.bijihoudaun.service.MarkdownFileService;
import com.test.bijihoudaun.util.SecurityUtil;
@@ -117,4 +118,26 @@ public class MarkdownController {
return R.success(files);
}
@Operation(summary = "分块加载Markdown文件内容", description = "用于大文件(> 500KB的分页加载")
@Parameters({
@Parameter(name = "id", description = "文件ID", required = true),
@Parameter(name = "chunkIndex", description = "块索引从0开始", required = false),
@Parameter(name = "chunkSize", description = "块大小字符数默认10000", required = false)
})
@GetMapping("/{id}/chunk")
public R<MarkdownFileChunk> getMarkdownChunk(
@PathVariable Long id,
@RequestParam(defaultValue = "0") int chunkIndex,
@RequestParam(defaultValue = "10000") int chunkSize) {
// 获取当前认证状态
boolean isAuthenticated = SecurityUtil.isUserAuthenticated();
MarkdownFileChunk chunk = markdownFileService.getMarkdownChunk(id, chunkIndex, chunkSize, isAuthenticated);
if (ObjectUtil.isNotNull(chunk)) {
return R.success(chunk);
}
return R.fail("文件未找到");
}
}

View File

@@ -0,0 +1,46 @@
package com.test.bijihoudaun.entity;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
/**
* Markdown文件分块加载响应实体
* 用于大文件分页加载
*/
@Data
@Schema(name = "Markdown文件分块")
public class MarkdownFileChunk implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "当前块的内容")
private String chunk;
@Schema(description = "当前块索引从0开始")
private int chunkIndex;
@Schema(description = "总块数")
private int totalChunks;
@Schema(description = "是否还有更多块")
private boolean hasMore;
@Schema(description = "笔记ID")
@JsonFormat(shape = JsonFormat.Shape.STRING)
private Long fileId;
@Schema(description = "笔记标题")
private String title;
@Schema(description = "是否私密 0-公开 1-私密")
private Integer isPrivate;
@Schema(description = "总字符数")
private long totalLength;
@Schema(description = "当前块字符数")
private int chunkLength;
}

View File

@@ -2,6 +2,7 @@ package com.test.bijihoudaun.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.test.bijihoudaun.entity.MarkdownFile;
import com.test.bijihoudaun.entity.MarkdownFileChunk;
import com.test.bijihoudaun.entity.MarkdownFileVO;
import java.util.List;
@@ -66,4 +67,15 @@ public interface MarkdownFileService extends IService<MarkdownFile> {
* @return 文件列表
*/
List<MarkdownFileVO> getRecentFiles(int limit);
/**
* 分块加载Markdown文件内容
* 用于大文件(> 500KB的分页加载
* @param id 文件ID
* @param chunkIndex 块索引从0开始
* @param chunkSize 块大小字符数默认10000
* @param isAuthenticated 是否已认证
* @return 文件块对象
*/
MarkdownFileChunk getMarkdownChunk(Long id, int chunkIndex, int chunkSize, boolean isAuthenticated);
}

View File

@@ -8,6 +8,7 @@ import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.test.bijihoudaun.entity.ImageName;
import com.test.bijihoudaun.entity.MarkdownFile;
import com.test.bijihoudaun.entity.MarkdownFileChunk;
import com.test.bijihoudaun.entity.MarkdownFileVO;
import com.test.bijihoudaun.mapper.ImageNameMapper;
import com.test.bijihoudaun.mapper.MarkdownFileMapper;
@@ -156,4 +157,92 @@ public class MarkdownFileServiceImpl
return markdownFileMapper.selectRecentWithGrouping(limit);
}
@Override
public MarkdownFileChunk getMarkdownChunk(Long id, int chunkIndex, int chunkSize, boolean isAuthenticated) {
// 获取文件基本信息(不包含内容)
MarkdownFileVO fileVO = markdownFileMapper.selectByIdWithGrouping(id);
if (fileVO == null) {
return null;
}
// 检查权限:私密笔记需要登录
if (fileVO.getIsPrivate() != null && fileVO.getIsPrivate() == 1 && !isAuthenticated) {
MarkdownFileChunk chunk = new MarkdownFileChunk();
chunk.setFileId(id);
chunk.setTitle(fileVO.getTitle());
chunk.setIsPrivate(fileVO.getIsPrivate());
chunk.setChunk("该笔记为私密笔记,请登录后查看");
chunk.setChunkIndex(0);
chunk.setTotalChunks(1);
chunk.setHasMore(false);
chunk.setTotalLength(0);
chunk.setChunkLength(0);
return chunk;
}
// 获取完整内容
MarkdownFile file = this.getById(id);
if (file == null || file.getContent() == null) {
return null;
}
String fullContent = file.getContent();
int totalLength = fullContent.length();
// 如果文件小于 500KB约50万字符直接返回全部内容
if (totalLength <= 500000) {
MarkdownFileChunk chunk = new MarkdownFileChunk();
chunk.setFileId(id);
chunk.setTitle(file.getTitle());
chunk.setIsPrivate(file.getIsPrivate());
chunk.setChunk(fullContent);
chunk.setChunkIndex(0);
chunk.setTotalChunks(1);
chunk.setHasMore(false);
chunk.setTotalLength(totalLength);
chunk.setChunkLength(totalLength);
return chunk;
}
// 大文件分页加载
// 计算总块数
int totalChunks = (int) Math.ceil((double) totalLength / chunkSize);
// 确保 chunkIndex 在有效范围内
if (chunkIndex < 0) {
chunkIndex = 0;
}
if (chunkIndex >= totalChunks) {
chunkIndex = totalChunks - 1;
}
// 计算当前块的起止位置
int start = chunkIndex * chunkSize;
int end = Math.min(start + chunkSize, totalLength);
// 截取内容
String chunkContent = fullContent.substring(start, end);
// 如果不是第一块,尝试从完整的换行处开始
if (chunkIndex > 0 && start < totalLength) {
// 找到第一个换行符,从下一行开始显示
int firstNewLine = chunkContent.indexOf('\n');
if (firstNewLine > 0) {
chunkContent = chunkContent.substring(firstNewLine + 1);
}
}
MarkdownFileChunk chunk = new MarkdownFileChunk();
chunk.setFileId(id);
chunk.setTitle(file.getTitle());
chunk.setIsPrivate(file.getIsPrivate());
chunk.setChunk(chunkContent);
chunk.setChunkIndex(chunkIndex);
chunk.setTotalChunks(totalChunks);
chunk.setHasMore(chunkIndex < totalChunks - 1);
chunk.setTotalLength(totalLength);
chunk.setChunkLength(chunkContent.length());
return chunk;
}
}

View File

@@ -16,6 +16,10 @@ export const markdownAll = () => axiosApi.get(`/api/markdown`);
// 预览markdown文件
export const Preview = (id) => axiosApi.get(`/api/markdown/${id}`);
// 分块加载markdown文件内容用于大文件
export const PreviewChunk = (id, chunkIndex = 0, chunkSize = 10000) =>
axiosApi.get(`/api/markdown/${id}/chunk?chunkIndex=${chunkIndex}&chunkSize=${chunkSize}`);
// 创建分类分组
export const addGroupings = (group) => {
return axiosApi.post('/api/groupings', group);

View File

@@ -33,6 +33,7 @@
:file="selectedFile"
:is-mobile="isMobile"
:is-user-logged-in="userStore.isLoggedIn"
:has-more-chunks="hasMoreChunks"
@back="selectedFile = null"
@edit="editNote(selectedFile)"
@delete="deleteNote(selectedFile)"
@@ -66,8 +67,11 @@
<el-icon class="is-loading"><Loading /></el-icon>
<span>加载中...</span>
</div>
<div v-else-if="hasMoreFiles && !showEditor && !selectedFile" class="load-more-trigger">
<el-button @click="loadMoreFiles" type="primary" plain>加载更多</el-button>
<div v-else-if="hasMoreFiles && !showEditor && !selectedFile" class="load-more-hint">
<span>继续滚动加载更多...</span>
</div>
<div v-else-if="!hasMoreFiles && displayedFiles.length > 0" class="no-more-hint">
<span>没有更多笔记了</span>
</div>
</div>
</div>
@@ -140,6 +144,7 @@ import {
groupingAll,
markdownList,
Preview,
PreviewChunk,
updateMarkdown,
searchMarkdown,
deleteMarkdown,
@@ -180,6 +185,7 @@ const displayedFiles = ref([]);
const currentPage = ref(0);
const pageSize = ref(16);
const isLoadingMore = ref(false);
const loadCount = ref(0); // 记录加载次数,用于动态增加加载数量
const noteListWrapper = ref(null);
const showEditor = ref(false);
const selectedFile = ref(null);
@@ -203,6 +209,12 @@ const vditorReady = ref(false);
// 提供给子组件使用
provide('vditorReady', vditorReady);
// 大文件分块加载状态
const currentChunkIndex = ref(0);
const hasMoreChunks = ref(false);
const isLoadingChunk = ref(false);
const totalChunks = ref(0);
// Data for dialogs
const itemToRename = ref(null);
const fileToImport = ref(null);
@@ -258,6 +270,7 @@ const resetToHomeView = async () => {
searchKeyword.value = '';
activeMenu.value = 'all';
currentPage.value = 0;
loadCount.value = 0; // 重置加载计数
try {
groupMarkdownFiles.value = await getRecentFiles(100) || [];
updateDisplayedFiles();
@@ -269,9 +282,21 @@ const resetToHomeView = async () => {
}
};
// 计算动态加载数量基础16个每次增加8个最多48个
const getDynamicPageSize = () => {
const baseSize = 16;
const increment = 8;
const maxSize = 48;
return Math.min(baseSize + loadCount.value * increment, maxSize);
};
const updateDisplayedFiles = () => {
const start = 0;
const end = (currentPage.value + 1) * pageSize.value;
const dynamicPageSize = getDynamicPageSize();
// 计算实际显示数量首次16个之后动态增加
const end = displayedFiles.value.length === 0
? dynamicPageSize
: displayedFiles.value.length + dynamicPageSize;
displayedFiles.value = groupMarkdownFiles.value.slice(start, end);
};
@@ -280,6 +305,7 @@ const loadMoreFiles = () => {
isLoadingMore.value = true;
setTimeout(() => {
loadCount.value++;
currentPage.value++;
updateDisplayedFiles();
isLoadingMore.value = false;
@@ -288,11 +314,21 @@ const loadMoreFiles = () => {
const handleScroll = (e) => {
const { scrollTop, scrollHeight, clientHeight } = e.target;
if (scrollHeight - scrollTop - clientHeight < 100 && hasMoreFiles.value && !isLoadingMore.value) {
// 距离底部 200px 时自动加载,给用户更流畅的体验
if (scrollHeight - scrollTop - clientHeight < 200 && hasMoreFiles.value && !isLoadingMore.value) {
loadMoreFiles();
}
};
// 预览区域滚动处理(用于大文件分块加载)
const handlePreviewScroll = (e) => {
const { scrollTop, scrollHeight, clientHeight } = e.target;
// 距离底部 300px 时加载下一页内容
if (scrollHeight - scrollTop - clientHeight < 300 && hasMoreChunks.value && !isLoadingChunk.value) {
loadMoreContent();
}
};
// Event Handlers from Components
const handleSelectFile = async (data) => {
resetEdit();
@@ -300,6 +336,7 @@ const handleSelectFile = async (data) => {
const files = await markdownList(data.id);
groupMarkdownFiles.value = files || [];
currentPage.value = 0;
loadCount.value = 0; // 重置加载计数
updateDisplayedFiles();
selectedFile.value = null;
showEditor.value = false;
@@ -377,26 +414,71 @@ const previewFile = async (file) => {
}
// 重置渲染缓存,确保每次打开笔记都重新渲染
lastRenderedKey = null;
// 重置分块加载状态
currentChunkIndex.value = 0;
hasMoreChunks.value = false;
isLoadingChunk.value = false;
totalChunks.value = 0;
// 先立即显示预览页(加载状态),让用户感知到响应
selectedFile.value = { ...file, content: '', isLoading: true, isRendering: false };
showEditor.value = false;
// 异步加载内容
// 异步加载内容(使用分块加载)
await loadNoteChunk(file.id, 0);
};
// 分块加载笔记内容
const loadNoteChunk = async (fileId, chunkIndex) => {
if (isLoadingChunk.value) return;
isLoadingChunk.value = true;
try {
const content = await Preview(file.id) || '';
// 内容加载完成后更新
if (selectedFile.value && selectedFile.value.id === file.id) {
const chunkData = await PreviewChunk(fileId, chunkIndex, 10000);
if (selectedFile.value && selectedFile.value.id === fileId) {
// 更新分块加载状态
currentChunkIndex.value = chunkData.chunkIndex;
hasMoreChunks.value = chunkData.hasMore;
totalChunks.value = chunkData.totalChunks;
// 如果是第一块,直接设置内容;否则追加内容
let newContent;
if (chunkIndex === 0) {
newContent = chunkData.chunk;
} else {
newContent = selectedFile.value.content + chunkData.chunk;
}
// 如果 Vditor 渲染引擎未就绪,显示渲染中状态
const isRendering = !vditorReady.value;
selectedFile.value = { ...file, content, isLoading: false, isRendering };
selectedFile.value = {
...selectedFile.value,
content: newContent,
isLoading: false,
isRendering,
title: chunkData.title || selectedFile.value.title,
isPrivate: chunkData.isPrivate !== undefined ? chunkData.isPrivate : selectedFile.value.isPrivate
};
}
} catch (error) {
// 错误已在 axios 拦截器中显示,这里不再重复显示
console.error('获取笔记内容失败:', error);
selectedFile.value = null;
} finally {
isLoadingChunk.value = false;
}
};
// 加载更多内容(滚动到底部时调用)
const loadMoreContent = async () => {
if (!selectedFile.value || !hasMoreChunks.value || isLoadingChunk.value) return;
const nextChunkIndex = currentChunkIndex.value + 1;
await loadNoteChunk(selectedFile.value.id, nextChunkIndex);
};
const editNote = (file) => {
editData.value = { ...file };
showEditor.value = true;
@@ -474,6 +556,7 @@ const handleSearch = async () => {
try {
groupMarkdownFiles.value = await searchMarkdown(searchKeyword.value) || [];
currentPage.value = 0;
loadCount.value = 0; // 重置加载计数
updateDisplayedFiles();
selectedFile.value = null;
showEditor.value = false;
@@ -669,6 +752,10 @@ const renderMarkdown = async (file) => {
img.setAttribute('loading', 'lazy');
}
});
// 添加滚动监听,用于大文件分块加载
if (hasMoreChunks.value) {
previewElement.addEventListener('scroll', handlePreviewScroll);
}
}
});
lastRenderedKey = renderKey;
@@ -776,4 +863,68 @@ watch(showEditor, (isEditor) => {
justify-content: center;
padding: 20px;
}
/* 没有更多笔记了 - 美化样式 */
.no-more-hint {
display: flex;
justify-content: center;
align-items: center;
padding: 30px 20px;
margin: 10px 0;
}
.no-more-hint span {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 24px;
background: linear-gradient(135deg, #f5f7fa 0%, #e4e7ed 100%);
border-radius: 24px;
color: #909399;
font-size: 14px;
font-weight: 500;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
border: 1px solid #ebeef5;
}
.no-more-hint span::before {
content: '';
display: inline-block;
width: 4px;
height: 4px;
background-color: #c0c4cc;
border-radius: 50%;
}
.no-more-hint span::after {
content: '';
display: inline-block;
width: 4px;
height: 4px;
background-color: #c0c4cc;
border-radius: 50%;
}
/* 暗黑模式适配 */
.dark-theme .no-more-hint span {
background: linear-gradient(135deg, #2c2c3d 0%, #1e1e2f 100%);
color: #606266;
border-color: #2c2c3d;
}
.dark-theme .no-more-hint span::before,
.dark-theme .no-more-hint span::after {
background-color: #606266;
}
/* 继续滚动加载更多提示 */
.load-more-hint {
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
color: var(--el-color-primary);
font-size: 14px;
font-weight: 500;
}
</style>

View File

@@ -51,6 +51,11 @@
<el-icon class="loading-icon is-loading"><Loading /></el-icon>
<span class="loading-text">正在渲染...</span>
</div>
<!-- 大文件分块加载提示 -->
<div v-if="file.hasMoreChunks" class="chunk-loading-hint">
<el-icon class="is-loading"><Loading /></el-icon>
<span>加载更多内容...</span>
</div>
</div>
</template>
@@ -64,6 +69,10 @@ const props = defineProps({
},
isMobile: Boolean,
isUserLoggedIn: Boolean,
hasMoreChunks: {
type: Boolean,
default: false
}
});
const emit = defineEmits([
@@ -192,4 +201,25 @@ const handleExport = (format) => {
font-size: 14px;
color: var(--text-color-secondary);
}
/* 分块加载提示 */
.chunk-loading-hint {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background-color: rgba(255, 255, 255, 0.9);
border-radius: 20px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
color: var(--el-color-primary);
font-size: 14px;
}
.dark-theme .chunk-loading-hint {
background-color: rgba(30, 30, 47, 0.9);
}
</style>