技术栈:
SpringBoot,MySQL, MybatisPlus, Knife4J,Lombok
Vue, Element-Plus
后端部分
项目初始化
使用Mybatis代码生成器生成实体类,mapper接口&xml文件等;
构建统一返回结果Result类,返回的提示信息统一封装在枚举类中。
项目初始化结构:

这里生成的mapper接口不带@mapper,并且,产生的是swagger2的@Api注解,这就需要我们额外操作。下面是生成代码器的代码
package cn.amebob.cv01;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.generator.FastAutoGenerator;
import com.baomidou.mybatisplus.generator.config.OutputFile;
import com.baomidou.mybatisplus.generator.engine.FreemarkerTemplateEngine;
import java.util.Collections;
public class CodeGenerator {
public static void main(String[] args) {
// 1. 数据库配置
String url = "jdbc:mysql://localhost:3306/test_db?serverTimezone=GMT%2B8";
String username = "root";
String password = "root";
FastAutoGenerator.create(url, username, password)
.globalConfig(builder -> {
builder.author("bob")
.enableSwagger()
.outputDir(System.getProperty("user.dir") + "/src/main/java");
})
.packageConfig(builder -> {
builder.parent("cn.amebob.cv01")
.pathInfo(Collections.singletonMap(OutputFile.xml, System.getProperty("user.dir") + "/src/main/resources/mapper"));
})
.strategyConfig(builder -> {
builder.addInclude("Experience", "Profile", "Project", "ProjectTag", "Tag", "User")
.entityBuilder()
.enableLombok()
.idType(IdType.AUTO)
.enableTableFieldAnnotation()
.controllerBuilder()
.enableRestStyle();
})
.templateEngine(new FreemarkerTemplateEngine())
.execute();
System.out.println("代码生成成功!");
}
}
这里Result类可以使用@Builder,方便我们链式调用;
return Result.success(user).setMessage("查询个人资料成功");
Result,由于需要返回多次,非单例等问题,不交予Spring容器管理,这里采用静态方式创建Result对象,是静态工厂方法。
package cn.amebob.cv01.common;
import lombok.Builder;
import lombok.Data;
import java.io.Serializable;
@Builder
@Data
public class Result<T> implements Serializable {
private int code;
private String message;
private T data;
private long timestamp;
public Result() {
this.timestamp = System.currentTimeMillis();
}
// 成功返回 - 无数据
public static <T> Result<T> success() {
return success(null);
}
// 成功返回 - 有数据
public static <T> Result<T> success(T data) {
Result<T> result = new Result<>();
result.setCode(ResultCode.SUCCESS.getCode());
result.setMessage(ResultCode.SUCCESS.getMessage());
result.setData(data);
return result;
}
// 失败返回 - 默认信息
public static <T> Result<T> fail() {
return fail(ResultCode.INTERNAL_SERVER_ERROR.getMessage());
}
// 失败返回 - 自定义信息
public static <T> Result<T> fail(String message) {
Result<T> result = new Result<>();
result.setCode(ResultCode.INTERNAL_SERVER_ERROR.getCode());
result.setMessage(message);
return result;
}
}
遇到的问题
1.浏览器网络面板(Network)看到状态码是 200,但控制台(Console)却报错 CORS。
现象解释:浏览器已经把请求发出去了,后端也处理完并返回了数据,但浏览器发现响应头里没有准许跨域的标识,于是出于安全考虑,把已经到手的数据“没收”了,并抛出一个跨域错误给你的 JS 代码。
解决方法:前端跨域设置正确,但是request.js的baseURL设置为了` http://localhost:8080` 这是后端地址,这样前端就直接跳过了跨域代理。
上线后的跨域问题:
既然 Proxy 只管开发环境,项目上线后通常有两种方案:
Nginx 转发(最常用):在服务器上配置 Nginx,让 Nginx 扮演和 Proxy 同样的角色。
后端开启 CORS:在 SpringBoot 中配置
WebMvcConfigurer允许跨域。这样前端可以直接请求http://api.yourdomain.com而不需要任何代理。
2.父组件向子组件传输信息,子组件未展示
起初以为是pinia的问题,在使用调试工具后,确认pinia中数据更新。提供js、模版代码给ai,得到答案,js中获取ref对象的属性值需要使用.value
// 如果直接解构 const { profileInfo } = profileStore; 会失去响应式
const { profileInfo, loading } = storeToRefs(profileStore);
const info = profileInfo.value; //正确
const info = profileInfo;//错误
//铺数据时,模板中可以省去.value
<div v-if="profileInfo">
<h1>你好,我是 <span class="highlight">{{ profileInfo.name }}</span></h1>
<h2>{{ profileInfo.jobTitle }}</h2>
<p>{{ profileInfo.bio }}</p>
<SocialLinks :links="socialData" />
</div>
- 更改Vue组建数据请求时,导致其它页面空白
3. knife文件上传测试失败
原因:前后端数据流不一样,后端渴望二进制流,前端发来json/dataform数据
解决方案
@PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(summary = "上传文件")
public Result<String> upload(@Parameter(name = "file", description = "文件", content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE, schema = @Schema(type = "string", format = "binary")))
@RequestPart("file") MultipartFile file){
if(file.isEmpty()){
return Result.fail("文件为空");
}
String url = fileUploadService.uploadImage(file);
return Result.success(url);
}
| 组件 | 作用 |
|---|---|
@PostMapping(consumes = ...) | 告诉服务器:只准接收文件流,别发 JSON 过来。 |
@Parameter(format = "binary") | 告诉文档:给我显示一个“上传”按钮。 |
@RequestPart("file") | 告诉代码:去请求里找那个叫 file 的二进制块。 |
文件上传功能是如何实现复用的呢?
reply:前端获得后端返回的url后,进行图像回显,提交更新时,该链接便一同提交到数据库「存在注入风险?若前端篡改了url」
4. 路由参数不匹配导致类型转换异常
请求显示如下图:

