20260907172356
This commit is contained in:
1 parent
78825797e7
commit
1b596ffa5b
4 files changed
+1123
-261
No files matched your search
@@ -141,7 +141,49 @@ create index ix_s_field_query_order
|
||||
on dbo.s_field_query (b_module_id, b_xh, b_field, b_condition_no);
|
||||
```
|
||||
|
||||
## 4. 自动编码
|
||||
## 4. 用户个性化
|
||||
|
||||
> 用户个性化只作用于列表布局和普通查询收起态,不改变模块级默认配置。权限过滤优先于个人偏好,个人偏好不能越过字段权限或字段 `b_canuse` 限制。
|
||||
|
||||
```sql
|
||||
create table dbo.s_user_field_pref (
|
||||
b_user_id varchar(50) not null,
|
||||
b_module_id varchar(50) not null,
|
||||
b_field varchar(50) not null,
|
||||
b_mode varchar(10) not null, -- 'view' 查看列表 / 'edit' 可编辑列表
|
||||
b_visible tinyint not null default 1,
|
||||
b_xh int not null default 0,
|
||||
b_width int null,
|
||||
b_updatedatetime datetime2 null,
|
||||
primary key (b_user_id, b_module_id, b_field, b_mode)
|
||||
);
|
||||
|
||||
create index ix_s_user_field_pref_order
|
||||
on dbo.s_user_field_pref (b_user_id, b_module_id, b_mode, b_xh, b_field);
|
||||
|
||||
create table dbo.s_user_query_field_pref (
|
||||
b_user_id varchar(50) not null,
|
||||
b_module_id varchar(50) not null,
|
||||
b_field varchar(50) not null,
|
||||
b_quick_visible tinyint not null default 1, -- 普通查询收起态是否显示
|
||||
b_xh int not null default 0, -- 普通查询字段顺序
|
||||
b_updatedatetime datetime2 null,
|
||||
primary key (b_user_id, b_module_id, b_field)
|
||||
);
|
||||
|
||||
create index ix_s_user_query_field_pref_order
|
||||
on dbo.s_user_query_field_pref (b_user_id, b_module_id, b_xh, b_field);
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `s_user_field_pref` 的 `b_mode` 区分查看列表和可编辑列表两套个人布局;
|
||||
- `s_user_query_field_pref` 不区分普通查询/高级查询模式,因为两者使用同一批查询字段;
|
||||
- 查询偏好只影响普通查询收起态,展开态和高级查询仍展示所有有权限且启用的查询字段;
|
||||
- 没有个人偏好时回落到对应的 `s_field_view` / `s_field_edit` / `s_field_query` 默认配置;
|
||||
- “恢复默认”删除当前用户、当前模块对应的个人偏好记录。
|
||||
|
||||
## 5. 自动编码
|
||||
|
||||
```sql
|
||||
create table dbo.s_autocode (
|
||||
@@ -160,7 +202,7 @@ create table dbo.s_autocode (
|
||||
);
|
||||
```
|
||||
|
||||
## 5. 模块关系
|
||||
## 6. 模块关系
|
||||
|
||||
```sql
|
||||
create table dbo.s_relation (
|
||||
@@ -190,7 +232,7 @@ create index ix_s_relation_target
|
||||
on dbo.s_relation (b_target_module_id);
|
||||
```
|
||||
|
||||
## 6. 菜单
|
||||
## 7. 菜单
|
||||
|
||||
```sql
|
||||
create table dbo.s_menu (
|
||||
@@ -219,7 +261,9 @@ create index ix_s_menu_route
|
||||
on dbo.s_menu (b_route, b_canuse, b_id);
|
||||
```
|
||||
|
||||
## 7. 权限
|
||||
## 8. 权限
|
||||
|
||||
> 权限直接授予用户,不引入角色继承。`s_power` 是权限点定义表,`s_user_power` 是用户授权表;数据范围使用独立的 `s_user_data_scope` 表。权限点按资源类型扩展,新增报表、页面等资源时复用同一模型。
|
||||
|
||||
```sql
|
||||
create table dbo.s_power (
|
||||
@@ -240,9 +284,54 @@ create index ix_s_power_module
|
||||
|
||||
create index ix_s_power_resource
|
||||
on dbo.s_power (b_power_type, b_resource, b_canuse);
|
||||
|
||||
create table dbo.s_user_power (
|
||||
b_user_id varchar(50) not null,
|
||||
b_power_id varchar(50) not null,
|
||||
b_canuse tinyint not null default 1,
|
||||
b_updatedatetime datetime2 null,
|
||||
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);
|
||||
|
||||
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_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)
|
||||
);
|
||||
|
||||
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);
|
||||
```
|
||||
|
||||
## 8. 多语言
|
||||
权限点约定:
|
||||
|
||||
- `menu/access`:菜单或页面入口访问权限;
|
||||
- `module/read|create|update|delete|export`:模块级数据操作权限;
|
||||
- `action/execute`:菜单或页面中的按钮、动作权限;
|
||||
- `field/view|edit|query|export`:字段查看、修改、查询、导出能力;
|
||||
- `page/access`、`report/access`:独立页面或报表访问权限,报表列表中的每个报表可作为一个资源;
|
||||
- `b_power_type` 和 `b_operation` 是可扩展编码,新增资源类型不新增权限表,但需要补充权限定义和前端/后端校验规则。
|
||||
- 当前数据库迁移中的权限类型/形状约束若仍只允许 `menu/module/action/field` 或 `field/view|edit`,新增 `page`、`report`、`field/query` 等编码时必须同步扩展约束;不要只修改前端枚举。
|
||||
|
||||
权限计算约定:
|
||||
|
||||
- 默认拒绝:用户没有有效授权记录时不具备该权限;
|
||||
- 用户存在有效 `s_user_power` 记录时获得该权限;停用的权限点或授权记录不生效;
|
||||
- 字段 `edit` 只代表用户有修改能力,最终是否可编辑仍受 `s_field_edit.b_readonly`、`b_disabled` 和业务校验约束;
|
||||
- 字段不可见时,其编辑、查询、导出能力一并无效;字段可见但无 `edit` 权限时,在可编辑列表中显示为只读;
|
||||
- 模块数据操作权限与数据范围同时生效:先判断操作权限,再按 `s_user_data_scope` 限制数据行;
|
||||
- 菜单/页面/报表访问权限只控制入口和访问,不能替代模块、字段或数据范围权限。
|
||||
|
||||
## 9. 多语言
|
||||
|
||||
```sql
|
||||
-- b_i18n / b_key 保存完整资源键,不追加 .name 或 .title 后缀:
|
||||
@@ -277,7 +366,7 @@ create index ix_s_i18n_locale
|
||||
on dbo.s_i18n (b_locale, b_canuse);
|
||||
```
|
||||
|
||||
## 9. 日志
|
||||
## 10. 日志
|
||||
|
||||
> 日志主键由业务层统一生成 UUIDv7。UUID 主键使用非聚集索引;技术、登录、审计日志按发生时间建立聚集索引,审计字段明细按审计事件建立聚集索引。日志顺序以 `b_occurdatetime` 为准,不依赖 UUID 排序。
|
||||
|
||||
@@ -379,7 +468,7 @@ create index ix_s_log_audit_module
|
||||
on dbo.s_log_audit (b_module_id, b_data_id, b_occurdatetime);
|
||||
```
|
||||
|
||||
## 10. 业务表主键约定
|
||||
## 11. 业务表主键约定
|
||||
|
||||
```sql
|
||||
create table dbo.b_example (
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Trash2,
|
||||
Upload,
|
||||
LockKeyhole,
|
||||
SlidersHorizontal,
|
||||
Settings2,
|
||||
} from '@lucide/vue'
|
||||
import { Button, Dropdown, Input, Pagination, RangePicker, Select, Tooltip } from '@/components/ui'
|
||||
@@ -247,12 +248,14 @@ const queryApplied = ref(false)
|
||||
const activeQueryMode = ref('quick')
|
||||
const queryMode = ref('quick')
|
||||
const advancedLogic = ref('and')
|
||||
const advancedNodes = reactive([
|
||||
{ id: 1, field: 'status', operator: 'eq', value: 'pending', label: '状态', source: 'advanced' },
|
||||
{ id: 2, field: 'invoiceStatus', operator: 'eq', value: 'unissued', label: '发票状态', source: 'advanced' },
|
||||
])
|
||||
const advancedNodes = reactive([])
|
||||
const advancedGroups = reactive([])
|
||||
const advancedSnapshot = ref(null)
|
||||
const draggingFieldKey = 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
|
||||
}
|
||||
|
||||
/* 渲染用的字段列表:标签 / 选项文案按当前语言解析(含「全部」空值项) */
|
||||
const searchFields = computed(() =>
|
||||
@@ -270,7 +273,7 @@ const searchFields = computed(() =>
|
||||
const quickSearchFields = computed(() =>
|
||||
searchFields.value
|
||||
.filter((field) => field.queryable !== false && queryPrefs[field.key]?.area === 'quick')
|
||||
.sort((a, b) => queryPrefs[a.key].order - queryPrefs[b.key].order),
|
||||
.toSorted((a, b) => queryPrefs[a.key].order - queryPrefs[b.key].order),
|
||||
)
|
||||
const extraQuickSearchFields = computed(() => quickSearchFields.value.slice(QUICK_VISIBLE_LIMIT))
|
||||
const extraQuickConditionCount = computed(() => extraQuickSearchFields.value.filter((field) => {
|
||||
@@ -289,18 +292,33 @@ function getFieldOptions(key) {
|
||||
}
|
||||
function getNodeOperators(node) {
|
||||
const type = fieldByKey.value[node.field]?.type
|
||||
if (type === 'input') return [{ value: 'contains', label: '包含(like)' }, { value: 'eq', label: '等于(eq)' }, { value: 'not_contains', label: '不包含' }]
|
||||
if (type === 'numrange') return [{ value: 'gt', label: '大于(gt)' }, { value: 'ge', label: '大于等于(ge)' }, { value: 'lt', label: '小于(lt)' }, { value: 'le', label: '小于等于(le)' }, { value: 'between', label: '介于(between)' }]
|
||||
if (type === 'daterange') return [{ value: 'between', label: '介于(between)' }, { value: 'ge', label: '不早于(ge)' }, { value: 'le', label: '不晚于(le)' }]
|
||||
if (node.field === 'invoiceStatus' || node.field === 'paymentStatus') return [{ value: 'eq', label: '等于(eq)' }, { value: 'not_eq', label: '不等于(neq)' }, { value: 'exists', label: '已存在(exists)' }]
|
||||
return [{ value: 'eq', label: '等于(eq)' }, { value: 'not_eq', label: '不等于(neq)' }, { value: 'in', label: '属于(in)' }]
|
||||
if (type === 'input') return [{ value: 'contains', label: '包含' }, { value: 'eq', label: '等于' }, { value: 'not_contains', label: '不包含' }]
|
||||
if (type === 'numrange') return [{ value: 'gt', label: '大于' }, { value: 'ge', label: '大于等于' }, { value: 'lt', label: '小于' }, { value: 'le', label: '小于等于' }, { value: 'between', label: '介于' }]
|
||||
if (type === 'daterange') return [{ value: 'between', label: '介于' }, { value: 'ge', label: '不早于' }, { value: 'le', label: '不晚于' }]
|
||||
if (node.field === 'invoiceStatus' || node.field === 'paymentStatus') return [{ value: 'eq', label: '等于' }, { value: 'not_eq', label: '不等于' }, { value: 'exists', label: '已存在' }]
|
||||
return [{ value: 'eq', label: '等于' }, { value: 'not_eq', label: '不等于' }, { value: 'in', label: '属于' }]
|
||||
}
|
||||
function emptyNodeValue(field, operator) {
|
||||
if (field?.type === 'daterange' || (field?.type === 'numrange' && operator === 'between')) return ['', '']
|
||||
return ''
|
||||
}
|
||||
function createAdvancedNode(fieldKey) {
|
||||
const field = fieldByKey.value[fieldKey]
|
||||
const node = { id: Date.now() + Math.random(), field: fieldKey, operator: 'eq', value: '', label: field?.label || fieldKey, source: field?.relation ? 'relation' : 'advanced' }
|
||||
node.operator = getNodeOperators(node)[0]?.value || 'eq'
|
||||
node.value = emptyNodeValue(field, node.operator)
|
||||
return node
|
||||
}
|
||||
function onNodeFieldChange(node) {
|
||||
node.operator = getNodeOperators(node)[0]?.value || 'eq'
|
||||
node.value = ''
|
||||
node.value = emptyNodeValue(fieldByKey.value[node.field], node.operator)
|
||||
node.label = getFieldLabel(node.field)
|
||||
}
|
||||
function onNodeOperatorChange(node) {
|
||||
node.value = emptyNodeValue(fieldByKey.value[node.field], node.operator)
|
||||
}
|
||||
function getNodeValueLabel(node) {
|
||||
if (Array.isArray(node.value)) return node.value.filter(Boolean).join(' - ')
|
||||
const option = getFieldOptions(node.field).find((item) => item.value === node.value)
|
||||
return option?.label || node.value
|
||||
}
|
||||
@@ -317,17 +335,15 @@ const quickQueryAst = computed(() => {
|
||||
})
|
||||
const advancedQueryAst = computed(() => {
|
||||
const children = []
|
||||
const advanced = advancedNodes.filter((node) => node.value !== '').map((node) => ({ type: 'condition', field: fieldByKey.value[node.field]?.path || node.field, operator: node.operator, value: node.value }))
|
||||
const advanced = advancedNodes.filter((node) => hasNodeValue(node.value)).map((node) => ({ type: 'condition', field: fieldByKey.value[node.field]?.path || node.field, operator: node.operator, value: node.value }))
|
||||
if (advanced.length) children.push({ type: 'group', logic: advancedLogic.value, children: advanced })
|
||||
advancedGroups.forEach((group) => {
|
||||
const groupChildren = group.nodes.filter((node) => node.value !== '').map((node) => ({ type: 'condition', field: fieldByKey.value[node.field]?.path || node.field, operator: node.operator, value: node.value }))
|
||||
const groupChildren = group.nodes.filter((node) => hasNodeValue(node.value)).map((node) => ({ type: 'condition', field: fieldByKey.value[node.field]?.path || node.field, operator: node.operator, value: node.value }))
|
||||
if (groupChildren.length) children.push({ type: 'group', logic: group.logic, children: groupChildren })
|
||||
})
|
||||
return { type: 'group', logic: 'and', children }
|
||||
})
|
||||
const queryAst = computed(() => activeQueryMode.value === 'advanced' ? advancedQueryAst.value : quickQueryAst.value)
|
||||
const queryAstPreview = computed(() => JSON.stringify(queryAst.value, null, 2))
|
||||
const advancedConditionCount = computed(() => allAdvancedNodes.value.filter((node) => node.value !== '').length)
|
||||
const advancedConditionCount = computed(() => allAdvancedNodes.value.filter((node) => hasNodeValue(node.value)).length)
|
||||
const querySummary = computed(() => {
|
||||
const items = []
|
||||
if (activeQueryMode.value === 'quick') {
|
||||
@@ -336,7 +352,7 @@ const querySummary = computed(() => {
|
||||
if (value) items.push({ id: `quick:${field.key}`, kind: 'quick', field: field.key, text: `${field.label} ${field.type === 'input' ? '包含' : '='} ${value}` })
|
||||
})
|
||||
} else {
|
||||
allAdvancedNodes.value.filter((node) => node.value).forEach((node) => items.push({ id: `advanced:${node.id}`, kind: 'advanced', nodeId: node.id, text: `${getFieldLabel(node.field)} ${node.operator === 'eq' ? '=' : node.operator} ${getNodeValueLabel(node)}` }))
|
||||
allAdvancedNodes.value.filter((node) => Array.isArray(node.value) ? node.value.some(Boolean) : node.value).forEach((node) => items.push({ id: `advanced:${node.id}`, kind: 'advanced', nodeId: node.id, text: `${getFieldLabel(node.field)} ${getNodeOperators(node).find((operator) => operator.value === node.operator)?.label || node.operator} ${getNodeValueLabel(node)}` }))
|
||||
}
|
||||
return items
|
||||
})
|
||||
@@ -355,9 +371,26 @@ function removeSummaryItem(item) {
|
||||
if (!querySummary.value.length) queryApplied.value = false
|
||||
}
|
||||
function openQueryDrawer(tab = 'conditions') {
|
||||
if (tab === 'conditions' && !drawerOpen.value) {
|
||||
advancedSnapshot.value = {
|
||||
logic: advancedLogic.value,
|
||||
nodes: JSON.parse(JSON.stringify(advancedNodes)),
|
||||
groups: JSON.parse(JSON.stringify(advancedGroups)),
|
||||
}
|
||||
}
|
||||
drawerTab.value = tab
|
||||
drawerOpen.value = true
|
||||
}
|
||||
function closeQueryDrawer() {
|
||||
if (advancedSnapshot.value) {
|
||||
advancedLogic.value = advancedSnapshot.value.logic
|
||||
advancedNodes.splice(0, advancedNodes.length, ...advancedSnapshot.value.nodes)
|
||||
advancedGroups.splice(0, advancedGroups.length, ...advancedSnapshot.value.groups)
|
||||
advancedSnapshot.value = null
|
||||
queryMode.value = activeQueryMode.value
|
||||
}
|
||||
drawerOpen.value = false
|
||||
}
|
||||
function selectQueryMode(mode) {
|
||||
queryMode.value = mode
|
||||
if (mode === 'advanced') openQueryDrawer('conditions')
|
||||
@@ -386,12 +419,36 @@ function removeAdvancedNode(id) {
|
||||
}
|
||||
}
|
||||
function addAdvancedNode() {
|
||||
const field = advancedSearchFields.value[0]
|
||||
advancedNodes.push({ id: Date.now(), field: field?.key || 'creator', operator: getNodeOperators({ field: field?.key || 'creator' })[0]?.value || 'eq', value: '', label: field?.label || '创建人', source: field?.relation ? 'relation' : 'advanced' })
|
||||
advancedNodes.push(createAdvancedNode(advancedSearchFields.value[0]?.key || 'creator'))
|
||||
}
|
||||
function addAdvancedGroup() {
|
||||
const field = advancedSearchFields.value[0]
|
||||
advancedGroups.push({ id: Date.now(), logic: 'or', nodes: [{ id: Date.now() + 1, field: field?.key || 'creator', operator: 'eq', value: '', label: field?.label || '创建人', source: 'advanced' }] })
|
||||
advancedGroups.push({ id: Date.now() + Math.random(), logic: 'or', nodes: [createAdvancedNode(advancedSearchFields.value[0]?.key || 'creator')] })
|
||||
}
|
||||
function resetAdvancedQuery() {
|
||||
advancedNodes.splice(0, advancedNodes.length)
|
||||
advancedGroups.splice(0, advancedGroups.length)
|
||||
advancedLogic.value = 'and'
|
||||
if (activeQueryMode.value === 'advanced') queryApplied.value = false
|
||||
}
|
||||
function resetQuickQuery() {
|
||||
Object.assign(searchForm, makeEmptyForm())
|
||||
if (activeQueryMode.value === 'quick') queryApplied.value = false
|
||||
}
|
||||
function startFieldDrag(field) {
|
||||
draggingFieldKey.value = field.key
|
||||
}
|
||||
function dropField(field, area) {
|
||||
const sourceKey = draggingFieldKey.value
|
||||
draggingFieldKey.value = ''
|
||||
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)
|
||||
const from = rows.findIndex((item) => item.key === sourceKey)
|
||||
const to = rows.findIndex((item) => item.key === field.key)
|
||||
if (from < 0 || to < 0) return
|
||||
const [moved] = rows.splice(from, 1)
|
||||
rows.splice(to, 0, moved)
|
||||
rows.forEach((item, index) => { queryPrefs[item.key].order = (index + 1) * 10 })
|
||||
}
|
||||
function onSearch() {
|
||||
activeQueryMode.value = 'quick'
|
||||
@@ -402,12 +459,17 @@ function applyAdvancedQuery() {
|
||||
activeQueryMode.value = 'advanced'
|
||||
queryMode.value = 'advanced'
|
||||
queryApplied.value = true
|
||||
advancedSnapshot.value = null
|
||||
drawerOpen.value = false
|
||||
}
|
||||
function onReset() {
|
||||
Object.assign(searchForm, makeEmptyForm())
|
||||
advancedNodes.splice(0, advancedNodes.length)
|
||||
advancedGroups.splice(0, advancedGroups.length)
|
||||
function resetAllQueries() {
|
||||
resetQuickQuery()
|
||||
resetAdvancedQuery()
|
||||
queryApplied.value = false
|
||||
advancedSnapshot.value = null
|
||||
}
|
||||
function returnToQuickQuery() {
|
||||
if (drawerOpen.value && drawerTab.value === 'conditions') closeQueryDrawer()
|
||||
activeQueryMode.value = 'quick'
|
||||
queryMode.value = 'quick'
|
||||
queryApplied.value = false
|
||||
@@ -545,20 +607,8 @@ function onMoreAction({ key }) {
|
||||
<div class="dashboard-page">
|
||||
<!-- 页面常用查询:字段来自模块默认配置,并可由用户个性化调整 -->
|
||||
<section class="table-card search-card">
|
||||
<div class="query-mode-bar">
|
||||
<div class="query-mode-switch" role="tablist" aria-label="查询模式">
|
||||
<span class="query-mode-label">查询模式</span>
|
||||
<button type="button" :class="{ active: queryMode === 'quick' }" role="tab" :aria-selected="queryMode === 'quick'" @click="selectQueryMode('quick')">普通查询</button>
|
||||
<button type="button" :class="{ active: queryMode === 'advanced' }" role="tab" :aria-selected="queryMode === 'advanced'" @click="selectQueryMode('advanced')">高级查询</button>
|
||||
</div>
|
||||
<div class="query-mode-status">
|
||||
当前生效:<strong>{{ activeQueryMode === 'quick' ? '普通查询' : '高级查询' }}</strong>
|
||||
<span v-if="queryMode !== activeQueryMode" class="query-mode-draft">正在编辑{{ queryMode === 'quick' ? '普通' : '高级' }}查询</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-search">
|
||||
<div v-if="queryMode === 'advanced'" class="advanced-mode-note">高级查询条件在右侧 Drawer 中配置。普通查询条件仍会保留,但当前不会参与结果。</div>
|
||||
<template v-else>
|
||||
<template v-if="activeQueryMode === 'quick'">
|
||||
<div v-for="field in (quickSearchExpanded ? quickSearchFields : quickSearchFields.slice(0, QUICK_VISIBLE_LIMIT))" :key="field.key" class="search-field">
|
||||
<label class="search-field__label">{{ field.label }}</label>
|
||||
<Input
|
||||
@@ -587,25 +637,35 @@ function onMoreAction({ key }) {
|
||||
</div>
|
||||
</template>
|
||||
<div class="list-search__actions">
|
||||
<Button v-if="queryMode === 'quick'" type="primary" @click="onSearch">{{ t('demo.action.search') }}</Button>
|
||||
<Button v-else type="primary" @click="openQueryDrawer('conditions')">编辑高级查询</Button>
|
||||
<Button type="outline" @click="onReset">{{ t('demo.action.reset') }}</Button>
|
||||
<Button v-if="queryMode === 'quick' && extraQuickSearchFields.length" type="ghost" class="quick-more-button" @click="quickSearchExpanded = !quickSearchExpanded">
|
||||
<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="search-toggle" @click="openQueryDrawer('conditions')">
|
||||
{{ queryMode === 'advanced' ? '编辑高级查询' : '高级查询' }} <span v-if="advancedConditionCount" class="query-count">{{ advancedConditionCount }}</span>
|
||||
<ChevronDown :size="14" />
|
||||
<Button type="ghost" class="advanced-query-button" @click="selectQueryMode('advanced')">
|
||||
<SlidersHorizontal :size="14" />
|
||||
高级查询
|
||||
<span v-if="advancedConditionCount" class="query-count">{{ advancedConditionCount }}</span>
|
||||
</Button>
|
||||
</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>
|
||||
</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>
|
||||
<button class="clear-query" @click="onReset">清除全部</button>
|
||||
<button class="clear-query" @click="resetAllQueries">清除全部</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -681,21 +741,20 @@ function onMoreAction({ key }) {
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- 高级查询 Drawer:条件与查询设置使用同一入口,配置不展开时不占列表空间 -->
|
||||
<div v-if="drawerOpen" class="query-drawer-mask" @click.self="drawerOpen = false">
|
||||
<!-- 查询 Drawer:高级条件与查询设置使用独立入口,共用 Drawer 外壳 -->
|
||||
<div v-if="drawerOpen" class="query-drawer-mask" @click.self="closeQueryDrawer">
|
||||
<aside class="query-drawer">
|
||||
<div class="query-drawer__head">
|
||||
<div><strong>查询</strong><span class="query-drawer__sub">普通查询与高级查询共用同一 Query AST</span></div>
|
||||
<button class="query-drawer__close" aria-label="关闭" @click="drawerOpen = false">×</button>
|
||||
</div>
|
||||
<div class="query-drawer__tabs">
|
||||
<button :class="{ active: drawerTab === 'conditions' }" @click="drawerTab = 'conditions'">查询条件</button>
|
||||
<button :class="{ active: drawerTab === 'settings' }" @click="drawerTab = 'settings'">查询设置</button>
|
||||
<div>
|
||||
<strong>{{ drawerTab === 'conditions' ? '高级查询' : '查询设置' }}</strong>
|
||||
<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="mode-notice"><span class="mode-notice__dot"></span>当前为高级查询编辑模式。点击“应用高级查询”后,只使用本 Drawer 条件;普通查询条件会保留但暂不生效。</div>
|
||||
<div class="drawer-section-title">当前条件</div>
|
||||
<div class="ast-root"><span>满足</span><select v-model="advancedLogic"><option value="and">全部条件(AND)</option><option value="or">任一条件(OR)</option></select></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-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)">
|
||||
@@ -703,11 +762,15 @@ function onMoreAction({ key }) {
|
||||
</select>
|
||||
<span v-if="fieldByKey[node.field]?.relation" class="relation-badge">关联</span>
|
||||
</div>
|
||||
<select v-model="node.operator" class="ast-op">
|
||||
<select v-model="node.operator" class="ast-op" @change="onNodeOperatorChange(node)">
|
||||
<option v-for="operator in getNodeOperators(node)" :key="operator.value" :value="operator.value">{{ operator.label }}</option>
|
||||
</select>
|
||||
<Input v-if="fieldByKey[node.field]?.type === 'input'" v-model="node.value" class="ast-value" placeholder="请输入值" />
|
||||
<div v-else-if="fieldByKey[node.field]?.type === 'numrange'" class="ast-range-value"><Input v-model="node.value" placeholder="数值" /></div>
|
||||
<RangePicker v-else-if="fieldByKey[node.field]?.type === 'daterange'" v-model="node.value" class="ast-value" />
|
||||
<div v-else-if="fieldByKey[node.field]?.type === 'numrange'" class="ast-range-value">
|
||||
<template v-if="node.operator === 'between'"><Input v-model="node.value[0]" placeholder="最小值" /><span>至</span><Input v-model="node.value[1]" placeholder="最大值" /></template>
|
||||
<Input v-else v-model="node.value" placeholder="请输入数值" />
|
||||
</div>
|
||||
<select v-else v-model="node.value" class="ast-value">
|
||||
<option value="">请选择</option>
|
||||
<option v-for="option in getFieldOptions(node.field)" :key="option.value" :value="option.value">{{ option.label }}</option>
|
||||
@@ -715,29 +778,41 @@ function onMoreAction({ key }) {
|
||||
<button class="ast-remove" title="删除条件" @click="removeAdvancedNode(node.id)">×</button>
|
||||
</div>
|
||||
<div v-for="group in advancedGroups" :key="group.id" class="ast-group">
|
||||
<div class="ast-group__head"><span>条件组</span><select v-model="group.logic"><option value="and">全部满足(AND)</option><option value="or">任一满足(OR)</option></select><button type="button" @click="advancedGroups.splice(advancedGroups.indexOf(group), 1)">删除组</button></div>
|
||||
<div class="ast-group__head"><span>条件组</span><select v-model="group.logic"><option value="and">全部满足</option><option value="or">任一满足</option></select><button type="button" @click="advancedGroups.splice(advancedGroups.indexOf(group), 1)">删除组</button></div>
|
||||
<div v-for="node in group.nodes" :key="node.id" class="ast-row ast-row--nested">
|
||||
<div class="ast-field-wrap"><select v-model="node.field" class="ast-field" @change="onNodeFieldChange(node)"><option v-for="field in advancedSearchFields" :key="field.key" :value="field.key">{{ field.label }}</option></select><span v-if="fieldByKey[node.field]?.relation" class="relation-badge">关联</span></div>
|
||||
<select v-model="node.operator" class="ast-op"><option v-for="operator in getNodeOperators(node)" :key="operator.value" :value="operator.value">{{ operator.label }}</option></select>
|
||||
<select v-model="node.operator" class="ast-op" @change="onNodeOperatorChange(node)"><option v-for="operator in getNodeOperators(node)" :key="operator.value" :value="operator.value">{{ operator.label }}</option></select>
|
||||
<Input v-if="fieldByKey[node.field]?.type === 'input'" v-model="node.value" class="ast-value" placeholder="请输入值" />
|
||||
<RangePicker v-else-if="fieldByKey[node.field]?.type === 'daterange'" v-model="node.value" class="ast-value" />
|
||||
<div v-else-if="fieldByKey[node.field]?.type === 'numrange'" class="ast-range-value"><template v-if="node.operator === 'between'"><Input v-model="node.value[0]" placeholder="最小值" /><span>至</span><Input v-model="node.value[1]" placeholder="最大值" /></template><Input v-else v-model="node.value" placeholder="请输入数值" /></div>
|
||||
<select v-else v-model="node.value" class="ast-value"><option value="">请选择</option><option v-for="option in getFieldOptions(node.field)" :key="option.value" :value="option.value">{{ option.label }}</option></select>
|
||||
<button class="ast-remove" title="删除条件" @click="removeAdvancedNode(node.id)">×</button>
|
||||
</div>
|
||||
<Button type="ghost" @click="group.nodes.push({ id: Date.now(), field: advancedSearchFields[0]?.key || 'creator', operator: 'eq', value: '', label: '创建人', source: 'advanced' })">+ 条件</Button>
|
||||
<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>
|
||||
<details class="ast-preview"><summary>查看统一 Query AST</summary><pre>{{ queryAstPreview }}</pre></details>
|
||||
</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')" :key="field.key" class="setting-row"><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')" :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,'quick')">移到常用</button><button class="setting-more" @click="setFieldArea(field,'hidden')">隐藏</button></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" 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="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">高级查询应用后只使用高级条件,当前共 {{ advancedQueryAst.children.length }} 个条件</span><Button type="ghost" @click="drawerTab === 'settings' ? resetQueryPrefs() : onReset()">{{ drawerTab === 'settings' ? '恢复默认' : '重置条件' }}</Button><Button type="primary" @click="drawerTab === 'settings' ? (drawerOpen = false) : applyAdvancedQuery()">{{ drawerTab === 'settings' ? '保存设置' : '应用高级查询' }}</Button></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>
|
||||
<template v-else>
|
||||
<Button type="ghost" @click="resetQueryPrefs">恢复默认</Button>
|
||||
<Button type="primary" @click="drawerOpen = false">完成设置</Button>
|
||||
</template>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</template>
|
||||
@@ -784,16 +859,11 @@ function onMoreAction({ key }) {
|
||||
padding: 12px 16px 8px;
|
||||
container-type: inline-size;
|
||||
}
|
||||
.query-mode-bar { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 12px; }
|
||||
.query-mode-switch { display: flex; align-items: center; gap: 2px; }
|
||||
.query-mode-label { margin-right: 8px; color: var(--fms-text-secondary); font-size: 12px; }
|
||||
.query-mode-switch button { min-height: 30px; padding: 0 12px; border: 1px solid var(--fms-border); background: var(--fms-card); color: var(--fms-text-secondary); font-size: 12px; cursor: pointer; }
|
||||
.query-mode-switch button:first-of-type { border-radius: 4px 0 0 4px; }
|
||||
.query-mode-switch button:last-of-type { border-left: 0; border-radius: 0 4px 4px 0; }
|
||||
.query-mode-switch button.active { border-color: var(--fms-primary); background: var(--fms-primary); color: #fff; font-weight: 600; }
|
||||
.query-mode-status { color: var(--fms-text-secondary); font-size: 12px; }
|
||||
.query-mode-status strong { color: var(--fms-text); font-weight: 600; }
|
||||
.query-mode-draft { margin-left: 8px; color: #8a6414; }
|
||||
.advanced-query-button { gap: 5px; color: var(--fms-text-secondary); }
|
||||
.advanced-query-button: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 {
|
||||
@@ -880,16 +950,6 @@ function onMoreAction({ key }) {
|
||||
.quick-more-button { gap: 5px; }
|
||||
.quick-more-button svg { transition: transform 160ms ease; }
|
||||
.quick-more-button svg.is-up { transform: rotate(180deg); }
|
||||
.advanced-mode-note { grid-column: 1 / -1; padding: 7px 9px; border-radius: 4px; background: #fff8e6; color: #8a6414; font-size: 12px; }
|
||||
|
||||
.search-toggle__icon {
|
||||
transition: transform 160ms ease;
|
||||
}
|
||||
|
||||
.search-toggle__icon.is-up {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* ---- ② 工具条:左「改数据」,中 状态,右「用数据」 ---- */
|
||||
.list-toolbar {
|
||||
display: flex;
|
||||
@@ -1055,10 +1115,6 @@ function onMoreAction({ key }) {
|
||||
.query-drawer__sub { display: block; margin-top: 4px; color: var(--fms-text-secondary); font-size: 12px; }
|
||||
.query-drawer__close { width: 30px; height: 30px; border: 0; border-radius: 5px; background: transparent; color: var(--fms-text-secondary); font-size: 22px; }
|
||||
.query-drawer__close:hover { background: var(--fms-background, #f5f6f8); }
|
||||
.query-drawer__tabs { display: flex; flex: none; gap: 22px; padding: 0 20px; border-bottom: 1px solid var(--fms-border-soft, #f0f1f3); }
|
||||
.query-drawer__tabs button { position: relative; height: 42px; padding: 0; border: 0; background: transparent; color: var(--fms-text-secondary); font-size: 13px; }
|
||||
.query-drawer__tabs button.active { color: var(--fms-primary); font-weight: 600; }
|
||||
.query-drawer__tabs button.active::after { position: absolute; right: 0; bottom: -1px; left: 0; height: 2px; background: var(--fms-primary); content: ''; }
|
||||
.query-drawer__body { flex: 1; min-height: 0; overflow: auto; padding: 18px 20px; }
|
||||
.mode-notice { display: flex; align-items: flex-start; gap: 8px; margin-bottom: 16px; padding: 9px 10px; border: 1px solid #d8e5f7; border-radius: 5px; background: #f7faff; color: #526176; font-size: 12px; line-height: 1.5; }
|
||||
.mode-notice__dot { flex: none; width: 7px; height: 7px; margin-top: 5px; border-radius: 50%; background: var(--fms-primary); }
|
||||
@@ -1066,6 +1122,7 @@ 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-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__head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; color: var(--fms-text-secondary); font-size: 12px; }
|
||||
@@ -1076,15 +1133,13 @@ function onMoreAction({ key }) {
|
||||
.ast-field-wrap .ast-field { min-width: 0; flex: 1; }
|
||||
.relation-badge { flex: none; padding: 2px 5px; border-radius: 3px; background: #f3edff; color: #7048b8; font-size: 10px; }
|
||||
.ast-value { width: 100%; min-width: 0; }
|
||||
.ast-range-value { min-width: 0; }
|
||||
.ast-range-value { display: flex; min-width: 0; align-items: center; gap: 5px; }
|
||||
.ast-range-value :deep(input) { min-width: 0; flex: 1; }
|
||||
.ast-range-value :deep(input) { width: 100%; }
|
||||
.ast-remove { width: 28px; height: 28px; border: 0; border-radius: 4px; background: transparent; color: var(--fms-text-secondary); font-size: 18px; }
|
||||
.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); }
|
||||
.ast-preview { margin-top: 16px; border-top: 1px solid var(--fms-border-soft); color: var(--fms-text-secondary); font-size: 12px; }
|
||||
.ast-preview summary { padding-top: 12px; cursor: pointer; }
|
||||
.ast-preview pre { max-height: 210px; margin: 10px 0 0; overflow: auto; padding: 10px; border-radius: 4px; background: #f7f9fc; color: #526176; font: 11px/1.55 ui-monospace, SFMono-Regular, Consolas, monospace; white-space: pre-wrap; }
|
||||
.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; }
|
||||
@@ -1095,5 +1150,5 @@ function onMoreAction({ key }) {
|
||||
.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; }
|
||||
@media (max-width: 760px) { .query-drawer__body { padding-inline: 12px; }.query-drawer__head, .query-drawer__tabs, .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; } }
|
||||
@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>
|
||||
@@ -1,159 +0,0 @@
|
||||
# 字段权限与字段个性化设计
|
||||
|
||||
> 状态:设计定稿,未实现。创建于 2026-09-04。
|
||||
> 关联文档:[FMS新系统核心表结构设计.md](./FMS新系统核心表结构设计.md)(其中 s_power / s_role_power 部分)
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
系统已有全局的字段显示配置(`s_field_view` / `s_field_edit`,在模块设计器中维护,对所有人生效)。本设计解决两个新需求:
|
||||
|
||||
1. **字段权限**:管理员(高权限角色)控制某个人/某些人**能不能看到**某些字段;
|
||||
2. **字段个性化**:用户自己调整列表的**字段顺序、列宽、显示/隐藏**,且只影响自己、跨设备持久化。
|
||||
|
||||
范围约定:
|
||||
|
||||
- 个性化只作用于**列表**(查看态列表 + 可编辑列表两套配置),**表单不参与**顺序/布局个性化;
|
||||
- 单公司系统(无多租户维度),后续如需多公司再扩展;
|
||||
- 不考虑旧数据迁移难度。
|
||||
|
||||
## 2. 三层模型
|
||||
|
||||
```
|
||||
第 1 层 默认配置(模板) s_field_view / s_field_edit 模块设计器维护,管理员设定
|
||||
第 2 层 字段权限(谁能看) s_power(field型) + s_role_power 管理员按角色控制可见性
|
||||
第 3 层 个人偏好(怎么摆) s_user_field_pref(新表) 用户自己拖拽,只影响自己
|
||||
```
|
||||
|
||||
职责边界是本设计的核心原则:
|
||||
|
||||
- **权限决定"全集"**:deny 的字段对用户不存在(列表不渲染、后端不返回);
|
||||
- **偏好只决定"排列"**:顺序、宽度、个人显隐,永远在权限允许的范围内生效,无法越过权限;
|
||||
- 终端用户能控制的只有自己的布局,可见性的控制权始终在管理员手里。
|
||||
|
||||
两层独立演化:将来权限规则变化,偏好数据不需要清洗(偏好里残留无权限字段只是无效数据,不构成越权)。
|
||||
|
||||
## 3. 数据结构
|
||||
|
||||
### 3.1 新表:s_user_field_pref(个人偏好)
|
||||
|
||||
一人 × 一模块 × 一字段 × 一模式 一行。每字段一行(而非 JSON 快照),可直接复用现有通用 CRUD(DataService)。
|
||||
|
||||
```sql
|
||||
CREATE TABLE s_user_field_pref (
|
||||
b_id VARCHAR(32) PRIMARY KEY,
|
||||
b_user_id VARCHAR(32) NOT NULL,
|
||||
b_module_id VARCHAR(32) NOT NULL,
|
||||
b_field_id VARCHAR(32) NOT NULL,
|
||||
b_mode VARCHAR(10) NOT NULL, -- 'view' 查看列表 / 'edit' 可编辑列表
|
||||
b_visible TINYINT DEFAULT 1, -- 个人显隐(1 显示,0 隐藏)
|
||||
b_xh INT, -- 个人顺序号
|
||||
b_width INT, -- 个人列宽
|
||||
b_update_time DATETIME,
|
||||
UNIQUE KEY uk_user_field (b_user_id, b_module_id, b_field_id, b_mode)
|
||||
);
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- 用户**没有**保存过偏好的字段不产生行,解析时落回默认配置(`s_field_view` / `s_field_edit`);
|
||||
- 保存过布局 = 该用户该模块该 mode 存在偏好行,此后**默认布局变更对该用户不再生效**(保持用户自主摆放的稳定性),可通过"恢复默认"删除偏好行回到跟随默认;
|
||||
- "恢复默认" = 删除该用户 + 该模块 + 该 mode 的全部行。
|
||||
|
||||
### 3.2 权限层:复用已有表 + 一张用户级覆盖表
|
||||
|
||||
- `s_power`(field 型:`b_module_id + b_field_id + b_operation`[view/edit])与 `s_role_power`(`b_effect` allow/deny)沿用现有表结构,见 [FMS新系统核心表结构设计.md](./FMS新系统核心表结构设计.md);
|
||||
- 新增用户级覆盖表,用于个别用户的特例(平时应为空表,只为"开小灶"使用,不值得建角色时用它):
|
||||
|
||||
```sql
|
||||
CREATE TABLE s_user_power (
|
||||
b_id VARCHAR(32) PRIMARY KEY,
|
||||
b_user_id VARCHAR(32) NOT NULL,
|
||||
b_module_id VARCHAR(32) NOT NULL,
|
||||
b_field_id VARCHAR(32) NOT NULL,
|
||||
b_operation VARCHAR(10) NOT NULL, -- 'view' / 'edit'
|
||||
b_effect VARCHAR(10) NOT NULL, -- 'allow' / 'deny'
|
||||
UNIQUE KEY uk_user_field_op (b_user_id, b_module_id, b_field_id, b_operation)
|
||||
);
|
||||
```
|
||||
|
||||
## 4. 解析算法
|
||||
|
||||
### 4.1 字段可见性(权限链)
|
||||
|
||||
```
|
||||
可见性 = 用户级覆盖(s_user_power,有配置则生效,优先级最高)
|
||||
↓ 该用户该字段无配置
|
||||
角色合并(该用户所有角色的 s_role_power,deny 优先:任一角色 deny 即 deny)
|
||||
↓ 无任何角色配置
|
||||
默认可见(allow)
|
||||
```
|
||||
|
||||
要点:
|
||||
|
||||
- **默认全部可见,黑名单式配置**:只为敏感字段配 deny,不为普通字段配 allow,维护量最小;
|
||||
- deny 优先(而非 allow 优先):敏感字段场景下更安全。
|
||||
|
||||
### 4.2 列表最终配置(合并链)
|
||||
|
||||
对查看列表和可编辑列表分别执行(mode = view / edit):
|
||||
|
||||
```
|
||||
最终配置 = 默认配置(s_field_view / s_field_edit,含 b_canuse 过滤)
|
||||
→ 剔除权限 deny 的字段
|
||||
→ 用个人偏好覆盖 b_xh / b_width / b_visible
|
||||
→ 偏好中没有的字段,按默认配置的顺序追加补全
|
||||
```
|
||||
|
||||
有效显示 = `权限可见 && 字段可用(b_canuse=1) && 个人未隐藏`。
|
||||
|
||||
补全规则保证:新增字段、默认配置新启用的字段,即使用户保存过布局也会按默认顺序出现在列表尾部,不会"消失"。
|
||||
|
||||
### 4.3 顺序合并细节
|
||||
|
||||
偏好只覆盖出现过的字段;用户保存布局后管理员再调默认顺序,对已保存用户无效(见 3.1)。
|
||||
|
||||
## 5. 边界场景约定
|
||||
|
||||
| 场景 | 行为 |
|
||||
|---|---|
|
||||
| 字段 `b_canuse=0`(下架) | 不进入候选集,即使偏好表里有该字段的行也不显示(偏好行成无效数据,暂不清理,量大后在字段下架入口顺带删除) |
|
||||
| 新增字段 | 所有用户可见:无偏好则按默认顺序出现在尾部 |
|
||||
| 权限从 allow 变 deny | 字段对用户消失;偏好数据保留,权限恢复后用户的自定义布局自动恢复 |
|
||||
| 用户隐藏字段 vs 权限隐藏字段 | 是两回事:个人隐藏 = 有权看但暂时不显示,"栏位设置"弹窗可随时勾回;权限隐藏 = 对该用户不存在,弹窗中**不展示**无权限字段 |
|
||||
| 必填字段被用户隐藏(可编辑列表) | **待定**,见第 8 节 |
|
||||
|
||||
## 6. 交互设计
|
||||
|
||||
- 列表列头拖拽排序、拖拽调宽(能力已有:`FmsTable.vue`);
|
||||
- 拖拽结束后防抖保存(约 500ms)upsert 到 `s_user_field_pref`;
|
||||
- 菜单保留"保存/恢复栏位设置",语义从全局改为个人:"恢复"即删除偏好行;
|
||||
- "栏位设置"弹窗:排序 + 个人显隐 + 列宽统一管理;不展示无权限字段;
|
||||
- 可选:管理员入口"保存为全局默认",仍写 `s_field_view` / `s_field_edit`。
|
||||
|
||||
## 7. 改动范围(实现清单)
|
||||
|
||||
| 位置 | 改动 |
|
||||
|---|---|
|
||||
| migration | 新增 `s_user_field_pref`、`s_user_power` 建表 |
|
||||
| 后端 DataService | ① 字段权限过滤:deny 字段从查询结果中剔除(设计文档既定要求,目前后端零过滤)② 偏好表 CRUD 复用现有通用机制 |
|
||||
| 前端 `stores/permissions.js` | 新增 `canField(moduleId, fieldId, operation)`:用户级 → 角色合并(deny 优先)→ 默认 allow |
|
||||
| 前端 `FmsModuleListPage.vue` | `loadData` 中实现合并链(默认 → 权限过滤 → 偏好覆盖 → 补全);`saveColumnSettings` 保存目标从 `s_field_view/s_field_edit` 改为 `s_user_field_pref` |
|
||||
| 权限管理 UI | 模块管理 `ModuleFieldsPanel` 旁新增"字段权限"页签:选角色 × 勾字段(view/edit);入口本身由现有模块/按钮权限(`canPower`)控制,实现"高权限的人才能配" |
|
||||
| 用户级覆盖 UI | 可后置:先支持直接改 `s_user_power` 数据,界面后续补 |
|
||||
|
||||
关键实现纪律:**合并偏好的代码必须在权限过滤之后执行**,保证偏好永远无法越过权限。
|
||||
|
||||
## 8. 待定事项
|
||||
|
||||
**必填字段被用户隐藏后的校验策略**(可编辑列表场景):
|
||||
|
||||
| 方案 | 规则 | 适用 |
|
||||
|---|---|---|
|
||||
| A. 所见即所验 | 隐藏后跳过必填校验 | "必填"只是引导性要求,空值可接受;且权限隐藏与用户隐藏行为天然统一,无需特殊分支 |
|
||||
| B. 必填不可藏 | 可编辑列表中必填字段置灰、禁止隐藏 | "必填"是业务硬规则,字段空值会损害下游使用 |
|
||||
|
||||
判断标准:该字段空着,会不会有人受害。会 → B;不会 → A。权限隐藏(管理员 deny)场景下无论选哪个方案,行为都与 A 一致(用户"没有这个字段",天然不校验)。
|
||||
|
||||
---
|
||||
|
||||
**结论摘要**:默认配置(全局模板)→ 字段权限(管理员按角色控制可见性,deny 优先,默认全可见,用户级覆盖兜底)→ 个人偏好(仅列表的顺序/列宽/显隐,权限范围内生效,未设置走默认)。
|
||||
@@ -0,0 +1,877 @@
|
||||
# FMS 权限体系设计文档
|
||||
|
||||
## 1. 设计目标
|
||||
|
||||
本权限体系用于 FMS / ERP 元数据驱动架构,核心目标:
|
||||
|
||||
1. 操作权限与数据权限分离。
|
||||
2. 权限直接绑定菜单、动作、模块、字段、页面、报表等资源。
|
||||
3. Power ID 不要求人工维护,直接由资源类型、资源 ID 和操作组成。
|
||||
4. 用户直接授权,不引入角色继承。
|
||||
5. 数据权限独立于菜单,不因为用户从哪个菜单进入而改变数据范围。
|
||||
6. 用户个性化设置不能突破权限。
|
||||
7. 权限默认拒绝(Default Deny)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 权限模型总览
|
||||
|
||||
整个权限体系分为两条线:
|
||||
|
||||
```text
|
||||
┌──────────────┐
|
||||
│ 资源定义 │
|
||||
└──────┬───────┘
|
||||
│
|
||||
┌──────────┼──────────┐
|
||||
│ │ │
|
||||
菜单/动作 模块/字段 页面/报表
|
||||
│ │ │
|
||||
└──────────┼──────────┘
|
||||
↓
|
||||
s_power
|
||||
↓
|
||||
s_user_power
|
||||
↓
|
||||
用户操作权限
|
||||
|
||||
|
||||
┌──────────────┐
|
||||
│ 数据模块 │
|
||||
└──────┬───────┘
|
||||
↓
|
||||
s_data_scope
|
||||
↓
|
||||
s_user_data_scope
|
||||
↓
|
||||
数据范围
|
||||
```
|
||||
|
||||
因此:
|
||||
|
||||
- `s_power`:定义「能做什么」
|
||||
- `s_user_power`:定义「用户能做什么」
|
||||
- `s_data_scope`:定义「能看到 / 操作哪些数据」
|
||||
- `s_user_data_scope`:定义「用户在某个模块、某个操作下使用哪个数据范围」
|
||||
|
||||
---
|
||||
|
||||
## 3. Power ID 设计
|
||||
|
||||
### 3.1 核心原则
|
||||
|
||||
`b_id` 不再使用随机 ID,也不要求人工创建类似:
|
||||
|
||||
```text
|
||||
power_cw_fee_update
|
||||
power_cw_fee_audit
|
||||
power_cw_fee_amount_edit
|
||||
```
|
||||
|
||||
而是直接使用稳定、可解析的权限编码。
|
||||
|
||||
统一格式:
|
||||
|
||||
```text
|
||||
menu.<MenuID>
|
||||
action.<ActionID>
|
||||
module.<ModuleID>.<Operation>
|
||||
field.<ModuleID>.<Field>.<Operation>
|
||||
page.<PageID>
|
||||
report.<ReportID>
|
||||
```
|
||||
|
||||
### 3.2 Power ID 示例
|
||||
|
||||
**菜单权限**
|
||||
|
||||
```text
|
||||
menu.cw_fee
|
||||
```
|
||||
|
||||
表示:可以进入 / 使用费用管理菜单。
|
||||
|
||||
**动作权限**
|
||||
|
||||
```text
|
||||
action.cw_fee_audit
|
||||
```
|
||||
|
||||
表示:可以执行费用审核动作。
|
||||
|
||||
**模块权限**
|
||||
|
||||
```text
|
||||
module.cw_fee.read
|
||||
module.cw_fee.create
|
||||
module.cw_fee.update
|
||||
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
|
||||
page.cw_fee
|
||||
```
|
||||
|
||||
**报表权限**
|
||||
|
||||
```text
|
||||
report.cw_fee_summary
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Power 类型
|
||||
|
||||
| b_power_type | 用途 | ID 格式 |
|
||||
| ------------ | ------------ | --------------------------------------- |
|
||||
| menu | 菜单入口 | `menu.<MenuID>` |
|
||||
| action | 业务动作 | `action.<ActionID>` |
|
||||
| module | 数据模块操作 | `module.<ModuleID>.<Operation>` |
|
||||
| field | 字段能力 | `field.<ModuleID>.<Field>.<Operation>` |
|
||||
| page | 页面入口 | `page.<PageID>` |
|
||||
| report | 报表入口 | `report.<ReportID>` |
|
||||
|
||||
---
|
||||
|
||||
## 5. 操作权限语义
|
||||
|
||||
### 5.1 menu
|
||||
|
||||
```text
|
||||
menu.cw_fee
|
||||
```
|
||||
|
||||
用于判断用户是否拥有菜单入口权限。
|
||||
|
||||
菜单权限只解决:
|
||||
|
||||
- 用户能不能进入这个菜单。
|
||||
|
||||
不负责:
|
||||
|
||||
- 数据范围
|
||||
- 字段权限
|
||||
- 审核权限
|
||||
- 删除权限
|
||||
|
||||
### 5.2 action
|
||||
|
||||
例如:
|
||||
|
||||
```text
|
||||
action.cw_fee_audit
|
||||
action.cw_fee_unaudit
|
||||
action.cw_fee_writeoff
|
||||
```
|
||||
|
||||
统一使用:
|
||||
|
||||
- `b_power_type = action`
|
||||
- `b_operation = execute`
|
||||
|
||||
动作权限解决:用户能不能执行这个具体业务动作。
|
||||
|
||||
### 5.3 module
|
||||
|
||||
例如:
|
||||
|
||||
```text
|
||||
module.cw_fee.read
|
||||
module.cw_fee.create
|
||||
module.cw_fee.update
|
||||
module.cw_fee.delete
|
||||
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
|
||||
|
||||
例如:
|
||||
|
||||
```text
|
||||
page.cw_fee
|
||||
```
|
||||
|
||||
用于页面访问控制。
|
||||
|
||||
页面权限和菜单权限可以同时存在:
|
||||
|
||||
```text
|
||||
menu.cw_fee
|
||||
page.cw_fee
|
||||
```
|
||||
|
||||
菜单解决入口展示 / 进入菜单,页面解决具体页面访问。
|
||||
|
||||
### 5.6 report
|
||||
|
||||
例如:
|
||||
|
||||
```text
|
||||
report.cw_fee_summary
|
||||
```
|
||||
|
||||
用于报表访问权限。
|
||||
|
||||
报表权限本身不等于数据权限。例如用户拥有 `report.cw_fee_summary`,还需要根据报表对应的数据模块执行数据范围控制。
|
||||
|
||||
---
|
||||
|
||||
## 6. 核心表结构
|
||||
|
||||
以下表结构暂时不加入索引、唯一约束、外键约束,后续根据实际运行情况再补充。
|
||||
|
||||
### 6.1 s_power
|
||||
|
||||
权限定义表。
|
||||
|
||||
```sql
|
||||
create table dbo.s_power (
|
||||
b_id varchar(300) not null,
|
||||
|
||||
b_name nvarchar(200) not null,
|
||||
b_i18n varchar(150) null,
|
||||
|
||||
b_power_type varchar(20) not null,
|
||||
-- menu / action / module / field / page / report
|
||||
|
||||
b_resource varchar(128) null,
|
||||
-- action / page / report 等资源 ID
|
||||
|
||||
b_menu_id varchar(50) null,
|
||||
-- 所属菜单,主要用于 menu / action
|
||||
|
||||
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,
|
||||
|
||||
b_xh int not null default 0
|
||||
);
|
||||
```
|
||||
|
||||
`b_id` 示例:
|
||||
|
||||
```text
|
||||
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
|
||||
```
|
||||
|
||||
这里的 `b_id` 就是 Power 的业务编码。
|
||||
|
||||
不再需要 `power_cw_fee_update`、`power_cw_fee_audit` 这种额外编码。
|
||||
|
||||
---
|
||||
|
||||
## 7. s_user_power
|
||||
|
||||
用户权限授权表。
|
||||
|
||||
```sql
|
||||
create table dbo.s_user_power (
|
||||
b_user_id varchar(50) not null,
|
||||
|
||||
b_power_id varchar(300) not null,
|
||||
|
||||
b_canuse tinyint not null default 1,
|
||||
|
||||
b_updatedatetime datetime2 null
|
||||
);
|
||||
```
|
||||
|
||||
语义:
|
||||
|
||||
- 存在有效记录 = 用户拥有权限
|
||||
- 不存在记录 = 用户没有权限
|
||||
- `b_canuse = 0` = 当前授权无效
|
||||
|
||||
当前设计不增加 allow / deny,即采用:
|
||||
|
||||
**默认拒绝,明确授权。**
|
||||
|
||||
---
|
||||
|
||||
## 8. 数据权限
|
||||
|
||||
操作权限与数据权限必须分开。
|
||||
|
||||
例如:
|
||||
|
||||
```text
|
||||
module.cw_fee.read
|
||||
```
|
||||
|
||||
只表示:用户具有读取费用数据的能力。
|
||||
|
||||
它并不表示:用户可以读取所有费用数据。
|
||||
|
||||
数据范围由 `s_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
|
||||
|
||||
用户数据权限授权表。
|
||||
|
||||
```sql
|
||||
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 / create / update / delete / export
|
||||
|
||||
b_scope_id varchar(50) not null,
|
||||
|
||||
b_xh int not null default 0,
|
||||
|
||||
b_updatedatetime datetime2 null
|
||||
);
|
||||
```
|
||||
|
||||
多个 Scope 同时存在时:
|
||||
|
||||
```text
|
||||
Scope A OR Scope B OR Scope C
|
||||
```
|
||||
|
||||
例如:
|
||||
|
||||
```text
|
||||
张三
|
||||
cw_fee
|
||||
read
|
||||
├── department
|
||||
└── own
|
||||
```
|
||||
|
||||
表示张三读取费用数据时:department 范围 OR own 范围。
|
||||
|
||||
---
|
||||
|
||||
## 11. 完整示例
|
||||
|
||||
假设系统有:
|
||||
|
||||
- 模块:`cw_fee` = 费用管理
|
||||
- 菜单:`cw_fee` = 费用管理
|
||||
- 动作:`cw_fee_audit` = 审核、`cw_fee_unaudit` = 反审核、`cw_fee_writeoff` = 核销
|
||||
- 字段:`mx_amount` = 金额
|
||||
|
||||
### 11.1 自动产生 Power
|
||||
|
||||
菜单:
|
||||
|
||||
```text
|
||||
menu.cw_fee
|
||||
```
|
||||
|
||||
动作:
|
||||
|
||||
```text
|
||||
action.cw_fee_audit
|
||||
action.cw_fee_unaudit
|
||||
action.cw_fee_writeoff
|
||||
```
|
||||
|
||||
模块:
|
||||
|
||||
```text
|
||||
module.cw_fee.read
|
||||
module.cw_fee.create
|
||||
module.cw_fee.update
|
||||
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. 用户授权示例
|
||||
|
||||
张三拥有:
|
||||
|
||||
- `menu.cw_fee`
|
||||
- `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 (
|
||||
b_user_id,
|
||||
b_power_id,
|
||||
b_canuse
|
||||
)
|
||||
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);
|
||||
```
|
||||
|
||||
张三没有 `module.cw_fee.delete`、`field.cw_fee.mx_amount.edit`,所以:
|
||||
|
||||
- 不能删除费用
|
||||
- 不能修改金额字段
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
)
|
||||
values (
|
||||
'zhangsan',
|
||||
'cw_fee',
|
||||
'read',
|
||||
'scope.cw_fee.department',
|
||||
0
|
||||
);
|
||||
```
|
||||
|
||||
此时张三拥有 `module.cw_fee.read`,并且 read 数据范围 = 本部门。
|
||||
|
||||
最终不是「张三可以读取全部费用」,而是「张三可以读取自己有权限范围内的费用」。
|
||||
|
||||
---
|
||||
|
||||
## 14. 权限执行顺序
|
||||
|
||||
一个业务操作建议按照以下顺序判断:
|
||||
|
||||
```text
|
||||
① 用户是否有效
|
||||
↓
|
||||
② 菜单 / 页面入口权限
|
||||
↓
|
||||
③ Action 权限
|
||||
↓
|
||||
④ Module 数据操作权限
|
||||
↓
|
||||
⑤ Field 字段权限
|
||||
↓
|
||||
⑥ Data Scope 数据范围
|
||||
↓
|
||||
⑦ 业务状态 / 业务规则
|
||||
↓
|
||||
⑧ 执行操作
|
||||
```
|
||||
|
||||
例如「审核费用」:
|
||||
|
||||
```text
|
||||
用户
|
||||
↓
|
||||
menu.cw_fee
|
||||
↓
|
||||
page.cw_fee
|
||||
↓
|
||||
action.cw_fee_audit
|
||||
↓
|
||||
module.cw_fee.update
|
||||
↓
|
||||
数据范围
|
||||
↓
|
||||
费用当前状态 = 送审
|
||||
↓
|
||||
执行审核
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 15. 不同权限解决不同问题
|
||||
|
||||
| 问题 | 权限 |
|
||||
| -------------- | ------------------------------- |
|
||||
| 能不能看到菜单 | `menu.*` |
|
||||
| 能不能进入页面 | `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` |
|
||||
| 能不能看报表 | `report.*` |
|
||||
| 能看哪些数据 | `s_data_scope` |
|
||||
|
||||
---
|
||||
|
||||
## 16. 与元数据体系的关系
|
||||
|
||||
Power 不应该成为另一套独立的资源体系,而应该从现有元数据自动生成。
|
||||
|
||||
```text
|
||||
s_menu
|
||||
↓
|
||||
menu.<MenuID>
|
||||
|
||||
Action 定义
|
||||
↓
|
||||
action.<ActionID>
|
||||
|
||||
s_module
|
||||
↓
|
||||
module.<ModuleID>.<Operation>
|
||||
|
||||
s_field
|
||||
↓
|
||||
field.<ModuleID>.<Field>.<Operation>
|
||||
|
||||
Page 定义
|
||||
↓
|
||||
page.<PageID>
|
||||
|
||||
Report 定义
|
||||
↓
|
||||
report.<ReportID>
|
||||
```
|
||||
|
||||
因此新增模块 `cw_air_fee`,系统可以自动注册:
|
||||
|
||||
```text
|
||||
module.cw_air_fee.read
|
||||
module.cw_air_fee.create
|
||||
module.cw_air_fee.update
|
||||
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 编码。
|
||||
|
||||
---
|
||||
|
||||
## 17. 权限与用户个性化的关系
|
||||
|
||||
用户个性化配置(`s_user_field_pref`、`s_user_query_field_pref`)只能调整:
|
||||
|
||||
- 显示
|
||||
- 排序
|
||||
- 宽度
|
||||
- 查询字段默认状态
|
||||
|
||||
不能增加权限。
|
||||
|
||||
最终优先级:
|
||||
|
||||
```text
|
||||
字段 b_canuse
|
||||
↓
|
||||
权限
|
||||
↓
|
||||
系统默认布局
|
||||
↓
|
||||
用户个性化
|
||||
```
|
||||
|
||||
例如:用户没有 `field.cw_fee.mx_amount.view`,即使 `s_user_field_pref.b_visible = 1`,也不能显示金额字段。
|
||||
|
||||
---
|
||||
|
||||
## 18. 最终推荐的核心关系
|
||||
|
||||
```text
|
||||
┌───────────────┐
|
||||
│ s_module │
|
||||
└───────┬───────┘
|
||||
│
|
||||
├──────────────┐
|
||||
↓ ↓
|
||||
s_field s_data_scope
|
||||
│ │
|
||||
↓ ↓
|
||||
s_power s_user_data_scope
|
||||
│
|
||||
↓
|
||||
s_user_power
|
||||
│
|
||||
↓
|
||||
b_user
|
||||
```
|
||||
|
||||
菜单和动作:
|
||||
|
||||
```text
|
||||
s_menu
|
||||
│
|
||||
├── menu.<MenuID>
|
||||
│
|
||||
└── action.<ActionID>
|
||||
↓
|
||||
s_power
|
||||
```
|
||||
|
||||
页面和报表:
|
||||
|
||||
```text
|
||||
page / report
|
||||
↓
|
||||
s_power
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 19. 最终结论
|
||||
|
||||
本设计的核心原则可以归纳为:
|
||||
|
||||
- 资源 ID = 资源本身的 ID
|
||||
- Power ID = 资源类型 + 资源 ID + Operation
|
||||
|
||||
统一规则:
|
||||
|
||||
```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` 是用户数据范围授权。
|
||||
- 操作权限和数据权限完全分离。
|
||||
- 菜单不负责数据范围。
|
||||
- 用户个性化不能突破权限。
|
||||
- 当前不引入角色继承。
|
||||
- 后续如果增加角色,可以在 `s_user_power` 之外增加角色授权层,而无需改变 Power 编码体系。
|
||||
|
||||
---
|
||||
|
||||
## 20. Demo 数据(4 张表)
|
||||
|
||||
以下为 4 张表的示例数据,沿用正文的 `cw_fee` 费用模块。示例用户:张三(费用会计)、李四(财务经理)。
|
||||
|
||||
### 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 |
|
||||
|
||||
要点:覆盖全部 6 种 `b_power_type`;`b_id` 由类型 + 资源 + 操作自动拼出,`b_module_id` / `b_field` 等列是解析冗余,便于按模块、字段反查权限。
|
||||
|
||||
### 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 |
|
||||
|
||||
要点:
|
||||
|
||||
- 张三的 `module.cw_fee.delete` 记录存在但 `b_canuse = 0`,演示「授权被手动停用 = 无权限」,与「无记录 = 无权限」效果相同;
|
||||
- 张三没有 `field.cw_fee.mx_amount.edit`,金额字段对他只读;李四全量字段能力。
|
||||
|
||||
### 20.3 s_data_scope(数据范围定义)
|
||||
|
||||
| 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 |
|
||||
|
||||
要点:
|
||||
|
||||
- 张三 read 挂了两条范围 → 部门数据 OR 本人数据;
|
||||
- 数据范围按操作分开授权:张三能看本部门,但只能改自己录的费用(update 仅 own);
|
||||
- 李四 delete 挂 `all`,经理可删全模块数据,符合「操作越重、范围越收」或「管理者放宽」都可表达的弹性。
|
||||
|
||||
### 20.5 组合结果解读
|
||||
|
||||
| 能力 | 张三(费用会计) | 李四(财务经理) |
|
||||
| ---------------- | ---------------------------------------- | -------------------------------- |
|
||||
| 看菜单 / 页面 | ✅ | ✅ |
|
||||
| 读取费用数据 | ✅ 本部门 + 本人 | ✅ 本部门及下属部门 |
|
||||
| 新增 | ❌ 无 module.create | ✅ |
|
||||
| 修改 | ✅ 仅本人录入的单据 | ✅ 本部门及下属部门的单据 |
|
||||
| 删除 | ❌ 授权已停用(b_canuse=0) | ✅ 全部数据 |
|
||||
| 审核 / 反审核 | ✅ 可审核,❌ 不可反审核 | ✅ 都可以 |
|
||||
| 金额字段 | 可看、可查询、❌ 不可编辑、❌ 不可导出字段列 | 可看、可编辑、可导出 |
|
||||
| 汇总报表 | ✅ | ✅(报表数据仍受 read 范围约束) |
|
||||
Reference in new issue
Block a user