201 lines
14 KiB
React
201 lines
14 KiB
React
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>
|
||
);
|
||
}
|