This commit is contained in:
丹尼尔
2026-03-10 17:29:22 +08:00
parent 36101a4405
commit 0655410134
5 changed files with 935 additions and 5 deletions

918
public/index.html Normal file
View File

@@ -0,0 +1,918 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>微信登录管理面板</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
:root {
color-scheme: light dark;
--bg: #0f172a;
--card: #020617;
--border: #1e293b;
--accent: #22c55e;
--accent-soft: rgba(34, 197, 94, 0.12);
--text: #e5e7eb;
--muted: #9ca3af;
--danger: #ef4444;
--radius: 16px;
--shadow: 0 18px 45px rgba(15, 23, 42, 0.7);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: radial-gradient(circle at top, #1e293b 0, #020617 55%, #000 100%);
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'SF Pro Text',
'Segoe UI', sans-serif;
color: var(--text);
}
.shell {
width: 100%;
max-width: 1080px;
margin: 32px;
border-radius: 28px;
background: radial-gradient(circle at top left, #22c55e1f 0, #020617 48%, #000 100%);
padding: 1px;
box-shadow: var(--shadow);
}
.shell-inner {
display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr);
gap: 24px;
padding: 26px 26px 26px 26px;
border-radius: 27px;
background:
radial-gradient(circle at top left, rgba(52, 211, 153, 0.09), transparent 55%),
radial-gradient(circle at bottom, rgba(59, 130, 246, 0.09), transparent 55%),
#020617f2;
border: 1px solid rgba(148, 163, 184, 0.24);
backdrop-filter: blur(26px);
}
@media (max-width: 880px) {
.shell-inner {
grid-template-columns: minmax(0, 1fr);
padding: 20px;
}
}
header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 18px;
}
.title {
display: flex;
flex-direction: column;
gap: 6px;
}
.title-main {
font-size: 20px;
font-weight: 600;
letter-spacing: 0.02em;
display: flex;
align-items: center;
gap: 8px;
}
.pill {
font-size: 11px;
padding: 2px 8px;
border-radius: 999px;
background: rgba(15, 23, 42, 0.9);
border: 1px solid rgba(148, 163, 184, 0.4);
color: var(--muted);
}
.title-sub {
font-size: 12px;
color: var(--muted);
}
.status-dot {
width: 7px;
height: 7px;
border-radius: 999px;
background: #22c55e;
box-shadow: 0 0 0 6px rgba(34, 197, 94, 0.25);
}
.status-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--muted);
}
.status-row strong {
color: var(--text);
font-weight: 500;
}
.card {
border-radius: var(--radius);
border: 1px solid var(--border);
background: radial-gradient(circle at top, rgba(15, 23, 42, 0.9), #020617);
padding: 18px 18px 16px;
display: flex;
flex-direction: column;
gap: 14px;
}
.card-title {
font-size: 13px;
font-weight: 500;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--muted);
}
.form-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 12px 14px;
}
@media (max-width: 640px) {
.form-grid {
grid-template-columns: minmax(0, 1fr);
}
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field label {
font-size: 12px;
color: var(--muted);
}
.field label span {
color: var(--danger);
margin-left: 2px;
}
input,
select {
font: inherit;
background: rgba(15, 23, 42, 0.95);
border-radius: 999px;
border: 1px solid rgba(51, 65, 85, 0.9);
padding: 8px 11px;
color: var(--text);
outline: none;
transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease,
transform 0.08s ease;
}
input::placeholder {
color: rgba(148, 163, 184, 0.8);
}
input:focus,
select:focus {
border-color: var(--accent);
box-shadow: 0 0 0 1px rgba(34, 197, 94, 0.55);
background: rgba(15, 23, 42, 0.99);
transform: translateY(-0.5px);
}
.checkbox-row {
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
font-size: 12px;
color: var(--muted);
}
.checkbox-row input {
width: 15px;
height: 15px;
border-radius: 4px;
padding: 0;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 4px;
}
button {
border: none;
border-radius: 999px;
padding: 8px 16px;
font-size: 13px;
font-weight: 500;
letter-spacing: 0.03em;
text-transform: uppercase;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
transition: transform 0.08s ease, box-shadow 0.12s ease, background 0.12s ease,
opacity 0.12s ease;
}
button.primary {
background: linear-gradient(135deg, #22c55e, #16a34a);
color: #022c22;
box-shadow: 0 10px 24px rgba(34, 197, 94, 0.35);
}
button.secondary {
background: rgba(15, 23, 42, 0.98);
color: var(--text);
border: 1px solid rgba(148, 163, 184, 0.4);
}
button.danger {
background: rgba(127, 29, 29, 0.96);
color: #fee2e2;
border: 1px solid rgba(248, 113, 113, 0.7);
}
button:disabled {
opacity: 0.6;
cursor: wait;
}
button:not(:disabled):hover {
transform: translateY(-1px);
box-shadow: 0 14px 32px rgba(15, 23, 42, 0.54);
}
button:not(:disabled):active {
transform: translateY(0px) scale(0.99);
box-shadow: 0 8px 16px rgba(15, 23, 42, 0.55);
}
.qr-card {
border-radius: var(--radius);
border: 1px solid rgba(30, 64, 175, 0.8);
background:
radial-gradient(circle at top, rgba(59, 130, 246, 0.35), transparent 60%),
rgba(15, 23, 42, 0.98);
padding: 18px 16px 15px;
display: flex;
flex-direction: column;
gap: 10px;
}
.qr-header {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 13px;
}
.badge {
font-size: 11px;
padding: 2px 8px;
border-radius: 999px;
background: rgba(15, 23, 42, 0.98);
border: 1px solid rgba(148, 163, 184, 0.6);
color: var(--muted);
}
.qr-box {
margin-top: 4px;
border-radius: 18px;
border: 1px dashed rgba(148, 163, 184, 0.7);
background:
radial-gradient(circle at top, rgba(148, 163, 184, 0.23), transparent 55%),
radial-gradient(circle at bottom, rgba(15, 23, 42, 0.95), #020617);
padding: 14px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
min-height: 220px;
}
.qr-img {
width: 190px;
height: 190px;
border-radius: 18px;
background: #020617;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
box-shadow:
0 0 0 1px rgba(15, 23, 42, 0.9),
0 15px 32px rgba(15, 23, 42, 0.9);
}
.qr-img img {
max-width: 100%;
max-height: 100%;
display: block;
}
.qr-placeholder {
font-size: 12px;
color: var(--muted);
text-align: center;
}
.hint {
font-size: 11px;
color: var(--muted);
text-align: center;
}
.hint strong {
color: var(--text);
font-weight: 500;
}
.log-card {
border-radius: 20px;
border: 1px solid rgba(55, 65, 81, 0.9);
background:
radial-gradient(circle at top left, rgba(34, 197, 94, 0.16), transparent 55%),
rgba(15, 23, 42, 0.98);
padding: 14px 13px 12px;
display: flex;
flex-direction: column;
gap: 8px;
min-height: 120px;
}
.log-header {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
}
.log-body {
font-size: 11px;
color: var(--muted);
max-height: 120px;
overflow: auto;
padding-right: 4px;
}
.log-line {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.log-line.ok {
color: #4ade80;
}
.log-line.warn {
color: #facc15;
}
.log-line.error {
color: #f97373;
}
.tag-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
font-size: 11px;
color: var(--muted);
}
.tag {
padding: 1px 7px;
border-radius: 999px;
border: 1px solid rgba(55, 65, 81, 0.9);
background: rgba(15, 23, 42, 0.92);
}
.small-label {
font-size: 11px;
color: var(--muted);
}
.login-state {
font-size: 13px;
font-weight: 500;
}
.login-state.ok {
color: #4ade80;
}
.login-state.pending {
color: #facc15;
}
.login-state.offline {
color: #f97373;
}
.login-state.unknown {
color: var(--muted);
}
.result-card {
border-radius: 20px;
border: 1px solid rgba(55, 65, 81, 0.9);
background: rgba(15, 23, 42, 0.98);
padding: 12px 12px 10px;
margin-top: 12px;
display: flex;
flex-direction: column;
gap: 6px;
}
.result-body {
max-height: 120px;
overflow: auto;
font-size: 11px;
color: var(--muted);
background: rgba(15, 23, 42, 0.9);
border-radius: 10px;
padding: 6px 8px;
white-space: pre-wrap;
word-break: break-all;
}
.result-actions {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.result-actions button {
padding: 4px 10px;
font-size: 11px;
}
.slider-card {
border-radius: 20px;
border: 1px solid rgba(59, 130, 246, 0.8);
background:
radial-gradient(circle at top, rgba(59, 130, 246, 0.25), transparent 60%),
rgba(15, 23, 42, 0.98);
padding: 10px 10px 8px;
margin-top: 12px;
display: none;
flex-direction: column;
gap: 6px;
}
.slider-frame {
width: 100%;
height: 260px;
border-radius: 12px;
border: 1px solid rgba(30, 64, 175, 0.8);
background: #020617;
}
</style>
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
</head>
<body>
<div class="shell">
<div class="shell-inner">
<section>
<header>
<div class="title">
<div class="title-main">
微信登录控制台
<span class="pill">Admin · iPad / Mac 直登</span>
</div>
<div class="title-sub">
按文档流程封装的可视化工具:生成二维码 · 轮询扫码 · 查看在线状态 · 一键退出登录
</div>
</div>
<div>
<div class="status-row">
<span class="status-dot" id="health-dot"></span>
<span>后端状态:<strong id="health-text">检测中...</strong></span>
</div>
</div>
</header>
<div class="card">
<div class="card-title">登录参数</div>
<div class="form-grid">
<div class="field">
<label for="key">
账号唯一标识 <span>*</span>
</label>
<input
id="key"
placeholder="例如your-account-key"
autocomplete="off"
/>
</div>
<div class="field">
<label for="proxy">代理(可选)</label>
<input
id="proxy"
placeholder="socks5://username:password@ipv4:port"
autocomplete="off"
/>
</div>
<div class="field">
<label for="device">登录设备类型</label>
<select id="device">
<option value="">自动</option>
<option value="ipad">iPad</option>
<option value="mac">Mac</option>
</select>
</div>
<div class="field">
<label>&nbsp;</label>
<div class="checkbox-row">
<input type="checkbox" id="check-proxy" />
<label for="check-proxy">修改代理时自动检测可用性</label>
</div>
</div>
</div>
<div class="actions">
<button class="primary" id="btn-qrcode">
获取登录二维码
</button>
<button class="secondary" id="btn-check-scan">
检测扫码状态
</button>
<button class="secondary" id="btn-online">
获取在线状态
</button>
<button class="danger" id="btn-logout">
退出当前账号
</button>
</div>
<div class="tag-row">
<span class="tag">通过 /api/auth/qrcode 代理 swagger 登录接口</span>
<span class="tag">无需直接暴露 8069 服务</span>
</div>
</div>
<div class="log-card" style="margin-top: 12px">
<div class="log-header">
<span>操作日志</span>
<button class="secondary" id="btn-clear-log" style="padding: 3px 10px; font-size: 11px">
清空
</button>
</div>
<div class="log-body" id="log-body"></div>
</div>
</section>
<aside>
<div class="qr-card">
<div class="qr-header">
<span>扫码登录二维码</span>
<span class="badge" id="login-state-badge">未创建</span>
</div>
<div class="qr-box">
<div class="qr-img" id="qr-img-box">
<div class="qr-placeholder" id="qr-placeholder">
点击左侧「获取登录二维码」后,将在这里显示二维码图片或登录链接信息。
</div>
</div>
<div class="hint" id="qr-hint">
请使用微信客户端扫描二维码完成登录。
</div>
</div>
</div>
<div style="height: 12px"></div>
<div class="card">
<div class="small-label">当前账号在线状态</div>
<div class="login-state unknown" id="login-state-text">未知</div>
<div class="small-label" id="login-extra">尚未查询</div>
</div>
<div class="result-card" id="result-card" style="display: none">
<div class="result-actions">
<span class="small-label">最近一次扫码状态返回数据</span>
<button class="secondary" id="btn-copy-result">复制结果到剪贴板</button>
</div>
<div class="result-body" id="result-body"></div>
</div>
<div class="slider-card" id="slider-card">
<div class="small-label">新设备滑块验证(内嵌窗口)</div>
<iframe
id="slider-frame"
class="slider-frame"
src=""
referrerpolicy="no-referrer"
></iframe>
<div class="small-label">
如 iframe 无法加载,可在新标签页打开:
<a href="http://113.44.162.180:7765/?key=408449830" target="_blank" style="color: #60a5fa">
滑块验证页面
</a>
</div>
</div>
</aside>
</div>
</div>
<script>
const $ = (id) => document.getElementById(id);
const SLIDER_VERIFY_URL = 'http://113.44.162.180:7765/?key=408449830';
const state = {
pollingScan: null,
pollingOnline: null,
sliderOpened: false,
};
function log(message, level = 'info') {
const logBody = $('log-body');
const line = document.createElement('div');
line.className = 'log-line ' + (level === 'error' ? 'error' : level === 'warn' ? 'warn' : '');
const ts = new Date().toLocaleTimeString('zh-CN', { hour12: false });
line.textContent = `[${ts}] ${message}`;
logBody.appendChild(line);
logBody.scrollTop = logBody.scrollHeight;
}
async function checkHealth() {
try {
const res = await fetch('/health', { cache: 'no-store' });
if (!res.ok) throw new Error('status ' + res.status);
const data = await res.json().catch(() => ({}));
$('health-dot').style.background = '#22c55e';
$('health-text').textContent = '在线';
log('后端健康检查成功: ' + JSON.stringify(data || {}));
} catch (e) {
$('health-dot').style.background = '#ef4444';
$('health-text').textContent = '不可用';
log('后端健康检查失败: ' + (e && e.message ? e.message : e), 'error');
}
}
function setLoading(loading) {
$('btn-qrcode').disabled = loading;
$('btn-check-scan').disabled = loading;
$('btn-online').disabled = loading;
$('btn-logout').disabled = loading;
}
function getCommonPayload() {
const key = $('key').value.trim();
const proxy = $('proxy').value.trim();
const device = $('device').value;
const check = $('check-proxy').checked;
if (!key) {
alert('请先填写账号唯一标识key');
return null;
}
return {
key,
proxy: proxy || undefined,
ipadOrMac: device,
check,
};
}
function updateLoginState(text, status = 'unknown', extra = '') {
const el = $('login-state-text');
el.textContent = text;
el.className = 'login-state ' + status;
$('login-extra').textContent = extra || '';
const badge = $('login-state-badge');
if (status === 'ok') {
badge.textContent = '已登录 / 在线';
} else if (status === 'pending') {
badge.textContent = '等待扫码 / 确认中';
} else if (status === 'offline') {
badge.textContent = '已离线 / 未登录';
} else {
badge.textContent = '未知';
}
}
function renderQrFromResponse(data) {
const box = $('qr-img-box');
box.innerHTML = '';
let qrText = null;
if (data && typeof data === 'object') {
if (data.qrUrl || data.qrcodeUrl || data.qr || data.url) {
qrText = data.qrUrl || data.qrcodeUrl || data.qr || data.url;
} else if (data.qrcode || data.qr_code) {
qrText = data.qrcode || data.qr_code;
}
if (!qrText) {
const json = JSON.stringify(data, null, 2);
const pre = document.createElement('pre');
pre.textContent = json;
pre.style.fontSize = '10px';
pre.style.padding = '8px';
pre.style.whiteSpace = 'pre-wrap';
pre.style.wordBreak = 'break-all';
box.appendChild(pre);
$('qr-hint').textContent = '返回结果中未找到二维码字符串,请根据具体字段名在前端进行适配。';
return;
}
} else if (typeof data === 'string') {
qrText = data;
}
const qrContainer = document.createElement('div');
qrContainer.id = 'qr-code';
box.appendChild(qrContainer);
try {
// 使用前端 QRCode 库将登录链接渲染为二维码
// @ts-ignore
new QRCode(qrContainer, {
text: qrText,
width: 190,
height: 190,
});
$('qr-hint').textContent = '请使用微信客户端扫描上方二维码完成登录。';
} catch (e) {
const fallback = document.createElement('pre');
fallback.textContent = qrText || '';
fallback.style.fontSize = '10px';
fallback.style.padding = '8px';
fallback.style.whiteSpace = 'pre-wrap';
fallback.style.wordBreak = 'break-all';
box.appendChild(fallback);
$('qr-hint').textContent = '二维码生成失败,请复制链接手动生成二维码。';
}
}
async function callApi(path, options = {}) {
const url = '/api' + path.replace(/^\/api/, '');
const res = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...(options.headers || {}),
},
});
let body = null;
try {
body = await res.json();
} catch (_) {
// ignore
}
if (!res.ok) {
const msg = (body && (body.error || body.message)) || res.statusText || '请求失败';
throw new Error(msg);
}
return body;
}
async function onGetQrCode() {
const payload = getCommonPayload();
if (!payload) return;
setLoading(true);
try {
log('请求登录二维码...');
const body = {
Proxy: payload.proxy || '',
IpadOrmac: payload.ipadOrMac || '',
Check: !!payload.check,
};
const data = await callApi('/auth/qrcode', {
method: 'POST',
body: JSON.stringify({ key: payload.key, ...body }),
});
log('获取二维码成功');
renderQrFromResponse(data);
updateLoginState('等待扫码 / 确认中', 'pending', '请在 60 秒内使用微信扫码。');
} catch (e) {
log('获取二维码失败: ' + e.message, 'error');
updateLoginState('二维码获取失败', 'offline', e.message || '');
} finally {
setLoading(false);
}
}
async function onCheckScanStatus(silent = false) {
const payload = getCommonPayload();
if (!payload) return;
if (!silent) setLoading(true);
try {
if (!silent) log('手动检测扫码状态...');
const data = await callApi(
`/auth/scan-status?key=${encodeURIComponent(payload.key)}`,
);
const text = JSON.stringify(data);
log('扫码状态: ' + text);
const lower = text.toLowerCase();
// 显示最近一次返回的数据,方便复制
const resultCard = $('result-card');
const resultBody = $('result-body');
if (resultCard && resultBody) {
resultBody.textContent = text;
resultCard.style.display = 'flex';
}
// 自动识别「新设备验证」,展示滑块 iframe
if (!state.sliderOpened && (text.includes('新设备') || lower.includes('new device'))) {
state.sliderOpened = true;
log('检测到新设备验证,打开内嵌滑块验证窗口。', 'warn');
const sliderCard = $('slider-card');
const sliderFrame = $('slider-frame');
if (sliderCard && sliderFrame) {
sliderFrame.src = SLIDER_VERIFY_URL;
sliderCard.style.display = 'flex';
}
}
if (lower.includes('\"state\":2') || /\"state\"\\s*:\\s*2/.test(lower)) {
updateLoginState('登录成功', 'ok', text);
if (state.pollingScan) {
clearInterval(state.pollingScan);
state.pollingScan = null;
}
} else if (lower.includes('expired') || lower.includes('timeout')) {
updateLoginState('二维码已过期,请重新获取', 'offline', text);
if (state.pollingScan) {
clearInterval(state.pollingScan);
state.pollingScan = null;
}
} else {
updateLoginState('扫码状态更新', 'pending', text);
}
} catch (e) {
if (!silent) log('检测扫码状态失败: ' + e.message, 'error');
} finally {
if (!silent) setLoading(false);
}
}
async function onGetOnlineStatus() {
const payload = getCommonPayload();
if (!payload) return;
setLoading(true);
try {
log('查询账号在线状态...');
const data = await callApi(`/auth/status?key=${encodeURIComponent(payload.key)}`);
const text = JSON.stringify(data);
log('在线状态: ' + text);
const lower = text.toLowerCase();
if (lower.includes('online') || lower.includes('true') || lower.includes('登录')) {
updateLoginState('当前账号在线', 'ok', text);
} else {
updateLoginState('当前账号离线或未登录', 'offline', text);
}
} catch (e) {
log('获取在线状态失败: ' + e.message, 'error');
updateLoginState('在线状态查询失败', 'unknown', e.message || '');
} finally {
setLoading(false);
}
}
async function onLogout() {
const payload = getCommonPayload();
if (!payload) return;
if (!confirm('确定要退出该账号的微信登录吗?')) return;
setLoading(true);
try {
log('正在退出登录...');
const data = await callApi('/auth/logout', {
method: 'POST',
body: JSON.stringify({ key: payload.key }),
});
log('退出登录完成: ' + JSON.stringify(data));
updateLoginState('已退出登录', 'offline');
if (state.pollingScan) {
clearInterval(state.pollingScan);
state.pollingScan = null;
}
} catch (e) {
log('退出登录失败: ' + e.message, 'error');
} finally {
setLoading(false);
}
}
function bindEvents() {
$('btn-qrcode').addEventListener('click', (e) => {
e.preventDefault();
onGetQrCode();
});
$('btn-check-scan').addEventListener('click', (e) => {
e.preventDefault();
onCheckScanStatus(false);
});
$('btn-online').addEventListener('click', (e) => {
e.preventDefault();
onGetOnlineStatus();
});
$('btn-logout').addEventListener('click', (e) => {
e.preventDefault();
onLogout();
});
$('btn-clear-log').addEventListener('click', () => {
$('log-body').innerHTML = '';
});
const copyBtn = $('btn-copy-result');
if (copyBtn) {
copyBtn.addEventListener('click', async () => {
const body = $('result-body');
if (!body || !body.textContent) {
alert('当前没有可复制的结果。');
return;
}
const text = body.textContent;
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
} else {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
}
log('扫码状态结果已复制到剪贴板。', 'ok');
alert('结果已复制到剪贴板,可直接粘贴使用。');
} catch (e) {
log('复制结果到剪贴板失败: ' + e.message, 'error');
alert('复制失败,请手动选择文本复制。');
}
});
}
}
bindEvents();
checkHealth();
</script>
</body>
</html>