Files
wechatAiclaw/public/index.html
Daniel d5f1b2ae77 Revert "fix: 优化登录项"
This reverts commit 3b3fac1cee.
2026-03-16 23:05:07 +08:00

1290 lines
47 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">代理(可选,仅支持带用户名密码的 socks5h</label>
<input
id="proxy"
placeholder="socks5h://user:pass@ip:port"
autocomplete="off"
/>
<button type="button" class="secondary" id="btn-check-proxy" style="margin-top: 6px; padding: 4px 10px; font-size: 12px;">检测代理是否正常</button>
</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>
<div class="actions">
<button class="primary" id="btn-qrcode">
获取登录二维码
</button>
<button class="secondary" id="btn-qrcode-mac" title="使用 Mac 取码(与上方选择 Mac 后点获取二维码等效)">
重新取码(Mac)
</button>
<button class="secondary" id="btn-wake">
唤醒
</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">滑块验证(无数字时:先点「重新取码(Mac)」,手机停确认页再滑)</div>
<div id="slider-app" data-v-app="">
<div class="params-section" style="margin-bottom: 12px;">
<label class="form-label" for="keyInput">Key (7765 服务方):</label>
<input type="text" class="form-control" id="keyInput" placeholder="408449830" 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,
qrcodeFetchedAt: 0,
};
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');
// 仅当有有效参数时填充,避免用 {} 清空已有数据
var keyVal = params && (params.key || params.Key);
var data62Val = params && params.data62;
var ticketVal = params && (params.ticket || params.original_ticket);
if (keyVal !== undefined && keyVal !== '') if (keyInput) keyInput.value = keyVal;
if (data62Val !== undefined && data62Val !== '') if (data62Input) data62Input.value = data62Val;
if (ticketVal !== undefined && ticketVal !== '') if (ticketInput) ticketInput.value = ticketVal;
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 {
// 通过 /api/ws-status 检测后端是否可用(同时避免某些部署环境未透出 /health
const res = await fetch('/api/ws-status', { 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;
var btnQrcodeMac = $('btn-qrcode-mac');
if (btnQrcodeMac) btnQrcodeMac.disabled = loading;
var btnWake = $('btn-wake');
if (btnWake) btnWake.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') && $('device').value) || '';
if (!key) {
alert('请先填写账号唯一标识key');
return null;
}
return {
key,
proxy: proxy || undefined,
ipadOrMac: device || 'ipad',
};
}
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 onWake() {
const payload = getCommonPayload();
if (!payload) return;
setLoading(true);
try {
log('唤醒登录(仅扫码登录)...');
const body = {
key: payload.key,
Check: !!payload.check,
IpadOrmac: payload.ipadOrMac || 'ipad',
Proxy: payload.proxy || '',
};
const data = await callApi('/auth/wake', {
method: 'POST',
body: JSON.stringify(body),
});
log('唤醒请求已发送');
if (data && typeof data === 'object' && (data.error || data.detail)) {
log(JSON.stringify(data.error || data.detail), 'warn');
}
updateLoginState('已发送唤醒', 'pending', '如需扫码请点击「获取登录二维码」');
} catch (e) {
log('唤醒失败: ' + (e.message || e), 'error');
updateLoginState('唤醒失败', 'offline', e.message || '');
} finally {
setLoading(false);
}
}
async function onCheckProxy() {
var proxyInput = ($('proxy') && $('proxy').value || '').trim();
var url = '/api/check-proxy';
if (proxyInput) url += '?proxy=' + encodeURIComponent(proxyInput);
log('正在检测代理…' + (proxyInput ? '(使用当前填写的代理)' : '(使用环境变量或 KDL'));
var btn = $('btn-check-proxy');
if (btn) btn.disabled = true;
try {
var data = await callApi(url);
if (data && data.ok) {
log('代理正常。来源: ' + (data.source || '') + (data.proxy_preview ? ',代理: ' + data.proxy_preview : ''), 'ok');
} else {
log('代理不可用: ' + (data && data.error ? data.error : JSON.stringify(data)), 'error');
}
} catch (e) {
log('检测代理失败: ' + (e.message || e), 'error');
} finally {
if (btn) btn.disabled = false;
}
}
async function onGetQrCode(forceMac) {
const payload = getCommonPayload();
if (!payload) return;
setLoading(true);
try {
var deviceLabel = forceMac ? 'Mac' : (payload.ipadOrMac || 'ipad');
log(forceMac ? '重新取码(Mac)…' : '请求登录二维码(' + deviceLabel + ')...');
const body = {
Proxy: payload.proxy || '',
IpadOrmac: forceMac ? 'mac' : (payload.ipadOrMac || 'ipad'),
Check: false,
force_mac: !!forceMac,
};
const data = await callApi('/auth/qrcode', {
method: 'POST',
body: JSON.stringify({ key: payload.key, ...body }),
});
log('获取二维码成功');
(function() {
var valid = data._data62_valid;
var msg = data._data62_check || '未知';
var rawLen = data._data62_raw_length;
var cleanLen = data._data62_clean_length;
var preview = data._data62_preview || '';
var line1 = 'Data62检查: ' + (valid ? '完整有效' : '不完整/无效') + (msg && msg !== '完整有效' ? '' + msg + '' : '');
var line2 = '原始长度: ' + (rawLen !== undefined ? rawLen : '—') + ', 清理后长度: ' + (cleanLen !== undefined ? cleanLen : '—');
var line3 = preview ? ('预览: ' + preview) : '';
log(line1);
log(line2);
if (line3) log(line3);
})();
state.qrcodeFetchedAt = Date.now();
renderQrFromResponse(data);
updateLoginState(forceMac ? '已用 Mac 重新取码,请手机停在确认页并完成下方滑块' : '等待扫码 / 确认中', 'pending', forceMac ? 'Data62 已更新,需滑块时请点「打开滑块验证」' : '请在 60 秒内使用微信扫码。');
if (forceMac && data) {
var newData62 = (data.Data && data.Data.Data62) || data.Data62 || data.data62 || '';
if (newData62) {
if (!state.sliderParams) state.sliderParams = {};
state.sliderParams.data62 = newData62;
state.sliderParams.key = state.sliderParams.key || '408449830';
state.sliderOpened = true;
var reopenWrap = $('slider-reopen-wrap');
if (reopenWrap) reopenWrap.style.display = 'block';
log('Mac 取码成功Data62 已更新。上方显示二维码/返回内容;需滑块时请点击「打开滑块验证」。', 'warn');
}
}
if (state.pollingScan) clearInterval(state.pollingScan);
setTimeout(() => onCheckScanStatus(true), 2000);
state.pollingScan = setInterval(() => { onCheckScanStatus(true); }, 5000);
} catch (e) {
log('获取二维码失败: ' + (e.message || e), 'error');
updateLoginState('二维码获取失败', 'offline', e.message || '');
} finally {
setLoading(false);
}
}
async function onCheckScanStatus(silent = false) {
const payload = getCommonPayload(false);
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在当前页右侧替换为滑块区域并自动填充参数取码后 15 秒内不自动切,避免未出码就进验证)
const sliderUrl = data.slider_url;
var allowSliderSwitch = !state.qrcodeFetchedAt || (Date.now() - state.qrcodeFetchedAt) > 15000;
if (sliderUrl && typeof sliderUrl === 'string' && allowSliderSwitch) {
state.sliderOpened = true;
log('需完成滑块验证,已切换到滑块验证区域。', 'warn');
const params = parseSliderUrlParams(sliderUrl);
if (params) state.sliderParams = params;
showSliderAreaAndFill(params);
} else if (sliderUrl && !allowSliderSwitch) {
log('检测到需验证,但刚取码不久,暂不自动切到滑块,请先扫码或稍后再检测。', 'warn');
}
// 检测状态是否成功登录state==2 或 Success+已登录 等,成功后自动跳转管理页
var loginSuccess = stateVal === 2 || lower.includes('\"state\":2') || /\"state\"\\s*:\\s*2/.test(lower) ||
(obj.Success === true && (lower.includes('登录') || lower.includes('online') || d && (d.state === 2 || d.State === 2)));
if (loginSuccess) {
updateLoginState('登录成功', 'ok', '');
if (state.pollingScan) {
clearInterval(state.pollingScan);
state.pollingScan = null;
}
log('检测到登录成功,正在跳转管理页…', 'ok');
showQrExpired('登录成功,正在跳转…');
var keyParam = payload.key ? '?key=' + encodeURIComponent(payload.key) : '';
window.location.href = 'manage.html' + keyParam;
// 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 ? '扫码完成,请完成验证后登录' : '');
// 判定到需验证时切换验证模块;取码后 15 秒内不自动切,避免未出码就进验证
if (needVerify || sliderUrl) {
var allowSwitch = !state.qrcodeFetchedAt || (Date.now() - state.qrcodeFetchedAt) > 15000;
if (allowSwitch) {
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(false);
});
$('btn-qrcode-mac') && $('btn-qrcode-mac').addEventListener('click', (e) => {
e.preventDefault();
onGetQrCode(true);
});
$('btn-wake') && $('btn-wake').addEventListener('click', function(e) {
e.preventDefault();
onWake();
});
$('btn-check-proxy') && $('btn-check-proxy').addEventListener('click', function(e) {
e.preventDefault();
onCheckProxy();
});
$('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>