Elder 开发手册 · 组件功能

Elder 开发手册 · 组件功能

本手册按通用技术点 / 组件用法整理养老项目(Vue3 + Element Plus / Vant4 + MyBatis-Plus)中反复出现的功能实现,每个功能包含:场景 → 完整代码 → 原理讲解。代码均来自项目实际实现。

业务模块见《elder开发手册模块功能.md》,JWT/RBAC/权限见《elder开发手册权限功能.md》,踩坑记录见《elder开发手册问题.md》。

目录

序号 功能 关键词
1 日期区间搜索 el-date-picker / between / 时区
2 滑动开关(状态切换) el-switch / 局部更新
3 create_time / update_time 自动填充 MetaObjectHandler / FieldFill
4 分页 + 条件查询 + 批量删除 分页插件 / IPage / removeByIds
5 图片上传(OSS) el-upload / MultipartFile / wangEditor
6 OSS 附件内联预览代理 后端流式 / blob / iframe
7 表单校验 rules / validator / el-form 实例
8 多选标签分配 checkbox-group / 先删后插
9 多标签搜索 join(‘,’) / containsAll AND 筛选
10 穿梭框分配项目 el-transfer / 价格自动汇总
11 树形表格与权限树 el-table row-key / el-tree 勾选
12 动态菜单与图标 el-menu / 全局图标 / IconPicker
13 Excel 导入导出 EasyExcel / blob 下载
14 首页图表 ECharts 三图 / 统计卡
15 富文本备注 wangEditor / 自定义上传
16 多标签页 + keep-alive tags store / onActivated
17 Pinia 状态与持久化 persist / 多端 key 前缀
18 api 封装与请求体细节 axios delete body / blob
19 手机端(Vant4)组件 tabbar / uploader / picker / 主题变量

1. 功能:日期区间搜索

image-20260824204242052

前端获取日期范围,并存入数组中

<el-form-item label="时间">
  <div class="block">
    <el-date-picker
        v-model="CreateChangeTime"
        type="daterange"
        range-separator="至"
        start-placeholder="开始时间"
        end-placeholder="结束时间"
    />
  </div>
</el-form-item>

判断是否进行了日期查询,把 Date 转成本地格式字符串再传(原因见下方原理)

const CreateChangeTime = ref([])

const loadData = () => {
  // 关键:发「本地无时区」字符串,避免 UTC 偏移(详见问题手册第 3 节)
  elderQuery.value.begintime = formatDate(CreateChangeTime.value?.[0]);   // "2026-08-26 00:00:00"
  elderQuery.value.endtime   = endOfDay(CreateChangeTime.value?.[1]);     // "2026-08-27 23:59:59"

  elderApi.list(elderQuery.value).then(result => {
    list.value = result.data.records
    total.value = result.data.total
  })
}

配套的 utils/date.js,用来消除时区歧义、补齐结束时间

// 把 Date 转成 "YYYY-MM-DD HH:mm:ss" 本地格式字符串。
// 原因:axios 会把 Date 参数自动转成 UTC 的 ISO 串(如 2026-08-25T16:00:00.000Z),
// 后端解析时忽略 Z 按本地时间处理,双重偏移导致时间范围查不到数据。
export const formatDate = (date) => {
  if (!date) return undefined
  const pad = (n) => String(n).padStart(2, '0')
  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` +
         `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
// 结束时间补到当天 23:59:59,避免 between 把当天 00:00 之后的数据过滤掉
export const endOfDay = (date) => {
  if (!date) return undefined
  const d = new Date(date)
  d.setHours(23, 59, 59, 999)
  return formatDate(d)
}

后端 Query 加时间字段 + LambdaQueryWrapper 的 between 条件查询

// UserQuery / ElderQuery 等查询对象中添加:
private Date begintime;
private Date endtime;
@Override
public IPage<User> list(UserQuery userQuery) {
    IPage<User> page = new Page<>(userQuery.getPage(), userQuery.getLimit());

    LambdaQueryWrapper<User> lambdaQueryWrapper = new LambdaQueryWrapper<>();
    lambdaQueryWrapper.like(!ObjectUtils.isEmpty(userQuery.getName()), User::getName, userQuery.getName())
            .like(!ObjectUtils.isEmpty(userQuery.getEmail()), User::getEmail, userQuery.getEmail())
            //两个时间都非空才拼 between,condition 参数实现「条件可选拼接」
            .between(!ObjectUtils.isEmpty(userQuery.getBegintime()) && !ObjectUtils.isEmpty(userQuery.getEndtime()),
                    User::getCreateTime, userQuery.getBegintime(), userQuery.getEndtime())
            .orderByDesc(User::getCreateTime);

    return userMapper.selectPage(page, lambdaQueryWrapper);
}

原理链路:

el-date-picker 返回 Date(本地东八区)
  → axios 默认对 Date 调 toISOString() → "2026-08-25T16:00:00.000Z"(UTC)
  → 后端解析时忽略 Z 当本地时间 → 偏早 8 小时
  → SQL between 区间错位 → 查不到数据

修复:前端手动转 "yyyy-MM-dd HH:mm:ss" 空格格式(无时区歧义)
  → WebConfig 的 Converter 按 "yyyy-MM-dd HH:mm:ss" 解析 → 正确

后端 WebConfig.addFormatters 里注册了 String→Date 转换器,兼容四种格式(含 ISO 带 Z 格式)兜底。


2. 功能:滑动开关(状态切换)

image-20260824205200517

前端绑定 row.status,通过 handleSwitchChange(row) 实现数据库更新

<el-table-column prop="status" label="状态">
  <template #default="{row}">
    <el-switch
        v-model="row.status"
        style="--el-switch-on-color: #13ce66; --el-switch-off-color: #ff4949"
        :active-value="1"
        :inactive-value="0"
        inline-prompt
        active-text="正常"
        inactive-text="禁用"
        @change="handleSwitchChange(row)"
    />
  </template>
</el-table-column>

只发 {id, status} 两个字段,后端 updateById 实现局部更新

const handleSwitchChange = (row) => {
  const user = {}
  user.id = row.id
  user.status = row.status
  userApi.update(user.id, user).then(result => {
    if (result.code === 1) {
      ElMessage.success(result.msg)
    } else {
      ElMessage.error(result.msg)
    }
  })
}
@PutMapping("/{id}")
public Result updateById(@PathVariable Long id, @RequestBody User user) {
    user.setId(id);
    iUserService.updateById(user);   // NULL 字段不更新,只更新非空字段
    return Result.ok("修改成功");
}

原理:updateById(实体里为 null 的字段不会拼进 UPDATE 语句) 是 MyBatis-Plus 的默认行为,所以只改了 status。这就是「局部更新」。

为什么推荐用 updateById 而不是整行覆盖?

  • 避免把前端没传的字段(头像、备注等)误改成 null;
  • 接口职责单一,一次只改一个字段,减少并发覆盖风险;
  • 更贴合「我在页面上只拨了个开关」这个动作。

进阶用法见模块手册 7.2:套餐价格回写 new ExamPackage(); setId; setPrice; updateById —— 同一机制的典型应用。


3. 功能:create_time / update_time 自动填充

配置类 MetaObjectHandler(填充规则)

@Component
public class MyMetaObjectHandler implements MetaObjectHandler {
    @Override
    public void insertFill(MetaObject metaObject) {
        // 插入时,创建时间和修改时间
        this.setFieldValByName("createTime", new Date(), metaObject);
        this.setFieldValByName("updateTime", new Date(), metaObject);
    }

    @Override
    public void updateFill(MetaObject metaObject) {
        this.setFieldValByName("updateTime", new Date(), metaObject);
    }
}

在实体字段上打 fill 标记(项目用 MPGenerator 统一生成,见下)

/** 创建时间 */
@TableField(value = "create_time", fill = FieldFill.INSERT)
private Date createTime;

/** 更新时间 */
@TableField(value = "update_time", fill = FieldFill.INSERT_UPDATE)
private Date updateTime;

MPGenerator 里对应的两行配置(新表自动带上这个能力):

IFill gmtCreate   = new Column("create_time", FieldFill.INSERT);
IFill gmtModified = new Column("update_time", FieldFill.INSERT_UPDATE);
strategyConfigBuilder.entityBuilder().addTableFills(gmtCreate, gmtModified);

注意:逻辑删除走 service.removeByIds(ids)(底层 removeBatchByIds 循环逐条删除)会触发 updateFill;直接调 mapper.deleteBatchIds 不会自动填充删除时间。


4. 功能:分页 + 条件查询 + 批量删除

4.1 后端:MyBatis-Plus 分页

//Page(当前页,每页条数)  页码从 1 开始,不是 0
IPage<User> page = new Page<>(1, 5);
QueryWrapper<User> qw = new QueryWrapper<>();
qw.gt("age", 22);
IPage<User> result = userMapper.selectPage(page, qw);

result.getRecords(); //当前页数据 List<User>
result.getTotal();   //总条数 Long
result.getPages();   //总页数 Long
result.getCurrent();//当前页码 Long
result.getSize();    //每页大小 Long
方法 返回类型 含义
getRecords() List<T> 当前页查询出来的数据集合
getTotal() Long 满足条件的全部总记录数
getPages() Long 总页数,total / size 向上取整
getCurrent() Long 当前请求的页码
getSize() Long 每页显示条数

1. new Page<>(1,5):第一页,每页 5 条。
2. selectPage(page, wrapper):page 既是入参也是出参,查询后 total、pages 回填到 page 对象。
3. 必须开启 MyBatis-Plus 分页插件,否则分页失效(查出全部且 total 为 0)。

@Configuration
public class MybatisPlusConfig {
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
        return interceptor;
    }
}

4.2 底层 SQL 执行两条 SQL

-- 1. 查询当前页数据
SELECT id,name,age... FROM user WHERE age > 22 LIMIT 0,5
-- 2. 自动执行 count 统计总条数
SELECT COUNT(*) FROM user WHERE age > 22

不需要总数时 page.setSearchCount(false) 关闭 count 查询(getTotal() 返回 0)。

4.3 批量删除接口

@DeleteMapping("/batch")
public Result<Void> batchRemove(@RequestBody List<Long> ids) {
    if (ids == null || ids.isEmpty()) {
        return Result.error("请选择待删除数据");
    }
    //✅逻辑删除:使用 removeByIds,开启自动填充,不要直接 mapper.deleteBatchIds
    return iUserService.removeByIds(ids) ? Result.ok() : Result.error("删除失败");
}

removeByIds 的坑:开启 @TableLogic 后不要直接调 baseMapper.deleteBatchIds(ids),不会触发自动填充;service.removeByIds(ids) 底层循环逐条删除、触发填充;大批量 id(>1000)建议手动分批。

4.4 前端:列表页「五件套」范式

<el-table :data="list" stripe @selection-change="handleSelectionChange">
  <el-table-column type="selection" width="55"/>
  <!-- 其他列 -->
</el-table>

<el-pagination
    v-model:current-page="query.page"
    v-model:page-size="query.limit"
    :page-sizes="[5, 10, 15, 20]"
    layout="total, sizes, prev, pager, next, jumper"
    :total="total"
    @change="loadData"
    style="margin-top: 20px; justify-content: flex-end"
/>
<el-button type="danger" @click="deleteAll">批量删除</el-button>
//① 查询对象:分页+条件全放一个 ref
const query = ref({page: 1, limit: 10, name: ''})
//② 多选:@selection-change 收集勾选行 id
let ids = []
const handleSelectionChange = (rows) => { ids = rows.map(row => row.id) }
//③ 搜索:重置到第一页再加载
const onSearch = () => { query.value.page = 1; loadData() }
//④ 单删(确认框 lockScroll:false 防抖动)
const deleteById = (id) => {
  ElMessageBox.confirm('您确认要删除该记录吗?', '警告',
      {confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning', lockScroll: false}
  ).then(() => {
    api.deleteById(id).then(result => { ElMessage.success(result.msg); loadData() })
  }).catch(() => {})
}
//⑤ 批删:空选拦截 + 确认 + 删完回第一页 + 清空勾选
const deleteAll = () => {
  if (ids.length === 0) { ElMessage.warning('请选择要删除的记录'); return }
  ElMessageBox.confirm('您确认要删除所选中的记录吗?', '警告', {...}).then(() => {
    api.deleteAll(ids).then(result => {
      ElMessage.success(result.msg)
      query.value.page = 1     // 删除后回到第一页刷新列表,防止当前页删完空白
      loadData()
      ids = []
    })
  }).catch(() => {})
}

前端细节清单:确认框捕获取消不进业务;删除成功重置 page=1;清空 ids;按钮 loading 防重复提交(可选 delLoading ref)。


5. 功能:图片上传(阿里云 OSS)

5.1 后端:AliOSSUtil(当前版本,配置全部来自 yml)

依赖:com.aliyun.oss:aliyun-sdk-oss:3.15.1(Java 9+ 另需 jaxb 三件套)。密钥不再写死在代码里,由环境变量注入(见问题手册第 14 节)。

@Component
public class AliOSSUtil {

    @Value("${oss.endpoint}")
    private String endpoint;

    @Value("${oss.bucket-name}")
    private String bucketName;

    @Value("${oss.access-key-id}")
    private String accessKeyId;

    @Value("${oss.access-key-secret}")
    private String accessKeySecret;

    public String uploadFile(String objectName, InputStream inputStream) {
        OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
        //公网访问地址:https://bucket名称.区域节点/objectName
        String url = "";
        try {
            PutObjectRequest putObjectRequest =
                    new PutObjectRequest(bucketName, objectName, inputStream);
            //按后缀设置 Content-Type,浏览器才能内联预览而非强制下载
            ObjectMetadata metadata = new ObjectMetadata();
            metadata.setContentType(guessContentType(objectName));
            putObjectRequest.setMetadata(metadata);
            ossClient.putObject(putObjectRequest);
            url = "https://" + bucketName + "."
                    + endpoint.substring(endpoint.lastIndexOf("/") + 1) + "/" + objectName;
        } finally {
            ossClient.shutdown();
        }
        return url;
    }
    // 另有 downloadFile(url, response) 流式回写、deleteFile(url) 见下一节
}
oss:
  endpoint: https://oss-cn-beijing.aliyuncs.com
  bucket-name: ellder
  access-key-id: ${ACCESS_KEY_ID:}
  access-key-secret: ${ACCESS_KEY_SECRET:}
spring:
  servlet:
    multipart:
      max-file-size: 50MB        # 上传大小限制
      max-request-size: 50MB

5.2 后端:UploadController(通用上传接口)

@RestController
public class UploadController {

    @Autowired
    private AliOSSUtil aliOSSUtil;

    //MultipartFile file 封装了所有图片上传信息
    @PostMapping("/upload")
    public Result upload(MultipartFile file) {
        //使用UUID生成唯一文件名,去掉中间默认添加的-
        String uuid = UUID.randomUUID().toString().replace("-", "");
        //a.png → 获取后缀.png
        String filename = file.getOriginalFilename();
        String entension = filename.substring(filename.lastIndexOf("."));
        String newFilename = uuid + entension;
        String url = aliOSSUtil.uploadFile(newFilename, file.getInputStream());
        return Result.ok("上传成功", url);
    }
}

5.3 前端:el-upload 直传

<el-table-column prop="avatar" label="头像">
  <template #default="{row}">
    <img :src="row.avatar" style="max-height: 40px; max-width: 120px;"/>
  </template>
</el-table-column>

<el-form-item label="照片">
  <el-upload
      class="avatar-uploader"
      action="/api/upload"
      :show-file-list="false"
      :on-success="handleAvatarSuccess"
      :on-error="handleUploadError"
      :headers="{Authorization: tokenStore.token}"  <!-- 不走 request.js 拿不到token,手动加 -->
  >
    <img v-if="elder.avatar" :src="elder.avatar" class="avatar"/>
    <el-icon v-else class="avatar-uploader-icon"><Plus/></el-icon>
  </el-upload>
</el-form-item>
//上传成功回调:result 是后端返回的 Result{code,msg,data},data 即图片 URL
const handleAvatarSuccess = (result) => {
  elder.value.avatar = result.data
}

uploadFile 对象结构(el-upload 回调第二参数):

uploadFile = {
  name: "xxx.jpg",       // 本地文件名
  raw: File,             // ✅浏览器原生File对象(二进制文件)
  status: 'success',     // 文件状态:ready / uploading / success / fail
  size: 12345,
  url: 本地预览临时地址,
  response: result,      // 后端返回结果,和第一个参数 result 一样
}

要点:

  • action="/api/upload" 是 el-upload 自己发请求,不经过 axios 封装,所以 token 要手动 :headers;
  • 手机端 Vant 用 van-uploader :after-read,File 在 file.file,用 FormData 直接 axios.post('/api/upload', formData)(见 19.5)。

6. 功能:OSS 附件内联预览代理

问题:OSS 返回的附件浏览器会强制下载而非预览(匿名访问无法覆盖响应头)。

方案:后端做代理——读对象流式写回,设置 Content-Disposition: inline。

//AliOSSUtil.downloadFile(节选)
public void downloadFile(String url, HttpServletResponse response) throws Exception {
    //从 URL path 提取 objectName
    String objectName = new URI(url).getPath().substring(1);
    OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
    try {
        OSSObject object = ossClient.getObject(bucketName, objectName);
        response.setContentType(object.getObjectMetadata().getContentType());
        response.setHeader("Content-Disposition", "inline");   // 内联预览,不下载
        //8192 字节缓冲流式写回
        try (InputStream is = object.getObjectContent();
             OutputStream os = response.getOutputStream()) {
            byte[] buffer = new byte[8192];
            int len;
            while ((len = is.read(buffer)) != -1) { os.write(buffer, 0, len); }
        }
    } finally { ossClient.shutdown(); }
}

管理端与手机端各挂一个代理接口:

@RestController
@RequestMapping({"/admin/oss"})       // 手机端同构:/app/oss
public class OssProxyController {
    @GetMapping("/get")
    public void get(@RequestParam("url") String url, HttpServletResponse response) throws Exception {
        aliOSSUtil.downloadFile(url, response);
    }
}

前端两种消费方式:

//① 管理端:blob 新窗口内联打开
contractApi.getOss(url).then(res => {          // responseType: 'blob'
  window.open(URL.createObjectURL(res.data), '_blank')
})

//② 手机端:van-popup 内 iframe 预览
previewUrl.value = URL.createObjectURL(res.data)
// <van-popup> ... <iframe :src="previewUrl" frameborder="0"></iframe>

URL.revokeObjectURL 可在关闭预览后释放内存;van-popup 会 teleport 到 body,其样式要写全局(scoped 无效)。


7. 功能:规则校验(el-form rules 与实例)

7.1 基本用法

//表单校验规则
const rules = ref({
  oldPassword: [
    {required: true, message: '请输入密码', trigger: 'blur'},
    {min: 3, max: 16, message: '密码长度必须为3~16位', trigger: 'blur'}
  ],
  reNewPassword: [
    {required: true, message: '请再次确认密码', trigger: 'blur'},
    {validator: rePasswordValid, trigger: 'blur'}     // 自定义校验器
  ]
})

//自定义 validator:校验两次密码一致
const rePasswordValid = (rule, value, callback) => {
  if (value == null || value === '') {
    return callback(new Error('请再次确认密码'))
  }
  //响应式对象要 restpasword.value 才能拿到值
  if (restpasword.value.newPassword !== value) {
    return callback(new Error('两次输入密码不一致'))
  }
  callback()   // 全部通过必须调用 callback() 且不传参
}
<el-form :model="restpasword" :rules="rules" ref="resetForm">
  <el-form-item prop="oldPassword">...</el-form-item>  <!-- prop 必须写,否则不参与校验 -->
</el-form>
<el-button type="primary" @click="resetPassword(resetForm)">保存修改</el-button>
const resetForm = ref()   // 模板引用,挂载后 = el-form 组件实例
const resetPassword = async (formEl) => {
  if (!formEl) return      // 防御实例未就绪
  await formEl.validate((valid, fields) => {
    if (valid) {
      userApi.resetPassword({...}).then(result => {
        if (result.code === 1) {
          ElMessage.success(result.msg)
          tokenStore.removeToken();
          userInfoStore.removesetUser();
          router.push('/login')     // 改密成功 → 清 token 跳登录
        } else { ElMessage.error(result.msg) }
      })
    } else { ElMessage.error('表单验证失败'); }
  })
}

7.2 rules 规则速查表

属性 类型 作用 示例
required boolean 是否必填 required: true
message string 校验失败提示文字 message:'请输入手机号'
trigger string 触发校验时机 'blur' / 'change'
pattern RegExp 正则校验 pattern:/^1[3-9]\d{9}$/
min / max / len number 长度(字符串/数组) min:3
type string 数据类型 'number' / 'email' / 'array'
validator Function 自定义校验函数 两次密码一致、身份证校验
whitespace boolean 纯空格视为空 whitespace:true
enum Array 枚举 enum:[0,1,2]

7.3 ref 是怎么拿到组件实例的

  • <script setup> 里 const resetForm = ref(),模板里 ref="resetForm" 会按同名自动把 el-form 实例赋给 resetForm.value;
  • 挂载前是 undefined,所以 if (!formEl) return 是防御;
  • 模板里 resetForm 自动解包,@click="resetPassword(resetForm)" 传的就是实例本身。

7.4 el-form 实例的 4 个常用方法

方法 作用 特点
validate(cb?) 校验所有表单项 返回 Promise;回调 (valid, fields)
validateField(props, cb?) 只校验指定 prop 字段 分步表单常用
resetFields() 重置为初始值 + 清空校验 ⚠️ 是恢复挂载时的值,不是清空
clearValidate(props?) 只清空校验提示,不动值 弹窗打开/关闭时用
//项目惯例:打开弹窗回显数据后,nextTick 清掉上一次的校验红字
const showUpdateDialog = (id) => {
  dialogFormVisible.value = true
  api.selectById(id).then(result => {
    form.value = result.data
    nextTick(() => formRef.value?.clearValidate())
  })
}

7.5 依赖表单状态的「条件必填」校验器

权限管理页的典型用法——按权限类型决定必填项:

//路由地址:仅"菜单"类型必填
const validatePath = (rule, value, callback) => {
  if (permission.value.type === 1 && !value) return callback(new Error('请输入路由地址'))
  callback()
}
//按钮权限值:仅"按钮"类型必填
const validatePermissionValue = (rule, value, callback) => {
  if (permission.value.type === 2 && !value) return callback(new Error('请输入按钮权限值'))
  callback()
}

编辑弹窗「密码可留空」的校验器同理(Elder.vue):新增必填 3~16 位,编辑留空表示不修改,提交前 if (!payload.password) delete payload.password。


8. 功能:多选标签分配(多对多回显 + 先删后插)

用一个 elder 表、一个 tag 表,中间表 elder_tag 记录多对多关系。实现思路:先查出全部 tag,再按老人 id 查中间表拿已分配的 tagId 集合,一一对应回显。

后端(ElderController + ElderServiceImpl,完整讲解见模块手册 3.2)

//查询指定老人已分配的标签:返回全部标签 + 该老人已选标签id
@GetMapping("/selectAssingnedTag/{elderId}")
public Result<ElderTagVo> selectAssingnedTag(@PathVariable("elderId") Long elderId) {
    return Result.ok(iElderService.selectAssinedRole(elderId));
}

//给老人分配标签(先删除旧的关联,再写入新的)
@PostMapping("/assignTag")
public Result assignTag(Long elderId, Long[] tagIds) {
    iElderService.assignTag(elderId, tagIds);
    return Result.ok("分配成功");
}

前端 api:分配类接口把 id 串放查询参数

selectAssingnedTag(elderId) {
    return request.get(`/elders/selectAssingnedTag/${elderId}`)
},
assignTag(elderId, tagIds) {
    return request.post(`/elders/assignTag`, null, {params: {elderId, tagIds}})
},

前端页面:打开弹窗回显,保存时 join 成逗号串

const dialogTagVisible = ref(false)
const tagList = ref([])
const assignedTagIdList = ref([])
const showAssignedTagDialog = (row) => {
  elder.value = row;
  elderApi.selectAssingnedTag(row.id).then((result) => {
    tagList.value = result.data.tagList;
    assignedTagIdList.value = result.data.assignedTagIdList;
    dialogTagVisible.value = true;
  });
}
const assignTag = () => {
  let tagIds = assignedTagIdList.value.join(',');   // [1,7] -> "1,7"
  elderApi.assignTag(elder.value.id, tagIds).then((result) => {
    ElMessage.success(result.msg)
    loadData()
    dialogTagVisible.value = false;
  });
}
<el-button type="success" size="small" @click="showAssignedTagDialog(row)">标签</el-button>

<el-dialog title="标签" v-model="dialogTagVisible" width="40%">
  <el-form ref="form" :model="elder" label-width="80px">
    <el-form-item label="用户名">
      <el-input v-model="elder.name" disabled></el-input>
    </el-form-item>
    <el-form-item label="标签列表">
      <el-checkbox-group v-model="assignedTagIdList">
        <el-checkbox v-for="tag in tagList" :key="tag.id" :label="tag.id">{{tag.name}}</el-checkbox>
      </el-checkbox-group>
    </el-form-item>
    <el-form-item>
      <el-button type="primary" @click="assignTag">保存</el-button>
      <el-button @click="dialogTagVisible = false">取消</el-button>
    </el-form-item>
  </el-form>
</el-dialog>

原理:前端把勾选数组 join(',') 拼成 "1,7",Spring 处理 query 参数时自动按逗号切分并转成 Long[] tagIds,不用手写解析——前端 join 和后端数组参数是一对配对约定。

DTO/VO/Entity 辨析:DTO 数据传输对象(接口入参封装);VO 视图对象(给前端页面的返回);Entity 实体(对应数据库表);POJO 简单 Java 对象统称。


9. 功能:多选标签搜索(AND 语义筛选)

数据模型:老人和标签多对多,elder_tag 中间表。选中「独居+高血压」要查同时拥有两个标签的老人。

第 1 步:前端下拉多选收集

<el-form-item label="标签">
  <el-select v-model="selectedTagIds" multiple placeholder="请选择标签" clearable style="width: 240px">
    <el-option v-for="t in searchTagList" :key="t.id" :label="t.name" :value="t.id"/>
  </el-select>
</el-form-item>
//页面加载时就拉全部标签当选项
const searchTagList = ref([])
const loadTags = () => {
  tagApi.list({page: 1, limit: 1000}).then(result => { searchTagList.value = result.data.records })
}
loadTags()

第 2 步:loadData 把数组拼成串

elderQuery.value.tagIds = selectedTagIds.value.join(',');   // [1,2] -> "1,2"
// URL 变成:GET /elders?tagIds=1,2&name=&page=1&limit=10

第 3 步:Spring 自动转 List(无需写代码)

ElderQuery 里字段是 List<Long> tagIds,Spring 自动把 "1,2" 按逗号切分转成 [1L, 2L]。

第 4 步:后端 containsAll 过滤(AND 语义)

//①-1 查中间表:所有拥有「这些标签」的绑定记录
List<ElderTag> rows = elderTagMapper.selectList(
        new LambdaQueryWrapper<ElderTag>().in(ElderTag::getTagId, queryTagIds));
//①-2 按老人分组,每个老人收集标签集合:{1:{1,7}, 6:{1,2}}
//①-3 containsAll —— 必须【包含】全部所选标签才留下
List<Long> matchedElderIds = rows.stream()
        .collect(Collectors.groupingBy(ElderTag::getElderId,
                Collectors.mapping(ElderTag::getTagId, Collectors.toSet())))
        .entrySet().stream()
        .filter(entry -> entry.getValue().containsAll(queryTagIds))
        .map(Map.Entry::getKey).collect(Collectors.toList());
if (matchedElderIds.isEmpty()) { return page; }        // 无人同时拥有 → 直接空结果
lambdaQueryWrapper.in(Elder::getId, matchedElderIds);  // WHERE id IN (...)

①-3 是关键:选中 [1,2] 时,老人1的 {1,7} 不含 2 → 过滤掉;老人6的 {1,2} → 留下。想要「满足任一标签」(OR 语义),把 containsAll 换成普通去重并集即可。

第 5 步:标签列回显(批量组装,避免 N+1)

//②-1 一次查出本页老人所有绑定记录
//②-2 selectBatchIds 批量查标签名建 Map
//②-3 按老人分组 computeIfAbsent
//②-4 records.forEach 塞回 tagList,前端 el-tag 循环显示

完整链路:

勾选[1,2] → join(',') → GET /elders?tagIds=1,2
  → Spring 转 List<Long> → 查 elder_tag in (1,2)
  → 分组 → containsAll 过滤 → WHERE id IN (6)
  → 查出老人6 → ②回显标签 → 表格第6行显示

10. 功能:穿梭框分配项目(el-transfer)

体检套餐-项目分配(ExamPackage.vue),区别于 checkbox:数据量大、要搜索、要左右移动时用穿梭框。

//打开分配弹窗:加载全部启用的体检项目作为左侧候选,回显该套餐已分配的项目
const showAssignDialog = (row) => {
  assignPackage.value = row
  assignedItemIds.value = []
  transferData.value = []
  assignVisible.value = true
  //左侧候选:全部启用状态的体检项目 → [{key, label}] 固定结构
  examItemApi.list({page: 1, limit: 1000, status: 1}).then(result => {
    transferData.value = result.data.records.map(item => ({
      key: item.id,
      label: `${item.name}(¥${item.price})`
    }))
  })
  //右侧回显:该套餐已关联的项目id
  examPackageApi.getItemIds(row.id).then(result => { assignedItemIds.value = result.data })
}

//确认分配:提交选中的项目id数组,后端全量覆盖并自动重算套餐价格
const assignItems = () => {
  examPackageApi.assignItems(assignPackage.value.id, assignedItemIds.value).then(result => {
    if (result.code === 1) {
      ElMessage.success('分配成功,套餐价格已更新')
      assignVisible.value = false
      loadData()
    }
  })
}
<el-transfer
    v-model="assignedItemIds"
    :data="transferData"
    filterable
    filter-placeholder="搜索体检项目"
    :titles="['可选项目', '已选项目']"
    :button-texts="['移除', '加入']"
/>

要点:

  • el-transfer 数据结构固定为 {key, label, disabled?},与后端实体不同,前端 map 一层转换;
  • 后端 assignItems:删旧关联 → insertBatch 批插(sort 用列表下标)→ sumPriceByIds 汇总回写套餐价格(见模块手册 7.2);
  • 套餐编辑弹窗中价格只读展示,避免人工填写与所含项目对不上。

11. 功能:树形表格与权限树

11.1 后端生成树(parent_id 自关联 + 递归)

@Override
public List<PermissionVo> selectPermissionTree() {
    List<Permission> all = permissionMapper.selectList(
            new LambdaQueryWrapper<Permission>().orderByAsc(Permission::getSort));

    List<PermissionVo> voList = all.stream().map(p -> {
        PermissionVo vo = new PermissionVo();
        BeanUtils.copyProperties(p, vo);
        return vo;
    }).toList();

    //顶级 = parent_id == 0,逐层挂 children
    return voList.stream()
            .filter(vo -> vo.getParentId() == 0)
            .map(vo -> { vo.setChildren(buildChildrenTree(vo, voList)); return vo; })
            .toList();
}

private List<PermissionVo> buildChildrenTree(PermissionVo parent, List<PermissionVo> all) {
    return all.stream()
            .filter(vo -> vo.getParentId().equals(parent.getId()))
            .map(vo -> { vo.setChildren(buildChildrenTree(vo, all)); return vo; })
            .toList();
}

PermissionVo extends Permission 加了 @TableField(exist=false) 的 List<PermissionVo> children,只为装子孙节点,不映射数据库列。

11.2 前端:el-table 树形渲染

el-table 自带树形:row-key="id" + 数据里有 children 字段即自动展开:

<el-table :data="list" row-key="id" border>
  <el-table-column prop="name" label="名称"/>
  <el-table-column prop="type" label="权限类型">
    <template #default="{row}">
      <el-tag v-if="row.type == 0">目录权限</el-tag>
      <el-tag v-else-if="row.type == 1" type="success">菜单权限</el-tag>
      <el-tag v-else-if="row.type == 2" type="warning">按钮权限</el-tag>
    </template>
  </el-table-column>
  <!-- 图标列:字符串动态渲染组件 -->
  <el-table-column prop="icon" width="80px" label="图标">
    <template #default="{row}"><el-icon><component :is="row.icon"/></el-icon></template>
  </el-table-column>
  <!-- 操作列:按钮(type=2)是叶子不能再加下级;有 children 的不能删 -->
  <el-table-column label="操作" fixed="right">
    <template #default="{row}">
      <el-button size="small" type="success" @click="showAddDialog(row)"
                 :disabled="row.type===2 || !hasBtnPermission('permission:add')">添加</el-button>
      <el-button size="small" type="danger" @click="deleteById(row.id)"
                 :disabled="row.children?.length > 0 || !hasBtnPermission('permission:deleteById')">删除</el-button>
    </template>
  </el-table-column>
</el-table>

11.3 添加下级时的类型限制

type 含义 能挂的下级
0 目录 只能挂 菜单(1)
1 菜单 只能挂 按钮(2)
2 按钮 叶子节点,不能有下级
const showAddDialog = (row) => {
  ...
  if (row.type == 0) {          // 目录下 → 只能加菜单
    permission.value.type = 1
    type0Disabled.value = true; type2Disabled.value = true
  } else if (row.type == 1) {   // 菜单下 → 只能加按钮
    permission.value.type = 2
    type0Disabled.value = true; type1Disabled.value = true
  } else {                      // 顶级 → 加目录
    permission.value.type = 0
  }
}

曾经的坑::disabled="row.type===3" —— type 只有 0/1/2,条件永远不生效,导致「按钮下还能点添加」,改回 ===2 才正确。

11.4 el-tree 勾选权限(角色分配,见权限手册详解)

<el-tree :data="treeData" ref="treeRef" show-checkbox node-key="id"
         default-expand-all :props="{ children: 'children', label: 'name' }"/>
//回显:只勾"叶子节点"——el-tree 父子联动,父节点选中会自动带上所有子节点,
//只 setCheckedKeys 叶子避免父子重复勾选
const getCheckedLeafIdList = (permissionVOList, assignedPermissionIdList, checkedLeafIdList) => {
  permissionVOList.forEach(permissionVO => {
    assignedPermissionIdList.forEach(id => {
      if (permissionVO.id == id && permissionVO.children.length == 0) {
        checkedLeafIdList.push(id)
      } else if (permissionVO.id == id && permissionVO.children.length != 0) {
        getCheckedLeafIdList(permissionVO.children, assignedPermissionIdList, checkedLeafIdList)  // 递归下钻
      }
    })
  })
}
treeRef.value.setCheckedKeys(checkedLeafIdList)

//提交:getCheckedNodes(false, true) 第二参数 includeHalfChecked=true
//把半选父节点一并算入,保证父目录权限也提交给后端
let checkedNodes = treeRef.value.getCheckedNodes(false, true)
let permissionIds = checkedNodes.map(node => node.id).join(',')
roleApi.assignPermission(role.value.id, permissionIds).then(...)

12. 功能:动态菜单与图标

12.1 全局注册图标(字符串当组件用的前提)

// main.js:所有 Element Plus 图标注册为全局组件,并把图标名收集到 $icons
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
app.config.globalProperties.$icons = []
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
  app.config.globalProperties.$icons.push(key)
  app.component(key, component)
}

