Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
904a4d0d6c |
@@ -2,7 +2,7 @@
|
||||
|
||||
> 兼容OpenAI API格式的多提供商代理系统,支持能力(Capability)路由、优先级自动切换
|
||||
|
||||
**版本:v2.1.1**
|
||||
**版本:v2.1.2**
|
||||
|
||||
## 功能特点
|
||||
|
||||
@@ -202,6 +202,16 @@ MODEL_ALIASES = {
|
||||
3. 请求失败自动切换到下一个托管同一模型的提供商(保持请求模型不变)
|
||||
4. 连续失败3次的提供商被熔断,冷却期(默认60秒)后自动半开恢复
|
||||
|
||||
## 邮件通知(auto 调用全部失败时)
|
||||
|
||||
某个 auto 配置被调用后所有提供商都失败(如全部熔断/停用),自动发邮件通知,方便及时处理。
|
||||
|
||||
- **配置入口**:后台「系统配置」页 → 邮件通知(SMTP 服务器/账号/密码/收件人/加密方式,均可在网页配置)
|
||||
- **防轰炸**:默认最小间隔 300 秒,短时间内连续失败只发一封
|
||||
- **测试**:配置页有「发送测试邮件」按钮
|
||||
- **端点**:`/api/admin/email`(GET/PUT)、`/api/admin/email/test`(POST)
|
||||
- **默认配置**:`mail.tphai.com:587`(plain)、收件人 `wlq@tphai.com`
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
|
||||
@@ -16,6 +16,10 @@ import time
|
||||
import random
|
||||
import hashlib
|
||||
import logging
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.header import Header
|
||||
from email.utils import formataddr
|
||||
from datetime import datetime, date
|
||||
from pathlib import Path
|
||||
import sys
|
||||
@@ -30,6 +34,7 @@ from config.settings import (
|
||||
load_config, save_config, get_provider, add_provider, update_provider,
|
||||
delete_provider, update_priority, update_model_alias, delete_model_alias,
|
||||
add_auto_profile, update_auto_profile, delete_auto_profile, load_routing_config, save_routing_config,
|
||||
load_email_config, save_email_config,
|
||||
DEFAULT_PROVIDERS, DEFAULT_MODEL_ALIASES, DEFAULT_AUTO_PROFILES
|
||||
)
|
||||
|
||||
@@ -37,7 +42,7 @@ app = Flask(__name__, template_folder='templates')
|
||||
app.config['TEMPLATES_AUTO_RELOAD'] = True # 模板修改即时生效(无需重启)
|
||||
CORS(app)
|
||||
|
||||
VERSION = "2.1.1"
|
||||
VERSION = "2.1.2"
|
||||
|
||||
# 数据目录和统计文件
|
||||
DATA_DIR = Path(__file__).parent / 'data'
|
||||
@@ -101,6 +106,86 @@ def remember_history_model(fp, provider_name, model):
|
||||
with history_cache_lock:
|
||||
history_model_cache[fp] = {'provider': provider_name, 'model': model, 'ts': time.time()}
|
||||
|
||||
|
||||
# ============ 邮件通知(auto 调用全部失败时发邮件) ============
|
||||
|
||||
_email_last_sent = 0
|
||||
_email_lock = threading.Lock()
|
||||
|
||||
|
||||
def _smtp_connect(cfg):
|
||||
"""建立 SMTP 连接(支持 plain / starttls / ssl)"""
|
||||
host = cfg.get('smtp_host', '')
|
||||
port = int(cfg.get('smtp_port', 25) or 25)
|
||||
mode = cfg.get('smtp_mode', 'plain')
|
||||
if mode == 'ssl':
|
||||
server = smtplib.SMTP_SSL(host, port, timeout=15)
|
||||
else:
|
||||
server = smtplib.SMTP(host, port, timeout=15)
|
||||
if mode == 'starttls':
|
||||
server.starttls()
|
||||
if cfg.get('smtp_user'):
|
||||
server.login(cfg.get('smtp_user'), cfg.get('smtp_password', ''))
|
||||
return server
|
||||
|
||||
|
||||
def send_email(subject, body, cfg=None, html=False):
|
||||
"""发送邮件,返回 {'success': bool, 'error'?: str}"""
|
||||
cfg = cfg or load_email_config()
|
||||
to_addrs = cfg.get('to_addrs') or []
|
||||
if isinstance(to_addrs, str):
|
||||
to_addrs = [a.strip() for a in to_addrs.split(',') if a.strip()]
|
||||
if not to_addrs:
|
||||
return {'success': False, 'error': '未配置收件人'}
|
||||
|
||||
msg = MIMEText(body, 'html' if html else 'plain', 'utf-8')
|
||||
msg['Subject'] = Header(subject, 'utf-8')
|
||||
from_addr = cfg.get('from_addr') or cfg.get('smtp_user') or ''
|
||||
msg['From'] = formataddr((str(Header(cfg.get('from_name', ''), 'utf-8')), from_addr))
|
||||
msg['To'] = ', '.join(to_addrs)
|
||||
|
||||
server = _smtp_connect(cfg)
|
||||
try:
|
||||
server.sendmail(from_addr, to_addrs, msg.as_string())
|
||||
return {'success': True}
|
||||
finally:
|
||||
try:
|
||||
server.quit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def notify_auto_failure(model, capability, last_error):
|
||||
"""auto 调用全部失败时发送邮件通知(带冷却防轰炸)"""
|
||||
global _email_last_sent
|
||||
cfg = load_email_config()
|
||||
if not cfg.get('enabled') or not cfg.get('notify_on_auto_failure'):
|
||||
return
|
||||
|
||||
with _email_lock:
|
||||
now = time.time()
|
||||
if now - _email_last_sent < int(cfg.get('cooldown_seconds', 300) or 300):
|
||||
return
|
||||
_email_last_sent = now
|
||||
|
||||
try:
|
||||
cap_label = CAPABILITY_DEFS.get(capability, capability)
|
||||
subject = f"[LLM Proxy] Auto 调用全部失败: {model}"
|
||||
body = (
|
||||
f"时间: {datetime.now().isoformat()}\n"
|
||||
f"Auto 配置: {model}\n"
|
||||
f"能力: {cap_label}\n"
|
||||
f"最后错误: {last_error}\n\n"
|
||||
f"提示: 系统已尝试所有可用提供商仍未成功,请登录后台 (http://<IP>:16003/admin) 检查提供商状态。"
|
||||
)
|
||||
r = send_email(subject, body, cfg)
|
||||
if r['success']:
|
||||
logger.warning(f"Auto failure notification sent for {model} (capability={capability})")
|
||||
else:
|
||||
logger.error(f"Auto failure email send failed: {r.get('error')}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send auto failure email: {e}")
|
||||
|
||||
# 提供商状态缓存
|
||||
provider_status = {}
|
||||
|
||||
@@ -664,6 +749,8 @@ def chat_completions():
|
||||
|
||||
if not provider:
|
||||
increment_stats(model, 'unknown', success=False, error=f'No provider for model: {model}')
|
||||
if is_auto_model(model):
|
||||
notify_auto_failure(model, capability, f"No available provider (capability: {capability})")
|
||||
return jsonify({"error": {"message": f"No available provider for model: {model} (capability: {capability})", "type": "invalid_request_error"}}), 400
|
||||
|
||||
request_provider = provider['name']
|
||||
@@ -743,6 +830,8 @@ def chat_completions():
|
||||
break
|
||||
|
||||
increment_stats(model, request_provider or 'unknown', success=False, error=str(last_error))
|
||||
if is_auto_model(model):
|
||||
notify_auto_failure(model, capability, str(last_error))
|
||||
return jsonify({"error": {"message": f"All providers failed. Last error: {last_error}", "type": "api_error"}}), 503
|
||||
|
||||
except Exception as e:
|
||||
@@ -811,6 +900,8 @@ def _generic_capability_endpoint(capability, path, error_msg="No available provi
|
||||
break
|
||||
|
||||
if not provider:
|
||||
if is_auto_model(model):
|
||||
notify_auto_failure(model, capability, error_msg)
|
||||
return jsonify({"error": {"message": f"{error_msg} (capability: {capability})", "type": "invalid_request_error"}}), 400
|
||||
|
||||
data['model'] = resolved_model if resolved_model else data.get('model')
|
||||
@@ -857,6 +948,8 @@ def audio_transcriptions():
|
||||
break
|
||||
|
||||
if not provider:
|
||||
if is_auto_model(model):
|
||||
notify_auto_failure(model, 'audio_in', "No ASR provider available")
|
||||
return jsonify({"error": {"message": "No ASR provider available (capability: audio_in)", "type": "invalid_request_error"}}), 400
|
||||
|
||||
response = proxy_raw(provider, 'audio/transcriptions')
|
||||
@@ -1695,6 +1788,91 @@ def api_admin_routing_put():
|
||||
return jsonify({'success': True, 'routing_config': result})
|
||||
|
||||
|
||||
# ============ 后台管理 API:邮件通知 ============
|
||||
|
||||
def _mask_email_config(cfg):
|
||||
"""返回给前端时掩码密码"""
|
||||
out = dict(cfg)
|
||||
out['smtp_password'] = '********' if cfg.get('smtp_password') else ''
|
||||
return out
|
||||
|
||||
|
||||
@app.route('/api/admin/email', methods=['GET'])
|
||||
def api_admin_email_get():
|
||||
"""获取邮件通知配置(密码掩码)"""
|
||||
return jsonify(_mask_email_config(load_email_config()))
|
||||
|
||||
|
||||
@app.route('/api/admin/email', methods=['PUT'])
|
||||
def api_admin_email_put():
|
||||
"""更新邮件通知配置"""
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({'error': 'Invalid request body'}), 400
|
||||
|
||||
cfg = load_email_config()
|
||||
update = {}
|
||||
|
||||
for key in ['enabled', 'notify_on_auto_failure', 'smtp_host', 'smtp_port', 'smtp_mode',
|
||||
'smtp_user', 'from_name', 'from_addr', 'to_addrs', 'cooldown_seconds']:
|
||||
if key not in data:
|
||||
continue
|
||||
v = data[key]
|
||||
if key in ('enabled', 'notify_on_auto_failure'):
|
||||
v = bool(v)
|
||||
elif key == 'smtp_port':
|
||||
try:
|
||||
v = int(v)
|
||||
except:
|
||||
continue
|
||||
elif key == 'cooldown_seconds':
|
||||
try:
|
||||
v = max(0, int(v))
|
||||
except:
|
||||
continue
|
||||
elif key == 'to_addrs':
|
||||
if isinstance(v, str):
|
||||
v = [a.strip() for a in v.split(',') if a.strip()]
|
||||
elif isinstance(v, list):
|
||||
v = [str(a).strip() for a in v if str(a).strip()]
|
||||
elif key == 'smtp_mode':
|
||||
if v not in ('plain', 'starttls', 'ssl'):
|
||||
continue
|
||||
update[key] = v
|
||||
|
||||
# 密码:掩码占位表示未修改
|
||||
if 'smtp_password' in data and data['smtp_password'] and data['smtp_password'] != '********':
|
||||
update['smtp_password'] = data['smtp_password']
|
||||
|
||||
if not update:
|
||||
return jsonify({'error': 'No valid fields'}), 400
|
||||
|
||||
result = save_email_config(update)
|
||||
return jsonify({'success': True, 'email_config': _mask_email_config(result)})
|
||||
|
||||
|
||||
@app.route('/api/admin/email/test', methods=['POST'])
|
||||
def api_admin_email_test():
|
||||
"""发送测试邮件(使用当前配置)"""
|
||||
cfg = load_email_config()
|
||||
to_addrs = cfg.get('to_addrs') or []
|
||||
if isinstance(to_addrs, str):
|
||||
to_addrs = [a.strip() for a in to_addrs.split(',') if a.strip()]
|
||||
if not to_addrs:
|
||||
return jsonify({'success': False, 'error': '请先配置收件人'}), 400
|
||||
try:
|
||||
r = send_email(
|
||||
"[LLM Proxy] 测试邮件",
|
||||
"这是一封来自 LLM Proxy 的测试邮件,说明邮件通知配置正常。",
|
||||
cfg
|
||||
)
|
||||
if r['success']:
|
||||
return jsonify({'success': True, 'message': f'测试邮件已发送到 {to_addrs}'})
|
||||
return jsonify({'success': False, 'error': r.get('error', '发送失败')}), 400
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 400
|
||||
|
||||
|
||||
# ============ 后台管理 API:对话 ============
|
||||
|
||||
def load_chats():
|
||||
|
||||
@@ -200,6 +200,36 @@ ROUTING_CONFIG = {
|
||||
"cache_ttl_seconds": 3600, # 记忆有效期(秒)
|
||||
}
|
||||
|
||||
# 邮件通知配置(auto 调用全部失败时发邮件通知)
|
||||
EMAIL_CONFIG = {
|
||||
"enabled": True, # 总开关
|
||||
"notify_on_auto_failure": True, # auto 调用全部失败时发邮件
|
||||
"smtp_host": "mail.tphai.com",
|
||||
"smtp_port": 587,
|
||||
"smtp_mode": "plain", # plain | starttls | ssl
|
||||
"smtp_user": "hz4th_coder@tphai.com",
|
||||
"smtp_password": "hz4th_coder@!",
|
||||
"from_name": "LLM Proxy",
|
||||
"from_addr": "hz4th_coder@tphai.com",
|
||||
"to_addrs": ["wlq@tphai.com"],
|
||||
"cooldown_seconds": 300, # 失败通知最小间隔(防轰炸)
|
||||
}
|
||||
|
||||
|
||||
def load_email_config():
|
||||
"""加载邮件通知配置(运行时配置优先)"""
|
||||
config = load_config()
|
||||
return {**EMAIL_CONFIG, **config.get("email_config", {})}
|
||||
|
||||
|
||||
def save_email_config(data):
|
||||
"""保存邮件通知配置"""
|
||||
config = load_config()
|
||||
merged = {**EMAIL_CONFIG, **data}
|
||||
config["email_config"] = merged
|
||||
save_config(config)
|
||||
return merged
|
||||
|
||||
|
||||
def load_routing_config():
|
||||
"""加载路由缓存配置(运行时配置优先)"""
|
||||
|
||||
@@ -110,6 +110,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl border border-gray-100 p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-800 mb-1">邮件通知(auto 调用全部失败时通知)</h2>
|
||||
<p class="text-sm text-gray-500 mb-4">某个 auto 配置被调用后所有提供商都失败,自动发送邮件到收件人</p>
|
||||
<div id="emailConfigContent" class="space-y-4">
|
||||
<p class="text-gray-500 text-sm">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl border border-gray-100 p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-800 mb-4">提供商配置</h2>
|
||||
<div class="space-y-4">
|
||||
@@ -151,6 +159,7 @@
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
loadEmailConfig();
|
||||
}
|
||||
|
||||
async function toggleCacheModel() {
|
||||
@@ -192,6 +201,155 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEmailConfig() {
|
||||
const res = await fetch('/api/admin/email');
|
||||
const e = await res.json();
|
||||
const container = document.getElementById('emailConfigContent');
|
||||
container.innerHTML = `
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="flex items-center gap-2 p-3 bg-gray-50 rounded-lg">
|
||||
<span class="text-sm text-gray-700">启用邮件通知</span>
|
||||
<button id="emailEnabledToggle" onclick="toggleEmailEnabled()" class="relative w-10 h-5 rounded-full transition ${e.enabled ? 'bg-green-500' : 'bg-gray-300'}">
|
||||
<span id="emailEnabledKnob" class="absolute top-0.5 w-4 h-4 bg-white rounded-full shadow transition ${e.enabled ? 'left-5' : 'left-0.5'}"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 p-3 bg-gray-50 rounded-lg">
|
||||
<span class="text-sm text-gray-700">auto 失败时通知</span>
|
||||
<button id="notifyToggle" onclick="toggleNotify()" class="relative w-10 h-5 rounded-full transition ${e.notify_on_auto_failure ? 'bg-green-500' : 'bg-gray-300'}">
|
||||
<span id="notifyKnob" class="absolute top-0.5 w-4 h-4 bg-white rounded-full shadow transition ${e.notify_on_auto_failure ? 'left-5' : 'left-0.5'}"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">SMTP 服务器</label>
|
||||
<input type="text" id="emailHost" value="${e.smtp_host}" class="w-full px-3 py-2 border border-gray-300 rounded-lg">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">端口</label>
|
||||
<input type="number" id="emailPort" value="${e.smtp_port}" class="w-full px-3 py-2 border border-gray-300 rounded-lg">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">加密方式</label>
|
||||
<select id="emailMode" class="w-full px-3 py-2 border border-gray-300 rounded-lg">
|
||||
<option value="plain" ${e.smtp_mode==='plain'?'selected':''}>plain(无加密)</option>
|
||||
<option value="starttls" ${e.smtp_mode==='starttls'?'selected':''}>STARTTLS</option>
|
||||
<option value="ssl" ${e.smtp_mode==='ssl'?'selected':''}>SSL</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">账号</label>
|
||||
<input type="text" id="emailUser" value="${e.smtp_user}" class="w-full px-3 py-2 border border-gray-300 rounded-lg">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">密码(留空不改)</label>
|
||||
<input type="password" id="emailPass" value="${e.smtp_password}" class="w-full px-3 py-2 border border-gray-300 rounded-lg">
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">发件人名称</label>
|
||||
<input type="text" id="emailFromName" value="${e.from_name}" class="w-full px-3 py-2 border border-gray-300 rounded-lg">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">发件地址</label>
|
||||
<input type="text" id="emailFromAddr" value="${e.from_addr}" class="w-full px-3 py-2 border border-gray-300 rounded-lg">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">收件人(逗号分隔)</label>
|
||||
<input type="text" id="emailTo" value="${(e.to_addrs||[]).join(', ')}" class="w-full px-3 py-2 border border-gray-300 rounded-lg">
|
||||
</div>
|
||||
<div class="flex items-end gap-3">
|
||||
<div class="flex-1">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">失败通知最小间隔(秒)</label>
|
||||
<input type="number" id="emailCooldown" value="${e.cooldown_seconds}" class="w-full px-3 py-2 border border-gray-300 rounded-lg">
|
||||
</div>
|
||||
<button onclick="saveEmail()" class="px-4 py-2 gradient-bg text-white rounded-lg hover:opacity-90">
|
||||
<i class="ri-save-line mr-1"></i> 保存
|
||||
</button>
|
||||
<button onclick="testEmail(this)" class="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600">
|
||||
<i class="ri-mail-send-line mr-1"></i> 发送测试邮件
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function toggleEmailEnabled() {
|
||||
const res = await fetch('/api/admin/email');
|
||||
const e = await res.json();
|
||||
const newVal = !e.enabled;
|
||||
const r = await fetch('/api/admin/email', {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: newVal })
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.success) {
|
||||
const btn = document.getElementById('emailEnabledToggle');
|
||||
const knob = document.getElementById('emailEnabledKnob');
|
||||
btn.classList.toggle('bg-green-500', newVal); btn.classList.toggle('bg-gray-300', !newVal);
|
||||
knob.classList.toggle('left-5', newVal); knob.classList.toggle('left-0.5', !newVal);
|
||||
} else { alert('保存失败: ' + (d.error || '')); }
|
||||
}
|
||||
|
||||
async function toggleNotify() {
|
||||
const res = await fetch('/api/admin/email');
|
||||
const e = await res.json();
|
||||
const newVal = !e.notify_on_auto_failure;
|
||||
const r = await fetch('/api/admin/email', {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ notify_on_auto_failure: newVal })
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.success) {
|
||||
const btn = document.getElementById('notifyToggle');
|
||||
const knob = document.getElementById('notifyKnob');
|
||||
btn.classList.toggle('bg-green-500', newVal); btn.classList.toggle('bg-gray-300', !newVal);
|
||||
knob.classList.toggle('left-5', newVal); knob.classList.toggle('left-0.5', !newVal);
|
||||
} else { alert('保存失败: ' + (d.error || '')); }
|
||||
}
|
||||
|
||||
async function saveEmail() {
|
||||
const data = {
|
||||
smtp_host: document.getElementById('emailHost').value.trim(),
|
||||
smtp_port: parseInt(document.getElementById('emailPort').value) || 25,
|
||||
smtp_mode: document.getElementById('emailMode').value,
|
||||
smtp_user: document.getElementById('emailUser').value.trim(),
|
||||
from_name: document.getElementById('emailFromName').value.trim(),
|
||||
from_addr: document.getElementById('emailFromAddr').value.trim(),
|
||||
to_addrs: document.getElementById('emailTo').value,
|
||||
cooldown_seconds: parseInt(document.getElementById('emailCooldown').value) || 300
|
||||
};
|
||||
const pass = document.getElementById('emailPass').value;
|
||||
if (pass && pass !== '********') data.smtp_password = pass;
|
||||
|
||||
const res = await fetch('/api/admin/email', {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
const d = await res.json();
|
||||
if (d.success) {
|
||||
alert('✅ 邮件配置已保存');
|
||||
loadEmailConfig();
|
||||
} else { alert('保存失败: ' + (d.error || '')); }
|
||||
}
|
||||
|
||||
async function testEmail(btn) {
|
||||
if (!btn) btn = event.target.closest('button');
|
||||
btn.disabled = true; btn.innerHTML = '<i class="ri-loader-4-line animate-spin mr-1"></i> 发送中...';
|
||||
try {
|
||||
const res = await fetch('/api/admin/email/test', { method: 'POST' });
|
||||
const d = await res.json();
|
||||
if (d.success) { alert('✅ ' + d.message); }
|
||||
else { alert('❌ ' + (d.error || '发送失败')); }
|
||||
} catch (e) {
|
||||
alert('❌ ' + e.message);
|
||||
}
|
||||
btn.disabled = false; btn.innerHTML = '<i class="ri-mail-send-line mr-1"></i> 发送测试邮件';
|
||||
}
|
||||
|
||||
loadConfig();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user