菜单管理

This commit is contained in:
zhang zhuo 2025-06-23 10:18:05 +08:00
parent 2a2d05b44f
commit f3b06f0a2a
12 changed files with 636 additions and 17 deletions

View File

@ -5,5 +5,5 @@ VITE_APP_TITLE=里派基础框架
VITE_APP_ENV='development' VITE_APP_ENV='development'
# 开发环境 # 开发环境
VITE_API_BASE='https://dev.api.leapy.cn/merchant/' VITE_API_BASE='https://server.leapy.cn/admin/'
VITE_WS_URL='wss://dev.api.leapy.cn/mms' VITE_WS_URL='wss://dev.api.leapy.cn/mms'

View File

@ -5,5 +5,5 @@ VITE_APP_TITLE=里派基础框架
VITE_APP_ENV='production' VITE_APP_ENV='production'
# 生产环境 # 生产环境
VITE_API_BASE='https://dev.api.leapy.cn/merchant/' VITE_API_BASE='https://server.leapy.cn/admin/'
VITE_WS_URL='wss://dev.api.leapy.cn/mms' VITE_WS_URL='wss://dev.api.leapy.cn/mms'

View File

@ -2,18 +2,18 @@ import http from "@/utils/request"
export default { export default {
captcha: async function (data = {}) { captcha: async function (data = {}) {
return await http.get("v1/captcha", data); return await http.get("captcha", data);
}, },
login: async function (data = {}) { login: async function (data = {}) {
return await http.post("v1/login", data); return await http.post("login", data);
}, },
info: async function () { info: async function () {
return await http.get("v1/info"); return await http.get("info");
}, },
menu: async function (data = {}) { menu: async function (data = {}) {
return await http.get("v1/menu", data) return await http.get("menu", data)
}, },
logout: async function(){ logout: async function(){
return await http.get("v1/logout"); return await http.get("logout");
}, },
} }

21
src/api/model/system.ts Normal file
View File

@ -0,0 +1,21 @@
import http from "@/utils/request"
export default {
menu: {
list: async function(data={}){
return await http.get("menu/list", data);
},
add: async function(data = {}){
return await http.post("menu/add", data);
},
edit: async function(data = {}){
return await http.put("menu/edit", data);
},
del: async function(data = {}){
return await http.delete("menu/del", data);
},
option: async function(data={}){
return await http.get("menu/option", data);
},
}
}

View File

