新增仪表盘和统计分析数据API,优化外链过期时间处理,完善日志记录功能,更新前端样式以提升用户体验,确保数据统计准确性和可视化展示。

This commit is contained in:
dockermen 2025-04-11 19:38:57 +08:00
parent aad2182d0c
commit c4ce2fc5a4
5 changed files with 463 additions and 45 deletions

Binary file not shown.

101
main.py
View File

@ -9,12 +9,31 @@ from werkzeug.routing import BaseConverter
from utils.login import login_quark from utils.login import login_quark
from utils.tools import get_cnb_weburl from utils.tools import get_cnb_weburl
from utils.detebase import CloudDriveDatabase from utils.detebase import CloudDriveDatabase
from datetime import datetime, timezone
import logging
from logging.handlers import RotatingFileHandler
app = Flask(__name__) app = Flask(__name__)
#app = Flask(__name__,template_folder='templates') #修改模板目录 #app = Flask(__name__,template_folder='templates') #修改模板目录
app.jinja_env.auto_reload = True app.jinja_env.auto_reload = True
# --- Logging Setup Start ---
if not os.path.exists('logs'):
os.mkdir('logs')
# Use RotatingFileHandler to prevent log file from growing indefinitely
file_handler = RotatingFileHandler('logs/app.log', maxBytes=10240, backupCount=10)
file_handler.setFormatter(logging.Formatter(
'%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]'
))
file_handler.setLevel(logging.INFO) # Set level to INFO to capture info, warning, error
app.logger.addHandler(file_handler)
app.logger.setLevel(logging.INFO)
app.logger.info('Flask App startup')
# --- Logging Setup End ---
# 数据库配置 # 数据库配置
DATABASE = 'database.db' DATABASE = 'database.db'
@ -149,17 +168,33 @@ def qrlink(id):
if link_info: if link_info:
# 检查是否已过期 # 检查是否已过期
from datetime import datetime
expiry_time = link_info.get('expiry_time') expiry_time = link_info.get('expiry_time')
if expiry_time: if expiry_time:
try: try:
expiry_datetime = datetime.strptime(expiry_time, '%Y-%m-%d %H:%M:%S') # 使用 fromisoformat 解析 ISO 8601 UTC 字符串
if datetime.now() > expiry_datetime: # Python < 3.11 doesn't handle Z directly, remove it.
if expiry_time.endswith('Z'):
expiry_time_str = expiry_time[:-1] + '+00:00'
else:
expiry_time_str = expiry_time # Assume it might already be offset-aware or naive
expiry_datetime = datetime.fromisoformat(expiry_time_str)
# Ensure expiry_datetime is offset-aware (UTC)
if expiry_datetime.tzinfo is None:
# If parsing resulted in naive, assume it was UTC as intended
expiry_datetime = expiry_datetime.replace(tzinfo=timezone.utc)
# 获取当前 UTC 时间进行比较
if datetime.now(timezone.utc) > expiry_datetime:
data["message"] = "此外链已过期" data["message"] = "此外链已过期"
return render_template('exlink_error.html', message=data["message"]) return render_template('exlink_error.html', message=data["message"])
except (ValueError, TypeError): except (ValueError, TypeError) as e:
# 如果日期格式有误,忽略过期检查 # 解析失败,记录错误,并可能视为无效链接
pass print(f"Error parsing expiry_time '{expiry_time}': {e}")
data["message"] = "外链信息有误(无效的过期时间)"
return render_template('exlink_error.html', message=data["message"])
# 检查使用次数是否超过限制 # 检查使用次数是否超过限制
used_quota = link_info.get('used_quota', 0) used_quota = link_info.get('used_quota', 0)
@ -456,8 +491,60 @@ def delete_external_link():
return Exlink().delete() return Exlink().delete()
if __name__ == "__main__": # 新增:仪表盘数据 API
@app.route('/admin/dashboard_data', methods=['GET'])
def get_dashboard_data():
db = get_db()
try:
user_drives_count = db.get_total_user_drives_count()
active_links_count = db.get_active_external_links_count()
# 使用外链总数作为"今日访问量"的简化替代
total_links_count = db.get_total_external_links_count()
data = {
"status": True,
"data": {
"user_drives_count": user_drives_count,
"active_links_count": active_links_count,
"total_links_count": total_links_count
}
}
except Exception as e:
print(f"获取仪表盘数据错误: {e}")
data = {"status": False, "message": "获取仪表盘数据失败"}
return jsonify(data)
# 新增:统计分析数据 API
@app.route('/admin/statistics_data', methods=['GET'])
def get_statistics_data():
db = get_db()
try:
drives_by_provider = db.get_user_drives_count_by_provider()
# 这里可以添加更多统计数据,例如外链访问趋势 (需要记录访问日志)
# 暂时只返回网盘分布数据
data = {
"status": True,
"data": {
"drives_by_provider": drives_by_provider,
# "access_trend": [] # 示例:将来可以添加访问趋势数据
}
}
except Exception as e:
print(f"获取统计数据错误: {e}")
data = {"status": False, "message": "获取统计数据失败"}
return jsonify(data)
# -----------------------------
# 应用程序运行入口
# -----------------------------
if __name__ == '__main__':
# 初始化数据库 (如果需要)
# init_db()
# 启动Flask应用
weburl = get_cnb_weburl(5000) weburl = get_cnb_weburl(5000)
print("Run_url:",weburl) print("Run_url:",weburl)
app.config.from_pyfile("config.py") app.config.from_pyfile("config.py")

