Files
wechatAiclaw/public/index.html
2026-03-11 12:21:25 +08:00

1189 lines
42 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: #ffffff;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
padding: 16px; /* 给二维码留出白边quiet zone */
box-sizing: border-box;
box-shadow:
0 0 0 1px rgba(15, 23, 42, 0.9),
0 15px 32px rgba(15, 23, 42, 0.9);
}
.qr-img img,
.qr-img canvas {
max-width: 100%;
max-height: 100%;
display: block;
}
.qr-placeholder {
font-size: 12px;
color: var(--muted);
text-align: center;
}
.qr-expired {
font-size: 13px;
color: var(--muted);
text-align: center;
padding: 12px;
background: rgba(15, 23, 42, 0.95);
border-radius: 12px;
border: 1px dashed rgba(148, 163, 184, 0.5);
}
.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;
}
.ticket-card {
border-radius: 20px;
border: 1px solid rgba(55, 65, 81, 0.9);
background: rgba(15, 23, 42, 0.98);
padding: 10px 10px 8px;
margin-top: 12px;
display: none;
flex-direction: column;
gap: 6px;
}
.ticket-body {
max-height: 80px;
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;
}
.data62-card {
border-radius: 20px;
border: 1px solid rgba(55, 65, 81, 0.9);
background: rgba(15, 23, 42, 0.98);
padding: 10px 10px 8px;
margin-top: 12px;
display: none;
flex-direction: column;
gap: 6px;
}
.data62-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;
}
.page-wrap {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
min-height: 100vh;
}
.home-banner {
width: 100%;
text-align: center;
padding: 20px 24px 16px;
border-bottom: 1px solid var(--border);
background: rgba(2, 6, 23, 0.6);
}
.home-banner .banner-text {
font-size: 28px;
font-weight: 700;
letter-spacing: 0.06em;
color: var(--text);
margin: 0;
text-shadow: 0 0 20px rgba(34, 197, 94, 0.2);
}
.home-banner .banner-text span { color: var(--accent); }
</style>
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
</head>
<body>
<div class="page-wrap">
<div class="home-banner">
<h1 class="banner-text">Wechat <span>智能托管服务</span></h1>
<p style="margin:8px 0 0;font-size:14px"><a href="swagger.html" target="_blank" style="color:var(--accent)">API 文档 (Swagger)</a></p>
</div>
<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>
<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>
<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 id="qr-area">
<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 id="slider-reopen-wrap" style="display: none; margin-top: 12px;">
<button type="button" class="secondary" id="btn-show-slider">打开滑块验证</button>
</div>
</div>
<div id="slider-area" style="display: none;">
<div class="card" style="max-width: 480px;">
<div class="card-title">滑块验证</div>
<div id="slider-app" data-v-app="">
<div class="params-section" style="margin-bottom: 12px;">
<label class="form-label" for="keyInput">Key:</label>
<input type="text" class="form-control" id="keyInput" placeholder="请输入key" style="width:100%;box-sizing:border-box;padding:8px;margin-bottom:8px;border:1px solid var(--border);border-radius:8px;background:rgba(15,23,42,0.6);color:var(--text);">
<label class="form-label" for="data62Input">Data62:</label>
<input type="text" class="form-control" id="data62Input" placeholder="请输入data62" style="width:100%;box-sizing:border-box;padding:8px;margin-bottom:8px;border:1px solid var(--border);border-radius:8px;background:rgba(15,23,42,0.6);color:var(--text);">
<label class="form-label" for="originalTicketInput">Original Ticket:</label>
<input type="text" class="form-control" id="originalTicketInput" placeholder="请输入original_ticket" style="width:100%;box-sizing:border-box;padding:8px;margin-bottom:12px;border:1px solid var(--border);border-radius:8px;background:rgba(15,23,42,0.6);color:var(--text);">
</div>
<div class="text-center">
<button type="button" class="btn btn-verify btn-lg" id="slider-btn-verify" disabled style="padding:10px 20px;background:var(--accent);color:#fff;border:none;border-radius:8px;cursor:pointer;">开始验证</button>
<div class="text-muted mt-2" style="font-size:12px;color:var(--muted);margin-top:8px;">请先填写完整的参数信息</div>
</div>
</div>
</div>
</div>
</aside>
</div>
</div>
</div>
<script>
const $ = (id) => document.getElementById(id);
// 相对路径由 Node 代理到后端;若从 file:// 打开则请求会失败,改用同源或默认地址
const API_BASE = (typeof location !== 'undefined' && location.origin && (location.protocol === 'http:' || location.protocol === 'https:'))
? '' : (typeof location !== 'undefined' ? 'http://127.0.0.1:3000' : '');
const state = {
pollingScan: null,
pollingOnline: null,
sliderOpened: false,
sliderParams: null,
sliderScriptLoaded: false,
sliderListenersBound: false,
};
function parseSliderUrlParams(sliderUrl) {
if (!sliderUrl || typeof sliderUrl !== 'string') return null;
const q = sliderUrl.indexOf('?');
if (q === -1) return null;
const params = {};
sliderUrl.slice(q + 1).split('&').forEach(function(pair) {
const i = pair.indexOf('=');
if (i > 0) params[decodeURIComponent(pair.slice(0, i).replace(/\+/g, ' '))] = decodeURIComponent((pair.slice(i + 1) || '').replace(/\+/g, ' '));
});
return params;
}
function showSliderAreaAndFill(params) {
var qrArea = $('qr-area');
var sliderArea = $('slider-area');
if (!qrArea || !sliderArea) return;
qrArea.style.display = 'none';
sliderArea.style.display = 'block';
var keyInput = $('keyInput');
var data62Input = $('data62Input');
var ticketInput = $('originalTicketInput');
var btnVerify = $('slider-btn-verify');
if (params) {
if (keyInput) keyInput.value = params.key || params.Key || '';
if (data62Input) data62Input.value = params.data62 || '';
if (ticketInput) ticketInput.value = params.ticket || params.original_ticket || '';
}
function toggleVerifyBtn() {
if (!btnVerify) return;
var hasAll = keyInput && data62Input && ticketInput &&
(keyInput.value || '').trim() && (data62Input.value || '').trim() && (ticketInput.value || '').trim();
btnVerify.disabled = !hasAll;
}
if (!state.sliderListenersBound) {
state.sliderListenersBound = true;
if (keyInput) keyInput.addEventListener('input', toggleVerifyBtn);
if (data62Input) data62Input.addEventListener('input', toggleVerifyBtn);
if (ticketInput) ticketInput.addEventListener('input', toggleVerifyBtn);
}
toggleVerifyBtn();
if (!state.sliderScriptLoaded) {
state.sliderScriptLoaded = true;
var script = document.createElement('script');
script.type = 'module';
script.crossOrigin = 'anonymous';
script.src = (API_BASE || '') + '/auth/slider-assets/N_jYM_2V.js';
document.body.appendChild(script);
}
}
function showQrArea() {
var qrArea = $('qr-area');
var sliderArea = $('slider-area');
var reopenWrap = $('slider-reopen-wrap');
if (qrArea) qrArea.style.display = 'block';
if (sliderArea) sliderArea.style.display = 'none';
if (reopenWrap) reopenWrap.style.display = state.sliderParams ? 'block' : 'none';
}
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 extractQrTextFromObject(obj) {
let result = null;
const base64Like = /^[A-Za-z0-9+/=]+$/;
function walk(value, keyHint) {
if (result || value == null) return;
if (typeof value === 'string') {
// 1) 优先从 URL / 文本中找 data= 后面的内容
const idx = value.indexOf('data=');
if (idx !== -1) {
let s = value.slice(idx + 5);
const stop = s.search(/[&\s"]/);
if (stop > 0) s = s.slice(0, stop);
if (s && s.length >= 16 && base64Like.test(s)) {
result = s;
return;
}
}
// 2) 兜底:如果键名看起来与二维码相关,并且值是长 base64则直接当作二维码数据
if (
keyHint &&
/qr|code|data/i.test(keyHint) &&
!/62/i.test(keyHint) &&
value.length >= 32 &&
base64Like.test(value)
) {
result = value;
return;
}
} else if (typeof value === 'object') {
if (Array.isArray(value)) {
value.forEach((v) => walk(v, keyHint));
} else {
Object.entries(value).forEach(([k, v]) => walk(v, k));
}
}
}
walk(obj, '');
return result;
}
// 从 QrCodeUrl 中解析出微信登录链接url= 参数),扫码后才能唤起微信登录
function getWechatLoginUrlFromResponse(data) {
if (!data || typeof data !== 'object') return null;
const qrCodeUrl = data.Data?.QrCodeUrl || data.QrCodeUrl || data.qrCodeUrl || data.qrUrl || data.url;
if (!qrCodeUrl || typeof qrCodeUrl !== 'string') return null;
const idx = qrCodeUrl.indexOf('url=');
if (idx === -1) {
if (/^https?:\/\/weixin\.qq\.com\//i.test(qrCodeUrl)) return qrCodeUrl;
return qrCodeUrl;
}
let url = qrCodeUrl.slice(idx + 4);
const end = url.search(/[&\s]/);
if (end > 0) url = url.slice(0, end);
try {
url = decodeURIComponent(url);
} catch (_) {}
return url || null;
}
function _unused_extractCleanTicketFromResponse(data) {
if (!data || typeof data !== 'object') return null;
const obj = data;
const d = obj.Data && typeof obj.Data === 'object' ? obj.Data : obj;
let raw =
d.ticket ||
obj.ticket ||
obj.Ticket ||
(typeof obj.wechat_verify_url === 'string' &&
obj.wechat_verify_url.includes('ticket=')
? obj.wechat_verify_url.slice(obj.wechat_verify_url.indexOf('ticket=') + 7)
: '');
if (!raw || typeof raw !== 'string') return null;
// 如果来自 wechat_verify_url去掉后续参数
const amp = raw.indexOf('&');
if (amp > 0) raw = raw.slice(0, amp);
let clean = '';
for (let i = 0; i < raw.length; i++) {
const ch = raw[i];
const code = ch.charCodeAt(0);
// '<27>' 或非可见 ASCII 视为乱码起点
if (ch === '<27>' || code < 32 || code > 126) break;
clean += ch;
}
if (!clean) return null;
return clean;
}
function renderQrFromResponse(data) {
const box = $('qr-img-box');
box.innerHTML = '';
let qrText = null;
if (data && typeof data === 'object') {
// 1) 优先用「微信登录链接」绘制二维码扫码后才会唤起登录Data62 是二进制数据,不能当二维码内容
qrText = getWechatLoginUrlFromResponse(data);
if (!qrText && (data.qrUrl || data.qrcodeUrl || data.qr || data.url)) {
qrText = data.qrUrl || data.qrcodeUrl || data.qr || data.url;
}
if (!qrText && (data.qrcode || data.qr_code)) {
qrText = data.qrcode || data.qr_code;
}
if (!qrText) {
qrText = extractQrTextFromObject(data);
}
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 =
'当前接口返回中未包含可识别的二维码字符串,请检查上游服务是否已按文档返回 data= 或专门的二维码字段。';
log('二维码接口返回(未能自动识别二维码字段): ' + json, 'warn');
return;
}
} else if (typeof data === 'string') {
qrText = data;
}
log('用于生成二维码的 data= 内容: ' + qrText, 'ok');
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 = '二维码生成失败,请复制链接手动生成二维码。';
log('二维码渲染失败: ' + (e && e.message ? e.message : e), 'error');
}
}
// 根据扫码状态:扫完或过期时刷新二维码区域为「已过期」提示
function showQrExpired(message) {
const box = $('qr-img-box');
if (!box) return;
box.innerHTML = '';
const placeholder = document.createElement('div');
placeholder.className = 'qr-expired';
placeholder.textContent = message || '二维码已过期,请重新获取';
box.appendChild(placeholder);
const hint = $('qr-hint');
if (hint) hint.textContent = message || '二维码已过期,请点击「获取登录二维码」重新取码。';
}
async function callApi(path, options = {}) {
const url = (API_BASE || '') + path;
let res;
try {
res = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...(options.headers || {}),
},
});
} catch (e) {
const msg = (e && e.message) ? String(e.message) : String(e);
const isRefused = /failed to fetch|networkerror|connection refused|load failed/i.test(msg);
log(isRefused ? '无法连接服务ERR_CONNECTION_REFUSED请通过前端地址访问如 http://localhost:3000并确保已启动 run-dev.sh 或 Docker。' : '请求失败: ' + msg, 'error');
throw new Error(isRefused ? '无法连接服务,请确认已通过 http://localhost:3000 打开页面且前后端已启动。' : msg);
}
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 秒内使用微信扫码。');
// 扫完码后自动检测:先 2 秒做一次检测,再每 5 秒轮询,避免误判为「二维码失效」
if (state.pollingScan) {
clearInterval(state.pollingScan);
}
setTimeout(() => onCheckScanStatus(true), 2000);
state.pollingScan = setInterval(() => {
onCheckScanStatus(true);
}, 5000);
} 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 obj = data && typeof data === 'object' ? data : {};
const d = obj.Data && typeof obj.Data === 'object' ? obj.Data : obj;
const stateVal = d.state ?? d.State;
// 后端返回滑块 path在当前页右侧替换为滑块区域并自动填充参数
const sliderUrl = data.slider_url;
if (sliderUrl && typeof sliderUrl === 'string') {
state.sliderOpened = true;
log('需完成滑块验证,已切换到滑块验证区域。', 'warn');
const params = parseSliderUrlParams(sliderUrl);
if (params) state.sliderParams = params;
showSliderAreaAndFill(params);
}
// state == 2 → 登录成功,跳转后端管理页
if (stateVal === 2 || lower.includes('\"state\":2') || /\"state\"\\s*:\\s*2/.test(lower)) {
updateLoginState('登录成功', 'ok', '');
if (state.pollingScan) {
clearInterval(state.pollingScan);
state.pollingScan = null;
}
showQrExpired('登录成功,正在跳转…');
window.location.href = 'manage.html';
// state == 1 → 先判定是否明确「过期」,否则一律视为可能需验证,继续轮询并优先根据 slider_url 切到滑块
} else if (stateVal === 1 || lower.includes('\"state\":1') || /\"state\"\\s*:\\s*1/.test(lower)) {
var needVerify = /请提交验证码|提交验证码后登录|验证码后登录|安全验证|完成验证/.test(text) ||
(d && (d.msg && /请提交验证码|验证码后登录|安全验证|完成验证/.test(d.msg)) || (obj.Text && /请提交验证码|验证码后登录|安全验证|完成验证/.test(obj.Text)));
var explicitExpired = /二维码已过期|已过期|已失效|失效|请重新获取|重新获取二维码/.test(text) ||
(d && (d.msg && /已过期|已失效|失效|请重新获取/.test(d.msg)) || (obj.Text && /已过期|已失效|失效|请重新获取/.test(obj.Text)));
if (explicitExpired && !sliderUrl) {
updateLoginState('二维码已过期,请重新获取', 'offline', '');
if (state.pollingScan) {
clearInterval(state.pollingScan);
state.pollingScan = null;
}
showQrExpired('二维码已过期,请重新获取');
} else {
updateLoginState(needVerify || sliderUrl ? '请完成滑块验证' : '正在确认登录状态…', 'pending',
needVerify || sliderUrl ? '扫码完成,请完成验证后登录' : '');
// 判定到需验证时直接切换验证模块,不等用户点「检测扫码状态」;有 slider_url 已在上方填参,无则先切过去,后续轮询会带参
if (needVerify || sliderUrl) {
state.sliderOpened = true;
if (!sliderUrl) log('需完成滑块验证,已切换到滑块验证区域,等待参数…', 'warn');
showSliderAreaAndFill(state.sliderParams || {});
}
}
} else {
updateLoginState('扫码状态更新', 'pending', '');
}
} 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', '');
} else {
updateLoginState('当前账号离线或未登录', 'offline', '');
}
} 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();
if ($('slider-area') && $('slider-area').style.display !== 'none') {
showQrArea();
}
onGetQrCode();
});
$('btn-show-slider') && $('btn-show-slider').addEventListener('click', function() {
if (state.sliderParams) showSliderAreaAndFill(state.sliderParams);
});
$('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 = '';
});
}
bindEvents();
checkHealth();
(function() {
try {
if (typeof localStorage !== 'undefined' && localStorage.getItem('wechat_key')) {
var keyEl = $('key');
if (keyEl && !keyEl.value.trim()) keyEl.value = localStorage.getItem('wechat_key');
}
$('key').addEventListener('change', function() { try { localStorage.setItem('wechat_key', this.value.trim()); } catch (_) {} });
$('key').addEventListener('blur', function() { try { localStorage.setItem('wechat_key', this.value.trim()); } catch (_) {} });
} catch (_) {}
})();
</script>
</body>
</html>