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 }} 子级 {{ node.children.length }} 子级
</el-tag> </el-tag>
</div> </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>
<!-- 卡片主体:字段键值列表 --> <!-- 卡片主体:字段键值列表 -->
<div class="crud-card-body"> <div class="crud-card-body">
@@ -763,7 +765,9 @@ function isCardSelected(row: Record<string, unknown>): boolean {
</div> </div>
<span class="crud-card-title">{{ (row as Record<string, unknown>)[cardTitleField] || '-' }}</span> <span class="crud-card-title">{{ (row as Record<string, unknown>)[cardTitleField] || '-' }}</span>
</div> </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>
<!-- 卡片主体:字段键值列表 --> <!-- 卡片主体:字段键值列表 -->
<div class="crud-card-body"> <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-icon-field="CompanyIcon"
card-tree-parent-field="ParentCompanyUUID" card-tree-parent-field="ParentCompanyUUID"
:default-page-size="1000" :default-page-size="1000"
:card-fields="['CompanyShort', 'CompanyStatus', 'CreateTime']" :card-fields="['CompanyShort', 'CompanyStatus', 'ParentCompanyUUID', 'TenantUUID', 'CreateTime']"
:card-value-maps="{ :card-value-maps="{
CompanyStatus: { 0: '禁用', 1: '启用' }, 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 表单编码), * 注意:api/menu.ts 的 saveMenu/deleteMenu 参数与原生后端字段不一致(Id vs MenuUUID、JSON vs 表单编码),
* 故此处直接使用 request 底层 post 并显式设置表单编码,以保持与原生后端契约一致。 * 故此处直接使用 request 底层 post 并显式设置表单编码,以保持与原生后端契约一致。
*/ */
import { ref, computed, onMounted, onBeforeUnmount } from 'vue' import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import request from '@/api/request' import request from '@/api/request'
import { setPageTitle } from '@/utils/serverScope' import { setPageTitle } from '@/utils/serverScope'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import AdminBadge from '@/components/AdminBadge.vue' import AdminBadge from '@/components/AdminBadge.vue'
import EntityPicker from '@/components/EntityPicker.vue'
import { entityPickerPresets } from '@/api/entityPickerPresets'
setPageTitle('菜单编辑器') setPageTitle('菜单编辑器')
@@ -64,13 +66,6 @@ interface MenuNode {
children?: MenuNode[] children?: MenuNode[]
} }
/** 公司搜索结果项 */
interface CompanyItem {
CompanyUUID: string
CompanyName: string
CompanyShort?: string
}
/** 标签页(每公司一个) */ /** 标签页(每公司一个) */
interface MenuTab { interface MenuTab {
companyUUID: string companyUUID: string
@@ -85,11 +80,9 @@ const tabs = ref<MenuTab[]>([])
const activeTabIndex = ref(-1) const activeTabIndex = ref(-1)
const activeTab = computed(() => (activeTabIndex.value >= 0 ? tabs.value[activeTabIndex.value] : null)) const activeTab = computed(() => (activeTabIndex.value >= 0 ? tabs.value[activeTabIndex.value] : null))
/** 公司搜索 */ /** 公司实体选择框(EntityPicker):选中公司 UUID 与回显标签 */
const companySearchKeyword = ref('') const companyPickerValue = ref('')
const companySearchResults = ref<CompanyItem[]>([]) const companyPickerLabel = ref('')
const companySearchVisible = ref(false)
let companySearchTimer: ReturnType<typeof setTimeout> | null = null
/** 右键菜单 */ /** 右键菜单 */
const ctxMenuVisible = ref(false) const ctxMenuVisible = ref(false)
@@ -167,48 +160,30 @@ function buildSubtree(flatMenus: MenuNode[], rootUUID: string): MenuNode | null
return result return result
} }
/** 公司搜索(防抖 300ms) */ /** 打开或切换公司标签页(EntityPicker 选择触发) */
function onCompanySearchInput(): void { function openCompanyTab(uuid: string, name: string): void {
if (companySearchTimer) clearTimeout(companySearchTimer) if (!uuid) return
const kw = companySearchKeyword.value.trim() const existing = tabs.value.findIndex((t) => t.companyUUID === uuid)
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)
if (existing >= 0) { if (existing >= 0) {
switchTab(existing) switchTab(existing)
} else { return
tabs.value.push({
companyUUID: c.CompanyUUID,
companyName: c.CompanyName,
menus: [],
selectedUUID: null,
})
switchTab(tabs.value.length - 1)
loadMenus()
} }
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 { function switchTab(index: number): void {
if (index < 0 || index >= tabs.value.length) return if (index < 0 || index >= tabs.value.length) return
@@ -754,11 +729,10 @@ onBeforeUnmount(() => {
document.removeEventListener('click', onDocClick) document.removeEventListener('click', onDocClick)
}) })
/** 文档点击:关闭右键菜单 / 公司搜索下拉 */ /** 文档点击:关闭右键菜单 */
function onDocClick(e: MouseEvent): void { function onDocClick(e: MouseEvent): void {
const target = e.target as HTMLElement const target = e.target as HTMLElement
if (!target.closest('.menu-ctx-panel')) ctxMenuVisible.value = false if (!target.closest('.menu-ctx-panel')) ctxMenuVisible.value = false
if (!target.closest('.company-search-wrap')) companySearchVisible.value = false
} }
</script> </script>
@@ -783,24 +757,14 @@ function onDocClick(e: MouseEvent): void {
<span>菜单编辑器</span> <span>菜单编辑器</span>
</h1> </h1>
<div class="company-search-wrap"> <div class="company-search-wrap">
<el-input <EntityPicker
v-model="companySearchKeyword" v-model="companyPickerValue"
:config="entityPickerPresets.Company"
:existing-label="companyPickerLabel"
placeholder="搜索公司添加标签页..." placeholder="搜索公司添加标签页..."
clearable
style="width: 240px" 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> </div>
<el-button type="primary" :disabled="!activeTab" @click="addRootMenu"> <el-button type="primary" :disabled="!activeTab" @click="addRootMenu">
<i class="fas fa-plus" /> 新增根菜单 <i class="fas fa-plus" /> 新增根菜单
@@ -1010,40 +974,7 @@ function onDocClick(e: MouseEvent): void {
} }
.company-search-wrap { .company-search-wrap {
position: relative; // EntityPicker 公司实体选择框容器(头部工具区布局占位)
.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);
}
}
}
} }
.menu-body { .menu-body {