SKILL.md 93 KB


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")
  • 前后端同时使用:两个注解同时使用确保兼容性
  • 前端显示格式化:解析字符串日期并格式化为所需格式

示例代码

@DateTimeFormat(pattern = "yyyy-MM-dd")
@JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
private Date planTimeStart;
// 前端日期解析和格式化
const parsedDate = new Date(dateString);
if (!isNaN(parsedDate.getTime())) {
  // 格式化为所需格式
  return `${year}-${month}-${day} ${hours}:${minutes}`;
}

2. 前端国际化配置

问题类型

  • 组件库默认语言为英文
  • 日期控件显示语言不符

解决方案

  • 在main.js中引入语言包
  • 配置Element Plus使用中文语言环境

示例代码

import zhCn from 'element-plus/es/locale/lang/zh-cn'

app.use(ElementPlus, {
  locale: zhCn
})

3. 搜索和分页功能最佳实践

问题类型

  • 搜索和列表功能分离
  • 大数据量性能问题
  • 用户体验不佳

解决方案

  • 合并搜索和列表功能,通过参数判断
  • 实现数据库分页,减少内存占用
  • 添加瀑布流加载,提升用户体验
  • 使用 COALESCE 函数处理空值判断

示例代码

@Query("SELECT new com.example.dto(...) " +
       "FROM ... " +
       "WHERE ... AND (COALESCE(:query, '') = '' OR ...) ")
Page<SearchResult> searchWithPage(@Param("query") String query, Pageable pageable);

4. 数据库读写分离和数据源配置

问题类型

  • 误解数据库架构
  • 错误的数据源配置
  • 混淆读写分离概念

解决方案

  • 明确区分两种数据库类型:
    • tugboatcommon:系统公共库,存储所有机构共享数据
    • liandatugboatmis:分支机构业务库,存储各机构业务数据
  • 使用有意义的命名而非READ/WRITE:
    • COMMON → tugboatcommon
    • BRANCH → liandatugboatmis
  • 只对同时存在于两库的表使用分离
  • 正确配置默认数据源

示例代码

public enum DataSourceType {
    COMMON("common"),      // tugboatcommon: 系统公共库
    BRANCH("branch");     // liandatugboatmis: 分支机构业务库
}

5. 前端组件优化

问题类型

  • 下拉选择器性能问题
  • 数据显示字段错误
  • 用户交互体验差

解决方案

  • 实现虚拟滚动或分页加载
  • 明确显示字段与值字段的区别
  • 添加加载状态提示
  • 实现滚动加载更多功能

示例代码

<el-select
  v-model="value"
  filterable
  remote
  popper-class="custom-popper"
  :remote-method="searchMethod">
  <div v-if="loadingMore" class="loading">加载中...</div>
  <el-option
    v-for="item in options"
    :key="item.id"
    :label="item.displayField"  <!-- 显示字段 -->
    :value="item.valueField">   <!-- 值字段 -->
  </el-option>
</el-select>

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状态(不要使用)

// 错误:多个操作共享同一个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状态(不要使用)

// 错误:多个初始化操作共享同一个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状态(推荐使用)

// 正确:初始化操作不需要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. 其他特定操作

  • 状态名称:根据操作类型命名(如resetPasswordLoadingexportLoading等)
  • 控制范围:特定操作的按钮
  • 使用场景:重置密码、导出数据、导入数据等

Loading状态使用原则总结

操作类型 是否需要loading 原因 示例
查询操作 用户主动触发,需要防止重复点击 查询按钮、分页切换、排序
初始化操作 页面加载时自动执行,用户不会重复触发 字典数据、下拉选项、基础数据
保存操作 用户主动触发,需要防止重复提交 新增、编辑、保存
删除操作 用户主动触发,需要防止重复删除 单条删除、批量删除
特定操作 用户主动触发,需要防止重复执行 重置密码、导出、导入

示例代码

7.1 基础Loading状态实现

<template>
  <div>
    <!-- 查询按钮 -->
    <el-button type="primary" @click="handleQuery" :loading="loading">查询</el-button>
    
    <!-- 保存按钮 -->
    <el-button type="primary" @click="handleSave" :loading="saveLoading">保存</el-button>
    
    <!-- 删除按钮 -->
    <el-button type="danger" @click="handleDelete" :loading="deleteLoading">删除</el-button>
  </div>
</template>

<script>
import { ref } from 'vue'
import { ElMessage } from 'element-plus'

export default {
  setup() {
    const loading = ref(false)           // 查询loading状态
    const saveLoading = ref(false)       // 保存loading状态
    const deleteLoading = ref(false)      // 删除loading状态
    
    const handleQuery = async () => {
      if (loading.value) return; // 防止重复点击
      
      loading.value = true;
      try {
        // 执行查询操作
        await this.queryData();
      } catch (error) {
        console.error('查询失败:', error);
        ElMessage.error('查询失败,请稍后重试');
      } finally {
        loading.value = false; // 确保无论成功失败都恢复状态
      }
    }
    
    const handleSave = async () => {
      if (saveLoading.value) return;
      
      saveLoading.value = true;
      try {
        // 执行保存操作
        await this.saveData();
        ElMessage.success('保存成功');
      } catch (error) {
        console.error('保存失败:', error);
        ElMessage.error('保存失败,请稍后重试');
      } finally {
        saveLoading.value = false;
      }
    }
    
    const handleDelete = async () => {
      if (deleteLoading.value) return;
      
      deleteLoading.value = true;
      try {
        // 执行删除操作
        await this.deleteData();
        ElMessage.success('删除成功');
      } catch (error) {
        console.error('删除失败:', error);
        ElMessage.error('删除失败,请稍后重试');
      } finally {
        deleteLoading.value = false;
      }
    }
    
    return {
      loading,
      saveLoading,
      deleteLoading,
      handleQuery,
      handleSave,
      handleDelete
    }
  }
}
</script>

7.2 表格Loading实现

<template>
  <div class="table-container">
    <!-- 表格使用v-loading显示加载状态 -->
    <el-table
      v-loading="loading"
      :data="tableData"
      stripe
      border
      style="width: 100%">
      <el-table-column prop="name" label="姓名"></el-table-column>
      <el-table-column prop="age" label="年龄"></el-table-column>
    </el-table>
    
    <!-- 分页控件 -->
    <el-pagination
      v-model:current-page="currentPage"
      v-model:page-size="pageSize"
      :total="total"
      @current-change="handlePageChange"
      @size-change="handleSizeChange">
    </el-pagination>
  </div>
