refactor: 重构项目结构,按功能模块拆分文件
- 后端:将 app.py 拆分为 lib/api/ 目录下的多个蓝图模块 - config_api.py: 配置管理 API - entity_api.py: 实体管理 API - field_api.py: 字段列表 API - report_api.py: 日报生成和历史记录 API - init_api.py: 数据库初始化 API - download_api.py: 文件下载 API - 前端:将 index.html 拆分为多个文件 - static/css/style.css: CSS 样式 - static/js/common.js: 通用工具函数 - static/js/config.js: 配置管理 - static/js/entity.js: 实体管理 - static/js/report.js: 日报生成 - static/js/history.js: 历史记录 - 更新 app.py 使用蓝图注册 API - 创建 AGENTS.md 项目文档 Coze-Commit-Type: user Coze-User-ID: 3722323274763196 Coze-Conversation-ID: 9894087
This commit is contained in:
157
AGENTS.md
Normal file
157
AGENTS.md
Normal file
@@ -0,0 +1,157 @@
|
||||
# 订单日报系统 - 项目开发指南
|
||||
|
||||
## 项目概述
|
||||
|
||||
订单日报系统是一个基于 Flask 的 Web 应用,用于自动生成充电订单的日报报表。系统支持按企业/用户/场站拆分数据,自定义字段和求和功能,并支持合并特来电平台数据。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **后端**: Python 3.12 + Flask 3.0
|
||||
- **数据库**: Apache Doris (MySQL 兼容)
|
||||
- **前端**: 原生 HTML/CSS/JavaScript
|
||||
- **依赖**: PyMySQL, APScheduler, openpyxl, Flask-CORS
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
.
|
||||
├── app.py # Flask 主应用(页面路由 + 定时任务)
|
||||
├── lib/
|
||||
│ ├── __init__.py
|
||||
│ ├── db.py # 数据库连接配置
|
||||
│ ├── field_mapping.py # 字段映射定义
|
||||
│ ├── report_generator.py # 日报生成核心逻辑
|
||||
│ ── api/ # API 蓝图模块
|
||||
│ ├── __init__.py # 蓝图注册
|
||||
│ ├── config_api.py # 配置管理 API
|
||||
│ ├── entity_api.py # 实体管理 API(企业/用户/场站)
|
||||
│ ├── field_api.py # 字段列表 API
|
||||
│ ├── report_api.py # 日报生成和历史记录 API
|
||||
│ ├── init_api.py # 数据库初始化 API
|
||||
│ └── download_api.py # 文件下载 API
|
||||
├── static/
|
||||
│ ├── css/
|
||||
│ │ └── style.css # 全局样式
|
||||
│ └── js/
|
||||
│ ├── common.js # 通用工具函数、标签切换
|
||||
│ ├── config.js # 配置管理(创建/编辑/复制/删除)
|
||||
│ ├── entity.js # 实体管理(分页/搜索)
|
||||
│ ├── report.js # 日报生成
|
||||
│ └── history.js # 历史记录(查看/下载/删除)
|
||||
── templates/
|
||||
│ └── index.html # 主页面 HTML 结构
|
||||
└── public/
|
||||
└── reports/ # 生成的报表文件目录
|
||||
```
|
||||
|
||||
## 核心功能模块
|
||||
|
||||
### 1. 配置管理 (`lib/api/config_api.py`)
|
||||
- 创建/编辑/删除/复制配置
|
||||
- 启用/禁用配置
|
||||
- 配置排序(上移/下移)
|
||||
- 配置字段:
|
||||
- `config_name`: 配置名称
|
||||
- `split_type`: 拆分方式(company_id/user_id/station_id)
|
||||
- `split_value`: 拆分值(支持多选,逗号分隔)
|
||||
- `selected_fields`: 选择的字段列表(JSON)
|
||||
- `sum_fields`: 求和字段列表(JSON)
|
||||
- `time_periods`: 分时段电量配置(JSON)
|
||||
- `merge_telecom`: 是否合并特来电数据
|
||||
- `telecom_vehicle_no`: 特来电车量自编号
|
||||
- `field_custom_names`: 字段自定义报表名头(JSON)
|
||||
|
||||
### 2. 实体管理 (`lib/api/entity_api.py`)
|
||||
- 企业列表(t_company,company_state=0 为启用)
|
||||
- 用户列表(t_user,支持手机号搜索)
|
||||
- 场站列表(t_station)
|
||||
- 支持分页和搜索
|
||||
|
||||
### 3. 日报生成 (`lib/report_generator.py`)
|
||||
- 按配置生成日报
|
||||
- 支持分时段电量统计(尖/峰/平/谷)
|
||||
- 支持合并特来电平台数据(t_hlht_tld 表)
|
||||
- 补单检测(finish_type=2)
|
||||
- 报表命名:`{config_name}_{month}月{day}日消费记录.xlsx`
|
||||
|
||||
### 4. 历史记录 (`lib/api/report_api.py`)
|
||||
- 查看生成历史
|
||||
- 下载报表文件
|
||||
- 批量删除历史记录
|
||||
- 按日期范围查询
|
||||
|
||||
## 数据库表
|
||||
|
||||
### t_daily_report_config(配置表)
|
||||
- 存储日报生成配置
|
||||
- 支持 JSON 字段存储复杂配置
|
||||
|
||||
### t_daily_report_history(历史表)
|
||||
- 存储每次生成的历史记录
|
||||
- 包含报表日期、时间范围、订单数、总金额等
|
||||
|
||||
### 业务表
|
||||
- `t_equipment_charge_order`: 充电订单表
|
||||
- `t_equipment_charge_order_detail`: 分时段电量表
|
||||
- `t_company`: 企业表
|
||||
- `t_user`: 用户表
|
||||
- `t_station`: 场站表
|
||||
- `t_equipment`: 设备表
|
||||
- `t_connector`: 连接器表
|
||||
- `t_hlht_tld`: 特来电平台数据表
|
||||
|
||||
## 开发规范
|
||||
|
||||
### 后端开发
|
||||
1. 新增 API 路由时,在 `lib/api/` 目录下创建对应的蓝图模块
|
||||
2. 在 `lib/api/__init__.py` 中注册新蓝图
|
||||
3. 数据库操作使用 `lib/db.py` 提供的工具函数
|
||||
4. 字段映射定义在 `lib/field_mapping.py` 中维护
|
||||
|
||||
### 前端开发
|
||||
1. CSS 样式统一在 `static/css/style.css` 中维护
|
||||
2. JavaScript 按功能模块拆分到 `static/js/` 目录
|
||||
3. HTML 结构保持在 `templates/index.html` 中
|
||||
4. 使用语义化类名,避免硬编码颜色值
|
||||
|
||||
### 代码风格
|
||||
- Python 遵循 PEP 8 规范
|
||||
- JavaScript 使用 ES6+ 语法
|
||||
- 注释使用中文,关键逻辑必须添加注释
|
||||
|
||||
## 定时任务
|
||||
|
||||
系统使用 APScheduler 实现定时任务:
|
||||
- 每天 08:01 自动执行日报生成
|
||||
- 遍历所有启用的配置,逐一生成日报
|
||||
|
||||
## 部署说明
|
||||
|
||||
### 环境变量
|
||||
- `DEPLOY_RUN_PORT`: 服务监听端口(默认 5000)
|
||||
- `DB_HOST`: 数据库主机(默认 haoslm2.xicp.net)
|
||||
- `DB_PORT`: 数据库端口(默认 10216)
|
||||
|
||||
### 启动命令
|
||||
```bash
|
||||
# 开发环境
|
||||
coze dev
|
||||
|
||||
# 生产环境
|
||||
coze build && coze start
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 1. 数据库连接失败
|
||||
- 检查数据库配置(lib/db.py)
|
||||
- 确认数据库服务正常运行
|
||||
|
||||
### 2. 报表生成失败
|
||||
- 检查配置是否正确
|
||||
- 查看日志文件(/app/work/logs/bypass/)
|
||||
- 确认订单数据存在
|
||||
|
||||
### 3. 前端页面加载异常
|
||||
- 检查静态文件路径是否正确
|
||||
- 清除浏览器缓存后重试
|
||||
699
app.py
699
app.py
@@ -3,16 +3,15 @@ Flask主应用
|
||||
订单日报系统
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
from datetime import datetime
|
||||
from flask import Flask, request, jsonify, send_from_directory, render_template
|
||||
from flask import Flask, render_template
|
||||
from flask_cors import CORS
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
import pytz
|
||||
|
||||
from lib.db import execute_query, execute_update, execute_insert
|
||||
from lib.field_mapping import get_all_fields_with_display
|
||||
from lib.db import execute_query
|
||||
from lib.report_generator import generate_daily_report
|
||||
from lib.api import register_blueprints
|
||||
|
||||
# 创建 Flask 应用
|
||||
app = Flask(__name__,
|
||||
@@ -23,6 +22,9 @@ CORS(app)
|
||||
# 确保 public/reports 目录存在
|
||||
os.makedirs(os.path.join('public', 'reports'), exist_ok=True)
|
||||
|
||||
# 注册 API 蓝图
|
||||
register_blueprints(app)
|
||||
|
||||
|
||||
# ==================== 页面路由 ====================
|
||||
|
||||
@@ -32,683 +34,6 @@ def index():
|
||||
return render_template('index.html')
|
||||
|
||||
|
||||
# ==================== 配置管理API ====================
|
||||
|
||||
@app.route('/api/config', methods=['GET'])
|
||||
def get_configs():
|
||||
"""获取所有配置"""
|
||||
try:
|
||||
configs = execute_query(
|
||||
'SELECT * FROM t_daily_report_config ORDER BY sort_order ASC, id DESC'
|
||||
)
|
||||
|
||||
# 解析JSON字段
|
||||
for config in configs:
|
||||
config['selected_fields'] = json.loads(config['selected_fields'])
|
||||
config['sum_fields'] = json.loads(config['sum_fields']) if config['sum_fields'] else []
|
||||
config['time_periods'] = json.loads(config['time_periods']) if config.get('time_periods') else {}
|
||||
|
||||
return jsonify({'success': True, 'data': configs})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/config', methods=['POST'])
|
||||
def create_config():
|
||||
"""创建配置"""
|
||||
try:
|
||||
data = request.json
|
||||
config_name = data.get('config_name')
|
||||
split_type = data.get('split_type')
|
||||
split_value = data.get('split_value')
|
||||
split_name = data.get('split_name', '')
|
||||
selected_fields = data.get('selected_fields', [])
|
||||
sum_fields = data.get('sum_fields', [])
|
||||
time_periods = data.get('time_periods', {})
|
||||
merge_telecom = 1 if data.get('merge_telecom') else 0
|
||||
telecom_vehicle_no = data.get('telecom_vehicle_no', '')
|
||||
field_custom_names = data.get('field_custom_names', {})
|
||||
|
||||
if not config_name or not split_type or not split_value:
|
||||
return jsonify({'success': False, 'message': '缺少必填字段'}), 400
|
||||
|
||||
if not selected_fields:
|
||||
return jsonify({'success': False, 'message': '请至少选择一个字段'}), 400
|
||||
|
||||
# 生成ID
|
||||
config_id = int(datetime.now().timestamp() * 1000)
|
||||
|
||||
# 获取最大排序值
|
||||
max_sort = execute_query('SELECT MAX(sort_order) as max_sort FROM t_daily_report_config')
|
||||
sort_order = (max_sort[0]['max_sort'] or 0) + 1
|
||||
|
||||
sql = """
|
||||
INSERT INTO t_daily_report_config
|
||||
(id, config_name, split_type, split_value, split_name, selected_fields,
|
||||
sum_fields, time_periods, merge_telecom, telecom_vehicle_no, field_custom_names, is_active, sort_order, create_time, update_time)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 1, %s, NOW(), NOW())
|
||||
"""
|
||||
|
||||
params = (
|
||||
config_id,
|
||||
config_name,
|
||||
split_type,
|
||||
split_value,
|
||||
split_name,
|
||||
json.dumps(selected_fields, ensure_ascii=False),
|
||||
json.dumps(sum_fields, ensure_ascii=False),
|
||||
json.dumps(time_periods, ensure_ascii=False),
|
||||
merge_telecom,
|
||||
telecom_vehicle_no,
|
||||
json.dumps(field_custom_names, ensure_ascii=False) if field_custom_names else None,
|
||||
sort_order
|
||||
)
|
||||
|
||||
execute_insert(sql, params)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': '配置创建成功',
|
||||
'data': {'id': config_id}
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/config/<int:config_id>', methods=['PUT'])
|
||||
def update_config(config_id):
|
||||
"""更新配置"""
|
||||
try:
|
||||
data = request.json
|
||||
config_name = data.get('config_name')
|
||||
split_type = data.get('split_type')
|
||||
split_value = data.get('split_value')
|
||||
split_name = data.get('split_name', '')
|
||||
selected_fields = data.get('selected_fields', [])
|
||||
sum_fields = data.get('sum_fields', [])
|
||||
time_periods = data.get('time_periods', {})
|
||||
merge_telecom = 1 if data.get('merge_telecom') else 0
|
||||
telecom_vehicle_no = data.get('telecom_vehicle_no', '')
|
||||
field_custom_names = data.get('field_custom_names', {})
|
||||
|
||||
sql = """
|
||||
UPDATE t_daily_report_config
|
||||
SET config_name = %s, split_type = %s, split_value = %s, split_name = %s,
|
||||
selected_fields = %s, sum_fields = %s, time_periods = %s,
|
||||
merge_telecom = %s, telecom_vehicle_no = %s, field_custom_names = %s, update_time = NOW()
|
||||
WHERE id = %s
|
||||
"""
|
||||
|
||||
params = (
|
||||
config_name,
|
||||
split_type,
|
||||
split_value,
|
||||
split_name,
|
||||
json.dumps(selected_fields, ensure_ascii=False),
|
||||
json.dumps(sum_fields, ensure_ascii=False),
|
||||
json.dumps(time_periods, ensure_ascii=False),
|
||||
merge_telecom,
|
||||
telecom_vehicle_no,
|
||||
json.dumps(field_custom_names, ensure_ascii=False) if field_custom_names else None,
|
||||
config_id
|
||||
)
|
||||
|
||||
execute_update(sql, params)
|
||||
|
||||
return jsonify({'success': True, 'message': '配置更新成功'})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/config/<int:config_id>', methods=['DELETE'])
|
||||
def delete_config(config_id):
|
||||
"""删除配置"""
|
||||
try:
|
||||
execute_update('DELETE FROM t_daily_report_config WHERE id = %s', (config_id,))
|
||||
return jsonify({'success': True, 'message': '配置删除成功'})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/config/<int:config_id>/copy', methods=['POST'])
|
||||
def copy_config(config_id):
|
||||
"""复制配置"""
|
||||
try:
|
||||
# 获取原配置
|
||||
configs = execute_query('SELECT * FROM t_daily_report_config WHERE id = %s', (config_id,))
|
||||
if not configs:
|
||||
return jsonify({'success': False, 'message': '原配置不存在'}), 404
|
||||
|
||||
original = configs[0]
|
||||
|
||||
# 生成新ID和新名称
|
||||
new_id = int(datetime.now().timestamp() * 1000)
|
||||
new_name = f"{original['config_name']}_副本"
|
||||
|
||||
# 获取最大排序值
|
||||
max_sort = execute_query('SELECT MAX(sort_order) as max_sort FROM t_daily_report_config')
|
||||
sort_order = (max_sort[0]['max_sort'] or 0) + 1
|
||||
|
||||
# 插入新配置
|
||||
execute_insert(
|
||||
'''INSERT INTO t_daily_report_config
|
||||
(id, config_name, split_type, split_value, split_name, selected_fields, sum_fields, time_periods, merge_telecom, telecom_vehicle_no, field_custom_names, is_active, sort_order, create_time, update_time)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW())''',
|
||||
(
|
||||
new_id,
|
||||
new_name,
|
||||
original['split_type'],
|
||||
original['split_value'],
|
||||
original['split_name'],
|
||||
original['selected_fields'],
|
||||
original.get('sum_fields', ''),
|
||||
original.get('time_periods', ''),
|
||||
original.get('merge_telecom', 0),
|
||||
original.get('telecom_vehicle_no', ''),
|
||||
original.get('field_custom_names', None),
|
||||
original['is_active'],
|
||||
sort_order
|
||||
)
|
||||
)
|
||||
|
||||
return jsonify({'success': True, 'message': '配置复制成功', 'id': new_id})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/config/<int:config_id>/toggle', methods=['POST'])
|
||||
def toggle_config(config_id):
|
||||
"""启用/禁用配置"""
|
||||
try:
|
||||
execute_update(
|
||||
'UPDATE t_daily_report_config SET is_active = NOT is_active, update_time = NOW() WHERE id = %s',
|
||||
(config_id,)
|
||||
)
|
||||
return jsonify({'success': True, 'message': '操作成功'})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/config/<int:config_id>/move', methods=['POST'])
|
||||
def move_config(config_id):
|
||||
"""移动配置排序"""
|
||||
try:
|
||||
direction = request.json.get('direction') # up 或 down
|
||||
|
||||
# 获取当前配置
|
||||
current = execute_query(
|
||||
'SELECT sort_order FROM t_daily_report_config WHERE id = %s',
|
||||
(config_id,)
|
||||
)
|
||||
if not current:
|
||||
return jsonify({'success': False, 'message': '配置不存在'}), 404
|
||||
|
||||
current_order = current[0]['sort_order']
|
||||
|
||||
if direction == 'up':
|
||||
# 找到上一个配置
|
||||
prev = execute_query(
|
||||
'SELECT id, sort_order FROM t_daily_report_config WHERE sort_order < %s ORDER BY sort_order DESC LIMIT 1',
|
||||
(current_order,)
|
||||
)
|
||||
if prev:
|
||||
# 交换排序
|
||||
execute_update(
|
||||
'UPDATE t_daily_report_config SET sort_order = %s WHERE id = %s',
|
||||
(prev[0]['sort_order'], config_id)
|
||||
)
|
||||
execute_update(
|
||||
'UPDATE t_daily_report_config SET sort_order = %s WHERE id = %s',
|
||||
(current_order, prev[0]['id'])
|
||||
)
|
||||
elif direction == 'down':
|
||||
# 找到下一个配置
|
||||
next_config = execute_query(
|
||||
'SELECT id, sort_order FROM t_daily_report_config WHERE sort_order > %s ORDER BY sort_order ASC LIMIT 1',
|
||||
(current_order,)
|
||||
)
|
||||
if next_config:
|
||||
# 交换排序
|
||||
execute_update(
|
||||
'UPDATE t_daily_report_config SET sort_order = %s WHERE id = %s',
|
||||
(next_config[0]['sort_order'], config_id)
|
||||
)
|
||||
execute_update(
|
||||
'UPDATE t_daily_report_config SET sort_order = %s WHERE id = %s',
|
||||
(current_order, next_config[0]['id'])
|
||||
)
|
||||
|
||||
return jsonify({'success': True, 'message': '移动成功'})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
|
||||
|
||||
# ==================== 企业和用户列表API ====================
|
||||
|
||||
@app.route('/api/entities', methods=['GET'])
|
||||
def get_entities():
|
||||
"""获取企业、用户或场站列表(支持分页和搜索)"""
|
||||
try:
|
||||
entity_type = request.args.get('type', 'company')
|
||||
page = int(request.args.get('page', 1))
|
||||
page_size = int(request.args.get('page_size', 100))
|
||||
search = request.args.get('search', '')
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
if entity_type == 'company':
|
||||
# 获取企业列表 (company_state=0 表示启用)
|
||||
if search:
|
||||
companies = execute_query(
|
||||
'SELECT id AS company_id, company_name FROM t_company WHERE company_state = 0 AND company_name LIKE %s ORDER BY company_name LIMIT %s OFFSET %s',
|
||||
(f'%{search}%', page_size, offset)
|
||||
)
|
||||
total_result = execute_query(
|
||||
'SELECT COUNT(*) as total FROM t_company WHERE company_state = 0 AND company_name LIKE %s',
|
||||
(f'%{search}%',)
|
||||
)
|
||||
else:
|
||||
companies = execute_query(
|
||||
'SELECT id AS company_id, company_name FROM t_company WHERE company_state = 0 ORDER BY company_name LIMIT %s OFFSET %s',
|
||||
(page_size, offset)
|
||||
)
|
||||
total_result = execute_query(
|
||||
'SELECT COUNT(*) as total FROM t_company WHERE company_state = 0'
|
||||
)
|
||||
total = total_result[0]['total'] if total_result else 0
|
||||
return jsonify({'success': True, 'data': companies, 'total': total, 'page': page, 'page_size': page_size})
|
||||
|
||||
elif entity_type == 'station':
|
||||
# 获取场站列表
|
||||
if search:
|
||||
stations = execute_query(
|
||||
'SELECT id AS station_id, station_name FROM t_station WHERE station_name LIKE %s ORDER BY station_name LIMIT %s OFFSET %s',
|
||||
(f'%{search}%', page_size, offset)
|
||||
)
|
||||
total_result = execute_query(
|
||||
'SELECT COUNT(*) as total FROM t_station WHERE station_name LIKE %s',
|
||||
(f'%{search}%',)
|
||||
)
|
||||
else:
|
||||
stations = execute_query(
|
||||
'SELECT id AS station_id, station_name FROM t_station ORDER BY station_name LIMIT %s OFFSET %s',
|
||||
(page_size, offset)
|
||||
)
|
||||
total_result = execute_query(
|
||||
'SELECT COUNT(*) as total FROM t_station'
|
||||
)
|
||||
total = total_result[0]['total'] if total_result else 0
|
||||
return jsonify({'success': True, 'data': stations, 'total': total, 'page': page, 'page_size': page_size})
|
||||
|
||||
else:
|
||||
# 获取用户列表(支持分页和搜索,搜索支持用户名和手机号)
|
||||
if search:
|
||||
users = execute_query(
|
||||
'SELECT id AS user_id, user_name, phone FROM t_user WHERE user_name LIKE %s OR phone LIKE %s ORDER BY user_name LIMIT %s OFFSET %s',
|
||||
(f'%{search}%', f'%{search}%', page_size, offset)
|
||||
)
|
||||
total_result = execute_query(
|
||||
'SELECT COUNT(*) as total FROM t_user WHERE user_name LIKE %s OR phone LIKE %s',
|
||||
(f'%{search}%', f'%{search}%')
|
||||
)
|
||||
else:
|
||||
users = execute_query(
|
||||
'SELECT id AS user_id, user_name, phone FROM t_user ORDER BY user_name LIMIT %s OFFSET %s',
|
||||
(page_size, offset)
|
||||
)
|
||||
total_result = execute_query(
|
||||
'SELECT COUNT(*) as total FROM t_user'
|
||||
)
|
||||
total = total_result[0]['total'] if total_result else 0
|
||||
return jsonify({'success': True, 'data': users, 'total': total, 'page': page, 'page_size': page_size})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/entities/by_ids', methods=['POST'])
|
||||
def get_entities_by_ids():
|
||||
"""根据ID列表查询实体信息(用于编辑回显)"""
|
||||
try:
|
||||
data = request.json
|
||||
entity_type = data.get('type', 'company')
|
||||
ids = data.get('ids', [])
|
||||
|
||||
if not ids:
|
||||
return jsonify({'success': True, 'data': []})
|
||||
|
||||
placeholders = ', '.join(['%s'] * len(ids))
|
||||
|
||||
if entity_type == 'company':
|
||||
entities = execute_query(
|
||||
f'SELECT id AS company_id, company_name FROM t_company WHERE id IN ({placeholders})',
|
||||
tuple(ids)
|
||||
)
|
||||
elif entity_type == 'station':
|
||||
entities = execute_query(
|
||||
f'SELECT id AS station_id, station_name FROM t_station WHERE id IN ({placeholders})',
|
||||
tuple(ids)
|
||||
)
|
||||
else:
|
||||
entities = execute_query(
|
||||
f'SELECT id AS user_id, user_name FROM t_user WHERE id IN ({placeholders})',
|
||||
tuple(ids)
|
||||
)
|
||||
|
||||
return jsonify({'success': True, 'data': entities})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
|
||||
|
||||
# ==================== 字段列表API ====================
|
||||
|
||||
@app.route('/api/fields', methods=['GET'])
|
||||
def get_fields():
|
||||
"""获取所有可用字段"""
|
||||
try:
|
||||
fields = get_all_fields_with_display()
|
||||
return jsonify({'success': True, 'data': fields})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
|
||||
|
||||
# ==================== 日报生成API ====================
|
||||
|
||||
@app.route('/api/report/generate', methods=['POST'])
|
||||
def generate_report():
|
||||
"""生成日报"""
|
||||
try:
|
||||
data = request.json
|
||||
config_id = data.get('config_id')
|
||||
|
||||
if not config_id:
|
||||
return jsonify({'success': False, 'message': '请选择配置'}), 400
|
||||
|
||||
# 解析时间参数
|
||||
start_time = None
|
||||
end_time = None
|
||||
|
||||
if data.get('start_time'):
|
||||
start_time = datetime.strptime(data['start_time'], '%Y-%m-%d %H:%M:%S')
|
||||
if data.get('end_time'):
|
||||
end_time = datetime.strptime(data['end_time'], '%Y-%m-%d %H:%M:%S')
|
||||
|
||||
result = generate_daily_report(config_id, start_time, end_time)
|
||||
|
||||
if result['success']:
|
||||
return jsonify(result)
|
||||
else:
|
||||
return jsonify(result), 400
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
|
||||
|
||||
# ==================== 历史记录API ====================
|
||||
|
||||
@app.route('/api/report/history', methods=['GET'])
|
||||
def get_report_history():
|
||||
"""获取日报生成历史"""
|
||||
try:
|
||||
page = int(request.args.get('page', 1))
|
||||
page_size = int(request.args.get('page_size', 20))
|
||||
start_date = request.args.get('start_date')
|
||||
end_date = request.args.get('end_date')
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
# 构建查询条件
|
||||
where_clauses = []
|
||||
params = []
|
||||
|
||||
if start_date:
|
||||
where_clauses.append('DATE(start_time) >= %s')
|
||||
params.append(start_date)
|
||||
|
||||
if end_date:
|
||||
where_clauses.append('DATE(end_time) <= %s')
|
||||
params.append(end_date)
|
||||
|
||||
where_sql = ' AND '.join(where_clauses) if where_clauses else '1=1'
|
||||
|
||||
# 查询总数
|
||||
total_result = execute_query(
|
||||
f'SELECT COUNT(*) as total FROM t_daily_report_history WHERE {where_sql}',
|
||||
tuple(params)
|
||||
)
|
||||
total = total_result[0]['total']
|
||||
|
||||
# 查询数据
|
||||
history = execute_query(
|
||||
f'SELECT * FROM t_daily_report_history WHERE {where_sql} ORDER BY create_time DESC LIMIT %s OFFSET %s',
|
||||
tuple(params) + (page_size, offset)
|
||||
)
|
||||
|
||||
# 解析JSON字段并确保file_path正确
|
||||
for item in history:
|
||||
item['sum_results'] = json.loads(item['sum_results']) if item['sum_results'] else {}
|
||||
# 确保file_path是字符串
|
||||
if item['file_path'] is None:
|
||||
item['file_path'] = ''
|
||||
|
||||
# 调试日志
|
||||
print(f"[历史记录] ID: {item['id']}, status: {item['status']}, file_path: '{item['file_path']}'")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': {
|
||||
'list': history,
|
||||
'total': total,
|
||||
'page': page,
|
||||
'page_size': page_size
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"[历史记录API] 错误: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
|
||||
|
||||
# ==================== 历史记录管理API ====================
|
||||
|
||||
@app.route('/api/report/history/<int:history_id>', methods=['DELETE'])
|
||||
def delete_report_history(history_id):
|
||||
"""删除单个历史记录"""
|
||||
try:
|
||||
# 先查询文件路径
|
||||
result = execute_query(
|
||||
'SELECT file_path FROM t_daily_report_history WHERE id = %s',
|
||||
(history_id,)
|
||||
)
|
||||
|
||||
if result:
|
||||
file_path = result[0].get('file_path', '')
|
||||
# 删除文件
|
||||
if file_path and os.path.exists(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'public', file_path.lstrip('/'))):
|
||||
os.remove(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'public', file_path.lstrip('/')))
|
||||
|
||||
# 删除数据库记录
|
||||
execute_update('DELETE FROM t_daily_report_history WHERE id = %s', (history_id,))
|
||||
|
||||
return jsonify({'success': True, 'message': '删除成功'})
|
||||
except Exception as e:
|
||||
print(f"[删除历史记录] 错误: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/report/history/batch-delete', methods=['POST'])
|
||||
def batch_delete_report_history():
|
||||
"""批量删除历史记录"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
ids = data.get('ids', [])
|
||||
|
||||
if not ids:
|
||||
return jsonify({'success': False, 'message': '请选择要删除的记录'}), 400
|
||||
|
||||
# 查询文件路径
|
||||
results = execute_query(
|
||||
f'SELECT file_path FROM t_daily_report_history WHERE id IN ({",".join(["%s"] * len(ids))})',
|
||||
tuple(ids)
|
||||
)
|
||||
|
||||
# 删除文件
|
||||
for result in results:
|
||||
file_path = result.get('file_path', '')
|
||||
if file_path and os.path.exists(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'public', file_path.lstrip('/'))):
|
||||
os.remove(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'public', file_path.lstrip('/')))
|
||||
|
||||
# 删除数据库记录
|
||||
execute_update(
|
||||
f'DELETE FROM t_daily_report_history WHERE id IN ({",".join(["%s"] * len(ids))})',
|
||||
tuple(ids)
|
||||
)
|
||||
|
||||
return jsonify({'success': True, 'message': f'成功删除 {len(ids)} 条记录'})
|
||||
except Exception as e:
|
||||
print(f"[批量删除历史记录] 错误: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/report/history/<int:history_id>/download', methods=['GET'])
|
||||
def download_report_by_id(history_id):
|
||||
"""根据ID下载历史记录"""
|
||||
try:
|
||||
# 查询历史记录
|
||||
result = execute_query(
|
||||
'SELECT file_path, split_name, report_date FROM t_daily_report_history WHERE id = %s',
|
||||
(history_id,)
|
||||
)
|
||||
|
||||
if not result:
|
||||
return jsonify({'success': False, 'message': '记录不存在'}), 404
|
||||
|
||||
file_path = result[0]['file_path']
|
||||
if not file_path:
|
||||
return jsonify({'success': False, 'message': '文件不存在'}), 404
|
||||
|
||||
# 构建完整路径
|
||||
full_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'public', file_path.lstrip('/'))
|
||||
|
||||
if not os.path.exists(full_path):
|
||||
return jsonify({'success': False, 'message': '文件不存在'}), 404
|
||||
|
||||
# 返回文件
|
||||
directory = os.path.dirname(full_path)
|
||||
filename = os.path.basename(full_path)
|
||||
return send_from_directory(directory, filename, as_attachment=True)
|
||||
except Exception as e:
|
||||
print(f"[下载历史记录] 错误: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
|
||||
|
||||
# ==================== 数据库初始化API ====================
|
||||
|
||||
@app.route('/api/init-tables', methods=['POST'])
|
||||
def init_tables():
|
||||
"""初始化数据库表"""
|
||||
try:
|
||||
# 创建配置表
|
||||
create_config_table = """
|
||||
CREATE TABLE IF NOT EXISTS t_daily_report_config (
|
||||
id BIGINT NOT NULL COMMENT '配置ID',
|
||||
config_name VARCHAR(100) NOT NULL COMMENT '配置名称',
|
||||
split_type VARCHAR(20) NOT NULL COMMENT '拆分方式',
|
||||
split_value VARCHAR(100) NOT NULL COMMENT '拆分值',
|
||||
split_name VARCHAR(200) NULL COMMENT '拆分显示名称',
|
||||
selected_fields VARCHAR(3000) NOT NULL COMMENT '选择的字段列表',
|
||||
sum_fields VARCHAR(3000) NULL COMMENT '求和字段列表',
|
||||
is_active TINYINT NULL COMMENT '是否启用',
|
||||
sort_order INT NULL COMMENT '排序顺序',
|
||||
create_time DATETIME NULL COMMENT '创建时间',
|
||||
update_time DATETIME NULL COMMENT '更新时间'
|
||||
)
|
||||
UNIQUE KEY(id)
|
||||
DISTRIBUTED BY HASH(id) BUCKETS 1
|
||||
PROPERTIES("replication_num" = "1")
|
||||
"""
|
||||
execute_update(create_config_table)
|
||||
|
||||
# 创建历史表
|
||||
create_history_table = """
|
||||
CREATE TABLE IF NOT EXISTS t_daily_report_history (
|
||||
id BIGINT NOT NULL COMMENT '历史ID',
|
||||
report_date VARCHAR(20) NULL COMMENT '报表日期',
|
||||
start_time VARCHAR(50) NULL COMMENT '开始时间',
|
||||
end_time VARCHAR(50) NULL COMMENT '结束时间',
|
||||
split_type VARCHAR(20) NULL COMMENT '拆分方式',
|
||||
split_value VARCHAR(100) NULL COMMENT '拆分值',
|
||||
split_name VARCHAR(200) NULL COMMENT '拆分显示名称',
|
||||
config_id BIGINT NULL COMMENT '配置ID',
|
||||
config_name VARCHAR(200) NULL COMMENT '配置名称',
|
||||
total_orders INT NULL COMMENT '订单总数',
|
||||
total_amount DECIMAL(10,2) NULL COMMENT '总金额',
|
||||
sum_results VARCHAR(2000) NULL COMMENT '求和结果',
|
||||
file_path VARCHAR(500) NULL COMMENT '文件路径',
|
||||
status TINYINT NULL COMMENT '状态',
|
||||
create_time DATETIME NULL COMMENT '创建时间'
|
||||
)
|
||||
UNIQUE KEY(id)
|
||||
DISTRIBUTED BY HASH(id) BUCKETS 1
|
||||
PROPERTIES("replication_num" = "1")
|
||||
"""
|
||||
execute_update(create_history_table)
|
||||
|
||||
# 为已存在的表添加 config_name 字段(如果不存在)
|
||||
try:
|
||||
execute_update("""
|
||||
ALTER TABLE t_daily_report_history
|
||||
ADD COLUMN IF NOT EXISTS config_name VARCHAR(200) NULL COMMENT '配置名称'
|
||||
""")
|
||||
except Exception as e:
|
||||
print(f"[数据库] 添加 config_name 字段时出错(可能已存在): {e}")
|
||||
|
||||
return jsonify({'success': True, 'message': '数据库表初始化成功'})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
|
||||
|
||||
# ==================== 文件下载API ====================
|
||||
|
||||
@app.route('/api/download', methods=['GET'])
|
||||
def download_file():
|
||||
"""下载文件"""
|
||||
try:
|
||||
file_path = request.args.get('path')
|
||||
print(f"[下载] 请求路径: {file_path}")
|
||||
|
||||
if not file_path:
|
||||
return jsonify({'success': False, 'message': '文件路径不能为空'}), 400
|
||||
|
||||
# 安全检查:确保路径在public目录下
|
||||
if not file_path.startswith('/reports/'):
|
||||
return jsonify({'success': False, 'message': '无效的文件路径'}), 400
|
||||
|
||||
filename = file_path.replace('/reports/', '')
|
||||
# 使用绝对路径
|
||||
reports_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'public', 'reports')
|
||||
|
||||
print(f"[下载] 文件目录: {reports_dir}")
|
||||
print(f"[下载] 文件名: {filename}")
|
||||
print(f"[下载] 文件是否存在: {os.path.exists(os.path.join(reports_dir, filename))}")
|
||||
|
||||
# 列出目录中的所有文件
|
||||
if os.path.exists(reports_dir):
|
||||
print(f"[下载] 目录中的文件: {os.listdir(reports_dir)}")
|
||||
|
||||
return send_from_directory(reports_dir, filename, as_attachment=True)
|
||||
except Exception as e:
|
||||
print(f"[下载] 错误: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
|
||||
|
||||
# ==================== 定时任务 ====================
|
||||
|
||||
def scheduled_job():
|
||||
@@ -729,17 +54,17 @@ def scheduled_job():
|
||||
|
||||
# 为每个配置生成日报
|
||||
for config in configs:
|
||||
print(f'正在生成: {config["config_name"]}')
|
||||
print(f'正在生成:{config["config_name"]}')
|
||||
result = generate_daily_report(config['id'])
|
||||
|
||||
if result['success']:
|
||||
print(f' ✓ 成功: {result["total_orders"]} 条订单, 金额: {result["total_amount"]}')
|
||||
print(f' ✓ 成功:{result["total_orders"]} 条订单,金额:{result["total_amount"]}')
|
||||
else:
|
||||
print(f' ✗ 失败: {result["message"]}')
|
||||
print(f' ✗ 失败:{result["message"]}')
|
||||
|
||||
print('定时任务执行完成')
|
||||
except Exception as e:
|
||||
print(f'定时任务执行失败: {e}')
|
||||
print(f'定时任务执行失败:{e}')
|
||||
|
||||
|
||||
# 启动定时任务
|
||||
@@ -754,6 +79,6 @@ print('定时任务已启动:每天 08:01 执行')
|
||||
if __name__ == '__main__':
|
||||
# 支持沙箱环境和本地环境
|
||||
port = int(os.environ.get('DEPLOY_RUN_PORT', os.environ.get('PORT', 5000)))
|
||||
print(f'启动服务器: http://localhost:{port}')
|
||||
print(f'数据库: {os.environ.get("DB_HOST", "haoslm2.xicp.net")}:{os.environ.get("DB_PORT", "10216")}')
|
||||
print(f'启动服务器:http://localhost:{port}')
|
||||
print(f'数据库:{os.environ.get("DB_HOST", "haoslm2.xicp.net")}:{os.environ.get("DB_PORT", "10216")}')
|
||||
app.run(host='0.0.0.0', port=port, debug=True)
|
||||
35
lib/api/__init__.py
Normal file
35
lib/api/__init__.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""
|
||||
API 蓝图模块
|
||||
"""
|
||||
from flask import Blueprint
|
||||
|
||||
# 配置管理蓝图
|
||||
config_bp = Blueprint('config', __name__, url_prefix='/api')
|
||||
|
||||
# 实体管理蓝图
|
||||
entity_bp = Blueprint('entity', __name__, url_prefix='/api')
|
||||
|
||||
# 字段列表蓝图
|
||||
field_bp = Blueprint('field', __name__, url_prefix='/api')
|
||||
|
||||
# 日报生成和历史记录蓝图
|
||||
report_bp = Blueprint('report', __name__, url_prefix='/api')
|
||||
|
||||
# 数据库初始化蓝图
|
||||
init_bp = Blueprint('init', __name__, url_prefix='/api')
|
||||
|
||||
# 文件下载蓝图
|
||||
download_bp = Blueprint('download', __name__, url_prefix='/api')
|
||||
|
||||
# 导入各模块的路由
|
||||
from . import config_api, entity_api, field_api, report_api, init_api, download_api
|
||||
|
||||
|
||||
def register_blueprints(app):
|
||||
"""注册所有蓝图"""
|
||||
app.register_blueprint(config_bp)
|
||||
app.register_blueprint(entity_bp)
|
||||
app.register_blueprint(field_bp)
|
||||
app.register_blueprint(report_bp)
|
||||
app.register_blueprint(init_bp)
|
||||
app.register_blueprint(download_bp)
|
||||
257
lib/api/config_api.py
Normal file
257
lib/api/config_api.py
Normal file
@@ -0,0 +1,257 @@
|
||||
"""
|
||||
配置管理 API
|
||||
"""
|
||||
import json
|
||||
from datetime import datetime
|
||||
from flask import request, jsonify
|
||||
from lib.db import execute_query, execute_update, execute_insert
|
||||
from . import config_bp
|
||||
|
||||
|
||||
@config_bp.route('/config', methods=['GET'])
|
||||
def get_configs():
|
||||
"""获取所有配置"""
|
||||
try:
|
||||
configs = execute_query(
|
||||
'SELECT * FROM t_daily_report_config ORDER BY sort_order ASC, id DESC'
|
||||
)
|
||||
|
||||
# 解析 JSON 字段
|
||||
for config in configs:
|
||||
config['selected_fields'] = json.loads(config['selected_fields'])
|
||||
config['sum_fields'] = json.loads(config['sum_fields']) if config['sum_fields'] else []
|
||||
config['time_periods'] = json.loads(config['time_periods']) if config.get('time_periods') else {}
|
||||
|
||||
return jsonify({'success': True, 'data': configs})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
|
||||
|
||||
@config_bp.route('/config', methods=['POST'])
|
||||
def create_config():
|
||||
"""创建配置"""
|
||||
try:
|
||||
data = request.json
|
||||
config_name = data.get('config_name')
|
||||
split_type = data.get('split_type')
|
||||
split_value = data.get('split_value')
|
||||
split_name = data.get('split_name', '')
|
||||
selected_fields = data.get('selected_fields', [])
|
||||
sum_fields = data.get('sum_fields', [])
|
||||
time_periods = data.get('time_periods', {})
|
||||
merge_telecom = 1 if data.get('merge_telecom') else 0
|
||||
telecom_vehicle_no = data.get('telecom_vehicle_no', '')
|
||||
field_custom_names = data.get('field_custom_names', {})
|
||||
|
||||
if not config_name or not split_type or not split_value:
|
||||
return jsonify({'success': False, 'message': '缺少必填字段'}), 400
|
||||
|
||||
if not selected_fields:
|
||||
return jsonify({'success': False, 'message': '请至少选择一个字段'}), 400
|
||||
|
||||
# 生成 ID
|
||||
config_id = int(datetime.now().timestamp() * 1000)
|
||||
|
||||
# 获取最大排序值
|
||||
max_sort = execute_query('SELECT MAX(sort_order) as max_sort FROM t_daily_report_config')
|
||||
sort_order = (max_sort[0]['max_sort'] or 0) + 1
|
||||
|
||||
sql = """
|
||||
INSERT INTO t_daily_report_config
|
||||
(id, config_name, split_type, split_value, split_name, selected_fields,
|
||||
sum_fields, time_periods, merge_telecom, telecom_vehicle_no, field_custom_names, is_active, sort_order, create_time, update_time)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 1, %s, NOW(), NOW())
|
||||
"""
|
||||
|
||||
params = (
|
||||
config_id,
|
||||
config_name,
|
||||
split_type,
|
||||
split_value,
|
||||
split_name,
|
||||
json.dumps(selected_fields, ensure_ascii=False),
|
||||
json.dumps(sum_fields, ensure_ascii=False),
|
||||
json.dumps(time_periods, ensure_ascii=False),
|
||||
merge_telecom,
|
||||
telecom_vehicle_no,
|
||||
json.dumps(field_custom_names, ensure_ascii=False) if field_custom_names else None,
|
||||
sort_order
|
||||
)
|
||||
|
||||
execute_insert(sql, params)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': '配置创建成功',
|
||||
'data': {'id': config_id}
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
|
||||
|
||||
@config_bp.route('/config/<int:config_id>', methods=['PUT'])
|
||||
def update_config(config_id):
|
||||
"""更新配置"""
|
||||
try:
|
||||
data = request.json
|
||||
config_name = data.get('config_name')
|
||||
split_type = data.get('split_type')
|
||||
split_value = data.get('split_value')
|
||||
split_name = data.get('split_name', '')
|
||||
selected_fields = data.get('selected_fields', [])
|
||||
sum_fields = data.get('sum_fields', [])
|
||||
time_periods = data.get('time_periods', {})
|
||||
merge_telecom = 1 if data.get('merge_telecom') else 0
|
||||
telecom_vehicle_no = data.get('telecom_vehicle_no', '')
|
||||
field_custom_names = data.get('field_custom_names', {})
|
||||
|
||||
sql = """
|
||||
UPDATE t_daily_report_config
|
||||
SET config_name = %s, split_type = %s, split_value = %s, split_name = %s,
|
||||
selected_fields = %s, sum_fields = %s, time_periods = %s,
|
||||
merge_telecom = %s, telecom_vehicle_no = %s, field_custom_names = %s, update_time = NOW()
|
||||
WHERE id = %s
|
||||
"""
|
||||
|
||||
params = (
|
||||
config_name,
|
||||
split_type,
|
||||
split_value,
|
||||
split_name,
|
||||
json.dumps(selected_fields, ensure_ascii=False),
|
||||
json.dumps(sum_fields, ensure_ascii=False),
|
||||
json.dumps(time_periods, ensure_ascii=False),
|
||||
merge_telecom,
|
||||
telecom_vehicle_no,
|
||||
json.dumps(field_custom_names, ensure_ascii=False) if field_custom_names else None,
|
||||
config_id
|
||||
)
|
||||
|
||||
execute_update(sql, params)
|
||||
|
||||
return jsonify({'success': True, 'message': '配置更新成功'})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
|
||||
|
||||
@config_bp.route('/config/<int:config_id>', methods=['DELETE'])
|
||||
def delete_config(config_id):
|
||||
"""删除配置"""
|
||||
try:
|
||||
execute_update('DELETE FROM t_daily_report_config WHERE id = %s', (config_id,))
|
||||
return jsonify({'success': True, 'message': '配置删除成功'})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
|
||||
|
||||
@config_bp.route('/config/<int:config_id>/copy', methods=['POST'])
|
||||
def copy_config(config_id):
|
||||
"""复制配置"""
|
||||
try:
|
||||
# 获取原配置
|
||||
configs = execute_query('SELECT * FROM t_daily_report_config WHERE id = %s', (config_id,))
|
||||
if not configs:
|
||||
return jsonify({'success': False, 'message': '原配置不存在'}), 404
|
||||
|
||||
original = configs[0]
|
||||
|
||||
# 生成新 ID 和新名称
|
||||
new_id = int(datetime.now().timestamp() * 1000)
|
||||
new_name = f"{original['config_name']}_副本"
|
||||
|
||||
# 获取最大排序值
|
||||
max_sort = execute_query('SELECT MAX(sort_order) as max_sort FROM t_daily_report_config')
|
||||
sort_order = (max_sort[0]['max_sort'] or 0) + 1
|
||||
|
||||
# 插入新配置
|
||||
execute_insert(
|
||||
'''INSERT INTO t_daily_report_config
|
||||
(id, config_name, split_type, split_value, split_name, selected_fields, sum_fields, time_periods, merge_telecom, telecom_vehicle_no, field_custom_names, is_active, sort_order, create_time, update_time)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW())''',
|
||||
(
|
||||
new_id,
|
||||
new_name,
|
||||
original['split_type'],
|
||||
original['split_value'],
|
||||
original['split_name'],
|
||||
original['selected_fields'],
|
||||
original.get('sum_fields', ''),
|
||||
original.get('time_periods', ''),
|
||||
original.get('merge_telecom', 0),
|
||||
original.get('telecom_vehicle_no', ''),
|
||||
original.get('field_custom_names', None),
|
||||
original['is_active'],
|
||||
sort_order
|
||||
)
|
||||
)
|
||||
|
||||
return jsonify({'success': True, 'message': '配置复制成功', 'id': new_id})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
|
||||
|
||||
@config_bp.route('/config/<int:config_id>/toggle', methods=['POST'])
|
||||
def toggle_config(config_id):
|
||||
"""启用/禁用配置"""
|
||||
try:
|
||||
execute_update(
|
||||
'UPDATE t_daily_report_config SET is_active = NOT is_active, update_time = NOW() WHERE id = %s',
|
||||
(config_id,)
|
||||
)
|
||||
return jsonify({'success': True, 'message': '操作成功'})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
|
||||
|
||||
@config_bp.route('/config/<int:config_id>/move', methods=['POST'])
|
||||
def move_config(config_id):
|
||||
"""移动配置排序"""
|
||||
try:
|
||||
direction = request.json.get('direction') # up 或 down
|
||||
|
||||
# 获取当前配置
|
||||
current = execute_query(
|
||||
'SELECT sort_order FROM t_daily_report_config WHERE id = %s',
|
||||
(config_id,)
|
||||
)
|
||||
if not current:
|
||||
return jsonify({'success': False, 'message': '配置不存在'}), 404
|
||||
|
||||
current_order = current[0]['sort_order']
|
||||
|
||||
if direction == 'up':
|
||||
# 找到上一个配置
|
||||
prev = execute_query(
|
||||
'SELECT id, sort_order FROM t_daily_report_config WHERE sort_order < %s ORDER BY sort_order DESC LIMIT 1',
|
||||
(current_order,)
|
||||
)
|
||||
if prev:
|
||||
# 交换排序
|
||||
execute_update(
|
||||
'UPDATE t_daily_report_config SET sort_order = %s WHERE id = %s',
|
||||
(prev[0]['sort_order'], config_id)
|
||||
)
|
||||
execute_update(
|
||||
'UPDATE t_daily_report_config SET sort_order = %s WHERE id = %s',
|
||||
(current_order, prev[0]['id'])
|
||||
)
|
||||
elif direction == 'down':
|
||||
# 找到下一个配置
|
||||
next_config = execute_query(
|
||||
'SELECT id, sort_order FROM t_daily_report_config WHERE sort_order > %s ORDER BY sort_order ASC LIMIT 1',
|
||||
(current_order,)
|
||||
)
|
||||
if next_config:
|
||||
# 交换排序
|
||||
execute_update(
|
||||
'UPDATE t_daily_report_config SET sort_order = %s WHERE id = %s',
|
||||
(next_config[0]['sort_order'], config_id)
|
||||
)
|
||||
execute_update(
|
||||
'UPDATE t_daily_report_config SET sort_order = %s WHERE id = %s',
|
||||
(current_order, next_config[0]['id'])
|
||||
)
|
||||
|
||||
return jsonify({'success': True, 'message': '移动成功'})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
40
lib/api/download_api.py
Normal file
40
lib/api/download_api.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""
|
||||
文件下载 API
|
||||
"""
|
||||
import os
|
||||
from flask import request, jsonify, send_from_directory
|
||||
from . import download_bp
|
||||
|
||||
|
||||
@download_bp.route('/download', methods=['GET'])
|
||||
def download_file():
|
||||
"""下载文件"""
|
||||
try:
|
||||
file_path = request.args.get('path')
|
||||
print(f"[下载] 请求路径:{file_path}")
|
||||
|
||||
if not file_path:
|
||||
return jsonify({'success': False, 'message': '文件路径不能为空'}), 400
|
||||
|
||||
# 安全检查:确保路径在 public 目录下
|
||||
if not file_path.startswith('/reports/'):
|
||||
return jsonify({'success': False, 'message': '无效的文件路径'}), 400
|
||||
|
||||
filename = file_path.replace('/reports/', '')
|
||||
# 使用绝对路径
|
||||
reports_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', '..', 'public', 'reports')
|
||||
|
||||
print(f"[下载] 文件目录:{reports_dir}")
|
||||
print(f"[下载] 文件名:{filename}")
|
||||
print(f"[下载] 文件是否存在:{os.path.exists(os.path.join(reports_dir, filename))}")
|
||||
|
||||
# 列出目录中的所有文件
|
||||
if os.path.exists(reports_dir):
|
||||
print(f"[下载] 目录中的文件:{os.listdir(reports_dir)}")
|
||||
|
||||
return send_from_directory(reports_dir, filename, as_attachment=True)
|
||||
except Exception as e:
|
||||
print(f"[下载] 错误:{str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
121
lib/api/entity_api.py
Normal file
121
lib/api/entity_api.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""
|
||||
实体管理 API(企业、用户、场站)
|
||||
"""
|
||||
from flask import request, jsonify
|
||||
from lib.db import execute_query
|
||||
from . import entity_bp
|
||||
|
||||
|
||||
@entity_bp.route('/entities', methods=['GET'])
|
||||
def get_entities():
|
||||
"""获取企业、用户或场站列表(支持分页和搜索)"""
|
||||
try:
|
||||
entity_type = request.args.get('type', 'company')
|
||||
page = int(request.args.get('page', 1))
|
||||
page_size = int(request.args.get('page_size', 100))
|
||||
search = request.args.get('search', '')
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
if entity_type == 'company':
|
||||
# 获取企业列表 (company_state=0 表示启用)
|
||||
if search:
|
||||
companies = execute_query(
|
||||
'SELECT id AS company_id, company_name FROM t_company WHERE company_state = 0 AND company_name LIKE %s ORDER BY company_name LIMIT %s OFFSET %s',
|
||||
(f'%{search}%', page_size, offset)
|
||||
)
|
||||
total_result = execute_query(
|
||||
'SELECT COUNT(*) as total FROM t_company WHERE company_state = 0 AND company_name LIKE %s',
|
||||
(f'%{search}%',)
|
||||
)
|
||||
else:
|
||||
companies = execute_query(
|
||||
'SELECT id AS company_id, company_name FROM t_company WHERE company_state = 0 ORDER BY company_name LIMIT %s OFFSET %s',
|
||||
(page_size, offset)
|
||||
)
|
||||
total_result = execute_query(
|
||||
'SELECT COUNT(*) as total FROM t_company WHERE company_state = 0'
|
||||
)
|
||||
total = total_result[0]['total'] if total_result else 0
|
||||
return jsonify({'success': True, 'data': companies, 'total': total, 'page': page, 'page_size': page_size})
|
||||
|
||||
elif entity_type == 'station':
|
||||
# 获取场站列表
|
||||
if search:
|
||||
stations = execute_query(
|
||||
'SELECT id AS station_id, station_name FROM t_station WHERE station_name LIKE %s ORDER BY station_name LIMIT %s OFFSET %s',
|
||||
(f'%{search}%', page_size, offset)
|
||||
)
|
||||
total_result = execute_query(
|
||||
'SELECT COUNT(*) as total FROM t_station WHERE station_name LIKE %s',
|
||||
(f'%{search}%',)
|
||||
)
|
||||
else:
|
||||
stations = execute_query(
|
||||
'SELECT id AS station_id, station_name FROM t_station ORDER BY station_name LIMIT %s OFFSET %s',
|
||||
(page_size, offset)
|
||||
)
|
||||
total_result = execute_query(
|
||||
'SELECT COUNT(*) as total FROM t_station'
|
||||
)
|
||||
total = total_result[0]['total'] if total_result else 0
|
||||
return jsonify({'success': True, 'data': stations, 'total': total, 'page': page, 'page_size': page_size})
|
||||
|
||||
else:
|
||||
# 获取用户列表(支持分页和搜索,搜索支持用户名和手机号)
|
||||
if search:
|
||||
users = execute_query(
|
||||
'SELECT id AS user_id, user_name, phone FROM t_user WHERE user_name LIKE %s OR phone LIKE %s ORDER BY user_name LIMIT %s OFFSET %s',
|
||||
(f'%{search}%', f'%{search}%', page_size, offset)
|
||||
)
|
||||
total_result = execute_query(
|
||||
'SELECT COUNT(*) as total FROM t_user WHERE user_name LIKE %s OR phone LIKE %s',
|
||||
(f'%{search}%', f'%{search}%')
|
||||
)
|
||||
else:
|
||||
users = execute_query(
|
||||
'SELECT id AS user_id, user_name, phone FROM t_user ORDER BY user_name LIMIT %s OFFSET %s',
|
||||
(page_size, offset)
|
||||
)
|
||||
total_result = execute_query(
|
||||
'SELECT COUNT(*) as total FROM t_user'
|
||||
)
|
||||
total = total_result[0]['total'] if total_result else 0
|
||||
return jsonify({'success': True, 'data': users, 'total': total, 'page': page, 'page_size': page_size})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
|
||||
|
||||
@entity_bp.route('/entities/by_ids', methods=['POST'])
|
||||
def get_entities_by_ids():
|
||||
"""根据 ID 列表查询实体信息(用于编辑回显)"""
|
||||
try:
|
||||
data = request.json
|
||||
entity_type = data.get('type', 'company')
|
||||
ids = data.get('ids', [])
|
||||
|
||||
if not ids:
|
||||
return jsonify({'success': True, 'data': []})
|
||||
|
||||
placeholders = ', '.join(['%s'] * len(ids))
|
||||
|
||||
if entity_type == 'company':
|
||||
entities = execute_query(
|
||||
f'SELECT id AS company_id, company_name FROM t_company WHERE id IN ({placeholders})',
|
||||
tuple(ids)
|
||||
)
|
||||
elif entity_type == 'station':
|
||||
entities = execute_query(
|
||||
f'SELECT id AS station_id, station_name FROM t_station WHERE id IN ({placeholders})',
|
||||
tuple(ids)
|
||||
)
|
||||
else:
|
||||
entities = execute_query(
|
||||
f'SELECT id AS user_id, user_name FROM t_user WHERE id IN ({placeholders})',
|
||||
tuple(ids)
|
||||
)
|
||||
|
||||
return jsonify({'success': True, 'data': entities})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
16
lib/api/field_api.py
Normal file
16
lib/api/field_api.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
字段列表 API
|
||||
"""
|
||||
from flask import jsonify
|
||||
from lib.field_mapping import get_all_fields_with_display
|
||||
from . import field_bp
|
||||
|
||||
|
||||
@field_bp.route('/fields', methods=['GET'])
|
||||
def get_fields():
|
||||
"""获取所有可用字段"""
|
||||
try:
|
||||
fields = get_all_fields_with_display()
|
||||
return jsonify({'success': True, 'data': fields})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
70
lib/api/init_api.py
Normal file
70
lib/api/init_api.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""
|
||||
数据库初始化 API
|
||||
"""
|
||||
from flask import jsonify
|
||||
from lib.db import execute_update
|
||||
from . import init_bp
|
||||
|
||||
|
||||
@init_bp.route('/init-tables', methods=['POST'])
|
||||
def init_tables():
|
||||
"""初始化数据库表"""
|
||||
try:
|
||||
# 创建配置表
|
||||
create_config_table = """
|
||||
CREATE TABLE IF NOT EXISTS t_daily_report_config (
|
||||
id BIGINT NOT NULL COMMENT '配置 ID',
|
||||
config_name VARCHAR(100) NOT NULL COMMENT '配置名称',
|
||||
split_type VARCHAR(20) NOT NULL COMMENT '拆分方式',
|
||||
split_value VARCHAR(100) NOT NULL COMMENT '拆分值',
|
||||
split_name VARCHAR(200) NULL COMMENT '拆分显示名称',
|
||||
selected_fields VARCHAR(3000) NOT NULL COMMENT '选择的字段列表',
|
||||
sum_fields VARCHAR(3000) NULL COMMENT '求和字段列表',
|
||||
is_active TINYINT NULL COMMENT '是否启用',
|
||||
sort_order INT NULL COMMENT '排序顺序',
|
||||
create_time DATETIME NULL COMMENT '创建时间',
|
||||
update_time DATETIME NULL COMMENT '更新时间'
|
||||
)
|
||||
UNIQUE KEY(id)
|
||||
DISTRIBUTED BY HASH(id) BUCKETS 1
|
||||
PROPERTIES("replication_num" = "1")
|
||||
"""
|
||||
execute_update(create_config_table)
|
||||
|
||||
# 创建历史表
|
||||
create_history_table = """
|
||||
CREATE TABLE IF NOT EXISTS t_daily_report_history (
|
||||
id BIGINT NOT NULL COMMENT '历史 ID',
|
||||
report_date VARCHAR(20) NULL COMMENT '报表日期',
|
||||
start_time VARCHAR(50) NULL COMMENT '开始时间',
|
||||
end_time VARCHAR(50) NULL COMMENT '结束时间',
|
||||
split_type VARCHAR(20) NULL COMMENT '拆分方式',
|
||||
split_value VARCHAR(100) NULL COMMENT '拆分值',
|
||||
split_name VARCHAR(200) NULL COMMENT '拆分显示名称',
|
||||
config_id BIGINT NULL COMMENT '配置 ID',
|
||||
config_name VARCHAR(200) NULL COMMENT '配置名称',
|
||||
total_orders INT NULL COMMENT '订单总数',
|
||||
total_amount DECIMAL(10,2) NULL COMMENT '总金额',
|
||||
sum_results VARCHAR(2000) NULL COMMENT '求和结果',
|
||||
file_path VARCHAR(500) NULL COMMENT '文件路径',
|
||||
status TINYINT NULL COMMENT '状态',
|
||||
create_time DATETIME NULL COMMENT '创建时间'
|
||||
)
|
||||
UNIQUE KEY(id)
|
||||
DISTRIBUTED BY HASH(id) BUCKETS 1
|
||||
PROPERTIES("replication_num" = "1")
|
||||
"""
|
||||
execute_update(create_history_table)
|
||||
|
||||
# 为已存在的表添加 config_name 字段(如果不存在)
|
||||
try:
|
||||
execute_update("""
|
||||
ALTER TABLE t_daily_report_history
|
||||
ADD COLUMN IF NOT EXISTS config_name VARCHAR(200) NULL COMMENT '配置名称'
|
||||
""")
|
||||
except Exception as e:
|
||||
print(f"[数据库] 添加 config_name 字段时出错(可能已存在): {e}")
|
||||
|
||||
return jsonify({'success': True, 'message': '数据库表初始化成功'})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
199
lib/api/report_api.py
Normal file
199
lib/api/report_api.py
Normal file
@@ -0,0 +1,199 @@
|
||||
"""
|
||||
日报生成和历史记录 API
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
from datetime import datetime
|
||||
from flask import request, jsonify, send_from_directory
|
||||
from lib.db import execute_query, execute_update
|
||||
from lib.report_generator import generate_daily_report
|
||||
from . import report_bp
|
||||
|
||||
|
||||
@report_bp.route('/report/generate', methods=['POST'])
|
||||
def generate_report():
|
||||
"""生成日报"""
|
||||
try:
|
||||
data = request.json
|
||||
config_id = data.get('config_id')
|
||||
|
||||
if not config_id:
|
||||
return jsonify({'success': False, 'message': '请选择配置'}), 400
|
||||
|
||||
# 解析时间参数
|
||||
start_time = None
|
||||
end_time = None
|
||||
|
||||
if data.get('start_time'):
|
||||
start_time = datetime.strptime(data['start_time'], '%Y-%m-%d %H:%M:%S')
|
||||
if data.get('end_time'):
|
||||
end_time = datetime.strptime(data['end_time'], '%Y-%m-%d %H:%M:%S')
|
||||
|
||||
result = generate_daily_report(config_id, start_time, end_time)
|
||||
|
||||
if result['success']:
|
||||
return jsonify(result)
|
||||
else:
|
||||
return jsonify(result), 400
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
|
||||
|
||||
@report_bp.route('/report/history', methods=['GET'])
|
||||
def get_report_history():
|
||||
"""获取日报生成历史"""
|
||||
try:
|
||||
page = int(request.args.get('page', 1))
|
||||
page_size = int(request.args.get('page_size', 20))
|
||||
start_date = request.args.get('start_date')
|
||||
end_date = request.args.get('end_date')
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
# 构建查询条件
|
||||
where_clauses = []
|
||||
params = []
|
||||
|
||||
if start_date:
|
||||
where_clauses.append('DATE(start_time) >= %s')
|
||||
params.append(start_date)
|
||||
|
||||
if end_date:
|
||||
where_clauses.append('DATE(end_time) <= %s')
|
||||
params.append(end_date)
|
||||
|
||||
where_sql = ' AND '.join(where_clauses) if where_clauses else '1=1'
|
||||
|
||||
# 查询总数
|
||||
total_result = execute_query(
|
||||
f'SELECT COUNT(*) as total FROM t_daily_report_history WHERE {where_sql}',
|
||||
tuple(params)
|
||||
)
|
||||
total = total_result[0]['total']
|
||||
|
||||
# 查询数据
|
||||
history = execute_query(
|
||||
f'SELECT * FROM t_daily_report_history WHERE {where_sql} ORDER BY create_time DESC LIMIT %s OFFSET %s',
|
||||
tuple(params) + (page_size, offset)
|
||||
)
|
||||
|
||||
# 解析 JSON 字段并确保 file_path 正确
|
||||
for item in history:
|
||||
item['sum_results'] = json.loads(item['sum_results']) if item['sum_results'] else {}
|
||||
# 确保 file_path 是字符串
|
||||
if item['file_path'] is None:
|
||||
item['file_path'] = ''
|
||||
|
||||
# 调试日志
|
||||
print(f"[历史记录] ID: {item['id']}, status: {item['status']}, file_path: '{item['file_path']}'")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': {
|
||||
'list': history,
|
||||
'total': total,
|
||||
'page': page,
|
||||
'page_size': page_size
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"[历史记录 API] 错误:{e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
|
||||
|
||||
@report_bp.route('/report/history/<int:history_id>', methods=['DELETE'])
|
||||
def delete_report_history(history_id):
|
||||
"""删除单个历史记录"""
|
||||
try:
|
||||
# 先查询文件路径
|
||||
result = execute_query(
|
||||
'SELECT file_path FROM t_daily_report_history WHERE id = %s',
|
||||
(history_id,)
|
||||
)
|
||||
|
||||
if result:
|
||||
file_path = result[0].get('file_path', '')
|
||||
# 删除文件
|
||||
if file_path and os.path.exists(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', '..', 'public', file_path.lstrip('/'))):
|
||||
os.remove(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', '..', 'public', file_path.lstrip('/')))
|
||||
|
||||
# 删除数据库记录
|
||||
execute_update('DELETE FROM t_daily_report_history WHERE id = %s', (history_id,))
|
||||
|
||||
return jsonify({'success': True, 'message': '删除成功'})
|
||||
except Exception as e:
|
||||
print(f"[删除历史记录] 错误:{e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
|
||||
|
||||
@report_bp.route('/report/history/batch-delete', methods=['POST'])
|
||||
def batch_delete_report_history():
|
||||
"""批量删除历史记录"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
ids = data.get('ids', [])
|
||||
|
||||
if not ids:
|
||||
return jsonify({'success': False, 'message': '请选择要删除的记录'}), 400
|
||||
|
||||
# 查询文件路径
|
||||
results = execute_query(
|
||||
f'SELECT file_path FROM t_daily_report_history WHERE id IN ({",".join(["%s"] * len(ids))})',
|
||||
tuple(ids)
|
||||
)
|
||||
|
||||
# 删除文件
|
||||
for result in results:
|
||||
file_path = result.get('file_path', '')
|
||||
if file_path and os.path.exists(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', '..', 'public', file_path.lstrip('/'))):
|
||||
os.remove(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', '..', 'public', file_path.lstrip('/')))
|
||||
|
||||
# 删除数据库记录
|
||||
execute_update(
|
||||
f'DELETE FROM t_daily_report_history WHERE id IN ({",".join(["%s"] * len(ids))})',
|
||||
tuple(ids)
|
||||
)
|
||||
|
||||
return jsonify({'success': True, 'message': f'成功删除 {len(ids)} 条记录'})
|
||||
except Exception as e:
|
||||
print(f"[批量删除历史记录] 错误:{e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
|
||||
|
||||
@report_bp.route('/report/history/<int:history_id>/download', methods=['GET'])
|
||||
def download_report_by_id(history_id):
|
||||
"""根据 ID 下载历史记录"""
|
||||
try:
|
||||
# 查询历史记录
|
||||
result = execute_query(
|
||||
'SELECT file_path, split_name, report_date FROM t_daily_report_history WHERE id = %s',
|
||||
(history_id,)
|
||||
)
|
||||
|
||||
if not result:
|
||||
return jsonify({'success': False, 'message': '记录不存在'}), 404
|
||||
|
||||
file_path = result[0]['file_path']
|
||||
if not file_path:
|
||||
return jsonify({'success': False, 'message': '文件不存在'}), 404
|
||||
|
||||
# 构建完整路径
|
||||
full_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', '..', 'public', file_path.lstrip('/'))
|
||||
|
||||
if not os.path.exists(full_path):
|
||||
return jsonify({'success': False, 'message': '文件不存在'}), 404
|
||||
|
||||
# 返回文件
|
||||
directory = os.path.dirname(full_path)
|
||||
filename = os.path.basename(full_path)
|
||||
return send_from_directory(directory, filename, as_attachment=True)
|
||||
except Exception as e:
|
||||
print(f"[下载历史记录] 错误:{e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return jsonify({'success': False, 'message': str(e)}), 500
|
||||
805
static/css/style.css
Normal file
805
static/css/style.css
Normal file
@@ -0,0 +1,805 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||
background: #f5f7fa;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
border-bottom: 2px solid #e1e8ed;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 12px 24px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 16px;
|
||||
color: #666;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: #3498db;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: #3498db;
|
||||
border-bottom: 2px solid #3498db;
|
||||
margin-bottom: -2px;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-group label .label-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #eef2f7;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.form-group .form-hint {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 4px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
border: 1.5px solid #e1e8ed;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: #3498db;
|
||||
background: white;
|
||||
box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1);
|
||||
}
|
||||
|
||||
select {
|
||||
background: #fafbfc;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
select[multiple] {
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
select[multiple] option {
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 2px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
select[multiple] option:hover {
|
||||
background: #eef2f7;
|
||||
}
|
||||
|
||||
select[multiple] option:checked {
|
||||
background: #3498db;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 20px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3498db;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #2980b9;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: #27ae60;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background: #229954;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #e74c3c;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #c0392b;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background: #f39c12;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-warning:hover {
|
||||
background: #d35400;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #eef2f7;
|
||||
color: #555;
|
||||
border: 1px solid #e1e8ed;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #dde4ed;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e1e8ed;
|
||||
}
|
||||
|
||||
th {
|
||||
background: #f8f9fa;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.checkbox-group {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 6px;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
border: 1.5px solid #e1e8ed;
|
||||
border-radius: 8px;
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.checkbox-group::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.checkbox-group::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.checkbox-group::-webkit-scrollbar-thumb {
|
||||
background: #ccc;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.checkbox-group::-webkit-scrollbar-thumb:hover {
|
||||
background: #aaa;
|
||||
}
|
||||
|
||||
.selected-fields-order {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 12px;
|
||||
border: 1.5px dashed #4a90e2;
|
||||
border-radius: 8px;
|
||||
background: #f8fbff;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.selected-field-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
background: #f0f9ff;
|
||||
border: 1px solid #bae6fd;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.selected-field-item .field-index {
|
||||
color: #0284c7;
|
||||
font-weight: 600;
|
||||
min-width: 24px;
|
||||
}
|
||||
|
||||
.selected-field-item .field-name {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.selected-field-item .field-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.selected-field-item .btn-move {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 1px solid #cbd5e1;
|
||||
background: white;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.selected-field-item .btn-move:hover:not(:disabled) {
|
||||
background: #e2e8f0;
|
||||
border-color: #94a3b8;
|
||||
}
|
||||
|
||||
.selected-field-item .btn-move:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.selected-field-item .field-index {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
background: #f0f0f0;
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.checkbox-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.checkbox-item:hover {
|
||||
background: #eef2f7;
|
||||
}
|
||||
|
||||
.checkbox-item.checked {
|
||||
background: #e8f4fd;
|
||||
border: 1px solid #bee5eb;
|
||||
}
|
||||
|
||||
.checkbox-item.required-field {
|
||||
background: #fff3cd;
|
||||
border: 1px solid #ffc107;
|
||||
}
|
||||
|
||||
.checkbox-item.required-field:hover {
|
||||
background: #fff3cd;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.required-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 6px;
|
||||
background: #ffc107;
|
||||
color: #000;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.checkbox-item input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: #3498db;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.checkbox-item label {
|
||||
font-size: 13px;
|
||||
color: #444;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-active {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.status-inactive {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 12px 16px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
background: #d1ecf1;
|
||||
color: #0c5460;
|
||||
border: 1px solid #bee5eb;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0,0,0,0.4);
|
||||
z-index: 1000;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.modal.active {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 0;
|
||||
max-width: 860px;
|
||||
width: 92%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.15);
|
||||
animation: slideUp 0.25s ease;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from { transform: translateY(20px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
.modal-content::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.modal-content::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.modal-content::-webkit-scrollbar-thumb {
|
||||
background: #ddd;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 28px;
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #eef2f7 100%);
|
||||
border-radius: 16px 16px 0 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #2c3e50;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.modal-title::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 4px;
|
||||
height: 20px;
|
||||
background: #3498db;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 22px;
|
||||
cursor: pointer;
|
||||
color: #999;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: #eef2f7;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 24px 28px;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
padding: 16px 28px;
|
||||
border-top: 1px solid #eef2f7;
|
||||
background: #fafbfc;
|
||||
border-radius: 0 0 16px 16px;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px dashed #eef2f7;
|
||||
}
|
||||
|
||||
.form-section:last-child {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.form-section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #3498db;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-section-title .section-num {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #3498db;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* 时段编辑器样式 */
|
||||
.time-period-editor {
|
||||
background: #f8f9fa;
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.time-period-header {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.time-period-hint {
|
||||
font-size: 12px;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.time-period-tools {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tp-tool-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 6px;
|
||||
background: white;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tp-tool-btn:hover {
|
||||
background: #f8f9fa;
|
||||
border-color: #adb5bd;
|
||||
}
|
||||
|
||||
.tp-tool-btn.active {
|
||||
background: #3498db;
|
||||
color: white;
|
||||
border-color: #3498db;
|
||||
}
|
||||
|
||||
.tp-tool-btn.tp-sharp { background: #e74c3c; color: white; border-color: #e74c3c; }
|
||||
.tp-tool-btn.tp-peak { background: #f39c12; color: white; border-color: #f39c12; }
|
||||
.tp-tool-btn.tp-flat { background: #27ae60; color: white; border-color: #27ae60; }
|
||||
.tp-tool-btn.tp-valley { background: #3498db; color: white; border-color: #3498db; }
|
||||
|
||||
.time-period-grid-wrapper {
|
||||
overflow-x: auto;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.tp-hours-header {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.tp-label-col {
|
||||
width: 50px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tp-hours-row {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tp-hour-label {
|
||||
width: 28px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
color: #6c757d;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tp-period-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tp-period-row {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tp-period-label {
|
||||
width: 50px;
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.tp-period-label.sharp { background: #e74c3c; color: white; }
|
||||
.tp-period-label.peak { background: #f39c12; color: white; }
|
||||
.tp-period-label.flat { background: #27ae60; color: white; }
|
||||
.tp-period-label.valley { background: #3498db; color: white; }
|
||||
|
||||
.tp-period-cells {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tp-cell {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 4px;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tp-cell:hover {
|
||||
border-color: #adb5bd;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.tp-cell.sharp { background: #e74c3c; border-color: #e74c3c; color: white; }
|
||||
.tp-cell.peak { background: #f39c12; border-color: #f39c12; color: white; }
|
||||
.tp-cell.flat { background: #27ae60; border-color: #27ae60; color: white; }
|
||||
.tp-cell.valley { background: #3498db; border-color: #3498db; color: white; }
|
||||
|
||||
.time-period-summary {
|
||||
font-size: 12px;
|
||||
color: #495057;
|
||||
padding: 8px 12px;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.time-period-summary span {
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.time-period-summary .tp-count {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.entity-select-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.entity-select-wrapper .selected-count {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: 12px;
|
||||
background: #3498db;
|
||||
color: white;
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.pagination button {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid #ddd;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.pagination button:hover:not(:disabled) {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.pagination button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.pagination span {
|
||||
color: #666;
|
||||
}
|
||||
31
static/js/common.js
Normal file
31
static/js/common.js
Normal file
@@ -0,0 +1,31 @@
|
||||
// 全局错误处理,防止JavaScript错误中断执行
|
||||
window.onerror = function(msg, url, line, col, error) {
|
||||
console.error('JavaScript错误:', msg, 'at', url, 'line:', line);
|
||||
return true; // 阻止错误传播
|
||||
};
|
||||
|
||||
let editingConfigId = null;
|
||||
let allFields = [];
|
||||
let currentPage = 1;
|
||||
const pageSize = 20;
|
||||
|
||||
// 切换标签页
|
||||
function switchTab(tabName, event) {
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
|
||||
|
||||
if (event && event.target) {
|
||||
event.target.classList.add('active');
|
||||
}
|
||||
document.getElementById(`${tabName}-tab`).classList.add('active');
|
||||
|
||||
if (tabName === 'config') {
|
||||
loadConfigs();
|
||||
} else if (tabName === 'generate') {
|
||||
loadGenerateConfigs();
|
||||
} else if (tabName === 'history') {
|
||||
loadHistory();
|
||||
}
|
||||
}
|
||||
|
||||
// 加载配置列表
|
||||
910
static/js/config.js
Normal file
910
static/js/config.js
Normal file
@@ -0,0 +1,910 @@
|
||||
async function loadConfigs() {
|
||||
try {
|
||||
const response = await fetch('/api/config');
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
renderConfigList(result.data);
|
||||
} else {
|
||||
alert('加载配置失败: ' + result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('加载配置失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染配置列表
|
||||
function renderConfigList(configs) {
|
||||
const tbody = document.getElementById('config-list');
|
||||
|
||||
if (configs.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="empty">暂无配置,请点击"创建配置"按钮</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = configs.map(config => `
|
||||
<tr>
|
||||
<td>${config.config_name}</td>
|
||||
<td>${config.split_type === 'company_id' ? '按企业ID' : '按用户ID'}</td>
|
||||
<td>${config.split_name || config.split_value}</td>
|
||||
<td>${config.selected_fields.length}</td>
|
||||
<td>
|
||||
<span class="status-badge ${config.is_active ? 'status-active' : 'status-inactive'}">
|
||||
${config.is_active ? '已启用' : '已禁用'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="action-buttons">
|
||||
<button class="btn btn-sm btn-warning" onclick="toggleConfig(${config.id})">
|
||||
${config.is_active ? '禁用' : '启用'}
|
||||
</button>
|
||||
<button class="btn btn-sm btn-primary" onclick="editConfig(${config.id})">编辑</button>
|
||||
<button class="btn btn-sm btn-success" onclick="copyConfig(${config.id})">复制</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteConfig(${config.id})">删除</button>
|
||||
<button class="btn btn-sm" onclick="moveConfig(${config.id}, 'up')">↑</button>
|
||||
<button class="btn btn-sm" onclick="moveConfig(${config.id}, 'down')">↓</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// 显示创建模态框
|
||||
function showCreateModal() {
|
||||
editingConfigId = null;
|
||||
document.getElementById('modal-title').textContent = '创建配置';
|
||||
document.getElementById('config-name').value = '';
|
||||
document.getElementById('split-type').value = '';
|
||||
document.getElementById('split-value').innerHTML = '<option value="">请先选择拆分方式</option>';
|
||||
|
||||
// 重置字段自定义名头
|
||||
fieldCustomNames = {};
|
||||
|
||||
// 重置合并特来电配置
|
||||
const mergeTelecomCheckbox = document.getElementById('merge-telecom');
|
||||
const telecomVehicleNoInput = document.getElementById('telecom-vehicle-no');
|
||||
const mergeTelecomGroup = document.getElementById('merge-telecom-group');
|
||||
if (mergeTelecomCheckbox) mergeTelecomCheckbox.checked = false;
|
||||
if (telecomVehicleNoInput) telecomVehicleNoInput.value = '';
|
||||
if (mergeTelecomGroup) mergeTelecomGroup.style.display = 'none';
|
||||
toggleTelecomVehicleNo();
|
||||
|
||||
loadFields();
|
||||
initTimePeriodEditor();
|
||||
clearAllTimePeriods();
|
||||
document.getElementById('config-modal').classList.add('active');
|
||||
}
|
||||
|
||||
// 加载字段列表
|
||||
async function loadFields(selectedFields = [], sumFields = []) {
|
||||
try {
|
||||
const response = await fetch('/api/fields');
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
allFields = result.data;
|
||||
renderFields(selectedFields, sumFields);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载字段失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 可求和的数值类型字段
|
||||
const SUMMABLE_FIELD_KEYS = [
|
||||
'charge_degree', 'charge_ah', 'charge_begin_soc', 'charge_end_soc',
|
||||
'receivable_electric_fee', 'receivable_service_fee', 'receivable_total_fee',
|
||||
'charge_elecfee_amount', 'charge_elecfee_cost_amount', 'charge_service_amount',
|
||||
'charging_amt', 'actual_pay_amount', 'pay_amount',
|
||||
'settle_electric_fee', 'settle_service_fee', 'settle_fee',
|
||||
'coupon_amount', 'coupon_total_amount', 'elecfee_coupon_amount', 'servicefee_coupon_amount',
|
||||
'invoice_fee', 'activity_electric_fee', 'activity_service_fee', 'activity_total_fee',
|
||||
'total_money',
|
||||
// 分时段电量
|
||||
'sharp_electricity', 'peak_electricity', 'flat_electricity', 'valley_electricity'
|
||||
];
|
||||
|
||||
// 渲染字段列表
|
||||
function renderFields(selectedFields = [], sumFields = []) {
|
||||
const fieldsList = document.getElementById('fields-list');
|
||||
const sumFieldsList = document.getElementById('sum-fields-list');
|
||||
|
||||
fieldsList.innerHTML = allFields.map(field => {
|
||||
const isSelected = selectedFields.includes(field.key);
|
||||
const isRequired = field.key === 'charge_degree'; // 充电量为必选字段
|
||||
return `
|
||||
<div class="checkbox-item ${isSelected ? 'checked' : ''} ${isRequired ? 'required-field' : ''}" onclick="${isRequired ? '' : 'toggleCheckboxItem(this)'}">
|
||||
<input type="checkbox" value="${field.key}"
|
||||
${isSelected ? 'checked' : ''}
|
||||
${isRequired ? 'disabled checked' : ''}
|
||||
onchange="onFieldCheckboxChange(this)">
|
||||
<label>${field.display}${isRequired ? ' <span class="required-badge">必选</span>' : ''}</label>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// 初始化求和字段列表(只显示已选字段中的可求和字段)
|
||||
refreshSumFieldsList(selectedFields, sumFields);
|
||||
}
|
||||
|
||||
// 点击 checkbox-item 容器时切换选中状态
|
||||
function toggleCheckboxItem(item) {
|
||||
const cb = item.querySelector('input[type="checkbox"]');
|
||||
if (event.target === cb) return; // 如果点击的就是 checkbox 本身,不重复触发
|
||||
cb.checked = !cb.checked;
|
||||
item.classList.toggle('checked', cb.checked);
|
||||
onFieldCheckboxChange(cb);
|
||||
}
|
||||
|
||||
// 字段 checkbox 变化时更新求和字段列表和已选字段顺序
|
||||
function onFieldCheckboxChange(cb) {
|
||||
// 防止取消选择必选字段(充电量)
|
||||
if (cb.value === 'charge_degree' && !cb.checked) {
|
||||
cb.checked = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const item = cb.closest('.checkbox-item');
|
||||
if (item) item.classList.toggle('checked', cb.checked);
|
||||
|
||||
const fieldsList = document.getElementById('fields-list');
|
||||
const checkedFields = Array.from(fieldsList.querySelectorAll('input:checked')).map(c => c.value);
|
||||
|
||||
// 保留当前求和字段的选择状态
|
||||
const sumFieldsList = document.getElementById('sum-fields-list');
|
||||
const currentSumFields = sumFieldsList ? Array.from(sumFieldsList.querySelectorAll('input:checked')).map(c => c.value) : [];
|
||||
|
||||
refreshSumFieldsList(checkedFields, currentSumFields);
|
||||
refreshSelectedFieldsOrder(checkedFields);
|
||||
}
|
||||
|
||||
// 字段自定义名头存储
|
||||
let fieldCustomNames = {};
|
||||
|
||||
// 刷新已选字段顺序列表
|
||||
function refreshSelectedFieldsOrder(selectedFields, customNames = null) {
|
||||
const orderGroup = document.getElementById('selected-fields-order-group');
|
||||
const orderList = document.getElementById('selected-fields-order');
|
||||
if (!orderList) return;
|
||||
|
||||
// 如果传入了自定义名头,更新全局变量
|
||||
if (customNames !== null) {
|
||||
fieldCustomNames = {...customNames};
|
||||
}
|
||||
|
||||
// 根据是否有已选字段,显示/隐藏字段顺序区域
|
||||
if (orderGroup) {
|
||||
orderGroup.style.display = selectedFields.length > 0 ? 'block' : 'none';
|
||||
}
|
||||
|
||||
if (selectedFields.length === 0) {
|
||||
orderList.innerHTML = '<small style="color:#999;text-align:center;padding:8px;">请先在上方勾选字段</small>';
|
||||
} else {
|
||||
orderList.innerHTML = selectedFields.map((fieldKey, index) => {
|
||||
const field = allFields.find(f => f.key === fieldKey);
|
||||
const displayName = field ? field.display : fieldKey;
|
||||
const customName = fieldCustomNames[fieldKey] || '';
|
||||
const isFirst = index === 0;
|
||||
const isLast = index === selectedFields.length - 1;
|
||||
return `
|
||||
<div class="selected-field-item" data-field="${fieldKey}">
|
||||
<span class="field-index">#${index + 1}</span>
|
||||
<span class="field-name" title="${displayName}">${displayName}</span>
|
||||
<input type="text" class="field-custom-name" placeholder="自定义报表名头" value="${customName}"
|
||||
onchange="updateFieldCustomName('${fieldKey}', this.value)"
|
||||
style="flex: 1; min-width: 100px; padding: 4px 8px; font-size: 12px;">
|
||||
<div class="field-actions">
|
||||
<button type="button" class="btn-move" onclick="moveFieldUp(${index})" ${isFirst ? 'disabled' : ''} title="上移">↑</button>
|
||||
<button type="button" class="btn-move" onclick="moveFieldDown(${index})" ${isLast ? 'disabled' : ''} title="下移">↓</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
}
|
||||
|
||||
// 更新字段自定义名头
|
||||
function updateFieldCustomName(fieldKey, customName) {
|
||||
if (customName && customName.trim()) {
|
||||
fieldCustomNames[fieldKey] = customName.trim();
|
||||
} else {
|
||||
delete fieldCustomNames[fieldKey];
|
||||
}
|
||||
}
|
||||
|
||||
// 获取字段自定义名头
|
||||
function getFieldCustomNames() {
|
||||
return {...fieldCustomNames};
|
||||
}
|
||||
|
||||
// 字段上移
|
||||
function moveFieldUp(index) {
|
||||
if (index <= 0) return;
|
||||
const orderList = document.getElementById('selected-fields-order');
|
||||
const items = Array.from(orderList.querySelectorAll('.selected-field-item'));
|
||||
const item = items[index];
|
||||
const prevItem = items[index - 1];
|
||||
orderList.insertBefore(item, prevItem);
|
||||
updateFieldIndexes();
|
||||
}
|
||||
|
||||
// 字段下移
|
||||
function moveFieldDown(index) {
|
||||
const orderList = document.getElementById('selected-fields-order');
|
||||
const items = Array.from(orderList.querySelectorAll('.selected-field-item'));
|
||||
if (index >= items.length - 1) return;
|
||||
const item = items[index];
|
||||
const nextItem = items[index + 1];
|
||||
orderList.insertBefore(nextItem, item);
|
||||
updateFieldIndexes();
|
||||
}
|
||||
|
||||
// 更新字段序号
|
||||
function updateFieldIndexes() {
|
||||
const orderList = document.getElementById('selected-fields-order');
|
||||
const items = orderList.querySelectorAll('.selected-field-item');
|
||||
items.forEach((item, index) => {
|
||||
const indexSpan = item.querySelector('.field-index');
|
||||
if (indexSpan) indexSpan.textContent = `#${index + 1}`;
|
||||
|
||||
// 更新按钮状态
|
||||
const upBtn = item.querySelector('.btn-move[title="上移"]');
|
||||
const downBtn = item.querySelector('.btn-move[title="下移"]');
|
||||
if (upBtn) upBtn.disabled = index === 0;
|
||||
if (downBtn) downBtn.disabled = index === items.length - 1;
|
||||
});
|
||||
}
|
||||
|
||||
// 获取当前字段顺序
|
||||
function getSelectedFieldsOrder() {
|
||||
const orderList = document.getElementById('selected-fields-order');
|
||||
if (!orderList) return [];
|
||||
const items = orderList.querySelectorAll('.selected-field-item');
|
||||
return Array.from(items).map(item => item.dataset.field);
|
||||
}
|
||||
|
||||
// 刷新求和字段列表(仅显示已选字段中的数值类型字段)
|
||||
function refreshSumFieldsList(selectedFields, currentSumFields) {
|
||||
const sumFieldsList = document.getElementById('sum-fields-list');
|
||||
const availableSumFields = allFields.filter(f =>
|
||||
selectedFields.includes(f.key) && SUMMABLE_FIELD_KEYS.includes(f.key)
|
||||
);
|
||||
|
||||
if (availableSumFields.length === 0) {
|
||||
sumFieldsList.innerHTML = '<small style="color:#999;padding:8px;">请先在上方勾选数值类型的字段</small>';
|
||||
} else {
|
||||
sumFieldsList.innerHTML = availableSumFields.map(field => `
|
||||
<div class="checkbox-item ${currentSumFields.includes(field.key) ? 'checked' : ''}" onclick="toggleCheckboxItem(this)">
|
||||
<input type="checkbox" value="${field.key}"
|
||||
${currentSumFields.includes(field.key) ? 'checked' : ''}>
|
||||
<label>${field.display}</label>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
}
|
||||
|
||||
// 切换特来电车量自编号输入框显示
|
||||
function toggleTelecomVehicleNo() {
|
||||
const mergeTelecom = document.getElementById('merge-telecom').checked;
|
||||
const vehicleNoGroup = document.getElementById('telecom-vehicle-no-group');
|
||||
if (vehicleNoGroup) {
|
||||
vehicleNoGroup.style.display = mergeTelecom ? 'block' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// 更新已选实体计数
|
||||
function updateSelectedCount() {
|
||||
const select = document.getElementById('split-value');
|
||||
const countEl = document.getElementById('selected-entity-count');
|
||||
const count = select.selectedOptions.length;
|
||||
if (count > 0) {
|
||||
countEl.textContent = '已选 ' + count + ' 项';
|
||||
countEl.style.display = 'inline';
|
||||
} else {
|
||||
countEl.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 时段编辑器 ==========
|
||||
const TIME_PERIODS = [
|
||||
{ key: 'sharp', label: '尖', color: '#e74c3c' },
|
||||
{ key: 'peak', label: '峰', color: '#f39c12' },
|
||||
{ key: 'flat', label: '平', color: '#27ae60' },
|
||||
{ key: 'valley', label: '谷', color: '#3498db' }
|
||||
];
|
||||
|
||||
// 时段配置: { sharp: [11,12], peak: [9,10], flat: [7,8], valley: [0,1,2,3] }
|
||||
let timePeriodConfig = { sharp: [], peak: [], flat: [], valley: [] };
|
||||
let currentTpTool = 'select'; // 'select' or period key
|
||||
|
||||
// 初始化时段编辑器
|
||||
function initTimePeriodEditor() {
|
||||
const hoursRow = document.getElementById('tp-hours-row');
|
||||
const periodRows = document.getElementById('tp-period-rows');
|
||||
|
||||
// 渲染小时标题行
|
||||
let hoursHtml = '';
|
||||
for (let h = 0; h < 24; h++) {
|
||||
hoursHtml += `<div class="tp-hour-label">${h}</div>`;
|
||||
}
|
||||
hoursRow.innerHTML = hoursHtml;
|
||||
|
||||
// 渲染时段行
|
||||
let rowsHtml = '';
|
||||
TIME_PERIODS.forEach(period => {
|
||||
rowsHtml += `
|
||||
<div class="tp-period-row" data-period="${period.key}">
|
||||
<div class="tp-period-label ${period.key}">${period.label}时</div>
|
||||
<div class="tp-period-cells" id="tp-cells-${period.key}">
|
||||
${Array.from({length: 24}, (_, h) =>
|
||||
`<div class="tp-cell" data-hour="${h}" data-period="${period.key}" onclick="toggleTimePeriodCell(${h}, '${period.key}')"></div>`
|
||||
).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
periodRows.innerHTML = rowsHtml;
|
||||
|
||||
updateTimePeriodSummary();
|
||||
}
|
||||
|
||||
// 切换时段格子
|
||||
function toggleTimePeriodCell(hour, periodKey) {
|
||||
console.log('toggleTimePeriodCell called:', hour, periodKey);
|
||||
// 检查该小时是否已被其他时段占用
|
||||
const occupiedBy = Object.entries(timePeriodConfig).find(([k, v]) => k !== periodKey && v.includes(hour));
|
||||
|
||||
if (occupiedBy) {
|
||||
// 已被其他时段占用,移除占用并分配给当前时段
|
||||
const [otherKey] = occupiedBy;
|
||||
timePeriodConfig[otherKey] = timePeriodConfig[otherKey].filter(h => h !== hour);
|
||||
updateCellDisplay(hour, otherKey);
|
||||
}
|
||||
|
||||
// 切换当前时段
|
||||
if (timePeriodConfig[periodKey].includes(hour)) {
|
||||
timePeriodConfig[periodKey] = timePeriodConfig[periodKey].filter(h => h !== hour);
|
||||
} else {
|
||||
timePeriodConfig[periodKey].push(hour);
|
||||
timePeriodConfig[periodKey].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
updateCellDisplay(hour, periodKey);
|
||||
updateTimePeriodSummary();
|
||||
}
|
||||
|
||||
// 更新格子显示
|
||||
function updateCellDisplay(hour, periodKey) {
|
||||
// 清除所有时段中该小时的样式
|
||||
TIME_PERIODS.forEach(p => {
|
||||
const cell = document.querySelector(`.tp-cell[data-hour="${hour}"][data-period="${p.key}"]`);
|
||||
if (cell) {
|
||||
cell.className = 'tp-cell';
|
||||
cell.textContent = '';
|
||||
}
|
||||
});
|
||||
|
||||
// 检查该小时属于哪个时段,并设置对应的样式
|
||||
for (const p of TIME_PERIODS) {
|
||||
if (timePeriodConfig[p.key].includes(hour)) {
|
||||
const cell = document.querySelector(`.tp-cell[data-hour="${hour}"][data-period="${p.key}"]`);
|
||||
if (cell) {
|
||||
cell.classList.add(p.key);
|
||||
cell.textContent = '✓';
|
||||
}
|
||||
break; // 每个小时只能属于一个时段
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新时段摘要
|
||||
function updateTimePeriodSummary() {
|
||||
const summaryEl = document.getElementById('tp-summary');
|
||||
const parts = TIME_PERIODS.map(p => {
|
||||
const hours = timePeriodConfig[p.key];
|
||||
const count = hours.length;
|
||||
const hoursStr = count > 0 ? hours.map(h => `${h}时`).join(',') : '未设置';
|
||||
return `<span style="color:${p.color}"><b>${p.label}时</b>: ${count}个 (${hoursStr})</span>`;
|
||||
});
|
||||
summaryEl.innerHTML = parts.join('');
|
||||
}
|
||||
|
||||
// 设置时段配置(编辑时加载)
|
||||
function setTimePeriodConfig(config) {
|
||||
if (!config) {
|
||||
timePeriodConfig = { sharp: [], peak: [], flat: [], valley: [] };
|
||||
} else {
|
||||
timePeriodConfig = {
|
||||
sharp: config.sharp || [],
|
||||
peak: config.peak || [],
|
||||
flat: config.flat || [],
|
||||
valley: config.valley || []
|
||||
};
|
||||
}
|
||||
|
||||
// 更新所有格子显示
|
||||
TIME_PERIODS.forEach(period => {
|
||||
for (let h = 0; h < 24; h++) {
|
||||
updateCellDisplay(h, period.key);
|
||||
}
|
||||
});
|
||||
updateTimePeriodSummary();
|
||||
}
|
||||
|
||||
// 获取时段配置(保存时调用)
|
||||
function getTimePeriodConfig() {
|
||||
console.log('getTimePeriodConfig called, current config:', timePeriodConfig);
|
||||
return { ...timePeriodConfig };
|
||||
}
|
||||
|
||||
// 清空所有时段
|
||||
function clearAllTimePeriods() {
|
||||
timePeriodConfig = { sharp: [], peak: [], flat: [], valley: [] };
|
||||
TIME_PERIODS.forEach(period => {
|
||||
for (let h = 0; h < 24; h++) {
|
||||
updateCellDisplay(h, period.key);
|
||||
}
|
||||
});
|
||||
updateTimePeriodSummary();
|
||||
}
|
||||
|
||||
// 当前实体加载状态
|
||||
let currentEntityType = '';
|
||||
let entityPage = 1;
|
||||
let currentSearch = '';
|
||||
const entityPageSize = 100;
|
||||
|
||||
// 加载企业/用户列表(支持分页和搜索)
|
||||
async function loadEntities(page = 1, search = '') {
|
||||
const splitType = document.getElementById('split-type').value;
|
||||
const select = document.getElementById('split-value');
|
||||
|
||||
// 显示/隐藏合并特来电选项(仅按企业拆分时显示)
|
||||
const mergeTelecomGroup = document.getElementById('merge-telecom-group');
|
||||
if (mergeTelecomGroup) {
|
||||
mergeTelecomGroup.style.display = splitType === 'company_id' ? 'block' : 'none';
|
||||
if (splitType !== 'company_id') {
|
||||
document.getElementById('merge-telecom').checked = false;
|
||||
toggleTelecomVehicleNo();
|
||||
}
|
||||
}
|
||||
|
||||
if (!splitType) {
|
||||
select.innerHTML = '<option value="">请先选择拆分方式</option>';
|
||||
return;
|
||||
}
|
||||
|
||||
// 保存当前状态
|
||||
currentEntityType = splitType === 'company_id' ? 'company' :
|
||||
splitType === 'user_id' ? 'user' :
|
||||
splitType === 'station_id' ? 'station' : '';
|
||||
entityPage = page;
|
||||
currentSearch = search;
|
||||
|
||||
select.innerHTML = '<option value="">加载中...</option>';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/entities?type=${currentEntityType}&page=${page}&page_size=${entityPageSize}&search=${encodeURIComponent(search)}`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
const entities = result.data;
|
||||
const total = result.total || 0;
|
||||
const totalPages = Math.ceil(total / entityPageSize);
|
||||
|
||||
if (entities.length === 0) {
|
||||
select.innerHTML = '<option value="">无数据</option>';
|
||||
} else {
|
||||
select.innerHTML = entities.map(e => {
|
||||
let value = '';
|
||||
let name = '';
|
||||
let typeLabel = '';
|
||||
|
||||
if (splitType === 'company_id') {
|
||||
value = e.company_id;
|
||||
name = e.company_name;
|
||||
} else if (splitType === 'user_id') {
|
||||
value = e.user_id;
|
||||
name = e.user_name;
|
||||
typeLabel = e.order_type === 3 ? '(企业用户)' : '(普通用户)';
|
||||
if (e.phone) {
|
||||
name = `${e.user_name} (${e.phone})`;
|
||||
}
|
||||
} else if (splitType === 'station_id') {
|
||||
value = e.station_id;
|
||||
name = e.station_name;
|
||||
}
|
||||
|
||||
return `<option value="${value}" data-name="${name}">${name} ${typeLabel}</option>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// 更新分页UI
|
||||
updatePagination(totalPages, total);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载列表失败:', error);
|
||||
select.innerHTML = '<option value="">加载失败</option>';
|
||||
}
|
||||
}
|
||||
|
||||
// 更新分页UI
|
||||
function updatePagination(totalPages, total) {
|
||||
const paginationDiv = document.getElementById('entity-pagination');
|
||||
if (totalPages <= 1) {
|
||||
paginationDiv.innerHTML = `<small class="text-muted">共 ${total} 条</small>`;
|
||||
return;
|
||||
}
|
||||
|
||||
let html = `<small class="text-muted">共 ${total} 条</small>`;
|
||||
html += `<button type="button" class="btn btn-sm btn-secondary" onclick="loadEntities(${entityPage - 1}, currentSearch)" ${entityPage <= 1 ? 'disabled' : ''}>上一页</button>`;
|
||||
html += `<span>${entityPage}/${totalPages}</span>`;
|
||||
html += `<button type="button" class="btn btn-sm btn-secondary" onclick="loadEntities(${entityPage + 1}, currentSearch)" ${entityPage >= totalPages ? 'disabled' : ''}>下一页</button>`;
|
||||
|
||||
paginationDiv.innerHTML = html;
|
||||
}
|
||||
|
||||
// 搜索实体
|
||||
function searchEntities() {
|
||||
const search = document.getElementById('entity-search').value.trim();
|
||||
loadEntities(1, search);
|
||||
}
|
||||
|
||||
// 回车搜索
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const searchInput = document.getElementById('entity-search');
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
searchEntities();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 保存配置
|
||||
async function saveConfig() {
|
||||
const configName = document.getElementById('config-name').value;
|
||||
const splitType = document.getElementById('split-type').value;
|
||||
const splitValueSelect = document.getElementById('split-value');
|
||||
|
||||
// 处理多选:获取所有选中的选项
|
||||
const selectedOptions = Array.from(splitValueSelect.selectedOptions).filter(opt => opt.value);
|
||||
const splitValue = selectedOptions.map(opt => opt.value).join(',');
|
||||
const splitName = selectedOptions.map(opt => opt.dataset.name || '').join(',');
|
||||
|
||||
// 获取字段顺序(从已选字段顺序列表获取)
|
||||
const selectedFields = getSelectedFieldsOrder();
|
||||
const sumFields = Array.from(document.querySelectorAll('#sum-fields-list input:checked')).map(cb => cb.value);
|
||||
|
||||
// 获取时段配置
|
||||
const timePeriods = getTimePeriodConfig();
|
||||
|
||||
// 获取合并特来电配置
|
||||
const mergeTelecom = document.getElementById('merge-telecom')?.checked || false;
|
||||
const telecomVehicleNo = document.getElementById('telecom-vehicle-no')?.value.trim() || '';
|
||||
|
||||
if (!configName || !splitType || !splitValue) {
|
||||
alert('请填写所有必填字段');
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedFields.length === 0) {
|
||||
alert('请至少选择一个字段');
|
||||
return;
|
||||
}
|
||||
|
||||
// 确保充电量为必选字段
|
||||
if (!selectedFields.includes('charge_degree')) {
|
||||
selectedFields.push('charge_degree');
|
||||
}
|
||||
|
||||
// 验证合并特来电配置
|
||||
if (mergeTelecom && splitType !== 'company_id') {
|
||||
alert('合并特来电数据仅支持按企业拆分');
|
||||
return;
|
||||
}
|
||||
if (mergeTelecom && !telecomVehicleNo) {
|
||||
alert('请输入特来电车量自编号');
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取字段自定义名头
|
||||
const fieldCustomNamesData = getFieldCustomNames();
|
||||
|
||||
const data = {
|
||||
config_name: configName,
|
||||
split_type: splitType,
|
||||
split_value: splitValue,
|
||||
split_name: splitName,
|
||||
selected_fields: selectedFields,
|
||||
sum_fields: sumFields,
|
||||
time_periods: timePeriods,
|
||||
merge_telecom: mergeTelecom,
|
||||
telecom_vehicle_no: telecomVehicleNo,
|
||||
field_custom_names: fieldCustomNamesData
|
||||
};
|
||||
|
||||
try {
|
||||
let response;
|
||||
if (editingConfigId) {
|
||||
response = await fetch(`/api/config/${editingConfigId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
} else {
|
||||
response = await fetch('/api/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
alert(editingConfigId ? '配置更新成功' : '配置创建成功');
|
||||
closeModal();
|
||||
loadConfigs();
|
||||
} else {
|
||||
alert('保存失败: ' + result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('保存失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑配置
|
||||
async function editConfig(configId) {
|
||||
try {
|
||||
const response = await fetch('/api/config');
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
const config = result.data.find(c => c.id === configId);
|
||||
if (config) {
|
||||
editingConfigId = configId;
|
||||
document.getElementById('modal-title').textContent = '编辑配置';
|
||||
document.getElementById('config-name').value = config.config_name;
|
||||
document.getElementById('split-type').value = config.split_type;
|
||||
|
||||
// 获取已选择的值
|
||||
const splitValues = config.split_value.split(',');
|
||||
|
||||
// 加载实体并回显
|
||||
await loadEntitiesForEdit(config.split_type, splitValues);
|
||||
|
||||
// 加载字段并回显选中状态
|
||||
await loadFields(config.selected_fields, config.sum_fields);
|
||||
|
||||
// 加载字段顺序和自定义名头
|
||||
const customNames = config.field_custom_names || {};
|
||||
refreshSelectedFieldsOrder(config.selected_fields, customNames);
|
||||
|
||||
// 初始化时段编辑器并加载配置
|
||||
initTimePeriodEditor();
|
||||
setTimePeriodConfig(config.time_periods);
|
||||
|
||||
// 加载合并特来电配置
|
||||
const mergeTelecomCheckbox = document.getElementById('merge-telecom');
|
||||
const telecomVehicleNoInput = document.getElementById('telecom-vehicle-no');
|
||||
if (mergeTelecomCheckbox) {
|
||||
mergeTelecomCheckbox.checked = config.merge_telecom === 1 || config.merge_telecom === true;
|
||||
toggleTelecomVehicleNo();
|
||||
}
|
||||
if (telecomVehicleNoInput) {
|
||||
telecomVehicleNoInput.value = config.telecom_vehicle_no || '';
|
||||
}
|
||||
|
||||
document.getElementById('config-modal').classList.add('active');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
alert('加载配置失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 加载实体用于编辑回显
|
||||
async function loadEntitiesForEdit(splitType, selectedValues) {
|
||||
const select = document.getElementById('split-value');
|
||||
|
||||
if (!splitType) {
|
||||
select.innerHTML = '<option value="">请先选择拆分方式</option>';
|
||||
return;
|
||||
}
|
||||
|
||||
const entityType = splitType === 'company_id' ? 'company' :
|
||||
splitType === 'user_id' ? 'user' :
|
||||
splitType === 'station_id' ? 'station' : '';
|
||||
|
||||
select.innerHTML = '<option value="">加载中...</option>';
|
||||
|
||||
try {
|
||||
// 先查询已选择的实体信息
|
||||
let selectedEntities = [];
|
||||
if (selectedValues.length > 0 && selectedValues[0] !== '') {
|
||||
const response = await fetch('/api/entities/by_ids', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ type: entityType, ids: selectedValues })
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
selectedEntities = result.data;
|
||||
}
|
||||
}
|
||||
|
||||
// 加载第一页数据
|
||||
const response = await fetch(`/api/entities?type=${entityType}&page=1&page_size=${entityPageSize}`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
const entities = result.data;
|
||||
const total = result.total || 0;
|
||||
const totalPages = Math.ceil(total / entityPageSize);
|
||||
|
||||
// 构建已选择实体的ID集合
|
||||
const selectedIdSet = new Set(selectedValues);
|
||||
|
||||
// 合并已选择的实体和第一页数据(去重)
|
||||
const mergedEntities = [];
|
||||
const addedIds = new Set();
|
||||
|
||||
// 先添加已选择的实体(确保它们显示在顶部)
|
||||
for (const e of selectedEntities) {
|
||||
let value = '';
|
||||
let name = '';
|
||||
let typeLabel = '';
|
||||
|
||||
if (splitType === 'company_id') {
|
||||
value = e.company_id;
|
||||
name = e.company_name;
|
||||
} else if (splitType === 'user_id') {
|
||||
value = e.user_id;
|
||||
name = e.user_name;
|
||||
typeLabel = e.order_type === 3 ? '(企业用户)' : '(普通用户)';
|
||||
if (e.phone) {
|
||||
name = `${e.user_name} (${e.phone})`;
|
||||
}
|
||||
} else if (splitType === 'station_id') {
|
||||
value = e.station_id;
|
||||
name = e.station_name;
|
||||
}
|
||||
|
||||
if (!addedIds.has(String(value))) {
|
||||
mergedEntities.push({ value, name, typeLabel, isSelected: true });
|
||||
addedIds.add(String(value));
|
||||
}
|
||||
}
|
||||
|
||||
// 再添加第一页数据(跳过已添加的)
|
||||
for (const e of entities) {
|
||||
let value = '';
|
||||
let name = '';
|
||||
let typeLabel = '';
|
||||
|
||||
if (splitType === 'company_id') {
|
||||
value = e.company_id;
|
||||
name = e.company_name;
|
||||
} else if (splitType === 'user_id') {
|
||||
value = e.user_id;
|
||||
name = e.user_name;
|
||||
typeLabel = e.order_type === 3 ? '(企业用户)' : '(普通用户)';
|
||||
if (e.phone) {
|
||||
name = `${e.user_name} (${e.phone})`;
|
||||
}
|
||||
} else if (splitType === 'station_id') {
|
||||
value = e.station_id;
|
||||
name = e.station_name;
|
||||
}
|
||||
|
||||
if (!addedIds.has(String(value))) {
|
||||
const isSelected = selectedIdSet.has(String(value));
|
||||
mergedEntities.push({ value, name, typeLabel, isSelected });
|
||||
addedIds.add(String(value));
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染选项
|
||||
select.innerHTML = mergedEntities.map(item =>
|
||||
`<option value="${item.value}" data-name="${item.name}" ${item.isSelected ? 'selected' : ''}>${item.name} ${item.typeLabel}</option>`
|
||||
).join('');
|
||||
|
||||
// 更新分页UI
|
||||
currentEntityType = entityType;
|
||||
entityPage = 1;
|
||||
updatePagination(totalPages, total);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载列表失败:', error);
|
||||
select.innerHTML = '<option value="">加载失败</option>';
|
||||
}
|
||||
}
|
||||
|
||||
// 复制配置
|
||||
async function copyConfig(configId) {
|
||||
try {
|
||||
const response = await fetch(`/api/config/${configId}/copy`, {
|
||||
method: 'POST'
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
alert('配置复制成功!');
|
||||
loadConfigs();
|
||||
} else {
|
||||
alert('复制失败: ' + (result.message || result.error || '未知错误'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('复制配置失败:', error);
|
||||
alert('复制失败,请稍后重试');
|
||||
}
|
||||
}
|
||||
|
||||
// 删除配置
|
||||
async function deleteConfig(configId) {
|
||||
if (!confirm('确定要删除这个配置吗?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/config/${configId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
alert('配置删除成功');
|
||||
loadConfigs();
|
||||
} else {
|
||||
alert('删除失败: ' + result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('删除失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 启用/禁用配置
|
||||
async function toggleConfig(configId) {
|
||||
try {
|
||||
const response = await fetch(`/api/config/${configId}/toggle`, {
|
||||
method: 'POST'
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
loadConfigs();
|
||||
} else {
|
||||
alert('操作失败: ' + result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('操作失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 移动配置
|
||||
async function moveConfig(configId, direction) {
|
||||
try {
|
||||
const response = await fetch(`/api/config/${configId}/move`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ direction })
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
loadConfigs();
|
||||
} else {
|
||||
alert('移动失败: ' + result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('移动失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭模态框
|
||||
function closeModal() {
|
||||
document.getElementById('config-modal').classList.remove('active');
|
||||
}
|
||||
|
||||
108
static/js/entity.js
Normal file
108
static/js/entity.js
Normal file
@@ -0,0 +1,108 @@
|
||||
// 加载企业/用户列表(支持分页和搜索)
|
||||
async function loadEntities(page = 1, search = '') {
|
||||
const splitType = document.getElementById('split-type').value;
|
||||
const select = document.getElementById('split-value');
|
||||
|
||||
// 显示/隐藏合并特来电选项(仅按企业拆分时显示)
|
||||
const mergeTelecomGroup = document.getElementById('merge-telecom-group');
|
||||
if (mergeTelecomGroup) {
|
||||
mergeTelecomGroup.style.display = splitType === 'company_id' ? 'block' : 'none';
|
||||
if (splitType !== 'company_id') {
|
||||
document.getElementById('merge-telecom').checked = false;
|
||||
toggleTelecomVehicleNo();
|
||||
}
|
||||
}
|
||||
|
||||
if (!splitType) {
|
||||
select.innerHTML = '<option value="">请先选择拆分方式</option>';
|
||||
return;
|
||||
}
|
||||
|
||||
// 保存当前状态
|
||||
currentEntityType = splitType === 'company_id' ? 'company' :
|
||||
splitType === 'user_id' ? 'user' :
|
||||
splitType === 'station_id' ? 'station' : '';
|
||||
entityPage = page;
|
||||
currentSearch = search;
|
||||
|
||||
select.innerHTML = '<option value="">加载中...</option>';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/entities?type=${currentEntityType}&page=${page}&page_size=${entityPageSize}&search=${encodeURIComponent(search)}`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
const entities = result.data;
|
||||
const total = result.total || 0;
|
||||
const totalPages = Math.ceil(total / entityPageSize);
|
||||
|
||||
if (entities.length === 0) {
|
||||
select.innerHTML = '<option value="">无数据</option>';
|
||||
} else {
|
||||
select.innerHTML = entities.map(e => {
|
||||
let value = '';
|
||||
let name = '';
|
||||
let typeLabel = '';
|
||||
|
||||
if (splitType === 'company_id') {
|
||||
value = e.company_id;
|
||||
name = e.company_name;
|
||||
} else if (splitType === 'user_id') {
|
||||
value = e.user_id;
|
||||
name = e.user_name;
|
||||
typeLabel = e.order_type === 3 ? '(企业用户)' : '(普通用户)';
|
||||
if (e.phone) {
|
||||
name = `${e.user_name} (${e.phone})`;
|
||||
}
|
||||
} else if (splitType === 'station_id') {
|
||||
value = e.station_id;
|
||||
name = e.station_name;
|
||||
}
|
||||
|
||||
return `<option value="${value}" data-name="${name}">${name} ${typeLabel}</option>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// 更新分页UI
|
||||
updatePagination(totalPages, total);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载列表失败:', error);
|
||||
select.innerHTML = '<option value="">加载失败</option>';
|
||||
}
|
||||
}
|
||||
|
||||
// 更新分页UI
|
||||
function updatePagination(totalPages, total) {
|
||||
const paginationDiv = document.getElementById('entity-pagination');
|
||||
if (totalPages <= 1) {
|
||||
paginationDiv.innerHTML = `<small class="text-muted">共 ${total} 条</small>`;
|
||||
return;
|
||||
}
|
||||
|
||||
let html = `<small class="text-muted">共 ${total} 条</small>`;
|
||||
html += `<button type="button" class="btn btn-sm btn-secondary" onclick="loadEntities(${entityPage - 1}, currentSearch)" ${entityPage <= 1 ? 'disabled' : ''}>上一页</button>`;
|
||||
html += `<span>${entityPage}/${totalPages}</span>`;
|
||||
html += `<button type="button" class="btn btn-sm btn-secondary" onclick="loadEntities(${entityPage + 1}, currentSearch)" ${entityPage >= totalPages ? 'disabled' : ''}>下一页</button>`;
|
||||
|
||||
paginationDiv.innerHTML = html;
|
||||
}
|
||||
|
||||
// 搜索实体
|
||||
function searchEntities() {
|
||||
const search = document.getElementById('entity-search').value.trim();
|
||||
loadEntities(1, search);
|
||||
}
|
||||
|
||||
// 回车搜索
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const searchInput = document.getElementById('entity-search');
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
searchEntities();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
225
static/js/history.js
Normal file
225
static/js/history.js
Normal file
@@ -0,0 +1,225 @@
|
||||
// 批量下载历史记录
|
||||
async function batchDownloadHistory() {
|
||||
const checkboxes = document.querySelectorAll('.history-checkbox:checked');
|
||||
if (checkboxes.length === 0) {
|
||||
alert('请至少选择一条记录');
|
||||
return;
|
||||
}
|
||||
|
||||
const ids = Array.from(checkboxes).map(cb => parseInt(cb.value));
|
||||
|
||||
// 逐个下载
|
||||
ids.forEach(id => {
|
||||
window.open(`/api/download?id=${id}`, '_blank');
|
||||
});
|
||||
}
|
||||
|
||||
// 按日期查询
|
||||
function queryHistoryByDate() {
|
||||
const startDate = document.getElementById('query-start-date').value;
|
||||
const endDate = document.getElementById('query-end-date').value;
|
||||
if (!startDate && !endDate) {
|
||||
alert('请至少选择一个日期');
|
||||
return;
|
||||
}
|
||||
// 重新加载历史记录,带上日期参数
|
||||
loadHistoryWithDate(1, startDate, endDate);
|
||||
}
|
||||
|
||||
// 清除日期查询
|
||||
function clearDateQuery() {
|
||||
document.getElementById('query-start-date').value = '';
|
||||
document.getElementById('query-end-date').value = '';
|
||||
loadHistory(1);
|
||||
}
|
||||
|
||||
// 带日期参数加载历史记录
|
||||
// 渲染历史记录列表
|
||||
function renderHistory(list) {
|
||||
const tbody = document.getElementById('history-list');
|
||||
if (!list || list.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="9" class="empty">暂无历史记录</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
// 解析日期字符串(支持多种格式)
|
||||
const parseDate = (dateStr) => {
|
||||
if (!dateStr) return null;
|
||||
|
||||
// 尝试匹配 "2026-07-12" 格式
|
||||
let match = dateStr.match(/(\d{4})-(\d{1,2})-(\d{1,2})/);
|
||||
if (match) {
|
||||
return {
|
||||
year: parseInt(match[1]),
|
||||
month: parseInt(match[2]),
|
||||
day: parseInt(match[3])
|
||||
};
|
||||
}
|
||||
|
||||
// 尝试匹配 "Sun, 12 Jul 2026 00:00:00 GMT" 格式
|
||||
match = dateStr.match(/(\d{1,2})\s+(\w+)\s+(\d{4})/);
|
||||
if (match) {
|
||||
const months = {
|
||||
'Jan': 1, 'Feb': 2, 'Mar': 3, 'Apr': 4, 'May': 5, 'Jun': 6,
|
||||
'Jul': 7, 'Aug': 8, 'Sep': 9, 'Oct': 10, 'Nov': 11, 'Dec': 12
|
||||
};
|
||||
return {
|
||||
year: parseInt(match[3]),
|
||||
month: months[match[2]] || 1,
|
||||
day: parseInt(match[1])
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// 解析时间字符串(支持多种格式)
|
||||
const parseTime = (timeStr) => {
|
||||
if (!timeStr) return null;
|
||||
|
||||
// 尝试匹配 "2026-07-12 08:00:00" 格式
|
||||
let match = timeStr.match(/(\d{4})-(\d{1,2})-(\d{1,2})\s+(\d{1,2}):(\d{1,2}):(\d{1,2})/);
|
||||
if (match) {
|
||||
return {
|
||||
year: match[1],
|
||||
month: match[2].padStart(2, '0'),
|
||||
day: match[3].padStart(2, '0'),
|
||||
hour: match[4].padStart(2, '0'),
|
||||
minute: match[5].padStart(2, '0'),
|
||||
second: match[6].padStart(2, '0')
|
||||
};
|
||||
}
|
||||
|
||||
// 尝试匹配 "Sun, 12 Jul 2026 08:00:00 GMT" 格式
|
||||
match = timeStr.match(/(\d{1,2})\s+(\w+)\s+(\d{4})\s+(\d{1,2}):(\d{1,2}):(\d{1,2})/);
|
||||
if (match) {
|
||||
const months = {
|
||||
'Jan': '01', 'Feb': '02', 'Mar': '03', 'Apr': '04', 'May': '05', 'Jun': '06',
|
||||
'Jul': '07', 'Aug': '08', 'Sep': '09', 'Oct': '10', 'Nov': '11', 'Dec': '12'
|
||||
};
|
||||
return {
|
||||
year: match[3],
|
||||
month: months[match[2]] || '01',
|
||||
day: match[1].padStart(2, '0'),
|
||||
hour: match[4].padStart(2, '0'),
|
||||
minute: match[5].padStart(2, '0'),
|
||||
second: match[6].padStart(2, '0')
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
tbody.innerHTML = list.map(item => {
|
||||
const statusText = item.status === 1 ? '成功' : '失败';
|
||||
const statusClass = item.status === 1 ? 'status-success' : 'status-failed';
|
||||
|
||||
// 格式化报表日期为中文
|
||||
let reportDate = '-';
|
||||
const dateObj = parseDate(item.report_date);
|
||||
if (dateObj) {
|
||||
reportDate = `${dateObj.year}年${dateObj.month}月${dateObj.day}日`;
|
||||
} else if (item.report_date) {
|
||||
reportDate = item.report_date;
|
||||
}
|
||||
|
||||
// 格式化时间范围
|
||||
let timeRange = '-';
|
||||
const startObj = parseTime(item.start_time);
|
||||
const endObj = parseTime(item.end_time);
|
||||
if (startObj && endObj) {
|
||||
const formatTimeObj = (obj) => `${obj.year}-${obj.month}-${obj.day} ${obj.hour}:${obj.minute}:${obj.second}`;
|
||||
timeRange = `${formatTimeObj(startObj)} 至 ${formatTimeObj(endObj)}`;
|
||||
} else if (item.start_time && item.end_time) {
|
||||
timeRange = `${item.start_time} 至 ${item.end_time}`;
|
||||
}
|
||||
|
||||
const configName = item.config_name || '-';
|
||||
const splitValue = item.split_name || '-';
|
||||
const totalOrders = item.total_orders || 0;
|
||||
const totalAmount = item.total_amount ? parseFloat(item.total_amount).toFixed(2) : '0.00';
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td><input type="checkbox" class="history-checkbox" value="${item.id}"></td>
|
||||
<td>${reportDate}</td>
|
||||
<td>${timeRange}</td>
|
||||
<td>${configName}</td>
|
||||
<td>${splitValue}</td>
|
||||
<td>${totalOrders}</td>
|
||||
<td>¥${totalAmount}</td>
|
||||
<td><span class="${statusClass}">${statusText}</span></td>
|
||||
<td>
|
||||
${item.status === 1 && item.file_path ?
|
||||
`<a href="/api/download?path=${encodeURIComponent(item.file_path)}" class="btn btn-sm btn-success">下载</a>` :
|
||||
'-'}
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteHistory(${item.id})">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// 渲染分页控件
|
||||
function renderPagination(page, pageSize, total) {
|
||||
const container = document.getElementById('history-pagination');
|
||||
if (!container) return;
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
if (totalPages <= 1) {
|
||||
container.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<div class="pagination">';
|
||||
|
||||
// 上一页
|
||||
if (page > 1) {
|
||||
html += `<button onclick="loadHistoryWithDate(${page - 1})">上一页</button>`;
|
||||
}
|
||||
|
||||
// 页码
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
if (i === page) {
|
||||
html += `<button class="active">${i}</button>`;
|
||||
} else {
|
||||
html += `<button onclick="loadHistoryWithDate(${i})">${i}</button>`;
|
||||
}
|
||||
}
|
||||
|
||||
// 下一页
|
||||
if (page < totalPages) {
|
||||
html += `<button onclick="loadHistoryWithDate(${page + 1})">下一页</button>`;
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// 加载历史记录(无日期筛选)
|
||||
async function loadHistory(page = 1) {
|
||||
loadHistoryWithDate(page);
|
||||
}
|
||||
|
||||
async function loadHistoryWithDate(page, startDate, endDate) {
|
||||
try {
|
||||
let url = `/api/report/history?page=${page}&page_size=10`;
|
||||
if (startDate) url += `&start_date=${startDate}`;
|
||||
if (endDate) url += `&end_date=${endDate}`;
|
||||
const response = await fetch(url);
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
renderHistory(result.data.list);
|
||||
renderPagination(result.data.total, page);
|
||||
} else {
|
||||
alert('加载失败: ' + (result.error || '未知错误'));
|
||||
}
|
||||
} catch (error) {
|
||||
alert('加载失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化
|
||||
loadConfigs();
|
||||
loadFields();
|
||||
</script>
|
||||
169
static/js/report.js
Normal file
169
static/js/report.js
Normal file
@@ -0,0 +1,169 @@
|
||||
// 加载生成配置列表
|
||||
async function loadGenerateConfigs() {
|
||||
try {
|
||||
const response = await fetch('/api/config');
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
const container = document.getElementById('config-checkbox-list');
|
||||
container.innerHTML = result.data.map(c => `
|
||||
<label style="display: flex; align-items: center; gap: 8px; padding: 6px 0; cursor: pointer;">
|
||||
<input type="checkbox" class="config-checkbox" value="${c.id}" data-name="${c.config_name}">
|
||||
<span>${c.config_name}</span>
|
||||
</label>
|
||||
`).join('');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载配置失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 全选/取消全选配置
|
||||
function toggleSelectAllConfigs() {
|
||||
const selectAll = document.getElementById('select-all-configs');
|
||||
const checkboxes = document.querySelectorAll('.config-checkbox');
|
||||
checkboxes.forEach(cb => {
|
||||
cb.checked = selectAll.checked;
|
||||
});
|
||||
}
|
||||
|
||||
// 设置预设时间
|
||||
function setPresetTime(preset) {
|
||||
const now = new Date();
|
||||
let start, end;
|
||||
|
||||
if (preset === 'yesterday') {
|
||||
end = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 8, 0, 0);
|
||||
start = new Date(end.getTime() - 24 * 60 * 60 * 1000);
|
||||
} else if (preset === 'last3days') {
|
||||
end = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 8, 0, 0);
|
||||
start = new Date(end.getTime() - 3 * 24 * 60 * 60 * 1000);
|
||||
} else if (preset === 'last7days') {
|
||||
end = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 8, 0, 0);
|
||||
start = new Date(end.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
document.getElementById('start-time').value = formatDateTimeLocal(start);
|
||||
document.getElementById('end-time').value = formatDateTimeLocal(end);
|
||||
}
|
||||
|
||||
// 格式化日期时间(用于输入框)
|
||||
function formatDateTimeLocal(date) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
// 格式化日期时间为数字格式(用于显示)
|
||||
function formatDateTimeDisplay(dateTimeStr) {
|
||||
if (!dateTimeStr) return '-';
|
||||
// 如果是字符串,直接返回(已经是数字格式)
|
||||
if (typeof dateTimeStr === 'string') {
|
||||
// 如果是 ISO 格式或其他格式,转换为 YYYY-MM-DD HH:MM:SS
|
||||
const date = new Date(dateTimeStr);
|
||||
if (!isNaN(date.getTime())) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
return dateTimeStr;
|
||||
}
|
||||
return String(dateTimeStr);
|
||||
}
|
||||
|
||||
// 格式化日期为数字格式(只显示日期,用于显示)
|
||||
function formatDateDisplay(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
const date = new Date(dateStr);
|
||||
if (!isNaN(date.getTime())) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
return String(dateStr);
|
||||
}
|
||||
|
||||
// 生成日报(支持多选)
|
||||
async function generateReport() {
|
||||
const checkboxes = document.querySelectorAll('.config-checkbox:checked');
|
||||
const startTime = document.getElementById('start-time').value;
|
||||
const endTime = document.getElementById('end-time').value;
|
||||
|
||||
if (checkboxes.length === 0) {
|
||||
alert('请至少选择一个配置');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!startTime || !endTime) {
|
||||
alert('请选择时间范围');
|
||||
return;
|
||||
}
|
||||
|
||||
const resultDiv = document.getElementById('generate-result');
|
||||
resultDiv.innerHTML = '<div class="alert alert-info">正在批量生成日报,请稍候...</div>';
|
||||
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
let results = [];
|
||||
|
||||
for (const cb of checkboxes) {
|
||||
const configId = parseInt(cb.value);
|
||||
const configName = cb.dataset.name;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/report/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
config_id: configId,
|
||||
start_time: startTime.replace('T', ' ') + ':00',
|
||||
end_time: endTime.replace('T', ' ') + ':00'
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
successCount++;
|
||||
results.push({ name: configName, success: true, ...result });
|
||||
} else {
|
||||
failCount++;
|
||||
results.push({ name: configName, success: false, message: result.message });
|
||||
}
|
||||
} catch (error) {
|
||||
failCount++;
|
||||
results.push({ name: configName, success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 显示结果
|
||||
let html = '<div class="alert alert-info"><strong>生成完成</strong><br><br>';
|
||||
html += `成功:${successCount} 个,失败:${failCount} 个<br><br>`;
|
||||
|
||||
results.forEach(r => {
|
||||
if (r.success) {
|
||||
html += `<div style="margin: 10px 0; padding: 10px; background: #f0f8ff; border-radius: 4px;">`;
|
||||
html += `<strong>✓ ${r.name}</strong><br>`;
|
||||
html += `订单数量:${r.total_orders} 条,总金额:¥${r.total_amount.toFixed(2)}<br>`;
|
||||
html += `<a href="/api/download?path=${r.file_path}" target="_blank" class="btn btn-sm btn-success" style="margin-top: 5px;">📥 下载</a>`;
|
||||
html += `</div>`;
|
||||
} else {
|
||||
html += `<div style="margin: 10px 0; padding: 10px; background: #fff0f0; border-radius: 4px;">`;
|
||||
html += `<strong>✗ ${r.name}</strong><br>`;
|
||||
html += `错误:${r.message}`;
|
||||
html += `</div>`;
|
||||
}
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
resultDiv.innerHTML = html;
|
||||
}
|
||||
|
||||
2344
templates/index.html
2344
templates/index.html
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user