Files
xb/wechat-webui/index.html
2026-02-27 15:24:20 +08:00

879 lines
24 KiB
HTML
Raw Permalink 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" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>WeChatPadPro 登录控制台</title>
<style>
:root {
--bg: #f3f6ff;
--card: #ffffff;
--line: #d9e1f6;
--text: #1f2a44;
--muted: #5f6b87;
--primary: #2356d8;
--primary-deep: #1b43ad;
--danger: #c9343f;
--ok: #14b86e;
--mono: "SFMono-Regular", Menlo, Consolas, monospace;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif;
color: var(--text);
background:
radial-gradient(1200px 500px at 80% -20%, #d9e7ff 0%, transparent 70%),
radial-gradient(800px 300px at 10% -10%, #eef4ff 0%, transparent 72%),
var(--bg);
min-height: 100vh;
padding: 18px;
}
.shell {
max-width: 1180px;
margin: 0 auto;
display: grid;
gap: 14px;
}
.top {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
background: linear-gradient(120deg, #2448a5 0%, #2f66e3 100%);
border-radius: 14px;
padding: 16px 18px;
color: #fff;
box-shadow: 0 10px 24px rgba(35, 86, 216, 0.2);
}
.title {
margin: 0;
font-size: 20px;
font-weight: 700;
}
.subtitle {
margin: 5px 0 0;
font-size: 13px;
opacity: 0.9;
}
.status-pill {
background: rgba(255, 255, 255, 0.16);
border: 1px solid rgba(255, 255, 255, 0.24);
border-radius: 999px;
padding: 7px 12px;
font-size: 13px;
display: inline-flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
.dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: #ffd25f;
box-shadow: 0 0 0 0 rgba(255, 210, 95, 0.6);
animation: pulse 1.5s infinite;
}
.dot.online {
background: var(--ok);
box-shadow: 0 0 0 0 rgba(20, 184, 110, 0.55);
}
@keyframes pulse {
0% {
box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.35);
}
70% {
box-shadow: 0 0 0 9px rgba(255, 255, 255, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(255, 255, 255, 0);
}
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.panel {
background: var(--card);
border: 1px solid var(--line);
border-radius: 14px;
padding: 14px;
box-shadow: 0 7px 20px rgba(30, 68, 163, 0.06);
min-height: 100%;
}
.panel h2 {
margin: 0 0 12px;
font-size: 16px;
font-weight: 700;
}
.field {
display: grid;
gap: 6px;
}
label {
font-size: 13px;
color: var(--muted);
}
select,
textarea {
width: 100%;
border-radius: 10px;
border: 1px solid #cbd7f7;
background: #fbfdff;
padding: 10px 11px;
color: var(--text);
font-size: 14px;
outline: none;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
select:focus,
textarea:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(35, 86, 216, 0.16);
}
.actions {
display: flex;
gap: 9px;
margin-top: 12px;
flex-wrap: wrap;
}
button {
border: none;
border-radius: 10px;
padding: 9px 14px;
font-size: 14px;
cursor: pointer;
transition: transform 0.06s ease, opacity 0.15s ease;
}
button:active {
transform: translateY(1px);
}
button:disabled {
cursor: not-allowed;
opacity: 0.58;
transform: none;
}
.btn-main {
color: #fff;
background: linear-gradient(120deg, var(--primary), var(--primary-deep));
}
.btn-danger {
color: #fff;
background: linear-gradient(120deg, #d73f4a, #b61e2a);
}
.qr-wrap {
border-radius: 12px;
border: 1px dashed #cad4f0;
min-height: 330px;
display: grid;
align-content: center;
justify-items: center;
gap: 8px;
background: #fcfdff;
padding: 12px;
}
.qr-img {
width: min(330px, 100%);
border: 1px solid #dde5f8;
border-radius: 12px;
background: #fff;
display: none;
}
.empty {
font-size: 13px;
color: var(--muted);
text-align: center;
line-height: 1.45;
}
.message-list {
display: grid;
gap: 9px;
max-height: 360px;
overflow: auto;
padding-right: 2px;
}
.message-card {
border-radius: 11px;
border: 1px solid #dbe4fa;
background: #fbfdff;
padding: 10px;
display: grid;
gap: 6px;
}
.message-meta {
font-family: var(--mono);
font-size: 12px;
color: #44506a;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.message-content {
font-size: 13px;
line-height: 1.42;
color: #0f172a;
white-space: pre-wrap;
word-break: break-word;
}
textarea {
min-height: 360px;
resize: vertical;
font-family: var(--mono);
font-size: 12px;
background: #f8fbff;
}
@media (max-width: 980px) {
.grid {
grid-template-columns: 1fr;
}
.top {
flex-direction: column;
align-items: flex-start;
}
.status-pill {
align-self: flex-start;
}
}
</style>
</head>
<body>
<main class="shell">
<section class="top">
<div>
<h1 class="title">WeChatPadPro 登录控制台</h1>
<p class="subtitle">只保留扫码登录,状态与消息自动轮询</p>
</div>
<div class="status-pill">
<span id="onlineDot" class="dot"></span>
<span id="onlineText">未连接</span>
</div>
</section>
<section class="grid">
<section class="panel">
<h2>1. 登录配置</h2>
<div class="field">
<label for="qrEndpoint">二维码类型</label>
<select id="qrEndpoint">
<option value="GetLoginQrCodeNewX">NewX默认</option>
<option value="GetLoginQrCodePadX">PadX</option>
<option value="GetLoginQrCodeWin">Win</option>
<option value="GetLoginQrCodeMac">Mac</option>
<option value="GetLoginQrCodeAndroidPad">AndroidPad</option>
<option value="GetLoginQrCodeCar">Car</option>
<option value="GetLoginQrCodeA16">A16</option>
<option value="GetLoginQrCodeNew">New</option>
</select>
</div>
<div class="actions">
<button id="startLoginBtn" class="btn-main">生成授权二维码</button>
<button id="clearLoginBtn" class="btn-danger">清除登录状态</button>
</div>
</section>
<section class="panel">
<h2>2. 扫码窗口</h2>
<div class="qr-wrap">
<img id="qrImg" class="qr-img" alt="二维码" />
<div id="qrHint" class="empty">点击“生成授权二维码”后在这里显示二维码</div>
</div>
</section>
</section>
<section class="panel">
<h2>状态日志</h2>
<textarea id="statusView" readonly></textarea>
</section>
</main>
<script>
const state = {
baseUrl: "http://127.0.0.1:18238",
authKey: "",
qrData: null,
isOnline: false,
statusKnown: false,
statusTimer: null,
messageTimer: null,
lastStatusSig: "",
lastStatusAlertSig: "",
lastStatusReqError: "",
seenMessageKeys: new Set(),
persistTimer: null
};
const STATUS_INTERVAL_MS = 2000;
const MESSAGE_INTERVAL_MS = 2000;
const MESSAGE_COUNT = 30;
const QR_HINT_DEFAULT = "点击“生成授权二维码”后在这里显示二维码";
const QR_HINT_ONLINE = "当前账号已登录,二维码已隐藏。掉线或清除登录状态后可重新生成。";
const QR_HINT_CHECKING = "检测到已保存会话,正在校验登录状态,请稍候。";
const QR_ENDPOINT_PRESET = [
{ value: "GetLoginQrCodeNewX", label: "NewX默认" },
{ value: "GetLoginQrCodePadX", label: "PadX" },
{ value: "GetLoginQrCodeWin", label: "Win" },
{ value: "GetLoginQrCodeMac", label: "Mac" },
{ value: "GetLoginQrCodeAndroidPad", label: "AndroidPad" },
{ value: "GetLoginQrCodeCar", label: "Car" },
{ value: "GetLoginQrCodeA16", label: "A16" },
{ value: "GetLoginQrCodeNew", label: "New" }
];
const el = (id) => document.getElementById(id);
function log(line) {
const area = el("statusView");
const stamp = new Date().toLocaleTimeString();
area.value = `[${stamp}] ${line}\n` + area.value;
}
async function api(path, method = "GET", payload = null) {
const options = { method, headers: {} };
if (payload) {
options.headers["Content-Type"] = "application/json";
options.body = JSON.stringify(payload);
}
const resp = await fetch(path, options);
let data;
try {
data = await resp.json();
} catch (err) {
throw new Error(`响应不是 JSON: ${resp.status}`);
}
if (!resp.ok) {
throw new Error(data.error || `请求失败: ${resp.status}`);
}
return data;
}
function setOnline(isOnline) {
state.isOnline = Boolean(isOnline);
const dot = el("onlineDot");
const text = el("onlineText");
if (state.isOnline) {
dot.classList.add("online");
text.textContent = "已在线";
} else {
dot.classList.remove("online");
text.textContent = "未在线";
}
refreshLoginUiByState();
}
function hideQr(hintText = QR_HINT_DEFAULT) {
const img = el("qrImg");
const hint = el("qrHint");
img.src = "";
img.style.display = "none";
hint.textContent = hintText;
}
function refreshLoginUiByState() {
const btn = el("startLoginBtn");
if (!btn) {
return;
}
if (state.authKey && !state.statusKnown) {
btn.disabled = true;
btn.textContent = "状态检测中...";
hideQr(QR_HINT_CHECKING);
return;
}
if (state.isOnline) {
btn.disabled = true;
btn.textContent = "当前已登录";
hideQr(QR_HINT_ONLINE);
return;
}
btn.disabled = false;
btn.textContent = "生成授权二维码";
if (state.qrData?.qrCodeBase64) {
renderQr(state.qrData);
} else {
hideQr(QR_HINT_DEFAULT);
}
}
function renderQr(qrData) {
const img = el("qrImg");
const hint = el("qrHint");
const base64 = qrData?.qrCodeBase64 || "";
if (!base64) {
hideQr(QR_HINT_DEFAULT);
return;
}
img.src = base64.startsWith("data:") ? base64 : `data:image/jpeg;base64,${base64}`;
img.style.display = "block";
hint.textContent = "请用微信扫码并在手机确认登录";
}
function clearMessageList() {
const list = el("messageList");
if (list) {
list.innerHTML = `<div class="empty">暂无消息</div>`;
}
state.seenMessageKeys.clear();
}
function appendMessages(items) {
if (!Array.isArray(items) || items.length === 0) {
return;
}
const list = el("messageList");
if (!list) {
return;
}
const firstEmpty = list.querySelector(".empty");
if (firstEmpty) {
firstEmpty.remove();
}
items.forEach((item) => {
const dedupeKey =
item.id && item.id !== "-"
? `id:${item.id}|type:${item.type ?? "-"}`
: [
item.id ?? "-",
item.type ?? "-",
item.time ?? "-",
item.from ?? "-",
item.to ?? "-",
String(item.content ?? "").slice(0, 120)
].join("|");
if (state.seenMessageKeys.has(dedupeKey)) {
return;
}
state.seenMessageKeys.add(dedupeKey);
if (state.seenMessageKeys.size > 3500) {
const first = state.seenMessageKeys.values().next().value;
state.seenMessageKeys.delete(first);
}
const card = document.createElement("article");
card.className = "message-card";
const meta = document.createElement("div");
meta.className = "message-meta";
meta.innerHTML =
`<span>id: ${item.id ?? "-"}</span>` +
`<span>type: ${item.type ?? "-"}</span>` +
`<span>from: ${item.from ?? "-"}</span>` +
`<span>to: ${item.to ?? "-"}</span>`;
const content = document.createElement("div");
content.className = "message-content";
content.textContent = item.content ?? "-";
card.appendChild(meta);
card.appendChild(content);
list.prepend(card);
});
}
function queuePersistSession() {
if (state.persistTimer) {
clearTimeout(state.persistTimer);
}
state.persistTimer = setTimeout(() => {
persistSession(true);
}, 250);
}
async function persistSession(silent = true) {
try {
await api("/api/session", "POST", {
baseUrl: state.baseUrl,
authKey: state.authKey,
qrData: state.qrData || {},
qrEndpoint: el("qrEndpoint").value
});
} catch (err) {
if (!silent) {
log(`保存会话失败: ${err.message}`);
}
}
}
function ensureStatusPolling() {
if (state.statusTimer) {
return;
}
state.statusTimer = setInterval(() => {
checkStatus(false);
}, STATUS_INTERVAL_MS);
log(`已启动自动状态轮询(${STATUS_INTERVAL_MS / 1000}秒)。`);
}
function ensureMessagePolling() {
if (state.messageTimer) {
return;
}
state.messageTimer = setInterval(() => {
pollMessages(false);
}, MESSAGE_INTERVAL_MS);
log(`已启动自动消息轮询(${MESSAGE_INTERVAL_MS / 1000}秒)。`);
}
function stopMessagePolling(silent = false) {
if (!state.messageTimer) {
return;
}
clearInterval(state.messageTimer);
state.messageTimer = null;
if (!silent) {
log("已停止自动消息轮询。");
}
}
function buildStatusSig(data) {
const checkCode = data?.check?.Code;
const checkState = data?.check?.Data?.state ?? "-";
const onlineCode = data?.online?.Code;
const onlineText = data?.online?.Text || "-";
return `${checkCode}|${checkState}|${onlineCode}|${onlineText}`;
}
function logStatusIfChanged(data) {
const sig = buildStatusSig(data);
if (sig === state.lastStatusSig) {
return;
}
state.lastStatusSig = sig;
const checkCode = data?.check?.Code;
const checkText = data?.check?.Text || "-";
const checkState = data?.check?.Data?.state;
const onlineCode = data?.online?.Code;
const onlineText = data?.online?.Text || "-";
log(
`状态: CheckLoginStatus=${checkCode}, state=${checkState ?? "-"}(${checkText}) | ` +
`GetLoginStatus=${onlineCode}(${onlineText})`
);
}
function reportStatusAlerts(data) {
if (state.isOnline) {
state.lastStatusAlertSig = "";
return;
}
const alerts = [];
const checkCode = Number(data?.check?.Code);
const checkState = data?.check?.Data?.state;
const checkText = String(data?.check?.Text || "").trim();
const onlineCode = Number(data?.online?.Code);
const onlineText = String(data?.online?.Text || "").trim();
if (onlineCode !== 200 && onlineText) {
alerts.push(`账号未在线: ${onlineCode}(${onlineText})`);
}
if (onlineText.includes("版本过低")) {
alerts.push("检测到版本过低请切换二维码类型Win/Mac/AndroidPad或升级后端组件。");
}
if (onlineText.includes("重新登录") || onlineText.includes("MMLoginStateNoLogin")) {
alerts.push("账号状态需要重新登录,请点击“生成授权二维码”重新扫码。");
}
if (checkCode === -3) {
alerts.push("当前流程需要验证码,若持续出现请改用支持验证码的登录流程。");
}
if (checkCode === 300 || checkCode === -2) {
alerts.push("授权或二维码状态异常(可能已失效),请重新生成授权二维码。");
}
if (checkCode < 0 && checkCode !== -2 && checkCode !== -3) {
alerts.push(`扫码状态异常: ${checkCode}(${checkText || "-"})`);
}
if (checkState === 4 && onlineCode !== 200) {
alerts.push("登录卡在新设备验证阶段,请在手机确认或换二维码类型重试。");
}
const sig = alerts.join("||");
if (!sig) {
state.lastStatusAlertSig = "";
return;
}
if (sig === state.lastStatusAlertSig) {
return;
}
state.lastStatusAlertSig = sig;
alerts.forEach((line) => log(`异常: ${line}`));
}
function rebuildQrEndpointOptions(serverEndpoints) {
const select = el("qrEndpoint");
const current = select.value;
const allowed =
Array.isArray(serverEndpoints) && serverEndpoints.length > 0
? new Set(serverEndpoints)
: null;
const options = [];
QR_ENDPOINT_PRESET.forEach((item) => {
if (!allowed || allowed.has(item.value)) {
options.push(item);
}
});
if (allowed) {
Array.from(allowed)
.sort()
.forEach((name) => {
if (!options.some((x) => x.value === name)) {
options.push({ value: name, label: name });
}
});
}
if (options.length === 0) {
options.push(...QR_ENDPOINT_PRESET);
}
select.innerHTML = "";
options.forEach((item) => {
const opt = document.createElement("option");
opt.value = item.value;
opt.textContent = item.label;
select.appendChild(opt);
});
if (current && options.some((x) => x.value === current)) {
select.value = current;
return;
}
if (options.some((x) => x.value === "GetLoginQrCodeNewX")) {
select.value = "GetLoginQrCodeNewX";
return;
}
select.value = options[0].value;
}
async function loadConfig() {
try {
const cfg = await api("/api/config");
if (cfg.baseUrl) {
state.baseUrl = cfg.baseUrl;
}
rebuildQrEndpointOptions(cfg.qrEndpoints);
if (cfg.hasDefaultAdminKey) {
log("已检测到后端默认 ADMIN_KEY可直接生成授权二维码。");
} else {
log("后端没有默认 ADMIN_KEY生成授权二维码会失败。请先在服务端 deploy/.env 配置。");
}
if (cfg.hasSavedSession) {
log("检测到已保存会话,正在恢复。");
}
} catch (err) {
log(`读取配置失败: ${err.message}`);
}
}
async function restoreSession() {
try {
const data = await api("/api/session");
const session = data.session || {};
if (session.baseUrl) {
state.baseUrl = session.baseUrl;
}
if (session.authKey) {
state.authKey = session.authKey;
state.statusKnown = false;
log("已恢复上次会话authKey/baseUrl。");
}
if (session.qrEndpoint) {
el("qrEndpoint").value = session.qrEndpoint;
}
if (session.qrData && typeof session.qrData === "object") {
state.qrData = session.qrData;
}
refreshLoginUiByState();
} catch (err) {
log(`恢复会话失败: ${err.message}`);
}
}
async function startLogin() {
if (state.isOnline) {
log("当前账号已在线,已禁止重复生成授权二维码。");
return;
}
try {
const payload = {
baseUrl: state.baseUrl,
qrEndpoint: el("qrEndpoint").value
};
const data = await api("/api/login/start", "POST", payload);
state.authKey = data.authKey || "";
state.qrData = data.qrData || null;
state.statusKnown = true;
if (data.qrEndpoint) {
el("qrEndpoint").value = data.qrEndpoint;
}
renderQr(state.qrData || {});
clearMessageList();
await persistSession(true);
log(`已生成 authKey: ${state.authKey}`);
ensureStatusPolling();
await checkStatus(true);
} catch (err) {
log(`生成授权二维码失败: ${err.message}`);
}
}
async function checkStatus(logError = true) {
if (!state.authKey) {
state.statusKnown = true;
state.lastStatusReqError = "";
setOnline(false);
stopMessagePolling(true);
return;
}
try {
const url =
`/api/login/status?authKey=${encodeURIComponent(state.authKey)}` +
`&baseUrl=${encodeURIComponent(state.baseUrl)}`;
const data = await api(url);
state.statusKnown = true;
state.lastStatusReqError = "";
setOnline(Boolean(data.isOnline));
logStatusIfChanged(data);
reportStatusAlerts(data);
queuePersistSession();
if (state.isOnline) {
if (state.qrData) {
state.qrData = null;
queuePersistSession();
}
ensureMessagePolling();
pollMessages(false);
} else {
stopMessagePolling(true);
}
} catch (err) {
const message = `查询状态失败: ${err.message}`;
if (logError || state.lastStatusReqError !== message) {
log(message);
}
state.lastStatusReqError = message;
state.statusKnown = false;
setOnline(false);
stopMessagePolling(true);
}
}
async function pollMessages(logError = true) {
if (!state.authKey || !state.isOnline) {
return;
}
try {
const payload = {
baseUrl: state.baseUrl,
authKey: state.authKey,
count: MESSAGE_COUNT
};
const data = await api("/api/message/poll", "POST", payload);
if (data.received > 0) {
appendMessages(data.items);
log(`收到 ${data.received} 条消息。`);
}
} catch (err) {
if (logError) {
log(`自动拉取消息失败: ${err.message}`);
}
}
}
async function clearLoginState() {
try {
const resp = await api("/api/login/clear", "POST", {
baseUrl: state.baseUrl,
authKey: state.authKey
});
const logout = resp.logout || {};
state.authKey = "";
state.qrData = null;
state.statusKnown = true;
state.lastStatusSig = "";
state.lastStatusAlertSig = "";
state.lastStatusReqError = "";
setOnline(false);
stopMessagePolling(true);
clearMessageList();
if (logout.attempted) {
if (logout.ok) {
log(`远端登出成功: ${logout.method} ${logout.path}`);
} else if (logout.error) {
log(`远端登出结果: ${logout.error}`);
}
}
log("已清除登录状态与持久化会话。");
} catch (err) {
log(`清除登录状态失败: ${err.message}`);
}
}
el("startLoginBtn").addEventListener("click", startLogin);
el("clearLoginBtn").addEventListener("click", clearLoginState);
el("qrEndpoint").addEventListener("change", queuePersistSession);
(async () => {
await loadConfig();
await restoreSession();
refreshLoginUiByState();
ensureStatusPolling();
if (state.authKey) {
await checkStatus(true);
} else {
state.statusKnown = true;
refreshLoginUiByState();
}
})();
</script>
</body>
</html>