19 changed files with 771 additions and 73 deletions
@ -0,0 +1,32 @@ |
|||
<!-- components/FeatureGroup.vue --> |
|||
<script setup lang="ts"> |
|||
import type { FeatureDto, FeatureGroupDto } from '../../types/features'; |
|||
import type { TreeNode } from './tree'; |
|||
|
|||
import { Card } from 'ant-design-vue'; |
|||
|
|||
import FeatureTreeNode from './FeatureTreeNode.vue'; |
|||
|
|||
defineProps<{ |
|||
baseIndentSize?: number; |
|||
group: FeatureGroupDto & { _treeRoots?: TreeNode[] }; |
|||
groupIndex: number; |
|||
}>(); |
|||
|
|||
const emit = defineEmits<{ |
|||
(event: 'change', feature: FeatureDto, groupIndex: number): void; |
|||
}>(); |
|||
</script> |
|||
|
|||
<template> |
|||
<Card :bordered="false" :title="group.displayName"> |
|||
<div v-for="root in group._treeRoots" :key="root.feature.name"> |
|||
<FeatureTreeNode |
|||
:node="root" |
|||
:group-index="groupIndex" |
|||
:base-indent-size="baseIndentSize" |
|||
@change="(feature, idx) => emit('change', feature, idx)" |
|||
/> |
|||
</div> |
|||
</Card> |
|||
</template> |
|||
@ -0,0 +1,133 @@ |
|||
<script setup lang="ts"> |
|||
import type { Validator } from '@abp/core'; |
|||
|
|||
import type { FeatureDto } from '../../types/features'; |
|||
|
|||
import { computed } from 'vue'; |
|||
|
|||
import { useValidation } from '@abp/core'; |
|||
import { Checkbox, Form, Input, InputNumber, Select } from 'ant-design-vue'; |
|||
|
|||
const props = defineProps<{ |
|||
feature: FeatureDto; |
|||
featureIndex: number; |
|||
groupIndex: number; |
|||
}>(); |
|||
|
|||
const emit = defineEmits<{ |
|||
(event: 'change', feature: FeatureDto, groupIndex: number): void; |
|||
}>(); |
|||
|
|||
const FormItem = Form.Item; |
|||
|
|||
const { |
|||
fieldMustBeetWeen, |
|||
fieldMustBeStringWithMinimumLengthAndMaximumLength, |
|||
fieldRequired, |
|||
} = useValidation(); |
|||
|
|||
function createRules(field: string, validator: Validator) { |
|||
const featureRules: any[] = []; |
|||
if (validator?.properties) { |
|||
switch (validator.name) { |
|||
case 'NUMERIC': { |
|||
featureRules.push( |
|||
...fieldMustBeetWeen({ |
|||
end: Number(validator.properties.MaxValue), |
|||
name: field, |
|||
start: Number(validator.properties.MinValue), |
|||
trigger: 'change', |
|||
}), |
|||
); |
|||
break; |
|||
} |
|||
case 'STRING': { |
|||
if (validator.properties.AllowNull?.toLowerCase() === 'true') { |
|||
featureRules.push( |
|||
...fieldRequired({ |
|||
name: field, |
|||
trigger: 'blur', |
|||
}), |
|||
); |
|||
} |
|||
featureRules.push( |
|||
...fieldMustBeStringWithMinimumLengthAndMaximumLength({ |
|||
maximum: Number(validator.properties.MaxLength), |
|||
minimum: Number(validator.properties.MinLength), |
|||
name: field, |
|||
trigger: 'blur', |
|||
}), |
|||
); |
|||
break; |
|||
} |
|||
} |
|||
} |
|||
return featureRules; |
|||
} |
|||
|
|||
function handleChange() { |
|||
emit('change', props.feature, props.groupIndex); |
|||
} |
|||
|
|||
const isCheckbox = computed( |
|||
() => |
|||
props.feature.valueType?.name === 'ToggleStringValueType' && |
|||
props.feature.valueType?.validator?.name === 'BOOLEAN', |
|||
); |
|||
|
|||
const isFreeText = computed( |
|||
() => props.feature.valueType?.name === 'FreeTextStringValueType', |
|||
); |
|||
|
|||
const isNumeric = computed( |
|||
() => props.feature.valueType?.validator?.name === 'NUMERIC', |
|||
); |
|||
|
|||
const isSelection = computed( |
|||
() => props.feature.valueType?.name === 'SelectionStringValueType', |
|||
); |
|||
</script> |
|||
|
|||
<template> |
|||
<FormItem |
|||
v-if="feature.valueType !== null" |
|||
:name="['groups', groupIndex, 'features', featureIndex, 'value']" |
|||
:label="feature.displayName" |
|||
:extra="feature.description" |
|||
:rules="createRules(feature.displayName, feature.valueType.validator)" |
|||
> |
|||
<!-- 复选框类型 --> |
|||
<Checkbox |
|||
v-if="isCheckbox" |
|||
v-model:checked="feature.value" |
|||
@change="handleChange" |
|||
> |
|||
{{ feature.displayName }} |
|||
</Checkbox> |
|||
|
|||
<!-- 文本/数字类型 --> |
|||
<div v-else-if="isFreeText"> |
|||
<InputNumber |
|||
v-if="isNumeric" |
|||
style="width: 100%" |
|||
v-model:value="feature.value" |
|||
@change="handleChange" |
|||
/> |
|||
<Input |
|||
v-else |
|||
v-model:value="feature.value" |
|||
autocomplete="off" |
|||
@change="handleChange" |
|||
/> |
|||
</div> |
|||
|
|||
<!-- 选择类型 --> |
|||
<Select |
|||
v-else-if="isSelection" |
|||
v-model:value="feature.value" |
|||
:options="feature.valueType.itemSource?.items" |
|||
:field-names="{ label: 'displayName', value: 'value' }" |
|||
@change="handleChange" |
|||
/> |
|||
</FormItem> |
|||
</template> |
|||
@ -0,0 +1,47 @@ |
|||
<!-- components/FeatureTreeNode.vue --> |
|||
<script setup lang="ts"> |
|||
import type { FeatureDto } from '../../types/features'; |
|||
import type { TreeNode } from './tree'; |
|||
|
|||
import { computed } from 'vue'; |
|||
|
|||
import FeatureInput from './FeatureInput.vue'; |
|||
import { getIndentStyle } from './useFeatureTree'; |
|||
|
|||
const props = defineProps<{ |
|||
baseIndentSize?: number; |
|||
groupIndex: number; |
|||
node: TreeNode; |
|||
}>(); |
|||
|
|||
const emit = defineEmits<{ |
|||
(event: 'change', feature: FeatureDto, groupIndex: number): void; |
|||
}>(); |
|||
|
|||
const indentSize = computed(() => props.baseIndentSize || 8); |
|||
const indentStyle = computed(() => |
|||
getIndentStyle(props.node.level, indentSize.value), |
|||
); |
|||
</script> |
|||
|
|||
<template> |
|||
<div v-if="node.visible"> |
|||
<div :style="{ marginLeft: indentStyle }"> |
|||
<FeatureInput |
|||
:feature="node.feature" |
|||
:group-index="groupIndex" |
|||
:feature-index="0" |
|||
@change="(feature, idx) => emit('change', feature, idx)" |
|||
/> |
|||
</div> |
|||
|
|||
<template v-for="child in node.children" :key="child.feature.name"> |
|||
<FeatureTreeNode |
|||
:node="child" |
|||
:group-index="groupIndex" |
|||
:base-indent-size="baseIndentSize" |
|||
@change="(feature, idx) => emit('change', feature, idx)" |
|||
/> |
|||
</template> |
|||
</div> |
|||
</template> |
|||
@ -0,0 +1,188 @@ |
|||
<script setup lang="ts"> |
|||
import type { FormInstance } from 'ant-design-vue/es/form/Form'; |
|||
|
|||
import type { FeatureGroupDto, UpdateFeaturesDto } from '../../types/features'; |
|||
|
|||
import { computed, ref, toValue, useTemplateRef } from 'vue'; |
|||
|
|||
import { useVbenModal } from '@vben/common-ui'; |
|||
import { $t } from '@vben/locales'; |
|||
|
|||
import { Form, message, Tabs } from 'ant-design-vue'; |
|||
|
|||
import { useFeaturesApi } from '../../api/useFeaturesApi'; |
|||
import FeatureGroup from './FeatureGroup.vue'; |
|||
import { |
|||
buildFeatureTree, |
|||
onFeatureValueChange, |
|||
processAllFeatures, |
|||
updateVisibility, |
|||
} from './useFeatureTree'; |
|||
|
|||
const TabPane = Tabs.TabPane; |
|||
|
|||
interface ModalState { |
|||
displayName?: string; |
|||
providerKey?: string; |
|||
providerName: string; |
|||
readonly?: boolean; |
|||
} |
|||
|
|||
interface FormModel { |
|||
groups: FeatureGroupDto[]; |
|||
} |
|||
|
|||
const activeTabKey = ref(''); |
|||
const modelState = ref<ModalState>(); |
|||
const formModel = ref<FormModel>({ groups: [] }); |
|||
const form = useTemplateRef<FormInstance>('form'); |
|||
|
|||
const getModalTitle = computed(() => { |
|||
if (modelState.value?.displayName) { |
|||
return `${$t('AbpFeatureManagement.Features')} - ${modelState.value.displayName}`; |
|||
} |
|||
return $t('AbpFeatureManagement.Features'); |
|||
}); |
|||
|
|||
const { getApi, updateApi } = useFeaturesApi(); |
|||
|
|||
const [Modal, modalApi] = useVbenModal({ |
|||
centered: true, |
|||
class: 'w-1/2', |
|||
async onConfirm() { |
|||
await form.value?.validate(); |
|||
await onSubmit(); |
|||
}, |
|||
async onOpenChange(isOpen) { |
|||
if (isOpen) { |
|||
formModel.value = { groups: [] }; |
|||
await onGet(); |
|||
if (formModel.value?.groups.length > 0) { |
|||
activeTabKey.value = formModel.value.groups[0]?.name!; |
|||
} |
|||
} |
|||
}, |
|||
}); |
|||
|
|||
function mapFeatures(groups: FeatureGroupDto[]): FeatureGroupDto[] { |
|||
groups.forEach((group) => { |
|||
// 构建树形结构 |
|||
const treeRoots = buildFeatureTree(group.features); |
|||
|
|||
// 存储树形结构到 group 中供模板使用 |
|||
(group as any)._treeRoots = treeRoots; |
|||
(group as any)._allFeatures = [...group.features]; |
|||
|
|||
// 初始化可见性 |
|||
treeRoots.forEach((root) => updateVisibility(root, true)); |
|||
|
|||
// 处理特性值类型转换 |
|||
processAllFeatures(treeRoots); |
|||
}); |
|||
return groups; |
|||
} |
|||
|
|||
function getFeatureInput(groups: FeatureGroupDto[]): UpdateFeaturesDto { |
|||
const input: UpdateFeaturesDto = { |
|||
features: [], |
|||
}; |
|||
groups.forEach((g) => { |
|||
const features = (g as any)._allFeatures || g.features; |
|||
features.forEach((f: any) => { |
|||
if (f.value !== null && f.value !== undefined && f.value !== '') { |
|||
input.features.push({ |
|||
name: f.name, |
|||
value: String(f.value), |
|||
}); |
|||
} |
|||
}); |
|||
}); |
|||
return input; |
|||
} |
|||
|
|||
async function onGet() { |
|||
try { |
|||
modalApi.setState({ loading: true }); |
|||
const state = modalApi.getData<ModalState>(); |
|||
const { groups } = await getApi({ |
|||
providerKey: state.providerKey, |
|||
providerName: state.providerName, |
|||
}); |
|||
formModel.value = { |
|||
groups: mapFeatures(groups), |
|||
}; |
|||
modelState.value = state; |
|||
} finally { |
|||
modalApi.setState({ loading: false }); |
|||
} |
|||
} |
|||
|
|||
function handleFeatureChange(feature: any, groupIndex: number) { |
|||
const group = formModel.value.groups[groupIndex]; |
|||
const treeRoots = (group as any)._treeRoots; |
|||
if (treeRoots) { |
|||
onFeatureValueChange(feature, treeRoots); |
|||
// 强制更新视图 |
|||
formModel.value = { ...formModel.value }; |
|||
} |
|||
} |
|||
|
|||
async function onSubmit() { |
|||
try { |
|||
modalApi.setState({ submitting: true }); |
|||
const model = toValue(formModel); |
|||
const state = modalApi.getData<ModalState>(); |
|||
const input = getFeatureInput(model.groups); |
|||
await updateApi( |
|||
{ |
|||
providerKey: state.providerKey, |
|||
providerName: state.providerName, |
|||
}, |
|||
input, |
|||
); |
|||
message.success($t('AbpUi.SavedSuccessfully')); |
|||
await onGet(); |
|||
} finally { |
|||
modalApi.setState({ submitting: false }); |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<template> |
|||
<Modal :title="getModalTitle"> |
|||
<Form :model="formModel" ref="form"> |
|||
<Tabs tab-position="left" type="card" v-model:active-key="activeTabKey"> |
|||
<TabPane |
|||
v-for="(group, gi) in formModel.groups" |
|||
:key="group.name" |
|||
:tab="group.displayName" |
|||
> |
|||
<FeatureGroup |
|||
:group="group" |
|||
:group-index="gi" |
|||
:base-indent-size="8" |
|||
@change="handleFeatureChange" |
|||
/> |
|||
</TabPane> |
|||
</Tabs> |
|||
</Form> |
|||
</Modal> |
|||
</template> |
|||
|
|||
<style lang="scss" scoped> |
|||
:deep(.ant-tabs) { |
|||
height: 34rem; |
|||
|
|||
.ant-tabs-nav { |
|||
width: 14rem; |
|||
} |
|||
|
|||
.ant-tabs-content-holder { |
|||
overflow: hidden auto !important; |
|||
} |
|||
} |
|||
|
|||
:deep(.ant-form-item) { |
|||
margin-bottom: 16px; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,9 @@ |
|||
import type { FeatureDto } from '../../types/features'; |
|||
|
|||
export interface TreeNode { |
|||
children: TreeNode[]; |
|||
feature: FeatureDto; |
|||
level: number; |
|||
parent?: TreeNode; |
|||
visible: boolean; |
|||
} |
|||
@ -0,0 +1,167 @@ |
|||
import type { FeatureDto } from '../../types/features'; |
|||
import type { TreeNode } from './tree'; |
|||
|
|||
import { $t } from '@vben/locales'; |
|||
|
|||
/** |
|||
* 将扁平的特性列表转换为树形结构 |
|||
*/ |
|||
export function buildFeatureTree(features: FeatureDto[]): TreeNode[] { |
|||
const nodeMap = new Map<string, TreeNode>(); |
|||
const roots: TreeNode[] = []; |
|||
|
|||
// 创建所有节点
|
|||
features.forEach((feature) => { |
|||
const node: TreeNode = { |
|||
feature, |
|||
children: [], |
|||
level: 0, |
|||
visible: true, |
|||
}; |
|||
nodeMap.set(feature.name, node); |
|||
}); |
|||
|
|||
// 建立父子关系
|
|||
features.forEach((feature) => { |
|||
const node = nodeMap.get(feature.name); |
|||
if (node && feature.parentName && nodeMap.has(feature.parentName)) { |
|||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|||
const parent = nodeMap.get(feature.parentName)!; |
|||
parent.children.push(node); |
|||
node.parent = parent; |
|||
node.level = parent.level + 1; |
|||
} else if (node && !feature.parentName) { |
|||
roots.push(node); |
|||
} |
|||
}); |
|||
|
|||
// 对子节点进行排序
|
|||
const sortChildren = (nodes: TreeNode[]) => { |
|||
nodes.forEach((node) => { |
|||
if (node.children.length > 0) { |
|||
node.children.sort((a, b) => |
|||
a.feature.displayName.localeCompare(b.feature.displayName), |
|||
); |
|||
sortChildren(node.children); |
|||
} |
|||
}); |
|||
}; |
|||
sortChildren(roots); |
|||
|
|||
return roots; |
|||
} |
|||
|
|||
/** |
|||
* 更新节点的可见性(基于父级状态) |
|||
*/ |
|||
export function updateVisibility( |
|||
node: TreeNode, |
|||
parentVisible: boolean = true, |
|||
): void { |
|||
node.visible = parentVisible; |
|||
|
|||
if (isCheckboxFeature(node.feature)) { |
|||
const isChecked = Boolean(node.feature.value); |
|||
node.children.forEach((child) => { |
|||
updateVisibility(child, parentVisible && isChecked); |
|||
}); |
|||
} else { |
|||
node.children.forEach((child) => { |
|||
updateVisibility(child, parentVisible); |
|||
}); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 检查是否为复选框类型的特性 |
|||
*/ |
|||
export function isCheckboxFeature(feature: FeatureDto): boolean { |
|||
return ( |
|||
feature.valueType?.name === 'ToggleStringValueType' && |
|||
feature.valueType?.validator?.name === 'BOOLEAN' |
|||
); |
|||
} |
|||
|
|||
/** |
|||
* 当特性值改变时,更新其子节点的可见性 |
|||
*/ |
|||
export function onFeatureValueChange( |
|||
feature: FeatureDto, |
|||
treeNodes: TreeNode[], |
|||
): void { |
|||
const findNode = (nodes: TreeNode[]): null | TreeNode => { |
|||
for (const node of nodes) { |
|||
if (node.feature.name === feature.name) { |
|||
return node; |
|||
} |
|||
const found = findNode(node.children); |
|||
if (found) return found; |
|||
} |
|||
return null; |
|||
}; |
|||
|
|||
const node = findNode(treeNodes); |
|||
if (node && isCheckboxFeature(node.feature)) { |
|||
updateVisibility(node, true); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 处理特性值类型转换 |
|||
*/ |
|||
export function processFeatureValue(feature: FeatureDto): void { |
|||
if (feature.valueType?.name === 'SelectionStringValueType') { |
|||
const valueType = feature.valueType as any; |
|||
if (valueType.itemSource?.items) { |
|||
valueType.itemSource.items.forEach((valueItem: any) => { |
|||
if (valueItem.displayText?.resourceName === 'Fixed') { |
|||
valueItem.displayName = valueItem.displayText.name; |
|||
} else if ( |
|||
valueItem.displayText?.resourceName && |
|||
valueItem.displayText?.name |
|||
) { |
|||
valueItem.displayName = $t( |
|||
`${valueItem.displayText.resourceName}.${valueItem.displayText.name}`, |
|||
); |
|||
} |
|||
}); |
|||
} |
|||
} else { |
|||
switch (feature.valueType?.validator?.name) { |
|||
case 'BOOLEAN': { |
|||
feature.value = String(feature.value).toLocaleLowerCase() === 'true'; |
|||
break; |
|||
} |
|||
case 'NUMERIC': { |
|||
if ( |
|||
feature.value !== null && |
|||
feature.value !== undefined && |
|||
feature.value !== '' |
|||
) { |
|||
feature.value = Number(feature.value); |
|||
} |
|||
break; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 递归处理所有特性值 |
|||
*/ |
|||
export function processAllFeatures(treeNodes: TreeNode[]): void { |
|||
const traverse = (nodes: TreeNode[]) => { |
|||
nodes.forEach((node) => { |
|||
processFeatureValue(node.feature); |
|||
traverse(node.children); |
|||
}); |
|||
}; |
|||
traverse(treeNodes); |
|||
} |
|||
|
|||
/** |
|||
* 获取缩进样式 |
|||
*/ |
|||
export function getIndentStyle(level: number, indentSize: number = 8): string { |
|||
return `${level * indentSize}px`; |
|||
} |
|||
Loading…
Reference in new issue