Files
wx-win-agent/control-plane/web/src/main.jsx
T
rogee c7c0ab273f
Build web service image / build (push) Successful in 1m9s
feat: validate single-client broadcast operations
2026-09-19 14:33:26 +08:00

541 lines
33 KiB
React
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.
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
import "./styles.css";
const NAV_ITEMS = [
{ id: "messages", label: "消息", description: "查找会话、阅读并回复", icon: "message" },
{ id: "broadcast", label: "群发", description: "创建受控群发任务", icon: "send" },
{ id: "contacts", label: "通讯录", description: "查找联系人和群聊", icon: "contacts" },
{ id: "tasks", label: "任务", description: "查看执行结果", icon: "tasks" },
];
const STATUS_LABELS = {
Online: "在线",
Degraded: "需要注意",
Offline: "已离线",
Registered: "已注册",
SessionLocked: "桌面已锁定",
WechatNotRunning: "微信未运行",
WechatNotLoggedIn: "微信未登录",
Pending: "待处理",
WaitingForClient: "等待 Client",
Accepted: "已接收",
Running: "执行中",
Succeeded: "已完成",
Failed: "失败",
Cancelled: "已取消",
Expired: "已过期",
ResultUnconfirmed: "待核对",
};
const TERMINAL_TASKS = new Set([
"Succeeded",
"Failed",
"Cancelled",
"Expired",
"ResultUnconfirmed",
]);
class ApiError extends Error {
constructor(message, status, code = "RequestFailed") {
super(message);
this.status = status;
this.code = code;
}
}
async function api(path, { token, method = "GET", body } = {}) {
const headers = {};
if (token) headers.Authorization = `Bearer ${token}`;
if (body !== undefined) headers["Content-Type"] = "application/json";
const response = await fetch(path, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new ApiError(
payload.error?.message || `请求失败(${response.status})`,
response.status,
payload.error?.code,
);
}
return payload;
}
const wait = (duration) => new Promise((resolve) => window.setTimeout(resolve, duration));
async function waitForTask(token, taskId) {
const deadline = Date.now() + 30000;
let delay = 250;
while (Date.now() < deadline) {
const task = await api(`/v1/tasks/${encodeURIComponent(taskId)}`, { token });
if (task.status === "Succeeded") return task.result?.content || {};
if (task.status === "WaitingForClient") {
throw new ApiError("当前 Client 暂不可用,请切换 Client 后重试。", 409, "ClientNotReady");
}
if (TERMINAL_TASKS.has(task.status)) {
throw new ApiError(
task.result?.message || `读取任务${STATUS_LABELS[task.status] || "未完成"}。`,
409,
task.result?.error_code || task.status,
);
}
await wait(delay);
delay = Math.min(1000, delay + 150);
}
throw new ApiError("读取超时,请稍后重试。", 408, "ReadTimeout");
}
async function readWithTask(path, body, token) {
const created = await api(path, {
token,
method: "POST",
body: {
...body,
idempotency_key: `${path}-${Date.now()}-${Math.random().toString(36).slice(2)}`,
},
});
if (created.status === "WaitingForClient") {
throw new ApiError("当前 Client 暂不可用,请切换 Client 后重试。", 409, "ClientNotReady");
}
return waitForTask(token, created.task_id);
}
function formatTime(value) {
if (!value) return "—";
const date = new Date(value);
return Number.isNaN(date.getTime())
? "—"
: date.toLocaleString("zh-CN", { hour12: false });
}
function shortId(value, length = 16) {
if (!value) return "—";
return value.length > length ? `${value.slice(0, length)}…` : value;
}
function statusTone(status) {
if (["Online", "Succeeded", "Accepted"].includes(status)) return "positive";
if (["Running", "Pending", "WaitingForClient", "Degraded"].includes(status)) return "warning";
if (
[
"Failed",
"Cancelled",
"Expired",
"ResultUnconfirmed",
"Offline",
"SessionLocked",
"WechatNotRunning",
"WechatNotLoggedIn",
].includes(status)
)
return "negative";
return "neutral";
}
function statusLabel(status) {
return STATUS_LABELS[status] || status || "未知";
}
function isClientAvailable(node) {
return Boolean(node && ["Online", "Degraded", "Registered"].includes(node.status));
}
function activeAccount(node) {
if (!node) return "";
if (node.active_account_id) return node.active_account_id;
return node.accounts?.find((account) => account.active && account.verified)?.account_id || "";
}
function extractItems(content, keys) {
if (Array.isArray(content)) return content;
for (const key of keys) {
if (Array.isArray(content?.[key])) return content[key];
}
return [];
}
function normalizeSessions(content) {
return extractItems(content, ["items", "sessions", "chats"]).map((item, index) => ({
id: String(item.chat_id ?? item.chatId ?? item.session_id ?? item.id ?? index),
title: String(item.name ?? item.display_name ?? item.displayName ?? item.nickname ?? item.chat_id ?? item.id ?? "未命名会话"),
preview: String(item.last_message ?? item.lastMessage ?? item.preview ?? "暂无最近消息"),
unread: Number(item.unread_count ?? item.unreadCount ?? 0),
type: item.chat_type ?? item.chatType ?? "Private",
}));
}
function normalizeMessages(content) {
return extractItems(content, ["items", "messages"]).map((item, index) => ({
id: String(item.message_id ?? item.messageId ?? item.id ?? index),
sender: String(item.sender ?? item.sender_name ?? item.senderName ?? "对方"),
text: typeof item === "string" ? item : String(item.content ?? item.text ?? item.message ?? ""),
at: item.occurred_at ?? item.occurredAt ?? item.created_at ?? item.createdAt,
}));
}
function normalizeContacts(content) {
return extractItems(content, ["items", "contacts", "sessions"]).map((item, index) => ({
id: String(item.contact_id ?? item.contactId ?? item.chat_id ?? item.chatId ?? item.id ?? index),
title: String(item.name ?? item.display_name ?? item.displayName ?? item.nickname ?? item.contact_id ?? item.id ?? "未命名联系人"),
type: item.chat_type ?? item.chatType ?? (item.is_group ? "Group" : "Private"),
detail: String(item.remark ?? item.alias ?? item.account_id ?? item.accountId ?? ""),
}));
}
function Icon({ name, size = 18 }) {
const paths = {
message: <><path d="M4 5.5A2.5 2.5 0 0 1 6.5 3h11A2.5 2.5 0 0 1 20 5.5v7a2.5 2.5 0 0 1-2.5 2.5H11l-4.5 3v-3h0A2.5 2.5 0 0 1 4 12.5z" /><path d="M8 8h8M8 11h5" /></>,
send: <><path d="m3 4 18 8-18 8 3.5-8z" /><path d="M6.5 12H21" /></>,
contacts: <><circle cx="9" cy="8" r="3" /><path d="M3.5 19a5.5 5.5 0 0 1 11 0M17 7v6M14 10h6" /></>,
tasks: <><rect x="4" y="3" width="16" height="18" rx="2" /><path d="M8 8h8M8 12h8M8 16h5" /></>,
settings: <><path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M18.4 5.6l-2.1 2.1M7.7 16.3l-2.1 2.1" /><circle cx="12" cy="12" r="4" /></>,
refresh: <><path d="M20 11a8 8 0 0 0-14.5-4.5L4 8" /><path d="M4 4v4h4M4 13a8 8 0 0 0 14.5 4.5L20 16" /><path d="M20 20v-4h-4" /></>,
chevron: <path d="m9 6 6 6-6 6" />,
search: <><circle cx="10.5" cy="10.5" r="6.5" /><path d="m16 16 4.5 4.5" /></>,
shield: <><path d="M12 3 19 6v5c0 4.2-2.8 7.8-7 9-4.2-1.2-7-4.8-7-9V6z" /><path d="m9 12 2 2 4-4" /></>,
alert: <><path d="M12 4 21 20H3z" /><path d="M12 10v4M12 17v.2" /></>,
close: <><path d="m6 6 12 12M18 6 6 18" /></>,
logout: <><path d="M10 4H5v16h5M14 8l4 4-4 4M8 12h10" /></>,
arrow: <><path d="M4 12h15M13 6l6 6-6 6" /></>,
check: <path d="m5 12 4 4L19 6" />,
};
return (
<svg aria-hidden="true" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
{paths[name] || paths.settings}
</svg>
);
}
function StatusBadge({ value }) {
return (
<span className={`status-badge ${statusTone(value)}`}>
<i />
{statusLabel(value)}
</span>
);
}
function Login({ onLogin }) {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const submit = async (event) => {
event.preventDefault();
setError("");
setBusy(true);
try {
const result = await api("/v1/auth/login", { method: "POST", body: { username, password } });
onLogin(result.access_token, username);
} catch (reason) {
setError(reason.message);
} finally {
setBusy(false);
}
};
return (
<main className="login-page">
<section className="login-panel" aria-labelledby="login-title">
<div className="brand-mark">W</div>
<p className="eyebrow">WXAGENT WORKSPACE</p>
<h1 id="login-title">进入工作台</h1>
<p className="login-intro">连接已授权的 Client,处理消息、通讯录和任务结果。</p>
<form onSubmit={submit} className="login-form">
<label>用户名<input autoFocus value={username} onChange={(event) => setUsername(event.target.value)} autoComplete="username" /></label>
<label>密码<input type="password" value={password} onChange={(event) => setPassword(event.target.value)} autoComplete="current-password" /></label>
{error && <div className="form-error" role="alert">{error}</div>}
<button className="primary-button full" disabled={busy || !username || !password}>{busy ? "登录中…" : "登录工作台"}</button>
</form>
<p className="login-footnote"><span className="secure-dot" /> 会话只保存在当前浏览器标签页</p>
</section>
</main>
);
}
function Sidebar({ view, setView, onLogout, username }) {
return (
<aside className="sidebar">
<div className="sidebar-brand">
<div className="brand-mark small">W</div>
<div><strong>WxAgent</strong><span>用户工作台</span></div>
</div>
<p className="nav-caption">工作区</p>
<nav aria-label="主导航">
{NAV_ITEMS.map((item) => (
<button key={item.id} className={view === item.id ? "nav-item active" : "nav-item"} onClick={() => setView(item.id)}>
<Icon name={item.icon} size={18} />
<span>{item.label}</span>
</button>
))}
</nav>
<div className="sidebar-spacer" />
<button className={view === "settings" ? "nav-item active" : "nav-item"} onClick={() => setView("settings")}>
<Icon name="settings" size={18} />
<span>设置与诊断</span>
</button>
<div className="connection-hint"><span className="secure-dot" /><div><strong>安全连接</strong><small>控制面 API 已认证</small></div></div>
<div className="user-menu">
<div className="avatar">{(username || "A").slice(0, 1).toUpperCase()}</div>
<div className="user-name"><strong>{username || "管理员"}</strong><small>已登录</small></div>
<button title="退出登录" aria-label="退出登录" onClick={onLogout}><Icon name="logout" size={17} /></button>
</div>
</aside>
);
}
function Topbar({ title, subtitle, nodes, selectedClientId, onClientChange, onRefresh, loading }) {
const selected = nodes.find((node) => node.node_id === selectedClientId);
return (
<header className="topbar">
<div className="topbar-copy"><h1>{title}</h1><p>{subtitle}</p></div>
<div className="topbar-actions">
<label className="client-switcher">
<span>当前 Client</span>
<select value={selectedClientId} onChange={(event) => onClientChange(event.target.value)} disabled={!nodes.length}>
{!nodes.length && <option value="">暂无 Client</option>}
{nodes.map((node) => <option key={node.node_id} value={node.node_id}>{node.node_id} · {statusLabel(node.status)}</option>)}
</select>
</label>
{selected && <StatusBadge value={selected.status} />}
<button className="refresh-button" onClick={onRefresh} disabled={loading} aria-label="刷新数据"><span className={loading ? "spin" : ""}><Icon name="refresh" size={16} /></span>{loading ? "同步中" : "刷新"}</button>
</div>
</header>
);
}
function ConnectionBanner({ nodes, selectedNode, onSettings }) {
if (!nodes.length) {
return <div className="connection-banner negative"><Icon name="alert" size={18} /><div><strong>还没有可用 Client</strong><span>请启动已配置的 Desktop Agent;浏览器不会模拟连接状态。</span></div><button className="text-button" onClick={onSettings}>查看连接说明</button></div>;
}
if (!isClientAvailable(selectedNode)) {
const other = nodes.some(isClientAvailable);
return <div className="connection-banner warning"><Icon name="alert" size={18} /><div><strong>当前 Client 不可用</strong><span>{other ? "其它 Client 不受影响,请从顶部切换。" : "所有 Client 当前都不可用,请先恢复连接。"}</span></div><button className="text-button" onClick={onSettings}>设置与诊断</button></div>;
}
if (!activeAccount(selectedNode)) {
return <div className="connection-banner warning"><Icon name="alert" size={18} /><div><strong>当前 Client 尚未确认微信账号</strong><span>只读数据需要已验证的活动账号,先完成账号绑定后再操作。</span></div><button className="text-button" onClick={onSettings}>查看账号</button></div>;
}
return null;
}
function ContextEmpty({ title = "没有选中的 Client", text = "请先连接并选择一个可用 Client。", onSettings }) {
return <div className="context-empty"><div className="empty-icon"><Icon name="shield" size={28} /></div><h3>{title}</h3><p>{text}</p>{onSettings && <button className="secondary-button" onClick={onSettings}>打开设置与诊断</button>}</div>;
}
function LoadingState({ text = "正在读取…" }) {
return <div className="loading-state"><span className="loader" />{text}</div>;
}
function Empty({ text, action }) {
return <div className="empty-state"><div className="empty-icon"><Icon name="search" size={26} /></div><p>{text}</p>{action}</div>;
}
function ReadError({ error, onRetry }) {
return <div className="inline-error" role="alert"><Icon name="alert" size={17} /><span>{error}</span>{onRetry && <button className="text-button" onClick={onRetry}>重试</button>}</div>;
}
function MessagesView({ token, client, onSettings }) {
const clientId = client?.node_id || "";
const accountId = activeAccount(client);
const [sessions, setSessions] = useState([]);
const [sessionsState, setSessionsState] = useState({ loading: false, error: "" });
const [query, setQuery] = useState("");
const [selectedChat, setSelectedChat] = useState(null);
const [messages, setMessages] = useState([]);
const [messagesState, setMessagesState] = useState({ loading: false, error: "" });
const requestRef = useRef(0);
const loadSessions = useCallback(async () => {
if (!clientId || !accountId) return;
const requestId = ++requestRef.current;
setSessionsState({ loading: true, error: "" });
try {
const content = await readWithTask("/v1/reads/sessions", { node_id: clientId, account_id: accountId, limit: 100 }, token);
if (requestId !== requestRef.current) return;
setSessions(normalizeSessions(content));
} catch (reason) {
if (requestId === requestRef.current) setSessionsState({ loading: false, error: reason.message });
return;
}
setSessionsState({ loading: false, error: "" });
}, [accountId, clientId, token]);
useEffect(() => {
setSelectedChat(null);
setSessions([]);
setMessages([]);
if (!clientId || !accountId) return;
loadSessions();
}, [clientId, accountId, loadSessions]);
useEffect(() => {
if (!selectedChat || selectedChat.clientId !== clientId || !clientId || !accountId) return;
const requestId = ++requestRef.current;
setMessagesState({ loading: true, error: "" });
readWithTask("/v1/reads/messages", { node_id: clientId, account_id: accountId, chat_id: selectedChat.id, limit: 100, include_content: true }, token)
.then((content) => {
if (requestId !== requestRef.current) return;
setMessages(normalizeMessages(content));
setMessagesState({ loading: false, error: "" });
})
.catch((reason) => {
if (requestId === requestRef.current) setMessagesState({ loading: false, error: reason.message });
});
}, [accountId, clientId, selectedChat, token]);
if (!client) return <ContextEmpty onSettings={onSettings} />;
const visibleSessions = sessions.filter((session) => !query || `${session.title} ${session.id}`.toLowerCase().includes(query.toLowerCase()));
return (
<div className="message-workspace">
<section className="session-panel">
<div className="section-heading compact"><div><h2>会话</h2><p>{sessions.length ? `${sessions.length} 个会话` : "当前 Client 的会话"}</p></div><button className="icon-button" onClick={loadSessions} disabled={sessionsState.loading} aria-label="刷新会话"><Icon name="refresh" size={16} /></button></div>
<label className="search-box"><Icon name="search" size={16} /><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索会话" /></label>
{sessionsState.loading ? <LoadingState text="正在读取会话…" /> : sessionsState.error ? <ReadError error={sessionsState.error} onRetry={loadSessions} /> : visibleSessions.length === 0 ? <Empty text="暂无会话,或当前账号还没有可见数据。" /> : <div className="session-list">{visibleSessions.map((session) => <button key={session.id} className={selectedChat?.id === session.id ? "session-row selected" : "session-row"} onClick={() => setSelectedChat({ ...session, clientId })}><span className="session-avatar">{session.title.slice(0, 1).toUpperCase()}</span><span className="session-copy"><strong>{session.title}</strong><small>{session.preview}</small></span>{session.unread > 0 && <b>{session.unread}</b>}</button>)}</div>}
</section>
<section className="conversation-panel">
{!selectedChat ? <ContextEmpty title="选择一个会话" text="从左侧选择会话后,读取该 Client 的消息。" /> : <><div className="conversation-head"><div><p className="eyebrow">当前 Client · {clientId}</p><h2>{selectedChat.title}</h2><span>{selectedChat.type === "Group" ? "群聊" : "私聊"} · {shortId(selectedChat.id, 28)}</span></div><StatusBadge value={client.status} /></div>{messagesState.loading ? <LoadingState text="正在读取消息…" /> : messagesState.error ? <ReadError error={messagesState.error} onRetry={() => setSelectedChat({ ...selectedChat })} /> : messages.length === 0 ? <Empty text="这个会话暂时没有可显示的消息。" /> : <div className="message-list">{messages.map((message) => <article className="message-row" key={message.id}><div className="message-avatar">{message.sender.slice(0, 1).toUpperCase()}</div><div><div className="message-meta"><strong>{message.sender}</strong><span>{formatTime(message.at)}</span></div><p>{message.text || "(无正文)"}</p></div></article>)}</div>}<div className="composer"><textarea disabled rows={2} placeholder="发送消息暂未开放,需完成 Windows 真机验收" /><button className="primary-button" disabled title="写操作尚未通过真机验收">发送</button></div></>}
</section>
</div>
);
}
function BroadcastView({ client, onSettings }) {
return <section className="workspace-panel gated-panel"><div className="gated-mark"><Icon name="shield" size={24} /></div><div><p className="eyebrow">CONTROLLED ACTION</p><h2>受控群发验证</h2><p>单 Client 的 Client 端已提供 broadcast-text:冻结获准对象名单,逐项串行发送,返回每个对象的结果,并支持停止和幂等重放。</p><div className="step-list"><span><b>1</b>冻结对象</span><span><b>2</b>预览确认</span><span><b>3</b>逐项发送</span><span><b>4</b>查看结果</span></div><button className="primary-button" disabled>{client ? "Web 提交仍需单独验收" : "请先连接 Client"}</button><button className="text-button" onClick={onSettings}>查看 Client 验证状态 <Icon name="arrow" size={14} /></button></div></section>;
}
function ContactsView({ token, client, onSettings }) {
const clientId = client?.node_id || "";
const accountId = activeAccount(client);
const [groupsOnly, setGroupsOnly] = useState(false);
const [query, setQuery] = useState("");
const [contacts, setContacts] = useState([]);
const [state, setState] = useState({ loading: false, error: "" });
const requestRef = useRef(0);
const load = useCallback(async () => {
if (!clientId || !accountId) return;
const requestId = ++requestRef.current;
setState({ loading: true, error: "" });
try {
const content = await readWithTask("/v1/reads/contacts", { node_id: clientId, account_id: accountId, limit: 200, groups_only: groupsOnly, contains: query }, token);
if (requestId !== requestRef.current) return;
setContacts(normalizeContacts(content));
setState({ loading: false, error: "" });
} catch (reason) {
if (requestId === requestRef.current) setState({ loading: false, error: reason.message });
}
}, [accountId, clientId, groupsOnly, query, token]);
useEffect(() => { setContacts([]); if (clientId && accountId) load(); }, [clientId, accountId, groupsOnly]);
if (!client) return <ContextEmpty onSettings={onSettings} />;
const visible = contacts.filter((contact) => !query || `${contact.title} ${contact.id} ${contact.detail}`.toLowerCase().includes(query.toLowerCase()));
return <section className="workspace-panel"><div className="section-heading"><div><h2>通讯录</h2><p>只读查看当前 Client 已授权的联系人和群聊。</p></div><div className="heading-actions"><label className="check-filter"><input type="checkbox" checked={groupsOnly} onChange={(event) => setGroupsOnly(event.target.checked)} />只看群聊</label><button className="secondary-button" disabled title="添加好友尚未通过真机验收">添加好友</button></div></div><div className="toolbar"><label className="search-box wide"><Icon name="search" size={16} /><input value={query} onChange={(event) => setQuery(event.target.value)} onKeyDown={(event) => event.key === "Enter" && load()} placeholder="搜索联系人或群聊" /></label><button className="secondary-button" onClick={load} disabled={state.loading}>{state.loading ? "读取中…" : "刷新"}</button></div>{state.loading ? <LoadingState text="正在读取通讯录…" /> : state.error ? <ReadError error={state.error} onRetry={load} /> : visible.length === 0 ? <Empty text="暂无可见联系人或群聊。" /> : <div className="contact-list">{visible.map((contact) => <div className="contact-row" key={contact.id}><span className="contact-avatar">{contact.title.slice(0, 1).toUpperCase()}</span><div><strong>{contact.title}</strong><small>{contact.type === "Group" ? "群聊" : "联系人"} · {shortId(contact.id, 28)}{contact.detail ? ` · ${contact.detail}` : ""}</small></div><button className="text-button" disabled title="写操作尚未通过真机验收">添加好友</button></div>)}</div>}</section>;
}
function TasksView({ tasks, selectedClientId }) {
const [filter, setFilter] = useState("all");
const visible = tasks.filter((task) => task.node_id === selectedClientId && (filter === "all" || task.status === filter));
return <section className="workspace-panel"><div className="section-heading"><div><h2>任务结果</h2><p>当前 Client 的任务状态和安全边界。</p></div><div className="heading-actions"><span className="read-only-note"><Icon name="shield" size={14} />只读视图</span><select value={filter} onChange={(event) => setFilter(event.target.value)}><option value="all">全部状态</option><option value="Pending">待处理</option><option value="WaitingForClient">等待 Client</option><option value="Running">执行中</option><option value="Succeeded">已完成</option><option value="Failed">失败</option><option value="ResultUnconfirmed">待核对</option></select></div></div>{visible.length === 0 ? <Empty text="当前 Client 暂无任务记录。" /> : <div className="task-list">{visible.map((task) => <article className="task-row" key={task.task_id}><div className="task-main"><div className="task-title"><strong>{task.kind}</strong><StatusBadge value={task.status} /></div><p>{task.account_id} · {shortId(task.task_id, 22)}</p><small>{task.status === "WaitingForClient" ? "Client 离线后等待显式恢复;不会自动重放写操作。" : task.result?.message || task.result?.error_code || `更新于 ${formatTime(task.updated_at)}`}</small></div><div className="task-meta"><span>第 {task.lease_generation || 0} 代租约</span><time>{formatTime(task.updated_at)}</time></div></article>)}</div>}</section>;
}
function NodesTable({ nodes }) {
return <div className="diagnostic-list">{nodes.length === 0 ? <Empty text="暂无注册 Client。" /> : nodes.map((node) => <article className="diagnostic-row" key={node.node_id}><div className="diagnostic-title"><span className="node-avatar">{node.node_id.slice(0, 1).toUpperCase()}</span><div><strong>{node.node_id}</strong><small>{node.active_account_id || "尚未确认活动账号"} · 最近心跳 {formatTime(node.last_heartbeat_at)}</small></div></div><StatusBadge value={node.status} /></article>)}</div>;
}
function EventsTable({ events, selectedClientId }) {
const visible = events.filter((event) => !selectedClientId || event.node_id === selectedClientId);
return <div className="diagnostic-list">{visible.length === 0 ? <Empty text="当前 Client 暂无事件。" /> : visible.slice(0, 30).map((event) => <article className="diagnostic-row" key={event.event_id}><div><strong>{shortId(event.chat_id, 24)}</strong><small>{event.node_id} · {event.chat_type === "Group" ? "群聊" : "私聊"} · {formatTime(event.received_at)}</small></div><span className="event-preview">{event.content || "无正文"}</span></article>)}</div>;
}
function AuditTable({ audit }) {
return <div className="diagnostic-list">{audit.length === 0 ? <Empty text="暂无审计记录。" /> : audit.slice(0, 30).map((item) => <article className="diagnostic-row" key={item.id}><div><strong>{item.action}</strong><small>{item.principal} · {item.resource}</small></div><span>{formatTime(item.at)}</span></article>)}</div>;
}
function SettingsView({ nodes, events, audit, selectedClientId, onSelectClient }) {
const [tab, setTab] = useState("connection");
return <section className="settings-layout"><aside className="settings-nav"><button className={tab === "connection" ? "settings-tab active" : "settings-tab"} onClick={() => setTab("connection")}><strong>连接与账号</strong><span>选择 Client、确认状态</span></button><button className={tab === "diagnostics" ? "settings-tab active" : "settings-tab"} onClick={() => setTab("diagnostics")}><strong>高级诊断</strong><span>节点、事件、AI、审计</span></button></aside><div className="settings-content">{tab === "connection" ? <><div className="workspace-panel"><div className="section-heading"><div><h2>连接与账号</h2><p>每个 Client 独立连接,单个 Client 离线不会阻断其它 Client。</p></div><StatusBadge value={nodes.some(isClientAvailable) ? "Online" : "Offline"} /></div><NodesTable nodes={nodes} /></div><div className="workspace-panel"><div className="section-heading"><div><h2>安全边界</h2><p>当前版本只开放真实后端已支持且可验证的只读能力。</p></div></div><ul className="boundary-list"><li><Icon name="check" size={15} />消息、通讯录、任务结果按选定 Client 隔离读取</li><li><Icon name="check" size={15} />Client 掉线只影响当前上下文,任务不会自动改投</li><li><Icon name="alert" size={15} />发送、群发、添加好友等待 Windows 真机验收</li></ul></div></> : <><div className="workspace-panel"><div className="section-heading"><div><h2>Client 诊断</h2><p>技术状态只在这里展示,不干扰普通操作。</p></div></div><div className="client-pills">{nodes.map((node) => <button key={node.node_id} className={node.node_id === selectedClientId ? "client-pill active" : "client-pill"} onClick={() => onSelectClient(node.node_id)}><span>{node.node_id}</span><StatusBadge value={node.status} /></button>)}</div></div><div className="workspace-panel"><div className="section-heading"><div><h2>AI 能力</h2><p>AI 入口保留在高级诊断中;当前不自动调用,也不影响消息、通讯录和任务操作。</p></div><span className="read-only-note"><Icon name="shield" size={14} />按能力矩阵开放</span></div></div><div className="workspace-panel"><div className="section-heading"><div><h2>白名单事件</h2><p>当前选中 Client 的已授权事件。</p></div></div><EventsTable events={events} selectedClientId={selectedClientId} /></div><div className="workspace-panel"><div className="section-heading"><div><h2>操作审计</h2><p>用于定位连接、任务和权限问题。</p></div></div><AuditTable audit={audit} /></div></>}</div></section>;
}
function App() {
const [token, setToken] = useState(() => sessionStorage.getItem("wxagent-token") || "");
const [username, setUsername] = useState(() => sessionStorage.getItem("wxagent-user") || "");
const [view, setView] = useState("messages");
const [nodes, setNodes] = useState([]);
const [selectedClientId, setSelectedClientId] = useState(() => sessionStorage.getItem("wxagent-client") || "");
const [tasks, setTasks] = useState([]);
const [events, setEvents] = useState([]);
const [audit, setAudit] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const logout = useCallback(() => {
sessionStorage.removeItem("wxagent-token");
sessionStorage.removeItem("wxagent-user");
sessionStorage.removeItem("wxagent-client");
setToken("");
setUsername("");
}, []);
const refresh = useCallback(async () => {
if (!token) return;
setLoading(true);
setError("");
try {
const [nodeData, taskData, eventData, auditData] = await Promise.all([
api("/v1/nodes", { token }),
api("/v1/tasks?limit=200", { token }),
api("/v1/events?limit=200", { token }),
api("/v1/audit?limit=200", { token }),
]);
setNodes(nodeData.nodes || []);
setTasks(taskData.tasks || []);
setEvents(eventData.events || []);
setAudit(auditData.audit || []);
} catch (reason) {
if (reason.status === 401) logout();
else setError(reason.message);
} finally {
setLoading(false);
}
}, [logout, token]);
useEffect(() => {
refresh();
if (!token) return undefined;
const id = window.setInterval(refresh, 15000);
return () => window.clearInterval(id);
}, [refresh, token]);
useEffect(() => {
if (!nodes.length) {
setSelectedClientId("");
return;
}
if (!nodes.some((node) => node.node_id === selectedClientId)) {
const next = nodes.find(isClientAvailable) || nodes[0];
setSelectedClientId(next.node_id);
sessionStorage.setItem("wxagent-client", next.node_id);
}
}, [nodes, selectedClientId]);
const handleClientChange = (clientId) => {
setSelectedClientId(clientId);
sessionStorage.setItem("wxagent-client", clientId);
};
const handleLogin = (nextToken, nextUser) => {
sessionStorage.setItem("wxagent-token", nextToken);
sessionStorage.setItem("wxagent-user", nextUser);
setToken(nextToken);
setUsername(nextUser);
};
const selectedNode = useMemo(() => nodes.find((node) => node.node_id === selectedClientId), [nodes, selectedClientId]);
const nav = NAV_ITEMS.find((item) => item.id === view);
const title = nav?.label || "设置与诊断";
const subtitle = nav?.description || "连接、账号和高级诊断信息";
if (!token) return <Login onLogin={handleLogin} />;
return <div className="app-shell"><Sidebar view={view} setView={setView} onLogout={logout} username={username} /><main className="main-area"><Topbar title={title} subtitle={subtitle} nodes={nodes} selectedClientId={selectedClientId} onClientChange={handleClientChange} onRefresh={refresh} loading={loading} />{error && <div className="global-error" role="alert"><Icon name="alert" size={16} /><span>{error}</span><button onClick={() => setError("")} aria-label="关闭错误"><Icon name="close" size={16} /></button></div>}<ConnectionBanner nodes={nodes} selectedNode={selectedNode} onSettings={() => setView("settings")} /><div className="page-content">{view === "messages" && <MessagesView token={token} client={selectedNode} onSettings={() => setView("settings")} />}{view === "broadcast" && <BroadcastView client={selectedNode} onSettings={() => setView("settings")} />}{view === "contacts" && <ContactsView token={token} client={selectedNode} onSettings={() => setView("settings")} />}{view === "tasks" && <TasksView tasks={tasks} selectedClientId={selectedClientId} />}{view === "settings" && <SettingsView nodes={nodes} events={events} audit={audit} selectedClientId={selectedClientId} onSelectClient={handleClientChange} />}</div><footer className="footer"><span>WxAgent 工作台 · Client 独立隔离</span><span>写操作需通过真实能力与真机验收</span></footer></main></div>;
}
createRoot(document.getElementById("root")).render(<App />);