</template>

<script>
import { ref } from 'vue'

export default {
  setup() {
    const loading = ref(false)
    const tableData = ref([])
    const currentPage = ref(1)
    const pageSize = ref(10)
    const total = ref(0)
    
    const getTableData = async () => {
      if (loading.value) return;
      
      loading.value = true;
      try {
        const response = await this.$axios.get('/api/data/list', {
          params: {
            page: currentPage.value,
            pageSize: pageSize.value
          }
        });
        tableData.value = response.data.content;
        total.value = response.data.totalElements;
      } catch (error) {
        console.error('获取数据失败:', error);
      } finally {
        loading.value = false;
      }
    }
    
    const handlePageChange = (page) => {
      currentPage.value = page;
      getTableData();
    }
    
    const handleSizeChange = (size) => {
      pageSize.value = size;
      currentPage.value = 1;
      getTableData();
    }
    
    return {
      loading,
      tableData,
      currentPage,
      pageSize,
      total,
      getTableData,
      handlePageChange,
      handleSizeChange
    }
  }
}
</script>

7.3 对话框Loading实现

<template>
  <el-dialog v-model="dialogVisible" title="编辑用户" width="600px">
    <el-form :model="userForm" :rules="userFormRules" ref="userFormRef">
      <el-form-item label="用户名" prop="name">
        <el-input v-model="userForm.name" placeholder="请输入用户名"></el-input>
      </el-form-item>
      <el-form-item label="账号" prop="loginId">
        <el-input v-model="userForm.loginId" placeholder="请输入账号"></el-input>
      </el-form-item>
    </el-form>
    <template #footer>
      <span class="dialog-footer">
        <el-button @click="dialogVisible = false">取消</el-button>
        <!-- 提交按钮绑定loading状态 -->
        <el-button type="primary" @click="confirmSave" :loading="saveLoading">提交</el-button>
      </span>
    </template>
  </el-dialog>
</template>

<script>
import { ref, reactive } from 'vue'
import { ElMessage } from 'element-plus'

export default {
  setup() {
    const dialogVisible = ref(false)
    const saveLoading = ref(false)
    const userFormRef = ref(null)
    
    const userForm = reactive({
      name: '',
      loginId: ''
    })
    
    const userFormRules = {
      name: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
      loginId: [{ required: true, message: '请输入账号', trigger: 'blur' }]
    }
    
    const confirmSave = async () => {
      if (!userFormRef.value) return;
      
      try {
        await userFormRef.value.validate();
      } catch (error) {
        console.error('表单验证失败:', error);
        return;
      }
      
      if (saveLoading.value) return;
      
      saveLoading.value = true;
      try {
        await this.$axios.post('/api/user/save', userForm);
        ElMessage.success('保存成功');
        dialogVisible.value = false;
        // 刷新列表
        this.getTableData();
      } catch (error) {
        console.error('保存失败:', error);
        ElMessage.error('保存失败,请稍后重试');
      } finally {
        saveLoading.value = false;
      }
    }
    
    return {
      dialogVisible,
      saveLoading,
      userFormRef,
      userForm,
      userFormRules,
      confirmSave
    }
  }
}
</script>

7.4 完整的用户管理页面Loading实现

<template>
  <div class="user-management">
    <!-- 查询条件 -->
    <div class="search-section">
      <div class="search-row">
        <el-input v-model="queryForm.name" placeholder="请输入用户名" clearable></el-input>
        <el-input v-model="queryForm.loginId" placeholder="请输入账号" clearable></el-input>
        <!-- 查询和重置按钮绑定loading状态 -->
        <el-button type="primary" @click="getUsers" :loading="loading">查询</el-button>
        <el-button @click="resetSearchForm" :loading="loading">重置</el-button>
      </div>
    </div>
    
    <!-- 操作按钮 -->
    <div class="action-section">
      <el-button type="primary" @click="openAddDialog">新增</el-button>
      <!-- 删除按钮绑定loading状态 -->
      <el-button type="danger" @click="deleteSelectedUsers" :loading="deleteLoading">删除</el-button>
    </div>
    
    <!-- 表格使用v-loading显示加载状态 -->
    <el-table
      v-loading="loading"
      :data="userList"
      stripe
      border
      @selection-change="handleSelectionChange">
      <el-table-column type="selection" width="55"></el-table-column>
      <el-table-column prop="name" label="用户名"></el-table-column>
      <el-table-column prop="loginId" label="账号"></el-table-column>
      <el-table-column label="操作" width="180">
        <template #default="{ row }">
          <el-button type="primary" size="small" @click="openEditDialog(row)">编辑</el-button>
          <el-button type="danger" size="small" @click="deleteUser(row.id)">删除</el-button>
        </template>
      </el-table-column>
    </el-table>
    
    <!-- 编辑对话框 -->
    <el-dialog v-model="userDialogVisible" title="编辑用户" width="600px">
      <el-form :model="userForm" :rules="userFormRules" ref="userFormRef">
        <el-form-item label="用户名" prop="name">
          <el-input v-model="userForm.name"></el-input>
        </el-form-item>
        <el-form-item label="账号" prop="loginId">
          <el-input v-model="userForm.loginId"></el-input>
        </el-form-item>
      </el-form>
      <template #footer>
        <span class="dialog-footer">
          <el-button @click="userDialogVisible = false">取消</el-button>
          <!-- 提交按钮绑定loading状态 -->
          <el-button type="primary" @click="confirmUser" :loading="saveLoading">提交</el-button>
        </span>
      </template>
    </el-dialog>
  </div>
</template>

<script>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'

