From c4ce2fc5a4e4f45762ec8dffe39c76075054ddf5 Mon Sep 17 00:00:00 2001 From: dockermen Date: Fri, 11 Apr 2025 19:38:57 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E4=BB=AA=E8=A1=A8=E7=9B=98?= =?UTF-8?q?=E5=92=8C=E7=BB=9F=E8=AE=A1=E5=88=86=E6=9E=90=E6=95=B0=E6=8D=AE?= =?UTF-8?q?API=EF=BC=8C=E4=BC=98=E5=8C=96=E5=A4=96=E9=93=BE=E8=BF=87?= =?UTF-8?q?=E6=9C=9F=E6=97=B6=E9=97=B4=E5=A4=84=E7=90=86=EF=BC=8C=E5=AE=8C?= =?UTF-8?q?=E5=96=84=E6=97=A5=E5=BF=97=E8=AE=B0=E5=BD=95=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=EF=BC=8C=E6=9B=B4=E6=96=B0=E5=89=8D=E7=AB=AF=E6=A0=B7=E5=BC=8F?= =?UTF-8?q?=E4=BB=A5=E6=8F=90=E5=8D=87=E7=94=A8=E6=88=B7=E4=BD=93=E9=AA=8C?= =?UTF-8?q?=EF=BC=8C=E7=A1=AE=E4=BF=9D=E6=95=B0=E6=8D=AE=E7=BB=9F=E8=AE=A1?= =?UTF-8?q?=E5=87=86=E7=A1=AE=E6=80=A7=E5=92=8C=E5=8F=AF=E8=A7=86=E5=8C=96?= =?UTF-8?q?=E5=B1=95=E7=A4=BA=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- database.db | Bin 28672 -> 28672 bytes main.py | 101 ++++++++++- templates/admin.html | 332 ++++++++++++++++++++++++++++++++----- templates/exlink_view.html | 2 +- utils/detebase.py | 73 ++++++++ 5 files changed, 463 insertions(+), 45 deletions(-) diff --git a/database.db b/database.db index 0ec678d257f2a996bfac09882e725a0f5dc702bd..89761403e5fc292d7d9438a9b98aaeac64ee460e 100644 GIT binary patch delta 444 zcma*jKT88K7{~ETPiPgAv>-a@PzTW^k|+P}PLLuMmCkk%kv6$2xah1)>)>wb8*p$I z%B>2%3yV|x9{K{jRK&HA?fLQHO9oRkn4;$`)EaI#QES|}Zy=g~H}jPZo*kd?ev{lxtnT88H*!lm`gKmA(ELk_Se}cAXq^09~1<^ z`S9_id5k?+X=7OmrPW4gWtd8U7h!SAl2ls;U7IOzva3w=A6EBO-pl9LRkU6vvw(h4 sN!Q4XUePD|Mpa&`zmn1W?QVZRY@=m*sUI%$LBtQ23C4an>fF8j06fKMQUCw| delta 128 zcmZp8z}WDBae_3X^F$eEM(2$QmG+E0n{U~x3y3o?FtG4_Vc_4(U&U|E_k~}WuLlTO zHVX>8;bq~wz~C~m(E&<3Gqz7`OxgS;PJmH_g+GRY|2lsRe=GkweiI1XEU2)AZ}Ofz X)rkQtj6o9{0~yauY*d*XlfMK2gjpv3 diff --git a/main.py b/main.py index 3b25299..144e466 100644 --- a/main.py +++ b/main.py @@ -9,12 +9,31 @@ from werkzeug.routing import BaseConverter from utils.login import login_quark from utils.tools import get_cnb_weburl from utils.detebase import CloudDriveDatabase +from datetime import datetime, timezone +import logging +from logging.handlers import RotatingFileHandler app = Flask(__name__) #app = Flask(__name__,template_folder='templates') #修改模板目录 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' @@ -149,17 +168,33 @@ def qrlink(id): if link_info: # 检查是否已过期 - from datetime import datetime expiry_time = link_info.get('expiry_time') if expiry_time: try: - expiry_datetime = datetime.strptime(expiry_time, '%Y-%m-%d %H:%M:%S') - if datetime.now() > expiry_datetime: + # 使用 fromisoformat 解析 ISO 8601 UTC 字符串 + # 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"] = "此外链已过期" return render_template('exlink_error.html', message=data["message"]) - except (ValueError, TypeError): - # 如果日期格式有误,忽略过期检查 - pass + except (ValueError, TypeError) as e: + # 解析失败,记录错误,并可能视为无效链接 + 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) @@ -456,8 +491,60 @@ def delete_external_link(): 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) print("Run_url:",weburl) app.config.from_pyfile("config.py") diff --git a/templates/admin.html b/templates/admin.html index 1b28297..d4848ce 100644 --- a/templates/admin.html +++ b/templates/admin.html @@ -33,6 +33,89 @@ background-color: #0d6efd; 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; + } @@ -74,26 +157,29 @@

系统概览

-
+
+
网盘账号总数
-

0

+

0

-
+
+
活跃外链数
-

0

+

0

-
+
-
今日访问量
-

0

+ +
外链总数
+

0

@@ -434,33 +520,141 @@ // showMessage('错误信息', 'error'); // showMessage('提示信息', 'info'); - // 初始化图表 - const accessCtx = document.getElementById('accessChart').getContext('2d'); - new Chart(accessCtx, { - type: 'line', - data: { - labels: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'], - datasets: [{ - label: '访问量', - data: [0, 0, 0, 0, 0, 0, 0], - borderColor: 'rgb(75, 192, 192)', - tension: 0.1 - }] - } - }); + // 初始化图表变量 + let accessChartInstance = null; + let storageChartInstance = null; - const storageCtx = document.getElementById('storageChart').getContext('2d'); - new Chart(storageCtx, { - type: 'doughnut', - data: { - labels: ['阿里云盘', '百度网盘'], - datasets: [{ - data: [0, 0], - backgroundColor: ['#ff6384', '#36a2eb'] - }] - } - }); + // 初始化图表函数 + function initCharts() { + const accessCtx = document.getElementById('accessChart').getContext('2d'); + accessChartInstance = new Chart(accessCtx, { + type: 'line', + data: { + labels: [], // 初始为空,稍后填充 + 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编辑器处理 function initAceEditor(editorElement) { const editor = ace.edit(editorElement); @@ -536,6 +730,14 @@ bindAddAccountEvent(); // 初始化添加账号事件 bindAddExlinkEvents(); // 初始化添加外链事件 loadExternalLinks(); // 加载外链列表 + setupModalFocusManagement(); // <-- 添加此行:初始化模态框焦点管理 + + initCharts(); // <-- 新增:初始化图表 + updateDashboard(); // <-- 新增:加载仪表盘数据 + updateStatisticsCharts(); // <-- 新增:加载统计图表数据 + + // 重新加载账户列表以获取初始数据(如果需要) + refreshAccountsList(); }); // 设置默认JSON模板 (Add Modal specific) @@ -887,8 +1089,22 @@ row.append(`${remainingCount} / ${totalQuota}`); // 状态 + 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 = ''; - if (remainingCount <= 0) { + // 如果剩余次数用完 或 已过期,则禁用 + if (remainingCount <= 0 || isExpired) { statusBadge = '已禁用'; } else { statusBadge = '正常'; @@ -1021,9 +1237,14 @@ return; } - // 转换日期时间格式:从YYYY-MM-DDThh:mm到YYYY-MM-DD hh:mm:ss - const expiryDate = new Date(expiry_time); - const formattedExpiryTime = expiryDate.toISOString().replace('T', ' ').substring(0, 19); + // 转换日期时间格式:从YYYY-MM-DDThh:mm到完整的ISO 8601 UTC字符串 + const expiryDate = new Date(expiry_time); // 解析本地时间输入 + // 检查日期是否有效 + if (isNaN(expiryDate.getTime())) { + showMessage('无效的日期时间格式', 'error'); + return; + } + const expiryTimeISO = expiryDate.toISOString(); // 转换为ISO 8601 UTC字符串 (e.g., "2025-04-12T11:08:00.000Z") // 发送创建外链请求 $.ajax({ @@ -1033,7 +1254,7 @@ data: JSON.stringify({ drive_id: drive_id, total_quota: total_quota, - expiry_time: formattedExpiryTime, + expiry_time: expiryTimeISO, // <-- 使用完整的 ISO UTC 字符串 remarks: remarks }), 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); + }); + }); + } \ No newline at end of file diff --git a/templates/exlink_view.html b/templates/exlink_view.html index eea7188..76976d3 100644 --- a/templates/exlink_view.html +++ b/templates/exlink_view.html @@ -212,7 +212,7 @@ let remainingCount = parseInt("{{ remaining_count }}") || 0; const expiryTimeString = "{{ expiry_time }}"; - const expiryTime = expiryTimeString ? new Date(expiryTimeString.replace(/-/g, '/')) : null; + const expiryTime = expiryTimeString ? new Date(expiryTimeString) : null; let countdownInterval = null; diff --git a/utils/detebase.py b/utils/detebase.py index c475454..1e5599d 100644 --- a/utils/detebase.py +++ b/utils/detebase.py @@ -320,6 +320,79 @@ class CloudDriveDatabase: except Exception: 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): """关闭数据库连接""" if self.conn: