mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-04-02 23:15:59 +00:00
feat: 初步实现服务号/公众号解析
This commit is contained in:
@@ -30,7 +30,7 @@ import { cloudControlService } from './services/cloudControlService'
|
||||
import { destroyNotificationWindow, registerNotificationHandlers, showNotification } from './windows/notificationWindow'
|
||||
import { httpService } from './services/httpService'
|
||||
import { messagePushService } from './services/messagePushService'
|
||||
|
||||
import { bizService } from './services/bizService'
|
||||
|
||||
// 配置自动更新
|
||||
autoUpdater.autoDownload = false
|
||||
@@ -1110,6 +1110,7 @@ const removeMatchedEntriesInDir = async (
|
||||
// 注册 IPC 处理器
|
||||
function registerIpcHandlers() {
|
||||
registerNotificationHandlers()
|
||||
bizService.registerHandlers()
|
||||
// 配置相关
|
||||
ipcMain.handle('config:get', async (_, key: string) => {
|
||||
return configService?.get(key as any)
|
||||
|
||||
@@ -413,6 +413,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
downloadEmoji: (params: { url: string; encryptUrl?: string; aesKey?: string }) => ipcRenderer.invoke('sns:downloadEmoji', params)
|
||||
},
|
||||
|
||||
biz: {
|
||||
listAccounts: (account?: string) => ipcRenderer.invoke('biz:listAccounts', account),
|
||||
listMessages: (username: string, account?: string, limit?: number, offset?: number) =>
|
||||
ipcRenderer.invoke('biz:listMessages', username, account, limit, offset),
|
||||
listPayRecords: (account?: string, limit?: number, offset?: number) =>
|
||||
ipcRenderer.invoke('biz:listPayRecords', account, limit, offset)
|
||||
},
|
||||
|
||||
|
||||
// 数据收集
|
||||
cloud: {
|
||||
|
||||
376
electron/services/bizService.ts
Normal file
376
electron/services/bizService.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
import { join } from 'path'
|
||||
import { readdirSync, existsSync } from 'fs'
|
||||
import { wcdbService } from './wcdbService'
|
||||
import { dbPathService } from './dbPathService'
|
||||
import { ConfigService } from './config'
|
||||
import * as fzstd from 'fzstd'
|
||||
import { DOMParser } from '@xmldom/xmldom'
|
||||
import { ipcMain } from 'electron'
|
||||
import { createHash } from 'crypto'
|
||||
import {ContactCacheService} from "./contactCacheService";
|
||||
|
||||
export interface BizAccount {
|
||||
username: string
|
||||
name: string
|
||||
avatar: string
|
||||
type: number
|
||||
last_time: number
|
||||
formatted_last_time: string
|
||||
}
|
||||
|
||||
export interface BizMessage {
|
||||
local_id: number
|
||||
create_time: number
|
||||
title: string
|
||||
des: string
|
||||
url: string
|
||||
cover: string
|
||||
content_list: any[]
|
||||
}
|
||||
|
||||
export interface BizPayRecord {
|
||||
local_id: number
|
||||
create_time: number
|
||||
title: string
|
||||
description: string
|
||||
merchant_name: string
|
||||
merchant_icon: string
|
||||
timestamp: number
|
||||
formatted_time: string
|
||||
}
|
||||
|
||||
export class BizService {
|
||||
private configService: ConfigService
|
||||
constructor() {
|
||||
this.configService = new ConfigService()
|
||||
}
|
||||
|
||||
private getAccountDir(account?: string): string {
|
||||
const root = dbPathService.getDefaultPath()
|
||||
if (account) {
|
||||
return join(root, account)
|
||||
}
|
||||
// Default to the first scanned account if no account specified
|
||||
const candidates = dbPathService.scanWxids(root)
|
||||
if (candidates.length > 0) {
|
||||
return join(root, candidates[0].wxid)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
private decompressZstd(data: Buffer): string {
|
||||
if (!data || data.length < 4) return data.toString('utf-8')
|
||||
const magic = data.readUInt32LE(0)
|
||||
if (magic !== 0xFD2FB528) {
|
||||
return data.toString('utf-8')
|
||||
}
|
||||
try {
|
||||
const decompressed = fzstd.decompress(data)
|
||||
return Buffer.from(decompressed).toString('utf-8')
|
||||
} catch (e) {
|
||||
console.error('[BizService] Zstd decompression failed:', e)
|
||||
return data.toString('utf-8')
|
||||
}
|
||||
}
|
||||
|
||||
private parseBizXml(xmlStr: string): any {
|
||||
if (!xmlStr) return null
|
||||
try {
|
||||
const doc = new DOMParser().parseFromString(xmlStr, 'text/xml')
|
||||
const q = (parent: any, selector: string) => {
|
||||
const nodes = parent.getElementsByTagName(selector)
|
||||
return nodes.length > 0 ? nodes[0].textContent || '' : ''
|
||||
}
|
||||
|
||||
const appMsg = doc.getElementsByTagName('appmsg')[0]
|
||||
if (!appMsg) return null
|
||||
|
||||
// 提取主封面
|
||||
let mainCover = q(appMsg, 'thumburl')
|
||||
if (!mainCover) {
|
||||
const coverNode = doc.getElementsByTagName('cover')[0]
|
||||
if (coverNode) mainCover = coverNode.textContent || ''
|
||||
}
|
||||
|
||||
const result = {
|
||||
title: q(appMsg, 'title'),
|
||||
des: q(appMsg, 'des'),
|
||||
url: q(appMsg, 'url'),
|
||||
cover: mainCover,
|
||||
content_list: [] as any[]
|
||||
}
|
||||
|
||||
const items = doc.getElementsByTagName('item')
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i]
|
||||
const itemStruct = {
|
||||
title: q(item, 'title'),
|
||||
url: q(item, 'url'),
|
||||
cover: q(item, 'cover'),
|
||||
summary: q(item, 'summary')
|
||||
}
|
||||
if (itemStruct.title) {
|
||||
result.content_list.push(itemStruct)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (e) {
|
||||
console.error('[BizService] XML parse failed:', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private parsePayXml(xmlStr: string): any {
|
||||
if (!xmlStr) return null
|
||||
try {
|
||||
const doc = new DOMParser().parseFromString(xmlStr, 'text/xml')
|
||||
const q = (parent: any, selector: string) => {
|
||||
const nodes = parent.getElementsByTagName(selector)
|
||||
return nodes.length > 0 ? nodes[0].textContent || '' : ''
|
||||
}
|
||||
|
||||
const appMsg = doc.getElementsByTagName('appmsg')[0]
|
||||
const header = doc.getElementsByTagName('template_header')[0]
|
||||
|
||||
const record = {
|
||||
title: appMsg ? q(appMsg, 'title') : '',
|
||||
description: appMsg ? q(appMsg, 'des') : '',
|
||||
merchant_name: header ? q(header, 'display_name') : '微信支付',
|
||||
merchant_icon: header ? q(header, 'icon_url') : '',
|
||||
timestamp: parseInt(q(doc, 'pub_time') || '0'),
|
||||
formatted_time: ''
|
||||
}
|
||||
return record
|
||||
} catch (e) {
|
||||
console.error('[BizService] Pay XML parse failed:', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async listAccounts(account?: string): Promise<BizAccount[]> {
|
||||
const root = this.configService.get('dbPath')
|
||||
console.log(root)
|
||||
let accountWxids: string[] = []
|
||||
|
||||
if (account) {
|
||||
accountWxids = [account]
|
||||
} else {
|
||||
const candidates = dbPathService.scanWxids(root)
|
||||
accountWxids = candidates.map(c => c.wxid)
|
||||
}
|
||||
|
||||
const allBizAccounts: Record<string, BizAccount> = {}
|
||||
|
||||
for (const wxid of accountWxids) {
|
||||
const accountDir = join(root, wxid)
|
||||
const dbDir = join(accountDir, 'db_storage', 'message')
|
||||
if (!existsSync(dbDir)) continue
|
||||
|
||||
const bizDbFiles = readdirSync(dbDir).filter(f => f.startsWith('biz_message') && f.endsWith('.db'))
|
||||
if (bizDbFiles.length === 0) continue
|
||||
|
||||
const bizIds = new Set<string>()
|
||||
const bizLatestTime: Record<string, number> = {}
|
||||
|
||||
for (const file of bizDbFiles) {
|
||||
const dbPath = join(dbDir, file)
|
||||
console.log(`path: ${dbPath}`)
|
||||
const name2idRes = await wcdbService.execQuery('biz', dbPath, 'SELECT username FROM Name2Id')
|
||||
console.log(`name2idRes success: ${name2idRes.success}`)
|
||||
console.log(`name2idRes length: ${name2idRes.rows?.length}`)
|
||||
|
||||
if (name2idRes.success && name2idRes.rows) {
|
||||
for (const row of name2idRes.rows) {
|
||||
if (row.username) {
|
||||
const uname = row.username
|
||||
bizIds.add(uname)
|
||||
|
||||
const md5Id = createHash('md5').update(uname).digest('hex').toLowerCase()
|
||||
const tableName = `Msg_${md5Id}`
|
||||
const timeRes = await wcdbService.execQuery('biz', dbPath, `SELECT MAX(create_time) as max_time FROM ${tableName}`)
|
||||
if (timeRes.success && timeRes.rows && timeRes.rows[0]?.max_time) {
|
||||
const t = timeRes.rows[0].max_time
|
||||
bizLatestTime[uname] = Math.max(bizLatestTime[uname] || 0, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bizIds.size === 0) continue
|
||||
|
||||
const contactDbPath = join(accountDir, 'contact.db')
|
||||
if (existsSync(contactDbPath)) {
|
||||
const idsArray = Array.from(bizIds)
|
||||
const batchSize = 100
|
||||
for (let i = 0; i < idsArray.length; i += batchSize) {
|
||||
const batch = idsArray.slice(i, i + batchSize)
|
||||
const placeholders = batch.map(() => '?').join(',')
|
||||
|
||||
const contactRes = await wcdbService.execQuery('contact', contactDbPath,
|
||||
`SELECT username, remark, nick_name, alias, big_head_url FROM contact WHERE username IN (${placeholders})`,
|
||||
batch
|
||||
)
|
||||
|
||||
if (contactRes.success && contactRes.rows) {
|
||||
for (const r of contactRes.rows) {
|
||||
const uname = r.username
|
||||
const name = r.remark || r.nick_name || r.alias || uname
|
||||
allBizAccounts[uname] = {
|
||||
username: uname,
|
||||
name: name,
|
||||
avatar: r.big_head_url,
|
||||
type: 3,
|
||||
last_time: Math.max(allBizAccounts[uname]?.last_time || 0, bizLatestTime[uname] || 0),
|
||||
formatted_last_time: ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const bizInfoRes = await wcdbService.execQuery('biz', contactDbPath,
|
||||
`SELECT username, type FROM biz_info WHERE username IN (${placeholders})`,
|
||||
batch
|
||||
)
|
||||
if (bizInfoRes.success && bizInfoRes.rows) {
|
||||
for (const r of bizInfoRes.rows) {
|
||||
if (allBizAccounts[r.username]) {
|
||||
allBizAccounts[r.username].type = r.type
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = Object.values(allBizAccounts).map(acc => ({
|
||||
...acc,
|
||||
formatted_last_time: acc.last_time ? new Date(acc.last_time * 1000).toISOString().split('T')[0] : ''
|
||||
})).sort((a, b) => {
|
||||
// 微信支付强制置顶
|
||||
if (a.username === 'gh_3dfda90e39d6') return -1
|
||||
if (b.username === 'gh_3dfda90e39d6') return 1
|
||||
return b.last_time - a.last_time
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private async getMsgContentBuf(messageContent: any): Promise<Buffer | null> {
|
||||
if (typeof messageContent === 'string') {
|
||||
if (messageContent.length > 0 && /^[0-9a-fA-F]+$/.test(messageContent)) {
|
||||
return Buffer.from(messageContent, 'hex')
|
||||
}
|
||||
return Buffer.from(messageContent, 'utf-8')
|
||||
} else if (messageContent && messageContent.data) {
|
||||
return Buffer.from(messageContent.data)
|
||||
} else if (Buffer.isBuffer(messageContent) || messageContent instanceof Uint8Array) {
|
||||
return Buffer.from(messageContent)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async listMessages(username: string, account?: string, limit: number = 20, offset: number = 0): Promise<BizMessage[]> {
|
||||
const accountDir = this.getAccountDir(account)
|
||||
const md5Id = createHash('md5').update(username).digest('hex').toLowerCase()
|
||||
const tableName = `Msg_${md5Id}`
|
||||
const dbDir = join(accountDir, 'db_storage')
|
||||
|
||||
if (!existsSync(dbDir)) return []
|
||||
const files = readdirSync(dbDir).filter(f => f.startsWith('biz_message') && f.endsWith('.db'))
|
||||
let targetDb: string | null = null
|
||||
|
||||
for (const file of files) {
|
||||
const dbPath = join(dbDir, file)
|
||||
const checkRes = await wcdbService.execQuery('biz', dbPath, `SELECT name FROM sqlite_master WHERE type='table' AND lower(name)='${tableName}'`)
|
||||
if (checkRes.success && checkRes.rows && checkRes.rows.length > 0) {
|
||||
targetDb = dbPath
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetDb) return []
|
||||
|
||||
const msgRes = await wcdbService.execQuery('biz', targetDb,
|
||||
`SELECT local_id, create_time, message_content FROM ${tableName} WHERE local_type != 1 ORDER BY create_time DESC LIMIT ${limit} OFFSET ${offset}`
|
||||
)
|
||||
|
||||
const messages: BizMessage[] = []
|
||||
if (msgRes.success && msgRes.rows) {
|
||||
for (const row of msgRes.rows) {
|
||||
const contentBuf = await this.getMsgContentBuf(row.message_content)
|
||||
if (!contentBuf) continue
|
||||
|
||||
const xmlStr = this.decompressZstd(contentBuf)
|
||||
const structData = this.parseBizXml(xmlStr)
|
||||
if (structData) {
|
||||
messages.push({
|
||||
local_id: row.local_id,
|
||||
create_time: row.create_time,
|
||||
...structData
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
async listPayRecords(account?: string, limit: number = 20, offset: number = 0): Promise<BizPayRecord[]> {
|
||||
const username = 'gh_3dfda90e39d6' // 硬编码的微信支付账号
|
||||
const accountDir = this.getAccountDir(account)
|
||||
const md5Id = createHash('md5').update(username).digest('hex').toLowerCase()
|
||||
const tableName = `Msg_${md5Id}`
|
||||
const dbDir = join(accountDir, 'db_storage')
|
||||
|
||||
if (!existsSync(dbDir)) return []
|
||||
const files = readdirSync(dbDir).filter(f => f.startsWith('biz_message') && f.endsWith('.db'))
|
||||
let targetDb: string | null = null
|
||||
|
||||
for (const file of files) {
|
||||
const dbPath = join(dbDir, file)
|
||||
const checkRes = await wcdbService.execQuery('biz', dbPath, `SELECT name FROM sqlite_master WHERE type='table' AND lower(name)='${tableName}'`)
|
||||
if (checkRes.success && checkRes.rows && checkRes.rows.length > 0) {
|
||||
targetDb = dbPath
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetDb) return []
|
||||
|
||||
const msgRes = await wcdbService.execQuery('biz', targetDb,
|
||||
`SELECT local_id, create_time, message_content FROM ${tableName} WHERE local_type = 21474836529 OR local_type != 1 ORDER BY create_time DESC LIMIT ${limit} OFFSET ${offset}`
|
||||
)
|
||||
|
||||
const records: BizPayRecord[] = []
|
||||
if (msgRes.success && msgRes.rows) {
|
||||
for (const row of msgRes.rows) {
|
||||
const contentBuf = await this.getMsgContentBuf(row.message_content)
|
||||
if (!contentBuf) continue
|
||||
|
||||
const xmlStr = this.decompressZstd(contentBuf)
|
||||
const parsedData = this.parsePayXml(xmlStr)
|
||||
if (parsedData) {
|
||||
const timestamp = parsedData.timestamp || row.create_time
|
||||
records.push({
|
||||
local_id: row.local_id,
|
||||
create_time: row.create_time,
|
||||
...parsedData,
|
||||
timestamp,
|
||||
formatted_time: new Date(timestamp * 1000).toLocaleString()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return records
|
||||
}
|
||||
|
||||
registerHandlers() {
|
||||
ipcMain.handle('biz:listAccounts', (_, account) => this.listAccounts(account))
|
||||
ipcMain.handle('biz:listMessages', (_, username, account, limit, offset) => this.listMessages(username, account, limit, offset))
|
||||
ipcMain.handle('biz:listPayRecords', (_, account, limit, offset) => this.listPayRecords(account, limit, offset))
|
||||
}
|
||||
}
|
||||
|
||||
export const bizService = new BizService()
|
||||
@@ -20,6 +20,7 @@ import ExportPage from './pages/ExportPage'
|
||||
import VideoWindow from './pages/VideoWindow'
|
||||
import ImageWindow from './pages/ImageWindow'
|
||||
import SnsPage from './pages/SnsPage'
|
||||
import BizPage from './pages/BizPage'
|
||||
import ContactsPage from './pages/ContactsPage'
|
||||
import ChatHistoryPage from './pages/ChatHistoryPage'
|
||||
import NotificationWindow from './pages/NotificationWindow'
|
||||
@@ -730,6 +731,7 @@ function App() {
|
||||
|
||||
<Route path="/export" element={<div className="export-route-anchor" aria-hidden="true" />} />
|
||||
<Route path="/sns" element={<SnsPage />} />
|
||||
<Route path="/biz" element={<BizPage />} />
|
||||
<Route path="/contacts" element={<ContactsPage />} />
|
||||
<Route path="/chat-history/:sessionId/:messageId" element={<ChatHistoryPage />} />
|
||||
<Route path="/chat-history-inline/:payloadId" element={<ChatHistoryPage />} />
|
||||
|
||||
293
src/pages/BizPage.scss
Normal file
293
src/pages/BizPage.scss
Normal 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
237
src/pages/BizPage.tsx
Normal 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;
|
||||
@@ -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}
|
||||
|
||||
5
src/types/electron.d.ts
vendored
5
src/types/electron.d.ts
vendored
@@ -326,6 +326,11 @@ export interface ElectronAPI {
|
||||
getMessage: (sessionId: string, localId: number) => Promise<{ success: boolean; message?: Message; error?: string }>
|
||||
onWcdbChange: (callback: (event: any, data: { type: string; json: string }) => void) => () => void
|
||||
}
|
||||
biz: {
|
||||
listAccounts: (account?: string) => Promise<any[]>
|
||||
listMessages: (username: string, account?: string, limit?: number, offset?: number) => Promise<any[]>
|
||||
listPayRecords: (account?: string, limit?: number, offset?: number) => Promise<any[]>
|
||||
}
|
||||
|
||||
image: {
|
||||
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => Promise<{ success: boolean; localPath?: string; liveVideoPath?: string; error?: string }>
|
||||
|
||||
Reference in New Issue
Block a user