增加配置的导入和导出功能

This commit is contained in:
2026-07-14 16:30:26 +08:00
parent cc2214ccf6
commit 9c3c1e26cc
3 changed files with 296 additions and 63 deletions

View File

@@ -274,38 +274,92 @@ def move_config(config_id):
return jsonify({'success': False, 'message': str(e)}), 500 return jsonify({'success': False, 'message': str(e)}), 500
EXCEL_COLUMNS = [
{'key': 'config_name', 'name': '配置名称', 'required': True},
{'key': 'split_type', 'name': '拆分方式', 'required': True, 'comment': 'company_id=按企业, user_id=按用户, station_id=按场站'},
{'key': 'split_value', 'name': '拆分值', 'required': True, 'comment': '多个值用英文逗号分隔'},
{'key': 'split_name', 'name': '拆分显示名称', 'required': False},
{'key': 'selected_fields', 'name': '选择字段(JSON数组)', 'required': True, 'comment': '如: ["charge_degree","total_energy","charge_money"]'},
{'key': 'sum_fields', 'name': '求和字段(JSON数组)', 'required': False, 'comment': '如: ["peak_charge","flat_charge","valley_charge","charge_money"]'},
{'key': 'time_periods', 'name': '分时段配置(JSON对象)', 'required': False, 'comment': '如: {"尖":{"start":"10:00","end":"12:00"}}'},
{'key': 'merge_telecom', 'name': '是否合并特来电', 'required': False, 'comment': '0=否, 1=是'},
{'key': 'telecom_vehicle_no', 'name': '特来电自编号', 'required': False},
{'key': 'field_custom_names', 'name': '字段自定义表头(JSON对象)', 'required': False, 'comment': '如: {"charge_degree":"充电量(kWh)"}'},
{'key': 'show_monthly_total', 'name': '是否显示月度总计', 'required': False, 'comment': '0=否, 1=是'},
{'key': 'is_active', 'name': '是否启用', 'required': False, 'comment': '0=禁用, 1=启用'},
{'key': 'sort_order', 'name': '排序号', 'required': False, 'comment': '数字越小越靠前'},
]
@config_bp.route('/config/export', methods=['GET']) @config_bp.route('/config/export', methods=['GET'])
def export_configs(): def export_configs():
"""导出所有配置为JSON文件""" """导出所有配置为Excel文件"""
try: try:
from flask import Response from flask import Response
import os from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
from io import BytesIO
from urllib.parse import quote
configs = execute_query( configs = execute_query(
'SELECT * FROM t_daily_report_config ORDER BY sort_order ASC, id ASC' 'SELECT * FROM t_daily_report_config ORDER BY sort_order ASC, id ASC'
) )
export_list = [] wb = Workbook()
for config in configs: ws = wb.active
export_item = {} ws.title = '日报配置'
for key, value in config.items():
if key == 'id':
continue
export_item[key] = value
export_list.append(export_item)
export_data = { header_font = Font(bold=True, color='FFFFFF', size=11)
'version': '1.0', header_fill = PatternFill(start_color='4472C4', end_color='4472C4', fill_type='solid')
'export_time': datetime.now().strftime('%Y-%m-%d %H:%M:%S'), header_alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
'total': len(export_list), thin_border = Border(
'configs': export_list left=Side(style='thin', color='000000'),
} right=Side(style='thin', color='000000'),
top=Side(style='thin', color='000000'),
bottom=Side(style='thin', color='000000')
)
even_fill = PatternFill(start_color='F2F2F2', end_color='F2F2F2', fill_type='solid')
json_str = json.dumps(export_data, ensure_ascii=False, indent=2) for col_idx, col in enumerate(EXCEL_COLUMNS, 1):
filename = f'日报配置导出_{datetime.now().strftime("%Y%m%d_%H%M%S")}.json' cell = ws.cell(row=1, column=col_idx)
cell.value = col['name']
cell.font = header_font
cell.fill = header_fill
cell.alignment = header_alignment
cell.border = thin_border
ws.column_dimensions[cell.column_letter].width = max(len(col['name']) * 2 + 4, 18)
response = Response(json_str, mimetype='application/json') ws.row_dimensions[1].height = 30
response.headers['Content-Disposition'] = f'attachment; filename="{filename}"'
for row_idx, config in enumerate(configs, 2):
is_even = (row_idx - 2) % 2 == 1
for col_idx, col in enumerate(EXCEL_COLUMNS, 1):
cell = ws.cell(row=row_idx, column=col_idx)
value = config.get(col['key'], '')
if value is None:
value = ''
if isinstance(value, datetime):
value = value.strftime('%Y-%m-%d %H:%M:%S')
if isinstance(value, list) or isinstance(value, dict):
value = json.dumps(value, ensure_ascii=False)
cell.value = str(value) if value is not None else ''
cell.border = thin_border
cell.alignment = Alignment(horizontal='left', vertical='center', wrap_text=True)
if is_even:
cell.fill = even_fill
ws.freeze_panes = 'A2'
output = BytesIO()
wb.save(output)
output.seek(0)
filename = f'日报配置导出_{datetime.now().strftime("%Y%m%d_%H%M%S")}.xlsx'
response = Response(
output.read(),
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
)
response.headers['Content-Disposition'] = f"attachment; filename*=UTF-8''{quote(filename)}"
return response return response
except Exception as e: except Exception as e:
import traceback import traceback
@@ -313,9 +367,122 @@ def export_configs():
return jsonify({'success': False, 'message': f'导出失败: {str(e)}'}), 500 return jsonify({'success': False, 'message': f'导出失败: {str(e)}'}), 500
@config_bp.route('/config/template', methods=['GET'])
def download_template():
"""下载导入模板"""
try:
from flask import Response
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
from io import BytesIO
from urllib.parse import quote
wb = Workbook()
ws = wb.active
ws.title = '日报配置'
header_font = Font(bold=True, color='FFFFFF', size=11)
header_fill = PatternFill(start_color='4472C4', end_color='4472C4', fill_type='solid')
header_alignment = Alignment(horizontal='center', vertical='center', wrap_text=True)
comment_font = Font(color='FF0000', size=9)
thin_border = Border(
left=Side(style='thin', color='000000'),
right=Side(style='thin', color='000000'),
top=Side(style='thin', color='000000'),
bottom=Side(style='thin', color='000000')
)
for col_idx, col in enumerate(EXCEL_COLUMNS, 1):
cell = ws.cell(row=1, column=col_idx)
title = col['name']
if col.get('required'):
title = '*' + title
cell.value = title
cell.font = header_font
cell.fill = header_fill
cell.alignment = header_alignment
cell.border = thin_border
ws.column_dimensions[cell.column_letter].width = max(len(col['name']) * 2 + 4, 20)
ws.row_dimensions[1].height = 30
for col_idx, col in enumerate(EXCEL_COLUMNS, 1):
cell = ws.cell(row=2, column=col_idx)
if col.get('comment'):
cell.value = col['comment']
cell.font = comment_font
cell.fill = PatternFill(start_color='FFF2CC', end_color='FFF2CC', fill_type='solid')
else:
cell.value = ''
cell.border = thin_border
cell.alignment = Alignment(horizontal='left', vertical='center', wrap_text=True)
ws.row_dimensions[2].height = 40
example_row = 3
example_data = {
'config_name': '示例-企业日报',
'split_type': 'company_id',
'split_value': '1001,1002',
'split_name': '',
'selected_fields': '["charge_degree","total_energy","charge_money"]',
'sum_fields': '["peak_charge","flat_charge","valley_charge","charge_money"]',
'time_periods': '{"尖":{"start":"10:00","end":"12:00"},"峰":{"start":"08:00","end":"10:00"}}',
'merge_telecom': '0',
'telecom_vehicle_no': '',
'field_custom_names': '{"charge_degree":"充电量(kWh)"}',
'show_monthly_total': '1',
'is_active': '0',
'sort_order': '1',
}
for col_idx, col in enumerate(EXCEL_COLUMNS, 1):
cell = ws.cell(row=example_row, column=col_idx)
cell.value = example_data.get(col['key'], '')
cell.border = thin_border
cell.alignment = Alignment(horizontal='left', vertical='center', wrap_text=True)
cell.font = Font(color='808080', size=10)
ws.freeze_panes = 'A3'
ws2 = wb.create_sheet('填写说明')
instructions = [
['日报配置导入模板填写说明', ''],
['', ''],
['1. 带 * 号的列为必填项', ''],
['2. 拆分方式可选值', 'company_id=按企业, user_id=按用户, station_id=按场站'],
['3. 拆分值', '多个值用英文逗号分隔,如: 1001,1002,1003'],
['4. JSON格式字段', '选择字段、求和字段、分时段配置、自定义表头都需要填写JSON格式字符串'],
['5. 是否类字段', '填 0 表示否,填 1 表示是'],
['6. 导入规则', '导入后配置名称自动添加"_导入"后缀,默认禁用,排序号追加到末尾'],
['7. 注意事项', '请勿修改第一行表头,第二行为说明行,从第三行开始填写数据(第三行为示例)'],
]
for row_idx, (key, val) in enumerate(instructions, 1):
ws2.cell(row=row_idx, column=1, value=key)
ws2.cell(row=row_idx, column=2, value=val)
ws2.cell(row=row_idx, column=1).font = Font(bold=True if row_idx == 1 else False)
ws2.column_dimensions['A'].width = 25
ws2.column_dimensions['B'].width = 60
output = BytesIO()
wb.save(output)
output.seek(0)
filename = '日报配置导入模板.xlsx'
response = Response(
output.read(),
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
)
response.headers['Content-Disposition'] = f"attachment; filename*=UTF-8''{quote(filename)}"
return response
except Exception as e:
import traceback
traceback.print_exc()
return jsonify({'success': False, 'message': f'模板下载失败: {str(e)}'}), 500
@config_bp.route('/config/import', methods=['POST']) @config_bp.route('/config/import', methods=['POST'])
def import_configs(): def import_configs():
"""从JSON文件导入配置""" """从Excel文件导入配置"""
try: try:
if 'file' not in request.files: if 'file' not in request.files:
return jsonify({'success': False, 'message': '请选择要导入的文件'}), 400 return jsonify({'success': False, 'message': '请选择要导入的文件'}), 400
@@ -324,18 +491,40 @@ def import_configs():
if file.filename == '': if file.filename == '':
return jsonify({'success': False, 'message': '请选择要导入的文件'}), 400 return jsonify({'success': False, 'message': '请选择要导入的文件'}), 400
if not file.filename.endswith('.json'): if not (file.filename.endswith('.xlsx') or file.filename.endswith('.xls')):
return jsonify({'success': False, 'message': '只支持 JSON 格式的配置文件'}), 400 return jsonify({'success': False, 'message': '只支持 Excel 格式(.xlsx/.xls)的配置文件'}), 400
file_content = file.read().decode('utf-8') from openpyxl import load_workbook
import_data = json.loads(file_content) from io import BytesIO
if 'configs' not in import_data: wb = load_workbook(BytesIO(file.read()), data_only=True)
return jsonify({'success': False, 'message': '文件格式不正确,缺少 configs 字段'}), 400 ws = wb.active
configs_to_import = import_data['configs'] headers = []
if not isinstance(configs_to_import, list) or len(configs_to_import) == 0: for cell in ws[1]:
return jsonify({'success': False, 'message': '没有可导入的配置'}), 400 headers.append(str(cell.value).strip() if cell.value else '')
col_key_map = {}
for col in EXCEL_COLUMNS:
if col['name'] in headers:
col_idx = headers.index(col['name'])
col_key_map[col['key']] = col_idx
configs_to_import = []
for row in ws.iter_rows(min_row=3, values_only=True):
row_data = {}
has_data = False
for key, col_idx in col_key_map.items():
value = row[col_idx] if col_idx < len(row) else None
if value is not None and str(value).strip():
has_data = True
row_data[key] = str(value).strip() if value is not None else ''
if has_data and row_data.get('config_name'):
configs_to_import.append(row_data)
if not configs_to_import:
return jsonify({'success': False, 'message': '没有可导入的配置,请从第3行开始填写数据'}), 400
max_sort_result = execute_query('SELECT MAX(sort_order) as max_sort FROM t_daily_report_config') max_sort_result = execute_query('SELECT MAX(sort_order) as max_sort FROM t_daily_report_config')
current_max_sort = max_sort_result[0]['max_sort'] or 0 current_max_sort = max_sort_result[0]['max_sort'] or 0
@@ -346,42 +535,75 @@ def import_configs():
for idx, config_data in enumerate(configs_to_import): for idx, config_data in enumerate(configs_to_import):
try: try:
config_name = config_data.get('config_name', '') config_name = config_data.get('config_name', '').strip()
split_type = config_data.get('split_type', '') split_type = config_data.get('split_type', '').strip()
split_value = config_data.get('split_value', '') split_value = config_data.get('split_value', '').strip()
if not config_name or not split_type or not split_value: if not config_name:
fail_count += 1 fail_count += 1
fail_messages.append(f'第{idx+1}条:缺少必填字段') fail_messages.append(f'第{idx+3}行:缺少配置名称')
continue
if not split_type:
fail_count += 1
fail_messages.append(f'第{idx+3}行:缺少拆分方式')
continue
if not split_value:
fail_count += 1
fail_messages.append(f'第{idx+3}行:缺少拆分值')
continue continue
new_id = int(datetime.now().timestamp() * 1000) + idx new_id = int(datetime.now().timestamp() * 1000) + idx
new_name = f"{config_name}_导入" new_name = f"{config_name}_导入"
sort_order = current_max_sort + 1
import_sort_order = config_data.get('sort_order', '').strip()
if import_sort_order and import_sort_order.isdigit():
sort_order = current_max_sort + 1
else:
sort_order = current_max_sort + 1
current_max_sort += 1 current_max_sort += 1
selected_fields = config_data.get('selected_fields', '[]') selected_fields_str = config_data.get('selected_fields', '[]').strip()
if isinstance(selected_fields, str): try:
selected_fields = json.loads(selected_fields) if selected_fields else [] selected_fields = json.loads(selected_fields_str) if selected_fields_str else []
except Exception:
selected_fields = []
sum_fields = config_data.get('sum_fields', '[]') sum_fields_str = config_data.get('sum_fields', '[]').strip()
if isinstance(sum_fields, str): try:
sum_fields = json.loads(sum_fields) if sum_fields else [] sum_fields = json.loads(sum_fields_str) if sum_fields_str else []
except Exception:
sum_fields = []
time_periods = config_data.get('time_periods', '{}') time_periods_str = config_data.get('time_periods', '{}').strip()
if isinstance(time_periods, str): try:
time_periods = json.loads(time_periods) if time_periods else {} time_periods = json.loads(time_periods_str) if time_periods_str else {}
except Exception:
time_periods = {}
field_custom_names = config_data.get('field_custom_names') field_custom_names_str = config_data.get('field_custom_names', '').strip()
if isinstance(field_custom_names, str): if field_custom_names_str:
field_custom_names = json.loads(field_custom_names) if field_custom_names else None try:
field_custom_names = json.loads(field_custom_names_str)
except Exception:
field_custom_names = None
else:
field_custom_names = None
merge_telecom = config_data.get('merge_telecom', '0').strip()
merge_telecom = 1 if merge_telecom in ('1', '是', 'true', 'True') else 0
show_monthly_total = config_data.get('show_monthly_total', '0').strip()
show_monthly_total = 1 if show_monthly_total in ('1', '是', 'true', 'True') else 0
is_active = config_data.get('is_active', '0').strip()
is_active = 1 if is_active in ('1', '是', 'true', 'True') else 0
sql = """ sql = """
INSERT INTO t_daily_report_config INSERT INTO t_daily_report_config
(id, config_name, split_type, split_value, split_name, selected_fields, (id, config_name, split_type, split_value, split_name, selected_fields,
sum_fields, time_periods, merge_telecom, telecom_vehicle_no, field_custom_names, sum_fields, time_periods, merge_telecom, telecom_vehicle_no, field_custom_names,
show_monthly_total, is_active, sort_order, create_time, update_time) show_monthly_total, is_active, sort_order, create_time, update_time)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 0, %s, NOW(), NOW()) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW())
""" """
params = ( params = (
@@ -393,10 +615,11 @@ def import_configs():
json.dumps(selected_fields, ensure_ascii=False), json.dumps(selected_fields, ensure_ascii=False),
json.dumps(sum_fields, ensure_ascii=False), json.dumps(sum_fields, ensure_ascii=False),
json.dumps(time_periods, ensure_ascii=False), json.dumps(time_periods, ensure_ascii=False),
config_data.get('merge_telecom', 0), merge_telecom,
config_data.get('telecom_vehicle_no', ''), config_data.get('telecom_vehicle_no', ''),
json.dumps(field_custom_names, ensure_ascii=False) if field_custom_names else None, json.dumps(field_custom_names, ensure_ascii=False) if field_custom_names else None,
config_data.get('show_monthly_total', 0), show_monthly_total,
is_active,
sort_order sort_order
) )
@@ -404,7 +627,7 @@ def import_configs():
success_count += 1 success_count += 1
except Exception as item_error: except Exception as item_error:
fail_count += 1 fail_count += 1
fail_messages.append(f'第{idx+1}条:{str(item_error)}') fail_messages.append(f'第{idx+3}行:{str(item_error)}')
return jsonify({ return jsonify({
'success': True, 'success': True,

View File

@@ -891,19 +891,28 @@ function exportConfigs() {
} }
} }
// 下载导入模板
function downloadTemplate() {
try {
window.open('/api/config/template', '_blank');
} catch (error) {
alert('模板下载失败: ' + error.message);
}
}
// 导入配置 // 导入配置
async function importConfigs(event) { async function importConfigs(event) {
const fileInput = event.target; const fileInput = event.target;
const file = fileInput.files[0]; const file = fileInput.files[0];
if (!file) return; if (!file) return;
if (!file.name.endsWith('.json')) { if (!file.name.endsWith('.xlsx') && !file.name.endsWith('.xls')) {
alert('只支持 JSON 格式的配置文件'); alert('只支持 Excel 格式(.xlsx/.xls)的配置文件');
fileInput.value = ''; fileInput.value = '';
return; return;
} }
if (!confirm('确定要导入配置吗?\n导入的配置会自动命名为"原名称_导入",并默认禁用。')) { if (!confirm('确定要导入配置吗?\n导入的配置会自动命名为"原名称_导入",默认禁用,排序号追加到末尾。')) {
fileInput.value = ''; fileInput.value = '';
return; return;
} }

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>订单日报系统</title> <title>订单日报系统</title>
<link rel="stylesheet" href="/public/static/css/style.css?v=2026071404"> <link rel="stylesheet" href="/public/static/css/style.css?v=2026071405">
</head> </head>
<body> <body>
<div class="container"> <div class="container">
@@ -22,8 +22,9 @@
<div class="card"> <div class="card">
<button class="btn btn-primary" onclick="showCreateModal()">创建配置</button> <button class="btn btn-primary" onclick="showCreateModal()">创建配置</button>
<button class="btn btn-info" onclick="exportConfigs()">导出配置</button> <button class="btn btn-info" onclick="exportConfigs()">导出配置</button>
<button class="btn btn-warning" onclick="downloadTemplate()">下载模板</button>
<button class="btn btn-success" onclick="document.getElementById('import-config-file').click()">导入配置</button> <button class="btn btn-success" onclick="document.getElementById('import-config-file').click()">导入配置</button>
<input type="file" id="import-config-file" accept=".json" style="display: none;" onchange="importConfigs(event)"> <input type="file" id="import-config-file" accept=".xlsx,.xls" style="display: none;" onchange="importConfigs(event)">
</div> </div>
<div class="card"> <div class="card">
@@ -339,11 +340,11 @@
</div> </div>
<!-- JavaScript 文件 --> <!-- JavaScript 文件 -->
<script src="/public/static/js/common.js?v=2026071404"></script> <script src="/public/static/js/common.js?v=2026071405"></script>
<script src="/public/static/js/config.js?v=2026071404"></script> <script src="/public/static/js/config.js?v=2026071405"></script>
<script src="/public/static/js/entity.js?v=2026071404"></script> <script src="/public/static/js/entity.js?v=2026071405"></script>
<script src="/public/static/js/report.js?v=2026071404"></script> <script src="/public/static/js/report.js?v=2026071405"></script>
<script src="/public/static/js/history.js?v=2026071404"></script> <script src="/public/static/js/history.js?v=2026071405"></script>
<script src="/public/static/js/sum_data.js?v=2026071404"></script> <script src="/public/static/js/sum_data.js?v=2026071405"></script>
</body> </body>
</html> </html>