Browse Source

feat: #0000 菜单管理 50%

luoyali 1 year ago
parent
commit
d983758a3e

+ 1 - 0
package.json

@@ -39,6 +39,7 @@
     "path-to-regexp": "^6.2.0",
     "pinia": "^2.1.3",
     "pinia-plugin-persistedstate": "^3.1.0",
+    "sortablejs": "^1.15.0",
     "vue": "^3.2.47",
     "vue-i18n": "^9.2.2",
     "vue-ls": "^4.2.0",

+ 2 - 2
src/api/system/resource.ts

@@ -29,11 +29,11 @@ function resourceListTreeApi(data: any): AxiosPromise {
 	})
 }
 
-function resourceListApi(data: any): AxiosPromise {
+function resourceListApi(params: any): AxiosPromise {
 	return request({
 		url: api.listApi,
 		method: 'get',
-		data
+		params
 	})
 }
 

+ 12 - 20
src/components/FormConfig/index.vue

@@ -117,7 +117,7 @@ const FormConfig = defineComponent({
 				const _prop = v.prop,
 					props = v.props // 绑定的其他数据
 				let defaultValue: any
-				if(v.itemType === 'inputNumberRange') defaultValue = []
+				if (v.itemType === 'inputNumberRange') defaultValue = []
 				params[_prop] = setItemData(formData[_prop], defaultValue) // 数据初始化
 				/*const itemProps = queryItemTypeKeys(v)
 				itemProps.map(prop => {
@@ -217,14 +217,17 @@ const FormConfig = defineComponent({
 				})
 			}
 		}
-		watch(() => props.formData, (newData, oldData) => {
-			// console.warn(JSON.stringify(newData), JSON.stringify(oldData), 'newFormData, oldFormData... 监听  formData')
-			changeFormData(newData)
-		})
+		watch(
+			() => props.formData,
+			(newData, oldData) => {
+				// console.warn(JSON.stringify(newData), JSON.stringify(oldData), 'newFormData, oldFormData... 监听  formData')
+				changeFormData(newData)
+			}
+		)
 		// 本地数据
 		const state = reactive({
 			// { params, bindProps }
-			...changeFormData(props.formData, true),
+			...changeFormData(props.formData, true)
 			/*params,
 			// 额外props 集合
 			bindProps: []*/
@@ -522,14 +525,8 @@ const FormConfig = defineComponent({
 				)
 			}
 			return (
-				<el-form
-					ref={formRef}
-					class={`le-form-config le-form-config--${size}`}
-					{...form_config}
-					size={size}
-					model={params}
-				>
-					<el-row class='form_wrap' gutter={gutter}>
+				<el-form ref={formRef} class={`le-form-config le-form-config--${size}`} {...form_config} size={size} model={params}>
+					<el-row class="form_wrap" gutter={gutter}>
 						{/*renderForms({forms: realForms.value, gutter, span})*/}
 						{realForms.value.map((form, idx) => {
 							const { span: _span, t_label, label, ...others } = form
@@ -539,12 +536,7 @@ const FormConfig = defineComponent({
 							}
 							return (
 								<el-col v-show={form.visible !== false} key={idx} span={_span ?? span}>
-									<el-form-item
-										class={form.showLabel === false ? 'hideLabel' : ''}
-										{...others}
-										label={_label}
-										v-slots={formItemSlots}
-									>
+									<el-form-item class={form.showLabel === false ? 'hideLabel' : ''} {...others} label={_label} v-slots={formItemSlots}>
 										{itemRender(form)}
 									</el-form-item>
 								</el-col>

+ 1 - 0
src/components/IconSelect/index.vue

@@ -36,6 +36,7 @@ function filterIcons() {
 
 function selectedIcon(name: string) {
 	emit('selected', name)
+	iconName.value = name
 	document.body.click()
 }
 

+ 132 - 0
src/components/scFormTable/index.vue

@@ -0,0 +1,132 @@
+<template>
+	<div class="sc-form-table" ref="scFormTableRef">
+		<el-table :data="data" ref="tableRef" border stripe>
+			<el-table-column type="index" width="50" fixed="left">
+				<template #header>
+					<el-button v-if="!hideAdd" type="primary" :icon="Plus" size="small" circle @click="rowAdd"></el-button>
+				</template>
+				<template #default="scope">
+					<div :class="['sc-form-table-handle', { 'sc-form-table-handle-delete': !hideDelete }]">
+						<span>{{ scope.$index + 1 }}</span>
+						<el-button v-if="!hideDelete" type="danger" :icon="Delete" size="small" plain circle @click="rowDel(scope.row, scope.$index)"></el-button>
+					</div>
+				</template>
+			</el-table-column>
+			<el-table-column label="" width="50" v-if="dragSort">
+				<template #default>
+					<div class="move" style="cursor: move"><el-icon-d-caret style="width: 1em; height: 1em" /></div>
+				</template>
+			</el-table-column>
+			<slot></slot>
+			<template #empty>
+				{{ placeholder }}
+			</template>
+		</el-table>
+	</div>
+</template>
+
+<script setup>
+import Sortable from 'sortablejs'
+import { Plus, Delete } from '@element-plus/icons-vue'
+import { defineEmits, nextTick, onMounted, ref, watch } from 'vue'
+
+const myProps = defineProps({
+	modelValue: { type: Array, default: () => [] },
+	addTemplate: { type: Object, default: () => {} },
+	placeholder: { type: String, default: '暂无数据' },
+	dragSort: { type: Boolean, default: false },
+	hideAdd: { type: Boolean, default: false },
+	hideDelete: { type: Boolean, default: false }
+})
+
+const $myEmit = defineEmits(['update:modelValue'])
+
+const data = ref([])
+const tableRef = ref(null)
+const scFormTableRef = ref(null)
+
+const rowDrop = () => {
+	const tbody = tableRef.value.$el.querySelector('.el-table__body-wrapper tbody')
+	Sortable.create(tbody, {
+		handle: '.move',
+		animation: 300,
+		ghostClass: 'ghost',
+		onEnd({ newIndex, oldIndex }) {
+			data.value.splice(newIndex, 0, data.value.splice(oldIndex, 1)[0])
+			const newArray = data.value.slice(0)
+			const tmpHeight = scFormTableRef.value.offsetHeight
+			scFormTableRef.value.style.setProperty('height', tmpHeight + 'px')
+			data.value = []
+			nextTick(() => {
+				data.value = newArray
+				nextTick(() => {
+					scFormTableRef.value.style.removeProperty('height')
+				})
+			})
+		}
+	})
+}
+
+const rowAdd = () => {
+	const temp = JSON.parse(JSON.stringify(myProps.addTemplate))
+	data.value.push(temp)
+}
+const rowDel = (row, index) => {
+	data.value.splice(index, 1)
+}
+//插入行
+const pushRow = row => {
+	const temp = row || JSON.parse(JSON.stringify(myProps.addTemplate))
+	data.value.push(temp)
+}
+//根据index删除
+const deleteRow = index => {
+	data.value.splice(index, 1)
+}
+
+watch(myProps.modelValue, async (newQuestion, oldQuestion) => {
+	// data.value = myProps.modelValue
+	data.value = newQuestion
+})
+
+watch(
+	data.value,
+	async (newQuestion, oldQuestion) => {
+		$myEmit('update:modelValue', newQuestion)
+	},
+	{ deep: true }
+)
+
+onMounted(() => {
+	data.value = myProps.modelValue
+	if (myProps.dragSort) {
+		rowDrop()
+	}
+})
+</script>
+
+<style scoped>
+.sc-form-table {
+	width: 100%;
+}
+.sc-form-table .sc-form-table-handle {
+	text-align: center;
+}
+.sc-form-table .sc-form-table-handle span {
+	display: inline-block;
+}
+.sc-form-table .sc-form-table-handle button {
+	display: none;
+}
+.sc-form-table .hover-row .sc-form-table-handle-delete span {
+	display: none;
+}
+.sc-form-table .hover-row .sc-form-table-handle-delete button {
+	display: inline-block;
+}
+.sc-form-table .move {
+	text-align: center;
+	font-size: 14px;
+	margin-top: 3px;
+}
+</style>

+ 6 - 0
src/router/index.ts

@@ -199,6 +199,12 @@ export const constantRoutes: Array<AppRouteRecordRaw> = [
 				component: () => import('@/views/setting/dict/index.vue'),
 				name: 'dict',
 				meta: { title: '字典管理', icon: '' }
+			},
+			{
+				path: 'menu',
+				component: () => import('@/views/setting/menu/index.vue'),
+				name: 'menu',
+				meta: { title: '菜单管理', icon: '' }
 			}
 		]
 	},

+ 227 - 0
src/views/setting/menu/index.vue

@@ -0,0 +1,227 @@
+<template>
+	<div class="pageWrap">
+		<el-aside v-loading="menuLoading" width="300px" style="background: #fff; margin-right: 10px">
+			<el-container style="height: 100%">
+				<el-header>
+					<el-input v-model="menuFilterText" placeholder="输入关键字进行过滤" clearable></el-input>
+				</el-header>
+				<el-main class="nopadding">
+					<el-tree
+						ref="menuRef"
+						class="menu-tree"
+						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 }}
+								</span>
+								<span class="do">
+									<el-icon @click.stop="add(node, data)"><Plus /></el-icon>
+								</span>
+							</span>
+						</template>
+					</el-tree>
+				</el-main>
+				<el-footer style="height: 51px">
+					<el-button :icon="Refresh" @click="getMenu()"></el-button>
+					<el-button type="primary" :icon="Plus" @click="add()"></el-button>
+					<el-button type="danger" plain :icon="Delete" @click="delMenu"></el-button>
+				</el-footer>
+			</el-container>
+		</el-aside>
+		<el-container style="background: #fff" class="container-bg">
+			<el-main ref="mainRef" class="nopadding" style="padding: 20px">
+				<save ref="saveRef" :menu="menuList"></save>
+			</el-main>
+		</el-container>
+	</div>
+</template>
+
+<script setup>
+import Save from './save'
+import resource from '@/api/system/resource'
+import { Plus, Refresh, Delete } from '@element-plus/icons-vue'
+import { onMounted, ref } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+
+const menuLoading = ref(false)
+const menuList = ref([])
+const menuFilterText = ref('')
+const mainRef = ref(null) // 右侧的大容器
+const saveRef = ref(null) // 右侧的表单
+const menuRef = ref(null) // 左侧菜单树
+const menuProps = {
+	label: data => {
+		return data.title
+	}
+}
+let newMenuIndex = 1
+
+// methods
+const getMenu = async () => {
+	//加载树数据
+	menuLoading.value = true
+	try {
+		let data = await resource.resourceListTreeApi()
+		menuLoading.value = false
+		menuList.value = data
+	} catch (e) {
+		console.log(e)
+		menuLoading.value = false
+	}
+}
+
+const menuClick = (data, node) => {
+	//树点击
+	let pid = +node.level === 1 ? undefined : node.parent.data.id
+	saveRef.value.setData(data, pid)
+	mainRef.value.$el.scrollTop = 0
+}
+
+const menuFilterNode = (value, data) => {
+	//树过滤
+	if (!value) return true
+	let targetText = data.title
+	return targetText.indexOf(value) !== -1
+}
+
+const add = async (node, data) => {
+	let newMenuName = '未命名' + newMenuIndex++
+	let newMenuData = {
+		pid: data ? data.id : '0',
+		name: newMenuName,
+		path: '',
+		component: '',
+		title: newMenuName,
+		type: '0'
+	}
+	menuLoading.value = true
+	try {
+		let res = await resource.resourceAddOrEditSaveApi(newMenuData)
+		menuLoading.value = false
+		newMenuData.id = res.data
+		menuRef.value.append(newMenuData, node)
+		menuRef.value.setCurrentKey(newMenuData.id)
+		let pid = node ? node.data.id : ''
+		saveRef.value.setData(newMenuData, pid)
+	} catch (e) {
+		menuLoading.value = false
+		console.log(e, '新增菜单失败')
+	}
+}
+
+const delMenu = () => {
+	//删除菜单
+	let checkedNodes = menuRef.value.getCheckedNodes()
+	if (!checkedNodes.length) {
+		return ElMessage.warning('请选择需要删除的项')
+	}
+	ElMessageBox.confirm(`确认删除已选择的菜单吗?`, '提示', {
+		type: 'warning',
+		confirmButtonText: '删除',
+		confirmButtonClass: 'el-button--danger'
+	})
+		.then(async () => {
+			menuLoading.value = true
+			let reqData = checkedNodes.map(item => item.id)
+			try {
+				await resource.resourceDeleteApi(reqData)
+				checkedNodes.forEach(item => {
+					let node = menuRef.value.getNode(item)
+					if (node.isCurrent) {
+						saveRef.value.setData({})
+					}
+					menuRef.value.remove(item)
+				})
+				menuLoading.value = false
+			} catch (e) {
+				menuLoading.value = false
+				console.log('删除左侧菜单失败', e)
+			}
+		})
+		.catch(() => {})
+}
+
+onMounted(() => {
+	getMenu()
+})
+</script>
+
+<style scoped lang="scss">
+.pageWrap {
+	flex: 1;
+	display: flex;
+	height: 100%;
+	//background: #fff;
+}
+
+// 角色的树结构样式
+:deep(.menu-tree) {
+	.el-tree-node__content {
+		height: 36px;
+	}
+	.el-tree-node__content .el-tree-node__label .icon {
+		margin-right: 5px;
+	}
+}
+
+.nopadding {
+	padding: 0px;
+}
+
+.content-warp {
+	flex: 1;
+	//width: calc(100% - 250px);
+	width: calc(100% - 210px);
+}
+.container-bg {
+	background: var(--el-bg-color-overlay);
+}
+.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;
+	padding: 5px;
+	font-size: 24px;
+}
+
+.custom-tree-node .do i:hover {
+	color: #333;
+}
+
+.custom-tree-node:hover .do {
+	display: inline-block;
+}
+</style>

+ 174 - 0
src/views/setting/menu/save.vue

@@ -0,0 +1,174 @@
+<template>
+	<el-row :gutter="40">
+		<el-col v-if="!form.id">
+			<el-empty description="请选择左侧菜单后操作" :image-size="100"></el-empty>
+		</el-col>
+		<template v-else>
+			<el-col :lg="12">
+				<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="name">
+						<el-input v-model="form.parentName" clearable placeholder="顶级菜单"></el-input>
+					</el-form-item>
+					<el-form-item label="类型" prop="type">
+						<el-radio-group v-model="form.type">
+							<el-radio-button label="0">菜单</el-radio-button>
+							<el-radio-button label="1">Iframe</el-radio-button>
+							<el-radio-button label="2">外链</el-radio-button>
+							<el-radio-button label="3">按钮</el-radio-button>
+						</el-radio-group>
+					</el-form-item>
+					<el-form-item label="别名" prop="alias">
+						<el-input v-model="form.alias" clearable placeholder="菜单别名"></el-input>
+						<div class="el-form-item-msg">系统唯一且与内置组件名一致,否则导致缓存失效。如类型为Iframe的菜单,别名将代替源地址显示在地址栏</div>
+					</el-form-item>
+					<el-form-item label="菜单图标" prop="icon">
+						<icon-select></icon-select>
+					</el-form-item>
+					<el-form-item label="路由地址" prop="path">
+						<el-input v-model="form.path" clearable placeholder=""></el-input>
+					</el-form-item>
+					<el-form-item label="重定向" prop="redirect">
+						<el-input v-model="form.redirect" clearable placeholder=""></el-input>
+					</el-form-item>
+					<el-form-item label="菜单高亮" prop="active">
+						<el-input v-model="form.active" clearable placeholder=""></el-input>
+						<div class="el-form-item-msg">子节点或详情页需要高亮的上级菜单路由地址</div>
+					</el-form-item>
+					<el-form-item label="视图" prop="component">
+						<el-input v-model="form.component" clearable placeholder="">
+							<template #prepend>views/</template>
+						</el-input>
+						<div class="el-form-item-msg">如父节点、链接或Iframe等没有视图的菜单不需要填写</div>
+					</el-form-item>
+					<el-form-item label="排序" prop="sort">
+						<el-input-number v-model="form.sort" :min="0" :max="9999"></el-input-number>
+						<div class="el-form-item-msg">排序规则、由大到小排序</div>
+					</el-form-item>
+					<el-form-item label="颜色" prop="color">
+						<el-color-picker v-model="form.color" :predefine="predefineColors"></el-color-picker>
+					</el-form-item>
+					<el-form-item label="是否隐藏" prop="hidden">
+						<el-checkbox v-model="form.hidden">隐藏菜单</el-checkbox>
+						<el-checkbox v-model="form.hiddenBreadcrumb">隐藏面包屑</el-checkbox>
+						<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>
+
+			<el-col :lg="12" class="apilist">
+				<h2>接口权限</h2>
+				<sc-form-table v-model="form.apiList" :addTemplate="apiListAddTemplate" placeholder="暂无匹配接口权限">
+					<el-table-column prop="code" label="标识" width="150">
+						<template #default="scope">
+							<el-input v-model="scope.row.code" placeholder="请输入内容"></el-input>
+						</template>
+					</el-table-column>
+					<el-table-column prop="url" label="Api url">
+						<template #default="scope">
+							<el-input v-model="scope.row.url" placeholder="请输入内容"></el-input>
+						</template>
+					</el-table-column>
+				</sc-form-table>
+			</el-col>
+		</template>
+	</el-row>
+</template>
+
+<script setup>
+import IconSelect from '@/components/IconSelect'
+import ScFormTable from '@/components/ScFormTable'
+import { ref } from 'vue'
+import resource from '@/api/system/resource'
+
+const form = ref({
+	id: '',
+	pid: '',
+	parentName: '',
+	alias: '',
+	path: '',
+	component: '',
+	redirect: '',
+	title: '',
+	icon: '',
+	active: '',
+	color: '',
+	type: '0',
+	sort: 0,
+	apiList: []
+})
+
+const predefineColors = ref(['#ff4500', '#ff8c00', '#ffd700', '#67C23A', '#00ced1', '#409EFF', '#c71585'])
+const rules = {
+	title: [{ required: true, message: '请输入显示名称', trigger: 'blur' }],
+	alias: [{ required: true, message: '请输入别名', trigger: 'blur' }],
+	sort: [{ required: true, trigger: 'blur' }]
+}
+const apiListAddTemplate = ref({
+	code: '',
+	url: ''
+})
+const loading = ref(false)
+
+// methods
+const save = () => {
+	// 保存
+	// this.loading = true
+	// let res = await this.$API.resource.save(this.form)
+	// this.loading = false
+	// if (res.code == 200) {
+	// 	this.$message.success('保存成功')
+	// } else {
+	// 	this.$message.warning(res.message)
+	// }
+}
+
+const setData = async data => {
+	//表单注入数据
+	loading.value = true
+	try {
+		let res = await resource.resourceListApi({ id: data.id })
+		loading.value = false
+		form.value = data
+		form.value.apiList = res
+	} catch (e) {
+		console.log('表单注入数据失败', e)
+		form.value.id = ''
+		loading.value = false
+	}
+}
+
+// 父组件使用的话需要导出
+defineExpose({
+	setData
+})
+</script>
+<style scoped lang="scss">
+h2 {
+	font-size: 17px;
+	color: #3c4a54;
+	padding: 0 0 30px 0;
+}
+.apilist {
+	border-left: 1px solid #eee;
+}
+[data-theme='dark'] h2 {
+	color: #fff;
+}
+[data-theme='dark'] .apilist {
+	border-color: #434343;
+}
+
+.el-form-item-msg {
+	font-size: 12px;
+	color: #999;
+	clear: both;
+	width: 100%;
+}
+</style>

+ 2 - 1
src/views/setting/user/index.vue

@@ -189,8 +189,9 @@ const formOptions = computed(() => {
 		forms: isCreate.value ? formsDialog : editFormDialog,
 		labelWidth: 120,
 		span: 30,
-		showResetBtn: true,
 		formConfig: {
+			showResetBtn: true,
+			showCancelBtn: true,
 			submitLoading: false
 		}
 	}