refactor: split node agent and Go control plane
This commit is contained in:
@@ -0,0 +1,569 @@
|
||||
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();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,89 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>WxAgent 工作台</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<header class="hero">
|
||||
<div>
|
||||
<p class="eyebrow">DESKTOP AGENT</p>
|
||||
<h1>WxAgent 工作台</h1>
|
||||
<p class="subtitle">连接 Agent,查看消息、联系人和执行任务。</p>
|
||||
</div>
|
||||
<span id="connectionBadge" class="badge neutral">未连接</span>
|
||||
</header>
|
||||
<p class="warning">HTTP 明文不会保护 Token、消息或附件;仅在可信隔离网络使用。</p>
|
||||
|
||||
<section id="login" class="panel login-panel">
|
||||
<h2>连接 Agent</h2>
|
||||
<p class="muted">本机打开会自动进入;远程访问请输入连接 Token。Token 不会保存到页面或 URL。</p>
|
||||
<form id="loginForm" class="login-form">
|
||||
<label for="token">连接 Token</label>
|
||||
<div class="input-row"><input id="token" type="password" autocomplete="off" required placeholder="粘贴 Token"><button id="loginButton">连接</button></div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section id="app" class="app-shell" hidden>
|
||||
<div class="toolbar panel">
|
||||
<div class="toolbar-group"><button id="refresh">刷新</button><label class="toggle"><input id="autoRefresh" type="checkbox"> 自动刷新 <span class="muted">15 秒</span></label><span id="lastUpdated" class="muted">尚未刷新</span></div>
|
||||
<div class="toolbar-group account-picker"><label for="accountSelect">当前账号</label><select id="accountSelect"><option value="">选择已绑定账号</option></select></div>
|
||||
<button id="logout" class="secondary">退出</button>
|
||||
</div>
|
||||
|
||||
<nav class="workbench-nav panel" aria-label="工作台导航">
|
||||
<button class="nav-button active" data-view="messages">消息</button>
|
||||
<button class="nav-button" data-view="contacts">联系人</button>
|
||||
<button class="nav-button" data-view="broadcast">群发</button>
|
||||
<button class="nav-button" data-view="operations">任务</button>
|
||||
<button class="nav-button" data-view="settings">连接与设置</button>
|
||||
</nav>
|
||||
|
||||
<p id="error" class="error" role="alert" aria-live="assertive"></p>
|
||||
|
||||
<section class="view-panel" data-view-panel="messages">
|
||||
<section class="panel intro-panel">
|
||||
<div><p class="eyebrow">消息</p><h2>从已确认账号开始</h2><p class="muted">先在“连接与设置”确认账号和微信窗口,再加载会话与消息。</p></div>
|
||||
<label class="toggle"><input id="content" type="checkbox"> 显示正文 <span class="muted">需要 content 权限</span></label>
|
||||
</section>
|
||||
<section class="panel"><div class="section-heading"><h2>会话</h2><span class="muted">当前账号</span></div><div id="sessions" class="list"></div></section>
|
||||
<section class="panel reply-panel"><div class="section-heading"><h2>回复</h2><span id="replyCapability" class="muted">发送能力检查中…</span></div><div class="reply-form"><label for="replyTarget">目标会话</label><select id="replyTarget"><option value="">先选择会话</option></select><label for="replyText">文本</label><textarea id="replyText" rows="4" maxlength="4000" placeholder="输入要发送的文本" disabled></textarea><div class="reply-actions"><button id="sendReply" disabled>发送</button><span id="replyState" class="muted">写能力尚未开放。</span></div></div></section>
|
||||
<section class="panel"><div class="section-heading"><h2>可见消息</h2><span class="muted">只显示当前可读取的数据</span></div><div id="messages" class="list"></div></section>
|
||||
</section>
|
||||
|
||||
<section class="view-panel" data-view-panel="contacts" hidden>
|
||||
<section class="panel intro-panel"><div><p class="eyebrow">联系人</p><h2>联系人和群</h2><p class="muted">当前阶段只读;群发会在目标确认和任务能力完成后开放。</p></div></section>
|
||||
<section class="panel"><div class="section-heading"><h2>联系人 / 群</h2><span class="muted">稳定 ID 去重</span></div><div id="contacts" class="list"></div></section>
|
||||
</section>
|
||||
|
||||
<section class="view-panel" data-view-panel="broadcast" hidden>
|
||||
<section class="panel placeholder"><p class="eyebrow">群发</p><h2>逐项确认后发送</h2><p class="muted">群发任务尚未开放。开放后会显示目标清单、幂等键、逐项结果和停止任务入口;当前不会发送消息。</p><button disabled>暂未开放</button></section>
|
||||
</section>
|
||||
|
||||
<section class="view-panel" data-view-panel="operations" hidden>
|
||||
<section class="panel"><div class="section-heading"><h2>任务中心</h2><span class="muted">当前登录身份的任务</span></div><div id="operations" class="list"></div></section>
|
||||
<section class="panel"><div class="section-heading"><h2>技术详情</h2><span class="muted">用于反馈和诊断</span></div><pre id="operation"></pre></section>
|
||||
</section>
|
||||
|
||||
<section class="view-panel" data-view-panel="settings" hidden>
|
||||
<div class="status-grid" aria-live="polite">
|
||||
<div class="metric panel"><span>服务</span><strong id="serviceState">—</strong></div>
|
||||
<div class="metric panel"><span>微信 UI</span><strong id="wechatState">—</strong></div>
|
||||
<div class="metric panel"><span>当前绑定</span><strong id="bindingState">—</strong></div>
|
||||
<div class="metric panel"><span>默认模式</span><strong id="modeState">只读</strong></div>
|
||||
</div>
|
||||
<details class="panel details"><summary>诊断详情</summary><pre id="status" role="status"></pre></details>
|
||||
<section class="panel"><div class="section-heading"><h2>能力</h2><span class="muted">按当前凭据显示</span></div><div id="capabilities" class="list"></div></section>
|
||||
<div class="dashboard-grid">
|
||||
<section class="panel"><div class="section-heading"><h2>账号绑定</h2><span class="muted">数据库账号 ↔ 微信窗口</span></div><div id="accounts" class="list"></div></section>
|
||||
<section class="panel"><div class="section-heading"><h2>微信窗口</h2><span class="muted">自动匹配;失败时手动绑定</span></div><div id="uiTargets" class="list"></div></section>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
<script type="module" src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user