后端 permission 表存的 icon 是图标名字符串(如 User、Crop)。菜单里 <component :is="menu.icon"> 之所以能用字符串解析成组件,靠的就是这个全局注册。

12.2 el-menu 动态渲染(数据来自后端 routerList)

<el-menu background-color="#232323" text-color="#fff"
         router :collapse="isCollapse" :collapse-transition="false"
         :default-active="route.path">
  <template v-for="(menu, index) in menuData" :index="index.toString()">
    <!-- 有子节点 → 子菜单(目录) -->
    <el-sub-menu v-if="menu.children?.length > 0" :index="menu.name">
      <template #title>
        <component class="icons" :is="menu.icon" style="width: 1em; height: 1em; margin-right: 8px"/>
        <span>{{ menu.name }}</span>
      </template>
      <el-menu-item v-for="child in menu.children" :index="child.path">
        <el-icon><component :is="child.icon"/></el-icon>
        <span>{{ child.name }}</span>
      </el-menu-item>
    </el-sub-menu>
    <!-- 无子节点 → 普通菜单项 -->
    <el-menu-item v-else :index="menu.path">
      <el-icon><component :is="menu.icon"/></el-icon>
      <span>{{ menu.name }}</span>
    </el-menu-item>
  </template>
</el-menu>

要点:router 属性让 index(= 路由 path)点击即跳转;:default-active="route.path" 高亮当前菜单;首页固定拼在 menuData 最前不依赖权限(见权限手册 5.3)。

12.3 IconPicker 图标选择器(v-model 双向绑定图标名)

<script setup lang="ts">
  //通过 getCurrentInstance 拿到 main.js 收集的 $icons 全局图标名数组
  const {appContext: {app: {config: {globalProperties}}}} = getCurrentInstance()
  const props = defineProps<{ modelValue: string }>()
  const emits = defineEmits(['update:modelValue'])
</script>

<template>
  <el-popover trigger="click" :width="256">
    <template #reference>
      <el-button style="width: 150px" :icon="modelValue">{{ modelValue }}</el-button>
    </template>
    <div class="el-icon-picker">
      <component v-for="icon in globalProperties.$icons" :key="icon"
                 :class="[icon, 'icon', { 'icon-active': icon == modelValue }]"
                 :is="icon"
                 @click="emits('update:modelValue', icon)"/>
    </div>
  </el-popover>
</template>

这是项目里唯一一处用 defineProps/defineEmits 手写 v-model 的组件:modelValue prop + update:modelValue 事件就是 v-model 的展开形式。用于 Permission.vue 的图标字段。


13. 功能:Excel 导入导出(EasyExcel)

13.1 后端:导出工具类

public class ExcelUtil {
    public static <T> void exportExcel(HttpServletResponse resp, List<T> list,
                                       Class<T> clazz, String fileName, String sheetName) {
        try {
            fileName = URLEncoder.encode(fileName, "UTF-8");   // 中文文件名编码
            resp.reset();
            resp.setCharacterEncoding("utf-8");
            resp.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=UTF-8");
            resp.setHeader("Content-disposition", "attachment;filename=" + fileName + ".xlsx");
            ServletOutputStream os = resp.getOutputStream();
            EasyExcel.write(os, clazz)
                    .autoCloseStream(false)
                    //列宽自适应
                    .registerWriteHandler(new LongestMatchColumnWidthStyleStrategy())
                    .sheet(sheetName).doWrite(list);
        } catch (IOException e) {
            throw new RuntimeException("导出Excel异常");
        }
    }
}

VO 字段用 @ExcelProperty("中文名") 标注列头(如 UserExcelVO、ElderExcelVO)。

//Controller 导出:查全表 → 拷贝到 ExcelVO → 输出
@GetMapping("/exportExcel")
public Result exportExcel(HttpServletResponse response) {
    List<User> users = iUserService.list();
    List<UserExcelVO> voList = users.stream().map(u -> {
        UserExcelVO vo = new UserExcelVO();
        BeanUtils.copyProperties(u, vo);
        return vo;
    }).toList();
    ExcelUtil.exportExcel(response, voList, UserExcelVO.class, "用户信息表");
    return Result.ok();
}

13.2 后端:导入监听器(非 Spring Bean 的依赖传递)

public class UserExcelListener extends AnalysisEventListener<UserExcelVO> {

    //Autowired members must be defined in valid Spring bean (@Component|@Service|...)
    //@Autowired 不行!监听器是 new 出来的,不在 Spring 容器里
    private UserMapper userMapper;
    public UserExcelListener(UserMapper userMapper) {   // 构造器传 Mapper
        this.userMapper = userMapper;
    }

    @Override
    public void invoke(UserExcelVO userExcelVO, AnalysisContext analysisContext) {
        User user = new User();
        BeanUtils.copyProperties(userExcelVO, user);
        user.setId(null);          // 防止 Excel 里带 id 覆盖已有数据
        userMapper.insert(user);   // 逐行入库
    }

    @Override
    public void doAfterAllAnalysed(AnalysisContext analysisContext) {
    }
}

//Controller 导入:
@PostMapping("/importExcel")
public Result importExcel(MultipartFile file) throws IOException {
    EasyExcel.read(file.getInputStream(), UserExcelVO.class, new UserExcelListener(userMapper))
            .sheet().doRead();
    return Result.ok("导入成功");
}

13.3 前端:blob 下载(导出)与直传(导入)

//api 层:导出必须 responseType: 'blob'(二进制)
exportExcel() {
    return request({url: `/users/exportExcel`, method: 'get', responseType: 'blob'})
}
//页面:导出——从 Content-Disposition 解析文件名,createObjectURL 触发下载
const exportExcel = () => {
  userApi.exportExcel().then((response) => {
    //响应拦截器对 blob 特殊放行返回完整 response,才能读到响应头
    const disposition = response.headers['content-disposition'];
    let fileName = '用户信息.xlsx';   // 兜底名
    if (disposition) {
      fileName = decodeURIComponent(disposition.split('filename=')[1]);  // 后端做过 URLEncoder
    }
    let url = window.URL.createObjectURL(response.data);
    const link = document.createElement("a");
    link.href = url;
    link.download = fileName;
    link.click();
    URL.revokeObjectURL(url);      // 释放临时对象
  });
}
<!--导入:el-upload 直传后端接口(同样要手动带 token)-->
<el-upload action="/api/users/importExcel" accept=".xlsx,.xls"
           :show-file-list="false" :on-success="importExcelSuccess"
           :headers="{Authorization: tokenStore.token}" name="file">
  <el-button type="primary" :icon="Upload">导入Excel</el-button>
</el-upload>

14. 功能:首页图表(ECharts)

Chart.vue 的三图一卡(完整讲解见模块手册 10):

import * as echarts from 'echarts'

const trendChartRef = ref(null)   // 近7日新增任务趋势(面积图)
let trendChart, barChart, pieChart

//初始化:容器挂载后再 init,避免拿到空 DOM
onMounted(async () => {
  await nextTick()
  trendChart = echarts.init(trendChartRef.value)
  window.addEventListener('resize', handleResize)   // 窗口变化重绘
  loadCounts().catch(() => {})                      // 各部分独立,单个失败不影响其它
})
//销毁:removeEventListener + dispose,防止内存泄漏
onUnmounted(() => {
  window.removeEventListener('resize', handleResize)
  trendChart?.dispose(); trendChart = null
})
//面积图核心:线性渐变 areaStyle
areaStyle: {
  color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
    {offset: 0, color: 'rgba(64,158,255,0.30)'},
    {offset: 1, color: 'rgba(64,158,255,0.02)'}
  ])
}

//环形图中心显示总数:graphic 文本
pieChart.setOption({
  graphic: [{type: 'text', left: 'center', top: '38%',
    style: {text: String(total), textAlign: 'center', fill: '#303133', fontSize: 26, fontWeight: 700}}]
})

数据侧技巧:总数用 list({page:1, limit:1}) 只拿 total,不拉真实数据;近 7 日趋势用 formatDate/endOfDay 按天拼区间循环查(见功能 1)。


15. 功能:富文本备注(wangEditor)

老人备注用 wangEditor,支持正文插图(复用 OSS 上传接口):

import '@wangeditor/editor/dist/css/style.css'

const elderRemark = ref('')        // 编辑器内容(v-model 双向绑定 HTML)
const editorRef = shallowRef()     // 编辑器实例(shallowRef 避免深响应拖慢性能)
const toolbarConfig = {}           // 工具栏默认全功能

const editorConfig = {
  placeholder: '请输入备注内容...',
  MENU_CONF: {
    uploadImage: {
      server: '/api/upload',       // 复用OSS上传接口
      fieldName: 'file',           // 与后端 MultipartFile 参数名一致
      headers: {Authorization: tokenStore.token},  // wangEditor 不走 request.js,需手动带token
      customInsert(res, insertFn) {// 后端返回 Result{code,data:url},转成 wangEditor 需要的数据
        if (res.code === 1) {
          insertFn(res.data, '', '')   // 把OSS返回的图片 url 插入正文
        }
      }
    }
  }
}

//提交前把编辑器 HTML 同步进表单
const addOrUpdate = async () => {
  elder.value.remark = elderRemark.value
  ...
}
<div style="border: 1px solid #ccc">
  <Toolbar :editor="editorRef" :defaultConfig="toolbarConfig" style="border-bottom: 1px solid #ccc"/>
  <Editor v-model="elderRemark" :defaultConfig="editorConfig" style="height: 300px; overflow-y: hidden"
          @onCreated="(editor) => (editorRef.value = editor)"/>
</div>

要点:三个上传场景(el-upload / van-uploader / wangEditor)都不走 axios 封装,token 都要手动带;都复用同一个 /api/upload。


16. 功能:多标签页 + keep-alive

16.1 tags store(组合式写法)

