570 lines
17 KiB
JavaScript
570 lines
17 KiB
JavaScript
const $ = (id) => document.querySelector(`#${id}`);
|
||
let csrf = "";
|
||
let refreshTimer = 0;
|
||
let busy = false;
|
||
let activeView = localStorage.getItem("wxagent-view") || "messages";
|
||
let capabilityMap = new Map();
|
||
|
||
const operationStates = {
|
||
Queued: "排队中",
|
||
Running: "执行中",
|
||
Succeeded: "已完成",
|
||
Failed: "失败",
|
||
Cancelled: "已取消",
|
||
Unconfirmed: "待核对",
|
||
};
|
||
|
||
function showError(error) {
|
||
const code = error?.code ? `${error.code}:` : "";
|
||
$("error").textContent = `${code}${error?.message || "请求失败"}`;
|
||
}
|
||
|
||
function clearError() {
|
||
$("error").textContent = "";
|
||
}
|
||
|
||
async function api(path, init = {}) {
|
||
const response = await fetch(path, {
|
||
...init,
|
||
credentials: "same-origin",
|
||
headers: { Accept: "application/json", ...init.headers },
|
||
});
|
||
if (!response.ok) {
|
||
let body = {};
|
||
try {
|
||
body = await response.json();
|
||
} catch {
|
||
/* response may not be JSON */
|
||
}
|
||
if (response.status === 401) {
|
||
$("app").hidden = true;
|
||
$("login").hidden = false;
|
||
$("connectionBadge").textContent = "未登录";
|
||
$("connectionBadge").className = "badge neutral";
|
||
}
|
||
const error = new Error(body.error?.message || `HTTP ${response.status}`);
|
||
error.code = body.error?.code;
|
||
error.correlationId =
|
||
response.headers.get("X-Correlation-Id") || body.correlationId;
|
||
throw error;
|
||
}
|
||
return response.status === 204 ? null : response.json();
|
||
}
|
||
|
||
async function get(path) {
|
||
try {
|
||
return { ok: true, value: await api(path) };
|
||
} catch (error) {
|
||
return { ok: false, error };
|
||
}
|
||
}
|
||
|
||
async function mutate(path, body) {
|
||
return api(path, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
"X-CSRF-Token": csrf,
|
||
"X-WxAgent-Local": "1",
|
||
},
|
||
body: JSON.stringify(body),
|
||
});
|
||
}
|
||
|
||
function setList(id, items, format) {
|
||
const target = $(id);
|
||
target.replaceChildren(
|
||
...items.map((item) => {
|
||
const row = document.createElement("div");
|
||
row.className = "row";
|
||
row.textContent = format(item);
|
||
return row;
|
||
}),
|
||
);
|
||
}
|
||
|
||
function setListMessage(id, message, error = false) {
|
||
const target = $(id);
|
||
const row = document.createElement("div");
|
||
row.className = error ? "row error-row" : "row";
|
||
row.textContent = message;
|
||
target.replaceChildren(row);
|
||
}
|
||
|
||
function value(result, fallback) {
|
||
return result.ok ? result.value : fallback;
|
||
}
|
||
|
||
function friendlyAccount(account) {
|
||
const binding = account.binding;
|
||
const identity =
|
||
binding?.nickname || binding?.wechatId || account.displayName;
|
||
return identity
|
||
? `${identity} · ${account.bindingStatus || "Unknown"}`
|
||
: `数据库账号 ${String(account.accountId).slice(0, 10)}… · ${account.bindingStatus || "Unknown"}`;
|
||
}
|
||
|
||
function renderStatus(result) {
|
||
if (!result.ok) {
|
||
$("connectionBadge").textContent = "状态读取失败";
|
||
$("connectionBadge").className = "badge error";
|
||
return;
|
||
}
|
||
const status = result.value;
|
||
$("status").textContent = JSON.stringify(status, null, 2);
|
||
$("serviceState").textContent = status.serviceOnline ? "在线" : "离线";
|
||
$("wechatState").textContent =
|
||
status.wechatAvailable && status.sessionAvailable ? "可用" : "不可用";
|
||
$("bindingState").textContent = status.activeAccountBound
|
||
? "已绑定"
|
||
: "未绑定";
|
||
$("modeState").textContent = status.defaultReadOnly ? "只读" : "可写";
|
||
const healthy =
|
||
status.serviceOnline && status.wechatAvailable && status.sessionAvailable;
|
||
$("connectionBadge").textContent = healthy ? "已连接" : "需检查";
|
||
$("connectionBadge").className = `badge ${healthy ? "ok" : "warn"}`;
|
||
}
|
||
|
||
function renderCapabilities(result) {
|
||
if (!result.ok) {
|
||
capabilityMap = new Map();
|
||
updateReplyAvailability();
|
||
return setListMessage("capabilities", result.error.message, true);
|
||
}
|
||
capabilityMap = new Map(result.value.map((item) => [item.operation, item]));
|
||
setList(
|
||
"capabilities",
|
||
result.value,
|
||
(item) =>
|
||
`${item.operation} — ${item.enabled ? "可用" : "禁用"}${item.disabledReason ? `:${item.disabledReason}` : ""}`,
|
||
);
|
||
updateReplyAvailability();
|
||
}
|
||
|
||
function renderBindings(accounts, targets) {
|
||
const accountRows = accounts.map((account) => {
|
||
const row = document.createElement("div");
|
||
row.className = "row";
|
||
const main = document.createElement("div");
|
||
main.className = "row-main";
|
||
const title = document.createElement("div");
|
||
title.className = "row-title";
|
||
title.textContent = friendlyAccount(account);
|
||
const meta = document.createElement("div");
|
||
meta.className = "row-meta";
|
||
meta.textContent = account.binding
|
||
? `${account.binding.wechatId || "微信号未读取"} · PID ${account.binding.processId} · HWND ${account.binding.windowHandle}`
|
||
: `Fingerprint: ${account.accountId}`;
|
||
main.append(title, meta);
|
||
row.append(main);
|
||
if (account.binding) {
|
||
const unbind = document.createElement("button");
|
||
unbind.className = "secondary";
|
||
unbind.textContent = "解绑";
|
||
unbind.onclick = async () => {
|
||
unbind.disabled = true;
|
||
try {
|
||
await mutate("/api/v1/accounts/unbind", {
|
||
accountId: account.accountId,
|
||
});
|
||
await refresh();
|
||
} catch (error) {
|
||
showError(error);
|
||
unbind.disabled = false;
|
||
}
|
||
};
|
||
row.append(unbind);
|
||
}
|
||
return row;
|
||
});
|
||
$("accounts").replaceChildren(...accountRows);
|
||
if (!accountRows.length) setListMessage("accounts", "暂无数据库账号。");
|
||
|
||
const targetRows = targets.map((target) => {
|
||
const row = document.createElement("div");
|
||
row.className = "row";
|
||
const main = document.createElement("div");
|
||
main.className = "row-main";
|
||
const title = document.createElement("div");
|
||
title.className = "row-title";
|
||
title.textContent = target.wechatId || target.nickname || "身份未读取";
|
||
const meta = document.createElement("div");
|
||
meta.className = "row-meta";
|
||
meta.textContent = `${target.targetId} · PID ${target.processId} · HWND ${target.windowHandle}${target.isBound ? " · 已绑定" : ""}`;
|
||
main.append(title, meta);
|
||
row.append(main);
|
||
if (!target.isBound) {
|
||
const select = document.createElement("select");
|
||
select.setAttribute("aria-label", `为 ${target.targetId} 选择数据库账号`);
|
||
select.append(
|
||
new Option("选择账号", ""),
|
||
...accounts
|
||
.filter((account) => !account.binding)
|
||
.map(
|
||
(account) =>
|
||
new Option(friendlyAccount(account), account.accountId),
|
||
),
|
||
);
|
||
const bind = document.createElement("button");
|
||
bind.textContent = "绑定";
|
||
bind.onclick = async () => {
|
||
if (!select.value) {
|
||
showError(new Error("请先选择数据库账号"));
|
||
return;
|
||
}
|
||
bind.disabled = true;
|
||
try {
|
||
await mutate("/api/v1/accounts/bind", {
|
||
accountId: select.value,
|
||
targetId: target.targetId,
|
||
});
|
||
await refresh();
|
||
} catch (error) {
|
||
showError(error);
|
||
bind.disabled = false;
|
||
}
|
||
};
|
||
row.append(select, bind);
|
||
}
|
||
return row;
|
||
});
|
||
$("uiTargets").replaceChildren(...targetRows);
|
||
if (!targetRows.length)
|
||
setListMessage("uiTargets", "未发现当前交互式会话中的微信主窗口。");
|
||
}
|
||
|
||
function populateAccounts(result) {
|
||
if (!result.ok) {
|
||
setListMessage("accounts", result.error.message, true);
|
||
return [];
|
||
}
|
||
const accounts = result.value.items || [];
|
||
const select = $("accountSelect");
|
||
const previous = select.value;
|
||
select.replaceChildren(
|
||
new Option("选择已绑定账号", ""),
|
||
...accounts
|
||
.filter((account) => account.binding)
|
||
.map(
|
||
(account) => new Option(friendlyAccount(account), account.accountId),
|
||
),
|
||
);
|
||
select.value = accounts.some(
|
||
(account) => account.accountId === previous && account.binding,
|
||
)
|
||
? previous
|
||
: "";
|
||
return accounts;
|
||
}
|
||
|
||
function renderScoped(result, id, format, emptyMessage) {
|
||
if (!result.ok) return setListMessage(id, result.error.message, true);
|
||
const items = result.value.items || [];
|
||
setList(id, items, format);
|
||
if (!items.length) setListMessage(id, emptyMessage);
|
||
}
|
||
|
||
function renderReplyTargets(result) {
|
||
const sessions = result.ok ? result.value.items || [] : [];
|
||
const select = $("replyTarget");
|
||
const previous = select.value;
|
||
select.replaceChildren(
|
||
new Option("选择会话", ""),
|
||
...sessions.map(
|
||
(session) => new Option(session.name, session.automationId),
|
||
),
|
||
);
|
||
select.value = sessions.some((session) => session.automationId === previous)
|
||
? previous
|
||
: "";
|
||
updateReplyAvailability();
|
||
}
|
||
|
||
function updateReplyAvailability() {
|
||
const capability = capabilityMap.get("send-text");
|
||
const hasAccount = Boolean($("accountSelect")?.value);
|
||
const hasTarget = Boolean($("replyTarget")?.value);
|
||
const enabled = Boolean(capability?.enabled && hasAccount && hasTarget);
|
||
$("replyTarget").disabled = !capability?.enabled;
|
||
$("replyText").disabled = !capability?.enabled;
|
||
$("sendReply").disabled = !enabled;
|
||
$("replyCapability").textContent = capability?.enabled
|
||
? "发送前会再次校验账号和目标"
|
||
: capability?.disabledReason || "写能力尚未开放。";
|
||
if (capability?.enabled) {
|
||
$("replyState").textContent = hasTarget
|
||
? "点击发送后创建任务。"
|
||
: "请选择目标会话。";
|
||
} else {
|
||
$("replyState").textContent = "当前不会发送消息。";
|
||
}
|
||
}
|
||
|
||
async function sendReply() {
|
||
const accountId = $("accountSelect").value;
|
||
const targetId = $("replyTarget").value;
|
||
const text = $("replyText").value;
|
||
if (!accountId || !targetId || !text.trim()) {
|
||
showError(new Error("请选择账号、目标会话并输入文本。"));
|
||
return;
|
||
}
|
||
const button = $("sendReply");
|
||
button.disabled = true;
|
||
clearError();
|
||
try {
|
||
await api("/api/v1/operations", {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
"X-CSRF-Token": csrf,
|
||
"X-WxAgent-Local": "1",
|
||
},
|
||
body: JSON.stringify({
|
||
kind: "send-text",
|
||
accountId,
|
||
targetId,
|
||
text,
|
||
idempotencyKey: crypto.randomUUID(),
|
||
confirmed: true,
|
||
}),
|
||
});
|
||
$("replyText").value = "";
|
||
setView("operations");
|
||
await refresh();
|
||
} catch (error) {
|
||
showError(error);
|
||
} finally {
|
||
updateReplyAvailability();
|
||
}
|
||
}
|
||
|
||
function operationState(state) {
|
||
return operationStates[state] || state || "未知";
|
||
}
|
||
|
||
function renderOperations(result) {
|
||
if (!result.ok)
|
||
return setListMessage("operations", result.error.message, true);
|
||
const items = result.value.items || [];
|
||
const target = $("operations");
|
||
target.replaceChildren(
|
||
...items.map((item) => {
|
||
const row = document.createElement("div");
|
||
row.className = "row task-row";
|
||
const main = document.createElement("div");
|
||
main.className = "row-main";
|
||
const title = document.createElement("div");
|
||
title.className = "row-title";
|
||
title.textContent = `${item.capability} · ${operationState(item.state)}`;
|
||
const meta = document.createElement("div");
|
||
meta.className = "row-meta";
|
||
const created = item.createdAt
|
||
? new Date(item.createdAt).toLocaleString()
|
||
: "时间未知";
|
||
meta.textContent = `${created} · ${item.stage || "未开始"} · 账号 ${String(item.accountId || "").slice(0, 10)}…`;
|
||
main.append(title, meta);
|
||
const actions = document.createElement("div");
|
||
actions.className = "row-actions";
|
||
const detail = document.createElement("button");
|
||
detail.className = "secondary";
|
||
detail.textContent = "详情";
|
||
detail.onclick = async () => {
|
||
detail.disabled = true;
|
||
try {
|
||
$("operation").textContent = JSON.stringify(
|
||
await api(`/api/v1/operations/${encodeURIComponent(item.id)}`),
|
||
null,
|
||
2,
|
||
);
|
||
} catch (error) {
|
||
showError(error);
|
||
} finally {
|
||
detail.disabled = false;
|
||
}
|
||
};
|
||
actions.append(detail);
|
||
if (item.state === "Queued" || item.state === "Running") {
|
||
const stop = document.createElement("button");
|
||
stop.className = "secondary";
|
||
stop.textContent = "停止";
|
||
stop.onclick = async () => {
|
||
stop.disabled = true;
|
||
try {
|
||
await api(
|
||
`/api/v1/operations/${encodeURIComponent(item.id)}/cancel`,
|
||
{
|
||
method: "POST",
|
||
headers: { "X-CSRF-Token": csrf, "X-WxAgent-Local": "1" },
|
||
},
|
||
);
|
||
await refresh();
|
||
} catch (error) {
|
||
showError(error);
|
||
stop.disabled = false;
|
||
}
|
||
};
|
||
actions.append(stop);
|
||
}
|
||
row.append(main, actions);
|
||
return row;
|
||
}),
|
||
);
|
||
if (!items.length) setListMessage("operations", "暂无任务。");
|
||
}
|
||
|
||
function setView(view) {
|
||
activeView = view;
|
||
localStorage.setItem("wxagent-view", view);
|
||
document
|
||
.querySelectorAll("[data-view]")
|
||
.forEach((button) =>
|
||
button.classList.toggle("active", button.dataset.view === view),
|
||
);
|
||
document.querySelectorAll("[data-view-panel]").forEach((panel) => {
|
||
panel.hidden = panel.dataset.viewPanel !== view;
|
||
});
|
||
}
|
||
|
||
async function refresh() {
|
||
if (busy) return;
|
||
busy = true;
|
||
clearError();
|
||
$("refresh").disabled = true;
|
||
$("refresh").textContent = "刷新中…";
|
||
try {
|
||
const [status, capabilities, accounts, targets, operations] =
|
||
await Promise.all([
|
||
get("/api/v1/status"),
|
||
get("/api/v1/capabilities"),
|
||
get("/api/v1/accounts?limit=50"),
|
||
get("/api/v1/ui-targets"),
|
||
get("/api/v1/operations?limit=50"),
|
||
]);
|
||
renderStatus(status);
|
||
renderCapabilities(capabilities);
|
||
renderOperations(operations);
|
||
const accountItems = populateAccounts(accounts);
|
||
renderBindings(accountItems, value(targets, []));
|
||
|
||
const accountId = $("accountSelect").value;
|
||
if (!accountId) {
|
||
const message = "在“连接与设置”确认账号后加载数据。";
|
||
setListMessage("sessions", message);
|
||
setListMessage("contacts", message);
|
||
setListMessage("messages", message);
|
||
renderReplyTargets({ ok: true, value: { items: [] } });
|
||
return;
|
||
}
|
||
const query = `&accountId=${encodeURIComponent(accountId)}`;
|
||
const [sessions, contacts, messages] = await Promise.all([
|
||
get(`/api/v1/sessions?limit=50${query}`),
|
||
get(`/api/v1/contacts?limit=50${query}`),
|
||
get(
|
||
`/api/v1/messages?limit=50&includeContent=${$("content").checked}${query}`,
|
||
),
|
||
]);
|
||
renderScoped(
|
||
sessions,
|
||
"sessions",
|
||
(item) => `${item.name}${item.isCurrent ? "(当前)" : ""}`,
|
||
"暂无可见会话。",
|
||
);
|
||
renderReplyTargets(sessions);
|
||
renderScoped(
|
||
contacts,
|
||
"contacts",
|
||
(item) => `${item.displayName || "[未知]"} — ${item.id}`,
|
||
"暂无联系人数据或当前凭据尚未开放。",
|
||
);
|
||
renderScoped(
|
||
messages,
|
||
"messages",
|
||
(item) =>
|
||
`${item.type} ${item.sender || ""}: ${item.content ?? item.summary ?? "[正文未授权]"}`,
|
||
"暂无可见消息。",
|
||
);
|
||
} finally {
|
||
$("refresh").disabled = false;
|
||
$("refresh").textContent = "刷新";
|
||
$("lastUpdated").textContent = `更新于 ${new Date().toLocaleTimeString()}`;
|
||
busy = false;
|
||
}
|
||
}
|
||
|
||
function setAutoRefresh(enabled) {
|
||
if (refreshTimer) window.clearInterval(refreshTimer);
|
||
refreshTimer = enabled
|
||
? window.setInterval(() => {
|
||
if (!document.hidden) refresh();
|
||
}, 15000)
|
||
: 0;
|
||
localStorage.setItem("wxagent-auto-refresh", enabled ? "1" : "0");
|
||
}
|
||
|
||
$("loginForm").addEventListener("submit", async (event) => {
|
||
event.preventDefault();
|
||
const button = $("loginButton");
|
||
button.disabled = true;
|
||
button.textContent = "连接中…";
|
||
clearError();
|
||
try {
|
||
const response = await api("/api/v1/login", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ token: $("token").value }),
|
||
});
|
||
csrf = response.csrfToken;
|
||
$("token").value = "";
|
||
$("login").hidden = true;
|
||
$("app").hidden = false;
|
||
await refresh();
|
||
} catch (error) {
|
||
showError(error);
|
||
} finally {
|
||
button.disabled = false;
|
||
button.textContent = "连接";
|
||
}
|
||
});
|
||
|
||
$("refresh").onclick = refresh;
|
||
$("accountSelect").onchange = refresh;
|
||
$("content").onchange = refresh;
|
||
$("replyTarget").onchange = updateReplyAvailability;
|
||
$("replyText").oninput = updateReplyAvailability;
|
||
$("sendReply").onclick = sendReply;
|
||
$("autoRefresh").checked = localStorage.getItem("wxagent-auto-refresh") === "1";
|
||
$("autoRefresh").onchange = (event) => setAutoRefresh(event.target.checked);
|
||
document.querySelectorAll("[data-view]").forEach((button) => {
|
||
button.onclick = () => setView(button.dataset.view);
|
||
});
|
||
setView(
|
||
document.querySelector(`[data-view="${activeView}"]`)
|
||
? activeView
|
||
: "messages",
|
||
);
|
||
if ($("autoRefresh").checked) setAutoRefresh(true);
|
||
$("logout").onclick = async () => {
|
||
try {
|
||
await api("/api/v1/logout", {
|
||
method: "POST",
|
||
headers: { "X-CSRF-Token": csrf, "X-WxAgent-Local": "1" },
|
||
});
|
||
} finally {
|
||
if (refreshTimer) window.clearInterval(refreshTimer);
|
||
csrf = "";
|
||
$("app").hidden = true;
|
||
$("login").hidden = false;
|
||
$("connectionBadge").textContent = "未连接";
|
||
$("connectionBadge").className = "badge neutral";
|
||
}
|
||
};
|
||
|
||
(async () => {
|
||
const local = await get("/api/v1/status");
|
||
if (local.ok) {
|
||
$("login").hidden = true;
|
||
$("app").hidden = false;
|
||
await refresh();
|
||
}
|
||
})();
|