v2.0.0 done

This commit is contained in:
2026-01-13 19:07:07 +08:00
commit 103ceb6c2e
16 changed files with 7346 additions and 0 deletions
+75
View File
@@ -0,0 +1,75 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Virtual Environment
.venv/
venv/
ENV/
env/
.ENV
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
# 项目特定目录
cache/
bak/
CapacityReportData/
# 日志文件
*.log
logs/
# 环境变量
.env
.env.local
# 数据库
*.db
*.sqlite
*.sqlite3
# 临时文件
*.tmp
*.temp
*.bak
*.cache
# 系统文件
Thumbs.db
desktop.ini
# 上传的文件
uploads/
*.zip
*.xlsx
*.xls
*.csv
# 配置文件(如果包含敏感信息)
# Configure.json # 如果需要保留配置,注释掉这行
+242
View File
@@ -0,0 +1,242 @@
{
"Update": "2026/01/13 18:04:33",
"MySQL_DBInfo": {
"host": "127.0.0.1",
"port": 3306,
"user": "root",
"passwd": "242520",
"dbname": "CapacityReport"
},
"SheetFilter": [
"指标(计数器)",
"Template"
],
"ExtractField": [
{
"Field": "日期时间",
"Extract": [
"开始时间"
]
},
{
"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))"
]
}
]
}
+24
View File
@@ -0,0 +1,24 @@
FROM python:3.11
WORKDIR /app
# 复制依赖文件
COPY requirements.txt ./
# 安装依赖
RUN pip install --no-cache-dir -r requirements.txt
# 创建日志目录
RUN mkdir -p /var/log/supervisor /var/run
# 复制应用代码
COPY . .
# 设置启动脚本权限
RUN chmod +x /app/start.sh
# 暴露端口
EXPOSE 9081
# 使用启动脚本
CMD ["/app/start.sh"]
+139
View File
@@ -0,0 +1,139 @@
# CapacityReport - 容量报表处理程序
容量报表数据处理系统,支持 Excel/CSV 文件上传、数据提取、清洗和导入 MySQL 数据库。
## 技术栈
- **后端**: FastAPI + Uvicorn + Supervisor
- **前端**: 原生 JavaScript + HTML/CSS
- **数据库**: MySQL (PyMySQL + SQLAlchemy)
- **数据处理**: Pandas + OpenPyXL
- **部署**: Docker + Supervisor
## 系统架构
```mermaid
graph TB
A[浏览器] -->|HTTP| B[FastAPI]
B -->|静态文件| C[Static Files]
B -->|API请求| D[业务逻辑层]
D -->|数据处理| E[DataProcessor]
D -->|数据管理| F[DatabaseManager]
D -->|历史记录| G[HistoryManager]
E -->|批量插入| H[MySQL]
F -->|连接池| H
I[Supervisor] -->|管理进程| J[Uvicorn]
J -->|运行| B
```
## 核心模块
- `app/main.py` - FastAPI 应用入口,API 路由
- `app/processor.py` - 数据处理核心(ZIP解压、Excel转CSV、CSV导入)
- `app/database.py` - 数据库连接与批量插入
- `app/history.py` - 处理历史记录管理
- `app/config.py` - 配置管理
## 数据流程
```mermaid
flowchart TD
A[上传文件] --> B{文件类型}
B -->|ZIP| C[解压ZIP]
B -->|Excel| D[Excel转CSV]
B -->|CSV| E[直接处理]
C --> D
D --> F[字段映射]
E --> F
F --> G[数据清洗]
G --> H[批量插入MySQL]
H --> I[执行SQL脚本]
I --> J[生成结果表]
```
## 上传处理时序
```mermaid
sequenceDiagram
participant U as 用户
participant F as 前端
participant A as FastAPI
participant P as DataProcessor
participant D as MySQL
U->>F: 选择文件上传
F->>A: POST /api/upload
A->>A: 保存文件到 cache/
A->>F: 返回 task_id
F->>A: POST /api/process/start
A->>P: 启动处理任务
P->>P: 解压ZIP/处理Excel
P->>P: 读取CSV并清洗
P->>D: 批量插入数据
P->>D: 执行SQL脚本
P->>A: 返回处理结果
A->>F: 推送日志更新
F->>U: 显示处理进度
```
## 部署
### Docker 部署
```bash
docker build -t capareport .
docker run -d --restart=always -p 9081:9081 capareport
```
### Windows 部署
```bash
# 安装依赖
pip install -r requirements.txt
# 启动服务(自动重启模式)
run.bat
```
### Linux 部署
```bash
# 使用 Supervisor
supervisord -c supervisord.conf
```
## 配置
配置文件: `Configure.json`
- `MySQL_DBInfo` - 数据库连接信息
- `SheetFilter` - Excel Sheet 过滤规则
- `ExtractField` - 字段映射配置
## API 端点
- `POST /api/upload` - 上传文件
- `POST /api/process/start` - 启动处理
- `POST /api/process/status` - 查询处理状态
- `POST /api/history` - 获取历史记录
- `POST /api/service/restart` - 重启服务
- `GET /api/service/status` - 服务状态
## 目录结构
```
CapaReport/
├── app/ # 应用代码
│ ├── main.py # FastAPI 入口
│ ├── processor.py # 数据处理
│ ├── database.py # 数据库管理
│ ├── history.py # 历史记录
│ └── config.py # 配置管理
├── static/ # 前端静态文件
├── cache/ # 临时文件目录
├── Configure.json # 配置文件
├── Dockerfile # Docker 配置
└── supervisord.conf # Supervisor 配置
```
+471
View File
@@ -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`;
+109
View File
@@ -0,0 +1,109 @@
"""
配置管理模块
"""
import json
from pathlib import Path
from datetime import datetime
from typing import Any, Dict, List, Optional
from dataclasses import dataclass, field
BASE_DIR = Path(__file__).resolve().parent.parent
CACHE_DIR = BASE_DIR / "cache"
CONFIG_FILE = BASE_DIR / "Configure.json"
SQL_SCRIPT = BASE_DIR / "ReportScript.sql"
@dataclass
class MySQLConfig:
host: str = "localhost"
port: int = 3306
user: str = "root"
passwd: str = ""
dbname: str = "CapacityReport"
@dataclass
class AppConfig:
update: str = ""
mysql: MySQLConfig = field(default_factory=MySQLConfig)
sheet_filter: List[str] = field(default_factory=list)
extract_fields: List[Dict[str, Any]] = field(default_factory=list)
@classmethod
def load(cls) -> "AppConfig":
"""从 Configure.json 加载配置"""
if not CONFIG_FILE.exists():
return cls()
with open(CONFIG_FILE, 'r', encoding='utf-8') as f:
data = json.load(f)
mysql_data = data.get("MySQL_DBInfo", {})
mysql_config = MySQLConfig(
host=mysql_data.get("host", "localhost"),
port=mysql_data.get("port", 3306),
user=mysql_data.get("user", "root"),
passwd=mysql_data.get("passwd", ""),
dbname=mysql_data.get("dbname", "CapacityReport")
)
return cls(
update=data.get("Update", ""),
mysql=mysql_config,
sheet_filter=data.get("SheetFilter", []),
extract_fields=data.get("ExtractField", [])
)
def save(self):
"""保存配置到 Configure.json,并自动更新 Update 时间"""
self.update = datetime.now().strftime("%Y/%m/%d %H:%M:%S")
data = {
"Update": self.update,
"MySQL_DBInfo": {
"host": self.mysql.host,
"port": self.mysql.port,
"user": self.mysql.user,
"passwd": self.mysql.passwd,
"dbname": self.mysql.dbname
},
"SheetFilter": self.sheet_filter,
"ExtractField": self.extract_fields
}
with open(CONFIG_FILE, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def to_dict(self) -> Dict[str, Any]:
"""转换为字典(用于返回给前端,隐藏密码)"""
return {
"update": self.update,
"mysql": {
"host": self.mysql.host,
"port": self.mysql.port,
"user": self.mysql.user,
"dbname": self.mysql.dbname
},
"sheet_filter": self.sheet_filter,
"extract_fields": self.extract_fields
}
def to_dict_full(self) -> Dict[str, Any]:
"""转换为完整字典(包含密码,用于编辑时回显)"""
return {
"update": self.update,
"mysql": {
"host": self.mysql.host,
"port": self.mysql.port,
"user": self.mysql.user,
"passwd": self.mysql.passwd,
"dbname": self.mysql.dbname
},
"sheet_filter": self.sheet_filter,
"extract_fields": self.extract_fields
}
# 确保缓存目录存在
CACHE_DIR.mkdir(exist_ok=True)
+262
View File
@@ -0,0 +1,262 @@
"""
数据库连接与操作模块 - 性能优化版
"""
import pymysql
import sqlalchemy
from sqlalchemy import create_engine, text, event
from sqlalchemy.pool import QueuePool
from urllib.parse import quote
from typing import Any, Dict, List, Optional, Tuple
from contextlib import contextmanager
from app.config import AppConfig
class DatabaseManager:
"""数据库管理器 - 高性能版"""
def __init__(self, config: AppConfig):
self.config = config
self._engine: Optional[sqlalchemy.Engine] = None
@property
def engine(self) -> sqlalchemy.Engine:
"""获取 SQLAlchemy 引擎(带连接池)- 优化配置"""
if self._engine is None:
mysql = self.config.mysql
self._engine = create_engine(
f'mysql+pymysql://'
f'{quote(mysql.user)}:'
f'{quote(mysql.passwd)}@'
f'{quote(mysql.host)}:'
f'{mysql.port}/'
f'{quote(mysql.dbname)}?charset=utf8mb4',
poolclass=QueuePool,
pool_size=10, # 增大连接池
max_overflow=20, # 增大溢出连接
pool_pre_ping=True,
pool_recycle=3600,
echo=False,
# 性能优化参数
connect_args={
'local_infile': True, # 允许 LOAD DATA LOCAL
'autocommit': False,
}
)
return self._engine
@contextmanager
def get_connection(self):
"""获取 PyMySQL 连接(上下文管理器)"""
mysql = self.config.mysql
conn = pymysql.connect(
host=mysql.host,
port=mysql.port,
user=mysql.user,
password=mysql.passwd,
database=mysql.dbname,
charset='utf8mb4',
cursorclass=pymysql.cursors.DictCursor,
local_infile=True, # 允许 LOAD DATA LOCAL
autocommit=False
)
try:
yield conn
finally:
conn.close()
@contextmanager
def get_fast_connection(self):
"""获取高性能 PyMySQL 连接(用于批量插入)"""
mysql = self.config.mysql
conn = pymysql.connect(
host=mysql.host,
port=mysql.port,
user=mysql.user,
password=mysql.passwd,
database=mysql.dbname,
charset='utf8mb4',
cursorclass=pymysql.cursors.Cursor, # 使用普通游标更快
local_infile=True,
autocommit=False,
read_timeout=300,
write_timeout=300
)
try:
yield conn
finally:
conn.close()
def test_connection(self) -> Tuple[bool, str]:
"""测试数据库连接"""
try:
with self.get_connection() as conn:
with conn.cursor() as cursor:
cursor.execute("SELECT 1")
return True, "连接成功"
except Exception as e:
return False, str(e)
def get_tables(self) -> List[str]:
"""获取所有表名"""
with self.get_connection() as conn:
with conn.cursor() as cursor:
cursor.execute("SHOW TABLES")
return [list(row.values())[0] for row in cursor.fetchall()]
def get_table_info(self, table_name: str) -> Dict[str, Any]:
"""获取表信息"""
with self.get_connection() as conn:
with conn.cursor() as cursor:
# 获取列信息
cursor.execute(f"DESCRIBE `{table_name}`")
columns = cursor.fetchall()
# 获取行数
cursor.execute(f"SELECT COUNT(*) as count FROM `{table_name}`")
count = cursor.fetchone()['count']
return {
"name": table_name,
"columns": columns,
"row_count": count
}
def query_table(
self,
table_name: str,
page: int = 1,
page_size: int = 50,
filters: Optional[Dict[str, str]] = None,
order_by: Optional[str] = None,
order_dir: str = "ASC"
) -> Dict[str, Any]:
"""分页查询表数据"""
offset = (page - 1) * page_size
with self.get_connection() as conn:
with conn.cursor() as cursor:
# 构建 WHERE 条件
where_clause = ""
params = []
if filters:
conditions = []
for col, val in filters.items():
if val:
conditions.append(f"`{col}` LIKE %s")
params.append(f"%{val}%")
if conditions:
where_clause = "WHERE " + " AND ".join(conditions)
# 获取总数
count_sql = f"SELECT COUNT(*) as count FROM `{table_name}` {where_clause}"
cursor.execute(count_sql, params)
total = cursor.fetchone()['count']
# 构建排序
order_clause = ""
if order_by:
order_clause = f"ORDER BY `{order_by}` {order_dir}"
# 查询数据
query_sql = f"SELECT * FROM `{table_name}` {where_clause} {order_clause} LIMIT %s OFFSET %s"
cursor.execute(query_sql, params + [page_size, offset])
rows = cursor.fetchall()
return {
"data": rows,
"total": total,
"page": page,
"page_size": page_size,
"total_pages": (total + page_size - 1) // page_size
}
def delete_rows(self, table_name: str, condition: str, params: List[Any]) -> int:
"""删除符合条件的行"""
with self.get_connection() as conn:
with conn.cursor() as cursor:
sql = f"DELETE FROM `{table_name}` WHERE {condition}"
cursor.execute(sql, params)
conn.commit()
return cursor.rowcount
def truncate_table(self, table_name: str) -> bool:
"""清空表"""
with self.get_connection() as conn:
with conn.cursor() as cursor:
cursor.execute(f"TRUNCATE TABLE `{table_name}`")
conn.commit()
return True
def drop_table(self, table_name: str) -> bool:
"""删除表"""
with self.get_connection() as conn:
with conn.cursor() as cursor:
cursor.execute(f"DROP TABLE IF EXISTS `{table_name}`")
conn.commit()
return True
def execute_sql(self, sql: str) -> Tuple[bool, Any]:
"""执行自定义 SQL"""
with self.get_connection() as conn:
with conn.cursor() as cursor:
try:
cursor.execute(sql)
if sql.strip().upper().startswith("SELECT"):
return True, cursor.fetchall()
else:
conn.commit()
return True, {"affected_rows": cursor.rowcount}
except Exception as e:
return False, str(e)
def bulk_insert(self, table_name: str, columns: List[str], data: List[Tuple],
batch_size: int = 5000) -> int:
"""
高性能批量插入
使用 executemany + 批量提交,比 to_sql 快 5-10 倍
"""
if not data:
return 0
total_inserted = 0
placeholders = ', '.join(['%s'] * len(columns))
column_names = ', '.join([f'`{col}`' for col in columns])
sql = f"INSERT INTO `{table_name}` ({column_names}) VALUES ({placeholders})"
with self.get_fast_connection() as conn:
with conn.cursor() as cursor:
# 优化插入性能的设置
cursor.execute("SET autocommit=0")
cursor.execute("SET unique_checks=0")
cursor.execute("SET foreign_key_checks=0")
# 分批插入
for i in range(0, len(data), batch_size):
batch = data[i:i + batch_size]
cursor.executemany(sql, batch)
total_inserted += len(batch)
# 提交并恢复设置
conn.commit()
cursor.execute("SET unique_checks=1")
cursor.execute("SET foreign_key_checks=1")
cursor.execute("SET autocommit=1")
return total_inserted
def create_table_from_columns(self, table_name: str, columns: List[str]):
"""根据列名创建表(所有列都是 VARCHAR(255))"""
column_defs = ', '.join([f'`{col}` VARCHAR(255)' for col in columns])
sql = f"CREATE TABLE IF NOT EXISTS `{table_name}` ({column_defs}) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
with self.get_connection() as conn:
with conn.cursor() as cursor:
cursor.execute(sql)
conn.commit()
def dispose(self):
"""释放连接池"""
if self._engine:
self._engine.dispose()
self._engine = None
+221
View File
@@ -0,0 +1,221 @@
"""
处理历史记录管理模块
"""
import json
import shutil
from pathlib import Path
from datetime import datetime
from typing import Any, Dict, List, Optional
from dataclasses import dataclass, field, asdict
from app.config import CACHE_DIR
HISTORY_FILE = CACHE_DIR / "history.json"
@dataclass
class HistoryRecord:
"""历史记录"""
id: str
timestamp: str
status: str # pending, processing, completed, failed
work_dir: str
file_count: int = 0
elapsed_time: float = 0.0
error: Optional[str] = None
result_tables: List[str] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
return asdict(self)
def get_log_file(self) -> Path:
"""获取日志文件路径"""
return Path(self.work_dir) / "log.txt"
class HistoryManager:
"""历史记录管理器"""
def __init__(self):
self._ensure_file()
def _ensure_file(self):
"""确保历史文件存在"""
if not HISTORY_FILE.exists():
HISTORY_FILE.write_text("[]", encoding='utf-8')
def _load(self) -> List[Dict[str, Any]]:
"""加载历史记录"""
try:
return json.loads(HISTORY_FILE.read_text(encoding='utf-8'))
except:
return []
def _save(self, records: List[Dict[str, Any]]):
"""保存历史记录"""
HISTORY_FILE.write_text(json.dumps(records, ensure_ascii=False, indent=2), encoding='utf-8')
def create(self, work_dir: Path, file_count: int, record_id: Optional[str] = None) -> HistoryRecord:
"""创建新的历史记录"""
# 如果提供了 record_id,使用它;否则生成新的
if record_id is None:
record_id = datetime.now().strftime("%Y%m%d_%H%M%S")
record = HistoryRecord(
id=record_id,
timestamp=datetime.now().isoformat(),
status="pending",
work_dir=str(work_dir),
file_count=file_count
)
records = self._load()
records.insert(0, record.to_dict())
# 只保留最近 100 条记录
records = records[:100]
self._save(records)
return record
def _clean_record(self, rec: Dict[str, Any]) -> Dict[str, Any]:
"""清理记录,移除不存在的字段(如旧的 logs 字段)"""
cleaned = {
'id': rec.get('id'),
'timestamp': rec.get('timestamp'),
'status': rec.get('status'),
'work_dir': rec.get('work_dir'),
'file_count': rec.get('file_count', 0),
'elapsed_time': rec.get('elapsed_time', 0.0),
'error': rec.get('error'),
'result_tables': rec.get('result_tables', [])
}
return cleaned
def update(self, record_id: str, **kwargs) -> Optional[HistoryRecord]:
"""更新历史记录"""
records = self._load()
for i, rec in enumerate(records):
if rec['id'] == record_id:
rec.update(kwargs)
self._save(records)
cleaned = self._clean_record(rec)
return HistoryRecord(**cleaned)
return None
def get(self, record_id: str) -> Optional[HistoryRecord]:
"""获取单条记录"""
records = self._load()
for rec in records:
if rec['id'] == record_id:
cleaned = self._clean_record(rec)
return HistoryRecord(**cleaned)
return None
def list(self, limit: int = 50) -> List[Dict[str, Any]]:
"""获取历史记录列表"""
records = self._load()
# 返回简化的列表(不包含日志)
result = []
for rec in records[:limit]:
item = rec.copy()
item.pop('logs', None) # 列表不返回日志(兼容旧数据)
result.append(item)
return result
def get_logs(self, record_id: str) -> List[str]:
"""从 log.txt 文件读取日志"""
record = self.get(record_id)
if not record:
return []
log_file = record.get_log_file()
if not log_file.exists():
return []
try:
content = log_file.read_text(encoding='utf-8')
# 按行分割,过滤空行
logs = [line.strip() for line in content.split('\n') if line.strip()]
return logs
except Exception as e:
print(f"读取日志文件失败: {log_file}, 错误: {e}")
return []
def delete(self, record_id: str) -> bool:
"""删除历史记录,同时删除对应的文件目录"""
records = self._load()
# 找到要删除的记录,获取其工作目录
work_dir = None
for rec in records:
if rec['id'] == record_id:
work_dir = rec.get('work_dir')
break
# 删除记录
new_records = [r for r in records if r['id'] != record_id]
if len(new_records) < len(records):
self._save(new_records)
# 删除对应的文件目录(安全检查:确保路径在cache目录内)
if work_dir:
try:
work_path = Path(work_dir).resolve()
cache_path = CACHE_DIR.resolve()
# 安全检查:确保要删除的目录在cache目录内
if work_path.exists() and work_path.is_dir():
# 检查路径是否在cache目录内
try:
work_path.relative_to(cache_path)
# 路径安全,可以删除
shutil.rmtree(work_path)
except ValueError:
# 路径不在cache目录内,跳过删除(安全保护)
print(f"警告: 尝试删除cache目录外的文件: {work_dir}")
except Exception as e:
# 记录错误但不影响删除历史记录的操作
print(f"删除文件目录失败: {work_dir}, 错误: {e}")
return True
return False
def clear(self) -> int:
"""清空所有历史记录,同时清空cache目录(保留history.json)"""
records = self._load()
count = len(records)
# 清空历史记录
self._save([])
# 清空cache目录(保留history.json文件)
try:
cache_path = CACHE_DIR.resolve()
for item in CACHE_DIR.iterdir():
try:
item_path = item.resolve()
# 安全检查:确保路径在cache目录内
item_path.relative_to(cache_path)
if item.is_dir():
# 删除所有子目录
shutil.rmtree(item_path)
elif item.is_file() and item.name != "history.json":
# 删除所有文件,但保留history.json
item_path.unlink()
except ValueError:
# 路径不在cache目录内,跳过(安全保护)
print(f"警告: 跳过cache目录外的文件: {item}")
except Exception as e:
# 单个文件/目录删除失败,继续处理其他文件
print(f"删除失败: {item}, 错误: {e}")
except Exception as e:
# 记录错误但不影响清空历史记录的操作
print(f"清空cache目录失败: {e}")
return count
+907
View File
@@ -0,0 +1,907 @@
"""
CapacityReport - 容量报表处理程序
FastAPI 主入口
"""
import os
import sys
import signal
import shutil
import asyncio
import subprocess
import platform
from pathlib import Path
from datetime import datetime
from typing import Any, Dict, List, Optional
from threading import Thread
from fastapi import FastAPI, File, UploadFile, HTTPException, Query, Body
from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse, FileResponse
from fastapi.middleware.cors import CORSMiddleware
from app.config import AppConfig, CACHE_DIR, BASE_DIR
from app.database import DatabaseManager
from app.processor import DataProcessor, ProcessLogger
from app.history import HistoryManager
# 创建应用
app = FastAPI(
title="CapacityReport",
description="容量报表数据处理系统",
version="2.0.0"
)
# CORS 配置
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 静态文件
STATIC_DIR = BASE_DIR / "static"
if STATIC_DIR.exists():
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
# 全局状态
config = AppConfig.load()
history_manager = HistoryManager()
processing_tasks: Dict[str, Dict[str, Any]] = {}
# 全局任务锁定状态(上传中或处理中)
global_task_lock: Dict[str, Any] = {
"locked": False,
"task_id": None,
"stage": None, # "uploading" 或 "processing"
"started_at": None
}
# ==================== 页面路由 ====================
@app.get("/", response_class=HTMLResponse)
async def index():
"""返回主页"""
index_file = STATIC_DIR / "index.html"
if index_file.exists():
return HTMLResponse(content=index_file.read_text(encoding='utf-8'))
return HTMLResponse(content="<h1>CapacityReport</h1><p>Static files not found.</p>")
# ==================== 文件上传 API ====================
# 存储进行中的上传任务
upload_sessions: Dict[str, Dict[str, Any]] = {}
@app.post("/api/upload/create")
async def create_upload_session():
"""
创建上传会话
返回 session_id,后续上传文件使用这个 ID
"""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
session_id = timestamp
work_dir = CACHE_DIR / timestamp
work_dir.mkdir(parents=True, exist_ok=True)
upload_sessions[session_id] = {
"work_dir": work_dir,
"files": [],
"created_at": datetime.now().isoformat()
}
return {
"success": True,
"session_id": session_id,
"work_dir": str(work_dir)
}
@app.post("/api/upload")
async def upload_files(
files: List[UploadFile] = File(...),
session_id: Optional[str] = None
):
"""
上传文件
支持多文件上传,会保持目录结构
如果提供 session_id,则追加到现有会话
上传前会检查并锁定全局任务状态
"""
if not files:
raise HTTPException(status_code=400, detail="没有上传文件")
is_new_session = False
# 检查全局锁定状态(如果是新会话)
if not session_id or session_id not in upload_sessions:
if global_task_lock["locked"]:
raise HTTPException(status_code=409, detail="已有任务在运行,请等待当前任务完成")
# 创建新会话并立即锁定
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
session_id = timestamp
work_dir = CACHE_DIR / timestamp
work_dir.mkdir(parents=True, exist_ok=True)
# 立即锁定全局任务
global_task_lock["locked"] = True
global_task_lock["task_id"] = session_id
global_task_lock["stage"] = "uploading"
global_task_lock["started_at"] = datetime.now().isoformat()
is_new_session = True
upload_sessions[session_id] = {
"work_dir": work_dir,
"files": [],
"created_at": datetime.now().isoformat()
}
session = upload_sessions[session_id]
else:
# 使用现有会话(追加文件)
work_dir = upload_sessions[session_id]["work_dir"]
session = upload_sessions[session_id]
# 验证会话是否属于当前锁定的任务
if global_task_lock["locked"] and global_task_lock["task_id"] != session_id:
raise HTTPException(status_code=409, detail="已有其他任务在运行")
try:
saved_files = []
for file in files:
# 保持目录结构
# 文件名可能包含路径,如 "4G/data.xlsx"
file_path = work_dir / file.filename
file_path.parent.mkdir(parents=True, exist_ok=True)
# 保存文件
content = await file.read()
file_path.write_bytes(content)
saved_files.append(file.filename)
session["files"].append(file.filename)
# 创建或更新历史记录(使用 session_id 作为记录 ID)
record = history_manager.get(session_id)
if not record:
record = history_manager.create(work_dir, len(session["files"]), record_id=session_id)
else:
history_manager.update(session_id, file_count=len(session["files"]))
return {
"success": True,
"task_id": session_id,
"session_id": session_id,
"work_dir": str(work_dir),
"file_count": len(saved_files),
"total_files": len(session["files"]),
"files": saved_files
}
except Exception as e:
# 上传失败,如果是新会话则解锁
if is_new_session:
global_task_lock["locked"] = False
global_task_lock["task_id"] = None
global_task_lock["stage"] = None
global_task_lock["started_at"] = None
raise HTTPException(status_code=500, detail=f"上传失败: {str(e)}")
@app.post("/api/upload/complete/{session_id}")
async def complete_upload_session(session_id: str):
"""
完成上传会话
"""
if session_id not in upload_sessions:
raise HTTPException(status_code=404, detail="上传会话不存在")
session = upload_sessions[session_id]
# 更新历史记录
history_manager.update(session_id, file_count=len(session["files"]))
# 清理会话(保留一段时间)
# upload_sessions.pop(session_id, None)
return {
"success": True,
"session_id": session_id,
"total_files": len(session["files"])
}
# ==================== 处理任务 API ====================
@app.get("/api/routes")
async def list_routes():
"""列出所有注册的路由(调试用)"""
routes = []
for route in app.routes:
if hasattr(route, 'path'):
routes.append({
"path": route.path,
"name": route.name if hasattr(route, 'name') else None,
"methods": list(route.methods) if hasattr(route, 'methods') else None
})
return {"routes": routes}
@app.post("/api/process/start/test")
async def test_process_start():
"""测试 /api/process/start 路由是否可访问"""
return {"success": True, "message": "/api/process/start 路由可访问"}
@app.get("/api/task/test")
async def test_task_api():
"""测试任务API是否正常工作"""
return {"success": True, "message": "任务API正常工作", "lock_status": global_task_lock}
@app.get("/api/task/status")
@app.post("/api/task/status")
async def get_global_task_status():
"""获取全局任务状态(是否有任务在上传或处理中)"""
# 自动清理:如果任务已完成但还锁定,则自动解锁
if global_task_lock["locked"]:
task_id = global_task_lock["task_id"]
if task_id:
# 检查任务是否已完成
record = history_manager.get(task_id)
if record and record.status in ["completed", "failed"]:
# 任务已完成但还锁定,自动解锁
global_task_lock["locked"] = False
global_task_lock["task_id"] = None
global_task_lock["stage"] = None
global_task_lock["started_at"] = None
return {"has_active": False}
# 检查内存中的任务状态
if task_id in processing_tasks:
task_status = processing_tasks[task_id].get("status")
if task_status in ["completed", "failed"]:
# 任务已完成但还锁定,自动解锁
global_task_lock["locked"] = False
global_task_lock["task_id"] = None
global_task_lock["stage"] = None
global_task_lock["started_at"] = None
return {"has_active": False}
# 任务还在进行中
return {
"has_active": True,
"task_id": global_task_lock["task_id"],
"stage": global_task_lock["stage"],
"started_at": global_task_lock["started_at"],
"logs": []
}
# 检查内存中正在处理的任务
active_tasks = {k: v for k, v in processing_tasks.items() if v.get("status") == "processing"}
if active_tasks:
task_id = list(active_tasks.keys())[0]
return {
"has_active": True,
"task_id": task_id,
"stage": "processing",
"logs": active_tasks[task_id].get("logs", [])
}
# 不检查历史记录,因为历史记录可能是旧的状态
# 如果服务器重启,历史记录中的 "processing" 状态可能是过期的
return {"has_active": False}
@app.post("/api/task/lock")
async def lock_task(task_id: str = Body(..., embed=True)):
"""锁定全局任务状态(开始上传时调用)"""
if global_task_lock["locked"]:
raise HTTPException(status_code=409, detail="已有任务在运行")
global_task_lock["locked"] = True
global_task_lock["task_id"] = task_id
global_task_lock["stage"] = "uploading"
global_task_lock["started_at"] = datetime.now().isoformat()
return {"success": True, "message": "任务已锁定"}
@app.post("/api/task/unlock")
async def unlock_task(task_id: str = Body(None, embed=True)):
"""解锁全局任务状态(上传失败或取消时调用)"""
# 只有锁定者或管理员可以解锁
if task_id and global_task_lock["task_id"] != task_id:
raise HTTPException(status_code=403, detail="无权解锁此任务")
global_task_lock["locked"] = False
global_task_lock["task_id"] = None
global_task_lock["stage"] = None
global_task_lock["started_at"] = None
return {"success": True, "message": "任务已解锁"}
@app.get("/api/process/active")
@app.post("/api/process/active")
async def get_active_task():
"""获取当前正在进行的任务(全局状态)- 兼容旧接口"""
return await get_global_task_status()
@app.post("/api/process/start")
async def start_processing(task_id: str = Body(..., embed=True)):
"""启动数据处理任务(task_id 放在 POST body 中)"""
record = history_manager.get(task_id)
if not record:
raise HTTPException(status_code=404, detail="任务不存在")
if record.status == "processing":
raise HTTPException(status_code=400, detail="任务正在处理中")
work_dir = Path(record.work_dir)
if not work_dir.exists():
raise HTTPException(status_code=400, detail="工作目录不存在")
# 创建日志记录器(实时写入 log.txt)
log_file = work_dir / "log.txt"
logs: List[str] = []
def log_callback(msg: str):
logs.append(msg)
processing_tasks[task_id] = {"logs": logs, "status": "processing"}
logger = ProcessLogger(log_file=log_file, callback=log_callback)
# 更新状态
history_manager.update(task_id, status="processing")
processing_tasks[task_id] = {"logs": logs, "status": "processing"}
# 更新全局锁定状态为处理中
global_task_lock["locked"] = True
global_task_lock["task_id"] = task_id
global_task_lock["stage"] = "processing"
global_task_lock["started_at"] = datetime.now().isoformat()
# 在后台线程执行处理
def run_processing():
try:
processor = DataProcessor(config, work_dir, logger)
result = processor.process()
# 更新历史记录(日志已写入文件,不需要再保存)
status = "completed" if result.get("success") else "failed"
history_manager.update(
task_id,
status=status,
elapsed_time=result.get("elapsed_time", 0),
error=result.get("error"),
result_tables=["4G_结果表", "5G_结果表"]
)
# 从文件读取最新日志
logs_from_file = history_manager.get_logs(task_id)
processing_tasks[task_id] = {"logs": logs_from_file, "status": status}
# 处理完成,解锁全局状态
global_task_lock["locked"] = False
global_task_lock["task_id"] = None
global_task_lock["stage"] = None
global_task_lock["started_at"] = None
except Exception as e:
history_manager.update(task_id, status="failed", error=str(e))
# 从文件读取最新日志
logs_from_file = history_manager.get_logs(task_id)
processing_tasks[task_id] = {"logs": logs_from_file, "status": "failed"}
# 处理失败,解锁全局状态
global_task_lock["locked"] = False
global_task_lock["task_id"] = None
global_task_lock["stage"] = None
global_task_lock["started_at"] = None
thread = Thread(target=run_processing)
thread.start()
return {"success": True, "message": "处理任务已启动", "task_id": task_id}
@app.post("/api/process/status")
async def get_processing_status(task_id: str = Body(..., embed=True)):
"""获取处理任务状态和日志(task_id 放在 POST body 中)"""
# 优先从文件读取日志(实时写入,保证最新)
logs = history_manager.get_logs(task_id)
# 检查内存中的实时状态(用于获取状态)
if task_id in processing_tasks:
return {
"task_id": task_id,
"status": processing_tasks[task_id]["status"],
"logs": logs # 使用文件中的日志
}
# 从历史记录获取
record = history_manager.get(task_id)
if not record:
raise HTTPException(status_code=404, detail="任务不存在")
return {
"task_id": task_id,
"status": record.status,
"logs": logs,
"elapsed_time": record.elapsed_time,
"error": record.error
}
# ==================== 历史记录 API ====================
@app.post("/api/history")
async def get_history(limit: int = Body(50, embed=True)):
"""获取处理历史记录"""
records = history_manager.list(limit)
return {"records": records}
@app.post("/api/history/delete")
async def delete_history(record_id: str = Body(..., embed=True)):
"""删除历史记录"""
if history_manager.delete(record_id):
return {"success": True, "message": "删除成功"}
raise HTTPException(status_code=404, detail="记录不存在")
@app.post("/api/history/clear")
async def clear_history():
"""清空所有历史记录"""
count = history_manager.clear()
return {"success": True, "deleted": count}
@app.post("/api/history/detail")
async def get_history_detail(record_id: str = Body(..., embed=True)):
"""获取历史记录详情(record_id 放在 POST body 中)"""
record = history_manager.get(record_id)
if not record:
raise HTTPException(status_code=404, detail="记录不存在")
# 从文件读取日志
logs = history_manager.get_logs(record_id)
result = record.to_dict()
result["logs"] = logs
return result
# ==================== 数据库管理 API ====================
@app.get("/api/database/test")
@app.post("/api/database/test")
async def test_database():
"""测试数据库连接(支持 GET 和 POST,无需传参)"""
db = DatabaseManager(config)
success, message = db.test_connection()
db.dispose()
return {"success": success, "message": message}
@app.get("/api/database/tables")
@app.post("/api/database/tables")
async def get_tables():
"""获取所有表(支持 GET 和 POST,无需传参)"""
try:
db = DatabaseManager(config)
tables = db.get_tables()
db.dispose()
return {"tables": tables}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/database/table/info")
async def get_table_info(table_name: str = Body(..., embed=True)):
"""获取表信息(table_name 放在 POST body 中)"""
try:
db = DatabaseManager(config)
info = db.get_table_info(table_name)
db.dispose()
return info
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/database/table/data")
async def query_table_data(
table_name: str = Body(..., embed=True),
page: int = Body(1),
page_size: int = Body(50),
order_by: Optional[str] = Body(None),
order_dir: str = Body("ASC")
):
"""分页查询表数据(参数放在 POST body 中)"""
try:
db = DatabaseManager(config)
result = db.query_table(table_name, page, page_size, order_by=order_by, order_dir=order_dir)
db.dispose()
return result
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/database/table/query")
async def query_table_with_filter(
table_name: str = Body(..., embed=True),
page: int = Body(1),
page_size: int = Body(50),
filters: Dict[str, str] = Body(default={}),
order_by: Optional[str] = Body(None),
order_dir: str = Body("ASC")
):
"""带筛选条件查询表数据(参数放在 POST body 中)"""
try:
db = DatabaseManager(config)
result = db.query_table(table_name, page, page_size, filters=filters, order_by=order_by, order_dir=order_dir)
db.dispose()
return result
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/database/table/truncate")
async def truncate_table(table_name: str = Body(..., embed=True)):
"""清空表数据(table_name 放在 POST body 中)"""
try:
db = DatabaseManager(config)
db.truncate_table(table_name)
db.dispose()
return {"success": True, "message": f"表 {table_name} 已清空"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/database/table/drop")
async def drop_table(table_name: str = Body(..., embed=True)):
"""删除表(table_name 放在 POST body 中)"""
try:
db = DatabaseManager(config)
db.drop_table(table_name)
db.dispose()
return {"success": True, "message": f"表 {table_name} 已删除"}
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"""
try:
db = DatabaseManager(config)
success, result = db.execute_sql(sql)
db.dispose()
if success:
return {"success": True, "result": result}
else:
raise HTTPException(status_code=400, detail=result)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ==================== 下载功能 API ====================
@app.post("/api/download")
async def download_table(
table_name: str = Body(..., embed=True),
format: str = Body("csv")
):
"""下载表数据(table_name/format 放在 POST body 中)"""
try:
db = DatabaseManager(config)
result = db.query_table(table_name, page=1, page_size=1000000) # 获取所有数据
db.dispose()
import pandas as pd
df = pd.DataFrame(result["data"])
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"{table_name}_{timestamp}.{format}"
filepath = CACHE_DIR / filename
if format == "csv":
df.to_csv(filepath, index=False, encoding='utf-8-sig')
media_type = "text/csv"
else:
df.to_excel(filepath, index=False)
media_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
return FileResponse(
path=str(filepath),
filename=filename,
media_type=media_type
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ==================== 配置 API ====================
@app.get("/api/config")
async def get_config():
"""获取当前配置(隐藏密码)"""
return config.to_dict()
@app.get("/api/config/full")
async def get_config_full():
"""获取完整配置(包含密码,用于编辑回显)"""
return config.to_dict_full()
@app.post("/api/config/mysql")
async def update_mysql_config(
host: str = Body(...),
port: int = Body(...),
user: str = Body(...),
passwd: str = Body(...),
dbname: str = Body(...)
):
"""更新数据库配置"""
global config
config.mysql.host = host
config.mysql.port = port
config.mysql.user = user
config.mysql.passwd = passwd
config.mysql.dbname = dbname
config.save()
return {"success": True, "message": "数据库配置已更新", "update": config.update}
@app.post("/api/config/sheet-filter")
async def update_sheet_filter(filters: List[str] = Body(...)):
"""更新 Sheet 过滤规则"""
global config
config.sheet_filter = filters
config.save()
return {"success": True, "message": "Sheet 过滤规则已更新", "update": config.update}
@app.post("/api/config/extract-fields")
async def update_extract_fields(fields: List[Dict[str, Any]] = Body(...)):
"""更新字段映射配置"""
global config
config.extract_fields = fields
config.save()
return {"success": True, "message": "字段映射配置已更新", "update": config.update}
# ==================== 清理 API ====================
@app.get("/api/cache/size")
@app.post("/api/cache/size")
async def get_cache_size():
"""获取 cache 目录占用大小(无需传参)"""
try:
total_size = 0
file_count = 0
dir_count = 0
def get_dir_size(path: Path):
"""递归计算目录大小"""
nonlocal total_size, file_count, dir_count
try:
if path.is_file():
total_size += path.stat().st_size
file_count += 1
elif path.is_dir():
dir_count += 1
for item in path.iterdir():
get_dir_size(item)
except (PermissionError, OSError):
pass
# 计算 cache 目录大小(排除 history.json)
for item in CACHE_DIR.iterdir():
if item.name != "history.json":
get_dir_size(item)
# 格式化大小
def format_size(size_bytes):
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
if size_bytes < 1024.0:
return f"{size_bytes:.2f} {unit}"
size_bytes /= 1024.0
return f"{size_bytes:.2f} PB"
return {
"success": True,
"size_bytes": total_size,
"size_formatted": format_size(total_size),
"file_count": file_count,
"dir_count": dir_count
}
except Exception as e:
return {
"success": False,
"error": str(e),
"size_formatted": "计算失败"
}
def get_dir_size(path: Path) -> int:
"""递归计算目录大小"""
total_size = 0
try:
if path.is_file():
total_size += path.stat().st_size
elif path.is_dir():
for item in path.iterdir():
total_size += get_dir_size(item)
except (PermissionError, OSError):
pass
return total_size
def format_size(size_bytes: int) -> str:
"""格式化文件大小"""
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
if size_bytes < 1024.0:
return f"{size_bytes:.2f} {unit}"
size_bytes /= 1024.0
return f"{size_bytes:.2f} PB"
@app.post("/api/history/size")
async def get_history_size(record_id: str = Body(..., embed=True)):
"""获取单个历史记录的占用大小"""
record = history_manager.get(record_id)
if not record:
raise HTTPException(status_code=404, detail="记录不存在")
work_dir = Path(record.work_dir)
if not work_dir.exists():
return {"success": True, "size": 0, "size_formatted": "0 B"}
try:
size = get_dir_size(work_dir)
return {
"success": True,
"size": size,
"size_formatted": format_size(size)
}
except Exception as e:
return {
"success": False,
"error": str(e),
"size_formatted": "计算失败"
}
# ==================== 服务管理 API ====================
def is_supervisor_running() -> bool:
"""检查是否在 supervisor 环境下运行"""
# 检查 supervisor socket 文件是否存在
supervisor_sock = Path("/var/run/supervisor.sock")
if supervisor_sock.exists():
return True
# 检查环境变量
if os.environ.get("SUPERVISOR_ENABLED") == "1":
return True
# 检查父进程是否是 supervisord
try:
import psutil # pyright: ignore[reportMissingModuleSource]
current = psutil.Process()
parent = current.parent()
if parent and "supervisor" in parent.name().lower():
return True
except:
pass
return False
def restart_via_supervisor() -> tuple:
"""通过 supervisor 重启服务"""
try:
result = subprocess.run(
["supervisorctl", "restart", "fastapi"],
capture_output=True,
text=True,
timeout=30
)
if result.returncode == 0:
return True, "服务正在通过 supervisor 重启..."
else:
return False, f"重启失败: {result.stderr or result.stdout}"
except subprocess.TimeoutExpired:
return False, "重启操作超时"
except FileNotFoundError:
return False, "找不到 supervisorctl 命令"
except Exception as e:
return False, f"重启异常: {str(e)}"
def restart_via_signal() -> tuple:
"""通过信号重启服务(适用于非 supervisor 环境)"""
try:
# 获取当前进程 PID
pid = os.getpid()
if platform.system() == "Windows":
# Windows: 通过结束进程的方式触发重启
# 需要配合外部重启机制(如 Docker restart policy 或 bat 脚本)
os._exit(0)
else:
# Linux/Mac: 发送 SIGHUP 信号让进程重启
# 或者直接退出,依赖外部重启机制
os.kill(pid, signal.SIGTERM)
return True, "服务正在重启..."
except Exception as e:
return False, f"重启失败: {str(e)}"
@app.post("/api/service/restart")
async def restart_service():
"""
重启服务
- 在 supervisor 环境下使用 supervisorctl restart
- 在非 supervisor 环境下发送信号或退出进程
"""
# 检查是否在 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"
}
return {
"success": success,
"message": message,
"method": "supervisor" if is_supervisor_running() else "signal"
}
@app.get("/api/service/status")
async def get_service_status():
"""获取服务运行状态"""
return {
"status": "running",
"version": "2.0.0",
"platform": platform.system(),
"supervisor": is_supervisor_running(),
"pid": os.getpid(),
"python_version": platform.python_version()
}
# ==================== 启动入口 ====================
if __name__ == "__main__":
import uvicorn
print(f"CapacityReport v2.0.0")
print(f"配置更新时间: {config.update}")
uvicorn.run("app.main:app", host="0.0.0.0", port=9081, reload=False)
+426
View File
@@ -0,0 +1,426 @@
"""
数据处理核心模块 - 性能优化版
"""
import os
import re
import time
import zipfile
import multiprocessing
import numpy as np
import pandas as pd
import sqlparse
from pathlib import Path
from typing import Any, Callable, Dict, Generator, List, Optional, Tuple
from datetime import datetime
from concurrent.futures import ThreadPoolExecutor, as_completed
from io import StringIO
from app.config import AppConfig, SQL_SCRIPT
from app.database import DatabaseManager
class ProcessLogger:
"""处理日志记录器"""
def __init__(self, log_file: Optional[Path] = None, callback: Optional[Callable[[str], None]] = None):
self.logs: List[str] = []
self.log_file = log_file
self.callback = callback
# 如果指定了日志文件,确保目录存在
if self.log_file:
self.log_file.parent.mkdir(parents=True, exist_ok=True)
# 清空或创建日志文件
self.log_file.write_text("", encoding='utf-8')
def log(self, message: str, level: str = "INFO"):
"""记录日志"""
timestamp = datetime.now().strftime("%H:%M:%S")
entry = f"[{timestamp}] [{level}] {message}"
self.logs.append(entry)
# 实时写入文件
if self.log_file:
try:
with self.log_file.open("a", encoding='utf-8') as f:
f.write(entry + "\n")
except Exception as e:
# 如果写入失败,至少记录到内存
print(f"写入日志文件失败: {e}")
if self.callback:
self.callback(entry)
def info(self, message: str):
self.log(message, "INFO")
def error(self, message: str):
self.log(message, "ERROR")
def warning(self, message: str):
self.log(message, "WARN")
def success(self, message: str):
self.log(message, "SUCCESS")
def get_logs(self) -> List[str]:
return self.logs.copy()
class DataProcessor:
"""数据处理器 - 高性能版"""
# 批量插入大小(根据实际测试,5000 是比较好的平衡点)
BATCH_SIZE = 5000
# Excel 并行处理的最大线程数(根据 CPU 核心数自动调整)
# 使用 CPU 核心数,但至少为 1,最多不超过 8(避免过多线程导致上下文切换开销)
MAX_WORKERS = min(max(multiprocessing.cpu_count(), 1), 8)
def __init__(self, config: AppConfig, work_dir: Path, logger: ProcessLogger):
self.config = config
self.work_dir = work_dir
self.logger = logger
self.db = DatabaseManager(config)
self.results: Dict[str, Any] = {}
# 预编译字段映射,避免重复查找
self._field_map = self._build_field_map()
def _build_field_map(self) -> Dict[str, str]:
"""预构建字段映射表,提高查找效率"""
field_map = {}
for field_def in self.config.extract_fields:
db_field = field_def.get("Field")
for extract_name in field_def.get("Extract", []):
field_map[extract_name] = db_field
return field_map
def process(self) -> Dict[str, Any]:
"""执行完整的数据处理流程"""
start_time = time.time()
self.logger.info(f"开始处理数据,工作目录: {self.work_dir}")
try:
# 1. 解压 ZIP 文件
self._unzip_files()
# 2. 处理 Excel 文件(并行)
self._process_excel_files_parallel()
# 3. 处理 CSV 文件并上传到数据库(高性能批量插入)
self._process_csv_files()
# 4. 执行 SQL 脚本
self._execute_sql_script()
elapsed = round(time.time() - start_time, 2)
self.logger.success(f"处理完成!总耗时: {elapsed} 秒")
self.results["success"] = True
self.results["elapsed_time"] = elapsed
except Exception as e:
self.logger.error(f"处理失败: {str(e)}")
self.results["success"] = False
self.results["error"] = str(e)
finally:
self.db.dispose()
return self.results
def _unzip_files(self):
"""解压所有 ZIP 文件(支持中文文件名)"""
self.logger.info("正在解压 ZIP 文件...")
zip_files = list(self.work_dir.rglob("*.zip"))
zip_count = 0
for zip_file in zip_files:
try:
rel_path = zip_file.relative_to(self.work_dir)
self.logger.info(f"解压: {rel_path}")
self._extract_zip_with_encoding(zip_file)
zip_count += 1
except Exception as e:
rel_path = zip_file.relative_to(self.work_dir)
self.logger.error(f"解压失败 {rel_path}: {e}")
self.logger.info(f"ZIP 解压完成,共 {zip_count} 个文件")
def _extract_zip_with_encoding(self, zip_file: Path):
"""
解压 ZIP 文件,自动处理中文文件名编码问题
支持 UTF-8、GBK、CP437 等多种编码
"""
# 优先尝试 UTF-8(现代 ZIP 文件标准)
try:
with zipfile.ZipFile(zip_file, 'r', metadata_encoding='utf-8') as zf:
zf.extractall(zip_file.parent)
return
except (UnicodeDecodeError, zipfile.BadZipFile):
# UTF-8 失败,尝试 GBK(Windows 中文系统常用)
try:
with zipfile.ZipFile(zip_file, 'r', metadata_encoding='gbk') as zf:
zf.extractall(zip_file.parent)
return
except (UnicodeDecodeError, zipfile.BadZipFile):
# GBK 也失败,尝试 CP437(DOS 编码)
try:
with zipfile.ZipFile(zip_file, 'r', metadata_encoding='cp437') as zf:
zf.extractall(zip_file.parent)
return
except Exception as e:
# 所有编码都失败
raise Exception(f"无法解压 ZIP 文件,编码检测失败: {e}")
def _scan_files(self, directory: Path, extensions: List[str]) -> Generator[Path, None, None]:
"""扫描指定扩展名的文件"""
for ext in extensions:
for file in directory.rglob(f"*{ext}"):
yield file
def _process_single_excel(self, excel_file: Path, sheet_filter: set) -> int:
"""处理单个 Excel 文件(用于并行)"""
processed = 0
try:
rel_path = excel_file.relative_to(self.work_dir)
# 使用 openpyxl 的 read_only 模式会更快,但这里保持兼容性
xl = pd.ExcelFile(excel_file, engine='openpyxl')
for sheet_name in xl.sheet_names:
if sheet_name not in sheet_filter:
output_file = excel_file.parent / f"{excel_file.stem}_{sheet_name}.csv"
# 直接读取并写入,不做额外处理
df = xl.parse(sheet_name)
df.to_csv(output_file, index=False, encoding='utf-8')
processed += 1
xl.close()
return processed
except Exception as e:
rel_path = excel_file.relative_to(self.work_dir)
self.logger.error(f"Excel 处理失败 {rel_path}: {e}")
return 0
def _process_excel_files_parallel(self):
"""并行处理 Excel 文件"""
self.logger.info("正在并行处理 Excel 文件...")
excel_files = list(self._scan_files(self.work_dir, ['.xlsx', '.xls']))
self.logger.info(f"找到 {len(excel_files)} 个 Excel 文件")
if not excel_files:
return
sheet_filter = set(self.config.sheet_filter)
total_processed = 0
# 使用线程池并行处理
with ThreadPoolExecutor(max_workers=self.MAX_WORKERS) as executor:
futures = {
executor.submit(self._process_single_excel, f, sheet_filter): f
for f in excel_files
}
for future in as_completed(futures):
excel_file = futures[future]
try:
count = future.result()
total_processed += count
rel_path = excel_file.relative_to(self.work_dir)
if count > 0:
self.logger.info(f"处理完成: {rel_path} ({count} 个 sheet)")
except Exception as e:
rel_path = excel_file.relative_to(self.work_dir)
self.logger.error(f"Excel 处理异常 {rel_path}: {e}")
self.logger.info(f"Excel 处理完成,共生成 {total_processed} 个 CSV 文件")
def _detect_encoding(self, file_path: Path) -> str:
"""快速检测文件编码(只读取前 8KB)"""
import chardet
with open(file_path, 'rb') as f:
# 只读取前 8KB,足够检测编码,比 64KB 快很多
result = chardet.detect(f.read(8192))
encoding = result.get('encoding', 'utf-8') or 'utf-8'
encoding = encoding.lower()
if 'utf' in encoding:
return 'utf-8'
elif 'gb' in encoding:
return 'gbk'
return 'utf-8'
def _process_csv_file_fast(self, csv_file: Path, table_name: str) -> int:
"""
高性能处理单个 CSV 文件
使用批量插入代替 to_sql,性能提升 5-10 倍
"""
encoding = self._detect_encoding(csv_file)
rel_path = csv_file.relative_to(self.work_dir)
self.logger.info(f"处理 CSV: {rel_path} (编码: {encoding})")
# 读取 CSV,使用优化参数
df = pd.read_csv(
csv_file,
encoding=encoding,
thousands=',',
low_memory=True, # 低内存模式
dtype=str, # 全部作为字符串读取,避免类型推断开销
na_values=[''], # 只把空字符串当作 NA
keep_default_na=False # 不使用默认的 NA 值
)
# 快速字段匹配(使用预编译的映射表)
col_mapping = {}
for col in df.columns:
if col in self._field_map:
col_mapping[col] = self._field_map[col]
if len(col_mapping) <= 3:
if 'kpis' in str(csv_file).lower():
self.logger.warning(f"跳过非数据文件: {rel_path}")
return 0
raise ValueError(f"字段匹配不足: {rel_path}")
# 选择需要的列并重命名
source_cols = list(col_mapping.keys())
target_cols = list(col_mapping.values())
df_result = df[source_cols]
# 向量化数据清洗(比逐列循环快 10 倍以上)
# 替换 NA 为 '0',去除百分号,截断长度
df_result = df_result.fillna('0')
# 使用 numpy 向量化操作
for col in df_result.columns:
# 去除百分号
df_result[col] = df_result[col].str.replace('%', '', regex=False)
# 截断超长字符串
mask = df_result[col].str.len() > 200
if mask.any():
df_result.loc[mask, col] = df_result.loc[mask, col].str[:200]
# 确保表存在
self.db.create_table_from_columns(table_name, target_cols)
# 转换为元组列表,用于批量插入
# 这比 to_sql 快很多
data_tuples = [tuple(row) for row in df_result.values]
# 使用批量插入
inserted = self.db.bulk_insert(table_name, target_cols, data_tuples, self.BATCH_SIZE)
return inserted
def _find_data_directories(self) -> Dict[str, Path]:
"""
查找包含数据文件的目录,返回 {表名: 目录路径}
"""
data_dirs = {}
target_names = {'4G', '5G', '4g', '5g'}
self.logger.info(f"开始查找数据目录,工作目录: {self.work_dir}")
# 递归查找所有名为 4G 或 5G 的目录
found_dirs = []
for subdir in self.work_dir.rglob('*'):
if subdir.is_dir() and subdir.name in target_names:
found_dirs.append(subdir)
self.logger.info(f"找到 {len(found_dirs)} 个候选目录")
for subdir in found_dirs:
table_name = f"{subdir.name.upper()}_UD"
if table_name not in data_dirs:
data_dirs[table_name] = subdir
self.logger.info(f"发现数据目录: {subdir.relative_to(self.work_dir)} -> 表: {table_name}")
if not data_dirs:
self.logger.warning("未找到 4G/5G 目录,使用直接子目录")
for subdir in self.work_dir.iterdir():
if subdir.is_dir():
table_name = f"{subdir.name}_UD"
data_dirs[table_name] = subdir
self.logger.info(f"使用直接子目录: {subdir.relative_to(self.work_dir)} -> 表: {table_name}")
return data_dirs
def _process_csv_files(self):
"""处理所有 CSV 文件(高性能版)"""
self.logger.info("正在处理 CSV 文件并上传到数据库...")
# 查找数据目录
data_dirs = self._find_data_directories()
if not data_dirs:
self.logger.warning("未找到任何数据目录")
return
# 按目录分组处理
for table_name, subdir in data_dirs.items():
self.logger.info(f"处理目录: {subdir.relative_to(self.work_dir)} -> 表: {table_name}")
# 删除旧表
self.db.drop_table(table_name)
# 处理该目录下的所有 CSV
csv_files = list(self._scan_files(subdir, ['.csv']))
self.logger.info(f"找到 {len(csv_files)} 个 CSV 文件")
total_rows = 0
start_time = time.time()
for i, csv_file in enumerate(csv_files, 1):
try:
rows = self._process_csv_file_fast(csv_file, table_name)
total_rows += rows
# 每处理 10 个文件报告一次进度
if i % 10 == 0:
elapsed = round(time.time() - start_time, 1)
self.logger.info(f"进度: {i}/{len(csv_files)} 文件, 已导入 {total_rows} 行, 耗时 {elapsed}s")
except Exception as e:
rel_path = csv_file.relative_to(self.work_dir)
self.logger.error(f"CSV 处理失败 {rel_path}: {e}")
elapsed = round(time.time() - start_time, 2)
speed = round(total_rows / elapsed) if elapsed > 0 else 0
self.logger.success(f"表 {table_name} 导入完成: {total_rows} 行, 耗时 {elapsed}s, 速度 {speed} 行/秒")
def _execute_sql_script(self):
"""执行 SQL 脚本"""
if not SQL_SCRIPT.exists():
self.logger.warning("SQL 脚本文件不存在,跳过")
return
self.logger.info("正在执行 SQL 脚本...")
with open(SQL_SCRIPT, 'r', encoding='utf-8') as f:
sql_text = f.read()
sqls = sqlparse.split(sql_text)
total = len(sqls)
with self.db.get_connection() as conn:
with conn.cursor() as cursor:
for i, sql in enumerate(sqls, 1):
sql = sql.strip()
if not sql or sql.startswith('#'):
continue
start_time = time.time()
preview = sql[:80].replace('\n', ' ')
self.logger.info(f"执行 SQL ({i}/{total}): {preview}...")
try:
cursor.execute(sql)
elapsed = round(time.time() - start_time, 2)
self.logger.info(f"完成,耗时 {elapsed} 秒")
except Exception as e:
self.logger.error(f"SQL 执行失败: {e}")
conn.commit()
self.logger.success("SQL 脚本执行完成")
+22
View File
@@ -0,0 +1,22 @@
# Web Framework
fastapi
uvicorn[standard]
python-multipart
# Database
pymysql
sqlalchemy
cryptography
# Data Processing
pandas
openpyxl
chardet
sqlparse
# Utilities
aiofiles
python-dateutil
# Process Manager
supervisor
+40
View File
@@ -0,0 +1,40 @@
@echo off
chcp 65001 >nul
title CapacityReport v2.0.0 [自动重启模式]
echo ========================================
echo CapacityReport - 容量报表处理程序
echo 自动重启模式
echo ========================================
echo.
cd /d "%~dp0"
:: 检查 Python 环境
python --version >nul 2>&1
if errorlevel 1 (
echo [错误] 未找到 Python,请先安装 Python 3.8+
pause
exit /b 1
)
:: 检查依赖
pip show fastapi >nul 2>&1
if errorlevel 1 (
echo [提示] 正在安装依赖...
pip install -r requirements.txt
)
:loop
echo.
echo [%date% %time%] 启动服务...
echo [启动] 服务地址: http://localhost:9081
echo [提示] 关闭此窗口可完全停止服务
echo.
python -m app.main
echo.
echo [%date% %time%] 服务已停止,3秒后自动重启...
timeout /t 3 /nobreak >nul
goto loop
+1992
View File
File diff suppressed because it is too large Load Diff
+414
View File
@@ -0,0 +1,414 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>容量报表处理程序</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📊</text></svg>">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<div class="app">
<!-- 侧边栏导航 -->
<aside class="sidebar" id="sidebar">
<div class="logo">
<div class="logo-icon">📊</div>
<span class="logo-text">CapacityReport</span>
</div>
<nav class="nav">
<a href="#" class="nav-item active" data-page="upload" title="数据上传">
<span class="nav-icon">📤</span>
<span>数据上传</span>
</a>
<a href="#" class="nav-item" data-page="history" title="处理历史">
<span class="nav-icon">📋</span>
<span>处理历史</span>
</a>
<a href="#" class="nav-item" data-page="database" title="数据库管理">
<span class="nav-icon">🗄️</span>
<span>数据库管理</span>
</a>
<a href="#" class="nav-item" data-page="settings" title="系统设置">
<span class="nav-icon">⚙️</span>
<span>系统设置</span>
</a>
</nav>
<div class="sidebar-footer" data-version="v2.0.0">
<div class="sidebar-footer-info">
<span class="version">版本:v2.0.0</span>
<span class="version">Power by:NIXEVOL</span>
</div>
</div>
</aside>
<!-- 主内容区 -->
<main class="main-content">
<!-- 上传页面 -->
<section id="page-upload" class="page active">
<header class="page-header">
<button class="sidebar-toggle" id="sidebarToggle" title="展开/收起侧边栏">
<span id="sidebarToggleIcon">☰</span>
</button>
<div class="page-header-content">
<h1>数据上传与处理</h1>
</div>
<div class="page-header-actions">
<button class="theme-toggle" id="themeToggle" title="切换主题">
<span>🌓</span>
</button>
<button class="restart-btn" id="restartService" title="重启服务">
<span>⏻</span>
</button>
</div>
</header>
<div class="page-body">
<div class="upload-zone" id="uploadZone">
<div class="upload-icon">📁</div>
<h3>拖拽文件夹或文件到这里</h3>
<p>支持 .zip, .xlsx, .xls, .csv 格式</p>
<p class="upload-hint">或者点击选择文件</p>
<input type="file" id="fileInput" multiple webkitdirectory directory>
<input type="file" id="fileInputSingle" multiple accept=".zip,.xlsx,.xls,.csv" style="display:none">
</div>
<div class="file-list" id="fileList" style="display: none;">
<div class="file-list-header">
<h4>已选择文件</h4>
<button class="btn btn-sm btn-text" id="clearFiles">清空</button>
</div>
<div class="file-list-footer">
<span id="fileCount">0 个文件</span>
<button class="btn btn-primary" id="startUpload">
<span>🚀</span> 上传并处理
</button>
</div>
<!-- 总进度 -->
<div class="total-progress" id="totalProgress" style="display: none; padding: 16px;">
<div class="total-progress-header">
<span class="label">上传进度</span>
<span class="percent" id="totalPercent">0%</span>
</div>
<div class="total-progress-bar">
<div class="total-progress-bar-inner" id="totalProgressBar"></div>
</div>
</div>
<!-- 上传统计 -->
<div class="upload-stats" id="uploadStats" style="display: none; margin: 0 16px;">
<div class="upload-stats-item">
<span>总计:</span>
<span class="count" id="statsTotal">0</span>
</div>
<div class="upload-stats-item uploading">
<span>上传中:</span>
<span class="count" id="statsUploading">0</span>
</div>
<div class="upload-stats-item success">
<span>已完成:</span>
<span class="count" id="statsSuccess">0</span>
</div>
<div class="upload-stats-item error">
<span>失败:</span>
<span class="count" id="statsError">0</span>
</div>
</div>
<div class="file-items" id="fileItems"></div>
</div>
<!-- 处理状态 -->
<div class="process-section" id="processSection" style="display: none;">
<div class="card">
<div class="card-header">
<div class="card-header-left">
<span class="card-title">处理进度</span>
<span class="process-status processing" id="processStatus">处理中...</span>
</div>
<div class="card-header-right">
<label class="auto-refresh-toggle">
<input type="checkbox" id="autoRefreshLog" checked>
<span>自动刷新</span>
</label>
<button class="btn btn-sm btn-outline" id="manualRefreshLog" title="手动刷新日志">
<span>🔄</span>
</button>
</div>
</div>
<div class="card-body">
<div class="log-container" id="logContainer">
<div class="log-content" id="logContent"></div>
</div>
</div>
<div class="card-footer process-actions" id="processActions" style="display: none;">
<button class="btn btn-success" id="downloadResult">
<span>📥</span> 下载结果
</button>
<button class="btn btn-outline" id="newProcess">
<span>🔄</span> 新建任务
</button>
</div>
</div>
</div>
</div>
</section>
<!-- 历史记录页面 -->
<section id="page-history" class="page">
<header class="page-header">
<button class="sidebar-toggle" id="sidebarToggle" title="展开/收起侧边栏">
<span id="sidebarToggleIcon">☰</span>
</button>
<div class="page-header-content">
<h1>处理历史</h1>
</div>
<div class="page-header-actions">
<button class="theme-toggle" id="themeToggle" title="切换主题">
<span>🌓</span>
</button>
<button class="restart-btn" id="restartService" title="重启服务">
<span>⏻</span>
</button>
</div>
</header>
<div class="page-body">
<div class="toolbar">
<div class="toolbar-left">
<button class="btn btn-outline" id="refreshHistory">
<span>🔄</span> 刷新
</button>
<button class="btn btn-danger" id="clearHistory">
<span>🗑️</span> 清空历史
</button>
</div>
<div class="toolbar-right">
<span class="total-size">总占用: <span id="totalHistorySize">计算中...</span></span>
</div>
</div>
<div class="history-list" id="historyList">
<div class="empty-state">
<span class="empty-icon">📭</span>
<p>暂无处理记录</p>
</div>
</div>
</div>
</section>
<!-- 数据库管理页面 -->
<section id="page-database" class="page">
<header class="page-header">
<button class="sidebar-toggle" id="sidebarToggle" title="展开/收起侧边栏">
<span id="sidebarToggleIcon">☰</span>
</button>
<div class="page-header-content">
<h1>数据库管理</h1>
</div>
<div class="page-header-actions">
<button class="theme-toggle" id="themeToggle" title="切换主题">
<span>🌓</span>
</button>
<button class="restart-btn" id="restartService" title="重启服务">
<span>⏻</span>
</button>
</div>
</header>
<div class="page-body" style="display: flex; flex-direction: column; height: calc(100% - 64px);">
<div class="database-layout" style="flex: 1; overflow: hidden;">
<div class="db-sidebar">
<div class="db-connection" id="dbConnection">
<span class="status-dot"></span>
<span>检测连接中...</span>
</div>
<div class="table-list" id="tableList">
<div class="loading">加载中...</div>
</div>
</div>
<div class="db-content">
<div class="db-toolbar" id="dbToolbar" style="display: none;">
<div class="toolbar-left">
<span class="current-table" id="currentTable"></span>
<span class="row-count" id="rowCount"></span>
</div>
<div class="toolbar-right">
<input type="text" class="search-input" id="searchInput" placeholder="搜索...">
<button class="btn btn-sm btn-primary" id="downloadTable">📥 导出</button>
<button class="btn btn-sm btn-warning" id="truncateTable">清空</button>
<button class="btn btn-sm btn-danger" id="dropTable">删除</button>
</div>
</div>
<div class="data-table-container" id="dataTableContainer">
<div class="empty-state">
<span class="empty-icon">👈</span>
<p>请选择左侧的数据表</p>
</div>
</div>
<div class="pagination" id="pagination" style="display: none;">
<button class="btn btn-sm btn-outline" id="prevPage">上一页</button>
<span id="pageInfo">第 1 页</span>
<button class="btn btn-sm btn-outline" id="nextPage">下一页</button>
</div>
</div>
</div>
</div>
</section>
<!-- 设置页面 -->
<section id="page-settings" class="page">
<header class="page-header">
<button class="sidebar-toggle" id="sidebarToggle" title="展开/收起侧边栏">
<span id="sidebarToggleIcon">☰</span>
</button>
<div class="page-header-content">
<h1>系统设置</h1>
</div>
<div class="page-header-actions">
<button class="theme-toggle" id="themeToggle" title="切换主题">
<span>🌓</span>
</button>
<button class="restart-btn" id="restartService" title="重启服务">
<span>⏻</span>
</button>
</div>
</header>
<div class="page-body settings-page-body">
<!-- 左侧列 -->
<div class="settings-column">
<!-- 数据库配置 -->
<div class="card settings-card">
<div class="card-header">
<span class="card-title">数据库配置</span>
<span class="config-update-time" id="configUpdate">-</span>
</div>
<div class="card-body">
<div class="form-row">
<div class="form-group">
<label>主机地址</label>
<input type="text" id="configHost" class="form-input" placeholder="localhost">
</div>
<div class="form-group form-group-sm">
<label>端口</label>
<input type="number" id="configPort" class="form-input" placeholder="3306">
</div>
</div>
<div class="form-group">
<label>数据库名</label>
<input type="text" id="configDbname" class="form-input" placeholder="CapacityReport">
</div>
<div class="form-row">
<div class="form-group">
<label>用户名</label>
<input type="text" id="configUser" class="form-input" placeholder="root">
</div>
<div class="form-group">
<label>密码</label>
<div class="password-input-wrapper">
<input type="password" id="configPasswd" class="form-input" placeholder="输入密码">
<button type="button" class="password-toggle" id="togglePassword" title="显示/隐藏密码">
<span class="eye-icon">👁</span>
</button>
</div>
</div>
</div>
</div>
<div class="card-footer">
<button class="btn btn-primary" id="saveMysqlConfig">保存配置</button>
<button class="btn btn-outline" id="testDbConnection">测试连接</button>
</div>
</div>
<!-- Sheet 过滤规则 -->
<div class="card settings-card">
<div class="card-header">
<span class="card-title">Sheet 过滤规则</span>
</div>
<div class="card-body">
<p class="form-hint">匹配这些关键词的 Sheet 将被跳过处理</p>
<div class="filter-list" id="sheetFilterList">
<!-- 动态生成 -->
</div>
<div class="add-filter-row">
<input type="text" id="newSheetFilter" class="form-input" placeholder="输入过滤关键词">
<button class="btn btn-outline btn-sm" id="addSheetFilter">添加</button>
</div>
</div>
<div class="card-footer">
<button class="btn btn-primary" id="saveSheetFilter">保存规则</button>
</div>
</div>
</div>
<!-- 右侧列 - 字段映射 -->
<div class="settings-column settings-column-wide">
<div class="card settings-card field-mapping-card">
<div class="card-header">
<span class="card-title">字段映射配置</span>
<span class="field-count" id="fieldCount">0 个字段</span>
</div>
<div class="card-body">
<p class="form-hint">定义 Excel 列名到数据库字段的映射规则,提取来源匹配任意一个即可</p>
<div class="field-mapping-list" id="fieldMappingList">
<!-- 动态生成 -->
</div>
</div>
<div class="card-footer">
<button class="btn btn-outline" id="addFieldMapping">添加字段</button>
<button class="btn btn-primary" id="saveFieldMapping">保存映射</button>
</div>
</div>
</div>
</div>
</section>
</main>
</div>
<!-- Toast 通知 -->
<div class="toast-container" id="toastContainer"></div>
<!-- 重启服务加载遮罩 -->
<div class="restart-overlay" id="restartOverlay">
<div class="restart-overlay-content">
<div class="restart-overlay-spinner"></div>
<div class="restart-overlay-text">正在重启服务...</div>
</div>
</div>
<!-- 确认对话框 -->
<div class="modal" id="confirmModal">
<div class="modal-backdrop"></div>
<div class="modal-content">
<h3 id="confirmTitle">确认操作</h3>
<p id="confirmMessage">确定要执行此操作吗?</p>
<div class="modal-actions">
<button class="btn btn-outline" id="confirmCancel">取消</button>
<button class="btn btn-danger" id="confirmOk">确定</button>
</div>
</div>
</div>
<!-- 下载选择对话框 -->
<div class="modal" id="downloadModal">
<div class="modal-backdrop"></div>
<div class="modal-content">
<h3>选择下载的数据表</h3>
<div class="download-options">
<button class="btn btn-lg btn-primary download-option" data-table="4G_结果表">
<span>📊</span> 4G 结果表
</button>
<button class="btn btn-lg btn-primary download-option" data-table="5G_结果表">
<span>📊</span> 5G 结果表
</button>
</div>
<div class="modal-actions">
<button class="btn btn-outline" id="downloadCancel">关闭</button>
</div>
</div>
</div>
<script src="/static/js/app.js"></script>
</body>
</html>
+1973
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
[supervisord]
nodaemon=true
logfile=/var/log/supervisor/supervisord.log
pidfile=/var/run/supervisord.pid
childlogdir=/var/log/supervisor
user=root
[unix_http_server]
file=/var/run/supervisor.sock
chmod=0700
[rpcinterface:supervisor]
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface
[supervisorctl]
serverurl=unix:///var/run/supervisor.sock
[program:fastapi]
command=uvicorn app.main:app --host 0.0.0.0 --port 9081 --workers 1
directory=/app
autostart=true
autorestart=true
startsecs=5
startretries=3
redirect_stderr=true
stdout_logfile=/var/log/supervisor/fastapi.log
stdout_logfile_maxbytes=50MB
stdout_logfile_backups=5
environment=PYTHONUNBUFFERED=1