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