web-dev-best-practices.md 38 KB

Web前端开发最佳实践

1. 页面布局规范

1.1 页面样式一致性原则

重要原则: 所有管理页面应保持一致的样式规范,确保用户体验的统一性。

参考页面: 用户管理(UserManagement.vue)是样式规范的参考标准。

一致性要求:

  • 所有管理页面应使用相同的字体大小、颜色、间距
  • 查询条件区域、操作按钮区域、表格区域的布局应保持一致
  • 使用相同的阴影效果、圆角、背景色
  • 保持相同的交互反馈和加载状态

1.2 标准页面结构

参考用户管理和调度录入的布局方式,页面应包含以下区域:

<template>
  <div class="page-container">
    <!-- 页面标题 -->
    <h2>页面名称</h2>
    
    <!-- 查询条件区域 -->
    <div class="search-section">
      <h3>查询条件</h3>
      <el-form :model="queryForm" class="search-form">
        <div class="search-row">
          <el-form-item label="字段名">
            <el-input v-model="queryForm.field" placeholder="请输入..." clearable></el-input>
          </el-form-item>
          <!-- 更多查询条件 -->
          <div class="search-actions">
            <el-button type="primary" @click="query">查询</el-button>
            <el-button @click="reset">重置</el-button>
          </div>
        </div>
      </el-form>
    </div>
    
    <!-- 操作按钮区域 -->
    <div class="action-section">
      <el-button type="primary" @click="add">新增</el-button>
      <el-button type="danger" @click="deleteSelected" :disabled="selectedIds.length === 0">删除</el-button>
    </div>
    
    <!-- 表格区域 -->
    <div class="table-container">
      <el-table>...</el-table>
    </div>
  </div>
</template>

1.3 样式规范

.page-container {
  padding: 15px;
  height: 100vh;
  overflow-y: auto;
}

.page-container h2 {
  margin: 0 0 15px 0;
  font-size: 18px;
  color: #303133;
}

.search-section {
  background: #fff;
  padding: 15px;
  margin-bottom: 15px;
  border-radius: 4px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}

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

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

.search-row {
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
  align-items: center;
}

.search-row .el-form-item {
  margin-bottom: 0;
  min-width: 200px;
}

.search-actions {
  display: flex;
  gap: 8px;
  margin-left: auto;
}

.action-section {
  margin-bottom: 15px;
  display: flex;
  gap: 8px;
}

.table-container {
  background: #fff;
  padding: 15px;
  border-radius: 4px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  flex: 1;
  display: flex;
  flex-direction: column;
}

.pagination {
  margin-top: 15px;
  display: flex;
  justify-content: center;
}

.dialog-footer {
  display: flex;
  justify-content: flex-end;
  gap: 8px;
}

关键样式说明:

字体大小规范

  • 页面标题(h2):18px
  • 查询条件标题(h3):15px
  • 表格内容:默认大小(Element Plus默认14px)

颜色规范

  • 页面标题:#303133(深灰色)
  • 查询条件标题:#606266(中灰色)
  • 背景色:#fff(白色)
  • 表格表头:#f5f7fa(浅灰色背景),#303133(深灰色文字)

间距规范

  • 页面容器padding:15px
  • 标题h2:margin: 0 0 15px 0
  • 查询条件区域padding:15px,margin-bottom: 15px
  • 标题h3:margin: 0 0 12px 0
  • search-row gap:12px
  • search-actions gap:8px
  • 操作按钮区域margin-bottom:15px,gap:8px
  • 表格容器padding:15px
  • 分页margin-top:15px
  • 对话框按钮gap:8px

