Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0404c7e274 | |||
| 69c5747867 |
@@ -86,6 +86,7 @@
|
|||||||
<button class="btn btn-primary" onclick="performSearch('exact')">关键词搜索</button>
|
<button class="btn btn-primary" onclick="performSearch('exact')">关键词搜索</button>
|
||||||
<button class="btn btn-secondary" onclick="performSearch('fuzzy')">模糊搜索</button>
|
<button class="btn btn-secondary" onclick="performSearch('fuzzy')">模糊搜索</button>
|
||||||
<button class="btn" onclick="loadAllData()">显示全部</button>
|
<button class="btn" onclick="loadAllData()">显示全部</button>
|
||||||
|
<button class="btn btn-primary" onclick="exportAllData()">一键导出Excel</button>
|
||||||
<button class="btn" onclick="clearSearch()">清空结果</button>
|
<button class="btn" onclick="clearSearch()">清空结果</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -507,6 +508,10 @@ function downloadReportCsv() {
|
|||||||
window.location.href = `/elastic/report/csv/?${params.toString()}`;
|
window.location.href = `/elastic/report/csv/?${params.toString()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function exportAllData() {
|
||||||
|
window.location.href = "/elastic/export_achievements_csv/";
|
||||||
|
}
|
||||||
|
|
||||||
// 渲染表格
|
// 渲染表格
|
||||||
function renderTable(data) {
|
function renderTable(data) {
|
||||||
tableBody.innerHTML = '';
|
tableBody.innerHTML = '';
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ urlpatterns = [
|
|||||||
path('filter/', views.filter_view, name='filter'),
|
path('filter/', views.filter_view, name='filter'),
|
||||||
path('report/', views.report_view, name='report'),
|
path('report/', views.report_view, name='report'),
|
||||||
path('report/csv/', views.report_csv_view, name='report_csv'),
|
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'),
|
path('users/', views.get_users, name='get_users'),
|
||||||
|
|||||||
230
elastic/views.py
230
elastic/views.py
@@ -9,6 +9,13 @@ import json
|
|||||||
import csv
|
import csv
|
||||||
import io
|
import io
|
||||||
import mimetypes
|
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
|
from datetime import datetime, timezone, timedelta
|
||||||
import tempfile
|
import tempfile
|
||||||
import concurrent.futures
|
import concurrent.futures
|
||||||
@@ -838,7 +845,26 @@ def upload(request):
|
|||||||
merged_group_data["错误信息"] = ";".join(group_errors[:3])
|
merged_group_data["错误信息"] = ";".join(group_errors[:3])
|
||||||
|
|
||||||
rel_paths = [f"images/{img[1]}" for img in group_images]
|
rel_paths = [f"images/{img[1]}" for img in group_images]
|
||||||
image_urls = [request.build_absolute_uri(settings.MEDIA_URL + rp) for rp in rel_paths]
|
|
||||||
|
# 改进:如果配置了 MinIO,则在上传阶段就同步到 MinIO,确保在线版本待处理列表能显示图片
|
||||||
|
image_urls = []
|
||||||
|
from minio_storage.minio_connect import is_minio_configured, upload_file, presigned_get_url
|
||||||
|
|
||||||
|
minio_enabled = is_minio_configured()
|
||||||
|
for rp in rel_paths:
|
||||||
|
abs_p = os.path.join(settings.MEDIA_ROOT, rp)
|
||||||
|
if minio_enabled:
|
||||||
|
try:
|
||||||
|
# 上传到 MinIO
|
||||||
|
upload_file(abs_p, rp)
|
||||||
|
# 生成预签名 URL
|
||||||
|
url = presigned_get_url(rp)
|
||||||
|
image_urls.append(url)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"上传临时图片到 MinIO 失败: {e}")
|
||||||
|
image_urls.append(request.build_absolute_uri(settings.MEDIA_URL + rp))
|
||||||
|
else:
|
||||||
|
image_urls.append(request.build_absolute_uri(settings.MEDIA_URL + rp))
|
||||||
|
|
||||||
file_results.append({
|
file_results.append({
|
||||||
"name": f.name,
|
"name": f.name,
|
||||||
@@ -1750,6 +1776,208 @@ def report_csv_view(request):
|
|||||||
out["Content-Disposition"] = 'attachment; filename="report.csv"'
|
out["Content-Disposition"] = 'attachment; filename="report.csv"'
|
||||||
return out
|
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
|
||||||
|
# 增加 extrasaction='ignore' 以忽略 _image_refs 等内部辅助字段
|
||||||
|
writer = csv.DictWriter(output, fieldnames=headers, extrasaction='ignore')
|
||||||
|
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"])
|
@require_http_methods(["POST"])
|
||||||
@csrf_protect
|
@csrf_protect
|
||||||
def revoke_registration_code_view(request):
|
def revoke_registration_code_view(request):
|
||||||
|
|||||||
Reference in New Issue
Block a user