20260920173439

This commit is contained in:
oneao committed 2026-09-20 17:34:40 +08:00
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 () => {