541 lines
33 KiB
React
541 lines
33 KiB
React
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 />);
|