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:
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.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(tasks_router, prefix="/api")
app.include_router(db_router, prefix="/api")
......@@ -77,6 +78,7 @@ app.include_router(operations_router, prefix="/api")
app.include_router(rules_router, prefix="/api")
app.include_router(table_design_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/*")
......
......@@ -226,6 +226,11 @@ def _render_info_columns_sql(dialect: str) -> str:
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:
from web.backend.core.sql_loader import get_sql_loader
return get_sql_loader().render("info_schema/list_tables", dialect=dialect)
......@@ -526,6 +531,11 @@ class DBConnection:
"未安装 oracledb,请执行: pip install oracledb\n"
"(oracledb thin 模式无需 Oracle Instant Client)"
) 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"
raise ValueError(f"不支持的数据库类型: {db_type!r}(目前支持 mysql / dameng / oracle)")
......@@ -732,6 +742,22 @@ class DBConnection:
finally:
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:
"""提交写入操作(备份还原等写库流程使用)。"""
if self._conn is None:
......@@ -748,9 +774,27 @@ class DBConnection:
SQL 通过 web/backend/core/sql_templates/info_schema/list_columns.<dialect>.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)
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]:
"""返回指定 schema 下所有表的元数据(schema 走绑定参数)"""
......
......@@ -20,16 +20,27 @@ def record_operation(
filename: str = "",
execute_setting: str = "",
) -> 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(
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 "",
tables_json=tables_json,
filename=(filename or "")[:200],
execute_setting=setting,
)
db.add(row)
db.commit()
......
This diff is collapsed.
......@@ -81,6 +81,19 @@ def _render_placeholders(template: str, params: dict) -> str:
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:
"""SQL 模板加载器(单例)"""
......@@ -130,7 +143,7 @@ class SQLLoader:
"""
text = self._load_text(name, dialect)
try:
return _render_placeholders(text, params)
return _strip_leading_comments(_render_placeholders(text, params))
except KeyError as e:
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 @@
-- 用 ROW_NUMBER() 子查询预聚合,每 (OWNER, TABLE_NAME, COLUMN_NAME) 取一条;
-- ORDER BY COMMENTS DESC NULLS LAST 假设「最长的注释就是用户最后写的」(启发式,
-- 99% 场景够用;如有歧义,用户改 desc 后新注释会按 COMMENTS 长度重新排序被选中)。
--
-- 2026-09-28:注释查询改为关联标量子查询(同 Oracle 方言)。
-- 原因1(性能):子查询不带过滤会对全库 ALL_COL_COMMENTS 做窗口聚合。
-- 原因2(绑定):过滤条件复制进子查询后 ? 出现 4 次而调用方只传 2 个值,
-- 按位占位符数量必须与绑定值一致;关联子查询引用外层 c.* 无需额外绑定。
-- MAX 忽略 NULL,与「DESC NULLS LAST 取第一条」等价(取最大非空注释)。
-- ============================================================================
SELECT
c.TABLE_NAME AS table_name,
......@@ -28,24 +34,13 @@ SELECT
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
(SELECT MAX(cc.COMMENTS)
FROM ALL_COL_COMMENTS cc
)
WHERE rn = 1
) cc
ON cc.OWNER = c.OWNER
WHERE cc.OWNER = c.OWNER
AND cc.TABLE_NAME = c.TABLE_NAME
AND cc.COLUMN_NAME = c.COLUMN_NAME
AND cc.COLUMN_NAME = c.COLUMN_NAME) AS column_comment,
NULL AS extra
FROM ALL_TAB_COLUMNS c
WHERE c.OWNER = ?
AND c.TABLE_NAME = ?
ORDER BY c.COLUMN_ID
-- ============================================================================
-- 列出指定 schema 下指定表的列元数据 (Oracle 方言)
-- 调用方:web/backend/core/db_adapter.py → list_columns()
-- 参数(命名占位符 :1/:2):
-- :1 schema 用户/模式名(调用方传入的可能是小写,这里 UPPER() 兼容)
-- :2 table_name 表名
-- 说明:
-- - Oracle 没有 INFORMATION_SCHEMA,使用 ALL_TAB_COLUMNS(结构与达梦高度一致)
-- - Oracle 的 OWNER 通常为大写;调用方传入的小写 schema 也用 UPPER() 兼容
-- - 占位符用 :1/: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
-- 参数(编号占位符各出现一次,勿重复——thick 模式重复编号会绑定错乱):
-- :1 schema(UPPER 兼容小写输入) :2 table_name
--
-- 2026-08-21:LEFT JOIN 右侧去重(同达梦)
-- Oracle 的 ALL_COL_COMMENTS 里同一 (OWNER, TABLE_NAME, COLUMN_NAME) 偶尔也有 2+ 行
-- (DBA / USER 视角双份、comment refresh 残留等),直接 LEFT JOIN 会翻倍。
-- 用 ROW_NUMBER() 子查询每组取一条,ORDER BY COMMENTS DESC NULLS LAST。
-- 2026-09-28 两条硬约束(公积金库 192.168.20.220 实测,详见 db_adapter.list_columns):
-- 1. 语句必须保持紧凑(<1000 字符):内网安全设备对超长 SQL 按签名掐连接
-- (ORA-03113/DPI-1080,挂 ~19s 断;阈值实测在 1070~1085 之间),
-- 所以本模板不做多行对齐排版,改完必须复测长度。
-- 2. 列注释不放本模板(嵌套子查询会显著加长语句),由
-- list_column_comments.oracle.sql 小查询单独取、db_adapter 合并。
-- - 11g 无 CHAR_LENGTH 之外的兼容问题;CHAR_LENGTH 按字符数(加密长度
-- 检查用),NUMBER 补 (p,s) 精度供还原 fallback 建表。
-- ============================================================================
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','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
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
......@@ -11,10 +11,11 @@ from .rule import Rule
from .rule_library import FieldRule, RuleLibrary
from .task import Task
from .task_group import TaskGroup
from .table_pref import TablePref
from .data_source import DataSource
from .operation_record import OperationRecord
from .table_design_config import TableDesignConfig
from .llm_config import LLMConfigRecord
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
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.oracle_ddl import capture_table_ddl
from web.backend.core.sql_loader import get_sql_loader
from web.backend.core.backup_storage import get_backup_dir
from web.backend.core.operation_history import record_operation
......@@ -80,6 +81,30 @@ def _qualified_table(cfg: DBConfig, schema: str, table: str) -> str:
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:
if not value or not value.strip():
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
if not row:
raise HTTPException(404, "数据源不存在")
cfg = _cfg(row)
if payload.database:
cfg.database = payload.database
schema = payload.schema or row.schema or cfg.database
_apply_database_override(cfg, payload.database)
schema = _resolve_schema(cfg, row, payload)
tables = list(dict.fromkeys(t.strip() for t in payload.tables if t and t.strip()))
if payload.scope == "tables" and not tables:
raise HTTPException(400, "至少选择一张表")
......@@ -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 "")
if 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:
sql = f"SELECT * FROM {_qualified_table(cfg, schema, actual_table)}"
item["rows"] = list(conn.iter_rows(sql))
......
......@@ -196,14 +196,14 @@ def list_tables(req: TestConnectionRequest):
"""复用 db_adapter.list_tables(),走 info_schema/list_tables.<dialect>.sql 模板"""
cfg = _build_db_config(req)
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}")
try:
with DBConnection(cfg) as db:
# Oracle:database 是 service_name(连实例),schema 才是 OWNER(查 ALL_TABLES 用)
# 其他方言: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)
logger.info(f"✅ 返回 {len(rows)} 张表")
logger.info("─" * 60)
......@@ -231,7 +231,7 @@ def list_columns(req: ListColumnsRequest):
"""
cfg = _build_db_config(req)
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/columns {_mask_password(cfg)} "
f"schema={schema!r} table={req.table_name!r}"
......
This diff is collapsed.
......@@ -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 确定性"""
col_sql = ", ".join(quote_ident(c, cfg.db_type) for c in col_names)
first_col = quote_ident(col_names[0], cfg.db_type)
return f"SELECT {col_sql} FROM {quote_ident(table, cfg.db_type)} ORDER BY {first_col}"
col_sql = ", ".join(quote_ident(_ident(cfg, c), cfg.db_type) for c in col_names)
first_col = quote_ident(_ident(cfg, col_names[0]), cfg.db_type)
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]]:
......@@ -411,15 +430,20 @@ def start_query(req: StartQueryRequest, db: Session = Depends(get_session)):
# 1) COUNT + 2) 字段注释 —— 2026-09-24 合并到同一条连接(原来开两条,
# 每条 ~50ms 建连 + info_schema 往返)。COUNT 失败整体失败(保持旧行为),
# 注释拉取失败只降级 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
try:
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)
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:
try:
col_meta = dbc.list_columns(schema, task.source_table) or []
......@@ -441,7 +465,7 @@ def start_query(req: StartQueryRequest, db: Session = Depends(get_session)):
# 3) 拼 base SQL(保留 ORDER BY 首列:流式扫一遍 + 全表只排序一次,
# 结果顺序与旧分页版一致;不再拼 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 一定能找到)
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]:
if not source_table:
return {}
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_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")
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'
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)
// 会话式扫描(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 getTableDesignConfig = (id) => http.get(`/table-design/configs/${id}`)
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 @@
:page-sizes="pageSizes"
:total="rows.length"
layout="total, sizes, prev, pager, next"
background
small
/>
</div>
</div>
......@@ -149,7 +147,7 @@ const props = defineProps({
rows: { type: Array, required: true }, // 每行:{ errorCells?, issues?, ...业务字段 }
// 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 },
// 行 key:默认用 index;父组件可传 (row, i) => string 走更稳定的 key(如 row.id)
rowKey: { type: Function, default: (_row, i) => i },
......@@ -374,12 +372,19 @@ function ruleHint(col) {
.result-table {
display: flex;
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 {
text-align: center;
color: #909399;
padding: 24px 0 !important;
background: #fafafa;
/* 配色来自 global.css 的 .empty-row */
}
.cell-error-content {
display: inline-flex;
......@@ -394,14 +399,16 @@ function ruleHint(col) {
}
.err-flag-clickable:hover {
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 {
padding-bottom: 8px;
}
/* 「备注」列固定在右侧 —— 数据列多了时只滚左侧,备注常驻 */
/* 「备注」列固定在右侧 —— 数据列多了时只滚左侧,备注常驻。
sticky 列背景必须不透明(基于 --app-bg-secondary),
不能沿用 global 的 color-mix 半透明底,否则滚动时下层内容透出。 */
.dq-table th.col-reason,
.dq-table td.cell-reason {
position: sticky;
......@@ -411,15 +418,19 @@ function ruleHint(col) {
box-shadow: -4px 0 8px -4px rgba(0, 0, 0, 0.12);
}
.dq-table th.col-reason {
/* header 高于普通列,避免被覆盖 */
z-index: 2;
background: #fde2e2;
/* 角落格:top+right 双向 sticky,层级最高 */
z-index: 3;
background: color-mix(in srgb, var(--color-danger) 22%, var(--app-bg-secondary));
}
.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 {
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>
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
......@@ -3,10 +3,25 @@ import ElementPlus from 'element-plus'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
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 App from './App.vue'
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)
......
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.
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
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>
<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>
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