技术栈:

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 只管开发环境,项目上线后通常有两种方案:

  1. Nginx 转发(最常用):在服务器上配置 Nginx,让 Nginx 扮演和 Proxy 同样的角色。

  2. 后端开启 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-uploadaction 直接使用原生的 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() 来获取完整的个人信息,确保两张表的数据都正确加载。

后续平台改进点:

通用后台管理系统

1.引入雪花算法生成随机id