diff --git a/cache.py b/cache.py
index 50c6b08..1f4429a 100644
--- a/cache.py
+++ b/cache.py
@@ -7,6 +7,8 @@ Redis缓存模块
import json
import logging
import os
+import uuid
+from datetime import datetime
from typing import Any, Optional
import redis.asyncio as redis
@@ -19,6 +21,9 @@ REDIS_URL = 'redis://:redis_XMiXNa@127.0.0.1:6379/0'
TTL_ACCOUNTS = 300 # 账户列表 5分钟
TTL_MESSAGES = 180 # 邮件列表 3分钟
TTL_PAYMENT = 600 # 支付状态 10分钟
+TTL_NOTIFICATIONS = 604800 # 通知 7天
+
+NOTIFICATIONS_KEY = 'notifications:unread'
class RedisCache:
@@ -101,4 +106,58 @@ class RedisCache:
await self.delete(self.payment_key())
+ # ---- 通知系统 ----
+
+ async def add_notification(self, email: str, ntype: str, message: str):
+ """添加一条通知(LPUSH + EXPIRE)"""
+ if not self._redis:
+ return
+ try:
+ notif = json.dumps({
+ 'id': str(uuid.uuid4()),
+ 'email': email,
+ 'type': ntype,
+ 'message': message,
+ 'created_at': datetime.now().strftime('%Y-%m-%d %H:%M:%S')
+ }, ensure_ascii=False)
+ await self._redis.lpush(NOTIFICATIONS_KEY, notif)
+ await self._redis.expire(NOTIFICATIONS_KEY, TTL_NOTIFICATIONS)
+ except Exception as e:
+ logger.debug(f"添加通知失败: {e}")
+
+ async def get_notifications(self) -> list:
+ """获取所有未读通知"""
+ if not self._redis:
+ return []
+ try:
+ items = await self._redis.lrange(NOTIFICATIONS_KEY, 0, -1)
+ return [json.loads(item) for item in items]
+ except Exception as e:
+ logger.debug(f"获取通知失败: {e}")
+ return []
+
+ async def dismiss_notification(self, notif_id: str):
+ """移除指定 id 的通知"""
+ if not self._redis:
+ return
+ try:
+ items = await self._redis.lrange(NOTIFICATIONS_KEY, 0, -1)
+ for item in items:
+ data = json.loads(item)
+ if data.get('id') == notif_id:
+ await self._redis.lrem(NOTIFICATIONS_KEY, 1, item)
+ break
+ except Exception as e:
+ logger.debug(f"移除通知失败: {e}")
+
+ async def dismiss_all_notifications(self):
+ """清除所有通知"""
+ if not self._redis:
+ return
+ try:
+ await self._redis.delete(NOTIFICATIONS_KEY)
+ except Exception:
+ pass
+
+
cache = RedisCache()
diff --git a/mail_api.py b/mail_api.py
index 4d7980d..4d516ef 100644
--- a/mail_api.py
+++ b/mail_api.py
@@ -1166,9 +1166,20 @@ async def _check_claude_payment_for_account(email_addr: str) -> dict:
else:
status = 'unknown'
+ # 查旧状态,状态变化时生成通知
+ old_info = await db_manager.get_claude_payment_status(email_addr)
+ old_status = old_info.get('status') if old_info else None
+
# 写入数据库
await db_manager.set_claude_payment_status(email_addr, status, payment_time, refund_time, suspended_time)
+ # 仅状态变化时创建通知(避免重复检测重复通知)
+ if status != old_status:
+ if status == 'refunded':
+ await cache.add_notification(email_addr, 'refund', f'{email_addr} 已退款')
+ elif status == 'suspended':
+ await cache.add_notification(email_addr, 'suspension', f'{email_addr} 已封号')
+
# 如果是支付状态且标题和备注为空,提取收据信息
if status == 'paid' and payment_msg:
current_info = await db_manager.get_claude_payment_status(email_addr)
@@ -1308,6 +1319,26 @@ async def get_claude_payment_status() -> ApiResponse:
await cache.set(cache.payment_key(), statuses, TTL_PAYMENT)
return ApiResponse(success=True, data=statuses)
+# ============================================================================
+# 通知系统
+# ============================================================================
+
+@app.get("/api/notifications")
+async def get_notifications():
+ """获取未读通知列表"""
+ notifications = await cache.get_notifications()
+ return {"success": True, "data": notifications}
+
+@app.post("/api/notifications/dismiss")
+async def dismiss_notification(request: Request):
+ """标记通知已读:传 {id} 移除单条,传 {all:true} 清除全部"""
+ body = await request.json()
+ if body.get('all'):
+ await cache.dismiss_all_notifications()
+ elif body.get('id'):
+ await cache.dismiss_notification(body['id'])
+ return {"success": True}
+
# ============================================================================
# 命令行入口
# ============================================================================
diff --git a/static/index.html b/static/index.html
index a10ed62..a2db346 100644
--- a/static/index.html
+++ b/static/index.html
@@ -65,6 +65,19 @@
+
diff --git a/static/script.js b/static/script.js
index 3a08e69..0ea826c 100644
--- a/static/script.js
+++ b/static/script.js
@@ -58,7 +58,9 @@ class MailManager {
init() {
this.bindAccountEvents();
this.bindEmailEvents();
+ this.bindNotificationEvents();
this.loadAccounts();
+ this.loadNotifications();
}
// ====================================================================
@@ -1215,6 +1217,7 @@ class MailManager {
} else if (data.type === 'done') {
btn.innerHTML = '完成';
setTimeout(() => { btn.innerHTML = origHtml; btn.disabled = false; }, 2000);
+ this.loadNotifications();
}
} catch (e) {}
}
@@ -1245,6 +1248,7 @@ class MailManager {
};
this.updateClaudeBadgeInTable(email);
this.showToast(`${email} 检测完成: ${result.data.status}`, 'success');
+ this.loadNotifications();
} else {
this.showToast(result.message || '检测失败', 'danger');
}
@@ -1278,6 +1282,106 @@ class MailManager {
}
}
+ // ====================================================================
+ // 通知系统
+ // ====================================================================
+
+ bindNotificationEvents() {
+ const btn = document.getElementById('notificationBtn');
+ const dropdown = document.getElementById('notifDropdown');
+
+ // 铃铛点击切换下拉面板
+ btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ const visible = dropdown.style.display !== 'none';
+ dropdown.style.display = visible ? 'none' : 'flex';
+ });
+
+ // 点击页面其他位置关闭
+ document.addEventListener('click', (e) => {
+ if (!e.target.closest('.notification-wrapper')) {
+ dropdown.style.display = 'none';
+ }
+ });
+
+ // 全部已读
+ document.getElementById('notifReadAll').addEventListener('click', () => this.dismissAllNotifications());
+ }
+
+ async loadNotifications() {
+ try {
+ const resp = await fetch('/api/notifications');
+ const result = await resp.json();
+ if (result.success) {
+ this.renderNotifications(result.data || []);
+ }
+ } catch (e) {
+ console.error('加载通知失败:', e);
+ }
+ }
+
+ renderNotifications(list) {
+ const badge = document.getElementById('notifBadge');
+ const listEl = document.getElementById('notifList');
+
+ // 更新角标
+ if (list.length > 0) {
+ badge.textContent = list.length;
+ badge.style.display = 'flex';
+ } else {
+ badge.style.display = 'none';
+ }
+
+ // 渲染列表
+ if (list.length === 0) {
+ listEl.innerHTML = '暂无通知
';
+ return;
+ }
+
+ listEl.innerHTML = list.map(n => {
+ const icon = n.type === 'suspension' ? 'bi-exclamation-triangle-fill' : 'bi-arrow-return-left';
+ return `
+
+
+
+
${this.escapeHtml(n.message)}
+
${n.created_at}
+
+
+
+ `;
+ }).join('');
+ }
+
+ async dismissNotification(id) {
+ try {
+ await fetch('/api/notifications/dismiss', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ id })
+ });
+ this.loadNotifications();
+ } catch (e) {
+ console.error('移除通知失败:', e);
+ }
+ }
+
+ async dismissAllNotifications() {
+ try {
+ await fetch('/api/notifications/dismiss', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ all: true })
+ });
+ this.loadNotifications();
+ document.getElementById('notifDropdown').style.display = 'none';
+ } catch (e) {
+ console.error('清除通知失败:', e);
+ }
+ }
+
// ====================================================================
// 工具方法
// ====================================================================
diff --git a/static/style.css b/static/style.css
index 015292d..1b2239f 100644
--- a/static/style.css
+++ b/static/style.css
@@ -1576,7 +1576,200 @@ body {
.pagination-controls { width: 100%; justify-content: center; }
}
+/* ============================================================================
+ 通知系统
+ ============================================================================ */
+.notification-wrapper {
+ position: relative;
+}
+
+.notification-badge {
+ position: absolute;
+ top: -4px;
+ right: -4px;
+ min-width: 18px;
+ height: 18px;
+ padding: 0 5px;
+ border-radius: 9px;
+ background: linear-gradient(135deg, #f5576c 0%, #e11d48 100%);
+ color: white;
+ font-size: 11px;
+ font-weight: 700;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ line-height: 1;
+ box-shadow: 0 2px 6px rgba(245, 87, 108, 0.4);
+ animation: badgePop 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+@keyframes badgePop {
+ 0% { transform: scale(0); }
+ 60% { transform: scale(1.2); }
+ 100% { transform: scale(1); }
+}
+
+.notification-dropdown {
+ position: absolute;
+ top: calc(100% + 8px);
+ right: 0;
+ width: 360px;
+ max-height: 420px;
+ background: rgba(255, 255, 255, 0.98);
+ backdrop-filter: blur(20px);
+ border-radius: 12px;
+ box-shadow: 0 12px 40px rgba(0, 0, 0, 0.12), 0 0 0 1px rgba(0, 0, 0, 0.04);
+ z-index: 2000;
+ display: flex;
+ flex-direction: column;
+ animation: notifSlideIn 0.2s ease;
+ overflow: hidden;
+}
+
+@keyframes notifSlideIn {
+ from { opacity: 0; transform: translateY(-8px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+.notif-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 14px 16px;
+ border-bottom: 1px solid rgba(0, 0, 0, 0.06);
+ font-size: 14px;
+ font-weight: 700;
+ color: #1e293b;
+}
+
+.notif-read-all-btn {
+ border: none;
+ background: rgba(102, 126, 234, 0.1);
+ color: #667eea;
+ font-size: 12px;
+ font-weight: 600;
+ padding: 4px 12px;
+ border-radius: 6px;
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.notif-read-all-btn:hover {
+ background: #667eea;
+ color: white;
+}
+
+.notif-list {
+ overflow-y: auto;
+ max-height: 360px;
+ flex: 1;
+}
+
+.notif-list::-webkit-scrollbar { width: 4px; }
+.notif-list::-webkit-scrollbar-thumb { background: #ddd; border-radius: 2px; }
+
+.notif-empty {
+ text-align: center;
+ padding: 32px 16px;
+ color: #94a3b8;
+ font-size: 13px;
+}
+
+.notif-empty i {
+ font-size: 32px;
+ display: block;
+ margin-bottom: 8px;
+ opacity: 0.4;
+}
+
+.notif-item {
+ display: flex;
+ align-items: flex-start;
+ gap: 10px;
+ padding: 12px 16px;
+ border-bottom: 1px solid rgba(0, 0, 0, 0.04);
+ border-left: 3px solid transparent;
+ transition: background 0.15s ease;
+ animation: fadeIn 0.2s ease;
+}
+
+.notif-item:hover {
+ background: rgba(0, 0, 0, 0.02);
+}
+
+.notif-item.suspension {
+ border-left-color: #dc2626;
+}
+
+.notif-item.refund {
+ border-left-color: #f59e0b;
+}
+
+.notif-icon {
+ width: 32px;
+ height: 32px;
+ border-radius: 8px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 14px;
+ flex-shrink: 0;
+ margin-top: 2px;
+}
+
+.notif-item.suspension .notif-icon {
+ background: rgba(220, 38, 38, 0.1);
+ color: #dc2626;
+}
+
+.notif-item.refund .notif-icon {
+ background: rgba(245, 158, 11, 0.1);
+ color: #f59e0b;
+}
+
+.notif-content {
+ flex: 1;
+ min-width: 0;
+}
+
+.notif-message {
+ font-size: 13px;
+ font-weight: 500;
+ color: #334155;
+ line-height: 1.4;
+ word-break: break-all;
+}
+
+.notif-time {
+ font-size: 11px;
+ color: #94a3b8;
+ margin-top: 2px;
+}
+
+.notif-close {
+ width: 24px;
+ height: 24px;
+ border: none;
+ background: transparent;
+ color: #94a3b8;
+ cursor: pointer;
+ border-radius: 6px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 14px;
+ flex-shrink: 0;
+ transition: all 0.2s ease;
+ margin-top: 2px;
+}
+
+.notif-close:hover {
+ background: rgba(245, 87, 108, 0.1);
+ color: #f5576c;
+}
+
@media (max-width: 480px) {
+ .notification-dropdown { width: 300px; right: -40px; }
.email-list-panel { width: 280px; left: -280px; }
.search-box { width: 100px; }