规则展示,bug修复

This commit is contained in:
MHW
2026-04-15 15:30:20 +08:00
parent a67e3e42ba
commit 4404d0e411
16 changed files with 1231 additions and 13 deletions

View File

@@ -58,4 +58,12 @@ export const routes: RouteRecordRaw[] = [
},
component: () => import('@/views/decision/rule/management.vue'),
},
{
name: 'decision-rule-config',
path: '/app/decision/rule-config',
meta: {
title: '规则聚合测试',
},
component: () => import('@/views/decision/rule-config/management.vue'),
},
]

View File

@@ -0,0 +1,44 @@
/*
* This file is part of the kernelstudio package.
*
* (c) 2014-2026 zlin <admin@kernelstudio.com>
*
* For the full copyright and license information, please view the LICENSE file
* that was distributed with this source code.
*/
import { HttpRequestClient } from '@/utils/request';
import type { ApiDataResponse, BasicResponse } from '@/types';
import type { RuleConfig, RuleConfigPageableResponse, RuleConfigRequest, RuleDictItem, RuleParamMeta } from './types';
const req = HttpRequestClient.create<BasicResponse>({
baseURL: '/api',
});
export const findRuleConfigByQuery = (query: Partial<RuleConfigRequest> = {}): Promise<RuleConfigPageableResponse> => {
return req.get('/system/rule/config/list', query);
};
export const findRuleConfigByCode = (ruleCode: string): Promise<ApiDataResponse<RuleConfig>> => {
return req.get(`/system/rule/config/${ruleCode}`);
};
export const createRuleConfig = (ruleConfig: RuleConfig): Promise<BasicResponse> => {
return req.postJson('/system/rule/config', ruleConfig);
};
export const updateRuleConfig = (ruleConfig: RuleConfig): Promise<BasicResponse> => {
return req.putJson('/system/rule/config', ruleConfig);
};
export const deleteRuleConfig = (ruleCode: string): Promise<BasicResponse> => {
return req.delete(`/system/rule/config/${ruleCode}`);
};
export const findRuleDictByType = (dictType: string): Promise<ApiDataResponse<RuleDictItem[]>> => {
return req.get(`/system/rule/config/dict/${dictType}`);
};
export const findRuleParamMeta = (): Promise<ApiDataResponse<RuleParamMeta[]>> => {
return req.get('/system/rule/config/param-meta');
};

View File