router/index.js(路由) 中,参数名为 :id;
handleEdit(组件) 中,传入的却是一个字符串类型的 slug;
5. 错误传入对象作为参数
http://localhost:3000/api/admin/projects/[object%20Object] 为何请求变成这个样子?
reply: 传入了对象,而不是目标的字符串参数。
const project = await getProjectDetail(projectSlug) // 传的是对象
// 在 script setup 中使用计算属性的值,必须带上 .value
const project = await getProjectDetail(projectSlug.value)
6. 两种图片上传
一种,无鉴权,自动上传
使用的是el-upload 的 action 直接使用原生的 XHR,只具备浏览器的默认能力,即
默认不带 Token:它不知道
localStorage里存了什么,除非明确用setRequestHeader塞进去。不认识拦截器:它直接走浏览器的网络堆栈,不会路过定义的
axios.interceptors。
<el-form-item label="封面图">
<el-input v-model="form.coverUrl" placeholder="封面图片 URL" clearable>
<template #append>
<el-upload
:action="uploadAction"
:show-file-list="false"
:on-success="(res) => form.coverUrl = res.data"
accept="image/*"
>
const uploadAction = '/api/common/upload' //定义图片上传的后端地址
一种,可带鉴权,手动上传
只要项目里配置了 axios 拦截器,使用第二种方式(http-request)让文件上传变得和普通 API 请求一模一样。即发出的axios请求被拦截并加上token
<el-form-item label="Wechat QR">
<el-upload
class="avatar-uploader"
action="#"
:http-request="uploadWechat"
:show-file-list="false"
:before-upload="beforeUpload"
>
<img v-if="userStore.userInfo.wechatUrl" :src="userStore.userInfo.wechatUrl" class="avatar" />
<el-icon v-else class="avatar-uploader-icon"><Plus /></el-icon>
</el-upload>
</el-form-item>
const handleCustomUpload = async (options, fieldName) => {
try {
const formData = new FormData()
formData.append('file', options.file)
const url = await uploadFile(formData)
userStore.userInfo[fieldName] = url
ElMessage.success('上传成功')
} catch (error) {
ElMessage.error('上传失败')
}
}
const uploadWechat = (opts) => handleCustomUpload(opts, 'wechatUrl')
const uploadAvatar = (opts) => handleCustomUpload(opts, 'avatarUrl')
7. 前后端markdown文本渲染与图片上传
设计上使用双文本(已废弃)+图片外链。
文本方面使用content存储原生markdown文本,使用htmlContent存储后端生成的、可用于直接渲染的markdown(类html)内容;
文本方面,前、后台统一使用md-editor-v3进行渲染,保证风格一致性。
图像方面调用公共图片上传接口,上传完成后,回调,插入外链到markdown文本中。
后端使用的库(适用于非个人项目)
<!--markdown 依赖-->
<dependency>
<groupId>org.commonmark</groupId>
<artifactId>commonmark</artifactId>
<version>0.24.0</version>
</dependency>
<!-- 如果需要表格、删除线等 GFM 扩展 -->
<dependency>
<groupId>org.commonmark</groupId>
<artifactId>commonmark-ext-gfm-tables</artifactId>
<version>0.24.0</version>
</dependency>
8.适配移动端
策略,element-table的元素,在移动端使用卡片;
右侧居中的按钮,移动端独占一行。
Dashboard中通过设置不同屏幕下一行卡片占的空间和边距来适配移动端
:xs=“24"独占一行(指宽度 小于 768px)
:sm=“8” 一行可以放3个(指宽度 大于等于 768px)
<template>
<div>
<h2>欢迎回来, {{ userStore.userInfo.name }}</h2>
<el-row :gutter="20" class="mt-4">
<el-col :xs="24" :sm="8" class="mb-4">
<el-card shadow="hover">
<template #header>项目总数</template>
<div class="stat-value">{{ projectNum }}</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="8" class="mb-4">
<el-card shadow="hover">
<template #header>工作经历</template>
<div class="stat-value">3 个</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<style scoped>
/* 默认移动端样式 */
.stat-value {
font-size: 20px;
font-weight: bold;
}
.mt-4 { margin-top: 1rem; }
.mb-4 { margin-bottom: 1rem; }
/* 针对大屏幕可以微调字体 */
@media (min-width: 768px) {
.stat-value {
font-size: 24px;
}
}
</style>
9.多表存储用户数据,登录无数据
根本原因是登录流程和数据获取的逻辑不匹配:
1. 架构设计:系统将账号信息和个人信息分为两张表
- 登录接口 /auth/login 只负责认证,返回 token
- 个人信息接口 /admin/info 才返回完整的个人资料(name, jobTitle, bio 等)
2. 原代码的问题:
// Login.vue 中
if (data.userInfo) {
userStore.setUserInfo(data.userInfo) // 可能只设置了部分信息
}
// 没有调用 getUser() 获取完整个人信息
3. 导致的后果:
- 如果登录接口返回了部分 userInfo(比如只有 id、username),store 中就有了不完整的数据
- 路由守卫判断 !userStore.userInfo.id && !userStore.isLoggedIn 时,发现已经有数据了,就不会再调用 getUser()
- 个人信息页面显示的就是这些不完整的数据。
简单来说:登录后只保存了 token,但没有主动去获取个人信息表的数据,导致个人信息页面是空的或不完整的。
修复方案就是在登录成功后,明确调用 getUser() 来获取完整的个人信息,确保两张表的数据都正确加载。
