diff --git a/src/locale/en-US.ts b/src/locale/en-US.ts index 85d3cff..8f8f895 100644 --- a/src/locale/en-US.ts +++ b/src/locale/en-US.ts @@ -176,8 +176,73 @@ export default { 'system.dataPerm.templates.field.code': 'Code', 'system.dataPerm.templates.field.description': 'Description', 'system.dataPerm.templates.field.roleCode': 'Bound role code', + 'system.dataPerm.templates.field.bindRole': 'Linked role', + 'system.dataPerm.templates.field.bindRolePlaceholder': 'Select an RBAC role', + 'system.dataPerm.templates.field.bindRoleExtra': 'Saved as roleCode — identifies which job role this template belongs to', + 'system.dataPerm.templates.section.bindApply': 'Role link & apply', + 'system.dataPerm.templates.section.applyNow': 'Apply now', + 'system.dataPerm.templates.bindApplyHint': 'Linking a role is metadata only. Use "Apply now" below to write template items into role or user permissions. Linking does not apply by itself.', + 'system.dataPerm.templates.applyToRole': 'Sync to linked role', + 'system.dataPerm.templates.applyToRoleDesc': 'Write template items into the role\'s data permissions (replaces role direct config)', + 'system.dataPerm.templates.applyToRoleBtn': 'Write to role', + 'system.dataPerm.templates.applyToUsers': 'Apply to users', + 'system.dataPerm.templates.applyToUsersDesc': 'Batch write direct user data permissions; replace or merge', + 'system.dataPerm.templates.applyToUsersBtn': 'Apply to users', + 'system.dataPerm.templates.field.targetUsers': 'Target users', + 'system.dataPerm.templates.field.targetUsersPlaceholder': 'Search and select internal users', + 'system.dataPerm.templates.field.applyMode': 'Apply mode', + 'system.dataPerm.templates.field.temporary': 'Temporary grant (users only)', + 'system.dataPerm.templates.field.effectiveTime': 'Effective from', + 'system.dataPerm.templates.field.expireTime': 'Expires at', + 'system.dataPerm.templates.mode.replace': 'Replace direct config', + 'system.dataPerm.templates.mode.merge': 'Merge with existing', + 'system.dataPerm.templates.applyNeedSave': 'Save the template before applying', + 'system.dataPerm.templates.bindRoleFirst': 'Select a linked role first', + 'system.dataPerm.templates.selectUsersFirst': 'Select target users', + 'system.dataPerm.templates.expireTimeRequired': 'Expire time is required for temporary grants', + 'system.dataPerm.templates.applySuccess': 'Applied to {count} target(s)', + 'system.dataPerm.templates.applyPartial': '{success} succeeded, {fail} failed', 'system.dataPerm.templates.roleCodePlaceholder': 'Optional, auto-apply when role is assigned', - 'system.dataPerm.templates.itemsTitle': 'Template items', + 'system.dataPerm.templates.itemsTitle': '模板明细', + 'system.dataPerm.templates.itemsHintForm': 'Select a permission type and fill the form — no JSON required', + 'system.dataPerm.templates.addExample': 'Add example preset', + 'system.dataPerm.templates.exampleAdded': 'Added example "{name}"', + 'system.dataPerm.templates.itemInvalid': 'Item #{index} is incomplete: {field}', + 'system.dataPerm.templates.example.salesStaff': 'Sales staff', + 'system.dataPerm.templates.example.salesStaffDesc': 'Personal data scope + public/internal secret levels', + 'system.dataPerm.templates.example.salesSupervisor': 'Sales supervisor', + 'system.dataPerm.templates.example.salesSupervisorDesc': 'Department scope + secret levels 1–3', + 'system.dataPerm.templates.example.branchManager': 'Branch manager', + 'system.dataPerm.templates.example.branchManagerDesc': 'Org-tree scope + secret levels 1–3', + 'system.dataPerm.templates.form.org': 'Organization', + 'system.dataPerm.templates.form.orgPlaceholder': 'Select organization', + 'system.dataPerm.templates.form.orgScope': 'Org scope', + 'system.dataPerm.templates.form.dataScope': 'Data visibility', + 'system.dataPerm.templates.form.module': 'Module', + 'system.dataPerm.templates.form.moduleAll': 'All modules (leave empty)', + 'system.dataPerm.templates.form.bizType': 'Tag type', + 'system.dataPerm.templates.form.bizTag': 'Tag value', + 'system.dataPerm.templates.form.bizTagPlaceholder': 'Select tag', + 'system.dataPerm.templates.form.secretLevels': 'Allowed secret levels', + 'system.dataPerm.templates.form.filterField': 'Field', + 'system.dataPerm.templates.form.filterValuePlaceholder': 'e.g. Paid or 1,2 (comma-separated)', + 'system.dataPerm.templates.form.addCondition': 'Add condition', + 'system.dataPerm.templates.form.orgExample': 'Example: grant East China Sales Dept including sub-orgs for regional leads.', + 'system.dataPerm.templates.form.dataScopeExample': 'Example: Personal = own records only; Department = all records in the dept.', + 'system.dataPerm.templates.form.bizExample': 'Example: restrict to Product Line A orders or customers.', + 'system.dataPerm.templates.form.secretExample': 'Example: Public + Internal means confidential data is hidden.', + 'system.dataPerm.templates.form.filterExample': 'Example: Order module + OrderStatus equals Paid.', + 'system.dataPerm.templates.form.tip.org': 'Which organizations\' data the user can access', + 'system.dataPerm.templates.form.tip.data_scope': 'How broad the visible business data is (personal → global)', + 'system.dataPerm.templates.form.tip.biz': 'Narrow data by product line, warehouse, etc.', + 'system.dataPerm.templates.form.tip.secret': 'Filter by secret level; unchecked levels are hidden', + 'system.dataPerm.templates.form.tip.filter': 'Simple filters on a module (multiple conditions use AND)', + 'system.dataPerm.templates.form.invalid.orgOrganization': 'Please select an organization', + 'system.dataPerm.templates.form.invalid.dataScope': 'Please select data scope', + 'system.dataPerm.templates.form.invalid.biz': 'Please select tag type and value', + 'system.dataPerm.templates.form.invalid.secretLevels': 'Select at least one secret level', + 'system.dataPerm.templates.form.invalid.filterResource': 'Please select a module', + 'system.dataPerm.templates.form.invalid.filterCondition': 'Add at least one filter condition', 'system.dataPerm.templates.itemJsonPlaceholder': 'JSON, e.g. {"dataScope":1}', 'system.dataPerm.templates.addItem': 'Add item row', 'system.dataPerm.templates.removeItem': 'Remove', diff --git a/src/locale/zh-CN.ts b/src/locale/zh-CN.ts index a1eb36f..e7a9c6a 100644 --- a/src/locale/zh-CN.ts +++ b/src/locale/zh-CN.ts @@ -1092,6 +1092,32 @@ export default { 'system.dataPerm.templates.field.code': '编码', 'system.dataPerm.templates.field.description': '说明', 'system.dataPerm.templates.field.roleCode': '绑定角色 Code', + 'system.dataPerm.templates.field.bindRole': '关联角色', + 'system.dataPerm.templates.field.bindRolePlaceholder': '选择要关联的 RBAC 角色', + 'system.dataPerm.templates.field.bindRoleExtra': '保存后写入 roleCode,用于标识「哪个岗位对应这份模板」', + 'system.dataPerm.templates.section.bindApply': '关联角色与套用', + 'system.dataPerm.templates.section.applyNow': '立即生效', + 'system.dataPerm.templates.bindApplyHint': '「关联角色」是模板元数据,标明岗位归属;下方「立即生效」才会把明细写入角色或用户的权限配置。两者不重复:关联不等于已套用。', + 'system.dataPerm.templates.applyToRole': '同步到关联角色', + 'system.dataPerm.templates.applyToRoleDesc': '将本模板明细写入该角色的数据权限(覆盖角色直配)', + 'system.dataPerm.templates.applyToRoleBtn': '写入角色权限', + 'system.dataPerm.templates.applyToUsers': '套用到指定用户', + 'system.dataPerm.templates.applyToUsersDesc': '批量写入用户的直配数据权限,可选择覆盖或合并', + 'system.dataPerm.templates.applyToUsersBtn': '套用到用户', + 'system.dataPerm.templates.field.targetUsers': '目标用户', + 'system.dataPerm.templates.field.targetUsersPlaceholder': '搜索并选择内部用户', + 'system.dataPerm.templates.field.applyMode': '套用模式', + 'system.dataPerm.templates.field.temporary': '临时授权(仅对用户生效)', + 'system.dataPerm.templates.field.effectiveTime': '生效时间', + 'system.dataPerm.templates.field.expireTime': '过期时间', + 'system.dataPerm.templates.mode.replace': '覆盖直配', + 'system.dataPerm.templates.mode.merge': '与现有合并', + 'system.dataPerm.templates.applyNeedSave': '请先保存模板,再进行套用操作', + 'system.dataPerm.templates.bindRoleFirst': '请先选择关联角色', + 'system.dataPerm.templates.selectUsersFirst': '请选择目标用户', + 'system.dataPerm.templates.expireTimeRequired': '临时授权需填写过期时间', + 'system.dataPerm.templates.applySuccess': '已成功套用 {count} 个目标', + 'system.dataPerm.templates.applyPartial': '成功 {success} 个,失败 {fail} 个', 'system.dataPerm.templates.roleCodePlaceholder': '可选,分配角色时自动套用', 'system.dataPerm.templates.itemsTitle': '模板明细', 'system.dataPerm.templates.itemJsonPlaceholder': 'JSON,如 {"dataScope":1}', @@ -1107,6 +1133,45 @@ export default { 'system.dataPerm.templates.tab.basic': '基本信息', 'system.dataPerm.templates.tab.items': '模板明细', 'system.dataPerm.templates.itemsHint': '每条明细对应一个维度的权限 JSON 配置', + 'system.dataPerm.templates.itemsHintForm': '选择权限类型后填写表单即可,无需手写 JSON', + 'system.dataPerm.templates.addExample': '添加示例配置', + 'system.dataPerm.templates.exampleAdded': '已添加示例「{name}」', + 'system.dataPerm.templates.itemInvalid': '第 {index} 条明细未填写完整:{field}', + 'system.dataPerm.templates.example.salesStaff': '普通销售', + 'system.dataPerm.templates.example.salesStaffDesc': '个人级数据 + 公开/内部密级', + 'system.dataPerm.templates.example.salesSupervisor': '销售主管', + 'system.dataPerm.templates.example.salesSupervisorDesc': '部门级数据 + 密级1-3', + 'system.dataPerm.templates.example.branchManager': '分公司经理', + 'system.dataPerm.templates.example.branchManagerDesc': '组织树级数据 + 密级1-3', + 'system.dataPerm.templates.form.org': '归属组织', + 'system.dataPerm.templates.form.orgPlaceholder': '选择要授权的组织', + 'system.dataPerm.templates.form.orgScope': '组织范围', + 'system.dataPerm.templates.form.dataScope': '可见数据范围', + 'system.dataPerm.templates.form.module': '适用模块', + 'system.dataPerm.templates.form.moduleAll': '全部模块(留空)', + 'system.dataPerm.templates.form.bizType': '标签类型', + 'system.dataPerm.templates.form.bizTag': '标签值', + 'system.dataPerm.templates.form.bizTagPlaceholder': '选择具体标签', + 'system.dataPerm.templates.form.secretLevels': '可访问密级', + 'system.dataPerm.templates.form.filterField': '字段', + 'system.dataPerm.templates.form.filterValuePlaceholder': '如:Paid 或 1,2(多值用逗号)', + 'system.dataPerm.templates.form.addCondition': '添加条件', + 'system.dataPerm.templates.form.orgExample': '示例:授权「华东销售部」且包含下级组织,适用于区域负责人。', + 'system.dataPerm.templates.form.dataScopeExample': '示例:选「个人级」表示只能看自己创建的数据;选「部门级」可看本部门所有人数据。', + 'system.dataPerm.templates.form.bizExample': '示例:限制只能访问「产品线 A」相关订单或客户。', + 'system.dataPerm.templates.form.secretExample': '示例:勾选「公开+内部」表示不能查看机密及以上级别的数据。', + 'system.dataPerm.templates.form.filterExample': '示例:订单模块 + 字段 OrderStatus 等于 Paid,表示只看已支付订单。', + 'system.dataPerm.templates.form.tip.org': '指定用户可访问哪些组织的数据', + 'system.dataPerm.templates.form.tip.data_scope': '控制能看到多大范围的业务数据(个人/小组/部门/组织/全局)', + 'system.dataPerm.templates.form.tip.biz': '按产品线、仓库等业务标签进一步收窄数据', + 'system.dataPerm.templates.form.tip.secret': '按数据密级过滤,未勾选的密级不可见', + 'system.dataPerm.templates.form.tip.filter': '对指定模块追加简单过滤条件(多条条件之间为「且」)', + 'system.dataPerm.templates.form.invalid.orgOrganization': '请选择组织', + 'system.dataPerm.templates.form.invalid.dataScope': '请选择数据范围', + 'system.dataPerm.templates.form.invalid.biz': '请选择业务标签类型和具体标签', + 'system.dataPerm.templates.form.invalid.secretLevels': '请至少勾选一个密级', + 'system.dataPerm.templates.form.invalid.filterResource': '请选择适用模块', + 'system.dataPerm.templates.form.invalid.filterCondition': '请至少填写一条过滤条件', 'system.dataPerm.templates.itemsEmpty': '暂无明细,点击下方按钮添加', 'system.dataPerm.templates.jsonPreset': 'JSON 预设', 'system.dataPerm.templates.dimensionPlaceholder': '选择维度', diff --git a/src/views/system/dataPerm/components/TemplateItemForm.vue b/src/views/system/dataPerm/components/TemplateItemForm.vue new file mode 100644 index 0000000..27b31e2 --- /dev/null +++ b/src/views/system/dataPerm/components/TemplateItemForm.vue @@ -0,0 +1,271 @@ + + + + + diff --git a/src/views/system/dataPerm/templates/index.vue b/src/views/system/dataPerm/templates/index.vue index 47b510e..d7b7e52 100644 --- a/src/views/system/dataPerm/templates/index.vue +++ b/src/views/system/dataPerm/templates/index.vue @@ -50,111 +50,211 @@ -
-

{{ t('system.dataPerm.templates.tab.basic') }}

- - - - - - - - - - - - - - - - - - - - +
+
+

{{ t('system.dataPerm.templates.tab.basic') }}

+ + + + + + + + + + + + + + + + + +
+ +
+

{{ t('system.dataPerm.templates.section.bindApply') }}

+ + {{ t('system.dataPerm.templates.bindApplyHint') }} + + + + + + + + + + + {{ t('system.dataPerm.templates.applyNeedSave') }} + +
- {{ t('system.dataPerm.templates.itemsHint') }} - - - {{ t('system.dataPerm.templates.addItem') }} - + {{ t('system.dataPerm.templates.itemsHintForm') }} + + + + {{ t('system.dataPerm.templates.addExample') }} + + + + + + + {{ t('system.dataPerm.templates.addItem') }} + +

{{ t('system.dataPerm.templates.itemsEmpty') }}

- {{ t('system.dataPerm.templates.addItem') }} + + {{ t('system.dataPerm.templates.addItem') }} + + {{ t('system.dataPerm.templates.example.salesStaff') }} + +
-
+
#{{ idx + 1 }} - - {{ dimensionLabel(item.dimensionType) }} + + + {{ dimensionLabel(item.form.dimensionType) }}
- - - {{ t('system.dataPerm.templates.jsonPreset') }} - - - - {{ t('system.dataPerm.templates.removeItem') }} - - + + {{ t('system.dataPerm.templates.removeItem') }} +
- -
- -
@@ -162,7 +262,7 @@ import { computed, onMounted, reactive, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' import { Message, Modal } from '@arco-design/web-vue' -import { IconFile, IconPlus } from '@arco-design/web-vue/es/icon' +import { IconFile, IconPlus, IconDown } from '@arco-design/web-vue/es/icon' import CustomLeftMenu from '@/components/CustomLeftMenu/index.vue' import RbacDetailShell from '@/components/RbacSplit/RbacDetailShell.vue' import type { RbacDetailTag } from '@/components/RbacSplit/RbacDetailShell.vue' @@ -170,17 +270,42 @@ import { hasPermission } from '@/utils/rbac-permission' import { handleApiError } from '@/utils/rbac-error-handler' import { buildFlatLeftMenuTree, findLeftMenuItemByKey } from '@/utils/rbacLeftMenuTree' import { + applyTemplate, createTemplate, deleteTemplate, + getDataPermMeta, getTemplateDetail, getTemplateList, updateTemplate, + type DataPermMetaDto, type DataPermTemplateListItem, } from '@/api/rbac/data-perm' -import ApplyTemplateModal from '../components/ApplyTemplateModal.vue' +import { OrganizationService, RoleService, getInternalUserList } from '@/api/rbac' +import type { OrganizationTreeDto } from '@/api/rbac' +import { + DEFAULT_DATA_SCOPES, + DEFAULT_ORG_SCOPES, + DEFAULT_SECRET_LEVELS, + DEFAULT_BIZ_TYPES, +} from '../constants' +import TemplateItemForm from '../components/TemplateItemForm.vue' +import { + buildExamplePresets, + createDefaultItemForm, + formToItemContent, + parseContentToForm, + validateItemForm, + type TemplateDimensionType, + type TemplateItemFormModel, +} from './templateItemContent' const { t } = useI18n() +interface TemplateItemRow { + _key: string + form: TemplateItemFormModel +} + const loading = ref(false) const detailLoading = ref(false) const formLoading = ref(false) @@ -190,11 +315,36 @@ const selectedTemplate = ref(null) const isCreating = ref(false) const expandedKeys = ref(['category-templates']) const activeTab = ref('basic') +const orgTree = ref<{ key: string; title: string; value: string; children?: unknown[] }[]>([]) const pagination = reactive({ current: 1, pageSize: 100, total: 0 }) -const applyVisible = ref(false) -const applyTemplateId = ref('') +const rolesLoading = ref(false) +const roleOptions = ref<{ value: string; label: string; code: string }[]>([]) +const boundRoleId = ref() +const applyLoading = ref(false) +const roleApplyLoading = ref(false) +const usersLoading = ref(false) +const userOptions = ref<{ value: string; label: string }[]>([]) + +const applyForm = reactive({ + targetUserIds: [] as string[], + mode: 'replace' as 'replace' | 'merge', + temporaryEnabled: false, + effectiveTime: '', + expireTime: '', +}) + +const canApply = computed(() => !isCreating.value && !!selectedTemplate.value?.id) + +const meta = reactive({ + orgScopes: DEFAULT_ORG_SCOPES, + dataScopes: DEFAULT_DATA_SCOPES, + secretLevels: DEFAULT_SECRET_LEVELS, + bizTypes: DEFAULT_BIZ_TYPES, + dataResources: [{ code: 'Order', name: '订单', filterableFields: ['CreateTime', 'OrderStatus', 'IsPaid'] }], + filterOperators: [], +}) const dimensionOptions = computed(() => [ { value: 'org', label: t('system.dataPerm.panel.tab.org') }, @@ -204,12 +354,20 @@ const dimensionOptions = computed(() => [ { value: 'filter', label: t('system.dataPerm.panel.tab.filter') }, ]) -const jsonPresets = [ - { key: 'dataScopeSelf', label: 'dataScope: 仅本人', json: '{"dataScope":1}' }, - { key: 'dataScopeDept', label: 'dataScope: 本部门', json: '{"dataScope":2}' }, - { key: 'dataScopeAll', label: 'dataScope: 全部', json: '{"dataScope":99}' }, - { key: 'orgScope', label: '组织范围', json: '{"orgScope":1,"organizationIds":[]}' }, -] +const examplePresets = computed(() => buildExamplePresets(t)) + +const orgScopeOptions = computed(() => + meta.orgScopes.map((o) => ({ value: Number(o.value), label: String(o.label) })) +) +const dataScopeOptions = computed(() => + meta.dataScopes.map((o) => ({ value: Number(o.value), label: String(o.label) })) +) +const secretLevelOptions = computed(() => + meta.secretLevels.map((o) => ({ value: Number(o.value), label: String(o.label) })) +) +const bizTypeOptions = computed(() => + meta.bizTypes.map((o) => ({ value: String(o.value), label: String(o.label) })) +) const form = reactive({ name: '', @@ -217,7 +375,7 @@ const form = reactive({ description: '', roleCode: '', isBuiltIn: false, - items: [] as { dimensionType: string; itemContentJson: string; sortOrder: number }[], + items: [] as TemplateItemRow[], }) const isEditing = computed(() => isCreating.value || !!selectedTemplate.value) @@ -245,7 +403,7 @@ const templateStats = computed(() => [ { label: t('system.dataPerm.templates.stat.items'), value: form.items.length }, { label: t('system.dataPerm.templates.stat.dimensions'), - value: new Set(form.items.map((i) => i.dimensionType)).size, + value: new Set(form.items.map((i) => i.form.dimensionType)).size, }, ]) @@ -276,6 +434,13 @@ const selectedKeys = computed(() => { return selectedTemplate.value?.id ? [`item-${selectedTemplate.value.id}`] : [] }) +function newItemRow(formModel?: TemplateItemFormModel): TemplateItemRow { + return { + _key: `item-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, + form: formModel || createDefaultItemForm('data_scope', 1), + } +} + function dimensionLabel(type: string) { return dimensionOptions.value.find((o) => o.value === type)?.label || type } @@ -291,6 +456,30 @@ function dimensionColor(type: string) { return map[type] || 'gray' } +function convertOrgTree(data: OrganizationTreeDto[]): typeof orgTree.value { + return data.map((item) => ({ + key: item.id!, + title: item.name!, + value: item.id!, + children: item.children?.length ? convertOrgTree(item.children) : undefined, + })) +} + +async function loadMeta() { + try { + const m = await getDataPermMeta() + Object.assign(meta, m) + } catch { + /* 使用 constants 兜底 */ + } + try { + const tree = await OrganizationService.getRbacOrganizationGetTree() + orgTree.value = convertOrgTree(tree || []) + } catch { + orgTree.value = [] + } +} + function resetForm() { Object.assign(form, { name: '', @@ -298,23 +487,162 @@ function resetForm() { description: '', roleCode: '', isBuiltIn: false, - items: [{ dimensionType: 'data_scope', itemContentJson: '{"dataScope":1}', sortOrder: 1 }], + items: [newItemRow(createDefaultItemForm('data_scope', 1))], }) + boundRoleId.value = undefined + Object.assign(applyForm, { + targetUserIds: [], + mode: 'replace', + temporaryEnabled: false, + effectiveTime: '', + expireTime: '', + }) +} + +async function loadRoles() { + rolesLoading.value = true + try { + const res = await RoleService.getRbacRoleGetList(undefined, 1, 100) + roleOptions.value = (res?.items || []) + .filter((r) => { + const code = r.code || '' + return code !== 'Customer' && !code.startsWith('CustomerSub_') + }) + .map((r) => ({ + value: r.id!, + label: `${r.name}(${r.code})`, + code: r.code || '', + })) + syncBoundRoleFromCode() + } catch { + roleOptions.value = [] + } finally { + rolesLoading.value = false + } +} + +function syncBoundRoleFromCode() { + if (!form.roleCode) { + boundRoleId.value = undefined + return + } + boundRoleId.value = roleOptions.value.find((r) => r.code === form.roleCode)?.value +} + +function onBoundRoleChange(roleId: string | undefined) { + const role = roleOptions.value.find((r) => r.value === roleId) + form.roleCode = role?.code || '' +} + +async function searchUsers(keyword?: string) { + usersLoading.value = true + try { + const res = await getInternalUserList({ keyword, page: 1, pageSize: 50 }) + userOptions.value = (res?.items || []).map((u) => ({ + value: u.id!, + label: `${u.name || u.account}(${u.account})`, + })) + } finally { + usersLoading.value = false + } +} + +function getTemplateIdForApply(): string | null { + if (isCreating.value || !selectedTemplate.value?.id) { + Message.warning(t('system.dataPerm.templates.applyNeedSave')) + return null + } + return selectedTemplate.value.id +} + +async function runApply( + targetType: 'user' | 'role', + targetIds: string[], + mode?: 'replace' | 'merge' +) { + const templateId = getTemplateIdForApply() + if (!templateId) return null + + const res = await applyTemplate({ + templateId, + targetType, + targetIds, + mode: mode ?? applyForm.mode, + temporary: applyForm.temporaryEnabled + ? { + enabled: true, + effectiveTime: applyForm.effectiveTime || undefined, + expireTime: applyForm.expireTime || undefined, + } + : undefined, + }) + + if (res.failures?.length) { + Message.warning( + t('system.dataPerm.templates.applyPartial', { + success: res.successCount, + fail: res.failures.length, + }) + ) + } else { + Message.success( + t('system.dataPerm.templates.applySuccess', { count: res.successCount }) + ) + } + return res +} + +async function applyToBoundRole() { + if (!boundRoleId.value) { + Message.warning(t('system.dataPerm.templates.bindRoleFirst')) + return + } + roleApplyLoading.value = true + try { + await runApply('role', [boundRoleId.value], 'replace') + } catch (e) { + handleApiError(e) + } finally { + roleApplyLoading.value = false + } +} + +async function applyToUsers() { + if (!applyForm.targetUserIds.length) { + Message.warning(t('system.dataPerm.templates.selectUsersFirst')) + return + } + if (applyForm.temporaryEnabled && !applyForm.expireTime) { + Message.warning(t('system.dataPerm.templates.expireTimeRequired')) + return + } + applyLoading.value = true + try { + await runApply('user', applyForm.targetUserIds) + } catch (e) { + handleApiError(e) + } finally { + applyLoading.value = false + } } function addItem() { - form.items.push({ - dimensionType: 'data_scope', - itemContentJson: '{"dataScope":1}', - sortOrder: form.items.length + 1, - }) + form.items.push(newItemRow(createDefaultItemForm('data_scope', form.items.length + 1))) } -function applyJsonPreset(idx: number, presetKey: string) { - const preset = jsonPresets.find((p) => p.key === presetKey) - if (preset && form.items[idx]) { - form.items[idx].itemContentJson = preset.json - } +function onDimensionChange(row: TemplateItemRow, dimensionType: string) { + const sortOrder = row.form.sortOrder + row.form = createDefaultItemForm(dimensionType as TemplateDimensionType, sortOrder) +} + +function applyExamplePreset(key: string) { + const preset = examplePresets.value.find((p) => p.key === key) + if (!preset) return + const start = form.items.length + preset.items.forEach((itemForm, i) => { + form.items.push(newItemRow({ ...itemForm, sortOrder: start + i + 1 })) + }) + Message.success(t('system.dataPerm.templates.exampleAdded', { name: t(preset.labelKey) })) } function selectTemplateFromKey(key: string) { @@ -369,12 +697,11 @@ async function loadDetail(id: string) { description: detail.description || '', roleCode: detail.roleCode || '', isBuiltIn: detail.isBuiltIn, - items: (detail.items || []).map((it, i) => ({ - dimensionType: it.dimensionType, - itemContentJson: JSON.stringify(it.itemContent, null, 2), - sortOrder: it.sortOrder ?? i + 1, - })), + items: (detail.items || []).map((it, i) => + newItemRow(parseContentToForm(it.dimensionType, it.itemContent, it.sortOrder ?? i + 1)) + ), }) + syncBoundRoleFromCode() } catch (e) { handleApiError(e) } finally { @@ -394,22 +721,31 @@ function openCreate() { resetForm() } -function openApply() { - if (!selectedTemplate.value?.id) return - applyTemplateId.value = selectedTemplate.value.id - applyVisible.value = true -} - async function handleSave() { if (!form.name?.trim() || !form.code?.trim()) { Message.warning(t('system.dataPerm.templates.nameCodeRequired')) return } + + for (let i = 0; i < form.items.length; i++) { + const errKey = validateItemForm(form.items[i].form) + if (errKey) { + Message.warning( + t('system.dataPerm.templates.itemInvalid', { + index: i + 1, + field: t(`system.dataPerm.templates.form.invalid.${errKey}`), + }) + ) + activeTab.value = 'items' + return + } + } + formLoading.value = true try { - const items = form.items.map((it, i) => ({ - dimensionType: it.dimensionType, - itemContent: JSON.parse(it.itemContentJson || '{}'), + const items = form.items.map((row, i) => ({ + dimensionType: row.form.dimensionType, + itemContent: formToItemContent({ ...row.form, sortOrder: i + 1 }), sortOrder: i + 1, })) const body = { @@ -432,11 +768,7 @@ async function handleSave() { Message.success(t('system.dataPerm.templates.updateSuccess')) await loadData() } - } catch (e: any) { - if (e instanceof SyntaxError) { - Message.error(t('system.dataPerm.templates.invalidJson')) - return - } + } catch (e) { handleApiError(e) } finally { formLoading.value = false @@ -476,7 +808,12 @@ watch( } ) -onMounted(loadData) +onMounted(async () => { + await loadMeta() + await loadRoles() + await loadData() + searchUsers() +}) diff --git a/src/views/system/dataPerm/templates/templateItemContent.ts b/src/views/system/dataPerm/templates/templateItemContent.ts new file mode 100644 index 0000000..aad09c9 --- /dev/null +++ b/src/views/system/dataPerm/templates/templateItemContent.ts @@ -0,0 +1,247 @@ +/** 模板明细:表单模型 ↔ 后端 ItemContent 转换 */ + +export type TemplateDimensionType = 'org' | 'data_scope' | 'biz' | 'secret' | 'filter' + +export interface FilterConditionForm { + field: string + op: string + value: string +} + +export interface TemplateItemFormModel { + dimensionType: TemplateDimensionType + sortOrder: number + /** 组织 */ + orgOrganizationId?: string + orgScope?: number + /** 数据范围 */ + dataScope?: number + dataResourceCode?: string | null + /** 业务标签 */ + bizType?: string + bizId?: string + bizName?: string + /** 密级(可多选) */ + secretLevels?: number[] + /** 自定义过滤 */ + filterResourceCode?: string + filterConditions?: FilterConditionForm[] +} + +export function createDefaultItemForm( + dimensionType: TemplateDimensionType = 'data_scope', + sortOrder = 1 +): TemplateItemFormModel { + const base: TemplateItemFormModel = { dimensionType, sortOrder } + switch (dimensionType) { + case 'org': + return { ...base, orgOrganizationId: '', orgScope: 2 } + case 'data_scope': + return { ...base, dataScope: 1, dataResourceCode: null } + case 'biz': + return { ...base, bizType: 'product_line', bizId: '', bizName: '' } + case 'secret': + return { ...base, secretLevels: [1, 2], dataResourceCode: null } + case 'filter': + return { + ...base, + filterResourceCode: 'Order', + filterConditions: [{ field: 'OrderStatus', op: 'eq', value: '' }], + } + default: + return base + } +} + +export function parseContentToForm( + dimensionType: string, + content: Record | null | undefined, + sortOrder: number +): TemplateItemFormModel { + const dim = (dimensionType || 'data_scope') as TemplateDimensionType + const defaults = createDefaultItemForm(dim, sortOrder) + if (!content || typeof content !== 'object') return defaults + + switch (dim) { + case 'org': + return { + ...defaults, + orgOrganizationId: str(content.organizationId), + orgScope: num(content.orgScope, defaults.orgScope!), + } + case 'data_scope': + return { + ...defaults, + dataScope: num(content.dataScope, defaults.dataScope!), + dataResourceCode: optStr(content.dataResourceCode), + } + case 'biz': + return { + ...defaults, + bizType: str(content.bizType) || defaults.bizType, + bizId: str(content.bizId), + bizName: optStr(content.bizName) || '', + } + case 'secret': { + const levels = parseSecretLevels(content) + return { + ...defaults, + secretLevels: levels.length ? levels : defaults.secretLevels, + dataResourceCode: optStr(content.dataResourceCode), + } + } + case 'filter': { + const fc = content.filterContent as { conditions?: FilterConditionForm[] } | undefined + const conditions = (fc?.conditions || []).map((c) => ({ + field: str(c.field), + op: str(c.op) || 'eq', + value: c.value != null ? String(c.value) : '', + })) + return { + ...defaults, + filterResourceCode: str(content.dataResourceCode) || defaults.filterResourceCode, + filterConditions: conditions.length ? conditions : defaults.filterConditions, + } + } + default: + return defaults + } +} + +export function formToItemContent(model: TemplateItemFormModel): Record { + switch (model.dimensionType) { + case 'org': + return { + organizationId: model.orgOrganizationId || '', + orgScope: model.orgScope ?? 2, + } + case 'data_scope': + return { + dataScope: model.dataScope ?? 1, + dataResourceCode: model.dataResourceCode || null, + } + case 'biz': + return { + bizType: model.bizType || '', + bizId: model.bizId || '', + bizName: model.bizName || undefined, + } + case 'secret': + return { + secretLevels: model.secretLevels?.length ? model.secretLevels : [1], + dataResourceCode: model.dataResourceCode || null, + } + case 'filter': + return { + dataResourceCode: model.filterResourceCode || 'Order', + filterContent: { + logic: 'and', + conditions: (model.filterConditions || []) + .filter((c) => c.field) + .map((c) => ({ + field: c.field, + op: c.op || 'eq', + value: parseFilterValue(c.op, c.value), + })), + }, + } + default: + return {} + } +} + +export function validateItemForm(model: TemplateItemFormModel): string | null { + switch (model.dimensionType) { + case 'org': + if (!model.orgOrganizationId) return 'orgOrganization' + return null + case 'data_scope': + if (!model.dataScope) return 'dataScope' + return null + case 'biz': + if (!model.bizType || !model.bizId) return 'biz' + return null + case 'secret': + if (!model.secretLevels?.length) return 'secretLevels' + return null + case 'filter': + if (!model.filterResourceCode) return 'filterResource' + if (!model.filterConditions?.some((c) => c.field)) return 'filterCondition' + return null + default: + return null + } +} + +function parseSecretLevels(content: Record): number[] { + if (Array.isArray(content.secretLevels)) { + return content.secretLevels.map((x) => Number(x)).filter((x) => !Number.isNaN(x)) + } + if (content.secretLevel != null) { + const n = Number(content.secretLevel) + return Number.isNaN(n) ? [] : [n] + } + return [] +} + +function parseFilterValue(op: string, raw: string): unknown { + if (!raw) return raw + if (op === 'in' || op === 'notIn' || op === 'between') { + return raw.split(',').map((s) => s.trim()) + } + return raw +} + +function str(v: unknown): string { + return v != null ? String(v).trim() : '' +} + +function optStr(v: unknown): string | null { + const s = str(v) + return s || null +} + +function num(v: unknown, fallback: number): number { + const n = Number(v) + return Number.isNaN(n) ? fallback : n +} + +/** 一键添加的示例模板 */ +export interface TemplateExamplePreset { + key: string + labelKey: string + descKey: string + items: TemplateItemFormModel[] +} + +export function buildExamplePresets(_t?: (key: string) => string): TemplateExamplePreset[] { + return [ + { + key: 'salesStaff', + labelKey: 'system.dataPerm.templates.example.salesStaff', + descKey: 'system.dataPerm.templates.example.salesStaffDesc', + items: [ + createDefaultItemForm('data_scope', 1), + { ...createDefaultItemForm('secret', 2), secretLevels: [1, 2] }, + ], + }, + { + key: 'salesSupervisor', + labelKey: 'system.dataPerm.templates.example.salesSupervisor', + descKey: 'system.dataPerm.templates.example.salesSupervisorDesc', + items: [ + { ...createDefaultItemForm('data_scope', 1), dataScope: 3 }, + { ...createDefaultItemForm('secret', 2), secretLevels: [1, 2, 3] }, + ], + }, + { + key: 'branchManager', + labelKey: 'system.dataPerm.templates.example.branchManager', + descKey: 'system.dataPerm.templates.example.branchManagerDesc', + items: [ + { ...createDefaultItemForm('data_scope', 1), dataScope: 4 }, + { ...createDefaultItemForm('secret', 2), secretLevels: [1, 2, 3] }, + ], + }, + ] +}