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:
user9994793890
2026-07-13 15:43:03 +08:00
parent b3f5918dff
commit f84aa8a4a9
16 changed files with 3252 additions and 2970 deletions

157
AGENTS.md Normal file
View 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. 前端页面加载异常
- 检查静态文件路径是否正确
- 清除浏览器缓存后重试

709
app.py
View File

@@ -1,28 +1,30 @@
"""
Flask主应用
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应用
# 创建 Flask 应用
app = Flask(__name__,
template_folder='templates',
static_folder='public')
CORS(app)
# 确保public/reports目录存在
# 确保 public/reports 目录存在
os.makedirs(os.path.join('public', 'reports'), exist_ok=True)
# 注册 API 蓝图
register_blueprints(app)
# ==================== 页面路由 ====================
@@ -32,687 +34,10 @@ 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():
"""定时任务:每天8:01自动生成日报"""
"""定时任务:每天 8:01 自动生成日报"""
print(f'[{datetime.now()}] 开始执行定时任务...')
try:
@@ -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")}')
app.run(host='0.0.0.0', port=port, debug=True)
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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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;
}

File diff suppressed because it is too large Load Diff