feat: add control-plane identity and management console
Build web service image / build (push) Successful in 2m7s
Build web service image / build (push) Successful in 2m7s
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+177
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-40
File diff suppressed because one or more lines are too long
+424
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
+6
-5
@@ -3,11 +3,12 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#f6f7fb" />
|
||||
<meta name="description" content="WxAgent 普通用户工作台" />
|
||||
<title>WxAgent 工作台</title>
|
||||
<script type="module" crossorigin src="/assets/index-BS_ZFWFu.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-6ql7JGil.css">
|
||||
<meta name="theme-color" content="#f5f7fb" />
|
||||
<meta name="csp-nonce" content="__WX_CSP_NONCE__" />
|
||||
<meta name="description" content="WxAgent control plane for Desktop Agent management" />
|
||||
<title>WxAgent 控制面</title>
|
||||
<script type="module" crossorigin src="/assets/index-B_02q8Ux.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DLJyOQT-.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#f6f7fb" />
|
||||
<meta name="description" content="WxAgent 普通用户工作台" />
|
||||
<title>WxAgent 工作台</title>
|
||||
<meta name="theme-color" content="#f5f7fb" />
|
||||
<meta name="csp-nonce" content="__WX_CSP_NONCE__" />
|
||||
<meta name="description" content="WxAgent control plane for Desktop Agent management" />
|
||||
<title>WxAgent 控制面</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Generated
+1588
File diff suppressed because it is too large
Load Diff
@@ -10,9 +10,16 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.6.1",
|
||||
"@ant-design/pro-components": "^2.8.10",
|
||||
"@ant-design/x": "^1.6.1",
|
||||
"antd": "^5.29.3",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"overrides": {
|
||||
"path-to-regexp": "8.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"vite": "^5.4.14"
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Avatar, Button, Dropdown, Form, Input, Select, Space, Spin, Tag, Typography } from "antd";
|
||||
import { PageContainer, ProLayout } from "@ant-design/pro-components";
|
||||
import {
|
||||
AppstoreOutlined,
|
||||
AuditOutlined,
|
||||
ContactsOutlined,
|
||||
DesktopOutlined,
|
||||
LogoutOutlined,
|
||||
MessageOutlined,
|
||||
ReloadOutlined,
|
||||
SendOutlined,
|
||||
TeamOutlined,
|
||||
UnorderedListOutlined,
|
||||
UserOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { api, activeAccount, isClientAvailable, statusColor, statusLabel } from "./api.js";
|
||||
const MessagesView = lazy(() => import("./views/MessagesView.jsx"));
|
||||
const UsersView = lazy(() => import("./views/AdminViews.jsx").then((module) => ({ default: module.UsersView })));
|
||||
const NodesView = lazy(() => import("./views/AdminViews.jsx").then((module) => ({ default: module.NodesView })));
|
||||
const OverviewView = lazy(() => import("./views/OperationsViews.jsx").then((module) => ({ default: module.OverviewView })));
|
||||
const ContactsView = lazy(() => import("./views/OperationsViews.jsx").then((module) => ({ default: module.ContactsView })));
|
||||
const TasksView = lazy(() => import("./views/OperationsViews.jsx").then((module) => ({ default: module.TasksView })));
|
||||
const BroadcastView = lazy(() => import("./views/OperationsViews.jsx").then((module) => ({ default: module.BroadcastView })));
|
||||
const DiagnosticsView = lazy(() => import("./views/OperationsViews.jsx").then((module) => ({ default: module.DiagnosticsView })));
|
||||
|
||||
const { Text } = Typography;
|
||||
const TOKEN_KEY = "wxagent-token";
|
||||
const CLIENT_KEY = "wxagent-client";
|
||||
|
||||
const PAGE_META = {
|
||||
overview: { title: "工作台概览", subtitle: "统一查看 Desktop Agent 连接状态与任务执行情况" },
|
||||
messages: { title: "消息工作台", subtitle: "按已授权 Client / Node 查看会话和消息历史" },
|
||||
contacts: { title: "通讯录", subtitle: "读取已授权账号的联系人与群聊缓存" },
|
||||
tasks: { title: "任务中心", subtitle: "跟踪 Client 领取、执行与结果状态" },
|
||||
agents: { title: "Desktop Agent", subtitle: "远程桌面节点与账号连接状态" },
|
||||
users: { title: "用户与权限", subtitle: "管理控制面用户,并分配可访问的 Desktop Agent / Node" },
|
||||
broadcast: { title: "受控群发", subtitle: "Web 写操作仍需单独验收" },
|
||||
diagnostics: { title: "诊断与审计", subtitle: "管理员可见的连接、事件与审计信息" },
|
||||
};
|
||||
|
||||
const VALID_VIEWS = new Set(Object.keys(PAGE_META));
|
||||
|
||||
function viewFromLocation() {
|
||||
const path = window.location.pathname.split("/").filter(Boolean).join("/");
|
||||
return VALID_VIEWS.has(path) ? path : "overview";
|
||||
}
|
||||
|
||||
function LoginPage({ onLogin }) {
|
||||
const [form] = Form.useForm();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const submit = async (values) => {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const result = await api("/v1/auth/login", { method: "POST", body: values });
|
||||
onLogin(result.access_token, result.user);
|
||||
} catch (reason) {
|
||||
setError(reason.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="login-shell">
|
||||
<section className="login-intro-panel">
|
||||
<div className="brand-lockup"><span className="brand-mark">WX</span><span>WxAgent <Text type="secondary">CONTROL PLANE</Text></span></div>
|
||||
<div className="login-intro-copy">
|
||||
<Tag color="blue">Desktop Agent 管理平台</Tag>
|
||||
<Typography.Title level={1}>把多个 Client,<br />放进同一个工作台。</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">按用户分配 Desktop Agent / Node 访问范围,在统一工作台中查看授权会话、通讯录与任务状态。</Typography.Paragraph>
|
||||
</div>
|
||||
<div className="login-capabilities">
|
||||
<div><DesktopOutlined /><span><strong>节点隔离</strong><small>成员仅能查看获分配的 Node</small></span></div>
|
||||
<div><TeamOutlined /><span><strong>持久化权限</strong><small>用户与分配关系由控制面保存</small></span></div>
|
||||
<div><MessageOutlined /><span><strong>消息优先</strong><small>保留现有真实消息和 Client 接口</small></span></div>
|
||||
</div>
|
||||
<Text className="login-legal" type="secondary">仅展示已授权、规范化的只读数据;写操作按真机验收状态开放。</Text>
|
||||
</section>
|
||||
<section className="login-form-panel">
|
||||
<div className="login-form-card">
|
||||
<div className="login-form-heading"><Avatar size={46} icon={<UserOutlined />} /><div><Typography.Title level={3}>登录控制面</Typography.Title><Text type="secondary">使用管理员为你创建的 Web 账号</Text></div></div>
|
||||
{error && <div className="login-error" role="alert">{error}</div>}
|
||||
<Form form={form} layout="vertical" onFinish={submit} requiredMark={false} size="large">
|
||||
<Form.Item name="username" label="用户名" rules={[{ required: true, message: "请输入用户名" }]}>
|
||||
<Input autoFocus autoComplete="username" placeholder="输入用户名" />
|
||||
</Form.Item>
|
||||
<Form.Item name="password" label="密码" rules={[{ required: true, message: "请输入密码" }]}>
|
||||
<Input.Password autoComplete="current-password" placeholder="输入密码" />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" block loading={busy}>登录工作台</Button>
|
||||
</Form>
|
||||
<div className="login-security"><span className="security-indicator" /> 会话凭据仅保存在当前浏览器标签页</div>
|
||||
</div>
|
||||
<Text className="login-footer" type="secondary">WxAgent · 远程 Client 管理与只读数据工作台</Text>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectionAlert({ node, nodes, onSettings }) {
|
||||
if (!nodes.length) {
|
||||
return <div className="context-alert"><Tag color="error">无可用 Node</Tag><Text>请启动已配置的 Desktop Agent;浏览器不会模拟连接状态。</Text><Button type="link" onClick={onSettings}>查看诊断</Button></div>;
|
||||
}
|
||||
if (!isClientAvailable(node)) {
|
||||
return <div className="context-alert"><Tag color="warning">当前 Node 不可用</Tag><Text>可从顶部切换到其它已分配的 Node;离线任务不会自动转派或重放。</Text><Button type="link" onClick={onSettings}>查看诊断</Button></div>;
|
||||
}
|
||||
if (!activeAccount(node)) {
|
||||
return <div className="context-alert"><Tag color="warning">未确认活动账号</Tag><Text>读取只针对已验证的活动账号。</Text><Button type="link" onClick={onSettings}>查看诊断</Button></div>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function ConsoleApp() {
|
||||
const [token, setToken] = useState(() => sessionStorage.getItem(TOKEN_KEY) || "");
|
||||
const [profile, setProfile] = useState(null);
|
||||
const [view, setView] = useState(viewFromLocation);
|
||||
const [nodes, setNodes] = useState([]);
|
||||
const [selectedNodeId, setSelectedNodeId] = useState(() => sessionStorage.getItem(CLIENT_KEY) || "");
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [events, setEvents] = useState([]);
|
||||
const [audit, setAudit] = useState([]);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const isAdmin = profile?.role === "admin";
|
||||
const selectedNode = useMemo(() => nodes.find((node) => node.node_id === selectedNodeId), [nodes, selectedNodeId]);
|
||||
const navigate = useCallback((nextView) => {
|
||||
if (!VALID_VIEWS.has(nextView)) return;
|
||||
window.history.pushState({}, "", nextView === "overview" ? "/" : `/${nextView}`);
|
||||
setView(nextView);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onPopState = () => setView(viewFromLocation());
|
||||
window.addEventListener("popstate", onPopState);
|
||||
return () => window.removeEventListener("popstate", onPopState);
|
||||
}, []);
|
||||
|
||||
const clearSession = useCallback(() => {
|
||||
sessionStorage.removeItem(TOKEN_KEY);
|
||||
sessionStorage.removeItem(CLIENT_KEY);
|
||||
setToken("");
|
||||
setProfile(null);
|
||||
setNodes([]);
|
||||
setTasks([]);
|
||||
setEvents([]);
|
||||
setAudit([]);
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
if (token) {
|
||||
try { await api("/v1/auth/logout", { token, method: "POST" }); } catch { /* The local session must still be cleared. */ }
|
||||
}
|
||||
clearSession();
|
||||
}, [clearSession, token]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!token) return;
|
||||
setRefreshing(true);
|
||||
setError("");
|
||||
try {
|
||||
const me = await api("/v1/auth/me", { token });
|
||||
setProfile(me.user);
|
||||
const [nodeData, taskData, eventData, auditData] = await Promise.all([
|
||||
api("/v1/nodes", { token }),
|
||||
api("/v1/tasks?limit=200", { token }),
|
||||
api("/v1/events?limit=200", { token }),
|
||||
me.user.role === "admin" ? api("/v1/audit?limit=200", { token }) : Promise.resolve({ audit: [] }),
|
||||
]);
|
||||
setNodes(nodeData.nodes || []);
|
||||
setTasks(taskData.tasks || []);
|
||||
setEvents(eventData.events || []);
|
||||
setAudit(auditData.audit || []);
|
||||
} catch (reason) {
|
||||
if (reason.status === 401) clearSession();
|
||||
else setError(reason.message);
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [clearSession, token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return undefined;
|
||||
void refresh();
|
||||
const timer = window.setInterval(() => void refresh(), 15000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [refresh, token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!nodes.length) {
|
||||
setSelectedNodeId("");
|
||||
sessionStorage.removeItem(CLIENT_KEY);
|
||||
return;
|
||||
}
|
||||
if (!nodes.some((node) => node.node_id === selectedNodeId)) {
|
||||
const next = nodes.find(isClientAvailable) || nodes[0];
|
||||
setSelectedNodeId(next.node_id);
|
||||
sessionStorage.setItem(CLIENT_KEY, next.node_id);
|
||||
}
|
||||
}, [nodes, selectedNodeId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!profile) return;
|
||||
const allowedViews = new Set(["overview", "messages", "contacts", "tasks", "agents", "broadcast", ...(isAdmin ? ["users", "diagnostics"] : [])]);
|
||||
if (!allowedViews.has(view)) navigate("overview");
|
||||
}, [isAdmin, navigate, profile, view]);
|
||||
|
||||
const handleLogin = (nextToken, nextProfile) => {
|
||||
sessionStorage.setItem(TOKEN_KEY, nextToken);
|
||||
setToken(nextToken);
|
||||
setProfile(nextProfile || null);
|
||||
};
|
||||
|
||||
const selectNode = (nodeID) => {
|
||||
setSelectedNodeId(nodeID);
|
||||
sessionStorage.setItem(CLIENT_KEY, nodeID);
|
||||
};
|
||||
|
||||
if (!token) return <LoginPage onLogin={handleLogin} />;
|
||||
if (!profile) return <div className="boot-screen"><span className="brand-mark">WX</span><Text type="secondary">正在验证会话…</Text></div>;
|
||||
|
||||
const navigation = [
|
||||
{ key: "overview", path: "/overview", name: "概览", icon: <AppstoreOutlined /> },
|
||||
{ key: "messages", path: "/messages", name: "消息工作台", icon: <MessageOutlined /> },
|
||||
{ key: "contacts", path: "/contacts", name: "通讯录", icon: <ContactsOutlined /> },
|
||||
{ key: "tasks", path: "/tasks", name: "任务中心", icon: <UnorderedListOutlined /> },
|
||||
{ key: "agents", path: "/agents", name: "Desktop Agent", icon: <DesktopOutlined /> },
|
||||
{ key: "broadcast", path: "/broadcast", name: "受控群发", icon: <SendOutlined /> },
|
||||
...(isAdmin ? [
|
||||
{ key: "users", path: "/users", name: "用户与权限", icon: <TeamOutlined /> },
|
||||
{ key: "diagnostics", path: "/diagnostics", name: "诊断与审计", icon: <AuditOutlined /> },
|
||||
] : []),
|
||||
];
|
||||
const page = PAGE_META[view] || PAGE_META.overview;
|
||||
const contextPage = ["messages", "contacts", "tasks"].includes(view);
|
||||
const userMenu = {
|
||||
items: [{ key: "logout", icon: <LogoutOutlined />, label: "退出登录" }],
|
||||
onClick: ({ key }) => key === "logout" && void logout(),
|
||||
};
|
||||
|
||||
return (
|
||||
<ProLayout
|
||||
className="wx-pro-layout"
|
||||
title="WxAgent"
|
||||
logo={<span className="pro-brand-mark">WX</span>}
|
||||
layout="mix"
|
||||
navTheme="light"
|
||||
fixedHeader
|
||||
fixSiderbar
|
||||
siderWidth={248}
|
||||
location={{ pathname: `/${view}` }}
|
||||
menuDataRender={() => navigation}
|
||||
menuItemRender={(item, dom) => <a className="pro-menu-link" href={item.path} onClick={(event) => { event.preventDefault(); navigate(item.key); }}>{dom}</a>}
|
||||
rightContentRender={() => (
|
||||
<Space size={14} className="header-actions">
|
||||
<Select
|
||||
aria-label="当前 Desktop Agent"
|
||||
value={selectedNodeId || undefined}
|
||||
placeholder="选择 Desktop Agent"
|
||||
disabled={!nodes.length}
|
||||
onChange={selectNode}
|
||||
options={nodes.map((node) => ({ value: node.node_id, label: `${node.node_id} · ${statusLabel(node.status)}` }))}
|
||||
style={{ width: 250 }}
|
||||
/>
|
||||
{selectedNode && <Tag color={statusColor(selectedNode.status)}>{statusLabel(selectedNode.status)}</Tag>}
|
||||
<Button type="text" icon={<ReloadOutlined />} loading={refreshing} onClick={() => void refresh()}>刷新</Button>
|
||||
<Dropdown menu={userMenu} placement="bottomRight" trigger={["click"]}>
|
||||
<Button type="text" className="profile-button"><Avatar size="small" icon={<UserOutlined />} />{profile.display_name || profile.username}<Tag color={isAdmin ? "blue" : "default"}>{isAdmin ? "管理员" : "成员"}</Tag></Button>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
)}
|
||||
menuFooterRender={() => <div className="pro-sidebar-footer"><span className="security-indicator" /> 会话已认证 · Node 权限隔离</div>}
|
||||
footerRender={() => null}
|
||||
contentStyle={{ padding: 0, flex: 1, minHeight: 0, overflow: "hidden" }}
|
||||
>
|
||||
<PageContainer title={page.title} subTitle={page.subtitle} className="wx-page-container">
|
||||
<Suspense fallback={<div className="view-loading"><Spin /><Text type="secondary">正在加载视图…</Text></div>}>
|
||||
{error && <div className="global-error"><Text type="danger">{error}</Text><Button type="link" onClick={() => setError("")}>关闭</Button></div>}
|
||||
{contextPage && <ConnectionAlert node={selectedNode} nodes={nodes} onSettings={() => navigate(isAdmin ? "diagnostics" : "agents")} />}
|
||||
{view === "overview" && <OverviewView nodes={nodes} tasks={tasks} events={events} onNavigate={navigate} onRefresh={() => void refresh()} refreshing={refreshing} />}
|
||||
{view === "messages" && <MessagesView token={token} client={selectedNode} onSettings={() => navigate(isAdmin ? "diagnostics" : "agents")} />}
|
||||
{view === "contacts" && <ContactsView token={token} client={selectedNode} />}
|
||||
{view === "tasks" && <TasksView tasks={tasks} selectedNodeId={selectedNodeId} />}
|
||||
{view === "agents" && <NodesView token={token} nodes={nodes} isAdmin={isAdmin} onGoUsers={() => navigate("users")} />}
|
||||
{view === "users" && isAdmin && <UsersView token={token} currentUser={profile} nodes={nodes} onChanged={() => void refresh()} />}
|
||||
{view === "broadcast" && <BroadcastView client={selectedNode} />}
|
||||
{view === "diagnostics" && isAdmin && <DiagnosticsView nodes={nodes} events={events} audit={audit} selectedNodeId={selectedNodeId} onSelectNode={selectNode} />}
|
||||
</Suspense>
|
||||
</PageContainer>
|
||||
</ProLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConsoleApp;
|
||||
@@ -0,0 +1,181 @@
|
||||
import {
|
||||
extractItems,
|
||||
mergeStableItems,
|
||||
normalizeMessages,
|
||||
normalizeSessions,
|
||||
resolveReadCoverage,
|
||||
shouldReplaceReadState,
|
||||
} from "./readState.js";
|
||||
|
||||
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"]);
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(message, status, code = "RequestFailed") {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export 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));
|
||||
|
||||
const TASK_ERROR_MESSAGES = {
|
||||
ReportingDisabled: "Client 未启用 Reporting 白名单。请在托盘远程连接页启用并配置白名单。",
|
||||
ReportingConfigInvalid: "Client 的 Reporting 配置无效,请检查托盘远程连接页。",
|
||||
AccountNotAuthorized: "当前账号未加入 Client 的 Reporting 白名单。",
|
||||
ChatNotAuthorized: "当前会话不在 Client 的 Reporting 白名单中。",
|
||||
ChatIdentityUnconfirmed: "当前会话身份尚未确认,暂不能读取。",
|
||||
DataTypeNotAuthorized: "当前读取类型未被 Reporting 白名单授权。",
|
||||
};
|
||||
|
||||
function taskFailureMessage(task) {
|
||||
const code = task.result?.error_code || task.status;
|
||||
return TASK_ERROR_MESSAGES[code] || task.result?.message || `读取任务${statusLabel(task.status)}。`;
|
||||
}
|
||||
|
||||
async function waitForTask(token, taskId) {
|
||||
const deadline = Date.now() + 30000;
|
||||
let delay = 250;
|
||||
let lastTask = { status: "Pending" };
|
||||
while (Date.now() < deadline) {
|
||||
const task = await api(`/v1/tasks/${encodeURIComponent(taskId)}`, { token });
|
||||
lastTask = task;
|
||||
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(taskFailureMessage(task), 409, task.result?.error_code || task.status);
|
||||
}
|
||||
await wait(delay);
|
||||
delay = Math.min(1000, delay + 150);
|
||||
}
|
||||
if (lastTask.status === "Running") {
|
||||
throw new ApiError(`Client 仍在执行读取任务(${taskId.slice(0, 12)}…),请稍后在任务页查看结果。`, 408, "TaskRunning");
|
||||
}
|
||||
throw new ApiError(`任务仍在等待 Client 领取(${taskId.slice(0, 12)}…),请检查 Reporting 账号配置。`, 408, "TaskPending");
|
||||
}
|
||||
|
||||
export 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);
|
||||
}
|
||||
|
||||
export function dataPath(accountId, resource, query = "") {
|
||||
return `/v1/data/accounts/${encodeURIComponent(accountId)}/${resource}${query}`;
|
||||
}
|
||||
|
||||
function canUseLegacyReadFallback(reason) {
|
||||
return ["AccountDataNotFound", "AccountDataUnavailable", "DataStatusFailed"].includes(reason.code);
|
||||
}
|
||||
|
||||
export async function readStoredOrFallback({ storedPath, legacyPath, legacyBody, token }) {
|
||||
try {
|
||||
return await api(storedPath, { token });
|
||||
} catch (reason) {
|
||||
if (!canUseLegacyReadFallback(reason)) throw reason;
|
||||
return readWithTask(legacyPath, legacyBody, token);
|
||||
}
|
||||
}
|
||||
|
||||
export function formatTime(value) {
|
||||
if (!value) return "—";
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? "—" : date.toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
export function shortId(value, length = 16) {
|
||||
if (!value) return "—";
|
||||
return value.length > length ? `${value.slice(0, length)}…` : value;
|
||||
}
|
||||
|
||||
export function statusLabel(status) {
|
||||
return STATUS_LABELS[status] || status || "未知";
|
||||
}
|
||||
|
||||
export function statusColor(status) {
|
||||
if (["Online", "Succeeded", "Accepted"].includes(status)) return "success";
|
||||
if (["Running", "Pending", "WaitingForClient", "Degraded"].includes(status)) return "warning";
|
||||
if (["Failed", "Cancelled", "Expired", "ResultUnconfirmed", "Offline", "SessionLocked", "WechatNotRunning", "WechatNotLoggedIn"].includes(status)) return "error";
|
||||
return "default";
|
||||
}
|
||||
|
||||
export function isClientAvailable(node) {
|
||||
return Boolean(node && ["Online", "Degraded", "Registered"].includes(node.status));
|
||||
}
|
||||
|
||||
export 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 || "";
|
||||
}
|
||||
|
||||
export 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 ?? ""),
|
||||
}));
|
||||
}
|
||||
|
||||
export function coverageNotice(label, coverage) {
|
||||
if (!coverage) return "";
|
||||
const freshness = coverage.lastSuccessAt ? `最后同步 ${formatTime(coverage.lastSuccessAt)}` : "尚未成功同步";
|
||||
const backlog = coverage.backlogCount == null ? "积压未知" : `积压 ${coverage.backlogCount}`;
|
||||
const suffix = `${freshness} · ${backlog}`;
|
||||
if (coverage.state === "complete") return coverage.source === "platform-cache" ? `${label}来自平台副本,${suffix}。` : "";
|
||||
if (coverage.state === "partial") return `${label}同步不完整,已保留已有数据;${suffix}。`;
|
||||
return `${label}同步状态未知,未用本次结果清除已有数据;${suffix}${coverage.errorMessage ? ` · ${coverage.errorMessage}` : ""}。`;
|
||||
}
|
||||
|
||||
export {
|
||||
extractItems,
|
||||
mergeStableItems,
|
||||
normalizeMessages,
|
||||
normalizeSessions,
|
||||
resolveReadCoverage,
|
||||
shouldReplaceReadState,
|
||||
};
|
||||
+16
-623
@@ -1,627 +1,20 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App as AntApp, ConfigProvider } from "antd";
|
||||
import zhCN from "antd/locale/zh_CN";
|
||||
import ConsoleApp from "./App.jsx";
|
||||
import "antd/dist/reset.css";
|
||||
import "./styles.css";
|
||||
import {
|
||||
mergeStableItems,
|
||||
normalizeMessages,
|
||||
normalizeSessions,
|
||||
resolveReadCoverage,
|
||||
shouldReplaceReadState,
|
||||
} from "./readState.js";
|
||||
|
||||
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 cspNonce = document.querySelector('meta[name="csp-nonce"]')?.content;
|
||||
|
||||
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));
|
||||
|
||||
const TASK_ERROR_MESSAGES = {
|
||||
ReportingDisabled: "Client 未启用 Reporting 白名单。请在托盘远程连接页启用并配置白名单。",
|
||||
ReportingConfigInvalid: "Client 的 Reporting 配置无效,请检查托盘远程连接页。",
|
||||
AccountNotAuthorized: "当前账号未加入 Client 的 Reporting 白名单。",
|
||||
ChatNotAuthorized: "当前会话不在 Client 的 Reporting 白名单中。",
|
||||
ChatIdentityUnconfirmed: "当前会话身份尚未确认,暂不能读取。",
|
||||
DataTypeNotAuthorized: "当前读取类型未被 Reporting 白名单授权。",
|
||||
};
|
||||
|
||||
function taskFailureMessage(task) {
|
||||
const code = task.result?.error_code || task.status;
|
||||
return TASK_ERROR_MESSAGES[code] || task.result?.message || `读取任务${STATUS_LABELS[task.status] || "未完成"}。`;
|
||||
}
|
||||
|
||||
async function waitForTask(token, taskId) {
|
||||
const deadline = Date.now() + 30000;
|
||||
let delay = 250;
|
||||
let lastTask = { status: "Pending" };
|
||||
while (Date.now() < deadline) {
|
||||
const task = await api(`/v1/tasks/${encodeURIComponent(taskId)}`, { token });
|
||||
lastTask = task;
|
||||
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(taskFailureMessage(task), 409, task.result?.error_code || task.status);
|
||||
}
|
||||
await wait(delay);
|
||||
delay = Math.min(1000, delay + 150);
|
||||
}
|
||||
if (lastTask.status === "Running") {
|
||||
throw new ApiError(`Client 仍在执行读取任务(${taskId.slice(0, 12)}…),请稍后在任务页查看结果。`, 408, "TaskRunning");
|
||||
}
|
||||
throw new ApiError(`任务仍在等待 Client 领取(${taskId.slice(0, 12)}…),请检查 Reporting 账号配置。`, 408, "TaskPending");
|
||||
}
|
||||
|
||||
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 dataPath(accountId, resource, query = "") {
|
||||
return `/v1/data/accounts/${encodeURIComponent(accountId)}/${resource}${query}`;
|
||||
}
|
||||
|
||||
function canUseLegacyReadFallback(reason) {
|
||||
return ["AccountDataNotFound", "AccountDataUnavailable", "DataStatusFailed"].includes(reason.code);
|
||||
}
|
||||
|
||||
async function readStoredOrFallback({ storedPath, legacyPath, legacyBody, token }) {
|
||||
try {
|
||||
return await api(storedPath, { token });
|
||||
} catch (reason) {
|
||||
if (!canUseLegacyReadFallback(reason)) throw reason;
|
||||
return readWithTask(legacyPath, legacyBody, token);
|
||||
}
|
||||
}
|
||||
|
||||
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 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 ReadNotice({ text, onRetry }) {
|
||||
return <div className="read-notice" role="status"><Icon name="alert" size={16} /><span>{text}</span>{onRetry && <button className="text-button" onClick={onRetry}>重试</button>}</div>;
|
||||
}
|
||||
|
||||
function coverageNotice(label, coverage) {
|
||||
if (!coverage) return "";
|
||||
const freshness = coverage.lastSuccessAt ? `最后同步 ${formatTime(coverage.lastSuccessAt)}` : "尚未成功同步";
|
||||
const backlog = coverage.backlogCount == null ? "积压未知" : `积压 ${coverage.backlogCount}`;
|
||||
const suffix = `${freshness} · ${backlog}`;
|
||||
if (coverage.state === "complete") return coverage.source === "platform-cache" ? `${label}来自平台副本,${suffix}。` : "";
|
||||
if (coverage.state === "partial") return `${label}同步不完整,已保留已有数据;${suffix}。`;
|
||||
return `${label}同步状态未知,未用本次结果清除已有数据;${suffix}${coverage.errorMessage ? ` · ${coverage.errorMessage}` : ""}。`;
|
||||
}
|
||||
|
||||
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: "", notice: "" });
|
||||
const [query, setQuery] = useState("");
|
||||
const [selectedChat, setSelectedChat] = useState(null);
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [messagesState, setMessagesState] = useState({ loading: false, error: "", notice: "" });
|
||||
const contextGenerationRef = useRef(0);
|
||||
const sessionsRequestRef = useRef(0);
|
||||
const messagesRequestRef = useRef(0);
|
||||
const refreshRequestRef = useRef(0);
|
||||
|
||||
const requestDataRefresh = useCallback(async () => {
|
||||
if (!accountId || !clientId) return;
|
||||
const requestId = ++refreshRequestRef.current;
|
||||
try {
|
||||
await api(dataPath(accountId, "refresh"), { token, method: "POST" });
|
||||
} catch (reason) {
|
||||
if (requestId === refreshRequestRef.current) {
|
||||
setSessionsState((current) => ({ ...current, notice: current.notice || `后台同步未受理:${reason.message}` }));
|
||||
}
|
||||
}
|
||||
}, [accountId, clientId, token]);
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
if (!clientId || !accountId) return;
|
||||
const requestId = ++sessionsRequestRef.current;
|
||||
const generation = contextGenerationRef.current;
|
||||
setSessionsState((current) => ({ ...current, loading: true, error: "" }));
|
||||
try {
|
||||
const content = await readStoredOrFallback({
|
||||
storedPath: dataPath(accountId, "conversations", "?limit=100"),
|
||||
legacyPath: "/v1/reads/sessions",
|
||||
legacyBody: { node_id: clientId, account_id: accountId, limit: 100 },
|
||||
token,
|
||||
});
|
||||
if (requestId !== sessionsRequestRef.current || generation !== contextGenerationRef.current) return;
|
||||
const next = normalizeSessions(content);
|
||||
if (content.sync && content.sync.state !== "complete") void requestDataRefresh();
|
||||
const coverage = resolveReadCoverage(content, next.length);
|
||||
setSessions((previous) => shouldReplaceReadState(coverage) ? next : mergeStableItems(previous, next));
|
||||
setSessionsState({ loading: false, error: "", notice: coverageNotice("会话", coverage) });
|
||||
} catch (reason) {
|
||||
if (requestId === sessionsRequestRef.current && generation === contextGenerationRef.current) {
|
||||
setSessionsState({ loading: false, error: reason.message, notice: "" });
|
||||
}
|
||||
}
|
||||
}, [accountId, clientId, requestDataRefresh, token]);
|
||||
|
||||
useEffect(() => {
|
||||
contextGenerationRef.current += 1;
|
||||
sessionsRequestRef.current += 1;
|
||||
messagesRequestRef.current += 1;
|
||||
setSelectedChat(null);
|
||||
setSessions([]);
|
||||
setMessages([]);
|
||||
setSessionsState({ loading: false, error: "", notice: "" });
|
||||
setMessagesState({ loading: false, error: "", notice: "" });
|
||||
if (!clientId || !accountId) return;
|
||||
loadSessions();
|
||||
}, [clientId, accountId, loadSessions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedChat || selectedChat.clientId !== clientId || selectedChat.accountId !== accountId || !clientId || !accountId) return;
|
||||
const requestId = ++messagesRequestRef.current;
|
||||
const generation = contextGenerationRef.current;
|
||||
setMessagesState((current) => ({ ...current, loading: true, error: "" }));
|
||||
readStoredOrFallback({
|
||||
storedPath: dataPath(accountId, "messages", `?chat_id=${encodeURIComponent(selectedChat.id)}&limit=100`),
|
||||
legacyPath: "/v1/reads/messages",
|
||||
legacyBody: { node_id: clientId, account_id: accountId, chat_id: selectedChat.id, limit: 100, include_content: true },
|
||||
token,
|
||||
}).then((content) => {
|
||||
if (requestId !== messagesRequestRef.current || generation !== contextGenerationRef.current) return;
|
||||
const next = normalizeMessages(content);
|
||||
const coverage = resolveReadCoverage(content, next.length);
|
||||
setMessages((previous) => shouldReplaceReadState(coverage) ? next : mergeStableItems(previous, next));
|
||||
setMessagesState({ loading: false, error: "", notice: coverageNotice("消息", coverage) });
|
||||
})
|
||||
.catch((reason) => {
|
||||
if (requestId === messagesRequestRef.current && generation === contextGenerationRef.current) {
|
||||
setMessagesState({ loading: false, error: reason.message, notice: "" });
|
||||
}
|
||||
});
|
||||
}, [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 && sessions.length === 0 ? <LoadingState text="正在读取会话…" /> : sessionsState.error && sessions.length === 0 ? <ReadError error={sessionsState.error} onRetry={loadSessions} /> : <>{sessionsState.error && <ReadNotice text={`读取会话失败,继续显示上次数据:${sessionsState.error}`} onRetry={loadSessions} />}{!sessionsState.error && sessionsState.notice && <ReadNotice text={sessionsState.notice} onRetry={loadSessions} />}{sessionsState.loading && sessions.length > 0 && <div className="refresh-hint">正在刷新,会话列表保持可用…</div>}{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, accountId })}><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 && messages.length === 0 ? <LoadingState text="正在读取消息…" /> : messagesState.error && messages.length === 0 ? <ReadError error={messagesState.error} onRetry={() => setSelectedChat({ ...selectedChat })} /> : <>{messagesState.error && <ReadNotice text={`读取消息失败,继续显示上次数据:${messagesState.error}`} onRetry={() => setSelectedChat({ ...selectedChat })} />}{!messagesState.error && messagesState.notice && <ReadNotice text={messagesState.notice} onRetry={() => setSelectedChat({ ...selectedChat })} />}{messagesState.loading && messages.length > 0 && <div className="refresh-hint">正在刷新,消息历史保持可用…</div>}{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 [syncInfo, setSyncInfo] = useState(null);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [syncNotice, setSyncNotice] = useState("");
|
||||
const [state, setState] = useState({ loading: false, error: "" });
|
||||
const requestRef = useRef(0);
|
||||
const load = useCallback(async (append = false) => {
|
||||
if (!clientId || !accountId) return;
|
||||
const requestId = ++requestRef.current;
|
||||
setState({ loading: true, error: "" });
|
||||
try {
|
||||
const params = new URLSearchParams({ limit: "200", offset: String(append ? contacts.length : 0), groups_only: String(groupsOnly) });
|
||||
if (query.trim()) params.set("contains", query.trim());
|
||||
const content = await api(dataPath(accountId, "contacts", `?${params.toString()}`), { token });
|
||||
if (requestId !== requestRef.current) return;
|
||||
const nextContacts = normalizeContacts(content);
|
||||
setContacts((current) => append ? [...current, ...nextContacts] : nextContacts);
|
||||
setHasMore(Boolean(content.has_more));
|
||||
setSyncInfo(content.sync || null);
|
||||
setState({ loading: false, error: "" });
|
||||
} catch (reason) {
|
||||
if (requestId === requestRef.current) setState({ loading: false, error: reason.message });
|
||||
}
|
||||
}, [accountId, clientId, contacts.length, groupsOnly, query, token]);
|
||||
const syncContacts = async () => {
|
||||
if (!clientId || !accountId || syncing) return;
|
||||
setSyncing(true);
|
||||
setSyncNotice("");
|
||||
try {
|
||||
await api(dataPath(accountId, "refresh"), { token, method: "POST", body: { stream_key: "contacts" } });
|
||||
setSyncNotice("通讯录同步任务已提交;完成后点击“刷新缓存”查看最新数据。");
|
||||
} catch (reason) {
|
||||
setState((current) => ({ ...current, error: reason.message }));
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
useEffect(() => { setContacts([]); setHasMore(false); setSyncInfo(null); setSyncNotice(""); 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>平台缓存 · {syncInfo?.state || "尚未同步"} · 最近同步 {formatTime(syncInfo?.last_success_at)}</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" onClick={syncContacts} disabled={syncing || state.loading}>{syncing ? "提交中…" : "同步通讯录"}</button><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><button className="secondary-button" onClick={() => load(true)} disabled={state.loading || !hasMore}>加载更多</button>{syncNotice && <span className="read-only-note" role="status">{syncNotice}</span>}</div>{state.loading ? <LoadingState text="正在读取通讯录…" /> : state.error ? <ReadError error={state.error} onRetry={load} /> : visible.length === 0 ? <Empty text={syncInfo?.state === "unknown" ? "平台尚未缓存通讯录,请点击“同步通讯录”。" : "暂无可见联系人或群聊。"} /> : <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 />);
|
||||
createRoot(document.getElementById("root")).render(
|
||||
<ConfigProvider
|
||||
locale={zhCN}
|
||||
csp={cspNonce && cspNonce !== "__WX_CSP_NONCE__" ? { nonce: cspNonce } : undefined}
|
||||
theme={{ token: { colorPrimary: "#1677ff", borderRadius: 8, fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" } }}
|
||||
>
|
||||
<AntApp>
|
||||
<ConsoleApp />
|
||||
</AntApp>
|
||||
</ConfigProvider>,
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
function extractItems(content, keys) {
|
||||
export function extractItems(content, keys) {
|
||||
if (Array.isArray(content)) return content;
|
||||
for (const key of keys) {
|
||||
if (Array.isArray(content?.[key])) return content[key];
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
extractItems,
|
||||
mergeStableItems,
|
||||
normalizeMessages,
|
||||
normalizeSessions,
|
||||
@@ -8,6 +9,11 @@ import {
|
||||
shouldReplaceReadState,
|
||||
} from "./readState.js";
|
||||
|
||||
test("contact API payloads expose their contact list", () => {
|
||||
const contacts = [{ contact_id: "wxid-example" }];
|
||||
assert.deepEqual(extractItems({ contacts }, ["items", "contacts"]), contacts);
|
||||
});
|
||||
|
||||
test("legacy empty reads are treated as unknown, not complete", () => {
|
||||
const coverage = resolveReadCoverage({ items: [] }, 0);
|
||||
assert.equal(coverage.state, "unknown");
|
||||
|
||||
+202
-1614
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,206 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { App as AntApp, Alert, Avatar, Button, Drawer, Form, Input, Modal, Popconfirm, Select, Space, Switch, Tag, Typography } from "antd";
|
||||
import { ProTable } from "@ant-design/pro-components";
|
||||
import { DesktopOutlined, KeyOutlined, PlusOutlined, ReloadOutlined, TeamOutlined, UserOutlined } from "@ant-design/icons";
|
||||
import { api, formatTime, shortId, statusColor, statusLabel } from "../api.js";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export function UsersView({ token, currentUser, nodes, onChanged }) {
|
||||
const { message } = AntApp.useApp();
|
||||
const [users, setUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [editing, setEditing] = useState(null);
|
||||
const [resetTarget, setResetTarget] = useState(null);
|
||||
const [form] = Form.useForm();
|
||||
const [passwordForm] = Form.useForm();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await api("/v1/users", { token });
|
||||
setUsers(result.users || []);
|
||||
} catch (reason) {
|
||||
message.error(reason.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [message, token]);
|
||||
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
const nodeNames = useMemo(() => new Map(nodes.map((node) => [node.node_id, node])), [nodes]);
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ role: "member", active: true, node_ids: [] });
|
||||
setDrawerOpen(true);
|
||||
};
|
||||
const openEdit = (user) => {
|
||||
setEditing(user);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
username: user.username,
|
||||
display_name: user.display_name,
|
||||
role: user.role,
|
||||
active: user.active,
|
||||
node_ids: user.node_ids || [],
|
||||
});
|
||||
setDrawerOpen(true);
|
||||
};
|
||||
const save = async (values) => {
|
||||
const body = {
|
||||
username: values.username,
|
||||
display_name: values.display_name,
|
||||
role: values.role,
|
||||
active: values.active,
|
||||
node_ids: values.role === "member" ? values.node_ids || [] : [],
|
||||
};
|
||||
try {
|
||||
if (editing) {
|
||||
await api(`/v1/users/${encodeURIComponent(editing.id)}`, { token, method: "PATCH", body: {
|
||||
display_name: body.display_name, role: body.role, active: body.active, node_ids: body.node_ids,
|
||||
} });
|
||||
} else {
|
||||
await api("/v1/users", { token, method: "POST", body: { ...body, password: values.password } });
|
||||
}
|
||||
message.success(editing ? "用户权限已更新" : "用户已创建");
|
||||
setDrawerOpen(false);
|
||||
await load();
|
||||
onChanged?.();
|
||||
} catch (reason) {
|
||||
message.error(reason.message);
|
||||
}
|
||||
};
|
||||
const toggleActive = async (user, active) => {
|
||||
try {
|
||||
await api(`/v1/users/${encodeURIComponent(user.id)}`, { token, method: "PATCH", body: {
|
||||
display_name: user.display_name, role: user.role, active, node_ids: user.node_ids || [],
|
||||
} });
|
||||
message.success(active ? "用户已启用" : "用户已停用,现有会话已撤销");
|
||||
await load();
|
||||
} catch (reason) {
|
||||
message.error(reason.message);
|
||||
}
|
||||
};
|
||||
const resetPassword = async (values) => {
|
||||
try {
|
||||
await api(`/v1/users/${encodeURIComponent(resetTarget.id)}/password`, { token, method: "POST", body: { password: values.password } });
|
||||
message.success("密码已重置,该用户的现有会话已撤销");
|
||||
setResetTarget(null);
|
||||
passwordForm.resetFields();
|
||||
} catch (reason) {
|
||||
message.error(reason.message);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: "用户", dataIndex: "username", key: "user", render: (_, user) => <Space><Avatar size="small" icon={<UserOutlined />}>{user.display_name?.slice(0, 1)}</Avatar><span><Text strong>{user.display_name}</Text><br /><Text type="secondary">{user.username}</Text></span></Space> },
|
||||
{ title: "角色", dataIndex: "role", width: 130, render: (role) => <Tag color={role === "admin" ? "blue" : "default"}>{role === "admin" ? "管理员" : "成员"}</Tag> },
|
||||
{ title: "可访问 Desktop Agent / Node", dataIndex: "node_ids", render: (ids, user) => user.role === "admin" ? <Text type="secondary">全部 Node</Text> : ids?.length ? <Space wrap>{ids.map((id) => <Tag key={id} color={nodeNames.has(id) ? "geekblue" : "default"}>{id}</Tag>)}</Space> : <Text type="secondary">尚未分配</Text> },
|
||||
{ title: "状态", dataIndex: "active", width: 100, render: (active) => <Tag color={active ? "success" : "default"}>{active ? "启用" : "停用"}</Tag> },
|
||||
{ title: "创建时间", dataIndex: "created_at", width: 175, render: formatTime },
|
||||
{ title: "操作", key: "actions", width: 230, valueType: "option", render: (_, user) => <Space size={4}>
|
||||
<Button type="link" size="small" onClick={() => openEdit(user)}>编辑与分配</Button>
|
||||
<Button type="link" size="small" icon={<KeyOutlined />} onClick={() => { setResetTarget(user); passwordForm.resetFields(); }}>重置密码</Button>
|
||||
<Popconfirm title={user.active ? "停用此账号?" : "重新启用此账号?"} description={user.active ? "该用户的现有会话将被撤销。" : undefined} onConfirm={() => void toggleActive(user, !user.active)} disabled={user.id === currentUser.id}>
|
||||
<Button type="link" size="small" disabled={user.id === currentUser.id}>{user.active ? "停用" : "启用"}</Button>
|
||||
</Popconfirm>
|
||||
</Space> },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="admin-page-stack">
|
||||
<Alert type="info" showIcon message="用户与 Node 权限使用真实控制面数据库" description="管理员拥有全部 Node 访问权;成员只可访问明确分配的 Desktop Agent / Node。环境变量中的旧 Web 账号仅在用户表首次初始化时导入为管理员。" />
|
||||
<ProTable
|
||||
rowKey="id"
|
||||
headerTitle={`控制面用户 · ${users.length}`}
|
||||
columns={columns}
|
||||
dataSource={users}
|
||||
loading={loading}
|
||||
search={false}
|
||||
options={false}
|
||||
scroll={{ x: "max-content", y: "max(100px, calc(100dvh - 500px))" }}
|
||||
pagination={{ pageSize: 10, showSizeChanger: true }}
|
||||
toolBarRender={() => [<Button key="reload" icon={<ReloadOutlined />} onClick={() => void load()}>刷新</Button>, <Button key="create" type="primary" icon={<PlusOutlined />} onClick={openCreate}>创建用户</Button>]}
|
||||
/>
|
||||
<Drawer title={editing ? "编辑用户与 Node 分配" : "创建控制面用户"} width={520} open={drawerOpen} onClose={() => setDrawerOpen(false)} destroyOnClose>
|
||||
<Form form={form} layout="vertical" onFinish={save} requiredMark="optional">
|
||||
<Form.Item name="username" label="用户名" rules={[{ required: true, min: 3, max: 64, pattern: /^[A-Za-z0-9._@-]+$/, message: "使用 3–64 位字母、数字或 . _ @ -" }]}>
|
||||
<Input disabled={Boolean(editing)} autoComplete="username" />
|
||||
</Form.Item>
|
||||
<Form.Item name="display_name" label="显示名称" rules={[{ required: true, max: 120 }]}><Input maxLength={120} /></Form.Item>
|
||||
{!editing && <Form.Item name="password" label="初始密码" rules={[{ required: true, min: 12, message: "密码至少 12 个字符" }]}><Input.Password autoComplete="new-password" /></Form.Item>}
|
||||
<Form.Item name="role" label="角色" rules={[{ required: true }]}>
|
||||
<Select options={[{ value: "member", label: "成员(按分配 Node 隔离)" }, { value: "admin", label: "管理员(可管理用户并访问全部 Node)" }]} disabled={editing?.id === currentUser.id} />
|
||||
</Form.Item>
|
||||
<Form.Item noStyle shouldUpdate={(previous, next) => previous.role !== next.role}>
|
||||
{({ getFieldValue }) => getFieldValue("role") === "member" ? (
|
||||
<Form.Item name="node_ids" label="分配 Desktop Agent / Node">
|
||||
<Select mode="multiple" allowClear showSearch optionFilterProp="label" placeholder="选择此成员可访问的 Node" options={nodes.map((node) => ({ value: node.node_id, label: `${node.node_id} · ${statusLabel(node.status)}` }))} />
|
||||
</Form.Item>
|
||||
) : <Alert type="info" showIcon message="管理员可访问全部 Node;管理员账号不使用成员分配列表。" />}
|
||||
</Form.Item>
|
||||
{editing && <Form.Item name="active" label="账号状态" valuePropName="checked"><Switch checkedChildren="启用" unCheckedChildren="停用" disabled={editing.id === currentUser.id} /></Form.Item>}
|
||||
<div className="drawer-footer"><Button onClick={() => setDrawerOpen(false)}>取消</Button><Button type="primary" htmlType="submit">{editing ? "保存变更" : "创建用户"}</Button></div>
|
||||
</Form>
|
||||
</Drawer>
|
||||
<Modal title={`重置密码 · ${resetTarget?.username || ""}`} open={Boolean(resetTarget)} onCancel={() => setResetTarget(null)} onOk={() => passwordForm.submit()} okText="重置并撤销会话" destroyOnClose>
|
||||
<Form form={passwordForm} layout="vertical" onFinish={resetPassword}>
|
||||
<Form.Item name="password" label="新密码" rules={[{ required: true, min: 12, message: "密码至少 12 个字符" }]}><Input.Password autoComplete="new-password" /></Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function NodesView({ token, nodes, isAdmin, onGoUsers }) {
|
||||
const { message } = AntApp.useApp();
|
||||
const [users, setUsers] = useState([]);
|
||||
const [loadingUsers, setLoadingUsers] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdmin) return;
|
||||
let current = true;
|
||||
setLoadingUsers(true);
|
||||
api("/v1/users", { token }).then((data) => { if (current) setUsers(data.users || []); }).catch((reason) => message.error(reason.message)).finally(() => { if (current) setLoadingUsers(false); });
|
||||
return () => { current = false; };
|
||||
}, [isAdmin, message, token]);
|
||||
|
||||
const assignedUsers = useMemo(() => {
|
||||
const names = new Map();
|
||||
for (const user of users) {
|
||||
if (user.role !== "member" || !user.active) continue;
|
||||
for (const nodeID of user.node_ids || []) names.set(nodeID, [...(names.get(nodeID) || []), user.display_name || user.username]);
|
||||
}
|
||||
return names;
|
||||
}, [users]);
|
||||
|
||||
const columns = [
|
||||
{ title: "Desktop Agent / Node", dataIndex: "node_id", key: "node_id", render: (id) => <Space><Avatar size="small" icon={<DesktopOutlined />} /> <Text strong>{id}</Text></Space> },
|
||||
{ title: "连接状态", dataIndex: "status", width: 130, render: (status) => <Tag color={statusColor(status)}>{statusLabel(status)}</Tag> },
|
||||
{ title: "活动账号", dataIndex: "active_account_id", render: (id) => id || <Text type="secondary">未确认</Text> },
|
||||
{ title: "最近心跳", dataIndex: "last_heartbeat_at", width: 180, render: formatTime },
|
||||
{ title: "Agent 版本", dataIndex: "agent_version", width: 150, render: (value) => value || "—" },
|
||||
{ title: "账号", dataIndex: "accounts", width: 90, render: (accounts) => accounts?.length || 0 },
|
||||
...(isAdmin ? [{ title: "已分配成员", dataIndex: "node_id", render: (nodeID) => loadingUsers ? "读取中…" : (assignedUsers.get(nodeID)?.join("、") || <Text type="secondary">未分配</Text>) }] : []),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="admin-page-stack">
|
||||
<Alert type="info" showIcon message="这里管理的是已连接的 Desktop Agent / Node" description="成员分配按 Node 生效,并同时约束该 Node 下已授权微信账号的消息、通讯录、事件与任务接口。AI Flow 不是本页面中的 Desktop Agent。" />
|
||||
<ProTable
|
||||
rowKey="node_id"
|
||||
headerTitle={`已授权节点 · ${nodes.length}`}
|
||||
columns={columns}
|
||||
dataSource={nodes}
|
||||
search={false}
|
||||
options={false}
|
||||
scroll={{ x: "max-content", y: "max(100px, calc(100dvh - 500px))" }}
|
||||
pagination={{ pageSize: 10 }}
|
||||
toolBarRender={isAdmin ? () => [<Button key="users" icon={<TeamOutlined />} onClick={onGoUsers}>管理用户分配</Button>] : false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Avatar, Badge, Button, Empty, Input, Space, Spin, Tag, Typography } from "antd";
|
||||
import { Bubble, Conversations, Sender } from "@ant-design/x";
|
||||
import { ReloadOutlined, SearchOutlined, TeamOutlined, UserOutlined } from "@ant-design/icons";
|
||||
import {
|
||||
activeAccount,
|
||||
api,
|
||||
coverageNotice,
|
||||
dataPath,
|
||||
formatTime,
|
||||
isClientAvailable,
|
||||
mergeStableItems,
|
||||
normalizeMessages,
|
||||
normalizeSessions,
|
||||
readStoredOrFallback,
|
||||
resolveReadCoverage,
|
||||
shortId,
|
||||
shouldReplaceReadState,
|
||||
statusColor,
|
||||
statusLabel,
|
||||
} from "../api.js";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
function ReadState({ loading, error, notice, emptyText, onRetry }) {
|
||||
if (loading) return <div className="workspace-state"><Spin /><Text type="secondary">正在读取…</Text></div>;
|
||||
if (error) return <div className="workspace-state"><Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={error} /><Button type="link" onClick={onRetry}>重试</Button></div>;
|
||||
if (notice) return <div className="read-coverage-note"><Text type="secondary">{notice}</Text><Button type="link" size="small" onClick={onRetry}>刷新</Button></div>;
|
||||
return <div className="workspace-state"><Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={emptyText} /></div>;
|
||||
}
|
||||
|
||||
export default 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: "", notice: "" });
|
||||
const [query, setQuery] = useState("");
|
||||
const [selectedChat, setSelectedChat] = useState(null);
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [messagesState, setMessagesState] = useState({ loading: false, error: "", notice: "" });
|
||||
const contextGenerationRef = useRef(0);
|
||||
const sessionsRequestRef = useRef(0);
|
||||
const messagesRequestRef = useRef(0);
|
||||
const refreshRequestRef = useRef(0);
|
||||
const historyRef = useRef(null);
|
||||
|
||||
const requestDataRefresh = useCallback(async () => {
|
||||
if (!accountId || !clientId) return;
|
||||
const requestId = ++refreshRequestRef.current;
|
||||
try {
|
||||
await api(dataPath(accountId, "refresh"), { token, method: "POST" });
|
||||
} catch (reason) {
|
||||
if (requestId === refreshRequestRef.current) {
|
||||
setSessionsState((current) => ({ ...current, notice: current.notice || `后台同步未受理:${reason.message}` }));
|
||||
}
|
||||
}
|
||||
}, [accountId, clientId, token]);
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
if (!clientId || !accountId) return;
|
||||
const requestId = ++sessionsRequestRef.current;
|
||||
const generation = contextGenerationRef.current;
|
||||
setSessionsState((current) => ({ ...current, loading: true, error: "" }));
|
||||
try {
|
||||
const content = await readStoredOrFallback({
|
||||
storedPath: dataPath(accountId, "conversations", "?limit=100"),
|
||||
legacyPath: "/v1/reads/sessions",
|
||||
legacyBody: { node_id: clientId, account_id: accountId, limit: 100 },
|
||||
token,
|
||||
});
|
||||
if (requestId !== sessionsRequestRef.current || generation !== contextGenerationRef.current) return;
|
||||
const next = normalizeSessions(content);
|
||||
if (content.sync && content.sync.state !== "complete") void requestDataRefresh();
|
||||
const coverage = resolveReadCoverage(content, next.length);
|
||||
setSessions((previous) => shouldReplaceReadState(coverage) ? next : mergeStableItems(previous, next));
|
||||
setSessionsState({ loading: false, error: "", notice: coverageNotice("会话", coverage) });
|
||||
} catch (reason) {
|
||||
if (requestId === sessionsRequestRef.current && generation === contextGenerationRef.current) {
|
||||
setSessionsState({ loading: false, error: reason.message, notice: "" });
|
||||
}
|
||||
}
|
||||
}, [accountId, clientId, requestDataRefresh, token]);
|
||||
|
||||
useEffect(() => {
|
||||
contextGenerationRef.current += 1;
|
||||
sessionsRequestRef.current += 1;
|
||||
messagesRequestRef.current += 1;
|
||||
setSelectedChat(null);
|
||||
setSessions([]);
|
||||
setMessages([]);
|
||||
setSessionsState({ loading: false, error: "", notice: "" });
|
||||
setMessagesState({ loading: false, error: "", notice: "" });
|
||||
if (clientId && accountId) void loadSessions();
|
||||
}, [clientId, accountId, loadSessions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedChat || selectedChat.clientId !== clientId || selectedChat.accountId !== accountId || !clientId || !accountId) return;
|
||||
const requestId = ++messagesRequestRef.current;
|
||||
const generation = contextGenerationRef.current;
|
||||
setMessagesState((current) => ({ ...current, loading: true, error: "" }));
|
||||
readStoredOrFallback({
|
||||
storedPath: dataPath(accountId, "messages", `?chat_id=${encodeURIComponent(selectedChat.id)}&limit=100`),
|
||||
legacyPath: "/v1/reads/messages",
|
||||
legacyBody: { node_id: clientId, account_id: accountId, chat_id: selectedChat.id, limit: 100, include_content: true },
|
||||
token,
|
||||
}).then((content) => {
|
||||
if (requestId !== messagesRequestRef.current || generation !== contextGenerationRef.current) return;
|
||||
const next = normalizeMessages(content);
|
||||
const coverage = resolveReadCoverage(content, next.length);
|
||||
setMessages((previous) => shouldReplaceReadState(coverage) ? next : mergeStableItems(previous, next));
|
||||
setMessagesState({ loading: false, error: "", notice: coverageNotice("消息", coverage) });
|
||||
}).catch((reason) => {
|
||||
if (requestId === messagesRequestRef.current && generation === contextGenerationRef.current) {
|
||||
setMessagesState({ loading: false, error: reason.message, notice: "" });
|
||||
}
|
||||
});
|
||||
}, [accountId, clientId, selectedChat, token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (historyRef.current) historyRef.current.scrollTop = historyRef.current.scrollHeight;
|
||||
}, [messages]);
|
||||
|
||||
const visibleSessions = useMemo(() => sessions.filter((session) =>
|
||||
!query || `${session.title} ${session.id} ${session.preview}`.toLowerCase().includes(query.toLowerCase())), [query, sessions]);
|
||||
const conversationItems = useMemo(() => visibleSessions.map((session, index) => ({
|
||||
key: session.id,
|
||||
group: "最近会话",
|
||||
timestamp: Date.now() - index * 60_000,
|
||||
icon: <Avatar size={34} icon={session.type === "Group" ? <TeamOutlined /> : <UserOutlined />}>{session.title?.slice(0, 1)}</Avatar>,
|
||||
label: <div className="conversation-item-label"><div className="conversation-item-title"><Text ellipsis>{session.title}</Text>{session.unread > 0 && <Badge count={session.unread} size="small" />}</div><Text type="secondary" ellipsis>{session.preview || "暂无最近消息"}</Text></div>,
|
||||
})), [visibleSessions]);
|
||||
|
||||
if (!client) {
|
||||
return <Empty className="page-empty" image={Empty.PRESENTED_IMAGE_SIMPLE} description="当前账号尚未分配可访问的 Desktop Agent / Node。" />;
|
||||
}
|
||||
|
||||
const activeSession = sessions.find((session) => session.id === selectedChat?.id);
|
||||
const selectConversation = (key) => {
|
||||
const session = sessions.find((item) => item.id === key);
|
||||
if (session) setSelectedChat({ ...session, clientId, accountId });
|
||||
};
|
||||
const retryMessages = () => selectedChat && setSelectedChat({ ...selectedChat });
|
||||
|
||||
return (
|
||||
<div className="messages-layout">
|
||||
<section className="conversation-rail">
|
||||
<div className="conversation-rail-header">
|
||||
<div><Text strong>会话</Text><Text type="secondary">{sessions.length} 个</Text></div>
|
||||
<Button type="text" icon={<ReloadOutlined />} aria-label="刷新会话" loading={sessionsState.loading} onClick={() => void loadSessions()} />
|
||||
</div>
|
||||
<Input allowClear prefix={<SearchOutlined />} value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索会话" />
|
||||
{sessionsState.loading && sessions.length === 0 ? <ReadState loading /> : sessionsState.error && sessions.length === 0 ? <ReadState error={sessionsState.error} onRetry={loadSessions} /> : (
|
||||
<>
|
||||
{sessionsState.error && <div className="compact-alert"><Text type="danger">读取失败,保留已有数据:{sessionsState.error}</Text><Button type="link" size="small" onClick={() => void loadSessions()}>重试</Button></div>}
|
||||
{!sessionsState.error && sessionsState.notice && <div className="compact-alert"><Text type="secondary">{sessionsState.notice}</Text></div>}
|
||||
{sessionsState.loading && sessions.length > 0 && <Text className="refresh-caption" type="secondary">正在刷新,会话列表保持可用…</Text>}
|
||||
{visibleSessions.length === 0 ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无可见会话" /> : (
|
||||
<Conversations items={conversationItems} activeKey={selectedChat?.id} onActiveChange={selectConversation} className="conversation-list" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="conversation-main">
|
||||
{!selectedChat ? (
|
||||
<div className="conversation-placeholder"><Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="选择一个会话,查看已授权的消息历史" /></div>
|
||||
) : (
|
||||
<>
|
||||
<div className="chat-header">
|
||||
<Avatar size={40} icon={activeSession?.type === "Group" ? <TeamOutlined /> : <UserOutlined />}>{activeSession?.title?.slice(0, 1)}</Avatar>
|
||||
<div className="chat-heading"><Text strong>{activeSession?.title}</Text><Text type="secondary">{activeSession?.type === "Group" ? "群聊" : "私聊"} · {shortId(selectedChat.id, 28)}</Text></div>
|
||||
<Tag color={statusColor(client.status)}>{statusLabel(client.status)}</Tag>
|
||||
</div>
|
||||
{messagesState.loading && messages.length === 0 ? <ReadState loading /> : messagesState.error && messages.length === 0 ? <ReadState error={messagesState.error} onRetry={retryMessages} /> : (
|
||||
<>
|
||||
{messagesState.error && <div className="compact-alert"><Text type="danger">读取失败,保留已有数据:{messagesState.error}</Text><Button type="link" size="small" onClick={retryMessages}>重试</Button></div>}
|
||||
{!messagesState.error && messagesState.notice && <div className="compact-alert"><Text type="secondary">{messagesState.notice}</Text></div>}
|
||||
{messagesState.loading && messages.length > 0 && <Text className="refresh-caption" type="secondary">正在刷新,消息历史保持可用…</Text>}
|
||||
{messages.length === 0 ? <div className="conversation-placeholder"><Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="这个会话暂时没有可显示的消息" /></div> : (
|
||||
<div className="chat-history" ref={historyRef}>
|
||||
{messages.map((item) => {
|
||||
const sender = item.sender || "未知";
|
||||
const fromCurrentUser = item.is_self === true || item.direction === "outgoing" || sender === "我";
|
||||
return <Bubble key={item.id} placement={fromCurrentUser ? "end" : "start"} avatar={<Avatar size={30}>{sender.slice(0, 1)}</Avatar>} header={<Space size={8}><Text strong>{sender}</Text><Text type="secondary">{formatTime(item.at)}</Text></Space>} content={item.text || "(无正文)"} />;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="composer-notice"><Text type="secondary">发送功能尚未开放,需完成 Windows 真机验收。</Text></div>
|
||||
<Sender disabled placeholder="发送消息暂未开放" />
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { App as AntApp, Alert, Button, Card, Empty, Input, Select, Space, Statistic, Table, Tabs, Tag, Typography } from "antd";
|
||||
import { ProCard, ProTable } from "@ant-design/pro-components";
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
DesktopOutlined,
|
||||
MessageOutlined,
|
||||
ReloadOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
SearchOutlined,
|
||||
TeamOutlined,
|
||||
UnorderedListOutlined,
|
||||
WarningOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { api, activeAccount, dataPath, formatTime, isClientAvailable, normalizeContacts, shortId, statusColor, statusLabel } from "../api.js";
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
export function OverviewView({ nodes, tasks, events, onNavigate, onRefresh, refreshing }) {
|
||||
const online = nodes.filter(isClientAvailable).length;
|
||||
const pending = tasks.filter((task) => ["Pending", "WaitingForClient", "Accepted", "Running"].includes(task.status)).length;
|
||||
const failed = tasks.filter((task) => ["Failed", "ResultUnconfirmed"].includes(task.status)).length;
|
||||
const recent = [...events].sort((a, b) => new Date(b.received_at || 0) - new Date(a.received_at || 0)).slice(0, 5);
|
||||
return (
|
||||
<div className="overview-stack">
|
||||
<div className="overview-welcome">
|
||||
<div><Title level={3}>实时运行状态</Title><Text type="secondary">真实连接状态 · 按用户分配范围隔离 · 管理操作留有审计记录</Text></div>
|
||||
<Button icon={<ReloadOutlined />} loading={refreshing} onClick={onRefresh}>刷新概览</Button>
|
||||
</div>
|
||||
<div className="overview-metric-grid">
|
||||
<Card bordered><Statistic title="可用 Desktop Agent" value={online} suffix={`/ ${nodes.length}`} prefix={<DesktopOutlined />} /></Card>
|
||||
<Card bordered><Statistic title="当前待处理任务" value={pending} prefix={<UnorderedListOutlined />} /></Card>
|
||||
<Card bordered><Statistic title="待核对 / 失败" value={failed} prefix={<WarningOutlined />} valueStyle={{ color: failed ? "#cf1322" : undefined }} /></Card>
|
||||
<Card bordered><Statistic title="近期授权事件" value={events.length} prefix={<MessageOutlined />} /></Card>
|
||||
</div>
|
||||
<div className="overview-primary-grid">
|
||||
<ProCard title="Desktop Agent 连接" className="overview-agent-card" bordered extra={<Button type="link" onClick={() => onNavigate("agents")}>查看全部</Button>}>
|
||||
{nodes.length === 0 ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有可访问的 Desktop Agent / Node" /> : (
|
||||
<div className="overview-node-list">{nodes.slice(0, 6).map((node) => <div key={node.node_id} className="overview-node-row"><span className="node-mark"><DesktopOutlined /></span><span className="overview-node-copy"><Text strong>{node.node_id}</Text><Text type="secondary">{node.active_account_id || "尚未确认活动账号"}</Text></span><Tag color={statusColor(node.status)}>{statusLabel(node.status)}</Tag></div>)}</div>
|
||||
)}
|
||||
</ProCard>
|
||||
<ProCard title="快速入口" className="overview-shortcuts-card" bordered>
|
||||
<div className="quick-entry-grid">
|
||||
<Button icon={<MessageOutlined />} onClick={() => onNavigate("messages")}>消息工作台</Button>
|
||||
<Button icon={<TeamOutlined />} onClick={() => onNavigate("agents")}>Desktop Agent</Button>
|
||||
<Button icon={<UnorderedListOutlined />} onClick={() => onNavigate("tasks")}>任务中心</Button>
|
||||
<Button icon={<SafetyCertificateOutlined />} onClick={() => onNavigate("contacts")}>通讯录</Button>
|
||||
</div>
|
||||
<Alert className="overview-boundary" type="info" showIcon message="权限由服务端强制执行" description="界面筛选只用于展示;节点列表、任务、事件和账号数据 API 都按用户分配范围校验。" />
|
||||
</ProCard>
|
||||
</div>
|
||||
<ProCard title="最近授权事件" bordered extra={<Text type="secondary">最多展示 5 条</Text>}>
|
||||
{recent.length ? <div className="event-preview-list">{recent.map((event) => <div key={event.event_id} className="event-preview-row"><span className="event-dot" /><div><Text strong>{event.chat_id || event.event_type || "授权事件"}</Text><Text type="secondary">{event.node_id} · {event.chat_type === "Group" ? "群聊" : "私聊"}</Text></div><Text type="secondary">{formatTime(event.received_at)}</Text></div>)}</div> : <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无事件" />}
|
||||
</ProCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContactsView({ token, client }) {
|
||||
const { message } = AntApp.useApp();
|
||||
const clientId = client?.node_id || "";
|
||||
const accountId = activeAccount(client);
|
||||
const [groupsOnly, setGroupsOnly] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [contacts, setContacts] = useState([]);
|
||||
const [syncInfo, setSyncInfo] = useState(null);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [state, setState] = useState({ loading: false, error: "" });
|
||||
const [requestVersion, setRequestVersion] = useState(0);
|
||||
|
||||
const load = useCallback(async (append = false) => {
|
||||
if (!clientId || !accountId) return;
|
||||
setState({ loading: true, error: "" });
|
||||
try {
|
||||
const params = new URLSearchParams({ limit: "200", offset: String(append ? contacts.length : 0), groups_only: String(groupsOnly) });
|
||||
if (query.trim()) params.set("contains", query.trim());
|
||||
const content = await api(dataPath(accountId, "contacts", `?${params.toString()}`), { token });
|
||||
const next = normalizeContacts(content);
|
||||
setContacts((current) => append ? [...current, ...next] : next);
|
||||
setHasMore(Boolean(content.has_more));
|
||||
setSyncInfo(content.sync || null);
|
||||
setState({ loading: false, error: "" });
|
||||
} catch (reason) {
|
||||
setState({ loading: false, error: reason.message });
|
||||
}
|
||||
}, [accountId, clientId, contacts.length, groupsOnly, query, token]);
|
||||
|
||||
useEffect(() => {
|
||||
setContacts([]);
|
||||
setHasMore(false);
|
||||
setSyncInfo(null);
|
||||
if (clientId && accountId) void load();
|
||||
}, [clientId, accountId, groupsOnly, requestVersion]);
|
||||
|
||||
const syncContacts = async () => {
|
||||
if (!clientId || !accountId || syncing) return;
|
||||
setSyncing(true);
|
||||
try {
|
||||
await api(dataPath(accountId, "refresh"), { token, method: "POST", body: { stream_key: "contacts" } });
|
||||
message.success("通讯录同步任务已提交;完成后刷新缓存查看最新数据。");
|
||||
} catch (reason) {
|
||||
message.error(reason.message);
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const visible = contacts.filter((contact) => !query || `${contact.title} ${contact.id} ${contact.detail}`.toLowerCase().includes(query.toLowerCase()));
|
||||
const columns = [
|
||||
{ title: "联系人 / 群聊", dataIndex: "title", render: (_, contact) => <Space><span className="contact-avatar">{contact.title?.slice(0, 1)}</span><span><Text strong>{contact.title}</Text><br /><Text type="secondary">{contact.type === "Group" ? "群聊" : "联系人"}</Text></span></Space> },
|
||||
{ title: "会话 ID", dataIndex: "id", render: (id) => <Text code>{shortId(id, 32)}</Text> },
|
||||
{ title: "备注 / 账号", dataIndex: "detail", render: (value) => value || "—" },
|
||||
];
|
||||
if (!client) return <Empty className="page-empty" image={Empty.PRESENTED_IMAGE_SIMPLE} description="当前没有可访问的 Desktop Agent / Node" />;
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<ProCard bordered title="通讯录缓存" subTitle={`${clientId} · ${accountId || "未确认活动账号"}`} extra={<Tag color={syncInfo?.state === "complete" ? "success" : "default"}>{syncInfo?.state || "尚未同步"}</Tag>}>
|
||||
<div className="table-toolbar">
|
||||
<Input.Search allowClear value={query} onChange={(event) => setQuery(event.target.value)} onSearch={() => void load()} placeholder="搜索联系人或群聊" style={{ maxWidth: 360 }} />
|
||||
<Space wrap>
|
||||
<Select value={groupsOnly} onChange={setGroupsOnly} options={[{ value: false, label: "全部联系人" }, { value: true, label: "只看群聊" }]} style={{ width: 145 }} />
|
||||
<Button onClick={syncContacts} loading={syncing}>同步通讯录</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => setRequestVersion((value) => value + 1)} loading={state.loading}>刷新缓存</Button>
|
||||
<Button disabled title="写操作尚未通过 Windows 真机验收">添加好友</Button>
|
||||
</Space>
|
||||
</div>
|
||||
{state.error && <Alert type="error" showIcon message={state.error} action={<Button type="link" onClick={() => void load()}>重试</Button>} />}
|
||||
<ProTable rowKey="id" columns={columns} dataSource={visible} loading={state.loading} search={false} options={false} scroll={{ x: "max-content", y: "max(100px, calc(100dvh - 520px))" }} pagination={false} toolBarRender={false} />
|
||||
<div className="table-footer"><Text type="secondary">最近同步:{formatTime(syncInfo?.last_success_at)} · {syncInfo?.backlog_count == null ? "积压未知" : `积压 ${syncInfo.backlog_count}`}</Text><Button type="link" disabled={!hasMore || state.loading} onClick={() => void load(true)}>加载更多</Button></div>
|
||||
</ProCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TasksView({ tasks, selectedNodeId }) {
|
||||
const [filter, setFilter] = useState("all");
|
||||
const filtered = tasks.filter((task) => task.node_id === selectedNodeId && (filter === "all" || task.status === filter));
|
||||
const columns = [
|
||||
{ title: "任务类型", dataIndex: "kind", width: 170, render: (value) => <Text strong>{value}</Text> },
|
||||
{ title: "状态", dataIndex: "status", width: 140, render: (value) => <Tag color={statusColor(value)}>{statusLabel(value)}</Tag> },
|
||||
{ title: "账号 / Node", dataIndex: "account_id", render: (value, task) => <span>{value || "—"}<br /><Text type="secondary">{task.node_id}</Text></span> },
|
||||
{ title: "任务 ID", dataIndex: "task_id", render: (value) => <Text code>{shortId(value, 24)}</Text> },
|
||||
{ title: "执行说明", dataIndex: "result", render: (result, task) => task.status === "WaitingForClient" ? "Client 离线后等待显式恢复;不会自动重放写操作。" : result?.message || result?.error_code || "—" },
|
||||
{ title: "更新时间", dataIndex: "updated_at", width: 175, render: formatTime },
|
||||
];
|
||||
if (!selectedNodeId) return <Empty className="page-empty" image={Empty.PRESENTED_IMAGE_SIMPLE} description="请先选择一个可访问的 Desktop Agent / Node" />;
|
||||
return <div className="page-stack table-view"><ProTable rowKey="task_id" headerTitle={`任务结果 · ${filtered.length}`} columns={columns} dataSource={filtered} search={false} options={false} scroll={{ x: "max-content", y: "max(100px, calc(100dvh - 480px))" }} pagination={{ pageSize: 10 }} toolBarRender={() => [<Select key="status" value={filter} onChange={setFilter} style={{ width: 170 }} options={[{ value: "all", label: "全部状态" }, ...["Pending", "WaitingForClient", "Accepted", "Running", "Succeeded", "Failed", "ResultUnconfirmed"].map((value) => ({ value, label: statusLabel(value) }))]} />]} /></div>;
|
||||
}
|
||||
|
||||
export function BroadcastView({ client }) {
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<Alert type="warning" showIcon message="Web 群发尚未开放" description="Desktop Agent 能力不等于 Web 写操作已验收。当前版本保留入口说明,不会创建或发送群发任务。" />
|
||||
<ProCard title="受控群发验收门槛" bordered>
|
||||
<Space direction="vertical" size="middle">
|
||||
<Text>当前上下文:{client?.node_id || "未选择 Node"} · {activeAccount(client) || "未确认活动账号"}</Text>
|
||||
<Text type="secondary">冻结获准对象名单 → 预览确认 → 串行执行 → 查看逐项结果。消息发送仍需在 Windows 已登录会话完成真机验收。</Text>
|
||||
<Space wrap><Tag color="success" icon={<CheckCircleOutlined />}>范围约束</Tag><Tag color="processing">任务 API 不自动重放</Tag><Tag color="warning">等待 Web 验收</Tag></Space>
|
||||
<Button type="primary" disabled>创建群发任务</Button>
|
||||
</Space>
|
||||
</ProCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DiagnosticsView({ nodes, events, audit, selectedNodeId, onSelectNode }) {
|
||||
const visibleEvents = events.filter((event) => !selectedNodeId || event.node_id === selectedNodeId);
|
||||
const eventColumns = [
|
||||
{ title: "时间", dataIndex: "received_at", width: 180, render: formatTime },
|
||||
{ title: "Node", dataIndex: "node_id", width: 180 },
|
||||
{ title: "会话", dataIndex: "chat_id", render: (value) => shortId(value, 28) },
|
||||
{ title: "类型", dataIndex: "chat_type", width: 100, render: (value) => value === "Group" ? "群聊" : "私聊" },
|
||||
{ title: "授权内容", dataIndex: "content", ellipsis: true },
|
||||
];
|
||||
const auditColumns = [
|
||||
{ title: "时间", dataIndex: "at", width: 180, render: formatTime },
|
||||
{ title: "操作者", dataIndex: "principal", width: 170 },
|
||||
{ title: "操作", dataIndex: "action", width: 180 },
|
||||
{ title: "资源", dataIndex: "resource" },
|
||||
{ title: "结果", dataIndex: "result", width: 100 },
|
||||
];
|
||||
const nodeColumns = [
|
||||
{ title: "Node", dataIndex: "node_id" },
|
||||
{ title: "状态", dataIndex: "status", render: (value) => <Tag color={statusColor(value)}>{statusLabel(value)}</Tag> },
|
||||
{ title: "活动账号", dataIndex: "active_account_id", render: (value) => value || "—" },
|
||||
{ title: "最后心跳", dataIndex: "last_heartbeat_at", render: formatTime },
|
||||
];
|
||||
return (
|
||||
<div className="page-stack diagnostics-stack">
|
||||
<Alert type="info" showIcon message="管理员诊断视图" description="事件按可访问 Node 过滤;审计记录包含跨用户管理信息,仅对管理员开放。" />
|
||||
<Tabs className="diagnostics-tabs" items={[
|
||||
{ key: "nodes", label: `Node 状态 · ${nodes.length}`, children: <div className="diagnostics-table"><Table size="small" rowKey="node_id" pagination={false} dataSource={nodes} columns={nodeColumns} scroll={{ x: "max-content", y: "max(100px, calc(100dvh - 460px))" }} /></div> },
|
||||
{ key: "events", label: `授权事件 · ${visibleEvents.length}`, children: <div className="diagnostics-table"><ProTable rowKey="event_id" columns={eventColumns} dataSource={visibleEvents} search={false} options={false} scroll={{ x: "max-content", y: "max(100px, calc(100dvh - 460px))" }} pagination={{ pageSize: 8 }} toolBarRender={false} /></div> },
|
||||
{ key: "audit", label: `操作审计 · ${audit.length}`, children: <div className="diagnostics-table"><ProTable rowKey="id" columns={auditColumns} dataSource={audit} search={false} options={false} scroll={{ x: "max-content", y: "max(100px, calc(100dvh - 460px))" }} pagination={{ pageSize: 8 }} toolBarRender={false} /></div> },
|
||||
]} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user