@@ -0,0 +1,557 @@
<template>
<Layout>
<template #sidebar>
<div class="ks-sidebar-header">
<a-flex class="ks-sidebar-title">
<span class="icon"></span>
<span class="text">规则聚合测试</span>
</a-flex>
<a-button class="ks-sidebar-add" size="small" @click="handleCreate">
<PlusOutlined />
新增
</a-button>
</div>
<a-tree
class="ks-sidebar-list"
:tree-data="treeData"
v-model:expandedKeys="expandedTreeKeys"
v-model:selectedKeys="selectedTreeKeys"
@select="handleTreeSelect"
/>
<a-pagination
v-model:current="query.pageNum"
:page-size="query.pageSize"
:total="datasourceTotal"
simple
size="small"
@change="handleChange"
/>
</template>
<div class="w-full h-full">
<a-card class="ks-page-card ks-algorithm-card">
<template #title>
<a-space>
<span class="point"></span>
<span class="text">规则聚合配置</span>
</a-space>
</template>
<div class="ks-scrollable" style="height: 80.5vh;overflow-y: auto;padding-right: 10px">
<a-row :gutter="15">
<a-col :span="16">
<a-form
ref="formRef"
:label-col="{ span: 6 }"
:model="selectedRuleConfig"
autocomplete="off"
layout="horizontal"
name="rule-config"
>
<a-alert
type="info"
show-icon
style="margin-bottom: 12px;"
message="仅保留业务参数参数作用、参数Key、参数值"
/>
<a-form-item
label="参数配置"
:rules="[{ required: true, message: '请至少配置一条参数', trigger: ['change'] }]"
name="params"
>
<a-form-item-rest>
<div class="ks-sidebar-list-param-list">
<div class="ks-sidebar-list-param-item" v-for="(item,index) in selectedRuleConfig.params" :key="`param-${index}`">
<a-row :gutter="10">
<a-col :span="6">
<a-input v-model:value="item.paramName" placeholder="参数作用(业务说明)" />
</a-col>
<a-col :span="6">
<a-select
v-model:value="item.paramKey"
placeholder="参数Key"
:options="paramMetaOptions"
show-search
:filter-option="filterParamKey"
@change="() => handleParamKeyChange(item)"
/>
</a-col>
<a-col :span="9">
<template v-if="resolveMeta(item.paramKey)?.valueType === 'bool'">
<a-select
v-model:value="item.paramVal"
placeholder="参数值"
:options="boolOptions"
/>
</template>
<template v-else-if="resolveMeta(item.paramKey)?.valueType === 'enum'">
<a-select
v-model:value="item.paramVal"
placeholder="参数值"
:options="toEnumOptions(resolveMeta(item.paramKey)?.enumOptions)"
/>
</template>
<template v-else-if="resolveMeta(item.paramKey)?.valueType === 'number'">
<a-input-number
v-model:value="item.paramVal"
placeholder="参数值"
style="width: 100%;"
:min="resolveMeta(item.paramKey)?.min ?? undefined"
:max="resolveMeta(item.paramKey)?.max ?? undefined"
/>
</template>
<template v-else>
<a-input v-model:value="item.paramVal" placeholder="参数值" />
</template>
<div class="text-xs text-red-400 mt-1" v-if="!resolveMeta(item.paramKey) && item.paramKey">
未知参数键当前为兼容展示请联系后端补充参数元数据后再保存
</div>
<div class="text-xs text-gray-400 mt-1" v-if="resolveMeta(item.paramKey)?.description">
{{ resolveMeta(item.paramKey)?.description }}<span v-if="resolveMeta(item.paramKey)?.example">示例{{ resolveMeta(item.paramKey)?.example }}</span>
</div>
</a-col>
<a-col :span="3">
<a-space class="ks-sidebar-list-param-actions">
<MinusCircleOutlined @click.stop="() => handleMinusParam(index)" />
<PlusCircleOutlined @click.stop="() => handleAddParam()" v-if="index === 0" />
</a-space>
</a-col>
</a-row>
</div>
</div>
</a-form-item-rest>
</a-form-item>
<a-form-item :wrapper-col="{ offset: 6 }">
<a-space>
<a-button @click="handleSave" type="primary">保存规则</a-button>
<a-popconfirm
v-if="selectedRuleConfig && selectedRuleConfig.id > 0 && selectedRuleConfig.ruleCode"
title="确定删除?"
@confirm="handleDelete"
>
<a-button danger>删除规则</a-button>
</a-popconfirm>
</a-space>
</a-form-item>
</a-form>
</a-col>
</a-row>
</div>
</a-card>
</div>
</Layout>
</template>
<script setup lang="ts">
import { nextTick, onMounted, ref } from 'vue';
import { type FormInstance, message } from 'ant-design-vue';
import { MinusCircleOutlined, PlusCircleOutlined, PlusOutlined } from '@ant-design/icons-vue';
import Layout from '../layout.vue';
import { createRuleConfig, deleteRuleConfig, findRuleConfigByCode, findRuleConfigByQuery, findRuleParamMeta, updateRuleConfig } from './api';
import type { RuleConfig, RuleConfigParam, RuleConfigRequest, RuleParamMeta } from './types';
const query = ref<RuleConfigRequest>({
pageNum: 1,
pageSize: 10,
});
const defaultParam = (): RuleConfigParam => ({
ruleCode: null,
paramKey: null,
paramVal: null,
valType: 'string',
paramName: null,
sortNo: 0,
enabled: 1,
remark: null,
});
const defaultRuleConfig: RuleConfig = {
id: 0,
ruleCode: null,
ruleName: null,
levelCode: null,
kindCode: null,
moduleCode: null,
priorityNo: 0,
conditionExpr: null,
actionExpr: null,
versionNo: 1,
enabled: 1,
remark: null,
params: [defaultParam()],
taskTypes: [],
};
const datasource = ref<RuleConfig[]>([]);
const datasourceTotal = ref<number>(0);
const selectedRuleConfig = ref<RuleConfig>(JSON.parse(JSON.stringify(defaultRuleConfig)));
const formRef = ref<FormInstance | null>(null);
const treeData = ref<any[]>([]);
const selectedTreeKeys = ref<string[]>([]);
const expandedTreeKeys = ref<string[]>([]);
const detailMap = ref<Record<string, RuleConfig>>({});
const paramMetaList = ref<RuleParamMeta[]>([]);
const paramMetaMap = ref<Record<string, RuleParamMeta>>({});
const boolOptions = [
{ label: 'true', value: 'true' },
{ label: 'false', value: 'false' },
];
const paramMetaOptions = ref<{ label: string; value: string }[]>([]);
const levelOptions = [
{ code: 'task', name: '任务级' },
{ code: 'platform', name: '平台级' },
{ code: 'action', name: '行动级' },
];
const kindOptions = [
{ code: 'select', name: '选择' },
{ code: 'assign', name: '分配' },
{ code: 'deploy', name: '部署' },
{ code: 'config', name: '配置' },
{ code: 'mode', name: '工作模式' },
{ code: 'spacetime', name: '时空约束' },
{ code: 'relation', name: '关联关系' },
{ code: 'limit', name: '限制条件' },
];
const buildTreeData = () => {
const group: Record<string, Record<string, any[]>> = {};
datasource.value.forEach((rule) => {
const detail = rule.ruleCode ? detailMap.value[rule.ruleCode] : null;
const levelCode = rule.levelCode;
const kindCode = rule.kindCode;
if (!levelCode || !kindCode) return;
const params = detail?.params?.filter((item) => item.paramKey) ?? [];
if (params.length === 0) return;
group[levelCode] = group[levelCode] ?? {};
group[levelCode][kindCode] = group[levelCode][kindCode] ?? [];
params.forEach((param, index) => {
const paramTitle = param.paramName || param.paramKey || `参数${index + 1}`;
group[levelCode][kindCode].push({
title: paramTitle,
key: `param:${rule.ruleCode}:${param.paramKey || index}`,
isLeaf: true,
nodeType: 'param',
ruleCode: rule.ruleCode,
});
});
});
treeData.value = levelOptions
.map((level) => {
const levelGroup = group[level.code] ?? {};
const kindChildren = kindOptions
.map((kind) => {
const children = levelGroup[kind.code] ?? [];
if (children.length === 0) return null;
return {
title: kind.name,
key: `kind:${level.code}:${kind.code}`,
nodeType: 'kind',
children,
};
})
.filter((item) => !!item);
if (kindChildren.length === 0) return null;
return {
title: level.name,
key: `level:${level.code}`,
nodeType: 'level',
children: kindChildren,
};
})
.filter((item) => !!item);
};
const load = async () => {
datasource.value = [];
datasourceTotal.value = 0;
detailMap.value = {};
if (selectedRuleConfig.value.id <= 0) {
selectedRuleConfig.value = JSON.parse(JSON.stringify(defaultRuleConfig));
nextTick(() => {
formRef.value?.resetFields();
});
}
const r = await findRuleConfigByQuery(query.value as Partial<RuleConfigRequest>);
datasource.value = r.rows ?? [];
datasourceTotal.value = r.total ?? 0;
await Promise.all(
datasource.value
.filter((item) => !!item.ruleCode)
.map(async (item) => {
if (!item.ruleCode) return;
const detailResp = await findRuleConfigByCode(item.ruleCode);
if (detailResp.code === 200 && detailResp.data) {
detailMap.value[item.ruleCode] = detailResp.data;
}
}),
);
buildTreeData();
};
const loadParamMeta = async () => {
const r = await findRuleParamMeta();
if (r.code !== 200 || !Array.isArray(r.data)) {
message.error(r.msg ?? '加载参数元数据失败');
return;
}
paramMetaList.value = r.data;
const map: Record<string, RuleParamMeta> = {};
const options: { label: string; value: string }[] = [];
r.data.forEach((item) => {
if (!item.paramKey) return;
map[item.paramKey] = item;
options.push({
label: item.label ? `${item.label} (${item.paramKey})` : String(item.paramKey),
value: String(item.paramKey),
});
});
paramMetaMap.value = map;
paramMetaOptions.value = options;
};
const resolveMeta = (paramKey: string | null): RuleParamMeta | null => {
if (!paramKey) return null;
return paramMetaMap.value[paramKey] ?? null;
};
const toEnumOptions = (enumOptions: string[] | null | undefined) => {
if (!Array.isArray(enumOptions)) return [];
return enumOptions.map((v) => ({ label: v, value: v }));
};
const filterParamKey = (input: string, option: { label: string; value: string }) => {
const text = `${option.label ?? ''}${option.value ?? ''}`.toLowerCase();
return text.includes(input.toLowerCase());
};
const ensureUnknownParamOptions = (params: RuleConfigParam[]) => {
const exists = new Set(paramMetaOptions.value.map((item) => item.value));
const append: { label: string; value: string }[] = [];
params.forEach((item) => {
if (!item?.paramKey) return;
if (!exists.has(item.paramKey)) {
const opt = { label: `[未知] ${item.paramKey}`, value: item.paramKey };
append.push(opt);
exists.add(item.paramKey);
}
});
if (append.length > 0) {
paramMetaOptions.value = [...paramMetaOptions.value, ...append];
}
};
const handleCreate = () => {
formRef.value?.resetFields();
selectedRuleConfig.value = JSON.parse(JSON.stringify(defaultRuleConfig));
};
const handleSelect = (item: RuleConfig) => {
if (!item.ruleCode) {
return;
}
findRuleConfigByCode(item.ruleCode).then((r) => {
if (r.code === 200 && r.data) {
formRef.value?.resetFields();
nextTick(() => {
const detail = JSON.parse(JSON.stringify(r.data)) as RuleConfig;
if (!Array.isArray(detail.params) || detail.params.length === 0) {
detail.params = [defaultParam()];
}
if (!Array.isArray(detail.taskTypes)) {
detail.taskTypes = [];
}
ensureUnknownParamOptions(detail.params ?? []);
selectedRuleConfig.value = detail;
});
} else {
message.error(r.msg ?? '查询详情失败');
}
});
};
const handleTreeSelect = (_keys: (string | number)[], e: any) => {
const node = e?.node;
if (!node || node.nodeType !== 'param') {
return;
}
const ruleCode = node.ruleCode as string | null;
if (!ruleCode) {
return;
}
selectedTreeKeys.value = [String(node.key)];
handleSelect({
...defaultRuleConfig,
ruleCode,
});
};
const handleDelete = () => {
if (selectedRuleConfig.value?.id > 0 && selectedRuleConfig.value.ruleCode) {
deleteRuleConfig(selectedRuleConfig.value.ruleCode).then((r) => {
if (r.code === 200) {
load();
message.success(r.msg ?? '删除成功');
} else {
message.error(r.msg ?? '删除失败');
}
});
}
};
const handleSave = () => {
formRef.value?.validate().then(() => {
const savedValue: RuleConfig = JSON.parse(JSON.stringify(selectedRuleConfig.value));
savedValue.params = (savedValue.params ?? []).filter((item) => item.paramKey);
if (savedValue.params.length === 0) {
message.error('请至少填写一个参数键');
return;
}
savedValue.params = savedValue.params.map((item, index) => ({
...item,
ruleCode: savedValue.ruleCode,
sortNo: item.sortNo ?? index,
enabled: item.enabled ?? 1,
valType: resolveMeta(item.paramKey)?.valueType === 'bool'
? 'bool'
: resolveMeta(item.paramKey)?.valueType === 'number'
? 'number'
: 'string',
paramVal: normalizeParamVal(item),
}));
const validMsg = validateParamsBeforeSubmit(savedValue.params);
if (validMsg) {
message.error(validMsg);
return;
}
// 保留后端必填字段,前端不展示时统一补默认值
savedValue.ruleCode = savedValue.ruleCode ?? `rule_${Date.now()}`;
savedValue.ruleName = savedValue.ruleName ?? savedValue.ruleCode;
savedValue.levelCode = savedValue.levelCode ?? 'task';
savedValue.kindCode = savedValue.kindCode ?? 'select';
savedValue.moduleCode = savedValue.moduleCode ?? 'equipment';
savedValue.priorityNo = savedValue.priorityNo ?? 100;
savedValue.versionNo = savedValue.versionNo ?? 1;
savedValue.enabled = savedValue.enabled ?? 1;
savedValue.taskTypes = Array.isArray(savedValue.taskTypes) ? savedValue.taskTypes : [];
const request = savedValue.id > 0
? updateRuleConfig(savedValue)
: createRuleConfig(savedValue);
request.then(async (r) => {
if (r.code === 200) {
await load();
message.success(r.msg ?? '操作成功');
} else {
message.error(r.msg ?? '操作失败');
}
}).catch((err) => {
message.error('请求失败:' + err.message);
});
}).catch((err) => {
message.error('表单验证失败:' + err.message);
});
};
const handleChange = async (page: number, pageSize: number) => {
query.value.pageNum = page;
query.value.pageSize = pageSize;
await load();
};
const handleAddParam = () => {
const row = defaultParam();
if (paramMetaOptions.value.length > 0) {
row.paramKey = paramMetaOptions.value[0].value;
handleParamKeyChange(row);
}
selectedRuleConfig.value.params.push(row);
};
const handleMinusParam = (index: number) => {
const params = [...selectedRuleConfig.value.params];
if (params.length <= 1) {
selectedRuleConfig.value.params = [defaultParam()];
return;
}
params.splice(index, 1);
selectedRuleConfig.value.params = params;
};
onMounted(() => {
loadParamMeta().then(() => load());
});
const handleParamKeyChange = (item: RuleConfigParam) => {
const meta = resolveMeta(item.paramKey);
if (!meta) {
item.valType = 'string';
return;
}
if (meta.valueType === 'bool') {
item.valType = 'bool';
if (item.paramVal !== 'true' && item.paramVal !== 'false') {
item.paramVal = 'true';
}
return;
}
if (meta.valueType === 'number') {
item.valType = 'number';
return;
}
item.valType = 'string';
if (meta.valueType === 'enum' && Array.isArray(meta.enumOptions) && meta.enumOptions.length > 0) {
if (!meta.enumOptions.includes(String(item.paramVal ?? ''))) {
item.paramVal = meta.enumOptions[0];
}
}
};
const normalizeParamVal = (item: RuleConfigParam): string | null => {
if (item.paramVal === null || item.paramVal === undefined) return null;
return String(item.paramVal);
};
const validateParamsBeforeSubmit = (params: RuleConfigParam[]): string | null => {
for (const item of params) {
const key = item.paramKey;
if (!key) return '参数Key不能为空';
const meta = resolveMeta(key);
if (!meta) return `不支持的参数Key: ${key}`;
const val = String(item.paramVal ?? '');
if (meta.required && !val) return `参数值不能为空: ${key}`;
if (meta.valueType === 'bool' && val !== 'true' && val !== 'false') {
return `布尔参数仅支持 true/false: ${key}`;
}
if (meta.valueType === 'enum' && Array.isArray(meta.enumOptions) && !meta.enumOptions.includes(val)) {
return `参数值不在可选范围内: ${key}`;
}
if (meta.valueType === 'number') {
const num = Number(val);
if (Number.isNaN(num)) return `数值参数格式错误: ${key}`;
if (meta.min !== null && meta.min !== undefined && num < meta.min) return `参数值小于最小值: ${key}`;
if (meta.max !== null && meta.max !== undefined && num > meta.max) return `参数值大于最大值: ${key}`;
}
if (meta.pattern) {
const regex = new RegExp(meta.pattern);
if (!regex.test(val)) return `参数格式错误: ${key}`;
}
}
return null;
};
</script>