export default {
  setup() {
    const loading = ref(false)           // 查询loading状态
    const saveLoading = ref(false)       // 保存loading状态
    const deleteLoading = ref(false)      // 删除loading状态
    
    const queryForm = reactive({
      name: '',
      loginId: ''
    })
    
    const userForm = reactive({
      id: '',
      name: '',
      loginId: ''
    })
    
    const userList = ref([])
    const employeeList = ref([])
    const recordStatusList = ref([])
    const selectedUserIds = ref([])
    const userDialogVisible = ref(false)
    const userFormRef = ref(null)
    
    const userFormRules = {
      name: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
      loginId: [{ required: true, message: '请输入账号', trigger: 'blur' }]
    }
    
    const getUsers = async () => {
      if (loading.value) return;
      
      loading.value = true;
      try {
        const response = await this.$axios.get('/api/user/list', {
          params: queryForm
        });
        userList.value = response.data.content;
      } catch (error) {
        console.error('获取用户列表失败:', error);
        ElMessage.error('获取用户列表失败');
      } 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);
        ElMessage.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);
        ElMessage.error('获取员工列表失败');
      }
    }
    
    const confirmUser = async () => {
      if (!userFormRef.value) return;
      
      try {
        await userFormRef.value.validate();
      } catch (error) {
        console.error('表单验证失败:', error);
        return;
      }
      
      if (saveLoading.value) return;
      
      saveLoading.value = true;
      try {
        await this.$axios.post('/api/user/save', userForm);
        ElMessage.success('保存成功');
        userDialogVisible.value = false;
        getUsers();
      } catch (error) {
        console.error('保存失败:', error);
        ElMessage.error('保存失败');
      } finally {
        saveLoading.value = false;
      }
    }
    
    const deleteSelectedUsers = async () => {
      if (selectedUserIds.value.length === 0) return;
      
      if (deleteLoading.value) return;
      
      deleteLoading.value = true;
      try {
        await this.$axios.delete('/api/user/delete', {
          data: selectedUserIds.value
        });
        ElMessage.success('删除成功');
        selectedUserIds.value = [];
        getUsers();
      } catch (error) {
        console.error('删除失败:', error);
        ElMessage.error('删除失败');
      } finally {
        deleteLoading.value = false;
      }
    }
    
    const deleteUser = async (userId) => {
      if (deleteLoading.value) return;
      
      deleteLoading.value = true;
      try {
        await this.$axios.delete('/api/user/delete', {
          data: [userId]
        });
        ElMessage.success('删除成功');
        getUsers();
      } catch (error) {
        console.error('删除失败:', error);
        ElMessage.error('删除失败');
      } finally {
        deleteLoading.value = false;
      }
    }
    
    const handleSelectionChange = (selection) => {
      selectedUserIds.value = selection.map(row => row.id);
    }
    
    const resetSearchForm = () => {
      queryForm.name = '';
      queryForm.loginId = '';
      getUsers();
    }
    
    const openAddDialog = () => {
      userForm.id = '';
      userForm.name = '';
      userForm.loginId = '';
      userDialogVisible.value = true;
    }
    
    const openEditDialog = (row) => {
      userForm.id = row.id;
      userForm.name = row.name;
      userForm.loginId = row.loginId;
      userDialogVisible.value = true;
    }
    
    onMounted(() => {
      getRecordStatusList();  // 加载状态列表
      getEmployeeList();     // 加载员工列表
      getUsers();            // 加载用户列表
    })
    
    return {
      loading,
      saveLoading,
      deleteLoading,
      queryForm,
      userForm,
      userFormRules,
      userList,
      employeeList,
      recordStatusList,
      selectedUserIds,
      userDialogVisible,
      userFormRef,
      getUsers,
      getRecordStatusList,
      getEmployeeList,
      confirmUser,
      deleteSelectedUsers,
      deleteUser,
      handleSelectionChange,
      resetSearchForm,
      openAddDialog,
      openEditDialog
    }
  }
}
</script>

关键点说明

  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
     <el-select v-model="userForm.employeeId" placeholder="请选择关联用户">
       <el-option 
         v-for="employee in employeeList" 
         :key="employee.employeeId" 
         :label="employee.name" 
         :value="employee.employeeId">
       </el-option>
     </el-select>
    

使用时机

在开发新页面功能、调试常见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 查询条件布局(按钮并排版本)

<template>
  <div class="search-section">
    <h3>查询条件</h3>
    <el-form :inline="true" :model="searchForm" class="demo-form-inline">
      <!-- 第一行(3个控件 + 按钮) -->
      <template v-if="showAllSearch || searchFormIndex < 3">
        <el-form-item label="作业时间">
          <el-date-picker
            v-model="searchForm.planTimeRange"
            type="daterange"
            range-separator="至"
            start-placeholder="开始日期"
            end-placeholder="结束日期"
            style="width: 240px"
          ></el-date-picker>
        </el-form-item>
        <el-form-item label="港区作业类型">
          <el-select
            v-model="searchForm.portAreaOperationType"
            placeholder="请选择"
            style="width: 180px"
          >
            <el-option label="全部" value=""></el-option>
            <el-option label="本港计划" value="本港计划"></el-option>
            <el-option label="外港计划" value="外港计划"></el-option>
          </el-select>
        </el-form-item>
        <el-form-item label="港口">
          <el-select
            v-model="searchForm.portId"
            placeholder="请选择"
            style="width: 180px"
            clearable
          >
            <el-option
              v-for="port in portList"
              :key="port.portId"
              :label="port.portName"
              :value="port.portId">
            </el-option>
          </el-select>
        </el-form-item>
        <div class="search-actions">
          <el-button 
            v-if="hasExtraRows" 
            type="primary" 
            @click="toggleSearchForm">
            {{ showAllSearch ? '收起' : '展开' }}
          </el-button>
          <el-button type="primary" @click="getPilotPlans">查询</el-button>
          <el-button @click="resetSearchForm">重置</el-button>
        </div>
      </template>
      
      <!-- 折叠的控件 -->
      <template v-if="showAllSearch">
        <el-form-item label="英文船名">
          <el-input
            v-model="searchForm.englishShipName"
            placeholder="请输入英文船名"
            style="width: 180px"
            clearable
          ></el-input>
        </el-form-item>
        <el-form-item label="状态">
          <el-select
            v-model="searchForm.status"
            placeholder="请选择"
            style="width: 180px"
          >
            <el-option label="全部" value=""></el-option>
            <el-option label="未调度" value="未调度"></el-option>
            <el-option label="已调度" value="已调度"></el-option>
          </el-select>
        </el-form-item>
        <el-form-item label="船公司">
          <el-select
            v-model="searchForm.shipownerId"
            placeholder="请选择"
            style="width: 180px"
            clearable
          >
            <el-option
              v-for="company in shipCompanyList"
              :key="company.companyId"
              :label="company.companyName"
              :value="company.companyId">
            </el-option>
          </el-select>
        </el-form-item>
        <el-form-item label="吃水">
          <el-input-group>
            <el-input
              v-model="searchForm.deepStart"
              placeholder="最小值"
              style="width: 80px"
              type="number"
            ></el-input>
            <span style="padding: 0 5px">-</span>
            <el-input
              v-model="searchForm.deepEnd"
              placeholder="最大值"
              style="width: 80px"
              type="number"
            ></el-input>
          </el-input-group>
        </el-form-item>
        <el-form-item label="交通船">
          <el-input
            v-model="searchForm.trafficboat"
            placeholder="请输入交通船"
            style="width: 180px"
            clearable
          ></el-input>
        </el-form-item>
      </template>
    </el-form>
  </div>
