# Web前端开发最佳实践 ## 1. 页面布局规范 ### 1.1 页面样式一致性原则 **重要原则:** 所有管理页面应保持一致的样式规范,确保用户体验的统一性。 **参考页面:** 用户管理(UserManagement.vue)是样式规范的参考标准。 **一致性要求:** - 所有管理页面应使用相同的字体大小、颜色、间距 - 查询条件区域、操作按钮区域、表格区域的布局应保持一致 - 使用相同的阴影效果、圆角、背景色 - 保持相同的交互反馈和加载状态 ### 1.2 标准页面结构 参考用户管理和调度录入的布局方式,页面应包含以下区域: ```vue ``` ### 1.3 样式规范 ```css .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 表格高度和宽度规范 ```vue ``` **说明:** - 表格宽度:`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状态管理 ```javascript 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状态应用:** ```vue 查询 重置 删除 ... ``` **注意:** - 查询和重置按钮使用`loading`状态 - 删除按钮使用`deleteLoading`状态 - 表格使用`loading`状态 - 在方法开始时检查`loading.value`,防止重复点击 - 使用`try-finally`确保loading状态一定会被恢复 ## 2. 表格设计规范 ### 2.1 表格属性 ```vue ``` ### 2.2 列设计规范 - **序号列**:宽度60px,固定居中,使用自定义模板实现连续序号 - **选择列**:宽度55px,固定居中 - **操作列**:宽度180px,固定右侧,使用图标按钮 - **状态列**:宽度80px,居中显示,使用el-tag - **日期时间列**:宽度160px,居中显示 - **用户名列**:宽度100px - **账号列**:宽度100px - **用户角色列**:宽度150px - **企业微信账号列**:宽度120px - **其他文本列**:使用固定宽度,支持文本溢出显示 ```vue ``` **重要说明:** - 使用固定宽度(width)而不是最小宽度(min-width)可以避免标题栏分行 - 序号列使用自定义模板实现连续序号,公式为`(currentPage - 1) * pageSize + $index + 1`,确保分页时序号连续 - 序号列宽度使用60px,比原来的50px更合适,可以显示更大的页码 - 操作列使用180px,足够容纳3个图标按钮 - 状态列使用80px,足够显示状态标签 - 用户名和账号列使用100px,适合大多数情况 - 用户角色列使用150px,可以容纳多个角色名称(用逗号分隔) - 企业微信账号列使用120px,适合大多数微信号长度 - 日期时间列使用160px,可以完整显示日期时间格式 ### 2.4 图标按钮使用规范 操作列应使用图标按钮,而不是文字按钮,使界面更加简洁美观。 #### 2.4.1 图标按钮示例 ```vue ``` #### 2.4.2 图标导入和使用 ```javascript 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`:按钮类型,如`primary`、`success`、`warning`、`danger`、`info` - `:icon`:图标组件,如`:icon="Edit"` - `circle`:圆形按钮 - `size`:按钮尺寸,推荐使用`small` - `@click`:点击事件 #### 2.4.5 Tooltip使用 使用`el-tooltip`为图标按钮提供功能提示: ```vue ``` **Tooltip属性:** - `content`:提示文本 - `placement`:提示位置,可选值:`top`、`bottom`、`left`、`right` ### 2.3 日期时间格式化 ```javascript 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 删除确认对话框 所有删除操作都必须显示确认对话框,防止误操作。 #### 单个删除 ```javascript 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('删除用户失败') } } } ``` #### 批量删除 ```javascript 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 后端删除逻辑 删除主表数据时,必须先删除关联表数据。 ```java @Transactional public void deleteUsers(List userIds) { for (String userId : userIds) { // 先删除关联表数据 sysUserRoleRepository.deleteByUserId(userId); // 再删除主表数据 userRepository.deleteById(userId); } } ``` ## 4. 排序功能 ### 4.1 前端排序 ```javascript 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 后端排序 ```java public Page 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请求和响应拦截器,统一处理认证和错误。 ```javascript 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`中配置路由守卫,在路由跳转前检查登录状态。 ```javascript // 路由守卫,检查登录状态 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工具,自动处理认证和错误。 ```javascript 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`,以统一管理分页和排序参数。 ```java 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基类定义:** ```java 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对象,而不是单独的分页参数。 **错误示例:** ```java @PostMapping("/list") public PageResponse list( @RequestBody DispatcherQueryDTO query, @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int pageSize, @RequestParam(required = false) String sortBy, @RequestParam(required = false) String sortOrder) { // ... } ``` **正确示例:** ```java @PostMapping("/list") public PageResponse 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 result = dispatcherService.queryDispatcherList(query, pageable); return new PageResponse<>( result.getContent(), result.getTotalElements(), result.getNumber() + 1, result.getSize() ); } ``` #### 5.2.3 前端分页和排序实现 前端需要维护分页和排序状态,并在查询时传递给后端。 ```javascript 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 表格绑定排序事件 ```vue ``` ### 5.3 错误处理 ```javascript try { await request.post('/api/endpoint', data) ElMessage.success('操作成功') } catch (error) { console.error('操作失败:', error) ElMessage.error('操作失败,请稍后重试') } ``` ## 6. 表单验证 ### 6.1 前端验证 ```vue ``` ### 6.2 后端验证 ```java 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,如`getUsers`、`handleSortChange` - 变量名:使用camelCase,如`userList`、`selectedIds` - 常量名:使用UPPER_SNAKE_CASE,如`MAX_PAGE_SIZE` ### 7.2 注释规范 ```javascript /** * 获取用户列表 */ const getUsers = async () => { // 实现代码 } /** * 处理排序变化 * @param {Object} param - 排序参数 * @param {String} param.prop - 排序字段 * @param {String} param.order - 排序方向 */ const handleSortChange = ({ prop, order }) => { // 实现代码 } ``` ## 8. 性能优化 ### 8.1 防抖和节流 ```javascript import { debounce } from 'lodash-es' const search = debounce(() => { getUsers() }, 300) ``` ### 8.2 虚拟滚动 对于大量数据,使用虚拟滚动提高性能。 ```vue ``` ## 9. 安全规范 ### 9.1 XSS防护 使用Vue的插值表达式,避免直接使用v-html。 ```vue
{{ user.name }}
``` ### 9.2 CSRF防护 所有POST、PUT、DELETE请求都应该携带CSRF token。 ```javascript 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 ```vue

页面标题

...
...
...
``` ### 10.2 键盘导航 确保所有交互元素都可以通过键盘访问。 ```vue 点击 ``` ## 11. 搜索下拉框规范 ### 11.1 适用场景 对于选项数量较多的下拉选择框(如船公司、代理公司、港口等),应使用可搜索的下拉框,并实现远程搜索和分页加载功能。 ### 11.2 搜索下拉框实现 #### 11.2.1 模板部分 ```vue ``` #### 11.2.2 样式部分 ```css /* 船公司下拉框样式 */ .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 数据属性 ```javascript data() { return { searchForm: { shipownerId: '' }, shipCompanyList: [], shipCompanyPage: 1, shipCompanyPageSize: 10, shipCompanyTotal: 0, shipCompanyLoading: false, shipCompanyHasMore: true } } ``` #### 11.2.4 方法实现 ```javascript 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 生命周期钩子 ```javascript beforeUnmount() { this.removeShipCompanyScrollListener(); } ``` ``` ### 11.3 后端API规范 #### 11.3.1 Controller实现 ```java @GetMapping("/list") public Page 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实现 ```java @Service public class CommonDataService { @Autowired private BusCustomerCompanyRepository busCustomerCompanyRepository; @Autowired private BusCustomerCustomerTypeRepository busCustomerCustomerTypeRepository; public Page searchShippingCompanies(String companyName, Pageable pageable) { // 查询CustomerType=2的所有客户ID List customerTypeRecords = busCustomerCustomerTypeRepository.findByCustomerType(2); Set shippingCompanyCustomerIds = customerTypeRecords.stream() .map(BusCustomerCustomerType::getCustomerId) .collect(Collectors.toSet()); // 查询对应的客户公司信息(支持模糊搜索和分页) Page 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 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实现 ```java @Repository public interface BusCustomerCompanyRepository extends JpaRepository { /** * 根据名称和记录状态查找客户公司(分页) */ Page findByNameContainingAndRecordStatus(String name, Integer recordStatus, Pageable pageable); /** * 根据记录状态查找客户公司(分页) */ Page findByRecordStatus(Integer recordStatus, Pageable pageable); } @Repository public interface BusCustomerCustomerTypeRepository extends JpaRepository { /** * 根据客户类型查找记录 */ List 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 前端显示规范 ```vue ``` **注意**: - `:label`必须绑定到`company.businessCode` - 不能使用`company.companyName`作为显示字段 - BusinessCode是船公司的业务代码,是用户识别船公司的主要标识 #### 11.6.3 后端DTO规范 ```java 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 后端查询规范 ```java // 查询时必须包含BusinessCode字段 shippingCompanies = jdbcTemplate.query(sql.toString(), (rs, rowNum) -> new ShippingCompanyDTO( rs.getString("CustomerCompanyBusinessId"), rs.getString("Name"), rs.getString("BusinessCode"), // 必须包含BusinessCode null, null )); ```