视觉效果规范

  • 圆角:border-radius: 4px
  • 阴影:box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1)
  • 背景色:查询条件和表格区域使用白色背景(#fff)
  • 表格:使用stripe属性(斑马纹)

注意: 所有间距都应保持紧凑,避免过大的空白区域,确保界面紧凑、专业。

1.4 表格高度和宽度规范

<el-table
  :data="tableData"
  stripe
  border
  size="default"
  :header-cell-style="{ background: '#f5f7fa', color: '#303133', fontWeight: 'bold' }"
  style="width: calc(100vw - 331px); height: calc(100vh - 395px);">

说明:

  • 表格宽度:calc(100vw - 331px),确保表格宽度适应视口宽度,减去侧边栏和边距
  • 表格高度:calc(100vh - 395px),确保表格高度适应视口高度,减去标题、查询条件、操作按钮和分页的高度
  • 使用size="default"确保表格使用紧凑模式

计算公式:

  • 331px = 侧边栏宽度(220px)+ 页面padding(15px × 2)+ 其他边距(81px)
  • 395px = 标题高度(18px)+ 标题margin(15px)+ 查询区域高度(约150px)+ 查询margin(15px)+ 操作按钮高度(约50px)+ 操作margin(15px)+ 表格padding(15px × 2)+ 分页高度(约50px)+ 分页margin(15px)

1.4 Loading状态管理

const loading = ref(false)
const deleteLoading = ref(false)

const getUsers = async () => {
  if (loading.value) return
  
  loading.value = true
  try {
    const response = await request.get('/user/list', {
      params: {
        ...queryForm,
        page: currentPage.value,
        pageSize: pageSize.value,
        sortBy: sortMap.value.sortBy,
        sortOrder: sortMap.value.sortOrder
      }
    })
    userList.value = response.data.content
    total.value = response.data.totalElements
  } catch (error) {
    console.error('获取用户列表失败:', error)
    ElMessage.error('获取用户列表失败')
  } finally {
    loading.value = false
  }
}

Loading状态应用:

<el-button type="primary" @click="getUsers" :loading="loading">查询</el-button>
<el-button @click="resetSearchForm" :loading="loading">重置</el-button>
<el-button type="danger" @click="deleteSelectedUsers" :disabled="selectedIds.length === 0" :loading="deleteLoading">删除</el-button>
<el-table v-loading="loading" :data="userList">...</el-table>

注意:

  • 查询和重置按钮使用loading状态
  • 删除按钮使用deleteLoading状态
  • 表格使用loading状态
  • 在方法开始时检查loading.value,防止重复点击
  • 使用try-finally确保loading状态一定会被恢复

2. 表格设计规范

2.1 表格属性

<el-table
  :data="tableData"
  stripe
  border
  size="default"
  :header-cell-style="{ background: '#f5f7fa', color: '#303133', fontWeight: 'bold' }"
  :height="tableHeight"
  @selection-change="handleSelectionChange"
  @sort-change="handleSortChange">

2.2 列设计规范

  • 序号列:宽度60px,固定居中,使用自定义模板实现连续序号
  • 选择列:宽度55px,固定居中
  • 操作列:宽度180px,固定右侧,使用图标按钮
  • 状态列:宽度80px,居中显示,使用el-tag
  • 日期时间列:宽度160px,居中显示
  • 用户名列:宽度100px
  • 账号列:宽度100px
  • 用户角色列:宽度150px
  • 企业微信账号列:宽度120px
  • 其他文本列:使用固定宽度,支持文本溢出显示
<el-table-column type="selection" width="55" fixed align="center"></el-table-column>
<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="name" label="用户名" width="100" sortable show-overflow-tooltip></el-table-column>
<el-table-column prop="loginId" label="账号" width="100" sortable show-overflow-tooltip></el-table-column>
<el-table-column prop="roleName" label="用户角色" width="150" sortable show-overflow-tooltip></el-table-column>
<el-table-column prop="weChatUserId" label="企业微信账号" width="120" sortable show-overflow-tooltip></el-table-column>
<el-table-column prop="recordStatusName" label="状态" width="80" sortable align="center">
  <template #default="scope">
    <el-tag :type="scope.row.recordStatus === 1 ? 'success' : 'info'" size="small">
      {{ scope.row.recordStatusName }}
    </el-tag>
  </template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" width="160" sortable align="center" show-overflow-tooltip>
  <template #default="scope">
    {{ formatDateTime(scope.row.createTime) }}
  </template>
</el-table-column>
<el-table-column prop="modifyTime" label="修改时间" width="160" sortable align="center" show-overflow-tooltip>
  <template #default="scope">
    {{ formatDateTime(scope.row.modifyTime) }}
  </template>
</el-table-column>
<el-table-column label="操作" width="180" align="center" fixed="right">
  <template #default="{ row }">
    <el-tooltip content="编辑" placement="top">
      <el-button type="primary" :icon="Edit" circle size="small" @click="edit(row)"></el-button>
    </el-tooltip>
    <el-tooltip content="删除" placement="top">
      <el-button type="danger" :icon="Delete" circle size="small" @click="delete(row.id)"></el-button>
    </el-tooltip>
  </template>
</el-table-column>

重要说明:

  • 使用固定宽度(width)而不是最小宽度(min-width)可以避免标题栏分行
  • 序号列使用自定义模板实现连续序号,公式为(currentPage - 1) * pageSize + $index + 1,确保分页时序号连续
  • 序号列宽度使用60px,比原来的50px更合适,可以显示更大的页码
  • 操作列使用180px,足够容纳3个图标按钮
  • 状态列使用80px,足够显示状态标签
  • 用户名和账号列使用100px,适合大多数情况
  • 用户角色列使用150px,可以容纳多个角色名称(用逗号分隔)
  • 企业微信账号列使用120px,适合大多数微信号长度
  • 日期时间列使用160px,可以完整显示日期时间格式

2.4 图标按钮使用规范

操作列应使用图标按钮,而不是文字按钮,使界面更加简洁美观。

2.4.1 图标按钮示例

<el-table-column label="操作" width="180" align="center" fixed="right">
  <template #default="{ row }">
    <el-tooltip content="编辑" placement="top">
      <el-button type="primary" :icon="Edit" circle size="small" @click="edit(row)"></el-button>
    </el-tooltip>
    <el-tooltip content="删除" placement="top">
      <el-button type="danger" :icon="Delete" circle size="small" @click="delete(row.id)"></el-button>
    </el-tooltip>
  </template>
</el-table-column>

2.4.2 图标导入和使用

import { Edit, Delete, User, Lock, Search } from '@element-plus/icons-vue'

export default {
  components: {
    Edit,
    Delete,
    User,
    Lock,
    Search
  }
}

2.4.3 常用图标说明

图标 名称 用途
Edit 编辑 修改数据
Delete 删除 删除数据
User 用户 用户/人员管理
Lock 锁定 锁定/重置密码
Search 搜索 搜索/查询
Plus 加号 新增
View 查看 查看详情
Download 下载 下载文件
Upload 上传 上传文件
Refresh 刷新 刷新数据

2.4.4 图标按钮属性说明

  • type:按钮类型,如primarysuccesswarningdangerinfo
  • :icon:图标组件,如:icon="Edit"
  • circle:圆形按钮
  • size:按钮尺寸,推荐使用small
  • @click:点击事件

2.4.5 Tooltip使用

使用el-tooltip为图标按钮提供功能提示:

<el-tooltip content="编辑" placement="top">
  <el-button type="primary" :icon="Edit" circle size="small" @click="edit(row)"></el-button>
</el-tooltip>

Tooltip属性:

  • content:提示文本
  • placement:提示位置,可选值:topbottomleftright

2.3 日期时间格式化

const formatDateTime = (dateStr) => {
  if (!dateStr) return ''
  const date = new Date(dateStr)
  const year = date.getFullYear()
  const month = String(date.getMonth() + 1).padStart(2, '0')
  const day = String(date.getDate()).padStart(2, '0')
  const hours = String(date.getHours()).padStart(2, '0')
  const minutes = String(date.getMinutes()).padStart(2, '0')
  const seconds = String(date.getSeconds()).padStart(2, '0')
  return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}

3. 删除操作规范

3.1 删除确认对话框

所有删除操作都必须显示确认对话框,防止误操作。

单个删除

const deleteUser = async (id) => {
  try {
    await ElMessageBox.confirm(
      '确定要删除该用户吗?删除后用户及其角色关联将被永久删除,不可恢复。',
      '删除确认',
      {
        confirmButtonText: '确定',
        cancelButtonText: '取消',
        type: 'warning'
      }
    )
    
    await request.delete(`/user/delete/${id}`)
    ElMessage.success('删除成功')
    getUsers()
  } catch (error) {
    if (error !== 'cancel') {
      console.error('删除用户失败:', error)
      ElMessage.error('删除用户失败')
    }
  }
}

批量删除

const deleteSelectedUsers = async () => {
  if (selectedUserIds.value.length === 0) {
    return
  }
  
  try {
    await ElMessageBox.confirm(
      `确定要删除选中的 ${selectedUserIds.value.length} 个用户吗?删除后用户及其角色关联将被永久删除,不可恢复。`,
      '删除确认',
      {
        confirmButtonText: '确定',
        cancelButtonText: '取消',
        type: 'warning'
      }
    )
    
    await request.delete('/user/delete', {
      data: selectedUserIds.value
    })
    ElMessage.success('删除成功')
    selectedUserIds.value = []
    getUsers()
  } catch (error) {
    if (error !== 'cancel') {
      console.error('删除用户失败:', error)
      ElMessage.error('删除用户失败')
    }
  }
}

3.2 后端删除逻辑

删除主表数据时,必须先删除关联表数据。

@Transactional
public void deleteUsers(List<String> userIds) {
    for (String userId : userIds) {
        // 先删除关联表数据
        sysUserRoleRepository.deleteByUserId(userId);
        // 再删除主表数据
        userRepository.deleteById(userId);
    }
}

4. 排序功能

4.1 前端排序

const handleSortChange = ({ prop, order }) => {
  if (order) {
    queryForm.value.sortBy = prop
    queryForm.value.sortOrder = order === 'ascending' ? 'asc' : 'desc'
  } else {
    queryForm.value.sortBy = null
    queryForm.value.sortOrder = null
  }
  getUsers()
}

4.2 后端排序

public Page<User> queryUsers(UserQuery query, Pageable pageable) {
    Sort sort = pageable.getSort();
    if (query.getSortBy() != null && query.getSortOrder() != null) {
        sort = Sort.by(
            "asc".equalsIgnoreCase(query.getSortOrder()) ? Sort.Direction.ASC : Sort.Direction.DESC,
            query.getSortBy()
        );
    }
    return userRepository.findAll(pageable.withSort(sort));
}

5. API请求规范

5.0 验证失败处理机制

重要原则: 所有API请求必须统一处理验证失败,确保用户在token失效或权限不足时能够及时跳转到登录页面。

5.0.1 请求拦截器配置

src/utils/request.js中配置axios请求和响应拦截器,统一处理认证和错误。

import axios from 'axios'

/**
 * 创建axios实例
 */
const request = axios.create({
  baseURL: '/api',
  timeout: 30000
})

/**
 * 请求拦截器 - 自动添加token
 */
request.interceptors.request.use(
  config => {
    const token = localStorage.getItem('token')
    if (token) {
      config.headers['Authorization'] = `Bearer ${token}`
    }
    return config
  },
  error => {
    console.error('请求错误:', error)
    return Promise.reject(error)
  }
)

/**
 * 响应拦截器 - 处理token过期和验证失败
 */
request.interceptors.response.use(
  response => {
    return response
  },
  error => {
    if (error.response) {
      switch (error.response.status) {
        case 401:
          console.error('未授权,请重新登录')
          localStorage.removeItem('token')
          localStorage.removeItem('userInfo')
          window.location.href = '/login'
          break
        case 403:
          console.error('没有权限访问,请重新登录')
          localStorage.removeItem('token')
          localStorage.removeItem('userInfo')
          window.location.href = '/login'
          break
        case 404:
          console.error('请求的资源不存在')
          break
        case 500:
          console.error('服务器错误')
          break
        default:
          console.error('请求失败:', error.response.status)
      }
    } else if (error.request) {
      console.error('网络错误,请检查网络连接')
    } else {
      console.error('请求配置错误:', error.message)
    }
    return Promise.reject(error)
  }
)

export default request

关键说明:

  • 401未授权:清除token和userInfo,跳转到登录页面
  • 403没有权限:清除token和userInfo,跳转到登录页面
  • 404资源不存在:仅记录错误日志
  • 500服务器错误:仅记录错误日志
  • 网络错误和配置错误:仅记录错误日志

5.0.2 路由守卫配置

src/router/index.js中配置路由守卫,在路由跳转前检查登录状态。

// 路由守卫,检查登录状态
router.beforeEach((to, from, next) => {
  // 登录页面不需要验证
  if (to.name === 'login') {
    next()
    return
  }
  
  // 检查是否有登录令牌
  const token = localStorage.getItem('token')
  if (!token) {
    // 未登录,重定向到登录页面
    localStorage.removeItem('userInfo')
    next({ name: 'login' })
    return
  }
  
  // 已登录,继续访问
  next()
})

关键说明:

  • 登录页面不需要验证
  • 检查localStorage中的token是否存在
  • 如果没有token,清除userInfo并跳转到登录页面

5.0.3 验证失败处理流程

完整的验证失败处理流程如下:

用户访问需要认证的页面
    ↓
路由守卫检查token
    ↓
没有token? → 清除userInfo,跳转到登录页面
    ↓
有token? → 继续访问
    ↓
发送API请求(自动添加token)
    ↓
服务器验证失败(401/403)
    ↓
响应拦截器捕获错误
    ↓
清除token和userInfo
    ↓
跳转到登录页面
    ↓
用户重新登录
    ↓
获取新的token
    ↓
正常访问系统

5.0.4 注意事项

  1. 统一清除:在401和403错误时,必须同时清除token和userInfo
  2. 强制跳转:使用window.location.href而不是router.push,确保页面强制刷新
  3. 错误日志:所有错误都应该记录到控制台,便于调试
  4. 用户体验:在跳转到登录页面前,应该给用户明确的提示信息
  5. 避免重复跳转:路由守卫和响应拦截器都应该检查token,避免重复跳转

5.1 使用request.js

所有API请求都应该使用统一的request工具,自动处理认证和错误。

import request from '@/utils/request'

export const getUsers = (params) => {
  return request.get('/user/list', { params })
}

export const createUser = (data) => {
  return request.post('/user/create', data)
}

export const deleteUser = (data) => {
  return request.delete('/user/delete', { data })
}

5.2 分页和排序规范

5.2.1 后端DTO规范

所有查询DTO必须继承BaseQueryDTO,以统一管理分页和排序参数。

package com.lianda.backend.dto;

import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;

/**
 * 调度录入查询条件DTO
 */
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
public class DispatcherQueryDTO extends BaseQueryDTO {
    
    /**
     * 计划时间-开始
     */
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
    private Date workTimeStart;
    
    /**
     * 计划时间-结束
     */
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
    private Date workTimeEnd;
    
    /**
     * 调度单号(模糊查询)
     */
    private String no;
    
    // 其他查询条件...
}

BaseQueryDTO基类定义:

package com.lianda.backend.dto;

/**
 * 查询DTO基类,包含通用的分页和排序属性
 */
public class BaseQueryDTO {
    private Integer page = 1;    // 页码,默认第一页
    private Integer pageSize = 20; // 每页大小,默认20条
    private String sortBy;  // 排序列名
    private String sortOrder;  // 排序方向,ASC或DESC

    // getters and setters...
}

5.2.2 后端Controller规范

Controller接口应接收完整的查询DTO对象,而不是单独的分页参数。

错误示例:

@PostMapping("/list")
public PageResponse<DispatcherListDTO> list(
        @RequestBody DispatcherQueryDTO query,
        @RequestParam(defaultValue = "1") int page,
        @RequestParam(defaultValue = "20") int pageSize,
        @RequestParam(required = false) String sortBy,
        @RequestParam(required = false) String sortOrder) {
    // ...
}

正确示例:

@PostMapping("/list")
public PageResponse<DispatcherListDTO> list(@RequestBody DispatcherQueryDTO query) {
    Sort sort = Sort.by(Sort.Direction.DESC, "workTime");
    if (query.getSortBy() != null && !query.getSortBy().isEmpty()) {
        sort = Sort.by(
            "asc".equalsIgnoreCase(query.getSortOrder()) ? Sort.Direction.ASC : Sort.Direction.DESC,
            query.getSortBy()
        );
    }
    
    Pageable pageable = PageRequest.of(query.getPage() - 1, query.getPageSize(), sort);
    Page<DispatcherListDTO> result = dispatcherService.queryDispatcherList(query, pageable);
    
    return new PageResponse<>(
        result.getContent(),
        result.getTotalElements(),
        result.getNumber() + 1,
        result.getSize()
    );
}

5.2.3 前端分页和排序实现

前端需要维护分页和排序状态,并在查询时传递给后端。

export default {
  data() {
    return {
      // 分页参数
      currentPage: 1,
      pageSize: 20,
      total: 0,
      
      // 排序参数
      sortMap: {
        sortBy: '',
        sortOrder: ''
      },
      
      // 查询条件
      searchForm: {
        // ...
      },
      
      // 数据列表
      dataList: []
    }
  },
  
  methods: {
    /**
     * 获取数据列表
     */
    async getDataList() {
      this.loading = true
      try {
        const params = {
          page: this.currentPage,
          pageSize: this.pageSize,
          sortBy: this.sortMap.sortBy,
          sortOrder: this.sortMap.sortOrder,
          ...this.searchForm  // 展开查询条件
        }
        
        const response = await request.post('/api/list', params)
        this.dataList = response.data.content || []
        this.total = response.data.totalElements || 0
      } catch (error) {
        console.error('查询失败:', error)
        this.$message.error('查询失败')
      } finally {
        this.loading = false
      }
    },
    
    /**
     * 处理排序变化
     */
    handleSortChange({ prop, order }) {
      if (prop && order) {
        this.sortMap = {
          sortBy: prop,
          sortOrder: order === 'ascending' ? 'ASC' : (order === 'descending' ? 'DESC' : null)
        }
      } else {
        this.sortMap = {
          sortBy: '',
          sortOrder: ''
        }
      }
      this.getDataList()
    },
    
    /**
     * 处理页码变化
     */
    handleCurrentChange(page) {
      this.currentPage = page
      this.getDataList()
    },
    
    /**
     * 处理每页大小变化
     */
    handleSizeChange(size) {
      this.pageSize = size
      this.currentPage = 1
      this.getDataList()
    }
  }
}

5.2.4 表格绑定排序事件

<el-table
  :data="dataList"
  v-loading="loading"
  @sort-change="handleSortChange"
  border
  stripe
  size="small">
  <el-table-column
    prop="no"
    label="调度单号"
    width="150"
    sortable>
  </el-table-column>
  <!-- 其他列 -->
</el-table>

<el-pagination
  @current-change="handleCurrentChange"
  @size-change="handleSizeChange"
  :current-page="currentPage"
  :page-sizes="[10, 20, 50, 100]"
  :page-size="pageSize"
  :total="total"
  layout="total, sizes, prev, pager, next, jumper">
</el-pagination>

5.3 错误处理

try {
  await request.post('/api/endpoint', data)
  ElMessage.success('操作成功')
} catch (error) {
  console.error('操作失败:', error)
  ElMessage.error('操作失败,请稍后重试')
}

6. 表单验证

6.1 前端验证

<el-form :model="form" :rules="rules" ref="formRef">
  <el-form-item label="用户名" prop="name">
    <el-input v-model="form.name" placeholder="请输入用户名"></el-input>
  </el-form-item>
</el-form>

<script>
const rules = {
  name: [
    { required: true, message: '请输入用户名', trigger: 'blur' },
    { min: 2, max: 50, message: '长度在 2 到 50 个字符', trigger: 'blur' }
  ]
}
</script>

6.2 后端验证

public class UserCreateDTO {
    @NotBlank(message = "用户名不能为空")
    @Size(min = 2, max = 50, message = "用户名长度必须在2-50之间")
    private String name;
    
    @NotBlank(message = "账号不能为空")
    private String loginId;
    
    @NotBlank(message = "密码不能为空")
    @Size(min = 6, message = "密码长度不能少于6位")
    private String password;
}

7. 代码规范

7.1 命名规范

  • 组件名:使用PascalCase,如UserManagement.vue
  • 方法名:使用camelCase,如getUsershandleSortChange
  • 变量名:使用camelCase,如userListselectedIds
  • 常量名:使用UPPER_SNAKE_CASE,如MAX_PAGE_SIZE

7.2 注释规范

/**
 * 获取用户列表
 */
const getUsers = async () => {
  // 实现代码
}

/**
 * 处理排序变化
 * @param {Object} param - 排序参数
 * @param {String} param.prop - 排序字段
 * @param {String} param.order - 排序方向
 */
const handleSortChange = ({ prop, order }) => {
  // 实现代码
}

8. 性能优化

8.1 防抖和节流

import { debounce } from 'lodash-es'

const search = debounce(() => {
  getUsers()
}, 300)

8.2 虚拟滚动

对于大量数据,使用虚拟滚动提高性能。

<el-table
  :data="tableData"
  height="600"
  v-loading="loading">
</el-table>

9. 安全规范

9.1 XSS防护

使用Vue的插值表达式,避免直接使用v-html。

<!-- 正确 -->
<div>{{ user.name }}</div>

<!-- 错误 -->
<div v-html="user.name"></div>

9.2 CSRF防护

所有POST、PUT、DELETE请求都应该携带CSRF token。

import request from '@/utils/request'

request.interceptors.request.use(config => {
  const token = localStorage.getItem('csrf_token')
  if (token) {
    config.headers['X-CSRF-TOKEN'] = token
  }
  return config
})

10. 可访问性

10.1 语义化HTML

<nav>...</nav>
<main>
  <h1>页面标题</h1>
  <section class="search-section">...</section>
  <section class="table-section">...</section>
</main>
<footer>...</footer>

10.2 键盘导航

确保所有交互元素都可以通过键盘访问。

<el-button @click="handleClick" @keyup.enter="handleClick">
  点击
</el-button>

11. 搜索下拉框规范

11.1 适用场景

对于选项数量较多的下拉选择框(如船公司、代理公司、港口等),应使用可搜索的下拉框,并实现远程搜索和分页加载功能。

11.2 搜索下拉框实现

11.2.1 模板部分

<el-form-item label="船公司">
  <el-select
    v-model="searchForm.shipownerId"
    placeholder="请选择"
    style="width: 180px"
    clearable
    filterable
    :loading="shipCompanyLoading"
    @visible-change="handleShipCompanyVisibleChange"
    popper-class="ship-company-select-dropdown"
  >
    <el-option
      v-for="company in shipCompanyList"
      :key="company.companyId"
      :label="company.companyName"
      :value="company.companyId">
    </el-option>
  </el-select>
</el-form-item>

11.2.2 样式部分

/* 船公司下拉框样式 */
.ship-company-select-dropdown {
  max-height: 300px;
  overflow-y: auto;
}

.ship-company-select-dropdown .el-select-dropdown__wrap {
  max-height: 300px;
  overflow-y: auto;
}

11.2.3 数据属性

data() {
  return {
    searchForm: {
      shipownerId: ''
    },
    shipCompanyList: [],
    shipCompanyPage: 1,
    shipCompanyPageSize: 10,
    shipCompanyTotal: 0,
    shipCompanyLoading: false,
    shipCompanyHasMore: true
  }
}

11.2.4 方法实现

methods: {
  getShipCompanyList(isLoadMore = false) {
    this.shipCompanyLoading = true;
    this.$axios.get('/api/ship-company/list', {
      params: {
        page: this.shipCompanyPage,
        pageSize: this.shipCompanyPageSize
      }
    })
      .then(response => {
        const newCompanies = response.data.content || [];
        if (isLoadMore) {
          this.shipCompanyList = [...this.shipCompanyList, ...newCompanies];
        } else {
          this.shipCompanyList = newCompanies;
        }
        this.shipCompanyTotal = response.data.totalElements || 0;
        this.shipCompanyHasMore = this.shipCompanyList.length < this.shipCompanyTotal;
      })
      .catch(error => {
        console.error('获取船公司列表失败:', error);
        this.$message.error('获取船公司列表失败,请稍后重试');
      })
      .finally(() => {
        this.shipCompanyLoading = false;
      });
  },
  handleShipCompanyVisibleChange(visible) {
    if (visible) {
      this.shipCompanyPage = 1;
      this.getShipCompanyList();
      this.addShipCompanyScrollListener();
    } else {
      this.removeShipCompanyScrollListener();
    }
  },
  handleShipCompanyScroll(event) {
    const { target } = event;
    const scrollBottom = target.scrollHeight - target.scrollTop - target.clientHeight;
    
    if (scrollBottom < 10 && this.shipCompanyHasMore && !this.shipCompanyLoading) {
      this.shipCompanyPage++;
      this.getShipCompanyList(true);
    }
  },
  addShipCompanyScrollListener() {
    this.$nextTick(() => {
      const dropdown = document.querySelector('.ship-company-select-dropdown');
      if (dropdown) {
        const dropdownWrap = dropdown.querySelector('.el-select-dropdown__wrap');
        if (dropdownWrap) {
          dropdownWrap.addEventListener('scroll', this.handleShipCompanyScroll);
        }
      }
    });
  },
  removeShipCompanyScrollListener() {
    const dropdown = document.querySelector('.ship-company-select-dropdown');
    if (dropdown) {
      const dropdownWrap = dropdown.querySelector('.el-select-dropdown__wrap');
      if (dropdownWrap) {
        dropdownWrap.removeEventListener('scroll', this.handleShipCompanyScroll);
      }
    }
  }
}

11.2.5 生命周期钩子

beforeUnmount() {
  this.removeShipCompanyScrollListener();
}

### 11.3 后端API规范

#### 11.3.1 Controller实现

```java
@GetMapping("/list")
public Page<ShippingCompanyDTO> list(
    @RequestParam(required = false) String companyName,
    @RequestParam(defaultValue = "1") int page,
    @RequestParam(defaultValue = "10") int pageSize
) {
    Pageable pageable = PageRequest.of(page - 1, pageSize);
    return commonDataService.searchShippingCompanies(companyName, pageable);
}

11.3.2 Service实现

@Service
public class CommonDataService {
    
    @Autowired
    private BusCustomerCompanyRepository busCustomerCompanyRepository;
    
    @Autowired
    private BusCustomerCustomerTypeRepository busCustomerCustomerTypeRepository;
    
    public Page<ShippingCompanyDTO> searchShippingCompanies(String companyName, Pageable pageable) {
        // 查询CustomerType=2的所有客户ID
        List<BusCustomerCustomerType> customerTypeRecords = busCustomerCustomerTypeRepository.findByCustomerType(2);
        Set<String> shippingCompanyCustomerIds = customerTypeRecords.stream()
                .map(BusCustomerCustomerType::getCustomerId)
                .collect(Collectors.toSet());

        // 查询对应的客户公司信息(支持模糊搜索和分页)
        Page<BusCustomerCompany> companyPage;
        if (companyName != null && !companyName.isEmpty()) {
            companyPage = busCustomerCompanyRepository.findByNameContainingAndRecordStatus(
                    companyName, 1, pageable);
        } else {
            Sort sort = Sort.by(Sort.Direction.ASC, "name");
            Pageable sortedPageable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), sort);
            companyPage = busCustomerCompanyRepository.findByRecordStatus(1, sortedPageable);
        }

        // 过滤出船公司类型的数据并转换为DTO
        List<ShippingCompanyDTO> filteredCompanies = companyPage.getContent().stream()
                .filter(company -> shippingCompanyCustomerIds.contains(company.getCustomerCompanyId()))
                .map(company -> new ShippingCompanyDTO(
                        company.getCustomerCompanyId(),
                        company.getName(),
                        company.getContact(),
                        company.getTel(),
                        company.getMobile()))
                .collect(Collectors.toList());

        // 创建新的Page对象
        return new PageImpl<>(
                filteredCompanies,
                pageable,
                companyPage.getTotalElements()
        );
    }
}

11.3.3 Repository实现

@Repository
public interface BusCustomerCompanyRepository extends JpaRepository<BusCustomerCompany, String> {
    
    /**
     * 根据名称和记录状态查找客户公司(分页)
     */
    Page<BusCustomerCompany> findByNameContainingAndRecordStatus(String name, Integer recordStatus, Pageable pageable);
    
    /**
     * 根据记录状态查找客户公司(分页)
     */
    Page<BusCustomerCompany> findByRecordStatus(Integer recordStatus, Pageable pageable);
}

@Repository
public interface BusCustomerCustomerTypeRepository extends JpaRepository<BusCustomerCustomerType, String> {
    
    /**
     * 根据客户类型查找记录
     */
    List<BusCustomerCustomerType> findByCustomerType(Integer customerType);
}

11.3.4 数据库表结构

Bus_CustomerCompany表:客户公司表

  • CustomerCompanyID:客户公司ID(主键)
  • Name:客户公司名称
  • Contact:联系人
  • Tel:电话
  • Mobile:手机
  • RecordStatus:记录状态(1=启用,0=禁用)

Bus_Customer_CustomerType表:客户类型关联表

  • CustomerID:客户ID(外键,关联Bus_CustomerCompany.CustomerCompanyID)
  • CustomerType:客户类型(2=船公司)

11.3.5 查询逻辑说明

船公司查询需要关联两个表:

  1. 首先从Bus_Customer_CustomerType表中查询CustomerType=2的所有客户ID
  2. 然后从Bus_CustomerCompany表中查询对应的客户公司信息
  3. 支持按公司名称进行模糊搜索
  4. 支持分页查询,按公司名称排序
  5. 只返回RecordStatus=1(启用)的客户公司 ```

11.4 关键属性说明

属性 说明
filterable 启用本地过滤功能
remote 启用远程搜索
remote-method 远程搜索方法,当用户输入时触发
loading 加载状态,用于显示加载动画
visible-change 下拉框显示/隐藏时触发
scroll 下拉列表滚动时触发,用于实现瀑布加载

11.5 注意事项

  1. 分页大小:建议每页加载10-20条记录
  2. 防抖处理:远程搜索建议添加防抖,避免频繁请求
  3. 错误处理:必须添加错误处理和用户提示
  4. 加载状态:必须显示加载状态,提升用户体验
  5. 清空处理:清空搜索条件时,应重置分页和列表
  6. 首次加载:下拉框首次打开时,应加载第一页数据
  7. 前后端数据结构不一致处理:当前后端数据结构不一致时,应优先修改前端代码来适应后端返回的数据格式,因为后端的方法可能被多个地方调用,修改后端可能影响其他功能。前端可以通过以下方式适应后端:
    • 使用本地过滤和分页:如果后端返回全部数据,前端可以在本地进行过滤和分页
    • 数据转换:在前端对后端返回的数据进行必要的转换
    • 兼容性处理:使用可选链操作符(?.)和默认值处理可能的字段缺失

11.6 船公司显示字段规范

重要:船公司下拉框必须显示BusinessCode字段,而非Name字段。

11.6.1 数据库表结构

Bus_CustomerCompanyBusiness表:客户公司业务表

  • CustomerCompanyBusinessId:客户公司业务ID(主键)
  • CustomerCompanyID:客户公司ID(外键)
  • BusinessCode:业务代码(下拉框显示字段
  • Description:描述
  • RecordStatus:记录状态

Bus_CustomerCompany表:客户公司表

  • CustomerCompanyID:客户公司ID(主键)
  • Name:客户公司名称(不用于下拉框显示

11.6.2 前端显示规范

<el-option
  v-for="company in shipCompanyList"
  :key="company.companyId"
  :label="company.businessCode"
  :value="company.companyId">
</el-option>

注意

  • :label必须绑定到company.businessCode
  • 不能使用company.companyName作为显示字段
  • BusinessCode是船公司的业务代码,是用户识别船公司的主要标识

11.6.3 后端DTO规范

public class ShippingCompanyDTO {
    private String companyId;
    private String companyName;
    private String businessCode;  // 必须包含此字段
    private String contact;
    private String tel;
    private String mobile;
    
    public String getBusinessCode() {
        return businessCode;
    }
    
    public void setBusinessCode(String businessCode) {
        this.businessCode = businessCode;
    }
}

11.6.4 后端查询规范

// 查询时必须包含BusinessCode字段
shippingCompanies = jdbcTemplate.query(sql.toString(), 
    (rs, rowNum) -> new ShippingCompanyDTO(
        rs.getString("CustomerCompanyBusinessId"),
        rs.getString("Name"),
        rs.getString("BusinessCode"),  // 必须包含BusinessCode
        null,
        null
    ));