</template>

<script>
export default {
  data() {
    return {
      showAllSearch: false, // 控制是否显示所有查询条件
      searchForm: {
        planTimeRange: [],
        portAreaOperationType: '',
        portId: '',
        englishShipName: '',
        status: '',
        shipownerId: '',
        deepStart: null,
        deepEnd: null,
        trafficboat: ''
      }
    }
  },
  computed: {
    hasExtraRows() {
      return true; // 根据实际情况判断是否有额外的查询条件行
    }
  },
  methods: {
    toggleSearchForm() {
      this.showAllSearch = !this.showAllSearch;
    },
    getPilotPlans() {
      // 查询逻辑
    },
    resetSearchForm() {
      // 重置逻辑
    }
  }
}
</script>

<style scoped>
.search-section {
  background-color: white;
  padding: 15px;
  margin-bottom: 15px;
  border-radius: 4px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}

.search-section h3 {
  color: #606266;
  margin-bottom: 12px;
  font-size: 15px;
}

.demo-form-inline {
  display: flex;
  flex-direction: column;
  gap: 12px;
}

.search-actions {
  display: flex;
  gap: 8px;
  align-items: center;
}
</style>

9.2 表格列头排序

<template>
  <el-table
    :data="tableData"
    style="width: 100%"
    @sort-change="handleSortChange">
    <el-table-column
      prop="index"
      label="序号"
      width="50"
      fixed
      type="index"
      align="center">
    </el-table-column>
    <el-table-column
      prop="planTime"
      label="计划时间"
      width="160"
      sortable
      align="center">
      <template #default="scope">
        {{ formatDateTime(scope.row.planTime) }}
      </template>
    </el-table-column>
    <el-table-column
      prop="cnShipName"
      label="中文船名"
      min-width="120"
      sortable
      show-overflow-tooltip>
    </el-table-column>
    <el-table-column
      prop="enShipName"
      label="英文船名"
      min-width="120"
      sortable
      show-overflow-tooltip>
    </el-table-column>
    <el-table-column
      prop="companyName"
      label="船公司"
      min-width="150"
      sortable
      show-overflow-tooltip>
    </el-table-column>
    <el-table-column
      prop="nation"
      label="国籍"
      width="100"
      sortable
      align="center">
    </el-table-column>
    <el-table-column
      prop="shipLength"
      label="船长"
      width="80"
      sortable
      align="center">
    </el-table-column>
    <el-table-column
      prop="deep"
      label="吃水"
      width="80"
      sortable
      align="center">
    </el-table-column>
    <el-table-column
      prop="pilotType"
      label="动态"
      width="100"
      sortable
      align="center">
    </el-table-column>
    <el-table-column
      prop="fromBerthageName"
      label="起点泊位"
      min-width="100"
      sortable
      show-overflow-tooltip>
    </el-table-column>
    <el-table-column
      prop="toBerthageName"
      label="终点泊位"
      min-width="100"
      sortable
      show-overflow-tooltip>
    </el-table-column>
    <el-table-column
      prop="mainPilotName"
      label="主引"
      width="80"
      sortable
      align="center">
    </el-table-column>
    <el-table-column
      prop="trafficBoat"
      label="交通船"
      width="100"
      sortable
      show-overflow-tooltip>
    </el-table-column>
    <el-table-column
      prop="agencyName"
      label="代理"
      min-width="150"
      sortable
      show-overflow-tooltip>
    </el-table-column>
    <el-table-column
      prop="portAreaOperationType"
      label="港区作业类型"
      width="120"
      sortable
      align="center">
    </el-table-column>
    <el-table-column
      prop="waterwayName"
      label="航道"
      min-width="100"
      sortable
      show-overflow-tooltip>
    </el-table-column>
    <el-table-column
      prop="status"
      label="状态"
      width="80"
      sortable
      align="center">
    </el-table-column>
    <el-table-column
      label="操作"
      width="100"
      fixed="right"
      align="center">
      <template #default="scope">
        <el-button
          type="text"
          size="small"
          @click="handleView(scope.row)">
          查看
        </el-button>
      </template>
    </el-table-column>
  </el-table>
</template>

<script>
export default {
  data() {
    return {
      tableData: [],
      sortMap: {} // 存储排序字段和顺序
    }
  },
  methods: {
    handleSortChange({ prop, order }) {
      if (prop) {
        this.sortMap = {
          field: this.sortFieldMap[prop] || prop,
          order: order === 'ascending' ? 'ASC' : 'DESC'
        };
      } else {
        this.sortMap = {};
      }
      this.getPilotPlans();
    }
  }
}
</script>

<style>
/* 表格样式优化 */
.el-table {
  font-size: 12px;
}

.el-table th.el-table__cell {
  background-color: #f5f7fa;
  color: #303133;
  font-weight: 600;
}

.el-table .el-table__cell {
  padding: 8px 0;
}

/* 固定列样式 */
.el-table .fixed-column {
  position: sticky;
  background-color: #fff;
  z-index: 2;
}

/* 表头排序箭头样式 */
.el-table .el-table__header th.el-table__cell > .el-table__cell.sortable {
  cursor: pointer;
}

.el-table .el-table__header th.el-table__cell > .el-table__cell.sortable:hover {
  background-color: #e6e6e6;
}

