Files
wechatAiclaw/public/index.html
丹尼尔 0655410134 fix:bug
2026-03-10 17:29:22 +08:00

919 lines
29 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>