diff --git a/lib/api/download_api.py b/lib/api/download_api.py index 4fc089f..0edd74d 100644 --- a/lib/api/download_api.py +++ b/lib/api/download_api.py @@ -6,7 +6,7 @@ import zipfile from datetime import datetime from flask import request, jsonify, send_from_directory from lib.db import execute_query -from lib.logger import log_info, log_error +from lib.logger import log_info, log_error, log_warning from . import download_bp @@ -120,31 +120,32 @@ def batch_download(): # 清理旧的临时文件 cleanup_old_temp_zips() - # 查询所有成功的历史记录 + # 查询所有成功的历史记录(附带筛选条件以增强安全性) placeholders = ', '.join(['%s'] * len(ids)) result = execute_query( - f'SELECT id, file_path, config_name, report_date, status FROM t_daily_report_history WHERE id IN ({placeholders})', + f'SELECT id, file_path, config_name, report_date, status, split_name FROM t_daily_report_history WHERE id IN ({placeholders})', tuple(ids) ) if not result: return jsonify({'success': False, 'message': '未找到对应的历史记录'}), 404 + total_matched = len(result) + success_records = [r for r in result if r.get('status') == 1] + skipped_failed = total_matched - len(success_records) + reports_dir = get_reports_dir() temp_dir = get_temp_dir() # 收集要打包的文件 files_to_zip = [] failed_names = [] + used_names = set() # 检测 zip 内文件名冲突 - for item in result: - if item.get('status') != 1: - failed_names.append(f"{item.get('config_name', '未知')} (生成失败)") - continue - + for item in success_records: file_path = item.get('file_path') if not file_path: - failed_names.append(f"{item.get('config_name', '未知')} (无文件)") + failed_names.append(f"{item.get('config_name', '未知')} (无文件路径)") continue # 提取文件名 @@ -157,17 +158,27 @@ def batch_download(): full_path = os.path.join(reports_dir, filename) if not os.path.exists(full_path): - failed_names.append(f"{item.get('config_name', '未知')} (文件不存在)") + failed_names.append(f"{item.get('config_name', '未知')}_{item.get('report_date', '')} (文件不存在)") continue - # 使用配置名作为zip内的文件名,避免重名 + # 使用配置名+报表日期+拆分值作为zip内的文件名,避免重名 config_name = item.get('config_name', '未知') report_date = item.get('report_date', '') - ext = os.path.splitext(filename)[1] + split_name = item.get('split_name', '') + ext = os.path.splitext(filename)[1] or '.xlsx' + + base_parts = [config_name] if report_date: - zip_inner_name = f"{config_name}_{report_date}{ext}" - else: - zip_inner_name = f"{config_name}{ext}" + base_parts.append(str(report_date)) + if split_name: + base_parts.append(str(split_name)) + base_name = '_'.join(base_parts) + zip_inner_name = f"{base_name}{ext}" + + # 处理文件名冲突:如果同名,追加记录ID + if zip_inner_name in used_names: + zip_inner_name = f"{base_name}_{item.get('id', '')}{ext}" + used_names.add(zip_inner_name) files_to_zip.append({ 'full_path': full_path, @@ -187,16 +198,23 @@ def batch_download(): for file_info in files_to_zip: zf.write(file_info['full_path'], file_info['inner_name']) - log_info(f'[下载API] 批量打包完成,共 {len(files_to_zip)} 个文件,失败 {len(failed_names)} 个', 'download') + log_info(f'[下载API] 批量打包完成,共 {len(files_to_zip)} 个文件,失败 {len(failed_names)} 个,跳过 {skipped_failed} 个失败记录', 'download') + if failed_names: + log_warning(f'[下载API] 缺失/无效文件列表: {failed_names[:20]}', 'download') - # 返回zip文件 + # 返回zip文件,通过响应头返回统计信息 from flask import send_file - return send_file( + response = send_file( zip_path, as_attachment=True, download_name=zip_filename, mimetype='application/zip' ) + response.headers['X-Total-Matched'] = str(total_matched) + response.headers['X-Files-Zipped'] = str(len(files_to_zip)) + response.headers['X-Files-Missing'] = str(len(failed_names)) + response.headers['X-Skipped-Failed'] = str(skipped_failed) + return response except Exception as e: log_error(f'[下载API] 批量下载错误: {e}', 'download') import traceback @@ -235,9 +253,8 @@ def batch_download_by_filter(): where_clauses.append('DATE(end_time) <= %s') params.append(end_date) - if status is not None and status != '': - where_clauses.append('status = %s') - params.append(int(status)) + # 注意:批量导出只导出成功记录(status=1),忽略用户传入的 status 筛选 + # 因为失败的记录没有文件可下载 if config_name: where_clauses.append('config_name LIKE %s') @@ -245,9 +262,17 @@ def batch_download_by_filter(): where_sql = ' AND '.join(where_clauses) - # 查询所有匹配的成功记录 + # 先查询总数,用于日志记录 + count_result = execute_query( + f'SELECT COUNT(*) as total FROM t_daily_report_history WHERE {where_sql}', + tuple(params) + ) + total_matched = count_result[0]['total'] if count_result else 0 + log_info(f'[下载API] 按筛选匹配到 {total_matched} 条成功记录', 'download') + + # 查询所有匹配的成功记录(按报表日期正序排列,方便用户查看) result = execute_query( - f'SELECT id, file_path, config_name, report_date, status FROM t_daily_report_history WHERE {where_sql} ORDER BY create_time DESC', + f'SELECT id, file_path, config_name, report_date, status, split_name FROM t_daily_report_history WHERE {where_sql} ORDER BY report_date ASC, config_name ASC, id ASC', tuple(params) ) @@ -260,11 +285,12 @@ def batch_download_by_filter(): # 收集要打包的文件 files_to_zip = [] failed_names = [] + used_names = set() # 用于检测 zip 内文件名冲突 for item in result: file_path = item.get('file_path') if not file_path: - failed_names.append(f"{item.get('config_name', '未知')} (无文件)") + failed_names.append(f"{item.get('config_name', '未知')}_{item.get('report_date', '')} (无文件路径)") continue # 提取文件名 @@ -277,25 +303,40 @@ def batch_download_by_filter(): full_path = os.path.join(reports_dir, filename) if not os.path.exists(full_path): - failed_names.append(f"{item.get('config_name', '未知')} (文件不存在)") + failed_names.append(f"{item.get('config_name', '未知')}_{item.get('report_date', '')} (文件不存在)") continue - # 使用配置名作为zip内的文件名,避免重名 + # 构建 zip 内文件名:配置名_报表日期(避免重名) config_name_val = item.get('config_name', '未知') report_date = item.get('report_date', '') - ext = os.path.splitext(filename)[1] + split_name = item.get('split_name', '') + ext = os.path.splitext(filename)[1] or '.xlsx' + + # 基础名称:配置名_报表日期 + base_parts = [config_name_val] if report_date: - zip_inner_name = f"{config_name_val}_{report_date}{ext}" - else: - zip_inner_name = f"{config_name_val}{ext}" + base_parts.append(str(report_date)) + if split_name: + base_parts.append(str(split_name)) + base_name = '_'.join(base_parts) + zip_inner_name = f"{base_name}{ext}" + + # 处理文件名冲突:如果同名,追加记录ID + if zip_inner_name in used_names: + zip_inner_name = f"{base_name}_{item.get('id', '')}{ext}" + used_names.add(zip_inner_name) files_to_zip.append({ 'full_path': full_path, 'inner_name': zip_inner_name }) + log_info(f'[下载API] 匹配 {total_matched} 条记录,成功打包 {len(files_to_zip)} 个文件,缺失 {len(failed_names)} 个文件', 'download') + if failed_names: + log_warning(f'[下载API] 缺失的文件列表: {failed_names[:20]}', 'download') + if len(files_to_zip) == 0: - return jsonify({'success': False, 'message': '没有可下载的文件'}), 400 + return jsonify({'success': False, 'message': '没有可下载的文件(匹配的记录文件均不存在)'}), 400 # 生成zip文件名 timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') @@ -309,16 +350,78 @@ def batch_download_by_filter(): log_info(f'[下载API] 按筛选批量打包完成,共 {len(files_to_zip)} 个文件,失败 {len(failed_names)} 个', 'download') - # 返回zip文件 + # 返回zip文件,通过自定义头部返回统计信息 from flask import send_file - return send_file( + response = send_file( zip_path, as_attachment=True, download_name=zip_filename, mimetype='application/zip' ) + # 通过响应头返回统计信息,便于前端诊断 + response.headers['X-Total-Matched'] = str(total_matched) + response.headers['X-Files-Zipped'] = str(len(files_to_zip)) + response.headers['X-Files-Missing'] = str(len(failed_names)) + return response except Exception as e: log_error(f'[下载API] 按筛选批量下载错误: {e}', 'download') import traceback traceback.print_exc() return jsonify({'success': False, 'message': str(e)}), 500 + + +@download_bp.route('/download/batch-filter-count', methods=['POST']) +def batch_download_filter_count(): + """按筛选条件统计可导出的记录数 + 在批量导出前调用,让用户确认导出范围。 + """ + try: + data = request.get_json(silent=True) or {} + config_name = data.get('config_name', '').strip() + start_date = data.get('start_date', '').strip() + end_date = data.get('end_date', '').strip() + + # 构建查询条件(与批量导出完全一致,只统计成功记录) + where_clauses = ['status = 1'] + 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) + + if config_name: + where_clauses.append('config_name LIKE %s') + params.append(f'%{config_name}%') + + where_sql = ' AND '.join(where_clauses) + + # 统计总记录数 + count_result = execute_query( + f'SELECT COUNT(*) as total FROM t_daily_report_history WHERE {where_sql}', + tuple(params) + ) + total = count_result[0]['total'] if count_result else 0 + + # 统计日期范围 + date_range = execute_query( + f'SELECT MIN(report_date) as min_date, MAX(report_date) as max_date FROM t_daily_report_history WHERE {where_sql}', + tuple(params) + ) + min_date = date_range[0]['min_date'] if date_range else None + max_date = date_range[0]['max_date'] if date_range else None + + return jsonify({ + 'success': True, + 'data': { + 'total': total, + 'min_date': str(min_date) if min_date else '', + 'max_date': str(max_date) if max_date else '' + } + }) + except Exception as e: + log_error(f'[下载API] 统计可导出记录数错误: {e}', 'download') + return jsonify({'success': False, 'message': str(e)}), 500 diff --git a/public/static/js/history.js b/public/static/js/history.js index a26dd4e..8b6ff04 100644 --- a/public/static/js/history.js +++ b/public/static/js/history.js @@ -16,59 +16,153 @@ function onHistoryStatusChange() { loadHistoryWithDate(1); } -// 批量下载历史记录 +// 统一的批量下载:勾选时下载选中记录,未勾选时按筛选条件下载全部匹配记录 async function batchDownloadHistory() { + // 同步当前筛选条件 + const configNameInput = document.getElementById('history-config-name'); + if (configNameInput) { + historyConfigName = configNameInput.value.trim(); + } + historyStatus = document.getElementById('history-status-filter').value; + historyStartDate = document.getElementById('history-start-date').value; + historyEndDate = document.getElementById('history-end-date').value; + const checkboxes = document.querySelectorAll('.history-checkbox:checked'); - if (checkboxes.length === 0) { - alert('请至少选择一条记录'); + const hasSelection = checkboxes.length > 0; + + if (hasSelection) { + // 模式1:下载勾选的记录 + const ids = Array.from(checkboxes).map(cb => parseInt(cb.value)); + const failedCount = Array.from(checkboxes).filter(cb => cb.dataset.status !== '1').length; + const successCount = checkboxes.length - failedCount; + + if (successCount === 0) { + alert('选中的记录都是生成失败的,无法下载'); + return; + } + + if (failedCount > 0) { + if (!confirm(`选中的 ${checkboxes.length} 条记录中有 ${failedCount} 条是生成失败的,无法下载。\n是否打包下载成功的 ${successCount} 条?`)) { + return; + } + } else { + if (!confirm(`确定要下载选中的 ${successCount} 条报表吗?`)) { + return; + } + } + + try { + const response = await fetch('/api/download/batch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ids: ids }) + }); + await handleDownloadResponse(response); + } catch (error) { + alert('下载失败: ' + error.message); + } + } else { + // 模式2:按筛选条件下载全部匹配的成功记录 + const filterDescParts = []; + if (historyConfigName) filterDescParts.push(`配置名称含"${historyConfigName}"`); + if (historyStartDate) filterDescParts.push(`开始日期 ${historyStartDate}`); + if (historyEndDate) filterDescParts.push(`结束日期 ${historyEndDate}`); + const filterDesc = filterDescParts.length > 0 ? filterDescParts.join(',') : '全部记录'; + + // 查询匹配数量 + let matchCount = 0; + let dateRange = ''; + try { + const countResponse = await fetch('/api/download/batch-filter-count', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + config_name: historyConfigName, + start_date: historyStartDate, + end_date: historyEndDate + }) + }); + const countResult = await countResponse.json(); + if (countResult.success) { + matchCount = countResult.data.total || 0; + if (countResult.data.min_date && countResult.data.max_date) { + dateRange = `\n报表日期范围: ${countResult.data.min_date} 至 ${countResult.data.max_date}`; + } + } + } catch (e) { + console.error('查询导出数量失败:', e); + } + + if (matchCount === 0) { + alert(`没有符合条件的成功记录可导出。\n筛选条件: ${filterDesc}`); + return; + } + + if (!confirm(`未选中任何记录,将按当前筛选条件导出全部匹配的成功记录。\n\n筛选条件: ${filterDesc}\n匹配记录数: ${matchCount} 条${dateRange}\n\n注意: 仅导出生成成功的报表。\n是否继续?`)) { + return; + } + + try { + const response = await fetch('/api/download/batch-filter', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + config_name: historyConfigName, + start_date: historyStartDate, + end_date: historyEndDate, + status: historyStatus + }) + }); + await handleDownloadResponse(response); + } catch (error) { + alert('下载失败: ' + error.message); + } + } +} + +// 处理下载响应(两种模式共用) +async function handleDownloadResponse(response) { + if (!response.ok) { + try { + const result = await response.json(); + alert('下载失败: ' + (result.message || '未知错误')); + } catch (e) { + alert('下载失败: 服务器返回错误'); + } return; } - const ids = Array.from(checkboxes).map(cb => parseInt(cb.value)); - - // 检查是否有生成失败的记录 - const failedCount = Array.from(checkboxes).filter(cb => cb.dataset.status !== '1').length; - const successCount = checkboxes.length - failedCount; - - if (successCount === 0) { - alert('选中的记录都是生成失败的,无法下载'); - return; - } - - if (failedCount > 0) { - if (!confirm(`选中的 ${checkboxes.length} 条记录中有 ${failedCount} 条是生成失败的,无法下载。\n是否打包下载成功的 ${successCount} 条?`)) { - return; + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + const timestamp = new Date().toISOString().slice(0, 19).replace(/[-T:]/g, ''); + a.download = `批量下载_${timestamp}.zip`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + window.URL.revokeObjectURL(url); + + // 显示统计信息(如果有) + const totalMatched = response.headers.get('X-Total-Matched'); + const filesZipped = response.headers.get('X-Files-Zipped'); + const filesMissing = response.headers.get('X-Files-Missing'); + const skippedFailed = response.headers.get('X-Skipped-Failed'); + if (totalMatched && filesZipped) { + const missing = filesMissing ? parseInt(filesMissing) : 0; + const skipped = skippedFailed ? parseInt(skippedFailed) : 0; + let msg = `下载完成!\n`; + msg += `匹配记录: ${totalMatched} 条\n`; + msg += `成功打包: ${filesZipped} 个文件`; + if (missing > 0) { + msg += `\n缺失文件: ${filesMissing} 个(历史文件可能已被清理)`; } - } - - try { - const response = await fetch('/api/download/batch', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ids: ids }) - }); - - if (response.ok) { - const blob = await response.blob(); - const url = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - const timestamp = new Date().toISOString().slice(0, 19).replace(/[-T:]/g, ''); - a.download = `批量下载_${timestamp}.zip`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - window.URL.revokeObjectURL(url); - } else { - try { - const result = await response.json(); - alert('下载失败: ' + (result.message || '未知错误')); - } catch (e) { - alert('下载失败: 服务器返回错误'); - } + if (skipped > 0) { + msg += `\n跳过失败记录: ${skipped} 个`; + } + if (missing > 0 || skipped > 0) { + alert(msg); } - } catch (error) { - alert('下载失败: ' + error.message); } } @@ -375,69 +469,5 @@ async function batchDeleteHistory() { } } -// 批量导出当前筛选条件下的所有记录(与搜索条件同步) -async function batchDownloadFilteredHistory() { - // 同步当前筛选条件 - const configNameInput = document.getElementById('history-config-name'); - if (configNameInput) { - historyConfigName = configNameInput.value.trim(); - } - historyStatus = document.getElementById('history-status-filter').value; - historyStartDate = document.getElementById('history-start-date').value; - historyEndDate = document.getElementById('history-end-date').value; - - // 构建筛选条件描述 - const filterDescParts = []; - if (historyStatus) { - filterDescParts.push(historyStatus === '1' ? '成功' : '失败'); - } - if (historyConfigName) { - filterDescParts.push(`配置名称含"${historyConfigName}"`); - } - if (historyStartDate) { - filterDescParts.push(`开始日期 ${historyStartDate}`); - } - if (historyEndDate) { - filterDescParts.push(`结束日期 ${historyEndDate}`); - } - const filterDesc = filterDescParts.length > 0 ? filterDescParts.join(',') : '全部记录'; - - if (!confirm(`将导出符合当前筛选条件的所有成功记录(${filterDesc})。\n注意:仅导出生成成功的报表。\n是否继续?`)) { - return; - } - - try { - const response = await fetch('/api/download/batch-filter', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - config_name: historyConfigName, - start_date: historyStartDate, - end_date: historyEndDate, - status: historyStatus - }) - }); - - if (response.ok) { - const blob = await response.blob(); - const url = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - const timestamp = new Date().toISOString().slice(0, 19).replace(/[-T:]/g, ''); - a.download = `批量导出_${timestamp}.zip`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - window.URL.revokeObjectURL(url); - } else { - try { - const result = await response.json(); - alert('导出失败: ' + (result.message || '未知错误')); - } catch (e) { - alert('导出失败: 服务器返回错误'); - } - } - } catch (error) { - alert('导出失败: ' + error.message); - } -} +// 批量导出当前筛选条件下的所有记录 - 已合并到 batchDownloadHistory 函数 +// 该函数已被移除,功能整合到统一的 batchDownloadHistory 中 diff --git a/templates/index.html b/templates/index.html index 72873f7..a868176 100644 --- a/templates/index.html +++ b/templates/index.html @@ -140,8 +140,7 @@ - - +