/* 内容截断样式 */
.el-table .cell {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
</style>

9.3 完整的表格配置

<template>
  <div class="table-container">
    <el-table
      :data="tableData"
      style="width: 100%"
      size="default"
      stripe
      border
      :header-cell-style="{ background: '#f5f7fa', color: '#303133', fontWeight: 'bold' }"
      @sort-change="handleSortChange"
      max-height="600">
      
      <!-- 序号列 - 固定在左侧 -->
      <el-table-column
        type="index"
        label="序号"
        width="50"
        fixed
        align="center">
      </el-table-column>
      
      <!-- 数据列 -->
      <el-table-column
        prop="planTime"
        label="计划时间"
        width="160"
        sortable
        align="center"
        show-overflow-tooltip>
        <template #default="scope">
          {{ formatDateTime(scope.row.planTime) }}
        </template>
      </el-table-column>
      
      <el-table-column
        prop="cnShipName"
        label="中文船名"
        min-width="120"
        sortable
        show-overflow-tooltip>
      </el-table-column>
      
      <el-table-column
        prop="enShipName"
        label="英文船名"
        min-width="120"
        sortable
        show-overflow-tooltip>
      </el-table-column>
      
      <el-table-column
        prop="companyName"
        label="船公司"
        min-width="150"
        sortable
        show-overflow-tooltip>
      </el-table-column>
      
      <el-table-column
        prop="nation"
        label="国籍"
        width="100"
        sortable
        align="center">
      </el-table-column>
      
      <el-table-column
        prop="shipLength"
        label="船长"
        width="80"
        sortable
        align="center">
      </el-table-column>
      
      <el-table-column
        prop="deep"
        label="吃水"
        width="80"
        sortable
        align="center">
      </el-table-column>
      
      <el-table-column
        prop="pilotType"
        label="动态"
        width="100"
        sortable
        align="center">
      </el-table-column>
      
      <el-table-column
        prop="fromBerthageName"
        label="起点泊位"
        min-width="100"
        sortable
        show-overflow-tooltip>
      </el-table-column>
      
      <el-table-column
        prop="toBerthageName"
        label="终点泊位"
        min-width="100"
        sortable
        show-overflow-tooltip>
      </el-table-column>
      
      <el-table-column
        prop="mainPilotName"
        label="主引"
        width="80"
        sortable
        align="center">
      </el-table-column>
      
      <el-table-column
        prop="trafficBoat"
        label="交通船"
        width="100"
        sortable
        show-overflow-tooltip>
      </el-table-column>
      
      <el-table-column
        prop="agencyName"
        label="代理"
        min-width="150"
        sortable
        show-overflow-tooltip>
      </el-table-column>
      
      <el-table-column
        prop="portAreaOperationType"
        label="港区作业类型"
        width="120"
        sortable
        align="center">
      </el-table-column>
      
      <el-table-column
        prop="waterwayName"
        label="航道"
        min-width="100"
        sortable
        show-overflow-tooltip>
      </el-table-column>
      
      <el-table-column
        prop="status"
        label="状态"
        width="80"
        sortable
        align="center">
      </el-table-column>
      
      <!-- 操作列 - 固定在右侧 -->
      <el-table-column
        label="操作"
        width="100"
        fixed="right"
        align="center">
        <template #default="scope">
          <el-button
            type="text"
            size="small"
            @click="handleView(scope.row)">
            查看
          </el-button>
        </template>
      </el-table-column>
    </el-table>
    
    <!-- 分页 -->
    <el-pagination
      v-model:current-page="pagination.page"
      v-model:page-size="pagination.pageSize"
      :total="pagination.total"
      :page-sizes="[10, 20, 50, 100]"
      layout="total, sizes, prev, pager, next, jumper"
      @size-change="handleSizeChange"
      @current-change="handlePageChange"
      style="margin-top: 15px">
    </el-pagination>
  </div>
</template>

<style scoped>
.table-container {
  max-width: 100%;
  overflow: auto;
}

/* 表格样式优化 */
:deep(.el-table) {
  font-size: 12px;
}

:deep(.el-table th.el-table__cell) {
  background-color: #f5f7fa;
  color: #303133;
  font-weight: 600;
}

:deep(.el-table .el-table__cell) {
  padding: 8px 0;
}

/* 内容截断样式 */
:deep(.el-table .cell) {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* 固定列背景色 */
:deep(.el-table .fixed-column) {
  background-color: #fff;
  z-index: 2;
}

/* 表头排序箭头样式 */
:deep(.el-table .el-table__header th.el-table__cell > .el-table__cell.sortable) {
  cursor: pointer;
}

:deep(.el-table .el-table__header th.el-table__cell > .el-table__cell.sortable:hover) {
  background-color: #e6e6e6;
}
</style>

9.4 后端Service实现(数据库级分页和排序)

@Service
public class PilotPlanService {
    
    @Autowired
    private DispPilotPlanReadRepository dispPilotPlanReadRepository;
    
    /**
     * 获取引航计划列表(支持数据库级分页和排序)
     */
    @DataSource(value = RoutingDataSourceConfig.DataSourceType.BRANCH)
    public List<PilotPlanDTO> 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<PilotPlanProjection> 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<PilotPlanProjection> projections = result.getContent();
        
        // 转换为DTO并返回
        List<PilotPlanDTO> 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层实现(返回分页响应)

@RestController
@RequestMapping("/pilot-plan")
public class PilotPlanController {
    
    @Autowired
    private PilotPlanService pilotPlanService;
    
    /**
     * 获取引航计划列表(支持分页和排序)
     */
    @GetMapping("/list")
    public ResponseEntity<PageResponse<PilotPlanDTO>> getPilotPlans(PilotPlanQueryDTO queryDTO) {
        PageResponse<PilotPlanDTO> pilotPlans = pilotPlanService.getPilotPlans(queryDTO);
        return new ResponseEntity<>(pilotPlans, HttpStatus.OK);
    }
}

9.6 前端API调用(处理分页响应)

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 自定义序号列实现

<template>
  <el-table
    :data="tableData"
    style="width: 100%"
    size="default"
    stripe
    border
    :header-cell-style="{ background: '#f5f7fa', color: '#303133', fontWeight: 'bold' }"
    @sort-change="handleSortChange">
    
    <!-- 自定义序号列 - 固定在左侧 -->
    <el-table-column
      label="序号"
      width="60"
      fixed
      align="center">
      <template #default="{ $index }">
        {{ (currentPage - 1) * pageSize + $index + 1 }}
      </template>
    </el-table-column>
    
    <!-- 数据列 -->
    <el-table-column
      prop="planTime"
      label="计划时间"
      width="160"
      sortable
      align="center">
      <template #default="scope">
        {{ formatDateTime(scope.row.planTime) }}
      </template>
    </el-table-column>
    
    <!-- 其他数据列... -->
    
  </el-table>
  
  <!-- 分页控件 -->
  <el-pagination
    v-model:current-page="currentPage"
    v-model:page-size="pageSize"
    :page-sizes="[10, 20, 50, 100]"
    :total="total"
    layout="total, sizes, prev, pager, next, jumper"
    @current-change="handlePageChange"
    @size-change="handleSizeChange">
  </el-pagination>
</template>

<script>
export default {
  data() {
    return {
      tableData: [],
      currentPage: 1,      // 当前页码,从1开始
      pageSize: 10,        // 每页显示的记录数
      total: 0             // 总记录数
    }
  },
  methods: {
    handlePageChange(page) {
      this.currentPage = page;
      this.getPilotPlans();
    },
    handleSizeChange(size) {
      this.pageSize = size;
      this.currentPage = 1;  // 改变每页大小时重置到第一页
      this.getPilotPlans();
    },
    async getPilotPlans() {
      const response = await this.$axios.get('/api/pilot-plan/list', {
        params: {
          page: this.currentPage,
          pageSize: this.pageSize
        }
      });
      this.tableData = response.data.content;
      this.total = response.data.totalElements;
    }
  }
}
</script>

9.7.2 错误实现示例(不要使用)

<!-- 错误:使用type="index"会导致每页序号都从1开始 -->
<el-table-column
  type="index"
  label="序号"
  width="60"
  fixed
  align="center">
</el-table-column>

9.7.3 正确实现示例(推荐)

<!-- 正确:使用自定义模板计算连续序号 -->
<el-table-column
  label="序号"
  width="60"
  fixed
  align="center">
  <template #default="{ $index }">
    {{ (currentPage - 1) * pageSize + $index + 1 }}
  </template>
</el-table-column>

关键点说明

  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刷新回调

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)

// 权限指令,用于控制按钮显示
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组件中使用权限指令

<template>
  <div>
    <!-- 查询按钮 -->
    <el-button type="primary" @click="getPilotPlans" v-permission="'pilotplan:query'">查询</el-button>
    
    <!-- 重置按钮 -->
    <el-button @click="resetSearchForm" v-permission="'pilotplan:reset'">重置</el-button>
    
    <!-- 导入按钮 -->
    <el-button type="primary" @click="openImportDialog" v-permission="'pilotplan:import'">导入引航计划</el-button>
    
    <!-- 导出按钮 -->
    <el-button type="success" @click="exportPilotPlans" v-permission="'pilotplan:export'">导出Excel</el-button>
    
    <!-- 导入对话框中的按钮 -->
    <el-button @click="importDialogVisible = false" v-permission="'pilotplan:import:cancel'">取消</el-button>
    <el-button type="primary" @click="parseImportData" v-permission="'pilotplan:import:parse'">解析数据</el-button>
    
    <!-- 预览对话框中的按钮 -->
    <el-button @click="previewDialogVisible = false" v-permission="'pilotplan:import:preview:cancel'">取消</el-button>
    <el-button type="primary" @click="confirmImport" v-permission="'pilotplan:import:preview:confirm'">
      确认导入
    </el-button>
  </div>
</template>

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组件,包含以下功能:

  • 标签页头部:显示所有打开的标签页
  • 标签页内容:使用<router-view>显示当前标签页内容
  • 标签页操作:支持关闭当前、关闭其他、关闭全部

11.2 修改主页组件

HomePage.vue中引入并使用TabsView组件:

<template>
  <div class="home-page">
    <!-- 顶部导航栏 -->
    <nav class="navbar">...</nav>
    
    <!-- 侧边栏 -->
    <div class="leftsidebar">...</div>
    
    <!-- 主内容区域 -->
    <section class="content">
      <div class="container-fluid">
        <tabs-view></tabs-view>
      </div>
    </section>
  </div>
</template>

<script>
import TabsView from './TabsView.vue'

export default {
  name: 'HomePage',
  components: {
    TabsView
  }
}
</script>

11.3 标签页组件实现

<template>
  <div class="tabs-container">
    <!-- 标签页头部 -->
    <div class="tabs-header">
      <div 
        v-for="tab in tabs" 
        :key="tab.name"
        :class="['tab-item', { 'active': activeTab === tab.name }]"
        @click="switchTab(tab)"
      >
        <span class="tab-title">{{ tab.title }}</span>
        <i 
          v-if="tab.closable" 
          class="el-icon-close tab-close"
          @click.stop="closeTab(tab)"
        ></i>
      </div>
      <div class="tabs-actions">
        <el-dropdown @command="handleTabAction">
          <span class="tabs-action-btn">
            <i class="el-icon-arrow-down"></i>
          </span>
          <template #dropdown>
            <el-dropdown-menu>
              <el-dropdown-item command="closeCurrent">关闭当前</el-dropdown-item>
              <el-dropdown-item command="closeOthers">关闭其他</el-dropdown-item>
              <el-dropdown-item command="closeAll">关闭全部</el-dropdown-item>
            </el-dropdown-menu>
          </template>
        </el-dropdown>
      </div>
    </div>
    
    <!-- 标签页内容 -->
    <div class="tabs-content">
      <router-view v-slot="{ Component, route }">
        <keep-alive :include="cachedViews">
          <component :is="Component" :key="route.name" />
        </keep-alive>
      </router-view>
    </div>
  </div>
</template>

<script>
export default {
  name: 'TabsView',
  data() {
    return {
      tabs: [],
      activeTab: ''
    }
  },
  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)
    },
    /**
     * 计算可关闭的标签页数量
     */
    closableTabsCount() {
      return this.tabs.filter(tab => tab.closable).length
    }
  },
  mounted() {
    this.ensureHomeTab()
  },
  watch: {
    $route: {
      handler(to) {
        this.addTab(to)
      },
      immediate: true
    }
  },
  methods: {
    /**
     * 确保首页标签页始终存在
     */
    ensureHomeTab() {
      const homeTab = this.tabs.find(tab => tab.name === 'home.index')
      if (!homeTab) {
        this.tabs.unshift({
          name: 'home.index',
          title: '首页',
          closable: false
        })
      }
    },
    
    /**
     * 添加标签页
     */
    addTab(route) {
      if (!route.name) return
      
      // 确保首页标签页始终存在
      this.ensureHomeTab()
      
      // 如果是首页,直接激活即可
      if (route.name === 'home.index') {
        this.activeTab = 'home.index'
        return
      }
      
      const tabTitle = this.getTabTitle(route)
      const existingTab = this.tabs.find(tab => tab.name === route.name)
      
      if (!existingTab) {
        // 在首页标签页之后插入新标签页
        const homeTabIndex = this.tabs.findIndex(tab => tab.name === 'home.index')
        const insertIndex = homeTabIndex + 1
        
        this.tabs.splice(insertIndex, 0, {
          name: route.name,
          title: tabTitle,
          closable: true
        })
      }
      
      this.activeTab = route.name
    },
    
    /**
     * 获取标签页标题
     */
    getTabTitle(route) {
      const titleMap = {
        'home.index': '首页',
        'home.mynotice': '我的通知',
        'home.settings_announcement': '公告管理',
        'home.settings_role': '角色管理',
        'home.settings_user': '用户管理',
        'home.pilot_plan': '引航计划'
      }
      
      return titleMap[route.name] || route.name
    },
    
    /**
     * 切换标签页
     */
    switchTab(tab) {
      if (tab.name !== this.activeTab) {
        this.$router.push({ name: tab.name })
      }
    },
    
    /**
     * 关闭标签页
     */
    closeTab(tab) {
      const index = this.tabs.findIndex(t => t.name === tab.name)
      if (index === -1) return
      
      this.tabs.splice(index, 1)
      
      // 如果关闭的是当前激活的标签页,切换到其他标签页
      if (tab.name === this.activeTab) {
        if (this.tabs.length > 0) {
          const newIndex = Math.min(index, this.tabs.length - 1)
          this.switchTab(this.tabs[newIndex])
        } else {
          this.$router.push({ name: 'home.index' })
        }
      }
    },
    
    /**
     * 处理标签页操作
     */
    handleTabAction(command) {
      switch (command) {
        case 'closeCurrent':
          this.closeCurrentTab()
          break
        case 'closeOthers':
          this.closeOtherTabs()
          break
        case 'closeAll':
          this.closeAllTabs()
          break
      }
    },
    
    /**
     * 关闭当前标签页
     */
    closeCurrentTab() {
      const currentTab = this.tabs.find(tab => tab.name === this.activeTab)
      if (currentTab && currentTab.closable) {
        this.closeTab(currentTab)
      }
    },
    
    /**
     * 关闭其他标签页
     */
    closeOtherTabs() {
      const currentTab = this.tabs.find(tab => tab.name === this.activeTab)
      if (currentTab) {
        this.tabs = [currentTab]
      }
    },
    
    /**
     * 关闭全部标签页
     */
    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' })
      }
    }
  }
}
</script>

