feat: 添加XSS防护工具函数并优化多个功能模块
refactor(前端): 重构登录页面和用户状态管理逻辑 fix(后端): 修复用户密码更新逻辑和错误提示 feat(后端): 实现分页搜索功能并优化文件删除逻辑 perf(前端): 优化笔记编辑器自动保存和状态提示 fix(后端): 增强图片上传安全验证 style(前端): 调整笔记预览页面按钮权限控制 chore: 更新生产环境配置和测试数据库依赖 feat(前端): 添加虚拟列表组件优化性能 fix(前端): 修复笔记编辑器返回逻辑和状态保存 refactor(前端): 优化axios拦截器错误处理逻辑 docs: 更新方法注释和参数说明
This commit is contained in:
@@ -67,6 +67,13 @@
|
|||||||
<artifactId>spring-boot-starter-test</artifactId>
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- H2 数据库(测试用) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.h2database</groupId>
|
||||||
|
<artifactId>h2</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-aop</artifactId>
|
<artifactId>spring-boot-starter-aop</artifactId>
|
||||||
|
|||||||
@@ -93,8 +93,13 @@ public class MarkdownController {
|
|||||||
|
|
||||||
@Operation(summary = "根据标题模糊搜索")
|
@Operation(summary = "根据标题模糊搜索")
|
||||||
@GetMapping("/search")
|
@GetMapping("/search")
|
||||||
public R<List<MarkdownFile>> searchByTitle(@RequestParam String keyword) {
|
public R<List<MarkdownFile>> searchByTitle(
|
||||||
List<MarkdownFile> files = markdownFileService.searchByTitle(keyword);
|
@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<MarkdownFile> files = markdownFileService.searchByTitle(keyword, page, pageSize);
|
||||||
return R.success(files);
|
return R.success(files);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,9 +49,11 @@ public interface MarkdownFileService extends IService<MarkdownFile> {
|
|||||||
/**
|
/**
|
||||||
* 根据标题模糊搜索
|
* 根据标题模糊搜索
|
||||||
* @param keyword 关键词
|
* @param keyword 关键词
|
||||||
|
* @param page 页码(从1开始)
|
||||||
|
* @param pageSize 每页数量
|
||||||
* @return 文件列表
|
* @return 文件列表
|
||||||
*/
|
*/
|
||||||
List<MarkdownFile> searchByTitle(String keyword);
|
List<MarkdownFile> searchByTitle(String keyword, int page, int pageSize);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 更新Markdown文件标题
|
* 更新Markdown文件标题
|
||||||
|
|||||||
@@ -21,8 +21,11 @@ import java.io.InputStream;
|
|||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
|
import java.util.Arrays;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@@ -35,9 +38,18 @@ public class ImageServiceImpl
|
|||||||
private static final Set<String> ALLOWED_EXTENSIONS = Set.of(
|
private static final Set<String> ALLOWED_EXTENSIONS = Set.of(
|
||||||
".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".svg"
|
".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".svg"
|
||||||
);
|
);
|
||||||
|
|
||||||
private static final long MAX_FILE_SIZE = 10 * 1024 * 1024;
|
private static final long MAX_FILE_SIZE = 10 * 1024 * 1024;
|
||||||
|
|
||||||
|
private static final Map<String, byte[]> 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}")
|
@Value("${file.upload-dir}")
|
||||||
private String uploadDir;
|
private String uploadDir;
|
||||||
@Resource
|
@Resource
|
||||||
@@ -73,6 +85,12 @@ public class ImageServiceImpl
|
|||||||
throw new BusinessException("文件内容类型无效");
|
throw new BusinessException("文件内容类型无效");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 验证文件头
|
||||||
|
String extWithoutDot = extension.substring(1).toLowerCase();
|
||||||
|
if (!validateFileSignature(file, extWithoutDot)) {
|
||||||
|
throw new BusinessException("文件内容与扩展名不匹配,可能是伪装的恶意文件");
|
||||||
|
}
|
||||||
|
|
||||||
Path uploadPath = Paths.get(uploadDir);
|
Path uploadPath = Paths.get(uploadDir);
|
||||||
if (!Files.exists(uploadPath)) {
|
if (!Files.exists(uploadPath)) {
|
||||||
Files.createDirectories(uploadPath);
|
Files.createDirectories(uploadPath);
|
||||||
@@ -114,6 +132,18 @@ public class ImageServiceImpl
|
|||||||
return image;
|
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
|
@Override
|
||||||
public boolean deleteImage(Long id) {
|
public boolean deleteImage(Long id) {
|
||||||
Image image = imageMapper.selectById(id);
|
Image image = imageMapper.selectById(id);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import com.test.bijihoudaun.mapper.ImageNameMapper;
|
|||||||
import com.test.bijihoudaun.mapper.MarkdownFileMapper;
|
import com.test.bijihoudaun.mapper.MarkdownFileMapper;
|
||||||
import com.test.bijihoudaun.service.MarkdownFileService;
|
import com.test.bijihoudaun.service.MarkdownFileService;
|
||||||
import com.test.bijihoudaun.util.MarkdownImageExtractor;
|
import com.test.bijihoudaun.util.MarkdownImageExtractor;
|
||||||
|
import com.test.bijihoudaun.util.SecurityUtil;
|
||||||
import com.test.bijihoudaun.util.SnowflakeIdGenerator;
|
import com.test.bijihoudaun.util.SnowflakeIdGenerator;
|
||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
@@ -113,10 +114,12 @@ public class MarkdownFileServiceImpl
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean deleteMarkdownFile(Long id) {
|
public boolean deleteMarkdownFile(Long id) {
|
||||||
|
Long currentUserId = SecurityUtil.getCurrentUserId();
|
||||||
LambdaUpdateWrapper<MarkdownFile> updateWrapper = new LambdaUpdateWrapper<>();
|
LambdaUpdateWrapper<MarkdownFile> updateWrapper = new LambdaUpdateWrapper<>();
|
||||||
updateWrapper.eq(MarkdownFile::getId, id)
|
updateWrapper.eq(MarkdownFile::getId, id)
|
||||||
.set(MarkdownFile::getIsDeleted, 1)
|
.set(MarkdownFile::getIsDeleted, 1)
|
||||||
.set(MarkdownFile::getDeletedAt, new Date());
|
.set(MarkdownFile::getDeletedAt, new Date())
|
||||||
|
.set(MarkdownFile::getDeletedBy, currentUserId);
|
||||||
return this.update(updateWrapper);
|
return this.update(updateWrapper);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,13 +134,16 @@ public class MarkdownFileServiceImpl
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<MarkdownFile> searchByTitle(String keyword) {
|
public List<MarkdownFile> searchByTitle(String keyword, int page, int pageSize) {
|
||||||
// 修复:使用 LambdaQueryWrapper 避免 SQL 注入风险
|
|
||||||
if (keyword == null || keyword.trim().isEmpty()) {
|
if (keyword == null || keyword.trim().isEmpty()) {
|
||||||
return List.of();
|
return List.of();
|
||||||
}
|
}
|
||||||
|
int offset = (page - 1) * pageSize;
|
||||||
LambdaQueryWrapper<MarkdownFile> queryWrapper = new LambdaQueryWrapper<>();
|
LambdaQueryWrapper<MarkdownFile> 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);
|
return this.list(queryWrapper);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
|
|||||||
if (remainingAttempts <= 0) {
|
if (remainingAttempts <= 0) {
|
||||||
throw new BadCredentialsException("登录失败次数过多,账号已被锁定30分钟");
|
throw new BadCredentialsException("登录失败次数过多,账号已被锁定30分钟");
|
||||||
}
|
}
|
||||||
throw new BadCredentialsException("用户名或密码错误,还剩" + remainingAttempts + "次机会");
|
throw new BadCredentialsException("登录失败,请稍后重试");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 登录成功,清除失败记录
|
// 登录成功,清除失败记录
|
||||||
@@ -144,7 +144,7 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
|
|||||||
if (ObjectUtil.isNull(user)) {
|
if (ObjectUtil.isNull(user)) {
|
||||||
throw new BusinessException("用户不存在");
|
throw new BusinessException("用户不存在");
|
||||||
}
|
}
|
||||||
|
|
||||||
String newPassword = updatePasswordBo.getNewPassword();
|
String newPassword = updatePasswordBo.getNewPassword();
|
||||||
if (newPassword == null || newPassword.isEmpty()) {
|
if (newPassword == null || newPassword.isEmpty()) {
|
||||||
throw new BusinessException("新密码不能为空");
|
throw new BusinessException("新密码不能为空");
|
||||||
@@ -152,10 +152,15 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
|
|||||||
if (newPassword.length() < MIN_PASSWORD_LENGTH || newPassword.length() > MAX_PASSWORD_LENGTH) {
|
if (newPassword.length() < MIN_PASSWORD_LENGTH || newPassword.length() > MAX_PASSWORD_LENGTH) {
|
||||||
throw new BusinessException("密码长度必须在" + MIN_PASSWORD_LENGTH + "-" + MAX_PASSWORD_LENGTH + "位之间");
|
throw new BusinessException("密码长度必须在" + MIN_PASSWORD_LENGTH + "-" + MAX_PASSWORD_LENGTH + "位之间");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!PasswordUtils.verify(updatePasswordBo.getOldPassword(), user.getPassword())) {
|
if (!PasswordUtils.verify(updatePasswordBo.getOldPassword(), user.getPassword())) {
|
||||||
throw new BusinessException("旧密码不正确");
|
throw new BusinessException("旧密码不正确");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (PasswordUtils.verify(newPassword, user.getPassword())) {
|
||||||
|
throw new BusinessException("新密码不能与旧密码相同");
|
||||||
|
}
|
||||||
|
|
||||||
user.setPassword(PasswordUtils.encrypt(newPassword));
|
user.setPassword(PasswordUtils.encrypt(newPassword));
|
||||||
updateById(user);
|
updateById(user);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,10 +110,23 @@ public class SecurityUtil {
|
|||||||
/**
|
/**
|
||||||
* 检查当前用户是否为管理员
|
* 检查当前用户是否为管理员
|
||||||
* 在本项目中,管理员角色定义为"ADMIN"
|
* 在本项目中,管理员角色定义为"ADMIN"
|
||||||
*
|
*
|
||||||
* @return 如果用户是管理员返回true,否则返回false
|
* @return 如果用户是管理员返回true,否则返回false
|
||||||
*/
|
*/
|
||||||
public static boolean isAdmin() {
|
public static boolean isAdmin() {
|
||||||
return hasRole("ADMIN");
|
return hasRole("ADMIN");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前已认证用户的ID
|
||||||
|
*
|
||||||
|
* @return 当前用户的ID,如果用户未认证则返回null
|
||||||
|
*/
|
||||||
|
public static Long getCurrentUserId() {
|
||||||
|
String username = getCurrentUsername();
|
||||||
|
if (username == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,4 +53,47 @@ knife4j:
|
|||||||
servlet:
|
servlet:
|
||||||
multipart:
|
multipart:
|
||||||
max-file-size: 10MB
|
max-file-size: 10MB
|
||||||
max-request-size: 10MB
|
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
|
||||||
@@ -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
|
|
||||||
@@ -34,6 +34,7 @@
|
|||||||
:is-mobile="isMobile"
|
:is-mobile="isMobile"
|
||||||
:is-user-logged-in="userStore.isLoggedIn"
|
:is-user-logged-in="userStore.isLoggedIn"
|
||||||
:has-more-chunks="hasMoreChunks"
|
:has-more-chunks="hasMoreChunks"
|
||||||
|
:user-role="userStore.userInfo?.role || 'USER'"
|
||||||
@back="selectedFile = null"
|
@back="selectedFile = null"
|
||||||
@edit="editNote(selectedFile)"
|
@edit="editNote(selectedFile)"
|
||||||
@delete="deleteNote(selectedFile)"
|
@delete="deleteNote(selectedFile)"
|
||||||
@@ -362,14 +363,26 @@ const handleCreateNote = (payload) => {
|
|||||||
selectedFile.value = null; // Ensure preview is hidden
|
selectedFile.value = null; // Ensure preview is hidden
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditorBack = (data) => {
|
const handleEditorBack = async (data) => {
|
||||||
showEditor.value = false;
|
showEditor.value = false;
|
||||||
if (data && data.id) {
|
if (data && data.id) {
|
||||||
const fileWithGrouping = {
|
// 重置渲染缓存
|
||||||
|
lastRenderedKey = null;
|
||||||
|
currentChunkIndex.value = 0;
|
||||||
|
hasMoreChunks.value = false;
|
||||||
|
isLoadingChunk.value = false;
|
||||||
|
totalChunks.value = 0;
|
||||||
|
|
||||||
|
// 设置预览状态并加载内容
|
||||||
|
selectedFile.value = {
|
||||||
...data,
|
...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 {
|
} else {
|
||||||
selectedFile.value = null;
|
selectedFile.value = null;
|
||||||
resetToHomeView();
|
resetToHomeView();
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
</el-input>
|
</el-input>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="密码" prop="password">
|
<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>
|
<template #prefix>
|
||||||
<el-icon><Lock /></el-icon>
|
<el-icon><Lock /></el-icon>
|
||||||
</template>
|
</template>
|
||||||
@@ -61,9 +61,8 @@ const handleLogin = async () => {
|
|||||||
const success = await userStore.login(loginForm.value.username, loginForm.value.password);
|
const success = await userStore.login(loginForm.value.username, loginForm.value.password);
|
||||||
if (success) {
|
if (success) {
|
||||||
ElMessage.success('登录成功');
|
ElMessage.success('登录成功');
|
||||||
|
loginForm.value.password = '';
|
||||||
router.push('/home');
|
router.push('/home');
|
||||||
} else {
|
|
||||||
// ElMessage.error('用户名或密码错误'); // 错误已由 axios 拦截器处理
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
51
biji-qianduan/src/components/VirtualList.vue
Normal file
51
biji-qianduan/src/components/VirtualList.vue
Normal 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 }))
|
||||||
|
))
|
||||||
|
])
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -5,7 +5,11 @@
|
|||||||
<div class="actions">
|
<div class="actions">
|
||||||
<el-button type="primary" @click="handleBack">返回</el-button>
|
<el-button type="primary" @click="handleBack">返回</el-button>
|
||||||
<el-button type="success" @click="save">保存</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>
|
</div>
|
||||||
</el-header>
|
</el-header>
|
||||||
<div id="vditor-editor" class="vditor" />
|
<div id="vditor-editor" class="vditor" />
|
||||||
@@ -16,8 +20,10 @@
|
|||||||
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue';
|
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue';
|
||||||
import Vditor from 'vditor';
|
import Vditor from 'vditor';
|
||||||
import 'vditor/dist/index.css';
|
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 { updateMarkdown, uploadImage } from '@/api/CommonApi.js';
|
||||||
|
import { useUserStore } from '@/stores/user';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
editData: {
|
editData: {
|
||||||
@@ -37,6 +43,8 @@ const lastSavedContent = ref('');
|
|||||||
const isSaving = ref(false);
|
const isSaving = ref(false);
|
||||||
// 维护当前最新的笔记数据
|
// 维护当前最新的笔记数据
|
||||||
const currentData = ref({ ...props.editData });
|
const currentData = ref({ ...props.editData });
|
||||||
|
// 保存 beforeunload 事件处理器引用
|
||||||
|
let handleBeforeUnload = null;
|
||||||
|
|
||||||
const initVditor = () => {
|
const initVditor = () => {
|
||||||
if (vditor.value) {
|
if (vditor.value) {
|
||||||
@@ -62,14 +70,17 @@ const initVditor = () => {
|
|||||||
},
|
},
|
||||||
input: (value) => {
|
input: (value) => {
|
||||||
if (!isInitialized.value) return;
|
if (!isInitialized.value) return;
|
||||||
|
|
||||||
clearTimeout(saveTimeout.value);
|
// 只有在内容真正改变时才启动定时器
|
||||||
saveStatus.value = '正在输入...';
|
if (value !== lastSavedContent.value) {
|
||||||
saveTimeout.value = setTimeout(() => {
|
clearTimeout(saveTimeout.value);
|
||||||
if (!isSaving.value && value !== lastSavedContent.value) {
|
saveStatus.value = '正在输入...';
|
||||||
save(value);
|
saveTimeout.value = setTimeout(() => {
|
||||||
}
|
if (!isSaving.value && value !== lastSavedContent.value) {
|
||||||
}, 3000);
|
save(value);
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
upload: {
|
upload: {
|
||||||
accept: 'image/*',
|
accept: 'image/*',
|
||||||
@@ -97,32 +108,32 @@ const initVditor = () => {
|
|||||||
|
|
||||||
const save = async (value) => {
|
const save = async (value) => {
|
||||||
if (isSaving.value) return;
|
if (isSaving.value) return;
|
||||||
|
|
||||||
// 修复:添加空值检查
|
// 修复:添加空值检查
|
||||||
if (!vditor.value) {
|
if (!vditor.value) {
|
||||||
console.warn('编辑器未初始化');
|
console.warn('编辑器未初始化');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
clearTimeout(saveTimeout);
|
clearTimeout(saveTimeout.value);
|
||||||
const content = typeof value === 'string' ? value : vditor.value?.getValue() || '';
|
const content = typeof value === 'string' ? value : vditor.value?.getValue() || '';
|
||||||
|
|
||||||
if (content === lastSavedContent.value && currentId.value) {
|
if (content === lastSavedContent.value && currentId.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
isSaving.value = true;
|
isSaving.value = true;
|
||||||
try {
|
try {
|
||||||
saveStatus.value = '正在保存...';
|
saveStatus.value = '正在保存...';
|
||||||
|
|
||||||
// 确保groupingId不会丢失:优先使用currentData中的值
|
// 确保groupingId不会丢失:优先使用currentData中的值
|
||||||
const groupingId = currentData.value.groupingId || props.editData.groupingId;
|
const groupingId = currentData.value.groupingId || props.editData.groupingId;
|
||||||
|
|
||||||
// 将ID转为字符串以避免JavaScript精度丢失
|
// 将ID转为字符串以避免JavaScript精度丢失
|
||||||
const idString = currentId.value ? String(currentId.value) : (currentData.value.id ? String(currentData.value.id) : null);
|
const idString = currentId.value ? String(currentId.value) : (currentData.value.id ? String(currentData.value.id) : null);
|
||||||
const groupingIdString = groupingId ? String(groupingId) : null;
|
const groupingIdString = groupingId ? String(groupingId) : null;
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
id: idString,
|
id: idString,
|
||||||
content: content,
|
content: content,
|
||||||
title: currentData.value.title || props.editData.title,
|
title: currentData.value.title || props.editData.title,
|
||||||
@@ -130,15 +141,13 @@ const save = async (value) => {
|
|||||||
fileName: currentData.value.fileName || props.editData.fileName,
|
fileName: currentData.value.fileName || props.editData.fileName,
|
||||||
isPrivate: currentData.value.isPrivate !== undefined ? currentData.value.isPrivate : props.editData.isPrivate
|
isPrivate: currentData.value.isPrivate !== undefined ? currentData.value.isPrivate : props.editData.isPrivate
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const response = await updateMarkdown(payload);
|
const response = await updateMarkdown(payload);
|
||||||
|
|
||||||
if (response && response.id) {
|
if (response && response.id) {
|
||||||
currentId.value = response.id;
|
currentId.value = response.id;
|
||||||
lastSavedContent.value = content;
|
lastSavedContent.value = content;
|
||||||
|
|
||||||
// 使用后端返回的数据,但确保groupingId不会丢失
|
// 使用后端返回的数据,但确保groupingId不会丢失
|
||||||
// 注意:后端返回的ID是字符串,保持字符串格式避免精度丢失
|
// 注意:后端返回的ID是字符串,保持字符串格式避免精度丢失
|
||||||
const updatedFile = {
|
const updatedFile = {
|
||||||
@@ -148,11 +157,18 @@ const save = async (value) => {
|
|||||||
groupingId: response.groupingId || groupingIdString,
|
groupingId: response.groupingId || groupingIdString,
|
||||||
groupingName: response.groupingName || currentData.value.groupingName
|
groupingName: response.groupingName || currentData.value.groupingName
|
||||||
};
|
};
|
||||||
|
|
||||||
// 更新currentData为最新数据
|
// 更新currentData为最新数据
|
||||||
currentData.value = updatedFile;
|
currentData.value = updatedFile;
|
||||||
emit('update:editData', updatedFile);
|
emit('update:editData', updatedFile);
|
||||||
saveStatus.value = '已保存';
|
saveStatus.value = '已保存';
|
||||||
|
|
||||||
|
// 2秒后清除成功状态
|
||||||
|
setTimeout(() => {
|
||||||
|
if (saveStatus.value === '已保存') {
|
||||||
|
saveStatus.value = '';
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
saveStatus.value = '保存失败';
|
saveStatus.value = '保存失败';
|
||||||
@@ -164,41 +180,71 @@ const save = async (value) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleBack = async () => {
|
const handleBack = async () => {
|
||||||
|
// 清除定时器,防止返回后继续保存
|
||||||
|
clearTimeout(saveTimeout.value);
|
||||||
|
|
||||||
const content = vditor.value ? vditor.value.getValue() : '';
|
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);
|
await save(content);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 确保groupingId不会丢失(保持字符串格式)
|
// 确保groupingId不会丢失(保持字符串格式)
|
||||||
const groupingId = currentData.value.groupingId || props.editData.groupingId;
|
const groupingId = currentData.value.groupingId || props.editData.groupingId;
|
||||||
const groupingName = currentData.value.groupingName || props.editData.groupingName;
|
const groupingName = currentData.value.groupingName || props.editData.groupingName;
|
||||||
|
|
||||||
|
// 修复:普通用户有未保存内容时,返回最后保存的内容
|
||||||
|
const finalContent = (hasChanges && userStore.userInfo?.role !== 'ADMIN')
|
||||||
|
? lastSavedContent.value
|
||||||
|
: content;
|
||||||
|
|
||||||
const returnData = {
|
const returnData = {
|
||||||
...currentData.value,
|
...currentData.value,
|
||||||
...props.editData,
|
...props.editData,
|
||||||
id: currentId.value ? String(currentId.value) : (currentData.value.id ? String(currentData.value.id) : null),
|
id: currentId.value ? String(currentId.value) : (currentData.value.id ? String(currentData.value.id) : null),
|
||||||
content: content,
|
content: finalContent,
|
||||||
groupingId: groupingId ? String(groupingId) : null,
|
groupingId: groupingId ? String(groupingId) : null,
|
||||||
groupingName: groupingName
|
groupingName: groupingName
|
||||||
};
|
};
|
||||||
|
|
||||||
emit('back', returnData);
|
emit('back', returnData);
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
initVditor();
|
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(() => {
|
onBeforeUnmount(() => {
|
||||||
// 修复:确保清理定时器
|
// 清理定时器
|
||||||
if (saveTimeout.value) {
|
if (saveTimeout.value) {
|
||||||
clearTimeout(saveTimeout.value);
|
clearTimeout(saveTimeout.value);
|
||||||
saveTimeout.value = null;
|
saveTimeout.value = null;
|
||||||
}
|
}
|
||||||
|
// 清理编辑器
|
||||||
if (vditor.value) {
|
if (vditor.value) {
|
||||||
vditor.value.destroy();
|
vditor.value.destroy();
|
||||||
vditor.value = null;
|
vditor.value = null;
|
||||||
}
|
}
|
||||||
|
// 移除页面刷新提示
|
||||||
|
if (handleBeforeUnload) {
|
||||||
|
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||||
|
handleBeforeUnload = null;
|
||||||
|
}
|
||||||
currentId.value = null;
|
currentId.value = null;
|
||||||
isInitialized.value = false;
|
isInitialized.value = false;
|
||||||
});
|
});
|
||||||
@@ -236,9 +282,46 @@ watch(() => props.editData, (newVal, oldVal) => {
|
|||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions .save-status {
|
.actions {
|
||||||
margin-left: 10px;
|
display: flex;
|
||||||
color: #909399;
|
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 {
|
.vditor {
|
||||||
|
|||||||
@@ -11,16 +11,16 @@
|
|||||||
<el-icon v-if="isMobile"><Back /></el-icon>
|
<el-icon v-if="isMobile"><Back /></el-icon>
|
||||||
<span v-else>返回</span>
|
<span v-else>返回</span>
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button v-if="isUserLoggedIn && !isMobile" type="warning" @click="$emit('show-move-note-dialog')">移动</el-button>
|
<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" @click="$emit('edit')">
|
<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>
|
<el-icon v-if="isMobile"><Edit /></el-icon>
|
||||||
<span v-else>编辑</span>
|
<span v-else>编辑</span>
|
||||||
</el-button>
|
</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>
|
<el-icon v-if="isMobile"><Delete /></el-icon>
|
||||||
<span v-else>删除</span>
|
<span v-else>删除</span>
|
||||||
</el-button>
|
</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>
|
<el-icon v-if="isMobile"><Lock /></el-icon>
|
||||||
<span v-else>{{ file.isPrivate === 1 ? '设为公开' : '设为私密' }}</span>
|
<span v-else>{{ file.isPrivate === 1 ? '设为公开' : '设为私密' }}</span>
|
||||||
</el-button>
|
</el-button>
|
||||||
@@ -72,6 +72,10 @@ const props = defineProps({
|
|||||||
hasMoreChunks: {
|
hasMoreChunks: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false
|
||||||
|
},
|
||||||
|
userRole: {
|
||||||
|
type: String,
|
||||||
|
default: 'USER'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import { login as loginApi } from '../api/CommonApi';
|
|||||||
|
|
||||||
export const useUserStore = defineStore('user', {
|
export const useUserStore = defineStore('user', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
token: '',
|
token: localStorage.getItem('user-token') || '',
|
||||||
userInfo: null,
|
userInfo: JSON.parse(localStorage.getItem('user-info') || 'null'),
|
||||||
tokenExpiry: null, // 添加 Token 过期时间
|
tokenExpiry: parseInt(localStorage.getItem('user-token-expiry') || '0'),
|
||||||
}),
|
}),
|
||||||
actions: {
|
actions: {
|
||||||
async login(username, password) {
|
async login(username, password) {
|
||||||
@@ -13,12 +13,15 @@ export const useUserStore = defineStore('user', {
|
|||||||
const response = await loginApi({ username, password });
|
const response = await loginApi({ username, password });
|
||||||
if (response && response.token) {
|
if (response && response.token) {
|
||||||
this.token = response.token;
|
this.token = response.token;
|
||||||
// 解析 JWT 获取过期时间
|
|
||||||
const payload = JSON.parse(atob(response.token.split('.')[1]));
|
const payload = JSON.parse(atob(response.token.split('.')[1]));
|
||||||
this.tokenExpiry = payload.exp * 1000; // 转换为毫秒
|
this.tokenExpiry = payload.exp * 1000;
|
||||||
if (response.userInfo) {
|
if (response.userInfo) {
|
||||||
this.userInfo = 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 true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -31,8 +34,10 @@ export const useUserStore = defineStore('user', {
|
|||||||
this.token = '';
|
this.token = '';
|
||||||
this.userInfo = null;
|
this.userInfo = null;
|
||||||
this.tokenExpiry = null;
|
this.tokenExpiry = null;
|
||||||
|
localStorage.removeItem('user-token');
|
||||||
|
localStorage.removeItem('user-info');
|
||||||
|
localStorage.removeItem('user-token-expiry');
|
||||||
},
|
},
|
||||||
// 检查 Token 是否过期
|
|
||||||
isTokenExpired() {
|
isTokenExpired() {
|
||||||
if (!this.tokenExpiry) return true;
|
if (!this.tokenExpiry) return true;
|
||||||
return Date.now() >= this.tokenExpiry;
|
return Date.now() >= this.tokenExpiry;
|
||||||
@@ -40,16 +45,6 @@ export const useUserStore = defineStore('user', {
|
|||||||
},
|
},
|
||||||
getters: {
|
getters: {
|
||||||
isLoggedIn: (state) => !!state.token && Date.now() < (state.tokenExpiry || 0),
|
isLoggedIn: (state) => !!state.token && Date.now() < (state.tokenExpiry || 0),
|
||||||
// 添加:判断是否为管理员
|
|
||||||
isAdmin: (state) => state.userInfo?.role === 'ADMIN',
|
isAdmin: (state) => state.userInfo?.role === 'ADMIN',
|
||||||
},
|
},
|
||||||
persist: {
|
|
||||||
enabled: true,
|
|
||||||
strategies: [
|
|
||||||
{
|
|
||||||
key: 'user-store',
|
|
||||||
storage: sessionStorage, // 使用 sessionStorage,比 localStorage 更安全
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ import { ElMessage } from 'element-plus'
|
|||||||
import router from '../router'
|
import router from '../router'
|
||||||
import { getReplayAttackHeaders, needsReplayAttackValidation } from './security'
|
import { getReplayAttackHeaders, needsReplayAttackValidation } from './security'
|
||||||
|
|
||||||
|
let retryCount = 0
|
||||||
|
const MAX_RETRIES = 3
|
||||||
|
|
||||||
const instance = axios.create({
|
const instance = axios.create({
|
||||||
baseURL: import.meta.env.VITE_API_BASE_URL,
|
baseURL: import.meta.env.VITE_API_BASE_URL,
|
||||||
// 开发环境使用withCredentials,生产环境关闭
|
|
||||||
// withCredentials: import.meta.env.DEV,
|
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
}
|
}
|
||||||
@@ -21,7 +22,13 @@ instance.interceptors.request.use(
|
|||||||
if (userStore.token) {
|
if (userStore.token) {
|
||||||
config.headers['Authorization'] = `Bearer ${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 请求)
|
// 添加防重放攻击请求头(POST/PUT/DELETE 请求)
|
||||||
if (needsReplayAttackValidation(config.method, config.url)) {
|
if (needsReplayAttackValidation(config.method, config.url)) {
|
||||||
const replayHeaders = getReplayAttackHeaders()
|
const replayHeaders = getReplayAttackHeaders()
|
||||||
@@ -41,6 +48,7 @@ instance.interceptors.request.use(
|
|||||||
// 响应拦截器
|
// 响应拦截器
|
||||||
instance.interceptors.response.use(
|
instance.interceptors.response.use(
|
||||||
response => {
|
response => {
|
||||||
|
retryCount = 0
|
||||||
const res = response.data;
|
const res = response.data;
|
||||||
if (res.code !== 200) {
|
if (res.code !== 200) {
|
||||||
ElMessage({
|
ElMessage({
|
||||||
@@ -53,11 +61,19 @@ instance.interceptors.response.use(
|
|||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
error => {
|
async error => {
|
||||||
if (error.response) {
|
if (error.response) {
|
||||||
const status = error.response.status;
|
const status = error.response.status;
|
||||||
const data = error.response.data;
|
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 - 未授权
|
// 401 - 未授权
|
||||||
if (status === 401) {
|
if (status === 401) {
|
||||||
try {
|
try {
|
||||||
@@ -70,38 +86,31 @@ instance.interceptors.response.use(
|
|||||||
}
|
}
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 403 - 权限不足
|
// 403 - 权限不足
|
||||||
if (status === 403) {
|
if (status === 403) {
|
||||||
const msg = data?.msg || '无权操作';
|
const msg = data?.msg || '无权操作';
|
||||||
ElMessage.error(msg);
|
ElMessage.error(msg);
|
||||||
return Promise.reject(new Error(msg));
|
return Promise.reject(new Error(msg));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 429 - 请求过于频繁
|
// 429 - 请求过于频繁
|
||||||
if (status === 429) {
|
if (status === 429) {
|
||||||
const msg = data?.msg || '请求过于频繁,请稍后再试';
|
const msg = data?.msg || '请求过于频繁,请稍后再试';
|
||||||
ElMessage.error(msg);
|
ElMessage.error(msg);
|
||||||
return Promise.reject(new Error(msg));
|
return Promise.reject(new Error(msg));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 400 - 验证码错误等
|
// 400 - 验证码错误等
|
||||||
if (status === 400) {
|
if (status === 400) {
|
||||||
const msg = data?.msg || '请求参数错误';
|
const msg = data?.msg || '请求参数错误';
|
||||||
ElMessage.error(msg);
|
ElMessage.error(msg);
|
||||||
return Promise.reject(new 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 isDev = import.meta.env.DEV;
|
||||||
const msg = isDev
|
const msg = isDev
|
||||||
? (data?.msg || error.message)
|
? (data?.msg || error.message)
|
||||||
: (data?.msg || '操作失败,请稍后重试');
|
: (data?.msg || '操作失败,请稍后重试');
|
||||||
ElMessage.error(msg);
|
ElMessage.error(msg);
|
||||||
|
|||||||
12
biji-qianduan/src/utils/xss.js
Normal file
12
biji-qianduan/src/utils/xss.js
Normal 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: [] })
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user