20260907224521
This commit is contained in:
1 parent
1b596ffa5b
commit
2f5eac079e
4 files changed
+510
-294
No files matched your search
+42
-21
@@ -263,31 +263,31 @@ create index ix_s_menu_route
|
||||
|
||||
## 8. 权限
|
||||
|
||||
> 权限直接授予用户,不引入角色继承。`s_power` 是权限点定义表,`s_user_power` 是用户授权表;数据范围使用独立的 `s_user_data_scope` 表。权限点按资源类型扩展,新增报表、页面等资源时复用同一模型。
|
||||
> 权限直接授予用户,不引入角色继承。`s_power.b_id` 是内部主键,`s_power.b_code` 是稳定且全局唯一的业务键;字段授权使用独立的字段访问策略表。
|
||||
|
||||
```sql
|
||||
create table dbo.s_power (
|
||||
b_id varchar(50) not null primary key, -- 权限编码本身
|
||||
b_id bigint not null,
|
||||
b_code varchar(300) not null,
|
||||
b_name nvarchar(200) not null,
|
||||
b_i18n varchar(150) null,
|
||||
b_power_type varchar(20) not null,
|
||||
b_resource varchar(128) null,
|
||||
b_module_id varchar(50) null,
|
||||
b_field varchar(50) null,
|
||||
b_operation varchar(30) not null,
|
||||
b_power_type varchar(20) not null, -- menu/page/report/module/action
|
||||
b_resource_id varchar(128) not null,
|
||||
b_owner_type varchar(20) null, -- action owner type
|
||||
b_owner_id varchar(128) null, -- action owner id
|
||||
b_capability varchar(30) not null, -- access/execute/read/create/update/delete/export
|
||||
b_canuse tinyint not null default 1,
|
||||
b_xh int not null default 0
|
||||
b_xh int not null default 0,
|
||||
primary key (b_id),
|
||||
unique (b_code)
|
||||
);
|
||||
|
||||
create index ix_s_power_module
|
||||
on dbo.s_power (b_module_id, b_field, b_canuse);
|
||||
|
||||
create index ix_s_power_resource
|
||||
on dbo.s_power (b_power_type, b_resource, b_canuse);
|
||||
on dbo.s_power (b_power_type, b_owner_type, b_owner_id, b_resource_id, b_canuse);
|
||||
|
||||
create table dbo.s_user_power (
|
||||
b_user_id varchar(50) not null,
|
||||
b_power_id varchar(50) not null,
|
||||
b_power_id bigint not null,
|
||||
b_canuse tinyint not null default 1,
|
||||
b_updatedatetime datetime2 null,
|
||||
primary key (b_user_id, b_power_id)
|
||||
@@ -296,20 +296,41 @@ create table dbo.s_user_power (
|
||||
create index ix_s_user_power_user
|
||||
on dbo.s_user_power (b_user_id, b_canuse, b_power_id);
|
||||
|
||||
create table dbo.s_user_field_permission (
|
||||
b_user_id varchar(50) not null,
|
||||
b_module_id varchar(50) not null,
|
||||
b_field varchar(128) not null,
|
||||
b_access_mode varchar(10) not null, -- hidden / view / edit
|
||||
b_allow_query tinyint not null default 0,
|
||||
b_allow_export tinyint not null default 0,
|
||||
b_canuse tinyint not null default 1,
|
||||
b_updatedatetime datetime2 null,
|
||||
primary key (b_user_id, b_module_id, b_field)
|
||||
);
|
||||
|
||||
create index ix_s_user_field_permission_module
|
||||
on dbo.s_user_field_permission (b_user_id, b_module_id, b_canuse, b_field);
|
||||
|
||||
create table dbo.s_user_data_scope (
|
||||
b_user_id varchar(50) not null,
|
||||
b_module_id varchar(50) not null,
|
||||
b_operation varchar(30) not null, -- read / update / delete / export
|
||||
b_operation varchar(30) not null, -- * / read / create / update / delete / export;* 表示模块默认范围
|
||||
b_scope_level varchar(10) not null default 'default', -- default / override
|
||||
b_scope_no int not null default 1,
|
||||
b_scope_type varchar(30) not null, -- own / department / department_tree / all / custom
|
||||
b_scope_field varchar(50) null, -- 业务数据中的归属用户/部门字段
|
||||
b_scope_value varchar(100) null, -- custom 时每个用户/部门值一行
|
||||
b_updatedatetime datetime2 null,
|
||||
primary key (b_user_id, b_module_id, b_operation, b_scope_no)
|
||||
primary key (b_user_id, b_module_id, b_operation, b_scope_level, b_scope_no),
|
||||
constraint ck_s_user_data_scope_level check (b_scope_level in ('default','override')),
|
||||
constraint ck_s_user_data_scope_operation check (
|
||||
(b_scope_level = 'default' and b_operation = '*')
|
||||
or (b_scope_level = 'override' and b_operation in ('read','create','update','delete','export'))
|
||||
)
|
||||
);
|
||||
|
||||
create index ix_s_user_data_scope_module
|
||||
on dbo.s_user_data_scope (b_user_id, b_module_id, b_operation, b_scope_type);
|
||||
on dbo.s_user_data_scope (b_user_id, b_module_id, b_operation, b_scope_level, b_scope_type);
|
||||
```
|
||||
|
||||
权限点约定:
|
||||
@@ -317,17 +338,17 @@ create index ix_s_user_data_scope_module
|
||||
- `menu/access`:菜单或页面入口访问权限;
|
||||
- `module/read|create|update|delete|export`:模块级数据操作权限;
|
||||
- `action/execute`:菜单或页面中的按钮、动作权限;
|
||||
- `field/view|edit|query|export`:字段查看、修改、查询、导出能力;
|
||||
- `s_user_field_permission`:字段 `hidden/view/edit` 访问级别,以及独立的查询/导出能力;
|
||||
- `page/access`、`report/access`:独立页面或报表访问权限,报表列表中的每个报表可作为一个资源;
|
||||
- `b_power_type` 和 `b_operation` 是可扩展编码,新增资源类型不新增权限表,但需要补充权限定义和前端/后端校验规则。
|
||||
- 当前数据库迁移中的权限类型/形状约束若仍只允许 `menu/module/action/field` 或 `field/view|edit`,新增 `page`、`report`、`field/query` 等编码时必须同步扩展约束;不要只修改前端枚举。
|
||||
- `b_power_type` 和 `b_capability` 是可扩展编码,新增资源类型不新增权限表,但需要补充权限定义和前端/后端校验规则。
|
||||
- 动作业务键必须包含所属资源命名空间,例如 `action.module.cw_fee.audit.execute`,不得使用裸 `action.audit`。
|
||||
|
||||
权限计算约定:
|
||||
|
||||
- 默认拒绝:用户没有有效授权记录时不具备该权限;
|
||||
- 用户存在有效 `s_user_power` 记录时获得该权限;停用的权限点或授权记录不生效;
|
||||
- 字段 `edit` 只代表用户有修改能力,最终是否可编辑仍受 `s_field_edit.b_readonly`、`b_disabled` 和业务校验约束;
|
||||
- 字段不可见时,其编辑、查询、导出能力一并无效;字段可见但无 `edit` 权限时,在可编辑列表中显示为只读;
|
||||
- 字段没有有效策略记录时为 `hidden`;`view` 表示可见只读,`edit` 表示可见可编辑;`s_field_view` / `s_field_edit` 等元数据只能收紧权限;
|
||||
- `hidden` 字段不可查询、不可导出;`b_allow_query` / `b_allow_export` 不能突破字段访问级别;
|
||||
- 模块数据操作权限与数据范围同时生效:先判断操作权限,再按 `s_user_data_scope` 限制数据行;
|
||||
- 菜单/页面/报表访问权限只控制入口和访问,不能替代模块、字段或数据范围权限。
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Columns3,
|
||||
Download,
|
||||
Ellipsis,
|
||||
GripVertical,
|
||||
Maximize2,
|
||||
Plus,
|
||||
Printer,
|
||||
@@ -252,6 +253,7 @@ const advancedNodes = reactive([])
|
||||
const advancedGroups = reactive([])
|
||||
const advancedSnapshot = ref(null)
|
||||
const draggingFieldKey = ref('')
|
||||
const dragOverFieldKey = ref('')
|
||||
const allAdvancedNodes = computed(() => advancedNodes.concat(advancedGroups.flatMap((group) => group.nodes)))
|
||||
function hasNodeValue(value) {
|
||||
return Array.isArray(value) ? value.some((item) => item !== '' && item !== null && item !== undefined) : value !== '' && value !== null && value !== undefined
|
||||
@@ -280,6 +282,13 @@ const extraQuickConditionCount = computed(() => extraQuickSearchFields.value.fil
|
||||
if (field.type === 'numrange') return searchForm[`${field.key}Min`] || searchForm[`${field.key}Max`]
|
||||
return searchForm[field.key] !== '' && searchForm[field.key] !== null && searchForm[field.key] !== undefined
|
||||
}).length)
|
||||
// 常用区已填条件总数:高级模式的说明条要用 —— 告诉用户这些值还在,只是不参与
|
||||
const quickConditionCount = computed(() => quickSearchFields.value.filter((field) => {
|
||||
if (field.type === 'numrange') return searchForm[`${field.key}Min`] || searchForm[`${field.key}Max`]
|
||||
return searchForm[field.key] !== '' && searchForm[field.key] !== null && searchForm[field.key] !== undefined
|
||||
}).length)
|
||||
// 高级模式下常用区强制收起:保留上下文(知道有哪几个常用条件),但不占版面
|
||||
const showAllQuickFields = computed(() => quickSearchExpanded.value && activeQueryMode.value === 'quick')
|
||||
const advancedSearchFields = computed(() =>
|
||||
searchFields.value.filter((field) => field.queryable !== false && queryPrefs[field.key]?.area !== 'hidden'),
|
||||
)
|
||||
@@ -434,12 +443,27 @@ function resetQuickQuery() {
|
||||
Object.assign(searchForm, makeEmptyForm())
|
||||
if (activeQueryMode.value === 'quick') queryApplied.value = false
|
||||
}
|
||||
function startFieldDrag(field) {
|
||||
function startFieldDrag(field, event) {
|
||||
draggingFieldKey.value = field.key
|
||||
// Firefox 下不写 dataTransfer 不会触发 drop,同时声明「移动」语义拿到正确的光标
|
||||
if (event?.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
event.dataTransfer.setData('text/plain', field.key)
|
||||
}
|
||||
}
|
||||
function endFieldDrag() {
|
||||
// 必须清理:否则在空白处松手后残留的 key 会让下一次 drop 误排序
|
||||
draggingFieldKey.value = ''
|
||||
dragOverFieldKey.value = ''
|
||||
}
|
||||
function rowDragClass(field) {
|
||||
if (draggingFieldKey.value === field.key) return 'is-dragging'
|
||||
if (dragOverFieldKey.value === field.key && draggingFieldKey.value !== field.key) return 'is-drop-target'
|
||||
return ''
|
||||
}
|
||||
function dropField(field, area) {
|
||||
const sourceKey = draggingFieldKey.value
|
||||
draggingFieldKey.value = ''
|
||||
endFieldDrag()
|
||||
if (!sourceKey || sourceKey === field.key || queryPrefs[sourceKey]?.area !== area) return
|
||||
const rows = searchFields.value.filter((item) => item.queryable !== false && queryPrefs[item.key]?.area === area)
|
||||
.toSorted((a, b) => queryPrefs[a.key].order - queryPrefs[b.key].order)
|
||||
@@ -605,11 +629,22 @@ function onMoreAction({ key }) {
|
||||
|
||||
<template>
|
||||
<div class="dashboard-page">
|
||||
<!-- 页面常用查询:字段来自模块默认配置,并可由用户个性化调整 -->
|
||||
<!-- 搜索区:常用条件来自模块默认配置,并可由用户个性化调整 -->
|
||||
<section class="table-card search-card">
|
||||
<div class="list-search">
|
||||
<template v-if="activeQueryMode === 'quick'">
|
||||
<div v-for="field in (quickSearchExpanded ? quickSearchFields : quickSearchFields.slice(0, QUICK_VISIBLE_LIMIT))" :key="field.key" class="search-field">
|
||||
<!-- 查询设置:卡片级配置,绝对定位在右上角 —— 不占流内高度,
|
||||
也不随字段增减、模式切换位移 -->
|
||||
<Button type="ghost" class="settings-button search-card__settings" title="调整常用条件与高级条件的显示和顺序" @click="openQueryDrawer('settings')">
|
||||
<Settings2 :size="15" /> 查询设置
|
||||
</Button>
|
||||
<!-- 高级模式:常用条件「降权」而不是消失,也不加遮罩 ——
|
||||
遮罩在交互语义上是「阻塞/锁定」,而这里只是「不参与本次查询」。
|
||||
只在你真的填了常用条件时才占这一行:没填值时降权区是空的,不解释也不会困惑 -->
|
||||
<div v-if="activeQueryMode === 'advanced' && quickConditionCount" class="mode-notice">
|
||||
<span class="mode-notice__dot" />
|
||||
<span>顶部已填的 {{ quickConditionCount }} 个常用条件暂不参与本次高级查询,改用简单查询后重新生效。</span>
|
||||
</div>
|
||||
<div class="list-search" :class="{ 'list-search--muted': activeQueryMode === 'advanced' }">
|
||||
<div v-for="field in (showAllQuickFields ? quickSearchFields : quickSearchFields.slice(0, QUICK_VISIBLE_LIMIT))" :key="field.key" class="search-field">
|
||||
<label class="search-field__label">{{ field.label }}</label>
|
||||
<Input
|
||||
v-if="field.type === 'input'"
|
||||
@@ -635,36 +670,44 @@ function onMoreAction({ key }) {
|
||||
<Input v-model="searchForm[`${field.key}Max`]" class="search-field__control--mini" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- 操作区只放「执行」类动作;模式切换统一在卡片头的分段控件上,
|
||||
不再让按钮在不同模式下变形 -->
|
||||
<div class="list-search__actions">
|
||||
<template v-if="activeQueryMode === 'quick'">
|
||||
<Button type="primary" @click="onSearch">{{ t('demo.action.search') }}</Button>
|
||||
<Button type="outline" @click="resetQuickQuery">{{ t('demo.action.reset') }}</Button>
|
||||
<Button v-if="extraQuickSearchFields.length" type="ghost" class="quick-more-button" @click="quickSearchExpanded = !quickSearchExpanded">
|
||||
{{ quickSearchExpanded ? '收起常用条件' : `更多常用条件(${extraQuickSearchFields.length})` }}
|
||||
<span v-if="!quickSearchExpanded && extraQuickConditionCount" class="query-count">{{ extraQuickConditionCount }}</span>
|
||||
<ChevronDown :size="14" :class="{ 'is-up': quickSearchExpanded }" />
|
||||
</Button>
|
||||
<Button type="ghost" class="advanced-query-button" @click="selectQueryMode('advanced')">
|
||||
<SlidersHorizontal :size="14" />
|
||||
高级查询
|
||||
<span v-if="advancedConditionCount" class="query-count">{{ advancedConditionCount }}</span>
|
||||
</Button>
|
||||
<div class="action-group">
|
||||
<Button type="primary" @click="onSearch">{{ t('demo.action.search') }}</Button>
|
||||
<Button type="outline" @click="resetQuickQuery">{{ t('demo.action.reset') }}</Button>
|
||||
</div>
|
||||
<div class="action-group">
|
||||
<Button v-if="extraQuickSearchFields.length" type="ghost" class="quick-more-button" @click="quickSearchExpanded = !quickSearchExpanded">
|
||||
{{ quickSearchExpanded ? '收起条件' : '更多条件' }}
|
||||
<span v-if="!quickSearchExpanded && extraQuickConditionCount" class="query-count">{{ extraQuickConditionCount }}</span>
|
||||
<ChevronDown :size="14" :class="{ 'is-up': quickSearchExpanded }" />
|
||||
</Button>
|
||||
<Button type="ghost" class="advanced-query-button advanced-query-button--ghost" @click="selectQueryMode('advanced')">
|
||||
<SlidersHorizontal :size="14" />
|
||||
高级查询
|
||||
<span v-if="advancedConditionCount" class="query-count">{{ advancedConditionCount }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Button type="ghost" class="advanced-query-button" @click="openQueryDrawer('conditions')">
|
||||
<SlidersHorizontal :size="14" /> 编辑高级查询
|
||||
<span v-if="advancedConditionCount" class="query-count">{{ advancedConditionCount }}</span>
|
||||
</Button>
|
||||
<Button type="ghost" class="return-quick-button" @click="returnToQuickQuery">改用普通查询</Button>
|
||||
<Button type="outline" @click="resetAllQueries">清除查询</Button>
|
||||
<div class="action-group">
|
||||
<Button type="outline" class="advanced-query-button" @click="openQueryDrawer('conditions')">
|
||||
<SlidersHorizontal :size="14" /> 编辑高级查询
|
||||
<span v-if="advancedConditionCount" class="query-count">{{ advancedConditionCount }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="action-group">
|
||||
<Button type="ghost" @click="returnToQuickQuery">改用简单查询</Button>
|
||||
<Button type="ghost" @click="resetAllQueries">清除查询</Button>
|
||||
</div>
|
||||
</template>
|
||||
<Button type="ghost" class="settings-button" title="设置常用查询" @click="openQueryDrawer('settings')"><Settings2 :size="15" /> 设置</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="queryApplied && querySummary.length" class="active-query-bar">
|
||||
<span class="active-query-label">{{ activeQueryMode === 'quick' ? '普通查询生效' : '高级查询生效' }}</span>
|
||||
<span v-for="item in querySummary" :key="item.id" class="active-query-item"><span>{{ item.text }}</span><button type="button" aria-label="移除条件" @click="removeSummaryItem(item)">×</button></span>
|
||||
<span class="active-query-label">{{ activeQueryMode === 'quick' ? '简单查询生效' : '高级查询生效' }}</span>
|
||||
<span v-for="item in querySummary" :key="item.id" class="active-query-item" :title="item.text"><span>{{ item.text }}</span><button type="button" aria-label="移除条件" @click="removeSummaryItem(item)">×</button></span>
|
||||
<button class="clear-query" @click="resetAllQueries">清除全部</button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -742,19 +785,22 @@ function onMoreAction({ key }) {
|
||||
</div>
|
||||
|
||||
<!-- 查询 Drawer:高级条件与查询设置使用独立入口,共用 Drawer 外壳 -->
|
||||
<div v-if="drawerOpen" class="query-drawer-mask" @click.self="closeQueryDrawer">
|
||||
<aside class="query-drawer">
|
||||
<div v-if="drawerOpen" class="query-drawer-mask" :class="{ 'query-drawer-mask--light': drawerTab === 'settings' }" @click.self="closeQueryDrawer">
|
||||
<aside class="query-drawer" :class="{ 'query-drawer--narrow': drawerTab === 'settings' }">
|
||||
<div class="query-drawer__head">
|
||||
<div>
|
||||
<strong>{{ drawerTab === 'conditions' ? '高级查询' : '查询设置' }}</strong>
|
||||
<span class="query-drawer__sub">{{ drawerTab === 'conditions' ? '设置本次查询需要满足的条件' : '调整常用条件的显示和顺序' }}</span>
|
||||
<span class="query-drawer__sub">{{ drawerTab === 'conditions' ? '设置本次查询需要满足的条件,将替代常用条件' : '调整常用条件的显示和顺序,改动即时生效' }}</span>
|
||||
</div>
|
||||
<button class="query-drawer__close" aria-label="关闭" @click="closeQueryDrawer">×</button>
|
||||
</div>
|
||||
<div v-if="drawerTab === 'conditions'" class="query-drawer__body">
|
||||
<div class="drawer-section-title">当前条件</div>
|
||||
<div class="ast-root"><span>满足</span><select v-model="advancedLogic"><option value="and">全部条件</option><option value="or">任一条件</option></select></div>
|
||||
<div v-if="!advancedNodes.length && !advancedGroups.length" class="ast-empty">暂无条件,请从下方添加</div>
|
||||
<div v-if="!advancedNodes.length && !advancedGroups.length" class="ast-empty">
|
||||
<p>暂无条件</p>
|
||||
<Button type="primary" @click="addAdvancedNode">+ 添加条件</Button>
|
||||
</div>
|
||||
<div v-for="node in advancedNodes" :key="node.id" class="ast-row">
|
||||
<div class="ast-field-wrap">
|
||||
<select v-model="node.field" class="ast-field" @change="onNodeFieldChange(node)">
|
||||
@@ -791,20 +837,19 @@ function onMoreAction({ key }) {
|
||||
<Button type="ghost" @click="group.nodes.push(createAdvancedNode(advancedSearchFields[0]?.key || 'creator'))">+ 条件</Button>
|
||||
</div>
|
||||
<div class="ast-hint"><LockKeyhole :size="14" /> 无权限字段不会出现在字段选择器中</div>
|
||||
<div class="drawer-actions"><Button type="outline" @click="addAdvancedNode">+ 添加条件</Button><Button type="outline" @click="addAdvancedGroup">+ 添加条件组</Button></div>
|
||||
<div class="drawer-actions"><Button type="outline" @click="addAdvancedNode">+ 添加条件</Button><Button type="outline" @click="addAdvancedGroup">+ 添加条件组</Button><Button type="ghost" class="clear-conditions" @click="resetAdvancedQuery">清空条件</Button></div>
|
||||
</div>
|
||||
<div v-else class="query-drawer__body settings-body">
|
||||
<div class="drawer-section-title">我的查询布局 <span>只影响当前用户</span></div>
|
||||
<div class="settings-note">拖动调整常用条件顺序,也可以把字段移到高级查询或隐藏。</div>
|
||||
<div class="setting-group"><div class="setting-group__head"><strong>页面常用查询</strong><span>直接显示在列表顶部</span></div><div v-for="field in searchFields.filter((item) => queryPrefs[item.key].area === 'quick').toSorted((a, b) => queryPrefs[a.key].order - queryPrefs[b.key].order)" :key="field.key" class="setting-row" draggable="true" @dragstart="startFieldDrag(field)" @dragover.prevent @drop="dropField(field, 'quick')"><span class="drag-handle">⋮⋮</span><span class="setting-label">{{ field.label }}</span><button class="setting-first" @click="setFirstQuick(field)">设为第一个</button><button class="setting-more" @click="setFieldArea(field,'advanced')">移到高级</button></div></div>
|
||||
<div class="setting-group"><div class="setting-group__head"><strong>仅高级查询</strong><span>不占页面空间</span></div><div v-for="field in searchFields.filter((item) => queryPrefs[item.key].area === 'advanced').toSorted((a, b) => queryPrefs[a.key].order - queryPrefs[b.key].order)" :key="field.key" class="setting-row" draggable="true" @dragstart="startFieldDrag(field)" @dragover.prevent @drop="dropField(field, 'advanced')"><span class="drag-handle">⋮⋮</span><span class="setting-label">{{ field.label }}</span><button class="setting-first" @click="setFieldArea(field,'quick')">移到常用</button><button class="setting-more" @click="setFieldArea(field,'hidden')">隐藏</button></div></div>
|
||||
<div class="setting-group"><div class="setting-group__head"><strong>已隐藏字段</strong><span>可恢复到高级查询</span></div><div v-for="field in searchFields.filter((item) => item.queryable !== false && queryPrefs[item.key].area === 'hidden')" :key="field.key" class="setting-row"><span class="drag-handle">⋮⋮</span><span class="setting-label">{{ field.label }}</span><button class="setting-first" @click="setFieldArea(field,'advanced')">恢复到高级</button></div><div v-if="!searchFields.some((item) => item.queryable !== false && queryPrefs[item.key].area === 'hidden')" class="setting-empty">暂无隐藏字段</div></div>
|
||||
<div class="settings-note">拖动调整常用条件顺序,也可以把字段移到高级条件或隐藏。</div>
|
||||
<div class="setting-group"><div class="setting-group__head"><strong>页面常用条件</strong><span>直接显示在列表顶部</span></div><div v-for="field in searchFields.filter((item) => queryPrefs[item.key].area === 'quick').toSorted((a, b) => queryPrefs[a.key].order - queryPrefs[b.key].order)" :key="field.key" class="setting-row" :class="rowDragClass(field)" draggable="true" @dragstart="startFieldDrag(field, $event)" @dragover.prevent="dragOverFieldKey = field.key" @dragleave="dragOverFieldKey = ''" @drop="dropField(field, 'quick')" @dragend="endFieldDrag"><GripVertical :size="14" class="drag-handle" /><span class="setting-label">{{ field.label }}</span><button class="setting-first" @click="setFirstQuick(field)">设为第一个</button><button class="setting-more" @click="setFieldArea(field,'advanced')">移到高级</button></div></div>
|
||||
<div class="setting-group"><div class="setting-group__head"><strong>仅高级条件</strong><span>不占页面空间</span></div><div v-for="field in searchFields.filter((item) => queryPrefs[item.key].area === 'advanced').toSorted((a, b) => queryPrefs[a.key].order - queryPrefs[b.key].order)" :key="field.key" class="setting-row" :class="rowDragClass(field)" draggable="true" @dragstart="startFieldDrag(field, $event)" @dragover.prevent="dragOverFieldKey = field.key" @dragleave="dragOverFieldKey = ''" @drop="dropField(field, 'advanced')" @dragend="endFieldDrag"><GripVertical :size="14" class="drag-handle" /><span class="setting-label">{{ field.label }}</span><button class="setting-first" @click="setFieldArea(field,'quick')">移到常用</button><button class="setting-more" @click="setFieldArea(field,'hidden')">隐藏</button></div></div>
|
||||
<div class="setting-group"><div class="setting-group__head"><strong>已隐藏字段</strong><span>可恢复到高级条件</span></div><div v-for="field in searchFields.filter((item) => item.queryable !== false && queryPrefs[item.key].area === 'hidden')" :key="field.key" class="setting-row"><GripVertical :size="14" class="drag-handle" /><span class="setting-label">{{ field.label }}</span><button class="setting-first" @click="setFieldArea(field,'advanced')">恢复到高级</button></div><div v-if="!searchFields.some((item) => item.queryable !== false && queryPrefs[item.key].area === 'hidden')" class="setting-empty">暂无隐藏字段</div></div>
|
||||
<div class="setting-group setting-group--muted"><div class="setting-group__head"><strong>不可用字段</strong><span>字段权限限制</span></div><div v-for="field in searchFields.filter((item) => item.queryable === false)" :key="field.key" class="setting-row is-disabled"><LockKeyhole :size="14" /><span class="setting-label">{{ field.label }}</span><span class="setting-disabled">无查询权限</span></div></div>
|
||||
</div>
|
||||
<div class="query-drawer__foot">
|
||||
<span v-if="drawerTab === 'conditions'" class="ast-foot-note">已配置 {{ advancedConditionCount }} 个条件</span>
|
||||
<template v-if="drawerTab === 'conditions'">
|
||||
<Button type="ghost" @click="resetAdvancedQuery">清空条件</Button>
|
||||
<Button type="outline" @click="closeQueryDrawer">取消</Button>
|
||||
<Button type="primary" @click="applyAdvancedQuery">应用查询</Button>
|
||||
</template>
|
||||
@@ -853,17 +898,32 @@ function onMoreAction({ key }) {
|
||||
不参与 flex:1 分配、不需要内部滚动。
|
||||
container-type:让 .list-search 的容器查询跟「卡片内容宽」而非视口宽 */
|
||||
.search-card {
|
||||
position: relative;
|
||||
flex: none;
|
||||
min-height: 0;
|
||||
overflow: visible;
|
||||
padding: 12px 16px 8px;
|
||||
container-type: inline-size;
|
||||
}
|
||||
.advanced-query-button { gap: 5px; color: var(--fms-text-secondary); }
|
||||
.advanced-query-button:hover { color: var(--fms-primary); }
|
||||
/* 右上角固定位:绝对定位不占流内高度(查询区本身就不宽裕),
|
||||
右缘与内容区(padding 16)对齐 */
|
||||
.search-card__settings { position: absolute; top: 10px; right: 16px; z-index: 1; }
|
||||
/* 高级模式:常用条件降权(低透明度 + 去饱和 + 禁用交互)而不是消失、也不是遮罩。
|
||||
遮罩的语义是「阻塞/锁定」,这里只是「不参与本次查询」。
|
||||
只降权 .search-field,操作区按钮保持可用 */
|
||||
.list-search--muted .search-field {
|
||||
opacity: 0.45;
|
||||
filter: saturate(0.5);
|
||||
pointer-events: none;
|
||||
}
|
||||
/* 说明条压到最薄:它只在有已填常用条件时才出现,不该再抢版面 */
|
||||
.search-card .mode-notice { margin: 0; padding: 6px 10px; }
|
||||
.advanced-query-button { gap: 5px; }
|
||||
/* ghost(常用模式下的入口)用次要色,不与「查询/重置」抢视觉;
|
||||
outline(高级模式下的主操作)保持主色 */
|
||||
.advanced-query-button--ghost { color: var(--fms-text-secondary); }
|
||||
.advanced-query-button--ghost:hover { color: var(--fms-primary); }
|
||||
.advanced-query-button .query-count { margin-left: 2px; }
|
||||
.return-quick-button { color: var(--fms-text-secondary); }
|
||||
.return-quick-button:hover { color: var(--fms-primary); }
|
||||
|
||||
/* ---- ① 搜索区:固定格宽 + 顶部标签 ---- */
|
||||
.list-search {
|
||||
@@ -936,17 +996,25 @@ function onMoreAction({ key }) {
|
||||
/* 查询/重置/展开:占据流末尾的格子,靠该行右缘对齐(不拉伸占满格子)。
|
||||
展开切换是纯文字 + 旋转箭头,视觉重量低于两个实体按钮,
|
||||
暗示它是「调整形状」而不是「执行动作」 */
|
||||
/* 操作区分「执行 / 扩展 / 配置」三组:组间距 12 > 组内间距 8,
|
||||
配置组 margin-left:auto 钉在行尾 —— 模式切换、字段增减时它的位置都不动 */
|
||||
.list-search__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
grid-column: 1 / -1;
|
||||
gap: 8px;
|
||||
gap: 12px;
|
||||
justify-self: start;
|
||||
align-self: end;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
padding-top: 2px;
|
||||
}
|
||||
.action-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.quick-more-button { gap: 5px; }
|
||||
.quick-more-button svg { transition: transform 160ms ease; }
|
||||
.quick-more-button svg.is-up { transform: rotate(180deg); }
|
||||
@@ -1101,6 +1169,9 @@ function onMoreAction({ key }) {
|
||||
.active-query-item button:hover { color: var(--fms-danger); }
|
||||
.clear-query { flex: none; border: 0; background: transparent; color: var(--fms-primary); font-size: 12px; }
|
||||
.query-drawer-mask { position: fixed; z-index: 50; inset: 0; background: rgb(31 35 41 / 32%); }
|
||||
/* 设置面板:遮罩压到最浅,让左侧搜索区保持可见 ——
|
||||
调顺序、移分组时能直接看到常用区的实时变化,不用关掉才知道结果 */
|
||||
.query-drawer-mask--light { background: rgb(31 35 41 / 8%); }
|
||||
.query-drawer {
|
||||
display: flex;
|
||||
width: min(760px, 100%);
|
||||
@@ -1110,6 +1181,8 @@ function onMoreAction({ key }) {
|
||||
background: var(--fms-card, #fff);
|
||||
box-shadow: -12px 0 36px rgb(31 35 41 / 15%);
|
||||
}
|
||||
/* 设置面板收窄,给左侧搜索区留出可视空间(条件面板仍需 760px 排布三段式条件行) */
|
||||
.query-drawer--narrow { width: min(420px, 92%); }
|
||||
.query-drawer__head { display: flex; flex: none; align-items: flex-start; justify-content: space-between; padding: 18px 20px 14px; border-bottom: 1px solid var(--fms-border-soft, #f0f1f3); }
|
||||
.query-drawer__head strong { display: block; color: var(--fms-text); font-size: 16px; }
|
||||
.query-drawer__sub { display: block; margin-top: 4px; color: var(--fms-text-secondary); font-size: 12px; }
|
||||
@@ -1122,9 +1195,11 @@ function onMoreAction({ key }) {
|
||||
.drawer-section-title span { color: var(--fms-text-secondary); font-size: 12px; font-weight: 400; }
|
||||
.ast-root { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; color: var(--fms-text-secondary); font-size: 12px; }
|
||||
.ast-root select, .ast-row select { height: 34px; border: 1px solid var(--fms-border); border-radius: 4px; background: var(--fms-card); color: var(--fms-text); padding: 0 9px; outline: none; }
|
||||
.ast-empty { margin: 8px 0 12px; padding: 18px 12px; border: 1px dashed var(--fms-border); border-radius: 5px; color: var(--fms-text-secondary); font-size: 12px; text-align: center; }
|
||||
.ast-empty { display: flex; flex-direction: column; align-items: center; gap: 10px; margin: 8px 0 12px; padding: 22px 12px; border: 1px dashed var(--fms-border); border-radius: 5px; color: var(--fms-text-secondary); font-size: 12px; text-align: center; }
|
||||
.ast-empty p { margin: 0; }
|
||||
.ast-row { display: grid; grid-template-columns: minmax(160px, 1.25fr) 150px minmax(130px, 1fr) 30px; gap: 8px; align-items: center; margin-bottom: 8px; }
|
||||
.ast-group { margin: 12px 0; padding: 10px; border: 1px solid #dfe6ef; border-radius: 6px; background: #fbfcfe; }
|
||||
/* 条件组:左侧粗竖线表达嵌套层级,让「这一组是一个整体」一眼可辨 */
|
||||
.ast-group { margin: 12px 0; padding: 10px; border: 1px solid #dfe6ef; border-left: 3px solid #b9d3f5; border-radius: 6px; background: #fbfcfe; }
|
||||
.ast-group__head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; color: var(--fms-text-secondary); font-size: 12px; }
|
||||
.ast-group__head select { height: 30px; }
|
||||
.ast-group__head button { margin-left: auto; border: 0; background: transparent; color: var(--fms-danger); font-size: 12px; cursor: pointer; }
|
||||
@@ -1140,6 +1215,9 @@ function onMoreAction({ key }) {
|
||||
.ast-remove:hover { background: #fff1f0; color: var(--fms-danger); }
|
||||
.ast-hint { display: flex; align-items: center; gap: 6px; margin: 12px 0; color: var(--fms-text-secondary); font-size: 12px; }
|
||||
.drawer-actions { display: flex; gap: 8px; padding-top: 12px; border-top: 1px dashed var(--fms-border); }
|
||||
/* 清空条件:从 footer 挪到条件列表底部并靠右,避免和「取消/应用」两个放弃类动作挤在一起误点 */
|
||||
.clear-conditions { margin-left: auto; color: var(--fms-text-secondary) !important; }
|
||||
.clear-conditions:hover { color: var(--fms-danger) !important; }
|
||||
.query-drawer__foot { display: flex; flex: none; align-items: center; justify-content: flex-end; gap: 8px; padding: 12px 20px; border-top: 1px solid var(--fms-border-soft, #f0f1f3); }
|
||||
.ast-foot-note { flex: 1; color: var(--fms-text-secondary); font-size: 12px; }
|
||||
.settings-note { margin-bottom: 14px; padding: 9px 10px; border: 1px solid #e8edf3; border-radius: 5px; background: #f7f9fc; color: var(--fms-text-secondary); font-size: 12px; }
|
||||
@@ -1149,6 +1227,10 @@ function onMoreAction({ key }) {
|
||||
.setting-group__head strong { color: var(--fms-text); font-size: 13px; }.setting-group__head span { color: var(--fms-text-secondary); font-size: 11px; }
|
||||
.setting-row { display: flex; align-items: center; gap: 8px; min-height: 42px; padding: 0 12px; border-bottom: 1px solid var(--fms-border-soft); }.setting-row:last-child { border-bottom: 0; }
|
||||
.setting-empty { padding: 12px; color: var(--fms-text-secondary); font-size: 12px; }
|
||||
.drag-handle { color: #a3acb8; letter-spacing: -2px; }.setting-label { flex: 1; min-width: 0; color: var(--fms-text); }.setting-row button { border: 0; background: transparent; color: var(--fms-primary); font-size: 12px; }.setting-row button:hover { text-decoration: underline; }.setting-more { color: var(--fms-text-secondary) !important; }.setting-row.is-disabled { color: var(--fms-disabled-text); }.setting-row.is-disabled .setting-label { color: var(--fms-disabled-text); }.setting-disabled { color: var(--fms-disabled-text); font-size: 12px; }
|
||||
.drag-handle { flex: none; color: #a3acb8; cursor: grab; }
|
||||
/* 拖拽排序的视觉反馈:源行半透明 + 目标行顶部插入指示线,
|
||||
否则用户拖的时候不知道会插到哪 */
|
||||
.setting-row.is-dragging { opacity: 0.45; background: #f2f7ff; }
|
||||
.setting-row.is-drop-target { box-shadow: inset 0 2px 0 0 var(--fms-primary, #1677ff); }.setting-label { flex: 1; min-width: 0; color: var(--fms-text); }.setting-row button { border: 0; background: transparent; color: var(--fms-primary); font-size: 12px; }.setting-row button:hover { text-decoration: underline; }.setting-more { color: var(--fms-text-secondary) !important; }.setting-row.is-disabled { color: var(--fms-disabled-text); }.setting-row.is-disabled .setting-label { color: var(--fms-disabled-text); }.setting-disabled { color: var(--fms-disabled-text); font-size: 12px; }
|
||||
@media (max-width: 760px) { .query-drawer__body { padding-inline: 12px; }.query-drawer__head, .query-drawer__foot { padding-inline: 12px; }.ast-row { grid-template-columns: 1fr 1fr; }.ast-row .ast-value { grid-column: 1 / span 2; }.ast-remove { justify-self: end; margin-top: -38px; } .active-query-bar { flex-wrap: wrap; } }
|
||||
</style>
|
||||
@@ -554,7 +554,7 @@ s_power
|
||||
cw_business.select_history_fee
|
||||
```
|
||||
|
||||
对应的 `s_power` 动作权限使用目标模块和动作编码建立关联,操作为 `execute`。动作配置不能绕过 `s_power`,字段权限也不能被动作配置提升。后端必须同时校验动作执行权限、来源字段 `view` 权限和目标字段 `edit` 权限。
|
||||
对应的 `s_power` 动作权限使用目标模块作为 owner,生成类似 `action.module.cw_business.select_history_fee.execute` 的全局唯一业务键,能力为 `execute`。动作配置不能绕过 `s_power`,字段策略也不能被动作配置提升。后端必须同时校验动作执行权限、来源字段最终为 `view` 或 `edit`、以及目标字段最终为 `edit`。
|
||||
|
||||
来源字段被停用、删除或当前用户无权查看时,动作应在配置校验或运行时被判定为无效,不能静默删掉映射项继续执行。
|
||||
|
||||
@@ -708,7 +708,7 @@ cw_business.select_history_fee
|
||||
- 来源模块是数据、字段和基础查询配置的唯一来源,不为每个选择器复制模块;
|
||||
- 动作只引用并裁剪来源模块已有的 `s_field_view` / `s_field_query`;
|
||||
- 动作展示列和查询项通过 `inherit / explicit` 明确区分继承和显式空配置;
|
||||
- 字段权限由 `s_power` 的 `field/view/edit` 规则统一控制,动作配置不能提升权限;
|
||||
- 字段权限由 `s_user_field_permission` 的 `hidden/view/edit` 策略统一控制,动作配置不能提升权限;
|
||||
- 动作执行权限使用独立的 `s_power` `action/execute` 权限;
|
||||
- 第一版优先实现 `fill/copy` 和 `append`,`replace` 延后;
|
||||
- 固定过滤、去重和关系字段可以隐藏,但不能作为普通可见数据返回前端。
|
||||
|
||||
+338
-225
@@ -40,7 +40,7 @@
|
||||
│ 数据模块 │
|
||||
└──────┬───────┘
|
||||
↓
|
||||
s_data_scope
|
||||
s_user_data_scope
|
||||
↓
|
||||
s_user_data_scope
|
||||
↓
|
||||
@@ -51,8 +51,7 @@
|
||||
|
||||
- `s_power`:定义「能做什么」
|
||||
- `s_user_power`:定义「用户能做什么」
|
||||
- `s_data_scope`:定义「能看到 / 操作哪些数据」
|
||||
- `s_user_data_scope`:定义「用户在某个模块、某个操作下使用哪个数据范围」
|
||||
- `s_user_data_scope`:定义「用户在某个模块、某个操作下可以作用于哪些数据」
|
||||
|
||||
---
|
||||
|
||||
@@ -76,7 +75,6 @@ power_cw_fee_amount_edit
|
||||
menu.<MenuID>
|
||||
action.<ActionID>
|
||||
module.<ModuleID>.<Operation>
|
||||
field.<ModuleID>.<Field>.<Operation>
|
||||
page.<PageID>
|
||||
report.<ReportID>
|
||||
```
|
||||
@@ -109,15 +107,6 @@ module.cw_fee.delete
|
||||
module.cw_fee.export
|
||||
```
|
||||
|
||||
**字段权限**
|
||||
|
||||
```text
|
||||
field.cw_fee.mx_amount.view
|
||||
field.cw_fee.mx_amount.edit
|
||||
field.cw_fee.mx_amount.query
|
||||
field.cw_fee.mx_amount.export
|
||||
```
|
||||
|
||||
**页面权限**
|
||||
|
||||
```text
|
||||
@@ -139,7 +128,6 @@ report.cw_fee_summary
|
||||
| menu | 菜单入口 | `menu.<MenuID>` |
|
||||
| action | 业务动作 | `action.<ActionID>` |
|
||||
| module | 数据模块操作 | `module.<ModuleID>.<Operation>` |
|
||||
| field | 字段能力 | `field.<ModuleID>.<Field>.<Operation>` |
|
||||
| page | 页面入口 | `page.<PageID>` |
|
||||
| report | 报表入口 | `report.<ReportID>` |
|
||||
|
||||
@@ -197,28 +185,7 @@ module.cw_fee.export
|
||||
|
||||
模块权限解决:用户对该业务模块具有什么数据操作能力。
|
||||
|
||||
### 5.4 field
|
||||
|
||||
例如:
|
||||
|
||||
```text
|
||||
field.cw_fee.mx_amount.view
|
||||
field.cw_fee.mx_amount.edit
|
||||
field.cw_fee.mx_amount.query
|
||||
field.cw_fee.mx_amount.export
|
||||
```
|
||||
|
||||
字段权限解决:用户对某个字段具有什么能力。
|
||||
|
||||
需要注意:`field.xxx.edit` 只是权限允许编辑,并不代表字段最终一定可编辑。最终还要同时考虑:
|
||||
|
||||
- 字段权限
|
||||
- `s_field_edit.b_readonly`
|
||||
- `s_field_edit.b_disabled`
|
||||
- 业务状态
|
||||
- 业务规则
|
||||
|
||||
### 5.5 page
|
||||
### 5.4 page
|
||||
|
||||
例如:
|
||||
|
||||
@@ -237,7 +204,7 @@ page.cw_fee
|
||||
|
||||
菜单解决入口展示 / 进入菜单,页面解决具体页面访问。
|
||||
|
||||
### 5.6 report
|
||||
### 5.5 report
|
||||
|
||||
例如:
|
||||
|
||||
@@ -267,7 +234,7 @@ create table dbo.s_power (
|
||||
b_i18n varchar(150) null,
|
||||
|
||||
b_power_type varchar(20) not null,
|
||||
-- menu / action / module / field / page / report
|
||||
-- menu / action / module / page / report
|
||||
|
||||
b_resource varchar(128) null,
|
||||
-- action / page / report 等资源 ID
|
||||
@@ -278,14 +245,10 @@ create table dbo.s_power (
|
||||
b_module_id varchar(50) null,
|
||||
-- 所属业务模块
|
||||
|
||||
b_field varchar(50) null,
|
||||
-- field 权限对应字段
|
||||
|
||||
b_operation varchar(30) not null,
|
||||
-- menu/page/report: access
|
||||
-- action: execute
|
||||
-- module: read/create/update/delete/export
|
||||
-- field: view/edit/query/export
|
||||
|
||||
b_canuse tinyint not null default 1,
|
||||
|
||||
@@ -300,8 +263,6 @@ menu.cw_fee
|
||||
action.cw_fee_audit
|
||||
module.cw_fee.read
|
||||
module.cw_fee.update
|
||||
field.cw_fee.mx_amount.view
|
||||
field.cw_fee.mx_amount.edit
|
||||
page.cw_fee
|
||||
report.cw_fee_summary
|
||||
```
|
||||
@@ -354,44 +315,11 @@ module.cw_fee.read
|
||||
|
||||
它并不表示:用户可以读取所有费用数据。
|
||||
|
||||
数据范围由 `s_data_scope` / `s_user_data_scope` 控制。
|
||||
数据范围直接由 `s_user_data_scope` 控制。
|
||||
|
||||
---
|
||||
|
||||
## 9. s_data_scope
|
||||
|
||||
数据范围定义表。
|
||||
|
||||
```sql
|
||||
create table dbo.s_data_scope (
|
||||
b_id varchar(50) not null,
|
||||
|
||||
b_name nvarchar(200) not null,
|
||||
|
||||
b_i18n varchar(150) null,
|
||||
|
||||
b_module_id varchar(50) not null,
|
||||
-- 数据权限作用的数据模块
|
||||
|
||||
b_scope_type varchar(30) not null,
|
||||
-- own / department / department_tree / all / custom
|
||||
|
||||
b_scope_field varchar(50) null,
|
||||
-- 业务数据中的归属字段
|
||||
-- 例如 b_inputuser_id / b_department_id / b_owner_id
|
||||
|
||||
b_scope_value varchar(100) null,
|
||||
-- custom 等场景使用
|
||||
|
||||
b_canuse tinyint not null default 1,
|
||||
|
||||
b_xh int not null default 0
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. s_user_data_scope
|
||||
## 9. s_user_data_scope
|
||||
|
||||
用户数据权限授权表。
|
||||
|
||||
@@ -402,17 +330,23 @@ create table dbo.s_user_data_scope (
|
||||
b_module_id varchar(50) not null,
|
||||
|
||||
b_operation varchar(30) not null,
|
||||
-- read / create / update / delete / export
|
||||
-- * / read / create / update / delete / export;* 表示模块默认范围
|
||||
|
||||
b_scope_id varchar(50) not null,
|
||||
b_scope_level varchar(10) not null default 'default',
|
||||
-- default / override;default 使用 b_operation='*'
|
||||
|
||||
b_xh int not null default 0,
|
||||
b_scope_no int not null default 1,
|
||||
b_scope_type varchar(30) not null,
|
||||
-- own / department / department_tree / all / custom
|
||||
|
||||
b_scope_field varchar(50) null,
|
||||
b_scope_value varchar(100) null,
|
||||
|
||||
b_updatedatetime datetime2 null
|
||||
);
|
||||
```
|
||||
|
||||
多个 Scope 同时存在时:
|
||||
多个同级范围同时存在时:
|
||||
|
||||
```text
|
||||
Scope A OR Scope B OR Scope C
|
||||
@@ -423,12 +357,12 @@ Scope A OR Scope B OR Scope C
|
||||
```text
|
||||
张三
|
||||
cw_fee
|
||||
read
|
||||
* / read / update
|
||||
├── department
|
||||
└── own
|
||||
```
|
||||
|
||||
表示张三读取费用数据时:department 范围 OR own 范围。
|
||||
表示同一操作下多个范围按 OR 合并;具体操作没有 override 时回退到 `b_operation='*'` 的默认范围。
|
||||
|
||||
---
|
||||
|
||||
@@ -467,15 +401,6 @@ module.cw_fee.delete
|
||||
module.cw_fee.export
|
||||
```
|
||||
|
||||
字段:
|
||||
|
||||
```text
|
||||
field.cw_fee.mx_amount.view
|
||||
field.cw_fee.mx_amount.edit
|
||||
field.cw_fee.mx_amount.query
|
||||
field.cw_fee.mx_amount.export
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. 用户授权示例
|
||||
@@ -486,7 +411,6 @@ field.cw_fee.mx_amount.export
|
||||
- `module.cw_fee.read`
|
||||
- `module.cw_fee.update`
|
||||
- `action.cw_fee_audit`
|
||||
- `field.cw_fee.mx_amount.view`
|
||||
|
||||
```sql
|
||||
insert into dbo.s_user_power (
|
||||
@@ -498,11 +422,10 @@ values
|
||||
('zhangsan', 'menu.cw_fee', 1),
|
||||
('zhangsan', 'module.cw_fee.read', 1),
|
||||
('zhangsan', 'module.cw_fee.update', 1),
|
||||
('zhangsan', 'action.cw_fee_audit', 1),
|
||||
('zhangsan', 'field.cw_fee.mx_amount.view', 1);
|
||||
('zhangsan', 'action.cw_fee_audit', 1);
|
||||
```
|
||||
|
||||
张三没有 `module.cw_fee.delete`、`field.cw_fee.mx_amount.edit`,所以:
|
||||
张三没有 `module.cw_fee.delete`,并且金额字段策略为 `view`,所以:
|
||||
|
||||
- 不能删除费用
|
||||
- 不能修改金额字段
|
||||
@@ -511,50 +434,30 @@ values
|
||||
|
||||
## 13. 数据范围示例
|
||||
|
||||
定义:
|
||||
|
||||
```sql
|
||||
insert into dbo.s_data_scope (
|
||||
b_id,
|
||||
b_name,
|
||||
b_module_id,
|
||||
b_scope_type,
|
||||
b_scope_field,
|
||||
b_canuse,
|
||||
b_xh
|
||||
)
|
||||
values
|
||||
(
|
||||
'scope.cw_fee.department',
|
||||
N'本部门',
|
||||
'cw_fee',
|
||||
'department',
|
||||
'b_department_id',
|
||||
1,
|
||||
0
|
||||
);
|
||||
```
|
||||
|
||||
然后授权:
|
||||
直接授权:
|
||||
|
||||
```sql
|
||||
insert into dbo.s_user_data_scope (
|
||||
b_user_id,
|
||||
b_module_id,
|
||||
b_operation,
|
||||
b_scope_id,
|
||||
b_xh
|
||||
b_scope_level,
|
||||
b_scope_no,
|
||||
b_scope_type,
|
||||
b_scope_field
|
||||
)
|
||||
values (
|
||||
'zhangsan',
|
||||
'cw_fee',
|
||||
'read',
|
||||
'scope.cw_fee.department',
|
||||
0
|
||||
'*',
|
||||
'default',
|
||||
1,
|
||||
'department',
|
||||
'b_department_id'
|
||||
);
|
||||
```
|
||||
|
||||
此时张三拥有 `module.cw_fee.read`,并且 read 数据范围 = 本部门。
|
||||
此时张三拥有 `module.cw_fee.read`,并且 read 数据范围回退为本部门。若再增加 `update + override + own`,则修改范围只限本人创建的数据。
|
||||
|
||||
最终不是「张三可以读取全部费用」,而是「张三可以读取自己有权限范围内的费用」。
|
||||
|
||||
@@ -612,12 +515,12 @@ module.cw_fee.update
|
||||
| 能不能进入页面 | `page.*` |
|
||||
| 能不能执行审核 | `action.*` |
|
||||
| 能不能修改费用 | `module.cw_fee.update` |
|
||||
| 能不能看到金额 | `field.cw_fee.mx_amount.view` |
|
||||
| 能不能修改金额 | `field.cw_fee.mx_amount.edit` |
|
||||
| 能不能查询金额 | `field.cw_fee.mx_amount.query` |
|
||||
| 能不能导出金额 | `field.cw_fee.mx_amount.export` |
|
||||
| 能不能看到金额 | `s_user_field_permission.b_access_mode in ('view','edit')` |
|
||||
| 能不能修改金额 | `s_user_field_permission.b_access_mode = 'edit'` |
|
||||
| 能不能查询金额 | `s_user_field_permission.b_allow_query = 1` |
|
||||
| 能不能导出金额 | `s_user_field_permission.b_allow_export = 1` |
|
||||
| 能不能看报表 | `report.*` |
|
||||
| 能看哪些数据 | `s_data_scope` |
|
||||
| 能看哪些数据 | `s_user_data_scope` |
|
||||
|
||||
---
|
||||
|
||||
@@ -638,10 +541,6 @@ s_module
|
||||
↓
|
||||
module.<ModuleID>.<Operation>
|
||||
|
||||
s_field
|
||||
↓
|
||||
field.<ModuleID>.<Field>.<Operation>
|
||||
|
||||
Page 定义
|
||||
↓
|
||||
page.<PageID>
|
||||
@@ -661,16 +560,7 @@ module.cw_air_fee.delete
|
||||
module.cw_air_fee.export
|
||||
```
|
||||
|
||||
新增字段 `mx_tax`,可以自动注册:
|
||||
|
||||
```text
|
||||
field.cw_air_fee.mx_tax.view
|
||||
field.cw_air_fee.mx_tax.edit
|
||||
field.cw_air_fee.mx_tax.query
|
||||
field.cw_air_fee.mx_tax.export
|
||||
```
|
||||
|
||||
不需要开发人员另外维护 Power 编码。
|
||||
新增字段 `mx_tax` 后,由模块字段元数据和用户字段策略直接控制,不生成 `s_power` 权限点。
|
||||
|
||||
---
|
||||
|
||||
@@ -697,7 +587,7 @@ field.cw_air_fee.mx_tax.export
|
||||
用户个性化
|
||||
```
|
||||
|
||||
例如:用户没有 `field.cw_fee.mx_amount.view`,即使 `s_user_field_pref.b_visible = 1`,也不能显示金额字段。
|
||||
例如:用户字段策略为 `hidden` 时,即使 `s_user_field_pref.b_visible = 1`,也不能显示金额字段。
|
||||
|
||||
---
|
||||
|
||||
@@ -710,8 +600,9 @@ field.cw_air_fee.mx_tax.export
|
||||
│
|
||||
├──────────────┐
|
||||
↓ ↓
|
||||
s_field s_data_scope
|
||||
│ │
|
||||
s_field
|
||||
│
|
||||
├──────────────┐
|
||||
↓ ↓
|
||||
s_power s_user_data_scope
|
||||
│
|
||||
@@ -748,26 +639,25 @@ page / report
|
||||
|
||||
本设计的核心原则可以归纳为:
|
||||
|
||||
- 资源 ID = 资源本身的 ID
|
||||
- Power ID = 资源类型 + 资源 ID + Operation
|
||||
- `s_power.b_id` 是内部主键,`s_power.b_code` 是系统生成的可读业务键;
|
||||
- 权限唯一性由资源类型、资源编码、owner 和能力字段共同保证;
|
||||
- 字段权限直接使用 `s_user_field_permission` 的访问策略;
|
||||
- 数据范围直接使用 `s_user_data_scope`,不再建立独立的范围模板表。
|
||||
|
||||
统一规则:
|
||||
权限业务键示例:
|
||||
|
||||
```text
|
||||
menu.<MenuID>
|
||||
action.<ActionID>
|
||||
module.<ModuleID>.<Operation>
|
||||
field.<ModuleID>.<Field>.<Operation>
|
||||
page.<PageID>
|
||||
report.<ReportID>
|
||||
```
|
||||
|
||||
其中:
|
||||
|
||||
- `b_id` 是稳定的业务权限编码,不需要人工随机生成。
|
||||
- `s_power` 是统一权限注册表。
|
||||
- `s_user_power` 是用户操作权限。
|
||||
- `s_data_scope` 是数据范围定义。
|
||||
- `s_user_data_scope` 是用户数据范围授权。
|
||||
- 操作权限和数据权限完全分离。
|
||||
- 菜单不负责数据范围。
|
||||
@@ -779,99 +669,322 @@ report.<ReportID>
|
||||
|
||||
## 20. Demo 数据(4 张表)
|
||||
|
||||
以下为 4 张表的示例数据,沿用正文的 `cw_fee` 费用模块。示例用户:张三(费用会计)、李四(财务经理)。
|
||||
以下为新权限模型的示例数据,沿用 `cw_fee` 费用模块。示例用户:张三(费用会计)、李四(财务经理)。数据范围直接保存在 `s_user_data_scope`,不再维护独立的范围模板表。
|
||||
|
||||
### 20.1 s_power(权限定义)
|
||||
|
||||
| b_id | b_name | b_power_type | b_resource | b_menu_id | b_module_id | b_field | b_operation | b_canuse | b_xh |
|
||||
| --------------------------------- | ---------- | ------------ | --------------- | --------- | ----------- | ---------- | ----------- | -------- | ---- |
|
||||
| menu.cw_fee | 费用管理菜单 | menu | cw_fee | cw_fee | null | null | access | 1 | 10 |
|
||||
| page.cw_fee | 费用页面 | page | cw_fee | cw_fee | null | null | access | 1 | 20 |
|
||||
| action.cw_fee_audit | 费用审核 | action | cw_fee_audit | cw_fee | cw_fee | null | execute | 1 | 30 |
|
||||
| action.cw_fee_unaudit | 费用反审核 | action | cw_fee_unaudit | cw_fee | cw_fee | null | execute | 1 | 40 |
|
||||
| module.cw_fee.read | 费用读取 | module | null | cw_fee | cw_fee | null | read | 1 | 10 |
|
||||
| module.cw_fee.create | 费用新增 | module | null | cw_fee | cw_fee | null | create | 1 | 20 |
|
||||
| module.cw_fee.update | 费用修改 | module | null | cw_fee | cw_fee | null | update | 1 | 30 |
|
||||
| module.cw_fee.delete | 费用删除 | module | null | cw_fee | cw_fee | null | delete | 1 | 40 |
|
||||
| module.cw_fee.export | 费用导出 | module | null | cw_fee | cw_fee | null | export | 1 | 50 |
|
||||
| field.cw_fee.mx_amount.view | 金额可查看 | field | null | cw_fee | cw_fee | mx_amount | view | 1 | 10 |
|
||||
| field.cw_fee.mx_amount.edit | 金额可编辑 | field | null | cw_fee | cw_fee | mx_amount | edit | 1 | 20 |
|
||||
| field.cw_fee.mx_amount.query | 金额可查询 | field | null | cw_fee | cw_fee | mx_amount | query | 1 | 30 |
|
||||
| field.cw_fee.mx_amount.export | 金额可导出 | field | null | cw_fee | cw_fee | mx_amount | export | 1 | 40 |
|
||||
| report.cw_fee_summary | 费用汇总报表 | report | cw_fee_summary | cw_fee | cw_fee | null | access | 1 | 60 |
|
||||
| b_id | b_code | b_name | b_power_type | b_resource_id | b_owner_type | b_owner_id | b_capability | b_canuse | b_xh |
|
||||
| ---: | --- | --- | --- | --- | --- | --- | --- | ---: | ---: |
|
||||
| 1001 | menu.cw_fee.access | 费用管理菜单 | menu | cw_fee | null | null | access | 1 | 10 |
|
||||
| 1002 | page.cw_fee.access | 费用页面 | page | cw_fee | null | null | access | 1 | 20 |
|
||||
| 1003 | action.module.cw_fee.audit.execute | 费用审核 | action | audit | module | cw_fee | execute | 1 | 30 |
|
||||
| 1004 | action.module.cw_fee.unaudit.execute | 费用反审核 | action | unaudit | module | cw_fee | execute | 1 | 40 |
|
||||
| 1005 | module.cw_fee.read | 费用读取 | module | cw_fee | null | null | read | 1 | 50 |
|
||||
| 1006 | module.cw_fee.create | 费用新增 | module | cw_fee | null | null | create | 1 | 60 |
|
||||
| 1007 | module.cw_fee.update | 费用修改 | module | cw_fee | null | null | update | 1 | 70 |
|
||||
| 1008 | module.cw_fee.delete | 费用删除 | module | cw_fee | null | null | delete | 1 | 80 |
|
||||
| 1009 | module.cw_fee.export | 费用导出 | module | cw_fee | null | null | export | 1 | 90 |
|
||||
| 1010 | report.cw_fee_summary.access | 费用汇总报表 | report | cw_fee_summary | null | null | access | 1 | 100 |
|
||||
|
||||
要点:覆盖全部 6 种 `b_power_type`;`b_id` 由类型 + 资源 + 操作自动拼出,`b_module_id` / `b_field` 等列是解析冗余,便于按模块、字段反查权限。
|
||||
`b_id` 是内部主键,`b_code` 是全局唯一业务键。动作的 `b_resource_id` 是动作编码,`b_owner_type/b_owner_id` 标识所属模块;因此不同模块可以同时拥有 `audit` 动作而不冲突。
|
||||
|
||||
### 20.2 s_user_power(用户授权)
|
||||
|
||||
| b_user_id | b_power_id | b_canuse | b_updatedatetime |
|
||||
| --------- | ------------------------------ | -------- | -------------------- |
|
||||
| zhangsan | menu.cw_fee | 1 | 2026-02-01 09:00:00 |
|
||||
| zhangsan | page.cw_fee | 1 | 2026-02-01 09:00:00 |
|
||||
| zhangsan | module.cw_fee.read | 1 | 2026-02-01 09:00:00 |
|
||||
| zhangsan | module.cw_fee.update | 1 | 2026-02-01 09:00:00 |
|
||||
| zhangsan | module.cw_fee.export | 1 | 2026-02-01 09:00:00 |
|
||||
| zhangsan | module.cw_fee.delete | **0** | 2026-02-10 14:30:00 |
|
||||
| zhangsan | action.cw_fee_audit | 1 | 2026-02-01 09:00:00 |
|
||||
| zhangsan | field.cw_fee.mx_amount.view | 1 | 2026-02-01 09:00:00 |
|
||||
| zhangsan | field.cw_fee.mx_amount.query | 1 | 2026-02-01 09:00:00 |
|
||||
| zhangsan | report.cw_fee_summary | 1 | 2026-02-01 09:00:00 |
|
||||
| lisi | menu.cw_fee | 1 | 2026-01-15 10:00:00 |
|
||||
| lisi | page.cw_fee | 1 | 2026-01-15 10:00:00 |
|
||||
| lisi | module.cw_fee.read | 1 | 2026-01-15 10:00:00 |
|
||||
| lisi | module.cw_fee.create | 1 | 2026-01-15 10:00:00 |
|
||||
| lisi | module.cw_fee.update | 1 | 2026-01-15 10:00:00 |
|
||||
| lisi | module.cw_fee.delete | 1 | 2026-01-15 10:00:00 |
|
||||
| lisi | module.cw_fee.export | 1 | 2026-01-15 10:00:00 |
|
||||
| lisi | action.cw_fee_audit | 1 | 2026-01-15 10:00:00 |
|
||||
| lisi | action.cw_fee_unaudit | 1 | 2026-01-15 10:00:00 |
|
||||
| lisi | field.cw_fee.mx_amount.view | 1 | 2026-01-15 10:00:00 |
|
||||
| lisi | field.cw_fee.mx_amount.edit | 1 | 2026-01-15 10:00:00 |
|
||||
| lisi | field.cw_fee.mx_amount.export | 1 | 2026-01-15 10:00:00 |
|
||||
| b_user_id | b_power_id | b_canuse | b_updatedatetime |
|
||||
| --- | ---: | ---: | --- |
|
||||
| zhangsan | 1001 | 1 | 2026-02-01 09:00:00 |
|
||||
| zhangsan | 1002 | 1 | 2026-02-01 09:00:00 |
|
||||
| zhangsan | 1005 | 1 | 2026-02-01 09:00:00 |
|
||||
| zhangsan | 1007 | 1 | 2026-02-01 09:00:00 |
|
||||
| zhangsan | 1009 | 1 | 2026-02-01 09:00:00 |
|
||||
| zhangsan | 1008 | 0 | 2026-02-10 14:30:00 |
|
||||
| zhangsan | 1003 | 1 | 2026-02-01 09:00:00 |
|
||||
| zhangsan | 1010 | 1 | 2026-02-01 09:00:00 |
|
||||
| lisi | 1001 | 1 | 2026-01-15 10:00:00 |
|
||||
| lisi | 1002 | 1 | 2026-01-15 10:00:00 |
|
||||
| lisi | 1005 | 1 | 2026-01-15 10:00:00 |
|
||||
| lisi | 1006 | 1 | 2026-01-15 10:00:00 |
|
||||
| lisi | 1007 | 1 | 2026-01-15 10:00:00 |
|
||||
| lisi | 1008 | 1 | 2026-01-15 10:00:00 |
|
||||
| lisi | 1009 | 1 | 2026-01-15 10:00:00 |
|
||||
| lisi | 1003 | 1 | 2026-01-15 10:00:00 |
|
||||
| lisi | 1004 | 1 | 2026-01-15 10:00:00 |
|
||||
| lisi | 1010 | 1 | 2026-01-15 10:00:00 |
|
||||
|
||||
要点:
|
||||
张三的 `1008` 对应 `module.cw_fee.delete`,记录存在但已停用,因此仍然没有删除权限。
|
||||
|
||||
- 张三的 `module.cw_fee.delete` 记录存在但 `b_canuse = 0`,演示「授权被手动停用 = 无权限」,与「无记录 = 无权限」效果相同;
|
||||
- 张三没有 `field.cw_fee.mx_amount.edit`,金额字段对他只读;李四全量字段能力。
|
||||
### 20.3 s_user_field_permission(字段访问策略)
|
||||
|
||||
### 20.3 s_data_scope(数据范围定义)
|
||||
| b_user_id | b_module_id | b_field | b_access_mode | b_allow_query | b_allow_export | b_canuse |
|
||||
| --- | --- | --- | --- | ---: | ---: | ---: |
|
||||
| zhangsan | cw_fee | mx_amount | view | 1 | 0 | 1 |
|
||||
| zhangsan | cw_fee | mx_internal_cost | hidden | 0 | 0 | 1 |
|
||||
| lisi | cw_fee | mx_amount | edit | 1 | 1 | 1 |
|
||||
| lisi | cw_fee | mx_internal_cost | view | 1 | 0 | 1 |
|
||||
|
||||
| b_id | b_name | b_module_id | b_scope_type | b_scope_field | b_scope_value | b_canuse | b_xh |
|
||||
| ------------------------------ | ------------ | ----------- | ---------------- | ---------------- | ------------- | -------- | ---- |
|
||||
| scope.cw_fee.own | 仅本人 | cw_fee | own | b_inputuser_id | null | 1 | 10 |
|
||||
| scope.cw_fee.department | 本部门 | cw_fee | department | b_department_id | null | 1 | 20 |
|
||||
| scope.cw_fee.department_tree | 本部门及下属 | cw_fee | department_tree | b_department_id | null | 1 | 30 |
|
||||
| scope.cw_fee.all | 全部费用 | cw_fee | all | null | null | 1 | 40 |
|
||||
| scope.cw_fee.custom_project | 指定项目 | cw_fee | custom | b_project_id | PRJ001 | 1 | 50 |
|
||||
|
||||
要点:`own` / `department` / `department_tree` / `all` 靠 `b_scope_type` 语义过滤,`b_scope_field` 指向业务表的归属字段;`custom` 额外用 `b_scope_value` 指定具体值。
|
||||
张三对金额字段是“可见、只读、可查询、不可导出”;对内部成本字段完全不可见。李四可以编辑和导出金额,但内部成本字段仍然只读。
|
||||
|
||||
### 20.4 s_user_data_scope(用户数据范围授权)
|
||||
|
||||
| b_user_id | b_module_id | b_operation | b_scope_id | b_xh | b_updatedatetime |
|
||||
| --------- | ----------- | ----------- | ---------------------------- | ---- | ------------------- |
|
||||
| zhangsan | cw_fee | read | scope.cw_fee.department | 0 | 2026-02-01 09:00:00 |
|
||||
| zhangsan | cw_fee | read | scope.cw_fee.own | 10 | 2026-02-01 09:00:00 |
|
||||
| zhangsan | cw_fee | update | scope.cw_fee.own | 0 | 2026-02-01 09:00:00 |
|
||||
| lisi | cw_fee | read | scope.cw_fee.department_tree | 0 | 2026-01-15 10:00:00 |
|
||||
| lisi | cw_fee | update | scope.cw_fee.department_tree | 0 | 2026-01-15 10:00:00 |
|
||||
| lisi | cw_fee | delete | scope.cw_fee.all | 0 | 2026-01-15 10:00:00 |
|
||||
| b_user_id | b_module_id | b_operation | b_scope_level | b_scope_type | b_scope_field | b_scope_value | b_scope_no | b_updatedatetime |
|
||||
| --------- | ----------- | ----------- | ------------- | ---------------- | --------------- | ------------- | ---------- | ------------------- |
|
||||
| zhangsan | cw_fee | * | default | department | b_department_id | null | 1 | 2026-02-01 09:00:00 |
|
||||
| zhangsan | cw_fee | update | override | own | b_inputuser_id | null | 1 | 2026-02-01 09:00:00 |
|
||||
| lisi | cw_fee | * | default | department_tree | b_department_id | null | 1 | 2026-01-15 10:00:00 |
|
||||
| lisi | cw_fee | delete | override | all | null | null | 1 | 2026-01-15 10:00:00 |
|
||||
|
||||
要点:
|
||||
|
||||
- 张三 read 挂了两条范围 → 部门数据 OR 本人数据;
|
||||
- 数据范围按操作分开授权:张三能看本部门,但只能改自己录的费用(update 仅 own);
|
||||
- 李四 delete 挂 `all`,经理可删全模块数据,符合「操作越重、范围越收」或「管理者放宽」都可表达的弹性。
|
||||
- 张三使用模块默认范围读取本部门数据,但 update 有操作级覆盖,只能修改本人录入的费用;
|
||||
- 李四使用默认范围处理一般操作,但 delete 有操作级覆盖为 `all`,经理可删全模块数据。
|
||||
|
||||
### 20.5 组合结果解读
|
||||
|
||||
| 能力 | 张三(费用会计) | 李四(财务经理) |
|
||||
| ---------------- | ---------------------------------------- | -------------------------------- |
|
||||
| 看菜单 / 页面 | ✅ | ✅ |
|
||||
| 读取费用数据 | ✅ 本部门 + 本人 | ✅ 本部门及下属部门 |
|
||||
| 读取费用数据 | ✅ 本部门 | ✅ 本部门及下属部门 |
|
||||
| 新增 | ❌ 无 module.create | ✅ |
|
||||
| 修改 | ✅ 仅本人录入的单据 | ✅ 本部门及下属部门的单据 |
|
||||
| 删除 | ❌ 授权已停用(b_canuse=0) | ✅ 全部数据 |
|
||||
| 审核 / 反审核 | ✅ 可审核,❌ 不可反审核 | ✅ 都可以 |
|
||||
| 金额字段 | 可看、可查询、❌ 不可编辑、❌ 不可导出字段列 | 可看、可编辑、可导出 |
|
||||
| 金额字段 | 可见、只读、可查询、不可导出 | 可见、可编辑、可查询、可导出 |
|
||||
| 内部成本字段 | 不可见 | 可见但只读 |
|
||||
| 汇总报表 | ✅ | ✅(报表数据仍受 read 范围约束) |
|
||||
|
||||
---
|
||||
|
||||
## 21. 权限模型重构(当前生效方案)
|
||||
|
||||
### 21.1 重构原因
|
||||
|
||||
旧方案有两个结构性问题:
|
||||
|
||||
1. `s_power.b_id` 同时承担数据库主键和业务权限编码。权限编码一旦变化会影响授权记录;动作编码在不同菜单/模块下重复时,`action.<ActionID>` 也无法保证全局唯一。
|
||||
2. 字段权限被拆成多个彼此独立的 `view/edit/query/export` 权限点,不能直接表达“不可见”“可见但只读”“可见且可编辑”等实际权限状态,也容易出现 `edit=1、view=0` 这种无效组合。
|
||||
|
||||
新方案将“权限点身份”和“字段访问策略”分开:
|
||||
|
||||
- `s_power` 只保存菜单、页面、报表、模块、动作等可执行能力;
|
||||
- `s_user_field_permission` 保存字段访问级别及查询/导出能力;
|
||||
- `b_id` 只做内部主键,业务侧使用不可变且全局唯一的 `b_code`;
|
||||
- 动作权限必须带上所属资源的命名空间,不再使用裸 `action.<ActionID>`。
|
||||
|
||||
### 21.2 s_power:内部主键与业务编码分离
|
||||
|
||||
```sql
|
||||
create table dbo.s_power (
|
||||
b_id bigint not null, -- 内部主键,雪花/序列生成,不表达业务含义
|
||||
b_code varchar(300) not null, -- 稳定、全局唯一的业务键,由系统生成
|
||||
b_name nvarchar(200) not null,
|
||||
b_i18n varchar(150) null,
|
||||
b_power_type varchar(20) not null, -- menu/page/report/module/action
|
||||
b_resource_id varchar(128) not null, -- 当前权限对应的资源编码
|
||||
b_owner_type varchar(20) null, -- action 的 owner 类型
|
||||
b_owner_id varchar(128) null, -- action 的 owner 编码
|
||||
b_capability varchar(30) not null, -- access/execute/read/create/update/delete/export
|
||||
b_canuse tinyint not null default 1,
|
||||
b_xh int not null default 0,
|
||||
constraint PK_s_power primary key (b_id),
|
||||
constraint UQ_s_power_code unique (b_code),
|
||||
constraint CK_s_power_type check (b_power_type in ('menu','page','report','module','action')),
|
||||
constraint CK_s_power_shape check (
|
||||
(b_power_type in ('menu','page','report')
|
||||
and b_owner_type is null and b_owner_id is null
|
||||
and b_capability = 'access')
|
||||
or (b_power_type = 'module'
|
||||
and b_owner_type is null and b_owner_id is null
|
||||
and b_capability in ('read','create','update','delete','export'))
|
||||
or (b_power_type = 'action'
|
||||
and b_owner_type in ('menu','page','module')
|
||||
and b_owner_id is not null
|
||||
and b_capability = 'execute')
|
||||
)
|
||||
);
|
||||
|
||||
create index IX_s_power_resource
|
||||
on dbo.s_power (b_power_type, b_owner_type, b_owner_id, b_resource_id, b_canuse);
|
||||
```
|
||||
|
||||
`b_code` 是由结构化身份规范化生成的唯一键,不允许人工随意修改。资源编码应使用稳定的业务键(建议只允许字母、数字、`_`、`-`),更名应通过资源迁移完成,而不是直接改写已发布权限的身份。推荐格式:
|
||||
|
||||
```text
|
||||
menu.cw_fee.access
|
||||
page.cw_fee.access
|
||||
report.cw_fee_summary.access
|
||||
module.cw_fee.read
|
||||
action.module.cw_fee.audit.execute
|
||||
```
|
||||
|
||||
动作至少包含 `owner_type + owner_id + action_id` 三段。两个模块都可以定义 `audit`,但它们会生成不同的 `b_code`。跨多个资源的动作必须为每个资源建立独立权限点,或指定明确的系统级 owner。
|
||||
|
||||
### 21.3 s_user_power:引用内部主键
|
||||
|
||||
```sql
|
||||
create table dbo.s_user_power (
|
||||
b_user_id varchar(50) not null,
|
||||
b_power_id bigint not null,
|
||||
b_canuse tinyint not null default 1,
|
||||
b_updatedatetime datetime2 null,
|
||||
constraint PK_s_user_power primary key (b_user_id, b_power_id)
|
||||
);
|
||||
|
||||
create index IX_s_user_power_user
|
||||
on dbo.s_user_power (b_user_id, b_canuse, b_power_id);
|
||||
```
|
||||
|
||||
授权、审计和关联查询使用 `b_power_id`;接口、缓存键、日志中需要可读标识时使用 `s_power.b_code`。权限名称或编码生成规则变化不会破坏数据库关联。
|
||||
|
||||
### 21.4 字段权限独立为访问策略
|
||||
|
||||
字段权限是有顺序的访问级别,不是四个互不相关的动作。新增用户字段策略表:
|
||||
|
||||
```sql
|
||||
create table dbo.s_user_field_permission (
|
||||
b_user_id varchar(50) not null,
|
||||
b_module_id varchar(50) not null,
|
||||
b_field varchar(128) not null,
|
||||
b_access_mode varchar(10) not null, -- hidden / view / edit
|
||||
b_allow_query tinyint not null default 0,
|
||||
b_allow_export tinyint not null default 0,
|
||||
b_canuse tinyint not null default 1,
|
||||
b_updatedatetime datetime2 null,
|
||||
constraint PK_s_user_field_permission
|
||||
primary key (b_user_id, b_module_id, b_field),
|
||||
constraint CK_s_user_field_permission_mode
|
||||
check (b_access_mode in ('hidden','view','edit')),
|
||||
constraint CK_s_user_field_permission_capability
|
||||
check (b_access_mode <> 'hidden' or (b_allow_query = 0 and b_allow_export = 0))
|
||||
);
|
||||
|
||||
create index IX_s_user_field_permission_module
|
||||
on dbo.s_user_field_permission (b_user_id, b_module_id, b_canuse, b_field);
|
||||
```
|
||||
|
||||
| `b_access_mode` | 返回字段 | 页面显示 | 可编辑 |
|
||||
| --------------- | -------- | -------- | ------ |
|
||||
| `hidden` | 否 | 否 | 否 |
|
||||
| `view` | 是 | 是 | 否 |
|
||||
| `edit` | 是 | 是 | 是 |
|
||||
|
||||
`b_allow_query` 和 `b_allow_export` 是附加能力,不能把 `hidden` 字段变成可查询或可导出字段。没有有效记录时按 `hidden` 处理。
|
||||
|
||||
系统元数据中的 `s_field_view`、`s_field_edit`、`s_field_query` 仍然只负责默认布局、只读、禁用和查询布局,不承担用户授权。最终字段状态按以下规则计算:
|
||||
|
||||
```text
|
||||
用户字段策略(hidden/view/edit)
|
||||
↓
|
||||
模块字段元数据:b_visible、b_readonly、b_disabled(只能收紧)
|
||||
↓
|
||||
业务状态与业务规则
|
||||
↓
|
||||
最终字段状态
|
||||
```
|
||||
|
||||
因此 `edit` 遇到 `b_readonly=1` 或 `b_disabled=1` 时降为 `view`,`b_visible=0` 时最终为 `hidden`。用户个性化不能恢复 `hidden` 字段,也不能把 `view` 变成 `edit`。
|
||||
|
||||
### 21.5 字段授权示例
|
||||
|
||||
```sql
|
||||
insert into dbo.s_user_field_permission
|
||||
(b_user_id, b_module_id, b_field, b_access_mode, b_allow_query, b_allow_export)
|
||||
values
|
||||
('zhangsan', 'cw_fee', 'mx_amount', 'view', 1, 0),
|
||||
('lisi', 'cw_fee', 'mx_amount', 'edit', 1, 1);
|
||||
```
|
||||
|
||||
写接口必须同时检查 `module.<module>.update`、字段最终为 `edit`、以及 update 数据范围。
|
||||
|
||||
### 21.6 权限判定顺序(重构后)
|
||||
|
||||
```text
|
||||
① 用户有效
|
||||
② menu/page/report 入口权限(如适用)
|
||||
③ action 权限(如适用)
|
||||
④ module 操作权限
|
||||
⑤ 字段策略:hidden / view / edit + query/export
|
||||
⑥ 数据范围
|
||||
⑦ 元数据只读/禁用、业务状态和业务规则
|
||||
⑧ 执行操作
|
||||
```
|
||||
|
||||
入口权限、模块权限、字段策略、数据范围是 AND 关系;同一用户多个数据范围仍按 OR 合并。
|
||||
|
||||
### 21.7 旧数据迁移规则
|
||||
|
||||
1. 原 `s_power.b_id` 字符串迁移到新表的 `b_code`;新 `b_id` 重新生成,`s_user_power.b_power_id` 通过旧编码映射到新主键。
|
||||
2. 原 `action.<ActionID>` 根据 `b_module_id` 或菜单归属补全 owner,生成 `action.<OwnerType>.<OwnerID>.<ActionID>.execute`。无法唯一确定归属的记录不得自动合并。
|
||||
3. 原字段权限按用户、模块、字段聚合:有有效 `edit` 则为 `edit`,否则有有效 `view` 则为 `view`,否则为 `hidden`;`query`、`export` 映射到附加标志,并受 `hidden` 约束。
|
||||
4. 迁移完成后旧字段权限记录删除或归档,权限服务只读取新模型,避免新旧模型同时生效。
|
||||
|
||||
### 21.8 数据范围配置 UI:默认范围 + 操作级覆盖
|
||||
|
||||
数据范围底层仍按“模块 + 操作”保存,但管理界面不要求管理员一开始就配置每个操作。采用两层配置:
|
||||
|
||||
#### 默认配置
|
||||
|
||||
用户选择一个模块后,先设置一个默认数据范围:
|
||||
|
||||
```text
|
||||
默认数据范围:仅本人 / 仅本部门 / 本部门及下属 / 全部 / 自定义
|
||||
```
|
||||
|
||||
默认范围应用于该模块所有需要数据过滤的操作。底层只保存一条模块默认记录:
|
||||
|
||||
```text
|
||||
* -> 默认范围
|
||||
```
|
||||
|
||||
对于不适用的操作(例如用户没有 `module.<module>.delete`),不生成数据范围记录也不会产生权限。
|
||||
|
||||
#### 操作级覆盖
|
||||
|
||||
在默认范围旁提供“按操作单独设置”入口。管理员展开后,可以针对具体操作覆盖默认值:
|
||||
|
||||
```text
|
||||
查询 read :跟随默认范围 = 本部门
|
||||
新增 create :跟随默认范围
|
||||
修改 update :仅本人
|
||||
删除 delete :无权限或不配置
|
||||
导出 export :跟随默认范围 = 本部门
|
||||
```
|
||||
|
||||
操作级覆盖只改变该操作的数据过滤条件,不会授予操作权限。用户仍然必须拥有对应的 `s_power` 权限。
|
||||
|
||||
#### 保存与读取规则
|
||||
|
||||
`s_user_data_scope` 使用来源标识和通配操作,区分默认值和覆盖值:
|
||||
|
||||
```sql
|
||||
b_scope_level varchar(10) not null default 'default'
|
||||
-- default = 来自模块默认范围
|
||||
-- override = 操作级覆盖
|
||||
|
||||
b_operation = '*'
|
||||
-- 仅 default 级别允许使用 '*'
|
||||
```
|
||||
|
||||
同一用户、模块、操作最多保留一组生效配置:
|
||||
|
||||
- 没有具体操作的 `override` 记录时,运行时回退到 `b_operation='*'` 的 `default` 范围;
|
||||
- 存在 `override` 记录时,只对该操作使用覆盖范围;
|
||||
- 一个操作可以配置多条范围,多条范围之间仍按 `OR` 合并;
|
||||
- UI 中的“无权限”表示不生成该操作的范围,同时仍以 `s_user_power` 为最终操作权限判断依据。
|
||||
|
||||
#### 典型示例
|
||||
|
||||
管理员只配置:
|
||||
|
||||
```text
|
||||
cw_fee 默认数据范围 = 本部门
|
||||
```
|
||||
|
||||
然后展开操作级配置,将修改覆盖为“仅本人”:
|
||||
|
||||
```text
|
||||
s_user_data_scope
|
||||
cw_fee + * + department (default)
|
||||
cw_fee + update + own (override)
|
||||
```
|
||||
|
||||
最终效果就是:张三可以查询和导出本部门费用,但只能修改本人创建的费用。这个例子也是保留操作级数据范围能力的主要原因。
|
||||
Reference in new issue
Block a user