feat: 初步实现服务号/公众号解析

This commit is contained in:
H3CoF6
2026-03-30 20:36:20 +08:00
parent 4f4e09c3de
commit 73a948c528
8 changed files with 1004 additions and 28 deletions

293
src/pages/BizPage.scss Normal file
View File

@@ -0,0 +1,293 @@
.biz-account-list {
flex: 1;
overflow-y: auto;
background-color: var(--bg-primary);
.biz-loading {
padding: 20px;
text-align: center;
font-size: 12px;
color: var(--text-muted);
}
.biz-account-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
cursor: pointer;
transition: background-color 0.2s;
border-bottom: 1px solid var(--border-subtle);
&:hover {
background-color: var(--bg-tertiary);
}
&.active {
background-color: var(--bg-active) !important;
}
&.pay-account {
background-color: var(--bg-soft);
}
.biz-avatar {
width: 40px;
height: 40px;
border-radius: 4px;
object-fit: cover;
flex-shrink: 0;
background-color: var(--bg-tertiary);
}
.biz-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
.biz-info-top {
display: flex;
justify-content: space-between;
align-items: center;
.biz-name {
font-size: 14px;
font-weight: 500;
color: var(--text-main);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.biz-time {
font-size: 11px;
color: var(--text-muted);
flex-shrink: 0;
}
}
.biz-badge {
font-size: 10px;
padding: 1px 6px;
border-radius: 4px;
width: fit-content;
margin-top: 2px;
&.type-service { color: #03C160; background: rgba(3, 193, 96, 0.1); }
&.type-sub { color: #108ee9; background: rgba(16, 142, 233, 0.1); }
&.type-enterprise { color: #f5222d; background: rgba(245, 34, 45, 0.1); }
&.type-unknown { color: #8c8c8c; background: rgba(140, 140, 140, 0.1); }
}
}
}
}
.biz-main {
height: 100%;
display: flex;
flex-direction: column;
background-color: var(--bg-tertiary);
.main-header {
height: 56px;
padding: 0 20px;
display: flex;
align-items: center;
border-bottom: 1px solid var(--border-dim);
background-color: var(--bg-primary);
flex-shrink: 0;
h2 {
font-size: 16px;
font-weight: 600;
color: var(--text-main);
}
}
.message-container {
flex: 1;
overflow-y: auto;
padding: 24px 16px;
.messages-wrapper {
width: 100%;
max-width: 600px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 24px;
}
}
.biz-loading-more {
text-align: center;
padding: 20px;
font-size: 12px;
color: var(--text-muted);
}
.pay-card {
background-color: var(--bg-primary);
border: 1px solid var(--border-dim);
border-radius: 12px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
.pay-header {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--text-muted);
margin-bottom: 20px;
.pay-icon {
width: 24px;
height: 24px;
border-radius: 50%;
object-fit: cover;
&.placeholder {
background-color: #03C160;
color: white;
display: flex;
align-items: center;
justify-content: center;
}
}
}
.pay-title {
text-align: center;
font-size: 22px;
font-weight: 500;
color: var(--text-main);
margin-bottom: 24px;
}
.pay-desc {
font-size: 13px;
line-height: 1.6;
color: var(--text-muted);
white-space: pre-wrap;
}
.pay-footer {
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid var(--border-subtle);
font-size: 12px;
color: var(--text-muted);
text-align: right;
}
}
.article-card {
background-color: var(--bg-primary);
border: 1px solid var(--border-dim);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
.main-article {
position: relative;
cursor: pointer;
.article-cover {
width: 100%;
height: 220px;
object-fit: cover;
background-color: var(--bg-tertiary);
}
.article-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 16px;
background: linear-gradient(transparent, rgba(0,0,0,0.8));
.article-title {
color: white;
font-size: 17px;
font-weight: 500;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
}
}
.article-digest {
padding: 12px 16px;
font-size: 14px;
color: var(--text-muted);
border-bottom: 1px solid var(--border-subtle);
}
.sub-articles {
.sub-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-top: 1px solid var(--border-subtle);
cursor: pointer;
&:hover { background-color: var(--bg-secondary); }
.sub-title {
flex: 1;
font-size: 15px;
color: var(--text-main);
padding-right: 12px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.sub-cover {
width: 48px;
height: 48px;
border-radius: 4px;
object-fit: cover;
flex-shrink: 0;
border: 1px solid var(--border-subtle);
}
}
}
}
}
.biz-empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
height: 100%;
.empty-icon {
width: 80px;
height: 80px;
margin-bottom: 20px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--bg-secondary);
color: var(--text-tertiary);
svg { width: 40px; height: 40px; }
}
p { color: var(--text-muted); font-size: 14px; }
}

