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; }