Merge remote-tracking branch 'origin/Django' into Django
This commit is contained in:
@@ -17,9 +17,8 @@ class ElasticConfig(AppConfig):
|
||||
return
|
||||
|
||||
# 延迟导入,避免循环导入或过早加载
|
||||
from .es_connect import create_index_with_mapping, start_daily_analytics_scheduler
|
||||
from .es_connect import create_index_with_mapping
|
||||
try:
|
||||
create_index_with_mapping()
|
||||
start_daily_analytics_scheduler()
|
||||
except Exception as e:
|
||||
print(f"❌ ES 初始化失败: {e}")
|
||||
@@ -9,8 +9,8 @@ from .documents import AchievementDocument, UserDocument, GlobalDocument
|
||||
from .indexes import ACHIEVEMENT_INDEX_NAME, USER_INDEX_NAME, GLOBAL_INDEX_NAME
|
||||
import hashlib
|
||||
import time
|
||||
from datetime import datetime, timezone, timedelta
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
|
||||
# 使用环境变量配置ES连接,默认为本机
|
||||
_ES_URL = os.environ.get('ELASTICSEARCH_URL', 'http://localhost:9200')
|
||||
@@ -309,67 +309,193 @@ def search_by_any_field(keyword):
|
||||
print(f"模糊搜索失败: {str(e)}")
|
||||
return []
|
||||
|
||||
ANALYTICS_CACHE = {"data": None, "ts": 0}
|
||||
|
||||
def _compute_hist(range_gte: str, interval: str, fmt: str):
|
||||
from elasticsearch_dsl import Search
|
||||
s = AchievementDocument.search()
|
||||
s = s.filter('range', time={'gte': range_gte, 'lte': 'now'})
|
||||
s = s.extra(size=0)
|
||||
s.aggs.bucket('b', 'date_histogram', field='time', calendar_interval=interval, format=fmt, min_doc_count=0)
|
||||
resp = s.execute()
|
||||
buckets = getattr(resp.aggs, 'b').buckets
|
||||
return [{"label": b.key_as_string, "count": b.doc_count} for b in buckets]
|
||||
|
||||
def _compute_type_counts(range_gte: str, types: list):
|
||||
counts = []
|
||||
|
||||
def _type_filters_from_list(limit: int = None):
|
||||
try:
|
||||
types = get_type_list()
|
||||
except Exception:
|
||||
types = ['软著', '专利', '奖状']
|
||||
if isinstance(limit, int) and limit > 0:
|
||||
types = types[:limit]
|
||||
filters = {}
|
||||
for t in types:
|
||||
s = AchievementDocument.search()
|
||||
s = s.filter('range', time={'gte': range_gte, 'lte': 'now'})
|
||||
s = s.query('match_phrase', data=str(t))
|
||||
total = s.count()
|
||||
counts.append({"type": str(t), "count": int(total)})
|
||||
return counts
|
||||
key = str(t)
|
||||
# 精确匹配键与值之间的关系,避免其它字段中的同名值造成误匹配
|
||||
pattern = f'*"数据类型": "{key}"*'
|
||||
filters[key] = {"wildcard": {"data.keyword": {"value": pattern}}}
|
||||
return filters
|
||||
|
||||
def compute_analytics():
|
||||
types = get_type_list()
|
||||
days = _compute_hist('now-10d/d', 'day', 'yyyy-MM-dd')
|
||||
weeks = _compute_hist('now-10w/w', 'week', 'yyyy-ww')
|
||||
months = _compute_hist('now-10M/M', 'month', 'yyyy-MM')
|
||||
pie_1m = _compute_type_counts('now-1M/M', types)
|
||||
pie_12m = _compute_type_counts('now-12M/M', types)
|
||||
return {
|
||||
"last_10_days": days[-10:],
|
||||
"last_10_weeks": weeks[-10:],
|
||||
"last_10_months": months[-10:],
|
||||
"type_pie_1m": pie_1m,
|
||||
"type_pie_12m": pie_12m,
|
||||
}
|
||||
def analytics_trend(gte: str = None, lte: str = None, interval: str = "day"):
|
||||
try:
|
||||
search = AchievementDocument.search()
|
||||
body = {
|
||||
"size": 0,
|
||||
"aggs": {
|
||||
"trend": {
|
||||
"date_histogram": {
|
||||
"field": "time",
|
||||
"calendar_interval": interval,
|
||||
"min_doc_count": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if gte or lte:
|
||||
rng = {}
|
||||
if gte:
|
||||
rng["gte"] = gte
|
||||
if lte:
|
||||
rng["lte"] = lte
|
||||
body["query"] = {"range": {"time": rng}}
|
||||
search = search.update_from_dict(body)
|
||||
resp = search.execute()
|
||||
buckets = resp.aggregations.trend.buckets if hasattr(resp, 'aggregations') else []
|
||||
return [{"key_as_string": b.key_as_string, "key": b.key, "doc_count": b.doc_count} for b in buckets]
|
||||
except Exception as e:
|
||||
print(f"分析趋势失败: {str(e)}")
|
||||
return []
|
||||
|
||||
def get_analytics_overview(force: bool = False):
|
||||
now_ts = time.time()
|
||||
if force or ANALYTICS_CACHE["data"] is None or (now_ts - ANALYTICS_CACHE["ts"]) > 3600:
|
||||
ANALYTICS_CACHE["data"] = compute_analytics()
|
||||
ANALYTICS_CACHE["ts"] = now_ts
|
||||
return ANALYTICS_CACHE["data"]
|
||||
def analytics_types(gte: str = None, lte: str = None, size: int = 10):
|
||||
try:
|
||||
filters = _type_filters_from_list(limit=size)
|
||||
body = {
|
||||
"size": 0,
|
||||
"aggs": {
|
||||
"by_type": {
|
||||
"filters": {
|
||||
"filters": filters
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if gte or lte:
|
||||
rng = {}
|
||||
if gte:
|
||||
rng["gte"] = gte
|
||||
if lte:
|
||||
rng["lte"] = lte
|
||||
body["query"] = {"range": {"time": rng}}
|
||||
resp = es.search(index=DATA_INDEX_NAME, body=body)
|
||||
buckets = resp.get("aggregations", {}).get("by_type", {}).get("buckets", {})
|
||||
out = []
|
||||
for k, v in buckets.items():
|
||||
try:
|
||||
out.append({"key": k, "doc_count": int(v.get("doc_count", 0))})
|
||||
except Exception:
|
||||
out.append({"key": str(k), "doc_count": 0})
|
||||
return out
|
||||
except Exception as e:
|
||||
print(f"分析类型占比失败: {str(e)}")
|
||||
return []
|
||||
|
||||
def _seconds_until_hour(h: int):
|
||||
now = datetime.now()
|
||||
tgt = now.replace(hour=h, minute=0, second=0, microsecond=0)
|
||||
if tgt <= now:
|
||||
tgt = tgt + timedelta(days=1)
|
||||
return max(0, int((tgt - now).total_seconds()))
|
||||
def analytics_types_trend(gte: str = None, lte: str = None, interval: str = "week", size: int = 8):
|
||||
try:
|
||||
filters = _type_filters_from_list(limit=size)
|
||||
body = {
|
||||
"size": 0,
|
||||
"aggs": {
|
||||
"by_interval": {
|
||||
"date_histogram": {
|
||||
"field": "time",
|
||||
"calendar_interval": interval,
|
||||
"min_doc_count": 0
|
||||
},
|
||||
"aggs": {
|
||||
"by_type": {
|
||||
"filters": {"filters": filters}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if gte or lte:
|
||||
rng = {}
|
||||
if gte:
|
||||
rng["gte"] = gte
|
||||
if lte:
|
||||
rng["lte"] = lte
|
||||
body["query"] = {"range": {"time": rng}}
|
||||
resp = es.search(index=DATA_INDEX_NAME, body=body)
|
||||
by_interval = resp.get("aggregations", {}).get("by_interval", {}).get("buckets", [])
|
||||
out = []
|
||||
for ib in by_interval:
|
||||
t_buckets = ib.get("by_type", {}).get("buckets", {})
|
||||
types_arr = []
|
||||
for k, v in t_buckets.items():
|
||||
types_arr.append({"key": k, "doc_count": int(v.get("doc_count", 0))})
|
||||
out.append({
|
||||
"key_as_string": ib.get("key_as_string"),
|
||||
"key": ib.get("key"),
|
||||
"doc_count": ib.get("doc_count", 0),
|
||||
"types": types_arr
|
||||
})
|
||||
return out
|
||||
except Exception as e:
|
||||
print(f"分析类型变化失败: {str(e)}")
|
||||
return []
|
||||
|
||||
def start_daily_analytics_scheduler():
|
||||
def _run_and_reschedule():
|
||||
try:
|
||||
get_analytics_overview(force=True)
|
||||
except Exception as e:
|
||||
print(f"分析任务失败: {e}")
|
||||
finally:
|
||||
threading.Timer(24 * 3600, _run_and_reschedule).start()
|
||||
delay = _seconds_until_hour(3)
|
||||
threading.Timer(delay, _run_and_reschedule).start()
|
||||
def analytics_recent(limit: int = 10, gte: str = None, lte: str = None):
|
||||
try:
|
||||
def _extract_type(s: str):
|
||||
if not s:
|
||||
return ""
|
||||
try:
|
||||
obj = json.loads(s)
|
||||
if isinstance(obj, dict):
|
||||
v = obj.get("数据类型")
|
||||
if isinstance(v, str) and v:
|
||||
return v
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
m = re.search(r'"数据类型"\s*:\s*"([^"]+)"', s)
|
||||
if m:
|
||||
return m.group(1)
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
search = AchievementDocument.search()
|
||||
body = {
|
||||
"size": max(1, min(limit, 100)),
|
||||
"sort": [{"time": {"order": "desc"}}]
|
||||
}
|
||||
if gte or lte:
|
||||
rng = {}
|
||||
if gte:
|
||||
rng["gte"] = gte
|
||||
if lte:
|
||||
rng["lte"] = lte
|
||||
body["query"] = {"range": {"time": rng}}
|
||||
search = search.update_from_dict(body)
|
||||
resp = search.execute()
|
||||
results = []
|
||||
for hit in resp:
|
||||
w = getattr(hit, 'writer_id', '')
|
||||
uname = None
|
||||
try:
|
||||
uname_lookup = get_user_by_id(w)
|
||||
uname = (uname_lookup or {}).get("username")
|
||||
except Exception:
|
||||
uname = None
|
||||
if not uname:
|
||||
try:
|
||||
uname_lookup = get_user_by_id(int(w))
|
||||
uname = (uname_lookup or {}).get("username")
|
||||
except Exception:
|
||||
uname = None
|
||||
tval = _extract_type(getattr(hit, 'data', ''))
|
||||
results.append({
|
||||
"_id": hit.meta.id,
|
||||
"writer_id": w,
|
||||
"username": uname or "",
|
||||
"type": tval or "",
|
||||
"time": getattr(hit, 'time', None)
|
||||
})
|
||||
return results
|
||||
except Exception as e:
|
||||
print(f"获取最近活动失败: {str(e)}")
|
||||
return []
|
||||
|
||||
def write_user_data(user_data):
|
||||
"""
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
INDEX_NAME = "wordsearch266666"
|
||||
INDEX_NAME = "wordsearch2666661"
|
||||
USER_NAME = "users11111"
|
||||
ACHIEVEMENT_INDEX_NAME = INDEX_NAME
|
||||
USER_INDEX_NAME = USER_NAME
|
||||
GLOBAL_INDEX_NAME = "global11111"
|
||||
GLOBAL_INDEX_NAME = "global11111111211"
|
||||
|
||||
@@ -29,13 +29,13 @@
|
||||
|
||||
.user-id {
|
||||
text-align: center;
|
||||
margin-bottom: auto;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.sidebar h3 {
|
||||
margin-top: 0;
|
||||
font-size: 18px;
|
||||
color: #ff79c6;
|
||||
color: #add8e6;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
@@ -103,6 +103,7 @@
|
||||
max-width: 120px;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 6px;
|
||||
cursor: pointer; /* 添加指针样式 */
|
||||
}
|
||||
.btn {
|
||||
padding: 6px 10px;
|
||||
@@ -230,6 +231,47 @@
|
||||
margin: 2px 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 图片放大模态框 */
|
||||
.image-modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 2000;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0,0,0,0.9);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.image-modal-content {
|
||||
margin: auto;
|
||||
display: block;
|
||||
width: 80%;
|
||||
max-width: 800px;
|
||||
max-height: 80%;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.image-modal-close {
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
right: 35px;
|
||||
color: #f1f1f1;
|
||||
font-size: 40px;
|
||||
font-weight: bold;
|
||||
transition: 0.3s;
|
||||
cursor: pointer;
|
||||
z-index: 2001;
|
||||
}
|
||||
|
||||
.image-modal-close:hover {
|
||||
color: #bbb;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -306,6 +348,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图片放大模态框 -->
|
||||
<div id="imageModal" class="image-modal">
|
||||
<span class="image-modal-close">×</span>
|
||||
<img class="image-modal-content" id="expandedImage">
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 获取CSRF token的函数
|
||||
function getCookie(name) {
|
||||
@@ -327,6 +375,11 @@ const editMsg = document.getElementById('editMsg');
|
||||
const addFieldBtn = document.getElementById('addFieldBtn');
|
||||
const syncFromTextBtn = document.getElementById('syncFromTextBtn');
|
||||
|
||||
// 图片放大相关元素
|
||||
const imageModal = document.getElementById('imageModal');
|
||||
const expandedImage = document.getElementById('expandedImage');
|
||||
const imageModalClose = document.querySelector('.image-modal-close');
|
||||
|
||||
// 全局变量
|
||||
let currentId = '';
|
||||
let currentWriter = '';
|
||||
@@ -476,7 +529,7 @@ function renderTable(data) {
|
||||
row.innerHTML = `
|
||||
<td style="max-width:140px; word-break:break-all; font-size: 12px;">${item._id || item.id || ''}</td>
|
||||
<td>
|
||||
${item.image ? `<img src="/media/${item.image}" onerror="this.src=''; this.alt='图片加载失败'" />` : '无图片'}
|
||||
${item.image ? `<img src="/media/${item.image}" onerror="this.src=''; this.alt='图片加载失败'" class="clickable-image" data-image="/media/${item.image}" />` : '无图片'}
|
||||
</td>
|
||||
<td>
|
||||
<pre style="white-space:pre-wrap; word-wrap:break-word; max-height: 100px; overflow-y: auto; font-size: 12px; margin: 0;">${escapeHtml(displayData)}</pre>
|
||||
@@ -722,6 +775,30 @@ document.getElementById('logoutBtn').addEventListener('click', async () => {
|
||||
msg.textContent = e.message || '发生错误';
|
||||
}
|
||||
});
|
||||
|
||||
// 图片放大功能
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// 为所有图片添加点击事件监听器
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.classList.contains('clickable-image')) {
|
||||
const imgSrc = e.target.src;
|
||||
expandedImage.src = imgSrc;
|
||||
imageModal.style.display = 'block';
|
||||
}
|
||||
});
|
||||
|
||||
// 点击关闭按钮关闭模态框
|
||||
imageModalClose.onclick = function() {
|
||||
imageModal.style.display = 'none';
|
||||
}
|
||||
|
||||
// 点击模态框外部区域关闭模态框
|
||||
window.onclick = function(event) {
|
||||
if (event.target === imageModal) {
|
||||
imageModal.style.display = 'none';
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -10,7 +10,7 @@
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
/* 导航栏样式 */
|
||||
/* 导航栏样式 - 保持原有样式 */
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
@@ -29,13 +29,13 @@
|
||||
|
||||
.user-id {
|
||||
text-align: center;
|
||||
margin-bottom: auto;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.sidebar h3 {
|
||||
margin-top: 0;
|
||||
font-size: 18px;
|
||||
color: #ff79c6;
|
||||
color: #add8e6;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
@@ -68,42 +68,207 @@
|
||||
background-color: rgba(139, 233, 253, 0.2);
|
||||
}
|
||||
|
||||
/* 主内容区 */
|
||||
/* 主内容区 - 改进后的样式 */
|
||||
.main-content {
|
||||
margin-left: 200px;
|
||||
padding: 20px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* 原有样式保持不变 */
|
||||
.container {
|
||||
max-width: 900px;
|
||||
margin: 6vh auto;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 6px 18px rgba(0,0,0,0.06);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 10px 24px rgba(31,35,40,0.08);
|
||||
padding: 24px;
|
||||
}
|
||||
.row { display: flex; gap: 16px; }
|
||||
.col { flex: 1; }
|
||||
textarea {
|
||||
width: 100%;
|
||||
min-height: 260px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.header h2 {
|
||||
margin: 0;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.header p {
|
||||
margin: 5px 0 0 0;
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
}
|
||||
img { max-width: 100%; border: 1px solid #eee; border-radius: 6px; }
|
||||
.btn {
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
|
||||
.upload-section {
|
||||
background: #f8fafc;
|
||||
border: 2px dashed #cbd5e1;
|
||||
border-radius: 12px;
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
transition: all 0.3s ease;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.upload-section:hover {
|
||||
border-color: #4f46e5;
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.upload-section.drag-over {
|
||||
border-color: #4f46e5;
|
||||
background: #e0e7ff;
|
||||
}
|
||||
|
||||
.upload-section input[type="file"] {
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
margin: 0 4px;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #4f46e5;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #4338ca;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #e2e8f0;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #cbd5e1;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #ef4444;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.preview-container {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.preview-container {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-box {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.preview-box h3 {
|
||||
margin-top: 0;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.preview-box img {
|
||||
max-width: 100%;
|
||||
max-height: 300px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.result-box {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.result-box h3 {
|
||||
margin-top: 0;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.form-controls {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
#kvForm {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
margin-bottom: 12px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr auto;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.form-row input {
|
||||
padding: 8px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
#resultBox {
|
||||
width: 100%;
|
||||
min-height: 200px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 14px;
|
||||
padding: 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
resize: vertical;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.status-message {
|
||||
padding: 10px;
|
||||
margin: 10px 0;
|
||||
border-radius: 6px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.status-message.success {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
|
||||
.status-message.error {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.btn-primary { background: #1677ff; color: #fff; }
|
||||
.btn-secondary { background: #f0f0f0; }
|
||||
.muted { color: #666; font-size: 12px; }
|
||||
.error { color: #d14343; }
|
||||
.success { color: #179957; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -123,38 +288,46 @@
|
||||
<!-- 主内容区域 -->
|
||||
<div class="main-content">
|
||||
<div class="container">
|
||||
<h2>图片上传与识别</h2>
|
||||
<p class="muted">选择图片后上传,服务端调用大模型解析为可编辑的 JSON,再确认入库。</p>
|
||||
|
||||
<form id="uploadForm" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
<input type="file" id="fileInput" name="file" accept="image/*" />
|
||||
<button type="submit" class="btn btn-primary">上传并识别</button>
|
||||
<span id="uploadMsg" class="muted"></span>
|
||||
</form>
|
||||
|
||||
<div class="row" style="margin-top:16px;">
|
||||
<div class="col">
|
||||
<h4>图片预览</h4>
|
||||
<img id="preview" alt="预览" />
|
||||
</div>
|
||||
<div class="col">
|
||||
<h4>识别结果(可编辑)</h4>
|
||||
<div style="display:flex; gap:8px; align-items:center; margin-bottom:8px;">
|
||||
<button id="addFieldBtn" class="btn btn-secondary" type="button">添加字段</button>
|
||||
<button id="syncFromTextBtn" class="btn btn-secondary" type="button">从文本区刷新表单</button>
|
||||
</div>
|
||||
<div id="kvForm" style="border:1px solid #eee; border-radius:6px; padding:8px; max-height:300px; overflow:auto;"></div>
|
||||
<div style="margin-top:8px;">
|
||||
<textarea id="resultBox" placeholder="识别结果JSON将显示在这里"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header">
|
||||
<div>
|
||||
<h2>图片上传与识别</h2>
|
||||
<p>选择图片后上传,服务端调用大模型解析为可编辑的 JSON,再确认入库。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:16px;">
|
||||
<button id="confirmBtn" class="btn btn-primary" disabled>确认并入库</button>
|
||||
<button id="clearBtn" class="btn btn-secondary" type="button">清空</button>
|
||||
<span id="confirmMsg" class="muted"></span>
|
||||
<div class="upload-section" id="dropArea">
|
||||
<h3>上传图片</h3>
|
||||
<p>点击下方按钮选择图片,或拖拽图片到此区域</p>
|
||||
<form id="uploadForm" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
<input type="file" id="fileInput" name="file" accept="image/*" required />
|
||||
<br>
|
||||
<button type="submit" class="btn btn-primary">上传并识别</button>
|
||||
</form>
|
||||
<div class="status-message" id="uploadMsg"></div>
|
||||
</div>
|
||||
|
||||
<div class="preview-container">
|
||||
<div class="preview-box">
|
||||
<h3>图片预览</h3>
|
||||
<img id="preview" alt="预览" />
|
||||
</div>
|
||||
|
||||
<div class="result-box">
|
||||
<h3>识别结果(可编辑)</h3>
|
||||
<div class="form-controls">
|
||||
<button id="addFieldBtn" class="btn btn-secondary" type="button">添加字段</button>
|
||||
<button id="syncFromTextBtn" class="btn btn-secondary" type="button">从文本区刷新表单</button>
|
||||
</div>
|
||||
<div id="kvForm"></div>
|
||||
<textarea id="resultBox" placeholder="识别结果JSON将显示在这里"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-buttons">
|
||||
<button id="confirmBtn" class="btn btn-primary" disabled>确认并入库</button>
|
||||
<button id="clearBtn" class="btn btn-secondary" type="button">清空</button>
|
||||
<span id="confirmMsg" class="muted"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -177,15 +350,63 @@ const confirmMsg = document.getElementById('confirmMsg');
|
||||
const kvForm = document.getElementById('kvForm');
|
||||
const addFieldBtn = document.getElementById('addFieldBtn');
|
||||
const syncFromTextBtn = document.getElementById('syncFromTextBtn');
|
||||
const dropArea = document.getElementById('dropArea');
|
||||
|
||||
let currentImageRel = '';
|
||||
|
||||
// 拖拽上传功能
|
||||
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
||||
dropArea.addEventListener(eventName, preventDefaults, false);
|
||||
});
|
||||
|
||||
function preventDefaults(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
['dragenter', 'dragover'].forEach(eventName => {
|
||||
dropArea.addEventListener(eventName, highlight, false);
|
||||
});
|
||||
|
||||
['dragleave', 'drop'].forEach(eventName => {
|
||||
dropArea.addEventListener(eventName, unhighlight, false);
|
||||
});
|
||||
|
||||
function highlight() {
|
||||
dropArea.classList.add('drag-over');
|
||||
}
|
||||
|
||||
function unhighlight() {
|
||||
dropArea.classList.remove('drag-over');
|
||||
}
|
||||
|
||||
dropArea.addEventListener('drop', handleDrop, false);
|
||||
|
||||
function handleDrop(e) {
|
||||
const dt = e.dataTransfer;
|
||||
const files = dt.files;
|
||||
if (files.length) {
|
||||
fileInput.files = files;
|
||||
const event = new Event('change', { bubbles: true });
|
||||
fileInput.dispatchEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
// 文件选择后预览
|
||||
fileInput.addEventListener('change', function(e) {
|
||||
const file = e.target.files[0];
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
preview.src = e.target.result;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
});
|
||||
|
||||
function createRow(k = '', v = '') {
|
||||
const row = document.createElement('div');
|
||||
row.style.display = 'grid';
|
||||
row.style.gridTemplateColumns = '1fr 1fr auto';
|
||||
row.style.gap = '8px';
|
||||
row.style.marginBottom = '6px';
|
||||
row.className = 'form-row';
|
||||
const keyInput = document.createElement('input');
|
||||
keyInput.type = 'text';
|
||||
keyInput.placeholder = '字段名';
|
||||
@@ -196,9 +417,17 @@ function createRow(k = '', v = '') {
|
||||
valInput.value = typeof v === 'object' ? JSON.stringify(v) : (v ?? '');
|
||||
const delBtn = document.createElement('button');
|
||||
delBtn.type = 'button';
|
||||
delBtn.className = 'btn btn-secondary';
|
||||
delBtn.className = 'btn btn-danger';
|
||||
delBtn.textContent = '删除';
|
||||
delBtn.onclick = () => { kvForm.removeChild(row); syncTextarea(); };
|
||||
delBtn.onclick = () => {
|
||||
if (kvForm.children.length > 1) {
|
||||
kvForm.removeChild(row);
|
||||
} else {
|
||||
keyInput.value = '';
|
||||
valInput.value = '';
|
||||
}
|
||||
syncTextarea();
|
||||
};
|
||||
keyInput.oninput = syncTextarea;
|
||||
valInput.oninput = syncTextarea;
|
||||
row.appendChild(keyInput);
|
||||
@@ -246,9 +475,16 @@ syncFromTextBtn.addEventListener('click', () => {
|
||||
try {
|
||||
const obj = JSON.parse(resultBox.value || '{}');
|
||||
renderFormFromObject(obj);
|
||||
uploadMsg.textContent = '已从文本区刷新表单';
|
||||
uploadMsg.className = 'status-message success';
|
||||
uploadMsg.style.display = 'block';
|
||||
setTimeout(() => {
|
||||
uploadMsg.style.display = 'none';
|
||||
}, 2000);
|
||||
} catch (e) {
|
||||
uploadMsg.textContent = '文本区不是有效JSON';
|
||||
uploadMsg.className = 'error';
|
||||
uploadMsg.className = 'status-message error';
|
||||
uploadMsg.style.display = 'block';
|
||||
}
|
||||
});
|
||||
|
||||
@@ -263,7 +499,8 @@ uploadForm.addEventListener('submit', async (e) => {
|
||||
const file = fileInput.files[0];
|
||||
if (!file) {
|
||||
uploadMsg.textContent = '请选择图片文件';
|
||||
uploadMsg.className = 'error';
|
||||
uploadMsg.className = 'status-message error';
|
||||
uploadMsg.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -282,14 +519,16 @@ uploadForm.addEventListener('submit', async (e) => {
|
||||
throw new Error(data.message || '上传识别失败');
|
||||
}
|
||||
uploadMsg.textContent = data.message || '识别成功';
|
||||
uploadMsg.className = 'success';
|
||||
uploadMsg.className = 'status-message success';
|
||||
uploadMsg.style.display = 'block';
|
||||
preview.src = data.image_url;
|
||||
renderFormFromObject(data.data || {});
|
||||
currentImageRel = data.image;
|
||||
confirmBtn.disabled = false;
|
||||
} catch (e) {
|
||||
uploadMsg.textContent = e.message || '发生错误';
|
||||
uploadMsg.className = 'error';
|
||||
uploadMsg.className = 'status-message error';
|
||||
uploadMsg.style.display = 'block';
|
||||
}
|
||||
});
|
||||
|
||||
@@ -311,10 +550,10 @@ confirmBtn.addEventListener('click', async () => {
|
||||
throw new Error(data.message || '录入失败');
|
||||
}
|
||||
confirmMsg.textContent = data.message || '录入成功';
|
||||
confirmMsg.className = 'success';
|
||||
confirmMsg.style.color = '#179957';
|
||||
} catch (e) {
|
||||
confirmMsg.textContent = e.message || '发生错误';
|
||||
confirmMsg.className = 'error';
|
||||
confirmMsg.style.color = '#d14343';
|
||||
}
|
||||
});
|
||||
|
||||
@@ -323,6 +562,7 @@ clearBtn.addEventListener('click', () => {
|
||||
preview.src = '';
|
||||
resultBox.value = '';
|
||||
kvForm.innerHTML = '';
|
||||
kvForm.appendChild(createRow()); // 保留一个空行
|
||||
uploadMsg.textContent = '';
|
||||
confirmMsg.textContent = '';
|
||||
confirmBtn.disabled = true;
|
||||
|
||||
@@ -29,13 +29,13 @@
|
||||
|
||||
.user-id {
|
||||
text-align: center;
|
||||
margin-bottom: auto;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.sidebar h3 {
|
||||
margin-top: 0;
|
||||
font-size: 18px;
|
||||
color: #ff79c6;
|
||||
color: #add8e6;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ urlpatterns = [
|
||||
path('search/', views.search, name='search'),
|
||||
path('fuzzy-search/', views.fuzzy_search, name='fuzzy_search'),
|
||||
path('all-data/', views.get_all_data, name='get_all_data'),
|
||||
path('analytics/overview/', views.analytics_overview, name='analytics_overview'),
|
||||
|
||||
# 用户管理
|
||||
path('users/', views.get_users, name='get_users'),
|
||||
@@ -33,4 +32,10 @@ urlpatterns = [
|
||||
# 管理页面
|
||||
path('manage/', views.manage_page, name='manage_page'),
|
||||
path('user_manage/', views.user_manage, name='user_manage'),
|
||||
|
||||
# 分析接口
|
||||
path('analytics/trend/', views.analytics_trend_view, name='analytics_trend'),
|
||||
path('analytics/types/', views.analytics_types_view, name='analytics_types'),
|
||||
path('analytics/types_trend/', views.analytics_types_trend_view, name='analytics_types_trend'),
|
||||
path('analytics/recent/', views.analytics_recent_view, name='analytics_recent'),
|
||||
]
|
||||
|
||||
@@ -14,6 +14,12 @@ from django.views.decorators.csrf import ensure_csrf_cookie
|
||||
from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie, csrf_protect
|
||||
from .es_connect import *
|
||||
from .es_connect import update_user_by_id as es_update_user_by_id, delete_user_by_id as es_delete_user_by_id
|
||||
from .es_connect import (
|
||||
analytics_trend as es_analytics_trend,
|
||||
analytics_types as es_analytics_types,
|
||||
analytics_types_trend as es_analytics_types_trend,
|
||||
analytics_recent as es_analytics_recent,
|
||||
)
|
||||
from PIL import Image
|
||||
|
||||
|
||||
@@ -80,16 +86,6 @@ def get_all_data(request):
|
||||
except Exception as e:
|
||||
return JsonResponse({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
@require_http_methods(["GET"])
|
||||
def analytics_overview(request):
|
||||
try:
|
||||
force = request.GET.get("force") == "1"
|
||||
data = get_analytics_overview(force=force)
|
||||
return JsonResponse({"status": "success", "data": data})
|
||||
except Exception as e:
|
||||
return JsonResponse({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
|
||||
@require_http_methods(["DELETE"])
|
||||
@csrf_exempt
|
||||
def delete_data(request, doc_id):
|
||||
@@ -159,7 +155,11 @@ def update_data(request, doc_id):
|
||||
if isinstance(v, dict):
|
||||
updated["data"] = json.dumps(v, ensure_ascii=False)
|
||||
else:
|
||||
updated["data"] = str(v)
|
||||
try:
|
||||
obj = json.loads(str(v))
|
||||
updated["data"] = json.dumps(obj, ensure_ascii=False)
|
||||
except Exception:
|
||||
updated["data"] = str(v)
|
||||
|
||||
success = update_by_id(doc_id, updated)
|
||||
if success:
|
||||
@@ -530,6 +530,67 @@ def manage_page(request):
|
||||
context = {"items": results, "user_id": user_id_qs or session_user_id}
|
||||
return render(request, "elastic/manage.html", context)
|
||||
|
||||
|
||||
@require_http_methods(["GET"])
|
||||
def analytics_trend_view(request):
|
||||
try:
|
||||
gte = request.GET.get("from")
|
||||
lte = request.GET.get("to")
|
||||
interval = request.GET.get("interval", "day")
|
||||
data = es_analytics_trend(gte=gte, lte=lte, interval=interval)
|
||||
return JsonResponse({"status": "success", "data": data})
|
||||
except Exception as e:
|
||||
return JsonResponse({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
|
||||
@require_http_methods(["GET"])
|
||||
def analytics_types_view(request):
|
||||
try:
|
||||
gte = request.GET.get("from")
|
||||
lte = request.GET.get("to")
|
||||
size = request.GET.get("size")
|
||||
try:
|
||||
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)
|
||||
return JsonResponse({"status": "success", "data": data})
|
||||
except Exception as e:
|
||||
return JsonResponse({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
|
||||
@require_http_methods(["GET"])
|
||||
def analytics_types_trend_view(request):
|
||||
try:
|
||||
gte = request.GET.get("from")
|
||||
lte = request.GET.get("to")
|
||||
interval = request.GET.get("interval", "week")
|
||||
size = request.GET.get("size")
|
||||
try:
|
||||
size_int = int(size) if size is not None else 8
|
||||
except Exception:
|
||||
size_int = 8
|
||||
data = es_analytics_types_trend(gte=gte, lte=lte, interval=interval, size=size_int)
|
||||
return JsonResponse({"status": "success", "data": data})
|
||||
except Exception as e:
|
||||
return JsonResponse({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
|
||||
@require_http_methods(["GET"])
|
||||
def analytics_recent_view(request):
|
||||
try:
|
||||
limit = request.GET.get("limit")
|
||||
gte = request.GET.get("from")
|
||||
lte = request.GET.get("to")
|
||||
try:
|
||||
limit_int = int(limit) if limit is not None else 10
|
||||
except Exception:
|
||||
limit_int = 10
|
||||
data = es_analytics_recent(limit=limit_int, gte=gte, lte=lte)
|
||||
return JsonResponse({"status": "success", "data": data})
|
||||
except Exception as e:
|
||||
return JsonResponse({"status": "error", "message": str(e)}, status=500)
|
||||
|
||||
@require_http_methods(["GET"])
|
||||
@ensure_csrf_cookie
|
||||
def user_manage(request):
|
||||
|
||||
Reference in New Issue
Block a user