20260920173439
This commit is contained in:
1 parent
f4bfc1ddfc
commit
6f03d92425
174 files changed
+23490
-157
No files matched your search
@@ -66,6 +66,9 @@ const props = defineProps({
|
||||
rowDraggable: { type: Boolean, default: false },
|
||||
// 行拖拽排序字段;未显式传入时不启用业务序号排序。
|
||||
orderField: { type: String, default: '' },
|
||||
// 临时放开的隐藏列(view JSON 里 visible:false 的字段):传入字段名即强制显示该列,
|
||||
// 供「查看密码」这类按需揭示敏感列的页面开关使用。只影响本次渲染,不回写 view 配置。
|
||||
revealedColumns: { type: Array, default: () => [] },
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
@@ -128,6 +131,78 @@ const columnDirty = ref(false)
|
||||
const columnPrefRowExists = ref(false)
|
||||
// 通用栏位行(s_module_schema.view 展开结果):个人增量与其比较,「保存为通用」也以它为底
|
||||
const columnGlobalRows = shallowRef([])
|
||||
// view JSON 里 visible:false 的列(如 b_password):默认不渲染,由 revealedColumns 开关按需追加
|
||||
const hiddenColumnRows = shallowRef([])
|
||||
// 个人层栏位增量(s_user_module_pref):revealedColumns 开关重算列时复用同一份,避免丢个人宽度/顺序
|
||||
const savedColumnPref = shallowRef(null)
|
||||
|
||||
/**
|
||||
* 从隐藏列里挑出本次要揭示的行。
|
||||
*
|
||||
* 关键:必须把 b_visible 改写成 1 再交出去。FmsModuleTable.buildColumns 会再过滤一次
|
||||
* `b_visible === 1`,原样透传 b_visible:0 会在那一层被丢掉,列永远出不来。
|
||||
* 返回新对象,不改 hiddenColumnRows 里的原行,保证关掉开关后仍是隐藏态。
|
||||
*
|
||||
* @param orderedRows view JSON 展开后的完整行序(listRows),用于保持列的原声明顺序
|
||||
* @param revealed 要揭示的字段名
|
||||
*/
|
||||
function revealedColumnsOf(orderedRows, revealed) {
|
||||
const wanted = new Set((revealed || []).map((field) => String(field)))
|
||||
if (!wanted.size) return []
|
||||
return (orderedRows || [])
|
||||
.filter((row) => wanted.has(String(row.b_field)))
|
||||
.map((row) => ({ ...row, b_visible: 1 }))
|
||||
}
|
||||
|
||||
/**
|
||||
* 把揭示出来的列按 view JSON 的声明顺序插回可见列里。
|
||||
*
|
||||
* 不能直接 [...visible, ...revealed] 追加到末尾:那样列位置由开关决定而不是配置决定,
|
||||
* 配置里写在 b_id 之后的列会跑到最后一列。这里按 b_xh(声明序号)做一次归并。
|
||||
*/
|
||||
function mergeRevealedColumns(visibleRows, revealedRows) {
|
||||
if (!revealedRows.length) return visibleRows
|
||||
const rank = (row) => {
|
||||
const xh = Number(row?.b_xh)
|
||||
// 无声明序号的排在最后,保持稳定
|
||||
return Number.isFinite(xh) ? xh : Number.MAX_SAFE_INTEGER
|
||||
}
|
||||
return [...visibleRows, ...revealedRows]
|
||||
.map((row, index) => ({ row, index }))
|
||||
.toSorted((a, b) => {
|
||||
const ra = rank(a.row)
|
||||
const rb = rank(b.row)
|
||||
return ra === rb ? a.index - b.index : ra - rb
|
||||
})
|
||||
.map((item) => item.row)
|
||||
}
|
||||
|
||||
/**
|
||||
* 把个人层已保存的栏位顺序,重写成「含揭示列」的新顺序。
|
||||
*
|
||||
* 个人层 order 是整体排列。用户早先保存过顺序时,列表里只含当时的可见列;
|
||||
* 揭示列(如 b_password)不在其中,mergeColumnPref 会按「未列入的字段追加在后」
|
||||
* 把它排到最后一列 —— 上游插得再对也会被这一步覆盖。
|
||||
* 这里按揭示列的 b_xh 找到它该在的位置,把 order 补全后再交给 mergeColumnPref。
|
||||
*
|
||||
* @param pref 个人层增量(含 order,可能为 null)
|
||||
* @param visibleRows 可见列(通用顺序,含 b_xh)
|
||||
* @param revealedRows 本次揭示的列(含 b_xh)
|
||||
*/
|
||||
function withRevealedInOrder(pref, visibleRows, revealedRows) {
|
||||
if (!pref || !revealedRows.length) return pref
|
||||
const orderList = Array.isArray(pref.order) ? pref.order.map(String) : []
|
||||
if (!orderList.length) return pref
|
||||
|
||||
// 按 b_xh 归并:揭示列插到第一个声明序号比它大的可见列之前,与真实列序一致
|
||||
const sequence = mergeRevealedColumns(visibleRows, revealedRows).map((row) =>
|
||||
String(row.b_field),
|
||||
)
|
||||
// 只保留确实存在于本次列集合里的字段,并保持个人层的相对顺序
|
||||
const kept = orderList.filter((field) => sequence.includes(field))
|
||||
const merged = sequence.filter((field) => kept.includes(field) || !orderList.includes(field))
|
||||
return { ...pref, order: merged }
|
||||
}
|
||||
// 「保存为通用」会改到所有人的默认视图,限系统管理员(与 /module/* 的 meta.adminOnly 同一判据)
|
||||
const canSaveColumnGlobal = computed(() => permissions.isG3soft)
|
||||
// 无查询权限的字段(s_user_field_power.b_query = 0),设置抽屉中灰显
|
||||
@@ -313,12 +388,23 @@ async function loadModuleConfig() {
|
||||
}
|
||||
editConfigById.value = editConfigByIdMap
|
||||
|
||||
// 通用栏位基线(lists)恒为「常规可见列」,揭示列不进基线:
|
||||
// 否则开关重算时会与隐藏列副本重复拼接,出现两列表头。
|
||||
const lists = []
|
||||
hiddenColumnRows.value = []
|
||||
for (const item of listRows) {
|
||||
if (Number(item.b_visible) !== 1) continue
|
||||
if (!fieldById.has(String(item.b_field))) continue
|
||||
if (Number(item.b_visible) !== 1) {
|
||||
// view 里显式隐藏的列(如 b_password):默认不渲染,只留副本供揭示开关取用
|
||||
hiddenColumnRows.value.push(item)
|
||||
continue
|
||||
}
|
||||
lists.push(item)
|
||||
}
|
||||
// 已揭示的隐藏列:按 view JSON 声明的原始顺序插回,而不是一律追加到末尾;
|
||||
// 基线仍是纯可见列,揭示列只参与本次渲染。
|
||||
const revealedLists = revealedColumnsOf(listRows, props.revealedColumns)
|
||||
const renderLists = mergeRevealedColumns(lists, revealedLists)
|
||||
// 通用栏位(lists)即「保存为通用」的基线与个人增量的比较基准;
|
||||
// 个人层只叠加宽度与顺序,不增删字段,因此先裁到同一字段集再合并。
|
||||
columnGlobalRows.value = lists
|
||||
@@ -347,7 +433,7 @@ async function loadModuleConfig() {
|
||||
// 两层配置(系统默认 → 个人)+ 字段查询权限;权限在偏好合并之后裁剪,不得放宽权限
|
||||
const userId = currentUserId()
|
||||
// 查询布局(query)与栏位布局(view)各有一份个人层增量,互不影响
|
||||
const [savedPref, savedColumnPref] = await Promise.all([
|
||||
const [savedPref, savedColumnPrefDelta] = await Promise.all([
|
||||
loadModulePref(userId, dataId),
|
||||
loadModulePref(userId, dataId, PREF_SCHEMA_TYPES.view),
|
||||
])
|
||||
@@ -374,9 +460,15 @@ async function loadModuleConfig() {
|
||||
queryLayout.value = mergeQueryLayout(defaultQueryLayout.value, savedPref, allowedFields)
|
||||
|
||||
// 栏位:通用(lists)→ 个人层叠加宽度与顺序;个人层不增删字段(通用没有的字段一律忽略)
|
||||
const mergedLists = mergeColumnPref(lists, savedColumnPref)
|
||||
columnPrefRowExists.value = Boolean(savedColumnPref)
|
||||
columnBaseline.value = columnRowsOf(mergedLists)
|
||||
// 首次渲染带上已揭示的隐藏列(按声明顺序插回);基线只取通用可见列,避免把揭示列固化进栏位设置
|
||||
// 个人层 order 不含揭示列时会把列挤到末尾,先补进 order 再合并
|
||||
const mergedLists = mergeColumnPref(
|
||||
renderLists,
|
||||
withRevealedInOrder(savedColumnPrefDelta, lists, revealedLists),
|
||||
)
|
||||
columnPrefRowExists.value = Boolean(savedColumnPrefDelta)
|
||||
savedColumnPref.value = savedColumnPrefDelta
|
||||
columnBaseline.value = columnRowsOf(mergeColumnPref(lists, savedColumnPrefDelta))
|
||||
columnDirty.value = false
|
||||
|
||||
// 默认条件取全部启用字段:简单查询与高级查询共用同一批启用字段
|
||||
@@ -1260,6 +1352,22 @@ onActivated(async () => {
|
||||
|
||||
watch(() => props.dataCode, initialize)
|
||||
|
||||
// 按需揭示的隐藏列(如「查看密码」):开关变化时不重新请求数据,
|
||||
// 只在已有的通用栏位基线上重算可见列,避免整表刷新造成闪烁。
|
||||
watch(
|
||||
() => props.revealedColumns,
|
||||
() => {
|
||||
if (!columnGlobalRows.value.length && !props.revealedColumns?.length) return
|
||||
// 按 view JSON 声明顺序插回,与 loadModuleConfig 走同一套归并,避免两处顺序不一致
|
||||
const next = revealedColumnsOf(hiddenColumnRows.value, props.revealedColumns)
|
||||
listConfig.value = mergeColumnPref(
|
||||
mergeRevealedColumns(columnGlobalRows.value, next),
|
||||
// 个人层 order 不含揭示列时会把列挤到末尾,先补进 order 再合并
|
||||
withRevealedInOrder(savedColumnPref.value, columnGlobalRows.value, next),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.fixedSearchCondition,
|
||||
() => {
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
<script setup>
|
||||
/**
|
||||
* 分配权限抽屉:按用户维护三类权限,对应核心表结构第 14 节的三张表。
|
||||
*
|
||||
* s_user_power 功能权限(菜单 / 模块 / 动作三层权限点的勾选,即 menu.* / module.* / action.*)
|
||||
* s_user_field_power 字段权限(按模块 × 字段的 查看 / 编辑 / 查询 / 导出)
|
||||
* s_user_data_power 数据范围(按模块 × 操作的范围类型与条件)
|
||||
*
|
||||
* 布局:右侧 Drawer + 顶部页签,三个页签分别承载三张表。
|
||||
* 页签用 nav-only(Tabs 只管导航),内容由下方 v-show 面板承载 —— 与模块管理的
|
||||
* 配置区同一形态,切换页签不销毁已挂载的面板。
|
||||
*
|
||||
* 当前「功能权限」已接入;字段权限与数据范围仍为空面板。
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { Button, Drawer, Message, Tabs } from '@/components/ui'
|
||||
import { loadDataApi, saveObjectApi } from '@/services/api'
|
||||
import UserPowerPanel from './UserPowerPanel.vue'
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
/** 待分配权限的账号行(b_user) */
|
||||
user: { type: Object, default: null },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:open'])
|
||||
|
||||
/* ---------- 页签 ---------- */
|
||||
const activeTab = ref('power')
|
||||
|
||||
const tabItems = [
|
||||
{ key: 'power', label: '功能权限' },
|
||||
{ key: 'field', label: '字段权限' },
|
||||
{ key: 'data', label: '数据范围' },
|
||||
]
|
||||
|
||||
/* ---------- 标题 ---------- */
|
||||
const userLabel = computed(() => {
|
||||
const row = props.user
|
||||
if (!row) return ''
|
||||
const name = String(row.b_name || '').trim()
|
||||
const code = String(row.b_id || '').trim()
|
||||
return name ? `${name}(${code})` : code
|
||||
})
|
||||
|
||||
const title = computed(() => (userLabel.value ? `分配权限 - ${userLabel.value}` : '分配权限'))
|
||||
|
||||
/* ================= 功能权限:加载 ================= */
|
||||
/** 安全 SQL 字符串字面量(search_condition 由后端拼 SQL,需转义单引号) */
|
||||
function sqlText(value) {
|
||||
return `N'${String(value ?? '').replace(/'/g, "''")}'`
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
/** 菜单树(s_menu 全量,b_parent_id 是权威父子关系) */
|
||||
const menuTree = ref([])
|
||||
/** 菜单编码 → s_menu_module 行数组 */
|
||||
const menuModules = ref({})
|
||||
/** 模块编码 → 模块行(s_module) */
|
||||
const moduleById = ref(new Map())
|
||||
/** 模块编码 → 权限点数组(s_power) */
|
||||
const powerByModule = ref({})
|
||||
|
||||
/** 已勾选的权限编码(s_user_power.b_power_id),写入基线用于保存 diff */
|
||||
const checkedPowerIds = ref([])
|
||||
/** 保存基线:打开时的已授权集合,保存时据此算增删 */
|
||||
let checkedBaseline = new Set()
|
||||
|
||||
/**
|
||||
* 加载功能权限所需的四份数据。
|
||||
*
|
||||
* s_menu_module 与 s_power 没有按用户过滤的概念(它们是全局配置),
|
||||
* 一次性取回后在前端按模块编码分组,避免逐个模块发请求(48 个模块就是 48 次)。
|
||||
*/
|
||||
async function loadPowerData() {
|
||||
const user = props.user
|
||||
const userId = String(user?.b_id || '').trim()
|
||||
if (!userId) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const [menuRes, bindRes, moduleRes, powerRes, grantedRes] = await Promise.all([
|
||||
loadDataApi('s_menu', 'b_canuse = 1', 'b_depth ASC, b_xh ASC, b_id ASC'),
|
||||
loadDataApi('s_menu_module', 'b_canuse = 1', 'b_menu_id ASC, b_xh ASC'),
|
||||
loadDataApi('s_module', 'b_canuse = 1', 'b_id ASC'),
|
||||
loadDataApi('s_power', 'b_canuse = 1', 'b_object_id ASC, b_xh ASC, b_id ASC'),
|
||||
loadDataApi('s_user_power', `b_user_id = ${sqlText(userId)}`, 'b_power_id ASC'),
|
||||
])
|
||||
|
||||
menuTree.value = menuRes.data || []
|
||||
|
||||
const moduleMap = new Map()
|
||||
for (const row of moduleRes.data || []) {
|
||||
moduleMap.set(String(row.b_id), row)
|
||||
}
|
||||
moduleById.value = moduleMap
|
||||
|
||||
// s_menu_module → { [b_menu_id]: [行] };只保留模块确实存在的绑定
|
||||
const byMenu = {}
|
||||
for (const row of bindRes.data || []) {
|
||||
const menuId = String(row.b_menu_id || '')
|
||||
const moduleId = String(row.b_module_id || '')
|
||||
if (!menuId || !moduleMap.has(moduleId)) continue
|
||||
if (!byMenu[menuId]) byMenu[menuId] = []
|
||||
byMenu[menuId].push(row)
|
||||
}
|
||||
menuModules.value = byMenu
|
||||
|
||||
// s_power → { [b_object_id]: [权限点] },按 b_xh 排序(已由 order_by 保证)
|
||||
const byModule = {}
|
||||
for (const row of powerRes.data || []) {
|
||||
const objectId = String(row.b_object_id || '')
|
||||
if (!objectId) continue
|
||||
if (!byModule[objectId]) byModule[objectId] = []
|
||||
byModule[objectId].push(row)
|
||||
}
|
||||
powerByModule.value = byModule
|
||||
|
||||
const granted = (grantedRes.data || [])
|
||||
.filter((row) => Number(row.b_canuse) === 1)
|
||||
.map((row) => String(row.b_power_id))
|
||||
checkedPowerIds.value = granted
|
||||
checkedBaseline = new Set(granted)
|
||||
} catch (error) {
|
||||
Message.error('权限数据加载失败:' + (error?.message || error))
|
||||
menuTree.value = []
|
||||
menuModules.value = {}
|
||||
powerByModule.value = {}
|
||||
checkedPowerIds.value = []
|
||||
checkedBaseline = new Set()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 打开时加载;账号变化(同一抽屉切换用户)也重新加载
|
||||
watch(
|
||||
[() => props.open, () => props.user?.b_id],
|
||||
([open]) => {
|
||||
if (!open) return
|
||||
void loadPowerData()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
/* ================= 功能权限:保存 ================= */
|
||||
/**
|
||||
* 保存功能权限:勾选 = 拥有(s_user_power 插入),取消 = 撤销(删除行)。
|
||||
* s_user_power 是「拥有」语义,不置 b_canuse = 0。
|
||||
*/
|
||||
async function savePower(userId) {
|
||||
const current = new Set(checkedPowerIds.value.map(String))
|
||||
const inserts = [...current]
|
||||
.filter((id) => !checkedBaseline.has(id))
|
||||
.map((b_power_id) => ({ b_user_id: userId, b_power_id, b_canuse: 1 }))
|
||||
const deletes = [...checkedBaseline]
|
||||
.filter((id) => !current.has(id))
|
||||
.map((b_power_id) => ({ b_user_id: userId, b_power_id }))
|
||||
|
||||
if (!inserts.length && !deletes.length) return false
|
||||
|
||||
const request = { table: 's_user_power', key_field: 'b_user_id,b_power_id' }
|
||||
if (inserts.length) request.inserts = inserts
|
||||
if (deletes.length) request.deletes = deletes
|
||||
await saveObjectApi([request])
|
||||
return true
|
||||
}
|
||||
|
||||
/* ---------- 抽屉动作 ---------- */
|
||||
function close() {
|
||||
emit('update:open', false)
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const userId = String(props.user?.b_id || '').trim()
|
||||
if (!userId || saving.value) return
|
||||
saving.value = true
|
||||
try {
|
||||
const changed = await savePower(userId)
|
||||
if (changed) {
|
||||
checkedBaseline = new Set(checkedPowerIds.value.map(String))
|
||||
Message.success('权限已保存')
|
||||
} else {
|
||||
Message.info('没有需要保存的改动')
|
||||
}
|
||||
} catch (error) {
|
||||
Message.error('保存失败:' + (error?.message || error))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Drawer
|
||||
:open="open"
|
||||
:title="title"
|
||||
placement="right"
|
||||
width="min(900px, 96%)"
|
||||
:mask-closable="false"
|
||||
@update:open="emit('update:open', $event)"
|
||||
@cancel="close"
|
||||
>
|
||||
<div class="power-drawer">
|
||||
<Tabs
|
||||
v-model="activeTab"
|
||||
nav-only
|
||||
class="power-drawer__nav"
|
||||
:items="tabItems"
|
||||
aria-label="权限分类"
|
||||
/>
|
||||
|
||||
<div class="power-drawer__body">
|
||||
<div v-show="activeTab === 'power'" class="power-pane" data-pane="power">
|
||||
<UserPowerPanel
|
||||
v-model="checkedPowerIds"
|
||||
:menu-tree="menuTree"
|
||||
:menu-modules="menuModules"
|
||||
:module-by-id="moduleById"
|
||||
:power-by-module="powerByModule"
|
||||
:loading="loading"
|
||||
/>
|
||||
</div>
|
||||
<div v-show="activeTab === 'field'" class="power-pane" data-pane="field" />
|
||||
<div v-show="activeTab === 'data'" class="power-pane" data-pane="data" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="power-footer">
|
||||
<Button type="outline" :disabled="saving" @click="close">取消</Button>
|
||||
<Button type="primary" :loading="saving" @click="save">保存</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Drawer>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* 抽屉内容区纵向铺满:页签固定高度,面板区自行滚动。
|
||||
body 的 padding 已由 :has() 规则清零(左右两栏要贴边滚动),间距在这里补回 */
|
||||
.power-drawer {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
padding: 12px 20px;
|
||||
}
|
||||
|
||||
.power-drawer__nav {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.power-drawer__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
/* 不在此滚动:面板左右两栏各占满高度、各自管理自己的滚动 */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 面板占满本页签剩余高度,内部左右两栏才能各自拿到确定的 100% */
|
||||
.power-pane {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.power-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- 非 scoped:header 由 ui Drawer 渲染,而 Drawer 根节点是 Teleport,
|
||||
class 无法透传(scoped 与外部 class 都命不中),因此用内容锚点 .power-drawer
|
||||
配合 :has() 定位本抽屉的面板(与 FmsFileModal / ui/select 同一做法)。
|
||||
只有本抽屉的内容才会渲染 .power-drawer,其它抽屉不受影响。 -->
|
||||
<style lang="scss">
|
||||
/* 头部不画分割线:页签自带底线即本级导航的分隔,避免两条平行线叠在一起 */
|
||||
.fms-drawer:has(.power-drawer) .fms-drawer-header {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* 高度链起点:.fms-drawer-body 是普通块容器(ui Drawer 的 scoped 样式,改不了),
|
||||
.power-drawer 的 flex:1 在块容器里不生效、高度塌成 auto,导致面板 height:100% 全链失效。
|
||||
这里把它变成 flex 容器,让 .power-drawer 能吃满剩余高度。 */
|
||||
.fms-drawer:has(.power-drawer) .fms-drawer-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* body 自带的 padding 与「左右两栏贴边」冲突,收敛到面板内部自理 */
|
||||
.fms-drawer:has(.power-drawer) .fms-drawer-body {
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,246 @@
|
||||
<script setup>
|
||||
/**
|
||||
* 功能权限面板(用户 → 分配权限 → 功能权限)。
|
||||
*
|
||||
* 三张表串起来的授权链路:
|
||||
* s_menu 左侧菜单树(b_parent_id 是权威父子关系)
|
||||
* s_menu_module 选中菜单实际使用的模块清单
|
||||
* s_power 每个模块的权限点(b_object_id = 模块编码)
|
||||
* s_user_power 勾选结果(用户 × 权限点,主键 b_user_id + b_power_id)
|
||||
*
|
||||
* 布局:左右两栏各占满高度、各自滚动。右侧是纵向列表(不用表格):
|
||||
* 数据模块为一级,模块下的权限点为二级,逐组列下来。
|
||||
*
|
||||
* 数据契约:父级持有菜单树与模块清单,本组件只读展示;勾选结果经 v-model 双向绑定
|
||||
* (选中权限编码数组),差异比对与保存由父级负责。
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { Checkbox, Spin } from '@/components/ui'
|
||||
import FmsTreePanel from '@/components/fms-tree/FmsTreePanel.vue'
|
||||
import { buildSortedTree, filterTreeByText } from '@/utils/tree'
|
||||
|
||||
const props = defineProps({
|
||||
/** 菜单树(s_menu 全量),父级加载 */
|
||||
menuTree: { type: Array, default: () => [] },
|
||||
/** 菜单编码 → s_menu_module 行数组(s_menu_module),父级加载 */
|
||||
menuModules: { type: Object, default: () => ({}) },
|
||||
/** 模块编码 → 模块行(s_module),用于展示模块名称 */
|
||||
moduleById: { type: Object, default: () => new Map() },
|
||||
/** 模块编码 → 该模块的权限点数组(s_power),父级加载 */
|
||||
powerByModule: { type: Object, default: () => ({}) },
|
||||
/** 树与数据加载中 */
|
||||
loading: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
/** 已勾选的权限编码(s_user_power 的 b_power_id),父级 v-model 绑定 */
|
||||
const checkedPowerIds = defineModel({ type: Array, default: () => [] })
|
||||
|
||||
/* ---------- 左侧菜单树 ---------- */
|
||||
const keyword = ref('')
|
||||
const expandedKeys = ref([])
|
||||
const selectedMenuId = ref('')
|
||||
|
||||
const treeData = computed(() => buildSortedTree(props.menuTree))
|
||||
const filteredTree = computed(() => filterTreeByText(treeData.value, keyword.value, ['b_name', 'b_id']))
|
||||
|
||||
function onMenuSelect(keys) {
|
||||
selectedMenuId.value = keys.length ? String(keys[0]) : ''
|
||||
}
|
||||
|
||||
/* ---------- 右侧:数据一级(模块)+ 权限二级(权限点) ---------- */
|
||||
|
||||
/** 当前菜单绑定的模块编码(s_menu_module.b_xh 排序由父级的 order_by 保证) */
|
||||
const boundModuleIds = computed(() => {
|
||||
const list = props.menuModules[selectedMenuId.value] || []
|
||||
return list.map((item) => String(item.b_module_id))
|
||||
})
|
||||
|
||||
/** 已勾选集合(Set 查询比数组 includes 快,每个勾选框状态都要查) */
|
||||
const checkedSet = computed(() => new Set(checkedPowerIds.value.map(String)))
|
||||
|
||||
function isChecked(powerId) {
|
||||
return checkedSet.value.has(String(powerId))
|
||||
}
|
||||
|
||||
/** 勾选 / 取消单个权限点 */
|
||||
function togglePower(powerId) {
|
||||
const id = String(powerId)
|
||||
const next = checkedPowerIds.value.map(String)
|
||||
const index = next.indexOf(id)
|
||||
if (index >= 0) next.splice(index, 1)
|
||||
else next.push(id)
|
||||
checkedPowerIds.value = next
|
||||
}
|
||||
|
||||
/** 整组(整模块)全选 / 全不选 */
|
||||
function toggleModule(row) {
|
||||
const ids = row.powers.map((power) => String(power.b_id))
|
||||
const next = checkedPowerIds.value.map(String).filter((id) => !ids.includes(id))
|
||||
if (!row.allChecked) next.push(...ids)
|
||||
checkedPowerIds.value = next
|
||||
}
|
||||
|
||||
/** 分组行:一级 = 模块(含全选状态统计),二级 = 权限点 */
|
||||
const groups = computed(() => {
|
||||
if (!selectedMenuId.value) return []
|
||||
return boundModuleIds.value.map((moduleId) => {
|
||||
const module = props.moduleById.get?.(moduleId) || null
|
||||
const powers = props.powerByModule[moduleId] || []
|
||||
const checked = powers.filter((power) => checkedSet.value.has(String(power.b_id))).length
|
||||
return {
|
||||
moduleId,
|
||||
moduleName: module?.b_name || moduleId,
|
||||
powers,
|
||||
checked,
|
||||
allChecked: powers.length > 0 && checked === powers.length,
|
||||
someChecked: checked > 0 && checked < powers.length,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
/* ---------- 默认选中第一个 page 菜单,避免右侧空着 ---------- */
|
||||
watch(
|
||||
() => props.menuTree,
|
||||
(list) => {
|
||||
if (selectedMenuId.value || !list?.length) return
|
||||
const firstPage = list.find((row) => row.b_menu_type === 'page')
|
||||
if (firstPage) selectedMenuId.value = String(firstPage.b_id)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="power-panel">
|
||||
<!-- 左:菜单树(占满高度,FmsTreePanel 内部自带滚动区) -->
|
||||
<div class="power-panel__tree">
|
||||
<FmsTreePanel
|
||||
v-model:keyword="keyword"
|
||||
v-model:expanded-keys="expandedKeys"
|
||||
placeholder="搜索菜单"
|
||||
show-root
|
||||
:tree-data="filteredTree"
|
||||
:loading="loading"
|
||||
:selected-keys="selectedMenuId ? [selectedMenuId] : []"
|
||||
@select="onMenuSelect"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 右:数据一级 / 权限二级 纵向列表(占满高度,本栏自己滚动) -->
|
||||
<div class="power-panel__main">
|
||||
<div v-if="loading" class="power-panel__state"><Spin /></div>
|
||||
|
||||
<div v-else-if="!selectedMenuId" class="power-panel__state">请选择左侧菜单</div>
|
||||
|
||||
<div v-else-if="!groups.length" class="power-panel__state">该菜单未绑定模块</div>
|
||||
|
||||
<div v-else class="power-panel__list">
|
||||
<section v-for="group in groups" :key="group.moduleId" class="power-group">
|
||||
<!-- 一级:数据模块 -->
|
||||
<div class="power-group__head">
|
||||
<Checkbox
|
||||
:model-value="group.allChecked"
|
||||
:indeterminate="group.someChecked"
|
||||
:aria-label="`${group.moduleName} 全选`"
|
||||
@change="toggleModule(group)"
|
||||
/>
|
||||
<span class="power-group__name" :title="group.moduleName">{{ group.moduleName }}</span>
|
||||
<span class="power-group__count">{{ group.checked }}/{{ group.powers.length }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 二级:权限点 -->
|
||||
<div v-if="group.powers.length" class="power-group__items">
|
||||
<Checkbox
|
||||
v-for="power in group.powers"
|
||||
:key="power.b_id"
|
||||
:model-value="isChecked(power.b_id)"
|
||||
:aria-label="String(power.b_name || power.b_id)"
|
||||
@change="togglePower(power.b_id)"
|
||||
>
|
||||
{{ power.b_name || power.b_action }}
|
||||
</Checkbox>
|
||||
</div>
|
||||
<div v-else class="power-group__empty">该模块未配置权限点</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* 左右两栏各占满高度:左栏由 FmsTreePanel 内部滚动,右栏自己滚动 */
|
||||
.power-panel {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.power-panel__tree {
|
||||
display: flex;
|
||||
flex: none;
|
||||
flex-direction: column;
|
||||
width: 230px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
border-right: 1px solid var(--fms-border);
|
||||
}
|
||||
|
||||
.power-panel__main {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.power-panel__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 4px 12px 16px 4px;
|
||||
}
|
||||
|
||||
/* 一级组:模块行 */
|
||||
.power-group__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 0 2px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.power-group__name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.power-group__count {
|
||||
flex: none;
|
||||
color: var(--fms-text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* 二级:权限点缩进挂在模块下 */
|
||||
.power-group__items {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 16px;
|
||||
padding: 0 0 4px 26px;
|
||||
}
|
||||
|
||||
.power-group__empty {
|
||||
padding: 0 0 4px 26px;
|
||||
color: var(--fms-text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.power-panel__state {
|
||||
padding: 24px 0;
|
||||
color: var(--fms-text-secondary);
|
||||
font-size: var(--fms-font-size);
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -15,7 +15,7 @@
|
||||
* 页面同时管两个领域,部门相关标识一律带 dept 前缀,避免与用户侧同名。
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { Pencil, Plus, Trash2 } from '@lucide/vue'
|
||||
import { Eye, EyeOff, Pencil, Plus, Trash2 } from '@lucide/vue'
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
import { FmsModuleListPage } from '@/components/fms-module-list'
|
||||
import { FmsModuleEditModal } from '@/components/fms-module-edit'
|
||||
import FmsTreePanel from '@/components/fms-tree/FmsTreePanel.vue'
|
||||
import UserPowerDrawer from './UserPowerDrawer.vue'
|
||||
import { escapeSqlText } from '@/components/fms-module-common/queryUtils'
|
||||
import { loadDataApi, saveObjectApi } from '@/services/api'
|
||||
import { usePermissionStore } from '@/stores/permissions'
|
||||
@@ -429,6 +430,20 @@ async function onSaved() {
|
||||
await listRef.value?.reload()
|
||||
}
|
||||
|
||||
/* ================= 查看密码(按需揭示隐藏列) ================= */
|
||||
/**
|
||||
* 密码列(b_password)在 v_b_user 的 view 配置里是 visible:false,默认不渲染。
|
||||
* 点「查看密码」把该字段加进 revealedColumns,FmsModuleListPage 会把它追加到列末尾;
|
||||
* 再点一次清空即隐藏。纯前端显隐开关,不回写任何配置。
|
||||
*/
|
||||
const passwordVisible = ref(false)
|
||||
|
||||
const revealedColumns = computed(() => (passwordVisible.value ? ['b_password'] : []))
|
||||
|
||||
function togglePassword() {
|
||||
passwordVisible.value = !passwordVisible.value
|
||||
}
|
||||
|
||||
/* ================= 行多选与批量删除 ================= */
|
||||
const selectedRowKeys = ref([])
|
||||
|
||||
@@ -439,10 +454,19 @@ const rowSelection = computed(() => ({
|
||||
},
|
||||
}))
|
||||
|
||||
/* ================= 行操作:编辑 / 删除 ================= */
|
||||
/* ================= 行操作:编辑 / 分配权限 / 删除 ================= */
|
||||
/** 单行删除进行中:阻止行操作按钮重复触发 */
|
||||
const rowSaving = ref(false)
|
||||
|
||||
/** 「分配权限」当前操作的账号,供权限抽屉读取 */
|
||||
const powerUser = ref(null)
|
||||
const powerOpen = ref(false)
|
||||
|
||||
function onAssignPower(row) {
|
||||
powerUser.value = row
|
||||
powerOpen.value = true
|
||||
}
|
||||
|
||||
// 删除走 FmsTable 内置的 confirm 状态化弹窗(与 i18n-type / contact 一致),
|
||||
// 不再手工 Modal.confirm,少一层控制流。
|
||||
const rowActions = computed(() => {
|
||||
@@ -453,6 +477,12 @@ const rowActions = computed(() => {
|
||||
disabled: () => rowSaving.value,
|
||||
onClick: onEdit,
|
||||
},
|
||||
{
|
||||
key: 'power',
|
||||
label: '分配权限',
|
||||
disabled: () => rowSaving.value,
|
||||
onClick: onAssignPower,
|
||||
},
|
||||
]
|
||||
if (canDeleteRow.value) {
|
||||
actions.push({
|
||||
@@ -541,7 +571,8 @@ void loadDepts()
|
||||
:fixed-search-condition="deptFilterCondition"
|
||||
:row-selection="rowSelection"
|
||||
:row-actions="rowActions"
|
||||
:inline-action-limit="2"
|
||||
:revealed-columns="revealedColumns"
|
||||
:inline-action-limit="3"
|
||||
>
|
||||
<template #actions="{ loading }">
|
||||
<Button
|
||||
@@ -553,6 +584,14 @@ void loadDepts()
|
||||
<Plus :size="14" />
|
||||
新增
|
||||
</Button>
|
||||
<Button
|
||||
:title="passwordVisible ? '隐藏密码列' : '显示密码列'"
|
||||
:disabled="loading"
|
||||
@click="togglePassword"
|
||||
>
|
||||
<component :is="passwordVisible ? EyeOff : Eye" :size="14" />
|
||||
{{ passwordVisible ? '隐藏密码' : '查看密码' }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="canDeleteRow"
|
||||
type="danger"
|
||||
@@ -613,5 +652,8 @@ void loadDepts()
|
||||
:on-before-save="onBeforeSave"
|
||||
@saved="onSaved"
|
||||
/>
|
||||
|
||||
<!-- 分配权限:功能权限 / 字段权限 / 数据范围三张表 -->
|
||||
<UserPowerDrawer v-model:open="powerOpen" :user="powerUser" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -275,7 +275,20 @@ describe('用户管理:行操作', () => {
|
||||
const wrapper = await mountUser()
|
||||
|
||||
const keys = wrapper.vm.rowActions.map((action) => action.key)
|
||||
expect(keys).toEqual(['edit', 'delete'])
|
||||
expect(keys).toEqual(['edit', 'power', 'delete'])
|
||||
})
|
||||
|
||||
it('提供分配权限操作,点开时把该行交给权限抽屉', async () => {
|
||||
const wrapper = await mountUser()
|
||||
|
||||
const powerAction = wrapper.vm.rowActions.find((action) => action.key === 'power')
|
||||
expect(powerAction).toBeTruthy()
|
||||
|
||||
const row = { b_id: 'u1', b_name: '张三' }
|
||||
powerAction.onClick(row)
|
||||
|
||||
expect(wrapper.vm.powerUser).toStrictEqual(row)
|
||||
expect(wrapper.vm.powerOpen).toBe(true)
|
||||
})
|
||||
|
||||
it('新增时把编辑对象清空,避免带入上一次编辑的行', async () => {
|
||||
|
||||
Reference in new issue
Block a user