fix(web): isolate UI reads and improve mobile messages
Build web service image / build (push) Successful in 1m29s
Build web service image / build (push) Successful in 1m29s
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
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
Vendored
+24
-24
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+8
-8
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -7,8 +7,8 @@
|
||||
<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">
|
||||
<script type="module" crossorigin src="/assets/index-CSGym-Bq.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-B7oeh8yt.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -67,6 +67,14 @@ export function mergeStableItems(previous, next, keyOf = (item) => item.id) {
|
||||
return result;
|
||||
}
|
||||
|
||||
export function mergeScopedItems(previous, previousScope, next, scope, replace) {
|
||||
return replace || previousScope !== scope ? next : mergeStableItems(previous, next);
|
||||
}
|
||||
|
||||
export function isCurrentScopedRequest(requestId, latestRequestId, requestScope, activeScope) {
|
||||
return requestId === latestRequestId && requestScope === activeScope;
|
||||
}
|
||||
|
||||
export function shouldReplaceReadState(coverage) {
|
||||
return coverage?.state === "complete";
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
extractItems,
|
||||
isCurrentScopedRequest,
|
||||
mergeScopedItems,
|
||||
mergeStableItems,
|
||||
normalizeMessages,
|
||||
normalizeSessions,
|
||||
@@ -41,6 +43,23 @@ test("partial reads preserve old rows and add newly observed rows", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("partial message reads never merge rows across chat contexts", () => {
|
||||
assert.deepEqual(
|
||||
mergeScopedItems([{ id: "old-chat" }], "node-1/account-1/chat-1", [{ id: "new-chat" }], "node-1/account-1/chat-2", false),
|
||||
[{ id: "new-chat" }],
|
||||
);
|
||||
assert.deepEqual(
|
||||
mergeScopedItems([{ id: "old" }], "same-chat", [{ id: "new" }], "same-chat", false),
|
||||
[{ id: "new" }, { id: "old" }],
|
||||
);
|
||||
});
|
||||
|
||||
test("requests from an old search or context cannot commit", () => {
|
||||
assert.equal(isCurrentScopedRequest(4, 5, "same-query", "same-query"), false);
|
||||
assert.equal(isCurrentScopedRequest(5, 5, "old-node", "new-node"), false);
|
||||
assert.equal(isCurrentScopedRequest(5, 5, "current-query", "current-query"), true);
|
||||
});
|
||||
|
||||
test("platform cache rows retain title and source timestamps", () => {
|
||||
assert.deepEqual(normalizeSessions({
|
||||
items: [{ chat_id: "filehelper", title: "文件传输助手", unread_count: 2, chat_type: "Private" }],
|
||||
|
||||
@@ -99,6 +99,7 @@ button, input { font: inherit; }
|
||||
.conversation-item-title .ant-typography { max-width: 190px; }
|
||||
.conversation-main { display: flex; min-width: 0; min-height: 0; flex-direction: column; background: #fff; }
|
||||
.chat-header { display: flex; align-items: center; gap: 12px; min-height: 72px; padding: 12px 20px; border-bottom: 1px solid var(--wx-border); }
|
||||
.chat-back-button { display: none; }
|
||||
.chat-heading { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 4px; }
|
||||
.chat-heading .ant-typography { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.chat-history { display: flex; min-height: 0; flex: 1; flex-direction: column; gap: 16px; padding: 22px clamp(16px, 4vw, 42px); overflow-y: auto; overscroll-behavior: contain; background: linear-gradient(#fbfcff, #fff); }
|
||||
@@ -144,11 +145,14 @@ button, input { font: inherit; }
|
||||
.header-actions .ant-tag { display: none; }
|
||||
.header-actions .ant-btn { padding-inline: 6px; }
|
||||
.header-actions .ant-select { width: 150px !important; }
|
||||
.messages-layout { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(120px, 30%) minmax(0, 1fr); }
|
||||
.messages-layout { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr); }
|
||||
.overview-primary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.conversation-rail { min-height: 0; max-height: none; overflow: hidden; border-right: 0; border-bottom: 1px solid var(--wx-border); }
|
||||
.conversation-rail { min-height: 0; max-height: none; overflow: hidden; border-right: 0; border-bottom: 0; }
|
||||
.conversation-list { max-height: none; }
|
||||
.conversation-main { min-height: 0; overflow: hidden; }
|
||||
.messages-layout:not(.messages-layout--chat-open) .conversation-main { display: none; }
|
||||
.messages-layout--chat-open .conversation-rail { display: none; }
|
||||
.chat-back-button { display: inline-flex; flex: 0 0 auto; margin-left: -8px; }
|
||||
.chat-history > * { max-width: 96%; }
|
||||
.overview-welcome { align-items: center; }
|
||||
}
|
||||
@@ -159,6 +163,8 @@ button, input { font: inherit; }
|
||||
.header-actions .ant-select:not(.ant-select-disabled) { width: 110px !important; }
|
||||
.login-shell { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr); }
|
||||
.login-intro-panel { min-height: 0; padding: 16px 20px 8px; }
|
||||
.chat-header { gap: 8px; padding-inline: 12px; }
|
||||
.chat-header > .ant-tag { display: none; }
|
||||
.login-intro-panel::after, .login-capabilities, .login-legal { display: none; }
|
||||
.login-intro-copy { margin: 14px 0 0; }
|
||||
.login-intro-panel .login-intro-copy > h1.ant-typography { margin: 10px 0 4px; font-size: 27px; line-height: 1.1; }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { Bubble, Conversations } from "@ant-design/x";
|
||||
import { LeftOutlined, ReloadOutlined, SearchOutlined, TeamOutlined, UserOutlined } from "@ant-design/icons";
|
||||
import {
|
||||
activeAccount,
|
||||
api,
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
dataPath,
|
||||
formatTime,
|
||||
isClientAvailable,
|
||||
mergeStableItems,
|
||||
normalizeMessages,
|
||||
normalizeSessions,
|
||||
readStoredOrFallback,
|
||||
@@ -19,8 +18,10 @@ import {
|
||||
statusColor,
|
||||
statusLabel,
|
||||
} from "../api.js";
|
||||
import { mergeScopedItems, mergeStableItems } from "../readState.js";
|
||||
|
||||
const { Text } = Typography;
|
||||
const chatContextKey = (chat) => JSON.stringify([chat.clientId, chat.accountId, chat.id]);
|
||||
|
||||
function ReadState({ loading, error, notice, emptyText, onRetry }) {
|
||||
if (loading) return <div className="workspace-state"><Spin /><Text type="secondary">正在读取…</Text></div>;
|
||||
@@ -36,8 +37,8 @@ export default function MessagesView({ token, client, onSettings }) {
|
||||
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 [messageData, setMessageData] = useState({ key: "", items: [] });
|
||||
const [messagesState, setMessagesState] = useState({ key: "", loading: false, error: "", notice: "" });
|
||||
const contextGenerationRef = useRef(0);
|
||||
const sessionsRequestRef = useRef(0);
|
||||
const messagesRequestRef = useRef(0);
|
||||
@@ -87,9 +88,9 @@ export default function MessagesView({ token, client, onSettings }) {
|
||||
messagesRequestRef.current += 1;
|
||||
setSelectedChat(null);
|
||||
setSessions([]);
|
||||
setMessages([]);
|
||||
setMessageData({ key: "", items: [] });
|
||||
setSessionsState({ loading: false, error: "", notice: "" });
|
||||
setMessagesState({ loading: false, error: "", notice: "" });
|
||||
setMessagesState({ key: "", loading: false, error: "", notice: "" });
|
||||
if (clientId && accountId) void loadSessions();
|
||||
}, [clientId, accountId, loadSessions]);
|
||||
|
||||
@@ -97,7 +98,8 @@ export default function MessagesView({ token, client, onSettings }) {
|
||||
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: "" }));
|
||||
const requestChatKey = chatContextKey(selectedChat);
|
||||
setMessagesState({ key: requestChatKey, loading: true, error: "", notice: "" });
|
||||
readStoredOrFallback({
|
||||
storedPath: dataPath(accountId, "messages", `?chat_id=${encodeURIComponent(selectedChat.id)}&limit=100`),
|
||||
legacyPath: "/v1/reads/messages",
|
||||
@@ -107,21 +109,31 @@ export default function MessagesView({ token, client, onSettings }) {
|
||||
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) });
|
||||
setMessageData((previous) => ({
|
||||
key: requestChatKey,
|
||||
items: mergeScopedItems(previous.items, previous.key, next, requestChatKey, shouldReplaceReadState(coverage)),
|
||||
}));
|
||||
setMessagesState({ key: requestChatKey, loading: false, error: "", notice: coverageNotice("消息", coverage) });
|
||||
}).catch((reason) => {
|
||||
if (requestId === messagesRequestRef.current && generation === contextGenerationRef.current) {
|
||||
setMessagesState({ loading: false, error: reason.message, notice: "" });
|
||||
setMessagesState({ key: requestChatKey, loading: false, error: reason.message, notice: "" });
|
||||
}
|
||||
});
|
||||
}, [accountId, clientId, selectedChat, token]);
|
||||
|
||||
const visibleSessions = useMemo(() => sessions.filter((session) =>
|
||||
!query || `${session.title} ${session.id} ${session.preview}`.toLowerCase().includes(query.toLowerCase())), [query, sessions]);
|
||||
const currentChat = selectedChat?.clientId === clientId && selectedChat?.accountId === accountId ? selectedChat : null;
|
||||
const currentChatKey = currentChat ? chatContextKey(currentChat) : "";
|
||||
const messages = messageData.key === currentChatKey ? messageData.items : [];
|
||||
const currentMessagesState = messagesState.key === currentChatKey
|
||||
? messagesState
|
||||
: { loading: Boolean(currentChatKey), error: "", notice: "" };
|
||||
|
||||
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: "最近会话",
|
||||
@@ -137,12 +149,22 @@ export default function MessagesView({ token, client, onSettings }) {
|
||||
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 });
|
||||
if (!session) return;
|
||||
const nextChat = { ...session, clientId, accountId };
|
||||
messagesRequestRef.current += 1;
|
||||
setMessageData({ key: chatContextKey(nextChat), items: [] });
|
||||
setMessagesState({ key: chatContextKey(nextChat), loading: true, error: "", notice: "" });
|
||||
setSelectedChat(nextChat);
|
||||
};
|
||||
const retryMessages = () => {
|
||||
if (!currentChat) return;
|
||||
messagesRequestRef.current += 1;
|
||||
setMessagesState({ key: currentChatKey, loading: true, error: "", notice: "" });
|
||||
setSelectedChat({ ...currentChat });
|
||||
};
|
||||
const retryMessages = () => selectedChat && setSelectedChat({ ...selectedChat });
|
||||
|
||||
return (
|
||||
<div className="messages-layout">
|
||||
<div className={`messages-layout${currentChat ? " messages-layout--chat-open" : ""}`}>
|
||||
<section className="conversation-rail">
|
||||
<div className="conversation-rail-header">
|
||||
<div><Text strong>会话</Text><Text type="secondary">{sessions.length} 个</Text></div>
|
||||
@@ -155,27 +177,28 @@ export default function MessagesView({ token, client, onSettings }) {
|
||||
{!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" />
|
||||
<Conversations items={conversationItems} activeKey={currentChat?.id} onActiveChange={selectConversation} className="conversation-list" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="conversation-main">
|
||||
{!selectedChat ? (
|
||||
{!currentChat ? (
|
||||
<div className="conversation-placeholder"><Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="选择一个会话,查看已授权的消息历史" /></div>
|
||||
) : (
|
||||
<>
|
||||
<div className="chat-header">
|
||||
<Button className="chat-back-button" type="text" icon={<LeftOutlined />} aria-label="返回会话列表" title="返回会话列表" onClick={() => setSelectedChat(null)} />
|
||||
<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} /> : (
|
||||
{currentMessagesState.loading && messages.length === 0 ? <ReadState loading /> : currentMessagesState.error && messages.length === 0 ? <ReadState error={currentMessagesState.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>}
|
||||
{currentMessagesState.error && <div className="compact-alert"><Text type="danger">读取失败,保留已有数据:{currentMessagesState.error}</Text><Button type="link" size="small" onClick={retryMessages}>重试</Button></div>}
|
||||
{!currentMessagesState.error && currentMessagesState.notice && <div className="compact-alert"><Text type="secondary">{currentMessagesState.notice}</Text></div>}
|
||||
{currentMessagesState.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) => {
|
||||
@@ -187,8 +210,7 @@ export default function MessagesView({ token, client, onSettings }) {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="composer-notice"><Text type="secondary">发送功能尚未开放,需完成 Windows 真机验收。</Text></div>
|
||||
<Sender disabled placeholder="发送消息暂未开放" />
|
||||
<div className="composer-notice" role="note"><Text type="secondary">当前仅支持查看消息;发送功能尚未通过 Windows 真机验收。</Text></div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, 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 {
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
WarningOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { api, activeAccount, dataPath, formatTime, isClientAvailable, normalizeContacts, shortId, statusColor, statusLabel } from "../api.js";
|
||||
import { isCurrentScopedRequest } from "../readState.js";
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
@@ -31,7 +32,7 @@ export function OverviewView({ nodes, tasks, events, onNavigate, onRefresh, refr
|
||||
<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>
|
||||
<Card bordered><Statistic title="近期授权事件(最多 200 条)" 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>}>
|
||||
@@ -62,36 +63,68 @@ export function ContactsView({ token, client }) {
|
||||
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 [contactData, setContactData] = useState({ scope: "", query: "", items: [], hasMore: false, syncInfo: null });
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [state, setState] = useState({ loading: false, error: "" });
|
||||
const [requestVersion, setRequestVersion] = useState(0);
|
||||
const [loadState, setLoadState] = useState({ scope: "", loading: false, error: "" });
|
||||
const contactsRequestRef = useRef(0);
|
||||
const contactDataRef = useRef(contactData);
|
||||
contactDataRef.current = contactData;
|
||||
const contextScope = JSON.stringify([clientId, accountId, groupsOnly]);
|
||||
const requestScope = JSON.stringify([contextScope, query.trim()]);
|
||||
const requestScopeRef = useRef(requestScope);
|
||||
requestScopeRef.current = requestScope;
|
||||
const requestContextRef = useRef(null);
|
||||
requestContextRef.current = { clientId, accountId, groupsOnly, query, token, contextScope, requestScope };
|
||||
const currentContactData = contactData.scope === contextScope
|
||||
? contactData
|
||||
: { scope: contextScope, query: "", items: [], hasMore: false, syncInfo: null };
|
||||
const contacts = currentContactData.items;
|
||||
const syncInfo = currentContactData.syncInfo;
|
||||
const hasMore = currentContactData.query === query.trim() && currentContactData.hasMore;
|
||||
const currentState = loadState.scope === requestScope ? loadState : { loading: false, error: "" };
|
||||
const loadContextScope = JSON.stringify([clientId, accountId, groupsOnly, token]);
|
||||
const previousLoadContextRef = useRef(loadContextScope);
|
||||
|
||||
const load = useCallback(async (append = false) => {
|
||||
if (!clientId || !accountId) return;
|
||||
setState({ loading: true, error: "" });
|
||||
const context = requestContextRef.current;
|
||||
if (!context?.clientId || !context.accountId) return;
|
||||
const { accountId, groupsOnly, query, token, contextScope, requestScope } = context;
|
||||
const requestId = ++contactsRequestRef.current;
|
||||
const queryValue = query.trim();
|
||||
setLoadState({ scope: requestScope, loading: true, error: "" });
|
||||
const cached = contactDataRef.current;
|
||||
const offset = append && cached.scope === contextScope && cached.query === queryValue ? cached.items.length : 0;
|
||||
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 params = new URLSearchParams({ limit: "200", offset: String(offset), groups_only: String(groupsOnly) });
|
||||
if (queryValue) params.set("contains", queryValue);
|
||||
const content = await api(dataPath(accountId, "contacts", `?${params.toString()}`), { token });
|
||||
if (!isCurrentScopedRequest(requestId, contactsRequestRef.current, requestScope, requestScopeRef.current)) return;
|
||||
const next = normalizeContacts(content);
|
||||
setContacts((current) => append ? [...current, ...next] : next);
|
||||
setHasMore(Boolean(content.has_more));
|
||||
setSyncInfo(content.sync || null);
|
||||
setState({ loading: false, error: "" });
|
||||
setContactData((previous) => {
|
||||
const appendToCurrent = append && previous.scope === contextScope && previous.query === queryValue;
|
||||
return {
|
||||
scope: contextScope,
|
||||
query: queryValue,
|
||||
items: appendToCurrent ? [...previous.items, ...next] : next,
|
||||
hasMore: Boolean(content.has_more),
|
||||
syncInfo: content.sync || null,
|
||||
};
|
||||
});
|
||||
setLoadState({ scope: requestScope, loading: false, error: "" });
|
||||
} catch (reason) {
|
||||
setState({ loading: false, error: reason.message });
|
||||
if (isCurrentScopedRequest(requestId, contactsRequestRef.current, requestScope, requestScopeRef.current)) {
|
||||
setLoadState({ scope: requestScope, loading: false, error: reason.message });
|
||||
}
|
||||
}
|
||||
}, [accountId, clientId, contacts.length, groupsOnly, query, token]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setContacts([]);
|
||||
setHasMore(false);
|
||||
setSyncInfo(null);
|
||||
const contextChanged = previousLoadContextRef.current !== loadContextScope;
|
||||
previousLoadContextRef.current = loadContextScope;
|
||||
if (!contextChanged && query.trim()) return;
|
||||
contactsRequestRef.current += 1;
|
||||
if (clientId && accountId) void load();
|
||||
}, [clientId, accountId, groupsOnly, requestVersion]);
|
||||
}, [loadContextScope, query, clientId, accountId, load]);
|
||||
|
||||
const syncContacts = async () => {
|
||||
if (!clientId || !accountId || syncing) return;
|
||||
@@ -118,17 +151,17 @@ export function ContactsView({ token, client }) {
|
||||
<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 }} />
|
||||
<Input.Search allowClear value={query} onChange={(event) => { contactsRequestRef.current += 1; 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 }} />
|
||||
<Select value={groupsOnly} onChange={(value) => { contactsRequestRef.current += 1; setGroupsOnly(value); }} 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 icon={<ReloadOutlined />} onClick={() => void load()} loading={currentState.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>
|
||||
{currentState.error && <Alert type="error" showIcon message={currentState.error} action={<Button type="link" onClick={() => void load()}>重试</Button>} />}
|
||||
<ProTable rowKey="id" columns={columns} dataSource={visible} loading={currentState.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 || currentState.loading} onClick={() => void load(true)}>加载更多</Button></div>
|
||||
</ProCard>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user