Commit 7725b457 authored by wangteng's avatar wangteng

整体调整

parent 67e8421c
......@@ -35,7 +35,8 @@ python -m web.backend.start
- 任务、字段、规则和数据源保存于 `web/data/web.db`。
- 数据源密码目前按内部工具模式保存;部署到共享或生产环境前,应替换为受保护的凭据方案。
- 备份、还原、加密、解密页面的操作记录目前写入浏览器 `localStorage`,按操作类型分别保存。实际执行和服务端操作记录表尚未接入。
- 备份、还原、清表、加密、解密的操作记录保存于 `web/data/web.db` 的 `operation_record` 表;浏览器缓存清理、换浏览器或换电脑不会丢失记录。
- 实际备份文件仍保存于系统配置指定的备份目录,操作记录保存文件名和执行结果,不保存加密密码。
## 验证
......
......@@ -60,6 +60,7 @@ from web.backend.routers.tasks import router as tasks_router # noqa: E402
from web.backend.routers.data_sources import router as data_sources_router # noqa: E402
from web.backend.routers.operations import router as operations_router # noqa: E402
from web.backend.routers.rules import router as rules_router # noqa: E402
from web.backend.routers.table_design import router as table_design_router # noqa: E402
app.include_router(task_groups_router, prefix="/api")
app.include_router(tasks_router, prefix="/api")
app.include_router(db_router, prefix="/api")
......@@ -69,6 +70,7 @@ app.include_router(queries_router, prefix="/api")
app.include_router(data_sources_router, prefix="/api")
app.include_router(operations_router, prefix="/api")
app.include_router(rules_router, prefix="/api")
app.include_router(table_design_router, prefix="/api")
logger.info("已注册路由:/api/task-groups, /api/tasks, /api/data-sources, /api/connect/*, /api/connection-presets, /api/ai/*, /api/queries/*")
......
......@@ -38,6 +38,24 @@ from web.backend.core.ai_regex import gen_regex, test_regex # noqa: E402,F401
# ── System Prompt ───────────────────────────────────────
_SYSTEM_PROMPT = "你是 Python 程序员,擅长写简短的数据校验函数。"
_DESCRIPTION_SYSTEM_PROMPT = "你是数据质量规则专家,擅长将简短的规则名称整理为准确、可执行的校验说明。"
_DESCRIPTION_TEMPLATE = """请为下面的数据校验规则生成一条详细、可执行且便于业务人员阅读的规则说明。
规则名称:「{name}」
AI 生成提示词(最高优先级):「{prompt}」
现有规则说明(可能为空或不完整):「{desc}」
规则类型:{rule_type}
要求:
1. 只输出规则说明本身,不要标题、序号、引号或 Markdown
2. 优先严格依据 AI 生成提示词,写清楚数据需要满足的格式、长度、字符范围、取值范围、日期范围、枚举值或校验关系
3. 对身份证、手机号、统一社会信用代码、银行卡号等常见证件/号码,名称或提示词已经明确其类型时必须写出标准的基础结构。例如中国大陆居民身份证至少说明:共 18 位、前 17 位为数字、最后一位为数字或 X/x;手机号至少说明 11 位数字及号码段要求(若提示词指定)。
4. 行政区划码、出生日期、校验码等额外校验,只有提示词明确要求时才加入;不要编造提示词中没有的业务阈值、枚举值或校验算法。说明中的每一项约束都必须能够由随后生成的规则实现
5. 使用中文,建议 80 到 250 字;信息较少时可简短,但不能只重复规则名称
6. 这段说明会直接展示在校验结果列表中,应使业务人员无需查看正则或代码也能判断不合规原因
"""
# ── User Prompt ─────────────────────────────────────────
_NUMBER_USER_TEMPLATE_INITIAL = """请把以下规则描述转换成一个 Python 校验函数。
......@@ -183,6 +201,46 @@ def _extract_code(text: str) -> str:
return "\n".join(lines).strip()
def gen_rule_description(name: str, desc: str, rule_type: str, prompt: str = "") -> tuple[str, str]:
"""用 AI 补全规则说明,返回 (description, note)。
说明和表达式/代码都由同一轮「AI 生成」产生,但拆成两次模型调用:先得到可读的
说明,再将说明作为生成校验代码的输入,避免代码与列表中展示的说明不一致。
"""
name = (name or "").strip()
desc = (desc or "").strip()
prompt = (prompt or "").strip()
if not name and not desc and not prompt:
return "", "请先填写规则名称、AI 生成提示词或规则说明"
if rule_type not in RULE_TYPES:
return "", f"不支持的规则类型:{rule_type!r}"
client = get_llm_client()
if not client.available:
return "", "LLM 未配置 API Key,请联系管理员配置或手动填写"
try:
text = client.complete(
_DESCRIPTION_TEMPLATE.format(
name=name or "(未填写)",
prompt=prompt or "(未填写,结合规则名称和现有说明推断)",
desc=desc or "(未填写)",
rule_type=rule_type,
),
system=_DESCRIPTION_SYSTEM_PROMPT,
)
except LLMUnavailable as e:
logger.warning(f"AI 规则说明生成失败 name={name!r} err={e}")
return "", f"LLM 调用失败:{e}"
description = (text or "").strip()
# 防御模型偶尔用 Markdown 包裹;不尝试猜测业务内容,只清理展示噪音。
description = re.sub(r"^```(?:text|markdown)?\s*|\s*```$", "", description).strip()
description = description.replace("\n", " ").strip().strip('"')
if not description:
return "", "AI 未生成规则说明"
return description[:500], "已生成详细规则说明"
# ── 主入口 ─────────────────────────────────────────────
def gen_rule(desc: str, rule_type: str) -> tuple[str, str]:
"""AI 生成规则代码。
......@@ -274,4 +332,4 @@ def test_rule(rule_type: str, code_or_regex: str, value: str, skip_null: bool =
return {"ok": False, "match": False, "pass_": False, "error": str(e)}
except Exception as e:
return {"ok": False, "match": False, "pass_": False,
"error": f"{type(e).__name__}: {e}"}
\ No newline at end of file
"error": f"{type(e).__name__}: {e}"}
"""操作历史的统一落库入口,避免页面历史依赖浏览器缓存。"""
from __future__ import annotations
import json
from typing import Iterable
from sqlalchemy.orm import Session
from web.backend.models.operation_record import OperationRecord
def record_operation(
db: Session,
*,
operation: str,
source,
database: str,
scope: str,
tables: Iterable[str],
filename: str = "",
execute_setting: str = "",
) -> OperationRecord:
"""保存一次已成功完成的操作;密码等敏感请求参数永不写入历史。"""
row = OperationRecord(
operation=operation,
source_id=source.id if source else None,
source_name=source.name if source else "",
database_name=database or "",
scope=scope or "",
tables_json=json.dumps([str(table) for table in tables if table], ensure_ascii=False),
filename=filename or "",
execute_setting=execute_setting or "",
)
db.add(row)
db.commit()
db.refresh(row)
return row
......@@ -104,6 +104,7 @@ def init_db() -> None:
# 一次性迁移:task 表加 table_comment 列(2026-08-21 接「数据表注释」UI)
_migrate_task_table_comment()
_migrate_task_data_source_id()
_migrate_rule_library_ai_prompt()
_migrate_legacy_rules_to_library()
logger.info(f"[init_db] DB 已存在,已执行增量建表检查:{DB_PATH}")
# 灌种子(每个 seed 函数内部判重,可重复调用)
......@@ -269,6 +270,15 @@ def _migrate_task_data_source_id() -> None:
conn.exec_driver_sql("ALTER TABLE task ADD COLUMN data_source_id INTEGER")
def _migrate_rule_library_ai_prompt() -> None:
"""给规则库增加 AI 生成提示词,供编辑时回显和再次生成使用。"""
with engine.begin() as conn:
cols = {row[1] for row in conn.exec_driver_sql("PRAGMA table_info(rule_library)").fetchall()}
if "ai_prompt" not in cols:
logger.info("[migrate] rule_library 缺 ai_prompt 列,补建(默认空字符串)")
conn.exec_driver_sql("ALTER TABLE rule_library ADD COLUMN ai_prompt TEXT NOT NULL DEFAULT ''")
def _migrate_legacy_rules_to_library() -> None:
"""将旧 field.rule 记录登记到规则库,保证历史校验任务可继续编辑/扫描。"""
from web.backend.models.field import Field
......
......@@ -98,6 +98,7 @@ CREATE INDEX idx_rule_field ON rule(field_id);
CREATE TABLE rule_library (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
ai_prompt TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
rule_type TEXT NOT NULL DEFAULT 'regex',
regex TEXT,
......@@ -192,3 +193,36 @@ CREATE TABLE data_source (
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- 9) operation_record · 数据库操作历史(不依赖浏览器 localStorage)
CREATE TABLE operation_record (
id INTEGER PRIMARY KEY AUTOINCREMENT,
operation TEXT NOT NULL,
source_id INTEGER REFERENCES data_source(id) ON DELETE SET NULL,
source_name TEXT NOT NULL DEFAULT '',
database_name TEXT NOT NULL DEFAULT '',
scope TEXT NOT NULL DEFAULT '',
tables_json TEXT NOT NULL DEFAULT '[]',
filename TEXT NOT NULL DEFAULT '',
execute_setting TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT '已完成',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_operation_record_operation_created ON operation_record(operation, created_at DESC);
-- 10) table_design_config · 表设计校验配置与最近扫描结果
CREATE TABLE table_design_config (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
source_id INTEGER NOT NULL REFERENCES data_source(id),
schema_name TEXT NOT NULL,
scope TEXT NOT NULL DEFAULT 'database',
tables_json TEXT NOT NULL DEFAULT '[]',
check_table_comment INTEGER NOT NULL DEFAULT 1,
check_column_comment INTEGER NOT NULL DEFAULT 1,
checked_at TIMESTAMP,
last_result_json TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_table_design_config_source ON table_design_config(source_id);
"""备份、还原、清表、加密、解密的持久化操作记录。"""
from __future__ import annotations
import json
from datetime import datetime
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.sql import func
from web.backend.db.database import Base
class OperationRecord(Base):
__tablename__ = "operation_record"
id = Column(Integer, primary_key=True, autoincrement=True)
operation = Column(String(20), nullable=False)
source_id = Column(Integer, ForeignKey("data_source.id", ondelete="SET NULL"), nullable=True)
source_name = Column(String(100), nullable=False, default="")
database_name = Column(String(255), nullable=False, default="")
scope = Column(String(20), nullable=False, default="")
tables_json = Column(Text, nullable=False, default="[]")
filename = Column(String(255), nullable=False, default="")
execute_setting = Column(Text, nullable=False, default="")
status = Column(String(20), nullable=False, default="已完成")
created_at = Column(DateTime, nullable=False, server_default=func.current_timestamp())
def to_dict(self) -> dict:
try:
tables = json.loads(self.tables_json or "[]")
except (TypeError, ValueError):
tables = []
return {
"id": self.id,
"operation": self.operation,
"source_id": self.source_id,
"source_name": self.source_name,
"database": self.database_name,
"scope": self.scope,
"tables": tables if isinstance(tables, list) else [],
"filename": self.filename,
"execute_setting": self.execute_setting,
"status": self.status,
"created_at": self.created_at.isoformat() if isinstance(self.created_at, datetime) else self.created_at,
}
......@@ -13,6 +13,7 @@ class RuleLibrary(Base):
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(120), nullable=False, unique=True)
ai_prompt = Column(Text, nullable=False, default="")
description = Column(Text, nullable=False, default="")
rule_type = Column(String(20), nullable=False, default="regex")
regex = Column(Text, nullable=True)
......@@ -23,7 +24,7 @@ class RuleLibrary(Base):
def to_dict(self) -> dict:
return {
"id": self.id, "name": self.name, "description": self.description,
"id": self.id, "name": self.name, "ai_prompt": self.ai_prompt or "", "description": self.description,
"rule_type": self.rule_type, "regex": self.regex, "code": self.code,
"skip_null": bool(self.skip_null),
"created_at": self.created_at.isoformat() if self.created_at else None,
......
"""表设计校验配置及其最近一次扫描结果。"""
from __future__ import annotations
import json
from datetime import datetime
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.sql import func
from web.backend.db.database import Base
class TableDesignConfig(Base):
__tablename__ = "table_design_config"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(120), nullable=False)
source_id = Column(Integer, ForeignKey("data_source.id"), nullable=False)
schema_name = Column(String(255), nullable=False)
scope = Column(String(20), nullable=False, default="database")
tables_json = Column(Text, nullable=False, default="[]")
check_table_comment = Column(Integer, nullable=False, default=1)
check_column_comment = Column(Integer, nullable=False, default=1)
checked_at = Column(DateTime, nullable=True)
last_result_json = Column(Text, nullable=True)
created_at = Column(DateTime, nullable=False, server_default=func.current_timestamp())
updated_at = Column(DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp())
def to_dict(self) -> dict:
try:
tables = json.loads(self.tables_json or "[]")
except (TypeError, ValueError):
tables = []
try:
last_result = json.loads(self.last_result_json) if self.last_result_json else None
except (TypeError, ValueError):
last_result = None
return {
"id": self.id,
"name": self.name,
"sourceId": self.source_id,
"schema": self.schema_name,
"scope": self.scope,
"tables": tables if isinstance(tables, list) else [],
"checkTableComment": bool(self.check_table_comment),
"checkColumnComment": bool(self.check_column_comment),
"checkedAt": self.checked_at.isoformat() if isinstance(self.checked_at, datetime) else self.checked_at,
"lastResult": last_result,
}
"""AI 相关 API(web)
端点:
POST /api/ai/rule body: {desc, rule_type} → {ok, code, note}
POST /api/ai/rule body: {name, prompt, desc, rule_type} → {ok, code, desc, note}
POST /api/rule/test body: {rule_type, code, value} → {ok, pass_, error}
POST /api/ai/explain_issue body: {field_key, field_comment, value,
rule_id, rule_name, rule_desc,
......@@ -27,7 +27,7 @@ from typing import Any, Optional
from fastapi import APIRouter
from pydantic import BaseModel, ConfigDict, Field
from web.backend.core.ai_rule import gen_rule, test_rule
from web.backend.core.ai_rule import gen_rule, gen_rule_description, test_rule
from web.backend.core.rule_runner import validate_user_code
from web.backend.core.ai_explain import explain_issue as _explain_issue
from web.backend.core.ai_regex import test_regex as _legacy_test_regex
......@@ -40,7 +40,9 @@ logger = get_logger("backend.routers.ai")
# ── /ai/rule ─────────────────────────────────────────────
class GenRuleRequest(BaseModel):
desc: str = Field(..., min_length=1, description="规则自然语言描述")
desc: str = Field("", max_length=500, description="现有规则说明;AI 生成提示词为空时作为生成依据")
name: str = Field("", max_length=120, description="规则名称;用于 AI 补全规则说明")
prompt: str = Field("", max_length=2000, description="AI 生成提示词;优先用于生成规则说明和规则")
rule_type: str = Field(
DEFAULT_RULE_TYPE,
description=f"规则类型:{ ' / '.join(RULE_TYPES) }",
......@@ -50,6 +52,7 @@ class GenRuleRequest(BaseModel):
class GenRuleResponse(BaseModel):
ok: bool
code: str = "" # regex: 正则字符串;number/date: Python 函数源码
desc: str = "" # AI 补全后的规则说明
note: str = ""
......@@ -63,12 +66,15 @@ async def ai_gen_rule(req: GenRuleRequest):
logger.info(f"POST /api/ai/rule rule_type={req.rule_type!r} desc={req.desc!r}")
started = time.monotonic()
try:
code, note = gen_rule(req.desc, req.rule_type)
description, desc_note = gen_rule_description(req.name, req.desc, req.rule_type, req.prompt)
if not description:
return GenRuleResponse(ok=False, code="", desc="", note=desc_note)
code, note = gen_rule(description, req.rule_type)
except Exception as e:
elapsed_ms = (time.monotonic() - started) * 1000
logger.exception(f"❌ AI 规则服务抛出异常(耗时 {elapsed_ms:.0f}ms)")
logger.info("─" * 60)
return GenRuleResponse(ok=False, code="", note=f"AI 规则服务异常: {type(e).__name__}: {e}")
return GenRuleResponse(ok=False, code="", desc="", note=f"AI 规则服务异常: {type(e).__name__}: {e}")
elapsed_ms = (time.monotonic() - started) * 1000
if code:
......@@ -79,7 +85,9 @@ async def ai_gen_rule(req: GenRuleRequest):
else:
logger.warning(f"❌ AI 规则生成失败(耗时 {elapsed_ms:.0f}ms)note={note!r}")
logger.info("─" * 60)
return GenRuleResponse(ok=bool(code), code=code, note=note)
return GenRuleResponse(
ok=bool(code), code=code, desc=description if code else "", note=note or desc_note,
)
# ── /rule/test ───────────────────────────────────────────
......
......@@ -14,6 +14,7 @@ from web.backend.db.database import get_session
from web.backend.models.data_source import DataSource
from web.backend.core.db_adapter import DBConfig, DBConnection, quote_ident
from web.backend.core.backup_storage import get_backup_dir
from web.backend.core.operation_history import record_operation
router = APIRouter(prefix="/data-sources", tags=["data-sources"])
......@@ -204,5 +205,15 @@ def backup_data_source(source_id: int, payload: BackupRequest, db: Session = Dep
if target.exists():
raise HTTPException(409, f"备份文件已存在:{filename}")
target.write_text(json.dumps(result, ensure_ascii=False, default=_json_default), encoding="utf-8")
record_operation(
db,
operation="backup",
source=row,
database=cfg.database,
scope=payload.scope,
tables=[item["table_name"] for item in result["tables"]],
filename=filename,
execute_setting="备份完成",
)
return {"filename": filename, "backup_dir": str(target.parent),
"tables": len(result["tables"]), "message": "备份完成"}
This diff is collapsed.
......@@ -18,7 +18,8 @@ _TYPES = {"regex", "number", "date", "string"}
class RulePayload(BaseModel):
name: str = Field(..., min_length=1, max_length=120)
description: str = ""
ai_prompt: str = Field("", max_length=2000)
description: str = Field(..., min_length=1, max_length=500)
rule_type: str = "regex"
regex: Optional[str] = None
code: Optional[str] = None
......@@ -33,6 +34,7 @@ def _apply(rule: RuleLibrary, payload: RulePayload) -> None:
if payload.rule_type != "regex" and not (payload.code or "").strip():
raise HTTPException(status_code=400, detail="自定义规则必须填写 check(value) 代码")
rule.name = payload.name.strip()
rule.ai_prompt = payload.ai_prompt.strip()
rule.description = payload.description.strip()
rule.rule_type = payload.rule_type
rule.regex = (payload.regex or "").strip() or None
......
"""表设计校验:检查表注释及字段注释。"""
from __future__ import annotations
import json
from datetime import datetime
from typing import Literal, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from web.backend.db.database import get_session
from web.backend.models.data_source import DataSource
from web.backend.models.table_design_config import TableDesignConfig
from web.backend.routers.data_sources import _cfg
from web.backend.core.db_adapter import DBConnection
router = APIRouter(prefix="/table-design", tags=["table-design"])
class DesignCheckRequest(BaseModel):
config_id: Optional[int] = None
source_id: int
schema: Optional[str] = None
database: Optional[str] = None
scope: str = "database"
tables: list[str] = Field(default_factory=list)
check_table_comment: bool = True
check_column_comment: bool = True
class DesignConfigPayload(BaseModel):
name: str = Field(..., min_length=1, max_length=120)
source_id: int
schema: str = Field(..., min_length=1, max_length=255)
scope: Literal["database", "tables"] = "database"
tables: list[str] = Field(default_factory=list)
check_table_comment: bool = True
check_column_comment: bool = True
class LegacyDesignConfig(DesignConfigPayload):
checked_at: Optional[datetime] = None
last_result: Optional[dict] = None
class LegacyDesignConfigImportRequest(BaseModel):
configs: list[LegacyDesignConfig] = Field(default_factory=list, max_length=10000)
def _validate_config_payload(db: Session, payload: DesignConfigPayload) -> None:
_source(db, payload.source_id)
if payload.scope == "tables" and not payload.tables:
raise HTTPException(400, "至少选择一张数据表")
if not payload.check_table_comment and not payload.check_column_comment:
raise HTTPException(400, "至少选择一条校验规则")
def _apply_config(row: TableDesignConfig, payload: DesignConfigPayload) -> None:
row.name = payload.name.strip()
row.source_id = payload.source_id
row.schema_name = payload.schema.strip()
row.scope = payload.scope
row.tables_json = json.dumps(list(dict.fromkeys(payload.tables)), ensure_ascii=False)
row.check_table_comment = int(payload.check_table_comment)
row.check_column_comment = int(payload.check_column_comment)
@router.get("/configs")
def list_design_configs(db: Session = Depends(get_session)):
return [row.to_dict() for row in db.query(TableDesignConfig).order_by(TableDesignConfig.id.desc()).all()]
@router.post("/configs/import")
def import_legacy_design_configs(payload: LegacyDesignConfigImportRequest, db: Session = Depends(get_session)):
"""将旧版浏览器表设计配置一次性迁移到业务库。"""
imported = 0
for item in payload.configs:
try:
_validate_config_payload(db, item)
except HTTPException:
continue
row = TableDesignConfig()
_apply_config(row, item)
row.checked_at = item.checked_at
row.last_result_json = json.dumps(item.last_result, ensure_ascii=False) if item.last_result else None
db.add(row)
imported += 1
db.commit()
return {"imported": imported}
@router.get("/configs/{config_id}")
def get_design_config(config_id: int, db: Session = Depends(get_session)):
row = db.get(TableDesignConfig, config_id)
if not row:
raise HTTPException(404, "表设计校验配置不存在")
return row.to_dict()
@router.post("/configs", status_code=201)
def create_design_config(payload: DesignConfigPayload, db: Session = Depends(get_session)):
_validate_config_payload(db, payload)
row = TableDesignConfig()
_apply_config(row, payload)
db.add(row)
db.commit()
db.refresh(row)
return row.to_dict()
@router.put("/configs/{config_id}")
def update_design_config(config_id: int, payload: DesignConfigPayload, db: Session = Depends(get_session)):
row = db.get(TableDesignConfig, config_id)
if not row:
raise HTTPException(404, "表设计校验配置不存在")
_validate_config_payload(db, payload)
_apply_config(row, payload)
db.commit()
db.refresh(row)
return row.to_dict()
@router.delete("/configs/{config_id}", status_code=204)
def delete_design_config(config_id: int, db: Session = Depends(get_session)):
row = db.get(TableDesignConfig, config_id)
if not row:
raise HTTPException(404, "表设计校验配置不存在")
db.delete(row)
db.commit()
def _source(db: Session, source_id: int) -> DataSource:
row = db.get(DataSource, source_id)
if not row:
raise HTTPException(404, "数据源不存在")
return row
@router.get("/{source_id}/objects")
def design_objects(source_id: int, schema: Optional[str] = Query(None), db: Session = Depends(get_session)):
row = _source(db, source_id)
cfg = _cfg(row)
try:
with DBConnection(cfg) as conn:
schemas = []
if cfg.db_type == "mysql":
schemas = [r.get("Database") or r.get("database") for r in conn.fetchall("SHOW DATABASES")]
else:
schemas = [r.get("schema_name") for r in conn.fetchall(
"SELECT DISTINCT OWNER AS schema_name FROM ALL_TABLES ORDER BY OWNER")]
schema = schema or row.schema or row.database or (schemas[0] if schemas else "")
tables = conn.list_tables(schema) if schema else []
return {"ok": True, "schemas": [s for s in schemas if s], "schema": schema,
"tables": tables}
except Exception as exc:
return {"ok": False, "message": f"读取数据库对象失败:{type(exc).__name__}: {exc}", "schemas": [], "tables": []}
@router.post("/check")
def check_design(payload: DesignCheckRequest, db: Session = Depends(get_session)):
row = _source(db, payload.source_id)
if not payload.check_table_comment and not payload.check_column_comment:
raise HTTPException(400, "至少选择一条校验规则")
cfg = _cfg(row)
schema = payload.schema or payload.database or row.schema or row.database
try:
with DBConnection(cfg) as conn:
available = conn.list_tables(schema)
by_name = {str(t.get("table_name")): t for t in available}
by_name_upper = {key.upper(): value for key, value in by_name.items()}
names = list(by_name) if payload.scope == "database" else list(dict.fromkeys(payload.tables))
results = []
for name in names:
meta = by_name.get(name) or by_name_upper.get(name.upper())
if not meta:
results.append({"table_name": name, "ok": False, "reasons": ["表不存在或无权访问"], "missing_columns": []})
continue
reasons = []
if payload.check_table_comment and not str(meta.get("table_comment") or "").strip():
reasons.append("缺少表注释")
missing_columns = []
if payload.check_column_comment:
cols = conn.list_columns(schema, name)
missing_columns = [c.get("column_name") for c in cols if not str(c.get("column_comment") or "").strip()]
if missing_columns:
reasons.append(f"{len(missing_columns)} 个字段缺少注释")
results.append({"table_name": name, "table_comment": meta.get("table_comment"),
"ok": not reasons, "reasons": reasons, "missing_columns": missing_columns})
passed = sum(1 for x in results if x["ok"])
result = {"ok": True, "checked_at": datetime.now().isoformat(), "schema": schema,
"total": len(results), "passed": passed, "failed": len(results) - passed,
"results": results}
if payload.config_id is not None:
config = db.get(TableDesignConfig, payload.config_id)
if not config:
raise HTTPException(404, "表设计校验配置不存在")
if config.source_id != payload.source_id:
raise HTTPException(400, "校验配置与数据源不匹配")
config.checked_at = datetime.fromisoformat(result["checked_at"])
config.last_result_json = json.dumps(result, ensure_ascii=False)
db.commit()
return result
except HTTPException:
raise
except Exception as exc:
raise HTTPException(502, f"表设计校验失败:{type(exc).__name__}: {exc}") from exc
......@@ -20,12 +20,14 @@ import { http } from './client'
* AI 生成规则代码
* @param {string} desc 规则说明(自然语言)
* @param {string} ruleType 'regex' | 'number' | 'date' | 'string'
* @returns { ok: boolean, code: string, note: string }
* @param {string} [name] 规则名称,用于 AI 同时生成可展示的规则说明
* @param {string} [prompt] AI 生成提示词,优先用于生成详细规则说明与规则
* @returns { ok: boolean, code: string, desc: string, note: string }
* - ok=true:code 非空,可直接填到对应输入框
* - ok=false:code 空字符串;note 是失败原因(前端展示)
*/
export function genRule(desc, ruleType = 'regex') {
return http.post('/ai/rule', { desc, rule_type: ruleType })
export function genRule(desc, ruleType = 'regex', name = '', prompt = '') {
return http.post('/ai/rule', { desc, name, prompt, rule_type: ruleType })
}
/**
......
......@@ -3,9 +3,13 @@ import { http } from './client'
export const getBackupConfig = () => http.get('/backup/config')
export const saveBackupConfig = (backupDir) => http.put('/backup/config', { backup_dir: backupDir })
export const listBackupVersions = () => http.get('/backup/versions')
export const listOperationRecords = (operation) => http.get('/backup/records', { params: { operation } })
export const importOperationRecords = (operation, records) => http.post(`/backup/records/import?operation=${encodeURIComponent(operation)}`, { records })
export const createBackup = (sourceId, payload) => http.post(`/data-sources/${sourceId}/backup`, payload)
export const restoreBackup = (payload) => http.post('/backup/restore', payload)
export const encryptBackup = (payload) => http.post('/backup/encrypt', payload)
export const decryptBackup = (payload) => http.post('/backup/decrypt', payload)
export const encryptData = (payload) => http.post('/backup/data-encrypt', payload)
export const decryptData = (payload) => http.post('/backup/data-decrypt', payload)
export const clearTables = (payload) => http.post('/backup/clear', payload)
export const restoreClearedTables = (payload) => http.post('/backup/clear/restore', payload)
......@@ -10,6 +10,22 @@
const BASE = '/api'
function errorDetailText(detail) {
if (typeof detail === 'string') return detail
if (Array.isArray(detail)) {
return detail
.map((item) => {
if (!item || typeof item !== 'object') return String(item || '')
const field = Array.isArray(item.loc) ? item.loc.filter((part) => part !== 'body').join('.') : ''
return [field, item.msg || item.message].filter(Boolean).join(':')
})
.filter(Boolean)
.join(';')
}
if (detail && typeof detail === 'object') return detail.msg || detail.message || JSON.stringify(detail)
return ''
}
async function request(path, { method = 'GET', body, params } = {}, { signal } = {}) {
// GET 请求把 params 拼到 query string
let fullPath = path
......@@ -33,7 +49,7 @@ async function request(path, { method = 'GET', body, params } = {}, { signal } =
let msg = `HTTP ${res.status}`
try {
const body = await res.json()
msg = body.detail || body.message || msg
msg = errorDetailText(body.detail || body.message) || msg
} catch (_) { /* 非 JSON */ }
throw new Error(msg)
}
......@@ -47,4 +63,4 @@ export const http = {
put: (path, body, opts2) => request(path, { method: 'PUT', body: JSON.stringify(body ?? {}) }, opts2 || {}),
patch: (path, body, opts2) => request(path, { method: 'PATCH', body: JSON.stringify(body ?? {}) }, opts2 || {}),
delete: (path, opts2) => request(path, { method: 'DELETE' }, opts2 || {}),
}
\ No newline at end of file
}
import { http } from './client'
export const getTableDesignObjects = (sourceId, schema) => http.get(`/table-design/${sourceId}/objects${schema ? `?schema=${encodeURIComponent(schema)}` : ''}`)
export const checkTableDesign = (payload) => http.post('/table-design/check', payload)
export const listTableDesignConfigs = () => http.get('/table-design/configs')
export const getTableDesignConfig = (id) => http.get(`/table-design/configs/${id}`)
export const createTableDesignConfig = (payload) => http.post('/table-design/configs', payload)
export const importTableDesignConfigs = (configs) => http.post('/table-design/configs/import', { configs })
export const updateTableDesignConfig = (id, payload) => http.put(`/table-design/configs/${id}`, payload)
export const deleteTableDesignConfig = (id) => http.delete(`/table-design/configs/${id}`)
......@@ -80,7 +80,7 @@
:key="idx"
class="issue-line"
:title="it.desc || ''"
>• {{ it.field }}:{{ it.name ? it.name : it.desc }}</span>
>• {{ it.field }}:{{ issueSummary(it) }}</span>
</template>
<span v-else class="null-val">—</span>
</template>
......@@ -335,15 +335,21 @@ function emitExplainIssue(row, col) {
// 可能同时违反多条 → 之前用 .find() 只取第一条,2026-08-24 改为 .filter() + 「;」连接)
// 注:后端 issues[].field 是小写 cf.field_key(db_adapter 归一过),
// 不用 col.cn(保留原大小写作为表头显示)
// 2026-08-24:tooltip 只列违规的规则名(name),不重复 desc —— desc 太长(一两百字),
// 在小 tooltip 里展开反而看不清是哪几条规则;老规则空名时回退 desc。
// 表头 ruleHint 仍然带 desc,那里用户需要看规则定义。
// 校验结果中的名称后明确带上规则说明,让业务人员无需回到规则配置页面也能判断原因。
function issueSummary(issue) {
const name = (issue.name || '').trim()
const desc = (issue.desc || '').trim()
if (name && desc && name !== desc) return `${name}(${desc})`
return name || desc || '该字段不合法'
}
// tooltip 与备注列保持同一内容,避免一个位置只显示名称、另一个位置才显示说明。
function errTip(row, key) {
if (!isErrorCell(row, key)) return ''
const issues = (row.issues || []).filter((it) => it.field === key)
if (issues.length === 0) return '该字段不合法'
return issues
.map((it) => (it.name && it.name.trim() ? it.name : it.desc))
.map(issueSummary)
.join(';')
}
function cellClass(row, key) {
......
......@@ -314,14 +314,16 @@ function onImportRules(rules) {
// AI 生成:按当前规则类型调后端 /api/ai/rule
async function genAI(idx) {
const rule = ruleList.value[idx]
if (!rule.desc.trim()) {
ElMessage.warning('请先填写规则说明')
const prompt = rule.desc.trim() || rule.name.trim()
if (!prompt) {
ElMessage.warning('请先填写规则名称或规则说明')
return
}
aiLoading.value[idx] = true
try {
const r = await apiGenRule(rule.desc, rule.rule_type)
const r = await apiGenRule(prompt, rule.rule_type, rule.name.trim())
if (r.ok && r.code) {
if (r.desc) rule.desc = r.desc
if (rule.rule_type === 'regex') {
rule.regex = r.code
} else {
......@@ -542,4 +544,4 @@ function onSave() {
width: 62px;
flex-shrink: 0;
}
</style>
\ No newline at end of file
</style>
......@@ -26,13 +26,18 @@
<template #title><el-icon><Download /></el-icon><span>备份</span></template>
<el-menu-item index="/backup"><template #title>备份</template></el-menu-item>
<el-menu-item index="/restore"><template #title>还原</template></el-menu-item>
<el-menu-item index="/clear-tables"><template #title>清表</template></el-menu-item>
</el-sub-menu>
<el-sub-menu index="crypto">
<template #title><el-icon><Lock /></el-icon><span>加密</span></template>
<el-menu-item index="/encrypt"><template #title>加密</template></el-menu-item>
<el-menu-item index="/decrypt"><template #title>解密</template></el-menu-item>
</el-sub-menu>
<el-menu-item index="/validation"><el-icon><Document /></el-icon><template #title>校验</template></el-menu-item>
<el-sub-menu index="validation">
<template #title><el-icon><Document /></el-icon><span>校验</span></template>
<el-menu-item index="/validation/table-design"><template #title>表设计</template></el-menu-item>
<el-menu-item index="/validation"><template #title>数据</template></el-menu-item>
</el-sub-menu>
<el-sub-menu index="config">
<template #title><el-icon><Setting /></el-icon><span>配置</span></template>
<el-menu-item index="/config/data-sources"><template #title>数据源</template></el-menu-item>
......@@ -82,7 +87,7 @@ import { listTaskGroups } from '@/api/taskGroups'
const route = useRoute()
const router = useRouter()
const sidebarCollapsed = ref(false)
const openGroups = ref(['backup', 'crypto', 'config'])
const openGroups = ref(['backup', 'crypto', 'config', 'validation'])
// ── 分组码表(来自后端 /api/task-groups) ───────────────────
const taskGroups = ref([]) // 原始 [{id,name,ord}, ...]
......@@ -91,25 +96,27 @@ provide('taskGroups', taskGroups) // 给子组件(任务弹
// 把「分组名」映射到「前端路由 slug」
// 说明:URL slug 是 UI 层概念(路由需要稳定值),不放进数据库
// 未来如果加了新分组没在表里,会进任务配置筛选但不会进左侧菜单
const activeMenu = computed(() => route.path.startsWith('/validation/') ? '/validation' : route.path)
const activeMenu = computed(() => route.path.startsWith('/validation/table-design') ? '/validation/table-design' : route.path.startsWith('/validation') ? '/validation' : route.path)
// 当前面包屑标题
const currentTitle = computed(() => {
if (route.path === '/validation' || route.path === '/validation/results') return '校验'
if (route.path.startsWith('/validation/table-design')) return '表设计'
if (route.path === '/validation' || route.path === '/validation/results') return '数据'
if (route.path === '/config/rule-config') return '规则设置'
if (route.path === '/config/data-sources') return '数据源'
if (route.path === '/config/system') return '系统配置'
if (route.path === '/backup') return '备份'
if (route.path === '/restore') return '还原'
if (route.path === '/clear-tables') return '清表'
if (route.path === '/encrypt') return '加密'
if (route.path === '/decrypt') return '解密'
return '数据质量'
})
const currentSection = computed(() => {
if (route.path === '/validation' || route.path === '/validation/results') return '校验'
if (route.path.startsWith('/validation')) return '校验'
if (route.path.startsWith('/config')) return '配置'
if (route.path === '/backup' || route.path === '/restore') return '备份'
if (route.path === '/backup' || route.path === '/restore' || route.path === '/clear-tables') return '备份'
if (route.path === '/encrypt' || route.path === '/decrypt') return '加密'
return currentTitle.value
})
......
......@@ -5,8 +5,11 @@ import DataQualityView from '@/views/DataQualityView.vue'
import TaskConfigView from '@/views/TaskConfigView.vue'
import DataSourceView from '@/views/DataSourceView.vue'
import OperationView from '@/views/OperationView.vue'
import ClearTablesView from '@/views/ClearTablesView.vue'
import SystemConfigView from '@/views/SystemConfigView.vue'
import RuleLibraryView from '@/views/RuleLibraryView.vue'
import TableDesignView from '@/views/TableDesignView.vue'
import TableDesignResultsView from '@/views/TableDesignResultsView.vue'
const routes = [
{
......@@ -19,6 +22,8 @@ const routes = [
name: 'ValidationTasks',
component: TaskConfigView,
},
{ path: 'validation/table-design', name: 'TableDesign', component: TableDesignView },
{ path: 'validation/table-design/results', name: 'TableDesignResults', component: TableDesignResultsView },
{
path: 'validation/results',
name: 'DataQuality',
......@@ -34,6 +39,7 @@ const routes = [
{ path: 'config/system', name: 'SystemConfig', component: SystemConfigView, meta: { title: '系统配置' } },
{ path: 'backup', name: 'Backup', component: OperationView, props: { type: 'backup' }, meta: { title: '备份' } },
{ path: 'restore', name: 'Restore', component: OperationView, props: { type: 'restore' }, meta: { title: '还原' } },
{ path: 'clear-tables', name: 'ClearTables', component: ClearTablesView, meta: { title: '清表' } },
{ path: 'encrypt', name: 'Encrypt', component: OperationView, props: { type: 'encrypt' }, meta: { title: '加密' } },
{ path: 'decrypt', name: 'Decrypt', component: OperationView, props: { type: 'decrypt' }, meta: { title: '解密' } },
{ path: 'quality/:type', redirect: '/validation' },
......
<template>
<div class="clear-page">
<div class="config-toolbar clear-toolbar">
<div class="left">
<input v-model="keyword" class="text-input clear-search" placeholder="备份文件名/数据库/数据表" @keyup.enter="loadVersions" />
<button class="btn btn-primary" @click="loadVersions">查询</button>
</div>
<div class="right"><button class="btn btn-danger" @click="openCreate">清空数据表</button></div>
</div>
<div class="q-card">
<div class="table-scroll">
<table class="config-list clear-list">
<thead><tr><th>备份文件</th><th>数据源</th><th>数据库 / Schema</th><th>已清空数据表</th><th>备份时间</th><th>操作</th></tr></thead>
<tbody>
<tr v-if="!records.length"><td colspan="6" class="empty-cell">暂无清表记录</td></tr>
<tr v-for="record in records" :key="record.id">
<td class="filename">{{ record.filename }}</td><td>{{ record.source?.name || '—' }}</td>
<td>{{ record.source?.database || record.source?.schema || '—' }}</td>
<td class="tables">{{ (record.tables || []).join('、') || '—' }}</td>
<td>{{ formatDate(record.created_at) }}</td>
<td><button class="btn-text" :disabled="restoring === record.filename" @click="restore(record)">{{ restoring === record.filename ? '恢复中...' : '还原' }}</button></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<Teleport to="body"><div class="modal-mask" :class="{ show: createOpen }" @click="createOpen = false"><div class="modal clear-modal" @click.stop>
<div class="modal-header"><span class="modal-title">清空数据表</span><button class="modal-close" aria-label="关闭" @click="createOpen = false">×</button></div>
<div class="modal-body clear-modal-body">
<div class="step"><span class="step-no">1</span><div class="step-content"><b>选择数据源</b><el-select v-model="form.source" placeholder="请选择数据源" class="source-select" @change="onSourceChange"><el-option v-for="source in sources" :key="source.id" :label="`${source.name}(${source.db_type})`" :value="source.id" /></el-select></div></div>
<div class="step"><span class="step-no">2</span><div class="step-content"><b>选择数据库</b><el-select v-model="form.database" placeholder="请选择数据库/Schema" class="source-select" :loading="loading" @change="loadTables"><el-option v-for="schema in schemas" :key="schema" :label="schema" :value="schema" /></el-select><div class="scope-choice"><el-radio-group v-model="form.scope" @change="form.tables = []"><el-radio label="database">整个数据库</el-radio><el-radio label="tables">指定数据表</el-radio></el-radio-group></div><div v-if="form.scope === 'tables'" class="table-picker"><el-checkbox-group v-model="form.tables"><el-checkbox v-for="table in tables" :key="table.table_name" :label="table.table_name">{{ table.table_name }}<small v-if="table.table_comment">({{ table.table_comment }})</small></el-checkbox></el-checkbox-group><span v-if="form.database && !tables.length" class="muted">{{ loading ? '正在加载...' : '暂无可访问的数据表' }}</span></div></div></div>
<div class="step"><span class="step-no">3</span><div class="step-content"><b>清表说明</b><div class="clear-warning">系统会先备份完整表结构和数据,再清空数据。可在本页面的清表备份记录中还原。</div></div></div>
</div>
<div class="modal-footer"><button class="btn" @click="createOpen = false">取消</button><button class="btn btn-danger" :disabled="clearing" @click="clear">{{ clearing ? '正在备份并清空...' : '确认清空' }}</button></div>
</div></div></Teleport>
</template>
<script setup>
import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { listDataSources } from '@/api/dataSources'
import { listSchemas, listTables } from '@/api/connections'
import { clearTables, listOperationRecords, restoreClearedTables } from '@/api/backup'
const sources = ref([]), schemas = ref([]), tables = ref([]), records = ref([])
const keyword = ref(''), createOpen = ref(false), loading = ref(false), clearing = ref(false), restoring = ref('')
const form = reactive({ source: null, database: '', scope: 'database', tables: [] })
function sourceForm(source, database = '') {
return { dbType: source.db_type, host: source.host, port: source.port, user: source.user, password: source.password || '', db: database || source.database || '', schema: database || source.schema || source.database || '' }
}
function formatDate(value) { return value ? new Date(value).toLocaleString() : '—' }
function resetForm() { Object.assign(form, { source: null, database: '', scope: 'database', tables: [] }); schemas.value = []; tables.value = [] }
function openCreate() { resetForm(); createOpen.value = true }
async function loadVersions() {
try {
const versions = await listOperationRecords('clear')
const q = keyword.value.trim().toLowerCase()
records.value = versions
.filter(item => !q || [item.filename, item.source_name, item.database, ...(item.tables || [])].join(' ').toLowerCase().includes(q))
.map(item => ({ ...item, source: { name: item.source_name, database: item.database } }))
} catch (error) { ElMessage.error(`加载清表记录失败:${error.message}`) }
}
async function onSourceChange() {
form.database = ''; form.tables = []; schemas.value = []; tables.value = []
const source = sources.value.find(item => item.id === form.source)
if (!source) return
loading.value = true
try { const result = await listSchemas(sourceForm(source)); schemas.value = result.schemas || []; if (!schemas.value.length && source.database) schemas.value = [source.database] } catch (error) { ElMessage.error(`加载数据库失败:${error.message}`) } finally { loading.value = false }
}
async function loadTables() {
form.tables = []; tables.value = []
const source = sources.value.find(item => item.id === form.source)
if (!source || !form.database) return
loading.value = true
try { const result = await listTables(sourceForm(source, form.database)); tables.value = result.tables || [] } catch (error) { ElMessage.error(`加载数据表失败:${error.message}`) } finally { loading.value = false }
}
async function clear() {
if (!form.source || !form.database || (form.scope === 'tables' && !form.tables.length)) { ElMessage.warning('请选择数据源、数据库和清空范围'); return }
const label = form.scope === 'database' ? '整个数据库中的所有数据表' : form.tables.join('、')
try { await ElMessageBox.confirm(`将先备份,再清空:${label}。确认继续吗?`, '确认清表', { type: 'warning', confirmButtonText: '确认清空', cancelButtonText: '取消' }) } catch (_) { return }
clearing.value = true
try {
const result = await clearTables({ source_id: form.source, database: form.database, schema: form.database, scope: form.scope, tables: form.tables })
createOpen.value = false; await loadVersions(); ElMessage.success(result.message)
} catch (error) { ElMessage.error(error.message) } finally { clearing.value = false }
}
async function restore(record) {
const source = sources.value.find(item => item.id === record.source_id)
if (!source) { ElMessage.error('对应数据源已不存在,无法恢复'); return }
const database = record.database
if (!database) { ElMessage.error('备份记录缺少目标数据库信息,无法恢复'); return }
try { await ElMessageBox.confirm(`恢复会先清空当前表数据,然后写回备份中的数据:${(record.tables || []).join('、')}。确认恢复吗?`, '确认还原', { type: 'warning', confirmButtonText: '确认还原', cancelButtonText: '取消' }) } catch (_) { return }
restoring.value = record.filename
try { const result = await restoreClearedTables({ filename: record.filename, source_id: source.id, database, schema: database }); ElMessage.success(result.message) } catch (error) { ElMessage.error(error.message) } finally { restoring.value = '' }
}
onMounted(async () => { try { sources.value = await listDataSources() } catch (_) { sources.value = [] } await loadVersions() })
</script>
<style scoped>
.clear-toolbar{margin-bottom:12px}.clear-search{min-width:220px}.clear-list{min-width:960px}.filename,.tables{max-width:300px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.empty-cell{text-align:center;color:#909399;padding:36px!important}.clear-modal{width:1000px;max-width:94vw}.clear-modal-body{padding:0 20px;max-height:64vh;overflow-y:auto}.step{display:flex;gap:14px;padding:22px 0;border-bottom:1px solid #ebeef5;align-items:flex-start}.step:last-child{border-bottom:0}.step-no{width:28px;height:28px;border-radius:50%;background:#409eff;color:#fff;display:inline-flex;align-items:center;justify-content:center;font-weight:600;flex:0 0 auto}.step-content{flex:1;min-width:0}.step-content b{display:inline-block;line-height:40px;color:var(--app-text)}.source-select{width:380px;margin-left:20px;vertical-align:middle}.scope-choice{margin-top:12px}.table-picker{margin-top:14px;max-height:220px;overflow:auto;padding:10px 14px;border:1px solid var(--app-border)}.table-picker :deep(.el-checkbox){display:block;margin:7px 0}.table-picker small,.muted{color:var(--app-text-muted)}.clear-warning{margin-top:10px;padding:10px 12px;background:#fdf6ec;border:1px solid #faecd8;color:#e6a23c;font-size:13px;line-height:1.6}.clear-modal .modal-footer{min-height:68px;padding:16px 20px;box-sizing:border-box}
</style>
This diff is collapsed.
......@@ -40,7 +40,8 @@
<el-form :model="form" label-width="110px">
<el-form-item label="规则名称" required><el-input v-model="form.name" placeholder="例如:身份证格式校验" /></el-form-item>
<el-form-item label="规则类型"><el-radio-group v-model="form.rule_type"><el-radio-button label="regex">正则</el-radio-button><el-radio-button label="number">数值</el-radio-button><el-radio-button label="date">日期</el-radio-button><el-radio-button label="string">字符串</el-radio-button></el-radio-group></el-form-item>
<el-form-item label="规则说明"><el-input v-model="form.description" type="textarea" :rows="2" placeholder="描述需要校验的数据格式或范围" /></el-form-item>
<el-form-item label="AI生成提示词"><el-input v-model="form.ai_prompt" type="textarea" :rows="3" maxlength="2000" show-word-limit placeholder="例如:校验中国大陆居民身份证。要求为 18 位,前 17 位为数字,末位为数字或 X;同时校验行政区划码、出生日期和校验码。" /></el-form-item>
<el-form-item label="规则说明" required><el-input v-model="form.description" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="AI 将根据提示词生成详细说明;也可以手动补充或修改" /></el-form-item>
<el-form-item :label="form.rule_type === 'regex' ? '正则表达式' : '校验代码'" required>
<el-input v-model="ruleContent" type="textarea" :rows="form.rule_type === 'regex' ? 2 : 8" :placeholder="form.rule_type === 'regex' ? '^.+$' : 'def check(value):\n return True'" />
<div class="expression-actions">
......@@ -66,7 +67,7 @@ import { createRule, deleteRule, listRules, updateRule } from '@/api/rules'
import { genRule, validateRule } from '@/api/ai'
const rules = ref([]); const keyword = ref(''); const appliedKeyword = ref(''); const dialogOpen = ref(false); const editing = ref(null)
const form = reactive({ name: '', description: '', rule_type: 'regex', regex: '', code: '', skip_null: false })
const form = reactive({ name: '', ai_prompt: '', description: '', rule_type: 'regex', regex: '', code: '', skip_null: false })
const aiLoading = ref(false); const testing = ref(false); const testResult = ref(null); const aiNote = ref('')
const ruleContent = computed({ get: () => form.rule_type === 'regex' ? form.regex : form.code, set: (v) => { if (form.rule_type === 'regex') form.regex = v; else form.code = v } })
const typeLabel = (v) => ({ regex: '正则', number: '数值', date: '日期', string: '字符串' })[v] || v
......@@ -75,6 +76,7 @@ const filteredRules = computed(() => {
if (!q) return rules.value
return rules.value.filter((rule) => [
rule.name,
rule.ai_prompt,
rule.description,
rule.regex,
rule.code,
......@@ -84,16 +86,20 @@ const filteredRules = computed(() => {
function onQuery() { appliedKeyword.value = keyword.value }
function onReset() { keyword.value = ''; appliedKeyword.value = '' }
async function load() { try { rules.value = await listRules() } catch (e) { ElMessage.error(`加载规则失败:${e.message}`) } }
function reset(rule = null) { editing.value = rule; Object.assign(form, rule ? { ...rule } : { name: '', description: '', rule_type: 'regex', regex: '', code: '', skip_null: false }); testResult.value = null; aiNote.value = '' }
function reset(rule = null) { editing.value = rule; Object.assign(form, rule ? { ai_prompt: '', ...rule } : { name: '', ai_prompt: '', description: '', rule_type: 'regex', regex: '', code: '', skip_null: false }); testResult.value = null; aiNote.value = '' }
function openCreate() { reset(); dialogOpen.value = true }; function openEdit(rule) { reset(rule); dialogOpen.value = true }
async function generateByAI() {
const prompt = form.description.trim() || form.name.trim()
if (!prompt) { ElMessage.warning('请先填写规则名称或规则说明,AI 才能生成表达式'); return }
const prompt = form.ai_prompt.trim() || form.description.trim() || form.name.trim()
if (!prompt) { ElMessage.warning('请先填写 AI 生成提示词、规则名称或规则说明'); return }
aiLoading.value = true; aiNote.value = ''
try {
const result = await genRule(prompt, form.rule_type)
const result = await genRule(form.description.trim() || form.name.trim(), form.rule_type, form.name.trim(), prompt)
if (!result.ok || !result.code) throw new Error(result.note || 'AI 未生成有效表达式')
ruleContent.value = result.code; aiNote.value = result.note || 'AI 已生成表达式'; ElMessage.success('AI 表达式已生成')
if (!result.desc || !result.desc.trim()) throw new Error('AI 未返回规则说明,请确认后端已更新并重试')
ruleContent.value = result.code
form.description = result.desc
aiNote.value = result.note || 'AI 已根据提示词生成详细规则说明和表达式'
ElMessage.success('AI 已生成详细规则说明和表达式')
} catch (e) { aiNote.value = e.message; ElMessage.error(`AI 生成失败:${e.message}`) } finally { aiLoading.value = false }
}
async function validateExpression() {
......@@ -105,7 +111,11 @@ async function validateExpression() {
finally { testing.value = false }
}
watch(() => form.rule_type, () => { testResult.value = null })
async function save() { try { const result = editing.value ? await updateRule(editing.value.id, form) : await createRule(form); const i = rules.value.findIndex((r) => r.id === result.id); if (i >= 0) rules.value[i] = result; else rules.value.unshift(result); dialogOpen.value = false; ElMessage.success('规则已保存') } catch (e) { ElMessage.error(`保存失败:${e.message}`) } }
async function save() {
if (!form.name.trim()) { ElMessage.warning('请填写规则名称'); return }
if (!form.description.trim()) { ElMessage.warning('请填写规则说明'); return }
try { const result = editing.value ? await updateRule(editing.value.id, form) : await createRule(form); const i = rules.value.findIndex((r) => r.id === result.id); if (i >= 0) rules.value[i] = result; else rules.value.unshift(result); dialogOpen.value = false; ElMessage.success('规则已保存') } catch (e) { ElMessage.error(`保存失败:${e.message}`) }
}
async function remove(rule) { try { await ElMessageBox.confirm(`删除规则「${rule.name}」?`, '删除规则', { type: 'warning' }); await deleteRule(rule.id); rules.value = rules.value.filter((r) => r.id !== rule.id); ElMessage.success('已删除') } catch (e) { if (e !== 'cancel' && e !== 'close') ElMessage.error(`删除失败:${e.message}`) } }
onMounted(load)
</script>
......
<template>
<div class="result-page">
<div class="q-card query-card">
<div class="query-row">
<div class="query-item">
<span class="query-label"><span class="required">*</span>校验名称:</span>
<select v-model="selectedConfigId" class="select-input" :disabled="loadingConfigs || !configs.length" @change="onConfigChange">
<option v-if="loadingConfigs" disabled value="">加载中…</option>
<option v-else-if="!configs.length" disabled value="">暂无表设计校验</option>
<option v-for="item in configs" :key="item.id" :value="String(item.id)">{{ item.name }}</option>
</select>
<button class="btn btn-primary" :disabled="!selectedConfigId || loading" @click="reload">{{ loading ? '扫描中…' : '开始扫描' }}</button>
<button class="btn" @click="back">返回列表</button>
</div>
</div>
</div>
<div v-if="selectedConfig" class="task-info-bar">
<el-icon><InfoFilled /></el-icon>
<span>当前任务:<b>{{ selectedConfig.name }}</b></span>
<span class="meta">数据库:<b>{{ selectedConfig.schema || '—' }}</b></span>
<span class="meta" v-if="result">共 <b>{{ result.total }}</b> 张表 · 合格 <b class="good-text">{{ result.passed }}</b> 张 · 不合格 <b class="bad-text">{{ result.failed }}</b> 张</span>
<span class="meta" v-else style="color:#909399;">尚未扫描</span>
</div>
<div class="q-card">
<div class="q-card__header"><div class="q-card__title">表设计校验结果 <span v-if="result" class="count">共 {{ result.total }} 张表</span></div></div>
<div v-if="error" class="query-error">{{ error }}</div>
<div v-if="loading" class="empty-cell">正在扫描,请稍候…</div>
<div v-else-if="!result" class="empty-cell">请选择校验配置并点击“开始扫描”</div>
<div v-else class="table-scroll"><table class="design-result-table"><thead><tr><th>数据表</th><th>表注释</th><th>状态</th><th>不合格原因</th></tr></thead><tbody><tr v-for="row in result.results" :key="row.table_name"><td>{{ row.table_name }}</td><td>{{ row.table_comment || '—' }}</td><td><span :class="row.ok ? 'status-good' : 'status-bad'">{{ row.ok ? '合格' : '不合格' }}</span></td><td><span v-if="row.ok">—</span><div v-else><div v-for="reason in row.reasons" :key="reason">{{ reason }}</div><div v-if="row.missing_columns?.length" class="missing">字段:{{ row.missing_columns.join('、') }}</div></div></td></tr><tr v-if="!result.results?.length"><td colspan="4" class="empty-cell">未找到可校验的数据表</td></tr></tbody></table></div>
</div>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { checkTableDesign, getTableDesignConfig, listTableDesignConfigs } from '@/api/tableDesign'
const route = useRoute(); const router = useRouter()
const configs = ref([]); const selectedConfigId = ref(''); const selectedConfig = ref(null); const result = ref(null); const loading = ref(false); const loadingConfigs = ref(false); const error = ref('')
function setConfig(id) { selectedConfigId.value = String(id || ''); selectedConfig.value = configs.value.find((x) => String(x.id) === selectedConfigId.value) || null; result.value = selectedConfig.value?.lastResult || null; error.value = '' }
async function onConfigChange() { setConfig(selectedConfigId.value); if (selectedConfig.value?.lastResult) return }
async function reload() { if (!selectedConfig.value) return; loading.value = true; error.value = ''; try { const c = await getTableDesignConfig(selectedConfig.value.id); selectedConfig.value = c; const r = await checkTableDesign({ config_id: c.id, source_id: c.sourceId, schema: c.schema, scope: c.scope, tables: c.tables, check_table_comment: c.checkTableComment, check_column_comment: c.checkColumnComment }); if (!r.ok) throw new Error(r.message); result.value = r; const i = configs.value.findIndex((x) => x.id === c.id); if (i >= 0) configs.value[i] = { ...c, lastResult: r } ; ElMessage.success(`扫描完成:共 ${r.total} 张表,不合格 ${r.failed} 张`) } catch (e) { error.value = e.message; ElMessage.error(`扫描失败:${e.message}`) } finally { loading.value = false } }
function back() { router.push({ name: 'TableDesign' }) }
onMounted(async () => { loadingConfigs.value = true; try { configs.value = await listTableDesignConfigs(); const id = route.query.config || configs.value[0]?.id; if (id) { setConfig(id); if (!selectedConfig.value?.lastResult) await reload() } } catch (e) { error.value = e.message } finally { loadingConfigs.value = false } })
</script>
<style scoped>
.result-page{min-width:900px}.query-card{padding:14px 16px}.design-result-table{width:100%;border-collapse:collapse;table-layout:fixed;font-size:13px}.design-result-table th{background:#3f477f;color:#fff;padding:10px 12px;text-align:left;font-weight:500}.design-result-table td{padding:10px 12px;color:rgba(255,255,255,.86);border-bottom:1px solid #31375a;vertical-align:top;word-break:break-word}.design-result-table tbody tr:hover{background:rgba(255,255,255,.04)}.design-result-table th:nth-child(1){width:22%}.design-result-table th:nth-child(2){width:26%}.design-result-table th:nth-child(3){width:12%}.design-result-table th:nth-child(4){width:40%}.empty-cell{text-align:center;color:#aeb7cd;padding:24px}.good-text,.status-good{color:#67c23a}.bad-text,.status-bad,.missing{color:#f56c6c}.status-good,.status-bad{font-weight:600}.missing{margin-top:4px}.query-error{margin:10px 16px;padding:8px 12px;color:#f56c6c;background:rgba(245,108,108,.1);border:1px solid rgba(245,108,108,.25);border-radius:4px}
</style>
This diff is collapsed.
......@@ -2,7 +2,7 @@ import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
export default defineConfig(({ mode }) => ({
plugins: [vue()],
resolve: {
alias: {
......@@ -18,9 +18,12 @@ export default defineConfig({
// /api/* 反向代理到 web 后端(FastAPI @ 8767),避免浏览器跨域
proxy: {
'/api': {
target: 'http://localhost:8767',
// codex-preview 是并行本地预览模式,避免占用用户已运行的 8767 服务。
target: mode === 'codex-preview'
? 'http://localhost:8768'
: process.env.VITE_API_TARGET || 'http://localhost:8767',
changeOrigin: true,
},
},
},
})
\ 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