11 Commits

Author SHA1 Message Date
e92964ce71 [0.2.8.10][ci]
All checks were successful
CI / docker-ci (push) Successful in 24s
2026-03-23 11:53:10 +08:00
1a3aee39e0 [0.2.8.10]
All checks were successful
CI / docker-ci (push) Has been skipped
2026-03-23 11:52:41 +08:00
DSQ
7fa7b42b1a Merge remote-tracking branch 'origin/Django' into Django
All checks were successful
CI / docker-ci (push) Has been skipped
2026-03-23 11:51:34 +08:00
DSQ
26452161f8 [0.2.8.0][ci] 2026-03-23 11:51:02 +08:00
07d3a4420c 生成镜像[0.2.7.9][ci]
All checks were successful
CI / docker-ci (push) Successful in 25s
2026-03-23 11:28:14 +08:00
2c3c2d6acf Merge branch 'Django' of gitea.spdis.space:Viajero/Achievement_Inputing into Django
All checks were successful
CI / docker-ci (push) Has been skipped
2026-03-23 11:06:59 +08:00
afc663844b 修复主页的类型分析的500问题[0.2.7.9][ci] 2026-03-23 11:06:45 +08:00
DSQ
9e3fe7150b [0.2.7.8][ci]
All checks were successful
CI / docker-ci (push) Successful in 26s
2026-03-23 11:02:00 +08:00
DSQ
c9611fa622 [0.2.7.7][ci]
All checks were successful
CI / docker-ci (push) Successful in 35s
2026-03-23 10:37:46 +08:00
DSQ
fe7f08ed1c 数据管理添加时间的显示和统计报表的功能[0.2.7.6][ci]
All checks were successful
CI / docker-ci (push) Successful in 35s
2026-03-19 16:38:16 +08:00
DSQ
5e38ebf856 [0.2.7.5][ci]
All checks were successful
CI / docker-ci (push) Successful in 32s
2026-03-18 21:56:39 +08:00
6 changed files with 778 additions and 115 deletions

View File

