时间排序

This commit is contained in:
H3CoF6
2026-04-04 02:34:57 +08:00
parent 19d5ae7e15
commit db429abf5b
3 changed files with 185 additions and 79 deletions

View File

@@ -92,6 +92,7 @@ export class BizService {
const officialContacts = contactsResult.contacts.filter(c => c.type === 'official') const officialContacts = contactsResult.contacts.filter(c => c.type === 'official')
const usernames = officialContacts.map(c => c.username) const usernames = officialContacts.map(c => c.username)
const enrichment = await chatService.enrichSessionsContactInfo(usernames) const enrichment = await chatService.enrichSessionsContactInfo(usernames)
const contactInfoMap = enrichment.success && enrichment.contacts ? enrichment.contacts : {} const contactInfoMap = enrichment.success && enrichment.contacts ? enrichment.contacts : {}
@@ -100,29 +101,44 @@ export class BizService {
const accountWxid = account || myWxid const accountWxid = account || myWxid
if (!root || !accountWxid) return [] if (!root || !accountWxid) return []
const dbDir = join(root, accountWxid, 'db_storage', 'message')
const bizLatestTime: Record<string, number> = {} const bizLatestTime: Record<string, number> = {}
if (existsSync(dbDir)) { // 暴力解决
const bizDbFiles = readdirSync(dbDir).filter(f => f.startsWith('biz_message') && f.endsWith('.db')) const timePromises = usernames.map(async (uname) => {
for (const file of bizDbFiles) { try {
const dbPath = join(dbDir, file) // limit 设置为 1只要最新的一条
const name2idRes = await wcdbService.execQuery('message', dbPath, 'SELECT username FROM Name2Id') const res = await chatService.getMessages(uname, 0, 1)
if (name2idRes.success && name2idRes.rows) { if (res.success && res.messages && res.messages.length > 0) {
for (const row of name2idRes.rows) { return { uname, time: res.messages[0].createTime }
const uname = row.username || row.user_name
if (uname) {
const md5 = createHash('md5').update(uname).digest('hex').toLowerCase()
const tName = `Msg_${md5}`
const timeRes = await wcdbService.execQuery('message', dbPath, `SELECT MAX(create_time) as max_time FROM ${tName}`)
if (timeRes.success && timeRes.rows && timeRes.rows[0]?.max_time) {
const t = parseInt(timeRes.rows[0].max_time)
if (!isNaN(t)) bizLatestTime[uname] = Math.max(bizLatestTime[uname] || 0, t)
}
}
}
} }
} catch (e) {
// 忽略没有消息或查询报错的账号
} }
return { uname, time: 0 }
})
const timeResults = await Promise.all(timePromises)
for (const r of timeResults) {
if (r.time > 0) {
bizLatestTime[r.uname] = r.time
}
}
const formatBizTime = (ts: number) => {
if (!ts) return ''
const date = new Date(ts * 1000)
const now = new Date()
const isToday = date.toDateString() === now.toDateString()
if (isToday) return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false })
const yesterday = new Date(now)
yesterday.setDate(now.getDate() - 1)
if (date.toDateString() === yesterday.toDateString()) return '昨天'
const isThisYear = date.getFullYear() === now.getFullYear()
if (isThisYear) return `${date.getMonth() + 1}/${date.getDate()}`
return `${date.getFullYear().toString().slice(-2)}/${date.getMonth() + 1}/${date.getDate()}`
} }
const result: BizAccount[] = officialContacts.map(contact => { const result: BizAccount[] = officialContacts.map(contact => {
@@ -135,20 +151,14 @@ export class BizService {
avatar: info?.avatarUrl || '', avatar: info?.avatarUrl || '',
type: 0, type: 0,
last_time: lastTime, last_time: lastTime,
formatted_last_time: lastTime ? new Date(lastTime * 1000).toISOString().split('T')[0] : '' formatted_last_time: formatBizTime(lastTime)
} }
}) })
const contactDbPath = join(root, accountWxid, 'db_storage', 'contact', 'contact.db') // const contactDbPath = join(root, accountWxid, 'db_storage', 'contact', 'contact.db')
// console.log(`contactDbPath: ${contactDbPath}`)
if (existsSync(contactDbPath)) { if (existsSync(contactDbPath)) {
// console.log('ok11')
const bizInfoRes = await wcdbService.execQuery('contact', contactDbPath, 'SELECT username, type FROM biz_info') const bizInfoRes = await wcdbService.execQuery('contact', contactDbPath, 'SELECT username, type FROM biz_info')
// console.log(JSON.stringify(bizInfoRes, null, 2))
if (bizInfoRes.success && bizInfoRes.rows) { if (bizInfoRes.success && bizInfoRes.rows) {
const typeMap: Record<string, number> = {} const typeMap: Record<string, number> = {}
for (const r of bizInfoRes.rows) typeMap[r.username] = r.type for (const r of bizInfoRes.rows) typeMap[r.username] = r.type
@@ -157,13 +167,18 @@ export class BizService {
} }
return result return result
.filter(acc => !acc.name.includes('广告')) .filter(acc => !acc.name.includes('广告'))
.sort((a, b) => { .sort((a, b) => {
if (a.username === 'gh_3dfda90e39d6') return -1 // 微信支付强制置顶
if (b.username === 'gh_3dfda90e39d6') return 1 if (a.username === 'gh_3dfda90e39d6') return -1
return b.last_time - a.last_time if (b.username === 'gh_3dfda90e39d6') return 1
}) // 后端直接排好序输出
} catch (e) { return [] } return b.last_time - a.last_time
})
} catch (e) {
console.error('获取账号列表失败:', e)
return []
}
} }
async listMessages(username: string, account?: string, limit: number = 20, offset: number = 0): Promise<BizMessage[]> { async listMessages(username: string, account?: string, limit: number = 20, offset: number = 0): Promise<BizMessage[]> {

View File

@@ -125,7 +125,22 @@
margin: 0 auto; margin: 0 auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 24px; gap: 16px; // 减小间距,因为有了 time-divider
}
}
.time-divider {
text-align: center;
margin: 16px 0 8px;
span {
display: inline-block;
padding: 2px 8px;
background-color: var(--bg-primary);
color: var(--text-tertiary);
font-size: 11px;
border-radius: 4px;
opacity: 0.8;
} }
} }

