Files
wx-win-agent/node-agent/WxAgent.Service/wwwroot/app.js
T

570 lines
17 KiB
JavaScript
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.
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();
}
})();