553 lines
16 KiB
Vue
553 lines
16 KiB
Vue
<script setup lang="ts">
|
||
import { formatDateTime } from '@/utils/datetime'
|
||
/** 菜单权限配置页 · 像素复刻旧版 admin.html panel-columns(自绘:面板头+新增菜单、拖拽手柄 ⠿、树形缩进+└、旧式表格、全量无分页同旧版)。
|
||
* script 逻辑沿用现有 Vue 实现(拖拽分组/落点/重排保存、弹窗表单)。 */
|
||
import { computed, onMounted, reactive, ref } from 'vue'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import OldPagination from '@/components/OldPagination.vue'
|
||
import { createMenu, deleteMenu, loadMenuManageTree, reorderMenus, updateMenu } from './menu-manage-api'
|
||
import {
|
||
MENU_TYPE_OPTIONS,
|
||
createMenuForm,
|
||
flattenMenuNodes,
|
||
menuDeleteReason,
|
||
menuFormFromNode,
|
||
menuParentCandidates,
|
||
menuParentChangeReason,
|
||
menuSiblingGroupKey,
|
||
menuTypeLabel,
|
||
nextSiblingSort,
|
||
siblingOrderAfterDrop,
|
||
validateMenuManageForm,
|
||
type MenuManageForm,
|
||
type MenuManageNode,
|
||
} from './menu-manage-model'
|
||
|
||
/** 树行视图:展平 + 深度(缩进与 └ 前缀)。 */
|
||
interface MenuRow extends MenuManageNode {
|
||
depth: number
|
||
}
|
||
|
||
const loading = ref(false)
|
||
const rows = ref<MenuManageNode[]>([])
|
||
/** 筛选:菜单名称/类型(用户要求账户域子菜单补筛选条件)。 */
|
||
const filterName = ref('')
|
||
const filterType = ref('')
|
||
const filteredMenuRows = computed<MenuRow[]>(() => {
|
||
const kw = (filterName.value || '').trim().toLowerCase()
|
||
const type = filterType.value || ''
|
||
return menuRows.value.filter((row) => {
|
||
const nameOk = !kw || (row.name || '').toLowerCase().includes(kw)
|
||
const typeOk = !type || row.menuType === type
|
||
return nameOk && typeOk
|
||
})
|
||
})
|
||
/** 全站分页统一:菜单树展平行客户端分页(10/20/50/100)。 */
|
||
const page = ref(1)
|
||
const pageSize = ref(10)
|
||
const pagedMenuRows = computed(() => filteredMenuRows.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
|
||
function changeMenuPage(p: number) { page.value = p }
|
||
function changeMenuSize(size: number) { pageSize.value = size; page.value = 1 }
|
||
/** 展平视图:前序 + depth。 */
|
||
const menuRows = computed<MenuRow[]>(() => {
|
||
const out: MenuRow[] = []
|
||
const walk = (list: MenuManageNode[], depth: number) => {
|
||
for (const node of list) {
|
||
out.push({ ...node, depth })
|
||
if (node.children?.length) walk(node.children, depth + 1)
|
||
}
|
||
}
|
||
walk(rows.value, 0)
|
||
return out
|
||
})
|
||
const createVisible = ref(false)
|
||
const saving = ref(false)
|
||
const editingNode = ref<MenuManageNode | null>(null)
|
||
const menuForm = reactive<MenuManageForm>(createMenuForm(null, 0))
|
||
const parentOptions = computed(() => menuParentCandidates(rows.value, menuForm.menuType))
|
||
const dialogTitle = computed(() => (editingNode.value ? '编辑菜单' : '新增菜单'))
|
||
/** 节点 id -> 名称,供「上级菜单」列展示。 */
|
||
const nameById = computed(() => {
|
||
const map = new Map<number, string>()
|
||
for (const node of flattenMenuNodes(rows.value)) map.set(node.id, node.name)
|
||
return map
|
||
})
|
||
|
||
/** 拖拽状态:同"菜单类型+父级"分组内才可落位(对齐 admin.js columnDragState)。 */
|
||
const dragState = ref<{ id: number; groupKey: string; menuType: string } | null>(null)
|
||
|
||
function parentNameOf(node: MenuManageNode): string {
|
||
if (node.parentId == null) return '-'
|
||
return nameById.value.get(node.parentId) ?? `#${node.parentId}`
|
||
}
|
||
|
||
async function loadMenus() {
|
||
loading.value = true
|
||
try {
|
||
rows.value = await loadMenuManageTree()
|
||
} catch (error) {
|
||
ElMessage.error(error instanceof Error ? error.message : '菜单列表加载失败')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
function openCreate() {
|
||
editingNode.value = null
|
||
Object.assign(menuForm, createMenuForm(null, nextSiblingSort(rows.value, null)))
|
||
createVisible.value = true
|
||
}
|
||
|
||
function openEdit(node: MenuManageNode) {
|
||
editingNode.value = node
|
||
Object.assign(menuForm, menuFormFromNode(node))
|
||
createVisible.value = true
|
||
}
|
||
|
||
/** 菜单类型切换时:当前父菜单不属于新类型则重置为根。 */
|
||
function onMenuTypeChange(): void {
|
||
const candidates = menuParentCandidates(rows.value, menuForm.menuType)
|
||
if (menuForm.parentId != null && !candidates.some((node) => node.id === menuForm.parentId)) {
|
||
menuForm.parentId = null
|
||
}
|
||
}
|
||
|
||
function onDragStart(event: DragEvent, node: MenuManageNode): void {
|
||
dragState.value = { id: node.id, groupKey: menuSiblingGroupKey(node), menuType: node.menuType }
|
||
if (event.dataTransfer) {
|
||
event.dataTransfer.effectAllowed = 'move'
|
||
try {
|
||
event.dataTransfer.setData('text/plain', String(node.id))
|
||
} catch {
|
||
// 某些浏览器 setData 受限,忽略即可。
|
||
}
|
||
}
|
||
}
|
||
|
||
function onDragEnd(): void {
|
||
dragState.value = null
|
||
}
|
||
|
||
function onDragOver(event: DragEvent, node: MenuManageNode): void {
|
||
const state = dragState.value
|
||
if (!state || state.groupKey !== menuSiblingGroupKey(node) || state.id === node.id) return
|
||
event.preventDefault()
|
||
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'
|
||
}
|
||
|
||
async function onDrop(event: DragEvent, target: MenuManageNode): Promise<void> {
|
||
const state = dragState.value
|
||
dragState.value = null
|
||
if (!state || state.groupKey !== menuSiblingGroupKey(target) || state.id === target.id) return
|
||
event.preventDefault()
|
||
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
|
||
const placeAfter = event.clientY - rect.top > rect.height / 2
|
||
const groupIds = flattenMenuNodes(rows.value)
|
||
.filter((node) => menuSiblingGroupKey(node) === state.groupKey)
|
||
.map((node) => node.id)
|
||
const next = siblingOrderAfterDrop(groupIds, state.id, target.id, placeAfter)
|
||
if (next.join(',') === groupIds.join(',')) return
|
||
try {
|
||
await reorderMenus(state.menuType, next)
|
||
ElMessage.success('排序已保存')
|
||
await loadMenus()
|
||
} catch (error) {
|
||
ElMessage.error(error instanceof Error ? error.message : '排序保存失败')
|
||
}
|
||
}
|
||
|
||
async function saveMenu() {
|
||
if (editingNode.value) {
|
||
const reason = menuParentChangeReason(editingNode.value, menuForm.parentId)
|
||
if (reason) {
|
||
ElMessage.warning(reason)
|
||
return
|
||
}
|
||
}
|
||
const { valid, errors } = validateMenuManageForm(menuForm)
|
||
if (!valid) {
|
||
ElMessage.warning(Object.values(errors)[0])
|
||
return
|
||
}
|
||
saving.value = true
|
||
try {
|
||
if (editingNode.value) {
|
||
await updateMenu(editingNode.value.id, menuForm)
|
||
ElMessage.success('保存成功')
|
||
} else {
|
||
await createMenu(menuForm)
|
||
ElMessage.success('创建成功')
|
||
}
|
||
createVisible.value = false
|
||
await loadMenus()
|
||
} catch (error) {
|
||
ElMessage.error(error instanceof Error ? error.message : '保存失败')
|
||
} finally {
|
||
saving.value = false
|
||
}
|
||
}
|
||
|
||
async function removeMenu(node: MenuManageNode) {
|
||
const reason = menuDeleteReason(node)
|
||
if (reason) {
|
||
ElMessage.warning(reason)
|
||
return
|
||
}
|
||
// 对齐旧版:原生 confirm。
|
||
if (!window.confirm(`确定删除菜单“${node.name}”吗?`)) return
|
||
try {
|
||
await deleteMenu(node.id)
|
||
ElMessage.success('删除成功')
|
||
await loadMenus()
|
||
} catch (error) {
|
||
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
||
}
|
||
}
|
||
|
||
onMounted(loadMenus)
|
||
</script>
|
||
|
||
<template>
|
||
<div class="menus-view">
|
||
<section class="panel-box">
|
||
<div class="menus-head">
|
||
<h3>菜单列表</h3>
|
||
<button class="btn" type="button" @click="openCreate">新增菜单</button>
|
||
</div>
|
||
<p class="columns-drag-tip">拖动每行左侧的手柄可调整同级菜单的显示顺序,松开后自动保存。</p>
|
||
|
||
<div class="form-row menus-filter-row">
|
||
<div class="form-group" style="min-width: 200px">
|
||
<label>菜单名称</label>
|
||
<input v-model="filterName" type="text" placeholder="输入菜单名称" />
|
||
</div>
|
||
<div class="form-group" style="min-width: 160px">
|
||
<label>菜单类型</label>
|
||
<select v-model="filterType">
|
||
<option value="">全部</option>
|
||
<option value="admin">后台</option>
|
||
<option value="app">软件</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="table-scroll columns-table-scroll">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th class="column-drag-cell"><span class="visually-hidden">排序</span></th>
|
||
<th style="width: 60px">ID</th>
|
||
<th style="width: 200px">菜单名称</th>
|
||
<th style="width: 100px">菜单类型</th>
|
||
<th style="width: 120px">上级菜单</th>
|
||
<th style="width: 170px">创建时间</th>
|
||
<th style="width: 170px">操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<template v-if="filteredMenuRows.length">
|
||
<tr
|
||
v-for="row in pagedMenuRows"
|
||
:key="row.id"
|
||
:class="{ 'is-column-dragging': dragState?.id === row.id }"
|
||
@dragover="onDragOver($event, row)"
|
||
@drop="onDrop($event, row)"
|
||
>
|
||
<td class="column-drag-cell">
|
||
<span
|
||
class="column-drag-handle"
|
||
draggable="true"
|
||
role="button"
|
||
:title="`拖动调整“${row.name}”的顺序`"
|
||
@dragstart="onDragStart($event, row)"
|
||
@dragend="onDragEnd"
|
||
>⠿</span>
|
||
</td>
|
||
<td>{{ row.id }}</td>
|
||
<td class="name-cell">
|
||
<span v-if="row.depth > 0" class="column-child-mark" :style="{ marginLeft: `${row.depth * 16}px` }">└</span>
|
||
<span class="menu-name-text">{{ row.name }}</span>
|
||
</td>
|
||
<td><span class="menu-type-text">{{ menuTypeLabel(row.menuType) }}</span></td>
|
||
<td>{{ parentNameOf(row) }}</td>
|
||
<td>{{ formatDateTime(row.createdAt) }}</td>
|
||
<td class="ops-cell">
|
||
<button class="btn btn-sm" type="button" @click="openEdit(row)">编辑</button>
|
||
<button
|
||
class="btn btn-sm btn-danger"
|
||
type="button"
|
||
:disabled="menuDeleteReason(row) !== undefined"
|
||
:title="menuDeleteReason(row) || undefined"
|
||
@click="removeMenu(row)"
|
||
>
|
||
删除
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
</template>
|
||
<tr v-else-if="loading">
|
||
<td colspan="7" class="empty-tip">加载中...</td>
|
||
</tr>
|
||
<tr v-else>
|
||
<td colspan="7" class="empty-tip">{{ filterName || filterType ? '暂无匹配菜单' : '暂无菜单,请先在上方新增' }}</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<OldPagination v-if="filteredMenuRows.length > 0" :total="filteredMenuRows.length" :page="page" :page-size="pageSize" @change="changeMenuPage" @size-change="changeMenuSize" />
|
||
</section>
|
||
|
||
<el-dialog v-model="createVisible" :title="dialogTitle" width="520px">
|
||
<el-form label-width="96px">
|
||
<el-form-item label="菜单名称">
|
||
<el-input v-model="menuForm.name" placeholder="如:用户管理" />
|
||
</el-form-item>
|
||
<el-form-item label="菜单类型">
|
||
<el-select filterable v-model="menuForm.menuType" style="width: 100%" @change="onMenuTypeChange">
|
||
<el-option v-for="opt in MENU_TYPE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="父菜单">
|
||
<el-select filterable v-model="menuForm.parentId" clearable placeholder="无(一级菜单)" style="width: 100%">
|
||
<el-option
|
||
v-for="node in parentOptions"
|
||
:key="node.id"
|
||
:label="node.name"
|
||
:value="node.id"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="排序">
|
||
<el-input-number v-model="menuForm.sortOrder" :min="0" />
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="createVisible = false">取消</el-button>
|
||
<el-button type="primary" :loading="saving" @click="saveMenu">保存</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
/* 像素复刻旧版 admin.html panel-columns(蓝白末层)。 */
|
||
.menus-view {
|
||
font-family: inherit;
|
||
color: #24384d;
|
||
}
|
||
.panel-box {
|
||
width: 100%;
|
||
min-width: 0;
|
||
padding: 20px 22px 24px;
|
||
border: 1px solid #d8e3ee;
|
||
border-radius: 14px;
|
||
background: linear-gradient(145deg, #ffffff, #f9fbfd);
|
||
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
|
||
}
|
||
h3 {
|
||
margin: 0;
|
||
font-size: 15px;
|
||
font-weight: 650;
|
||
color: #24384d;
|
||
letter-spacing: 0.2px;
|
||
}
|
||
.menus-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
}
|
||
.columns-drag-tip {
|
||
margin: 10px 0 16px;
|
||
font-size: 13px;
|
||
color: #5b6f83;
|
||
}
|
||
.menus-filter-row {
|
||
margin: 0 0 16px;
|
||
}
|
||
.form-row {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: flex-end;
|
||
gap: 14px 18px;
|
||
}
|
||
.form-group {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 7px;
|
||
margin-bottom: 0;
|
||
}
|
||
.form-group label {
|
||
color: #5b6f83;
|
||
font-size: 12.5px;
|
||
font-weight: 600;
|
||
}
|
||
.form-group input,
|
||
.form-group select {
|
||
min-width: 0;
|
||
min-height: 42px;
|
||
padding: 8px 12px;
|
||
border: 1px solid #cbd9e6;
|
||
border-radius: 9px;
|
||
background: #f8fbfd;
|
||
color: #24384d;
|
||
font-size: 13.5px;
|
||
font-family: inherit;
|
||
color-scheme: light;
|
||
outline: none;
|
||
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||
}
|
||
.form-group input:hover,
|
||
.form-group select:hover {
|
||
border-color: #9fb7cd;
|
||
}
|
||
.form-group input:focus,
|
||
.form-group select:focus {
|
||
background: #ffffff;
|
||
border-color: #5f85ad;
|
||
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
|
||
}
|
||
.btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 8px;
|
||
min-height: 42px;
|
||
padding: 9px 18px;
|
||
border: 1px solid #4f78a5;
|
||
border-radius: 9px;
|
||
background: linear-gradient(135deg, #5f85ad, #4f78a5);
|
||
color: #ffffff;
|
||
font-family: inherit;
|
||
font-size: 13.5px;
|
||
cursor: pointer;
|
||
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
|
||
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
|
||
}
|
||
.btn:hover:not(:disabled) {
|
||
background: linear-gradient(135deg, #7094ba, #5d83ac);
|
||
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
|
||
}
|
||
.btn:disabled {
|
||
cursor: not-allowed;
|
||
opacity: 0.55;
|
||
}
|
||
.btn-danger {
|
||
background: linear-gradient(135deg, #c06d77, #b35f6a);
|
||
border-color: #b35f6a;
|
||
}
|
||
.btn-danger:hover:not(:disabled) {
|
||
background: linear-gradient(135deg, #cb7c84, #b96570);
|
||
}
|
||
.btn-sm {
|
||
min-height: 36px;
|
||
padding: 7px 12px;
|
||
}
|
||
.table-scroll {
|
||
width: 100%;
|
||
min-width: 0;
|
||
overflow-x: auto;
|
||
border: 1px solid #dbe5ee;
|
||
border-radius: 10px;
|
||
background: #ffffff;
|
||
}
|
||
.table-scroll table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
table-layout: fixed;
|
||
}
|
||
.columns-table-scroll > table {
|
||
min-width: 860px;
|
||
}
|
||
.table-scroll th,
|
||
.table-scroll td {
|
||
padding: 10px 12px;
|
||
text-align: left;
|
||
font-size: 13.5px;
|
||
line-height: 1.5;
|
||
border-bottom: 1px solid #e0e8ef;
|
||
vertical-align: middle;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.table-scroll th {
|
||
background: #edf4fa;
|
||
color: #4e6479;
|
||
border-bottom-color: #d5e1eb;
|
||
font-size: 12.5px;
|
||
font-weight: 600;
|
||
letter-spacing: 0.4px;
|
||
}
|
||
.table-scroll tbody tr:hover td {
|
||
background: #f1f7fb;
|
||
}
|
||
.table-scroll tbody tr:last-child td {
|
||
border-bottom: 0;
|
||
}
|
||
.column-drag-cell {
|
||
width: 48px;
|
||
}
|
||
.column-drag-handle {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 28px;
|
||
height: 28px;
|
||
border: 1px solid transparent;
|
||
border-radius: 9px;
|
||
background: none;
|
||
color: #93a6b8;
|
||
font-size: 15px;
|
||
line-height: 1;
|
||
cursor: grab;
|
||
user-select: none;
|
||
}
|
||
.column-drag-handle:hover {
|
||
border-color: #cbd9e6;
|
||
background: #eef4fa;
|
||
color: #2f5d8b;
|
||
}
|
||
.column-drag-handle:active {
|
||
cursor: grabbing;
|
||
}
|
||
tr.is-column-dragging td {
|
||
opacity: 0.45;
|
||
}
|
||
.column-child-mark {
|
||
margin-right: 6px;
|
||
color: #9fb1c2;
|
||
}
|
||
.menu-name-text {
|
||
font-weight: 500;
|
||
}
|
||
.menu-type-text {
|
||
color: #5b6f83;
|
||
}
|
||
.ops-cell {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.empty-tip {
|
||
padding: 44px 24px;
|
||
text-align: center;
|
||
color: #8293a5;
|
||
font-size: 13.5px;
|
||
}
|
||
.visually-hidden {
|
||
position: absolute;
|
||
width: 1px;
|
||
height: 1px;
|
||
margin: -1px;
|
||
padding: 0;
|
||
border: 0;
|
||
overflow: hidden;
|
||
white-space: nowrap;
|
||
clip: rect(0 0 0 0);
|
||
clip-path: inset(50%);
|
||
}
|
||
</style>
|