name: "web-dev-best-practices"
@DateTimeFormat(pattern = "yyyy-MM-dd")@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}`;
}
import zhCn from 'element-plus/es/locale/lang/zh-cn'
app.use(ElementPlus, {
locale: zhCn
})
COALESCE 函数处理空值判断@Query("SELECT new com.example.dto(...) " +
"FROM ... " +
"WHERE ... AND (COALESCE(:query, '') = '' OR ...) ")
Page<SearchResult> searchWithPage(@Param("query") String query, Pageable pageable);
COMMON → tugboatcommonBRANCH → liandatugboatmispublic enum DataSourceType {
COMMON("common"), // tugboatcommon: 系统公共库
BRANCH("branch"); // liandatugboatmis: 分支机构业务库
}
<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>
在所有触发后台操作的前端事件中添加进度条和防重复点击机制。
当多个异步操作共享同一个loading状态时,会导致以下问题:
// 错误:多个操作共享同一个loading状态
const loading = ref(false)
const getUsers = async () => {
if (loading.value) return;
loading.value = true;
try {
await this.$axios.get('/api/user/list');
} finally {
loading.value = false;
}
}
const getRecordStatusList = async () => {
if (loading.value) return; // 如果getUsers正在执行,这里会被阻塞
loading.value = true;
try {
await this.$axios.get('/api/record-status');
} finally {
loading.value = false;
}
}
const getEmployeeList = async () => {
if (loading.value) return; // 如果getUsers或getRecordStatusList正在执行,这里会被阻塞
loading.value = true;
try {
await this.$axios.get('/api/employee/list');
} finally {
loading.value = false;
}
}
// 页面初始化时同时调用这三个方法
onMounted(() => {
getUsers(); // 第一个执行
getRecordStatusList(); // 被阻塞,无法执行
getEmployeeList(); // 被阻塞,无法执行
})
// 错误:多个初始化操作共享同一个initLoading状态
const initLoading = ref(false)
const getRecordStatusList = async () => {
if (initLoading.value) return; // 如果getEmployeeList正在执行,这里会被阻塞
initLoading.value = true;
try {
await this.$axios.get('/api/record-status');
} finally {
initLoading.value = false;
}
}
const getEmployeeList = async () => {
if (initLoading.value) return; // 如果getRecordStatusList正在执行,这里会被阻塞
initLoading.value = true;
try {
await this.$axios.get('/api/employee/list');
} finally {
initLoading.value = false;
}
}
// 页面初始化时同时调用这两个方法
onMounted(() => {
getRecordStatusList(); // 第一个执行
getEmployeeList(); // 被阻塞,无法执行
})
// 正确:初始化操作不需要loading状态
const loading = ref(false) // 查询loading状态
const saveLoading = ref(false) // 保存loading状态
const deleteLoading = ref(false) // 删除loading状态
const getUsers = async () => {
if (loading.value) return;
loading.value = true;
try {
await this.$axios.get('/api/user/list');
} finally {
loading.value = false;
}
}
const getRecordStatusList = async () => {
// 不需要loading状态,直接执行
try {
const response = await this.$axios.get('/api/record-status');
recordStatusList.value = response.data;
} catch (error) {
console.error('获取状态列表失败:', error);
}
}
const getEmployeeList = async () => {
// 不需要loading状态,直接执行
try {
const response = await this.$axios.get('/api/employee/list');
employeeList.value = response.data.content;
} catch (error) {
console.error('获取员工列表失败:', error);
}
}
// 页面初始化时同时调用这三个方法,互不阻塞
onMounted(() => {
getUsers(); // 使用loading状态
getRecordStatusList(); // 不使用loading状态,可以同时执行
getEmployeeList(); // 不使用loading状态,可以同时执行
})
loadingsaveLoadingdeleteLoadingresetPasswordLoading、exportLoading等)| 操作类型 | 是否需要loading | 原因 | 示例 |
|---|---|---|---|
| 查询操作 | 是 | 用户主动触发,需要防止重复点击 | 查询按钮、分页切换、排序 |
| 初始化操作 | 否 | 页面加载时自动执行,用户不会重复触发 | 字典数据、下拉选项、基础数据 |
| 保存操作 | 是 | 用户主动触发,需要防止重复提交 | 新增、编辑、保存 |
| 删除操作 | 是 | 用户主动触发,需要防止重复删除 | 单条删除、批量删除 |
| 特定操作 | 是 | 用户主动触发,需要防止重复执行 | 重置密码、导出、导入 |
<template>
<div>
<!-- 查询按钮 -->
<el-button type="primary" @click="handleQuery" :loading="loading">查询</el-button>
<!-- 保存按钮 -->
<el-button type="primary" @click="handleSave" :loading="saveLoading">保存</el-button>
<!-- 删除按钮 -->
<el-button type="danger" @click="handleDelete" :loading="deleteLoading">删除</el-button>
</div>
</template>
<script>
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
export default {
setup() {
const loading = ref(false) // 查询loading状态
const saveLoading = ref(false) // 保存loading状态
const deleteLoading = ref(false) // 删除loading状态
const handleQuery = async () => {
if (loading.value) return; // 防止重复点击
loading.value = true;
try {
// 执行查询操作
await this.queryData();
} catch (error) {
console.error('查询失败:', error);
ElMessage.error('查询失败,请稍后重试');
} finally {
loading.value = false; // 确保无论成功失败都恢复状态
}
}
const handleSave = async () => {
if (saveLoading.value) return;
saveLoading.value = true;
try {
// 执行保存操作
await this.saveData();
ElMessage.success('保存成功');
} catch (error) {
console.error('保存失败:', error);
ElMessage.error('保存失败,请稍后重试');
} finally {
saveLoading.value = false;
}
}
const handleDelete = async () => {
if (deleteLoading.value) return;
deleteLoading.value = true;
try {
// 执行删除操作
await this.deleteData();
ElMessage.success('删除成功');
} catch (error) {
console.error('删除失败:', error);
ElMessage.error('删除失败,请稍后重试');
} finally {
deleteLoading.value = false;
}
}
return {
loading,
saveLoading,
deleteLoading,
handleQuery,
handleSave,
handleDelete
}
}
}
</script>
<template>
<div class="table-container">
<!-- 表格使用v-loading显示加载状态 -->
<el-table
v-loading="loading"
:data="tableData"
stripe
border
style="width: 100%">
<el-table-column prop="name" label="姓名"></el-table-column>
<el-table-column prop="age" label="年龄"></el-table-column>
</el-table>
<!-- 分页控件 -->
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:total="total"
@current-change="handlePageChange"
@size-change="handleSizeChange">
</el-pagination>
</div>
</template>
<script>
import { ref } from 'vue'
export default {
setup() {
const loading = ref(false)
const tableData = ref([])
const currentPage = ref(1)
const pageSize = ref(10)
const total = ref(0)
const getTableData = async () => {
if (loading.value) return;
loading.value = true;
try {
const response = await this.$axios.get('/api/data/list', {
params: {
page: currentPage.value,
pageSize: pageSize.value
}
});
tableData.value = response.data.content;
total.value = response.data.totalElements;
} catch (error) {
console.error('获取数据失败:', error);
} finally {
loading.value = false;
}
}
const handlePageChange = (page) => {
currentPage.value = page;
getTableData();
}
const handleSizeChange = (size) => {
pageSize.value = size;
currentPage.value = 1;
getTableData();
}
return {
loading,
tableData,
currentPage,
pageSize,
total,
getTableData,
handlePageChange,
handleSizeChange
}
}
}
</script>
<template>
<el-dialog v-model="dialogVisible" title="编辑用户" width="600px">
<el-form :model="userForm" :rules="userFormRules" ref="userFormRef">
<el-form-item label="用户名" prop="name">
<el-input v-model="userForm.name" placeholder="请输入用户名"></el-input>
</el-form-item>
<el-form-item label="账号" prop="loginId">
<el-input v-model="userForm.loginId" placeholder="请输入账号"></el-input>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<!-- 提交按钮绑定loading状态 -->
<el-button type="primary" @click="confirmSave" :loading="saveLoading">提交</el-button>
</span>
</template>
</el-dialog>
</template>
<script>
import { ref, reactive } from 'vue'
import { ElMessage } from 'element-plus'
export default {
setup() {
const dialogVisible = ref(false)
const saveLoading = ref(false)
const userFormRef = ref(null)
const userForm = reactive({
name: '',
loginId: ''
})
const userFormRules = {
name: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
loginId: [{ required: true, message: '请输入账号', trigger: 'blur' }]
}
const confirmSave = async () => {
if (!userFormRef.value) return;
try {
await userFormRef.value.validate();
} catch (error) {
console.error('表单验证失败:', error);
return;
}
if (saveLoading.value) return;
saveLoading.value = true;
try {
await this.$axios.post('/api/user/save', userForm);
ElMessage.success('保存成功');
dialogVisible.value = false;
// 刷新列表
this.getTableData();
} catch (error) {
console.error('保存失败:', error);
ElMessage.error('保存失败,请稍后重试');
} finally {
saveLoading.value = false;
}
}
return {
dialogVisible,
saveLoading,
userFormRef,
userForm,
userFormRules,
confirmSave
}
}
}
</script>
<template>
<div class="user-management">
<!-- 查询条件 -->
<div class="search-section">
<div class="search-row">
<el-input v-model="queryForm.name" placeholder="请输入用户名" clearable></el-input>
<el-input v-model="queryForm.loginId" placeholder="请输入账号" clearable></el-input>
<!-- 查询和重置按钮绑定loading状态 -->
<el-button type="primary" @click="getUsers" :loading="loading">查询</el-button>
<el-button @click="resetSearchForm" :loading="loading">重置</el-button>
</div>
</div>
<!-- 操作按钮 -->
<div class="action-section">
<el-button type="primary" @click="openAddDialog">新增</el-button>
<!-- 删除按钮绑定loading状态 -->
<el-button type="danger" @click="deleteSelectedUsers" :loading="deleteLoading">删除</el-button>
</div>
<!-- 表格使用v-loading显示加载状态 -->
<el-table
v-loading="loading"
:data="userList"
stripe
border
@selection-change="handleSelectionChange">
<el-table-column type="selection" width="55"></el-table-column>
<el-table-column prop="name" label="用户名"></el-table-column>
<el-table-column prop="loginId" label="账号"></el-table-column>
<el-table-column label="操作" width="180">
<template #default="{ row }">
<el-button type="primary" size="small" @click="openEditDialog(row)">编辑</el-button>
<el-button type="danger" size="small" @click="deleteUser(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 编辑对话框 -->
<el-dialog v-model="userDialogVisible" title="编辑用户" width="600px">
<el-form :model="userForm" :rules="userFormRules" ref="userFormRef">
<el-form-item label="用户名" prop="name">
<el-input v-model="userForm.name"></el-input>
</el-form-item>
<el-form-item label="账号" prop="loginId">
<el-input v-model="userForm.loginId"></el-input>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="userDialogVisible = false">取消</el-button>
<!-- 提交按钮绑定loading状态 -->
<el-button type="primary" @click="confirmUser" :loading="saveLoading">提交</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
export default {
setup() {
const loading = ref(false) // 查询loading状态
const saveLoading = ref(false) // 保存loading状态
const deleteLoading = ref(false) // 删除loading状态
const queryForm = reactive({
name: '',
loginId: ''
})
const userForm = reactive({
id: '',
name: '',
loginId: ''
})
const userList = ref([])
const employeeList = ref([])
const recordStatusList = ref([])
const selectedUserIds = ref([])
const userDialogVisible = ref(false)
const userFormRef = ref(null)
const userFormRules = {
name: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
loginId: [{ required: true, message: '请输入账号', trigger: 'blur' }]
}
const getUsers = async () => {
if (loading.value) return;
loading.value = true;
try {
const response = await this.$axios.get('/api/user/list', {
params: queryForm
});
userList.value = response.data.content;
} catch (error) {
console.error('获取用户列表失败:', error);
ElMessage.error('获取用户列表失败');
} finally {
loading.value = false;
}
}
const getRecordStatusList = async () => {
// 不需要loading状态,直接执行
try {
const response = await this.$axios.get('/api/record-status');
recordStatusList.value = response.data;
} catch (error) {
console.error('获取状态列表失败:', error);
ElMessage.error('获取状态列表失败');
}
}
const getEmployeeList = async () => {
// 不需要loading状态,直接执行
try {
const response = await this.$axios.get('/api/employee/list');
employeeList.value = response.data.content;
} catch (error) {
console.error('获取员工列表失败:', error);
ElMessage.error('获取员工列表失败');
}
}
const confirmUser = async () => {
if (!userFormRef.value) return;
try {
await userFormRef.value.validate();
} catch (error) {
console.error('表单验证失败:', error);
return;
}
if (saveLoading.value) return;
saveLoading.value = true;
try {
await this.$axios.post('/api/user/save', userForm);
ElMessage.success('保存成功');
userDialogVisible.value = false;
getUsers();
} catch (error) {
console.error('保存失败:', error);
ElMessage.error('保存失败');
} finally {
saveLoading.value = false;
}
}
const deleteSelectedUsers = async () => {
if (selectedUserIds.value.length === 0) return;
if (deleteLoading.value) return;
deleteLoading.value = true;
try {
await this.$axios.delete('/api/user/delete', {
data: selectedUserIds.value
});
ElMessage.success('删除成功');
selectedUserIds.value = [];
getUsers();
} catch (error) {
console.error('删除失败:', error);
ElMessage.error('删除失败');
} finally {
deleteLoading.value = false;
}
}
const deleteUser = async (userId) => {
if (deleteLoading.value) return;
deleteLoading.value = true;
try {
await this.$axios.delete('/api/user/delete', {
data: [userId]
});
ElMessage.success('删除成功');
getUsers();
} catch (error) {
console.error('删除失败:', error);
ElMessage.error('删除失败');
} finally {
deleteLoading.value = false;
}
}
const handleSelectionChange = (selection) => {
selectedUserIds.value = selection.map(row => row.id);
}
const resetSearchForm = () => {
queryForm.name = '';
queryForm.loginId = '';
getUsers();
}
const openAddDialog = () => {
userForm.id = '';
userForm.name = '';
userForm.loginId = '';
userDialogVisible.value = true;
}
const openEditDialog = (row) => {
userForm.id = row.id;
userForm.name = row.name;
userForm.loginId = row.loginId;
userDialogVisible.value = true;
}
onMounted(() => {
getRecordStatusList(); // 加载状态列表
getEmployeeList(); // 加载员工列表
getUsers(); // 加载用户列表
})
return {
loading,
saveLoading,
deleteLoading,
queryForm,
userForm,
userFormRules,
userList,
employeeList,
recordStatusList,
selectedUserIds,
userDialogVisible,
userFormRef,
getUsers,
getRecordStatusList,
getEmployeeList,
confirmUser,
deleteSelectedUsers,
deleteUser,
handleSelectionChange,
resetSearchForm,
openAddDialog,
openEditDialog
}
}
}
</script>
注解使用场景:
@DateTimeFormat 用于请求参数绑定@JsonFormat 用于JSON序列化/反序列化命名规范:
数据一致性:
性能考虑:
关联数据显示问题:
// DTO类提供多个fromEntity重载方法 public static UserDTO fromEntity(User user, String roleName, String recordStatusName) {
UserDTO dto = new UserDTO();
dto.setEmployeeId(""); // 不包含employeeId
// ... 其他字段设置
return dto;
}
public static UserDTO fromEntity(User user, String roleName, String recordStatusName, String employeeId) {
UserDTO dto = new UserDTO();
dto.setEmployeeId(employeeId != null ? employeeId : ""); // 包含employeeId
// ... 其他字段设置
return dto;
}
- **前端实现**:
```vue
<el-select v-model="userForm.employeeId" placeholder="请选择关联用户">
<el-option
v-for="employee in employeeList"
:key="employee.employeeId"
:label="employee.name"
:value="employee.employeeId">
</el-option>
</el-select>
在开发新页面功能、调试常见Web应用问题、处理日期时间、配置数据源、优化前端组件时使用此技能。
按钮并排布局实现:
数据库级排序和分页实现:
size="small"或size="default"属性white-space: nowrap防止内容换行text-overflow: ellipsis截断显示position: sticky或Element Plus的sticky-header属性fixed="left"锁定序号列、操作列等fixed="right"锁定操作列<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>
<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>
<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>
@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;
}
}
关键点说明:
page * pageSize + i + 1,确保序号连续且正确@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);
}
}
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('获取引航计划失败,请稍后重试');
});
}
}
关键点说明:
type="index"时,每页序号都从1开始(currentPage - 1) * pageSize + $index + 1(currentPage - 1) * pageSize + $index + 1
(1-1) * 10 + 0 + 1 = 1(1-1) * 10 + 9 + 1 = 10(2-1) * 10 + 0 + 1 = 11(2-1) * 10 + 9 + 1 = 20<template>
<el-table
:data="tableData"
style="width: 100%"
size="default"
stripe
border
:header-cell-style="{ background: '#f5f7fa', color: '#303133', fontWeight: 'bold' }"
@sort-change="handleSortChange">
<!-- 自定义序号列 - 固定在左侧 -->
<el-table-column
label="序号"
width="60"
fixed
align="center">
<template #default="{ $index }">
{{ (currentPage - 1) * pageSize + $index + 1 }}
</template>
</el-table-column>
<!-- 数据列 -->
<el-table-column
prop="planTime"
label="计划时间"
width="160"
sortable
align="center">
<template #default="scope">
{{ formatDateTime(scope.row.planTime) }}
</template>
</el-table-column>
<!-- 其他数据列... -->
</el-table>
<!-- 分页控件 -->
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@current-change="handlePageChange"
@size-change="handleSizeChange">
</el-pagination>
</template>
<script>
export default {
data() {
return {
tableData: [],
currentPage: 1, // 当前页码,从1开始
pageSize: 10, // 每页显示的记录数
total: 0 // 总记录数
}
},
methods: {
handlePageChange(page) {
this.currentPage = page;
this.getPilotPlans();
},
handleSizeChange(size) {
this.pageSize = size;
this.currentPage = 1; // 改变每页大小时重置到第一页
this.getPilotPlans();
},
async getPilotPlans() {
const response = await this.$axios.get('/api/pilot-plan/list', {
params: {
page: this.currentPage,
pageSize: this.pageSize
}
});
this.tableData = response.data.content;
this.total = response.data.totalElements;
}
}
}
</script>
<!-- 错误:使用type="index"会导致每页序号都从1开始 -->
<el-table-column
type="index"
label="序号"
width="60"
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>
#default="{ $index }"获取当前行的索引(currentPage - 1) * pageSize + $index + 1确保序号连续fixed属性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')
// 权限指令,用于控制按钮显示
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;
<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>
根据常见命名规范,引航计划相关的功能代码可能如下:
| 按钮 | 功能代码 | 说明 |
|---|---|---|
| 展开 | 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表中查询确认。
创建TabsView.vue组件,包含以下功能:
<router-view>显示当前标签页内容在HomePage.vue中引入并使用TabsView组件:
<template>
<div class="home-page">
<!-- 顶部导航栏 -->
<nav class="navbar">...</nav>
<!-- 侧边栏 -->
<div class="leftsidebar">...</div>
<!-- 主内容区域 -->
<section class="content">
<div class="container-fluid">
<tabs-view></tabs-view>
</div>
</section>
</div>
</template>
<script>
import TabsView from './TabsView.vue'
export default {
name: 'HomePage',
components: {
TabsView
}
}
</script>
<template>
<div class="tabs-container">
<!-- 标签页头部 -->
<div class="tabs-header">
<div
v-for="tab in tabs"
:key="tab.name"
:class="['tab-item', { 'active': activeTab === tab.name }]"
@click="switchTab(tab)"
>
<span class="tab-title">{{ tab.title }}</span>
<i
v-if="tab.closable"
class="el-icon-close tab-close"
@click.stop="closeTab(tab)"
></i>
</div>
<div class="tabs-actions">
<el-dropdown @command="handleTabAction">
<span class="tabs-action-btn">
<i class="el-icon-arrow-down"></i>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="closeCurrent">关闭当前</el-dropdown-item>
<el-dropdown-item command="closeOthers">关闭其他</el-dropdown-item>
<el-dropdown-item command="closeAll">关闭全部</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
<!-- 标签页内容 -->
<div class="tabs-content">
<router-view v-slot="{ Component, route }">
<keep-alive :include="cachedViews">
<component :is="Component" :key="route.name" />
</keep-alive>
</router-view>
</div>
</div>
</template>
<script>
export default {
name: 'TabsView',
data() {
return {
tabs: [],
activeTab: ''
}
},
computed: {
/**
* 计算需要缓存的视图名称
*/
cachedViews() {
const routeToComponentMap = {
'home.index': 'MainIndex',
'home.mynotice': 'MyNotice',
'home.settings_announcement': 'AnnouncementManagement',
'home.settings_role': 'RoleManagement',
'home.settings_user': 'UserManagement',
'home.pilot_plan': 'PilotPlan'
}
return this.tabs.map(tab => routeToComponentMap[tab.name] || tab.name)
},
/**
* 计算可关闭的标签页数量
*/
closableTabsCount() {
return this.tabs.filter(tab => tab.closable).length
}
},
mounted() {
this.ensureHomeTab()
},
watch: {
$route: {
handler(to) {
this.addTab(to)
},
immediate: true
}
},
methods: {
/**
* 确保首页标签页始终存在
*/
ensureHomeTab() {
const homeTab = this.tabs.find(tab => tab.name === 'home.index')
if (!homeTab) {
this.tabs.unshift({
name: 'home.index',
title: '首页',
closable: false
})
}
},
/**
* 添加标签页
*/
addTab(route) {
if (!route.name) return
// 确保首页标签页始终存在
this.ensureHomeTab()
// 如果是首页,直接激活即可
if (route.name === 'home.index') {
this.activeTab = 'home.index'
return
}
const tabTitle = this.getTabTitle(route)
const existingTab = this.tabs.find(tab => tab.name === route.name)
if (!existingTab) {
// 在首页标签页之后插入新标签页
const homeTabIndex = this.tabs.findIndex(tab => tab.name === 'home.index')
const insertIndex = homeTabIndex + 1
this.tabs.splice(insertIndex, 0, {
name: route.name,
title: tabTitle,
closable: true
})
}
this.activeTab = route.name
},
/**
* 获取标签页标题
*/
getTabTitle(route) {
const titleMap = {
'home.index': '首页',
'home.mynotice': '我的通知',
'home.settings_announcement': '公告管理',
'home.settings_role': '角色管理',
'home.settings_user': '用户管理',
'home.pilot_plan': '引航计划'
}
return titleMap[route.name] || route.name
},
/**
* 切换标签页
*/
switchTab(tab) {
if (tab.name !== this.activeTab) {
this.$router.push({ name: tab.name })
}
},
/**
* 关闭标签页
*/
closeTab(tab) {
const index = this.tabs.findIndex(t => t.name === tab.name)
if (index === -1) return
this.tabs.splice(index, 1)
// 如果关闭的是当前激活的标签页,切换到其他标签页
if (tab.name === this.activeTab) {
if (this.tabs.length > 0) {
const newIndex = Math.min(index, this.tabs.length - 1)
this.switchTab(this.tabs[newIndex])
} else {
this.$router.push({ name: 'home.index' })
}
}
},
/**
* 处理标签页操作
*/
handleTabAction(command) {
switch (command) {
case 'closeCurrent':
this.closeCurrentTab()
break
case 'closeOthers':
this.closeOtherTabs()
break
case 'closeAll':
this.closeAllTabs()
break
}
},
/**
* 关闭当前标签页
*/
closeCurrentTab() {
const currentTab = this.tabs.find(tab => tab.name === this.activeTab)
if (currentTab && currentTab.closable) {
this.closeTab(currentTab)
}
},
/**
* 关闭其他标签页
*/
closeOtherTabs() {
const currentTab = this.tabs.find(tab => tab.name === this.activeTab)
if (currentTab) {
this.tabs = [currentTab]
}
},
/**
* 关闭全部标签页
*/
closeAllTabs() {
// 只关闭可关闭的标签页,保留首页
this.tabs = this.tabs.filter(tab => !tab.closable)
// 如果当前标签页被关闭了,切换到首页
const currentTab = this.tabs.find(tab => tab.name === this.activeTab)
if (!currentTab) {
this.$router.push({ name: 'home.index' })
}
}
}
}
</script>
tabs数组管理所有打开的标签页watch监听$route变化,自动添加标签页keep-alive组件缓存页面状态,切换标签页时不会重新加载cachedViews计算属性动态管理需要缓存的视图名称问题类型:
解决方案:
keep-alive组件缓存组件实例include属性指定需要缓存的组件名称v-slot获取路由信息,为每个组件设置唯一的key<!-- 标签页内容 -->
<div class="tabs-content">
<router-view v-slot="{ Component, route }">
<keep-alive :include="cachedViews">
<component :is="Component" :key="route.name" />
</keep-alive>
</router-view>
</div>
computed: {
/**
* 计算需要缓存的视图名称
*/
cachedViews() {
const routeToComponentMap = {
'home.index': 'MainIndex',
'home.mynotice': 'MyNotice',
'home.settings_announcement': 'AnnouncementManagement',
'home.settings_role': 'RoleManagement',
'home.settings_user': 'UserManagement',
'home.pilot_plan': 'PilotPlan'
}
return this.tabs.map(tab => routeToComponentMap[tab.name] || tab.name)
}
}
重要:keep-alive的include属性需要的是组件的name选项,而不是路由名称。因此需要建立路由名称到组件名称的映射关系。
映射表:
| 路由名称 | 组件名称 | 说明 |
|---|---|---|
| home.index | MainIndex | 首页 |
| home.mynotice | MyNotice | 我的通知 |
| home.settings_announcement | AnnouncementManagement | 公告管理 |
| home.settings_role | RoleManagement | 角色管理 |
| home.settings_user | UserManagement | 用户管理 |
| home.pilot_plan | PilotPlan | 引航计划 |
动态映射:
const routeToComponentMap = {
'home.index': 'MainIndex',
'home.mynotice': 'MyNotice',
'home.settings_announcement': 'AnnouncementManagement',
'home.settings_role': 'RoleManagement',
'home.settings_user': 'UserManagement',
'home.pilot_plan': 'PilotPlan'
}
return this.tabs.map(tab => routeToComponentMap[tab.name] || tab.name)
为了使keep-alive正常工作,组件必须定义name选项:
export default {
name: 'UserManagement', // 必须定义name
// ...
}
所有组件的name定义:
// MainIndex.vue
export default {
name: 'MainIndex',
// ...
}
// MyNotice.vue
export default {
name: 'MyNotice',
// ...
}
// Announcement.vue
export default {
name: 'AnnouncementManagement',
// ...
}
// RoleManagement.vue
export default {
name: 'RoleManagement',
// ...
}
// UserManagement.vue
export default {
name: 'UserManagement',
// ...
}
// PilotPlan.vue
export default {
name: 'PilotPlan',
// ...
}
name选项cachedViews中的名称必须与组件的name选项完全匹配/* 标签页容器 */
.tabs-container {
display: flex;
flex-direction: column;
height: 100%;
background-color: #f5f5f5;
}
/* 标签页头部 */
.tabs-header {
display: flex;
align-items: center;
background-color: #fff;
border-bottom: 1px solid #e4e7ed;
padding: 0 16px;
height: 40px;
}
/* 标签页项 */
.tab-item {
display: flex;
align-items: center;
padding: 0 16px;
height: 32px;
margin: 4px 4px 4px 0;
background-color: #f5f7fa;
border: 1px solid #e4e7ed;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s ease;
font-size: 13px;
}
.tab-item:hover {
background-color: #e6f7ff;
border-color: #1890ff;
color: #1890ff;
}
.tab-item.active {
background-color: #fff;
border-color: #1890ff;
color: #1890ff;
}
/* 标签页标题 */
.tab-title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 120px;
flex-shrink: 0;
}
/* 标签页关闭按钮 */
.tab-close {
margin-left: 8px;
font-size: 14px;
opacity: 1 !important;
transition: all 0.2s ease;
flex-shrink: 0;
display: flex !important;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 50%;
cursor: pointer;
visibility: visible !important;
}
.tab-close:hover {
opacity: 1 !important;
color: #ff4d4f;
background-color: #fff0f0;
}
/* 确保tab-item hover时关闭按钮也可见 */
.tab-item:hover .tab-close {
opacity: 1 !important;
visibility: visible !important;
}
/* 标签页内容区域 */
.tabs-content {
flex: 1;
overflow: hidden;
background-color: #ecf0f5;
}
getTabTitle方法中维护路由名称到标题的映射v-if="tab.closable"控制显示opacity: 1 !important:使用!important确保关闭按钮始终可见visibility: visible !important:强制设置可见性display: flex !important:强制显示font-size: 14px:使用合适的字体大小width/height: 16px:设置固定尺寸,确保按钮可点击border-radius: 50%:圆形背景,鼠标悬停时显示圆形背景flex-shrink: 0:防止按钮被压缩cursor: pointer:鼠标悬停时显示手型光标max-width: 120px,flex-shrink: 0,确保关闭按钮有足够空间keep-alive组件保留表单状态问题:关闭按钮不显示或不够明显
解决方案:
/* 标签页标题 */
.tab-title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 120px; /* 限制标题宽度,为关闭按钮留出空间 */
flex-shrink: 0; /* 防止标题被压缩 */
}
/* 标签页关闭按钮 */
.tab-close {
margin-left: 8px;
font-size: 14px; /* 足够大的字体 */
opacity: 1 !important; /* 使用!important强制始终可见 */
transition: all 0.2s ease;
flex-shrink: 0; /* 防止按钮被压缩 */
display: flex !important; /* 强制显示 */
align-items: center;
justify-content: center;
width: 16px; /* 固定宽度 */
height: 16px; /* 固定高度 */
border-radius: 50%; /* 圆形背景 */
cursor: pointer;
visibility: visible !important; /* 强制设置可见性 */
color: #909399 !important; /* 设置默认颜色,确保可见 */
background-color: transparent; /* 透明背景 */
}
.tab-close:hover {
opacity: 1 !important;
color: #ff4d4f !important; /* 悬停时显示红色 */
background-color: #fff0f0 !important; /* 浅红色背景 */
}
/* 确保tab-item hover时关闭按钮也可见 */
.tab-item:hover .tab-close {
opacity: 1 !important;
visibility: visible !important;
color: #606266 !important; /* hover时显示深灰色 */
}
/* 激活状态的关闭按钮 */
.tab-item.active .tab-close {
color: #606266 !important; /* 激活状态显示深灰色 */
}
.tab-item.active .tab-close:hover {
color: #ff4d4f !important;
background-color: #fff0f0 !important;
}
关键点:
opacity: 1 !important确保关闭按钮始终可见,使用!important防止被其他样式覆盖display: flex !important和visibility: visible !important强制按钮显示width和height设置为固定值,确保按钮可点击flex-shrink: 0防止按钮被标题挤压border-radius: 50%创建圆形背景color: #909399 !important设置默认颜色,确保按钮可见.tab-item:hover .tab-close规则,确保鼠标悬停时按钮也可见.tab-item.active .tab-close设置激活状态的颜色问题:下拉菜单按钮(关闭全部等)看不见或不够明显
解决方案:
/* 标签页操作区域 */
.tabs-actions {
margin-left: auto;
display: flex;
align-items: center;
padding-left: 8px; /* 添加左边距,与标签页分隔 */
}
.tabs-action-btn {
display: flex !important;
align-items: center;
justify-content: center;
width: 32px; /* 固定宽度 */
height: 32px; /* 固定高度 */
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
color: #606266 !important; /* 设置深灰色,确保可见 */
background-color: transparent; /* 透明背景 */
opacity: 1 !important; /* 始终可见 */
visibility: visible !important; /* 强制设置可见性 */
}
.tabs-action-btn:hover {
background-color: #f5f7fa !important; /* 悬停时显示浅灰色背景 */
color: #1890ff !important; /* 悬停时显示蓝色 */
}
关键点:
display: flex !important、opacity: 1 !important和visibility: visible !important强制按钮显示width: 32px和height: 32px设置固定尺寸,确保按钮可点击color: #606266 !important设置深灰色,确保按钮可见background-color: transparent设置透明背景,不干扰标签页样式padding-left: 8px添加左边距,与标签页分隔transition: all 0.2s ease添加平滑的过渡动画问题:关闭全部标签页时会连首页的tab也关闭掉
解决方案:
/**
* 关闭全部标签页
*/
closeAllTabs() {
// 只关闭可关闭的标签页,保留首页
this.tabs = this.tabs.filter(tab => !tab.closable)
// 如果当前标签页被关闭了,切换到首页
const currentTab = this.tabs.find(tab => tab.name === this.activeTab)
if (!currentTab) {
this.$router.push({ name: 'home.index' })
}
}
关键点:
filter方法只保留closable: false的标签页(即首页)closable属性为false,不会被关闭$router.push跳转到首页路由el-icon-*类名无法正常工作Element Plus 2.x版本的图标使用方式与Element UI 2.x完全不同:
<i class="el-icon-close"></i>类名方式确保项目中已安装@element-plus/icons-vue包:
npm install @element-plus/icons-vue
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
const app = createApp(App)
app.use(ElementPlus, {
locale: zhCn
})
// 注册所有Element Plus图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.mount('#app')
<template>
<div>
<!-- 方式1:使用el-icon组件包裹图标组件 -->
<el-icon class="icon-class">
<Close />
</el-icon>
<!-- 方式2:直接使用图标组件 -->
<ArrowDown />
<!-- 方式3:在按钮中使用 -->
<el-button @click="handleClose">
<el-icon><Close /></el-icon>
关闭
</el-button>
</div>
</template>
<script>
import { Close, ArrowDown } from '@element-plus/icons-vue'
export default {
name: 'MyComponent',
components: {
Close,
ArrowDown
}
}
</script>
| 功能 | Element UI 2.x | Element Plus 2.x |
|---|---|---|
| 关闭 | <i class="el-icon-close"></i> |
<el-icon><Close /></el-icon> |
| 箭头向下 | <i class="el-icon-arrow-down"></i> |
<el-icon><ArrowDown /></el-icon> |
| 箭头向上 | <i class="el-icon-arrow-up"></i> |
<el-icon><ArrowUp /></el-icon> |
| 箭头向左 | <i class="el-icon-arrow-left"></i> |
<el-icon><ArrowLeft /></el-icon> |
| 箭头向右 | <i class="el-icon-arrow-right"></i> |
<el-icon><ArrowRight /></el-icon> |
| 加载中 | <i class="el-icon-loading"></i> |
<el-icon><Loading /></el-icon> |
| 搜索 | <i class="el-icon-search"></i> |
<el-icon><Search /></el-icon> |
| 编辑 | <i class="el-icon-edit"></i> |
<el-icon><Edit /></el-icon> |
| 删除 | <i class="el-icon-delete"></i> |
<el-icon><Delete /></el-icon> |
| 加号 | <i class="el-icon-plus"></i> |
<el-icon><Plus /></el-icon> |
| 减号 | <i class="el-icon-minus"></i> |
<el-icon><Minus /></el-icon> |
| 信息 | <i class="el-icon-info"></i> |
<el-icon><InfoFilled /></el-icon> |
| 成功 | <i class="el-icon-success"></i> |
<el-icon><SuccessFilled /></el-icon> |
| 警告 | <i class="el-icon-warning"></i> |
<el-icon><WarningFilled /></el-icon> |
| 错误 | <i class="el-icon-error"></i> |
<el-icon><CircleCloseFilled /></el-icon> |
el-icon-*类名方式<el-icon>组件包裹图标组件Close、ArrowDownclass属性控制图标样式,不要直接在组件上设置样式/* 图标基础样式 */
.el-icon {
font-size: 16px;
color: #606266;
}
/* 图标悬停效果 */
.el-icon:hover {
color: #1890ff;
cursor: pointer;
}
/* 图标禁用状态 */
.el-icon.disabled {
color: #c0c4cc;
cursor: not-allowed;
}
/* 图标旋转动画 */
.el-icon.rotating {
animation: rotate 1s linear infinite;
}
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
<template>
<div class="tabs-container">
<div class="tabs-header">
<div
v-for="tab in tabs"
:key="tab.name"
:class="['tab-item', { 'active': activeTab === tab.name }]"
@click="switchTab(tab)"
>
<span class="tab-title">{{ tab.title }}</span>
<el-icon
v-if="tab.closable"
class="tab-close"
@click.stop="closeTab(tab)"
>
<Close />
</el-icon>
</div>
<div class="tabs-actions">
<el-dropdown @command="handleTabAction">
<span class="tabs-action-btn">
<el-icon><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="closeCurrent">关闭当前</el-dropdown-item>
<el-dropdown-item command="closeOthers">关闭其他</el-dropdown-item>
<el-dropdown-item command="closeAll">关闭全部</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</div>
</template>
<script>
import { Close, ArrowDown } from '@element-plus/icons-vue'
export default {
name: 'TabsView',
components: {
Close,
ArrowDown
},
data() {
return {
tabs: [],
activeTab: ''
}
},
methods: {
closeTab(tab) {
// 关闭标签页逻辑
},
handleTabAction(command) {
// 处理标签页操作
}
}
}
</script>
<style scoped>
.tab-close {
margin-left: 8px;
font-size: 14px;
opacity: 1 !important;
transition: all 0.2s ease;
flex-shrink: 0;
display: flex !important;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 50%;
cursor: pointer;
visibility: visible !important;
color: #909399 !important;
background-color: transparent;
}
.tab-close:hover {
opacity: 1 !important;
color: #ff4d4f !important;
background-color: #fff0f0 !important;
}
.tabs-action-btn {
display: flex !important;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
color: #606266 !important;
background-color: transparent;
opacity: 1 !important;
visibility: visible !important;
}
.tabs-action-btn:hover {
background-color: #f5f7fa !important;
color: #1890ff !important;
}
</style>
@element-plus/icons-vue包class属性控制图标样式,不要直接在组件上设置样式Close、ArrowDown<el-icon>组件包裹图标组件错误代码:
<template>
<i class="el-icon-close"></i>
</template>
正确代码:
<template>
<el-icon><Close /></el-icon>
</template>
<script>
import { Close } from '@element-plus/icons-vue'
export default {
components: { Close }
}
</script>
错误信息:
Failed to resolve component: Close
解决方案:
错误代码:
<template>
<el-icon style="font-size: 20px; color: red;">
<Close />
</el-icon>
</template>
正确代码:
<template>
<el-icon class="my-icon">
<Close />
</el-icon>
</template>
<style scoped>
.my-icon {
font-size: 20px;
color: red;
}
</style>
el-icon-*类名方式@element-plus/icons-vue包<el-icon>组件包裹图标组件class属性,不要直接在组件上设置样式Close、ArrowDown