feat: 添加XSS防护工具函数并优化多个功能模块

refactor(前端): 重构登录页面和用户状态管理逻辑

fix(后端): 修复用户密码更新逻辑和错误提示

feat(后端): 实现分页搜索功能并优化文件删除逻辑

perf(前端): 优化笔记编辑器自动保存和状态提示

fix(后端): 增强图片上传安全验证

style(前端): 调整笔记预览页面按钮权限控制

chore: 更新生产环境配置和测试数据库依赖

feat(前端): 添加虚拟列表组件优化性能

fix(前端): 修复笔记编辑器返回逻辑和状态保存

refactor(前端): 优化axios拦截器错误处理逻辑

docs: 更新方法注释和参数说明
This commit is contained in:
ikmkj
2026-03-04 18:29:52 +08:00
parent 5ea9c776e7
commit 23ced99e20
17 changed files with 369 additions and 104 deletions

View File

@@ -34,6 +34,7 @@
:is-mobile="isMobile"
:is-user-logged-in="userStore.isLoggedIn"
:has-more-chunks="hasMoreChunks"
:user-role="userStore.userInfo?.role || 'USER'"
@back="selectedFile = null"
@edit="editNote(selectedFile)"
@delete="deleteNote(selectedFile)"
@@ -362,14 +363,26 @@ const handleCreateNote = (payload) => {
selectedFile.value = null; // Ensure preview is hidden
};
const handleEditorBack = (data) => {
const handleEditorBack = async (data) => {
showEditor.value = false;
if (data && data.id) {
const fileWithGrouping = {
// 重置渲染缓存
lastRenderedKey = null;
currentChunkIndex.value = 0;
hasMoreChunks.value = false;
isLoadingChunk.value = false;
totalChunks.value = 0;
// 设置预览状态并加载内容
selectedFile.value = {
...data,
groupingName: data.groupingName || getCategoryName(data.groupingId)
groupingName: data.groupingName || getCategoryName(data.groupingId),
isLoading: true,
isRendering: false
};
selectedFile.value = fileWithGrouping;
// 加载笔记内容
await loadNoteChunk(data.id, 0);
} else {
selectedFile.value = null;
resetToHomeView();

View File

@@ -14,7 +14,7 @@
</el-input>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input v-model="loginForm.password" type="password" placeholder="请输入您的密码" show-password size="large">
<el-input v-model="loginForm.password" type="password" placeholder="请输入您的密码" show-password size="large" @keyup.enter="handleLogin">
<template #prefix>
<el-icon><Lock /></el-icon>
</template>
@@ -61,9 +61,8 @@ const handleLogin = async () => {
const success = await userStore.login(loginForm.value.username, loginForm.value.password);
if (success) {
ElMessage.success('登录成功');
loginForm.value.password = '';
router.push('/home');
} else {
// ElMessage.error('用户名或密码错误'); // 错误已由 axios 拦截器处理
}
}
};

View File

@@ -0,0 +1,51 @@
import { defineComponent, h } from 'vue'
export default defineComponent({
name: 'VirtualList',
props: {
items: {
type: Array,
required: true
},
itemHeight: {
type: Number,
required: true
},
containerHeight: {
type: Number,
default: 600
}
},
setup(props, { slots }) {
const visibleStart = ref(0)
const visibleEnd = ref(Math.ceil(props.containerHeight / props.itemHeight))
const handleScroll = (e) => {
const scrollTop = e.target.scrollTop
visibleStart.value = Math.floor(scrollTop / props.itemHeight)
visibleEnd.value = visibleStart.value + Math.ceil(props.containerHeight / props.itemHeight)
}
const visibleItems = computed(() => {
return props.items.slice(visibleStart.value, visibleEnd.value)
})
return () => h('div', {
style: { height: `${props.containerHeight}px`, overflow: 'auto' },
onScroll: handleScroll
}, [
h('div', {
style: { height: `${props.items.length * props.itemHeight}px`, position: 'relative' }
}, visibleItems.value.map((item, idx) =>
h('div', {
key: visibleStart.value + idx,
style: {
position: 'absolute',
top: `${(visibleStart.value + idx) * props.itemHeight}px`,
height: `${props.itemHeight}px`
}
}, slots.default?.({ item, index: visibleStart.value + idx }))
))
])
}
})

View File

@@ -5,7 +5,11 @@
<div class="actions">
<el-button type="primary" @click="handleBack">返回</el-button>
<el-button type="success" @click="save">保存</el-button>
<span class="save-status">{{ saveStatus }}</span>
<div class="save-status">
<el-icon v-if="saveStatus === '正在输入...'" class="is-loading saving-icon"><Loading /></el-icon>
<el-icon v-else-if="saveStatus === '保存失败'" class="error-icon"><CircleCloseFilled /></el-icon>
<el-icon v-else-if="saveStatus === '已保存'" class="success-icon"><CircleCheckFilled /></el-icon>
</div>
</div>
</el-header>
<div id="vditor-editor" class="vditor" />
@@ -16,8 +20,10 @@
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue';
import Vditor from 'vditor';
import 'vditor/dist/index.css';
import { ElMessage } from 'element-plus';
import { ElMessage, ElMessageBox } from 'element-plus';
import { Loading, CircleCloseFilled, CircleCheckFilled } from '@element-plus/icons-vue';
import { updateMarkdown, uploadImage } from '@/api/CommonApi.js';
import { useUserStore } from '@/stores/user';
const props = defineProps({
editData: {
@@ -37,6 +43,8 @@ const lastSavedContent = ref('');
const isSaving = ref(false);
// 维护当前最新的笔记数据
const currentData = ref({ ...props.editData });
// 保存 beforeunload 事件处理器引用
let handleBeforeUnload = null;
const initVditor = () => {
if (vditor.value) {
@@ -62,14 +70,17 @@ const initVditor = () => {
},
input: (value) => {
if (!isInitialized.value) return;
clearTimeout(saveTimeout.value);
saveStatus.value = '正在输入...';
saveTimeout.value = setTimeout(() => {
if (!isSaving.value && value !== lastSavedContent.value) {
save(value);
}
}, 3000);
// 只有在内容真正改变时才启动定时器
if (value !== lastSavedContent.value) {
clearTimeout(saveTimeout.value);
saveStatus.value = '正在输入...';
saveTimeout.value = setTimeout(() => {
if (!isSaving.value && value !== lastSavedContent.value) {
save(value);
}
}, 5000);
}
},
upload: {
accept: 'image/*',
@@ -97,32 +108,32 @@ const initVditor = () => {
const save = async (value) => {
if (isSaving.value) return;
// 修复:添加空值检查
if (!vditor.value) {
console.warn('编辑器未初始化');
return;
}
clearTimeout(saveTimeout);
clearTimeout(saveTimeout.value);
const content = typeof value === 'string' ? value : vditor.value?.getValue() || '';
if (content === lastSavedContent.value && currentId.value) {
return;
}
isSaving.value = true;
try {
saveStatus.value = '正在保存...';
// 确保groupingId不会丢失优先使用currentData中的值
const groupingId = currentData.value.groupingId || props.editData.groupingId;
// 将ID转为字符串以避免JavaScript精度丢失
const idString = currentId.value ? String(currentId.value) : (currentData.value.id ? String(currentData.value.id) : null);
const groupingIdString = groupingId ? String(groupingId) : null;
const payload = {
const payload = {
id: idString,
content: content,
title: currentData.value.title || props.editData.title,
@@ -130,15 +141,13 @@ const save = async (value) => {
fileName: currentData.value.fileName || props.editData.fileName,
isPrivate: currentData.value.isPrivate !== undefined ? currentData.value.isPrivate : props.editData.isPrivate
};
const response = await updateMarkdown(payload);
if (response && response.id) {
currentId.value = response.id;
lastSavedContent.value = content;
// 使用后端返回的数据但确保groupingId不会丢失
// 注意后端返回的ID是字符串保持字符串格式避免精度丢失
const updatedFile = {
@@ -148,11 +157,18 @@ const save = async (value) => {
groupingId: response.groupingId || groupingIdString,
groupingName: response.groupingName || currentData.value.groupingName
};
// 更新currentData为最新数据
currentData.value = updatedFile;
emit('update:editData', updatedFile);
saveStatus.value = '已保存';
// 2秒后清除成功状态
setTimeout(() => {
if (saveStatus.value === '已保存') {
saveStatus.value = '';
}
}, 2000);
}
} catch (error) {
saveStatus.value = '保存失败';
@@ -164,41 +180,71 @@ const save = async (value) => {
};
const handleBack = async () => {
// 清除定时器,防止返回后继续保存
clearTimeout(saveTimeout.value);
const content = vditor.value ? vditor.value.getValue() : '';
if (content !== lastSavedContent.value && !isSaving.value) {
const hasChanges = content !== lastSavedContent.value;
// ADMIN 角色自动保存未保存的内容
const userStore = useUserStore();
if (hasChanges && !isSaving.value && userStore.userInfo?.role === 'ADMIN') {
await save(content);
}
// 确保groupingId不会丢失保持字符串格式
const groupingId = currentData.value.groupingId || props.editData.groupingId;
const groupingName = currentData.value.groupingName || props.editData.groupingName;
// 修复:普通用户有未保存内容时,返回最后保存的内容
const finalContent = (hasChanges && userStore.userInfo?.role !== 'ADMIN')
? lastSavedContent.value
: content;
const returnData = {
...currentData.value,
...props.editData,
id: currentId.value ? String(currentId.value) : (currentData.value.id ? String(currentData.value.id) : null),
content: content,
content: finalContent,
groupingId: groupingId ? String(groupingId) : null,
groupingName: groupingName
};
emit('back', returnData);
};
onMounted(() => {
initVditor();
// 页面刷新时提示用户保存
handleBeforeUnload = (e) => {
const content = vditor.value ? vditor.value.getValue() : '';
if (content !== lastSavedContent.value && !isSaving.value) {
e.preventDefault();
e.returnValue = '您有未保存的内容,确定要离开吗?';
return '您有未保存的内容,确定要离开吗?';
}
};
window.addEventListener('beforeunload', handleBeforeUnload);
});
onBeforeUnmount(() => {
// 修复:确保清理定时器
// 清理定时器
if (saveTimeout.value) {
clearTimeout(saveTimeout.value);
saveTimeout.value = null;
}
// 清理编辑器
if (vditor.value) {
vditor.value.destroy();
vditor.value = null;
}
// 移除页面刷新提示
if (handleBeforeUnload) {
window.removeEventListener('beforeunload', handleBeforeUnload);
handleBeforeUnload = null;
}
currentId.value = null;
isInitialized.value = false;
});
@@ -236,9 +282,46 @@ watch(() => props.editData, (newVal, oldVal) => {
font-size: 20px;
}
.actions .save-status {
margin-left: 10px;
color: #909399;
.actions {
display: flex;
align-items: center;
gap: 10px;
}
.save-status {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.saving-icon {
font-size: 20px;
color: #409eff;
}
.success-icon {
font-size: 20px;
color: #67c23a;
animation: fadeOut 2s ease-in-out forwards;
}
.error-icon {
font-size: 20px;
color: #f56c6c;
}
@keyframes fadeOut {
0% {
opacity: 1;
}
80% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.vditor {

View File

@@ -11,16 +11,16 @@
<el-icon v-if="isMobile"><Back /></el-icon>
<span v-else>返回</span>
</el-button>
<el-button v-if="isUserLoggedIn && !isMobile" type="warning" @click="$emit('show-move-note-dialog')">移动</el-button>
<el-button v-if="isUserLoggedIn" :circle="isMobile" :size="isMobile ? 'small' : 'default'" type="primary" @click="$emit('edit')">
<el-button v-if="isUserLoggedIn && !isMobile" type="warning" :disabled="userRole !== 'ADMIN'" @click="$emit('show-move-note-dialog')">移动</el-button>
<el-button v-if="isUserLoggedIn" :circle="isMobile" :size="isMobile ? 'small' : 'default'" type="primary" :disabled="userRole !== 'ADMIN'" @click="$emit('edit')">
<el-icon v-if="isMobile"><Edit /></el-icon>
<span v-else>编辑</span>
</el-button>
<el-button v-if="isUserLoggedIn" :circle="isMobile" :size="isMobile ? 'small' : 'default'" type="danger" @click="$emit('delete')">
<el-button v-if="isUserLoggedIn" :circle="isMobile" :size="isMobile ? 'small' : 'default'" type="danger" :disabled="userRole !== 'ADMIN'" @click="$emit('delete')">
<el-icon v-if="isMobile"><Delete /></el-icon>
<span v-else>删除</span>
</el-button>
<el-button v-if="isUserLoggedIn" :circle="isMobile" :size="isMobile ? 'small' : 'default'" type="info" @click="$emit('show-privacy-dialog')">
<el-button v-if="isUserLoggedIn" :circle="isMobile" :size="isMobile ? 'small' : 'default'" type="info" :disabled="userRole !== 'ADMIN'" @click="$emit('show-privacy-dialog')">
<el-icon v-if="isMobile"><Lock /></el-icon>
<span v-else>{{ file.isPrivate === 1 ? '设为公开' : '设为私密' }}</span>
</el-button>
@@ -72,6 +72,10 @@ const props = defineProps({
hasMoreChunks: {
type: Boolean,
default: false
},
userRole: {
type: String,
default: 'USER'
}
});

View File

@@ -3,9 +3,9 @@ import { login as loginApi } from '../api/CommonApi';
export const useUserStore = defineStore('user', {
state: () => ({
token: '',
userInfo: null,
tokenExpiry: null, // 添加 Token 过期时间
token: localStorage.getItem('user-token') || '',
userInfo: JSON.parse(localStorage.getItem('user-info') || 'null'),
tokenExpiry: parseInt(localStorage.getItem('user-token-expiry') || '0'),
}),
actions: {
async login(username, password) {
@@ -13,12 +13,15 @@ export const useUserStore = defineStore('user', {
const response = await loginApi({ username, password });
if (response && response.token) {
this.token = response.token;
// 解析 JWT 获取过期时间
const payload = JSON.parse(atob(response.token.split('.')[1]));
this.tokenExpiry = payload.exp * 1000; // 转换为毫秒
this.tokenExpiry = payload.exp * 1000;
if (response.userInfo) {
this.userInfo = response.userInfo;
}
// 持久化到 localStorage
localStorage.setItem('user-token', response.token);
localStorage.setItem('user-info', JSON.stringify(response.userInfo));
localStorage.setItem('user-token-expiry', this.tokenExpiry.toString());
return true;
}
return false;
@@ -31,8 +34,10 @@ export const useUserStore = defineStore('user', {
this.token = '';
this.userInfo = null;
this.tokenExpiry = null;
localStorage.removeItem('user-token');
localStorage.removeItem('user-info');
localStorage.removeItem('user-token-expiry');
},
// 检查 Token 是否过期
isTokenExpired() {
if (!this.tokenExpiry) return true;
return Date.now() >= this.tokenExpiry;
@@ -40,16 +45,6 @@ export const useUserStore = defineStore('user', {
},
getters: {
isLoggedIn: (state) => !!state.token && Date.now() < (state.tokenExpiry || 0),
// 添加:判断是否为管理员
isAdmin: (state) => state.userInfo?.role === 'ADMIN',
},
persist: {
enabled: true,
strategies: [
{
key: 'user-store',
storage: sessionStorage, // 使用 sessionStorage比 localStorage 更安全
}
],
},
});

View File

@@ -4,10 +4,11 @@ import { ElMessage } from 'element-plus'
import router from '../router'
import { getReplayAttackHeaders, needsReplayAttackValidation } from './security'
let retryCount = 0
const MAX_RETRIES = 3
const instance = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
// 开发环境使用withCredentials生产环境关闭
// withCredentials: import.meta.env.DEV,
headers: {
'Content-Type': 'application/json'
}
@@ -21,7 +22,13 @@ instance.interceptors.request.use(
if (userStore.token) {
config.headers['Authorization'] = `Bearer ${userStore.token}`
}
// 添加 CSRF token
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content
if (csrfToken) {
config.headers['X-CSRF-Token'] = csrfToken
}
// 添加防重放攻击请求头POST/PUT/DELETE 请求)
if (needsReplayAttackValidation(config.method, config.url)) {
const replayHeaders = getReplayAttackHeaders()
@@ -41,6 +48,7 @@ instance.interceptors.request.use(
// 响应拦截器
instance.interceptors.response.use(
response => {
retryCount = 0
const res = response.data;
if (res.code !== 200) {
ElMessage({
@@ -53,11 +61,19 @@ instance.interceptors.response.use(
return res.data;
}
},
error => {
async error => {
if (error.response) {
const status = error.response.status;
const data = error.response.data;
// 503 - 服务器繁忙,重试
if (status === 503 && retryCount < MAX_RETRIES) {
retryCount++
await new Promise(resolve => setTimeout(resolve, 1000 * retryCount))
return instance(error.config)
}
retryCount = 0
// 401 - 未授权
if (status === 401) {
try {
@@ -70,38 +86,31 @@ instance.interceptors.response.use(
}
return Promise.reject(error);
}
// 403 - 权限不足
if (status === 403) {
const msg = data?.msg || '无权操作';
ElMessage.error(msg);
return Promise.reject(new Error(msg));
}
// 429 - 请求过于频繁
if (status === 429) {
const msg = data?.msg || '请求过于频繁,请稍后再试';
ElMessage.error(msg);
return Promise.reject(new Error(msg));
}
// 400 - 验证码错误等
if (status === 400) {
const msg = data?.msg || '请求参数错误';
ElMessage.error(msg);
return Promise.reject(new Error(msg));
}
// 503 - 服务器繁忙(内存不足)
if (status === 503) {
const msg = data?.msg || '服务器繁忙,请稍后再试';
ElMessage.error(msg);
return Promise.reject(new Error(msg));
}
// 其他错误,生产环境隐藏详细错误信息
// 其他错误
const isDev = import.meta.env.DEV;
const msg = isDev
const msg = isDev
? (data?.msg || error.message)
: (data?.msg || '操作失败,请稍后重试');
ElMessage.error(msg);

View File

@@ -0,0 +1,12 @@
import DOMPurify from 'dompurify'
export const sanitizeHtml = (html) => {
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'code', 'pre', 'img'],
ALLOWED_ATTR: ['href', 'title', 'src', 'alt']
})
}
export const sanitizeText = (text) => {
return DOMPurify.sanitize(text, { ALLOWED_TAGS: [] })
}