Files
crawler-plugin/admin-frontend-vue/src/pages/account/MenusPage.vue
T

553 lines
16 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.
<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>