From 1b596ffa5b18d4dc319472c8f5b37dbc75036080 Mon Sep 17 00:00:00 2001 From: ZhangAo Date: Mon, 7 Sep 2026 17:23:56 +0800 Subject: [PATCH] 20260907172356 --- code/fms/FMS新系统核心表结构设计.md | 103 +- .../fms/fms-vue/src/views/dashboard/index.vue | 245 +++-- code/fms/字段权限与字段个性化设计.md | 159 ---- code/fms/字段权限与用户个性化设计.md | 877 ++++++++++++++++++ 4 files changed, 1123 insertions(+), 261 deletions(-) delete mode 100644 code/fms/字段权限与字段个性化设计.md create mode 100644 code/fms/字段权限与用户个性化设计.md diff --git a/code/fms/FMS新系统核心表结构设计.md b/code/fms/FMS新系统核心表结构设计.md index 6f9d6ef3..e5c20677 100644 --- a/code/fms/FMS新系统核心表结构设计.md +++ b/code/fms/FMS新系统核心表结构设计.md @@ -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 ( diff --git a/code/fms/fms-vue/src/views/dashboard/index.vue b/code/fms/fms-vue/src/views/dashboard/index.vue index 4e137407..20c19736 100644 --- a/code/fms/fms-vue/src/views/dashboard/index.vue +++ b/code/fms/fms-vue/src/views/dashboard/index.vue @@ -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 }) {
-
-
- 查询模式 - - -
-
- 当前生效:{{ activeQueryMode === 'quick' ? '普通查询' : '高级查询' }} - 正在编辑{{ queryMode === 'quick' ? '普通' : '高级' }}查询 -
-