SKILL.md 37 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. 统一组件封装:创建可复用的防重复点击组件

示例代码

<template>
  <el-button 
    :loading="loading" 
    @click="handleAction"
    :disabled="loading">
    {{ loading ? '处理中...' : '提交' }}
  </el-button>
</template>

<script>
export default {
  data() {
    return {
      loading: false
    }
  },
  methods: {
    async handleAction() {
      if (this.loading) return; // 防止重复点击
      
      this.loading = true;
      try {
        // 执行后台操作
        await this.apiCall();
      } finally {
        this.loading = false; // 确保无论成功失败都恢复状态
      }
    }
  }
}
</script>

8. 常见陷阱和注意事项

  1. 注解使用场景

    • @DateTimeFormat 用于请求参数绑定
    • @JsonFormat 用于JSON序列化/反序列化
  2. 命名规范

    • 避免使用可能引起误解的命名(如READ/WRITE)
    • 使用业务含义明确的命名
  3. 数据一致性

    • 确保前后端日期格式一致
    • 保证显示字段与业务需求一致
  4. 性能考虑

    • 大数据量时使用分页
    • 合理使用缓存
    • 避免不必要的数据传输

使用时机

在开发新页面功能、调试常见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并重新查询

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表查询确认,不要随意猜测