@@ -0,0 +1,186 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>注册码申请管理</title>
<style>
body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; background: #f5f6fa; }
.sidebar { position: fixed; top: 0; left: 0; width: 180px; height: 100vh; background: #1e1e2e; color: white; padding: 20px; box-shadow: 2px 0 5px rgba(0,0,0,0.1); z-index: 1000; display: flex; flex-direction: column; align-items: center; }
.sidebar h3 { margin-top: 0; font-size: 18px; color: #add8e6; text-align: center; margin-bottom: 20px; }
.navigation-links { width: 100%; margin-top: 60px; }
.sidebar a { display: block; color: #8be9fd; text-decoration: none; margin: 10px 0; font-size: 16px; padding: 15px; border-radius: 4px; transition: all 0.2s ease; }
.sidebar a:hover { color: #ff79c6; background-color: rgba(139, 233, 253, 0.2); }
.main-content { margin-left: 220px; padding: 40px; }
.card { background: #fff; border-radius: 14px; box-shadow: 0 10px 24px rgba(31,35,40,0.08); padding: 24px; }
.header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
.btn { padding: 8px 12px; border: none; border-radius: 10px; cursor: pointer; }
.btn-primary { background: #4f46e5; color: #fff; }
.btn-secondary { background: #64748b; color: #fff; }
.btn-danger { background: #ff4d4f; color: #fff; }
.muted { color: #6b7280; font-size: 12px; }
table { width: 100%; border-collapse: collapse; margin-top: 12px; }
th, td { text-align: left; border-bottom: 1px solid #e5e7eb; padding: 10px 8px; vertical-align: top; font-size: 13px; }
tr:hover { background: #f8fafc; }
.tag { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 12px; background: #eef2ff; color: #3730a3; }
.tag.pending { background: #fff7ed; color: #9a3412; }
.tag.approved { background: #dcfce7; color: #166534; }
.tag.rejected { background: #fee2e2; color: #991b1b; }
</style>
{% csrf_token %}
</head>
<body>
<div class="sidebar">
<h3>你好,{{ username|default:"管理员" }}</h3>
<div class="navigation-links">
<a href="{% url 'main:home' %}">返回主页</a>
<a id="logoutBtn" style="cursor:pointer;">退出登录</a>
<div id="logoutMsg" class="muted" style="margin-top:6px;"></div>
{% csrf_token %}
</div>
</div>
<div class="main-content">
<div class="card">
<div class="header">
<h2 style="margin:0;">注册码申请管理</h2>
<div style="display:flex; gap:10px; align-items:center;">
<select id="statusFilter" style="padding:8px 10px; border:1px solid #d1d5db; border-radius:10px;">
<option value="pending">待审核</option>
<option value="">全部</option>
<option value="approved">已同意</option>
<option value="rejected">已拒绝</option>
</select>
<button id="refreshBtn" class="btn btn-secondary" type="button">刷新</button>
</div>
</div>
<div class="muted">同意后,用户会获得“注册码管理”入口,且仅能使用自己新增的 key。</div>
<table>
<thead>
<tr>
<th style="width:120px;">用户</th>
<th>申请理由</th>
<th style="width:170px;">时间</th>
<th style="width:110px;">状态</th>
<th style="width:220px;">操作</th>
</tr>
</thead>
<tbody id="reqBody"></tbody>
</table>
<div id="pageMsg" class="muted" style="margin-top:12px;"></div>
</div>
</div>
<script>
function getCookie(name){const v=`; ${document.cookie}`;const p=v.split(`; ${name}=`);if(p.length===2) return p.pop().split(';').shift();}
document.getElementById('logoutBtn').addEventListener('click', async () => {
const msg = document.getElementById('logoutMsg');
msg.textContent = '';
const csrftoken = getCookie('csrftoken');
try {
const resp = await fetch('/accounts/logout/', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrftoken || '' },
body: JSON.stringify({})
});
const data = await resp.json();
if (data.ok) window.location.href = data.redirect_url;
} catch (e) { msg.textContent = '登出失败'; }
});
function fmtTime(t){
try{
const d = new Date(t);
if(String(d) !== 'Invalid Date'){
const pad = n=> String(n).padStart(2,'0');
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
}catch(e){}
return t || '';
}
function renderStatus(s){
const v = String(s || 'pending');
const cls = (v === 'approved' || v === 'rejected') ? v : 'pending';
const text = v === 'approved' ? '已同意' : (v === 'rejected' ? '已拒绝' : '待审核');
return `<span class="tag ${cls}">${text}</span>`;
}
async function loadRequests(){
const status = document.getElementById('statusFilter').value;
const msg = document.getElementById('pageMsg');
msg.textContent = '加载中...';
const url = status ? `/accounts/registration-code/requests/list/?status=${encodeURIComponent(status)}` : '/accounts/registration-code/requests/list/';
try{
const resp = await fetch(url, { credentials: 'same-origin' });
const data = await resp.json();
if(!(resp.ok && data && data.ok)){
msg.textContent = (data && data.message) ? data.message : '加载失败';
return;
}
const body = document.getElementById('reqBody');
body.innerHTML = '';
const rows = data.data || [];
if(!rows.length){
msg.textContent = '暂无数据';
return;
}
msg.textContent = '';
rows.forEach(r=>{
const tr = document.createElement('tr');
const uname = (r.username || '') + (r.user_id !== undefined ? `${r.user_id}` : '');
const reason = String(r.reason || '').replace(/</g,'&lt;').replace(/>/g,'&gt;');
const created = fmtTime(r.created_at);
const statusHtml = renderStatus(r.status);
const id = r.request_id || r._id || '';
const ops = (String(r.status || 'pending') === 'pending')
? `<button class="btn btn-primary" data-act="approve" data-id="${id}">同意</button>
<button class="btn btn-danger" data-act="reject" data-id="${id}">拒绝</button>`
: `<button class="btn btn-secondary" data-act="view" data-id="${id}">查看</button>`;
tr.innerHTML = `<td>${uname}</td><td style="white-space:pre-wrap;">${reason}</td><td>${created}</td><td>${statusHtml}</td><td>${ops}</td>`;
body.appendChild(tr);
});
}catch(e){
msg.textContent = '加载失败';
}
}
async function decide(id, action){
const csrftoken = getCookie('csrftoken');
const note = '';
const resp = await fetch('/accounts/registration-code/requests/decide/', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrftoken || '' },
body: JSON.stringify({ request_id: id, action, note })
});
const data = await resp.json();
if(!(resp.ok && data && data.ok)){
alert((data && data.message) ? data.message : '操作失败');
return;
}
loadRequests();
}
document.getElementById('refreshBtn').addEventListener('click', loadRequests);
document.getElementById('statusFilter').addEventListener('change', loadRequests);
document.addEventListener('click', (e)=>{
const t = e.target;
if(!(t && t.dataset && t.dataset.id && t.dataset.act)) return;
const id = t.dataset.id;
const act = t.dataset.act;
if(act === 'approve'){
if(confirm('确定同意该申请吗?')) decide(id, 'approve');
}else if(act === 'reject'){
if(confirm('确定拒绝该申请吗?')) decide(id, 'reject');
}else if(act === 'view'){
return;
}
});
loadRequests();
</script>
</body>
</html>

View File

@@ -307,7 +307,8 @@ def search_data(query):
"_id": hit.meta.id,
"writer_id": hit.writer_id,
"data": hit.data,
"image": hit.image
"image": hit.image,
"time": getattr(hit, "time", None),
})
return results
@@ -328,7 +329,8 @@ def search_all():
"_id": hit.meta.id,
"writer_id": hit.writer_id,
"data": hit.data,
"image": hit.image
"image": hit.image,
"time": getattr(hit, "time", None),
})
return results
@@ -435,7 +437,8 @@ def search_by_any_field(keyword):
"_id": hit.meta.id,
"writer_id": hit.writer_id,
"data": hit.data,
"image": hit.image
"image": hit.image,
"time": getattr(hit, "time", None),
})
return results
@@ -828,12 +831,8 @@ def get_user_by_username(username):
def get_all_users():
"""获取所有用户"""
try:
search = UserDocument.search()
search = search.query("match_all")
response = search.execute()
users = []
for hit in response:
for hit in UserDocument.search().query("match_all").scan():
users.append({
"user_id": hit.user_id,
"username": hit.username,
@@ -845,7 +844,6 @@ def get_all_users():
"key": list(getattr(hit, 'key', []) or []),
"manage_key": list(getattr(hit, 'manage_key', []) or []),
})
return users
except Exception as e:
print(f"获取所有用户失败: {str(e)}")

View File

@@ -81,6 +81,8 @@
<input type="text" id="searchQuery" class="search-input" placeholder="请输入搜索关键词...">
<select id="keyFilter" class="search-input"></select>
<button class="btn" onclick="clearKeyFilter()">清空Key筛查</button>
<select id="typeFilter" class="search-input"></select>
<button class="btn" onclick="clearTypeFilter()">清空类型筛查</button>
<button class="btn btn-primary" onclick="performSearch('exact')">关键词搜索</button>
<button class="btn btn-secondary" onclick="performSearch('fuzzy')">模糊搜索</button>
<button class="btn" onclick="loadAllData()">显示全部</button>
@@ -93,12 +95,31 @@
</div>
</div>
{% if is_admin or has_manage_key %}
<div class="search-container" style="margin-top: 12px;">
<div style="font-weight: 600; margin-bottom: 8px;">统计报表</div>
<div class="search-controls" style="flex-wrap: wrap;">
<input type="datetime-local" id="reportFrom" class="search-input" placeholder="开始时间">
<input type="datetime-local" id="reportTo" class="search-input" placeholder="结束时间">
<select id="reportInterval" class="search-input">
<option value="day">按天</option>
<option value="week">按周</option>
<option value="month">按月</option>
</select>
<button class="btn btn-primary" onclick="generateReport()">生成报表</button>
<button class="btn" onclick="downloadReportCsv()">下载CSV</button>
</div>
<div id="reportBox" class="search-result" style="display: none; margin-top: 10px;"></div>
</div>
{% endif %}
<!-- 数据表格 -->
<table id="dataTable">
<thead>
<tr>
<th>图片</th>
<th>数据</th>
<th>时间</th>
<th>录入人</th>
<th>操作</th>
</tr>
@@ -153,6 +174,11 @@ function getCookie(name) {
// DOM元素引用
const searchQueryInput = document.getElementById('searchQuery');
const keyFilterSelect = document.getElementById('keyFilter');
const typeFilterSelect = document.getElementById('typeFilter');
const reportFromInput = document.getElementById('reportFrom');
const reportToInput = document.getElementById('reportTo');
const reportIntervalSelect = document.getElementById('reportInterval');
const reportBox = document.getElementById('reportBox');
const searchResultDiv = document.getElementById('searchResult');
const searchStatus = document.getElementById('searchStatus');
const searchCount = document.getElementById('searchCount');
@@ -173,6 +199,9 @@ const zoomOutBtn = document.getElementById('zoomOutBtn');
const resetZoomBtn = document.getElementById('resetZoomBtn');
const zoomValue = document.getElementById('zoomValue');
const IS_ADMIN = {{ is_admin|yesno:"true,false" }};
const HAS_MANAGE_KEY = {{ has_manage_key|yesno:"true,false" }};
// 全局变量
let currentId = '';
let currentWriter = '';
@@ -182,6 +211,7 @@ let currentSearchQuery = ''; // 记录当前搜索查询
let isFuzzySearch = false; // 记录当前是否为模糊搜索
let isDeleting = false; // 标记是否正在删除
let currentKeyFilter = '';
let currentTypeFilter = '';
// 图片缩放相关变量
let currentScale = 1;
@@ -205,6 +235,10 @@ async function performSearch(type) {
currentKeyFilter = '';
if (keyFilterSelect) keyFilterSelect.value = '';
}
if (currentTypeFilter) {
currentTypeFilter = '';
if (typeFilterSelect) typeFilterSelect.value = '';
}
currentSearchQuery = query;
isFuzzySearch = type === 'fuzzy';
@@ -270,11 +304,11 @@ async function loadAllData() {
showSearchLoading();
try {
if (currentKeyFilter) {
const response = await fetch(`/elastic/filter-by-key/?key=${encodeURIComponent(currentKeyFilter)}`);
if (currentKeyFilter || currentTypeFilter) {
const response = await fetch(`/elastic/filter/?key=${encodeURIComponent(currentKeyFilter)}&type=${encodeURIComponent(currentTypeFilter)}`);
const data = await response.json();
if (data.status === 'success') {
displayAllData(data.data || [], currentKeyFilter);
displayAllData(data.data || [], currentKeyFilter, currentTypeFilter);
} else {
showSearchMessage(`加载数据失败: ${data.message || '未知错误'}`, 'error');
}
@@ -306,7 +340,10 @@ async function loadAllData() {
function displayAllData(data, key) {
searchResultDiv.style.display = 'block';
searchResultDiv.className = 'search-result';
searchStatus.textContent = key ? `按Key筛查${key}` : '显示全部数据';
const labels = [];
if (key) labels.push(`Key${key}`);
if (currentTypeFilter) labels.push(`类型:${currentTypeFilter}`);
searchStatus.textContent = labels.length ? `筛查:${labels.join('')}` : '显示全部数据';
searchCount.textContent = `${data.length} 条记录`;
renderTable(data);
@@ -318,7 +355,7 @@ function clearSearch() {
searchResultDiv.style.display = 'none';
currentSearchQuery = '';
if (currentKeyFilter) {
if (currentKeyFilter || currentTypeFilter) {
loadAllData();
return;
}
@@ -359,13 +396,124 @@ function clearKeyFilter() {
loadAllData();
}
async function initTypeFilter() {
if (!typeFilterSelect) return;
typeFilterSelect.innerHTML = '<option value="">全部类型</option>';
try {
const resp = await fetch('/elastic/types-for-filter/', { credentials: 'same-origin' });
const data = await resp.json();
if (data.status !== 'success') return;
const types = data.data || [];
types.forEach(t => {
const opt = document.createElement('option');
opt.value = String(t || '');
opt.textContent = String(t || '');
typeFilterSelect.appendChild(opt);
});
} catch (e) {
}
typeFilterSelect.addEventListener('change', () => {
currentTypeFilter = (typeFilterSelect.value || '').trim();
loadAllData();
});
}
function clearTypeFilter() {
currentTypeFilter = '';
if (typeFilterSelect) typeFilterSelect.value = '';
loadAllData();
}
function buildReportParams() {
const params = new URLSearchParams();
if (currentKeyFilter) params.set('key', currentKeyFilter);
if (currentTypeFilter) params.set('type', currentTypeFilter);
const iv = (reportIntervalSelect && reportIntervalSelect.value) ? reportIntervalSelect.value : 'day';
params.set('interval', iv);
const fromVal = reportFromInput ? (reportFromInput.value || '').trim() : '';
const toVal = reportToInput ? (reportToInput.value || '').trim() : '';
if (fromVal) params.set('from', fromVal);
if (toVal) params.set('to', toVal);
return params;
}
async function generateReport() {
if (!reportBox) return;
reportBox.style.display = 'block';
reportBox.className = 'search-result';
reportBox.innerHTML = '<div>正在生成报表...</div>';
try {
const params = buildReportParams();
const resp = await fetch(`/elastic/report/?${params.toString()}`, { credentials: 'same-origin' });
const ct = (resp.headers.get('content-type') || '').toLowerCase();
if (!ct.includes('application/json')) {
const text = await resp.text();
throw new Error(text ? String(text).slice(0, 200) : `HTTP ${resp.status}`);
}
const data = await resp.json();
if (data.status !== 'success') {
reportBox.className = 'search-result error';
reportBox.innerHTML = `<div>生成失败:${data.message || '未知错误'}</div>`;
return;
}
const r = data.data || {};
const total = r.total || 0;
const byType = r.by_type || [];
const byTime = r.by_time || [];
const rng = r.range || {};
const flt = r.filters || {};
const lines = [];
const filterParts = [];
if (flt.key) filterParts.push(`Key${flt.key}`);
if (flt.type) filterParts.push(`类型:${flt.type}`);
if (flt.interval) filterParts.push(`粒度:${flt.interval}`);
lines.push(`<div style="font-weight: 600;">总数:${total}</div>`);
if (rng.from || rng.to) {
lines.push(`<div class="muted" style="margin-top: 4px;">时间范围:${(rng.from || '')} ~ ${(rng.to || '')}</div>`);
}
if (filterParts.length) {
lines.push(`<div class="muted" style="margin-top: 4px;">筛查:${filterParts.join('')}</div>`);
}
const typeRows = byType.map(it => `<tr><td style="padding:4px 8px;">${it.type || ''}</td><td style="padding:4px 8px; text-align:right;">${it.count || 0}</td></tr>`).join('');
const timeRows = byTime.map(it => `<tr><td style="padding:4px 8px;">${it.bucket || ''}</td><td style="padding:4px 8px; text-align:right;">${it.count || 0}</td></tr>`).join('');
lines.push(`<div style="display:flex; gap:12px; flex-wrap: wrap; margin-top: 10px;">
<div style="min-width: 260px; flex: 1;">
<div style="font-weight: 600; margin-bottom: 6px;">按成果类型</div>
<table style="width:100%; border-collapse: collapse;">
<thead><tr><th style="text-align:left; padding:4px 8px; border-bottom:1px solid #eee;">类型</th><th style="text-align:right; padding:4px 8px; border-bottom:1px solid #eee;">数量</th></tr></thead>
<tbody>${typeRows || '<tr><td colspan="2" class="muted" style="padding:6px 8px;">暂无</td></tr>'}</tbody>
</table>
</div>
<div style="min-width: 260px; flex: 1;">
<div style="font-weight: 600; margin-bottom: 6px;">按时间</div>
<table style="width:100%; border-collapse: collapse;">
<thead><tr><th style="text-align:left; padding:4px 8px; border-bottom:1px solid #eee;">时间</th><th style="text-align:right; padding:4px 8px; border-bottom:1px solid #eee;">数量</th></tr></thead>
<tbody>${timeRows || '<tr><td colspan="2" class="muted" style="padding:6px 8px;">暂无</td></tr>'}</tbody>
</table>
</div>
</div>`);
reportBox.innerHTML = lines.join('');
} catch (e) {
reportBox.className = 'search-result error';
reportBox.innerHTML = `<div>生成失败:${e.message || '未知错误'}</div>`;
}
}
function downloadReportCsv() {
const params = buildReportParams();
window.location.href = `/elastic/report/csv/?${params.toString()}`;
}
// 渲染表格
function renderTable(data) {
tableBody.innerHTML = '';
if (!data || data.length === 0) {
const row = document.createElement('tr');
row.innerHTML = '<td colspan="4" style="text-align: center; color: #999;">暂无数据</td>';
row.innerHTML = '<td colspan="5" style="text-align: center; color: #999;">暂无数据</td>';
tableBody.appendChild(row);
return;
}
@@ -422,6 +570,7 @@ function renderTable(data) {
<td>
${displayData}
</td>
<td style="font-size: 12px; white-space: nowrap;">${formatDateTime(item.time)}</td>
<td style="font-size: 12px;">${item.writer_name || item.writer_id || ''}</td>
<td>
<button class="btn btn-primary" onclick="openEdit('${item._id || item.id}')">编辑</button>
@@ -432,6 +581,18 @@ function renderTable(data) {
});
}
function formatDateTime(t) {
if (!t) return '';
try {
const d = new Date(t);
if (String(d) === 'Invalid Date') return String(t);
const pad = n => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
} catch (e) {
return String(t);
}
}
function buildImageCell(item) {
const urls = Array.isArray(item.image_urls) ? item.image_urls : (item.image_url ? [item.image_url] : []);
if (!urls.length) return '无图片';
@@ -667,6 +828,18 @@ async function doDelete(id){
// 页面加载时自动加载所有数据
document.addEventListener('DOMContentLoaded', function() {
initKeyFilter();
initTypeFilter();
if (reportFromInput && reportToInput && reportIntervalSelect) {
const now = new Date();
const pad = n => String(n).padStart(2, '0');
const toLocal = d => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
if (!reportToInput.value) reportToInput.value = toLocal(now);
if (!reportFromInput.value) {
const from = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
reportFromInput.value = toLocal(from);
}
if (!reportIntervalSelect.value) reportIntervalSelect.value = 'day';
}
loadAllData();
});

View File

@@ -494,6 +494,11 @@ uploadForm.addEventListener('submit', async (e) => {
body: formData,
});
clearInterval(timer);
const ct = (resp.headers.get('content-type') || '').toLowerCase();
if (!ct.includes('application/json')) {
const text = await resp.text();
throw new Error(text ? String(text).slice(0, 200) : `HTTP ${resp.status}`);
}
const data = await resp.json();
if (!resp.ok || data.status !== 'success') {
throw new Error(data.message || '上传识别失败');

View File

@@ -19,6 +19,10 @@ urlpatterns = [
path('all-data/', views.get_all_data, name='get_all_data'),
path('filter-by-key/', views.filter_by_key, name='filter_by_key'),
path('keys-for-filter/', views.keys_for_filter_view, name='keys_for_filter'),
path('types-for-filter/', views.types_for_filter_view, name='types_for_filter'),
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('users/', views.get_users, name='get_users'),

View File

@@ -6,10 +6,13 @@ import re
import uuid
import base64
import json
import csv
import io
from datetime import datetime, timezone, timedelta
import tempfile
import concurrent.futures
from django.conf import settings
from django.http import JsonResponse
from django.http import JsonResponse, HttpResponse
from django.shortcuts import render
from django.views.decorators.http import require_http_methods
from django.views.decorators.csrf import ensure_csrf_cookie
@@ -719,115 +722,117 @@ def upload_page(request):
# 上传并识别(不入库)
@require_http_methods(["POST"])
def upload(request):
if request.session.get("user_id") is None:
fallback_uid = request.POST.get("user_id") or request.GET.get("user_id")
if fallback_uid:
request.session["user_id"] = fallback_uid
request.session.setdefault("permission", 1)
else:
return JsonResponse({"status": "error", "message": "未登录"}, status=401)
try:
if request.session.get("user_id") is None:
fallback_uid = request.POST.get("user_id") or request.GET.get("user_id")
if fallback_uid:
request.session["user_id"] = fallback_uid
request.session.setdefault("permission", 1)
else:
return JsonResponse({"status": "error", "message": "未登录"}, status=401)
files = request.FILES.getlist("file")
if not files:
one = request.FILES.get("file")
if one:
files = [one]
if not files:
return JsonResponse({"status": "error", "message": "未选择文件"}, status=400)
files = request.FILES.getlist("file")
if not files:
one = request.FILES.get("file")
if one:
files = [one]
if not files:
return JsonResponse({"status": "error", "message": "未选择文件"}, status=400)
images_dir = os.path.join(settings.MEDIA_ROOT, "images")
os.makedirs(images_dir, exist_ok=True)
# 按照原始文件进行分组处理
file_results = []
for f in files:
group_images = [] # 存储该文件生成的所有图片路径信息 (abs_path, filename)
is_pdf = f.name.lower().endswith('.pdf')
images_dir = os.path.join(settings.MEDIA_ROOT, "images")
os.makedirs(images_dir, exist_ok=True)
if is_pdf:
if not HAS_PDF_SUPPORT:
return JsonResponse({"status": "error", "message": f"服务器未安装PDF处理组件(PyMuPDF): {PDF_ERROR}"}, status=500)
file_results = []
for f in files:
group_images = []
is_pdf = f.name.lower().endswith('.pdf')
with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as tmp:
for chunk in f.chunks():
tmp.write(chunk)
tmp_path = tmp.name
try:
doc = fitz.open(tmp_path)
for i in range(len(doc)):
page = doc.load_page(i)
pix = page.get_pixmap(dpi=150)
img_filename = f"{uuid.uuid4()}_page_{i+1}.jpg"
img_abs_path = os.path.join(images_dir, img_filename)
pix.save(img_abs_path)
group_images.append((img_abs_path, img_filename))
doc.close()
except Exception as e:
return JsonResponse({"status": "error", "message": f"PDF {f.name} 转换失败: {str(e)}"}, status=500)
finally:
if os.path.exists(tmp_path):
os.remove(tmp_path)
else:
filename = f"{uuid.uuid4()}_{f.name}"
abs_path = os.path.join(images_dir, filename)
with open(abs_path, "wb") as dst:
for chunk in f.chunks():
dst.write(chunk)
group_images.append((abs_path, filename))
if is_pdf:
if not HAS_PDF_SUPPORT:
return JsonResponse({"status": "error", "message": f"服务器未安装PDF处理组件(PyMuPDF): {PDF_ERROR}"}, status=500)
with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as tmp:
for chunk in f.chunks():
tmp.write(chunk)
tmp_path = tmp.name
try:
doc = fitz.open(tmp_path)
for i in range(len(doc)):
page = doc.load_page(i)
pix = page.get_pixmap(dpi=150)
img_filename = f"{uuid.uuid4()}_page_{i+1}.jpg"
img_abs_path = os.path.join(images_dir, img_filename)
pix.save(img_abs_path)
group_images.append((img_abs_path, img_filename))
doc.close()
except Exception as e:
return JsonResponse({"status": "error", "message": f"PDF {f.name} 转换失败: {str(e)}"}, status=500)
finally:
if os.path.exists(tmp_path):
os.remove(tmp_path)
else:
filename = f"{uuid.uuid4()}_{f.name}"
abs_path = os.path.join(images_dir, filename)
with open(abs_path, "wb") as dst:
for chunk in f.chunks():
dst.write(chunk)
group_images.append((abs_path, filename))
# 对该组图片并行进行 OCR 识别
def run_ocr(img_info):
abs_p, fname = img_info
try:
data = ocr_and_extract_info(abs_p)
return data
except Exception:
return None
def run_ocr(img_info):
abs_p, fname = img_info
try:
data = ocr_and_extract_info(abs_p)
return data
except Exception:
return None
group_data_list = []
with concurrent.futures.ThreadPoolExecutor(max_workers=min(len(group_images), 8)) as executor:
futures = [executor.submit(run_ocr, img_info) for img_info in group_images]
for future in concurrent.futures.as_completed(futures):
res = future.result()
if res:
group_data_list.append(res)
group_data_list = []
with concurrent.futures.ThreadPoolExecutor(max_workers=min(len(group_images), 8)) as executor:
futures = [executor.submit(run_ocr, img_info) for img_info in group_images]
for future in concurrent.futures.as_completed(futures):
res = future.result()
if res:
group_data_list.append(res)
# 合并该文件的多页识别结果
merged_group_data = {}
for item in group_data_list:
if not isinstance(item, dict): continue
for k, v in item.items():
key = str(k).strip()
if not key: continue
if key not in merged_group_data or merged_group_data.get(key) in (None, ''):
merged_group_data[key] = v
elif merged_group_data.get(key) != v:
base = key
idx = 2
while f"{base}_{idx}" in merged_group_data: idx += 1
merged_group_data[f"{base}_{idx}"] = v
merged_group_data = {}
for item in group_data_list:
if not isinstance(item, dict):
continue
for k, v in item.items():
key = str(k).strip()
if not key:
continue
if key not in merged_group_data or merged_group_data.get(key) in (None, ''):
merged_group_data[key] = v
elif merged_group_data.get(key) != v:
base = key
idx = 2
while f"{base}_{idx}" in merged_group_data:
idx += 1
merged_group_data[f"{base}_{idx}"] = v
if not merged_group_data:
# 如果没识别到,至少保留一个空结构或者包含文件名的提示
merged_group_data = {"文件名": f.name, "提示": "未识别到具体内容"}
if not merged_group_data:
merged_group_data = {"文件名": f.name, "提示": "未识别到具体内容"}
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]
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]
file_results.append({
"name": f.name,
"data": merged_group_data,
"images": rel_paths,
"image_urls": image_urls,
file_results.append({
"name": f.name,
"data": merged_group_data,
"images": rel_paths,
"image_urls": image_urls,
})
return JsonResponse({
"status": "success",
"message": f"成功处理 {len(file_results)} 个文件,请确认数据后点击录入",
"items": file_results,
})
return JsonResponse({
"status": "success",
"message": f"成功处理 {len(file_results)} 个文件,请确认数据后点击录入",
"items": file_results,
})
except Exception as e:
return JsonResponse({"status": "error", "message": str(e) or "上传失败"}, status=500)
# 确认并入库
@@ -953,6 +958,7 @@ def manage_page(request):
me = get_user_by_id(session_user_id) or {}
is_admin = int(request.session.get("permission", 1)) == 0
has_manage_key = bool(me.get("manage_key") or [])
if is_admin:
raw_results = search_all()
else:
@@ -980,6 +986,7 @@ def manage_page(request):
"elastic/manage.html",
{
"is_admin": is_admin,
"has_manage_key": has_manage_key,
"user_id": session_user_id,
"username": me.get("username"),
},
@@ -1020,7 +1027,7 @@ def analytics_types_view(request):
size_int = int(size) if size is not None else 10
except Exception:
size_int = 10
data = es_analytics_types(gte=gte, lte=lte, size=size_int)
data = es_analytics_types(gte=gte, lte=lte, limit=size_int)
return JsonResponse({"status": "success", "data": data})
except Exception as e:
return JsonResponse({"status": "error", "message": str(e)}, status=500)
@@ -1428,6 +1435,296 @@ def keys_for_filter_view(request):
add(out, seen, v)
return JsonResponse({"status": "success", "data": out})
def _extract_type_from_data(value):
s = value
if s is None:
return ""
if not isinstance(s, str):
try:
s = json.dumps(s, ensure_ascii=False)
except Exception:
s = str(s)
s = str(s)
try:
obj = json.loads(s)
if isinstance(obj, dict):
t = obj.get("数据类型")
if isinstance(t, str) and t.strip():
return t.strip()
except Exception:
pass
try:
m = re.search(r'"数据类型"\s*:\s*"([^"]+)"', s)
if m:
return str(m.group(1)).strip()
except Exception:
pass
return ""
@require_http_methods(["GET"])
def types_for_filter_view(request):
uid = request.session.get("user_id")
if uid is None:
return JsonResponse({"status": "error", "message": "未登录"}, status=401)
try:
types = [str(t).strip() for t in (get_type_list() or []) if str(t).strip()]
except Exception:
types = []
seen = set()
out = []
for t in types:
if t in seen:
continue
seen.add(t)
out.append(t)
return JsonResponse({"status": "success", "data": out})
@require_http_methods(["GET"])
def filter_view(request):
session_user_id = request.session.get("user_id")
if session_user_id is None:
return JsonResponse({"status": "error", "message": "未登录"}, status=401)
key = (request.GET.get("key") or "").strip()
typ = (request.GET.get("type") or "").strip()
results = search_all()
results = _filter_results_for_user(request, results)
filtered = list(results or [])
if key:
selected = str(key).strip()
try:
users = get_all_users() or []
except Exception:
users = []
writer_keys_by_id = {}
for u in users:
try:
u_id = str(u.get("user_id", "")).strip()
except Exception:
u_id = ""
if not u_id:
continue
try:
u_keys = {str(k).strip() for k in (u.get("key") or []) if str(k).strip()}
except Exception:
u_keys = set()
writer_keys_by_id[u_id] = u_keys
tmp = []
for r in filtered:
writer_id = str(r.get("writer_id", "")).strip()
writer_keys = writer_keys_by_id.get(writer_id)
if writer_keys and selected in writer_keys:
tmp.append(r)
continue
if selected and selected in str(r.get("data", "")):
tmp.append(r)
filtered = tmp
if typ:
tsel = str(typ).strip()
filtered = [r for r in filtered if _extract_type_from_data(r.get("data")) == tsel]
data = _attach_writer_names(_attach_image_urls(request, filtered))
return JsonResponse({"status": "success", "data": data})
def _parse_dt(value):
if not value:
return None
if hasattr(value, "isoformat"):
try:
dt = value
if getattr(dt, "tzinfo", None) is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt
except Exception:
pass
s = str(value).strip()
if not s:
return None
if s.endswith("Z"):
s = s[:-1] + "+00:00"
try:
dt = datetime.fromisoformat(s)
if getattr(dt, "tzinfo", None) is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt
except Exception:
return None
def _floor_dt(dt, interval: str):
if not dt:
return None
iv = str(interval or "day").strip().lower()
if iv == "month":
return dt.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
if iv == "week":
base = dt.replace(hour=0, minute=0, second=0, microsecond=0)
return base - timedelta(days=base.weekday())
return dt.replace(hour=0, minute=0, second=0, microsecond=0)
def _dt_label(dt, interval: str):
if not dt:
return ""
iv = str(interval or "day").strip().lower()
if iv == "month":
return dt.strftime("%Y-%m")
return dt.strftime("%Y-%m-%d")
@require_http_methods(["GET"])
def report_view(request):
try:
session_user_id = request.session.get("user_id")
if session_user_id is None:
return JsonResponse({"status": "error", "message": "未登录"}, status=401)
is_admin = int(request.session.get("permission", 1)) == 0
me = get_user_by_id(session_user_id) or {}
has_manage_key = bool(me.get("manage_key") or [])
if (not is_admin) and (not has_manage_key):
return JsonResponse({"status": "error", "message": "无权限"}, status=403)
gte = (request.GET.get("from") or "").strip()
lte = (request.GET.get("to") or "").strip()
interval = (request.GET.get("interval") or "day").strip()
key = (request.GET.get("key") or "").strip()
typ = (request.GET.get("type") or "").strip()
gte_dt = _parse_dt(gte) if gte else None
lte_dt = _parse_dt(lte) if lte else None
results = search_all()
results = _filter_results_for_user(request, results)
filtered = list(results or [])
if key:
selected = str(key).strip()
try:
users = get_all_users() or []
except Exception:
users = []
writer_keys_by_id = {}
for u in users:
try:
u_id = str(u.get("user_id", "")).strip()
except Exception:
u_id = ""
if not u_id:
continue
try:
u_keys = {str(k).strip() for k in (u.get("key") or []) if str(k).strip()}
except Exception:
u_keys = set()
writer_keys_by_id[u_id] = u_keys
tmp = []
for r in filtered:
writer_id = str(r.get("writer_id", "")).strip()
writer_keys = writer_keys_by_id.get(writer_id)
if writer_keys and selected in writer_keys:
tmp.append(r)
continue
if selected and selected in str(r.get("data", "")):
tmp.append(r)
filtered = tmp
if typ:
tsel = str(typ).strip()
filtered = [r for r in filtered if _extract_type_from_data(r.get("data")) == tsel]
ranged = []
for r in filtered:
t = _parse_dt(r.get("time"))
if (gte_dt or lte_dt) and (t is None):
continue
if gte_dt and t and t < gte_dt:
continue
if lte_dt and t and t > lte_dt:
continue
rr = dict(r)
rr["_time_dt"] = t
ranged.append(rr)
by_type = {}
by_bucket = {}
for r in ranged:
tval = _extract_type_from_data(r.get("data"))
if tval:
by_type[tval] = by_type.get(tval, 0) + 1
bucket_dt = _floor_dt(r.get("_time_dt"), interval)
if bucket_dt:
label = _dt_label(bucket_dt, interval)
if label:
by_bucket[label] = by_bucket.get(label, 0) + 1
by_type_arr = [{"type": k, "count": int(v)} for k, v in sorted(by_type.items(), key=lambda x: (-x[1], x[0]))]
by_time_arr = [{"bucket": k, "count": int(v)} for k, v in sorted(by_bucket.items(), key=lambda x: x[0])]
return JsonResponse(
{
"status": "success",
"data": {
"generated_at": datetime.now(timezone.utc).isoformat(),
"range": {"from": gte or "", "to": lte or ""},
"filters": {"key": key or "", "type": typ or "", "interval": interval or "day"},
"total": len(ranged),
"by_type": by_type_arr,
"by_time": by_time_arr,
},
}
)
except Exception as e:
return JsonResponse({"status": "error", "message": str(e) or "生成失败"}, status=500)
@require_http_methods(["GET"])
def report_csv_view(request):
resp = report_view(request)
if getattr(resp, "status_code", 200) != 200:
return resp
try:
payload = json.loads(resp.content.decode("utf-8"))
except Exception:
return JsonResponse({"status": "error", "message": "生成失败"}, status=500)
data = (payload or {}).get("data") or {}
buf = io.StringIO()
w = csv.writer(buf)
w.writerow(["统计报表"])
w.writerow(["生成时间", data.get("generated_at", "")])
rng = data.get("range") or {}
w.writerow(["时间范围", f"{rng.get('from','')} ~ {rng.get('to','')}"])
flt = data.get("filters") or {}
w.writerow(["筛查Key", flt.get("key", "")])
w.writerow(["筛查类型", flt.get("type", "")])
w.writerow(["时间粒度", flt.get("interval", "")])
w.writerow([])
w.writerow(["总数", data.get("total", 0)])
w.writerow([])
w.writerow(["按成果类型统计"])
w.writerow(["类型", "数量"])
for it in list(data.get("by_type") or []):
w.writerow([it.get("type", ""), it.get("count", 0)])
w.writerow([])
w.writerow(["按时间统计"])
w.writerow(["时间", "数量"])
for it in list(data.get("by_time") or []):
w.writerow([it.get("bucket", ""), it.get("count", 0)])
content = buf.getvalue()
out = HttpResponse(content, content_type="text/csv; charset=utf-8")
out["Content-Disposition"] = 'attachment; filename="report.csv"'
return out
@require_http_methods(["POST"])
@csrf_protect
def revoke_registration_code_view(request):