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):