View File

@ -33,6 +33,89 @@
background-color: #0d6efd; background-color: #0d6efd;
color: white; color: white;
} }
/* Digital Cockpit Styles */
body {
/* Optional: Set a base background for the whole page */
/* background-color: #1a1a2e; */
/* color: #e0e0e0; */
}
.main-content {
/* background-color: #1f1f38; /* Slightly lighter background for content */
/* border-radius: 10px; */
/* margin-top: 10px; */
}
#dashboard {
/* background: linear-gradient(to bottom right, #2a2a4a, #1f1f38); */
padding: 30px;
border-radius: 8px;
/* box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); */
}
#dashboard h2, #statistics h2 {
color: #343a40; /* Keep headings readable */
border-bottom: 2px solid #0d6efd;
padding-bottom: 10px;
margin-bottom: 30px !important; /* Override default margin */
}
.kpi-card {
background-color: #ffffff; /* White card on potentially dark background */
border: none;
border-radius: 10px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
margin-bottom: 20px; /* Add spacing between cards */
color: #333;
overflow: hidden; /* Ensure consistency */
position: relative; /* For potential pseudo-elements */
}
.kpi-card:hover {
transform: translateY(-5px);
box-shadow: 0 5px 15px rgba(0,0,0,0.15);
}
.kpi-card .card-body {
padding: 25px;
text-align: center;
}
.kpi-card .card-title {
font-size: 1rem;
font-weight: 500;
color: #6c757d; /* Muted color for title */
margin-bottom: 15px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.kpi-card .kpi-value {
font-size: 2.8rem;
font-weight: 700;
color: #0d6efd; /* Highlight color for value */
line-height: 1.2;
}
.kpi-card .kpi-icon {
font-size: 1.5rem;
margin-bottom: 10px;
color: #0d6efd;
opacity: 0.7;
display: block; /* Center icon */
}
/* Style for charts container */
#statistics .card {
background-color: #ffffff;
border: none;
border-radius: 10px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
color: #333;
margin-bottom: 20px;
}
#statistics .card-body {
padding: 25px;
}
#statistics .card-title {
font-size: 1.1rem;
font-weight: 600;
color: #343a40;
margin-bottom: 20px;
text-align: center;
}
</style> </style>
</head> </head>
<body> <body>
@ -74,26 +157,29 @@
<h2 class="mb-4">系统概览</h2> <h2 class="mb-4">系统概览</h2>
<div class="row"> <div class="row">
<div class="col-md-4"> <div class="col-md-4">
<div class="card"> <div class="card kpi-card">
<div class="card-body"> <div class="card-body">
<i class="bi bi-cloud-fill kpi-icon"></i>
<h5 class="card-title">网盘账号总数</h5> <h5 class="card-title">网盘账号总数</h5>
<p class="card-text display-4">0</p> <p class="kpi-value">0</p>
</div> </div>
</div> </div>
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<div class="card"> <div class="card kpi-card">
<div class="card-body"> <div class="card-body">
<i class="bi bi-link-45deg kpi-icon"></i>
<h5 class="card-title">活跃外链数</h5> <h5 class="card-title">活跃外链数</h5>
<p class="card-text display-4">0</p> <p class="kpi-value">0</p>
</div> </div>
</div> </div>
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<div class="card"> <div class="card kpi-card">
<div class="card-body"> <div class="card-body">
<h5 class="card-title">今日访问量</h5> <i class="bi bi-bar-chart-line-fill kpi-icon"></i>
<p class="card-text display-4">0</p> <h5 class="card-title">外链总数</h5>
<p class="kpi-value">0</p>
</div> </div>
</div> </div>
</div> </div>
@ -434,33 +520,141 @@
// showMessage('错误信息', 'error'); // showMessage('错误信息', 'error');
// showMessage('提示信息', 'info'); // showMessage('提示信息', 'info');
// 初始化图表 // 初始化图表变量
const accessCtx = document.getElementById('accessChart').getContext('2d'); let accessChartInstance = null;
new Chart(accessCtx, { let storageChartInstance = null;
type: 'line',
data: {
labels: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'],
datasets: [{
label: '访问量',
data: [0, 0, 0, 0, 0, 0, 0],
borderColor: 'rgb(75, 192, 192)',
tension: 0.1
}]
}
});
const storageCtx = document.getElementById('storageChart').getContext('2d'); // 初始化图表函数
new Chart(storageCtx, { function initCharts() {
type: 'doughnut', const accessCtx = document.getElementById('accessChart').getContext('2d');
data: { accessChartInstance = new Chart(accessCtx, {
labels: ['阿里云盘', '百度网盘'], type: 'line',
datasets: [{ data: {
data: [0, 0], labels: [], // 初始为空,稍后填充
backgroundColor: ['#ff6384', '#36a2eb'] datasets: [{
}] label: '访问量', // 暂时保留,实际数据需记录日志
} data: [], // 初始为空
}); borderColor: 'rgb(75, 192, 192)',
tension: 0.1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
grid: { display: false },
ticks: { color: '#6c757d' } // X轴标签颜色
},
y: {
grid: { color: 'rgba(108, 117, 125, 0.2)' }, // Y轴网格线颜色
ticks: { color: '#6c757d' } // Y轴标签颜色
}
},
plugins: {
legend: {
labels: { color: '#343a40' } // 图例文字颜色
}
}
}
});
const storageCtx = document.getElementById('storageChart').getContext('2d');
storageChartInstance = new Chart(storageCtx, {
type: 'doughnut',
data: {
labels: [], // 初始为空
datasets: [{
data: [], // 初始为空
backgroundColor: [ // 提供更多颜色以备不时之需
'#ff6384',
'#36a2eb',
'#ffce56',
'#4bc0c0',
'#9966ff',
'#ff9f40'
]
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top',
labels: { color: '#343a40' } // 图例文字颜色
}
}
}
});
}
// 更新仪表盘数据函数
function updateDashboard() {
$.ajax({
url: '/admin/dashboard_data',
type: 'GET',
success: function(response) {
if (response.status && response.data) {
// 更新美化后的KPI卡片值
$('#dashboard .kpi-value').eq(0).text(response.data.user_drives_count || 0);
$('#dashboard .kpi-value').eq(1).text(response.data.active_links_count || 0);
$('#dashboard .kpi-value').eq(2).text(response.data.total_links_count || 0);
} else {
showMessage('获取仪表盘数据失败', 'error');
}
},
error: function(error) {
console.error('获取仪表盘数据出错:', error);
showMessage('获取仪表盘数据时发生网络错误', 'error');
}
});
}
// 更新统计图表函数
function updateStatisticsCharts() {
$.ajax({
url: '/admin/statistics_data',
type: 'GET',
success: function(response) {
if (response.status && response.data) {
// 更新网盘使用分布图 (Doughnut Chart)
if (response.data.drives_by_provider && storageChartInstance) {
const providerData = response.data.drives_by_provider;
const labels = Object.keys(providerData);
const data = Object.values(providerData);
storageChartInstance.data.labels = labels;
storageChartInstance.data.datasets[0].data = data;
storageChartInstance.update();
}
// 更新外链访问趋势图 (Line Chart) - 需要后端实现日志记录和数据提供
// 示例:假设 response.data.access_trend = [{date: '2023-01-01', count: 10}, ...]
// if (response.data.access_trend && accessChartInstance) {
// const trendData = response.data.access_trend;
// accessChartInstance.data.labels = trendData.map(item => item.date);
// accessChartInstance.data.datasets[0].data = trendData.map(item => item.count);
// accessChartInstance.update();
// } else {
// 如果没有访问数据,显示空状态或提示
if (accessChartInstance) {
accessChartInstance.data.labels = ['无数据'];
accessChartInstance.data.datasets[0].data = [0];
accessChartInstance.update();
}
// }
} else {
showMessage('获取统计数据失败', 'error');
}
},
error: function(error) {
console.error('获取统计数据出错:', error);
showMessage('获取统计数据时发生网络错误', 'error');
}
});
}
// json编辑器处理 // json编辑器处理
function initAceEditor(editorElement) { function initAceEditor(editorElement) {
const editor = ace.edit(editorElement); const editor = ace.edit(editorElement);
@ -536,6 +730,14 @@
bindAddAccountEvent(); // 初始化添加账号事件 bindAddAccountEvent(); // 初始化添加账号事件
bindAddExlinkEvents(); // 初始化添加外链事件 bindAddExlinkEvents(); // 初始化添加外链事件
loadExternalLinks(); // 加载外链列表 loadExternalLinks(); // 加载外链列表
setupModalFocusManagement(); // <-- 添加此行初始化模态框焦点管理
initCharts(); // <-- 新增初始化图表
updateDashboard(); // <-- 新增加载仪表盘数据
updateStatisticsCharts(); // <-- 新增加载统计图表数据
// 重新加载账户列表以获取初始数据(如果需要)
refreshAccountsList();
}); });
// 设置默认JSON模板 (Add Modal specific) // 设置默认JSON模板 (Add Modal specific)
@ -887,8 +1089,22 @@
row.append(`<td>${remainingCount} / ${totalQuota}</td>`); row.append(`<td>${remainingCount} / ${totalQuota}</td>`);
// 状态 // 状态
let isExpired = false;
if (link.expiry_time) {
try {
const expiryDate = new Date(link.expiry_time); // Directly parse ISO string
if (!isNaN(expiryDate.getTime()) && new Date() > expiryDate) {
isExpired = true;
}
} catch (e) {
console.error("Error parsing expiry_time in admin list:", link.expiry_time, e);
// Optionally treat parse error as expired or show different status
}
}
let statusBadge = ''; let statusBadge = '';
if (remainingCount <= 0) { // 如果剩余次数用完 或 已过期,则禁用
if (remainingCount <= 0 || isExpired) {
statusBadge = '<span class="badge bg-danger">已禁用</span>'; statusBadge = '<span class="badge bg-danger">已禁用</span>';
} else { } else {
statusBadge = '<span class="badge bg-success">正常</span>'; statusBadge = '<span class="badge bg-success">正常</span>';
@ -1021,9 +1237,14 @@
return; return;
} }
// 转换日期时间格式从YYYY-MM-DDThh:mm到YYYY-MM-DD hh:mm:ss // 转换日期时间格式从YYYY-MM-DDThh:mm到完整的ISO 8601 UTC字符串
const expiryDate = new Date(expiry_time); const expiryDate = new Date(expiry_time); // 解析本地时间输入
const formattedExpiryTime = expiryDate.toISOString().replace('T', ' ').substring(0, 19); // 检查日期是否有效
if (isNaN(expiryDate.getTime())) {
showMessage('无效的日期时间格式', 'error');
return;
}
const expiryTimeISO = expiryDate.toISOString(); // 转换为ISO 8601 UTC字符串 (e.g., "2025-04-12T11:08:00.000Z")
// 发送创建外链请求 // 发送创建外链请求
$.ajax({ $.ajax({
@ -1033,7 +1254,7 @@
data: JSON.stringify({ data: JSON.stringify({
drive_id: drive_id, drive_id: drive_id,
total_quota: total_quota, total_quota: total_quota,
expiry_time: formattedExpiryTime, expiry_time: expiryTimeISO, // <-- 使用完整的 ISO UTC 字符串
remarks: remarks remarks: remarks
}), }),
success: function(response) { success: function(response) {
@ -1091,6 +1312,43 @@
}); });
}); });
} }
// Focus management for modals to fix aria-hidden warning
function setupModalFocusManagement() {
const modals = document.querySelectorAll('.modal');
let triggerElement = null;
modals.forEach(modal => {
// Store the trigger element when the modal is about to show
modal.addEventListener('show.bs.modal', function (event) {
// event.relatedTarget is the element that triggered the modal
triggerElement = event.relatedTarget;
});
// Return focus when the modal is hidden
modal.addEventListener('hidden.bs.modal', function () {
// Small delay to ensure the modal is fully hidden and elements are interactable
setTimeout(() => {
if (triggerElement && typeof triggerElement.focus === 'function') {
// Check if trigger element still exists and is focusable
if (document.body.contains(triggerElement)) {
triggerElement.focus();
} else {
// Fallback if trigger element is gone (e.g., deleted from DOM)
// Focusing body might not be ideal UX, consider a more specific fallback if needed
document.body.focus();
console.log("Modal trigger element no longer exists, focusing body.");
}
} else {
// Fallback if no trigger element recorded or it's not focusable
document.body.focus();
console.log("No modal trigger element recorded or it's not focusable, focusing body.");
}
triggerElement = null; // Reset for the next modal
}, 0);
});
});
}
</script> </script>
</body> </body>
</html> </html>

