Files
wx-win-agent/control-plane/web/src/views/OperationsViews.jsx
T
rogee 8760fa49f0
Build web service image / build (push) Successful in 2m7s
feat: add control-plane identity and management console
2026-09-27 20:27:31 +08:00

201 lines
14 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, 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>
);
}