Add project files: Outlook mail manager with Docker support
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
7
.claude/settings.local.json
Normal file
7
.claude/settings.local.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(python:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
63
.dockerignore
Normal file
63
.dockerignore
Normal file
@@ -0,0 +1,63 @@
|
||||
# Python缓存文件
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# 虚拟环境
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
|
||||
# IDE文件
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# 操作系统文件
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# 日志文件
|
||||
*.log
|
||||
|
||||
# 临时文件
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
# Git文件
|
||||
.git/
|
||||
.gitignore
|
||||
|
||||
# Docker文件
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
|
||||
# 文档文件
|
||||
README.md
|
||||
*.md
|
||||
|
||||
# 测试文件
|
||||
tests/
|
||||
test_*.py
|
||||
|
||||
# 其他不需要的文件
|
||||
outlook.txt
|
||||
20
.env.example
Normal file
20
.env.example
Normal file
@@ -0,0 +1,20 @@
|
||||
# Outlook邮件管理系统环境配置
|
||||
# 复制此文件为 .env 并修改相应配置
|
||||
|
||||
# 管理员访问令牌(请修改为安全的密码)
|
||||
ADMIN_TOKEN=admin123
|
||||
|
||||
# 服务器端口(默认5000)
|
||||
SERVER_PORT=5000
|
||||
|
||||
# 服务器主机(默认0.0.0.0,接受所有连接)
|
||||
SERVER_HOST=0.0.0.0
|
||||
|
||||
# 时区设置
|
||||
TZ=Asia/Shanghai
|
||||
|
||||
# 日志级别(DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# 是否启用详细日志
|
||||
VERBOSE_LOGGING=false
|
||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
config.txt
|
||||
outlook_manager.db
|
||||
.codebuddy
|
||||
_pychache_
|
||||
*.pyc
|
||||
config.txt
|
||||
38
Dockerfile
Normal file
38
Dockerfile
Normal file
@@ -0,0 +1,38 @@
|
||||
# 使用Python 3.12官方镜像作为基础镜像
|
||||
FROM python:3.12-slim
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 设置环境变量
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
|
||||
# 安装系统依赖
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 复制requirements.txt并安装Python依赖
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# 复制应用程序代码
|
||||
COPY . .
|
||||
|
||||
# 创建配置文件目录(如果不存在)
|
||||
RUN touch config.txt
|
||||
|
||||
# 设置权限
|
||||
RUN chmod +x mail_api.py
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 5000
|
||||
|
||||
# 设置健康检查
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:5000/ || exit 1
|
||||
|
||||
# 启动命令
|
||||
CMD ["python", "mail_api.py", "web"]
|
||||
240
TASK_CLAUDE_PAYMENT.md
Normal file
240
TASK_CLAUDE_PAYMENT.md
Normal file
@@ -0,0 +1,240 @@
|
||||
# 任务:实现 Claude 支付检测工具
|
||||
|
||||
## 需求
|
||||
|
||||
自动扫描邮箱收件箱,检测 Anthropic 的支付/退款邮件,自动给账号打标签并记录时间。
|
||||
|
||||
**匹配规则:**
|
||||
- 发件人:`invoice+statements@mail.anthropic.com`
|
||||
- 标题含 `Your receipt from Anthropic` → 标记 **已支付Claude**(status=`paid`),记录支付时间
|
||||
- 标题含 `Your refund from Anthropic` → 标记 **已退款**(status=`refunded`),记录退款时间
|
||||
- 时间取邮件的 `receivedDateTime`
|
||||
- 如果同时存在支付和退款邮件,比较时间取最新的作为当前状态
|
||||
|
||||
---
|
||||
|
||||
## 实现方案(共 5 个改动点)
|
||||
|
||||
### 1. `database.py` — 新增表 + 4 个方法
|
||||
|
||||
在 `init_database()` 中创建新表:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS claude_payment_status (
|
||||
email TEXT PRIMARY KEY,
|
||||
status TEXT DEFAULT 'unknown', -- paid / refunded / unknown / error
|
||||
payment_time TEXT,
|
||||
refund_time TEXT,
|
||||
checked_at TEXT
|
||||
)
|
||||
```
|
||||
|
||||
新增 3 个 async 方法(参考现有 `get_account_tags` / `set_account_tags` 的写法):
|
||||
- `set_claude_payment_status(email, status, payment_time=None, refund_time=None)` — INSERT OR REPLACE,checked_at 取当前时间
|
||||
- `get_claude_payment_status(email)` → Optional[Dict]
|
||||
- `get_all_claude_payment_statuses()` → Dict[str, Dict]
|
||||
|
||||
修改 `delete_account()`:在删除账户时联动删除 `claude_payment_status` 表中对应记录。
|
||||
|
||||
### 2. `mail_api.py` — 检测逻辑 + 2 个新端点
|
||||
|
||||
添加 `StreamingResponse` 到 import:
|
||||
```python
|
||||
from fastapi.responses import FileResponse, JSONResponse, PlainTextResponse, StreamingResponse
|
||||
```
|
||||
|
||||
在"命令行入口"注释之前,添加以下内容:
|
||||
|
||||
**常量:**
|
||||
```python
|
||||
ANTHROPIC_SENDER = "invoice+statements@mail.anthropic.com"
|
||||
RECEIPT_KEYWORD = "Your receipt from Anthropic"
|
||||
REFUND_KEYWORD = "Your refund from Anthropic"
|
||||
```
|
||||
|
||||
**核心检测函数** `_check_claude_payment_for_account(email_addr)`:
|
||||
1. 调用 `email_manager.get_client(email_addr)` 获取客户端
|
||||
2. 调用 `client.get_messages_with_content(top=30)` 获取最近 30 封 INBOX 邮件
|
||||
3. 遍历邮件,匹配 `sender.emailAddress.address` == ANTHROPIC_SENDER(不区分大小写)
|
||||
4. 匹配标题关键词,记录最新的 payment_time 和 refund_time
|
||||
5. 确定状态:两者都有则比较时间取最新;只有支付=paid;只有退款=refunded;都没有=unknown
|
||||
6. 调用 `db_manager.set_claude_payment_status()` 写入数据库
|
||||
7. 同时双写标签:获取现有标签,移除旧的"已支付Claude"/"已退款"标签,根据状态添加新标签
|
||||
8. 返回 `{email, status, payment_time, refund_time}`
|
||||
9. 出错时记录 error 状态并返回 `{email, status: "error", message: str(e)}`
|
||||
|
||||
**端点 1:** `POST /api/tools/check-claude-payment` — SSE 流式扫描
|
||||
- 获取所有账户 `load_accounts_config()`
|
||||
- 逐个调用 `_check_claude_payment_for_account()`
|
||||
- SSE 事件格式(每行 `data: {JSON}\n\n`):
|
||||
- `{type: "start", total: N}`
|
||||
- `{type: "progress", current: i, total: N, email: "..."}`(扫描前发送)
|
||||
- `{type: "result", current: i, total: N, email, status, payment_time, refund_time}`(扫描后发送)
|
||||
- `{type: "done", total: N}`
|
||||
- 账户之间 `await asyncio.sleep(0.5)` 避免限速
|
||||
- 返回 `StreamingResponse(event_generator(), media_type="text/event-stream")`
|
||||
|
||||
**端点 2:** `GET /api/tools/claude-payment-status` — 获取缓存状态
|
||||
- 调用 `db_manager.get_all_claude_payment_statuses()`
|
||||
- 返回 `ApiResponse(success=True, data=statuses)`
|
||||
|
||||
### 3. `static/index.html` — 按钮 + 表格列
|
||||
|
||||
**导航栏**:在 `nav-actions` 的"导出"按钮之后、"刷新"按钮之前,添加:
|
||||
```html
|
||||
<button class="btn btn-primary" id="claudePaymentBtn">
|
||||
<i class="bi bi-credit-card"></i>
|
||||
<span>Claude检测</span>
|
||||
</button>
|
||||
```
|
||||
|
||||
**表格表头**:在"令牌"和"操作"之间,添加新列:
|
||||
```html
|
||||
<th>Claude状态</th>
|
||||
```
|
||||
同时把所有 `colspan="6"` 改为 `colspan="7"`(空状态行)。
|
||||
|
||||
### 4. `static/script.js` — SSE 扫描 + 状态渲染
|
||||
|
||||
**constructor** 中新增:
|
||||
```javascript
|
||||
this.claudePaymentStatuses = {};
|
||||
```
|
||||
|
||||
**bindAccountEvents()** 中新增按钮绑定:
|
||||
```javascript
|
||||
document.getElementById('claudePaymentBtn').addEventListener('click', () => this.startClaudePaymentCheck());
|
||||
```
|
||||
|
||||
**loadAccounts()** 中:在 `renderAccounts()` 调用前,先 `await this.loadClaudePaymentStatuses()`。同时把所有 `colspan="6"` 改为 `colspan="7"`。
|
||||
|
||||
**renderAccounts()** 中:在令牌列 `</td>` 和操作列 `<td>` 之间,插入新列:
|
||||
```javascript
|
||||
<td>${this.renderClaudeStatusBadge(email)}</td>
|
||||
```
|
||||
|
||||
**新增 4 个方法:**
|
||||
|
||||
```javascript
|
||||
async loadClaudePaymentStatuses() {
|
||||
try {
|
||||
const resp = await fetch('/api/tools/claude-payment-status');
|
||||
if (resp.ok) {
|
||||
const result = await resp.json();
|
||||
if (result.success) this.claudePaymentStatuses = result.data || {};
|
||||
}
|
||||
} catch (e) { console.error('加载Claude支付状态失败:', e); }
|
||||
}
|
||||
|
||||
renderClaudeStatusBadge(email) {
|
||||
const info = this.claudePaymentStatuses[email];
|
||||
if (!info) return '<span class="claude-badge claude-unknown">未检测</span>';
|
||||
const map = {
|
||||
paid: { cls: 'claude-paid', label: '已支付' },
|
||||
refunded: { cls: 'claude-refunded', label: '已退款' },
|
||||
unknown: { cls: 'claude-unknown', label: '未知' },
|
||||
error: { cls: 'claude-error', label: '错误' }
|
||||
};
|
||||
const s = map[info.status] || map.unknown;
|
||||
let tips = [];
|
||||
if (info.payment_time) tips.push('支付: ' + info.payment_time);
|
||||
if (info.refund_time) tips.push('退款: ' + info.refund_time);
|
||||
if (info.checked_at) tips.push('检测: ' + info.checked_at);
|
||||
const title = tips.length ? ` title="${this.escapeHtml(tips.join('\n'))}"` : '';
|
||||
return `<span class="claude-badge ${s.cls}"${title}>${s.label}</span>`;
|
||||
}
|
||||
|
||||
async startClaudePaymentCheck() {
|
||||
const btn = document.getElementById('claudePaymentBtn');
|
||||
if (!btn) return;
|
||||
btn.disabled = true;
|
||||
const origHtml = btn.innerHTML;
|
||||
btn.innerHTML = '<i class="bi bi-hourglass-split"></i><span>检测中...</span>';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/tools/check-claude-payment', { method: 'POST' });
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop();
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
if (data.type === 'progress') {
|
||||
btn.innerHTML = `<i class="bi bi-hourglass-split"></i><span>${data.current}/${data.total}</span>`;
|
||||
} else if (data.type === 'result') {
|
||||
this.claudePaymentStatuses[data.email] = {
|
||||
status: data.status,
|
||||
payment_time: data.payment_time || null,
|
||||
refund_time: data.refund_time || null,
|
||||
checked_at: new Date().toLocaleString('zh-CN')
|
||||
};
|
||||
this.updateClaudeBadgeInTable(data.email);
|
||||
} else if (data.type === 'done') {
|
||||
btn.innerHTML = '<i class="bi bi-check-circle"></i><span>完成</span>';
|
||||
setTimeout(() => { btn.innerHTML = origHtml; btn.disabled = false; }, 2000);
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Claude支付检测失败:', err);
|
||||
btn.innerHTML = '<i class="bi bi-exclamation-triangle"></i><span>失败</span>';
|
||||
setTimeout(() => { btn.innerHTML = origHtml; btn.disabled = false; }, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
updateClaudeBadgeInTable(email) {
|
||||
const tbody = document.getElementById('accountTableBody');
|
||||
if (!tbody) return;
|
||||
for (const row of tbody.querySelectorAll('tr')) {
|
||||
const firstTd = row.querySelector('td');
|
||||
if (firstTd && firstTd.textContent.includes(email)) {
|
||||
// Claude状态列 = 第6个td(index 5,在令牌后、操作前)
|
||||
const cells = row.querySelectorAll('td');
|
||||
if (cells.length >= 6) {
|
||||
cells[5].innerHTML = this.renderClaudeStatusBadge(email);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. `static/style.css` — 徽章样式
|
||||
|
||||
在文件末尾(`@media` 之前或之后均可)添加:
|
||||
|
||||
```css
|
||||
/* Claude支付状态徽章 */
|
||||
.claude-badge {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: default;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.claude-paid { background: rgba(16, 185, 129, 0.12); color: #059669; }
|
||||
.claude-refunded { background: rgba(245, 87, 108, 0.12); color: #e11d48; }
|
||||
.claude-unknown { background: rgba(148, 163, 184, 0.15); color: #64748b; }
|
||||
.claude-error { background: rgba(245, 158, 11, 0.15); color: #d97706; }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 验证步骤
|
||||
|
||||
1. `python mail_api.py web` → 访问 `http://localhost:5001/`
|
||||
2. 表格应多出一列"Claude状态",初始都显示灰色"未检测"
|
||||
3. 点击导航栏"Claude检测"按钮 → 按钮显示进度 `1/N`、`2/N`...
|
||||
4. 扫描完成后,有 Anthropic 收据的账号显示绿色"已支付",有退款的显示红色"已退款"
|
||||
5. 鼠标悬停徽章可看到支付/退款时间
|
||||
6. 刷新页面后状态仍然保留(数据库持久化)
|
||||
79
auth.py
Normal file
79
auth.py
Normal file
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
OAuth2认证模块
|
||||
处理Microsoft OAuth2令牌获取和刷新
|
||||
"""
|
||||
|
||||
import requests
|
||||
import logging
|
||||
from typing import Optional
|
||||
from fastapi import HTTPException
|
||||
|
||||
from config import CLIENT_ID, TOKEN_URL
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ============================================================================
|
||||
# OAuth2令牌获取函数
|
||||
# ============================================================================
|
||||
|
||||
async def get_access_token(refresh_token: str, check_only: bool = False, client_id: str = None) -> Optional[str]:
|
||||
"""使用refresh_token获取access_token
|
||||
|
||||
Args:
|
||||
refresh_token: 刷新令牌
|
||||
check_only: 如果为True,验证失败时返回None而不是抛出异常
|
||||
client_id: 可选,指定client_id,默认使用全局CLIENT_ID
|
||||
|
||||
Returns:
|
||||
成功返回access_token,如果check_only=True且验证失败则返回None
|
||||
"""
|
||||
data = {
|
||||
'client_id': client_id or CLIENT_ID,
|
||||
'grant_type': 'refresh_token',
|
||||
'refresh_token': refresh_token,
|
||||
'scope': 'https://outlook.office.com/IMAP.AccessAsUser.All offline_access'
|
||||
}
|
||||
|
||||
try:
|
||||
# 使用requests而不是httpx,因为exp.py验证有效
|
||||
response = requests.post(TOKEN_URL, data=data)
|
||||
response.raise_for_status()
|
||||
|
||||
token_data = response.json()
|
||||
access_token = token_data.get('access_token')
|
||||
|
||||
if not access_token:
|
||||
error_msg = f"获取 access_token 失败: {token_data.get('error_description', '响应中未找到 access_token')}"
|
||||
logger.error(error_msg)
|
||||
if check_only:
|
||||
return None
|
||||
raise HTTPException(status_code=401, detail=error_msg)
|
||||
|
||||
new_refresh_token = token_data.get('refresh_token')
|
||||
if new_refresh_token and new_refresh_token != refresh_token:
|
||||
logger.debug("提示: refresh_token 已被服务器更新")
|
||||
|
||||
return access_token
|
||||
|
||||
except requests.exceptions.HTTPError as http_err:
|
||||
logger.error(f"请求 access_token 时发生HTTP错误: {http_err}")
|
||||
if http_err.response is not None:
|
||||
logger.error(f"服务器响应: {http_err.response.status_code} - {http_err.response.text}")
|
||||
|
||||
if check_only:
|
||||
return None
|
||||
raise HTTPException(status_code=401, detail="Refresh token已过期或无效,需要重新获取授权")
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"请求 access_token 时发生网络错误: {e}")
|
||||
if check_only:
|
||||
return None
|
||||
raise HTTPException(status_code=500, detail="Token acquisition failed")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"解析 access_token 响应时出错: {e}")
|
||||
if check_only:
|
||||
return None
|
||||
raise HTTPException(status_code=500, detail="Token acquisition failed")
|
||||
|
||||
41
config.py
Normal file
41
config.py
Normal file
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
配置常量和设置
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
|
||||
# ============================================================================
|
||||
# Microsoft OAuth2配置
|
||||
# ============================================================================
|
||||
|
||||
# 使用exp.py中验证有效的CLIENT_ID
|
||||
CLIENT_ID = 'dbc8e03a-b00c-46bd-ae65-b683e7707cb0'
|
||||
TOKEN_URL = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token"
|
||||
|
||||
# ============================================================================
|
||||
# IMAP配置
|
||||
# ============================================================================
|
||||
|
||||
IMAP_SERVER = 'outlook.live.com'
|
||||
IMAP_PORT = 993
|
||||
INBOX_FOLDER_NAME = "INBOX"
|
||||
JUNK_FOLDER_NAME = "Junk"
|
||||
|
||||
# ============================================================================
|
||||
# 系统配置
|
||||
# ============================================================================
|
||||
|
||||
# 管理认证配置
|
||||
ADMIN_TOKEN = os.getenv('ADMIN_TOKEN', 'admin123') # 从环境变量获取,默认为admin123
|
||||
|
||||
# 邮件获取配置
|
||||
DEFAULT_EMAIL_LIMIT = 10 # 默认邮件获取数量限制(V2默认获取10封)
|
||||
|
||||
# ============================================================================
|
||||
# 日志配置
|
||||
# ============================================================================
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
631
database.py
Normal file
631
database.py
Normal file
@@ -0,0 +1,631 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
SQLite数据库管理模块
|
||||
用于管理邮件和标签数据
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from pathlib import Path
|
||||
import asyncio
|
||||
import threading
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class DatabaseManager:
|
||||
"""数据库管理器"""
|
||||
|
||||
def __init__(self, db_path: str = "outlook_manager.db"):
|
||||
self.db_path = db_path
|
||||
self._local = threading.local()
|
||||
self.init_database()
|
||||
|
||||
def get_connection(self) -> sqlite3.Connection:
|
||||
"""获取线程本地的数据库连接"""
|
||||
if not hasattr(self._local, 'connection'):
|
||||
self._local.connection = sqlite3.connect(self.db_path)
|
||||
self._local.connection.row_factory = sqlite3.Row
|
||||
return self._local.connection
|
||||
|
||||
def init_database(self):
|
||||
"""初始化数据库表"""
|
||||
conn = self.get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 创建账户表
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
email TEXT PRIMARY KEY,
|
||||
password TEXT DEFAULT '',
|
||||
client_id TEXT DEFAULT '',
|
||||
refresh_token TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
# 创建账户标签表
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS account_tags (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL,
|
||||
tags TEXT NOT NULL, -- JSON格式存储标签数组
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
# 创建邮件缓存表
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS email_cache (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL,
|
||||
message_id TEXT NOT NULL,
|
||||
subject TEXT,
|
||||
sender TEXT,
|
||||
received_date TEXT,
|
||||
body_preview TEXT,
|
||||
body_content TEXT,
|
||||
body_type TEXT DEFAULT 'text',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(email, message_id)
|
||||
)
|
||||
''')
|
||||
|
||||
# 创建系统配置表
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS system_config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
# 创建Claude支付状态表
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS claude_payment_status (
|
||||
email TEXT PRIMARY KEY,
|
||||
status TEXT DEFAULT 'unknown',
|
||||
payment_time TEXT,
|
||||
refund_time TEXT,
|
||||
suspended_time TEXT,
|
||||
title TEXT DEFAULT '',
|
||||
remark TEXT DEFAULT '',
|
||||
card_number TEXT DEFAULT '',
|
||||
proxy TEXT DEFAULT '',
|
||||
proxy_expire_days INTEGER DEFAULT 30,
|
||||
proxy_share TEXT DEFAULT 'exclusive',
|
||||
proxy_purchase_date TEXT DEFAULT '',
|
||||
checked_at TEXT
|
||||
)
|
||||
''')
|
||||
|
||||
# 兼容旧表:动态添加缺少的列
|
||||
for col in ['suspended_time', 'title', 'remark', 'card_number', 'proxy', 'proxy_expire_days', 'proxy_share', 'proxy_purchase_date']:
|
||||
try:
|
||||
cursor.execute(f"SELECT {col} FROM claude_payment_status LIMIT 1")
|
||||
except sqlite3.OperationalError:
|
||||
defaults = {'title': "DEFAULT ''", 'remark': "DEFAULT ''", 'card_number': "DEFAULT ''", 'proxy': "DEFAULT ''",
|
||||
'proxy_expire_days': "DEFAULT 30", 'proxy_share': "DEFAULT 'exclusive'", 'proxy_purchase_date': "DEFAULT ''"}
|
||||
default = defaults.get(col, '')
|
||||
cursor.execute(f"ALTER TABLE claude_payment_status ADD COLUMN {col} TEXT {default}")
|
||||
logger.info(f"已为 claude_payment_status 添加 {col} 列")
|
||||
|
||||
# 创建索引
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_accounts_email ON accounts(email)')
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_account_tags_email ON account_tags(email)')
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_email_cache_email ON email_cache(email)')
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_email_cache_message_id ON email_cache(message_id)')
|
||||
|
||||
conn.commit()
|
||||
logger.info("数据库初始化完成")
|
||||
|
||||
async def get_account_tags(self, email: str) -> List[str]:
|
||||
"""获取账户标签"""
|
||||
def _sync_get():
|
||||
conn = self.get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT tags FROM account_tags WHERE email = ?', (email,))
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
try:
|
||||
return json.loads(row['tags'])
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
return []
|
||||
|
||||
return await asyncio.to_thread(_sync_get)
|
||||
|
||||
async def set_account_tags(self, email: str, tags: List[str]) -> bool:
|
||||
"""设置账户标签"""
|
||||
def _sync_set():
|
||||
try:
|
||||
conn = self.get_connection()
|
||||
cursor = conn.cursor()
|
||||
tags_json = json.dumps(tags, ensure_ascii=False)
|
||||
|
||||
# 先检查记录是否存在
|
||||
cursor.execute('SELECT id FROM account_tags WHERE email = ?', (email,))
|
||||
existing_record = cursor.fetchone()
|
||||
|
||||
if existing_record:
|
||||
# 更新现有记录
|
||||
cursor.execute('''
|
||||
UPDATE account_tags
|
||||
SET tags = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE email = ?
|
||||
''', (tags_json, email))
|
||||
logger.info(f"更新账户 {email} 的标签: {tags}")
|
||||
else:
|
||||
# 插入新记录
|
||||
cursor.execute('''
|
||||
INSERT INTO account_tags (email, tags, created_at, updated_at)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
''', (email, tags_json))
|
||||
logger.info(f"为账户 {email} 创建新标签: {tags}")
|
||||
|
||||
conn.commit()
|
||||
logger.info(f"成功保存账户 {email} 的标签")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"设置账户标签失败: {e}")
|
||||
conn.rollback()
|
||||
return False
|
||||
|
||||
return await asyncio.to_thread(_sync_set)
|
||||
|
||||
async def get_all_tags(self) -> List[str]:
|
||||
"""获取所有标签"""
|
||||
def _sync_get():
|
||||
conn = self.get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT tags FROM account_tags')
|
||||
rows = cursor.fetchall()
|
||||
|
||||
all_tags = set()
|
||||
for row in rows:
|
||||
try:
|
||||
tags = json.loads(row['tags'])
|
||||
all_tags.update(tags)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
return sorted(list(all_tags))
|
||||
|
||||
return await asyncio.to_thread(_sync_get)
|
||||
|
||||
async def get_accounts_with_tags(self) -> Dict[str, List[str]]:
|
||||
"""获取所有账户及其标签"""
|
||||
def _sync_get():
|
||||
conn = self.get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT email, tags FROM account_tags')
|
||||
rows = cursor.fetchall()
|
||||
|
||||
result = {}
|
||||
for row in rows:
|
||||
try:
|
||||
tags = json.loads(row['tags'])
|
||||
result[row['email']] = tags
|
||||
except json.JSONDecodeError:
|
||||
result[row['email']] = []
|
||||
|
||||
return result
|
||||
|
||||
return await asyncio.to_thread(_sync_get)
|
||||
|
||||
async def cache_email(self, email: str, message_id: str, email_data: Dict) -> bool:
|
||||
"""缓存邮件数据"""
|
||||
def _sync_cache():
|
||||
try:
|
||||
conn = self.get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 提取邮件信息
|
||||
subject = email_data.get('subject', '')
|
||||
sender_info = email_data.get('sender', {}).get('emailAddress', {})
|
||||
sender = f"{sender_info.get('name', '')} <{sender_info.get('address', '')}>"
|
||||
received_date = email_data.get('receivedDateTime', '')
|
||||
body_preview = email_data.get('bodyPreview', '')
|
||||
body_info = email_data.get('body', {})
|
||||
body_content = body_info.get('content', '')
|
||||
body_type = body_info.get('contentType', 'text')
|
||||
|
||||
cursor.execute('''
|
||||
INSERT OR REPLACE INTO email_cache
|
||||
(email, message_id, subject, sender, received_date, body_preview, body_content, body_type)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
''', (email, message_id, subject, sender, received_date, body_preview, body_content, body_type))
|
||||
|
||||
conn.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"缓存邮件失败: {e}")
|
||||
return False
|
||||
|
||||
return await asyncio.to_thread(_sync_cache)
|
||||
|
||||
async def get_cached_email(self, email: str, message_id: str) -> Optional[Dict]:
|
||||
"""获取缓存的邮件数据"""
|
||||
def _sync_get():
|
||||
conn = self.get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
SELECT * FROM email_cache
|
||||
WHERE email = ? AND message_id = ?
|
||||
''', (email, message_id))
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row:
|
||||
return {
|
||||
'id': row['message_id'],
|
||||
'subject': row['subject'],
|
||||
'sender': {'emailAddress': {'name': row['sender'].split(' <')[0] if ' <' in row['sender'] else row['sender'],
|
||||
'address': row['sender'].split('<')[1].rstrip('>') if '<' in row['sender'] else row['sender']}},
|
||||
'receivedDateTime': row['received_date'],
|
||||
'bodyPreview': row['body_preview'],
|
||||
'body': {'content': row['body_content'], 'contentType': row['body_type']}
|
||||
}
|
||||
return None
|
||||
|
||||
return await asyncio.to_thread(_sync_get)
|
||||
|
||||
async def get_system_config(self, key: str, default_value: str = None) -> Optional[str]:
|
||||
"""获取系统配置"""
|
||||
def _sync_get():
|
||||
conn = self.get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT value FROM system_config WHERE key = ?', (key,))
|
||||
row = cursor.fetchone()
|
||||
return row['value'] if row else default_value
|
||||
|
||||
return await asyncio.to_thread(_sync_get)
|
||||
|
||||
async def set_system_config(self, key: str, value: str) -> bool:
|
||||
"""设置系统配置"""
|
||||
def _sync_set():
|
||||
try:
|
||||
conn = self.get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
INSERT OR REPLACE INTO system_config (key, value, updated_at)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
''', (key, value))
|
||||
conn.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"设置系统配置失败: {e}")
|
||||
return False
|
||||
|
||||
return await asyncio.to_thread(_sync_set)
|
||||
|
||||
async def cleanup_old_emails(self, days: int = 30) -> int:
|
||||
"""清理旧的邮件缓存"""
|
||||
def _sync_cleanup():
|
||||
try:
|
||||
conn = self.get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
DELETE FROM email_cache
|
||||
WHERE created_at < datetime('now', '-{} days')
|
||||
'''.format(days))
|
||||
deleted_count = cursor.rowcount
|
||||
conn.commit()
|
||||
return deleted_count
|
||||
except Exception as e:
|
||||
logger.error(f"清理旧邮件失败: {e}")
|
||||
return 0
|
||||
|
||||
return await asyncio.to_thread(_sync_cleanup)
|
||||
|
||||
async def get_all_accounts(self) -> Dict[str, Dict[str, str]]:
|
||||
"""获取所有账户"""
|
||||
def _sync_get():
|
||||
conn = self.get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT email, password, client_id, refresh_token FROM accounts')
|
||||
rows = cursor.fetchall()
|
||||
|
||||
result = {}
|
||||
for row in rows:
|
||||
result[row['email']] = {
|
||||
'password': row['password'] or '',
|
||||
'client_id': row['client_id'] or '',
|
||||
'refresh_token': row['refresh_token']
|
||||
}
|
||||
return result
|
||||
|
||||
return await asyncio.to_thread(_sync_get)
|
||||
|
||||
async def add_account(self, email: str, password: str = '', client_id: str = '', refresh_token: str = '') -> bool:
|
||||
"""添加账户"""
|
||||
def _sync_add():
|
||||
try:
|
||||
conn = self.get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
INSERT INTO accounts (email, password, client_id, refresh_token)
|
||||
VALUES (?, ?, ?, ?)
|
||||
''', (email, password, client_id, refresh_token))
|
||||
conn.commit()
|
||||
return True
|
||||
except sqlite3.IntegrityError:
|
||||
# 账户已存在
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"添加账户失败: {e}")
|
||||
return False
|
||||
|
||||
return await asyncio.to_thread(_sync_add)
|
||||
|
||||
async def update_account(self, email: str, password: str = None, client_id: str = None, refresh_token: str = None) -> bool:
|
||||
"""更新账户"""
|
||||
def _sync_update():
|
||||
try:
|
||||
conn = self.get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 构建更新语句
|
||||
updates = []
|
||||
params = []
|
||||
|
||||
if password is not None:
|
||||
updates.append('password = ?')
|
||||
params.append(password)
|
||||
|
||||
if client_id is not None:
|
||||
updates.append('client_id = ?')
|
||||
params.append(client_id)
|
||||
|
||||
if refresh_token is not None:
|
||||
updates.append('refresh_token = ?')
|
||||
params.append(refresh_token)
|
||||
|
||||
if not updates:
|
||||
return True # 没有更新内容
|
||||
|
||||
updates.append('updated_at = CURRENT_TIMESTAMP')
|
||||
params.append(email)
|
||||
|
||||
sql = f"UPDATE accounts SET {', '.join(updates)} WHERE email = ?"
|
||||
cursor.execute(sql, params)
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
except Exception as e:
|
||||
logger.error(f"更新账户失败: {e}")
|
||||
return False
|
||||
|
||||
return await asyncio.to_thread(_sync_update)
|
||||
|
||||
async def delete_account(self, email: str) -> bool:
|
||||
"""删除账户"""
|
||||
def _sync_delete():
|
||||
try:
|
||||
conn = self.get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 删除账户
|
||||
cursor.execute('DELETE FROM accounts WHERE email = ?', (email,))
|
||||
|
||||
# 同时删除相关的标签
|
||||
cursor.execute('DELETE FROM account_tags WHERE email = ?', (email,))
|
||||
|
||||
# 删除相关的邮件缓存
|
||||
cursor.execute('DELETE FROM email_cache WHERE email = ?', (email,))
|
||||
|
||||
# 删除Claude支付状态
|
||||
cursor.execute('DELETE FROM claude_payment_status WHERE email = ?', (email,))
|
||||
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
except Exception as e:
|
||||
logger.error(f"删除账户失败: {e}")
|
||||
return False
|
||||
|
||||
return await asyncio.to_thread(_sync_delete)
|
||||
|
||||
async def account_exists(self, email: str) -> bool:
|
||||
"""检查账户是否存在"""
|
||||
def _sync_check():
|
||||
conn = self.get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT 1 FROM accounts WHERE email = ?', (email,))
|
||||
return cursor.fetchone() is not None
|
||||
|
||||
return await asyncio.to_thread(_sync_check)
|
||||
|
||||
async def get_account(self, email: str) -> Optional[Dict[str, str]]:
|
||||
"""获取单个账户信息"""
|
||||
def _sync_get():
|
||||
conn = self.get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT password, client_id, refresh_token FROM accounts WHERE email = ?', (email,))
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row:
|
||||
return {
|
||||
'password': row['password'] or '',
|
||||
'client_id': row['client_id'] or '',
|
||||
'refresh_token': row['refresh_token']
|
||||
}
|
||||
return None
|
||||
|
||||
return await asyncio.to_thread(_sync_get)
|
||||
|
||||
async def migrate_from_config_file(self, config_file_path: str = 'config.txt') -> Tuple[int, int]:
|
||||
"""从config.txt迁移数据到数据库"""
|
||||
def _sync_migrate():
|
||||
try:
|
||||
import os
|
||||
if not os.path.exists(config_file_path):
|
||||
return 0, 0
|
||||
|
||||
conn = self.get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
added_count = 0
|
||||
error_count = 0
|
||||
|
||||
with open(config_file_path, 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if line.startswith('#') or not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
parts = line.split('----')
|
||||
if len(parts) >= 4:
|
||||
email, password, client_id, refresh_token = parts[0], parts[1], parts[2], parts[3]
|
||||
|
||||
# 检查账户是否已存在
|
||||
cursor.execute('SELECT 1 FROM accounts WHERE email = ?', (email.strip(),))
|
||||
if not cursor.fetchone():
|
||||
cursor.execute('''
|
||||
INSERT INTO accounts (email, password, client_id, refresh_token)
|
||||
VALUES (?, ?, ?, ?)
|
||||
''', (email.strip(), password.strip(), client_id.strip(), refresh_token.strip()))
|
||||
added_count += 1
|
||||
elif len(parts) == 2: # 兼容旧格式
|
||||
email, refresh_token = parts
|
||||
cursor.execute('SELECT 1 FROM accounts WHERE email = ?', (email.strip(),))
|
||||
if not cursor.fetchone():
|
||||
cursor.execute('''
|
||||
INSERT INTO accounts (email, password, client_id, refresh_token)
|
||||
VALUES (?, ?, ?, ?)
|
||||
''', (email.strip(), '', '', refresh_token.strip()))
|
||||
added_count += 1
|
||||
except Exception as e:
|
||||
logger.error(f"迁移行失败: {line}, 错误: {e}")
|
||||
error_count += 1
|
||||
|
||||
conn.commit()
|
||||
return added_count, error_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"迁移配置文件失败: {e}")
|
||||
return 0, 1
|
||||
|
||||
return await asyncio.to_thread(_sync_migrate)
|
||||
|
||||
async def set_claude_payment_status(self, email: str, status: str, payment_time: str = None, refund_time: str = None, suspended_time: str = None) -> bool:
|
||||
"""设置Claude支付状态"""
|
||||
def _sync_set():
|
||||
try:
|
||||
conn = self.get_connection()
|
||||
cursor = conn.cursor()
|
||||
checked_at = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
# 先查已有的 remark/card_number,避免被覆盖
|
||||
cursor.execute('SELECT title, remark, card_number, proxy, proxy_expire_days, proxy_share, proxy_purchase_date FROM claude_payment_status WHERE email = ?', (email,))
|
||||
existing = cursor.fetchone()
|
||||
old_title = existing['title'] if existing else ''
|
||||
old_remark = existing['remark'] if existing else ''
|
||||
old_card = existing['card_number'] if existing else ''
|
||||
old_proxy = existing['proxy'] if existing else ''
|
||||
old_expire = existing['proxy_expire_days'] if existing else 30
|
||||
old_share = existing['proxy_share'] if existing else 'exclusive'
|
||||
old_purchase = existing['proxy_purchase_date'] if existing else ''
|
||||
cursor.execute('''
|
||||
INSERT OR REPLACE INTO claude_payment_status (email, status, payment_time, refund_time, suspended_time, title, remark, card_number, proxy, proxy_expire_days, proxy_share, proxy_purchase_date, checked_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
''', (email, status, payment_time, refund_time, suspended_time, old_title or '', old_remark or '', old_card or '', old_proxy or '', old_expire or 30, old_share or 'exclusive', old_purchase or '', checked_at))
|
||||
conn.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"设置Claude支付状态失败: {e}")
|
||||
return False
|
||||
|
||||
return await asyncio.to_thread(_sync_set)
|
||||
|
||||
async def get_claude_payment_status(self, email: str) -> Optional[Dict]:
|
||||
"""获取Claude支付状态"""
|
||||
def _sync_get():
|
||||
conn = self.get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT status, payment_time, refund_time, suspended_time, title, remark, card_number, proxy, proxy_expire_days, proxy_share, proxy_purchase_date, checked_at FROM claude_payment_status WHERE email = ?', (email,))
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
return {
|
||||
'status': row['status'],
|
||||
'payment_time': row['payment_time'],
|
||||
'refund_time': row['refund_time'],
|
||||
'suspended_time': row['suspended_time'],
|
||||
'title': row['title'] or '',
|
||||
'remark': row['remark'] or '',
|
||||
'card_number': row['card_number'] or '',
|
||||
'proxy': row['proxy'] or '',
|
||||
'proxy_expire_days': row['proxy_expire_days'] or 30,
|
||||
'proxy_share': row['proxy_share'] or 'exclusive',
|
||||
'proxy_purchase_date': row['proxy_purchase_date'] or '',
|
||||
'checked_at': row['checked_at']
|
||||
}
|
||||
return None
|
||||
|
||||
return await asyncio.to_thread(_sync_get)
|
||||
|
||||
async def get_all_claude_payment_statuses(self) -> Dict[str, Dict]:
|
||||
"""获取所有Claude支付状态"""
|
||||
def _sync_get():
|
||||
conn = self.get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT email, status, payment_time, refund_time, suspended_time, title, remark, card_number, proxy, proxy_expire_days, proxy_share, proxy_purchase_date, checked_at FROM claude_payment_status')
|
||||
rows = cursor.fetchall()
|
||||
result = {}
|
||||
for row in rows:
|
||||
result[row['email']] = {
|
||||
'status': row['status'],
|
||||
'payment_time': row['payment_time'],
|
||||
'refund_time': row['refund_time'],
|
||||
'suspended_time': row['suspended_time'],
|
||||
'title': row['title'] or '',
|
||||
'remark': row['remark'] or '',
|
||||
'card_number': row['card_number'] or '',
|
||||
'proxy': row['proxy'] or '',
|
||||
'proxy_expire_days': row['proxy_expire_days'] or 30,
|
||||
'proxy_share': row['proxy_share'] or 'exclusive',
|
||||
'proxy_purchase_date': row['proxy_purchase_date'] or '',
|
||||
'checked_at': row['checked_at']
|
||||
}
|
||||
return result
|
||||
|
||||
return await asyncio.to_thread(_sync_get)
|
||||
|
||||
async def update_claude_payment_note(self, email: str, **kwargs) -> bool:
|
||||
"""更新备注、卡号、代理等字段"""
|
||||
allowed = {'title', 'remark', 'card_number', 'proxy', 'proxy_expire_days', 'proxy_share', 'proxy_purchase_date'}
|
||||
def _sync_update():
|
||||
try:
|
||||
conn = self.get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT 1 FROM claude_payment_status WHERE email = ?', (email,))
|
||||
if not cursor.fetchone():
|
||||
cursor.execute("INSERT INTO claude_payment_status (email, status) VALUES (?, 'unknown')", (email,))
|
||||
updates = []
|
||||
params = []
|
||||
for k, v in kwargs.items():
|
||||
if k in allowed and v is not None:
|
||||
updates.append(f'{k} = ?')
|
||||
params.append(v)
|
||||
if updates:
|
||||
params.append(email)
|
||||
cursor.execute(f"UPDATE claude_payment_status SET {', '.join(updates)} WHERE email = ?", params)
|
||||
conn.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"更新备注/卡号失败: {e}")
|
||||
return False
|
||||
|
||||
return await asyncio.to_thread(_sync_update)
|
||||
|
||||
def close(self):
|
||||
"""关闭数据库连接"""
|
||||
if hasattr(self._local, 'connection'):
|
||||
self._local.connection.close()
|
||||
delattr(self._local, 'connection')
|
||||
|
||||
# 全局数据库管理器实例
|
||||
db_manager = DatabaseManager()
|
||||
31
docker-compose.yml
Normal file
31
docker-compose.yml
Normal file
@@ -0,0 +1,31 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
outlook-mail-system:
|
||||
build: .
|
||||
container_name: outlook-mail-automation
|
||||
ports:
|
||||
- "5000:5000"
|
||||
volumes:
|
||||
# 挂载配置文件,便于修改邮箱配置
|
||||
- ./config.txt:/app/config.txt
|
||||
# 可选:挂载日志目录
|
||||
- ./logs:/app/logs
|
||||
environment:
|
||||
# 管理员令牌,可以通过环境变量设置
|
||||
- ADMIN_TOKEN=${ADMIN_TOKEN:-admin123}
|
||||
# 可选:设置时区
|
||||
- TZ=Asia/Shanghai
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:5000/"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
networks:
|
||||
- outlook-mail-network
|
||||
|
||||
networks:
|
||||
outlook-mail-network:
|
||||
driver: bridge
|
||||
370
imap_client.py
Normal file
370
imap_client.py
Normal file
@@ -0,0 +1,370 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
IMAP邮件客户端模块
|
||||
处理IMAP连接和邮件获取操作
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import imaplib
|
||||
import email
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional
|
||||
from email.header import decode_header
|
||||
from email import utils as email_utils
|
||||
from fastapi import HTTPException
|
||||
|
||||
from config import IMAP_SERVER, IMAP_PORT, INBOX_FOLDER_NAME
|
||||
from auth import get_access_token
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ============================================================================
|
||||
# 辅助函数
|
||||
# ============================================================================
|
||||
|
||||
def decode_header_value(header_value):
|
||||
"""解码邮件头部信息"""
|
||||
if header_value is None:
|
||||
return ""
|
||||
decoded_string = ""
|
||||
try:
|
||||
parts = decode_header(str(header_value))
|
||||
for part, charset in parts:
|
||||
if isinstance(part, bytes):
|
||||
try:
|
||||
decoded_string += part.decode(charset if charset else 'utf-8', 'replace')
|
||||
except LookupError:
|
||||
decoded_string += part.decode('utf-8', 'replace')
|
||||
else:
|
||||
decoded_string += str(part)
|
||||
except Exception:
|
||||
if isinstance(header_value, str):
|
||||
return header_value
|
||||
try:
|
||||
return str(header_value, 'utf-8', 'replace') if isinstance(header_value, bytes) else str(header_value)
|
||||
except:
|
||||
return "[Header Decode Error]"
|
||||
return decoded_string
|
||||
|
||||
# ============================================================================
|
||||
# IMAP客户端类
|
||||
# ============================================================================
|
||||
|
||||
class IMAPEmailClient:
|
||||
"""IMAP邮件客户端(按需连接模式)"""
|
||||
|
||||
def __init__(self, email: str, account_info: Dict):
|
||||
"""初始化IMAP邮件客户端
|
||||
|
||||
Args:
|
||||
email: 邮箱地址
|
||||
account_info: 包含refresh_token的账户信息
|
||||
"""
|
||||
self.email = email
|
||||
self.refresh_token = account_info['refresh_token']
|
||||
self.client_id = account_info.get('client_id', '')
|
||||
self.access_token = ''
|
||||
self.expires_at = 0
|
||||
|
||||
# Token管理锁
|
||||
self._token_lock = asyncio.Lock()
|
||||
|
||||
logger.debug(f"IMAPEmailClient初始化 ({email}),采用按需连接策略")
|
||||
|
||||
def is_token_expired(self) -> bool:
|
||||
"""检查access token是否过期或即将过期"""
|
||||
buffer_time = 300 # 5分钟缓冲时间
|
||||
return datetime.now().timestamp() + buffer_time >= self.expires_at
|
||||
|
||||
async def ensure_token_valid(self):
|
||||
"""确保token有效(异步版本,带并发控制)"""
|
||||
async with self._token_lock:
|
||||
if not self.access_token or self.is_token_expired():
|
||||
logger.info(f"{self.email} access token已过期或不存在,需要刷新")
|
||||
await self.refresh_access_token()
|
||||
|
||||
async def refresh_access_token(self) -> None:
|
||||
"""刷新访问令牌"""
|
||||
try:
|
||||
logger.info(f"🔑 正在刷新 {self.email} 的访问令牌...")
|
||||
access_token = await get_access_token(self.refresh_token, client_id=self.client_id)
|
||||
|
||||
if access_token:
|
||||
self.access_token = access_token
|
||||
self.expires_at = time.time() + 3600 # 默认1小时过期
|
||||
expires_at_str = datetime.fromtimestamp(self.expires_at).strftime('%Y-%m-%d %H:%M:%S')
|
||||
logger.info(f"✓ Token刷新成功(有效期至: {expires_at_str})")
|
||||
else:
|
||||
raise HTTPException(status_code=401, detail="Failed to refresh access token")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"✗ Token刷新失败 {self.email}: {e}")
|
||||
raise
|
||||
|
||||
async def create_imap_connection(self, mailbox_to_select=INBOX_FOLDER_NAME):
|
||||
"""创建IMAP连接(按需创建,带超时和重试)"""
|
||||
await self.ensure_token_valid()
|
||||
|
||||
max_retries = 3
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
if attempt > 0:
|
||||
logger.info(f"🔄 重试连接 IMAP (第{attempt+1}次)")
|
||||
|
||||
def _sync_connect():
|
||||
imap_conn = imaplib.IMAP4_SSL(IMAP_SERVER, IMAP_PORT)
|
||||
auth_string = f"user={self.email}\1auth=Bearer {self.access_token}\1\1"
|
||||
typ, data = imap_conn.authenticate('XOAUTH2', lambda x: auth_string.encode('utf-8'))
|
||||
|
||||
if typ == 'OK':
|
||||
stat_select, data_select = imap_conn.select(mailbox_to_select, readonly=True)
|
||||
if stat_select == 'OK':
|
||||
return imap_conn
|
||||
else:
|
||||
error_msg = data_select[0].decode('utf-8', 'replace') if data_select and data_select[0] else "未知错误"
|
||||
raise Exception(f"选择邮箱 '{mailbox_to_select}' 失败: {error_msg}")
|
||||
else:
|
||||
error_message = data[0].decode('utf-8', 'replace') if data and data[0] else "未知认证错误"
|
||||
raise Exception(f"IMAP XOAUTH2 认证失败: {error_message} (Type: {typ})")
|
||||
|
||||
# 在线程池中执行,带10秒超时
|
||||
imap_conn = await asyncio.wait_for(
|
||||
asyncio.to_thread(_sync_connect), timeout=10.0
|
||||
)
|
||||
logger.info(f"🔌 IMAP连接已建立 → {mailbox_to_select}")
|
||||
return imap_conn
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
logger.error(f"创建IMAP连接超时 ({self.email}), 第{attempt+1}次尝试")
|
||||
if attempt < max_retries - 1:
|
||||
await asyncio.sleep(1)
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.error(f"创建IMAP连接失败 ({self.email}), 第{attempt+1}次尝试: {e}")
|
||||
if attempt < max_retries - 1:
|
||||
await asyncio.sleep(1)
|
||||
continue
|
||||
|
||||
logger.error(f"经过{max_retries}次尝试,仍无法创建IMAP连接 ({self.email})")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to connect to IMAP server for {self.email}")
|
||||
|
||||
def close_imap_connection(self, imap_conn):
|
||||
"""安全关闭IMAP连接"""
|
||||
if imap_conn:
|
||||
try:
|
||||
current_state = getattr(imap_conn, 'state', None)
|
||||
|
||||
try:
|
||||
if current_state == 'SELECTED':
|
||||
imap_conn.close()
|
||||
except Exception as e:
|
||||
logger.debug(f"关闭邮箱时出现预期错误: {e}")
|
||||
|
||||
try:
|
||||
if current_state != 'LOGOUT':
|
||||
imap_conn.logout()
|
||||
except Exception as e:
|
||||
logger.debug(f"登出时出现预期错误: {e}")
|
||||
|
||||
logger.info(f"🔌 IMAP连接已关闭")
|
||||
except Exception as e:
|
||||
logger.debug(f"关闭IMAP连接时发生预期错误: {e}")
|
||||
|
||||
async def get_messages_with_content(self, folder_id: str = INBOX_FOLDER_NAME, top: int = 5) -> List[Dict]:
|
||||
"""获取指定文件夹的邮件(一次性获取完整内容,包括正文)
|
||||
|
||||
优化点:
|
||||
- 一次性获取邮件的完整内容(头部+正文)
|
||||
- 前端可以缓存这些数据,查看详情时无需再次请求
|
||||
|
||||
Args:
|
||||
folder_id: 文件夹ID, 默认为'INBOX'
|
||||
top: 获取的邮件数量
|
||||
"""
|
||||
import time
|
||||
start_time = time.time()
|
||||
logger.info(f"📧 开始获取 {self.email} 的邮件(文件夹: {folder_id}, 请求数量: {top})")
|
||||
|
||||
imap_conn = None
|
||||
try:
|
||||
imap_conn = await self.create_imap_connection(folder_id)
|
||||
|
||||
def _sync_get_messages_full():
|
||||
# 快速扫描邮件UID列表(毫秒级操作)
|
||||
scan_start = time.time()
|
||||
typ, uid_data = imap_conn.uid('search', None, "ALL")
|
||||
if typ != 'OK':
|
||||
raise Exception(f"在 '{folder_id}' 中搜索邮件失败 (status: {typ})。")
|
||||
|
||||
if not uid_data[0]:
|
||||
return []
|
||||
|
||||
uids = uid_data[0].split()
|
||||
scan_time = (time.time() - scan_start) * 1000
|
||||
logger.info(f"📋 扫描完成: 共 {len(uids)} 封邮件 (耗时: {scan_time:.0f}ms)")
|
||||
|
||||
# 只获取最新的top条邮件
|
||||
uids = uids[-top:] if len(uids) > top else uids
|
||||
uids.reverse() # 最新的在前
|
||||
|
||||
fetch_start = time.time()
|
||||
logger.info(f"📥 开始获取 {len(uids)} 封邮件的完整内容(包含正文和附件)...")
|
||||
|
||||
messages = []
|
||||
for idx, uid_bytes in enumerate(uids, 1):
|
||||
try:
|
||||
# 一次性获取完整邮件内容(RFC822)
|
||||
typ, msg_data = imap_conn.uid('fetch', uid_bytes, '(RFC822)')
|
||||
|
||||
if typ == 'OK' and msg_data and msg_data[0] is not None:
|
||||
raw_email_bytes = None
|
||||
if isinstance(msg_data[0], tuple) and len(msg_data[0]) == 2:
|
||||
raw_email_bytes = msg_data[0][1]
|
||||
|
||||
if raw_email_bytes:
|
||||
email_message = email.message_from_bytes(raw_email_bytes)
|
||||
|
||||
# 解析头部信息
|
||||
subject = decode_header_value(email_message['Subject']) or "(No Subject)"
|
||||
from_str = decode_header_value(email_message['From']) or "(Unknown Sender)"
|
||||
to_str = decode_header_value(email_message['To']) or ""
|
||||
date_str = email_message['Date'] or "(Unknown Date)"
|
||||
|
||||
# 解析From字段
|
||||
from_name = "(Unknown)"
|
||||
from_email = ""
|
||||
if '<' in from_str and '>' in from_str:
|
||||
from_name = from_str.split('<')[0].strip().strip('"')
|
||||
from_email = from_str.split('<')[1].split('>')[0].strip()
|
||||
else:
|
||||
from_email = from_str.strip()
|
||||
if '@' in from_email:
|
||||
from_name = from_email.split('@')[0]
|
||||
|
||||
# 解析日期
|
||||
try:
|
||||
dt_obj = email_utils.parsedate_to_datetime(date_str)
|
||||
if dt_obj:
|
||||
date_str = dt_obj.strftime('%Y-%m-%d %H:%M:%S')
|
||||
except Exception:
|
||||
date_str = date_str[:25] if len(date_str) > 25 else date_str
|
||||
|
||||
# 解析邮件正文(优先HTML)
|
||||
body_content = ""
|
||||
body_type = "text"
|
||||
body_preview = ""
|
||||
|
||||
if email_message.is_multipart():
|
||||
html_content = None
|
||||
text_content = None
|
||||
|
||||
for part in email_message.walk():
|
||||
content_type = part.get_content_type()
|
||||
content_disposition = str(part.get("Content-Disposition"))
|
||||
|
||||
if 'attachment' not in content_disposition.lower():
|
||||
try:
|
||||
charset = part.get_content_charset() or 'utf-8'
|
||||
payload = part.get_payload(decode=True)
|
||||
|
||||
if content_type == 'text/html' and not html_content:
|
||||
html_content = payload.decode(charset, errors='replace')
|
||||
elif content_type == 'text/plain' and not text_content:
|
||||
text_content = payload.decode(charset, errors='replace')
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# 优先使用HTML内容
|
||||
if html_content:
|
||||
body_content = html_content
|
||||
body_type = "html"
|
||||
# 生成预览文本(移除HTML标签)
|
||||
import re
|
||||
body_preview = re.sub('<[^<]+?>', '', html_content)[:150]
|
||||
elif text_content:
|
||||
body_content = text_content
|
||||
body_type = "text"
|
||||
body_preview = text_content[:150]
|
||||
else:
|
||||
body_content = "[未找到可读的邮件内容]"
|
||||
body_preview = "[未找到可读的邮件内容]"
|
||||
else:
|
||||
# 非多部分邮件
|
||||
try:
|
||||
charset = email_message.get_content_charset() or 'utf-8'
|
||||
payload = email_message.get_payload(decode=True)
|
||||
body_content = payload.decode(charset, errors='replace')
|
||||
|
||||
# 检查是否为HTML内容
|
||||
if '<html' in body_content.lower() or '<body' in body_content.lower():
|
||||
body_type = "html"
|
||||
import re
|
||||
body_preview = re.sub('<[^<]+?>', '', body_content)[:150]
|
||||
else:
|
||||
body_preview = body_content[:150]
|
||||
except Exception:
|
||||
body_content = "[Failed to decode email body]"
|
||||
body_preview = "[Failed to decode email body]"
|
||||
|
||||
if not body_content:
|
||||
body_content = "[未找到可读的文本内容]"
|
||||
body_preview = "[未找到可读的文本内容]"
|
||||
|
||||
# 构建完整邮件信息(包含正文)
|
||||
message = {
|
||||
'id': uid_bytes.decode('utf-8'),
|
||||
'subject': subject,
|
||||
'receivedDateTime': date_str,
|
||||
'sender': {
|
||||
'emailAddress': {
|
||||
'address': from_email,
|
||||
'name': from_name
|
||||
}
|
||||
},
|
||||
'from': {
|
||||
'emailAddress': {
|
||||
'address': from_email,
|
||||
'name': from_name
|
||||
}
|
||||
},
|
||||
'toRecipients': [{'emailAddress': {'address': to_str, 'name': to_str}}] if to_str else [],
|
||||
'body': {
|
||||
'content': body_content,
|
||||
'contentType': body_type
|
||||
},
|
||||
'bodyPreview': body_preview
|
||||
}
|
||||
messages.append(message)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f" ✗ 处理邮件UID {uid_bytes}时出错: {e}")
|
||||
continue
|
||||
|
||||
fetch_time = (time.time() - fetch_start) * 1000
|
||||
logger.info(f"📬 内容获取完成: {len(messages)} 封邮件 (耗时: {fetch_time:.0f}ms, 平均: {fetch_time/len(messages) if messages else 0:.0f}ms/封)")
|
||||
|
||||
return messages
|
||||
|
||||
# 在线程池中执行同步IMAP操作
|
||||
messages = await asyncio.to_thread(_sync_get_messages_full)
|
||||
|
||||
total_time = (time.time() - start_time) * 1000
|
||||
logger.info(f"✅ 完成!总耗时: {total_time:.0f}ms | 获取 {len(messages)} 封完整邮件(已包含正文,前端可缓存)")
|
||||
return messages
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.warning(f"获取邮件操作被取消 ({self.email})")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"获取邮件失败 {self.email}: {e}")
|
||||
raise HTTPException(status_code=500, detail="Failed to retrieve emails")
|
||||
finally:
|
||||
if imap_conn:
|
||||
self.close_imap_connection(imap_conn)
|
||||
|
||||
async def cleanup(self):
|
||||
"""清理资源"""
|
||||
logger.debug(f"IMAPEmailClient清理完成 ({self.email})")
|
||||
|
||||
1298
mail_api.py
Normal file
1298
mail_api.py
Normal file
File diff suppressed because it is too large
Load Diff
125
models.py
Normal file
125
models.py
Normal file
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
数据模型定义
|
||||
Pydantic模型用于API请求和响应
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Optional, Union
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
# ============================================================================
|
||||
# 请求模型
|
||||
# ============================================================================
|
||||
|
||||
class EmailVerifyRequest(BaseModel):
|
||||
email: EmailStr
|
||||
|
||||
class EmailListRequest(BaseModel):
|
||||
email: EmailStr
|
||||
top: int = 5
|
||||
|
||||
class EmailDetailRequest(BaseModel):
|
||||
email: EmailStr
|
||||
message_id: str
|
||||
|
||||
class AccountCredentials(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
client_id: str
|
||||
refresh_token: str
|
||||
|
||||
class ImportAccountData(BaseModel):
|
||||
"""单个导入账户数据模型"""
|
||||
email: str # 暂时使用str而不EmailStr避免验证问题
|
||||
password: str = ""
|
||||
client_id: str = ""
|
||||
refresh_token: str
|
||||
|
||||
class ImportRequest(BaseModel):
|
||||
"""批量导入请求模型"""
|
||||
accounts: List[ImportAccountData]
|
||||
merge_mode: str = "update" # "update": 更新现有账户, "skip": 跳过重复账户, "replace": 替换所有数据
|
||||
|
||||
class ParsedImportRequest(BaseModel):
|
||||
"""解析后的导入请求模型(包含解析统计信息)"""
|
||||
accounts: List[ImportAccountData]
|
||||
parsed_count: int
|
||||
error_count: int
|
||||
errors: List[str]
|
||||
merge_mode: str = "update"
|
||||
|
||||
class ImportResult(BaseModel):
|
||||
"""导入结果模型"""
|
||||
success: bool
|
||||
total_count: int
|
||||
added_count: int
|
||||
updated_count: int
|
||||
skipped_count: int
|
||||
error_count: int
|
||||
details: List[Dict[str, str]] # 详细信息
|
||||
message: str
|
||||
|
||||
class AdminTokenRequest(BaseModel):
|
||||
"""管理令牌验证请求"""
|
||||
token: str
|
||||
|
||||
class DeleteAccountRequest(BaseModel):
|
||||
"""删除账户请求"""
|
||||
email: EmailStr
|
||||
|
||||
class TempAccountRequest(BaseModel):
|
||||
"""临时账户请求"""
|
||||
email: EmailStr
|
||||
password: str = ""
|
||||
client_id: str = ""
|
||||
refresh_token: str
|
||||
top: int = 5
|
||||
folder: str = "INBOX"
|
||||
|
||||
class TempMessageDetailRequest(BaseModel):
|
||||
"""临时账户邮件详情请求"""
|
||||
email: EmailStr
|
||||
password: str = ""
|
||||
client_id: str = ""
|
||||
refresh_token: str
|
||||
message_id: str
|
||||
|
||||
class SystemConfigRequest(BaseModel):
|
||||
"""系统配置请求"""
|
||||
email_limit: int = 5
|
||||
|
||||
class AccountTagRequest(BaseModel):
|
||||
"""账户标签请求"""
|
||||
email: EmailStr
|
||||
tags: List[str] = []
|
||||
|
||||
class TestEmailRequest(BaseModel):
|
||||
"""测试邮件请求模型"""
|
||||
email: EmailStr
|
||||
password: str = ""
|
||||
client_id: str = ""
|
||||
refresh_token: str = ""
|
||||
|
||||
# ============================================================================
|
||||
# 响应模型
|
||||
# ============================================================================
|
||||
|
||||
class ApiResponse(BaseModel):
|
||||
success: bool
|
||||
message: str = ""
|
||||
data: Optional[Union[Dict, List, str]] = None
|
||||
|
||||
class EmailMessage(BaseModel):
|
||||
"""邮件消息模型"""
|
||||
id: str
|
||||
subject: str
|
||||
receivedDateTime: str
|
||||
sender: Dict
|
||||
from_: Optional[Dict] = None
|
||||
body: Dict
|
||||
bodyPreview: str = ""
|
||||
toRecipients: Optional[List[Dict]] = None
|
||||
|
||||
class Config:
|
||||
fields = {'from_': 'from'}
|
||||
|
||||
8
requirements.txt
Normal file
8
requirements.txt
Normal file
@@ -0,0 +1,8 @@
|
||||
fastapi==0.104.1
|
||||
uvicorn[standard]==0.24.0
|
||||
pydantic==2.5.0
|
||||
httpx==0.25.2
|
||||
requests==2.31.0
|
||||
python-multipart==0.0.6
|
||||
jinja2==3.1.2
|
||||
aiofiles==23.2.1
|
||||
337
static/admin.html
Normal file
337
static/admin.html
Normal file
@@ -0,0 +1,337 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Outlook邮件系统 - 账号管理</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<style>
|
||||
/* 背景与容器风格对齐用户页 */
|
||||
body {
|
||||
background: #f5f7fa;
|
||||
}
|
||||
.admin-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
.admin-card {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
max-width: 1100px;
|
||||
animation: slideIn 0.5s ease-out;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@keyframes slideIn {
|
||||
from { opacity: 0; transform: translateY(30px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.admin-header {
|
||||
background: #0078d4;
|
||||
color: white;
|
||||
padding: 16px 18px;
|
||||
text-align: center;
|
||||
}
|
||||
.admin-header h2 { font-size: 18px; font-weight: 600; margin: 0 0 4px 0; }
|
||||
.admin-header p { margin: 0; opacity: .9; }
|
||||
.admin-content { padding: 16px; }
|
||||
.login-section { max-width: 420px; margin: 0 auto; }
|
||||
.management-section { display: none; }
|
||||
|
||||
/* 账号卡片风格 */
|
||||
.account-item {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
padding: 14px;
|
||||
margin-bottom: 12px;
|
||||
background: #ffffff;
|
||||
transition: box-shadow .2s ease, border-color .2s ease;
|
||||
}
|
||||
.account-item:hover {
|
||||
border-color: #cbd5e0;
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* 按钮与输入对齐用户页 */
|
||||
.btn-admin {
|
||||
background: #0078d4;
|
||||
border: none;
|
||||
color: white;
|
||||
border-radius: 6px;
|
||||
padding: 6px 12px;
|
||||
transition: all 0.2s ease;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
}
|
||||
.btn-admin:hover { background: #005a9e; color: #fff; transform: translateY(-1px); }
|
||||
|
||||
.input-group-custom { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
|
||||
.input-group-custom input {
|
||||
flex: 1; min-width: 200px; padding: 6px 10px; border: 1px solid #e2e8f0; border-radius: 6px; font-size: 13px; outline: none; transition: all .3s;
|
||||
}
|
||||
.input-group-custom input:focus { border-color: #0078d4; box-shadow: 0 0 0 3px rgba(0, 120, 212, 0.1); }
|
||||
.btn-custom { padding: 6px 10px; border: none; border-radius: 6px; font-weight: 500; cursor: pointer; transition: all .3s; display: inline-flex; align-items: center; gap: 6px; font-size: 13px; }
|
||||
.btn-primary-custom { background: #0078d4; color: #fff; }
|
||||
.btn-primary-custom:hover { background: #005a9e; }
|
||||
.btn-outline-secondary { border: 1px solid #e2e8f0; color: #718096; padding: 6px 8px; border-radius: 6px; font-size: 12px; }
|
||||
|
||||
/* 选项卡与内容区域 */
|
||||
.nav-tabs .nav-link { border: 1px solid transparent; border-radius: 8px 8px 0 0; color: #0078d4; padding: 6px 10px; font-size: 13px; }
|
||||
.nav-tabs .nav-link.active { background: #0078d4; color: white; border-color: #0078d4; }
|
||||
.tab-content { border: 1px solid #e2e8f0; border-top: none; border-radius: 0 0 8px 8px; padding: 12px; background: white; max-height: 55vh; overflow-y: auto; }
|
||||
.admin-content { padding: 16px; flex: 1; overflow-y: auto; max-height: calc(100vh - 160px); }
|
||||
|
||||
/* 模态风格对齐用户页 */
|
||||
.modal-content { border: none; border-radius: 12px; box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2); }
|
||||
.modal-header { background: #0078d4; color: white; border-radius: 12px 12px 0 0; border-bottom: none; padding: 15px 20px; }
|
||||
.modal-title { font-size: 16px; font-weight: 600; }
|
||||
.modal-header .btn-close { filter: brightness(0) invert(1); opacity: 0.8; }
|
||||
.modal-header .btn-close:hover { opacity: 1; }
|
||||
.modal-body .form-label { font-weight: 600; color: #2d3748; margin-bottom: 6px; font-size: 14px; }
|
||||
.modal-body .form-control { border: 1px solid #e2e8f0; border-radius: 6px; padding: 8px 12px; transition: all 0.3s; font-size: 14px; }
|
||||
.modal-body .form-control:focus { border-color: #0078d4; box-shadow: 0 0 0 3px rgba(0, 120, 212, 0.1); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="admin-container">
|
||||
<div class="admin-card">
|
||||
<div class="admin-header P-3">
|
||||
<h2><i class="bi bi-gear me-2"></i>Outlook邮件系统 - 账号管理</h2>
|
||||
</div>
|
||||
|
||||
<div class="admin-content">
|
||||
<!-- 登录验证区域 -->
|
||||
<div id="loginSection" class="login-section">
|
||||
<div class="text-center mb-4">
|
||||
<i class="bi bi-shield-lock display-4 text-primary"></i>
|
||||
<h4 class="mt-3">身份验证</h4>
|
||||
<p class="text-muted">请输入管理令牌以访问账号管理功能</p>
|
||||
</div>
|
||||
|
||||
<form id="loginForm">
|
||||
<div class="mb-3">
|
||||
<label for="tokenInput" class="form-label">管理令牌</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">
|
||||
<i class="bi bi-key"></i>
|
||||
</span>
|
||||
<input type="password" class="form-control" id="tokenInput"
|
||||
placeholder="请输入管理令牌" required>
|
||||
</div>
|
||||
<div class="form-text">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
默认令牌:admin123(可通过环境变量 ADMIN_TOKEN 修改)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-admin w-100">
|
||||
<i class="bi bi-unlock me-2"></i>验证并登录
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="text-center mt-3">
|
||||
<a href="/" class="text-decoration-none">
|
||||
<i class="bi bi-arrow-left me-1"></i>返回邮件管理
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 管理功能区域(单页紧凑版) -->
|
||||
<div id="managementSection" class="management-section">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div class="d-flex justify-content-end gap-2">
|
||||
<button class="btn btn-outline-secondary btn-sm" id="openImportModalBtn" title="导入">
|
||||
<i class="bi bi-upload"></i> 导入
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" id="exportDataBtn" title="导出">
|
||||
<i class="bi bi-download"></i> 导出
|
||||
</button>
|
||||
</div>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<div class="input-group-custom" style="width: 280px;">
|
||||
<input type="text" id="accountSearchInput" placeholder="搜索邮箱...">
|
||||
<button class="btn btn-outline-secondary btn-sm" id="searchAccountsBtn" title="搜索">
|
||||
<i class="bi bi-search"></i>
|
||||
</button>
|
||||
</div>
|
||||
<button class="btn btn-outline-secondary btn-sm" id="refreshAccountsBtn" title="刷新">
|
||||
<i class="bi bi-arrow-clockwise"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="accountsList" class="accounts-list">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="small text-muted" style="width:50%">邮箱</th>
|
||||
<th class="small text-muted">标签</th>
|
||||
<th class="text-end small text-muted" style="width:120px">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="accountsTbody">
|
||||
<tr>
|
||||
<td colspan="3" class="text-center py-4">
|
||||
<div class="spinner-border text-primary" role="status"></div>
|
||||
<div class="mt-2 small text-muted">正在加载账号列表...</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mt-3">
|
||||
<div class="small text-muted" id="accountsPagerInfo">第 1 / 1 页,共 0 条</div>
|
||||
<div class="btn-group" role="group">
|
||||
<button class="btn btn-outline-secondary btn-sm" id="prevPageBtn" title="上一页"><i class="bi bi-chevron-left"></i></button>
|
||||
<button class="btn btn-outline-secondary btn-sm" id="nextPageBtn" title="下一页"><i class="bi bi-chevron-right"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<!-- 退出登录 -->
|
||||
<div class="text-center mt-4">
|
||||
<button class="btn btn-sm btn-outline-danger" id="logoutBtn">
|
||||
<i class="bi bi-box-arrow-right me-2"></i>退出管理
|
||||
</button>
|
||||
<a href="/" class="btn btn-outline-secondary ms-2">
|
||||
<i class="bi bi-arrow-left me-2"></i>返回邮件管理
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 成功提示模态框 -->
|
||||
<div class="modal fade" id="successModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header border-0">
|
||||
<h5 class="modal-title">
|
||||
<i class="bi bi-check-circle me-2"></i>操作成功
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p id="successMessage" class="mb-0"></p>
|
||||
</div>
|
||||
<div class="modal-footer border-0">
|
||||
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">确定</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 错误提示模态框 -->
|
||||
<div class="modal fade" id="errorModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header border-0">
|
||||
<h5 class="modal-title text-danger">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>错误提示
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p id="errorMessage" class="mb-0"></p>
|
||||
</div>
|
||||
<div class="modal-footer border-0">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">确定</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 标签管理模态框(保留用于行内编辑) -->
|
||||
<div class="modal fade" id="tagManagementModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="tagManagementModalTitle">
|
||||
<i class="bi bi-tags me-2"></i>管理标签
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label for="tagManagementInput" class="form-label">标签</label>
|
||||
<input type="text" class="form-control" id="tagManagementInput"
|
||||
placeholder="输入标签,用逗号分隔,如:微软,谷歌,苹果">
|
||||
<div class="form-text">
|
||||
<i class="bi bi-lightbulb me-1"></i>
|
||||
多个标签用逗号分隔,如:微软,谷歌,苹果
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<small class="text-muted">当前标签:</small>
|
||||
<div id="tagManagementCurrentTags" class="mt-1">
|
||||
<span class="text-muted">暂无标签</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-primary" id="tagManagementSaveBtn">
|
||||
<i class="bi bi-save me-1"></i>保存标签
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 导入对话框 -->
|
||||
<div class="modal fade" id="importModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">
|
||||
<i class="bi bi-upload me-2"></i>批量导入账号
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-info" role="alert" style="font-size: 12px;">
|
||||
<i class="bi bi-info-circle"></i>
|
||||
支持:完整格式「邮箱----密码----client_id----refresh_token」或简化格式「邮箱----refresh_token」。
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label for="importTextarea" class="form-label small mb-1">账户数据</label>
|
||||
<textarea class="form-control" id="importTextarea" rows="8" placeholder="每行一个账户,使用四个短横线(----)分隔字段"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label for="mergeMode" class="form-label small mb-1">合并模式</label>
|
||||
<select class="form-select form-select-sm" id="mergeMode">
|
||||
<option value="update">更新现有账户</option>
|
||||
<option value="skip">跳过重复账户</option>
|
||||
<option value="replace">替换所有数据</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-dismiss="modal">取消</button>
|
||||
<button type="button" class="btn btn-admin btn-sm" id="executeImportBtn">
|
||||
<i class="bi bi-upload me-1"></i>导入
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="admin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
743
static/admin.js
Normal file
743
static/admin.js
Normal file
@@ -0,0 +1,743 @@
|
||||
// 账号管理页面JavaScript
|
||||
|
||||
class AdminManager {
|
||||
constructor() {
|
||||
this.isAuthenticated = false;
|
||||
this.token = '';
|
||||
// 分页与搜索状态
|
||||
this.page = 1;
|
||||
this.pageSize = 10;
|
||||
this.total = 0;
|
||||
this.query = '';
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.bindEvents();
|
||||
this.checkStoredAuth();
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
// 登录表单
|
||||
document.getElementById('loginForm').addEventListener('submit', this.handleLogin.bind(this));
|
||||
|
||||
// 刷新账号列表
|
||||
document.getElementById('refreshAccountsBtn').addEventListener('click', () => {
|
||||
this.page = 1;
|
||||
this.loadAccounts();
|
||||
});
|
||||
|
||||
// 搜索
|
||||
const searchInput = document.getElementById('accountSearchInput');
|
||||
const searchBtn = document.getElementById('searchAccountsBtn');
|
||||
if (searchBtn) {
|
||||
searchBtn.addEventListener('click', () => {
|
||||
this.query = searchInput.value.trim();
|
||||
this.page = 1;
|
||||
this.loadAccounts();
|
||||
});
|
||||
}
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
this.query = searchInput.value.trim();
|
||||
this.page = 1;
|
||||
this.loadAccounts();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 分页
|
||||
const prevBtn = document.getElementById('prevPageBtn');
|
||||
const nextBtn = document.getElementById('nextPageBtn');
|
||||
if (prevBtn) {
|
||||
prevBtn.addEventListener('click', () => {
|
||||
if (this.page > 1) {
|
||||
this.page -= 1;
|
||||
this.loadAccounts();
|
||||
}
|
||||
});
|
||||
}
|
||||
if (nextBtn) {
|
||||
nextBtn.addEventListener('click', () => {
|
||||
const maxPage = Math.max(1, Math.ceil(this.total / this.pageSize));
|
||||
if (this.page < maxPage) {
|
||||
this.page += 1;
|
||||
this.loadAccounts();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 导入相关(存在才绑定)
|
||||
const executeImportBtn = document.getElementById('executeImportBtn');
|
||||
if (executeImportBtn) {
|
||||
executeImportBtn.addEventListener('click', this.executeImport.bind(this));
|
||||
}
|
||||
const clearImportBtn = document.getElementById('clearImportBtn');
|
||||
if (clearImportBtn) {
|
||||
clearImportBtn.addEventListener('click', this.clearImport.bind(this));
|
||||
}
|
||||
|
||||
// 导出
|
||||
document.getElementById('exportDataBtn').addEventListener('click', this.exportData.bind(this));
|
||||
|
||||
// 退出登录
|
||||
document.getElementById('logoutBtn').addEventListener('click', this.logout.bind(this));
|
||||
|
||||
// 单页:不使用tabs,直接加载列表
|
||||
setTimeout(() => this.loadAccounts(), 100);
|
||||
|
||||
// 打开导入对话框
|
||||
const openImportBtn = document.getElementById('openImportModalBtn');
|
||||
if (openImportBtn) {
|
||||
openImportBtn.addEventListener('click', () => {
|
||||
const modal = new bootstrap.Modal(document.getElementById('importModal'));
|
||||
modal.show();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
checkStoredAuth() {
|
||||
// 检查是否有保存的认证信息(仅在当前会话有效)
|
||||
const storedToken = sessionStorage.getItem('admin_token');
|
||||
if (storedToken) {
|
||||
this.token = storedToken;
|
||||
this.showManagement();
|
||||
}
|
||||
}
|
||||
|
||||
async handleLogin(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const tokenInput = document.getElementById('tokenInput');
|
||||
const enteredToken = tokenInput.value.trim();
|
||||
|
||||
if (!enteredToken) {
|
||||
this.showError('请输入管理令牌');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 验证令牌
|
||||
const isValid = await this.verifyToken(enteredToken);
|
||||
|
||||
if (isValid) {
|
||||
this.token = enteredToken;
|
||||
this.isAuthenticated = true;
|
||||
|
||||
// 保存到会话存储
|
||||
sessionStorage.setItem('admin_token', enteredToken);
|
||||
|
||||
this.showManagement();
|
||||
this.showSuccess('登录成功');
|
||||
} else {
|
||||
this.showError('令牌验证失败,请检查输入的令牌是否正确');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('登录失败:', error);
|
||||
this.showError('登录失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async verifyToken(token) {
|
||||
try {
|
||||
// 发送验证请求到后端
|
||||
const response = await fetch('/api/admin/verify', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ token: token })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
return result.success;
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('令牌验证错误:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
showManagement() {
|
||||
document.getElementById('loginSection').style.display = 'none';
|
||||
document.getElementById('managementSection').style.display = 'block';
|
||||
|
||||
// 自动加载账号列表
|
||||
this.loadAccounts();
|
||||
}
|
||||
|
||||
async loadAccounts() {
|
||||
const accountsList = document.getElementById('accountsList');
|
||||
|
||||
// 显示加载状态(表格内)
|
||||
const tbodyLoading = document.getElementById('accountsTbody');
|
||||
if (tbodyLoading) {
|
||||
tbodyLoading.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="3" class="text-center py-4">
|
||||
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
|
||||
<div class="mt-2 small text-muted">正在加载账号列表...</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', String(this.page));
|
||||
params.set('page_size', String(this.pageSize));
|
||||
if (this.query) params.set('q', this.query);
|
||||
const response = await fetch(`/api/accounts/paged?${params.toString()}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
const items = (result.data && result.data.items) ? result.data.items : [];
|
||||
this.total = (result.data && typeof result.data.total === 'number') ? result.data.total : items.length;
|
||||
this.page = (result.data && typeof result.data.page === 'number') ? result.data.page : this.page;
|
||||
this.pageSize = (result.data && typeof result.data.page_size === 'number') ? result.data.page_size : this.pageSize;
|
||||
this.renderAccounts(items);
|
||||
this.renderPager();
|
||||
} else {
|
||||
throw new Error(result.message);
|
||||
}
|
||||
} else {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载账号列表失败:', error);
|
||||
accountsList.innerHTML = `
|
||||
<div class="text-center py-4 text-danger">
|
||||
<i class="bi bi-exclamation-triangle display-4"></i>
|
||||
<div class="mt-2">加载失败: ${error.message}</div>
|
||||
<button class="btn btn-outline-primary btn-sm mt-2" onclick="adminManager.loadAccounts()">
|
||||
<i class="bi bi-arrow-clockwise me-1"></i>重试
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
async renderAccounts(accounts) {
|
||||
const tbody = document.getElementById('accountsTbody');
|
||||
if (!tbody) return;
|
||||
|
||||
if (accounts.length === 0) {
|
||||
tbody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="3" class="text-center py-4 text-muted">
|
||||
<i class="bi bi-inbox"></i>
|
||||
<div class="mt-2 small">暂无账号数据</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取所有账户的标签信息
|
||||
const accountsWithTags = await this.loadAccountsWithTags();
|
||||
|
||||
const accountsHtml = accounts.map((account, index) => {
|
||||
const tags = accountsWithTags[account.email] || [];
|
||||
const tagsHtml = tags.length > 0 ?
|
||||
tags.map(tag => `<span class="badge bg-secondary me-1 mb-1">${this.escapeHtml(tag)}</span>`).join('') :
|
||||
'<span class="text-muted small">无标签</span>';
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td class="small"><i class="bi bi-envelope me-1"></i>${account.email}</td>
|
||||
<td>${tagsHtml}</td>
|
||||
<td class="text-end">
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button class="btn btn-outline-secondary btn-sm"
|
||||
onclick="adminManager.showTagManagementDialog('${account.email}')"
|
||||
title="管理标签">
|
||||
<i class="bi bi-tags"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
tbody.innerHTML = accountsHtml;
|
||||
}
|
||||
|
||||
// 已移除测试与系统配置相关功能
|
||||
|
||||
// 删除账户功能不在精简范围内,已移除调用
|
||||
|
||||
clearImport() {
|
||||
document.getElementById('importTextarea').value = '';
|
||||
document.getElementById('mergeMode').value = 'update';
|
||||
this.showSuccess('已清空导入内容');
|
||||
}
|
||||
|
||||
async executeImport() {
|
||||
const textarea = document.getElementById('importTextarea');
|
||||
const mergeMode = document.getElementById('mergeMode');
|
||||
|
||||
const importText = textarea.value.trim();
|
||||
if (!importText) {
|
||||
this.showError('请输入要导入的账户数据');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 解析文本
|
||||
const parseResponse = await fetch('/api/parse-import-text', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.token}`
|
||||
},
|
||||
body: JSON.stringify({ text: importText })
|
||||
});
|
||||
|
||||
if (!parseResponse.ok) {
|
||||
throw new Error(`解析失败: HTTP ${parseResponse.status}`);
|
||||
}
|
||||
|
||||
const parseResult = await parseResponse.json();
|
||||
if (!parseResult.success) {
|
||||
throw new Error(parseResult.message);
|
||||
}
|
||||
|
||||
// 执行导入
|
||||
const importResponse = await fetch('/api/import', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
accounts: parseResult.data.accounts, // 只提取accounts数组
|
||||
merge_mode: mergeMode.value
|
||||
})
|
||||
});
|
||||
|
||||
if (!importResponse.ok) {
|
||||
throw new Error(`导入失败: HTTP ${importResponse.status}`);
|
||||
}
|
||||
|
||||
const importResult = await importResponse.json();
|
||||
|
||||
if (importResult.success) {
|
||||
this.showSuccess(`导入完成! 新增: ${importResult.added_count}, 更新: ${importResult.updated_count}, 跳过: ${importResult.skipped_count}`);
|
||||
// 清空导入内容
|
||||
document.getElementById('importTextarea').value = '';
|
||||
this.loadAccounts(); // 刷新账号列表
|
||||
} else {
|
||||
this.showError(`导入失败: ${importResult.message}`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('导入失败:', error);
|
||||
this.showError(`导入失败: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async exportData() {
|
||||
try {
|
||||
const response = await fetch('/api/export');
|
||||
|
||||
if (response.ok) {
|
||||
// 直接获取文本内容
|
||||
const content = await response.text();
|
||||
|
||||
// 从响应头获取文件名
|
||||
const contentDisposition = response.headers.get('Content-Disposition');
|
||||
let filename = 'outlook_accounts_config.txt';
|
||||
if (contentDisposition) {
|
||||
const match = contentDisposition.match(/filename=(.+)/);
|
||||
if (match) {
|
||||
filename = match[1];
|
||||
}
|
||||
}
|
||||
|
||||
// 下载文件
|
||||
this.downloadTextFile(content, filename);
|
||||
this.showSuccess('数据导出成功');
|
||||
} else {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('导出失败:', error);
|
||||
this.showError(`导出失败: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
downloadTextFile(content, filename) {
|
||||
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
logout() {
|
||||
this.isAuthenticated = false;
|
||||
this.token = '';
|
||||
sessionStorage.removeItem('admin_token');
|
||||
|
||||
document.getElementById('managementSection').style.display = 'none';
|
||||
document.getElementById('loginSection').style.display = 'block';
|
||||
|
||||
// 清空表单
|
||||
document.getElementById('tokenInput').value = '';
|
||||
|
||||
this.showSuccess('已安全退出管理');
|
||||
}
|
||||
|
||||
showSuccess(message) {
|
||||
document.getElementById('successMessage').textContent = message;
|
||||
const modal = new bootstrap.Modal(document.getElementById('successModal'));
|
||||
modal.show();
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
document.getElementById('errorMessage').textContent = message;
|
||||
const modal = new bootstrap.Modal(document.getElementById('errorModal'));
|
||||
modal.show();
|
||||
}
|
||||
|
||||
// ==================== 标签管理功能 ====================
|
||||
|
||||
async loadAccountsWithTags() {
|
||||
try {
|
||||
const response = await fetch('/api/accounts/tags', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.token}`
|
||||
}
|
||||
});
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
return result.data.accounts || {};
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载账户标签失败:', error);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
async showTagManagementDialog(email) {
|
||||
// 显示标签管理对话框
|
||||
const modal = new bootstrap.Modal(document.getElementById('tagManagementModal'));
|
||||
const modalTitle = document.getElementById('tagManagementModalTitle');
|
||||
const tagInput = document.getElementById('tagManagementInput');
|
||||
const currentTagsDisplay = document.getElementById('tagManagementCurrentTags');
|
||||
|
||||
modalTitle.textContent = `管理标签 - ${email}`;
|
||||
|
||||
// 加载当前标签
|
||||
try {
|
||||
const response = await fetch(`/api/account/${encodeURIComponent(email)}/tags`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
const tags = result.data.tags || [];
|
||||
tagInput.value = tags.join(',');
|
||||
|
||||
if (tags.length > 0) {
|
||||
const tagsHtml = tags.map(tag =>
|
||||
`<span class="badge bg-secondary me-1">${this.escapeHtml(tag)}</span>`
|
||||
).join('');
|
||||
currentTagsDisplay.innerHTML = tagsHtml;
|
||||
} else {
|
||||
currentTagsDisplay.innerHTML = '<span class="text-muted">暂无标签</span>';
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载账户标签失败:', error);
|
||||
}
|
||||
|
||||
// 设置保存按钮事件
|
||||
const saveBtn = document.getElementById('tagManagementSaveBtn');
|
||||
saveBtn.onclick = () => this.saveAccountTagsFromDialog(email);
|
||||
|
||||
modal.show();
|
||||
}
|
||||
|
||||
async saveAccountTagsFromDialog(email) {
|
||||
const tagInput = document.getElementById('tagManagementInput');
|
||||
const tagsText = tagInput.value.trim();
|
||||
const tags = tagsText ? tagsText.split(',').map(tag => tag.trim()).filter(tag => tag) : [];
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/account/${encodeURIComponent(email)}/tags`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.token}`
|
||||
},
|
||||
body: JSON.stringify({ email: email, tags: tags })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
this.showSuccess('标签保存成功');
|
||||
// 关闭对话框
|
||||
const modal = bootstrap.Modal.getInstance(document.getElementById('tagManagementModal'));
|
||||
modal.hide();
|
||||
// 刷新账户列表
|
||||
this.loadAccounts();
|
||||
} else {
|
||||
this.showError('保存失败: ' + result.message);
|
||||
}
|
||||
} else {
|
||||
this.showError(`保存失败: HTTP ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存标签失败:', error);
|
||||
this.showError('保存标签失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async loadAllTags() {
|
||||
const allTagsList = document.getElementById('allTagsList');
|
||||
|
||||
allTagsList.innerHTML = `
|
||||
<div class="text-center py-3">
|
||||
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
|
||||
<div class="mt-2 small">加载中...</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/accounts/tags', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.token}`
|
||||
}
|
||||
});
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
this.renderAllTags(result.data.tags || []);
|
||||
} else {
|
||||
throw new Error(result.message);
|
||||
}
|
||||
} else {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载标签失败:', error);
|
||||
allTagsList.innerHTML = `
|
||||
<div class="text-center py-3 text-danger">
|
||||
<i class="bi bi-exclamation-triangle"></i>
|
||||
<div class="mt-2 small">加载失败: ${error.message}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
renderAllTags(tags) {
|
||||
const allTagsList = document.getElementById('allTagsList');
|
||||
|
||||
if (tags.length === 0) {
|
||||
allTagsList.innerHTML = `
|
||||
<div class="text-center py-3 text-muted">
|
||||
<i class="bi bi-tags"></i>
|
||||
<div class="mt-2 small">暂无标签</div>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const tagsHtml = tags.map(tag => `
|
||||
<span class="badge bg-primary me-1 mb-1">${this.escapeHtml(tag)}</span>
|
||||
`).join('');
|
||||
|
||||
allTagsList.innerHTML = `
|
||||
<div class="mb-2">
|
||||
<small class="text-muted">共 ${tags.length} 个标签:</small>
|
||||
</div>
|
||||
<div>${tagsHtml}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async loadAccountsForTags() {
|
||||
const tagAccountSelect = document.getElementById('tagAccountSelect');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/accounts');
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
tagAccountSelect.innerHTML = '<option value="">请选择账户...</option>';
|
||||
result.data.forEach(account => {
|
||||
const option = document.createElement('option');
|
||||
option.value = account.email;
|
||||
option.textContent = account.email;
|
||||
tagAccountSelect.appendChild(option);
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载账户列表失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async loadAccountTags() {
|
||||
const tagAccountSelect = document.getElementById('tagAccountSelect');
|
||||
const accountTagsInput = document.getElementById('accountTagsInput');
|
||||
const currentAccountTags = document.getElementById('currentAccountTags');
|
||||
const currentTagsDisplay = document.getElementById('currentTagsDisplay');
|
||||
|
||||
const selectedEmail = tagAccountSelect.value;
|
||||
if (!selectedEmail) {
|
||||
accountTagsInput.value = '';
|
||||
currentAccountTags.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/account/${encodeURIComponent(selectedEmail)}/tags`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.token}`
|
||||
}
|
||||
});
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
const tags = result.data.tags || [];
|
||||
accountTagsInput.value = tags.join(',');
|
||||
|
||||
if (tags.length > 0) {
|
||||
const tagsHtml = tags.map(tag =>
|
||||
`<span class="badge bg-secondary me-1">${this.escapeHtml(tag)}</span>`
|
||||
).join('');
|
||||
currentTagsDisplay.innerHTML = tagsHtml;
|
||||
currentAccountTags.style.display = 'block';
|
||||
} else {
|
||||
currentAccountTags.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载账户标签失败:', error);
|
||||
this.showError('加载账户标签失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async saveAccountTags() {
|
||||
const tagAccountSelect = document.getElementById('tagAccountSelect');
|
||||
const accountTagsInput = document.getElementById('accountTagsInput');
|
||||
|
||||
const selectedEmail = tagAccountSelect.value;
|
||||
if (!selectedEmail) {
|
||||
this.showError('请先选择账户');
|
||||
return;
|
||||
}
|
||||
|
||||
const tagsText = accountTagsInput.value.trim();
|
||||
const tags = tagsText ? tagsText.split(',').map(tag => tag.trim()).filter(tag => tag) : [];
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/account/${encodeURIComponent(selectedEmail)}/tags`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.token}`
|
||||
},
|
||||
body: JSON.stringify({ email: selectedEmail, tags: tags })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
this.showSuccess('标签保存成功');
|
||||
this.loadAccountTags(); // 刷新显示
|
||||
this.loadAllTags(); // 刷新所有标签列表
|
||||
} else {
|
||||
this.showError('保存失败: ' + result.message);
|
||||
}
|
||||
} else {
|
||||
this.showError(`保存失败: HTTP ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存标签失败:', error);
|
||||
this.showError('保存标签失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
clearAccountTags() {
|
||||
document.getElementById('accountTagsInput').value = '';
|
||||
document.getElementById('currentAccountTags').style.display = 'none';
|
||||
}
|
||||
|
||||
// ==================== 分页信息渲染 ====================
|
||||
renderPager() {
|
||||
const info = document.getElementById('accountsPagerInfo');
|
||||
if (!info) return;
|
||||
const maxPage = Math.max(1, Math.ceil(this.total / this.pageSize));
|
||||
info.textContent = `第 ${this.page} / ${maxPage} 页,共 ${this.total} 条`;
|
||||
const prevBtn = document.getElementById('prevPageBtn');
|
||||
const nextBtn = document.getElementById('nextPageBtn');
|
||||
if (prevBtn) prevBtn.disabled = this.page <= 1;
|
||||
if (nextBtn) nextBtn.disabled = this.page >= maxPage;
|
||||
}
|
||||
|
||||
// ==================== 辅助方法 ====================
|
||||
|
||||
escapeHtml(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
formatDate(dateString) {
|
||||
if (!dateString) return '未知时间';
|
||||
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
if (isNaN(date.getTime())) {
|
||||
return dateString;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const messageDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
|
||||
const timeDiff = today.getTime() - messageDate.getTime();
|
||||
const daysDiff = Math.floor(timeDiff / (1000 * 3600 * 24));
|
||||
|
||||
if (daysDiff === 0) {
|
||||
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
|
||||
} else if (daysDiff === 1) {
|
||||
return '昨天';
|
||||
} else if (daysDiff < 7) {
|
||||
return `${daysDiff}天前`;
|
||||
} else {
|
||||
return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Date formatting error:', error);
|
||||
return dateString;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 页面加载完成后初始化
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
window.adminManager = new AdminManager();
|
||||
});
|
||||
350
static/index.html
Normal file
350
static/index.html
Normal file
@@ -0,0 +1,350 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Outlook 邮件管理器</title>
|
||||
<link href="https://cdn.bootcdn.net/ajax/libs/twitter-bootstrap/5.1.3/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.bootcdn.net/ajax/libs/bootstrap-icons/1.13.1/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<link href="style.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ====================================================================
|
||||
视图1:账号管理(默认主页)
|
||||
==================================================================== -->
|
||||
<div id="accountView" class="view-container">
|
||||
<!-- 顶部导航栏 -->
|
||||
<nav class="navbar">
|
||||
<div class="navbar-container">
|
||||
<a href="#" class="logo">
|
||||
<div class="logo-icon"><i class="bi bi-envelope-fill"></i></div>
|
||||
<span class="logo-text">邮箱助手</span>
|
||||
</a>
|
||||
<div class="nav-actions">
|
||||
<div class="search-box">
|
||||
<i class="bi bi-search"></i>
|
||||
<input type="text" id="accountSearch" placeholder="搜索邮箱..." autocomplete="off">
|
||||
</div>
|
||||
<button class="btn btn-primary" id="importBtn">
|
||||
<i class="bi bi-clipboard-plus"></i>
|
||||
<span>粘贴导入</span>
|
||||
</button>
|
||||
<button class="btn btn-primary" id="exportBtn">
|
||||
<i class="bi bi-download"></i>
|
||||
<span>导出</span>
|
||||
</button>
|
||||
<button class="btn btn-primary" id="claudePaymentBtn">
|
||||
<i class="bi bi-credit-card"></i>
|
||||
<span>Claude检测</span>
|
||||
</button>
|
||||
<button class="btn btn-icon" id="refreshAccountsBtn" title="刷新">
|
||||
<i class="bi bi-arrow-clockwise"></i>
|
||||
</button>
|
||||
<a href="/admin" class="btn btn-icon" title="管理后台">
|
||||
<i class="bi bi-gear"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<div class="main-container">
|
||||
<!-- 表格容器 -->
|
||||
<div class="table-container">
|
||||
<div class="table-header">
|
||||
<h2 class="table-title">
|
||||
<i class="bi bi-people"></i>
|
||||
邮箱账号列表
|
||||
</h2>
|
||||
</div>
|
||||
<div class="table-scroll">
|
||||
<table class="data-table" id="accountTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-num">#</th>
|
||||
<th>邮箱</th>
|
||||
<th>密码</th>
|
||||
<th class="col-hide-mobile">客户ID</th>
|
||||
<th>令牌</th>
|
||||
<th>支付状态</th>
|
||||
<th>退款状态</th>
|
||||
<th>支付时间</th>
|
||||
<th>退款时间</th>
|
||||
<th>封号时间</th>
|
||||
<th>备注/卡号</th>
|
||||
<th>代理</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="accountTableBody">
|
||||
<tr>
|
||||
<td colspan="13">
|
||||
<div class="no-data">
|
||||
<i class="bi bi-inbox"></i>
|
||||
<div>暂无邮箱数据,点击"粘贴导入"添加</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-container" id="paginationContainer">
|
||||
<div class="pagination-info">
|
||||
<span id="pagerInfo">共 0 条</span>
|
||||
<span class="divider">|</span>
|
||||
<span id="pagerCurrent">1/1 页</span>
|
||||
</div>
|
||||
<div class="pagination-controls" id="pagerControls">
|
||||
<button class="pagination-btn" id="prevPageBtn" disabled title="上一页">
|
||||
<i class="bi bi-chevron-left"></i>
|
||||
</button>
|
||||
<button class="pagination-btn active">1</button>
|
||||
<button class="pagination-btn" id="nextPageBtn" disabled title="下一页">
|
||||
<i class="bi bi-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ====================================================================
|
||||
视图2:邮件查看器
|
||||
==================================================================== -->
|
||||
<div id="emailView" class="view-container" style="display:none;">
|
||||
<!-- 顶部导航 -->
|
||||
<nav class="navbar">
|
||||
<div class="navbar-container">
|
||||
<button class="btn btn-primary" id="backToAccounts">
|
||||
<i class="bi bi-arrow-left"></i>
|
||||
<span>返回邮箱管理</span>
|
||||
</button>
|
||||
<div class="current-email-info">
|
||||
<div class="info-icon"><i class="bi bi-envelope"></i></div>
|
||||
<div class="info-text">
|
||||
<div class="info-label">当前查看</div>
|
||||
<div class="info-value">
|
||||
<span id="topbarEmail"></span>
|
||||
<span class="folder-badge" id="topbarFolder"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 移动端邮件列表按钮 -->
|
||||
<button class="btn btn-icon d-md-none" id="mobileMailToggle" title="邮件列表">
|
||||
<i class="bi bi-list-ul"></i>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- 双栏主体 -->
|
||||
<div class="email-main">
|
||||
<!-- 左侧邮件列表 -->
|
||||
<div class="email-list-panel" id="emailListPanel">
|
||||
<div class="email-list-header">
|
||||
<span id="emailListTitle">邮件列表</span>
|
||||
<span class="email-count-badge" id="emailCountBadge" style="display:none;">0</span>
|
||||
</div>
|
||||
<div class="email-list-content" id="emailListContent">
|
||||
<div class="empty-state">
|
||||
<i class="bi bi-envelope"></i>
|
||||
<p>加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧邮件详情 -->
|
||||
<div class="email-detail-panel" id="emailDetailPanel">
|
||||
<div class="detail-content" id="detailContent">
|
||||
<div class="empty-detail">
|
||||
<i class="bi bi-envelope-open"></i>
|
||||
<h6>选择一封邮件查看详情</h6>
|
||||
<p>从左侧列表中选择邮件</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 移动端遮罩 -->
|
||||
<div class="mobile-overlay" id="mobileOverlay"></div>
|
||||
</div>
|
||||
|
||||
<!-- ====================================================================
|
||||
导入模态框
|
||||
==================================================================== -->
|
||||
<div class="paste-modal" id="importModal">
|
||||
<div class="paste-modal-content">
|
||||
<div class="paste-modal-header">
|
||||
<div class="paste-modal-icon"><i class="bi bi-clipboard-plus"></i></div>
|
||||
<div>
|
||||
<h3 class="paste-modal-title">粘贴导入邮箱</h3>
|
||||
<p class="paste-modal-subtitle">将邮箱数据粘贴到下方输入框中</p>
|
||||
</div>
|
||||
</div>
|
||||
<textarea class="paste-textarea" id="importText"
|
||||
placeholder="每行一个账户 格式: 邮箱----密码----客户ID----令牌 简化: 邮箱----令牌"></textarea>
|
||||
<div class="paste-modal-hint">
|
||||
<i class="bi bi-info-circle"></i>
|
||||
<span>使用 <code>----</code> 分隔各字段,每行一个邮箱</span>
|
||||
</div>
|
||||
<div class="merge-mode-section">
|
||||
<label class="input-label">合并模式</label>
|
||||
<div class="merge-options">
|
||||
<label class="merge-option">
|
||||
<input type="radio" name="mergeMode" value="update" checked>
|
||||
<span>更新已有</span>
|
||||
</label>
|
||||
<label class="merge-option">
|
||||
<input type="radio" name="mergeMode" value="skip">
|
||||
<span>跳过重复</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="paste-modal-buttons">
|
||||
<button class="btn btn-cancel" id="cancelImportBtn">取消</button>
|
||||
<button class="btn btn-primary" id="doImportBtn">
|
||||
<i class="bi bi-check2"></i>
|
||||
<span>确定导入</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 备注/卡号编辑弹窗 -->
|
||||
<div class="paste-modal" id="noteModal">
|
||||
<div class="paste-modal-content" style="max-width:420px;">
|
||||
<div class="paste-modal-header">
|
||||
<div class="paste-modal-icon"><i class="bi bi-pencil-square"></i></div>
|
||||
<div>
|
||||
<h3 class="paste-modal-title">编辑备注</h3>
|
||||
<p class="paste-modal-subtitle" id="noteModalEmail"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="note-fields">
|
||||
<div class="note-field-group">
|
||||
<label class="input-label">标题</label>
|
||||
<input type="text" class="proxy-input" id="noteTitle" placeholder="如:淘宝、开台" autocomplete="off">
|
||||
</div>
|
||||
<div class="note-field-group">
|
||||
<label class="input-label">卡号</label>
|
||||
<input type="text" class="proxy-input" id="noteCardNumber" placeholder="银行卡号" autocomplete="off">
|
||||
</div>
|
||||
<div class="note-field-group">
|
||||
<label class="input-label">备注</label>
|
||||
<textarea class="proxy-input" id="noteRemark" rows="3" placeholder="其他备注信息" style="resize:vertical;min-height:60px;"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="paste-modal-buttons">
|
||||
<button class="btn btn-cancel" id="cancelNoteBtn">取消</button>
|
||||
<button class="btn btn-primary" id="saveNoteBtn">
|
||||
<i class="bi bi-check2"></i>
|
||||
<span>保存</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 代理编辑弹窗 -->
|
||||
<div class="paste-modal" id="proxyModal">
|
||||
<div class="paste-modal-content" style="max-width:480px;">
|
||||
<div class="paste-modal-header">
|
||||
<div class="paste-modal-icon"><i class="bi bi-globe"></i></div>
|
||||
<div>
|
||||
<h3 class="paste-modal-title">编辑代理</h3>
|
||||
<p class="paste-modal-subtitle" id="proxyModalEmail"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="proxy-format-section">
|
||||
<label class="input-label">协议类型</label>
|
||||
<div class="proxy-format-options">
|
||||
<label class="merge-option"><input type="radio" name="proxyProtocol" value="http" checked><span>HTTP</span></label>
|
||||
<label class="merge-option"><input type="radio" name="proxyProtocol" value="https"><span>HTTPS</span></label>
|
||||
<label class="merge-option"><input type="radio" name="proxyProtocol" value="socks5"><span>SOCKS5</span></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="proxy-fields">
|
||||
<div class="proxy-row">
|
||||
<div class="proxy-field">
|
||||
<label class="input-label">IP地址</label>
|
||||
<input type="text" class="proxy-input" id="proxyHost" placeholder="127.0.0.1" autocomplete="off">
|
||||
</div>
|
||||
<div class="proxy-field proxy-field-sm">
|
||||
<label class="input-label">端口</label>
|
||||
<input type="text" class="proxy-input" id="proxyPort" placeholder="1080" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<div class="proxy-row">
|
||||
<div class="proxy-field">
|
||||
<label class="input-label">账号 <span style="color:#94a3b8;font-weight:400">(可选)</span></label>
|
||||
<input type="text" class="proxy-input" id="proxyUser" placeholder="username" autocomplete="off">
|
||||
</div>
|
||||
<div class="proxy-field">
|
||||
<label class="input-label">密码 <span style="color:#94a3b8;font-weight:400">(可选)</span></label>
|
||||
<input type="text" class="proxy-input" id="proxyPass" placeholder="password" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="proxy-extra-section">
|
||||
<div class="proxy-row">
|
||||
<div class="proxy-field">
|
||||
<label class="input-label">IP有效期</label>
|
||||
<div class="proxy-expire-options">
|
||||
<label class="merge-option"><input type="radio" name="proxyExpire" value="10"><span>10天</span></label>
|
||||
<label class="merge-option"><input type="radio" name="proxyExpire" value="30" checked><span>30天</span></label>
|
||||
<label class="merge-option"><input type="radio" name="proxyExpire" value="custom"><span>自定义</span></label>
|
||||
<input type="number" class="proxy-input proxy-input-mini" id="proxyExpireCustom" placeholder="天" min="1" style="display:none;">
|
||||
</div>
|
||||
</div>
|
||||
<div class="proxy-field">
|
||||
<label class="input-label">类型</label>
|
||||
<div class="proxy-share-options">
|
||||
<label class="merge-option"><input type="radio" name="proxyShare" value="exclusive" checked><span>独享</span></label>
|
||||
<label class="merge-option"><input type="radio" name="proxyShare" value="shared"><span>共享</span></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="proxy-row">
|
||||
<div class="proxy-field">
|
||||
<label class="input-label">购买日期</label>
|
||||
<input type="date" class="proxy-input" id="proxyPurchaseDate">
|
||||
</div>
|
||||
<div class="proxy-field">
|
||||
<label class="input-label">到期日期 <span style="color:#94a3b8;font-weight:400">(自动计算)</span></label>
|
||||
<input type="text" class="proxy-input" id="proxyExpireDate" readonly style="background:#f8fafc;color:#64748b;">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="paste-modal-hint" style="margin-top:10px;">
|
||||
<i class="bi bi-info-circle"></i>
|
||||
<span>也可直接粘贴:<code>ip:端口:账号:密码</code> 或 <code>socks5://user:pass@host:port</code></span>
|
||||
</div>
|
||||
<div class="proxy-raw-section">
|
||||
<label class="input-label">快速粘贴</label>
|
||||
<input type="text" class="proxy-input" id="proxyRaw" placeholder="粘贴完整代理字符串,自动解析" autocomplete="off">
|
||||
</div>
|
||||
<div class="proxy-preview" id="proxyPreview"></div>
|
||||
<div class="paste-modal-buttons">
|
||||
<button class="btn btn-cancel" id="clearProxyBtn" style="margin-right:auto;">清除代理</button>
|
||||
<button class="btn btn-cancel" id="cancelProxyBtn">取消</button>
|
||||
<button class="btn btn-primary" id="saveProxyBtn">
|
||||
<i class="bi bi-check2"></i>
|
||||
<span>保存</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast 提示 -->
|
||||
<div class="toast-container" id="toastContainer"></div>
|
||||
|
||||
<!-- Loading 遮罩 -->
|
||||
<div class="loading-overlay" id="loadingOverlay">
|
||||
<div class="loading-spinner"></div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="script.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
1165
static/script.js
Normal file
1165
static/script.js
Normal file
File diff suppressed because it is too large
Load Diff
1432
static/style.css
Normal file
1432
static/style.css
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user