237
src/pages/BizPage.tsx Normal file
View File

@@ -0,0 +1,237 @@
import React, { useState, useEffect, useMemo, useRef } from 'react';
import { useThemeStore } from '../stores/themeStore';
import { useAppStore } from '../stores/appStore';
import { ChevronLeft, Newspaper } from 'lucide-react';
import './BizPage.scss';
export interface BizAccount {
username: string;
name: string;
avatar: string;
type: number;
last_time: number;
formatted_last_time: string;
}
export const BizAccountList: React.FC<{
onSelect: (account: BizAccount) => void;
selectedUsername?: string;
searchKeyword?: string;
}> = ({ onSelect, selectedUsername, searchKeyword }) => {
const [accounts, setAccounts] = useState<BizAccount[]>([]);
const [loading, setLoading] = useState(false);
const [myWxid, setMyWxid] = useState<string>('');
useEffect(() => {
const initWxid = async () => {
try {
const wxid = await window.electronAPI.config.get('myWxid');
if (wxid) {
setMyWxid(wxid as string);
}
} catch (e) {
console.error("获取 myWxid 失败:", e);
}
};
initWxid();
}, []);
useEffect(() => {
const fetch = async () => {
if (!myWxid) {
return;
}
setLoading(true);
try {
const res = await window.electronAPI.biz.listAccounts(myWxid)
setAccounts(res || []);
} catch (err) {
console.error('获取服务号列表失败:', err);
} finally {
setLoading(false);
}
};
fetch();
}, [myWxid]);
const filtered = useMemo(() => {
if (!searchKeyword) return accounts;
const q = searchKeyword.toLowerCase();
return accounts.filter(a =>
(a.name && a.name.toLowerCase().includes(q)) ||
(a.username && a.username.toLowerCase().includes(q))
);
}, [accounts, searchKeyword]);
if (loading) return <div className="biz-loading">...</div>;
return (
<div className="biz-account-list">
{filtered.map(item => (
<div
key={item.username}
onClick={() => onSelect(item)}
className={`biz-account-item ${selectedUsername === item.username ? 'active' : ''} ${item.username === 'gh_3dfda90e39d6' ? 'pay-account' : ''}`}
>
<img
src={item.avatar || 'https://res.wx.qq.com/a/wx_fed/assets/res/NTI4MWU5.png'}
className="biz-avatar"
alt=""
/>
<div className="biz-info">
<div className="biz-info-top">
<span className="biz-name">{item.name || item.username}</span>
<span className="biz-time">{item.formatted_last_time}</span>
</div>
<div className={`biz-badge ${
item.type === 1 ? 'type-service' :
item.type === 0 ? 'type-sub' :
item.type === 2 ? 'type-enterprise' : 'type-unknown'
}`}>
{item.type === 1 ? '服务号' : item.type === 0 ? '订阅号' : item.type === 2 ? '企业号' : '未知'}
</div>
</div>
</div>
))}
</div>
);
};
// 2. 公众号消息区域组件 (展示在右侧消息区)
export const BizMessageArea: React.FC<{
account: BizAccount | null;
}> = ({ account }) => {
const themeMode = useThemeStore((state) => state.themeMode);
const [messages, setMessages] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [offset, setOffset] = useState(0);
const [hasMore, setHasMore] = useState(true);
const limit = 20;
const messageListRef = useRef<HTMLDivElement>(null);
const myWxid = useAppStore((state) => state.myWxid);
const isDark = useMemo(() => {
if (themeMode === 'dark') return true;
if (themeMode === 'system') {
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
return false;
}, [themeMode]);
useEffect(() => {
if (account) {
setMessages([]);
setOffset(0);
setHasMore(true);
loadMessages(account.username, 0);
}
}, [account]);
const loadMessages = async (username: string, currentOffset: number) => {
if (loading || !myWxid) return; // 没账号直接 return
setLoading(true);
try {
let res;
if (username === 'gh_3dfda90e39d6') {
// 传入 myWxid
res = await window.electronAPI.biz.listPayRecords(myWxid, limit, currentOffset);
} else {
// 传入 myWxid替换掉 undefined
res = await window.electronAPI.biz.listMessages(username, myWxid, limit, currentOffset);
}
if (res) {
if (res.length < limit) setHasMore(false);
setMessages(prev => currentOffset === 0 ? res : [...prev, ...res]);
setOffset(currentOffset + limit);
}
} catch (err) {
console.error('加载消息失败:', err);
} finally {
setLoading(false);
}
};
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
const target = e.currentTarget;
if (target.scrollHeight - Math.abs(target.scrollTop) - target.clientHeight < 50) {
if (!loading && hasMore && account) {
loadMessages(account.username, offset);
}
}
};
if (!account) {
return (
<div className="biz-empty-state">
<div className="empty-icon"><Newspaper size={40} /></div>
<p></p>
</div>
);
}
const defaultImage = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MDAiIGhlaWdodD0iMTgwIj48cmVjdCB3aWR0aD0iNDAwIiBoZWlnaHQ9IjE4MCIgZmlsbD0iI2Y1ZjVmNSIvPjwvc3ZnPg==';
return (
<div className={`biz-main ${isDark ? 'dark' : ''}`}>
<div className="main-header">
<h2>{account.name}</h2>
</div>
<div className="message-container" onScroll={handleScroll} ref={messageListRef}>
<div className="messages-wrapper">
{messages.map((msg) => (
<div key={msg.local_id}>
{account.username === 'gh_3dfda90e39d6' ? (
<div className="pay-card">
<div className="pay-header">
{msg.merchant_icon ? <img src={msg.merchant_icon} className="pay-icon" alt=""/> : <div className="pay-icon placeholder">¥</div>}
<span>{msg.merchant_name || '微信支付'}</span>
</div>
<div className="pay-title">{msg.title}</div>
<div className="pay-desc">{msg.description}</div>
<div className="pay-footer">{msg.formatted_time}</div>
</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 > 0 && (
<div className="sub-articles">
{msg.content_list.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 && <div className="biz-loading-more">...</div>}
</div>
</div>
</div>
);
};
// 保持 BizPage 作为入口 (如果需要独立页面)
const BizPage: React.FC = () => {
const [selectedAccount, setSelectedAccount] = useState<BizAccount | null>(null);
return (
<div className="biz-page">
<div className="biz-sidebar">
<BizAccountList onSelect={setSelectedAccount} selectedUsername={selectedAccount?.username} />
</div>
<BizMessageArea account={selectedAccount} />
</div>
);
}
export default BizPage;

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed, UserCheck, Crown, Aperture } from 'lucide-react'
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed, UserCheck, Crown, Aperture, Newspaper } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { createPortal } from 'react-dom'
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
@@ -16,6 +16,7 @@ import JumpToDatePopover from '../components/JumpToDatePopover'
import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog'
import { type ContactSnsTimelineTarget, isSingleContactSession } from '../components/Sns/contactSnsTimeline'
import * as configService from '../services/config'
import BizPage, { BizAccountList, BizMessageArea, BizAccount } from './BizPage'
import {
finishBackgroundTask,
isBackgroundTaskCancelRequested,
@@ -36,6 +37,8 @@ const SYSTEM_MESSAGE_TYPES = [
266287972401, // 拍一拍
]
const OFFICIAL_ACCOUNTS_VIRTUAL_ID = 'official_accounts_virtual'
interface PendingInSessionSearchPayload {
sessionId: string
keyword: string
@@ -1204,6 +1207,8 @@ function ChatPage(props: ChatPageProps) {
const [highlightedMessageKeys, setHighlightedMessageKeys] = useState<string[]>([])
const [isRefreshingSessions, setIsRefreshingSessions] = useState(false)
const [foldedView, setFoldedView] = useState(false) // 是否在"折叠的群聊"视图
const [bizView, setBizView] = useState(false) // 是否在"公众号"视图
const [selectedBizAccount, setSelectedBizAccount] = useState<BizAccount | null>(null)
const [hasInitialMessages, setHasInitialMessages] = useState(false)
const [isSessionSwitching, setIsSessionSwitching] = useState(false)
const [noMessageTable, setNoMessageTable] = useState(false)
@@ -2691,6 +2696,9 @@ function ChatPage(props: ChatPageProps) {
setConnected(false)
setConnecting(false)
setHasMoreMessages(true)
setFoldedView(false)
setBizView(false)
setSelectedBizAccount(null)
setHasMoreLater(false)
const scope = await resolveChatCacheScope()
hydrateSessionListCache(scope)
@@ -3964,6 +3972,12 @@ function ChatPage(props: ChatPageProps) {
setFoldedView(true)
return
}
// 点击公众号入口,切换到公众号视图
if (session.username === OFFICIAL_ACCOUNTS_VIRTUAL_ID) {
setBizView(true)
setSelectedBizAccount(null) // 切入时默认不选中任何公众号
return
}
selectSessionById(session.username)
}
@@ -4946,11 +4960,31 @@ function ChatPage(props: ChatPageProps) {
const foldedGroups = sessions.filter(s => s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup'))
const hasFoldedGroups = foldedGroups.length > 0
const visible = sessions.filter(s => {
let visible = sessions.filter(s => {
if (s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup')) return false
return true
})
// 注入“订阅号/服务号”虚拟项
const bizEntry: ChatSession = {
username: OFFICIAL_ACCOUNTS_VIRTUAL_ID,
displayName: '订阅号/服务号',
summary: '查看公众号历史消息',
type: 0,
sortTimestamp: 9999999999, // 确保在前面,或者您可以根据需要调整排序
lastTimestamp: 0,
lastMsgType: 0,
unreadCount: 0,
isMuted: false,
isFolded: false
}
// 检查是否已经存在(防止重复注入)
if (!visible.some(s => s.username === OFFICIAL_ACCOUNTS_VIRTUAL_ID)) {
// 插入到首位或者折叠项之后
visible.unshift(bizEntry)
}
// 如果有折叠的群聊,但列表中没有入口,则插入入口
if (hasFoldedGroups && !visible.some(s => s.username.toLowerCase().includes('placeholder_foldgroup'))) {
// 找到最新的折叠消息
@@ -6031,7 +6065,7 @@ function ChatPage(props: ChatPageProps) {
ref={sidebarRef}
style={{ width: sidebarWidth, minWidth: sidebarWidth, maxWidth: sidebarWidth }}
>
<div className={`session-header session-header-viewport ${foldedView ? 'folded' : ''}`}>
<div className={`session-header session-header-viewport ${foldedView || bizView ? 'folded' : ''}`}>
{/* 普通 header */}
<div className="session-header-panel main-header">
<div className="search-row">
@@ -6061,12 +6095,18 @@ function ChatPage(props: ChatPageProps) {
{/* 折叠群 header */}
<div className="session-header-panel folded-header">
<div className="folded-view-header">
<button className="icon-btn back-btn" onClick={() => setFoldedView(false)}>
<button className="icon-btn back-btn" onClick={() => {
setFoldedView(false)
setBizView(false)
}}>
<ChevronLeft size={18} />
</button>
<span className="folded-view-title">
<Users size={14} />
{foldedView ? (
<><Users size={14} /> </>
) : bizView ? (
<><Newspaper size={14} /> /</>
) : null}
</span>
</div>
</div>
@@ -6173,7 +6213,7 @@ function ChatPage(props: ChatPageProps) {
))}
</div>
) : (
<div className={`session-list-viewport ${foldedView ? 'folded' : ''}`}>
<div className={`session-list-viewport ${foldedView || bizView ? 'folded' : ''}`}>
{/* 普通会话列表 */}
<div className="session-list-panel main-panel">
{Array.isArray(filteredSessions) && filteredSessions.length > 0 ? (
@@ -6218,24 +6258,36 @@ function ChatPage(props: ChatPageProps) {
{/* 折叠群列表 */}
<div className="session-list-panel folded-panel">
{foldedSessions.length > 0 ? (
<div className="session-list">
{foldedSessions.map(session => (
<SessionItem
key={session.username}
session={session}
isActive={currentSessionId === session.username}
onSelect={handleSelectSession}
formatTime={formatSessionTime}
searchKeyword={searchKeyword}
{foldedView && (
foldedSessions.length > 0 ? (
<div className="session-list">
{foldedSessions.map(session => (
<SessionItem
key={session.username}
session={session}
isActive={currentSessionId === session.username}
onSelect={handleSelectSession}
formatTime={formatSessionTime}
searchKeyword={searchKeyword}
/>
))}
</div>
) : (
<div className="empty-sessions">
<Users size={32} />
<p></p>
</div>
)
)}
{bizView && (
<div style={{ height: '100%', overflowY: 'auto' }}>
<BizAccountList
onSelect={setSelectedBizAccount}
selectedUsername={selectedBizAccount?.username}
searchKeyword={searchKeyword}
/>
))}
</div>
) : (
<div className="empty-sessions">
<Users size={32} />
<p></p>
</div>
</div>
)}
</div>
</div>
@@ -6247,9 +6299,11 @@ function ChatPage(props: ChatPageProps) {
{/* 右侧消息区域 */}
<div className="message-area">
{currentSession ? (
<>
<div className="message-header">
{bizView ? (
<BizMessageArea account={selectedBizAccount} />
) : currentSession ? (
<>
<div className="message-header">
<Avatar
src={currentSession.avatarUrl}
name={currentSession.displayName || currentSession.username}