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. 功能:日期区间搜索

前端获取日期范围,并存入数组中
<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. 功能:滑动开关(状态切换)

前端绑定
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 的组件:modelValueprop +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,业务码由各页面自己判断(VantshowToast提示)。原因与取舍见模块手册 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>