Commit cd2c1475 authored by Data Governance Dev's avatar Data Governance Dev

feat(web): 自定义规则卡条件树 UI + 字段类型分类 + 字段注释排版

把单条 el-table 行(含 1 个 el-input 关键字)重写为 div + CSS Grid 4 列布局。

- 字段类型 → 比较符映射(前端镜像 web/core/rule_types.py)
  - NUMERIC_TYPES_FRONT / DATE_TYPES_FRONT / TEXT_TYPES_FRONT 三集合
  - classifyField(dataType) → text/numeric/date/unknown
  - COMPARATORS_FRONT / INPUT_HINT_FRONT 按字段类分发
  - fieldClassTagType(dataType) → 数值 warning / 日期 info / 字符 success

- 全新递归组件 <condition-tree>
  - app.component('condition-tree', { name: 'ConditionTree', ... })
  - props: node(默认空 group) / column / dataType / depth
  - 渲染:group 节点显示 AND/OR radio + 项数 + 「+ 子条件 / + 嵌套组」按钮
  - 渲染:leaf 节点显示 comparator select + value input + × 删除
  - emits: change / remove

- 列表 div 替代 el-table
  - 4 列 grid:规则名称 / 对象·字段·类型 / 条件树 / 操作
  - .field-block 容器:表名 + 表注释 / 字段名 + 类型 + 字段注释 inline 排
  - .field-block--field dashed border-top 视觉分隔两行

- addCustomRule 改造
  - 透传 row.data_type / row.column_type(决定可用比较符)
  - 初始化 group = { kind: 'group', op: 'AND', children: [] }
  - table_comment 兜底查 tableSummary(字段行 row 不带)
  - 自动展开卡片(customConfigCollapsed = false)

- buildCustomRulesForSubmit 改读新结构
  - hasValidLeaf(node) 递归判断:跳过空树
  - 输出包含 {rule_name, table_name, column_name, data_type, group_op, group}

- 修复 2 个调试 bug
  - fieldClassTagType 是模块作用域函数,模板调用须 setup() return 出来
  - <condition-tree> props.node 加 default 兜底,避免 rule.group undefined 渲染崩溃
