Commit b0550425 authored by wangteng's avatar wangteng

整体功能优化,支持oracle

parent aeb29b90
This diff is collapsed.
...@@ -66,6 +66,7 @@ from web.backend.routers.operations import router as operations_router # noqa: ...@@ -66,6 +66,7 @@ from web.backend.routers.operations import router as operations_router # noqa:
from web.backend.routers.rules import router as rules_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 from web.backend.routers.table_design import router as table_design_router # noqa: E402
from web.backend.routers.llm_config import router as llm_config_router # noqa: E402 from web.backend.routers.llm_config import router as llm_config_router # noqa: E402
from web.backend.routers.table_prefs import router as table_prefs_router # noqa: E402
app.include_router(task_groups_router, prefix="/api") app.include_router(task_groups_router, prefix="/api")
app.include_router(tasks_router, prefix="/api") app.include_router(tasks_router, prefix="/api")
app.include_router(db_router, prefix="/api") app.include_router(db_router, prefix="/api")
...@@ -77,6 +78,7 @@ app.include_router(operations_router, prefix="/api") ...@@ -77,6 +78,7 @@ app.include_router(operations_router, prefix="/api")
app.include_router(rules_router, prefix="/api") app.include_router(rules_router, prefix="/api")
app.include_router(table_design_router, prefix="/api") app.include_router(table_design_router, prefix="/api")
app.include_router(llm_config_router, prefix="/api") app.include_router(llm_config_router, prefix="/api")
app.include_router(table_prefs_router, prefix="/api")
logger.info("已注册路由:/api/task-groups, /api/tasks, /api/data-sources, /api/connect/*, /api/connection-presets, /api/ai/*, /api/queries/*") logger.info("已注册路由:/api/task-groups, /api/tasks, /api/data-sources, /api/connect/*, /api/connection-presets, /api/ai/*, /api/queries/*")
......
...@@ -226,6 +226,11 @@ def _render_info_columns_sql(dialect: str) -> str: ...@@ -226,6 +226,11 @@ def _render_info_columns_sql(dialect: str) -> str:
return get_sql_loader().render("info_schema/list_columns", dialect=dialect) return get_sql_loader().render("info_schema/list_columns", dialect=dialect)
def _render_info_column_comments_sql(dialect: str) -> str:
from web.backend.core.sql_loader import get_sql_loader
return get_sql_loader().render("info_schema/list_column_comments", dialect=dialect)
def _render_info_tables_sql(dialect: str) -> str: def _render_info_tables_sql(dialect: str) -> str:
from web.backend.core.sql_loader import get_sql_loader from web.backend.core.sql_loader import get_sql_loader
return get_sql_loader().render("info_schema/list_tables", dialect=dialect) return get_sql_loader().render("info_schema/list_tables", dialect=dialect)
...@@ -526,6 +531,11 @@ class DBConnection: ...@@ -526,6 +531,11 @@ class DBConnection:
"未安装 oracledb,请执行: pip install oracledb\n" "未安装 oracledb,请执行: pip install oracledb\n"
"(oracledb thin 模式无需 Oracle Instant Client)" "(oracledb thin 模式无需 Oracle Instant Client)"
) from e ) from e
# 2026-09-28:LOB 列直接取值(CLOB→str / BLOB→bytes),不要 LOB 定位符对象。
# 默认 fetch_lobs=True 时 CLOB 列返回 oracledb.LOB 对象,下游
# json 序列化会写成 "<oracledb.LOB object at 0x...>"(备份静默损坏),
# 加密侧 isinstance(old, str) 判断失效。进程级一次性设置。
oracledb.defaults.fetch_lobs = False
return "oracledb" return "oracledb"
raise ValueError(f"不支持的数据库类型: {db_type!r}(目前支持 mysql / dameng / oracle)") raise ValueError(f"不支持的数据库类型: {db_type!r}(目前支持 mysql / dameng / oracle)")
...@@ -732,6 +742,22 @@ class DBConnection: ...@@ -732,6 +742,22 @@ class DBConnection:
finally: finally:
cur.close() cur.close()
def executemany(self, sql: str, rows: list[tuple]) -> int:
"""批量执行同构参数化语句(还原数据按批插入用)。
pymysql / oracledb / dmPython 均支持 DBAPI executemany;
oracledb 对编号占位符(:1/:2)的批量绑定有专门优化。
"""
cur = self._cursor()
started = time.monotonic()
try:
n = cur.executemany(sql, rows)
elapsed_ms = (time.monotonic() - started) * 1000
logger.debug(f"[SQL] executemany {len(rows)} rows ({elapsed_ms:.1f}ms): {sql[:120]}...")
return n
finally:
cur.close()
def commit(self) -> None: def commit(self) -> None:
"""提交写入操作(备份还原等写库流程使用)。""" """提交写入操作(备份还原等写库流程使用)。"""
if self._conn is None: if self._conn is None:
...@@ -748,9 +774,27 @@ class DBConnection: ...@@ -748,9 +774,27 @@ class DBConnection:
SQL 通过 web/backend/core/sql_templates/info_schema/list_columns.<dialect>.sql 加载, SQL 通过 web/backend/core/sql_templates/info_schema/list_columns.<dialect>.sql 加载,
schema / table_name 都作为绑定参数传给 cursor,由驱动层负责转义,杜绝 SQL 注入。 schema / table_name 都作为绑定参数传给 cursor,由驱动层负责转义,杜绝 SQL 注入。
2026-09-28(Oracle):列注释不再放进大 SQL(嵌套子查询 + 12 列的长语句会被
内网安全设备按签名掐连接 —— ORA-03113/DPI-1080,挂 ~19 秒后断;逐项删减
任何一列都能过,属长度+复杂度组合签名)。改为模板只查列元数据,
注释用 list_column_comments 的小查询单独取,Python 侧按列名合并。
同一列多条注释时保留最长非空(与原 ROW_NUMBER DESC NULLS LAST 启发式一致)。
""" """
sql = _render_info_columns_sql(self.cfg.db_type) sql = _render_info_columns_sql(self.cfg.db_type)
return self.fetchall(sql, (schema, table_name)) rows = self.fetchall(sql, (schema, table_name))
if self.cfg.db_type == "oracle":
csql = _render_info_column_comments_sql(self.cfg.db_type)
comments: dict[str, str] = {}
for row in self.fetchall(csql, (schema, table_name)):
key = str(row.get("column_name") or "").lower()
value = str(row.get("comments") or "").strip()
if value and len(value) > len(comments.get(key, "")):
comments[key] = value
for row in rows:
row["column_comment"] = comments.get(
str(row.get("column_name") or "").lower()) or None
return rows
def list_tables(self, schema: str) -> list[dict]: def list_tables(self, schema: str) -> list[dict]:
"""返回指定 schema 下所有表的元数据(schema 走绑定参数)""" """返回指定 schema 下所有表的元数据(schema 走绑定参数)"""
......
...@@ -20,16 +20,27 @@ def record_operation( ...@@ -20,16 +20,27 @@ def record_operation(
filename: str = "", filename: str = "",
execute_setting: str = "", execute_setting: str = "",
) -> OperationRecord: ) -> OperationRecord:
"""保存一次已成功完成的操作;密码等敏感请求参数永不写入历史。""" """保存一次已成功完成的操作;密码等敏感请求参数永不写入历史。
2026-09-28:execute_setting / tables_json 统一截断 —— 落库 INSERT 的单条
报文超 ~700B 会被内网安全设备掐断(2013,且连接残留僵尸事务),长消息
(如整表加密跳过几十个字段名)在这里兜底,不让成功的操作因记录失败而报错。
"""
setting = execute_setting or ""
if len(setting) > 400:
setting = setting[:400] + "…"
tables_json = json.dumps([str(table) for table in tables if table], ensure_ascii=False)
if len(tables_json) > 400:
tables_json = tables_json[:400] + "…]"
row = OperationRecord( row = OperationRecord(
operation=operation, operation=operation,
source_id=source.id if source else None, source_id=source.id if source else None,
source_name=source.name if source else "", source_name=source.name if source else "",
database_name=database or "", database_name=database or "",
scope=scope or "", scope=scope or "",
tables_json=json.dumps([str(table) for table in tables if table], ensure_ascii=False), tables_json=tables_json,
filename=filename or "", filename=(filename or "")[:200],
execute_setting=execute_setting or "", execute_setting=setting,
) )
db.add(row) db.add(row)
db.commit() db.commit()
......
This diff is collapsed.
...@@ -81,6 +81,19 @@ def _render_placeholders(template: str, params: dict) -> str: ...@@ -81,6 +81,19 @@ def _render_placeholders(template: str, params: dict) -> str:
return _PLACEHOLDER_RE.sub(_replace, template) return _PLACEHOLDER_RE.sub(_replace, template)
# ── 剥离模板头部注释 ─────────────────────────────────────
# 模板文件顶部的 -- 注释横幅只在源码里有意义;发给数据库前统一剥掉。
# 2026-09-28 实测:192.168.20.10 的 MySQL 对「带长中文注释头的
# information_schema 查询」稳定掐连接(2013 Lost connection,疑似链路
# 安全设备对特定报文内容误判),裸 SQL 正常。只剥第一段注释——字符串
# 字面量不可能出现在首条语句之前,安全。
_LEADING_COMMENT_RE = re.compile(r"\A(?:\s*--[^\n]*\n)+\s*")
def _strip_leading_comments(sql: str) -> str:
return _LEADING_COMMENT_RE.sub("", sql, count=1)
# ── 加载器 ──────────────────────────────────────────────── # ── 加载器 ────────────────────────────────────────────────
class SQLLoader: class SQLLoader:
"""SQL 模板加载器(单例)""" """SQL 模板加载器(单例)"""
...@@ -130,7 +143,7 @@ class SQLLoader: ...@@ -130,7 +143,7 @@ class SQLLoader:
""" """
text = self._load_text(name, dialect) text = self._load_text(name, dialect)
try: try:
return _render_placeholders(text, params) return _strip_leading_comments(_render_placeholders(text, params))
except KeyError as e: except KeyError as e:
raise KeyError(f"SQL 模板 {name!r} (dialect={dialect}): {e}") from None raise KeyError(f"SQL 模板 {name!r} (dialect={dialect}): {e}") from None
......
-- ============================================================================
-- 指定表的列注释(Oracle 方言)
-- 调用方:web/backend/core/db_adapter.py → list_columns()(Oracle 专用第二步,
-- 与 list_columns.oracle.sql 配套;列注释拆小查询的原因见该模板头注释)
-- 参数(编号占位符各出现一次,勿重复):
-- :1 schema 用户/模式名
-- :2 table_name 表名
-- 说明:
-- - ALL_COL_COMMENTS 同一列可能有 2+ 行(DBA/USER 视角双份等),
-- 全部返回,由 Python 侧按「保留最长非空」合并
-- ============================================================================
SELECT cc.COLUMN_NAME AS column_name,
cc.COMMENTS AS comments
FROM ALL_COL_COMMENTS cc
WHERE cc.OWNER = UPPER(:1)
AND cc.TABLE_NAME = UPPER(:2)
...@@ -16,6 +16,12 @@ ...@@ -16,6 +16,12 @@
-- 用 ROW_NUMBER() 子查询预聚合,每 (OWNER, TABLE_NAME, COLUMN_NAME) 取一条; -- 用 ROW_NUMBER() 子查询预聚合,每 (OWNER, TABLE_NAME, COLUMN_NAME) 取一条;
-- ORDER BY COMMENTS DESC NULLS LAST 假设「最长的注释就是用户最后写的」(启发式, -- ORDER BY COMMENTS DESC NULLS LAST 假设「最长的注释就是用户最后写的」(启发式,
-- 99% 场景够用;如有歧义,用户改 desc 后新注释会按 COMMENTS 长度重新排序被选中)。 -- 99% 场景够用;如有歧义,用户改 desc 后新注释会按 COMMENTS 长度重新排序被选中)。
--
-- 2026-09-28:注释查询改为关联标量子查询(同 Oracle 方言)。
-- 原因1(性能):子查询不带过滤会对全库 ALL_COL_COMMENTS 做窗口聚合。
-- 原因2(绑定):过滤条件复制进子查询后 ? 出现 4 次而调用方只传 2 个值,
-- 按位占位符数量必须与绑定值一致;关联子查询引用外层 c.* 无需额外绑定。
-- MAX 忽略 NULL,与「DESC NULLS LAST 取第一条」等价(取最大非空注释)。
-- ============================================================================ -- ============================================================================
SELECT SELECT
c.TABLE_NAME AS table_name, c.TABLE_NAME AS table_name,
...@@ -28,24 +34,13 @@ SELECT ...@@ -28,24 +34,13 @@ SELECT
c.DATA_SCALE AS numeric_scale, c.DATA_SCALE AS numeric_scale,
c.NULLABLE AS is_nullable, c.NULLABLE AS is_nullable,
c.DATA_DEFAULT AS column_default, c.DATA_DEFAULT AS column_default,
cc.COMMENTS AS column_comment, (SELECT MAX(cc.COMMENTS)
FROM ALL_COL_COMMENTS cc
WHERE cc.OWNER = c.OWNER
AND cc.TABLE_NAME = c.TABLE_NAME
AND cc.COLUMN_NAME = c.COLUMN_NAME) AS column_comment,
NULL AS extra NULL AS extra
FROM ALL_TAB_COLUMNS c FROM ALL_TAB_COLUMNS c
LEFT JOIN (
SELECT OWNER, TABLE_NAME, COLUMN_NAME, COMMENTS
FROM (
SELECT cc.OWNER, cc.TABLE_NAME, cc.COLUMN_NAME, cc.COMMENTS,
ROW_NUMBER() OVER (
PARTITION BY cc.OWNER, cc.TABLE_NAME, cc.COLUMN_NAME
ORDER BY cc.COMMENTS DESC NULLS LAST
) AS rn
FROM ALL_COL_COMMENTS cc
)
WHERE rn = 1
) cc
ON cc.OWNER = c.OWNER
AND cc.TABLE_NAME = c.TABLE_NAME
AND cc.COLUMN_NAME = c.COLUMN_NAME
WHERE c.OWNER = ? WHERE c.OWNER = ?
AND c.TABLE_NAME = ? AND c.TABLE_NAME = ?
ORDER BY c.COLUMN_ID ORDER BY c.COLUMN_ID
-- ============================================================================ -- ============================================================================
-- 列出指定 schema 下指定表的列元数据 (Oracle 方言) -- 列出指定 schema 下指定表的列元数据 (Oracle 方言)
-- 调用方:web/backend/core/db_adapter.py → list_columns() -- 调用方:web/backend/core/db_adapter.py → list_columns()
-- 参数(命名占位符 :1/:2): -- 参数(编号占位符各出现一次,勿重复——thick 模式重复编号会绑定错乱):
-- :1 schema 用户/模式名(调用方传入的可能是小写,这里 UPPER() 兼容) -- :1 schema(UPPER 兼容小写输入) :2 table_name
-- :2 table_name 表名
-- 说明:
-- - Oracle 没有 INFORMATION_SCHEMA,使用 ALL_TAB_COLUMNS(结构与达梦高度一致)
-- - Oracle 的 OWNER 通常为大写;调用方传入的小写 schema 也用 UPPER() 兼容
-- - 占位符用 :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
-- --
-- 2026-08-21:LEFT JOIN 右侧去重(同达梦) -- 2026-09-28 两条硬约束(公积金库 192.168.20.220 实测,详见 db_adapter.list_columns):
-- Oracle 的 ALL_COL_COMMENTS 里同一 (OWNER, TABLE_NAME, COLUMN_NAME) 偶尔也有 2+ 行 -- 1. 语句必须保持紧凑(<1000 字符):内网安全设备对超长 SQL 按签名掐连接
-- (DBA / USER 视角双份、comment refresh 残留等),直接 LEFT JOIN 会翻倍。 -- (ORA-03113/DPI-1080,挂 ~19s 断;阈值实测在 1070~1085 之间),
-- 用 ROW_NUMBER() 子查询每组取一条,ORDER BY COMMENTS DESC NULLS LAST。 -- 所以本模板不做多行对齐排版,改完必须复测长度。
-- 2. 列注释不放本模板(嵌套子查询会显著加长语句),由
-- list_column_comments.oracle.sql 小查询单独取、db_adapter 合并。
-- - 11g 无 CHAR_LENGTH 之外的兼容问题;CHAR_LENGTH 按字符数(加密长度
-- 检查用),NUMBER 补 (p,s) 精度供还原 fallback 建表。
-- ============================================================================ -- ============================================================================
SELECT SELECT c.TABLE_NAME AS table_name, c.COLUMN_NAME AS column_name, c.COLUMN_ID AS ordinal_position, CASE WHEN c.DATA_TYPE IN ('VARCHAR2','NVARCHAR2','CHAR','NCHAR') THEN c.DATA_TYPE || '(' || c.CHAR_LENGTH || ')' WHEN c.DATA_TYPE = 'RAW' THEN c.DATA_TYPE || '(' || c.DATA_LENGTH || ')' WHEN c.DATA_TYPE = 'NUMBER' AND c.DATA_PRECISION IS NOT NULL AND c.DATA_SCALE IS NOT NULL AND c.DATA_SCALE > 0 THEN c.DATA_TYPE || '(' || c.DATA_PRECISION || ',' || c.DATA_SCALE || ')' WHEN c.DATA_TYPE = 'NUMBER' AND c.DATA_PRECISION IS NOT NULL THEN c.DATA_TYPE || '(' || c.DATA_PRECISION || ')' ELSE c.DATA_TYPE END AS column_type, c.DATA_TYPE AS data_type, c.CHAR_LENGTH AS char_max_length, c.DATA_PRECISION AS numeric_precision, c.DATA_SCALE AS numeric_scale, c.NULLABLE AS is_nullable, c.DATA_DEFAULT AS column_default, NULL AS extra FROM ALL_TAB_COLUMNS c WHERE c.OWNER = UPPER(:1) AND c.TABLE_NAME = UPPER(:2) ORDER BY c.COLUMN_ID
c.TABLE_NAME AS table_name,
c.COLUMN_NAME AS column_name,
c.COLUMN_ID AS ordinal_position,
CASE
WHEN c.DATA_TYPE IN ('VARCHAR2','NVARCHAR2','CHAR','RAW')
THEN c.DATA_TYPE || '(' || c.DATA_LENGTH || ')'
ELSE c.DATA_TYPE
END AS column_type,
c.DATA_TYPE AS data_type,
c.DATA_LENGTH AS char_max_length,
c.DATA_PRECISION AS numeric_precision,
c.DATA_SCALE AS numeric_scale,
c.NULLABLE AS is_nullable,
c.DATA_DEFAULT AS column_default,
cc.COMMENTS AS column_comment,
NULL AS extra
FROM ALL_TAB_COLUMNS c
LEFT JOIN (
SELECT OWNER, TABLE_NAME, COLUMN_NAME, COMMENTS
FROM (
SELECT cc.OWNER, cc.TABLE_NAME, cc.COLUMN_NAME, cc.COMMENTS,
ROW_NUMBER() OVER (
PARTITION BY cc.OWNER, cc.TABLE_NAME, cc.COLUMN_NAME
ORDER BY cc.COMMENTS DESC NULLS LAST
) AS rn
FROM ALL_COL_COMMENTS cc
)
WHERE rn = 1
) 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)
AND c.TABLE_NAME = UPPER(:2)
ORDER BY c.COLUMN_ID
...@@ -11,10 +11,11 @@ from .rule import Rule ...@@ -11,10 +11,11 @@ from .rule import Rule
from .rule_library import FieldRule, RuleLibrary from .rule_library import FieldRule, RuleLibrary
from .task import Task from .task import Task
from .task_group import TaskGroup from .task_group import TaskGroup
from .table_pref import TablePref
from .data_source import DataSource from .data_source import DataSource
from .operation_record import OperationRecord from .operation_record import OperationRecord
from .table_design_config import TableDesignConfig from .table_design_config import TableDesignConfig
from .llm_config import LLMConfigRecord from .llm_config import LLMConfigRecord
from .system_config import SystemConfig from .system_config import SystemConfig
__all__ = ["ConnectionPreset", "Field", "Rule", "RuleLibrary", "FieldRule", "Task", "TaskGroup", "DataSource", "OperationRecord", "TableDesignConfig", "LLMConfigRecord", "SystemConfig"] __all__ = ["ConnectionPreset", "Field", "Rule", "RuleLibrary", "FieldRule", "Task", "TaskGroup", "DataSource", "OperationRecord", "TableDesignConfig", "LLMConfigRecord", "SystemConfig", "TablePref"]
"""table_pref ORM 模型 —— 列表页表格偏好,按页面标识存储
用途:各列表页「表设置」功能的持久化,完整对齐参考项目的偏好模型:
列宽 / 列显隐 / 列对齐 / 列顺序(列级),行高 / 线框粗细(页面级)。
存储约定:
- 列级偏好:每列一行(col_key = 表头文字);
- 页面级偏好:单独一行,col_key = '__page__',row_height / border_width 有值。
所有业务数据统一存 MySQL(SQLite 已废弃,仅作历史备份)。
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import Column, DateTime, Integer, String, UniqueConstraint
from sqlalchemy.sql import func
from web.backend.db.database import Base
# 页面级偏好行使用的特殊列标识
PAGE_LEVEL_COL_KEY = "__page__"
class TablePref(Base):
__tablename__ = "table_pref"
__table_args__ = (
UniqueConstraint("page_key", "col_key", name="uq_table_pref_page_col"),
# 表注释(建表时写入 MySQL COMMENT;已存在的表由维护脚本补 ALTER)
{"comment": "列表页表格偏好(表设置:列宽/列显隐/列对齐/列顺序/行高/线框粗细)"},
)
id = Column(Integer, primary_key=True, autoincrement=True, comment="主键")
# 页面标识:task-config / data-sources / rule-library / operation-backup / clear-tables / table-design
page_key = Column(String(64), nullable=False, comment="页面标识")
# 列标识:表头文字;页面级偏好行固定为 __page__
col_key = Column(String(100), nullable=False, comment="列标识(表头文字;__page__ 为页面级偏好行)")
# 列宽(像素);NULL 表示未自定义(跟随默认布局)
width = Column(Integer, nullable=True, comment="列宽 px;NULL=未自定义")
# 是否隐藏:0 显示 / 1 隐藏
hidden = Column(Integer, nullable=False, default=0, comment="是否隐藏:0 显示 1 隐藏")
# 列对齐:left / center / right;NULL 表示跟随页面默认(各列自身的样式)
align = Column(String(10), nullable=True, comment="列对齐:left/center/right;NULL=默认")
# 列顺序:0 起的显示顺序;NULL 表示保持原始位置
ord = Column(Integer, nullable=True, comment="列顺序(0 起);NULL=原始位置")
# 页面级行高(像素):36 紧凑 / 40 默认 / 48 宽松;仅 __page__ 行使用
row_height = Column(Integer, nullable=True, comment="行高 px(36/40/48);仅页面级行使用")
# 页面级线框粗细:0.5 细 / 1 中 / 2 粗;仅 __page__ 行使用
border_width = Column(Integer, nullable=True, comment="线框粗细档位(0=细 1=中 2=粗);仅页面级行使用")
updated_at = Column(
DateTime,
nullable=False,
server_default=func.current_timestamp(),
onupdate=func.current_timestamp(),
comment="最近更新时间",
)
def to_dict(self) -> dict:
return {
"col_key": self.col_key,
"width": self.width,
"hidden": bool(self.hidden),
"align": self.align,
"ord": self.ord,
}
def __repr__(self) -> str:
return f"<TablePref page={self.page_key!r} col={self.col_key!r} width={self.width} hidden={self.hidden}>"
...@@ -13,6 +13,7 @@ from sqlalchemy.orm import Session ...@@ -13,6 +13,7 @@ from sqlalchemy.orm import Session
from web.backend.db.database import get_session from web.backend.db.database import get_session
from web.backend.models.data_source import DataSource from web.backend.models.data_source import DataSource
from web.backend.core.db_adapter import DBConfig, DBConnection, quote_ident from web.backend.core.db_adapter import DBConfig, DBConnection, quote_ident
from web.backend.core.oracle_ddl import capture_table_ddl
from web.backend.core.sql_loader import get_sql_loader from web.backend.core.sql_loader import get_sql_loader
from web.backend.core.backup_storage import get_backup_dir from web.backend.core.backup_storage import get_backup_dir
from web.backend.core.operation_history import record_operation from web.backend.core.operation_history import record_operation
...@@ -80,6 +81,30 @@ def _qualified_table(cfg: DBConfig, schema: str, table: str) -> str: ...@@ -80,6 +81,30 @@ def _qualified_table(cfg: DBConfig, schema: str, table: str) -> str:
return table_sql return table_sql
def _apply_database_override(cfg: DBConfig, database) -> None:
"""MySQL:database=目标库,可覆盖连接库;Oracle/达梦:database 是
service_name,只来自数据源配置 —— 前端弹窗里用户选的「数据库/Schema」
会误传进 database(如 CDGJJ),覆盖后连接直接 ORA-12514(服务不存在),
schema 一律走 payload.schema / row.schema(见 _resolve_schema)。"""
if database and cfg.db_type not in ("oracle", "dameng"):
cfg.database = database
def _resolve_schema(cfg: DBConfig, row: DataSource, payload) -> str:
"""确定目标 schema:请求指定 > 数据源保存值 > 方言默认。
Oracle 的 cfg.database 是 service_name(如 ORCLPDB1),不能当 schema 回退
(会查出「该数据库没有可访问的数据表」);Oracle 登录用户的当前 schema
就是用户本身,且未加引号的标识符一律大写存储,这里回退 user.upper()。
"""
schema = payload.schema or row.schema
if schema:
return schema
if cfg.db_type == "oracle":
return (cfg.user or "").upper() or cfg.database
return cfg.database
def _backup_filename(value: Optional[str]) -> str: def _backup_filename(value: Optional[str]) -> str:
if not value or not value.strip(): if not value or not value.strip():
return f"db-backup-{datetime.now().strftime('%Y%m%d-%H%M%S-%f')}.json" return f"db-backup-{datetime.now().strftime('%Y%m%d-%H%M%S-%f')}.json"
...@@ -154,9 +179,8 @@ def backup_data_source(source_id: int, payload: BackupRequest, db: Session = Dep ...@@ -154,9 +179,8 @@ def backup_data_source(source_id: int, payload: BackupRequest, db: Session = Dep
if not row: if not row:
raise HTTPException(404, "数据源不存在") raise HTTPException(404, "数据源不存在")
cfg = _cfg(row) cfg = _cfg(row)
if payload.database: _apply_database_override(cfg, payload.database)
cfg.database = payload.database schema = _resolve_schema(cfg, row, payload)
schema = payload.schema or row.schema or cfg.database
tables = list(dict.fromkeys(t.strip() for t in payload.tables if t and t.strip())) tables = list(dict.fromkeys(t.strip() for t in payload.tables if t and t.strip()))
if payload.scope == "tables" and not tables: if payload.scope == "tables" and not tables:
raise HTTPException(400, "至少选择一张表") raise HTTPException(400, "至少选择一张表")
...@@ -196,6 +220,15 @@ def backup_data_source(source_id: int, payload: BackupRequest, db: Session = Dep ...@@ -196,6 +220,15 @@ def backup_data_source(source_id: int, payload: BackupRequest, db: Session = Dep
ddl = str(r0.get("Create Table") or r0.get("create table") or "") ddl = str(r0.get("Create Table") or r0.get("create table") or "")
if ddl: if ddl:
item["create_ddl"] = ddl item["create_ddl"] = ddl
# 2026-09-28:Oracle 用 DBMS_METADATA 抓全量 DDL —— 主键/唯一/
# 外键/CHECK 约束、二级索引、表列注释、触发器及其引用的序列全保真,
# 还原侧按语句分类分阶段执行(见 operations.py restore_backup)
if payload.include_schema and cfg.db_type == "oracle":
ddl_info = capture_table_ddl(conn, schema, actual_table)
if ddl_info.get("create_ddl"):
item["create_ddl"] = ddl_info["create_ddl"]
item["triggers"] = ddl_info["triggers"]
item["sequences"] = ddl_info["sequences"]
if payload.include_data: if payload.include_data:
sql = f"SELECT * FROM {_qualified_table(cfg, schema, actual_table)}" sql = f"SELECT * FROM {_qualified_table(cfg, schema, actual_table)}"
item["rows"] = list(conn.iter_rows(sql)) item["rows"] = list(conn.iter_rows(sql))
......
...@@ -196,14 +196,14 @@ def list_tables(req: TestConnectionRequest): ...@@ -196,14 +196,14 @@ def list_tables(req: TestConnectionRequest):
"""复用 db_adapter.list_tables(),走 info_schema/list_tables.<dialect>.sql 模板""" """复用 db_adapter.list_tables(),走 info_schema/list_tables.<dialect>.sql 模板"""
cfg = _build_db_config(req) cfg = _build_db_config(req)
logger.info("─" * 60) logger.info("─" * 60)
schema = req.schema or req.database schema = req.schema or ((cfg.user or "").upper() if cfg.db_type == "oracle" else req.database)
logger.info(f"POST /api/connect/tables {_mask_password(cfg)} schema={schema!r}") logger.info(f"POST /api/connect/tables {_mask_password(cfg)} schema={schema!r}")
try: try:
with DBConnection(cfg) as db: with DBConnection(cfg) as db:
# Oracle:database 是 service_name(连实例),schema 才是 OWNER(查 ALL_TABLES 用) # Oracle:database 是 service_name(连实例),schema 才是 OWNER(查 ALL_TABLES 用)
# 其他方言:database == schema,缺省时(schema 为 None)fallback 到 database 即可 # 其他方言:database == schema,缺省时(schema 为 None)fallback 到 database 即可
schema = req.schema or req.database schema = req.schema or ((cfg.user or "").upper() if cfg.db_type == "oracle" else req.database)
rows = db.list_tables(schema) rows = db.list_tables(schema)
logger.info(f"✅ 返回 {len(rows)} 张表") logger.info(f"✅ 返回 {len(rows)} 张表")
logger.info("─" * 60) logger.info("─" * 60)
...@@ -231,7 +231,7 @@ def list_columns(req: ListColumnsRequest): ...@@ -231,7 +231,7 @@ def list_columns(req: ListColumnsRequest):
""" """
cfg = _build_db_config(req) cfg = _build_db_config(req)
logger.info("─" * 60) logger.info("─" * 60)
schema = req.schema or req.database schema = req.schema or ((cfg.user or "").upper() if cfg.db_type == "oracle" else req.database)
logger.info( logger.info(
f"POST /api/connect/columns {_mask_password(cfg)} " f"POST /api/connect/columns {_mask_password(cfg)} "
f"schema={schema!r} table={req.table_name!r}" f"schema={schema!r} table={req.table_name!r}"
...@@ -248,4 +248,4 @@ def list_columns(req: ListColumnsRequest): ...@@ -248,4 +248,4 @@ def list_columns(req: ListColumnsRequest):
logger.exception(f"❌ {msg}") logger.exception(f"❌ {msg}")
_log_failure_hint(str(e)) _log_failure_hint(str(e))
logger.info("─" * 60) logger.info("─" * 60)
return {"ok": False, "message": msg, "columns": []} return {"ok": False, "message": msg, "columns": []}
\ No newline at end of file
This diff is collapsed.
...@@ -261,11 +261,30 @@ def _build_db_config(task: Task) -> DBConfig: ...@@ -261,11 +261,30 @@ def _build_db_config(task: Task) -> DBConfig:
) )
def _build_select_sql(cfg: DBConfig, col_names: list[str], table: str) -> str: def _ident(cfg: DBConfig, name: str) -> str:
"""Oracle 标识符大写归一:未加引号建的对象一律大写存储,
任务里手输的小写表/字段名直接引用会 ORA-00904。"""
return name.upper() if cfg.db_type == "oracle" else name
def _qualified_source_table(cfg: DBConfig, table: str, schema: str) -> str:
"""校验扫描的表引用:Oracle/达梦拼 schema 前缀。
连接用户 ≠ 表 OWNER 时裸表名 ORA-00942(除非建了同义词);
MySQL 由连接库选库,不需要前缀。
"""
quoted = quote_ident(_ident(cfg, table), cfg.db_type)
if schema and cfg.db_type in ("oracle", "dameng"):
return f"{quote_ident(_ident(cfg, schema), cfg.db_type)}.{quoted}"
return quoted
def _build_select_sql(cfg: DBConfig, col_names: list[str], table: str, schema: str = "") -> str:
"""拼 base SQL(不含 LIMIT/OFFSET),按首列 ORDER BY 保证 OFFSET 确定性""" """拼 base SQL(不含 LIMIT/OFFSET),按首列 ORDER BY 保证 OFFSET 确定性"""
col_sql = ", ".join(quote_ident(c, cfg.db_type) for c in col_names) col_sql = ", ".join(quote_ident(_ident(cfg, c), cfg.db_type) for c in col_names)
first_col = quote_ident(col_names[0], cfg.db_type) first_col = quote_ident(_ident(cfg, col_names[0]), cfg.db_type)
return f"SELECT {col_sql} FROM {quote_ident(table, cfg.db_type)} ORDER BY {first_col}" return (f"SELECT {col_sql} FROM {_qualified_source_table(cfg, table, schema)} "
f"ORDER BY {first_col}")
def _evaluate_row(compiled_fields: list[CompiledFieldRules], raw: dict[str, Any]) -> tuple[list[str], list[dict]]: def _evaluate_row(compiled_fields: list[CompiledFieldRules], raw: dict[str, Any]) -> tuple[list[str], list[dict]]:
...@@ -411,15 +430,20 @@ def start_query(req: StartQueryRequest, db: Session = Depends(get_session)): ...@@ -411,15 +430,20 @@ def start_query(req: StartQueryRequest, db: Session = Depends(get_session)):
# 1) COUNT + 2) 字段注释 —— 2026-09-24 合并到同一条连接(原来开两条, # 1) COUNT + 2) 字段注释 —— 2026-09-24 合并到同一条连接(原来开两条,
# 每条 ~50ms 建连 + info_schema 往返)。COUNT 失败整体失败(保持旧行为), # 每条 ~50ms 建连 + info_schema 往返)。COUNT 失败整体失败(保持旧行为),
# 注释拉取失败只降级 warn(保持旧行为)。 # 注释拉取失败只降级 warn(保持旧行为)。
# schema:Oracle/达梦的扫描 SQL 要拼 schema 前缀(见 _qualified_source_table);
# Oracle 的 conn_json["db"] 是 service_name,不能当 schema 回退(会查错 OWNER)
conn = _parse_conn_json(task.conn_json)
schema = ((conn or {}).get("schema") or "").strip()
if not schema and cfg.db_type != "oracle":
schema = ((conn or {}).get("db") or "").strip()
total_rows = 0 total_rows = 0
try: try:
with DBConnection(cfg) as dbc: with DBConnection(cfg) as dbc:
count_sql = f"SELECT COUNT(*) AS n FROM {quote_ident(task.source_table, cfg.db_type)}" count_sql = (f"SELECT COUNT(*) AS n "
f"FROM {_qualified_source_table(cfg, task.source_table, schema)}")
count_row = dbc.fetchone(count_sql) count_row = dbc.fetchone(count_sql)
total_rows = int((count_row or {}).get("n") or 0) total_rows = int((count_row or {}).get("n") or 0)
conn = _parse_conn_json(task.conn_json)
schema = ((conn or {}).get("schema") or (conn or {}).get("db") or "").strip()
if schema: if schema:
try: try:
col_meta = dbc.list_columns(schema, task.source_table) or [] col_meta = dbc.list_columns(schema, task.source_table) or []
...@@ -441,7 +465,7 @@ def start_query(req: StartQueryRequest, db: Session = Depends(get_session)): ...@@ -441,7 +465,7 @@ def start_query(req: StartQueryRequest, db: Session = Depends(get_session)):
# 3) 拼 base SQL(保留 ORDER BY 首列:流式扫一遍 + 全表只排序一次, # 3) 拼 base SQL(保留 ORDER BY 首列:流式扫一遍 + 全表只排序一次,
# 结果顺序与旧分页版一致;不再拼 LIMIT/OFFSET) # 结果顺序与旧分页版一致;不再拼 LIMIT/OFFSET)
base_sql = _build_select_sql(cfg, col_names, task.source_table) base_sql = _build_select_sql(cfg, col_names, task.source_table, schema)
# 4) 建 session + 起扫描线程(先 put 后 start,保证 /pull 一定能找到) # 4) 建 session + 起扫描线程(先 put 后 start,保证 /pull 一定能找到)
sess = QuerySession( sess = QuerySession(
......
This diff is collapsed.
"""表格偏好路由 —— 列表页「表设置」功能的读写接口
端点:
GET /api/table-prefs/{page_key} 读取页面偏好(列级 + 页面级行高/线框)
PUT /api/table-prefs/{page_key} 保存偏好(列级按 col_key 增量合并;页面级整体覆盖)
DELETE /api/table-prefs/{page_key} 清空页面偏好(表设置里的「重置」)
设计:
- 偏好按「页面标识 + 列标识(表头文字)」唯一,存 MySQL(所有业务数据统一存 MySQL)
- PUT 为增量合并:前端拖动单列时只发该列,不覆盖其他列的已有偏好
- 页面级行高 / 线框粗细保存在 col_key='__page__' 的特殊行
- 无用户体系(内网工具),不区分用户
"""
from __future__ import annotations
from typing import List, Optional
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from web.backend.db.database import get_session
from web.backend.models.table_pref import PAGE_LEVEL_COL_KEY, TablePref
router = APIRouter(prefix="/table-prefs", tags=["table-prefs"])
# ── Pydantic schema ──
class ColPref(BaseModel):
col_key: str = Field(..., min_length=1, max_length=100, description="列标识(表头文字)")
width: Optional[int] = Field(None, ge=40, le=1200, description="列宽 px;NULL=恢复默认")
hidden: bool = Field(False, description="是否隐藏该列")
align: Optional[str] = Field(None, description="列对齐:left/center/right;NULL=默认")
ord: Optional[int] = Field(None, ge=0, le=99, description="列顺序(0 起);NULL=原始位置")
class ColPrefOut(ColPref):
pass
class PagePref(BaseModel):
row_height: Optional[int] = Field(None, description="行高 px:36/40/48;NULL=默认")
border_width: Optional[int] = Field(None, description="线框档位:0 细/1 中/2 粗;NULL=默认")
class TablePrefsOut(BaseModel):
page_key: str
prefs: List[ColPrefOut]
row_height: Optional[int] = None
border_width: Optional[int] = None
class TablePrefsPut(BaseModel):
prefs: List[ColPref] = Field(default_factory=list, description="要保存的列偏好(按 col_key 合并)")
page: Optional[PagePref] = Field(None, description="页面级偏好(行高/线框);传了才更新")
# ── 内部工具 ──
def _load_rows(db: Session, page_key: str) -> list[TablePref]:
return db.query(TablePref).filter(TablePref.page_key == page_key).all()
def _to_out(page_key: str, rows: list[TablePref]) -> TablePrefsOut:
page_row = next((r for r in rows if r.col_key == PAGE_LEVEL_COL_KEY), None)
return TablePrefsOut(
page_key=page_key,
prefs=[
ColPrefOut(col_key=r.col_key, width=r.width, hidden=bool(r.hidden), align=r.align, ord=r.ord)
for r in rows if r.col_key != PAGE_LEVEL_COL_KEY
],
row_height=page_row.row_height if page_row else None,
border_width=page_row.border_width if page_row else None,
)
# ── 路由 ──
@router.get("/{page_key}", response_model=TablePrefsOut, summary="读取页面的表格偏好")
def get_table_prefs(page_key: str, db: Session = Depends(get_session)) -> TablePrefsOut:
return _to_out(page_key, _load_rows(db, page_key))
@router.put("/{page_key}", response_model=TablePrefsOut, summary="保存表格偏好(增量合并)")
def put_table_prefs(page_key: str, body: TablePrefsPut, db: Session = Depends(get_session)) -> TablePrefsOut:
# 列级偏好:按 col_key 合并;None 字段保留旧值(增量更新不互相覆盖),
# align 传空串表示「恢复默认对齐」(存 NULL)
for pref in body.prefs:
row = (
db.query(TablePref)
.filter(TablePref.page_key == page_key, TablePref.col_key == pref.col_key)
.first()
)
if row is None:
row = TablePref(page_key=page_key, col_key=pref.col_key)
db.add(row)
if pref.width is not None:
row.width = pref.width
row.hidden = 1 if pref.hidden else 0
if pref.align is not None:
row.align = pref.align or None
if pref.ord is not None:
row.ord = pref.ord
# 页面级偏好(行高/线框):单独行;None 字段保留旧值(前端只提交本次改的项)
if body.page is not None:
row = (
db.query(TablePref)
.filter(TablePref.page_key == page_key, TablePref.col_key == PAGE_LEVEL_COL_KEY)
.first()
)
if row is None:
row = TablePref(page_key=page_key, col_key=PAGE_LEVEL_COL_KEY)
db.add(row)
if body.page.row_height is not None:
row.row_height = body.page.row_height
if body.page.border_width is not None:
row.border_width = body.page.border_width
db.commit()
return _to_out(page_key, _load_rows(db, page_key))
@router.delete("/{page_key}", summary="清空页面的表格偏好(重置为默认布局)")
def reset_table_prefs(page_key: str, db: Session = Depends(get_session)) -> dict:
deleted = (
db.query(TablePref)
.filter(TablePref.page_key == page_key)
.delete()
)
db.commit()
return {"ok": True, "deleted": deleted}
...@@ -197,9 +197,10 @@ def _fetch_field_comments(t: Task, conn: dict) -> dict[str, str]: ...@@ -197,9 +197,10 @@ def _fetch_field_comments(t: Task, conn: dict) -> dict[str, str]:
if not source_table: if not source_table:
return {} return {}
table_name = source_table.split("/")[-1] if "/" in source_table else source_table table_name = source_table.split("/")[-1] if "/" in source_table else source_table
schema = conn.get("schema") or conn.get("database") or ""
db_type_label = t.db_type or "MySQL" db_type_label = t.db_type or "MySQL"
db_type_internal = _TASK_DB_TYPE_LABEL_TO_INTERNAL.get(db_type_label, db_type_label.lower()) db_type_internal = _TASK_DB_TYPE_LABEL_TO_INTERNAL.get(db_type_label, db_type_label.lower())
schema = conn.get("schema") or (str(conn.get("user") or "").upper()
if db_type_internal == "oracle" else conn.get("database")) or ""
host = conn.get("host") host = conn.get("host")
port = conn.get("port") port = conn.get("port")
......
import unittest
from unittest.mock import Mock
from web.backend.core.oracle_ddl import classify_statement, split_statements
from web.backend.core.db_adapter import DBConfig
from web.backend.routers.operations import _oracle_restore_pipeline
class OracleDdlTests(unittest.TestCase):
def test_split_keeps_trigger_body_and_semicolon_literals(self):
ddl = """
CREATE TABLE \"APP\".\"T\" (\"ID\" NUMBER);
COMMENT ON TABLE \"APP\".\"T\" IS 'a; b';
CREATE OR REPLACE EDITIONABLE TRIGGER \"APP\".\"TRG\"
BEFORE INSERT ON \"APP\".\"T\" FOR EACH ROW
BEGIN
:NEW.ID := 1;
NULL;
END;
/
"""
statements = split_statements(ddl)
self.assertEqual(len(statements), 3)
self.assertEqual(classify_statement(statements[0]), "table")
self.assertEqual(classify_statement(statements[1]), "comment")
self.assertEqual(classify_statement(statements[2]), "trigger")
self.assertIn(":NEW.ID := 1;", statements[2])
def test_classify_oracle_objects(self):
self.assertEqual(classify_statement('CREATE SEQUENCE "APP"."S"'), "sequence")
self.assertEqual(classify_statement('CREATE UNIQUE INDEX "APP"."I" ON "APP"."T" ("ID")'), "index")
self.assertEqual(classify_statement('ALTER TABLE "APP"."T" ADD CONSTRAINT "PK_T" PRIMARY KEY ("ID")'), "constraint")
def test_replace_refuses_incomplete_ddl_without_drop(self):
conn = Mock()
conn.list_tables.return_value = [{"table_name": "T"}]
cfg = DBConfig("oracle", "localhost", 1521, "APP", "", "service")
with self.assertRaisesRegex(ValueError, "缺少完整 Oracle DDL"):
_oracle_restore_pipeline(conn, cfg, "APP", [{"table_name": "T", "rows": []}],
"replace", "APP")
conn.execute.assert_not_called()
def test_replace_refuses_external_fk_without_drop(self):
conn = Mock()
conn.list_tables.return_value = [{"table_name": "T"}]
conn.fetchall.return_value = [{"owner": "APP", "table_name": "OTHER",
"constraint_name": "FK_OTHER"}]
cfg = DBConfig("oracle", "localhost", 1521, "APP", "", "service")
item = {"table_name": "T", "columns": [{"column_name": "ID"}],
"create_ddl": 'CREATE TABLE "APP"."T" ("ID" NUMBER);'}
with self.assertRaisesRegex(ValueError, "范围外的外键"):
_oracle_restore_pipeline(conn, cfg, "APP", [item], "replace", "APP")
conn.execute.assert_not_called()
...@@ -2,6 +2,11 @@ import { http } from './client' ...@@ -2,6 +2,11 @@ import { http } from './client'
export const getTableDesignObjects = (sourceId, schema) => http.get(`/table-design/${sourceId}/objects${schema ? `?schema=${encodeURIComponent(schema)}` : ''}`) 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 checkTableDesign = (payload) => http.post('/table-design/check', payload)
// 会话式扫描(2026-09-28,对齐数据扫描的 start/pull/cancel):start 立即返回,
// 结果页轮询 pull 展示进度,cancel 中断
export const startTableDesignCheck = (payload) => http.post('/table-design/check/start', payload)
export const pullTableDesignCheck = (sessionId, after = 0) => http.post('/table-design/check/pull', { session_id: sessionId, after })
export const cancelTableDesignCheck = (sessionId) => http.post('/table-design/check/cancel', { session_id: sessionId })
export const listTableDesignConfigs = () => http.get('/table-design/configs') export const listTableDesignConfigs = () => http.get('/table-design/configs')
export const getTableDesignConfig = (id) => http.get(`/table-design/configs/${id}`) export const getTableDesignConfig = (id) => http.get(`/table-design/configs/${id}`)
export const createTableDesignConfig = (payload) => http.post('/table-design/configs', payload) export const createTableDesignConfig = (payload) => http.post('/table-design/configs', payload)
......
<script setup>
/**
* 通用表单弹框(对齐 housekeeping-pc FormDialog)
* - 默认宽 1080px,小表单传 width="680px"
* - destroy-on-close + draggable + align-center,点遮罩不关闭
* - body 自滚动,整框 max-height 88vh / max-width 94vw
* - footer 按钮统一放 #footer 插槽内:`<div class="dialog-footer">取消 + 保存</div>`
* 组件型用法(保持对外 v-model:open 协议):
* <CommonFormDialog :model-value="open" title="…" width="680px"
* @update:model-value="$emit('update:open', $event)">…</CommonFormDialog>
*/
defineProps({
modelValue: { type: Boolean, default: false },
title: { type: String, default: '' },
width: { type: String, default: '1080px' },
})
defineEmits(['update:modelValue'])
</script>
<template>
<el-dialog
:model-value="modelValue"
:title="title"
:width="width"
destroy-on-close
draggable
overflow
align-center
class="common-form-dialog"
:close-on-click-modal="false"
@update:model-value="$emit('update:modelValue', $event)"
>
<template v-if="$slots.header" #header>
<slot name="header" />
</template>
<slot />
<template v-if="$slots.footer" #footer>
<slot name="footer" />
</template>
</el-dialog>
</template>
<!-- 非 scoped:el-dialog 内容 Teleport 到 body,全局类最稳(housekeeping 同款做法) -->
<style>
.common-form-dialog {
display: flex;
flex-direction: column;
max-height: 88vh;
max-width: 94vw;
margin: 0 auto;
}
.common-form-dialog .el-dialog__header,
.common-form-dialog .el-dialog__footer {
flex-shrink: 0;
}
.common-form-dialog .el-dialog__title {
font-family: var(--font-family-base);
font-size: var(--font-size-xl);
font-weight: 400;
}
.common-form-dialog .el-dialog__body {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
}
</style>
This diff is collapsed.
<template>
<Teleport to="body">
<div class="drawer-mask" :class="{ show: open }" @click="$emit('update:open', false)"></div>
<div class="drawer" :class="{ show: open }">
<div class="drawer-header">
<span class="drawer-title">展示字段配置</span>
<button class="drawer-close" @click="$emit('update:open', false)">×</button>
</div>
<div class="drawer-body">
<div class="drawer-hint">勾选要在结果表中展示的字段;右侧 ↑↓ 可调整展示顺序。</div>
<div class="field-check-list">
<!-- 2026-08-24:每项加 ↑↓ 按钮(icon 左边) —— 调整字段在结果表里的列展示顺序 -->
<label
v-for="(f, idx) in localItems"
:key="f.key"
class="field-check-item"
>
<input type="checkbox" class="checkbox" v-model="f.checked" />
<span class="cn">{{ f.cn }}</span>
<span class="en">{{ f.en }}</span>
<!-- 2026-08-21:字段规则数 —— 跟结果表表头的「⚠ N规则」徽章一致,
让用户一眼看出「这个字段配了几条规则」,也提示点开后能改规则 -->
<el-tooltip
v-if="f.rules > 0"
:content="ruleHint(f)"
placement="top"
effect="dark"
>
<span class="rule-mark">⚠ {{ f.rules }}规则</span>
</el-tooltip>
<!-- 2026-08-24:排序 ↑↓ + 置顶 —— 在 ⓘ icon 左边;首行禁用上移+置顶,末行禁用下移 -->
<span class="ord-group">
<button
class="ord-btn"
:disabled="idx === 0"
title="置顶"
@click.prevent="moveItemToTop(idx)"
>⤒</button>
<button
class="ord-btn"
:disabled="idx === 0"
title="上移"
@click.prevent="moveItem(idx, -1)"
>↑</button>
<button
class="ord-btn"
:disabled="idx === localItems.length - 1"
title="下移"
@click.prevent="moveItem(idx, 1)"
>↓</button>
</span>
<!-- 字段注释:有 comment 时在右侧放个 ⓘ 图标,hover 出 tooltip -->
<el-tooltip
v-if="f.comment"
:content="f.comment"
placement="top"
effect="dark"
>
<el-icon class="field-comment-icon"><InfoFilled /></el-icon>
</el-tooltip>
</label>
</div>
</div>
<div class="drawer-footer">
<button class="btn" @click="$emit('update:open', false)">取消</button>
<button class="btn btn-primary" @click="onSave">保存</button>
</div>
</div>
</Teleport>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
const props = defineProps({
open: { type: Boolean, default: false },
fields: { type: Array, default: () => [] },
visibleFields: { type: Array, default: () => [] },
})
const emit = defineEmits(['update:open', 'save'])
// 业务字段(排除内部 __reason)
const businessFields = computed(() => props.fields.filter((f) => f.key !== '__reason'))
// 2026-08-24:把原来的 localChecked dict 改成 localItems 数组 —— 同时管顺序 + 勾选
// 每个 item = { key, cn, en, comment, rules, checked },数组顺序就是用户在结果表里的展示顺序
const localItems = ref([])
watch(
() => [businessFields.value, props.visibleFields],
() => {
// 初始化顺序:先按 props.visibleFields 的顺序,再把未勾选字段按 props.fields 顺序追加
const visibleSet = new Set(props.visibleFields)
const byKey = new Map(businessFields.value.map((f) => [f.key, f]))
const out = []
// 1) 已勾选字段按 visibleFields 顺序排
for (const k of props.visibleFields) {
const f = byKey.get(k)
if (f) out.push({ ...f, checked: true })
}
// 2) 未勾选字段按原始顺序追加
for (const f of businessFields.value) {
if (!visibleSet.has(f.key)) {
out.push({ ...f, checked: false })
}
}
localItems.value = out
},
{ immediate: true, deep: true }
)
// 调整顺序(dir=-1 上移,dir=+1 下移)
function moveItem(idx, dir) {
const target = idx + dir
if (target < 0 || target >= localItems.value.length) return
const arr = localItems.value
const tmp = arr[idx]
arr[idx] = arr[target]
arr[target] = tmp
}
// 2026-08-24:置顶 —— splice 出来再 unshift 到最前(不是交换,是「把这个字段搬到顶」)
function moveItemToTop(idx) {
if (idx <= 0 || idx >= localItems.value.length) return
const arr = localItems.value
const [item] = arr.splice(idx, 1)
arr.unshift(item)
}
function onSave() {
// 按用户调整后的顺序 + 仅勾选字段,emit 给父组件
const list = localItems.value
.filter((f) => f.checked)
.map((f) => f.key)
emit('save', list)
}
// 字段规则数 hover 提示文案 —— 跟 ResultTable.ruleHint 同款:
// 2026-08-24 起优先展示「规则名(desc)」,老规则空名时回退到 desc
function ruleHint(f) {
if (Array.isArray(f.ruleList) && f.ruleList.length > 0) {
return f.ruleList
.map((r) => (r.name && r.name.trim() ? `${r.name}(${r.desc})` : r.desc))
.join(';')
}
return `该字段配置了 ${f.rules || 0} 条校验规则`
}
</script>
<style scoped>
/* 2026-08-21:字段规则数徽章 —— 跟 ResultTable 表头 rule-mark 风格统一
(黄橙色 + help 光标 + hover 出 tooltip)。放在 scoped 而非 global,避免污染其他组件。*/
.field-check-item .rule-mark {
color: #e6a23c;
margin-left: 4px;
font-size: 12px;
cursor: help;
}
/* 2026-08-24:↑↓ 排序按钮组 —— 复用 TaskModal 的 .ord-btn 视觉但 scoped 化
(避免跨组件污染;样式一致但独立维护)*/
.ord-group {
display: inline-flex;
flex-direction: row; /* 2026-08-24:左右排版 ↑↓ —— 上下太丑 */
gap: 4px;
margin-left: auto; /* 推到右侧,跟 ⓘ icon 相邻 */
}
.ord-btn {
width: 22px;
height: 18px;
padding: 0;
border: 1px solid #dcdfe6;
background: #fff;
color: #606266;
border-radius: 3px;
cursor: pointer;
font-size: 11px;
line-height: 16px;
font-family: inherit;
transition: all 0.15s;
}
.ord-btn:hover:not(:disabled) {
border-color: #409eff;
color: #409eff;
background: #ecf5ff;
}
.ord-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
</style>
\ No newline at end of file
This diff is collapsed.
...@@ -134,8 +134,6 @@ ...@@ -134,8 +134,6 @@
:page-sizes="pageSizes" :page-sizes="pageSizes"
:total="rows.length" :total="rows.length"
layout="total, sizes, prev, pager, next" layout="total, sizes, prev, pager, next"
background
small
/> />
</div> </div>
</div> </div>
...@@ -149,7 +147,7 @@ const props = defineProps({ ...@@ -149,7 +147,7 @@ const props = defineProps({
rows: { type: Array, required: true }, // 每行:{ errorCells?, issues?, ...业务字段 } rows: { type: Array, required: true }, // 每行:{ errorCells?, issues?, ...业务字段 }
// 2026-08-21:参考 web2 IdCardTable.vue 的 el-pagination 配置 // 2026-08-21:参考 web2 IdCardTable.vue 的 el-pagination 配置
// 与配置列表保持一致,便于在不同列表页面按相同粒度浏览 // 与配置列表保持一致,便于在不同列表页面按相同粒度浏览
pageSizes: { type: Array, default: () => [10, 15, 20, 30] }, pageSizes: { type: Array, default: () => [10, 15, 20, 25, 30] },
defaultPageSize: { type: Number, default: 10 }, defaultPageSize: { type: Number, default: 10 },
// 行 key:默认用 index;父组件可传 (row, i) => string 走更稳定的 key(如 row.id) // 行 key:默认用 index;父组件可传 (row, i) => string 走更稳定的 key(如 row.id)
rowKey: { type: Function, default: (_row, i) => i }, rowKey: { type: Function, default: (_row, i) => i },
...@@ -374,12 +372,19 @@ function ruleHint(col) { ...@@ -374,12 +372,19 @@ function ruleHint(col) {
.result-table { .result-table {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 0;
}
/* table-card 内:表格区撑满(分页贴卡片底),数据多时收缩内滚,
表头 sticky 常驻(thead th 的 sticky 定位在 global.css) */
.result-table .table-scroll {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
} }
.empty-row { .empty-row {
text-align: center; text-align: center;
color: #909399;
padding: 24px 0 !important; padding: 24px 0 !important;
background: #fafafa; /* 配色来自 global.css 的 .empty-row */
} }
.cell-error-content { .cell-error-content {
display: inline-flex; display: inline-flex;
...@@ -394,14 +399,16 @@ function ruleHint(col) { ...@@ -394,14 +399,16 @@ function ruleHint(col) {
} }
.err-flag-clickable:hover { .err-flag-clickable:hover {
transform: scale(1.15); transform: scale(1.15);
box-shadow: 0 0 0 3px rgba(245, 108, 108, 0.18); box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-danger) 18%, transparent);
} }
/* 表格底部留白(防止最后一行贴到分页器 / 卡片底) */ /* 表格底部留白(防止最后一行贴到分页器 / 卡片底) */
.table-scroll { .table-scroll {
padding-bottom: 8px; padding-bottom: 8px;
} }
/* 「备注」列固定在右侧 —— 数据列多了时只滚左侧,备注常驻 */ /* 「备注」列固定在右侧 —— 数据列多了时只滚左侧,备注常驻。
sticky 列背景必须不透明(基于 --app-bg-secondary),
不能沿用 global 的 color-mix 半透明底,否则滚动时下层内容透出。 */
.dq-table th.col-reason, .dq-table th.col-reason,
.dq-table td.cell-reason { .dq-table td.cell-reason {
position: sticky; position: sticky;
...@@ -411,15 +418,19 @@ function ruleHint(col) { ...@@ -411,15 +418,19 @@ function ruleHint(col) {
box-shadow: -4px 0 8px -4px rgba(0, 0, 0, 0.12); box-shadow: -4px 0 8px -4px rgba(0, 0, 0, 0.12);
} }
.dq-table th.col-reason { .dq-table th.col-reason {
/* header 高于普通列,避免被覆盖 */ /* 角落格:top+right 双向 sticky,层级最高 */
z-index: 2; z-index: 3;
background: #fde2e2; background: color-mix(in srgb, var(--color-danger) 22%, var(--app-bg-secondary));
} }
.dq-table td.cell-reason { .dq-table td.cell-reason {
background: #fef7f7; background: color-mix(in srgb, var(--color-danger) 10%, var(--app-bg-secondary));
} }
/* hover 时整行变色,备注列也得跟着变,否则会和 tr 的 #f5f7fa 背景脱节 */ /* hover 时整行变色,备注列也得跟着变(不透明底 + danger 混合),否则会和 tr 背景脱节 */
.dq-table tbody tr:hover td.cell-reason { .dq-table tbody tr:hover td.cell-reason {
background: #f5f7fa; background: color-mix(in srgb, var(--color-danger) 14%, var(--app-bg-secondary));
}
/* 斑马纹联动:偶数行的 sticky 备注列用不透明斑马底混色(--table-zebra-bg),滚动时不透底 */
.dq-table tbody tr:nth-child(even) td.cell-reason {
background: color-mix(in srgb, var(--color-danger) 10%, var(--table-zebra-bg));
} }
</style> </style>
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
/**
* UI 偏好(主题 + 字号档位)
* - 主题:deep-sea(深海,默认) / light(明亮),写到 <html data-ui-theme>
* - 字号:small / medium / large,写到 <html data-font-scale>
* - 持久化到 localStorage(db-tools-ui-theme / db-tools-ui-font-scale)
* - main.js 在 app.mount 前调用 initUiPrefs(),避免首帧闪默认主题
*/
import { computed, ref } from 'vue'
const THEME_KEY = 'db-tools-ui-theme'
const SCALE_KEY = 'db-tools-ui-font-scale'
const THEMES = ['dark', 'light']
const SCALES = ['small', 'medium', 'large']
const DEFAULT_THEME = 'dark'
const DEFAULT_SCALE = 'medium'
export const themeLabels = { dark: '深海', light: '明亮' }
export const fontScaleOptions = [
{ key: 'small', label: '小' },
{ key: 'medium', label: '中' },
{ key: 'large', label: '大' },
]
/** 读 localStorage 并做白名单校验,非法/缺失回落默认值 */
function readPref(key, allowed, fallback) {
try {
const val = localStorage.getItem(key)
return allowed.includes(val) ? val : fallback
} catch {
return fallback
}
}
/* 模块级单例:所有组件共享同一份状态 */
const theme = ref(readPref(THEME_KEY, THEMES, DEFAULT_THEME))
const fontScale = ref(readPref(SCALE_KEY, SCALES, DEFAULT_SCALE))
/** 状态同步到 <html> 属性 + color-scheme,并持久化 */
function apply() {
const el = document.documentElement
el.setAttribute('data-ui-theme', theme.value)
el.setAttribute('data-font-scale', fontScale.value)
el.style.colorScheme = theme.value
try {
localStorage.setItem(THEME_KEY, theme.value)
localStorage.setItem(SCALE_KEY, fontScale.value)
} catch {
/* 隐私模式等场景下静默失败,仅当次会话生效 */
}
}
/** 启动时调用(app.mount 前),保证首帧就是用户上次的偏好 */
export function initUiPrefs() {
apply()
}
export function useUiPrefs() {
const currentScaleLabel = computed(
() => fontScaleOptions.find(o => o.key === fontScale.value)?.label ?? '中'
)
const themeTip = computed(() =>
theme.value === 'dark' ? '切换到明亮' : '切换到深海'
)
const themeIcon = computed(() => (theme.value === 'dark' ? '☀' : '🌙'))
function setTheme(value) {
if (!THEMES.includes(value)) return
theme.value = value
apply()
}
function toggleTheme() {
setTheme(theme.value === 'dark' ? 'light' : 'dark')
}
function setFontScale(value) {
if (!SCALES.includes(value)) return
fontScale.value = value
apply()
}
return {
theme,
fontScale,
currentScaleLabel,
themeTip,
themeIcon,
setTheme,
toggleTheme,
setFontScale,
}
}
This diff is collapsed.
...@@ -3,10 +3,25 @@ import ElementPlus from 'element-plus' ...@@ -3,10 +3,25 @@ import ElementPlus from 'element-plus'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs' import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import * as ElementPlusIconsVue from '@element-plus/icons-vue' import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import 'element-plus/dist/index.css' import 'element-plus/dist/index.css'
import './styles/tokens.css'
import './styles/element-plus.css'
import './styles/page-shell.css'
import './styles/global.css' import './styles/global.css'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
import { initUiPrefs } from '@/composables/useUiPrefs'
import { setupEllipsisTitle } from '@/utils/ellipsisTitle'
import { setupTablePrefs } from '@/utils/tablePrefs'
// 应用用户上次的主题 / 字号偏好(须在 mount 前,避免首帧闪默认主题)
initUiPrefs()
// 表格超长单元格:移入显示完整内容(原生 title)
setupEllipsisTitle()
// 列表页表设置:列宽拖动 + 列显隐,偏好记忆到后端 MySQL
setupTablePrefs()
const app = createApp(App) const app = createApp(App)
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
/**
* 表格单元格「超长省略 + 移入显示完整内容」的通用提示注入。
*
* CSS 侧(global.css / 各表格)已对超长单元格做 nowrap + ellipsis;
* 这里用事件委托在鼠标移入时检测溢出并写入原生 title,
* 浏览器原生 tooltip 随即显示完整内容 —— 各页面模板零改动。
*
* 覆盖范围:config-list(配置列表)、dq-table(数据扫描结果)、
* design-result-table(表设计结果)。每次移入都重算,翻页/刷新后不过期。
*
* 溢出检测用 Range 测量内容自然宽度,而非 scrollWidth ——
* table-cell 的 scrollWidth 在 Chrome 中经常等于 clientWidth(单元格溢出
* 反映在表格布局层,不进入 cell 的 scroll extent),靠它检测会漏。
*/
const TABLE_SELECTOR = 'table.config-list, table.dq-table, table.design-result-table'
function isOverflowed(td) {
const range = document.createRange()
range.selectNodeContents(td)
const contentWidth = range.getBoundingClientRect().width
const cs = getComputedStyle(td)
const available = td.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight)
return contentWidth > available + 1
}
export function setupEllipsisTitle() {
document.addEventListener('mouseover', (e) => {
const td = e.target instanceof Element ? e.target.closest('td') : null
if (!td) return
// 只处理目标表格内的数据单元格(表头 th、含操作按钮的单元格由 CSS 排除省略,这里同样跳过)
if (!td.closest(TABLE_SELECTOR)) return
if (td.classList.contains('empty-cell') || td.classList.contains('cell-reason')) return
// 模板已绑定的 :title(数据层截断列的完整内容)优先,注入不覆盖
if (td.title && !td.dataset.ellipsisTitle) return
// 溢出才提示(带 dataset 标记区分注入与模板绑定,翻页后可重算更新)
if (isOverflowed(td)) {
const text = td.innerText.trim()
if (text) {
td.title = text
td.dataset.ellipsisTitle = '1'
return
}
}
if (td.dataset.ellipsisTitle) {
td.removeAttribute('title')
delete td.dataset.ellipsisTitle
}
}, { passive: true })
}
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
...@@ -19,5 +19,5 @@ onMounted(async () => { try { backupDir.value = (await getBackupConfig()).backup ...@@ -19,5 +19,5 @@ onMounted(async () => { try { backupDir.value = (await getBackupConfig()).backup
async function saveDir() { saving.value = true; try { backupDir.value = (await saveBackupConfig(backupDir.value)).backup_dir; ElMessage.success('系统配置已保存') } catch (e) { ElMessage.error(`保存失败:${e.message}`) } finally { saving.value = false } } async function saveDir() { saving.value = true; try { backupDir.value = (await saveBackupConfig(backupDir.value)).backup_dir; ElMessage.success('系统配置已保存') } catch (e) { ElMessage.error(`保存失败:${e.message}`) } finally { saving.value = false } }
</script> </script>
<style scoped> <style scoped>
.config-panel{max-width:1000px}.config-form{padding:24px}.config-row{display:flex;align-items:flex-start;gap:18px;padding:18px 0;border-bottom:1px solid var(--app-border)}.config-row:last-child{border-bottom:0}.config-row label{width:150px;flex:0 0 150px;color:var(--app-text);line-height:32px}.config-field{flex:1;min-width:0}.config-field .text-input{width:min(620px,100%);box-sizing:border-box}.hint{margin-top:7px;color:var(--app-text-muted);font-size:12px;line-height:1.5}.readonly{color:var(--app-text-muted)} .config-panel{max-width:1000px}.config-form{padding:24px}.config-row{display:flex;align-items:flex-start;gap:18px;padding:18px 0;border-bottom:1px solid var(--color-border-base)}.config-row:last-child{border-bottom:0}.config-row label{width:150px;flex:0 0 150px;color:var(--color-text-regular);line-height:32px}.config-field{flex:1;min-width:0}.config-field .text-input{width:min(620px,100%);box-sizing:border-box}.hint{margin-top:7px;color:var(--app-text-muted);font-size:12px;line-height:1.5}.readonly{color:var(--app-text-muted)}
</style> </style>
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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