关键点说明

  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

实现方式

<!-- 标签页内容 -->
<div class="tabs-content">
  <router-view v-slot="{ Component, route }">
    <keep-alive :include="cachedViews">
      <component :is="Component" :key="route.name" />
    </keep-alive>
  </router-view>
</div>
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-aliveinclude属性需要的是组件的name选项,而不是路由名称。因此需要建立路由名称到组件名称的映射关系。

映射表

路由名称 组件名称 说明
home.index MainIndex 首页
home.mynotice MyNotice 我的通知
home.settings_announcement AnnouncementManagement 公告管理
home.settings_role RoleManagement 角色管理
home.settings_user UserManagement 用户管理
home.pilot_plan PilotPlan 引航计划

动态映射

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选项:

export default {
  name: 'UserManagement',  // 必须定义name
  // ...
}

所有组件的name定义

// 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进行全局状态管理

样式建议

/* 标签页容器 */
.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: 120pxflex-shrink: 0,确保关闭按钮有足够空间
  6. 状态管理:使用keep-alive组件保留表单状态
  7. 性能优化:标签页过多时,考虑限制最大标签页数量或使用虚拟滚动
  8. 权限控制:根据用户权限控制标签页的显示和关闭功能
  9. 关闭全部标签页:关闭全部时只关闭可关闭的标签页,保留首页标签页

关闭按钮样式说明

问题:关闭按钮不显示或不够明显

解决方案

/* 标签页标题 */
.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 !importantvisibility: visible !important强制按钮显示
  3. 固定尺寸widthheight设置为固定值,确保按钮可点击
  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设置激活状态的颜色

下拉菜单按钮样式说明

问题:下拉菜单按钮(关闭全部等)看不见或不够明显

解决方案

/* 标签页操作区域 */
.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 !importantopacity: 1 !importantvisibility: visible !important强制按钮显示
  2. 固定尺寸width: 32pxheight: 32px设置固定尺寸,确保按钮可点击
  3. 颜色设置color: #606266 !important设置深灰色,确保按钮可见
  4. 透明背景background-color: transparent设置透明背景,不干扰标签页样式
  5. 悬停效果:鼠标悬停时显示浅灰色背景和蓝色文字,提示用户可以点击
  6. 左边距padding-left: 8px添加左边距,与标签页分隔
  7. 过渡动画transition: all 0.2s ease添加平滑的过渡动画

关闭全部标签页逻辑说明

问题:关闭全部标签页时会连首页的tab也关闭掉

解决方案

/**
 * 关闭全部标签页
 */
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:使用<i class="el-icon-close"></i>类名方式
  • Element Plus 2.x:使用独立的图标组件,需要单独引入和注册

解决方案

12.1 安装图标库

确保项目中已安装@element-plus/icons-vue包:

npm install @element-plus/icons-vue

12.2 在main.js中引入和注册所有图标

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 在组件中使用图标

<template>
  <div>
    <!-- 方式1:使用el-icon组件包裹图标组件 -->
    <el-icon class="icon-class">
      <Close />
    </el-icon>
    
    <!-- 方式2:直接使用图标组件 -->
    <ArrowDown />
    
    <!-- 方式3:在按钮中使用 -->
    <el-button @click="handleClose">
      <el-icon><Close /></el-icon>
      关闭
    </el-button>
  </div>
</template>

<script>
import { Close, ArrowDown } from '@element-plus/icons-vue'

export default {
  name: 'MyComponent',
  components: {
    Close,
    ArrowDown
  }
}
</script>

12.4 常用图标对照表

