feat: CellData 脚本编辑与跨库表复制
This commit is contained in:
@@ -9,9 +9,9 @@ from fastapi import APIRouter, Body, File, HTTPException, UploadFile
|
|||||||
|
|
||||||
from app import state
|
from app import state
|
||||||
from app.api.routers.task_runtime import set_task_stage
|
from app.api.routers.task_runtime import set_task_stage
|
||||||
from app.config import AppConfig, CACHE_DIR
|
from app.config import AppConfig, CACHE_DIR, CELLDATA_SCRIPT
|
||||||
from app.processor import ProcessLogger
|
from app.processor import ProcessLogger
|
||||||
from app.services.cell_data import CellDataProcessor, refresh_cell_data
|
from app.services.cell_data import CellDataProcessor, execute_celldata_script, refresh_cell_data
|
||||||
from app.utils.files import safe_relative_path
|
from app.utils.files import safe_relative_path
|
||||||
|
|
||||||
router = APIRouter(tags=["cell-data"])
|
router = APIRouter(tags=["cell-data"])
|
||||||
@@ -139,6 +139,7 @@ def _run_cell_data_processing(task_id: str, work_dir: Path, logger: ProcessLogge
|
|||||||
started = time.time()
|
started = time.time()
|
||||||
try:
|
try:
|
||||||
result = refresh_cell_data(app_config, work_dir, logger)
|
result = refresh_cell_data(app_config, work_dir, logger)
|
||||||
|
execute_celldata_script(CELLDATA_SCRIPT, app_config, logger)
|
||||||
elapsed = round(time.time() - started, 2)
|
elapsed = round(time.time() - started, 2)
|
||||||
state.processing_tasks[task_id] = {
|
state.processing_tasks[task_id] = {
|
||||||
"logs": logger.get_logs(),
|
"logs": logger.get_logs(),
|
||||||
@@ -169,6 +170,7 @@ def _run_uploaded_cell_data_processing(task_id: str, upload_dir: Path, work_dir:
|
|||||||
started = time.time()
|
started = time.time()
|
||||||
try:
|
try:
|
||||||
result = CellDataProcessor(app_config, work_dir, logger).run_local(upload_dir)
|
result = CellDataProcessor(app_config, work_dir, logger).run_local(upload_dir)
|
||||||
|
execute_celldata_script(CELLDATA_SCRIPT, app_config, logger)
|
||||||
elapsed = round(time.time() - started, 2)
|
elapsed = round(time.time() - started, 2)
|
||||||
state.processing_tasks[task_id] = {
|
state.processing_tasks[task_id] = {
|
||||||
"logs": logger.get_logs(),
|
"logs": logger.get_logs(),
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ from app.api.routers.task_runtime import (
|
|||||||
)
|
)
|
||||||
from app.config import AppConfig, CACHE_DIR, RemoteDataConfig
|
from app.config import AppConfig, CACHE_DIR, RemoteDataConfig
|
||||||
from app.processor import DataProcessor, ProcessLogger
|
from app.processor import DataProcessor, ProcessLogger
|
||||||
from app.services.cell_data import refresh_cell_data
|
from app.config import CELLDATA_SCRIPT
|
||||||
|
from app.services.cell_data import copy_celldata_tables_to_capacity, execute_celldata_script, refresh_cell_data
|
||||||
from app.services.license import LicenseError, check_processing_allowed
|
from app.services.license import LicenseError, check_processing_allowed
|
||||||
from app.services.platform import PlatformStorageDownloader, make_source_downloader
|
from app.services.platform import PlatformStorageDownloader, make_source_downloader
|
||||||
from app.services.pipeline import RESULT_TABLES, run_import_and_report
|
from app.services.pipeline import RESULT_TABLES, run_import_and_report
|
||||||
@@ -221,6 +222,8 @@ def _try_refresh_cell_data(app_config: AppConfig, work_dir: Path, logger: Proces
|
|||||||
f"CellData 更新完成:{result.imported_rows} 行"
|
f"CellData 更新完成:{result.imported_rows} 行"
|
||||||
f"(解析 {result.parsed_rows},跳过 {result.skipped_rows})"
|
f"(解析 {result.parsed_rows},跳过 {result.skipped_rows})"
|
||||||
)
|
)
|
||||||
|
execute_celldata_script(CELLDATA_SCRIPT, app_config, logger)
|
||||||
|
copy_celldata_tables_to_capacity(app_config, logger)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning(f"CellData 更新失败,继续容量处理: {exc}")
|
logger.warning(f"CellData 更新失败,继续容量处理: {exc}")
|
||||||
|
|
||||||
|
|||||||
+68
-22
@@ -4,41 +4,62 @@ from datetime import datetime
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from threading import Thread
|
from threading import Thread
|
||||||
|
|
||||||
from fastapi import APIRouter, Body, HTTPException
|
from fastapi import APIRouter, Body, HTTPException, Query
|
||||||
|
|
||||||
from app import state
|
from app import state
|
||||||
from app.api.routers.task_runtime import set_task_stage
|
from app.api.routers.task_runtime import set_task_stage
|
||||||
from app.config import AppConfig, CACHE_DIR, SQL_SCRIPT
|
from app.config import AppConfig, CACHE_DIR, CELLDATA_SCRIPT, SQL_SCRIPT
|
||||||
from app.processor import DataProcessor, ProcessLogger
|
from app.processor import DataProcessor, ProcessLogger
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(tags=["script"])
|
router = APIRouter(tags=["script"])
|
||||||
|
|
||||||
|
SCRIPT_PATHS = {
|
||||||
|
"report": SQL_SCRIPT,
|
||||||
|
"celldata": CELLDATA_SCRIPT,
|
||||||
|
}
|
||||||
|
SCRIPT_LABELS = {
|
||||||
|
"report": "报表脚本",
|
||||||
|
"celldata": "CellData 脚本",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_script_path(script_type: str) -> Path:
|
||||||
|
path = SCRIPT_PATHS.get(script_type)
|
||||||
|
if path is None:
|
||||||
|
raise HTTPException(status_code=400, detail=f"不支持的脚本类型: {script_type}")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/script/content")
|
@router.get("/api/script/content")
|
||||||
async def get_script_content():
|
async def get_script_content(type: str = Query("report")):
|
||||||
|
script_path = _resolve_script_path(type)
|
||||||
|
label = SCRIPT_LABELS.get(type, type)
|
||||||
try:
|
try:
|
||||||
if not SQL_SCRIPT.exists():
|
if not script_path.exists():
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"content": "# SQL 脚本文件不存在,请在此编写脚本\n",
|
"content": f"# {label}文件不存在,请在此编写脚本\n",
|
||||||
"modified": None,
|
"modified": None,
|
||||||
"path": str(SQL_SCRIPT),
|
"path": str(script_path),
|
||||||
|
"script_type": type,
|
||||||
}
|
}
|
||||||
|
|
||||||
modified = datetime.fromtimestamp(SQL_SCRIPT.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S")
|
modified = datetime.fromtimestamp(script_path.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"content": SQL_SCRIPT.read_text(encoding="utf-8"),
|
"content": script_path.read_text(encoding="utf-8"),
|
||||||
"modified": modified,
|
"modified": modified,
|
||||||
"path": str(SQL_SCRIPT),
|
"path": str(script_path),
|
||||||
|
"script_type": type,
|
||||||
}
|
}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return {"success": False, "error": str(exc)}
|
return {"success": False, "error": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/script/execute")
|
@router.post("/api/script/execute")
|
||||||
async def execute_script():
|
async def execute_script(script_type: str = Body("report", embed=True)):
|
||||||
|
script_path = _resolve_script_path(script_type)
|
||||||
if state.global_task_lock["locked"]:
|
if state.global_task_lock["locked"]:
|
||||||
raise HTTPException(status_code=409, detail="已有任务在运行,请等待完成")
|
raise HTTPException(status_code=409, detail="已有任务在运行,请等待完成")
|
||||||
|
|
||||||
@@ -62,29 +83,48 @@ async def execute_script():
|
|||||||
set_task_stage(task_id, "processing", logs)
|
set_task_stage(task_id, "processing", logs)
|
||||||
app_config = state.current_config()
|
app_config = state.current_config()
|
||||||
|
|
||||||
thread = Thread(target=_run_script, args=(task_id, logger, logs, app_config), daemon=True)
|
thread = Thread(
|
||||||
|
target=_run_script,
|
||||||
|
args=(task_id, logger, logs, app_config, script_path, script_type),
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
thread.start()
|
thread.start()
|
||||||
return {"success": True, "message": "脚本执行任务已启动", "task_id": task_id}
|
label = SCRIPT_LABELS.get(script_type, script_type)
|
||||||
|
return {"success": True, "message": f"{label}执行任务已启动", "task_id": task_id}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/script/save")
|
@router.post("/api/script/save")
|
||||||
async def save_script_content(content: str = Body(..., embed=True)):
|
async def save_script_content(
|
||||||
|
content: str = Body(...),
|
||||||
|
script_type: str = Body("report"),
|
||||||
|
):
|
||||||
|
script_path = _resolve_script_path(script_type)
|
||||||
try:
|
try:
|
||||||
if SQL_SCRIPT.exists():
|
if script_path.exists():
|
||||||
shutil.copy(SQL_SCRIPT, SQL_SCRIPT.with_suffix(".sql.bak"))
|
shutil.copy(script_path, script_path.with_suffix(".sql.bak"))
|
||||||
|
|
||||||
SQL_SCRIPT.write_text(content, encoding="utf-8")
|
script_path.write_text(content, encoding="utf-8")
|
||||||
modified = datetime.fromtimestamp(SQL_SCRIPT.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S")
|
modified = datetime.fromtimestamp(script_path.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
return {"success": True, "message": "脚本保存成功", "modified": modified}
|
return {"success": True, "message": "脚本保存成功", "modified": modified}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return {"success": False, "error": str(exc)}
|
return {"success": False, "error": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
def _run_script(task_id: str, logger: ProcessLogger, logs: list[str], app_config: AppConfig) -> None:
|
def _run_script(
|
||||||
|
task_id: str,
|
||||||
|
logger: ProcessLogger,
|
||||||
|
logs: list[str],
|
||||||
|
app_config: AppConfig,
|
||||||
|
script_path: Path,
|
||||||
|
script_type: str,
|
||||||
|
) -> None:
|
||||||
temp_work_dir: Path | None = None
|
temp_work_dir: Path | None = None
|
||||||
|
label = SCRIPT_LABELS.get(script_type, script_type)
|
||||||
try:
|
try:
|
||||||
logger.info("开始执行 SQL 脚本...")
|
logger.info(f"开始执行{label}...")
|
||||||
if app_config.warehouse_type == "metrix":
|
if script_type == "celldata":
|
||||||
|
_run_celldata_script(script_path, app_config, logger)
|
||||||
|
elif app_config.warehouse_type == "metrix":
|
||||||
from app.services.pipeline import run_report_sql
|
from app.services.pipeline import run_report_sql
|
||||||
|
|
||||||
run_report_sql(app_config, logger)
|
run_report_sql(app_config, logger)
|
||||||
@@ -93,12 +133,18 @@ def _run_script(task_id: str, logger: ProcessLogger, logs: list[str], app_config
|
|||||||
temp_work_dir.mkdir(parents=True, exist_ok=True)
|
temp_work_dir.mkdir(parents=True, exist_ok=True)
|
||||||
processor = DataProcessor(app_config, temp_work_dir, logger)
|
processor = DataProcessor(app_config, temp_work_dir, logger)
|
||||||
processor._execute_sql_script()
|
processor._execute_sql_script()
|
||||||
logger.success("SQL 脚本执行完成")
|
logger.success(f"{label}执行完成")
|
||||||
set_task_stage(task_id, "completed", logs, status="completed")
|
set_task_stage(task_id, "completed", logs, status="completed")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error(f"SQL 脚本执行失败: {exc}")
|
logger.error(f"{label}执行失败: {exc}")
|
||||||
set_task_stage(task_id, "failed", logs, status="failed")
|
set_task_stage(task_id, "failed", logs, status="failed")
|
||||||
finally:
|
finally:
|
||||||
if temp_work_dir and temp_work_dir.exists():
|
if temp_work_dir and temp_work_dir.exists():
|
||||||
shutil.rmtree(temp_work_dir, ignore_errors=True)
|
shutil.rmtree(temp_work_dir, ignore_errors=True)
|
||||||
state.reset_task_lock()
|
state.reset_task_lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _run_celldata_script(script_path: Path, app_config: AppConfig, logger: ProcessLogger) -> None:
|
||||||
|
from app.services.cell_data import execute_celldata_script
|
||||||
|
|
||||||
|
execute_celldata_script(script_path, app_config, logger)
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ from app.api.routers.task_runtime import (
|
|||||||
)
|
)
|
||||||
from app.config import AppConfig
|
from app.config import AppConfig
|
||||||
from app.processor import DataProcessor, ProcessLogger
|
from app.processor import DataProcessor, ProcessLogger
|
||||||
from app.services.cell_data import refresh_cell_data
|
from app.config import CELLDATA_SCRIPT
|
||||||
|
from app.services.cell_data import copy_celldata_tables_to_capacity, execute_celldata_script, refresh_cell_data
|
||||||
from app.services.license import LicenseError, check_processing_allowed
|
from app.services.license import LicenseError, check_processing_allowed
|
||||||
|
|
||||||
|
|
||||||
@@ -227,5 +228,7 @@ def _try_refresh_cell_data(app_config: AppConfig, work_dir: Path, logger: Proces
|
|||||||
f"CellData 更新完成:{result.imported_rows} 行"
|
f"CellData 更新完成:{result.imported_rows} 行"
|
||||||
f"(解析 {result.parsed_rows},跳过 {result.skipped_rows})"
|
f"(解析 {result.parsed_rows},跳过 {result.skipped_rows})"
|
||||||
)
|
)
|
||||||
|
execute_celldata_script(CELLDATA_SCRIPT, app_config, logger)
|
||||||
|
copy_celldata_tables_to_capacity(app_config, logger)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning(f"CellData 更新失败,继续容量处理: {exc}")
|
logger.warning(f"CellData 更新失败,继续容量处理: {exc}")
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ BASE_DIR = _resolve_base_dir()
|
|||||||
CACHE_DIR = BASE_DIR / "cache"
|
CACHE_DIR = BASE_DIR / "cache"
|
||||||
CONFIG_FILE = BASE_DIR / "Configure.json"
|
CONFIG_FILE = BASE_DIR / "Configure.json"
|
||||||
SQL_SCRIPT = BASE_DIR / "ReportScript.sql"
|
SQL_SCRIPT = BASE_DIR / "ReportScript.sql"
|
||||||
|
CELLDATA_SCRIPT = BASE_DIR / "CellDataScript.sql"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|||||||
@@ -428,3 +428,153 @@ class CellDataProcessor:
|
|||||||
|
|
||||||
def refresh_cell_data(app_config: AppConfig, work_dir: Path, logger=None) -> CellDataResult:
|
def refresh_cell_data(app_config: AppConfig, work_dir: Path, logger=None) -> CellDataResult:
|
||||||
return CellDataProcessor(app_config, work_dir, logger).run()
|
return CellDataProcessor(app_config, work_dir, logger).run()
|
||||||
|
|
||||||
|
|
||||||
|
def execute_celldata_script(script_path: Path, app_config: AppConfig, logger=None) -> None:
|
||||||
|
if not script_path.exists():
|
||||||
|
if logger:
|
||||||
|
logger.info("CellData 脚本文件不存在,跳过")
|
||||||
|
return
|
||||||
|
sql_text = script_path.read_text(encoding="utf-8").strip()
|
||||||
|
if not sql_text:
|
||||||
|
if logger:
|
||||||
|
logger.info("CellData 脚本为空,跳过")
|
||||||
|
return
|
||||||
|
|
||||||
|
from app.processor import DataProcessor
|
||||||
|
|
||||||
|
statements = DataProcessor.parse_sql_script(sql_text)
|
||||||
|
if not statements:
|
||||||
|
if logger:
|
||||||
|
logger.info("CellData 脚本中没有有效语句,跳过")
|
||||||
|
return
|
||||||
|
|
||||||
|
mysql = app_config.cell_data.mysql.normalized()
|
||||||
|
conn = pymysql.connect(
|
||||||
|
host=mysql.host,
|
||||||
|
port=mysql.port,
|
||||||
|
user=mysql.user,
|
||||||
|
password=mysql.passwd,
|
||||||
|
database=mysql.dbname,
|
||||||
|
charset="utf8mb4",
|
||||||
|
cursorclass=pymysql.cursors.DictCursor,
|
||||||
|
autocommit=False,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cursor:
|
||||||
|
for i, sql in enumerate(statements, 1):
|
||||||
|
preview = sql[:80].replace("\n", " ")
|
||||||
|
if logger:
|
||||||
|
logger.info(f"CellData SQL ({i}/{len(statements)}): {preview}...")
|
||||||
|
cursor.execute(sql)
|
||||||
|
affected = cursor.rowcount if cursor.rowcount >= 0 else 0
|
||||||
|
if affected > 0 and logger:
|
||||||
|
logger.info(f"完成,影响 {affected} 行")
|
||||||
|
conn.commit()
|
||||||
|
if logger:
|
||||||
|
logger.success(f"CellData 脚本执行完成,共 {len(statements)} 条语句")
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def copy_celldata_tables_to_capacity(app_config: AppConfig, logger=None) -> int:
|
||||||
|
cd_mysql = app_config.cell_data.mysql.normalized()
|
||||||
|
cap_mysql = app_config.mysql.normalized()
|
||||||
|
|
||||||
|
if (
|
||||||
|
cd_mysql.host == cap_mysql.host
|
||||||
|
and cd_mysql.port == cap_mysql.port
|
||||||
|
and cd_mysql.dbname == cap_mysql.dbname
|
||||||
|
):
|
||||||
|
if logger:
|
||||||
|
logger.info("CellData 与容量数据库相同,跳过表复制")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
cd_conn = pymysql.connect(
|
||||||
|
host=cd_mysql.host, port=cd_mysql.port,
|
||||||
|
user=cd_mysql.user, password=cd_mysql.passwd,
|
||||||
|
database=cd_mysql.dbname, charset="utf8mb4",
|
||||||
|
cursorclass=pymysql.cursors.DictCursor,
|
||||||
|
)
|
||||||
|
cap_conn = pymysql.connect(
|
||||||
|
host=cap_mysql.host, port=cap_mysql.port,
|
||||||
|
user=cap_mysql.user, password=cap_mysql.passwd,
|
||||||
|
database=cap_mysql.dbname, charset="utf8mb4",
|
||||||
|
cursorclass=pymysql.cursors.DictCursor,
|
||||||
|
local_infile=True, autocommit=False,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
tables = _list_tables(cd_conn)
|
||||||
|
if not tables:
|
||||||
|
if logger:
|
||||||
|
logger.info("CellData 数据库中没有表,跳过复制")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
copied = 0
|
||||||
|
for table in tables:
|
||||||
|
rows = _copy_one_table(cd_conn, cap_conn, table, logger)
|
||||||
|
if rows >= 0:
|
||||||
|
copied += 1
|
||||||
|
cap_conn.commit()
|
||||||
|
if logger:
|
||||||
|
logger.success(f"已将 {copied} 张表从 CellData 复制到容量数据库")
|
||||||
|
return copied
|
||||||
|
except Exception:
|
||||||
|
cap_conn.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
cd_conn.close()
|
||||||
|
cap_conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _list_tables(conn) -> list[str]:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("SHOW TABLES")
|
||||||
|
return [list(row.values())[0] for row in cur.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
def _copy_one_table(src_conn, dst_conn, table: str, logger=None) -> int:
|
||||||
|
with src_conn.cursor() as cur:
|
||||||
|
cur.execute(f"SHOW CREATE TABLE `{table}`")
|
||||||
|
row = cur.fetchone()
|
||||||
|
create_sql = row.get("Create Table") or list(row.values())[1]
|
||||||
|
|
||||||
|
with src_conn.cursor() as cur:
|
||||||
|
cur.execute(f"SELECT COUNT(*) AS `cnt` FROM `{table}`")
|
||||||
|
count = cur.fetchone()["cnt"]
|
||||||
|
|
||||||
|
with dst_conn.cursor() as cur:
|
||||||
|
cur.execute(f"DROP TABLE IF EXISTS `{table}`")
|
||||||
|
cur.execute(create_sql)
|
||||||
|
|
||||||
|
if count == 0:
|
||||||
|
if logger:
|
||||||
|
logger.info(f"复制表 {table}: 0 行(空表)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
with src_conn.cursor() as src_cur:
|
||||||
|
src_cur.execute(f"SELECT * FROM `{table}`")
|
||||||
|
columns = [desc[0] for desc in src_cur.description]
|
||||||
|
col_list = ", ".join(f"`{c}`" for c in columns)
|
||||||
|
placeholders = ", ".join(["%s"] * len(columns))
|
||||||
|
insert_sql = f"INSERT INTO `{table}` ({col_list}) VALUES ({placeholders})"
|
||||||
|
|
||||||
|
batch: list[tuple] = []
|
||||||
|
inserted = 0
|
||||||
|
with dst_conn.cursor() as dst_cur:
|
||||||
|
for row in src_cur:
|
||||||
|
batch.append(tuple(row.values()))
|
||||||
|
if len(batch) >= 5000:
|
||||||
|
dst_cur.executemany(insert_sql, batch)
|
||||||
|
inserted += len(batch)
|
||||||
|
batch.clear()
|
||||||
|
if batch:
|
||||||
|
dst_cur.executemany(insert_sql, batch)
|
||||||
|
inserted += len(batch)
|
||||||
|
|
||||||
|
if logger:
|
||||||
|
logger.info(f"复制表 {table}: {inserted} 行")
|
||||||
|
return inserted
|
||||||
|
|||||||
@@ -868,3 +868,14 @@
|
|||||||
- 修复:`processor.py::_convert_datetime_column` 在 ISO8601 解析前先用正则剥离时区后缀(`+08:00`、`-05:30`、`Z`)和 `T` 分隔符,再按朴素日期时间解析,保留原始壁钟时间。
|
- 修复:`processor.py::_convert_datetime_column` 在 ISO8601 解析前先用正则剥离时区后缀(`+08:00`、`-05:30`、`Z`)和 `T` 分隔符,再按朴素日期时间解析,保留原始壁钟时间。
|
||||||
- SQL 清理:`ReportScript.sql` 删除 4G/5G 的 `DATE_ADD(INTERVAL 8 HOUR)` 补偿语句,因为数据导入阶段已正确保留本地时间,不再需要 SQL 层面修正。
|
- SQL 清理:`ReportScript.sql` 删除 4G/5G 的 `DATE_ADD(INTERVAL 8 HOUR)` 补偿语句,因为数据导入阶段已正确保留本地时间,不再需要 SQL 层面修正。
|
||||||
- 验证:`python -m compileall -q app` 通过。
|
- 验证:`python -m compileall -q app` 通过。
|
||||||
|
|
||||||
|
## 2026-06-25:CellData 脚本编辑与跨库表复制
|
||||||
|
|
||||||
|
- 新增 `CellDataScript.sql` 脚本路径(`config.py::CELLDATA_SCRIPT = BASE_DIR / "CellDataScript.sql"`),与 `ReportScript.sql` 并列,用于 CellData 数据导入后执行的 SQL。
|
||||||
|
- 脚本 API(`script.py`)三个接口(读取/保存/执行)新增 `script_type` 参数(`report` / `celldata`),默认 `report` 保持向后兼容;CellData 脚本执行在 CellData 数据库上下文中运行。
|
||||||
|
- 新增 `cell_data.py::execute_celldata_script()`:在 CellData MySQL 库上执行 CellDataScript.sql,复用 `DataProcessor.parse_sql_script` 解析。
|
||||||
|
- 新增 `cell_data.py::copy_celldata_tables_to_capacity()`:列出 CellData 库所有表,逐表 `SHOW CREATE TABLE` → 目标库建表 → 分批 `SELECT * / INSERT INTO` 复制数据;两库相同时跳过。
|
||||||
|
- CellData 独立处理(远程 / 本地上传)完成后自动执行 CellData 脚本(不复制表)。
|
||||||
|
- 容量处理集成(`tasks.py` / `remote.py` 的 `_try_refresh_cell_data`):CellData 数据更新 → 执行 CellData 脚本 → 复制表到容量库 → 后续 ReportScript.sql 可直接引用 CellData 表。
|
||||||
|
- 前端脚本编辑页(`ScriptPanel.vue`)编辑器上方新增 Naive UI segment tabs 切换「报表脚本」和「CellData 脚本」;切换时检查未保存状态、加载对应脚本内容;保存和运行按钮跟随当前脚本类型。
|
||||||
|
- 验证:`python -m compileall -q app` 通过;`frontend` `npm run build`(vue-tsc)通过;构建产物已清理。
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="script-page-body">
|
<div class="script-page-body">
|
||||||
<div class="script-editor-container">
|
<div class="script-editor-container">
|
||||||
|
<div class="script-type-bar">
|
||||||
|
<n-tabs
|
||||||
|
:value="scriptType"
|
||||||
|
type="segment"
|
||||||
|
size="small"
|
||||||
|
@update:value="switchScript"
|
||||||
|
>
|
||||||
|
<n-tab-pane name="report" tab="报表脚本" :disabled="switching" />
|
||||||
|
<n-tab-pane name="celldata" tab="CellData 脚本" :disabled="switching" />
|
||||||
|
</n-tabs>
|
||||||
|
</div>
|
||||||
<div ref="editorHost" class="script-editor" />
|
<div ref="editorHost" class="script-editor" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -63,6 +74,11 @@ type MonacoGlobal = typeof globalThis & {
|
|||||||
getWorker: () => new editorWorker()
|
getWorker: () => new editorWorker()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SCRIPT_LABELS: Record<string, string> = {
|
||||||
|
report: '报表脚本',
|
||||||
|
celldata: 'CellData 脚本',
|
||||||
|
};
|
||||||
|
|
||||||
const message = useMessage();
|
const message = useMessage();
|
||||||
const editorHost = ref<HTMLDivElement | null>(null);
|
const editorHost = ref<HTMLDivElement | null>(null);
|
||||||
const editor = shallowRef<monaco.editor.IStandaloneCodeEditor | null>(null);
|
const editor = shallowRef<monaco.editor.IStandaloneCodeEditor | null>(null);
|
||||||
@@ -77,6 +93,8 @@ const isModified = ref(false);
|
|||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
const executing = ref(false);
|
const executing = ref(false);
|
||||||
|
const scriptType = ref('report');
|
||||||
|
const switching = ref(false);
|
||||||
let timer: number | undefined;
|
let timer: number | undefined;
|
||||||
let themeObserver: MutationObserver | undefined;
|
let themeObserver: MutationObserver | undefined;
|
||||||
let applyingRemoteContent = false;
|
let applyingRemoteContent = false;
|
||||||
@@ -108,13 +126,15 @@ const statusText = computed(() => {
|
|||||||
const editorStatus = computed(() => {
|
const editorStatus = computed(() => {
|
||||||
if (loading.value) return '加载中';
|
if (loading.value) return '加载中';
|
||||||
if (saving.value) return '保存中';
|
if (saving.value) return '保存中';
|
||||||
|
if (switching.value) return '切换中';
|
||||||
return isModified.value ? '未保存' : '就绪';
|
return isModified.value ? '未保存' : '就绪';
|
||||||
});
|
});
|
||||||
const modifiedText = computed(() => (modified.value ? `最后修改: ${modified.value}` : '最后修改: -'));
|
const modifiedText = computed(() => (modified.value ? `最后修改: ${modified.value}` : '最后修改: -'));
|
||||||
|
const currentLabel = computed(() => SCRIPT_LABELS[scriptType.value] || scriptType.value);
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
setPageHeader({
|
setPageHeader({
|
||||||
subtitle: computed(() => scriptPath.value || 'ReportScript.sql'),
|
subtitle: computed(() => currentLabel.value + (scriptPath.value ? ` (${scriptPath.value})` : '')),
|
||||||
actions: [
|
actions: [
|
||||||
{ key: 'script-modified', kind: 'text', label: modifiedText },
|
{ key: 'script-modified', kind: 'text', label: modifiedText },
|
||||||
{
|
{
|
||||||
@@ -129,7 +149,7 @@ onMounted(async () => {
|
|||||||
{ key: 'format-script', label: '格式化', icon: BrushOutline, onClick: formatScript },
|
{ key: 'format-script', label: '格式化', icon: BrushOutline, onClick: formatScript },
|
||||||
{
|
{
|
||||||
key: 'run-script',
|
key: 'run-script',
|
||||||
label: '运行脚本',
|
label: computed(() => `运行${currentLabel.value}`),
|
||||||
icon: PlayOutline,
|
icon: PlayOutline,
|
||||||
type: 'success',
|
type: 'success',
|
||||||
variant: 'solid',
|
variant: 'solid',
|
||||||
@@ -162,7 +182,7 @@ function createEditor() {
|
|||||||
if (!editorHost.value || editor.value) return;
|
if (!editorHost.value || editor.value) return;
|
||||||
|
|
||||||
editor.value = monaco.editor.create(editorHost.value, {
|
editor.value = monaco.editor.create(editorHost.value, {
|
||||||
value: '-- 正在加载 ReportScript.sql',
|
value: '-- 正在加载脚本...',
|
||||||
language: 'sql',
|
language: 'sql',
|
||||||
theme: currentEditorTheme(),
|
theme: currentEditorTheme(),
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
@@ -204,10 +224,22 @@ function createEditor() {
|
|||||||
updateCursor();
|
updateCursor();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function switchScript(type: string) {
|
||||||
|
if (type === scriptType.value || switching.value) return;
|
||||||
|
if (isModified.value) {
|
||||||
|
const confirm = window.confirm(`当前${currentLabel.value}有未保存的修改,是否放弃?`);
|
||||||
|
if (!confirm) return;
|
||||||
|
}
|
||||||
|
switching.value = true;
|
||||||
|
scriptType.value = type;
|
||||||
|
await loadScript();
|
||||||
|
switching.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadScript() {
|
async function loadScript() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const result = await apiGet<ScriptContent>('/api/script/content');
|
const result = await apiGet<ScriptContent>(`/api/script/content?type=${scriptType.value}`);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
throw new Error(result.error || '加载脚本失败');
|
throw new Error(result.error || '加载脚本失败');
|
||||||
}
|
}
|
||||||
@@ -226,7 +258,8 @@ async function saveScript() {
|
|||||||
try {
|
try {
|
||||||
const value = getEditorContent();
|
const value = getEditorContent();
|
||||||
const result = await apiPost<ApiMessage & { modified?: string }>('/api/script/save', {
|
const result = await apiPost<ApiMessage & { modified?: string }>('/api/script/save', {
|
||||||
content: value
|
content: value,
|
||||||
|
script_type: scriptType.value
|
||||||
});
|
});
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
throw new Error(result.error || '保存失败');
|
throw new Error(result.error || '保存失败');
|
||||||
@@ -261,7 +294,9 @@ async function executeScript() {
|
|||||||
if (isModified.value) return;
|
if (isModified.value) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await apiPost<ApiMessage>('/api/script/execute');
|
const result = await apiPost<ApiMessage>('/api/script/execute', {
|
||||||
|
script_type: scriptType.value
|
||||||
|
});
|
||||||
if (!result.task_id) {
|
if (!result.task_id) {
|
||||||
throw new Error(result.message || '脚本任务启动失败');
|
throw new Error(result.message || '脚本任务启动失败');
|
||||||
}
|
}
|
||||||
@@ -335,3 +370,13 @@ async function refreshStatus() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.script-type-bar {
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-bottom: 1px solid var(--n-border-color, #e0e0e6);
|
||||||
|
}
|
||||||
|
.script-type-bar :deep(.n-tabs .n-tabs-tab) {
|
||||||
|
padding: 4px 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
feat: CellData 脚本编辑与跨库表复制
|
||||||
Reference in New Issue
Block a user