fix(messages): dismiss sync warnings and fill available page height

This commit is contained in:
2026-10-07 12:24:38 +08:00
parent 142c176423
commit a5c23d8081
2 changed files with 128 additions and 22 deletions
+39 -9
View File
@@ -1,7 +1,7 @@
import { Bubble, Conversations, Sender } from '@ant-design/x';
import { Alert, App, Button, Card, Empty, Flex, Form, Input, Modal, Select, Spin, Splitter, Tooltip, Typography } from 'antd';
import { CheckCircleOutlined, CloseCircleOutlined, LoadingOutlined, PlusOutlined, QuestionCircleOutlined } from '@ant-design/icons';
import { useEffect, useRef, useState } from 'react';
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { creatorCreate, creatorGet } from '@/services/api';
import { conflictMessage, dateTime } from '@/utils/helpers';
import {
@@ -36,6 +36,9 @@ export default function PrivateMessagesPage() {
const [accounts, setAccounts] = useState<Account[]>([]);
const accountsRef = useRef<Account[]>([]);
const [statuses, setStatuses] = useState<SyncStatus[]>([]);
const [dismissedSyncErrors, setDismissedSyncErrors] = useState<Record<string, string>>({});
const cardRef = useRef<HTMLDivElement>(null);
const [cardHeight, setCardHeight] = useState<number>();
const [accountId, setAccountId] = useState<string>();
const [contacts, setContacts] = useState<Conversation[]>([]);
const [contactTotal, setContactTotal] = useState(0);
@@ -69,6 +72,25 @@ export default function PrivateMessagesPage() {
const [sendForm] = Form.useForm<SendValues>();
const [newChatError, setNewChatError] = useState('');
useLayoutEffect(() => {
const card = cardRef.current;
if (!card?.parentElement) return;
const parent = card.parentElement;
const resize = () => {
const bottomPadding = Number.parseFloat(getComputedStyle(parent).paddingBottom);
const top = card.getBoundingClientRect().top + window.scrollY;
setCardHeight(Math.max(0, window.innerHeight - top - bottomPadding));
};
resize();
const observer = new ResizeObserver(resize);
observer.observe(parent);
window.addEventListener('resize', resize);
return () => {
observer.disconnect();
window.removeEventListener('resize', resize);
};
}, []);
function changeDraft(key: string, text: string) {
draftsRef.current = updateDraft(draftsRef.current, key, text);
setDrafts(draftsRef.current);
@@ -128,6 +150,9 @@ export default function PrivateMessagesPage() {
accountsRef.current = accountRows;
setAccounts(accountRows);
setStatuses(statusRows);
setDismissedSyncErrors((previous) => Object.fromEntries(
Object.entries(previous).filter(([id, error]) => statusRows.some((status) => status.account_id === id && status.error === error)),
));
const pages = await Promise.all(Array.from({ length: contactPages.current }, (_, index) =>
readPage<Conversation>(`/creator/private-messages/conversations?${conversationQuery({ accountId }, index + 1, PAGE_SIZE)}`)));
if (stopped) return;
@@ -306,17 +331,22 @@ export default function PrivateMessagesPage() {
&& enabledAccounts.some((account) => account.id === contact.account_id));
return (
<Card>
<Flex vertical gap={16}>
<Flex justify="space-between" align="center" gap={16}>
<Card ref={cardRef} style={{ height: cardHeight, display: 'flex', flexDirection: 'column' }} styles={{ body: { flex: 1, minHeight: 0 } }}>
<Flex vertical gap={16} style={{ height: '100%', minHeight: 0 }}>
<Flex justify="space-between" align="center" gap={16} style={{ flexShrink: 0 }}>
<Typography.Text type="secondary">{contactTotal} 个私信会话</Typography.Text>
<Button type="primary" icon={<PlusOutlined />} disabled={!enabledAccounts.length} onClick={() => { setNewChatError(''); setSendOpen(true); }}>新建私信</Button>
</Flex>
{contactError && <Alert type="error" showIcon title="私信会话加载失败" description={contactError} />}
{statuses.filter((status) => status.error).map((status) => <Alert key={status.account_id} type="warning" showIcon title={`${accountName(status.account_name)}:私信同步异常`} description={status.error} />)}
<Splitter style={{ height: 600 }}>
{statuses.filter((status) => status.error && dismissedSyncErrors[status.account_id] !== status.error).map((status) => <Alert
key={`${status.account_id}:${status.error}`} type="warning" showIcon
title={`${accountName(status.account_name)}:私信同步异常`} description={status.error}
closable={{ onClose: () => setDismissedSyncErrors((previous) => ({ ...previous, [status.account_id]: status.error })) }}
style={{ flexShrink: 0 }}
/>)}
<Splitter style={{ flex: 1, minHeight: 0 }}>
<Splitter.Panel defaultSize="35%" min={240}>
<Flex vertical gap={12} style={{ height: '100%', padding: 16 }}>
<Flex vertical gap={12} style={{ height: '100%', minHeight: 0, padding: 16 }}>
<Select aria-label="所属账号" allowClear placeholder="全部账号" value={accountId} options={accountOptions} onChange={setAccountId} />
{contactLoading && <Spin />}
{visibleContacts.length ? <Conversations
@@ -336,7 +366,7 @@ export default function PrivateMessagesPage() {
</Flex>
</Splitter.Panel>
<Splitter.Panel min={300}>
<Flex vertical gap={12} style={{ height: '100%', padding: 16 }}>
<Flex vertical gap={12} style={{ height: '100%', minHeight: 0, padding: 16 }}>
{active ? <>
<Flex justify="space-between" align="center" gap={12}>
<Typography.Text strong>{active.peer_name?.trim() || '昵称未获取'}</Typography.Text>
@@ -373,7 +403,7 @@ export default function PrivateMessagesPage() {
})}
style={{ flex: 1, minHeight: 0 }}
/>
<Sender
<Sender style={{ flexShrink: 0 }}
value={drafts[conversationKey(active)]?.text || ''}
onChange={(value) => changeDraft(conversationKey(active), value)}
onSubmit={(value) => sendText(active, value)} submitType="enter"
+89 -13
View File
@@ -54,6 +54,15 @@ function harness(options = {}) {
const file = resolve(__dirname, '../src/pages/creator/messages/index.tsx');
const loaded = new Module(file, module);
loaded.filename = file;
function queueEffect(callback, deps) {
const index = cursor++;
const prior = effects[index];
if (!prior || deps.some((v, i) => !Object.is(v, prior.deps[i]))) {
prior?.cleanup?.();
effects[index] = { deps };
queued.push(() => { effects[index].cleanup = callback(); });
}
}
loaded.require = (id) => {
if (id === 'react') return {
useRef(initial) {
@@ -66,15 +75,8 @@ function harness(options = {}) {
if (!(index in states)) states[index] = typeof initial === 'function' ? initial() : initial;
return [states[index], (value) => { states[index] = typeof value === 'function' ? value(states[index]) : value; }];
},
useEffect(callback, deps) {
const index = cursor++;
const prior = effects[index];
if (!prior || deps.some((v, i) => !Object.is(v, prior.deps[i]))) {
prior?.cleanup?.();
effects[index] = { deps };
queued.push(() => { effects[index].cleanup = callback(); });
}
},
useEffect: queueEffect,
useLayoutEffect: queueEffect,
};
if (id === 'react/jsx-runtime') return require(id);
if (id === 'antd') return antd;
@@ -109,19 +111,25 @@ function harness(options = {}) {
};
// Timers and UUIDs are isolated per harness; polling never opens a browser.
function globals(callback) {
const prior = [globalThis.__dmTimers, globalThis.__dmCrypto, globalThis.__dmConsole];
const prior = [globalThis.__dmTimers, globalThis.__dmCrypto, globalThis.__dmConsole, globalThis.__dmDOM];
globalThis.__dmTimers = { set(fn) { const id = ++timerID; timers.set(id, fn); return id; }, clear(id) { timers.delete(id); } };
globalThis.__dmCrypto = { randomUUID: () => `request-${++uuid}` };
globalThis.__dmConsole = { error: (...args) => logs.push(args) };
globalThis.__dmDOM = options.dom;
try { return callback(); } finally {
[globalThis.__dmTimers, globalThis.__dmCrypto, globalThis.__dmConsole] = prior;
[globalThis.__dmTimers, globalThis.__dmCrypto, globalThis.__dmConsole, globalThis.__dmDOM] = prior;
}
}
// Recompile with the per-instance globals installed so asynchronous callbacks retain them.
globals(() => loaded._compile(`const setTimeout = globalThis.__dmTimers.set; const clearTimeout = globalThis.__dmTimers.clear; const crypto = globalThis.__dmCrypto; const console = globalThis.__dmConsole;\n${transpile(file)}`, file));
globals(() => loaded._compile(`const setTimeout = globalThis.__dmTimers.set; const clearTimeout = globalThis.__dmTimers.clear; const crypto = globalThis.__dmCrypto; const console = globalThis.__dmConsole; const window = globalThis.__dmDOM?.window; const ResizeObserver = globalThis.__dmDOM?.ResizeObserver; const getComputedStyle = globalThis.__dmDOM?.getComputedStyle;\n${transpile(file)}`, file));
const h = {
antd, x, calls, sendCalls, logs, notices,
render() { cursor = 0; return loaded.exports.default(); },
render() {
cursor = 0;
const tree = loaded.exports.default();
if (tree.props.ref && options.cardElement) tree.props.ref.current = options.cardElement;
return tree;
},
async flush() {
let tree;
for (let i = 0; i < 6; i++) {
@@ -209,6 +217,74 @@ test('same peer on different accounts has distinct stable keys, source names and
assert.equal(query.get('account_id'), 'b'); assert.equal(query.get('peer_uid'), '100');
});
test('sync warnings can be dismissed per account without hiding changed or recurring errors', async (t) => {
let statuses = [{ account_id: 'a', account_name: '账号甲', error: '连接失败' }, { account_id: 'b', account_name: '', error: '需要登录' }];
const h = harness({ get: (path) => path === '/creator/private-messages/status' ? statuses : undefined });
t.after(h.dispose);
const warnings = (tree) => all(tree, (node) => node.type === h.antd.Alert && node.props.title.endsWith('私信同步异常'));
let tree = await h.flush();
assert.equal(warnings(tree).length, 2);
assert.equal(typeof warnings(tree)[0].props.closable?.onClose, 'function');
warnings(tree)[0].props.closable.onClose();
tree = await h.flush();
assert.equal(warnings(tree).length, 1);
assert.match(warnings(tree)[0].props.description, /需要登录/);
tree = await h.poll();
assert.equal(warnings(tree).length, 1);
statuses = [{ ...statuses[0], error: '网关不可用' }, statuses[1]];
tree = await h.poll();
assert.equal(warnings(tree).length, 2);
assert.match(warnings(tree)[0].props.description, /网关不可用/);
warnings(tree)[0].props.closable.onClose();
tree = await h.flush();
assert.equal(warnings(tree).length, 1);
statuses = [{ ...statuses[0], error: '' }, statuses[1]];
await h.poll();
statuses = [{ ...statuses[0], error: '网关不可用' }, statuses[1]];
tree = await h.poll();
assert.equal(warnings(tree).length, 2);
assert.equal(statuses[0].error, '网关不可用');
assert.equal(h.sendCalls.length, 0);
});
test('chat Card fills available height, responds to viewport and layout changes, and cleans up observers', async (t) => {
let top = 200, callback, disconnected = false;
const listeners = new Map();
const parentElement = {};
const dom = {
window: { innerHeight: 1000, scrollY: 0, addEventListener: (name, fn) => listeners.set(name, fn), removeEventListener: (name) => listeners.delete(name) },
getComputedStyle: (element) => { assert.equal(element, parentElement); return { paddingBottom: '32px' }; },
ResizeObserver: class {
constructor(fn) { callback = fn; }
observe(element) { assert.equal(element, parentElement); }
disconnect() { disconnected = true; }
},
};
const h = harness({ dom, cardElement: { parentElement, getBoundingClientRect: () => ({ top }) } });
t.after(() => { h.dispose(); assert.equal(listeners.size, 0); assert.equal(disconnected, true); });
let tree = await h.flush();
assert.equal(tree.props.style.height, 768);
assert.equal(tree.props.styles.body.flex, 1);
assert.equal(tree.props.styles.body.minHeight, 0);
const splitter = find(tree, h.antd.Splitter);
assert.equal(splitter.props.style.flex, 1);
assert.equal(splitter.props.style.minHeight, 0);
assert.equal(splitter.props.style.height, undefined);
assert.equal(conversations(h, tree).props.style.overflowY, 'auto');
tree = await choose(h, 'a:100');
assert.equal(list(h, tree).props.style.minHeight, 0);
assert.equal(sender(h, tree).props.style.flexShrink, 0);
dom.window.innerHeight = 1200;
listeners.get('resize')(); tree = await h.flush();
assert.equal(tree.props.style.height, 968);
top = 240; callback(); tree = await h.flush();
assert.equal(tree.props.style.height, 928);
dom.window.scrollY = 8; top = 232; callback(); tree = await h.flush();
assert.equal(tree.props.style.height, 928);
dom.window.innerHeight = 250; listeners.get('resize')(); tree = await h.flush();
assert.equal(tree.props.style.height, 0);
});
test('conversation list and message header show real nicknames and never use UID as a missing-name placeholder', async (t) => {
const h = harness({ contacts: [{ ...contacts[0], peer_name: '真实昵称' }, { ...contacts[1], peer_name: '' }] });
t.after(h.dispose);