# 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
{{ (currentPage - 1) * pageSize + $index + 1 }}
{{ scope.row.recordStatusName }}
{{ formatDateTime(scope.row.createTime) }}
{{ formatDateTime(scope.row.modifyTime) }}
```
**重要说明:**
- 使用固定宽度(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
));
```