export const useTagsStore = defineStore('tags', () => {
  // 每个标签:{ path, fullPath, title },以 path 去重(同一页面只占一个标签)
  const tags = ref([])

  // 打开/激活一个标签。已存在则只刷新 fullPath(例如合同页带不同 elderId 查询参数)
  function addTag(tag) {
    const exist = tags.value.find(t => t.path === tag.path)
    if (!exist) {
      tags.value.push({...tag})
    } else if (exist.fullPath !== tag.fullPath) {
      exist.fullPath = tag.fullPath
    }
  }
  function removeTag(path) { ... }
  function removeOtherTags(path) { ... }   // 关闭其它(预留)
  return {tags, addTag, removeTag, removeOtherTags}
})

16.2 Index.vue 的联动

//路由变化时自动把新页面加入标签栏(无 meta.title 的页面如 /login 不生成)
watch(() => route.fullPath, () => {
  if (!route.meta || !route.meta.title) return
  tagsStore.addTag({path: route.path, fullPath: route.fullPath, title: route.meta.title})
}, {immediate: true})

//关闭标签:若关的是当前页,跳到相邻标签;没有剩余时回菜单页
const closeTab = (tag) => {
  const idx = tagsStore.tags.findIndex(t => t.path === tag.path)
  const isActive = tag.path === route.path
  tagsStore.removeTag(tag.path)
  if (isActive) {
    const next = tagsStore.tags[idx] || tagsStore.tags[idx - 1]
    next ? router.push(next.fullPath) : router.push('/user')
  }
}
<!-- keep-alive 缓存页面组件,key 用 fullPath 让带参路由各自缓存 -->
<router-view v-slot="{ Component }">
  <keep-alive>
    <component :is="Component" :key="route.fullPath"/>
  </keep-alive>
</router-view>

16.3 keep-alive 页面刷新数据的正确姿势

问题:keep-alive 缓存后回到页面不会重新执行 onMounted,其它页面新增的护工/老人不会出现在下拉里(问题手册第 10 节)。

//onMounted 与 onActivated 都加载:首次挂载和每次被 keep-alive 重新激活时都会执行
onMounted(loadOptions)
onActivated(loadOptions)

17. 功能:Pinia 状态与持久化

export const useTokenStore = defineStore('token', {
    state() { return { token: '' } },
    actions: {
        setToken(newToken) { this.token = newToken },
        removeToken() { this.token = '' }
    },
    persist: {
        enabled: true,             // 开启缓存 默认存储在 localStorage
        key: 'elder-admin-token',  // 独立key:前后台部署在同一域名下时避免与手机端(elder-app-token)互相覆盖
    }
})

为什么要持久化:Pinia 的 state 在内存里,刷新页面就丢;pinia-plugin-persistedstate 自动同步到 localStorage,刷新后恢复(问题手册第 6 节)。

为什么要自定义 key:管理端和手机端部署在同一域名下时,两个项目的默认 key(token/userInfo)相同会互相覆盖;统一用 elder-admin- / elder-app- 前缀隔离(问题手册第 12 节)。

userInfo store 的路由级鉴权配套(allowedPaths getter、resetAll() 防换账号越权)见权限手册 6.2。


18. 功能:api 封装与请求体细节

18.1 封装模式

//每个 api 文件:import request → 导出 REST 方法对象 → 默认导出
import request from "@/utils/request.js";

const userApi = {
    list(userQuery)  { return request.get("/users", {params: userQuery}) },
    selectById(id)   { return request.get(`/users/${id}`) },
    add(user)        { return request.post("/users", user) },
    update(id, user) { return request.put(`/users/${id}`, user) },
    deleteById(id)   { return request.delete(`/users/${id}`) },
    //⚠️ axios 的 delete 第 2 个参数是 config,请求体必须放在 data 字段里
    deleteAll(ids)   { return request.delete("/users", {data: ids}) },
    //分配类接口:id 列表放查询参数(POST body 传 null)
    assignRole(userId, roleIds) {
        return request.post(`/users/assignRole`, null, {params: {userId, roleIds}})
    },
    //导出:blob
    exportExcel() { return request({url: `/users/exportExcel`, method: 'get', responseType: 'blob'}) },
}
export default userApi

18.2 request.js 拦截器(管理端)

const request = axios.create({baseURL: '/api/admin'})

//响应拦截器:业务码判断集中在这一层
request.interceptors.response.use(
    response => {
        //blob 响应(如 excel 导出)需要读取响应头里的文件名,返回完整 response
        if (response.config.responseType === 'blob') { return response }
        if (response.data.code == 1) { return response.data }   // 剥壳:页面拿到的直接是 {code,msg,data}
        ElMessage.error(response.data.msg ? response.data.msg : '服务异常')
        return Promise.reject(response.data)
    },
    error => {
        if (error.response.status === 401) {
            ElMessage.error('请先登录')
            tokenStore.removeToken()
            router.push('/login')
        } else { ElMessage.error('服务异常') }
        return Promise.reject(error)
    }
)

//请求拦截器:有 token 就自动带上 Authorization 头
request.interceptors.request.use((config) => {
    const tokenStore = useTokenStore();
    if (tokenStore.token) { config.headers.Authorization = tokenStore.token }
    return config;
})

管理端 vs 手机端拦截器差异:管理端在拦截器层判断 code==1(页面统一 result.code === 1 / result.data 取数);手机端拦截器只剥 response.data,业务码由各页面自己判断(Vant showToast 提示)。原因与取舍见模块手册 9.1。


19. 手机端(Vant4)组件

19.1 底部导航布局(Layout.vue)

<van-tabbar v-model="active" route placeholder>
  <van-tabbar-item replace to="/home" icon="home-o">首页</van-tabbar-item>
  <van-tabbar-item replace to="/package" icon="shopping-cart-o">体检套餐</van-tabbar-item>
  <van-tabbar-item replace to="/service" icon="shop-o">护理服务</van-tabbar-item>
  <van-tabbar-item replace to="/appointment" icon="clock-o">我的预约</van-tabbar-item>
  <van-tabbar-item replace to="/profile" icon="user-o">我的</van-tabbar-item>
</van-tabbar>
//路由和Tabbar序号的对应(直接敲 URL 进子页时高亮同步)
const tabRoutes = ['/home', '/package', '/service', '/appointment', '/profile']
watch(() => route.path, (path) => {
  const index = tabRoutes.indexOf(path)
  if (index !== -1) { active.value = index }
}, {immediate: true})

route 属性让 tabbar-item 跟路由联动;placeholder 占位防止内容被固定定位的 tabbar 盖住。页面骨架惯例:van-nav-bar fixed placeholder + .panel 卡片。

19.2 日期/时间选择器(预约表单)

const dateShow = ref(false)
const minDate = new Date(Date.now() + 24 * 60 * 60 * 1000)        // 最早明天
const maxDate = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000)   // 最晚90天后

//van-date-picker 的 v-model 是 ['2026','09','05'] 这样的数组
const onDateConfirm = ({selectedValues}) => {
  appointment.value.date = selectedValues.join('-')   // "2026-09-05"
  dateShow.value = false
}
const onTimeConfirm = ({selectedValues}) => {
  appointment.value.time = selectedValues.join(':')   // ['08','30'] -> "08:30"
  timeShow.value = false
}
<van-popup v-model:show="dateShow" position="bottom" round>
  <van-date-picker v-model="..." :min-date="minDate" :max-date="maxDate" @confirm="onDateConfirm"/>
</van-popup>
<!-- 时间选择限制营业时间 8:00-17:00 -->
<van-time-picker v-model="..." min-time="08:00" max-time="17:00" @confirm="onTimeConfirm"/>

19.3 下拉刷新 + 列表(ExamPackage.vue)

