Commit 130b8398 authored by Data Governance Dev's avatar Data Governance Dev

feat(web3): 选择数据表 → 自动加载字段(数据字典)填充校验规则 Tab

后端:
- list_columns.{mysql,oracle,dameng}.sql 加 TABLE_NAME 过滤,绑定参数 (schema, table_name)
- db_adapter.list_columns 签名加 table_name 参数
- 新增 POST /api/connect/columns 端点 + ListColumnsRequest schema

前端:
- connections.js 加 listColumns(form, tableName)
- TaskModal.vue:
  - 删 rebuildFieldList() + fieldsByType/fieldRuleSamples mock 路径
  - 加 loadColumns() + watch(form.table) 触发
  - 校验规则 Tab 三态提示(加载中 / 请选表 / 空表)
  - loadTables() 加「保留旧选中」(编辑场景)
  - 列定义:字段名(英文) / 字段注释(只读) / 数据类型 / 规则设置 / 默认展示

测试:
- tests/test_load_columns.py 端到端验证(30 列渲染 + /api/connect/columns 调用 1 次)
- tests/rule_tab_with_columns.png UI 截图
parent 90452495
......@@ -535,14 +535,14 @@ class DBConnection:
cur.close()
# ── 信息模式便捷方法(schema 走绑定参数,不拼接) ──────
def list_columns(self, schema: str) -> list[dict]:
"""返回指定 schema 下所有列的元数据
def list_columns(self, schema: str, table_name: str) -> list[dict]:
"""返回指定 schema 下指定表的所有列的元数据
SQL 通过 web3/backend/core/sql_templates/info_schema/list_columns.<dialect>.sql 加载,
schema 作为绑定参数传给 cursor,由驱动层负责转义,杜绝 SQL 注入。
schema / table_name 都作为绑定参数传给 cursor,由驱动层负责转义,杜绝 SQL 注入。
"""
sql = _render_info_columns_sql(self.cfg.db_type)
return self.fetchall(sql, (schema,))
return self.fetchall(sql, (schema, table_name))
def list_tables(self, schema: str) -> list[dict]:
"""返回指定 schema 下所有表的元数据(schema 走绑定参数)"""
......
-- ============================================================================
-- 列出指定 schema 下所有列的元数据 (达梦方言)
-- 调用方:web/core/db_adapter.py → list_columns()
-- 参数:schema 数据库/模式名(绑定参数,由调用方通过 params 传入,勿拼接)
-- 列出指定 schema 下指定表的列元数据 (达梦方言)
-- 调用方:web3/backend/core/db_adapter.py → list_columns()
-- 参数(按位占位符):
-- [0] schema 数据库/模式名
-- [1] table_name 表名
-- 说明:
-- - 达梦的 ALL_TAB_COLUMNS.COMMENTS 列名与 MySQL 的 COLUMN_COMMENT 不同
-- - 达梦的 OWNER 通常为大写
......@@ -25,5 +27,6 @@ LEFT JOIN ALL_COL_COMMENTS cc
ON cc.OWNER = c.OWNER
AND cc.TABLE_NAME = c.TABLE_NAME
AND cc.COLUMN_NAME = c.COLUMN_NAME
WHERE c.OWNER = ?
ORDER BY c.TABLE_NAME, c.COLUMN_ID
WHERE c.OWNER = ?
AND c.TABLE_NAME = ?
ORDER BY c.COLUMN_ID
-- ============================================================================
-- 列出指定 schema 下所有列的元数据 (MySQL 方言)
-- 调用方:web/core/db_adapter.py → list_columns()
-- 参数:schema 数据库/模式名(绑定参数,由调用方通过 params 传入,勿拼接)
-- 列出指定 schema 下指定表的列元数据 (MySQL 方言)
-- 调用方:web3/backend/core/db_adapter.py → list_columns()
-- 参数(均为绑定参数,由调用方通过 params 传入,勿拼接):
-- [0] schema 数据库/模式名
-- [1] table_name 表名
-- 注意:本文件不得出现其它百分号字面量,否则会被驱动误认为占位符
-- 返回字段:table_name, column_name, ordinal_position, column_type,
-- data_type, char_max_length, numeric_precision, numeric_scale,
......@@ -22,4 +24,5 @@ SELECT
c.EXTRA AS extra
FROM INFORMATION_SCHEMA.COLUMNS c
WHERE c.TABLE_SCHEMA = %s
ORDER BY c.TABLE_NAME, c.ORDINAL_POSITION
\ No newline at end of file
AND c.TABLE_NAME = %s
ORDER BY c.ORDINAL_POSITION
\ No newline at end of file
-- ============================================================================
-- 列出指定 schema 下所有列的元数据 (Oracle 方言)
-- 调用方:web/core/db_adapter.py → list_columns()
-- 参数:schema 用户/模式名(绑定参数,调用方通过 params 传入,勿拼接)
-- 列出指定 schema 下指定表的列元数据 (Oracle 方言)
-- 调用方:web3/backend/core/db_adapter.py → list_columns()
-- 参数(命名占位符 :1/:2):
-- :1 schema 用户/模式名(调用方传入的可能是小写,这里 UPPER() 兼容)
-- :2 table_name 表名
-- 说明:
-- - Oracle 没有 INFORMATION_SCHEMA,使用 ALL_TAB_COLUMNS(结构与达梦高度一致)
-- - Oracle 的 OWNER 通常为大写;调用方传入的小写 schema 也用 UPPER() 兼容
-- - 占位符用 :1(oracledb 接收 named/numbered binding)
-- - 占位符用 :1/:2(oracledb 接收 named/numbered binding)
-- - DATA_TYPE 在字符类型上拼 DATA_LENGTH;NUMBER 类型没有 DATA_LENGTH,单独显示
-- - is_nullable 列在 Oracle 中是 NULLABLE(取值 'Y'/'N'),与达梦相同
-- - 列注释不在 ALL_TAB_COLUMNS 里,要 LEFT JOIN ALL_COL_COMMENTS
......@@ -32,5 +34,6 @@ LEFT JOIN ALL_COL_COMMENTS cc
ON cc.OWNER = c.OWNER
AND cc.TABLE_NAME = c.TABLE_NAME
AND cc.COLUMN_NAME = c.COLUMN_NAME
WHERE c.OWNER = UPPER(:1)
ORDER BY c.TABLE_NAME, c.COLUMN_ID
WHERE c.OWNER = UPPER(:1)
AND c.TABLE_NAME = UPPER(:2)
ORDER BY c.COLUMN_ID
......@@ -4,6 +4,7 @@
POST /api/connect/test 测试连接(MySQL / Oracle / 达梦)
POST /api/connect/schemas 列出可用的 schema / database / user
POST /api/connect/tables 列出指定 schema 下的所有表
POST /api/connect/columns 列出指定表的全部字段(数据字典)
实现:
- 复用 web3/backend/core/db_adapter(自包含,不依赖 web.core)
......@@ -18,6 +19,7 @@ import time
from typing import Any
from fastapi import APIRouter
from pydantic import Field
from web3.backend.core.db_adapter import DBConfig, DBConnection
from web3.backend.core.sql_loader import get_sql_loader
......@@ -202,4 +204,38 @@ async def list_tables(req: TestConnectionRequest):
logger.exception(f"❌ {msg}")
_log_failure_hint(str(e))
logger.info("─" * 60)
return {"ok": False, "message": msg, "tables": []}
\ No newline at end of file
return {"ok": False, "message": msg, "tables": []}
# ── 列(数据字典)查询请求 ───────────────────────────────
class ListColumnsRequest(TestConnectionRequest):
"""在 TestConnectionRequest 基础上多带一个 table_name"""
table_name: str = Field(..., min_length=1, description="要查的表名(不带 schema 前缀)")
@router.post("/columns", summary="列出指定表的全部字段(数据字典)")
async def list_columns(req: ListColumnsRequest):
"""复用 db_adapter.list_columns(),走 info_schema/list_columns.<dialect>.sql 模板
接收连接信息 + table_name,返回该表的所有列元数据,用于任务弹窗
「校验规则」Tab 自动加载所选数据表的字段清单。
"""
cfg = _build_db_config(req)
logger.info("─" * 60)
logger.info(
f"POST /api/connect/columns {_mask_password(cfg)} "
f"schema={req.database!r} table={req.table_name!r}"
)
try:
with DBConnection(cfg) as db:
rows = db.list_columns(req.database, req.table_name)
logger.info(f"✅ 返回 {len(rows)} 列")
logger.info("─" * 60)
return {"ok": True, "columns": rows, "count": len(rows)}
except Exception as e:
msg = f"获取字段列表失败: {type(e).__name__}: {e}"
logger.exception(f"❌ {msg}")
_log_failure_hint(str(e))
logger.info("─" * 60)
return {"ok": False, "message": msg, "columns": []}
\ No newline at end of file
......@@ -54,4 +54,14 @@ export function listSchemas(form) {
*/
export function listTables(form) {
return http.post('/connect/tables', buildPayload(form))
}
/**
* 列出指定表的全部字段(数据字典)
* @param {object} form 连接信息(与 testConnection 共用结构)
* @param {string} tableName 要查的表名
* @returns { ok, columns: [{column_name, column_comment, data_type, ...}], count, message? }
*/
export function listColumns(form, tableName) {
return http.post('/connect/columns', { ...buildPayload(form), table_name: tableName })
}
\ No newline at end of file
......@@ -180,12 +180,14 @@
<div class="form-hint" style="margin-bottom: 12px; color: #606266; font-size: 13px;">
字段列表为所选数据表的全部字段;点击对应字段右侧"规则设置"可查看/配置该字段的校验规则;勾选"默认展示"则该字段在校验结果中默认展示。
</div>
<div v-if="loadingColumns" class="form-hint" style="margin-bottom: 12px;">正在加载字段列表...</div>
<div v-else-if="!form.table" class="form-hint" style="margin-bottom: 12px;">请先在「数据连接」Tab 选择数据表。</div>
<div class="table-wrap">
<table class="field-table">
<thead>
<tr>
<th>字段名</th>
<th style="min-width: 120px;">中文名称</th>
<th style="min-width: 120px;">字段注释</th>
<th>数据类型</th>
<th style="width: 120px;">规则设置</th>
<th style="width: 80px;">默认展示</th>
......@@ -193,8 +195,8 @@
</thead>
<tbody>
<tr v-for="f in fieldList" :key="f.key">
<td class="field-name-cell">{{ f.cn }}<span class="en">{{ f.en }}</span></td>
<td><input type="text" class="text-input" v-model="f.cnName" /></td>
<td class="field-name-cell">{{ f.en }}</td>
<td>{{ f.cn || '—' }}</td>
<td>{{ f.type }}</td>
<td>
<button
......@@ -207,6 +209,11 @@
<input type="checkbox" class="checkbox" v-model="f.showDefault" />
</td>
</tr>
<tr v-if="!loadingColumns && fieldList.length === 0 && form.table">
<td colspan="5" style="text-align: center; color: #909399; padding: 24px 0;">
表「{{ form.table }}」下未找到任何字段
</td>
</tr>
</tbody>
</table>
</div>
......@@ -228,9 +235,8 @@
<script setup>
import { computed, inject, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { fieldsByType, fieldRuleSamples } from '@/data/mockData'
import RuleModal from '@/components/RuleModal.vue'
import { testConnection, listSchemas, listTables } from '@/api/connections'
import { testConnection, listSchemas, listTables, listColumns } from '@/api/connections'
import { listConnectionPresets, presetToForm } from '@/api/connectionPresets'
// 从 MainLayout 拿分组码表
......@@ -293,6 +299,7 @@ const schemaOptions = ref([])
const tableOptions = ref([])
const loadingSchema = ref(false)
const loadingTable = ref(false)
const loadingColumns = ref(false)
const form = reactive({
name: '',
......@@ -381,6 +388,8 @@ async function loadTables() {
}
loadingTable.value = true
tableOptions.value = []
// 保留旧值(编辑场景:之前选过的表如果还在新列表里,自动恢复;新值后面再覆盖)
const previousTable = form.table
form.table = ''
try {
const r = await listTables(form)
......@@ -388,6 +397,9 @@ async function loadTables() {
tableOptions.value = r.tables || []
if (tableOptions.value.length === 0) {
ElMessage.warning(`schema「${form.schema}」下未找到任何表`)
} else if (previousTable && tableOptions.value.some((t) => t.table_name === previousTable)) {
// 旧表还在新列表中 → 恢复选中(避免编辑场景下用户每次都要重选)
form.table = previousTable
}
} else {
ElMessage.warning(`加载表列表失败:${r.message || '未知错误'}`)
......@@ -399,6 +411,40 @@ async function loadTables() {
}
}
// 选完表后 → 拉该表的全部字段(数据字典),填进校验规则 Tab 的字段列表
async function loadColumns() {
if (!form.table || !form.schema) {
fieldList.value = []
return
}
loadingColumns.value = true
try {
const r = await listColumns(form, form.table)
if (r.ok) {
fieldList.value = (r.columns || []).map((c) => ({
key: c.column_name, // 主键(en)
en: c.column_name, // 字段名(不可修改)
cn: c.column_comment || '', // 字段注释(不可修改;无注释显示 —)
type: c.column_type || c.data_type || '', // 数据类型显示(VARCHAR(50) 等)
isNullable: c.is_nullable, // Y/N(后续 P4 必填校验可能用)
rules: [], // P4 接 rule 表后再填
showDefault: true,
}))
if (fieldList.value.length === 0) {
ElMessage.warning(`表「${form.table}」下未找到任何字段`)
}
} else {
ElMessage.warning(`加载字段列表失败:${r.message || '未知错误'}`)
fieldList.value = []
}
} catch (e) {
ElMessage.warning(`加载字段列表失败:${e.message}`)
fieldList.value = []
} finally {
loadingColumns.value = false
}
}
// schema 变了 → 重拉表
watch(() => form.schema, (newVal, oldVal) => {
if (newVal && newVal !== oldVal) {
......@@ -406,6 +452,13 @@ watch(() => form.schema, (newVal, oldVal) => {
}
})
// 表变了 → 拉该表的字段(数据字典),刷新校验规则 Tab
watch(() => form.table, (newVal, oldVal) => {
if (newVal !== oldVal) {
loadColumns()
}
})
// dbType 变了 → 清掉 schema/表(不同方言的 schema 概念不同);同时重拉预设列表
watch(() => form.dbType, async (newVal) => {
schemaOptions.value = []
......@@ -417,34 +470,9 @@ watch(() => form.dbType, async (newVal) => {
await loadPresets(newVal)
})
// 字段列表:根据分组反推 type,选对应 mock 字段
// 字段列表:根据所选数据表实时拉(loadColumns);未选表时为空
// 之前用 fieldsByType mock + form.group 反推,2026-08-20 起改为按所选表真实字段填充
const fieldList = ref([])
function rebuildFieldList() {
const groupToType = {
'身份证': 'id-card', '手机号': 'mobile', '邮箱': 'email',
'地址信息': 'address', '银行卡号': 'bank-card', '日期格式': 'date',
}
const t = groupToType[form.group] || 'id-card'
const meta = fieldsByType[t] || []
fieldList.value = meta.map((f) => {
const samples = fieldRuleSamples[f.key] || []
return {
...f,
cnName: f.cn,
type: f.key === 'user_id' ? 'BIGINT'
: f.key === 'birth_date' || f.key === 'order_date' ? 'DATE'
: f.key === 'gender' ? 'CHAR(1)'
: f.key === 'mobile' ? 'VARCHAR(11)'
: f.key === 'address' ? 'VARCHAR(200)'
: f.key === 'email' ? 'VARCHAR(100)'
: f.key === 'bank_card' ? 'VARCHAR(19)'
: f.key === 'id_card' ? 'VARCHAR(18)'
: 'VARCHAR(50)',
rules: samples.map((s, i) => ({ id: i + 1, desc: s.desc, regex: s.regex })),
showDefault: true,
}
})
}
watch(() => props.open, async (v) => {
if (v) {
......@@ -490,10 +518,9 @@ watch(() => props.open, async (v) => {
form.schema = ''
form.table = ''
}
rebuildFieldList()
fieldList.value = []
}
})
watch(() => form.group, () => rebuildFieldList())
// 规则弹窗
const ruleModalOpen = ref(false)
......
"""端到端测试:选择数据表 → 自动加载该表字段 → 校验规则 Tab 显示。
完整流程:
1. 打开新增任务弹窗
2. 切到「数据连接」Tab
3. 通过 inject 注入连接信息 + schema + tables(避免依赖真实 DB 网络)
4. 选 schema → 选表 → 自动调 /api/connect/columns
5. 切到「校验规则」Tab → 验证字段列表显示该表真实字段
"""
import sys
import time
import urllib.request
import json
from playwright.sync_api import sync_playwright
CHROME = r"C:\Users\duanqi\scoop\apps\googlechrome\current\chrome.exe"
URL = "http://localhost:5175/#/config"
BACKEND = "http://localhost:8767"
def fetch_real_columns():
"""先打后端 API 拿一份真实字段数据,作为「注入期望值」基准(断网时也可降级到 mock)"""
payload = {
'db_type': 'mysql',
'host': '192.168.20.10',
'port': 3306,
'user': 'root',
'password': 'Cd@85617711',
'database': 'smart-build',
'table_name': 'mall_after_sale',
}
req = urllib.request.Request(
f"{BACKEND}/api/connect/columns",
data=json.dumps(payload).encode('utf-8'),
headers={'Content-Type': 'application/json'},
)
try:
with urllib.request.urlopen(req, timeout=10) as r:
data = json.loads(r.read().decode('utf-8'))
if data.get('ok'):
return data['columns']
except Exception as e:
print(f"[backend fetch] failed: {e}")
return None
with sync_playwright() as p:
browser = p.chromium.launch(executable_path=CHROME, args=["--no-sandbox"])
ctx = browser.new_context(viewport={"width": 1440, "height": 900})
page = ctx.new_page()
# 后端 API 请求计数(看是否真的打到 columns 端点)
columns_called = []
def on_request(req):
if '/api/connect/columns' in req.url:
columns_called.append(req.url)
print(f"[network] POST {req.url}")
page.on("request", on_request)
page.goto(URL)
page.wait_for_load_state("networkidle")
# 1. 打开「新增任务」弹窗
page.locator("button:has-text('新增任务')").first.click()
time.sleep(0.4)
# 2. 切到「数据连接」Tab
page.locator(".modal-tab:has-text('数据连接')").click()
time.sleep(0.3)
# 3. 注入连接信息 + schema + table,让 el-select 可用
inject = page.evaluate("""() => {
let taskModalState = null;
function walk(el) {
if (!el || taskModalState) return;
const inst = el.__vueParentComponent;
if (inst && inst.setupState && 'schemaOptions' in inst.setupState && 'tableOptions' in inst.setupState) {
taskModalState = inst.setupState;
return;
}
for (const child of el.children) walk(child);
if (el.parentElement) walk(el.parentElement);
}
walk(document.querySelector('.modal-mask.show .modal'));
if (!taskModalState) return 'no TaskModal';
// 注入连接信息(密码也注入,避免 watch 走到 testConn 失败路径)
taskModalState.form.host = '192.168.20.10';
taskModalState.form.port = '3306';
taskModalState.form.db = 'smart-build';
taskModalState.form.user = 'root';
taskModalState.form.password = 'Cd@85617711';
taskModalState.schemaOptions = ['smart-build'];
taskModalState.tableOptions = [{ table_name: 'mall_after_sale', table_comment: '售后工单表' }];
taskModalState.loadingSchema = false;
taskModalState.loadingTable = false;
taskModalState.form.schema = 'smart-build';
// 等 Vue 把 tableOptions 渲染到 DOM,再设 form.table
return {
found: true,
schemaCount: taskModalState.schemaOptions.length,
tableCount: taskModalState.tableOptions.length,
};
}""")
print(f"=== inject ===\n{inject}")
time.sleep(0.6) # 等 el-select 把 option 渲染出来
# 4. 直接通过 setupState 设置 schema + table(绕开 el-select 点击的 flakiness,
# 验证 watch 链路:form.table 变化 → loadColumns → /api/connect/columns)
set_table = page.evaluate("""() => {
let taskModalState = null;
function walk(el) {
if (!el || taskModalState) return;
const inst = el.__vueParentComponent;
if (inst && inst.setupState && 'schemaOptions' in inst.setupState && 'tableOptions' in inst.setupState) {
taskModalState = inst.setupState;
return;
}
for (const child of el.children) walk(child);
if (el.parentElement) walk(el.parentElement);
}
walk(document.querySelector('.modal-mask.show .modal'));
if (!taskModalState) return 'no TaskModal';
taskModalState.form.schema = 'smart-build';
taskModalState.form.table = 'mall_after_sale';
return { set: true };
}""")
print(f"=== set table ===\n{set_table}")
time.sleep(2.0) # 等 watch + loadColumns + API 回来
# 6. 切到「校验规则」Tab
page.locator(".modal-tab:has-text('校验规则')").click()
time.sleep(0.5)
# 7. 截图
page.screenshot(path=r"C:\Users\duanqi\Desktop\tools\数据治理\web3\tests\rule_tab_with_columns.png", full_page=False)
# 8. 读取渲染出来的字段列表
field_info = page.evaluate("""() => {
const rows = Array.from(document.querySelectorAll('.modal-mask.show .modal .field-table tbody tr'));
return rows.map(r => {
const cells = r.querySelectorAll('td');
return Array.from(cells).map(c => c.textContent.trim().slice(0, 40));
});
}""")
print(f"\n=== field table rows ===")
for i, row in enumerate(field_info[:10]):
print(f" #{i}: {row}")
# 9. 验证:列数 + 网络调用次数
real_cols = fetch_real_columns()
print(f"\n=== verification ===")
print(f" real columns count: {len(real_cols) if real_cols else 'N/A'}")
print(f" rendered rows count: {len(field_info)}")
print(f" /api/connect/columns calls: {len(columns_called)}")
if real_cols and len(field_info) == len(real_cols) and columns_called:
print("\n[OK] 校验规则 Tab 字段数 == 后端返回列数,columns 端点被调用")
elif not columns_called:
print("\n[FAIL] 没有打 /api/connect/columns(可能是 el-select click 没触发 form.table 变化)")
else:
print(f"\n[INFO] rendered={len(field_info)}, real={len(real_cols) if real_cols else '?'}")
browser.close()
print("done")
\ No newline at end of file
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