功能 Element UI 2.x Element Plus 2.x
关闭 <i class="el-icon-close"></i> <el-icon><Close /></el-icon>
箭头向下 <i class="el-icon-arrow-down"></i> <el-icon><ArrowDown /></el-icon>
箭头向上 <i class="el-icon-arrow-up"></i> <el-icon><ArrowUp /></el-icon>
箭头向左 <i class="el-icon-arrow-left"></i> <el-icon><ArrowLeft /></el-icon>
箭头向右 <i class="el-icon-arrow-right"></i> <el-icon><ArrowRight /></el-icon>
加载中 <i class="el-icon-loading"></i> <el-icon><Loading /></el-icon>
搜索 <i class="el-icon-search"></i> <el-icon><Search /></el-icon>
编辑 <i class="el-icon-edit"></i> <el-icon><Edit /></el-icon>
删除 <i class="el-icon-delete"></i> <el-icon><Delete /></el-icon>
加号 <i class="el-icon-plus"></i> <el-icon><Plus /></el-icon>
减号 <i class="el-icon-minus"></i> <el-icon><Minus /></el-icon>
信息 <i class="el-icon-info"></i> <el-icon><InfoFilled /></el-icon>
成功 <i class="el-icon-success"></i> <el-icon><SuccessFilled /></el-icon>
警告 <i class="el-icon-warning"></i> <el-icon><WarningFilled /></el-icon>
错误 <i class="el-icon-error"></i> <el-icon><CircleCloseFilled /></el-icon>

关键点说明

12.5 图标引入方式

  1. 全局注册:在main.js中注册所有图标,可以在任何组件中直接使用
  2. 局部引入:在组件中按需引入需要的图标,减少打包体积
  3. 推荐方式:对于常用图标使用全局注册,对于不常用图标使用局部引入

12.6 图标使用注意事项

  1. 不要使用类名:Element Plus 2.x不再支持el-icon-*类名方式
  2. 必须使用组件:必须使用<el-icon>组件包裹图标组件
  3. 图标组件大小写:图标组件名称使用PascalCase,如CloseArrowDown
  4. 样式控制:通过class属性控制图标样式,不要直接在组件上设置样式

12.7 图标样式控制

/* 图标基础样式 */
.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 图标在标签页中的应用示例

<template>
  <div class="tabs-container">
    <div class="tabs-header">
      <div 
        v-for="tab in tabs" 
        :key="tab.name"
        :class="['tab-item', { 'active': activeTab === tab.name }]"
        @click="switchTab(tab)"
      >
        <span class="tab-title">{{ tab.title }}</span>
        <el-icon 
          v-if="tab.closable" 
          class="tab-close"
          @click.stop="closeTab(tab)"
        >
          <Close />
        </el-icon>
      </div>
      <div class="tabs-actions">
        <el-dropdown @command="handleTabAction">
          <span class="tabs-action-btn">
            <el-icon><ArrowDown /></el-icon>
          </span>
          <template #dropdown>
            <el-dropdown-menu>
              <el-dropdown-item command="closeCurrent">关闭当前</el-dropdown-item>
              <el-dropdown-item command="closeOthers">关闭其他</el-dropdown-item>
              <el-dropdown-item command="closeAll">关闭全部</el-dropdown-item>
            </el-dropdown-menu>
          </template>
        </el-dropdown>
      </div>
    </div>
  </div>
</template>

<script>
import { Close, ArrowDown } from '@element-plus/icons-vue'

export default {
  name: 'TabsView',
  components: {
    Close,
    ArrowDown
  },
  data() {
    return {
      tabs: [],
      activeTab: ''
    }
  },
  methods: {
    closeTab(tab) {
      // 关闭标签页逻辑
    },
    handleTabAction(command) {
      // 处理标签页操作
    }
  }
}
</script>

<style scoped>
.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;
  color: #909399 !important;
  background-color: transparent;
}

.tab-close:hover {
  opacity: 1 !important;
  color: #ff4d4f !important;
  background-color: #fff0f0 !important;
}

.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;
}
</style>

注意事项

  1. 版本兼容性:Element Plus 2.x与Element UI 2.x的图标使用方式完全不同,不能混用
  2. 图标包安装:确保已安装@element-plus/icons-vue
  3. 全局注册:在main.js中注册所有图标,避免在每个组件中重复引入
  4. 组件引入:在组件中按需引入需要的图标,减少打包体积
  5. 样式控制:通过class属性控制图标样式,不要直接在组件上设置样式
  6. 图标名称:图标组件名称使用PascalCase,如CloseArrowDown
  7. el-icon包裹:必须使用<el-icon>组件包裹图标组件
  8. 调试方法:如果图标不显示,检查控制台是否有错误,确认图标是否正确引入和注册
  9. 图标列表:查看Element Plus官方文档获取完整的图标列表
  10. 性能优化:对于大型项目,建议使用按需引入的方式,减少打包体积

常见错误及解决方案

错误1:图标不显示

错误代码

<template>
  <i class="el-icon-close"></i>
</template>

正确代码

<template>
  <el-icon><Close /></el-icon>
</template>

<script>
import { Close } from '@element-plus/icons-vue'
export default {
  components: { Close }
}
</script>

错误2:图标组件未注册

错误信息

Failed to resolve component: Close

解决方案

  1. 在main.js中全局注册图标
  2. 或者在组件中局部引入图标

错误3:图标样式不生效

错误代码

<template>
  <el-icon style="font-size: 20px; color: red;">
    <Close />
  </el-icon>
</template>

正确代码

<template>
  <el-icon class="my-icon">
    <Close />
  </el-icon>
</template>

<style scoped>
.my-icon {
  font-size: 20px;
  color: red;
}
</style>

总结

  1. Element Plus 2.x使用独立的图标组件,不再支持el-icon-*类名方式
  2. 必须安装@element-plus/icons-vue
  3. 必须注册图标组件,可以在main.js中全局注册或在组件中局部引入
  4. 必须使用<el-icon>组件包裹图标组件
  5. 样式控制通过class属性,不要直接在组件上设置样式
  6. 图标名称使用PascalCase,如CloseArrowDown
  7. 调试方法:检查控制台错误,确认图标是否正确引入和注册
  8. 性能优化:对于大型项目,建议使用按需引入的方式
  9. 官方文档:查看Element Plus官方文档获取完整的图标列表和使用说明
  10. 版本兼容性:Element Plus 2.x与Element UI 2.x的图标使用方式完全不同,不能混用