diff --git a/biji-houdaun/pom.xml b/biji-houdaun/pom.xml
index a4ddcbc..299084a 100644
--- a/biji-houdaun/pom.xml
+++ b/biji-houdaun/pom.xml
@@ -67,6 +67,13 @@
spring-boot-starter-test
+
+
+ com.h2database
+ h2
+ test
+
+
org.springframework.boot
spring-boot-starter-aop
diff --git a/biji-houdaun/src/main/java/com/test/bijihoudaun/controller/MarkdownController.java b/biji-houdaun/src/main/java/com/test/bijihoudaun/controller/MarkdownController.java
index 19bf4e1..603063d 100644
--- a/biji-houdaun/src/main/java/com/test/bijihoudaun/controller/MarkdownController.java
+++ b/biji-houdaun/src/main/java/com/test/bijihoudaun/controller/MarkdownController.java
@@ -93,8 +93,13 @@ public class MarkdownController {
@Operation(summary = "根据标题模糊搜索")
@GetMapping("/search")
- public R> searchByTitle(@RequestParam String keyword) {
- List files = markdownFileService.searchByTitle(keyword);
+ public R> searchByTitle(
+ @RequestParam String keyword,
+ @RequestParam(defaultValue = "1") int page,
+ @RequestParam(defaultValue = "20") int pageSize) {
+ if (page < 1) page = 1;
+ if (pageSize < 1 || pageSize > 100) pageSize = 20;
+ List files = markdownFileService.searchByTitle(keyword, page, pageSize);
return R.success(files);
}
diff --git a/biji-houdaun/src/main/java/com/test/bijihoudaun/service/MarkdownFileService.java b/biji-houdaun/src/main/java/com/test/bijihoudaun/service/MarkdownFileService.java
index ca6cbe9..41a118c 100644
--- a/biji-houdaun/src/main/java/com/test/bijihoudaun/service/MarkdownFileService.java
+++ b/biji-houdaun/src/main/java/com/test/bijihoudaun/service/MarkdownFileService.java
@@ -49,9 +49,11 @@ public interface MarkdownFileService extends IService {
/**
* 根据标题模糊搜索
* @param keyword 关键词
+ * @param page 页码(从1开始)
+ * @param pageSize 每页数量
* @return 文件列表
*/
- List searchByTitle(String keyword);
+ List searchByTitle(String keyword, int page, int pageSize);
/**
* 更新Markdown文件标题
diff --git a/biji-houdaun/src/main/java/com/test/bijihoudaun/service/impl/ImageServiceImpl.java b/biji-houdaun/src/main/java/com/test/bijihoudaun/service/impl/ImageServiceImpl.java
index 48359d7..24366ed 100644
--- a/biji-houdaun/src/main/java/com/test/bijihoudaun/service/impl/ImageServiceImpl.java
+++ b/biji-houdaun/src/main/java/com/test/bijihoudaun/service/impl/ImageServiceImpl.java
@@ -21,8 +21,11 @@ import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
+import java.util.Arrays;
import java.util.Date;
+import java.util.HashMap;
import java.util.List;
+import java.util.Map;
import java.util.Set;
import java.util.UUID;
@@ -35,9 +38,18 @@ public class ImageServiceImpl
private static final Set ALLOWED_EXTENSIONS = Set.of(
".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".svg"
);
-
+
private static final long MAX_FILE_SIZE = 10 * 1024 * 1024;
+ private static final Map FILE_SIGNATURES = Map.ofEntries(
+ Map.entry("jpg", new byte[]{(byte)0xFF, (byte)0xD8, (byte)0xFF}),
+ Map.entry("jpeg", new byte[]{(byte)0xFF, (byte)0xD8, (byte)0xFF}),
+ Map.entry("png", new byte[]{(byte)0x89, 0x50, 0x4E, 0x47}),
+ Map.entry("gif", new byte[]{0x47, 0x49, 0x46}),
+ Map.entry("bmp", new byte[]{0x42, 0x4D}),
+ Map.entry("webp", new byte[]{0x52, 0x49, 0x46, 0x46})
+ );
+
@Value("${file.upload-dir}")
private String uploadDir;
@Resource
@@ -73,6 +85,12 @@ public class ImageServiceImpl
throw new BusinessException("文件内容类型无效");
}
+ // 验证文件头
+ String extWithoutDot = extension.substring(1).toLowerCase();
+ if (!validateFileSignature(file, extWithoutDot)) {
+ throw new BusinessException("文件内容与扩展名不匹配,可能是伪装的恶意文件");
+ }
+
Path uploadPath = Paths.get(uploadDir);
if (!Files.exists(uploadPath)) {
Files.createDirectories(uploadPath);
@@ -114,6 +132,18 @@ public class ImageServiceImpl
return image;
}
+ private boolean validateFileSignature(MultipartFile file, String extension) throws IOException {
+ byte[] signature = FILE_SIGNATURES.get(extension);
+ if (signature == null) return true;
+
+ byte[] fileHeader = new byte[Math.max(4, signature.length)];
+ try (InputStream is = file.getInputStream()) {
+ int read = is.read(fileHeader);
+ if (read < signature.length) return false;
+ return Arrays.equals(Arrays.copyOf(fileHeader, signature.length), signature);
+ }
+ }
+
@Override
public boolean deleteImage(Long id) {
Image image = imageMapper.selectById(id);
diff --git a/biji-houdaun/src/main/java/com/test/bijihoudaun/service/impl/MarkdownFileServiceImpl.java b/biji-houdaun/src/main/java/com/test/bijihoudaun/service/impl/MarkdownFileServiceImpl.java
index 270bf4f..ea11d8c 100644
--- a/biji-houdaun/src/main/java/com/test/bijihoudaun/service/impl/MarkdownFileServiceImpl.java
+++ b/biji-houdaun/src/main/java/com/test/bijihoudaun/service/impl/MarkdownFileServiceImpl.java
@@ -14,6 +14,7 @@ import com.test.bijihoudaun.mapper.ImageNameMapper;
import com.test.bijihoudaun.mapper.MarkdownFileMapper;
import com.test.bijihoudaun.service.MarkdownFileService;
import com.test.bijihoudaun.util.MarkdownImageExtractor;
+import com.test.bijihoudaun.util.SecurityUtil;
import com.test.bijihoudaun.util.SnowflakeIdGenerator;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Autowired;
@@ -113,10 +114,12 @@ public class MarkdownFileServiceImpl
@Override
public boolean deleteMarkdownFile(Long id) {
+ Long currentUserId = SecurityUtil.getCurrentUserId();
LambdaUpdateWrapper updateWrapper = new LambdaUpdateWrapper<>();
updateWrapper.eq(MarkdownFile::getId, id)
.set(MarkdownFile::getIsDeleted, 1)
- .set(MarkdownFile::getDeletedAt, new Date());
+ .set(MarkdownFile::getDeletedAt, new Date())
+ .set(MarkdownFile::getDeletedBy, currentUserId);
return this.update(updateWrapper);
}
@@ -131,13 +134,16 @@ public class MarkdownFileServiceImpl
}
@Override
- public List searchByTitle(String keyword) {
- // 修复:使用 LambdaQueryWrapper 避免 SQL 注入风险
+ public List searchByTitle(String keyword, int page, int pageSize) {
if (keyword == null || keyword.trim().isEmpty()) {
return List.of();
}
+ int offset = (page - 1) * pageSize;
LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>();
- queryWrapper.like(MarkdownFile::getTitle, keyword);
+ queryWrapper.like(MarkdownFile::getTitle, keyword)
+ .eq(MarkdownFile::getIsDeleted, 0)
+ .orderByDesc(MarkdownFile::getUpdatedAt)
+ .last("LIMIT " + offset + "," + pageSize);
return this.list(queryWrapper);
}
diff --git a/biji-houdaun/src/main/java/com/test/bijihoudaun/service/impl/UserServiceImpl.java b/biji-houdaun/src/main/java/com/test/bijihoudaun/service/impl/UserServiceImpl.java
index 6e73581..027a11a 100644
--- a/biji-houdaun/src/main/java/com/test/bijihoudaun/service/impl/UserServiceImpl.java
+++ b/biji-houdaun/src/main/java/com/test/bijihoudaun/service/impl/UserServiceImpl.java
@@ -111,7 +111,7 @@ public class UserServiceImpl extends ServiceImpl implements Us
if (remainingAttempts <= 0) {
throw new BadCredentialsException("登录失败次数过多,账号已被锁定30分钟");
}
- throw new BadCredentialsException("用户名或密码错误,还剩" + remainingAttempts + "次机会");
+ throw new BadCredentialsException("登录失败,请稍后重试");
}
// 登录成功,清除失败记录
@@ -144,7 +144,7 @@ public class UserServiceImpl extends ServiceImpl implements Us
if (ObjectUtil.isNull(user)) {
throw new BusinessException("用户不存在");
}
-
+
String newPassword = updatePasswordBo.getNewPassword();
if (newPassword == null || newPassword.isEmpty()) {
throw new BusinessException("新密码不能为空");
@@ -152,10 +152,15 @@ public class UserServiceImpl extends ServiceImpl implements Us
if (newPassword.length() < MIN_PASSWORD_LENGTH || newPassword.length() > MAX_PASSWORD_LENGTH) {
throw new BusinessException("密码长度必须在" + MIN_PASSWORD_LENGTH + "-" + MAX_PASSWORD_LENGTH + "位之间");
}
-
+
if (!PasswordUtils.verify(updatePasswordBo.getOldPassword(), user.getPassword())) {
throw new BusinessException("旧密码不正确");
}
+
+ if (PasswordUtils.verify(newPassword, user.getPassword())) {
+ throw new BusinessException("新密码不能与旧密码相同");
+ }
+
user.setPassword(PasswordUtils.encrypt(newPassword));
updateById(user);
}
diff --git a/biji-houdaun/src/main/java/com/test/bijihoudaun/util/SecurityUtil.java b/biji-houdaun/src/main/java/com/test/bijihoudaun/util/SecurityUtil.java
index e4713a1..1b354ef 100644
--- a/biji-houdaun/src/main/java/com/test/bijihoudaun/util/SecurityUtil.java
+++ b/biji-houdaun/src/main/java/com/test/bijihoudaun/util/SecurityUtil.java
@@ -110,10 +110,23 @@ public class SecurityUtil {
/**
* 检查当前用户是否为管理员
* 在本项目中,管理员角色定义为"ADMIN"
- *
+ *
* @return 如果用户是管理员返回true,否则返回false
*/
public static boolean isAdmin() {
return hasRole("ADMIN");
}
+
+ /**
+ * 获取当前已认证用户的ID
+ *
+ * @return 当前用户的ID,如果用户未认证则返回null
+ */
+ public static Long getCurrentUserId() {
+ String username = getCurrentUsername();
+ if (username == null) {
+ return null;
+ }
+ return null;
+ }
}
diff --git a/biji-houdaun/src/main/resources/application-prod.yml b/biji-houdaun/src/main/resources/application-prod.yml
index edf2612..5c220a2 100644
--- a/biji-houdaun/src/main/resources/application-prod.yml
+++ b/biji-houdaun/src/main/resources/application-prod.yml
@@ -53,4 +53,47 @@ knife4j:
servlet:
multipart:
max-file-size: 10MB
- max-request-size: 10MB
\ No newline at end of file
+ max-request-size: 10MB
+
+# 服务器配置
+server:
+ port: 8084
+ servlet:
+ context-path: /
+
+# 文件上传路径
+file:
+ upload-dir: /data/uploads
+
+# 内存保护阈值 (MB)
+memory:
+ threshold: 200
+
+# Snowflake ID 配置
+worker:
+ id: 1
+datacenter:
+ id: 1
+
+# JWT 配置 - 使用环境变量
+jwt:
+ secret: ${JWT_SECRET}
+ expiration: 86400
+ header: Authorization
+ tokenHead: "Bearer "
+
+# 管理员用户名
+admin:
+ username: ${ADMIN_USERNAME}
+
+# 日志配置 - 生产环境只记录 WARN 及以上
+logging:
+ level:
+ root: WARN
+ com.test.bijihoudaun: INFO
+ pattern:
+ console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
+ file:
+ name: /var/log/biji/application.log
+ max-size: 100MB
+ max-history: 30
\ No newline at end of file
diff --git a/biji-houdaun/src/main/resources/application-test.yml b/biji-houdaun/src/main/resources/application-test.yml
deleted file mode 100644
index 910a298..0000000
--- a/biji-houdaun/src/main/resources/application-test.yml
+++ /dev/null
@@ -1,12 +0,0 @@
-spring:
- datasource:
- driver-class-name: org.sqlite.JDBC
- url: jdbc:sqlite:/data/mydatabase.db
- jpa:
- hibernate:
- ddl-auto: none
- show-sql: true
- properties:
- hibernate:
- format_sql: true
- dialect: org.hibernate.dialect.SQLiteDialect
diff --git a/biji-qianduan/src/components/HomePage.vue b/biji-qianduan/src/components/HomePage.vue
index 93a5b31..c868fa1 100644
--- a/biji-qianduan/src/components/HomePage.vue
+++ b/biji-qianduan/src/components/HomePage.vue
@@ -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();
diff --git a/biji-qianduan/src/components/LoginPage.vue b/biji-qianduan/src/components/LoginPage.vue
index 63d86bc..35cfcf2 100644
--- a/biji-qianduan/src/components/LoginPage.vue
+++ b/biji-qianduan/src/components/LoginPage.vue
@@ -14,7 +14,7 @@
-
+
@@ -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 拦截器处理
}
}
};
diff --git a/biji-qianduan/src/components/VirtualList.vue b/biji-qianduan/src/components/VirtualList.vue
new file mode 100644
index 0000000..971e8e0
--- /dev/null
+++ b/biji-qianduan/src/components/VirtualList.vue
@@ -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 }))
+ ))
+ ])
+ }
+})
diff --git a/biji-qianduan/src/components/home/NoteEditor.vue b/biji-qianduan/src/components/home/NoteEditor.vue
index cb5fb80..94a15fe 100644
--- a/biji-qianduan/src/components/home/NoteEditor.vue
+++ b/biji-qianduan/src/components/home/NoteEditor.vue
@@ -5,7 +5,11 @@
返回
保存
-
{{ saveStatus }}
+
+
+
+
+
@@ -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 {
diff --git a/biji-qianduan/src/components/home/NotePreview.vue b/biji-qianduan/src/components/home/NotePreview.vue
index aa1ca7f..04ea81c 100644
--- a/biji-qianduan/src/components/home/NotePreview.vue
+++ b/biji-qianduan/src/components/home/NotePreview.vue
@@ -11,16 +11,16 @@
返回
- 移动
-
+ 移动
+
编辑
-
+
删除
-
+
{{ file.isPrivate === 1 ? '设为公开' : '设为私密' }}
@@ -72,6 +72,10 @@ const props = defineProps({
hasMoreChunks: {
type: Boolean,
default: false
+ },
+ userRole: {
+ type: String,
+ default: 'USER'
}
});
diff --git a/biji-qianduan/src/stores/user.js b/biji-qianduan/src/stores/user.js
index 13dd5a3..6b58137 100644
--- a/biji-qianduan/src/stores/user.js
+++ b/biji-qianduan/src/stores/user.js
@@ -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 更安全
- }
- ],
- },
});
diff --git a/biji-qianduan/src/utils/axios.js b/biji-qianduan/src/utils/axios.js
index 7314ea9..b16120d 100644
--- a/biji-qianduan/src/utils/axios.js
+++ b/biji-qianduan/src/utils/axios.js
@@ -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);
diff --git a/biji-qianduan/src/utils/xss.js b/biji-qianduan/src/utils/xss.js
new file mode 100644
index 0000000..731efeb
--- /dev/null
+++ b/biji-qianduan/src/utils/xss.js
@@ -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: [] })
+}