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] ?? ''}