View File

@@ -0,0 +1,88 @@
/*
* This file is part of the kernelstudio package.
*
* (c) 2014-2026 zlin <admin@kernelstudio.com>
*
* For the full copyright and license information, please view the LICENSE file
* that was distributed with this source code.
*/
import type { NullableString, PageableResponse } from '@/types';
export interface RuleConfigParam {
// 规则编码
ruleCode: NullableString,
// 参数键
paramKey: NullableString,
// 参数值
paramVal: NullableString | number,
// 值类型(string/number/bool/json)
valType: NullableString,
// 参数名称
paramName: NullableString,
// 排序号
sortNo: number | null,
// 是否启用(1是0否)
enabled: number | null,
// 备注
remark: NullableString,
}
export interface RuleDictItem {
dictType: NullableString,
dictCode: NullableString,
dictName: NullableString,
sortNo: number | null,
enabled: number | null,
remark: NullableString,
}
export interface RuleParamMeta {
paramKey: NullableString,
label: NullableString,
valueType: NullableString,
required: boolean | null,
enumOptions: string[] | null,
min: number | null,
max: number | null,
pattern: NullableString,
example: NullableString,
description: NullableString,
}
export interface RuleConfig {
id: number,
// 规则编码
ruleCode: NullableString,
// 规则名称
ruleName: NullableString,
// 层级编码
levelCode: NullableString,
// 种类编码
kindCode: NullableString,
// 模块编码
moduleCode: NullableString,
// 优先级(数值越小优先级越高)
priorityNo: number | null,
// 条件表达式
conditionExpr: NullableString,
// 动作表达式
actionExpr: NullableString,
// 版本号
versionNo: number | null,
// 是否启用1是0否
enabled: number | null,
// 备注
remark: NullableString,
// 参数列表
params: RuleConfigParam[],
// 适用任务类型编码列表
taskTypes: string[],
}
export interface RuleConfigRequest extends Partial<RuleConfig> {
pageNum: number,
pageSize: number,
}
export interface RuleConfigPageableResponse extends PageableResponse<RuleConfig> {}

