Commit 337a9ad3 authored by Data Governance Dev's avatar Data Governance Dev

feat(web3): P2 任务分组码表 + 前端接码表

后端:
- db/schema.sql:建 6 张业务表(task_group / task / field / rule / validation_run / violation)
- db/seed.py:DEFAULT_TASK_GROUPS = 6 个(身份证/手机号/邮箱/地址信息/银行卡号/日期格式)
- db/database.py:init_db() 首次启动读 schema.sql + seed
- models/task_group.py:ORM + to_dict()
- routers/task_groups.py:GET / POST(name 重名 409,按 ord 升序)
- app.py:注册 task_groups 路由 + 启动 init_db()

前端:
- src/api/client.js:fetch 封装(get/post/put/delete)
- src/api/taskGroups.js:listTaskGroups / createTaskGroup
- MainLayout.vue:
  - onMounted 拉 /api/task-groups,provide('taskGroups') 给子组件
  - NAME_TO_MENU(name → path+icon)只显示码表里有映射的项
- TaskModal.vue / TaskConfigView.vue:inject('taskGroups'),分组下拉/筛选用同一数据源
parent 4ffe4159
"""web3 后端 · FastAPI 入口(P1 骨架)""" """web3 后端 · FastAPI 入口"""
from __future__ import annotations from __future__ import annotations
import os import os
...@@ -31,6 +31,12 @@ logger.info(f"Python: {sys.version.split()[0]}, 平台: {sys.platform}") ...@@ -31,6 +31,12 @@ logger.info(f"Python: {sys.version.split()[0]}, 平台: {sys.platform}")
logger.info(f"工作目录: {PROJECT_ROOT}") logger.info(f"工作目录: {PROJECT_ROOT}")
# ── DB 首次启动初始化 ──
from web3.backend.db.database import init_db, DB_PATH # noqa: E402
init_db()
logger.info(f"DB: {DB_PATH} (存在={DB_PATH.exists()})")
# ── 创建 FastAPI 应用 ── # ── 创建 FastAPI 应用 ──
app = FastAPI( app = FastAPI(
title="数据治理 Web 工具 · web3 后端", title="数据治理 Web 工具 · web3 后端",
...@@ -48,6 +54,12 @@ app.add_middleware( ...@@ -48,6 +54,12 @@ app.add_middleware(
) )
# ── 注册路由 ──
from web3.backend.routers.task_groups import router as task_groups_router # noqa: E402
app.include_router(task_groups_router, prefix="/api")
logger.info("已注册路由:/api/task-groups")
# ── 健康检查 ── # ── 健康检查 ──
@app.get("/api/health") @app.get("/api/health")
async def health(): async def health():
......
"""SQLAlchemy 引擎与会话(P1 骨架) """SQLAlchemy 引擎与会话
- 单例 engine(sqlite + check_same_thread=False,FastAPI 跨线程访问需要) - 单例 engine(sqlite + check_same_thread=False,FastAPI 跨线程访问需要)
- SessionLocal:每次请求拿一个新 session - SessionLocal:每次请求拿一个新 session
- init_db() 占位;P2 起会读 schema.sql + seed - init_db():首次启动时若 DB 文件不存在,跑 schema.sql 建表 + seed 灌入种子数据
""" """
from __future__ import annotations from __future__ import annotations
from contextlib import contextmanager from contextlib import contextmanager
from pathlib import Path
from sqlalchemy import create_engine, event from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker, Session, declarative_base from sqlalchemy.orm import sessionmaker, Session, declarative_base
...@@ -57,7 +58,16 @@ def session_scope() -> Session: ...@@ -57,7 +58,16 @@ def session_scope() -> Session:
db.close() db.close()
# ── 首次启动初始化(占位,P2 接入 schema + seed) ── # ── 首次启动初始化 ──
def init_db() -> None: def init_db() -> None:
"""P2 起:若 DB 文件不存在,建表 + 灌种子。已存在则什么都不做。""" """若 DB 文件不存在,建表 + 灌种子。已存在则什么都不做。"""
pass from web3.backend.db import seed
\ No newline at end of file if DB_PATH.exists():
return
schema = Path(__file__).parent / "schema.sql"
sql = schema.read_text(encoding="utf-8")
with engine.begin() as conn:
for stmt in [s.strip() for s in sql.split(";") if s.strip()]:
conn.exec_driver_sql(stmt)
seed.seed_task_groups()
print(f"[init_db] 已建表并 seed:{DB_PATH}")
\ No newline at end of file
-- ============================================================
-- web3 主业务库 · schema.sql
-- ============================================================
-- 数据库:data/web3.db(SQLite)
-- 维护方式:DB 文件首次生成时由 db/database.py::init_db() 全量执行
-- 注意:本文件是「从零建表」脚本,已存在 DB 时不会执行。
-- 表结构变更后续走迁移(plan §11 待办)。
-- ============================================================
-- ─────────────────────────────────────────────────────────────
-- 1) task_group · 任务分组码表
-- ─────────────────────────────────────────────────────────────
-- 用于左侧菜单「校验任务」分组渲染 + 「任务配置」分组筛选 + 任务弹窗分组下拉
-- 数据来源:当前来自 mock 需求里的 6 个分组
-- ord 显示顺序(小的靠前),由 seed 控制
-- created_at 建表时间
CREATE TABLE task_group (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
ord INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_task_group_ord ON task_group(ord);
-- ─────────────────────────────────────────────────────────────
-- 2) task · 任务
-- ─────────────────────────────────────────────────────────────
-- 一个任务 = 一个分组 + 一张源表 + 一组字段 + 一组规则 + 一次最近检测
-- data_source_path:data/sources/**/*.sqlite 的相对路径(相对项目根)
-- source_table: 该 sqlite 文件里的一张表名
-- status: 启用 / 停用
-- db_type / conn_json:仅 UI 展示冗余;真实校验读本地 sqlite
CREATE TABLE task (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
group_id INTEGER NOT NULL REFERENCES task_group(id),
data_source_path TEXT,
source_table TEXT,
status TEXT NOT NULL DEFAULT '启用',
description TEXT,
db_type TEXT NOT NULL DEFAULT 'MySQL',
conn_json TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_task_group ON task(group_id);
-- ─────────────────────────────────────────────────────────────
-- 3) field · 字段(每个字段属于一个 task)
-- ─────────────────────────────────────────────────────────────
CREATE TABLE field (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER NOT NULL REFERENCES task(id) ON DELETE CASCADE,
field_key TEXT NOT NULL,
field_cn TEXT,
data_type TEXT,
show_default INTEGER NOT NULL DEFAULT 1,
ord INTEGER NOT NULL DEFAULT 0,
UNIQUE(task_id, field_key)
);
CREATE INDEX idx_field_task ON field(task_id);
-- ─────────────────────────────────────────────────────────────
-- 4) rule · 规则(每条规则挂在一个 field 下)
-- ─────────────────────────────────────────────────────────────
CREATE TABLE rule (
id INTEGER PRIMARY KEY AUTOINCREMENT,
field_id INTEGER NOT NULL REFERENCES field(id) ON DELETE CASCADE,
desc TEXT NOT NULL,
regex TEXT,
ord INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_rule_field ON rule(field_id);
-- ─────────────────────────────────────────────────────────────
-- 5) validation_run · 一次检测任务(历史持久化;运行时进度走内存)
-- ─────────────────────────────────────────────────────────────
CREATE TABLE validation_run (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER NOT NULL REFERENCES task(id),
status TEXT NOT NULL DEFAULT 'pending',
total_rows INTEGER NOT NULL DEFAULT 0,
bad_rows INTEGER NOT NULL DEFAULT 0,
started_at TIMESTAMP,
finished_at TIMESTAMP,
error_msg TEXT
);
CREATE INDEX idx_run_task ON validation_run(task_id);
-- ─────────────────────────────────────────────────────────────
-- 6) violation · 不合规明细
-- ─────────────────────────────────────────────────────────────
CREATE TABLE violation (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id INTEGER NOT NULL REFERENCES validation_run(id) ON DELETE CASCADE,
row_pk TEXT,
payload_json TEXT,
issues_json TEXT,
bad_field_id INTEGER REFERENCES field(id)
);
CREATE INDEX idx_violation_run ON violation(run_id);
\ No newline at end of file
"""种子数据 —— 首次建库后灌入基础数据
- seed_task_groups():6 个分组(来自 mock 需求里的左侧菜单「校验任务」)
"""
from __future__ import annotations
import logging
from web3.backend.db.database import session_scope
from web3.backend.models.task_group import TaskGroup
logger = logging.getLogger(__name__)
# ── DML ─────────────────────────────────────────────────────
# 来自 mock web3/requirement/数据质量检测结果.html 里左侧菜单「校验任务」下的 6 个分组
# 注意:不要把「任务配置」加进来 —— 它是导航项不是分组
DEFAULT_TASK_GROUPS = [
("身份证", 10),
("手机号", 20),
("邮箱", 30),
("地址信息", 40),
("银行卡号", 50),
("日期格式", 60),
]
def seed_task_groups() -> None:
"""首次建库时插入 6 个默认分组;已存在则跳过。"""
with session_scope() as db:
existed = db.query(TaskGroup).count()
if existed > 0:
logger.info(f"[seed] task_group 已有 {existed} 条,跳过")
return
for name, ord in DEFAULT_TASK_GROUPS:
db.add(TaskGroup(name=name, ord=ord))
logger.info(f"[seed] task_group 新增 {len(DEFAULT_TASK_GROUPS)} 条")
\ No newline at end of file
"""task_group ORM 模型
对应 schema.sql 里的:
CREATE TABLE task_group (
id, name, ord, created_at
)
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import Column, Integer, String, DateTime
from sqlalchemy.sql import func
from web3.backend.db.database import Base
class TaskGroup(Base):
__tablename__ = "task_group"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String, nullable=False, unique=True)
ord = Column(Integer, nullable=False, default=0)
created_at = Column(DateTime, nullable=False, server_default=func.current_timestamp())
def to_dict(self) -> dict:
return {
"id": self.id,
"name": self.name,
"ord": self.ord,
"created_at": self.created_at.isoformat() if isinstance(self.created_at, datetime) else self.created_at,
}
def __repr__(self) -> str:
return f"<TaskGroup id={self.id} name={self.name!r} ord={self.ord}>"
\ No newline at end of file
"""任务分组路由
端点:
GET /api/task-groups 列表(按 ord 升序)—— 供左侧菜单 + 任务弹窗分组下拉
POST /api/task-groups 新建分组
设计:
- 左侧菜单「校验任务」下的 6 个分组来自 mock 需求,已通过 seed 灌入
- 前端不传分组 → 不需要分页/筛选(量很小)
- name 唯一 → 冲突时返回 409
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from web3.backend.db.database import get_session
from web3.backend.models.task_group import TaskGroup
router = APIRouter(prefix="/task-groups", tags=["task-groups"])
# ── Pydantic schema ──
class TaskGroupOut(BaseModel):
id: int
name: str
ord: int
created_at: str
class Config:
from_attributes = True
class TaskGroupCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=50, description="分组名(唯一)")
ord: int = Field(0, description="显示顺序;小的靠前")
# ── 路由 ──
@router.get("", response_model=list[TaskGroupOut], summary="列出所有任务分组")
def list_task_groups(db: Session = Depends(get_session)) -> list[TaskGroupOut]:
rows = db.query(TaskGroup).order_by(TaskGroup.ord, TaskGroup.id).all()
return [TaskGroupOut(**g.to_dict()) for g in rows]
@router.post("", response_model=TaskGroupOut, summary="新建任务分组")
def create_task_group(payload: TaskGroupCreate, db: Session = Depends(get_session)) -> TaskGroupOut:
g = TaskGroup(name=payload.name.strip(), ord=payload.ord)
db.add(g)
try:
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(status_code=409, detail=f"分组名已存在:{payload.name}")
db.refresh(g)
return TaskGroupOut(**g.to_dict())
\ No newline at end of file
/**
* 极简 fetch 封装
*
* - baseURL:开发模式走 vite proxy (/api),生产可改为绝对地址
* - 统一 JSON 解析 + 错误抛出
* - 不引 axios,省一个依赖
*/
const BASE = '/api'
async function request(path, { method = 'GET', body } = {}) {
const init = {
method,
headers: { 'Content-Type': 'application/json' },
}
if (body !== undefined) init.body = body
const res = await fetch(`${BASE}${path}`, init)
if (!res.ok) {
let msg = `HTTP ${res.status}`
try {
const body = await res.json()
msg = body.detail || body.message || msg
} catch (_) { /* 非 JSON */ }
throw new Error(msg)
}
if (res.status === 204) return null
return res.json()
}
export const http = {
get: (path) => request(path, { method: 'GET' }),
post: (path, body) => request(path, { method: 'POST', body: JSON.stringify(body ?? {}) }),
put: (path, body) => request(path, { method: 'PUT', body: JSON.stringify(body ?? {}) }),
patch: (path, body) => request(path, { method: 'PATCH', body: JSON.stringify(body ?? {}) }),
delete: (path) => request(path, { method: 'DELETE' }),
}
\ No newline at end of file
/**
* 任务分组 API 客户端
*
* - GET /api/task-groups 列表(按 ord 升序)
* - POST /api/task-groups 新建
*/
import { http } from './client'
export function listTaskGroups() {
return http.get('/task-groups')
}
export function createTaskGroup(payload) {
return http.post('/task-groups', payload)
}
\ No newline at end of file
...@@ -175,11 +175,14 @@ ...@@ -175,11 +175,14 @@
</template> </template>
<script setup> <script setup>
import { computed, reactive, ref, watch } from 'vue' import { computed, inject, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { fieldsByType, fieldRuleSamples } from '@/data/mockData' import { fieldsByType, fieldRuleSamples } from '@/data/mockData'
import RuleModal from '@/components/RuleModal.vue' import RuleModal from '@/components/RuleModal.vue'
// 从 MainLayout 拿分组码表
const taskGroups = inject('taskGroups', ref([]))
const props = defineProps({ const props = defineProps({
open: { type: Boolean, default: false }, open: { type: Boolean, default: false },
mode: { type: String, default: 'add' }, mode: { type: String, default: 'add' },
...@@ -196,7 +199,7 @@ const tabs = [ ...@@ -196,7 +199,7 @@ const tabs = [
] ]
const activeTab = ref('basic') const activeTab = ref('basic')
const groupOptions = ['身份证', '手机号', '邮箱', '地址信息', '银行卡号', '日期格式'] const groupOptions = computed(() => taskGroups.value.map((g) => g.name))
const dbTypes = ['MySQL', 'Oracle', 'SQL Server', 'PostgreSQL', '达梦 DM', 'JDBC 自定义'] const dbTypes = ['MySQL', 'Oracle', 'SQL Server', 'PostgreSQL', '达梦 DM', 'JDBC 自定义']
const schemaOptions = ['public', 'customer', 'sys'] const schemaOptions = ['public', 'customer', 'sys']
const tableOptions = ['t_user_info(用户信息表)', 't_customer(客户主表)', 't_employee(员工档案表)'] const tableOptions = ['t_user_info(用户信息表)', 't_customer(客户主表)', 't_employee(员工档案表)']
......
...@@ -13,12 +13,12 @@ ...@@ -13,12 +13,12 @@
> >
<div class="menu-group-title">校验任务</div> <div class="menu-group-title">校验任务</div>
<el-menu-item <el-menu-item
v-for="t in taskMenus" v-for="m in menuGroups"
:key="t.path" :key="m.path"
:index="t.path" :index="m.path"
> >
<el-icon><component :is="t.icon" /></el-icon> <el-icon><component :is="m.icon" /></el-icon>
<template #title>{{ t.title }}</template> <template #title>{{ m.title }}</template>
</el-menu-item> </el-menu-item>
<div class="menu-group-title">管理</div> <div class="menu-group-title">管理</div>
...@@ -56,29 +56,43 @@ ...@@ -56,29 +56,43 @@
</template> </template>
<script setup> <script setup>
import { computed } from 'vue' import { computed, onMounted, provide, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { listTaskGroups } from '@/api/taskGroups'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
// 校验任务子菜单 // ── 分组码表(来自后端 /api/task-groups) ───────────────────
const taskMenus = [ const taskGroups = ref([]) // 原始 [{id,name,ord}, ...]
{ path: '/quality/id-card', title: '身份证', icon: 'IdCard' }, provide('taskGroups', taskGroups) // 给子组件(任务弹窗 / 任务配置)共享
{ path: '/quality/mobile', title: '手机号', icon: 'Cellphone' },
{ path: '/quality/email', title: '邮箱', icon: 'Message' }, // 把「分组名」映射到「前端路由 slug」+「菜单图标」
{ path: '/quality/address', title: '地址信息', icon: 'Location' }, // 说明:URL slug 是 UI 层概念(路由需要稳定值),不放进数据库
{ path: '/quality/bank-card', title: '银行卡号', icon: 'CreditCard' }, // 未来如果加了新分组没在表里,会进任务配置筛选但不会进左侧菜单
{ path: '/quality/date', title: '日期格式', icon: 'Calendar' }, const NAME_TO_MENU = {
] '身份证': { path: '/quality/id-card', icon: 'IdCard' },
'手机号': { path: '/quality/mobile', icon: 'Cellphone' },
'邮箱': { path: '/quality/email', icon: 'Message' },
'地址信息': { path: '/quality/address', icon: 'Location' },
'银行卡号': { path: '/quality/bank-card', icon: 'CreditCard'},
'日期格式': { path: '/quality/date', icon: 'Calendar' },
}
// 左侧菜单只显示能在 NAME_TO_MENU 里找到映射的分组,按 ord 升序
const menuGroups = computed(() =>
taskGroups.value
.filter((g) => NAME_TO_MENU[g.name])
.map((g) => ({ name: g.name, ...NAME_TO_MENU[g.name], title: g.name }))
)
const activeMenu = computed(() => route.path) const activeMenu = computed(() => route.path)
// 当前面包屑标题 // 当前面包屑标题
const currentTitle = computed(() => { const currentTitle = computed(() => {
if (route.path.startsWith('/quality')) { if (route.path.startsWith('/quality')) {
const t = taskMenus.find((m) => m.path === route.path) const m = menuGroups.value.find((x) => x.path === route.path)
return t ? t.title : '数据质量' return m ? m.title : '数据质量'
} }
if (route.path === '/config') return '任务配置' if (route.path === '/config') return '任务配置'
return '数据质量' return '数据质量'
...@@ -93,6 +107,15 @@ const todayStr = computed(() => { ...@@ -93,6 +107,15 @@ const todayStr = computed(() => {
function onMenuSelect(index) { function onMenuSelect(index) {
if (route.path !== index) router.push(index) if (route.path !== index) router.push(index)
} }
// ── 启动时拉分组码表 ───────────────────────────────────────
onMounted(async () => {
try {
taskGroups.value = await listTaskGroups()
} catch (e) {
console.error('[MainLayout] 加载任务分组失败:', e)
}
})
</script> </script>
<style scoped> <style scoped>
......
...@@ -66,7 +66,7 @@ ...@@ -66,7 +66,7 @@
</template> </template>
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed, inject, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { taskList } from '@/data/mockData' import { taskList } from '@/data/mockData'
import TaskModal from '@/components/TaskModal.vue' import TaskModal from '@/components/TaskModal.vue'
...@@ -75,7 +75,9 @@ const list = ref([...taskList]) ...@@ -75,7 +75,9 @@ const list = ref([...taskList])
const groupFilter = ref('') const groupFilter = ref('')
const keyword = ref('') const keyword = ref('')
const groups = computed(() => [...new Set(list.value.map((r) => r.group))]) // 分组下拉:从 MainLayout 注入的 taskGroups 码表取(与左侧菜单同源)
const taskGroups = inject('taskGroups', ref([]))
const groups = computed(() => taskGroups.value.map((g) => g.name))
const filteredList = computed(() => { const filteredList = computed(() => {
return list.value.filter((r) => { return list.value.filter((r) => {
......
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