@ -0,0 +1,189 @@
<template>
<div class="pi-icon-select">
<div class="pi-icon-select__wrapper" :class="{'hasValue':value}" @click="open">
<el-input :prefix-icon="value||'el-icon-plus'" v-model="value" :disabled="disabled" readonly></el-input>
</div>
<el-dialog title="图标选择器" v-model="dialogVisible" :width="760" destroy-on-close append-to-body>
<div class="pi-icon-select__dialog">
<el-form :rules="{}">
<el-form-item prop="searchText">
<el-input class="pi-icon-select__search-input" prefix-icon="el-icon-search" v-model="searchText"
placeholder="搜索" size="large" clearable/>
</el-form-item>
</el-form>
<el-tabs>
<el-tab-pane v-for="item in data" :key="item.name" lazy>
<template #label>
{{ item.name }}
<el-tag size="small" type="info">{{ item.icons.length }}</el-tag>
</template>
<div class="pi-icon-select__list">
<el-scrollbar>
<ul @click="selectIcon">
<el-empty v-if="item.icons.length==0" :image-size="100"
description="未查询到相关图标"/>
<li v-for="icon in item.icons" :key="icon">
<span :data-icon="icon"></span>
<el-icon>
<component :is="icon"/>
</el-icon>
</li>
</ul>
</el-scrollbar>
</div>
</el-tab-pane>
</el-tabs>
</div>
<template #footer>
<el-button @click="clear" text>清除</el-button>
<el-button @click="dialogVisible=false">取消</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import {ref, watch, onMounted, getCurrentInstance} from "vue";
import config from "@/config/icon"
const {proxy} = getCurrentInstance()
const prop = defineProps({
modelValue: {type: String, default: ""},
disabled: {type: Boolean, default: false},
})
let value = ref("")
let dialogVisible = ref(false)
let data = ref([])
let searchText = ref("")
watch(() => prop.modelValue, (val) => {
value.value = val
})
watch(value, (val) => {
proxy.$emit('update:modelValue', val)
})
watch(searchText, (val) => {
search(val)
})
onMounted(() => {
value.value = prop.modelValue
data.value.push(...config.icons)
})
function open() {
if (prop.disabled) {
return false
}
dialogVisible.value = true
}
function selectIcon(e) {
if (e.target.tagName != 'SPAN') {
return false
}
value.value = e.target.dataset.icon
dialogVisible.value = false
}
function clear() {
value.value = ""
dialogVisible.value = false
}
function search(text) {
if (text) {
const filterData = JSON.parse(JSON.stringify(config.icons))
filterData.forEach(t => {
t.icons = t.icons.filter(n => n.includes(text))
})
data.value = filterData
} else {
data.value = JSON.parse(JSON.stringify(config.icons))
}
}
</script>
<style scoped>
.pi-icon-select {
display: inline-flex;
}
.pi-icon-select__wrapper {
cursor: pointer;
display: inline-flex;
}
.pi-icon-select__wrapper:deep(.el-input__wrapper).is-focus {
box-shadow: 0 0 0 1px var(--el-input-hover-border-color) inset;
}
.pi-icon-select__wrapper:deep(.el-input__inner) {
flex-grow: 0;
width: 0;
}
.pi-icon-select__wrapper:deep(.el-input__icon) {
margin: 0;
font-size: 16px;
}
.pi-icon-select__wrapper.hasValue:deep(.el-input__icon) {
color: var(--el-text-color-regular);
}
.pi-icon-select__list {
height: 270px;
overflow: auto;
}
.pi-icon-select__list ul {
}
.pi-icon-select__list li {
display: inline-block;
width: 80px;
height: 80px;
margin: 5px;
vertical-align: top;
transition: all 0.1s;
border-radius: 4px;
position: relative;
}
.pi-icon-select__list li span {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1;
cursor: pointer;
}
.pi-icon-select__list li i {
display: inline-block;
width: 100%;
height: 100%;
font-size: 26px;
color: #6d7882;
display: flex;
justify-content: center;
align-items: center;
border-radius: 4px;
}
.pi-icon-select__list li:hover {
box-shadow: 0 0 1px 4px var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
.pi-icon-select__list li:hover i {
color: var(--el-color-primary);
}
</style>

29
src/config/icon.js Normal file
View File

@ -0,0 +1,29 @@
//图标选择器配置
import * as elIconsLib from '@element-plus/icons-vue'
import * as piIconsLib from '@/assets/icons'
//el-icon图标
const elIcons = [];
for (let icon in elIconsLib) {
let iconName = `elIcon${icon}`
iconName = iconName.replace(/[A-Z]/g, match => `-${match.toLowerCase()}`)
elIcons.push(iconName)
}
//pi-icon图标
const piIcons = [];
for (let icon in piIconsLib.default) {
let iconName = `piIcon${icon}`
iconName = iconName.replace(/[A-Z]/g, match => `-${match.toLowerCase()}`)
piIcons.push(iconName)
}
export default {
icons: [{
name: '默认',
icons: elIcons
}, {
name: '扩展',
icons: piIcons
}]
}

View File

@ -64,8 +64,8 @@
</div> </div>
<el-dropdown class="user panel-item" trigger="click" @command="handleUser"> <el-dropdown class="user panel-item" trigger="click" @command="handleUser">
<div class="user-avatar"> <div class="user-avatar">
<el-avatar :size="30" :src="avatar">{{ realnameF }}</el-avatar> <el-avatar :size="30" :src="avatar">{{ nicknameF }}</el-avatar>
<label>{{ realname }}</label> <label>{{ nickname }}</label>
<el-icon class="el-icon--right"> <el-icon class="el-icon--right">
<el-icon-arrow-down/> <el-icon-arrow-down/>
</el-icon> </el-icon>
@ -126,8 +126,8 @@ const settingDialog = ref(false)
const userInfo = tools.data.get("USER_INFO"); const userInfo = tools.data.get("USER_INFO");
let msgTab = ref("notice") let msgTab = ref("notice")
let realname = userInfo.realname; let nickname = userInfo.nickname || userInfo.username;
let realnameF = realname.substring(0, 1); let nicknameF = nickname.substring(0, 1);
let avatar = userInfo.avatar let avatar = userInfo.avatar
// mounted // mounted

View File

@ -238,7 +238,7 @@
</div> </div>
</template> </template>
<script setup name="index"> <script setup>
import {ref, watch, computed, nextTick} from 'vue' import {ref, watch, computed, nextTick} from 'vue'
import SideM from './components/sideM.vue'; import SideM from './components/sideM.vue';
import Topbar from './components/topbar.vue'; import Topbar from './components/topbar.vue';

View File

@ -24,12 +24,12 @@ axios.interceptors.request.use((config) => {
//响应拦截 //响应拦截
axios.interceptors.response.use((response) => { axios.interceptors.response.use((response) => {
let res = response.data let res = response.data
if (res.code == 200) { if (res.code == 0) {
return Promise.resolve(res) return Promise.resolve(res)
} else if (res.code == 400) { // 操作失败拦截 } else if (res.code == 1) { // 操作失败拦截
ElNotification.error({title: '操作失败', message: res.msg}); ElNotification.error({title: '操作失败', message: res.msg});
return Promise.reject(res) return Promise.reject(res)
} else if (res.code == 500) { // 权限不足拦截 } else if (res.code == 2) { // 权限不足拦截
ElNotification.error({title: '权限不足', message: res.msg}); ElNotification.error({title: '权限不足', message: res.msg});
return Promise.reject(res) return Promise.reject(res)
} else { // 登录失效拦截 } else { // 登录失效拦截

View File

@ -54,10 +54,10 @@ const tools = {
return CryptoJS.MD5(data).toString() return CryptoJS.MD5(data).toString()
}, },
}, },
makeMenu: function (menus, parent_id = 0) { makeMenu: function (menus, pid = 0) {
const arr = []; const arr = [];
for (let item of menus) { for (let item of menus) {
if (item.parent_id === parent_id) { if (item.pid === pid) {
// 数据格式处理 // 数据格式处理
const tmp = { const tmp = {
name: item['name'], name: item['name'],

View File

@ -0,0 +1,202 @@
<template>
<el-container>
<el-aside width="300px" v-loading="menuloading">
<el-container>
<el-header>
<el-input placeholder="输入关键字进行过滤" v-model="menuFilterText" clearable></el-input>
</el-header>
<el-main class="nopadding">
<el-tree ref="menuRef" class="menu" node-key="id" :data="menuList" :props="menuProps"
highlight-current :expand-on-click-node="false" check-strictly show-checkbox
:filter-node-method="menuFilterNode" @node-click="menuClick">
<template #default="{node, data}">
<span class="custom-tree-node el-tree-node__label">
<span class="label">
{{ node.label }}
<el-button v-if="data.type == 0" link size="small"
style="color: #e6a23c;margin-left: 5px;">菜单</el-button>
<el-button v-if="data.type == 1" link size="small"
style="color: #79bbff;margin-left: 5px;">按钮</el-button>
<el-button v-if="data.type == 2" link size="small"
style="color: #67c23a;margin-left: 5px;">接口</el-button>
</span>
<span class="do">
<el-icon @click.stop="add(node, data)"><el-icon-plus/></el-icon>
</span>
</span>
</template>
</el-tree>
</el-main>
<el-footer style="height:51px;">
<el-button type="primary" size="small" icon="el-icon-plus" @click="add()"></el-button>
<el-button type="danger" size="small" plain icon="el-icon-delete" @click="delMenu"></el-button>
</el-footer>
</el-container>
</el-aside>
<el-container>
<el-main class="nopadding" style="padding:20px;" ref="mainRef">
<save ref="saveRef" :menu="menuList"></save>
</el-main>
</el-container>
</el-container>
</template>
<script setup>
import {ref, watch, getCurrentInstance} from "vue";
import save from './save'
import api from "@/api/index";
defineOptions({
name: "systemMenu"
})
const menuRef = ref(null)
const saveRef = ref(null)
const mainRef = ref(null)
const {proxy} = getCurrentInstance()
let menuloading = ref(false)
let menuList = ref([])
let menuProps = ref({
label: (data) => {
return data.title
}
})
let menuFilterText = ref("")
let newMenuIndex = ref(1);
watch(menuFilterText, (val) => {
menuRef.value.filter(val);
})
getMenu()
async function getMenu() {
menuloading.value = true
var res = await api.system.menu.list();
menuloading.value = false
menuList.value = treeData(res.data, 0);
}
function treeData(menus, menu_id) {
if (menus == undefined || menus.length <= 0) return [];
const arr = [];
for (let item of menus) {
//
const tmp = item;
if (item.pid == menu_id) {
var children = treeData(menus, item.menu_id);
if (children.length > 0) {
tmp['children'] = children
}
arr.push(tmp)
}
}
return arr
}
function menuClick(data) {
saveRef.value.setData(data)
mainRef.value.$el.scrollTop = 0
}
function menuFilterNode(value, data) {
if (!value) return true;
var targetText = data.title;
return targetText.indexOf(value) !== -1;
}
async function add(node, data) {
var newMenuName = "未命名" + newMenuIndex.value++;
var newMenuData = {
menu_id: "",
pid: data ? data.menu_id : 0,
name: newMenuName,
path: "",
title: newMenuName,
type: 0,
rank: 1
}
menuloading.value = false
menuRef.value.append(newMenuData, node)
menuRef.value.setCurrentKey(newMenuData.menu_id)
saveRef.value.setData(newMenuData)
}
async function delMenu() {
var CheckedNodes = menuRef.value.getCheckedNodes()
if (CheckedNodes.length == 0) {
proxy.$message.warning("请选择需要删除的项")
return false;
}
var confirm = await proxy.$confirm('确认删除已选择的菜单吗?', '提示', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消',
confirmButtonClass: 'el-button--danger'
}).catch(() => {
})
if (confirm != 'confirm') {
return false
}
menuloading.value = true
var ids = CheckedNodes.map(item => item.menu_id)
var res = await api.system.menu.del({menu_id: ids.toString()})
menuloading.value = false
proxy.$message.success(res.msg)
CheckedNodes.forEach(item => {
var node = menuRef.value.getNode(item)
if (node == null) {
reload()
} else if (node.isCurrent) {
saveRef.value.setData({})
}
menuRef.value.remove(item)
})
}
function reload() {
getMenu()
saveRef.value.setData(menuList.value[0])
mainRef.value.$el.scrollTop = 0
}
</script>
<style scoped>
.custom-tree-node {
display: flex;
flex: 1;
align-items: center;
justify-content: space-between;
font-size: 14px;
padding-right: 24px;
height: 100%;
}
.custom-tree-node .label {
display: flex;
align-items: center;;
height: 100%;
}
.custom-tree-node .label .el-tag {
margin-left: 5px;
}
.custom-tree-node .do {
display: none;
}
.custom-tree-node .do i {
margin-left: 5px;
color: #999;
}
.custom-tree-node .do i:hover {
color: #333;
}
.custom-tree-node:hover .do {
display: inline-block;
}
</style>

View File

@ -0,0 +1,178 @@
<template>
<el-row :gutter="40">
<el-col v-if="form.parentId===''">
<el-empty description="请选择左侧菜单后操作" :image-size="100"></el-empty>
</el-col>
<template v-else>
<el-col :lg="20">
<h2>{{ form.title || "新增菜单" }}</h2>
<el-form :model="form" :rules="rules" ref="dialogForm" label-width="80px" label-position="left">
<el-form-item label="显示名称" prop="title">
<el-input v-model="form.title" clearable placeholder="菜单显示名字"></el-input>
</el-form-item>
<el-form-item label="上级菜单" prop="pid">
<el-cascader ref="parentId" v-model="form.pid" :options="menuOptions" :props="menuProps"
:show-all-levels="false" placeholder="顶级菜单" clearable></el-cascader>
</el-form-item>
<el-form-item label="类型" prop="type">
<el-radio-group v-model="form.type">
<el-radio-button :value="0">菜单</el-radio-button>
<el-radio-button :value="1">按钮</el-radio-button>
<el-radio-button :value="2">接口</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item v-if="form.type == 0" label="菜单图标" prop="icon">
<pi-icon v-model="form.icon" clearable></pi-icon>
</el-form-item>
<el-form-item v-if="form.type == 0" label="页面名称" prop="name">
<el-input v-model="form.name" clearable></el-input>
<div class="el-form-item-msg">
系统唯一且与内置组件名一致否则导致缓存失效如类型为Iframe的菜单别名将代替源地址显示在地址栏
</div>
</el-form-item>
<el-form-item v-if="form.type == 0" label="路由地址" prop="path">
<el-input v-model="form.path" clearable></el-input>
<div class="el-form-item-msg">首位不需要填写 "/" 自动匹配页面路径</div>
</el-form-item>
<el-form-item v-if="form.type == 0" label="是否隐藏" prop="hidden">
<el-checkbox v-model="form.hidden" :true-value="1" :false-value="0">隐藏菜单</el-checkbox>
</el-form-item>
<el-form-item v-if="form.type == 0" label="页面顺序" prop="rank">
<el-input v-model="form.rank" clearable></el-input>
<div class="el-form-item-msg">数值越大越靠前</div>
</el-form-item>
<el-form-item v-if="form.type == 2" label="请求方式" prop="method">
<el-select v-model="form.method">
<el-option
v-for="item in methodOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item v-if="form.type == 1 || form.type == 2" label="权限标识" prop="flag">
<el-input v-model="form.flag" clearable></el-input>
<div class="el-form-item-msg">权限分隔符":"分割</div>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="save" :loading="loading"> </el-button>
</el-form-item>
</el-form>
</el-col>
</template>
</el-row>
</template>
<script setup>
import piIcon from '@/components/piIcon'
import {ref, watch, getCurrentInstance} from "vue";
import api from "@/api/index";
defineExpose({
setData
})
const props = defineProps({
menu: {
type: Object, default: () => {
}
},
})
const {proxy} = getCurrentInstance()
let form = ref({
menu_id: "",
pid: "",
belong: 0,
name: "",
path: "",
title: "",
icon: "",
type: 0,
flag: "",
hidden: "",
method: "",
rank: 1
})
let menuOptions = ref([])
let menuProps = ref({
value: 'menu_id',
label: 'title',
checkStrictly: true
})
let rules = ref([])
let loading = ref(false)
let methodOptions = ref([
{
label: "get",
value: "get"
},
{
label: "post",
value: "post"
},
{
label: "put",
value: "put"
},
{
label: "delete",
value: "delete"
}
])
watch(() => props.menu, () => {
menuOptions.value = treeToMap(props.menu)
}, {deep: true})
function treeToMap(tree) {
const map = []
tree.forEach(item => {
var obj = {
menu_id: item.menu_id,
pid: item.pid,
title: item.title,
children: item.children && item.children.length > 0 ? treeToMap(item.children) : null
}
map.push(obj)
})
return map
}
async function save() {
loading.value = true
if (Array.isArray(form.value.pid)) {
form.value.pid = form.value.pid[form.value.pid.length - 1] ?? 0
}
const res = form.value.menu_id == "" ? await api.system.menu.add(form.value) :
await api.system.menu.edit(form.value);
loading.value = false
proxy.$message.success(res.msg)
if (form.value.menu_id == "") {
form.value.menu_id = res.data.menu_id
}
}
function setData(data) {
form.value = data
}
</script>
<style scoped>
h2 {
font-size: 17px;
color: #3c4a54;
padding: 0 0 30px 0;
}
[data-theme="dark"] h2 {
color: #fff;
}
</style>