feat: add wechat webui login server and page

This commit is contained in:
237899745
2026-02-27 15:24:20 +08:00
parent 0951732c7a
commit 5d5ddf0679
3 changed files with 1626 additions and 0 deletions

878
wechat-webui/index.html Normal file
View File

@@ -0,0 +1,878 @@
<!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>