View File

@@ -12,6 +12,7 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
AAlert: typeof import('ant-design-vue/es')['Alert']
ABadge: typeof import('ant-design-vue/es')['Badge']
AButton: typeof import('ant-design-vue/es')['Button']
ACard: typeof import('ant-design-vue/es')['Card']
@@ -40,6 +41,8 @@ declare module 'vue' {
AMenuItem: typeof import('ant-design-vue/es')['MenuItem']
APagination: typeof import('ant-design-vue/es')['Pagination']
APopconfirm: typeof import('ant-design-vue/es')['Popconfirm']
ARadioButton: typeof import('ant-design-vue/es')['RadioButton']
ARadioGroup: typeof import('ant-design-vue/es')['RadioGroup']
ARow: typeof import('ant-design-vue/es')['Row']
ASelect: typeof import('ant-design-vue/es')['Select']
ASelectOption: typeof import('ant-design-vue/es')['SelectOption']
@@ -49,6 +52,7 @@ declare module 'vue' {
ATabs: typeof import('ant-design-vue/es')['Tabs']
ATextarea: typeof import('ant-design-vue/es')['Textarea']
ATooltip: typeof import('ant-design-vue/es')['Tooltip']
ATree: typeof import('ant-design-vue/es')['Tree']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}
@@ -56,6 +60,7 @@ declare module 'vue' {
// For TSX support
declare global {
const AAlert: typeof import('ant-design-vue/es')['Alert']
const ABadge: typeof import('ant-design-vue/es')['Badge']
const AButton: typeof import('ant-design-vue/es')['Button']
const ACard: typeof import('ant-design-vue/es')['Card']
@@ -84,6 +89,8 @@ declare global {
const AMenuItem: typeof import('ant-design-vue/es')['MenuItem']
const APagination: typeof import('ant-design-vue/es')['Pagination']
const APopconfirm: typeof import('ant-design-vue/es')['Popconfirm']
const ARadioButton: typeof import('ant-design-vue/es')['RadioButton']
const ARadioGroup: typeof import('ant-design-vue/es')['RadioGroup']
const ARow: typeof import('ant-design-vue/es')['Row']
const ASelect: typeof import('ant-design-vue/es')['Select']
const ASelectOption: typeof import('ant-design-vue/es')['SelectOption']
@@ -93,6 +100,7 @@ declare global {
const ATabs: typeof import('ant-design-vue/es')['Tabs']
const ATextarea: typeof import('ant-design-vue/es')['Textarea']
const ATooltip: typeof import('ant-design-vue/es')['Tooltip']
const ATree: typeof import('ant-design-vue/es')['Tree']
const RouterLink: typeof import('vue-router')['RouterLink']
const RouterView: typeof import('vue-router')['RouterView']
}