View File

@ -212,7 +212,7 @@
let remainingCount = parseInt("{{ remaining_count }}") || 0; let remainingCount = parseInt("{{ remaining_count }}") || 0;
const expiryTimeString = "{{ expiry_time }}"; const expiryTimeString = "{{ expiry_time }}";
const expiryTime = expiryTimeString ? new Date(expiryTimeString.replace(/-/g, '/')) : null; const expiryTime = expiryTimeString ? new Date(expiryTimeString) : null;
let countdownInterval = null; let countdownInterval = null;

View File

@ -320,6 +320,79 @@ class CloudDriveDatabase:
except Exception: except Exception:
return False return False
# 新增统计方法
def get_total_user_drives_count(self) -> int:
"""获取用户网盘总数"""
try:
self.cursor.execute("SELECT COUNT(*) FROM user_drives")
result = self.cursor.fetchone()
return result[0] if result else 0
except Exception as e:
print(f"获取用户网盘总数错误: {e}")
return 0
def get_active_external_links_count(self) -> int:
"""获取活跃外链数量 (未过期且有剩余次数)"""
try:
from datetime import datetime, timezone
now_utc_iso = datetime.now(timezone.utc).isoformat()
# 注意SQLite 不直接支持 ISO 8601 比较,此查询可能需要调整或在 Python 中过滤
# 简单起见,我们先只检查次数和时间是否存在
# 更精确的查询可能需要 DATETIME 函数,或在 Python 中处理
self.cursor.execute(
"""
SELECT COUNT(*) FROM external_links
WHERE (used_quota < total_quota)
AND (expiry_time IS NOT NULL AND expiry_time > ?)
""",
(now_utc_iso,) # 这个比较可能不适用于所有 SQLite 版本/配置,后续可能需要调整
)
# 备选(更兼容但效率低):获取所有链接在 Python 中过滤
# self.cursor.execute("SELECT link_uuid, expiry_time, used_quota, total_quota FROM external_links")
# links = self.cursor.fetchall()
# count = 0
# for link in links:
# is_active = False
# if link['used_quota'] < link['total_quota']:
# if link['expiry_time']:
# try:
# expiry_dt = datetime.fromisoformat(link['expiry_time'].replace('Z', '+00:00'))
# if datetime.now(timezone.utc) < expiry_dt:
# is_active = True
# except: pass # Ignore parsing errors
# else: # No expiry time means active if quota is available
# is_active = True
# if is_active:
# count += 1
# return count
result = self.cursor.fetchone()
return result[0] if result else 0
except Exception as e:
print(f"获取活跃外链数量错误: {e}")
return 0 # 返回 0 或其他错误指示
def get_total_external_links_count(self) -> int:
"""获取外链总数"""
try:
self.cursor.execute("SELECT COUNT(*) FROM external_links")
result = self.cursor.fetchone()
return result[0] if result else 0
except Exception as e:
print(f"获取外链总数错误: {e}")
return 0
def get_user_drives_count_by_provider(self) -> Dict[str, int]:
"""按提供商统计用户网盘数量"""
try:
self.cursor.execute("SELECT provider_name, COUNT(*) as count FROM user_drives GROUP BY provider_name")
results = self.cursor.fetchall()
return {row['provider_name']: row['count'] for row in results}
except Exception as e:
print(f"按提供商统计用户网盘数量错误: {e}")
return {}
def close(self): def close(self):
"""关闭数据库连接""" """关闭数据库连接"""
if self.conn: if self.conn: