feat(company): 新增权限管理页面并重构菜单编辑器公司选择

- 新增 company/permission 权限管理页

- 菜单编辑器公司选择改用 EntityPicker

- CrudTable 卡片查看按钮图标化,公司卡片补充父级/租户字段
This commit is contained in:
TermiNexus committed 2026-08-08 13:00:11 +08:00
1 parent fb03d60d80
commit 47506bd0b9
4 files changed
+89 -104

No files matched your search

+6 -2
View File
@@ -689,7 +689,9 @@ function isCardSelected(row: Record<string, unknown>): boolean {
{{ node.children.length }} 子级
</el-tag>
</div>
<el-button link type="primary" @click.stop="handleView(node.row as T)">查看</el-button>
<el-button size="small" type="primary" @click.stop="handleView(node.row as T)">
<el-icon><View /></el-icon>查看
</el-button>
</div>
<!-- 卡片主体:字段键值列表 -->
<div class="crud-card-body">
@@ -763,7 +765,9 @@ function isCardSelected(row: Record<string, unknown>): boolean {
</div>
<span class="crud-card-title">{{ (row as Record<string, unknown>)[cardTitleField] || '-' }}</span>
</div>
<el-button link type="primary" @click.stop="handleView(row as T)">查看</el-button>
<el-button size="small" type="primary" @click.stop="handleView(row as T)">
<el-icon><View /></el-icon>查看
</el-button>
</div>
<!-- 卡片主体:字段键值列表 -->
<div class="crud-card-body">
+1 -1
View File
@@ -104,7 +104,7 @@ async function handleIconChange(event: Event): Promise<void> {
card-icon-field="CompanyIcon"
card-tree-parent-field="ParentCompanyUUID"
:default-page-size="1000"
:card-fields="['CompanyShort', 'CompanyStatus', 'CreateTime']"
:card-fields="['CompanyShort', 'CompanyStatus', 'ParentCompanyUUID', 'TenantUUID', 'CreateTime']"
:card-value-maps="{
CompanyStatus: { 0: '禁用', 1: '启用' },
}"
+50
View File
@@ -0,0 +1,50 @@
<script setup lang="ts">
/**
* 权限管理(Permission)
*
* 修复:#/Company/permission 曾 404(迁移遗漏页面)。
* 权限表(Permission)为全局共享配置(后端 COMPANY_SCOPE_EXEMPT_TABLES 豁免公司隔离),
* 列表页直接走 CrudTable 卡片模式:
* - 卡片标题取 PermName(权限名称)
* - 展示 PermCode(权限键)/ PermCategory(分类)/ PermStatus(状态)/ PermDesc(描述)/ CreateTime
*
* 权限无独立详情页,"查看"不启用(保留编辑/删除走通用弹窗)。
*/
import CrudTable from '@/components/CrudTable.vue'
import AdminBadge from '@/components/AdminBadge.vue'
import { setPageTitle } from '@/utils/serverScope'
setPageTitle('权限管理')
</script>
<template>
<div class="permission-page">
<!-- 管理员特权横幅(仅特权模式显示) -->
<AdminBadge />
<CrudTable
table="Permission"
title="权限管理"
pk-field="PermUUID"
searchable
pageable
stretch
selectable
card-mode
card-title-field="PermName"
:card-fields="['PermCode', 'PermCategory', 'PermStatus', 'PermDesc', 'CreateTime']"
:card-value-maps="{
PermStatus: { 0: '禁用', 1: '启用' },
}"
/>
</div>
</template>
<style scoped lang="scss">
.permission-page {
padding: 20px;
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
</style>
+32 -101
View File
@@ -31,12 +31,14 @@
* 注意:api/menu.ts 的 saveMenu/deleteMenu 参数与原生后端字段不一致(Id vs MenuUUID、JSON vs 表单编码),
* 故此处直接使用 request 底层 post 并显式设置表单编码,以保持与原生后端契约一致。
*/
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import request from '@/api/request'
import { setPageTitle } from '@/utils/serverScope'
import { useUserStore } from '@/stores/user'
import AdminBadge from '@/components/AdminBadge.vue'
import EntityPicker from '@/components/EntityPicker.vue'
import { entityPickerPresets } from '@/api/entityPickerPresets'
setPageTitle('菜单编辑器')
@@ -64,13 +66,6 @@ interface MenuNode {
children?: MenuNode[]
}
/** 公司搜索结果项 */
interface CompanyItem {
CompanyUUID: string
CompanyName: string
CompanyShort?: string
}
/** 标签页(每公司一个) */
interface MenuTab {
companyUUID: string
@@ -85,11 +80,9 @@ const tabs = ref<MenuTab[]>([])
const activeTabIndex = ref(-1)
const activeTab = computed(() => (activeTabIndex.value >= 0 ? tabs.value[activeTabIndex.value] : null))
/** 公司搜索 */
const companySearchKeyword = ref('')
const companySearchResults = ref<CompanyItem[]>([])
const companySearchVisible = ref(false)
let companySearchTimer: ReturnType<typeof setTimeout> | null = null
/** 公司实体选择框(EntityPicker):选中公司 UUID 与回显标签 */
const companyPickerValue = ref('')
const companyPickerLabel = ref('')
/** 右键菜单 */
const ctxMenuVisible = ref(false)
@@ -167,48 +160,30 @@ function buildSubtree(flatMenus: MenuNode[], rootUUID: string): MenuNode | null
return result
}
/** 公司搜索(防抖 300ms) */
function onCompanySearchInput(): void {
if (companySearchTimer) clearTimeout(companySearchTimer)
const kw = companySearchKeyword.value.trim()
if (!kw) {
companySearchResults.value = []
companySearchVisible.value = false
return
}
companySearchTimer = setTimeout(async () => {
try {
const data = await request.get<CompanyItem[]>('/API/FormAPI/CompanySearch', {
params: { keyword: kw, limit: 10 },
})
companySearchResults.value = Array.isArray(data) ? data : []
companySearchVisible.value = companySearchResults.value.length > 0
} catch {
companySearchResults.value = []
companySearchVisible.value = false
}
}, 300)
}
/** 选择公司:打开或切换标签页 */
function selectCompany(c: CompanyItem): void {
companySearchKeyword.value = c.CompanyName
companySearchVisible.value = false
const existing = tabs.value.findIndex((t) => t.companyUUID === c.CompanyUUID)
/** 打开或切换公司标签页(EntityPicker 选择触发) */
function openCompanyTab(uuid: string, name: string): void {
if (!uuid) return
const existing = tabs.value.findIndex((t) => t.companyUUID === uuid)
if (existing >= 0) {
switchTab(existing)
} else {
tabs.value.push({
companyUUID: c.CompanyUUID,
companyName: c.CompanyName,
menus: [],
selectedUUID: null,
})
switchTab(tabs.value.length - 1)
loadMenus()
return
}
tabs.value.push({
companyUUID: uuid,
companyName: name || uuid,
menus: [],
selectedUUID: null,
})
switchTab(tabs.value.length - 1)
void loadMenus()
}
/** 公司实体选择框选中 → 打开/切换公司标签页 */
watch(companyPickerValue, (uuid) => {
if (!uuid) return
openCompanyTab(uuid, companyPickerLabel.value || uuid)
})
/** 切换标签页 */
function switchTab(index: number): void {
if (index < 0 || index >= tabs.value.length) return
@@ -754,11 +729,10 @@ onBeforeUnmount(() => {
document.removeEventListener('click', onDocClick)
})
/** 文档点击:关闭右键菜单 / 公司搜索下拉 */
/** 文档点击:关闭右键菜单 */
function onDocClick(e: MouseEvent): void {
const target = e.target as HTMLElement
if (!target.closest('.menu-ctx-panel')) ctxMenuVisible.value = false
if (!target.closest('.company-search-wrap')) companySearchVisible.value = false
}
</script>
@@ -783,24 +757,14 @@ function onDocClick(e: MouseEvent): void {
<span>菜单编辑器</span>
</h1>
<div class="company-search-wrap">
<el-input
v-model="companySearchKeyword"
<EntityPicker
v-model="companyPickerValue"
:config="entityPickerPresets.Company"
:existing-label="companyPickerLabel"
placeholder="搜索公司添加标签页..."
clearable
style="width: 240px"
@input="onCompanySearchInput"
@update:label="(label: string) => (companyPickerLabel = label)"
/>
<div v-if="companySearchVisible" class="company-search-dropdown">
<div
v-for="c in companySearchResults"
:key="c.CompanyUUID"
class="company-search-item"
@click="selectCompany(c)"
>
<strong>{{ c.CompanyName }}</strong>
<span>{{ c.CompanyShort || '' }}</span>
</div>
</div>
</div>
<el-button type="primary" :disabled="!activeTab" @click="addRootMenu">
<i class="fas fa-plus" /> 新增根菜单
@@ -1010,40 +974,7 @@ function onDocClick(e: MouseEvent): void {
}
.company-search-wrap {
position: relative;
.company-search-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 100;
max-height: 240px;
overflow-y: auto;
.company-search-item {
padding: 8px 12px;
cursor: pointer;
font-size: 13px;
&:hover {
background: var(--el-fill-color-light);
}
strong {
display: block;
}
span {
font-size: 11px;
color: var(--el-text-color-secondary);
}
}
}
// EntityPicker 公司实体选择框容器(头部工具区布局占位)
}
.menu-body {