diff --git a/elastic/templates/elastic/manage.html b/elastic/templates/elastic/manage.html index 76b1e48..e6b8d83 100644 --- a/elastic/templates/elastic/manage.html +++ b/elastic/templates/elastic/manage.html @@ -86,6 +86,7 @@ + @@ -507,6 +508,10 @@ function downloadReportCsv() { window.location.href = `/elastic/report/csv/?${params.toString()}`; } +function exportAllData() { + window.location.href = "/elastic/export_achievements_csv/"; +} + // 渲染表格 function renderTable(data) { tableBody.innerHTML = ''; diff --git a/elastic/urls.py b/elastic/urls.py index 43ea181..6d261cc 100644 --- a/elastic/urls.py +++ b/elastic/urls.py @@ -23,6 +23,7 @@ urlpatterns = [ path('filter/', views.filter_view, name='filter'), path('report/', views.report_view, name='report'), path('report/csv/', views.report_csv_view, name='report_csv'), + path('export_achievements_csv/', views.export_achievements_csv, name='export_achievements_csv'), # 用户管理 path('users/', views.get_users, name='get_users'), diff --git a/elastic/views.py b/elastic/views.py index b6e8966..6f2429d 100644 --- a/elastic/views.py +++ b/elastic/views.py @@ -9,6 +9,13 @@ import json import csv import io import mimetypes +try: + import openpyxl + from openpyxl.utils import get_column_letter + from openpyxl.drawing.image import Image as XLImage + HAS_OPENPYXL = True +except ImportError: + HAS_OPENPYXL = False from datetime import datetime, timezone, timedelta import tempfile import concurrent.futures @@ -1750,6 +1757,207 @@ def report_csv_view(request): out["Content-Disposition"] = 'attachment; filename="report.csv"' return out + +@require_http_methods(["GET"]) +def export_achievements_csv(request): + """一键导出所有可见成果为 Excel (如果支持) 或 CSV""" + try: + session_user_id = request.session.get("user_id") + if session_user_id is None: + return HttpResponse("Unauthorized", status=401) + + # 1. 获取所有数据 + results = search_all() + # 2. 根据权限过滤 + results = _filter_results_for_user(request, results) + # 3. 补充录入人姓名 + results = _attach_writer_names(results) + + if not results: + return HttpResponse("No data to export", status=404) + + # 4. 解析数据并准备数据列表 + parsed_data_list = [] + all_data_keys = set() + + for item in results: + raw_data = item.get("data", "{}") + try: + if isinstance(raw_data, str): + parsed_dict = json.loads(raw_data) + else: + parsed_dict = raw_data + except Exception: + parsed_dict = {"原始数据": str(raw_data)} + + if not isinstance(parsed_dict, dict): + parsed_dict = {"数据内容": str(parsed_dict)} + + # 展平基础字段和动态数据字段 + flat_item = { + "ID": item.get("_id", ""), + "录入人": item.get("writer_name") or item.get("writer_id", ""), + "时间": format_datetime_for_export(item.get("time")), + } + # 清理动态字段中的换行符 + clean_parsed_dict = {} + for k, v in parsed_dict.items(): + if isinstance(v, str): + clean_parsed_dict[k] = v.replace('\r', '').replace('\n', ' ') + else: + clean_parsed_dict[k] = v + + flat_item.update(clean_parsed_dict) + # 保存原始图片引用以便导出 Excel 时使用 + flat_item["_image_refs"] = _parse_image_refs(item.get("image", "")) + parsed_data_list.append(flat_item) + all_data_keys.update(clean_parsed_dict.keys()) + + # 确定表头:基础字段 + 动态字段(按字母排序) + dynamic_headers = sorted(list(all_data_keys)) + headers = ["ID", "录入人", "时间"] + dynamic_headers + + # 如果是 Excel 且支持图片,添加图片列 + if HAS_OPENPYXL: + headers.append("成果图片") + + filename_base = f"achievements_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}" + + # 5. 如果安装了 openpyxl,生成 Excel + if HAS_OPENPYXL: + wb = openpyxl.Workbook() + ws = wb.active + ws.title = "成果数据" + + # 写入表头 + for col_num, header in enumerate(headers, 1): + cell = ws.cell(row=1, column=col_num, value=header) + cell.font = openpyxl.styles.Font(bold=True) + cell.alignment = openpyxl.styles.Alignment(horizontal='center', vertical='center') + + # 写入数据 + img_col_index = headers.index("成果图片") + 1 if "成果图片" in headers else None + + for row_num, row_data in enumerate(parsed_data_list, 2): + for col_num, header in enumerate(headers, 1): + if header == "成果图片": + continue # 图片单独处理 + ws.cell(row=row_num, column=col_num, value=row_data.get(header, "")) + + # 处理图片插入 + if img_col_index and row_data.get("_image_refs"): + first_ref = row_data["_image_refs"][0] + img_bytes = _get_image_bytes(first_ref) + if img_bytes: + try: + img = XLImage(img_bytes) + # 调整图片大小以适应单元格 (假设高度 80 像素左右) + aspect_ratio = img.width / img.height + img.height = 80 + img.width = 80 * aspect_ratio + + # 计算插入位置 + cell_address = f"{get_column_letter(img_col_index)}{row_num}" + ws.add_image(img, cell_address) + # 设置行高以容纳图片 (80 像素约为 60 磅) + ws.row_dimensions[row_num].height = 65 + except Exception as e: + ws.cell(row=row_num, column=img_col_index, value=f"图片加载失败: {str(e)}") + + # 自动调整列宽 + for i, column_cells in enumerate(ws.columns, 1): + header = headers[i-1] + if header == "成果图片": + ws.column_dimensions[get_column_letter(i)].width = 20 + continue + length = max(len(str(cell.value or "")) for cell in column_cells) + ws.column_dimensions[get_column_letter(i)].width = min(length + 2, 50) + + response = HttpResponse( + content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ) + response['Content-Disposition'] = f'attachment; filename="{filename_base}.xlsx"' + wb.save(response) + return response + + # 6. 否则回退到 CSV + output = io.StringIO() + output.write('\ufeff') # UTF-8 BOM + writer = csv.DictWriter(output, fieldnames=headers) + writer.writeheader() + for row in parsed_data_list: + writer.writerow(row) + + response = HttpResponse(output.getvalue(), content_type='text/csv; charset=utf-8') + response['Content-Disposition'] = f'attachment; filename="{filename_base}.csv"' + return response + except Exception as e: + import traceback + traceback.print_exc() + return HttpResponse(f"导出失败: {str(e)}", status=500) + +def format_datetime_for_export(t): + if not t: return "" + try: + if isinstance(t, datetime): + return t.strftime("%Y-%m-%d %H:%M:%S") + d = datetime.fromisoformat(str(t).replace('Z', '+00:00')) + return d.strftime("%Y-%m-%d %H:%M:%S") + except Exception: + return str(t) + +def _get_image_bytes(image_ref): + """根据 image_ref 获取图片字节流,并确保转换为 Excel 支持的格式 (如 JPEG/PNG)""" + s = str(image_ref or '').strip() + if not s: + return None + + img_raw_bytes = None + if s.startswith('minio:'): + object_name = s[len('minio:'):].lstrip('/') + try: + from minio_storage.minio_connect import get_minio_client, get_bucket_name + client = get_minio_client() + bucket = get_bucket_name() + if client: + response = client.get_object(bucket, object_name) + img_raw_bytes = response.read() + except Exception: + pass + + elif s.startswith('local:'): + rel_path = s[len('local:'):].lstrip('/') + abs_path = os.path.join(settings.MEDIA_ROOT, rel_path) + if os.path.isfile(abs_path): + try: + with open(abs_path, 'rb') as f: + img_raw_bytes = f.read() + except Exception: + pass + + if not img_raw_bytes: + return None + + # 处理 WebP 或其他 openpyxl 可能不支持的格式 + try: + from PIL import Image as PILImage + img_io = io.BytesIO(img_raw_bytes) + with PILImage.open(img_io) as pil_img: + # 如果是 WebP 或带有透明通道的图片,转换为 RGB 格式并存为 JPEG 或 PNG + # Excel 对 PNG 支持较好 + output_io = io.BytesIO() + if pil_img.format == 'WEBP' or pil_img.mode in ('RGBA', 'LA', 'P'): + rgb_img = pil_img.convert('RGB') + rgb_img.save(output_io, format='JPEG', quality=85) + else: + pil_img.save(output_io, format=pil_img.format or 'JPEG') + output_io.seek(0) + return output_io + except Exception as e: + print(f"图片转换失败: {str(e)}") + return io.BytesIO(img_raw_bytes) # 尝试直接返回原始数据作为最后手段 + + @require_http_methods(["POST"]) @csrf_protect def revoke_registration_code_view(request):