398 lines
16 KiB
HTML
398 lines
16 KiB
HTML
<!DOCTYPE html>
|
||
{% load static %}
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<title>数据管理系统</title>
|
||
<script src="{% static 'vendor/echarts.min.js' %}"></script>
|
||
<style>
|
||
body {margin: 0;font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;background: #fafafa;}
|
||
/* 导航栏样式 */
|
||
.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;}
|
||
.user-id {text-align: center;margin-bottom: 0px;}
|
||
.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,
|
||
.sidebar button {display: block;color: #8be9fd;text-decoration: none;margin: 10px 0;font-size: 16px;padding: 15px;border-radius: 4px;background: transparent;
|
||
border: none;cursor: pointer; width: calc(100% - 40px);text-align: left;transition: all 0.2s ease;}
|
||
.sidebar a:hover,
|
||
.sidebar button:hover {color: #ff79c6;background-color: rgba(139, 233, 253, 0.2);}
|
||
/* 主内容区 */
|
||
.main-content {margin-left: 200px;padding: 20px;color: #333;}
|
||
.card {background: #fff;border-radius: 14px;box-shadow: 0 10px 24px rgba(31,35,40,0.08);padding: 20px;}
|
||
.grid {display: grid;grid-template-columns: repeat(2, 1fr);gap: 16px;}
|
||
.grid-3 {display: grid;grid-template-columns: repeat(3, 1fr);gap: 16px; }
|
||
.header {display: flex;align-items: center; justify-content: space-between; margin-bottom: 12px; }
|
||
.badge { background: #eef2ff; color: #3730a3; border-radius: 999px; padding: 4px 10px; font-size: 12px; }
|
||
.legend {display: flex;gap: 12px;align-items: center;}
|
||
.legend .dot { width: 8px;height: 8px;border-radius: 50%;display: inline-block; }
|
||
.muted {color: #6b7280;font-size: 12px;}
|
||
.btn {padding: 8px 12px;border: none; border-radius: 8px;cursor: pointer; }
|
||
.btn-primary {background: #4f46e5;color: #fff;}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<!-- 左侧固定栏目 -->
|
||
<div class="sidebar">
|
||
<div class="user-id">
|
||
<h3>你好,{{ username|default:"访客" }}</h3>
|
||
</div>
|
||
<div class="navigation-links">
|
||
<a href="{% url 'main:home' %}" onclick="return handleNavClick(this, '/');">主页</a>
|
||
<a href="{% url 'elastic:upload_page' %}" onclick="return handleNavClick(this, '/elastic/upload/');">图片上传与识别</a>
|
||
{% if is_admin or has_manage_key %}
|
||
<a href="{% url 'elastic:manage_page' %}" onclick="return handleNavClick(this, '/elastic/manage/');">数据管理</a>
|
||
{% endif %}
|
||
{% if is_admin or has_manage_key %}
|
||
<a href="{% url 'elastic:user_manage' %}" onclick="return handleNavClick(this, '/elastic/user_manage/');">用户管理</a>
|
||
{% endif %}
|
||
<a href="/accounts/profile/">个人中心</a>
|
||
{% if is_admin or has_manage_key or can_manage_registration_codes %}
|
||
<a href="{% url 'elastic:registration_code_manage_page' %}" onclick="return handleNavClick(this, '/elastic/registration-codes/manage/');">注册码管理</a>
|
||
{% endif %}
|
||
{% if is_admin %}
|
||
<a href="{% url 'accounts:registration_code_requests_page' %}">注册码申请管理</a>
|
||
{% endif %}
|
||
{% if not is_admin and not has_manage_key and not can_manage_registration_codes and not has_registration_code %}
|
||
<a id="applyRegBtn" href="javascript:void(0)">申请注册码管理</a>
|
||
{% endif %}
|
||
<a id="logoutBtn">退出登录</a>
|
||
<div id="logoutMsg"></div>
|
||
{% csrf_token %}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 主内容区域 -->
|
||
<div class="main-content">
|
||
<div class="card">
|
||
<div class="header">
|
||
<h2>师生共创系统</h2>
|
||
<span class="badge">用户:{{ user_id }}</span>
|
||
</div>
|
||
<div class="muted">数据可视化概览:录入量变化、类型占比、类型变化、最近活动</div>
|
||
</div>
|
||
<div class="grid" style="margin-top:16px;">
|
||
<div class="card">
|
||
<div class="header"><h3>录入量变化(近90天)</h3></div>
|
||
<div id="chartTrend" style="width:100%;height:320px;"></div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="header">
|
||
<h3>类型占比(近30天)</h3>
|
||
<button id="toggleTypesChartBtn" class="btn btn-primary" style="font-size: 12px; padding: 4px 8px;">切换图表</button>
|
||
</div>
|
||
<div id="chartTypes" style="width:100%;height:320px;"></div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="header"><h3>类型变化(近180天,按周)</h3></div>
|
||
<div id="chartTypesTrend" style="width:100%;height:320px;"></div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="header"><h3>最近活动(近7天)</h3></div>
|
||
<ul id="recentList" style="list-style:none;padding:0;margin:0;"></ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="applyRegModal" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.45); z-index:3000; align-items:center; justify-content:center;">
|
||
<div class="card" style="width:min(560px, calc(100vw - 40px));">
|
||
<div class="header">
|
||
<h3 style="margin:0;">申请注册码管理权限</h3>
|
||
<button id="applyRegClose" class="btn" type="button" style="background:#e5e7eb;">关闭</button>
|
||
</div>
|
||
<div class="muted" style="margin-bottom:10px;">填写申请理由,管理员同意后可进入“注册码管理”页面。</div>
|
||
<div style="margin-top:10px;">
|
||
<label for="applyReason" style="display:block; margin-bottom:6px; font-weight:600;">申请理由</label>
|
||
<textarea id="applyReason" rows="5" style="width:100%; padding:10px 12px; border:1px solid #d1d5db; border-radius:10px; box-sizing:border-box; resize: vertical;"></textarea>
|
||
</div>
|
||
<div id="applyRegMsg" class="muted" style="margin-top:10px;"></div>
|
||
<div style="display:flex; gap:10px; justify-content:flex-end; margin-top:14px;">
|
||
<button id="applyRegSubmit" class="btn btn-primary" type="button">提交申请</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
// 获取CSRF令牌的函数
|
||
function getCookie(name) {
|
||
const value = `; ${document.cookie}`;
|
||
const parts = value.split(`; ${name}=`);
|
||
if (parts.length === 2) return parts.pop().split(';').shift();
|
||
}
|
||
|
||
// 导航点击处理函数,提供备用URL
|
||
function handleNavClick(element, fallbackUrl) {
|
||
// 尝试使用Django模板生成的URL,如果失败则使用备用URL
|
||
try {
|
||
// 如果模板渲染正常,直接返回true让默认行为处理
|
||
return true;
|
||
} catch (e) {
|
||
// 如果模板渲染有问题,使用备用URL
|
||
window.location.href = fallbackUrl;
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// 修复用户管理链接跳转问题
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
// 为用户管理链接添加事件监听器,确保正确跳转
|
||
const userManagementLink = document.querySelector('a[href*="get_users"]');
|
||
if (userManagementLink) {
|
||
userManagementLink.addEventListener('click', function(e) {
|
||
// 阻止默认行为
|
||
e.preventDefault();
|
||
|
||
// 获取备用URL
|
||
const fallbackUrl = this.getAttribute('onclick').match(/'([^']+)'/g)[1].replace(/'/g, '');
|
||
|
||
// 直接跳转到用户管理页面
|
||
window.location.href = fallbackUrl;
|
||
});
|
||
}
|
||
});
|
||
|
||
// 登出功能
|
||
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 (!resp.ok || !data.ok) {
|
||
throw new Error('登出失败');
|
||
}
|
||
document.cookie = 'sessionid=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/';
|
||
document.cookie = 'csrftoken=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/';
|
||
window.location.href = data.redirect_url;
|
||
} catch (e) {
|
||
msg.textContent = e.message || '发生错误';
|
||
}
|
||
});
|
||
|
||
const applyRegBtn = document.getElementById('applyRegBtn');
|
||
const applyRegModal = document.getElementById('applyRegModal');
|
||
const applyRegClose = document.getElementById('applyRegClose');
|
||
const applyRegSubmit = document.getElementById('applyRegSubmit');
|
||
const applyRegMsg = document.getElementById('applyRegMsg');
|
||
const applyReason = document.getElementById('applyReason');
|
||
|
||
function openApplyRegModal() {
|
||
if (!applyRegModal) return;
|
||
applyRegMsg.textContent = '';
|
||
applyReason.value = '';
|
||
applyRegModal.style.display = 'flex';
|
||
}
|
||
function closeApplyRegModal() {
|
||
if (!applyRegModal) return;
|
||
applyRegModal.style.display = 'none';
|
||
}
|
||
if (applyRegBtn) applyRegBtn.addEventListener('click', openApplyRegModal);
|
||
if (applyRegClose) applyRegClose.addEventListener('click', closeApplyRegModal);
|
||
if (applyRegModal) {
|
||
applyRegModal.addEventListener('click', (e) => {
|
||
if (e.target === applyRegModal) closeApplyRegModal();
|
||
});
|
||
}
|
||
if (applyRegSubmit) {
|
||
applyRegSubmit.addEventListener('click', async () => {
|
||
const reason = (applyReason.value || '').trim();
|
||
if (!reason) {
|
||
applyRegMsg.textContent = '请填写申请理由';
|
||
return;
|
||
}
|
||
applyRegMsg.textContent = '提交中...';
|
||
const csrftoken = getCookie('csrftoken');
|
||
try {
|
||
const resp = await fetch('/accounts/registration-code/request/submit/', {
|
||
method: 'POST',
|
||
credentials: 'same-origin',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-CSRFToken': csrftoken || ''
|
||
},
|
||
body: JSON.stringify({ reason })
|
||
});
|
||
const data = await resp.json();
|
||
if (resp.ok && data.ok) {
|
||
applyRegMsg.textContent = '已提交申请,请等待管理员审核';
|
||
if (applyRegBtn) {
|
||
applyRegBtn.textContent = '已提交申请';
|
||
applyRegBtn.disabled = true;
|
||
applyRegBtn.style.opacity = '0.6';
|
||
applyRegBtn.style.cursor = 'not-allowed';
|
||
}
|
||
setTimeout(() => closeApplyRegModal(), 800);
|
||
} else {
|
||
applyRegMsg.textContent = (data && data.message) ? data.message : '提交失败';
|
||
}
|
||
} catch (e) {
|
||
applyRegMsg.textContent = '提交失败';
|
||
}
|
||
});
|
||
}
|
||
|
||
|
||
function fetchJSON(url){ return fetch(url, {credentials:'same-origin'}).then(r=>r.json()); }
|
||
function qs(params){ const u = new URLSearchParams(params); return u.toString(); }
|
||
|
||
const trendChart = echarts.init(document.getElementById('chartTrend'));
|
||
const typesChart = echarts.init(document.getElementById('chartTypes'));
|
||
const typesTrendChart = echarts.init(document.getElementById('chartTypesTrend'));
|
||
|
||
async function loadTrend(){
|
||
const url = '/elastic/analytics/trend/?' + qs({ from:'now-90d', to:'now', interval:'day' });
|
||
const res = await fetchJSON(url);
|
||
if(res.status!=='success') return;
|
||
const buckets = res.data || [];
|
||
const x = buckets.map(b=>b.key_as_string||'');
|
||
const y = buckets.map(b=>b.doc_count||0);
|
||
trendChart.setOption({
|
||
tooltip:{trigger:'axis'},
|
||
xAxis:{type:'category', data:x},
|
||
yAxis:{type:'value'},
|
||
series:[{ type:'line', areaStyle:{}, data:y, smooth:true, color:'#4f46e5' }]
|
||
});
|
||
}
|
||
|
||
let typesChartData = [];
|
||
let currentChartType = 'pie';
|
||
let typesChartInterval = null;
|
||
|
||
async function loadTypes(){
|
||
const url = '/elastic/analytics/types/?' + qs({ from:'now-30d', to:'now', size:10 });
|
||
const res = await fetchJSON(url);
|
||
if(res.status!=='success') return;
|
||
const buckets = res.data || [];
|
||
typesChartData = buckets.map(b=>({ name: String(b.key||'未知'), value: b.doc_count||0 }));
|
||
renderTypesChart();
|
||
startTypesChartRotation();
|
||
}
|
||
|
||
function renderTypesChart() {
|
||
if (currentChartType === 'pie') {
|
||
typesChart.setOption({
|
||
tooltip:{trigger:'item'},
|
||
legend:{type:'scroll', top:'bottom'},
|
||
grid: { top: 0, bottom: 0, left: 0, right: 0 },
|
||
xAxis: { show: false },
|
||
yAxis: { show: false },
|
||
series:[{
|
||
type:'pie',
|
||
radius:['40%','70%'],
|
||
center: ['50%', '50%'],
|
||
data: typesChartData,
|
||
label: { show: false },
|
||
itemStyle: { borderRadius: 10, borderColor: '#fff', borderWidth: 2 }
|
||
}]
|
||
}, true);
|
||
} else {
|
||
const names = typesChartData.map(d => d.name);
|
||
const values = typesChartData.map(d => d.value);
|
||
typesChart.setOption({
|
||
tooltip:{trigger:'axis', axisPointer:{type:'shadow'}},
|
||
legend:{show: false},
|
||
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
|
||
xAxis: { type: 'category', data: names, show: true },
|
||
yAxis: { type: 'value', show: true },
|
||
series: [{
|
||
type: 'bar',
|
||
data: values,
|
||
itemStyle: { color: '#5470c6' },
|
||
barWidth: '60%'
|
||
}]
|
||
}, true);
|
||
}
|
||
}
|
||
|
||
function toggleChartType() {
|
||
currentChartType = currentChartType === 'pie' ? 'bar' : 'pie';
|
||
renderTypesChart();
|
||
}
|
||
|
||
function startTypesChartRotation() {
|
||
if (typesChartInterval) clearInterval(typesChartInterval);
|
||
typesChartInterval = setInterval(() => {
|
||
toggleChartType();
|
||
}, 5000);
|
||
}
|
||
|
||
document.getElementById('toggleTypesChartBtn').addEventListener('click', () => {
|
||
toggleChartType();
|
||
// Reset timer on manual interaction
|
||
startTypesChartRotation();
|
||
});
|
||
|
||
async function loadTypesTrend(){
|
||
const url = '/elastic/analytics/types_trend/?' + qs({ from:'now-180d', to:'now', interval:'week', size:6 });
|
||
const res = await fetchJSON(url);
|
||
if(res.status!=='success') return;
|
||
const rows = res.data || [];
|
||
const x = rows.map(r=>r.key_as_string||'');
|
||
const typeSet = new Set();
|
||
rows.forEach(r=> (r.types||[]).forEach(t=> typeSet.add(String(t.key||'未知'))));
|
||
const types = Array.from(typeSet);
|
||
const series = types.map(tp=>({
|
||
name: tp,
|
||
type:'line',
|
||
smooth:true,
|
||
data: rows.map(r=>{
|
||
const b = (r.types||[]).find(x=>String(x.key||'')===tp);
|
||
return b? b.doc_count||0 : 0;
|
||
})
|
||
}));
|
||
typesTrendChart.setOption({
|
||
tooltip:{trigger:'axis'},
|
||
legend:{type:'scroll'},
|
||
xAxis:{type:'category', data:x},
|
||
yAxis:{type:'value'},
|
||
series
|
||
});
|
||
}
|
||
|
||
function formatTime(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||'';
|
||
}
|
||
|
||
async function loadRecent(){
|
||
const listEl = document.getElementById('recentList');
|
||
const url = '/elastic/analytics/recent/?' + qs({ from:'now-7d', to:'now', limit:10 });
|
||
const res = await fetchJSON(url);
|
||
if(res.status!=='success') return;
|
||
const items = res.data || [];
|
||
listEl.innerHTML = '';
|
||
items.forEach(it=>{
|
||
const li = document.createElement('li');
|
||
const t = formatTime(it.time);
|
||
const u = it.username || '';
|
||
const ty = it.type || '未知';
|
||
const de = it.detail ? `,${it.detail}` : '';
|
||
li.textContent = `${t},${u},${ty}${de}`;
|
||
listEl.appendChild(li);
|
||
});
|
||
}
|
||
|
||
loadTrend();
|
||
loadTypes();
|
||
loadTypesTrend();
|
||
loadRecent();
|
||
</script>
|
||
</body>
|
||
</html>
|