<van-pull-refresh v-model="loading" @refresh="loadData">
  <van-list :loading="loading" :finished="finished" finished-text="没有更多了">
    <div v-for="p in list" :key="p.id" @click="router.push('/package/' + p.id)">...</div>
  </van-list>
</van-pull-refresh>
//一次性加载:拉完直接 finished=true,van-list 不再触发 load
const loadData = () => {
  loading.value = true
  examPackageApi.list().then(result => {
    if (result.code == 1) {
      //缩略图配色后端没有,前端按下标取暖色色板(utils/tint.js)
      list.value = result.data.map((examPackage, index) => ({...examPackage, tint: tintAt(index)}))
    }
  }).finally(() => { loading.value = false; finished.value = true })
}

19.4 确认框与轻提示(区别于 Element Plus)

import {showToast, showConfirmDialog, showLoadingToast, showSuccessToast} from 'vant'

showToast(result.msg)                       // 轻提示(对应 ElMessage)
await showConfirmDialog({title: '退出登录', message: '确定要退出登录吗?'})  // Promise,catch 即取消
const toast = showLoadingToast({message: '取消中...', forbidClick: true, duration: 0})
toast.close()                               // 手动关闭 loading

函数式组件需要在 main.js 显式引入样式:import 'vant/lib/index.css'。

19.5 van-uploader 头像上传

const afterReadAvatar = (file) => {
  file.status = 'uploading'
  file.message = '上传中'
  const formData = new FormData()
  formData.append('file', file.file)   // 原生 File 在 file.file
  //upload 接口不在 /app 前缀下,直接用 axios 全路径
  axios.post('/api/upload', formData, {
    headers: {Authorization: tokenStore.token, 'Content-Type': 'multipart/form-data'}
  }).then(res => {
    if (res.data.code === 1) { form.value.avatar = res.data.data; file.status = 'done' }
    else { file.status = 'failed'; file.message = '上传失败' }
  })
}
<van-uploader :after-read="afterReadAvatar" :max-count="1" preview-size="56"/>

19.6 主题变量(styles/theme.css)

/* 思路:定义自己的语义变量(--brand/--accent/--ok...),再映射到 Vant 的 --van-* 变量,
   必须在 vant 样式之后引入才能生效 */
:root {
  --bg: #fafaf5;          /* 暖纸底 */
  --brand: #2e7ce8;       /* logo 房屋蓝 */
  --accent: #e8823f;      /* 爱心橙(价格/强调) */
  ...
  --van-primary-color: var(--brand);
  --van-tabbar-item-active-color: var(--brand-deep);
  --van-button-primary-background: var(--brand);
}

/* 通用卡片配方:各页面卡片统一视觉 */
.panel {
  margin: 12px 16px 0; padding: 14px;
  background: var(--card); border: 1px solid var(--line); border-radius: 14px;
}
/* 语义色常量(各页面统一取用):#e6a23c 待办 / #2aa876 进行中 / #2e7ce8 完成 / #e05e52 异常 / #a4aea4 终止 */

桌面模拟手机屏:#app { max-width: 480px; margin: 0 auto; };底部安全区 padding-bottom: env(safe-area-inset-bottom)。

20 密码加密

BCrypt + 随机盐值 + 数据库存储 BCrypt 结果

使用 BCrypt 库

<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-crypto</artifactId>
</dependency>

密码加密工具类

/**
 * 密码加密工具类:BCrypt哈希(自带随机盐,同一明文每次哈希结果都不同)
 */
public class PasswordUtil {

    //BCryptPasswordEncoder线程安全,静态单例复用即可
    private static final BCryptPasswordEncoder ENCODER = new BCryptPasswordEncoder();

    //明文密码 -> BCrypt哈希(固定60字符,$2a$10$开头)
    public static String hash(String rawPassword) {
        return ENCODER.encode(rawPassword);
    }

    //明文密码 与 库中哈希 比对
    public static boolean matches(String rawPassword, String hashedPassword) {
        //任一为空直接判失败,避免脏数据导致NPE
        if (rawPassword == null || hashedPassword == null || hashedPassword.isEmpty()) {
            return false;
        }
        return ENCODER.matches(rawPassword, hashedPassword);
    }

    public static void main(String[] args) {
        //生成已知明文的哈希,用于手动重置存量账号密码
        System.out.println(hash("123456"));
    }
}

添加:

//密码BCrypt哈希后再入库(放log前,日志里不出现明文密码)
user.setPassword(PasswordUtil.hash(user.getPassword()));
log.info("添加用户:{}", user);
userMapper.insert(user);

登录:

if (!PasswordUtil.matches(user.getPassword(), dbUser.getPassword())) {
    return Result.error("密码错误");
}

验证码添加

  <!--验证码-->
        <dependency>
            <groupId>com.github.penggle</groupId>
            <artifactId>kaptcha</artifactId>
            <version>2.3.2</version>
        </dependency>

配置类

/**
 * 验证码(Kaptcha)相关 Bean 配置
 *
 * 用 {@link DefaultKaptcha} 作为验证码生成器,可注入 {@code Producer} 使用。
 * 只负责"怎么画验证码":文字长度、字符集、宽高、字体、背景等,
 * 生成/存储/校验交给 Controller(见 UserController 的 /admin/users/captcha 与 login)。
 */
@Configuration
public class KaptchaConfig {

    @Bean
    public DefaultKaptcha captchaProducer() {
        DefaultKaptcha kaptcha = new DefaultKaptcha();
        Properties props = new Properties();
        //无边框,接近输入框一样的干净样式
        props.setProperty("kaptcha.border", "no");
        //图片尺寸,和登录页输入框高度相近
        props.setProperty("kaptcha.image.width", "120");
        props.setProperty("kaptcha.image.height", "40");
        //只出 4 位,字符集含字母+数字
        props.setProperty("kaptcha.textproducer.char.string", "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ");
        props.setProperty("kaptcha.textproducer.char.length", "4");
        //字符间距、字号、颜色
        props.setProperty("kaptcha.textproducer.char.space", "4");
        props.setProperty("kaptcha.textproducer.font.size", "32");
        props.setProperty("kaptcha.textproducer.font.color", "0,0,0");
        props.setProperty("kaptcha.textproducer.font.names", "Arial");
        //浅色渐变背景,更容易辨认
        props.setProperty("kaptcha.background.clear.from", "230,240,255");
        props.setProperty("kaptcha.background.clear.to", "255,255,255");
        //干扰线颜色
        props.setProperty("kaptcha.noise.color", "140,140,140");

        Config config = new Config(props);
        kaptcha.setConfig(config);
        return kaptcha;
    }
}

controller

 @Autowired
    private Producer captchaProducer;
    @GetMapping("/captcha")
    public Result captcha(HttpSession session) {
        String text = captchaProducer.createText();
        //存入 session,登录时取出比对(key 与登录校验处一致)
        session.setAttribute(CAPTCHA_SESSION_KEY, text);
        BufferedImage image = captchaProducer.createImage(text);
        ByteArrayOutputStream out = new ByteArrayOutputStream();
        try {
            ImageIO.write(image, "png", out);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
        //转成 data:image/png;base64,... 前端直接作为 img src 展示
        //注意:必须强转 Object,否则会命中 Result.ok(String msg) 重载,把图片放进了 msg 字段而非 data
        String base64 = "data:image/png;base64," + Base64.getEncoder().encodeToString(out.toByteArray());
        return Result.ok((Object) base64);
    }

前端

      <el-form-item prop="captcha">
        <div class="captcha-row">
          <el-input :prefix-icon="Key" placeholder="请输入验证码" v-model="user.captcha" @keyup.enter="login"/>
          <img :src="captchaImg" class="captcha-img" title="点击换一张" alt="验证码" @click="refreshCaptcha"/>
        </div>
      </el-form-item>
上一篇
下一篇