Files
klp-oa/klp-ui/src/views/system/menu/index.vue
砂糖 ef0614f987 feat: 新增绩效系统核心模块并修复菜单路径问题
本次提交完成多项核心功能开发与优化:
1. 修复菜单路径计算逻辑,目录节点路径拼接祖先路径
2. 为绩效相关实体类添加日期格式化注解,统一参数绑定格式
3. 新增10+个绩效系统API接口,覆盖部门、薪资、考核等模块
4. 新增绩效系统首页、部门管理、薪资明细等多个页面组件
2026-07-10 10:43:16 +08:00

796 lines
28 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="app-container">
<el-form :model="queryParams" ref="queryForm" size="small" :inline="true" v-show="showSearch">
<el-form-item label="菜单名称" prop="menuName">
<el-input
v-model="queryParams.menuName"
placeholder="请输入菜单名称"
clearable
@keyup.enter.native="handleQuery"
/>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="queryParams.status" placeholder="菜单状态" clearable>
<el-option
v-for="dict in dict.type.sys_normal_disable"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button
type="primary"
plain
icon="el-icon-plus"
size="mini"
@click="handleAdd"
v-hasPermi="['system:menu:add']"
>新增</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="info"
plain
icon="el-icon-sort"
size="mini"
@click="toggleExpandAll"
>展开/折叠</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="warning"
plain
icon="el-icon-download"
size="mini"
@click="handleExport"
>导出</el-button>
</el-col>
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
</el-row>
<KLPTable
v-if="refreshTable"
v-loading="loading"
:data="menuList"
row-key="menuId"
:default-expand-all="isExpandAll"
:tree-props="{children: 'children', hasChildren: 'hasChildren'}"
>
<el-table-column prop="menuName" label="菜单名称" :show-overflow-tooltip="true" width="160"></el-table-column>
<el-table-column prop="icon" label="图标" align="center" width="100">
<template slot-scope="scope">
<svg-icon :icon-class="scope.row.icon" />
</template>
</el-table-column>
<el-table-column prop="orderNum" label="排序" width="60"></el-table-column>
<el-table-column prop="perms" label="权限标识" :show-overflow-tooltip="true"></el-table-column>
<el-table-column prop="component" label="组件路径" :show-overflow-tooltip="true"></el-table-column>
<el-table-column prop="status" label="状态" width="80">
<template slot-scope="scope">
<dict-tag :options="dict.type.sys_normal_disable" :value="scope.row.status"/>
</template>
</el-table-column>
<el-table-column label="创建时间" align="center" prop="createTime">
<template slot-scope="scope">
<span>{{ parseTime(scope.row.createTime) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" align="center" class-name="small-padding fixed-width">
<template slot-scope="scope">
<el-button
size="mini"
type="text"
icon="el-icon-edit"
@click="handleUpdate(scope.row)"
v-hasPermi="['system:menu:edit']"
>修改</el-button>
<el-button
size="mini"
type="text"
icon="el-icon-plus"
@click="handleAdd(scope.row)"
v-hasPermi="['system:menu:add']"
>新增</el-button>
<el-button
size="mini"
type="text"
icon="el-icon-document-copy"
@click="handleCopyAdd(scope.row)"
v-hasPermi="['system:menu:add']"
>复制新增</el-button>
<el-button
size="mini"
type="text"
icon="el-icon-delete"
@click="handleDelete(scope.row)"
v-hasPermi="['system:menu:remove']"
>删除</el-button>
</template>
</el-table-column>
</KLPTable>
<!-- 添加或修改菜单对话框 -->
<el-dialog :title="title" :visible.sync="open" width="680px" append-to-body>
<el-form ref="form" :model="form" :rules="rules" label-width="100px">
<el-row>
<el-col :span="24">
<el-form-item label="上级菜单" prop="parentId">
<treeselect
v-model="form.parentId"
:options="menuOptions"
:normalizer="normalizer"
:show-count="true"
placeholder="选择上级菜单"
/>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="菜单类型" prop="menuType">
<el-radio-group v-model="form.menuType">
<el-radio label="M">目录</el-radio>
<el-radio label="C">菜单</el-radio>
<el-radio label="F">按钮</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :span="24" v-if="form.menuType != 'F'">
<el-form-item label="菜单图标" prop="icon">
<el-popover
placement="bottom-start"
width="460"
trigger="click"
@show="$refs['iconSelect'].reset()"
>
<IconSelect ref="iconSelect" @selected="selected" :active-icon="form.icon" />
<el-input slot="reference" v-model="form.icon" placeholder="点击选择图标" readonly>
<svg-icon
v-if="form.icon"
slot="prefix"
:icon-class="form.icon"
style="width: 25px;"
/>
<i v-else slot="prefix" class="el-icon-search el-input__icon" />
</el-input>
</el-popover>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="菜单名称" prop="menuName">
<el-input v-model="form.menuName" placeholder="请输入菜单名称" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="显示排序" prop="orderNum">
<el-input-number :controls=false controls-position="right" v-model="form.orderNum" :min="0" />
</el-form-item>
</el-col>
<el-col :span="12" v-if="form.menuType != 'F'">
<el-form-item prop="isFrame">
<span slot="label">
<el-tooltip content="选择是外链则路由地址需要以`http(s)://`开头" placement="top">
<i class="el-icon-question"></i>
</el-tooltip>
是否外链
</span>
<el-radio-group v-model="form.isFrame">
<el-radio label="0"></el-radio>
<el-radio label="1"></el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :span="12" v-if="form.menuType != 'F'">
<el-form-item prop="path">
<span slot="label">
<el-tooltip content="访问的路由地址,如:`user`,如外网地址需内链访问则以`http(s)://`开头" placement="top">
<i class="el-icon-question"></i>
</el-tooltip>
路由地址
</span>
<el-input v-model="form.path" placeholder="请输入路由地址" />
</el-form-item>
</el-col>
<el-col :span="12" v-if="form.menuType == 'C'">
<el-form-item prop="component">
<span slot="label">
<el-tooltip content="访问的组件路径,如:`system/user/index`,默认在`views`目录下" placement="top">
<i class="el-icon-question"></i>
</el-tooltip>
组件路径
</span>
<el-input v-model="form.component" placeholder="请输入组件路径" />
</el-form-item>
</el-col>
<el-col :span="12" v-if="form.menuType != 'M'">
<el-form-item prop="perms">
<el-input v-model="form.perms" placeholder="请输入权限标识" maxlength="100" />
<span slot="label">
<el-tooltip content="控制器中定义的权限字符,如:@SaCheckPermission('system:user:list')" placement="top">
<i class="el-icon-question"></i>
</el-tooltip>
权限字符
</span>
</el-form-item>
</el-col>
<el-col :span="12" v-if="form.menuType == 'C'">
<el-form-item prop="queryParam">
<el-input v-model="form.queryParam" placeholder="请输入路由参数" maxlength="255" />
<span slot="label">
<el-tooltip content='访问路由的默认传递参数,如:`{"id": 1, "name": "ry"}`' placement="top">
<i class="el-icon-question"></i>
</el-tooltip>
路由参数
</span>
</el-form-item>
</el-col>
<el-col :span="24" v-if="form.menuType != 'F'">
<el-form-item prop="style">
<span slot="label">
<el-tooltip content='菜单样式JSON格式如`{"backgroundColor":"#ff0000","color":"#fff","fontWeight":"bold"}`' placement="top">
<i class="el-icon-question"></i>
</el-tooltip>
菜单样式
</span>
<div class="style-editor">
<el-input
type="textarea"
v-model="form.style"
placeholder='请输入菜单样式JSON格式'
:rows="4"
maxlength="500"
show-word-limit
class="style-textarea"
/>
<div class="style-toolbar">
<div class="style-presets">
<span class="preset-label">快捷样式</span>
<el-button size="mini" @click="applyStylePreset('danger')">浅红</el-button>
<el-button size="mini" @click="applyStylePreset('success')">浅绿</el-button>
<el-button size="mini" @click="applyStylePreset('warning')">浅橙</el-button>
<el-button size="mini" @click="applyStylePreset('primary')">浅蓝</el-button>
<el-button size="mini" @click="applyStylePreset('accent')">浅紫</el-button>
<el-button size="mini" @click="applyStylePreset('warm')">暖色</el-button>
<el-button size="mini" @click="applyStylePreset('bold')">加粗</el-button>
<el-button size="mini" @click="applyStylePreset('italic')">斜体</el-button>
<el-button size="mini" @click="applyStylePreset('clear')">清除</el-button>
</div>
<div class="style-actions">
<el-button size="mini" type="success" icon="el-icon-check" @click="validateStyle">校验</el-button>
<el-button size="mini" type="info" icon="el-icon-view" @click="showPreview = !showPreview">{{ showPreview ? '隐藏预览' : '预览样式' }}</el-button>
</div>
</div>
<div v-if="styleError" class="style-error">
<i class="el-icon-warning"></i> {{ styleError }}
</div>
<div v-if="showPreview && form.style" class="style-preview">
<div class="preview-title">菜单预览效果</div>
<div class="preview-container">
<div class="preview-menu-item" :style="parsedStyle">
<span class="preview-icon">📁</span>
<span class="preview-text">{{ form.menuName || '菜单名称' }}</span>
</div>
</div>
</div>
</div>
</el-form-item>
</el-col>
<el-col :span="12" v-if="form.menuType == 'C'">
<el-form-item prop="isCache">
<span slot="label">
<el-tooltip content="选择是则会被`keep-alive`缓存,需要匹配组件的`name`和地址保持一致" placement="top">
<i class="el-icon-question"></i>
</el-tooltip>
是否缓存
</span>
<el-radio-group v-model="form.isCache">
<el-radio label="0">缓存</el-radio>
<el-radio label="1">不缓存</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :span="12" v-if="form.menuType != 'F'">
<el-form-item prop="visible">
<span slot="label">
<el-tooltip content="选择隐藏则路由将不会出现在侧边栏,但仍然可以访问" placement="top">
<i class="el-icon-question"></i>
</el-tooltip>
显示状态
</span>
<el-radio-group v-model="form.visible">
<el-radio
v-for="dict in dict.type.sys_show_hide"
:key="dict.value"
:label="dict.value"
>{{dict.label}}</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :span="12" v-if="form.menuType != 'F'">
<el-form-item prop="status">
<span slot="label">
<el-tooltip content="选择停用则路由将不会出现在侧边栏,也不能被访问" placement="top">
<i class="el-icon-question"></i>
</el-tooltip>
菜单状态
</span>
<el-radio-group v-model="form.status">
<el-radio
v-for="dict in dict.type.sys_normal_disable"
:key="dict.value"
:label="dict.value"
>{{dict.label}}</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
</el-row>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button type="primary" @click="submitForm"> </el-button>
<el-button @click="cancel"> </el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import { listMenu, getMenu, delMenu, addMenu, updateMenu } from "@/api/system/menu";
import Treeselect from "@riophae/vue-treeselect";
import "@riophae/vue-treeselect/dist/vue-treeselect.css";
import IconSelect from "@/components/IconSelect";
import * as XLSX from 'xlsx';
export default {
name: "Menu",
dicts: ['sys_show_hide', 'sys_normal_disable'],
components: { Treeselect, IconSelect },
computed: {
parsedStyle() {
if (!this.form.style) return {};
try {
return JSON.parse(this.form.style);
} catch (e) {
return {};
}
}
},
data() {
return {
// 遮罩层
loading: true,
// 显示搜索条件
showSearch: true,
// 菜单表格树数据
menuList: [],
// 菜单树选项
menuOptions: [],
// 弹出层标题
title: "",
// 是否显示弹出层
open: false,
// 是否展开,默认全部折叠
isExpandAll: false,
// 重新渲染表格状态
refreshTable: true,
// 查询参数
queryParams: {
menuName: undefined,
visible: undefined
},
// 菜单样式预览
showPreview: false,
// 菜单样式校验错误
styleError: '',
// 表单参数
form: {},
// 表单校验
rules: {
menuName: [
{ required: true, message: "菜单名称不能为空", trigger: "blur" }
],
orderNum: [
{ required: true, message: "菜单顺序不能为空", trigger: "blur" }
],
path: [
{ required: true, message: "路由地址不能为空", trigger: "blur" }
]
}
};
},
created() {
this.getList();
},
methods: {
// 选择图标
selected(name) {
this.form.icon = name;
},
/** 应用样式预设 */
applyStylePreset(type) {
const presets = {
danger: { background: 'linear-gradient(135deg, #fff5f5 0%, #ffe0e0 100%)', color: '#c45c5c', borderRadius: '4px', border: '1px solid #ffd4d4' },
success: { background: 'linear-gradient(135deg, #f0fff4 0%, #dcffe4 100%)', color: '#5c8a6b', borderRadius: '4px', border: '1px solid #c3e6cb' },
warning: { background: 'linear-gradient(135deg, #fffdf0 0%, #fff3cd 100%)', color: '#b8860b', borderRadius: '4px', border: '1px solid #ffeeba' },
primary: { background: 'linear-gradient(135deg, #f0f7ff 0%, #d6eaff 100%)', color: '#4a7fb5', borderRadius: '4px', border: '1px solid #b8daff' },
accent: { background: 'linear-gradient(135deg, #f8f0ff 0%, #e8d5ff 100%)', color: '#8b5cf6', borderRadius: '4px', border: '1px solid #d8b4fe' },
warm: { background: 'linear-gradient(135deg, #fff8f0 0%, #ffedd5 100%)', color: '#c27832', borderRadius: '4px', border: '1px solid #fed7aa' },
bold: { fontWeight: '600', color: '#303133' },
italic: { fontStyle: 'italic', color: '#606266' },
clear: {}
};
if (type === 'clear') {
this.form.style = '';
this.styleError = '';
} else {
// 合并已有样式
let currentStyle = {};
if (this.form.style) {
try {
currentStyle = JSON.parse(this.form.style);
} catch (e) {
currentStyle = {};
}
}
const mergedStyle = { ...currentStyle, ...presets[type] };
this.form.style = JSON.stringify(mergedStyle);
this.validateStyle();
}
},
/** 校验菜单样式JSON格式 */
validateStyle() {
this.styleError = '';
if (!this.form.style) {
this.$modal.msgSuccess('样式为空,无需校验');
return;
}
try {
const styleObj = JSON.parse(this.form.style);
if (typeof styleObj !== 'object' || styleObj === null) {
this.styleError = '样式必须是JSON对象格式';
this.$modal.msgError('校验失败样式必须是JSON对象格式');
return;
}
// 检查是否包含有效的CSS属性
const validCssProps = [
'backgroundColor', 'background', 'color', 'fontWeight', 'fontSize', 'fontStyle',
'borderRadius', 'padding', 'margin', 'border', 'textAlign',
'textDecoration', 'lineHeight', 'letterSpacing', 'opacity', 'boxShadow'
];
const invalidProps = Object.keys(styleObj).filter(prop => !validCssProps.includes(prop));
if (invalidProps.length > 0) {
this.styleError = `可能无效的属性:${invalidProps.join(', ')}`;
}
this.$modal.msgSuccess('校验通过JSON格式正确');
} catch (e) {
this.styleError = `JSON格式错误${e.message}`;
this.$modal.msgError('校验失败:' + e.message);
}
},
/** 查询菜单列表 */
getList() {
this.loading = true;
listMenu(this.queryParams).then(response => {
this.menuList = this.handleTree(response.data, "menuId");
this.loading = false;
});
},
/** 转换菜单数据结构 */
normalizer(node) {
if (node.children && !node.children.length) {
delete node.children;
}
return {
id: node.menuId,
label: node.menuName,
children: node.children
};
},
/** 查询菜单下拉树结构 */
getTreeselect() {
listMenu().then(response => {
this.menuOptions = [];
const menu = { menuId: 0, menuName: '主类目', children: [] };
menu.children = this.handleTree(response.data, "menuId");
this.menuOptions.push(menu);
});
},
// 取消按钮
cancel() {
this.open = false;
this.reset();
},
// 表单重置
reset() {
this.form = {
menuId: undefined,
parentId: 0,
menuName: undefined,
icon: undefined,
menuType: "M",
orderNum: undefined,
isFrame: "1",
isCache: "0",
visible: "0",
status: "0"
};
this.resetForm("form");
},
/** 搜索按钮操作 */
handleQuery() {
this.getList();
},
/** 重置按钮操作 */
resetQuery() {
this.resetForm("queryForm");
this.handleQuery();
},
/** 新增按钮操作 */
handleAdd(row) {
this.reset();
this.getTreeselect();
if (row != null && row.menuId) {
this.form.parentId = row.menuId;
} else {
this.form.parentId = 0;
}
this.open = true;
this.title = "添加菜单";
},
/** 复制新增按钮操作:将当前菜单信息填入新增弹窗 */
handleCopyAdd(row) {
this.reset();
this.getTreeselect();
// 直接使用当前行数据拷贝给表单,不拷贝 menuId 确保提交走新增
const data = { ...row };
data.menuId = undefined;
this.form = data;
this.open = true;
this.title = "复制新增菜单";
},
/** 展开/折叠操作 */
toggleExpandAll() {
this.refreshTable = false;
this.isExpandAll = !this.isExpandAll;
this.$nextTick(() => {
this.refreshTable = true;
});
},
/** 修改按钮操作 */
handleUpdate(row) {
this.reset();
this.getTreeselect();
getMenu(row.menuId).then(response => {
this.form = response.data;
this.open = true;
this.title = "修改菜单";
});
},
/** 提交按钮 */
submitForm: function() {
this.$refs["form"].validate(valid => {
if (valid) {
if (this.form.menuId != undefined) {
updateMenu(this.form).then(response => {
this.$modal.msgSuccess("修改成功");
this.open = false;
this.getList();
});
} else {
addMenu(this.form).then(response => {
this.$modal.msgSuccess("新增成功");
this.open = false;
this.getList();
});
}
}
});
},
/** 删除按钮操作 */
handleDelete(row) {
this.$modal.confirm('是否确认删除名称为"' + row.menuName + '"的数据项?').then(function() {
return delMenu(row.menuId);
}).then(() => {
this.getList();
this.$modal.msgSuccess("删除成功");
}).catch(() => {});
},
/** 导出菜单(仅可见且未停用的菜单,父节点被过滤则子节点一并排除) */
handleExport() {
// 菜单类型映射
const menuTypeMap = { M: '目录', C: '菜单', F: '按钮' };
// 递归过滤:只保留 visible='0'(显示) && status='0'(正常) 的节点
const filterTree = (nodes) => {
return nodes
.filter(node => node.visible === '0' && node.status === '0')
.map(node => ({
...node,
children: node.children ? filterTree(node.children) : []
}));
};
// 递归展平为一维数组,缩进体现层级
const flattenTree = (nodes, level = 0, parentPath = '', result = []) => {
nodes.forEach(node => {
const prefix = '\u3000'.repeat(level); // 全角空格缩进
const ownPath = node.path || '';
// 计算完整路由地址(仅菜单类型 C 有效)
let fullPath = '';
if (node.menuType === 'C') {
if (node.isFrame === '0') {
// 外链:完整路由就是自身路径
fullPath = ownPath;
} else {
// 内部路由:父路径 + 自身路径
const parent = parentPath ? (parentPath.endsWith('/') ? parentPath : parentPath + '/') : '';
fullPath = '/' + parent + ownPath;
}
}
// 目录(M)的路径拼接祖先路径后作为子节点的父路径
const currentParentPath = node.menuType === 'M' ? (parentPath ? parentPath + '/' + ownPath : ownPath) : parentPath;
result.push({
menuName: prefix + node.menuName,
menuType: menuTypeMap[node.menuType] || node.menuType,
path: ownPath,
fullPath: fullPath,
icon: node.icon || '',
orderNum: node.orderNum,
perms: node.perms || '',
component: node.component || '',
status: '正常',
createTime: node.createTime || ''
});
if (node.children && node.children.length > 0) {
flattenTree(node.children, level + 1, currentParentPath, result);
}
});
return result;
};
const filtered = filterTree(this.menuList);
const flatData = flattenTree(filtered);
if (flatData.length === 0) {
this.$modal.msgWarning('没有可导出的菜单数据');
return;
}
// 构建 Excel 数据
const wsData = [
['菜单名称', '菜单类型', '路由地址', '完整路由地址', '图标', '排序', '权限标识', '组件路径', '状态', '创建时间'],
...flatData.map(item => [
item.menuName, item.menuType, item.path,
item.fullPath, item.icon, item.orderNum,
item.perms, item.component, item.status,
item.createTime
])
];
const ws = XLSX.utils.aoa_to_sheet(wsData);
ws['!cols'] = [
{ wch: 30 }, { wch: 10 }, { wch: 22 },
{ wch: 30 }, { wch: 12 }, { wch: 8 },
{ wch: 25 }, { wch: 30 }, { wch: 8 },
{ wch: 20 }
];
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, '菜单列表');
XLSX.writeFile(wb, '菜单列表.xlsx');
}
}
};
</script>
<style lang="scss" scoped>
.style-editor {
width: 100%;
max-width: 600px;
.style-textarea {
margin-bottom: 8px;
:deep(.el-textarea__inner) {
font-family: 'Consolas', 'Monaco', monospace;
font-size: 12px;
line-height: 1.5;
padding: 8px;
}
}
.style-toolbar {
display: flex;
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
.style-presets {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
.preset-label {
font-size: 12px;
color: #909399;
margin-right: 4px;
}
.el-button {
margin-left: 0;
}
}
.style-actions {
display: flex;
gap: 4px;
}
}
.style-error {
color: #F56C6C;
font-size: 12px;
margin-bottom: 8px;
padding: 4px 8px;
background-color: #FEF0F0;
border-radius: 4px;
border: 1px solid #FBC4C4;
i {
margin-right: 4px;
}
}
.style-preview {
border: 1px solid #EBEEF5;
border-radius: 4px;
padding: 12px;
background-color: #FAFAFA;
.preview-title {
font-size: 12px;
color: #909399;
margin-bottom: 8px;
}
.preview-container {
display: flex;
justify-content: center;
padding: 16px;
background-color: #F5F7FA;
border-radius: 4px;
.preview-menu-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background-color: #fff;
border-radius: 4px;
min-width: 120px;
justify-content: center;
transition: all 0.3s ease;
.preview-icon {
font-size: 16px;
}
.preview-text {
font-size: 14px;
}
}
}
}
}
</style>