parent 0967e522
...@@ -70,6 +70,164 @@ ...@@ -70,6 +70,164 @@
.section-card.is-collapsed:last-of-type { .section-card.is-collapsed:last-of-type {
margin-bottom: 0 !important; margin-bottom: 0 !important;
} }
/* ── 自定义规则条件树(v2) ──────────────────────────────── */
.cond-tree {
font-size: 13px;
line-height: 1.6;
}
.cond-tree--leaf {
display: inline-block;
}
.cond-tree--group {
border: 1px solid #e4e7ed;
border-radius: 6px;
padding: 6px 8px;
background: #fafbfc;
max-width: 100%; /* 不撑爆 grid 单元格 */
/* 不要固定 min-width —— 让父表格列控制宽度,避免撑爆列 */
}
.cond-tree--nested {
margin-left: 12px;
border-left: 3px solid #dcdfe6;
background: #fff;
}
.cond-tree-header {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
margin-bottom: 4px;
}
.cond-tree-meta {
color: #909399;
font-size: 12px;
}
.cond-tree-actions {
margin-left: auto;
display: flex;
gap: 4px;
}
.cond-tree-children {
display: flex;
flex-direction: column;
gap: 6px;
padding-left: 6px;
}
.cond-tree-row {
display: flex;
align-items: flex-start;
}
.cond-tree-empty {
color: #c0c4cc;
font-size: 12px;
font-style: italic;
padding: 4px 0;
}
.cond-leaf {
display: flex;
align-items: center;
gap: 4px;
/* 不固定 width —— 让 el-table 列宽自适应 */
}
/* 字段名列的类型 tag:紧贴字段名下方,独占一行 */
.column-type-wrap {
margin-top: 2px;
}
.column-type-tag {
max-width: 100%;
}
.column-type-tag .el-tag__content {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── 自定义规则列表(div + flex,替代 el-table) ───────────── */
.custom-rule-list {
border: 1px solid #ebeef5;
border-radius: 6px;
overflow: hidden;
background: #fff;
}
.custom-rule-header,
.custom-rule-row {
display: grid;
/* 4 列:name 1fr / field 1.4fr / cond 2fr / op 56px */
grid-template-columns: 1fr 1.4fr 2fr 56px;
gap: 0;
align-items: start;
}
.custom-rule-header {
background: #f5f7fa;
border-bottom: 1px solid #ebeef5;
font-size: 12px;
color: #909399;
font-weight: 500;
}
.custom-rule-row {
border-bottom: 1px solid #ebeef5;
padding: 8px 10px;
}
.custom-rule-row:last-child {
border-bottom: none;
}
/* 斑马纹:单数行加浅色底(与 el-table stripe 一致) */
.custom-rule-row:nth-child(odd) {
background: #fafafa;
}
.custom-rule-cell {
padding: 0 8px;
font-size: 13px;
min-width: 0; /* 关键:让 grid 子项可被压缩 */
}
.custom-rule-cell--name {
padding-top: 4px;
}
.custom-rule-cell--field {
/* 表名 + 字段 + 类型 紧凑排版 */
}
.custom-rule-cell--cond {
padding-top: 0;
}
.custom-rule-cell--op {
display: flex;
justify-content: center;
align-items: flex-start;
padding-top: 4px;
}
.rule-field-row {
margin-top: 4px;
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.rule-field-row .column-type-tag {
flex: 0 1 auto;
max-width: 100%;
}
/* 字段列两行:表块 / 字段块(视觉分隔) */
/* 块内 inline 排版:表名 + 表注释 / 字段名 + 类型 + 字段注释 各一行 */
.field-block {
display: flex;
align-items: baseline;
gap: 8px;
flex-wrap: wrap;
}
.field-block > .dict-comment {
flex: 1 1 auto;
min-width: 0;
}
.field-block--field {
margin-top: 6px;
padding-top: 6px;
border-top: 1px dashed #e4e7ed;
}
/* 卡片在折叠态时,条件树也别占用太多空间 */
.section-card.is-collapsed .cond-tree {
display: none;
}
</style> </style>
<!-- 前端依赖全部本地化(避免 CDN 被墙/慢) --> <!-- 前端依赖全部本地化(避免 CDN 被墙/慢) -->
<!-- Element Plus CSS --> <!-- Element Plus CSS -->
...@@ -381,55 +539,61 @@ ...@@ -381,55 +539,61 @@
</div> </div>
</div> </div>
</template> </template>
<transition name="collapse"> <div v-show="!customConfigCollapsed">
<div v-show="!customConfigCollapsed"> <!-- 自定义规则列表(v2:用 div + grid 替代 el-table,避免 el-table 列宽压缩导致
<el-table 条件组件错位;每行 = 一张 card) -->
v-if="customRules.length > 0" <div v-if="customRules.length > 0" class="custom-rule-list">
:data="customRules" <!-- 表头:4 段 -->
stripe size="small" <div class="custom-rule-header">
style="width: 100%" <div class="custom-rule-cell custom-rule-cell--name">规则名称</div>
> <div class="custom-rule-cell custom-rule-cell--field">对象 / 字段 / 类型</div>
<el-table-column label="规则名称(展示用)" min-width="200"> <div class="custom-rule-cell custom-rule-cell--cond">条件(AND/OR 嵌套)</div>
<template #default="{ row }"> <div class="custom-rule-cell custom-rule-cell--op">操作</div>
</div>
<!-- 每条规则一行 -->
<div
v-for="(rule, $index) in customRules"
:key="rule.id"
class="custom-rule-row"
>
<div class="custom-rule-cell custom-rule-cell--name">
<el-input <el-input
v-model="row.rule_name" v-model="rule.rule_name"
size="small" size="small"
placeholder="(留空回落到 表名.字段名)" placeholder="(留空回落 表.字段)"
clearable clearable
/> />
</template> </div>
</el-table-column> <div class="custom-rule-cell custom-rule-cell--field">
<el-table-column label="对象表名 / 注释" min-width="240"> <div class="field-block">
<template #default="{ row }"> <code class="dict-name" :title="rule.table_name">{{ rule.table_name }}</code>
<code class="dict-name" :title="row.table_name">{{ row.table_name }}</code> <div v-if="rule.table_comment" class="dict-comment" :title="rule.table_comment">{{ rule.table_comment }}</div>
<div v-if="row.table_comment" class="dict-comment" :title="row.table_comment">{{ row.table_comment }}</div> <div v-else class="dict-comment dict-comment--empty">(无表注释)</div>
<div v-else class="dict-comment dict-comment--empty">(无表注释)</div> </div>
</template> <div class="field-block field-block--field">
</el-table-column> <div class="rule-field-row">
<el-table-column label="字段名 / 注释" min-width="240"> <code class="dict-name--field" :title="rule.column_name">{{ rule.column_name }}</code>
<template #default="{ row }"> <el-tag
<code class="dict-name--field" :title="row.column_name">{{ row.column_name }}</code> size="small"
<div v-if="row.column_comment" class="dict-comment" :title="row.column_comment">{{ row.column_comment }}</div> :type="fieldClassTagType(rule.data_type)"
<div v-else class="dict-comment dict-comment--empty">(无字段注释)</div> effect="plain"
</template> class="column-type-tag"
</el-table-column> >{{ rule.data_type || rule.column_type || 'unknown' }}</el-tag>
<el-table-column label="规则" width="160" align="center"> </div>
<template #default> <div v-if="rule.column_comment" class="dict-comment" :title="rule.column_comment">{{ rule.column_comment }}</div>
<el-tag size="small" type="primary" effect="plain">包含关键字</el-tag> <div v-else class="dict-comment dict-comment--empty">(无字段注释)</div>
</template> </div>
</el-table-column> </div>
<el-table-column label="用户输入(关键字)" min-width="220"> <div class="custom-rule-cell custom-rule-cell--cond">
<template #default="{ row }"> <condition-tree
<el-input :node="rule.group || { kind: 'group', op: 'AND', children: [] }"
v-model="row.user_input" :column="rule.column_name"
size="small" :data-type="rule.data_type"
placeholder="输入要匹配的关键字" :depth="0"
clearable @change="rule.group = $event"
/> />
</template> </div>
</el-table-column> <div class="custom-rule-cell custom-rule-cell--op">
<el-table-column label="操作" width="64" align="center" fixed="right">
<template #default="{ $index }">
<el-button <el-button
size="small" size="small"
type="danger" type="danger"
...@@ -439,19 +603,25 @@ ...@@ -439,19 +603,25 @@
> >
<el-icon><Delete /></el-icon> <el-icon><Delete /></el-icon>
</el-button> </el-button>
</template> </div>
</el-table-column> </div>
</el-table> </div>
<div v-else class="dict-status" style="padding: 24px; text-align: center;"> <div v-else class="dict-status" style="padding: 24px; text-align: center;">
暂无自定义规则 —— 在下方「数据字典浏览器」中点击字段右侧的 暂无自定义规则 —— 在下方「数据字典浏览器」中点击字段右侧的
<el-icon style="vertical-align: middle"><Plus /></el-icon> <el-icon style="vertical-align: middle"><Plus /></el-icon>
按钮添加 按钮添加
</div> </div>
<div class="form-hint" style="margin-top: 12px;"> <div class="form-hint" style="margin-top: 12px;">
框架演示:当前只支持「字段包含关键字」一种规则。后续会扩展更多规则类型。 字段类型决定可用的比较符:
<el-tag size="small" type="success" effect="plain">字符</el-tag>
包含 / 不包含;
<el-tag size="small" type="warning" effect="plain">数值</el-tag>
&gt; / &lt; / =
;<el-tag size="small" type="info" effect="plain">日期</el-tag>
早于 / 晚于。
在条件组里可点「+ 子条件」加叶子、「+ 嵌套组」加括号层(OR/AND 切换连接方式)。
</div> </div>
</div> </div>
</transition>
</el-card> </el-card>
<!-- 卡片 3:数据字典浏览器(连接成功后才有数据) --> <!-- 卡片 3:数据字典浏览器(连接成功后才有数据) -->
...@@ -1408,9 +1578,13 @@ ...@@ -1408,9 +1578,13 @@
} }
// ── 自定义规则 ── // ── 自定义规则 ──
// 点击字段表右侧 + 号:拷贝字段信息,规则类型固定为 'contains_keyword' // 点击字段表右侧 + 号:拷贝字段信息 + 字段 data_type(决定可用比较符)
// 已存在的同一字段会被去重(防止用户重复点) // 已存在的同一字段会被去重(防止用户重复点)
// table_comment 兜底:字段行 row 可能不带,从 tableSummary 反查
function addCustomRule(row) { function addCustomRule(row) {
if (!row || !row.table_name || !row.column_name) {
return;
}
const dup = customRules.value.find( const dup = customRules.value.find(
r => r.table_name === row.table_name && r.column_name === row.column_name, r => r.table_name === row.table_name && r.column_name === row.column_name,
); );
...@@ -1420,18 +1594,32 @@ ...@@ -1420,18 +1594,32 @@
); );
return; return;
} }
customRules.value.push({ // 表注释兜底:字段行 row.table_comment 可能为空,从 tableSummary 反查
let tableComment = row.table_comment || '';
if (!tableComment) {
const t = (tableSummary.value || []).find(
x => x.table_name === row.table_name,
);
tableComment = (t && t.table_comment) || '';
}
const newRule = {
id: `rule_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`, id: `rule_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
// 用户输入的展示名(2026-08-12 起新增列):留空时后端回落到 "<table>.<column>" // 用户输入的展示名(2026-08-12 起新增列):留空时后端回落到 "<table>.<column>"
rule_name: '', rule_name: '',
table_name: row.table_name, table_name: row.table_name,
table_comment: row.table_comment || '', table_comment: tableComment,
column_name: row.column_name, column_name: row.column_name,
column_comment: row.column_comment || '', column_comment: row.column_comment || '',
rule_type: 'contains_keyword', // 当前唯一规则类型(占位) // 字段 data_type(裸类型 varchar / int / datetime 等),决定该规则能选哪些比较符
user_input: '', data_type: row.data_type || row.column_type || '',
}); column_type: row.column_type || '',
// 添加后自动展开卡片,方便用户编辑关键字 rule_type: 'tree', // v2:固定为条件树(保留字段兼容旧 step9)
// 顶层条件组(默认 AND + 空 children);用户进 UI 后点「+ 子条件」/「+ 嵌套组」填充
group: { kind: 'group', op: 'AND', children: [] },
};
// 用整体替换而不是 .push() —— 避免任何潜在的 ref 数组响应式丢失
customRules.value = customRules.value.concat([newRule]);
// 添加后自动展开卡片,方便用户添加子条件
customConfigCollapsed.value = false; customConfigCollapsed.value = false;
} }
...@@ -1655,29 +1843,42 @@ ...@@ -1655,29 +1843,42 @@
} }
// ── 提交时把 custom_rules 转成后端要的 list 结构 ── // ── 提交时把 custom_rules 转成后端要的 list 结构 ──
// 输入:customRules.value = [{id, rule_name, table_name, table_comment, // 输入:customRules.value = [{id, rule_name, table_name, ..., data_type, group}, ...]
// column_name, column_comment, rule_type, user_input}, ...]
// 输出:[{rule_name, table_name, table_comment, column_name, column_comment, // 输出:[{rule_name, table_name, table_comment, column_name, column_comment,
// rule_type, user_input}, ...] // data_type, group_op, group: {kind, op, children}}, ...]
// 过滤 user_input 为空的项(用户加了规则但没填关键字) // 过滤:group.children 里没有任何「value 非空叶子」的项
// rule_name 透传(空字符串也透传,让后端走 fallback 逻辑)
function buildCustomRulesForSubmit() { function buildCustomRulesForSubmit() {
const out = []; const out = [];
for (const r of (customRules.value || [])) { for (const r of (customRules.value || [])) {
const kw = (r.user_input || '').trim(); if (!hasValidLeaf(r.group)) continue; // 跳过空树
if (!kw) continue;
out.push({ out.push({
rule_name: (r.rule_name || '').trim(), rule_name: (r.rule_name || '').trim(),
table_name: r.table_name, table_name: r.table_name,
table_comment: r.table_comment, table_comment: r.table_comment,
column_name: r.column_name, column_name: r.column_name,
column_comment: r.column_comment, column_comment: r.column_comment,
rule_type: r.rule_type, data_type: r.data_type || '',
user_input: kw, column_type: r.column_type || '',
group_op: (r.group && r.group.op) || 'AND',
group: r.group,
}); });
} }
return out; return out;
} }
// 递归检查 group 树里是否存在「value 非空叶子」(countLeaves 的快捷版)
function hasValidLeaf(node) {
if (!node) return false;
if (node.kind === 'leaf') {
const v = node.value;
if (v === null || v === undefined) return false;
if (typeof v === 'string' && !v.trim()) return false;
return true;
}
if (node.kind === 'group' && Array.isArray(node.children)) {
return node.children.some(hasValidLeaf);
}
return false;
}
// ── 订阅日志 ── // ── 订阅日志 ──
function subscribeLogs(jobId) { function subscribeLogs(jobId) {
...@@ -1985,6 +2186,7 @@ ...@@ -1985,6 +2186,7 @@
analysisConfigCollapsed, dataDictCollapsed, connectionCollapsed, analysisConfigCollapsed, dataDictCollapsed, connectionCollapsed,
customRules, customConfigCollapsed, customRules, customConfigCollapsed,
addCustomRule, removeCustomRule, clearCustomRules, addCustomRule, removeCustomRule, clearCustomRules,
fieldClassTagType,
onDbTypeChange, onDbTypeChange,
selectedField, selectFieldFilter, clearFieldFilter, selectedField, selectFieldFilter, clearFieldFilter,
}; };
...@@ -1993,6 +2195,161 @@ ...@@ -1993,6 +2195,161 @@
const app = createApp(App); const app = createApp(App);
app.use(ElementPlus); app.use(ElementPlus);
// ── 自定义规则 v2:递归条件树组件 ──────────────────────────
// 字段类型 → 可用比较符映射(前端镜像 web/core/rule_types.py)
// text → [contains, not_contains] 字符
// numeric → [gt, lt, eq] 数值
// date → [date_before, date_after] 日期
// 字段 data_type 来自后端 list_columns SQL 的 data_type 列(如 varchar / int / datetime)
const NUMERIC_TYPES_FRONT = new Set([
'int','integer','smallint','tinyint','bigint','mediumint',
'decimal','numeric','float','double','real','number',
'binary_float','binary_double','money','smallmoney','dec','year',
]);
const DATE_TYPES_FRONT = new Set([
'date','datetime','timestamp','time','year',
'smalldatetime','datetime2','datetimeoffset','interval',
]);
const TEXT_TYPES_FRONT = new Set([
'varchar','char','text','longtext','mediumtext','tinytext','enum','set',
'varchar2','nvarchar2','clob','nclob','long','longvarchar',
]);
function classifyField(dataType) {
const dt = (dataType || '').toLowerCase().trim();
if (!dt) return 'unknown';
if (NUMERIC_TYPES_FRONT.has(dt)) return 'numeric';
if (DATE_TYPES_FRONT.has(dt)) return 'date';
if (TEXT_TYPES_FRONT.has(dt)) return 'text';
return 'unknown';
}
const COMPARATORS_FRONT = {
text: [{value:'contains',label:'包含'},{value:'not_contains',label:'不包含'}],
numeric: [{value:'gt',label:'>'},{value:'lt',label:'<'},{value:'eq',label:'='}],
date: [{value:'date_before',label:'早于'},{value:'date_after',label:'晚于'}],
};
const INPUT_HINT_FRONT = {
text: '关键字(LIKE %x%)',
numeric: '数值',
date: '日期 YYYY-MM-DD',
};
function fieldClassTagType(dataType) {
const cls = classifyField(dataType);
if (cls === 'numeric') return 'warning';
if (cls === 'date') return 'info';
if (cls === 'text') return 'success';
return '';
}
app.component('condition-tree', {
// name 让 template 里递归调用 <condition-tree> 自身能找到组件
// (app.component 已经注册为全局组件,但 name 字段是更稳的兜底)
name: 'ConditionTree',
props: {
node: { type: Object, default: () => ({ kind: 'group', op: 'AND', children: [] }) },
column: { type: String, default: '' },
dataType: { type: String, default: '' },
depth: { type: Number, default: 0 },
},
emits: ['change', 'remove'],
template: `
<div :class="['cond-tree', isGroup ? 'cond-tree--group' : 'cond-tree--leaf', { 'cond-tree--nested': depth > 0 }]">
<template v-if="isGroup">
<div class="cond-tree-header">
<el-radio-group v-model="node.op" size="small" @change="emitChange">
<el-radio-button label="AND">AND</el-radio-button>
<el-radio-button label="OR">OR</el-radio-button>
</el-radio-group>
<span class="cond-tree-meta">{{ node.children.length }} 项</span>
<div class="cond-tree-actions">
<el-button size="small" @click="addLeaf">+ 子条件</el-button>
<el-button size="small" @click="addGroup">+ 嵌套组</el-button>
<el-button size="small" type="danger" text @click="emitRemove"
v-if="depth > 0">删除</el-button>
</div>
</div>
<div class="cond-tree-children">
<div v-for="(child, i) in node.children" :key="i" class="cond-tree-row">
<condition-tree
:node="child"
:column="column"
:data-type="dataType"
:depth="depth + 1"
@change="emitChange"
@remove="removeChild(i)"
/>
</div>
<div v-if="node.children.length === 0" class="cond-tree-empty">
空组 —— 点「+ 子条件」开始添加
</div>
</div>
</template>
<template v-else>
<div class="cond-leaf">
<el-select v-model="node.comparator" size="small" style="width: 110px"
@change="emitChange">
<el-option
v-for="c in comparators"
:key="c.value"
:label="c.label"
:value="c.value"
/>
</el-select>
<el-input
v-model="node.value"
size="small"
style="flex: 1"
:placeholder="inputHint"
@input="emitChange"
/>
<el-button size="small" type="danger" text @click="emitRemove">×</el-button>
</div>
</template>
</div>
`,
computed: {
isGroup() { return this.node && this.node.kind === 'group'; },
isLeaf() { return this.node && this.node.kind === 'leaf'; },
fieldClass() { return classifyField(this.dataType); },
comparators() {
const cls = this.fieldClass;
return COMPARATORS_FRONT[cls] || COMPARATORS_FRONT.text;
},
inputHint() {
const cls = this.fieldClass;
return INPUT_HINT_FRONT[cls] || '值';
},
},
methods: {
emitChange() { this.$emit('change', this.node); },
emitRemove() { this.$emit('remove'); },
addLeaf() {
if (!Array.isArray(this.node.children)) this.node.children = [];
this.node.children.push({
kind: 'leaf',
comparator: this.comparators[0].value,
value: '',
});
this.emitChange();
},
addGroup() {
if (!Array.isArray(this.node.children)) this.node.children = [];
this.node.children.push({
kind: 'group',
op: 'AND',
children: [],
});
this.emitChange();
},
removeChild(i) {
if (Array.isArray(this.node.children)) {
this.node.children.splice(i, 1);
this.emitChange();
}
},
},
});
for (const [key, comp] of Object.entries(ElementPlusIconsVue)) { for (const [key, comp] of Object.entries(ElementPlusIconsVue)) {
app.component(key, comp); app.component(key, comp);
} }
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment