From 5b50c9750f91ed787cd5fcbafc926e823fabc166 Mon Sep 17 00:00:00 2001 From: Nixevol Date: Wed, 14 Jan 2026 18:27:05 +0800 Subject: [PATCH] Enhance database management features by adding an API endpoint to drop all tables with double confirmation. Implement configuration file upload and download functionalities, including backup of the existing configuration. Update UI elements for improved user experience, including pagination controls and toast notifications. Adjust Docker and git ignore files to optimize build processes. --- .dockerignore | 3 +- .gitignore | 6 +- Configure.json | 2 +- Dockerfile | 2 +- app/database.py | 28 ++ app/main.py | 217 ++++++++++++--- dist/.gitkeep | 2 + dist/Configure.json | 243 +++++++++++++++++ dist/ReportScript.sql | 471 +++++++++++++++++++++++++++++++++ dist/build.bat | 220 +++++++++++++++ dist/build.py | 313 ++++++++++++++++++++++ dist/deploy.sh | 240 +++++++++++++++++ dist/docker-compose.yml | 79 ++++++ dist/mysql/conf.d/custom.cnf | 35 +++ dist/mysql/init/01-init-db.sql | 13 + docker-compose.yml | 3 +- static/css/style.css | 304 +++++++++++++++++---- static/index.html | 87 ++++-- static/js/app.js | 322 +++++++++++++++++----- 19 files changed, 2424 insertions(+), 166 deletions(-) create mode 100644 dist/.gitkeep create mode 100644 dist/Configure.json create mode 100644 dist/ReportScript.sql create mode 100644 dist/build.bat create mode 100644 dist/build.py create mode 100644 dist/deploy.sh create mode 100644 dist/docker-compose.yml create mode 100644 dist/mysql/conf.d/custom.cnf create mode 100644 dist/mysql/init/01-init-db.sql diff --git a/.dockerignore b/.dockerignore index 5495c0c..72fe3e4 100644 --- a/.dockerignore +++ b/.dockerignore @@ -12,6 +12,7 @@ __pycache__ .venv venv/ ENV/ +dist/ # IDE .idea/ @@ -23,7 +24,7 @@ ENV/ cache/ logs/ *.log - +CapacityReportData/ # Docker docker-compose*.yml Dockerfile* diff --git a/.gitignore b/.gitignore index ede13d1..cd20011 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,6 @@ __pycache__/ .Python build/ develop-eggs/ -dist/ downloads/ eggs/ .eggs/ @@ -72,3 +71,8 @@ uploads/ # 配置文件(如果包含敏感信息) # Configure.json # 如果需要保留配置,注释掉这行 + +# dist 目录(保留脚本和配置文件,忽略生成的镜像) +dist/images/ +dist/cache/ +dist/logs/ \ No newline at end of file diff --git a/Configure.json b/Configure.json index dded27f..835f555 100644 --- a/Configure.json +++ b/Configure.json @@ -1,5 +1,5 @@ { - "Update": "2026/01/14 09:14:43", + "Update": "2026/01/14 17:10:35", "MySQL_DBInfo": { "host": "127.0.0.1", "port": 3306, diff --git a/Dockerfile b/Dockerfile index 5f6a4e1..40e1a90 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # CapacityReport Dockerfile # 容量报表处理系统 - 用于内网离线部署 -FROM python:3.13-slim +FROM python:3.13.11-slim LABEL maintainer="CapacityReport" LABEL description="容量报表处理系统" diff --git a/app/database.py b/app/database.py index cec2ac2..4cb1acd 100644 --- a/app/database.py +++ b/app/database.py @@ -245,6 +245,34 @@ class DatabaseManager: conn.commit() return True + def drop_all_tables(self) -> Dict[str, Any]: + """删除所有表""" + with self.get_connection() as conn: + with conn.cursor() as cursor: + # 获取所有表名 + cursor.execute("SHOW TABLES") + tables = [list(row.values())[0] for row in cursor.fetchall()] + + if not tables: + return {"success": True, "dropped_count": 0, "tables": []} + + # 删除所有表 + dropped_tables = [] + for table in tables: + try: + cursor.execute(f"DROP TABLE IF EXISTS `{table}`") + dropped_tables.append(table) + except Exception as e: + # 记录错误但继续删除其他表 + pass + + conn.commit() + return { + "success": True, + "dropped_count": len(dropped_tables), + "tables": dropped_tables + } + def execute_sql(self, sql: str) -> Tuple[bool, Any]: """执行自定义 SQL""" with self.get_connection() as conn: diff --git a/app/main.py b/app/main.py index 4772268..1f18828 100644 --- a/app/main.py +++ b/app/main.py @@ -4,6 +4,7 @@ FastAPI 主入口 """ import os import sys +import json import signal import shutil import asyncio @@ -626,6 +627,23 @@ async def drop_table(table_name: str = Body(..., embed=True)): raise HTTPException(status_code=500, detail=str(e)) +@app.post("/api/database/table/drop-all") +async def drop_all_tables(): + """删除所有表(危险操作,需要确认)""" + try: + db = DatabaseManager(config) + result = db.drop_all_tables() + db.dispose() + return { + "success": True, + "message": f"已删除 {result['dropped_count']} 个表", + "dropped_count": result['dropped_count'], + "tables": result['tables'] + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + @app.post("/api/database/execute") async def execute_sql(sql: str = Body(..., embed=True)): """执行自定义 SQL""" @@ -737,6 +755,85 @@ async def update_extract_fields(fields: List[Dict[str, Any]] = Body(...)): return {"success": True, "message": "字段映射配置已更新", "update": config.update} +@app.get("/api/config/download") +async def download_config(): + """下载配置文件(JSON 格式)""" + config_file = BASE_DIR / "Configure.json" + + if not config_file.exists(): + raise HTTPException(status_code=404, detail="配置文件不存在") + + # 生成带时间戳的文件名 + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"Configure_{timestamp}.json" + + return FileResponse( + path=str(config_file), + filename=filename, + media_type="application/json" + ) + + +@app.post("/api/config/upload") +async def upload_config(file: UploadFile = File(...)): + """上传配置文件(JSON 格式)""" + global config + + # 验证文件类型 + if not file.filename.endswith('.json'): + raise HTTPException(status_code=400, detail="只支持 JSON 格式的配置文件") + + backup_file = None + try: + # 读取文件内容 + content = await file.read() + data = json.loads(content.decode('utf-8')) + + # 验证配置结构 + if not isinstance(data, dict): + raise ValueError("配置文件格式错误:必须是 JSON 对象") + + # 备份当前配置 + config_file = BASE_DIR / "Configure.json" + if config_file.exists(): + backup_file = BASE_DIR / f"Configure.backup.{datetime.now().strftime('%Y%m%d_%H%M%S')}.json" + shutil.copy2(config_file, backup_file) + + # 验证并更新配置 + # 验证 MySQL 配置 + mysql_data = data.get("MySQL_DBInfo", {}) + if not all(key in mysql_data for key in ["host", "port", "user", "passwd", "dbname"]): + raise ValueError("配置文件缺少必需的 MySQL 配置项") + + # 更新配置 + config.mysql.host = mysql_data.get("host", "localhost") + config.mysql.port = mysql_data.get("port", 3306) + config.mysql.user = mysql_data.get("user", "root") + config.mysql.passwd = mysql_data.get("passwd", "") + config.mysql.dbname = mysql_data.get("dbname", "CapacityReport") + + # 更新其他配置 + config.sheet_filter = data.get("SheetFilter", []) + config.extract_fields = data.get("ExtractField", []) + + # 保存配置 + config.save() + + return { + "success": True, + "message": "配置文件上传成功", + "update": config.update, + "backup": backup_file.name if backup_file and backup_file.exists() else None + } + + except json.JSONDecodeError: + raise HTTPException(status_code=400, detail="配置文件格式错误:不是有效的 JSON 文件") + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=f"上传失败: {str(e)}") + + # ==================== 清理 API ==================== @app.get("/api/cache/size") @@ -858,16 +955,27 @@ async def get_history_size(record_id: str = Body(..., embed=True)): def is_supervisor_running() -> bool: """检查是否在 supervisor 环境下运行""" - # 检查 supervisor socket 文件是否存在 - supervisor_sock = Path("/var/run/supervisor.sock") - if supervisor_sock.exists(): - return True - - # 检查环境变量 + # 1. 检查环境变量(最可靠的方式) if os.environ.get("SUPERVISOR_ENABLED") == "1": return True - # 检查父进程是否是 supervisord + # 2. 检查 supervisor socket 文件是否存在 + supervisor_sock = Path("/var/run/supervisor.sock") + if supervisor_sock.exists(): + # 尝试连接验证 + try: + result = subprocess.run( + ["supervisorctl", "status"], + capture_output=True, + text=True, + timeout=5 + ) + if result.returncode == 0: + return True + except: + pass + + # 3. 检查父进程是否是 supervisord try: import psutil # pyright: ignore[reportMissingModuleSource] current = psutil.Process() @@ -877,12 +985,26 @@ def is_supervisor_running() -> bool: except: pass + # 4. 检查进程列表中是否有 supervisord + try: + result = subprocess.run( + ["pgrep", "-f", "supervisord"], + capture_output=True, + text=True, + timeout=2 + ) + if result.returncode == 0 and result.stdout.strip(): + return True + except: + pass + return False def restart_via_supervisor() -> tuple: """通过 supervisor 重启服务""" try: + # 尝试使用 supervisorctl 重启 result = subprocess.run( ["supervisorctl", "restart", "fastapi"], capture_output=True, @@ -892,7 +1014,8 @@ def restart_via_supervisor() -> tuple: if result.returncode == 0: return True, "服务正在通过 supervisor 重启..." else: - return False, f"重启失败: {result.stderr or result.stdout}" + error_msg = result.stderr or result.stdout or "未知错误" + return False, f"重启失败: {error_msg}" except subprocess.TimeoutExpired: return False, "重启操作超时" except FileNotFoundError: @@ -925,36 +1048,68 @@ def restart_via_signal() -> tuple: async def restart_service(): """ 重启服务 - - 在 supervisor 环境下使用 supervisorctl restart - - 在非 supervisor 环境下发送信号或退出进程 + - 优先使用 supervisorctl restart(Docker/Linux 环境) + - 如果检测失败但命令可用,尝试直接调用 supervisorctl + - Windows 环境使用进程退出方式 """ - # 检查是否在 supervisor 环境下 + # 方案 1: 如果检测到 supervisor,直接使用 if is_supervisor_running(): success, message = restart_via_supervisor() - else: - # 非 supervisor 环境,使用延迟退出 - # 先返回响应,然后在后台线程中退出进程 - def delayed_exit(): - import time - time.sleep(1) # 等待响应发送完成 - if platform.system() == "Windows": - os._exit(0) - else: - os.kill(os.getpid(), signal.SIGTERM) - - thread = Thread(target=delayed_exit, daemon=True) - thread.start() - return { - "success": True, - "message": "服务正在重启,请稍后刷新页面...", - "method": "signal" + "success": success, + "message": message, + "method": "supervisor" } + # 方案 2: 在非 Windows 环境下,即使检测不到也尝试直接调用 supervisorctl + # 这在 Docker 中可能有效(检测逻辑可能失败但命令实际可用) + if platform.system() != "Windows": + try: + result = subprocess.run( + ["supervisorctl", "restart", "fastapi"], + capture_output=True, + text=True, + timeout=30 + ) + if result.returncode == 0: + return { + "success": True, + "message": "服务正在通过 supervisor 重启...", + "method": "supervisor" + } + # 如果返回非 0,记录错误但继续尝试其他方案 + except FileNotFoundError: + # supervisorctl 不存在,跳过 + pass + except subprocess.TimeoutExpired: + return { + "success": False, + "message": "重启操作超时,请检查 supervisor 状态", + "method": "supervisor" + } + except Exception as e: + # 其他错误,记录但继续 + pass + + # 方案 3: 非 supervisor 环境,使用延迟退出 + # 先返回响应,然后在后台线程中退出进程 + def delayed_exit(): + import time + time.sleep(1) # 等待响应发送完成 + if platform.system() == "Windows": + os._exit(0) + else: + # Linux/Mac: 发送 SIGTERM 信号 + # 在 Docker 中,如果容器有 restart policy,会自动重启 + os.kill(os.getpid(), signal.SIGTERM) + + thread = Thread(target=delayed_exit, daemon=True) + thread.start() + return { - "success": success, - "message": message, - "method": "supervisor" if is_supervisor_running() else "signal" + "success": True, + "message": "服务正在重启,请稍后刷新页面...", + "method": "signal" } diff --git a/dist/.gitkeep b/dist/.gitkeep new file mode 100644 index 0000000..7433af7 --- /dev/null +++ b/dist/.gitkeep @@ -0,0 +1,2 @@ +# 此文件用于确保 dist 目录被 Git 跟踪 +# dist 目录用于存放离线部署包 diff --git a/dist/Configure.json b/dist/Configure.json new file mode 100644 index 0000000..cd097ba --- /dev/null +++ b/dist/Configure.json @@ -0,0 +1,243 @@ +{ + "Update": "2026/01/14 09:14:43", + "MySQL_DBInfo": { + "host": "capacity-mysql", + "port": 3306, + "user": "root", + "passwd": "gmcc123", + "dbname": "CapacityReport" + }, + "SheetFilter": [ + "指标(计数器)", + "Template" + ], + "ExtractField": [ + { + "Field": "日期时间", + "Extract": [ + "开始时间" + ], + "Type": "datetime" + }, + { + "Field": "网元ID", + "Extract": [ + "网元ID", + "网元" + ] + }, + { + "Field": "eNodeBId", + "Extract": [ + "eNodeBId", + "eNodeB" + ] + }, + { + "Field": "cellLocalId", + "Extract": [ + "cellId", + "小区" + ] + }, + { + "Field": "基站名称", + "Extract": [ + "eNodeB CU名称", + "eNodeB名称" + ] + }, + { + "Field": "小区名称", + "Extract": [ + "小区名称", + "E-UTRAN FDD小区名称", + "E-UTRAN TDD小区名称" + ] + }, + { + "Field": "上下行总流量_GB", + "Extract": [ + "集团-上下行总业务量(GB)", + "业务流量上下行(GB)", + "YJ_业务流量上下行(GB)", + "FDD总流量(G)" + ] + }, + { + "Field": "ERAB流量", + "Extract": [ + "ERAB流量(新高负荷)", + "ERAB流量", + "ERAB流量(新高负荷)", + "ERAB流量(新高负荷)-FDD" + ] + }, + { + "Field": "上行流量_GB", + "Extract": [ + "上行流量(新高负荷GB)", + "上行流量(新高负荷GB)", + "高负荷-上行流量(GB)", + "FDD上行流量(G)" + ] + }, + { + "Field": "下行流量_GB", + "Extract": [ + "下行流量(新高负荷GB)", + "下行流量(新高负荷GB)", + "高负荷-下行流量(GB)", + "FDD下行流量(G)" + ] + }, + { + "Field": "用户面平均激活UE数", + "Extract": [ + "用户面平均激活UE数", + "YY-用户面平均激活UE数(个)", + "用户面平均激活UE数(unit)" + ] + }, + { + "Field": "RRC连接建立最大用户数", + "Extract": [ + "YY-RRC连接建立最大用户数(个)", + "RRC连接建立最大用户数(unit)", + "RRC连接建立最大用户数" + ] + }, + { + "Field": "上行PUSCH利用率", + "Extract": [ + "上行PUSCH利用率", + "YY-上行PUSCH利用率", + "上行PUSCH利用率-FDD" + ] + }, + { + "Field": "下行PDSCH利用率", + "Extract": [ + "下行PDSCH利用率", + "YY-下行PDSCH利用率", + "下行PDSCH利用率-FDD" + ] + }, + { + "Field": "PDCCH资源利用率", + "Extract": [ + "PDCCH资源利用率", + "PDCCH资源利用率_1548034713403-33-2", + "PDCCH资源使用率(%)" + ] + }, + { + "Field": "用户面最大激活UE数", + "Extract": [ + "开发者大会保障--最大激活UE数", + "用户面最大激活UE数", + "YY-用户面最大激活UE数(个)", + "用户面最大激活UE数(unit)" + ] + }, + { + "Field": "CU小区配置名称", + "Extract": [ + "CU小区配置名称" + ] + }, + { + "Field": "gNBId", + "Extract": [ + "gNBId" + ] + }, + { + "Field": "gNBplmn", + "Extract": [ + "gNBplmn" + ] + }, + { + "Field": "5G上下行总流量_GB", + "Extract": [ + "5G上下行总流量(上行PDCP PDU数据量+下行PDCP成功发送数据量)(GB)" + ] + }, + { + "Field": "5G上行流量_GB", + "Extract": [ + "5G上行流量(上行PDCP PDU数据量)_1603677232210(GB)", + "5G上行流量(上行PDCP PDU数据量)(GB)" + ] + }, + { + "Field": "5G下行流量_GB", + "Extract": [ + "5G下行流量(下行PDCP成功发送数据量)_1603677232245(GB)", + "5G下行流量(下行PDCP成功发送数据量)(GB)" + ] + }, + { + "Field": "小区平均激活UE数", + "Extract": [ + "小区平均激活UE数" + ] + }, + { + "Field": "RRC连接最大连接用户数", + "Extract": [ + "RRC连接最大连接用户数" + ] + }, + { + "Field": "RRC连接平均连接用户数", + "Extract": [ + "RRC连接平均连接用户数" + ] + }, + { + "Field": "小区最大激活UE数", + "Extract": [ + "小区最大激活UE数" + ] + }, + { + "Field": "上行PRB平均利用率", + "Extract": [ + "上行PRB平均利用率" + ] + }, + { + "Field": "下行PRB平均利用率", + "Extract": [ + "下行PRB平均利用率" + ] + }, + { + "Field": "小区下行RLC_SDU字节数_MByte", + "Extract": [ + "小区下行RLC SDU字节数(MByte)" + ] + }, + { + "Field": "小区上行RLC_SDU字节数_MByte", + "Extract": [ + "小区上行RLC SDU字节数(MByte)" + ] + }, + { + "Field": "PDCCH信道动态CCE占用率", + "Extract": [ + "PDCCH信道CCE占用率(动态)(%)" + ] + }, + { + "Field": "平均单Flow流量_MB", + "Extract": [ + "平均单Flow流量(MB)", + "平均每flow流量(流量/flow(MB/flow))" + ] + } + ] +} \ No newline at end of file diff --git a/dist/ReportScript.sql b/dist/ReportScript.sql new file mode 100644 index 0000000..5a1f6c6 --- /dev/null +++ b/dist/ReportScript.sql @@ -0,0 +1,471 @@ +# Update: 2024/11/25 09:30:00 + +SET GLOBAL wait_timeout = 86400; +DROP TABLE IF EXISTS `4G`; +DROP TABLE IF EXISTS `5G`; +DROP TABLE IF EXISTS `4G_Date`; +DROP TABLE IF EXISTS `4G_MAX`; +DROP TABLE IF EXISTS `CCE_MAX`; +DROP TABLE IF EXISTS `ULPRB_MAX`; +DROP TABLE IF EXISTS `DLPRB_MAX`; +DROP TABLE IF EXISTS `4G_MAX_IDX`; +DROP TABLE IF EXISTS `CCE_MAX_IDX`; +DROP TABLE IF EXISTS `ULPRB_MAX_IDX`; +DROP TABLE IF EXISTS `DLPRB_MAX_IDX`; +DROP TABLE IF EXISTS `日流量`; +DROP TABLE IF EXISTS `4G日均流量`; +DROP TABLE IF EXISTS `4G_结果表`; + +DROP TABLE IF EXISTS `5G_Date`; +DROP TABLE IF EXISTS `5G_MAX`; +DROP TABLE IF EXISTS `5G_MAX_IDX`; +DROP TABLE IF EXISTS `5G日均流量`; +DROP TABLE IF EXISTS `5G_结果表`; + + +CREATE TABLE `4G` AS SELECT * FROM `4G_UD`; +CREATE TABLE `5G` AS SELECT * FROM `5G_UD`; + +UPDATE 4G SET `日期时间` = DATE_FORMAT(`日期时间`, '%Y-%m-%d %H:%i:%s'); +UPDATE 5G SET `日期时间` = DATE_FORMAT(`日期时间`, '%Y-%m-%d %H:%i:%s'); + +ALTER TABLE `4G` +ADD COLUMN `日期` date NULL FIRST, +MODIFY COLUMN `日期时间` datetime, +ADD COLUMN `CGI` varchar(100) NULL AFTER `日期时间`, +MODIFY COLUMN `网元ID` int, +MODIFY COLUMN `基站名称` varchar(200), +MODIFY COLUMN `小区名称` varchar(200), +MODIFY COLUMN `cellLocalId` int, +MODIFY COLUMN `eNodeBId` int, +MODIFY COLUMN `上下行总流量_GB` float, +MODIFY COLUMN `ERAB流量` float, +MODIFY COLUMN `上行流量_GB` float, +MODIFY COLUMN `下行流量_GB` float, +MODIFY COLUMN `用户面平均激活UE数` float, +MODIFY COLUMN `RRC连接建立最大用户数` int, +MODIFY COLUMN `上行PUSCH利用率` float, +MODIFY COLUMN `下行PDSCH利用率` float, +MODIFY COLUMN `PDCCH资源利用率` float, +MODIFY COLUMN `用户面最大激活UE数` int; + +ALTER TABLE `5G` +ADD COLUMN `日期` date NULL FIRST, +MODIFY COLUMN `日期时间` datetime, +ADD COLUMN `NCGI` varchar(100) NULL AFTER `日期时间`, +MODIFY COLUMN `网元ID` int, +MODIFY COLUMN `CU小区配置名称` varchar(200), +MODIFY COLUMN `cellLocalId` int, +MODIFY COLUMN `gNBId` int, +MODIFY COLUMN `gNBplmn` varchar(200), +MODIFY COLUMN `5G上下行总流量_GB` float, +MODIFY COLUMN `5G上行流量_GB` float, +MODIFY COLUMN `5G下行流量_GB` float, +MODIFY COLUMN `小区平均激活UE数` float, +MODIFY COLUMN `RRC连接最大连接用户数` int, +MODIFY COLUMN `RRC连接平均连接用户数` float, +MODIFY COLUMN `小区最大激活UE数` float, +MODIFY COLUMN `上行PRB平均利用率` float, +MODIFY COLUMN `下行PRB平均利用率` float, +MODIFY COLUMN `小区下行RLC_SDU字节数_MByte` float, +MODIFY COLUMN `小区上行RLC_SDU字节数_MByte` float, +MODIFY COLUMN `PDCCH信道动态CCE占用率` float, +MODIFY COLUMN `平均单Flow流量_MB` float; + +UPDATE 4G SET `日期` = DATE_FORMAT(`日期时间`, '%Y-%m-%d'), CGI = CONCAT("460-00-",eNodeBId,"-", cellLocalId); +UPDATE 5G SET gNBplmn = "460-00" WHERE gNBplmn IS NULL; +UPDATE 5G SET `日期` = DATE_FORMAT(`日期时间`, '%Y-%m-%d'), NCGI = CONCAT(gNBplmn, "-",gNBId,"-", cellLocalId); + +ALTER TABLE 4G +ADD INDEX(`日期`), +ADD INDEX(`日期时间`), +ADD INDEX(`CGI`), +ADD INDEX `TC`(`日期时间`, `CGI`), +ADD INDEX `DC`(`日期`, `CGI`); + +ALTER TABLE 5G +ADD INDEX(`日期`), +ADD INDEX(`日期时间`), +ADD INDEX(`NCGI`), +ADD INDEX `TC`(`日期时间`, `NCGI`), +ADD INDEX `DC`(`日期`, `NCGI`); + + +CREATE TEMPORARY TABLE IF NOT EXISTS `CCE_MAX_IDX`( + SELECT + `日期`, + CGI, + MAX( `PDCCH资源利用率`) AS `PDCCH资源利用率_忙时` + FROM 4G + GROUP BY `日期`, CGI +); + +ALTER TABLE `CCE_MAX_IDX` +ADD COLUMN `日期时间` datetime, +ADD INDEX `DC`(`日期`, `CGI`, `PDCCH资源利用率_忙时`), +ADD INDEX `TC`(`日期时间`, `CGI`); + +UPDATE `CCE_MAX_IDX` INNER JOIN 4G +ON + `CCE_MAX_IDX`.`日期` = 4G.`日期` AND + `CCE_MAX_IDX`.CGI=4G.CGI AND + `CCE_MAX_IDX`.`PDCCH资源利用率_忙时`=4G.`PDCCH资源利用率` +SET + `CCE_MAX_IDX`.`日期时间`=4G.`日期时间`; + + +CREATE TEMPORARY TABLE IF NOT EXISTS `CCE_MAX` ( + SELECT 4G.* + FROM `CCE_MAX_IDX` LEFT JOIN 4G + ON `CCE_MAX_IDX`.CGI=4G.CGI AND + `CCE_MAX_IDX`.`日期时间`=4G.`日期时间` +); + +DROP TABLE IF EXISTS `CCE_MAX_IDX`; + +CREATE TEMPORARY TABLE IF NOT EXISTS `ULPRB_MAX_IDX`( + SELECT + `日期`, + CGI, + MAX( `上行PUSCH利用率`) AS `上行PUSCH利用率_忙时` + FROM 4G + GROUP BY `日期`, CGI +); + +ALTER TABLE `ULPRB_MAX_IDX` +ADD COLUMN `日期时间` datetime, +ADD INDEX `DC`(`日期`, `CGI`, `上行PUSCH利用率_忙时`), +ADD INDEX `TC`(`日期时间`, `CGI`); + +UPDATE `ULPRB_MAX_IDX` INNER JOIN 4G +ON + `ULPRB_MAX_IDX`.`日期` = 4G.`日期` AND + `ULPRB_MAX_IDX`.CGI=4G.CGI AND + `ULPRB_MAX_IDX`.`上行PUSCH利用率_忙时`=4G.`上行PUSCH利用率` +SET + `ULPRB_MAX_IDX`.`日期时间`=4G.`日期时间`; + +CREATE TABLE IF NOT EXISTS `ULPRB_MAX` ( + SELECT 4G.* + FROM `ULPRB_MAX_IDX` LEFT JOIN 4G + ON `ULPRB_MAX_IDX`.CGI=4G.CGI AND + `ULPRB_MAX_IDX`.`日期时间`=4G.`日期时间` +); + +DROP TABLE IF EXISTS `ULPRB_MAX_IDX`; + +CREATE TEMPORARY TABLE IF NOT EXISTS `DLPRB_MAX_IDX`( + SELECT + `日期`, + CGI, + MAX( `下行PDSCH利用率`) AS `下行PDSCH利用率_忙时` + FROM 4G + GROUP BY `日期`, CGI +); + +ALTER TABLE `DLPRB_MAX_IDX` +ADD COLUMN `日期时间` datetime, +ADD INDEX `DC`(`日期`, `CGI`, `下行PDSCH利用率_忙时`), +ADD INDEX `TC`(`日期时间`, `CGI`); + +UPDATE `DLPRB_MAX_IDX` INNER JOIN 4G +ON + `DLPRB_MAX_IDX`.`日期` = 4G.`日期` AND + `DLPRB_MAX_IDX`.CGI=4G.CGI AND + `DLPRB_MAX_IDX`.`下行PDSCH利用率_忙时`=4G.`下行PDSCH利用率` +SET + `DLPRB_MAX_IDX`.`日期时间`=4G.`日期时间`; + +CREATE TEMPORARY TABLE IF NOT EXISTS `DLPRB_MAX` ( + SELECT 4G.* + FROM `DLPRB_MAX_IDX` LEFT JOIN 4G + ON `DLPRB_MAX_IDX`.CGI=4G.CGI AND + `DLPRB_MAX_IDX`.`日期时间`=4G.`日期时间` +); + +DROP TABLE IF EXISTS `DLPRB_MAX_IDX`; + +ALTER TABLE `CCE_MAX` ADD INDEX `DC`(`日期`, `CGI`); +ALTER TABLE `DLPRB_MAX` ADD INDEX `DC`(`日期`, `CGI`); +ALTER TABLE `ULPRB_MAX` ADD INDEX `DC`(`日期`, `CGI`); + + + +CREATE TEMPORARY TABLE IF NOT EXISTS `4G_Date` ( + SELECT `日期`, CGI FROM 4G GROUP BY `日期`, CGI +); +ALTER TABLE `4G_Date` ADD INDEX `DC`(`日期`, CGI); + + +CREATE TEMPORARY TABLE IF NOT EXISTS `4G_MAX_IDX` ( + SELECT + 4G_Date.`日期`, + 4G_Date.CGI, + GREATEST(`ULPRB_MAX`.`上行PUSCH利用率`, DLPRB_MAX.`下行PDSCH利用率`,CCE_MAX.`PDCCH资源利用率`) AS `最大值` + FROM + 4G_Date + INNER JOIN + DLPRB_MAX + ON + 4G_Date.`日期` = DLPRB_MAX.`日期` AND + 4G_Date.CGI = DLPRB_MAX.CGI + INNER JOIN + CCE_MAX + ON + 4G_Date.`日期` = CCE_MAX.`日期` AND + 4G_Date.CGI = CCE_MAX.CGI + INNER JOIN + ULPRB_MAX + ON + 4G_Date.`日期` = ULPRB_MAX.`日期` AND + 4G_Date.CGI = ULPRB_MAX.CGI +); +DROP TABLE IF EXISTS `4G_Date`; +ALTER TABLE `4G_MAX_IDX` ADD INDEX `DC`(`日期`, CGI), ADD INDEX (`最大值`); + +CREATE TABLE IF NOT EXISTS `4G_MAX` ( + SELECT * FROM 4G LIMIT 0 +); + + +ALTER TABLE `4G_MAX` ADD INDEX (`日期`), ADD INDEX (`CGI`), ADD INDEX `DC` (`日期`,CGI), ADD INDEX `idx`(`CGI`, `基站名称`, `小区名称`); + +ALTER TABLE DLPRB_MAX ADD COLUMN `insert` INT NOT NULL DEFAULT 0; +ALTER TABLE CCE_MAX ADD COLUMN `insert` INT NOT NULL DEFAULT 0; + + + + +TRUNCATE `4G_MAX`; +INSERT INTO `4G_MAX` + SELECT ULPRB_MAX.* FROM ULPRB_MAX INNER JOIN `4G_MAX_IDX` + ON + ULPRB_MAX.`日期` = 4G_MAX_IDX.`日期` AND + ULPRB_MAX.CGI = 4G_MAX_IDX.`CGI` AND + ULPRB_MAX.`上行PUSCH利用率` = 4G_MAX_IDX.`最大值`; + +UPDATE DLPRB_MAX INNER JOIN 4G_MAX ON DLPRB_MAX.`日期` = 4G_MAX.`日期` AND DLPRB_MAX.CGI = 4G_MAX.CGI SET DLPRB_MAX.`insert` = 1; +ALTER TABLE DLPRB_MAX ADD INDEX (`insert`); +DELETE FROM DLPRB_MAX WHERE `insert` = 1; +ALTER TABLE DLPRB_MAX DROP COLUMN `insert`; +INSERT INTO `4G_MAX` + SELECT DLPRB_MAX.* FROM DLPRB_MAX INNER JOIN `4G_MAX_IDX` + ON + DLPRB_MAX.`日期` = 4G_MAX_IDX.`日期` AND + DLPRB_MAX.CGI = 4G_MAX_IDX.`CGI` AND + DLPRB_MAX.`下行PDSCH利用率` = 4G_MAX_IDX.`最大值`; + +UPDATE CCE_MAX INNER JOIN 4G_MAX ON CCE_MAX.`日期` = 4G_MAX.`日期` AND CCE_MAX.CGI = 4G_MAX.CGI SET CCE_MAX.`insert` = 1; +ALTER TABLE CCE_MAX ADD INDEX (`insert`); +DELETE FROM CCE_MAX WHERE `insert` = 1; +ALTER TABLE CCE_MAX DROP COLUMN `insert`; +INSERT INTO `4G_MAX` + SELECT CCE_MAX.* FROM CCE_MAX INNER JOIN `4G_MAX_IDX` + ON + CCE_MAX.`日期` = 4G_MAX_IDX.`日期` AND + CCE_MAX.CGI = 4G_MAX_IDX.`CGI` AND + CCE_MAX.`PDCCH资源利用率` = 4G_MAX_IDX.`最大值`; + + +DROP TABLE IF EXISTS `CCE_MAX`; +DROP TABLE IF EXISTS `ULPRB_MAX`; +DROP TABLE IF EXISTS `DLPRB_MAX`; +DROP TABLE IF EXISTS `4G_MAX_IDX`; + +CREATE TEMPORARY TABLE IF NOT EXISTS `日流量`(SELECT `日期`,CGI, SUM(`上下行总流量_GB`) AS `日流量` FROM 4G GROUP BY `日期`,CGI); +ALTER TABLE `日流量` ADD INDEX (`CGI`); +DROP TABLE IF EXISTS `4G日均流量`; +CREATE TEMPORARY TABLE IF NOT EXISTS `4G日均流量` ( + SELECT CGI, AVG(`日流量`) AS `日均流量`, COUNT(`日期`) AS `流量有效天数` FROM `日流量` WHERE `日流量` > 0 GROUP BY CGI +); + + + + +CREATE TABLE IF NOT EXISTS `4G_结果表` ( + SELECT CGI, `基站名称`, `小区名称`, + ROUND(AVG(`上下行总流量_GB`),6) AS `自忙时流量(GB)`, + ROUND(AVG(`ERAB流量`),6) AS `ERAB流量`, + ROUND(AVG(`上行流量_GB`),6) AS `上行流量(GB)`, + ROUND(AVG(`下行流量_GB`),6) AS `下行流量(GB)`, + ROUND(AVG(`用户面平均激活UE数`),6) AS `用户面平均激活UE数`, + ROUND(AVG(`RRC连接建立最大用户数`),6) AS `YY-RRC连接建立最大用户数`, + ROUND(AVG(`上行PUSCH利用率`) / 100,6) AS `上行PUSCH利用率`, + ROUND(AVG(`下行PDSCH利用率`) / 100,6) AS `下行PDSCH利用率`, + ROUND(AVG(`PDCCH资源利用率`) / 100,6) AS `PDCCH资源利用率`, + ROUND(AVG(`用户面最大激活UE数`),6) AS `用户面最大激活UE数` + FROM 4G_MAX GROUP BY CGI, `基站名称`, `小区名称` +); +DROP TABLE IF EXISTS `4G_MAX`; +ALTER TABLE `4G_结果表` ADD COLUMN `日均流量(GB)` DOUBLE AFTER `小区名称`, ADD INDEX (`CGI`); +ALTER TABLE `4G日均流量` ADD INDEX (CGI); +UPDATE `4G_结果表` INNER JOIN `4G日均流量` +ON `4G_结果表`.CGI=`4G日均流量`.CGI +SET `4G_结果表`.`日均流量(GB)` = ROUND(`4G日均流量`.`日均流量`,6); + +DROP TABLE IF EXISTS `日流量`; +DROP TABLE IF EXISTS `4G日均流量`; +DROP TABLE IF EXISTS `4G`; + + +# 5G脚本 + +CREATE TEMPORARY TABLE IF NOT EXISTS `ULPRB_MAX_IDX`( + SELECT + `日期`, + NCGI, + MAX(`上行PRB平均利用率`) AS `上行PRB平均利用率_忙时` + FROM 5G + GROUP BY `日期`, NCGI +); + +ALTER TABLE `ULPRB_MAX_IDX` +ADD COLUMN `日期时间` datetime, +ADD INDEX `DC`(`日期`, `NCGI`, `上行PRB平均利用率_忙时`), +ADD INDEX `TC`(`日期时间`, `NCGI`); + +UPDATE `ULPRB_MAX_IDX` INNER JOIN 5G +ON + `ULPRB_MAX_IDX`.`日期` = 5G.`日期` AND + `ULPRB_MAX_IDX`.NCGI=5G.NCGI AND + `ULPRB_MAX_IDX`.`上行PRB平均利用率_忙时`=5G.`上行PRB平均利用率` +SET + `ULPRB_MAX_IDX`.`日期时间`=5G.`日期时间`; + +CREATE TEMPORARY TABLE IF NOT EXISTS `ULPRB_MAX` ( + SELECT 5G.* + FROM `ULPRB_MAX_IDX` LEFT JOIN 5G + ON `ULPRB_MAX_IDX`.NCGI=5G.NCGI AND + `ULPRB_MAX_IDX`.`日期时间`=5G.`日期时间` +); + +# 下行PRB MAX表 +CREATE TEMPORARY TABLE IF NOT EXISTS `DLPRB_MAX_IDX`( + SELECT + `日期`, + NCGI, + MAX( `下行PRB平均利用率`) AS `下行PRB平均利用率_忙时` + FROM 5G + GROUP BY `日期`, NCGI +); + +ALTER TABLE `DLPRB_MAX_IDX` +ADD COLUMN `日期时间` datetime, +ADD INDEX `DC`(`日期`, `NCGI`, `下行PRB平均利用率_忙时`), +ADD INDEX `TC`(`日期时间`, `NCGI`); + +UPDATE `DLPRB_MAX_IDX` INNER JOIN 5G +ON + `DLPRB_MAX_IDX`.`日期` = 5G.`日期` AND + `DLPRB_MAX_IDX`.NCGI=5G.NCGI AND + `DLPRB_MAX_IDX`.`下行PRB平均利用率_忙时`=5G.`下行PRB平均利用率` +SET + `DLPRB_MAX_IDX`.`日期时间`=5G.`日期时间`; + +CREATE TEMPORARY TABLE IF NOT EXISTS `DLPRB_MAX` ( + SELECT 5G.* + FROM `DLPRB_MAX_IDX` LEFT JOIN 5G + ON `DLPRB_MAX_IDX`.NCGI=5G.NCGI AND + `DLPRB_MAX_IDX`.`日期时间`=5G.`日期时间` +); + +DROP TABLE IF EXISTS `DLPRB_MAX_IDX`; +ALTER TABLE `DLPRB_MAX` ADD INDEX `DC`(`日期`, `NCGI`); +ALTER TABLE `ULPRB_MAX` ADD INDEX `DC`(`日期`, `NCGI`); + +# 5G 统计MAX数据 + +DROP TABLE IF EXISTS `5G_Date`; +CREATE TEMPORARY TABLE IF NOT EXISTS `5G_Date` ( + SELECT `日期`, NCGI FROM 5G GROUP BY `日期`, NCGI +); +ALTER TABLE `5G_Date` ADD INDEX `DC`(`日期`, NCGI); + +CREATE TEMPORARY TABLE IF NOT EXISTS `5G_MAX_IDX` ( + SELECT + 5G_Date.`日期`, + 5G_Date.NCGI, + GREATEST(ULPRB_MAX.`上行PRB平均利用率`, DLPRB_MAX.`下行PRB平均利用率`) AS `最大值` + FROM + 5G_Date + INNER JOIN + DLPRB_MAX + ON + 5G_Date.`日期` = DLPRB_MAX.`日期` AND + 5G_Date.NCGI = DLPRB_MAX.NCGI + INNER JOIN + ULPRB_MAX + ON + 5G_Date.`日期` = ULPRB_MAX.`日期` AND + 5G_Date.NCGI = ULPRB_MAX.NCGI +); + +DROP TABLE IF EXISTS `5G_Date`; +ALTER TABLE `5G_MAX_IDX` ADD INDEX `DC`(`日期`, NCGI), ADD INDEX (`最大值`); + + +CREATE TABLE IF NOT EXISTS `5G_MAX` ( + SELECT * FROM 5G LIMIT 0 +); + + +ALTER TABLE `5G_MAX` ADD INDEX (`日期`), ADD INDEX (`NCGI`), ADD INDEX `DC` (`日期`,NCGI), ADD INDEX `idx`(`NCGI`, `CU小区配置名称`); + +ALTER TABLE DLPRB_MAX ADD COLUMN `insert` INT NOT NULL DEFAULT 0; +TRUNCATE `5G_MAX`; +INSERT INTO `5G_MAX` + SELECT ULPRB_MAX.* FROM ULPRB_MAX INNER JOIN `5G_MAX_IDX` + ON + ULPRB_MAX.`日期` = 5G_MAX_IDX.`日期` AND + ULPRB_MAX.NCGI = 5G_MAX_IDX.`NCGI` AND + ULPRB_MAX.`上行PRB平均利用率` = 5G_MAX_IDX.`最大值`; + +UPDATE DLPRB_MAX INNER JOIN 5G_MAX ON DLPRB_MAX.`日期` = 5G_MAX.`日期` AND DLPRB_MAX.NCGI = 5G_MAX.NCGI SET DLPRB_MAX.`insert` = 1; +ALTER TABLE DLPRB_MAX ADD INDEX (`insert`); +DELETE FROM DLPRB_MAX WHERE `insert` = 1; +ALTER TABLE DLPRB_MAX DROP COLUMN `insert`; +INSERT INTO `5G_MAX` + SELECT DLPRB_MAX.* FROM DLPRB_MAX INNER JOIN `5G_MAX_IDX` + ON + DLPRB_MAX.`日期` = 5G_MAX_IDX.`日期` AND + DLPRB_MAX.NCGI = 5G_MAX_IDX.`NCGI` AND + DLPRB_MAX.`下行PRB平均利用率` = 5G_MAX_IDX.`最大值`; + +DROP TABLE IF EXISTS `ULPRB_MAX`; +DROP TABLE IF EXISTS `DLPRB_MAX`; +DROP TABLE IF EXISTS `5G_MAX_IDX`; + +CREATE TEMPORARY TABLE IF NOT EXISTS `日流量`(SELECT `日期`,NCGI, SUM(`5G上下行总流量_GB`) AS `日流量` FROM 5G GROUP BY `日期`,NCGI); +ALTER TABLE `日流量` ADD INDEX (`NCGI`); +CREATE TEMPORARY TABLE IF NOT EXISTS `5G日均流量` ( + SELECT NCGI, AVG(`日流量`) AS `日均流量`, COUNT(`日期`) AS `流量有效天数` FROM `日流量` WHERE `日流量` > 0 GROUP BY NCGI +); + +CREATE TABLE IF NOT EXISTS `5G_结果表` ( + SELECT NCGI, `CU小区配置名称`, + ROUND(AVG(`5G上下行总流量_GB`),6) AS `5G上下行总流量(上行PDCP PDU数据量+下行PDCP成功发送数据量)(GB)`, + ROUND(AVG(`5G上行流量_GB`),6) AS `5G上行流量(上行PDCP PDU数据量)(GB)`, + ROUND(AVG(`5G下行流量_GB`),6) AS `5G下行流量(下行PDCP成功发送数据量)(GB)`, + ROUND(AVG(`小区平均激活UE数`),6) AS `小区平均激活UE数`, + ROUND(AVG(`RRC连接最大连接用户数`),6) AS `RRC连接最大连接用户数`, + ROUND(AVG(`RRC连接平均连接用户数`),6) AS `RRC连接平均连接用户数`, + ROUND(AVG(`小区最大激活UE数`),6) AS `小区最大激活UE数`, + ROUND(AVG(`上行PRB平均利用率`) / 100,6) AS `上行PRB平均利用率`, + ROUND(AVG(`下行PRB平均利用率`) / 100,6) AS `下行PRB平均利用率`, + ROUND(AVG(`PDCCH信道动态CCE占用率`) / 100,6) AS `PDCCH信道CCE占用率(动态)(%)`, + ROUND(AVG(`小区下行RLC_SDU字节数_MByte`),6) AS `小区下行RLC SDU字节数(MByte)`, + ROUND(AVG(`小区上行RLC_SDU字节数_MByte`),6) AS `小区上行RLC SDU字节数(MByte)`, + ROUND(AVG(`平均单Flow流量_MB`),6) AS `单Flow流量(MB)` + FROM 5G_MAX GROUP BY NCGI, `CU小区配置名称` +); + +ALTER TABLE `5G_结果表` ADD COLUMN `日均流量(GB)` DOUBLE AFTER `CU小区配置名称`, ADD INDEX (`NCGI`); +ALTER TABLE `5G日均流量` ADD INDEX (NCGI); +UPDATE `5G_结果表` INNER JOIN `5G日均流量` +ON `5G_结果表`.NCGI=`5G日均流量`.NCGI +SET `5G_结果表`.`日均流量(GB)` = ROUND(`5G日均流量`.`日均流量`,6); + +DROP TABLE IF EXISTS `日流量`; +DROP TABLE IF EXISTS `5G日均流量`; +DROP TABLE IF EXISTS `4G`; +DROP TABLE IF EXISTS `5G`; \ No newline at end of file diff --git a/dist/build.bat b/dist/build.bat new file mode 100644 index 0000000..341b8f9 --- /dev/null +++ b/dist/build.bat @@ -0,0 +1,220 @@ +@echo off +chcp 65001 >nul +REM CapacityReport 镜像构建和导出脚本 (Windows) +REM 在有外网的 Windows 机器上运行此脚本,生成离线部署包 +@REM set http_proxy=http://127.0.0.1:7897 +@REM set https_proxy=http://127.0.0.1:7897 +setlocal enabledelayedexpansion + +set SCRIPT_DIR=%~dp0 +set "PROJECT_ROOT=%~dp0..\" +set DIST_DIR=%~dp0 +REM 规范化路径(去掉末尾的反斜杠,如果有的话) +if "%PROJECT_ROOT:~-1%"=="\" set "PROJECT_ROOT=%PROJECT_ROOT:~0,-1%" + +echo ========================================== +echo CapacityReport 离线部署包构建脚本 +echo ========================================== +echo. + +REM 清理旧的构建文件 +echo 清理旧的构建文件... +if exist "%DIST_DIR%images\capacity-images.tar" ( + del /f /q "%DIST_DIR%images\capacity-images.tar" + echo - 已删除旧镜像文件 +) +if exist "%DIST_DIR%Configure.json" ( + del /f /q "%DIST_DIR%Configure.json" + echo - 已删除旧 Configure.json +) +if exist "%DIST_DIR%ReportScript.sql" ( + del /f /q "%DIST_DIR%ReportScript.sql" + echo - 已删除旧 ReportScript.sql +) +if exist "%DIST_DIR%mysql\conf.d\custom.cnf" ( + del /f /q "%DIST_DIR%mysql\conf.d\custom.cnf" + echo - 已删除旧 mysql\conf.d\custom.cnf +) +if exist "%DIST_DIR%mysql\init\01-init-db.sql" ( + del /f /q "%DIST_DIR%mysql\init\01-init-db.sql" + echo - 已删除旧 mysql\init\01-init-db.sql +) +echo 清理完成 +echo. + +REM 检查 Docker 是否安装 +where docker >nul 2>&1 +if %errorlevel% neq 0 ( + echo 错误: 未检测到 Docker,请先安装 Docker Desktop + exit /b 1 +) + +REM 检查 Docker Compose 是否安装 +docker compose version >nul 2>&1 +if %errorlevel% neq 0 ( + docker-compose version >nul 2>&1 + if %errorlevel% neq 0 ( + echo 错误: 未检测到 Docker Compose,请先安装 Docker Compose + exit /b 1 + ) +) + +echo 步骤 1: 拉取基础镜像... +REM 检查 Dockerfile 中的 Python 版本 +set PYTHON_TAG= +for /f "tokens=2 delims=:" %%a in ('findstr /R "^FROM python:" "!PROJECT_ROOT!\Dockerfile"') do ( + set "PYTHON_TAG=%%a" + goto :found_python +) +:found_python +if defined PYTHON_TAG ( + REM 去掉可能的空格 + for /f "tokens=1 delims= " %%b in ("!PYTHON_TAG!") do ( + set "PYTHON_TAG=%%b" + ) + echo 从 Dockerfile 检测到 Python 镜像标签: !PYTHON_TAG! +) else ( + echo 错误: 无法从 Dockerfile 中找到 Python 镜像标签 + echo 请确保 Dockerfile 中包含 "FROM python:xxx" 行 + exit /b 1 +) + +REM 检查 Python 镜像是否已存在 +docker images --format "{{.Repository}}:{{.Tag}}" | findstr /C:"python:!PYTHON_TAG!" >nul +if %errorlevel% equ 0 ( + echo Python 镜像 python:!PYTHON_TAG! 已存在,跳过拉取 +) else ( + echo 正在拉取 python:!PYTHON_TAG!... + docker pull python:!PYTHON_TAG! + if %errorlevel% neq 0 ( + echo 错误: 拉取 Python 镜像失败 + echo 提示: 如果遇到网络问题,请检查网络连接或稍后重试 + echo 镜像标签: python:!PYTHON_TAG! + exit /b 1 + ) +) + +REM 检查 MySQL 镜像是否已存在 +docker images --format "{{.Repository}}:{{.Tag}}" | findstr /C:"mysql:8.0.44" >nul +if %errorlevel% equ 0 ( + echo MySQL 镜像 mysql:8.0.44 已存在,跳过拉取 +) else ( + echo 正在拉取 mysql:8.0.44... + docker pull mysql:8.0.44 + if %errorlevel% neq 0 ( + echo 错误: 拉取 MySQL 镜像失败 + exit /b 1 + ) +) + +echo. +echo 步骤 2: 构建应用镜像... +cd /d "!PROJECT_ROOT!" +docker build -t capacity-report-app:latest -f Dockerfile . +if %errorlevel% neq 0 ( + echo 错误: 构建应用镜像失败 + exit /b 1 +) + +echo. +echo 步骤 3: 标记 MySQL 镜像... +docker tag mysql:8.0.44 capacity-mysql:8.0.44 +if %errorlevel% neq 0 ( + echo 错误: 标记 MySQL 镜像失败 + exit /b 1 +) + +echo. +echo 步骤 4: 导出镜像到 dist 目录... +if not exist "%DIST_DIR%images" mkdir "%DIST_DIR%images" +echo 正在导出镜像到单个 tar 文件: +echo - capacity-report-app:latest (应用镜像) +echo - capacity-mysql:8.0.44 (MySQL 镜像) +docker save capacity-report-app:latest capacity-mysql:8.0.44 -o "%DIST_DIR%images\capacity-images.tar" +if %errorlevel% neq 0 ( + echo 错误: 导出镜像失败 + exit /b 1 +) +echo 镜像导出完成: %DIST_DIR%images\capacity-images.tar +echo 注意: 此文件包含应用镜像和 MySQL 镜像,部署时会自动加载所有镜像 + +echo. +echo 步骤 5: 复制必要文件到 dist 目录... +REM 创建必要的目录结构 +if not exist "%DIST_DIR%mysql\conf.d" mkdir "%DIST_DIR%mysql\conf.d" +if not exist "%DIST_DIR%mysql\init" mkdir "%DIST_DIR%mysql\init" + +REM 复制配置文件 +echo 正在复制配置文件... +if exist "!PROJECT_ROOT!\Configure.json" ( + copy "!PROJECT_ROOT!\Configure.json" "!DIST_DIR!" >nul + echo - Configure.json 已复制 + REM 更新为 Docker 环境配置 + echo 正在更新 Configure.json 为 Docker 环境配置... + powershell -NoProfile -Command "$json = Get-Content \"%DIST_DIR%Configure.json\" -Raw -Encoding UTF8 | ConvertFrom-Json; $json.MySQL_DBInfo.host = 'capacity-mysql'; $json.MySQL_DBInfo.port = 3306; $json.MySQL_DBInfo.user = 'root'; $json.MySQL_DBInfo.passwd = 'gmcc123'; $json.MySQL_DBInfo.dbname = 'CapacityReport'; $json | ConvertTo-Json -Depth 10 | Set-Content \"%DIST_DIR%Configure.json\" -Encoding UTF8" + if %errorlevel% equ 0 ( + echo - 已更新数据库配置: host=capacity-mysql, port=3306, user=root, passwd=gmcc123 + ) else ( + echo 警告: 无法自动更新 Configure.json,请手动修改数据库配置 + ) +) else ( + echo 警告: Configure.json 不存在: !PROJECT_ROOT!\Configure.json +) + +if exist "!PROJECT_ROOT!\ReportScript.sql" ( + copy "!PROJECT_ROOT!\ReportScript.sql" "!DIST_DIR!" >nul + echo - ReportScript.sql 已复制 +) else ( + echo 警告: ReportScript.sql 不存在: !PROJECT_ROOT!\ReportScript.sql +) + +if exist "!PROJECT_ROOT!\mysql\conf.d\custom.cnf" ( + copy "!PROJECT_ROOT!\mysql\conf.d\custom.cnf" "!DIST_DIR!mysql\conf.d\" >nul + echo - mysql\conf.d\custom.cnf 已复制 +) else ( + echo 警告: mysql\conf.d\custom.cnf 不存在: !PROJECT_ROOT!\mysql\conf.d\custom.cnf +) + +if exist "!PROJECT_ROOT!\mysql\init\01-init-db.sql" ( + copy "!PROJECT_ROOT!\mysql\init\01-init-db.sql" "!DIST_DIR!mysql\init\" >nul + echo - mysql\init\01-init-db.sql 已复制 +) else ( + echo 警告: mysql\init\01-init-db.sql 不存在: !PROJECT_ROOT!\mysql\init\01-init-db.sql +) + +echo. +echo 步骤 6: 计算文件大小... +for %%A in ("%DIST_DIR%images\capacity-images.tar") do ( + set SIZE=%%~zA + set /a SIZE_MB=!SIZE!/1024/1024 + echo 镜像文件大小: !SIZE_MB! MB +) + +echo. +echo 步骤 7: 清理临时目录... +if exist "%DIST_DIR%cache" ( + rd /s /q "%DIST_DIR%cache" 2>nul + echo - 已删除 cache 目录 +) +if exist "%DIST_DIR%logs" ( + rd /s /q "%DIST_DIR%logs" 2>nul + echo - 已删除 logs 目录 +) + +echo. +echo ========================================== +echo 构建完成! +echo ========================================== +echo. +echo 离线部署包位置: %DIST_DIR% +echo. +echo 部署步骤: +echo 1. 将整个 dist 目录传输到目标 Linux 机器 +echo 2. 在目标机器上执行: sh dist/deploy.sh +echo. +echo 端口映射: +echo - 应用端口: 19081 (原 9081) +echo - MySQL 端口: 13306 (原 3306) +echo. + +endlocal diff --git a/dist/build.py b/dist/build.py new file mode 100644 index 0000000..918cc3b --- /dev/null +++ b/dist/build.py @@ -0,0 +1,313 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +CapacityReport 镜像构建和导出脚本 (Windows) +在有外网的 Windows 机器上运行此脚本,生成离线部署包 +""" + +import os +import sys +import json +import shutil +import subprocess +import re +from pathlib import Path + +# 脚本所在目录 +SCRIPT_DIR = Path(__file__).parent.absolute() +PROJECT_ROOT = SCRIPT_DIR.parent +DIST_DIR = SCRIPT_DIR + +# 颜色输出(Windows 支持) +import platform +if platform.system() == 'Windows': + try: + import ctypes + kernel32 = ctypes.windll.kernel32 + kernel32.SetConsoleMode(kernel32.GetStdHandle(-11), 7) + except: + pass + +class Colors: + GREEN = '\033[92m' + YELLOW = '\033[93m' + RED = '\033[91m' + RESET = '\033[0m' + +def print_step(msg): + print(f"\n{Colors.GREEN}{msg}{Colors.RESET}") + +def print_info(msg): + print(f" {msg}") + +def print_warning(msg): + print(f"{Colors.YELLOW}警告: {msg}{Colors.RESET}") + +def print_error(msg): + print(f"{Colors.RED}错误: {msg}{Colors.RESET}") + +def run_cmd(cmd, check=True, capture_output=False): + """执行命令""" + if isinstance(cmd, str): + cmd = cmd.split() + try: + result = subprocess.run( + cmd, + check=check, + capture_output=capture_output, + text=True + ) + return result + except subprocess.CalledProcessError as e: + if check: + print_error(f"命令执行失败: {' '.join(cmd)}") + sys.exit(1) + return None + except FileNotFoundError: + print_error(f"命令未找到: {cmd[0]}") + sys.exit(1) + +def check_command(cmd): + """检查命令是否存在""" + try: + if cmd == "docker": + subprocess.run([cmd, "--version"], capture_output=True, check=True) + elif cmd == "docker-compose": + subprocess.run([cmd, "version"], capture_output=True, check=True) + return True + except (subprocess.CalledProcessError, FileNotFoundError): + return False + +def get_python_tag_from_dockerfile(): + """从 Dockerfile 中提取 Python 镜像标签""" + dockerfile_path = PROJECT_ROOT / "Dockerfile" + if not dockerfile_path.exists(): + print_error(f"Dockerfile 不存在: {dockerfile_path}") + sys.exit(1) + + with open(dockerfile_path, 'r', encoding='utf-8') as f: + for line in f: + match = re.match(r'^FROM\s+python:(.+?)(?:\s|$)', line, re.IGNORECASE) + if match: + tag = match.group(1).strip() + return tag + + print_error("无法从 Dockerfile 中找到 Python 镜像标签") + print_error("请确保 Dockerfile 中包含 'FROM python:xxx' 行") + sys.exit(1) + +def image_exists(image_name): + """检查镜像是否已存在""" + try: + result = subprocess.run( + ["docker", "images", "--format", "{{.Repository}}:{{.Tag}}"], + capture_output=True, + text=True, + check=True + ) + return image_name in result.stdout + except: + return False + +def format_size(size_bytes): + """格式化文件大小""" + for unit in ['B', 'KB', 'MB', 'GB']: + if size_bytes < 1024.0: + return f"{size_bytes:.1f} {unit}" + size_bytes /= 1024.0 + return f"{size_bytes:.1f} TB" + +def main(): + print("=" * 50) + print("CapacityReport 离线部署包构建脚本") + print("=" * 50) + print() + + # 清理旧的构建文件 + print_step("清理旧的构建文件...") + files_to_clean = [ + DIST_DIR / "images" / "capacity-images.tar", + DIST_DIR / "Configure.json", + DIST_DIR / "ReportScript.sql", + DIST_DIR / "mysql" / "conf.d" / "custom.cnf", + DIST_DIR / "mysql" / "init" / "01-init-db.sql", + ] + + for file_path in files_to_clean: + if file_path.exists(): + if file_path.is_file(): + file_path.unlink() + print_info(f"已删除旧 {file_path.name}") + elif file_path.is_dir(): + shutil.rmtree(file_path) + print_info(f"已删除旧 {file_path.name}") + + print_info("清理完成") + + # 检查 Docker + print_step("检查 Docker 环境...") + if not check_command("docker"): + print_error("未检测到 Docker,请先安装 Docker Desktop") + sys.exit(1) + print_info("Docker: OK") + + # 检查 Docker Compose + docker_compose_cmd = None + try: + subprocess.run(["docker", "compose", "version"], capture_output=True, check=True) + docker_compose_cmd = "docker compose" + print_info("Docker Compose: OK (docker compose)") + except: + if check_command("docker-compose"): + docker_compose_cmd = "docker-compose" + print_info("Docker Compose: OK (docker-compose)") + else: + print_error("未检测到 Docker Compose,请先安装 Docker Compose") + sys.exit(1) + + # 步骤 1: 拉取基础镜像 + print_step("步骤 1: 拉取基础镜像...") + + # 获取 Python 版本 + python_tag = get_python_tag_from_dockerfile() + python_image = f"python:{python_tag}" + print_info(f"从 Dockerfile 检测到 Python 镜像标签: {python_tag}") + + # 检查并拉取 Python 镜像 + if image_exists(python_image): + print_info(f"Python 镜像 {python_image} 已存在,跳过拉取") + else: + print_info(f"正在拉取 {python_image}...") + run_cmd(["docker", "pull", python_image]) + print_info(f"Python 镜像拉取完成") + + # 检查并拉取 MySQL 镜像 + mysql_image = "mysql:8.0.44" + if image_exists(mysql_image): + print_info(f"MySQL 镜像 {mysql_image} 已存在,跳过拉取") + else: + print_info(f"正在拉取 {mysql_image}...") + run_cmd(["docker", "pull", mysql_image]) + print_info(f"MySQL 镜像拉取完成") + + # 步骤 2: 构建应用镜像 + print_step("步骤 2: 构建应用镜像...") + os.chdir(PROJECT_ROOT) + run_cmd(["docker", "build", "-t", "capacity-report-app:latest", "-f", "Dockerfile", "."]) + print_info("应用镜像构建完成") + + # 步骤 3: 标记 MySQL 镜像 + print_step("步骤 3: 标记 MySQL 镜像...") + run_cmd(["docker", "tag", mysql_image, "capacity-mysql:8.0.44"]) + print_info("MySQL 镜像标记完成") + + # 步骤 4: 导出镜像 + print_step("步骤 4: 导出镜像到 dist 目录...") + images_dir = DIST_DIR / "images" + images_dir.mkdir(exist_ok=True) + + tar_file = images_dir / "capacity-images.tar" + print_info("正在导出镜像到单个 tar 文件:") + print_info(" - capacity-report-app:latest (应用镜像)") + print_info(" - capacity-mysql:8.0.44 (MySQL 镜像)") + + run_cmd([ + "docker", "save", + "capacity-report-app:latest", + "capacity-mysql:8.0.44", + "-o", str(tar_file) + ]) + + print_info(f"镜像导出完成: {tar_file}") + print_info("注意: 此文件包含应用镜像和 MySQL 镜像,部署时会自动加载所有镜像") + + # 步骤 5: 复制必要文件 + print_step("步骤 5: 复制必要文件到 dist 目录...") + + # 创建必要的目录结构 + (DIST_DIR / "mysql" / "conf.d").mkdir(parents=True, exist_ok=True) + (DIST_DIR / "mysql" / "init").mkdir(parents=True, exist_ok=True) + + # 复制配置文件 + print_info("正在复制配置文件...") + + files_to_copy = [ + (PROJECT_ROOT / "Configure.json", DIST_DIR / "Configure.json"), + (PROJECT_ROOT / "ReportScript.sql", DIST_DIR / "ReportScript.sql"), + (PROJECT_ROOT / "mysql" / "conf.d" / "custom.cnf", DIST_DIR / "mysql" / "conf.d" / "custom.cnf"), + (PROJECT_ROOT / "mysql" / "init" / "01-init-db.sql", DIST_DIR / "mysql" / "init" / "01-init-db.sql"), + ] + + for src, dst in files_to_copy: + if src.exists(): + shutil.copy2(src, dst) + print_info(f"{src.name} 已复制") + else: + print_warning(f"{src.name} 不存在: {src}") + + # 更新 Configure.json 为 Docker 环境配置 + config_file = DIST_DIR / "Configure.json" + if config_file.exists(): + print_info("正在更新 Configure.json 为 Docker 环境配置...") + try: + with open(config_file, 'r', encoding='utf-8') as f: + config = json.load(f) + + config['MySQL_DBInfo'] = { + 'host': 'capacity-mysql', + 'port': 3306, + 'user': 'root', + 'passwd': 'gmcc123', + 'dbname': 'CapacityReport' + } + + with open(config_file, 'w', encoding='utf-8') as f: + json.dump(config, f, ensure_ascii=False, indent=2) + + print_info("已更新数据库配置: host=capacity-mysql, port=3306, user=root, passwd=gmcc123") + except Exception as e: + print_warning(f"无法自动更新 Configure.json: {e}") + print_warning("请手动修改数据库配置") + + # 步骤 6: 计算文件大小 + print_step("步骤 6: 计算文件大小...") + if tar_file.exists(): + size = tar_file.stat().st_size + print_info(f"镜像文件大小: {format_size(size)}") + + # 步骤 7: 清理临时目录 + print_step("步骤 7: 清理临时目录...") + temp_dirs = [DIST_DIR / "cache", DIST_DIR / "logs"] + for temp_dir in temp_dirs: + if temp_dir.exists(): + shutil.rmtree(temp_dir, ignore_errors=True) + print_info(f"已删除 {temp_dir.name} 目录") + + # 完成 + print() + print("=" * 50) + print("构建完成!") + print("=" * 50) + print() + print(f"离线部署包位置: {DIST_DIR}") + print() + print("部署步骤:") + print("1. 将整个 dist 目录传输到目标 Linux 机器") + print("2. 在目标机器上执行: sh dist/deploy.sh") + print() + print("端口映射:") + print(" - 应用端口: 19081 (原 9081)") + print(" - MySQL 端口: 13306 (原 3306)") + print() + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print("\n\n构建已取消") + sys.exit(1) + except Exception as e: + print_error(f"构建失败: {e}") + import traceback + traceback.print_exc() + sys.exit(1) diff --git a/dist/deploy.sh b/dist/deploy.sh new file mode 100644 index 0000000..ec33a84 --- /dev/null +++ b/dist/deploy.sh @@ -0,0 +1,240 @@ +#!/bin/bash +# CapacityReport Deployment Script +# Run this script on offline machine to import images and start services + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +echo "==========================================" +echo "CapacityReport Deployment" +echo "==========================================" +echo "" + +# Set permissions for dist directory and make scripts executable +echo "Setting permissions..." +chmod -R 0777 "$SCRIPT_DIR" 2>/dev/null || true +find "$SCRIPT_DIR" -type f \( -name "*.sh" -o -name "*.py" \) -exec chmod +x {} \; 2>/dev/null || true +echo " Permissions set" + +echo "" + +# Check Docker +if ! command -v docker &> /dev/null; then + echo "Error: Docker not found, please install Docker" + exit 1 +fi + +# Check Docker Compose +DOCKER_COMPOSE_CMD="" +if docker compose version &> /dev/null; then + DOCKER_COMPOSE_CMD="docker compose" +elif command -v docker-compose &> /dev/null; then + DOCKER_COMPOSE_CMD="docker-compose" +else + echo "Error: Docker Compose not found, please install Docker Compose" + exit 1 +fi + +# Check image file +if [ ! -f "images/capacity-images.tar" ]; then + echo "Error: Image file not found: images/capacity-images.tar" + exit 1 +fi + +echo "Step 1: Checking ports..." +# 检查端口是否被占用 +check_port() { + local port=$1 + local name=$2 + local in_use=false + + # 尝试多种方法检查端口 + if command -v lsof &> /dev/null; then + if lsof -Pi :$port -sTCP:LISTEN -t >/dev/null 2>&1; then + in_use=true + fi + elif command -v netstat &> /dev/null; then + if netstat -tuln 2>/dev/null | grep -q ":$port "; then + in_use=true + fi + elif command -v ss &> /dev/null; then + if ss -tuln 2>/dev/null | grep -q ":$port "; then + in_use=true + fi + fi + + if [ "$in_use" = true ]; then + echo "Warning: Port $port ($name) is in use" + read -p "Continue? (y/n) " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + exit 1 + fi + fi +} + +check_port 19081 "App" +check_port 13306 "MySQL" + +echo "" +echo "Step 2: Loading images..." +docker load -i images/capacity-images.tar >/dev/null 2>&1 + +echo "" +echo "Step 3: Verifying images..." +if docker images | grep -q "capacity-report-app.*latest"; then + echo " App image: OK" +else + echo " App image: FAILED" + exit 1 +fi + +if docker images | grep -q "capacity-mysql.*8.0.44"; then + echo " MySQL image: OK" +else + echo " MySQL image: FAILED" + exit 1 +fi + +echo "" +echo "Step 4: Creating directories..." +mkdir -p cache logs +chmod 777 cache logs 2>/dev/null || true + +echo "" +echo "Step 5: Checking config..." +if [ ! -f "Configure.json" ]; then + echo " Creating default Configure.json..." + cat > Configure.json << 'EOF' +{ + "MySQL_DBInfo": { + "host": "capacity-mysql", + "port": 3306, + "user": "root", + "passwd": "gmcc123", + "dbname": "CapacityReport" + }, + "ExtractField": [] +} +EOF +else + # Update config for Docker environment + if command -v python3 &> /dev/null || command -v python &> /dev/null; then + (command -v python3 >/dev/null && python3 || python) << 'PYEOF' +import json +import sys + +try: + with open('Configure.json', 'r', encoding='utf-8') as f: + config = json.load(f) + + db_info = config.get('MySQL_DBInfo', {}) + needs_update = False + + if db_info.get('host') != 'capacity-mysql': + db_info['host'] = 'capacity-mysql' + needs_update = True + + if db_info.get('port') != 3306: + db_info['port'] = 3306 + needs_update = True + + if db_info.get('user') != 'root': + db_info['user'] = 'root' + needs_update = True + + if db_info.get('passwd') != 'gmcc123': + db_info['passwd'] = 'gmcc123' + needs_update = True + + if db_info.get('dbname') != 'CapacityReport': + db_info['dbname'] = 'CapacityReport' + needs_update = True + + if needs_update: + with open('Configure.json', 'w', encoding='utf-8') as f: + json.dump(config, f, ensure_ascii=False, indent=2) + print(" Updated Configure.json") +except: + pass +PYEOF + fi +fi + +if [ ! -f "ReportScript.sql" ]; then + touch ReportScript.sql +fi + +echo "" +echo "Step 6: Starting services..." +$DOCKER_COMPOSE_CMD down 2>/dev/null || true +$DOCKER_COMPOSE_CMD up -d >/dev/null 2>&1 + +echo "" +echo "Step 7: Waiting for services..." +for i in {1..30}; do + if docker exec capacity-mysql mysqladmin ping -h localhost -u root -pgmcc123 --silent 2>/dev/null; then + echo " MySQL: Ready" + break + fi + if [ $i -eq 30 ]; then + echo " MySQL: Timeout" + exit 1 + fi + sleep 2 +done + +if command -v curl &> /dev/null; then + for i in {1..30}; do + if curl -f http://localhost:19081/health >/dev/null 2>&1; then + echo " App: Ready" + break + fi + if [ $i -eq 30 ]; then + echo " App: Timeout" + echo " Check logs: $DOCKER_COMPOSE_CMD logs capacity-app" + exit 1 + fi + sleep 2 + done +else + for i in {1..30}; do + if docker ps --filter "name=capacity-report-app" --filter "status=running" --format "{{.Names}}" | grep -q "capacity-report-app"; then + if docker inspect capacity-report-app --format='{{.State.Health.Status}}' 2>/dev/null | grep -q "healthy"; then + echo " App: Ready" + break + elif [ $i -gt 10 ]; then + echo " App: Running" + break + fi + fi + if [ $i -eq 30 ]; then + echo " App: Timeout" + exit 1 + fi + sleep 2 + done +fi + +echo "" +echo "==========================================" +echo "Deployment completed!" +echo "==========================================" +echo "" +echo "Access: http://localhost:19081" +echo "" +echo "Commands:" +echo " Logs: $DOCKER_COMPOSE_CMD logs -f" +echo " Stop: $DOCKER_COMPOSE_CMD down" +echo " Restart: $DOCKER_COMPOSE_CMD restart" +echo " Status: $DOCKER_COMPOSE_CMD ps" +echo "" +echo "Database:" +echo " Host: localhost" +echo " Port: 13306" +echo " User: root" +echo " Password: gmcc123" +echo " Database: CapacityReport" +echo "" diff --git a/dist/docker-compose.yml b/dist/docker-compose.yml new file mode 100644 index 0000000..3421433 --- /dev/null +++ b/dist/docker-compose.yml @@ -0,0 +1,79 @@ +# CapacityReport Docker 编排文件 +# 用于内网离线部署 +# +# 使用方法: +# sh deploy.sh # 一键导入镜像并启动服务 +# docker-compose up -d # 启动所有服务 +# docker-compose down # 停止所有服务 + +version: '3.8' + +services: + # 容量报表应用 + capacity-app: + image: capacity-report-app:latest + container_name: capacity-report-app + restart: unless-stopped + ports: + - "19081:9081" + volumes: + # 持久化数据 + - ./cache:/app/cache + - ./Configure.json:/app/Configure.json + - ./ReportScript.sql:/app/ReportScript.sql + # 日志目录 + - ./logs:/var/log/supervisor + environment: + - TZ=Asia/Shanghai + - PYTHONUNBUFFERED=1 + - SUPERVISOR_ENABLED=1 + depends_on: + capacity-mysql: + condition: service_healthy + networks: + - capacity-network + + # MySQL 数据库 + capacity-mysql: + image: capacity-mysql:8.0.44 + container_name: capacity-mysql + restart: unless-stopped + ports: + - "13306:3306" + volumes: + # 数据持久化 + - capacity-mysql-data:/var/lib/mysql + # 自定义配置(启用 LOAD DATA INFILE) + - ./mysql/conf.d:/etc/mysql/conf.d:ro + # 初始化脚本(仅在首次启动时执行) + - ./mysql/init:/docker-entrypoint-initdb.d:ro + environment: + - TZ=Asia/Shanghai + - MYSQL_ROOT_PASSWORD=gmcc123 + - MYSQL_DATABASE=CapacityReport + command: + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci + - --local-infile=ON + - --secure-file-priv= + - --max_allowed_packet=256M + - --innodb_buffer_pool_size=512M + - --innodb_log_file_size=128M + - --innodb_flush_log_at_trx_commit=2 + - --sync_binlog=0 + networks: + - capacity-network + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-pgmcc123"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + +networks: + capacity-network: + driver: bridge + +volumes: + capacity-mysql-data: + driver: local diff --git a/dist/mysql/conf.d/custom.cnf b/dist/mysql/conf.d/custom.cnf new file mode 100644 index 0000000..80ea686 --- /dev/null +++ b/dist/mysql/conf.d/custom.cnf @@ -0,0 +1,35 @@ +# MySQL 自定义配置 - CapacityReport +# 启用 LOAD DATA LOCAL INFILE 支持 + +[mysqld] +# 字符集 +character-set-server = utf8mb4 +collation-server = utf8mb4_unicode_ci + +# 启用 LOAD DATA LOCAL INFILE +local_infile = ON +secure_file_priv = "" + +# 性能优化 +max_allowed_packet = 256M +innodb_buffer_pool_size = 512M +innodb_log_file_size = 128M +innodb_flush_log_at_trx_commit = 2 +sync_binlog = 0 + +# 连接设置 +max_connections = 200 +wait_timeout = 28800 +interactive_timeout = 28800 + +# 日志(可选,生产环境建议注释掉) +# general_log = 1 +# general_log_file = /var/lib/mysql/general.log + +[mysql] +# 客户端设置 +local_infile = ON +default-character-set = utf8mb4 + +[client] +default-character-set = utf8mb4 diff --git a/dist/mysql/init/01-init-db.sql b/dist/mysql/init/01-init-db.sql new file mode 100644 index 0000000..afef3b5 --- /dev/null +++ b/dist/mysql/init/01-init-db.sql @@ -0,0 +1,13 @@ +-- MySQL 初始化脚本 - 确保数据库以正确的字符集创建 +-- 此脚本仅在首次初始化时执行(数据目录为空时) + +-- 如果数据库不存在,则创建(使用 utf8mb4 字符集) +CREATE DATABASE IF NOT EXISTS CapacityReport + CHARACTER SET utf8mb4 + COLLATE utf8mb4_unicode_ci; + +-- 切换到数据库 +USE CapacityReport; + +-- 输出确认信息 +SELECT 'CapacityReport database initialized with utf8mb4 charset' AS status; diff --git a/docker-compose.yml b/docker-compose.yml index 199d87f..99225cd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,6 +36,7 @@ services: environment: - TZ=Asia/Shanghai - PYTHONUNBUFFERED=1 + - SUPERVISOR_ENABLED=1 depends_on: mysql: condition: service_healthy @@ -50,7 +51,7 @@ services: # 注意:MySQL 9.5 目前没有官方 Docker 镜像,建议使用 8.0 系列 # LOAD DATA INFILE 支持:MySQL 8.0 完全支持,已通过配置启用 mysql: - image: mysql:8.0 + image: mysql:8.0.44 container_name: capacity-mysql restart: unless-stopped ports: diff --git a/static/css/style.css b/static/css/style.css index 94db699..544ae22 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -456,7 +456,7 @@ body { display: inline-flex; align-items: center; justify-content: center; - width: 32px; + padding: 0 12px; height: 32px; border-radius: var(--td-radius-default); font-size: 14px; @@ -465,6 +465,8 @@ body { color: #e34d59; cursor: pointer; transition: all var(--td-transition); + white-space: nowrap; + flex-shrink: 0; } .restart-btn:hover { @@ -481,9 +483,10 @@ body { transform: scale(0.95); } -.restart-btn span { - font-size: 14px; - line-height: 1; +.restart-btn svg { + width: 16px; + height: 16px; + display: block; } .page-body { @@ -655,6 +658,7 @@ body { border: 1px solid var(--td-border-color-light); border-radius: var(--td-radius-default); background: var(--td-bg-color-container); + margin-top: 20px; } .file-item { @@ -1178,7 +1182,7 @@ body { .row-count { font-size: 12px; - color: var(--td-text-color-placeholder); + color: var(--td-text-color-secondary); background: var(--td-bg-color-component); padding: 2px 8px; border-radius: 10px; @@ -1190,26 +1194,6 @@ body { gap: 8px; } -.search-input { - padding: 6px 12px; - background: var(--td-bg-color-container); - border: 1px solid var(--td-border-color); - border-radius: var(--td-radius-default); - color: var(--td-text-color-primary); - font-size: 13px; - width: 180px; - transition: all var(--td-transition); - outline: none; -} - -.search-input::placeholder { - color: var(--td-text-color-placeholder); -} - -.search-input:focus { - border-color: var(--td-brand-color); - box-shadow: 0 0 0 2px var(--td-brand-color-focus); -} .data-table-container { flex: 1; @@ -1251,19 +1235,128 @@ body { .pagination { display: flex; - justify-content: center; + justify-content: space-between; align-items: center; - gap: 12px; + gap: 16px; padding: 12px 16px; border-top: 1px solid var(--td-border-color-light); background: var(--td-bg-color-secondarycontainer); } -#pageInfo { +.pagination-left { + display: flex; + align-items: center; + gap: 12px; +} + +.pagination-total { font-size: 13px; color: var(--td-text-color-secondary); } +.pagination-size-select { + padding: 4px 8px; + background: var(--td-bg-color-container); + border: 1px solid var(--td-border-color); + border-radius: var(--td-radius-default); + color: var(--td-text-color-primary); + font-size: 13px; + cursor: pointer; + outline: none; + transition: all var(--td-transition); +} + +.pagination-size-select:hover { + border-color: var(--td-brand-color); +} + +.pagination-size-select:focus { + border-color: var(--td-brand-color); + box-shadow: 0 0 0 2px var(--td-brand-color-focus); +} + +.pagination-center { + display: flex; + align-items: center; + gap: 8px; +} + +.pagination-btn { + min-width: 32px; + height: 32px; + padding: 0 8px; + background: var(--td-bg-color-container); + border: 1px solid var(--td-border-color); + border-radius: var(--td-radius-default); + color: var(--td-text-color-primary); + font-size: 14px; + cursor: pointer; + transition: all var(--td-transition); + display: inline-flex; + align-items: center; + justify-content: center; + outline: none; +} + +.pagination-btn:hover:not(:disabled) { + border-color: var(--td-brand-color); + color: var(--td-brand-color); + background: var(--td-brand-color-light); +} + +.pagination-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.pagination-btn:active:not(:disabled) { + background: var(--td-bg-color-container-active); +} + +.pagination-jump { + display: flex; + align-items: center; + gap: 4px; + font-size: 13px; + color: var(--td-text-color-secondary); +} + +.pagination-input { + width: 50px; + height: 32px; + padding: 0 8px; + background: var(--td-bg-color-container); + border: 1px solid var(--td-border-color); + border-radius: var(--td-radius-default); + color: var(--td-text-color-primary); + font-size: 13px; + text-align: center; + outline: none; + transition: all var(--td-transition); + /* 去除 number input 的调节器 */ + -moz-appearance: textfield; + -webkit-appearance: none; +} + +.pagination-input::-webkit-outer-spin-button, +.pagination-input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +.pagination-input:hover { + border-color: var(--td-brand-color); +} + +.pagination-input:focus { + border-color: var(--td-brand-color); + box-shadow: 0 0 0 2px var(--td-brand-color-focus); +} + +.pagination-total-pages { + color: var(--td-text-color-secondary); +} + /* ==================== 设置页面 ==================== */ .settings-page-body { display: grid; @@ -1294,6 +1387,41 @@ body { justify-content: space-between; align-items: center; width: 100%; + gap: 12px; +} + +.card-header-left { + display: flex; + align-items: center; + gap: 8px; +} + +.field-search-input { + padding: 6px 12px; + background: var(--td-bg-color-container); + border: 1px solid var(--td-border-color); + border-radius: var(--td-radius-default); + color: var(--td-text-color-primary); + font-size: 13px; + width: 200px; + transition: all var(--td-transition); + outline: none; +} + +.field-search-input::placeholder { + color: var(--td-text-color-placeholder); +} + +.field-search-input:focus { + border-color: var(--td-brand-color); + box-shadow: 0 0 0 2px var(--td-brand-color-focus); +} + +.card-header-actions { + display: flex; + justify-content: flex-end; + width: 100%; + margin-top: 4px; } .field-mapping-card .card-header .form-hint { @@ -1306,12 +1434,14 @@ body { color: var(--td-text-color-placeholder); } + .settings-card .card-body { padding: 16px 20px; } .settings-card .card-footer { display: flex; + justify-content: flex-end; gap: 12px; padding: 12px 20px; border-top: 1px solid var(--td-border-color-light); @@ -1896,55 +2026,139 @@ body { /* ==================== Toast 通知 ==================== */ .toast-container { position: fixed; - top: 80px; - right: 16px; + top: 16px; + left: 50%; z-index: 9999; display: flex; flex-direction: column; gap: 8px; + align-items: center; + pointer-events: none; + transform: translateX(-50%); + width: auto; } .toast { - padding: 12px 16px; - border-radius: var(--td-radius-default); + padding: 10px 16px; + border-radius: 4px; background: var(--td-bg-color-container); border: 1px solid var(--td-border-color-light); - box-shadow: var(--td-shadow-2); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12); display: flex; align-items: center; - gap: 10px; - font-size: 14px; + gap: 8px; + font-size: 13px; animation: toastIn 0.3s ease; - min-width: 280px; + min-width: 240px; + max-width: 90vw; color: var(--td-text-color-primary); + pointer-events: auto; + transition: all 0.3s ease; + transform: translateY(0); +} + +.toast-message { + flex: 1; } @keyframes toastIn { from { - transform: translateX(100%); + transform: translateY(-100%); opacity: 0; } to { - transform: translateX(0); + transform: translateY(0); opacity: 1; } } +@keyframes toastOut { + from { + transform: translateY(0); + opacity: 1; + } + to { + transform: translateY(-100%); + opacity: 0; + } +} + .toast-icon { - width: 20px; - height: 20px; + width: 16px; + height: 16px; border-radius: 50%; display: flex; align-items: center; justify-content: center; - font-size: 12px; + font-size: 11px; + font-weight: 600; color: white; + flex-shrink: 0; } -.toast.success .toast-icon { background: var(--td-success-color); } -.toast.error .toast-icon { background: var(--td-error-color); } -.toast.warning .toast-icon { background: var(--td-warning-color); } -.toast.info .toast-icon { background: var(--td-info-color); } +.toast.success { + background: rgba(0, 200, 83, 0.08); + border-color: rgba(0, 200, 83, 0.25); +} + +.toast.success .toast-icon { + background: var(--td-success-color); +} + +.toast.error { + background: rgba(245, 63, 63, 0.08); + border-color: rgba(245, 63, 63, 0.25); +} + +.toast.error .toast-icon { + background: var(--td-error-color); +} + +.toast.warning { + background: rgba(255, 177, 0, 0.08); + border-color: rgba(255, 177, 0, 0.25); +} + +.toast.warning .toast-icon { + background: var(--td-warning-color); +} + +.toast.info { + background: rgba(0, 122, 255, 0.08); + border-color: rgba(0, 122, 255, 0.25); +} + +.toast.info .toast-icon { + background: var(--td-info-color); +} + +.toast-close { + width: 18px; + height: 18px; + border: none; + background: transparent; + color: var(--td-text-color-secondary); + font-size: 18px; + line-height: 1; + cursor: pointer; + padding: 0; + margin-left: 4px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 2px; + transition: all 0.2s ease; + flex-shrink: 0; +} + +.toast-close:hover { + background: rgba(0, 0, 0, 0.06); + color: var(--td-text-color-primary); +} + +.toast-close:active { + background: rgba(0, 0, 0, 0.1); +} /* ==================== 模态框 ==================== */ .modal { diff --git a/static/index.html b/static/index.html index 63d23e3..cbd797b 100644 --- a/static/index.html +++ b/static/index.html @@ -65,7 +65,10 @@ 🌓 @@ -173,28 +176,25 @@

处理历史

+ + + 总占用: 计算中...
-
-
- - -
-
- 总占用: 计算中... -
-
📭 @@ -214,11 +214,17 @@

数据管理

+
@@ -250,7 +256,6 @@
- @@ -263,9 +268,27 @@
@@ -294,7 +317,10 @@ 🌓 @@ -319,11 +345,21 @@

系统设置

+ +
@@ -399,8 +435,11 @@
- 字段映射配置 - 0 个字段 +
+ 字段映射配置 + 0 个字段 +
+

定义 Excel 列名到数据库字段的映射规则,提取来源匹配任意一个即可

diff --git a/static/js/app.js b/static/js/app.js index a66b569..cf72425 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -52,14 +52,29 @@ function showToast(message, type = 'info') { toast.innerHTML = ` ${icons[type] || 'i'} - ${message} + ${message} + `; container.appendChild(toast); - setTimeout(() => { - toast.style.animation = 'toastIn 0.3s ease reverse'; + // 触发重排,确保动画生效 + toast.offsetHeight; + + // 关闭函数 + const closeToast = () => { + if (toast.timeoutId) { + clearTimeout(toast.timeoutId); + } + toast.style.animation = 'toastOut 0.3s ease forwards'; setTimeout(() => toast.remove(), 300); - }, 3000); + }; + + // 绑定关闭按钮事件 + const closeBtn = toast.querySelector('.toast-close'); + closeBtn.addEventListener('click', closeToast); + + // 自动关闭 + toast.timeoutId = setTimeout(closeToast, 3000); } // 确认对话框 @@ -1242,28 +1257,40 @@ class DatabaseManager { } init() { - $('#searchInput').addEventListener('keyup', (e) => { - if (e.key === 'Enter') { - this.search(); - } - }); - $('#downloadTable').addEventListener('click', () => this.downloadTable()); $('#truncateTable').addEventListener('click', () => this.truncateTable()); $('#dropTable').addEventListener('click', () => this.dropTable()); + $('#dropAllTables').addEventListener('click', () => this.dropAllTables()); - $('#prevPage').addEventListener('click', () => { - if (this.currentPage > 1) { - this.currentPage--; - this.loadData(); + // 分页按钮事件 + $('#firstPage').addEventListener('click', () => this.goToPage(1)); + $('#prevPage').addEventListener('click', () => this.goToPage(this.currentPage - 1)); + $('#nextPage').addEventListener('click', () => this.goToPage(this.currentPage + 1)); + $('#lastPage').addEventListener('click', () => this.goToPage(this.totalPages)); + + // 页面大小选择 + $('#pageSizeSelect').addEventListener('change', (e) => { + this.pageSize = parseInt(e.target.value); + this.currentPage = 1; + this.loadData(); + }); + + // 跳转页面输入框 + $('#pageJumpInput').addEventListener('keyup', (e) => { + if (e.key === 'Enter') { + const page = parseInt(e.target.value); + if (page >= 1 && page <= this.totalPages) { + this.goToPage(page); + } else { + e.target.value = this.currentPage; + showToast(`页码必须在 1-${this.totalPages} 之间`, 'error'); + } } }); - $('#nextPage').addEventListener('click', () => { - if (this.currentPage < this.totalPages) { - this.currentPage++; - this.loadData(); - } + // 失去焦点时恢复当前页 + $('#pageJumpInput').addEventListener('blur', (e) => { + e.target.value = this.currentPage; }); window.addEventListener('pagechange', (e) => { @@ -1391,6 +1418,10 @@ class DatabaseManager { this.currentTable = tableName; this.currentPage = 1; + // 重置页面大小为默认值 + $('#pageSizeSelect').value = this.pageSize.toString(); + $('#pageJumpInput').value = '1'; + $('#dbToolbar').style.display = 'flex'; $('#currentTable').textContent = tableName; $('#pagination').style.display = 'flex'; @@ -1410,47 +1441,43 @@ class DatabaseManager { } } + goToPage(page) { + if (page < 1) page = 1; + if (page > this.totalPages) page = this.totalPages; + if (page === this.currentPage) return; + + this.currentPage = page; + this.loadData(); + } + async loadData() { const container = $('#dataTableContainer'); container.innerHTML = '
加载中...
'; try { - const searchValue = $('#searchInput').value.trim(); - let result; - - if (searchValue && this.columns.length > 0) { - // 构建筛选条件(只用第一个字段搜索以提高性能) - const filters = {}; - if (this.columns[0]) { - filters[this.columns[0]] = searchValue; - } - - result = await api('/database/table/query', { - method: 'POST', - body: JSON.stringify({ - table_name: this.currentTable, - page: this.currentPage, - page_size: this.pageSize, - filters: filters - }) - }); - } else { - result = await api('/database/table/data', { - method: 'POST', - body: JSON.stringify({ - table_name: this.currentTable, - page: this.currentPage, - page_size: this.pageSize - }) - }); - } + const result = await api('/database/table/data', { + method: 'POST', + body: JSON.stringify({ + table_name: this.currentTable, + page: this.currentPage, + page_size: this.pageSize + }) + }); this.totalPages = result.total_pages; this.renderTable(result.data); - $('#pageInfo').textContent = `第 ${this.currentPage} / ${this.totalPages} 页 (共 ${result.total} 条)`; + // 更新分页信息 + $('#totalRecords').textContent = result.total; + $('#totalPagesDisplay').textContent = this.totalPages; + $('#pageJumpInput').value = this.currentPage; + $('#pageJumpInput').max = this.totalPages; + + // 更新按钮状态 + $('#firstPage').disabled = this.currentPage <= 1; $('#prevPage').disabled = this.currentPage <= 1; $('#nextPage').disabled = this.currentPage >= this.totalPages; + $('#lastPage').disabled = this.currentPage >= this.totalPages; } catch (error) { container.innerHTML = `

加载失败: ${error.message}

`; @@ -1460,13 +1487,16 @@ class DatabaseManager { renderTable(data) { const container = $('#dataTableContainer'); - if (data.length === 0) { + // 使用已保存的字段列表,如果没有则从数据中获取 + const columns = this.columns && this.columns.length > 0 + ? this.columns + : (data.length > 0 ? Object.keys(data[0]) : []); + + if (columns.length === 0) { container.innerHTML = '

暂无数据

'; return; } - const columns = Object.keys(data[0]); - container.innerHTML = ` @@ -1475,7 +1505,15 @@ class DatabaseManager { - ${data.map(row => ` + ${data.length === 0 ? ` + + + + ` : data.map(row => ` ${columns.map(col => ``).join('')} @@ -1485,11 +1523,6 @@ class DatabaseManager { `; } - search() { - this.currentPage = 1; - this.loadData(); - } - async downloadTable() { if (!this.currentTable) return; try { @@ -1561,6 +1594,59 @@ class DatabaseManager { showToast(`删除失败: ${error.message}`, 'error'); } } + + async dropAllTables() { + // 首先获取所有表 + let tables = []; + try { + const result = await api('/database/tables', { method: 'POST' }); + tables = result.tables || []; + } catch (error) { + showToast(`获取表列表失败: ${error.message}`, 'error'); + return; + } + + if (tables.length === 0) { + showToast('数据库中没有表', 'info'); + return; + } + + // 第一次确认 + const firstConfirm = await showConfirm( + '删除全部表', + `警告:此操作将删除数据库中的所有 ${tables.length} 个表!\n\n表列表:${tables.slice(0, 10).join(', ')}${tables.length > 10 ? '...' : ''}\n\n此操作不可恢复,请谨慎操作!` + ); + + if (!firstConfirm) return; + + // 第二次确认(双重确认,防止误操作) + const secondConfirm = await showConfirm( + '最后确认', + `您确定要删除所有 ${tables.length} 个表吗?\n\n此操作将永久删除所有数据,无法恢复!\n\n请再次确认。` + ); + + if (!secondConfirm) return; + + // 执行删除 + try { + const result = await api('/database/table/drop-all', { + method: 'POST' + }); + + showToast(`已删除 ${result.dropped_count} 个表`, 'success'); + + // 重置当前状态 + this.currentTable = null; + $('#dbToolbar').style.display = 'none'; + $('#pagination').style.display = 'none'; + $('#dataTableContainer').innerHTML = '
📭

数据库中没有表

'; + + // 重新加载表列表 + this.loadTables(); + } catch (error) { + showToast(`删除失败: ${error.message}`, 'error'); + } + } } @@ -1570,6 +1656,7 @@ class SettingsManager { constructor() { this.sheetFilters = []; this.extractFields = []; + this.fieldSearchKeyword = ''; this.init(); } @@ -1592,6 +1679,16 @@ class SettingsManager { $('#addFieldMapping').addEventListener('click', () => this.addFieldMapping()); $('#saveFieldMapping').addEventListener('click', () => this.saveFieldMappings()); + // 字段搜索 + $('#fieldSearchInput').addEventListener('input', (e) => { + this.fieldSearchKeyword = e.target.value.trim().toLowerCase(); + this.renderFieldMappings(); + }); + + // 配置管理 + $('#downloadConfig').addEventListener('click', () => this.downloadConfig()); + $('#uploadConfig').addEventListener('change', (e) => this.uploadConfig(e)); + window.addEventListener('pagechange', (e) => { if (e.detail.page === 'settings') { this.loadConfig(); @@ -1738,6 +1835,7 @@ class SettingsManager { renderFieldMappings() { const container = $('#fieldMappingList'); + const countEl = $('#fieldCount'); // 更新字段数量显示 @@ -1759,8 +1857,16 @@ class SettingsManager { { value: 'text', label: '长文本' } ]; - container.innerHTML = this.extractFields.map((field, index) => ` -
+ container.innerHTML = this.extractFields.map((field, index) => { + // 根据搜索关键词决定是否显示 + const shouldShow = !this.fieldSearchKeyword || (() => { + const fieldName = (field.Field || '').toLowerCase(); + const extractSources = (field.Extract || []).join(' ').toLowerCase(); + return fieldName.includes(this.fieldSearchKeyword) || extractSources.includes(this.fieldSearchKeyword); + })(); + + return ` +
${index + 1}
@@ -1803,7 +1909,8 @@ class SettingsManager { `}
- `).join(''); + `; + }).join(''); // 绑定删除映射事件 container.querySelectorAll('.remove-mapping').forEach(btn => { @@ -1907,6 +2014,99 @@ class SettingsManager { showToast(`保存失败: ${error.message}`, 'error'); } } + + async downloadConfig() { + try { + const response = await fetch('/api/config/download', { + method: 'GET' + }); + + if (!response.ok) { + throw new Error('下载失败'); + } + + // 获取文件名(从 Content-Disposition 头或使用默认名称) + const contentDisposition = response.headers.get('Content-Disposition'); + let filename = 'Configure.json'; + if (contentDisposition) { + const filenameMatch = contentDisposition.match(/filename="?(.+)"?/); + if (filenameMatch) { + filename = filenameMatch[1]; + } + } + + // 下载文件 + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + window.URL.revokeObjectURL(url); + + showToast('配置文件下载成功', 'success'); + } catch (error) { + showToast(`下载失败: ${error.message}`, 'error'); + } + } + + async uploadConfig(event) { + const file = event.target.files[0]; + if (!file) return; + + // 验证文件类型 + if (!file.name.endsWith('.json')) { + showToast('只支持 JSON 格式的配置文件', 'error'); + event.target.value = ''; + return; + } + + // 确认上传 + const confirmed = await showConfirm( + '上传配置', + `确定要上传配置文件 "${file.name}" 吗?当前配置将被替换,系统会自动备份原配置。` + ); + + if (!confirmed) { + event.target.value = ''; + return; + } + + try { + const formData = new FormData(); + formData.append('file', file); + + const response = await fetch('/api/config/upload', { + method: 'POST', + body: formData + }); + + const result = await response.json(); + + if (!response.ok) { + throw new Error(result.detail || '上传失败'); + } + + // 重新加载配置 + await this.loadConfig(); + + let message = '配置文件上传成功'; + if (result.backup) { + message += `,原配置已备份为 ${result.backup}`; + } + + showToast(message, 'success'); + $('#configUpdate').textContent = `更新时间: ${result.update}`; + + } catch (error) { + showToast(`上传失败: ${error.message}`, 'error'); + } finally { + // 清空文件选择,允许重复选择同一文件 + event.target.value = ''; + } + } }
+
+

暂无数据

+
+
${row[col] ?? ''}