--- name: "web-dev-best-practices" description: "Provides best practices for web development including date handling, internationalization, pagination, and data source configuration. Invoke when developing new pages or troubleshooting common web app issues." --- # Web开发最佳实践指南 ## 1. 时间日期处理最佳实践 ### 问题类型 - 前端日期字符串与后端Date对象转换问题 - GET请求参数中的日期格式处理 - JSON序列化中的日期格式处理 - 前端显示的日期格式化 ### 解决方案 - **后端接收GET参数**:使用 `@DateTimeFormat(pattern = "yyyy-MM-dd")` - **后端JSON序列化**:使用 `@JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")` - **前后端同时使用**:两个注解同时使用确保兼容性 - **前端显示格式化**:解析字符串日期并格式化为所需格式 ### 示例代码 ```java @DateTimeFormat(pattern = "yyyy-MM-dd") @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8") private Date planTimeStart; ``` ```javascript // 前端日期解析和格式化 const parsedDate = new Date(dateString); if (!isNaN(parsedDate.getTime())) { // 格式化为所需格式 return `${year}-${month}-${day} ${hours}:${minutes}`; } ``` ## 2. 前端国际化配置 ### 问题类型 - 组件库默认语言为英文 - 日期控件显示语言不符 ### 解决方案 - 在main.js中引入语言包 - 配置Element Plus使用中文语言环境 ### 示例代码 ```javascript import zhCn from 'element-plus/es/locale/lang/zh-cn' app.use(ElementPlus, { locale: zhCn }) ``` ## 3. 搜索和分页功能最佳实践 ### 问题类型 - 搜索和列表功能分离 - 大数据量性能问题 - 用户体验不佳 ### 解决方案 - 合并搜索和列表功能,通过参数判断 - 实现数据库分页,减少内存占用 - 添加瀑布流加载,提升用户体验 - 使用 `COALESCE` 函数处理空值判断 ### 示例代码 ```java @Query("SELECT new com.example.dto(...) " + "FROM ... " + "WHERE ... AND (COALESCE(:query, '') = '' OR ...) ") Page searchWithPage(@Param("query") String query, Pageable pageable); ``` ## 4. 数据库读写分离和数据源配置 ### 问题类型 - 误解数据库架构 - 错误的数据源配置 - 混淆读写分离概念 ### 解决方案 - 明确区分两种数据库类型: - **tugboatcommon**:系统公共库,存储所有机构共享数据 - **liandatugboatmis**:分支机构业务库,存储各机构业务数据 - 使用有意义的命名而非READ/WRITE: - `COMMON` → tugboatcommon - `BRANCH` → liandatugboatmis - 只对同时存在于两库的表使用分离 - 正确配置默认数据源 ### 示例代码 ```java public enum DataSourceType { COMMON("common"), // tugboatcommon: 系统公共库 BRANCH("branch"); // liandatugboatmis: 分支机构业务库 } ``` ## 5. 前端组件优化 ### 问题类型 - 下拉选择器性能问题 - 数据显示字段错误 - 用户交互体验差 ### 解决方案 - 实现虚拟滚动或分页加载 - 明确显示字段与值字段的区别 - 添加加载状态提示 - 实现滚动加载更多功能 ### 示例代码 ```html
加载中...
:value="item.valueField">
``` ## 6. 调试和开发流程 ### 最佳实践 - **针对性启动**:修改哪个项目就重新调试哪个项目,不要每次都全部重启 - **渐进式开发**:先实现基础功能,再逐步优化 - **前后端分离调试**:分别启动前后端服务进行调试 - **日志监控**:关注错误信息和警告信息 - **版本控制**:及时保存中间状态,避免丢失重要修改 ## 7. 前端防重复点击最佳实践 ### 问题类型 - 用户快速多次点击按钮或触发异步操作 - 多次发送相同请求 - 数据重复提交 - 用户体验不佳 - 系统资源浪费 ### 解决方案 在所有触发后台操作的前端事件中添加进度条和防重复点击机制。 ### 实现方法 1. **使用loading状态控制**:在按钮上绑定loading属性,在请求期间禁用按钮 2. **全局Loading覆盖**:对于页面级别的操作,使用全局Loading遮罩 3. **防抖和节流技术**:对于频繁触发的操作,使用debounce或throttle 4. **统一组件封装**:创建可复用的防重复点击组件 5. **finally块保证状态恢复**:使用try-finally确保无论成功失败都恢复loading状态 6. **表格v-loading指令**:在表格上使用v-loading显示加载状态 7. **使用ElMessage替代alert**:使用Element Plus的ElMessage组件显示提示信息 ### Loading状态管理原则 1. **查询操作**:使用统一的loading状态,控制查询按钮和表格加载 2. **保存操作**:使用独立的saveLoading状态,控制保存按钮 3. **删除操作**:使用独立的deleteLoading状态,控制删除按钮 4. **重置密码操作**:使用独立的resetPasswordLoading状态,控制重置密码按钮 5. **状态检查**:在每个异步操作开始前检查loading状态,避免重复执行 6. **状态独立**:不同类型的操作必须使用独立的loading状态,避免相互阻塞 7. **初始化操作**:页面初始化时的数据加载(如字典数据、下拉选项等)不需要loading状态 ### Loading状态阻塞问题及解决方案 #### 问题场景 当多个异步操作共享同一个loading状态时,会导致以下问题: - **初始化阻塞**:页面初始化时同时加载多个数据源,第一个执行的操作会阻塞其他操作 - **操作互斥**:不同类型的操作(如查询和保存)共享loading状态,导致无法同时执行 - **用户体验差**:用户点击不同按钮时,因为共享loading状态而被意外阻止 #### 错误示例1:多个操作共享同一个loading状态(不要使用) ```javascript // 错误:多个操作共享同一个loading状态 const loading = ref(false) const getUsers = async () => { if (loading.value) return; loading.value = true; try { await this.$axios.get('/api/user/list'); } finally { loading.value = false; } } const getRecordStatusList = async () => { if (loading.value) return; // 如果getUsers正在执行,这里会被阻塞 loading.value = true; try { await this.$axios.get('/api/record-status'); } finally { loading.value = false; } } const getEmployeeList = async () => { if (loading.value) return; // 如果getUsers或getRecordStatusList正在执行,这里会被阻塞 loading.value = true; try { await this.$axios.get('/api/employee/list'); } finally { loading.value = false; } } // 页面初始化时同时调用这三个方法 onMounted(() => { getUsers(); // 第一个执行 getRecordStatusList(); // 被阻塞,无法执行 getEmployeeList(); // 被阻塞,无法执行 }) ``` #### 错误示例2:初始化操作共享initLoading状态(不要使用) ```javascript // 错误:多个初始化操作共享同一个initLoading状态 const initLoading = ref(false) const getRecordStatusList = async () => { if (initLoading.value) return; // 如果getEmployeeList正在执行,这里会被阻塞 initLoading.value = true; try { await this.$axios.get('/api/record-status'); } finally { initLoading.value = false; } } const getEmployeeList = async () => { if (initLoading.value) return; // 如果getRecordStatusList正在执行,这里会被阻塞 initLoading.value = true; try { await this.$axios.get('/api/employee/list'); } finally { initLoading.value = false; } } // 页面初始化时同时调用这两个方法 onMounted(() => { getRecordStatusList(); // 第一个执行 getEmployeeList(); // 被阻塞,无法执行 }) ``` #### 正确示例:初始化操作不需要loading状态(推荐使用) ```javascript // 正确:初始化操作不需要loading状态 const loading = ref(false) // 查询loading状态 const saveLoading = ref(false) // 保存loading状态 const deleteLoading = ref(false) // 删除loading状态 const getUsers = async () => { if (loading.value) return; loading.value = true; try { await this.$axios.get('/api/user/list'); } finally { loading.value = false; } } const getRecordStatusList = async () => { // 不需要loading状态,直接执行 try { const response = await this.$axios.get('/api/record-status'); recordStatusList.value = response.data; } catch (error) { console.error('获取状态列表失败:', error); } } const getEmployeeList = async () => { // 不需要loading状态,直接执行 try { const response = await this.$axios.get('/api/employee/list'); employeeList.value = response.data.content; } catch (error) { console.error('获取员工列表失败:', error); } } // 页面初始化时同时调用这三个方法,互不阻塞 onMounted(() => { getUsers(); // 使用loading状态 getRecordStatusList(); // 不使用loading状态,可以同时执行 getEmployeeList(); // 不使用loading状态,可以同时执行 }) ``` ### Loading状态分类建议 #### 1. 查询类操作 - **状态名称**:`loading` - **控制范围**:查询按钮、表格加载、分页切换 - **使用场景**:列表查询、数据刷新、分页切换、排序 - **特点**:用户主动触发的操作,需要loading状态防止重复点击 #### 2. 初始化类操作 - **状态名称**:不需要loading状态 - **控制范围**:页面初始化时的数据加载 - **使用场景**:字典数据加载、下拉选项加载、基础数据加载 - **特点**:页面加载时自动执行,用户不会重复触发,不需要loading状态 #### 3. 保存类操作 - **状态名称**:`saveLoading` - **控制范围**:保存/新增/编辑对话框的提交按钮 - **使用场景**:新增、编辑、保存操作 #### 4. 删除类操作 - **状态名称**:`deleteLoading` - **控制范围**:删除按钮 - **使用场景**:单条删除、批量删除 #### 5. 其他特定操作 - **状态名称**:根据操作类型命名(如`resetPasswordLoading`、`exportLoading`等) - **控制范围**:特定操作的按钮 - **使用场景**:重置密码、导出数据、导入数据等 ### Loading状态使用原则总结 | 操作类型 | 是否需要loading | 原因 | 示例 | |---------|--------------|------|------| | 查询操作 | 是 | 用户主动触发,需要防止重复点击 | 查询按钮、分页切换、排序 | | 初始化操作 | 否 | 页面加载时自动执行,用户不会重复触发 | 字典数据、下拉选项、基础数据 | | 保存操作 | 是 | 用户主动触发,需要防止重复提交 | 新增、编辑、保存 | | 删除操作 | 是 | 用户主动触发,需要防止重复删除 | 单条删除、批量删除 | | 特定操作 | 是 | 用户主动触发,需要防止重复执行 | 重置密码、导出、导入 | ### 示例代码 #### 7.1 基础Loading状态实现 ```vue ``` #### 7.2 表格Loading实现 ```vue ``` #### 7.3 对话框Loading实现 ```vue ``` #### 7.4 完整的用户管理页面Loading实现 ```vue ``` ### 关键点说明 1. **状态检查**:在每个异步操作开始前检查loading状态,避免重复执行 2. **finally块**:使用try-finally确保无论成功失败都恢复loading状态 3. **ElMessage替代alert**:使用Element Plus的ElMessage组件显示提示信息,提升用户体验 4. **表格v-loading**:在表格上使用v-loading指令显示加载状态 5. **按钮loading属性**:在按钮上绑定loading属性,在请求期间禁用按钮并显示加载动画 6. **独立状态管理**:不同操作使用独立的loading状态,避免相互影响 7. **状态恢复**:确保在finally块中恢复loading状态,避免状态卡死 8. **初始化操作不需要loading**:页面初始化时的数据加载不需要loading状态,避免阻塞查询操作 9. **状态分类清晰**:按操作类型分类loading状态(查询、保存、删除等),便于维护 ### 注意事项 1. **不要使用alert**:使用ElMessage替代alert,提升用户体验 2. **finally块必须**:使用finally确保loading状态一定会被恢复 3. **状态检查位置**:在异步操作开始前检查loading状态 4. **独立状态**:不同操作使用独立的loading状态,避免相互影响 5. **用户体验**:loading状态下按钮会显示加载动画,提升用户体验 6. **错误处理**:在catch块中使用ElMessage.error显示错误信息 7. **成功提示**:操作成功后使用ElMessage.success显示成功信息 8. **初始化操作不使用loading**:页面初始化时的数据加载(如字典数据、下拉选项)不需要loading状态 9. **避免状态冲突**:查询操作和初始化操作不能共享同一个loading状态,否则会相互阻塞 ## 8. 常见陷阱和注意事项 1. **注解使用场景**: - `@DateTimeFormat` 用于请求参数绑定 - `@JsonFormat` 用于JSON序列化/反序列化 2. **命名规范**: - 避免使用可能引起误解的命名(如READ/WRITE) - 使用业务含义明确的命名 3. **数据一致性**: - 确保前后端日期格式一致 - 保证显示字段与业务需求一致 4. **性能考虑**: - 大数据量时使用分页 - 合理使用缓存 - 避免不必要的数据传输 5. **关联数据显示问题**: - **问题场景**:编辑对话框中的关联用户下拉控件显示ID而不是姓名 - **根本原因**:后端返回的DTO中没有包含关联字段的完整信息 - **解决方案**: - 在DTO类中添加关联字段(如employeeId) - 在Service层查询时,同时查询关联表数据并填充到DTO中 - 使用DTO的fromEntity方法重载版本,传入关联字段值 - **示例代码**: ```java // Service层查询用户时,同时查询关联的员工信息 SalEmployee employee = salEmployeeRepository.findByUserId(user.getId()); String employeeId = employee != null ? employee.getEmployeeId() : null; return UserDTO.fromEntity(user, roleName, recordStatusName, employeeId); // DTO类提供多个fromEntity重载方法 public static UserDTO fromEntity(User user, String roleName, String recordStatusName) { UserDTO dto = new UserDTO(); dto.setEmployeeId(""); // 不包含employeeId // ... 其他字段设置 return dto; } public static UserDTO fromEntity(User user, String roleName, String recordStatusName, String employeeId) { UserDTO dto = new UserDTO(); dto.setEmployeeId(employeeId != null ? employeeId : ""); // 包含employeeId // ... 其他字段设置 return dto; } ``` - **前端实现**: ```vue ``` ## 使用时机 在开发新页面功能、调试常见Web应用问题、处理日期时间、配置数据源、优化前端组件时使用此技能。 ## 9. 前端UI布局最佳实践 ### 问题类型 - 查询条件控件过多导致页面拥挤 - 表格列头没有排序提示 - 表格内容换行影响阅读 - 表格标题显示不完整 - 横向滚动时标题和固定列无法锁定 ### 解决方案 #### 9.1 查询条件布局 - **每行三个控件**:将查询条件控件按每行三个进行布局 - **默认显示第一行**:只显示第一行的三个控件,其他控件折叠 - **展开/收起功能**:添加"展开/收起"按钮,用户点击后显示/隐藏剩余控件 - **按钮并排布局**:将"展开/收起"、"查询"、"重置"三个按钮并排放在第一行查询条件的右边,与查询条件控件在同一行对齐 **按钮并排布局实现**: - 在第一行查询条件容器的末尾添加按钮组 - 按钮与查询条件控件使用相同的flex布局 - 确保按钮与查询条件控件垂直居中对齐 - 节省垂直空间,让表格可看到更多内容 #### 9.2 表格列头排序(数据库级排序和分页) - **排序箭头**:表格列头显示排序箭头(↑↓) - **点击排序**:点击列头可切换排序方向(升序→降序→无排序) - **当前排序标识**:当前排序的列头显示箭头,未排序列头显示空心箭头 - **数据库级排序**:排序参数传递到后端,在数据库层面进行排序,而不是在前端排序 - **数据库级分页**:分页参数传递到后端,在数据库层面进行分页,而不是在前端分页 - **BaseQueryDTO基类**:使用BaseQueryDTO作为查询参数基类,包含page、pageSize、sortBy、sortOrder字段 - **字段映射**:前端表格列prop映射到后端数据库字段名 **数据库级排序和分页实现**: - 前端监听表格的@sort-change事件 - 将排序字段和方向传递给后端 - 前端将分页参数(page、pageSize)传递给后端 - 后端使用BaseQueryDTO接收分页和排序参数 - 后端使用Spring Data的PageRequest和Sort对象进行数据库分页和排序 - 确保分页和排序在数据库层面执行,提高性能 - page参数需要减1,因为Spring Data JPA的页码从0开始 #### 9.3 表格紧凑布局 - **紧凑模式**:使用`size="small"`或`size="default"`属性 - **禁止换行**:设置`white-space: nowrap`防止内容换行 - **文本截断**:内容过长时使用`text-overflow: ellipsis`截断显示 - **工具提示**:鼠标悬停时显示完整内容的tooltip #### 9.4 表格标题锁定 - **固定标题行**:使用`position: sticky`或Element Plus的`sticky-header`属性 - **滚动时锁定**:表格滚动时标题行保持在顶部 #### 9.5 固定列锁定 - **左侧固定列**:使用`fixed="left"`锁定序号列、操作列等 - **右侧固定列**:使用`fixed="right"`锁定操作列 - **横向滚动锁定**:固定列在横向滚动时保持不动 ### 示例代码 #### 9.1 查询条件布局(按钮并排版本) ```vue ``` #### 9.2 表格列头排序 ```vue ``` #### 9.3 完整的表格配置 ```vue ``` #### 9.4 后端Service实现(数据库级分页和排序) ```java @Service public class PilotPlanService { @Autowired private DispPilotPlanReadRepository dispPilotPlanReadRepository; /** * 获取引航计划列表(支持数据库级分页和排序) */ @DataSource(value = RoutingDataSourceConfig.DataSourceType.BRANCH) public List getPilotPlans(PilotPlanQueryDTO queryDTO) { // 获取分页参数(BaseQueryDTO中定义) Integer page = queryDTO.getPage() != null ? queryDTO.getPage() - 1 : 0; Integer pageSize = queryDTO.getPageSize() != null ? queryDTO.getPageSize() : 20; // 创建分页对象 Pageable pageable = PageRequest.of(page, pageSize); // 如果有排序参数,则添加排序 if (queryDTO.getSortBy() != null && !queryDTO.getSortBy().isEmpty()) { String sortBy = queryDTO.getSortBy(); String sortOrder = queryDTO.getSortOrder(); if (sortOrder != null && !sortOrder.isEmpty()) { Sort sort = Sort.by(Sort.Order.asc(sortBy)); if ("DESC".equalsIgnoreCase(sortOrder)) { sort = Sort.by(Sort.Order.desc(sortBy)); } // 更新分页对象,添加排序 pageable = PageRequest.of(page, pageSize, sort); } } // 使用Repository查询,传入分页和排序参数 Page result = dispPilotPlanReadRepository.findWithFiltersProjected( planTimeStart, planTimeEnd, null, isEmpty(queryDTO.getPortId()) ? null : queryDTO.getPortId(), null, isEmpty(queryDTO.getStatus()) ? null : queryDTO.getStatus(), isEmpty(queryDTO.getShipownerId()) ? null : queryDTO.getShipownerId(), queryDTO.getDeepStart(), queryDTO.getDeepEnd(), isEmpty(queryDTO.getTrafficboat()) ? null : queryDTO.getTrafficboat(), queryDTO.getIsInnerPort(), isEmpty(queryDTO.getEnglishShipName()) ? null : queryDTO.getEnglishShipName(), pageable // 传入分页和排序参数 ); // 获取查询结果 List projections = result.getContent(); // 转换为DTO并返回 List pilotPlanDTOs = new ArrayList<>(); for (int i = 0; i < projections.size(); i++) { PilotPlanProjection projection = projections.get(i); PilotPlanDTO pilotPlanDTO = new PilotPlanDTO(); // 设置序号(基于分页计算) pilotPlanDTO.setIndex(page * pageSize + i + 1); // 设置其他字段... pilotPlanDTO.setPilotPlanId(projection.getPilotPlanId()); pilotPlanDTO.setPlanTime(projection.getPlanTime()); pilotPlanDTOs.add(pilotPlanDTO); } return pilotPlanDTOs; } } ``` **关键点说明**: 1. **分页参数处理**:从BaseQueryDTO获取page和pageSize,page需要减1(Spring Data JPA页码从0开始) 2. **排序参数处理**:从BaseQueryDTO获取sortBy和sortOrder,创建Sort对象 3. **Pageable对象**:使用PageRequest.of()创建分页对象,支持分页和排序 4. **Repository查询**:将Pageable对象传递给Repository方法,在数据库层面执行分页和排序 5. **序号计算**:序号基于分页计算:`page * pageSize + i + 1`,确保序号连续且正确 6. **性能优化**:数据库级分页和排序比前端分页和排序性能更好,特别是大数据量时 7. **分页响应结构**:使用PageResponse包装返回数据,包含content(数据列表)、totalElements(总记录数)、currentPage(当前页)、pageSize(每页大小)、totalPages(总页数) #### 9.5 Controller层实现(返回分页响应) ```java @RestController @RequestMapping("/pilot-plan") public class PilotPlanController { @Autowired private PilotPlanService pilotPlanService; /** * 获取引航计划列表(支持分页和排序) */ @GetMapping("/list") public ResponseEntity> getPilotPlans(PilotPlanQueryDTO queryDTO) { PageResponse pilotPlans = pilotPlanService.getPilotPlans(queryDTO); return new ResponseEntity<>(pilotPlans, HttpStatus.OK); } } ``` #### 9.6 前端API调用(处理分页响应) ```javascript methods: { getPilotPlans() { const params = { planTimeStart: this.searchForm.planTimeRange && this.searchForm.planTimeRange.length > 0 ? this.formatDate(this.searchForm.planTimeRange[0]) : '', planTimeEnd: this.searchForm.planTimeRange && this.searchForm.planTimeRange.length > 1 ? this.formatDate(this.searchForm.planTimeRange[1]) : '', portAreaOperationType: this.searchForm.portAreaOperationType, portId: this.searchForm.portId, englishShipName: this.searchForm.englishShipName, status: this.searchForm.status, shipownerId: this.searchForm.shipownerId, deepStart: this.searchForm.deepStart, deepEnd: this.searchForm.deepEnd, trafficboat: this.searchForm.trafficboat, page: this.currentPage, // 当前页码 pageSize: this.pageSize, // 每页大小 sortBy: this.sortMap.sortBy, // 排序字段 sortOrder: this.sortMap.sortOrder // 排序方向 }; this.$axios.get('/api/pilot-plan/list', { params }) .then(response => { // 从PageResponse中获取数据 this.pilotPlans = response.data.content; // 当前页数据 this.total = response.data.totalElements; // 总记录数 // 可选:获取其他分页信息 // this.totalPages = response.data.totalPages; // 总页数 // this.currentPage = response.data.currentPage; // 当前页码 }) .catch(error => { console.error('获取引航计划失败:', error); this.$message.error('获取引航计划失败,请稍后重试'); }); } } ``` **关键点说明**: 1. **分页响应结构**:后端返回PageResponse对象,包含完整的分页信息 2. **数据提取**:从response.data.content获取当前页的数据列表 3. **总记录数**:从response.data.totalElements获取总记录数,用于分页控件显示 4. **分页控件绑定**:将total绑定到el-pagination的total属性,currentPage绑定到current-page属性 5. **分页事件**:监听el-pagination的@current-change和@size-change事件,更新currentPage和pageSize并重新查询 #### 9.7 表格序号连续显示(分页时序号不重置) ### 问题类型 - 使用`type="index"`时,每页序号都从1开始 - 分页后序号不连续,用户体验不佳 - 需要序号在翻页时保持连续性 ### 解决方案 - **不使用type="index"**:不要使用Element Plus的内置索引列 - **自定义序号列**:使用自定义模板计算序号 - **序号计算公式**:`(currentPage - 1) * pageSize + $index + 1` - **响应式更新**:序号会随着currentPage和pageSize的变化自动更新 ### 序号计算说明 - **currentPage**:当前页码(从1开始) - **pageSize**:每页显示的记录数 - **$index**:当前页内的行索引(从0开始) - **计算公式**:`(currentPage - 1) * pageSize + $index + 1` - 第1页第1行:`(1-1) * 10 + 0 + 1 = 1` - 第1页第10行:`(1-1) * 10 + 9 + 1 = 10` - 第2页第1行:`(2-1) * 10 + 0 + 1 = 11` - 第2页第10行:`(2-1) * 10 + 9 + 1 = 20` ### 示例代码 #### 9.7.1 自定义序号列实现 ```vue ``` #### 9.7.2 错误实现示例(不要使用) ```vue ``` #### 9.7.3 正确实现示例(推荐) ```vue ``` ### 关键点说明 1. **不要使用type="index"**:内置索引列会在每页都从1开始计数 2. **使用自定义模板**:通过`#default="{ $index }"`获取当前行的索引 3. **序号计算公式**:`(currentPage - 1) * pageSize + $index + 1`确保序号连续 4. **响应式更新**:序号会自动响应currentPage和pageSize的变化 5. **固定列**:序号列通常固定在左侧,使用`fixed`属性 6. **列宽设置**:序号列宽度建议设置为60px,避免数字换行 ### 注意事项 1. **currentPage从1开始**:前端分页通常从1开始,后端Spring Data JPA从0开始 2. **$index从0开始**:Vue模板中的行索引从0开始 3. **公式中的+1**:因为$index从0开始,所以需要+1使序号从1开始 4. **pageSize变化时**:改变每页大小时,建议重置currentPage为1 5. **排序不影响序号**:序号计算独立于排序,始终按分页顺序显示 ## 10. 前端权限控制最佳实践 ### 问题类型 - 用户登录信息即将失效时需要自动更新权限 - 不同用户角色需要显示不同的按钮 - 需要根据功能代码控制按钮显示/隐藏 ### 解决方案 - **Token刷新机制**:使用TokenRefreshManager监控token并在即将过期时自动刷新 - **权限自动更新**:在token刷新成功后自动更新userFunctionCodes - **权限指令**:使用v-permission指令控制按钮显示/隐藏 - **功能代码命名**:使用规范的功能代码命名(如:pilotplan:query、pilotplan:import等) ### Token刷新和权限更新机制 - **TokenRefreshManager**:监控token并在即将过期前10分钟自动刷新 - **权限更新回调**:在token刷新成功后自动调用PermissionManager更新权限 - **localStorage存储**:权限信息存储在localStorage的userFunctionCodes中 ### 权限指令实现 - **指令注册**:在main.js中注册v-permission指令 - **权限检查**:指令从localStorage读取userFunctionCodes并检查是否包含指定功能代码 - **自动隐藏**:用户没有权限时自动隐藏按钮(display: none) - **响应式更新**:当权限信息更新时自动重新检查并更新按钮显示状态 ### 功能代码命名规范 - **模块:操作**:如pilotplan:query(引航计划查询) - **模块:操作:子操作**:如pilotplan:import:parse(引航计划导入解析) - **英文小写**:使用英文小写,用冒号分隔层级 ### 示例代码 #### 10.1 main.js中注册权限指令和设置token刷新回调 ```javascript import { createApp } from 'vue' import App from './App.vue' import ElementPlus from 'element-plus' import 'element-plus/dist/index.css' import zhCn from 'element-plus/es/locale/lang/zh-cn' import router from './router' import axios from 'axios' import tokenRefreshManager from './utils/tokenRefreshManager' import PermissionManager from './utils/permission' import permissionDirective from './directives/permission' const app = createApp(App) app.use(ElementPlus, { locale: zhCn }) app.use(router) // 注册权限指令 app.directive('permission', permissionDirective) // 配置axios app.config.globalProperties.$axios = axios // 设置token刷新回调,刷新时更新权限信息 tokenRefreshManager.setOnTokenRefresh(async (newToken) => { console.log('Token已刷新,更新权限信息...'); try { await PermissionManager.initializePermissions(`Bearer ${newToken}`); console.log('权限信息更新完成'); } catch (error) { console.error('更新权限信息失败:', error); } }); app.mount('#app') ``` #### 10.2 权限指令实现(directives/permission.js) ```javascript // 权限指令,用于控制按钮显示 const permissionDirective = { // 在绑定元素插入父节点时调用 mounted(el, binding) { // 获取按钮需要的功能代码 const requiredFunctionCode = binding.value; // 从localStorage中获取当前用户的功能代码列表 const userFunctionCodesStr = localStorage.getItem('userFunctionCodes'); if (!userFunctionCodesStr) { // 如果没有功能代码列表,隐藏按钮 el.style.display = 'none'; return; } try { const userFunctionCodes = JSON.parse(userFunctionCodesStr); // 检查用户是否拥有该功能代码 if (!userFunctionCodes.includes(requiredFunctionCode)) { // 用户没有权限,隐藏按钮 el.style.display = 'none'; } } catch (error) { console.error('解析用户功能代码失败:', error); // 解析失败时隐藏按钮 el.style.display = 'none'; } }, // 在绑定元素的父节点更新时调用 updated(el, binding) { // 获取按钮需要的功能代码 const requiredFunctionCode = binding.value; // 从localStorage中获取当前用户的功能代码列表 const userFunctionCodesStr = localStorage.getItem('userFunctionCodes'); if (!userFunctionCodesStr) { // 如果没有功能代码列表,隐藏按钮 el.style.display = 'none'; return; } try { const userFunctionCodes = JSON.parse(userFunctionCodesStr); // 检查用户是否拥有该功能代码 if (!userFunctionCodes.includes(requiredFunctionCode)) { // 用户没有权限,隐藏按钮 el.style.display = 'none'; } else { // 用户有权限,显示按钮 el.style.display = ''; } } catch (error) { console.error('解析用户功能代码失败:', error); // 解析失败时隐藏按钮 el.style.display = 'none'; } } }; export default permissionDirective; ``` #### 10.3 在Vue组件中使用权限指令 ```vue ``` #### 10.4 引航计划功能代码示例 根据常见命名规范,引航计划相关的功能代码可能如下: | 按钮 | 功能代码 | 说明 | |------|----------|------| | 展开 | pilotplan:expand | 展开/收起查询条件 | | 查询 | pilotplan:query | 查询引航计划列表 | | 重置 | pilotplan:reset | 重置查询条件 | | 导入 | pilotplan:import | 导入引航计划 | | 导出 | pilotplan:export | 导出引航计划Excel | | 取消(导入) | pilotplan:import:cancel | 取消导入操作 | | 解析数据 | pilotplan:import:parse | 解析导入数据 | | 取消(预览) | pilotplan:import:preview:cancel | 取消预览 | | 确认导入 | pilotplan:import:preview:confirm | 确认导入数据 | **注意**:以上功能代码是根据常见命名规范推测的,实际功能代码需要从数据库Sys_FunctionCode表中查询确认。 ### 关键点说明 1. **Token自动刷新**:TokenRefreshManager在token即将过期前10分钟自动刷新,避免用户需要重新登录 2. **权限自动更新**:token刷新成功后自动调用PermissionManager更新userFunctionCodes,确保权限信息最新 3. **指令响应式**:v-permission指令在mounted和updated时都会检查权限,确保权限更新后按钮显示状态正确 4. **功能代码规范**:使用模块:操作:子操作的命名规范,便于管理和理解 5. **安全第一**:默认隐藏按钮,只有明确有权限时才显示,避免权限泄露 6. **数据库确认**:实际功能代码需要从Sys_FunctionCode表查询确认,不要随意猜测 ## 11. 前端标签页管理最佳实践 ### 问题类型 - 用户需要在多个页面之间快速切换 - 需要保留页面状态,避免重复加载数据 - 需要关闭不需要的标签页 - 需要支持标签页的批量操作 ### 解决方案 - 实现标签页管理组件,支持多标签页切换 - 使用Vue Router的keep-alive机制保留页面状态 - 提供标签页的打开、关闭、批量关闭功能 - 支持标签页的右键菜单和快捷操作 ### 实现步骤 #### 11.1 创建标签页管理组件 创建`TabsView.vue`组件,包含以下功能: - 标签页头部:显示所有打开的标签页 - 标签页内容:使用``显示当前标签页内容 - 标签页操作:支持关闭当前、关闭其他、关闭全部 #### 11.2 修改主页组件 在`HomePage.vue`中引入并使用`TabsView`组件: ```vue ``` #### 11.3 标签页组件实现 ```vue ``` ### 关键点说明 1. **标签页管理**:使用`tabs`数组管理所有打开的标签页 2. **路由监听**:使用`watch`监听`$route`变化,自动添加标签页 3. **状态保留**:使用`keep-alive`组件缓存页面状态,切换标签页时不会重新加载 4. **首页始终存在**:首页标签页始终存在且不可关闭,确保用户随时可以返回首页 5. **首页固定位置**:首页标签页始终在第一个位置,其他标签页在其后插入 6. **批量操作**:提供关闭当前、关闭其他、关闭全部的批量操作 7. **用户体验**:标签页标题使用中文,便于用户识别 8. **缓存管理**:使用`cachedViews`计算属性动态管理需要缓存的视图名称 ### keep-alive状态保持说明 #### 11.4 使用keep-alive保持页面状态 **问题类型**: - 切换标签页后页面重新加载,丢失表单数据 - 查询条件被重置,需要重新输入 - 滚动位置丢失,需要重新滚动 **解决方案**: - 使用Vue的`keep-alive`组件缓存组件实例 - 通过`include`属性指定需要缓存的组件名称 - 使用`v-slot`获取路由信息,为每个组件设置唯一的key #### 实现方式 ```vue
``` ```javascript computed: { /** * 计算需要缓存的视图名称 */ cachedViews() { const routeToComponentMap = { 'home.index': 'MainIndex', 'home.mynotice': 'MyNotice', 'home.settings_announcement': 'AnnouncementManagement', 'home.settings_role': 'RoleManagement', 'home.settings_user': 'UserManagement', 'home.pilot_plan': 'PilotPlan' } return this.tabs.map(tab => routeToComponentMap[tab.name] || tab.name) } } ``` #### 路由名称到组件名称的映射 **重要**:`keep-alive`的`include`属性需要的是组件的`name`选项,而不是路由名称。因此需要建立路由名称到组件名称的映射关系。 **映射表**: | 路由名称 | 组件名称 | 说明 | |----------|----------|------| | home.index | MainIndex | 首页 | | home.mynotice | MyNotice | 我的通知 | | home.settings_announcement | AnnouncementManagement | 公告管理 | | home.settings_role | RoleManagement | 角色管理 | | home.settings_user | UserManagement | 用户管理 | | home.pilot_plan | PilotPlan | 引航计划 | **动态映射**: ```javascript const routeToComponentMap = { 'home.index': 'MainIndex', 'home.mynotice': 'MyNotice', 'home.settings_announcement': 'AnnouncementManagement', 'home.settings_role': 'RoleManagement', 'home.settings_user': 'UserManagement', 'home.pilot_plan': 'PilotPlan' } return this.tabs.map(tab => routeToComponentMap[tab.name] || tab.name) ``` #### 组件名称要求 为了使`keep-alive`正常工作,组件必须定义`name`选项: ```javascript export default { name: 'UserManagement', // 必须定义name // ... } ``` **所有组件的name定义**: ```javascript // MainIndex.vue export default { name: 'MainIndex', // ... } // MyNotice.vue export default { name: 'MyNotice', // ... } // Announcement.vue export default { name: 'AnnouncementManagement', // ... } // RoleManagement.vue export default { name: 'RoleManagement', // ... } // UserManagement.vue export default { name: 'UserManagement', // ... } // PilotPlan.vue export default { name: 'PilotPlan', // ... } ``` #### 注意事项 1. **组件名称必须定义**:所有需要缓存的组件都必须定义`name`选项 2. **名称必须匹配**:`cachedViews`中的名称必须与组件的`name`选项完全匹配 3. **路由与组件映射**:必须建立路由名称到组件名称的映射关系,否则缓存失效 4. **缓存清理**:关闭标签页时,对应的组件实例会被销毁,缓存也会被清理 5. **性能考虑**:缓存过多组件会占用内存,建议限制最大标签页数量 6. **状态管理**:对于复杂状态,建议使用Vuex或Pinia进行全局状态管理 ### 样式建议 ```css /* 标签页容器 */ .tabs-container { display: flex; flex-direction: column; height: 100%; background-color: #f5f5f5; } /* 标签页头部 */ .tabs-header { display: flex; align-items: center; background-color: #fff; border-bottom: 1px solid #e4e7ed; padding: 0 16px; height: 40px; } /* 标签页项 */ .tab-item { display: flex; align-items: center; padding: 0 16px; height: 32px; margin: 4px 4px 4px 0; background-color: #f5f7fa; border: 1px solid #e4e7ed; border-radius: 4px; cursor: pointer; transition: all 0.3s ease; font-size: 13px; } .tab-item:hover { background-color: #e6f7ff; border-color: #1890ff; color: #1890ff; } .tab-item.active { background-color: #fff; border-color: #1890ff; color: #1890ff; } /* 标签页标题 */ .tab-title { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 120px; flex-shrink: 0; } /* 标签页关闭按钮 */ .tab-close { margin-left: 8px; font-size: 14px; opacity: 1 !important; transition: all 0.2s ease; flex-shrink: 0; display: flex !important; align-items: center; justify-content: center; width: 16px; height: 16px; border-radius: 50%; cursor: pointer; visibility: visible !important; } .tab-close:hover { opacity: 1 !important; color: #ff4d4f; background-color: #fff0f0; } /* 确保tab-item hover时关闭按钮也可见 */ .tab-item:hover .tab-close { opacity: 1 !important; visibility: visible !important; } /* 标签页内容区域 */ .tabs-content { flex: 1; overflow: hidden; background-color: #ecf0f5; } ``` ### 注意事项 1. **标签页标题映射**:在`getTabTitle`方法中维护路由名称到标题的映射 2. **首页保护**:首页标签页设置为不可关闭,确保用户始终可以返回首页 3. **关闭按钮显示**:所有非首页标签页都应显示关闭按钮,使用`v-if="tab.closable"`控制显示 4. **关闭按钮样式**: - `opacity: 1 !important`:使用!important确保关闭按钮始终可见 - `visibility: visible !important`:强制设置可见性 - `display: flex !important`:强制显示 - `font-size: 14px`:使用合适的字体大小 - `width/height: 16px`:设置固定尺寸,确保按钮可点击 - `border-radius: 50%`:圆形背景,鼠标悬停时显示圆形背景 - `flex-shrink: 0`:防止按钮被压缩 - `cursor: pointer`:鼠标悬停时显示手型光标 5. **标题宽度控制**:`max-width: 120px`,`flex-shrink: 0`,确保关闭按钮有足够空间 6. **状态管理**:使用`keep-alive`组件保留表单状态 7. **性能优化**:标签页过多时,考虑限制最大标签页数量或使用虚拟滚动 8. **权限控制**:根据用户权限控制标签页的显示和关闭功能 9. **关闭全部标签页**:关闭全部时只关闭可关闭的标签页,保留首页标签页 #### 关闭按钮样式说明 **问题**:关闭按钮不显示或不够明显 **解决方案**: ```css /* 标签页标题 */ .tab-title { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 120px; /* 限制标题宽度,为关闭按钮留出空间 */ flex-shrink: 0; /* 防止标题被压缩 */ } /* 标签页关闭按钮 */ .tab-close { margin-left: 8px; font-size: 14px; /* 足够大的字体 */ opacity: 1 !important; /* 使用!important强制始终可见 */ transition: all 0.2s ease; flex-shrink: 0; /* 防止按钮被压缩 */ display: flex !important; /* 强制显示 */ align-items: center; justify-content: center; width: 16px; /* 固定宽度 */ height: 16px; /* 固定高度 */ border-radius: 50%; /* 圆形背景 */ cursor: pointer; visibility: visible !important; /* 强制设置可见性 */ color: #909399 !important; /* 设置默认颜色,确保可见 */ background-color: transparent; /* 透明背景 */ } .tab-close:hover { opacity: 1 !important; color: #ff4d4f !important; /* 悬停时显示红色 */ background-color: #fff0f0 !important; /* 浅红色背景 */ } /* 确保tab-item hover时关闭按钮也可见 */ .tab-item:hover .tab-close { opacity: 1 !important; visibility: visible !important; color: #606266 !important; /* hover时显示深灰色 */ } /* 激活状态的关闭按钮 */ .tab-item.active .tab-close { color: #606266 !important; /* 激活状态显示深灰色 */ } .tab-item.active .tab-close:hover { color: #ff4d4f !important; background-color: #fff0f0 !important; } ``` **关键点**: 1. **始终可见**:`opacity: 1 !important`确保关闭按钮始终可见,使用!important防止被其他样式覆盖 2. **强制显示**:`display: flex !important`和`visibility: visible !important`强制按钮显示 3. **固定尺寸**:`width`和`height`设置为固定值,确保按钮可点击 4. **防止压缩**:`flex-shrink: 0`防止按钮被标题挤压 5. **圆形背景**:`border-radius: 50%`创建圆形背景 6. **颜色设置**:`color: #909399 !important`设置默认颜色,确保按钮可见 7. **悬停效果**:鼠标悬停时显示浅红色背景和红色文字,提示用户可以点击 8. **标题宽度**:限制标题宽度,为关闭按钮留出足够空间 9. **hover状态**:添加`.tab-item:hover .tab-close`规则,确保鼠标悬停时按钮也可见 10. **激活状态**:`.tab-item.active .tab-close`设置激活状态的颜色 #### 下拉菜单按钮样式说明 **问题**:下拉菜单按钮(关闭全部等)看不见或不够明显 **解决方案**: ```css /* 标签页操作区域 */ .tabs-actions { margin-left: auto; display: flex; align-items: center; padding-left: 8px; /* 添加左边距,与标签页分隔 */ } .tabs-action-btn { display: flex !important; align-items: center; justify-content: center; width: 32px; /* 固定宽度 */ height: 32px; /* 固定高度 */ border-radius: 4px; cursor: pointer; transition: all 0.2s ease; color: #606266 !important; /* 设置深灰色,确保可见 */ background-color: transparent; /* 透明背景 */ opacity: 1 !important; /* 始终可见 */ visibility: visible !important; /* 强制设置可见性 */ } .tabs-action-btn:hover { background-color: #f5f7fa !important; /* 悬停时显示浅灰色背景 */ color: #1890ff !important; /* 悬停时显示蓝色 */ } ``` **关键点**: 1. **强制显示**:`display: flex !important`、`opacity: 1 !important`和`visibility: visible !important`强制按钮显示 2. **固定尺寸**:`width: 32px`和`height: 32px`设置固定尺寸,确保按钮可点击 3. **颜色设置**:`color: #606266 !important`设置深灰色,确保按钮可见 4. **透明背景**:`background-color: transparent`设置透明背景,不干扰标签页样式 5. **悬停效果**:鼠标悬停时显示浅灰色背景和蓝色文字,提示用户可以点击 6. **左边距**:`padding-left: 8px`添加左边距,与标签页分隔 7. **过渡动画**:`transition: all 0.2s ease`添加平滑的过渡动画 #### 关闭全部标签页逻辑说明 **问题**:关闭全部标签页时会连首页的tab也关闭掉 **解决方案**: ```javascript /** * 关闭全部标签页 */ closeAllTabs() { // 只关闭可关闭的标签页,保留首页 this.tabs = this.tabs.filter(tab => !tab.closable) // 如果当前标签页被关闭了,切换到首页 const currentTab = this.tabs.find(tab => tab.name === this.activeTab) if (!currentTab) { this.$router.push({ name: 'home.index' }) } } ``` **关键点**: 1. **过滤可关闭标签页**:使用`filter`方法只保留`closable: false`的标签页(即首页) 2. **保留首页**:首页的`closable`属性为`false`,不会被关闭 3. **检查当前标签页**:如果当前激活的标签页被关闭了,自动切换到首页 4. **路由跳转**:使用`$router.push`跳转到首页路由 ## 12. Element Plus图标使用最佳实践 ### 问题类型 - 图标不显示或显示为空白 - 使用`el-icon-*`类名无法正常工作 - 图标无法导航到CSS文件 - 图标组件报错 ### 根本原因 Element Plus 2.x版本的图标使用方式与Element UI 2.x完全不同: - **Element UI 2.x**:使用``类名方式 - **Element Plus 2.x**:使用独立的图标组件,需要单独引入和注册 ### 解决方案 #### 12.1 安装图标库 确保项目中已安装`@element-plus/icons-vue`包: ```bash npm install @element-plus/icons-vue ``` #### 12.2 在main.js中引入和注册所有图标 ```javascript import { createApp } from 'vue' import ElementPlus from 'element-plus' import 'element-plus/dist/index.css' import * as ElementPlusIconsVue from '@element-plus/icons-vue' import zhCn from 'element-plus/es/locale/lang/zh-cn' const app = createApp(App) app.use(ElementPlus, { locale: zhCn }) // 注册所有Element Plus图标 for (const [key, component] of Object.entries(ElementPlusIconsVue)) { app.component(key, component) } app.mount('#app') ``` #### 12.3 在组件中使用图标 ```vue ``` #### 12.4 常用图标对照表 | 功能 | Element UI 2.x | Element Plus 2.x | |------|----------------|------------------| | 关闭 | `` | `` | | 箭头向下 | `` | `` | | 箭头向上 | `` | `` | | 箭头向左 | `` | `` | | 箭头向右 | `` | `` | | 加载中 | `` | `` | | 搜索 | `` | `` | | 编辑 | `` | `` | | 删除 | `` | `` | | 加号 | `` | `` | | 减号 | `` | `` | | 信息 | `` | `` | | 成功 | `` | `` | | 警告 | `` | `` | | 错误 | `` | `` | ### 关键点说明 #### 12.5 图标引入方式 1. **全局注册**:在main.js中注册所有图标,可以在任何组件中直接使用 2. **局部引入**:在组件中按需引入需要的图标,减少打包体积 3. **推荐方式**:对于常用图标使用全局注册,对于不常用图标使用局部引入 #### 12.6 图标使用注意事项 1. **不要使用类名**:Element Plus 2.x不再支持`el-icon-*`类名方式 2. **必须使用组件**:必须使用``组件包裹图标组件 3. **图标组件大小写**:图标组件名称使用PascalCase,如`Close`、`ArrowDown` 4. **样式控制**:通过`class`属性控制图标样式,不要直接在组件上设置样式 #### 12.7 图标样式控制 ```css /* 图标基础样式 */ .el-icon { font-size: 16px; color: #606266; } /* 图标悬停效果 */ .el-icon:hover { color: #1890ff; cursor: pointer; } /* 图标禁用状态 */ .el-icon.disabled { color: #c0c4cc; cursor: not-allowed; } /* 图标旋转动画 */ .el-icon.rotating { animation: rotate 1s linear infinite; } @keyframes rotate { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } ``` #### 12.8 图标在标签页中的应用示例 ```vue ``` ### 注意事项 1. **版本兼容性**:Element Plus 2.x与Element UI 2.x的图标使用方式完全不同,不能混用 2. **图标包安装**:确保已安装`@element-plus/icons-vue`包 3. **全局注册**:在main.js中注册所有图标,避免在每个组件中重复引入 4. **组件引入**:在组件中按需引入需要的图标,减少打包体积 5. **样式控制**:通过`class`属性控制图标样式,不要直接在组件上设置样式 6. **图标名称**:图标组件名称使用PascalCase,如`Close`、`ArrowDown` 7. **el-icon包裹**:必须使用``组件包裹图标组件 8. **调试方法**:如果图标不显示,检查控制台是否有错误,确认图标是否正确引入和注册 9. **图标列表**:查看[Element Plus官方文档](https://element-plus.org/zh-CN/component/icon.html)获取完整的图标列表 10. **性能优化**:对于大型项目,建议使用按需引入的方式,减少打包体积 ### 常见错误及解决方案 #### 错误1:图标不显示 **错误代码**: ```vue ``` **正确代码**: ```vue ``` #### 错误2:图标组件未注册 **错误信息**: ``` Failed to resolve component: Close ``` **解决方案**: 1. 在main.js中全局注册图标 2. 或者在组件中局部引入图标 #### 错误3:图标样式不生效 **错误代码**: ```vue ``` **正确代码**: ```vue ``` ### 总结 1. **Element Plus 2.x**使用独立的图标组件,不再支持`el-icon-*`类名方式 2. **必须安装**`@element-plus/icons-vue`包 3. **必须注册**图标组件,可以在main.js中全局注册或在组件中局部引入 4. **必须使用**``组件包裹图标组件 5. **样式控制**通过`class`属性,不要直接在组件上设置样式 6. **图标名称**使用PascalCase,如`Close`、`ArrowDown` 7. **调试方法**:检查控制台错误,确认图标是否正确引入和注册 8. **性能优化**:对于大型项目,建议使用按需引入的方式 9. **官方文档**:查看[Element Plus官方文档](https://element-plus.org/zh-CN/component/icon.html)获取完整的图标列表和使用说明 10. **版本兼容性**:Element Plus 2.x与Element UI 2.x的图标使用方式完全不同,不能混用