View File

@@ -55,15 +55,24 @@ export const BizAccountList: React.FC<{
fetch().then(_r => { } ); fetch().then(_r => { } );
}, [myWxid]); }, [myWxid]);
const filtered = useMemo(() => { const filtered = useMemo(() => {
if (!searchKeyword) return accounts; let result = accounts;
const q = searchKeyword.toLowerCase(); if (searchKeyword) {
return accounts.filter(a => const q = searchKeyword.toLowerCase();
(a.name && a.name.toLowerCase().includes(q)) || result = accounts.filter(a =>
(a.username && a.username.toLowerCase().includes(q)) (a.name && a.name.toLowerCase().includes(q)) ||
); (a.username && a.username.toLowerCase().includes(q))
);
}
return result.sort((a, b) => {
if (a.username === 'gh_3dfda90e39d6') return -1; // 微信支付置顶
if (b.username === 'gh_3dfda90e39d6') return 1;
return b.last_time - a.last_time;
});
}, [accounts, searchKeyword]); }, [accounts, searchKeyword]);
if (loading) return <div className="biz-loading">...</div>; if (loading) return <div className="biz-loading">...</div>;
return ( return (
@@ -91,9 +100,10 @@ export const BizAccountList: React.FC<{
<div className={`biz-badge ${ <div className={`biz-badge ${
item.type === '1' ? 'type-service' : item.type === '1' ? 'type-service' :
item.type === '0' ? 'type-sub' : item.type === '0' ? 'type-sub' :
item.type === '2' ? 'type-enterprise' : 'type-unknown' item.type === '2' ? 'type-enterprise' :
item.type === '3' ? 'type-enterprise' : 'type-unknown'
}`}> }`}>
{item.type === '0' ? '公众号' : item.type === '1' ? '服务号' : item.type === '2' ? '企业号' : '未知'} {item.type === '0' ? '公众号' : item.type === '1' ? '服务号' : item.type === '2' ? '企业号' : item.type === '3' ? '企业附属' : '未知'}
</div> </div>
</div> </div>
@@ -103,7 +113,6 @@ export const BizAccountList: React.FC<{
); );
}; };
// 2. 公众号消息区域组件
export const BizMessageArea: React.FC<{ export const BizMessageArea: React.FC<{
account: BizAccount | null; account: BizAccount | null;
}> = ({ account }) => { }> = ({ account }) => {
@@ -114,6 +123,8 @@ export const BizMessageArea: React.FC<{
const [hasMore, setHasMore] = useState(true); const [hasMore, setHasMore] = useState(true);
const limit = 20; const limit = 20;
const messageListRef = useRef<HTMLDivElement>(null); const messageListRef = useRef<HTMLDivElement>(null);
const lastScrollHeightRef = useRef<number>(0);
const isInitialLoadRef = useRef<boolean>(true);
const [myWxid, setMyWxid] = useState<string>(''); const [myWxid, setMyWxid] = useState<string>('');
@@ -142,6 +153,7 @@ export const BizMessageArea: React.FC<{
setMessages([]); setMessages([]);
setOffset(0); setOffset(0);
setHasMore(true); setHasMore(true);
isInitialLoadRef.current = true;
loadMessages(account.username, 0); loadMessages(account.username, 0);
} }
}, [account, myWxid]); }, [account, myWxid]);
@@ -150,6 +162,10 @@ export const BizMessageArea: React.FC<{
if (loading || !myWxid) return; if (loading || !myWxid) return;
setLoading(true); setLoading(true);
if (messageListRef.current) {
lastScrollHeightRef.current = messageListRef.current.scrollHeight;
}
try { try {
let res; let res;
if (username === 'gh_3dfda90e39d6') { if (username === 'gh_3dfda90e39d6') {
@@ -157,9 +173,15 @@ export const BizMessageArea: React.FC<{
} else { } else {
res = await window.electronAPI.biz.listMessages(username, myWxid, limit, currentOffset); res = await window.electronAPI.biz.listMessages(username, myWxid, limit, currentOffset);
} }
if (res) { if (res) {
if (res.length < limit) setHasMore(false); if (res.length < limit) setHasMore(false);
setMessages(prev => currentOffset === 0 ? res : [...prev, ...res]);
setMessages(prev => {
const combined = currentOffset === 0 ? res : [...res, ...prev];
const uniqueMessages = Array.from(new Map(combined.map(item => [item.local_id || item.create_time, item])).values());
return uniqueMessages.sort((a, b) => a.create_time - b.create_time);
});
setOffset(currentOffset + limit); setOffset(currentOffset + limit);
} }
} catch (err) { } catch (err) {
@@ -169,9 +191,26 @@ export const BizMessageArea: React.FC<{
} }
}; };
useEffect(() => {
if (!messageListRef.current) return;
if (isInitialLoadRef.current && messages.length > 0) {
messageListRef.current.scrollTop = messageListRef.current.scrollHeight;
isInitialLoadRef.current = false;
} else if (messages.length > 0 && !isInitialLoadRef.current && !loading) {
const newScrollHeight = messageListRef.current.scrollHeight;
const heightDiff = newScrollHeight - lastScrollHeightRef.current;
if (heightDiff > 0 && messageListRef.current.scrollTop < 100) {
messageListRef.current.scrollTop += heightDiff;
}
}
}, [messages, loading]);
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => { const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
const target = e.currentTarget; const target = e.currentTarget;
if (target.scrollHeight - Math.abs(target.scrollTop) - target.clientHeight < 50) { // 向上滚动到顶部附近触发加载更多(更旧的消息)
if (target.scrollTop < 50) {
if (!loading && hasMore && account) { if (!loading && hasMore && account) {
loadMessages(account.username, offset); loadMessages(account.username, offset);
} }
@@ -187,6 +226,30 @@ export const BizMessageArea: React.FC<{
); );
} }
const formatMessageTime = (timestamp: number) => {
if (!timestamp) return '';
const date = new Date(timestamp * 1000);
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
if (isToday) {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false });
}
const yesterday = new Date(now);
yesterday.setDate(now.getDate() - 1);
if (date.toDateString() === yesterday.toDateString()) {
return `昨天 ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
}
const isThisYear = date.getFullYear() === now.getFullYear();
if (isThisYear) {
return `${date.getMonth() + 1}${date.getDate()}${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
}
return `${date.getFullYear()}${date.getMonth() + 1}${date.getDate()}`;
};
const defaultImage = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MDAiIGhlaWdodD0iMTgwIj48cmVjdCB3aWR0aD0iNDAwIiBoZWlnaHQ9IjE4MCIgZmlsbD0iI2Y1ZjVmNSIvPjwvc3ZnPg=='; const defaultImage = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MDAiIGhlaWdodD0iMTgwIj48cmVjdCB3aWR0aD0iNDAwIiBoZWlnaHQ9IjE4MCIgZmlsbD0iI2Y1ZjVmNSIvPjwvc3ZnPg==';
return ( return (
@@ -196,6 +259,9 @@ export const BizMessageArea: React.FC<{
</div> </div>
<div className="message-container" onScroll={handleScroll} ref={messageListRef}> <div className="message-container" onScroll={handleScroll} ref={messageListRef}>
<div className="messages-wrapper"> <div className="messages-wrapper">
{hasMore && messages.length > 0 && (
<div className="biz-loading-more">{loading ? '加载中...' : '向上滚动加载更多历史消息'}</div>
)}
{!loading && messages.length === 0 && ( {!loading && messages.length === 0 && (
<div className="biz-no-record-container"> <div className="biz-no-record-container">
<div className="no-record-icon"> <div className="no-record-icon">
@@ -205,40 +271,50 @@ export const BizMessageArea: React.FC<{
<p></p> <p></p>
</div> </div>
)} )}
{messages.map((msg) => ( {messages.map((msg, index) => {
<div key={msg.local_id}> const showTime = true;
{account.username === 'gh_3dfda90e39d6' ? (
<div className="pay-card"> return (
<div className="pay-header"> <div key={msg.local_id || index}>
{msg.merchant_icon ? <img src={msg.merchant_icon} className="pay-icon" alt=""/> : <div className="pay-icon-placeholder">¥</div>} {showTime && (
<span>{msg.merchant_name || '微信支付'}</span> <div className="time-divider">
</div> <span>{formatMessageTime(msg.create_time)}</span>
<div className="pay-title">{msg.title}</div> </div>
<div className="pay-desc">{msg.description}</div> )}
<div className="pay-footer">{msg.formatted_time}</div>
</div> {account.username === 'gh_3dfda90e39d6' ? (
) : ( <div className="pay-card">
<div className="article-card"> <div className="pay-header">
<div onClick={() => window.electronAPI.shell.openExternal(msg.url)} className="main-article"> {msg.merchant_icon ? <img src={msg.merchant_icon} className="pay-icon" alt=""/> : <div className="pay-icon-placeholder">¥</div>}
<img src={msg.cover || defaultImage} className="article-cover" alt=""/> <span>{msg.merchant_name || '微信支付'}</span>
<div className="article-overlay"><h3 className="article-title">{msg.title}</h3></div>
</div>
{msg.des && <div className="article-digest">{msg.des}</div>}
{msg.content_list && msg.content_list.length > 1 && (
<div className="sub-articles">
{msg.content_list.slice(1).map((item: any, idx: number) => (
<div key={idx} onClick={() => window.electronAPI.shell.openExternal(item.url)} className="sub-item">
<span className="sub-title">{item.title}</span>
{item.cover && <img src={item.cover} className="sub-cover" alt=""/>}
</div>
))}
</div> </div>
)} <div className="pay-title">{msg.title}</div>
</div> <div className="pay-desc">{msg.description}</div>
)} {/* <div className="pay-footer">{msg.formatted_time}</div> */}
</div> </div>
))} ) : (
{loading && <div className="biz-loading-more">...</div>} <div className="article-card">
<div onClick={() => window.electronAPI.shell.openExternal(msg.url)} className="main-article">
<img src={msg.cover || defaultImage} className="article-cover" alt=""/>
<div className="article-overlay"><h3 className="article-title">{msg.title}</h3></div>
</div>
{msg.des && <div className="article-digest">{msg.des}</div>}
{msg.content_list && msg.content_list.length > 1 && (
<div className="sub-articles">
{msg.content_list.slice(1).map((item: any, idx: number) => (
<div key={idx} onClick={() => window.electronAPI.shell.openExternal(item.url)} className="sub-item">
<span className="sub-title">{item.title}</span>
{item.cover && <img src={item.cover} className="sub-cover" alt=""/>}
</div>
))}
</div>
)}
</div>
)}
</div>
);
})}
{loading && offset === 0 && <div className="biz-loading-more">...</div>}
</div> </div>
</div> </div>
</div> </div>