mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-24 23:06:51 +00:00
支持折叠的群聊判定
This commit is contained in:
@@ -34,6 +34,8 @@ export interface ChatSession {
|
|||||||
lastMsgSender?: string
|
lastMsgSender?: string
|
||||||
lastSenderDisplayName?: string
|
lastSenderDisplayName?: string
|
||||||
selfWxid?: string
|
selfWxid?: string
|
||||||
|
isFolded?: boolean // 是否已折叠进"折叠的群聊"
|
||||||
|
isMuted?: boolean // 是否开启免打扰
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
@@ -413,12 +415,29 @@ class ChatService {
|
|||||||
lastMsgType,
|
lastMsgType,
|
||||||
displayName,
|
displayName,
|
||||||
avatarUrl,
|
avatarUrl,
|
||||||
lastMsgSender: row.last_msg_sender, // 数据库返回字段
|
lastMsgSender: row.last_msg_sender,
|
||||||
lastSenderDisplayName: row.last_sender_display_name, // 数据库返回字段
|
lastSenderDisplayName: row.last_sender_display_name,
|
||||||
selfWxid: myWxid
|
selfWxid: myWxid
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 批量拉取 extra_buffer 状态(isFolded/isMuted),不阻塞主流程
|
||||||
|
const allUsernames = sessions.map(s => s.username)
|
||||||
|
try {
|
||||||
|
const statusResult = await wcdbService.getContactStatus(allUsernames)
|
||||||
|
if (statusResult.success && statusResult.map) {
|
||||||
|
for (const s of sessions) {
|
||||||
|
const st = statusResult.map[s.username]
|
||||||
|
if (st) {
|
||||||
|
s.isFolded = st.isFolded
|
||||||
|
s.isMuted = st.isMuted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 状态获取失败不影响会话列表返回
|
||||||
|
}
|
||||||
|
|
||||||
// 不等待联系人信息加载,直接返回基础会话列表
|
// 不等待联系人信息加载,直接返回基础会话列表
|
||||||
// 前端可以异步调用 enrichSessionsWithContacts 来补充信息
|
// 前端可以异步调用 enrichSessionsWithContacts 来补充信息
|
||||||
return { success: true, sessions }
|
return { success: true, sessions }
|
||||||
@@ -2846,15 +2865,16 @@ class ChatService {
|
|||||||
private shouldKeepSession(username: string): boolean {
|
private shouldKeepSession(username: string): boolean {
|
||||||
if (!username) return false
|
if (!username) return false
|
||||||
const lowered = username.toLowerCase()
|
const lowered = username.toLowerCase()
|
||||||
if (lowered.includes('@placeholder') || lowered.includes('foldgroup')) return false
|
// placeholder_foldgroup 是折叠群入口,需要保留
|
||||||
|
if (lowered.includes('@placeholder') && !lowered.includes('foldgroup')) return false
|
||||||
if (username.startsWith('gh_')) return false
|
if (username.startsWith('gh_')) return false
|
||||||
|
|
||||||
const excludeList = [
|
const excludeList = [
|
||||||
'weixin', 'qqmail', 'fmessage', 'medianote', 'floatbottle',
|
'weixin', 'qqmail', 'fmessage', 'medianote', 'floatbottle',
|
||||||
'newsapp', 'brandsessionholder', 'brandservicesessionholder',
|
'newsapp', 'brandsessionholder', 'brandservicesessionholder',
|
||||||
'notifymessage', 'opencustomerservicemsg', 'notification_messages',
|
'notifymessage', 'opencustomerservicemsg', 'notification_messages',
|
||||||
'userexperience_alarm', 'helper_folders', 'placeholder_foldgroup',
|
'userexperience_alarm', 'helper_folders',
|
||||||
'@helper_folders', '@placeholder_foldgroup'
|
'@helper_folders'
|
||||||
]
|
]
|
||||||
|
|
||||||
for (const prefix of excludeList) {
|
for (const prefix of excludeList) {
|
||||||
|
|||||||
@@ -3,6 +3,48 @@ import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, readFileS
|
|||||||
|
|
||||||
// DLL 初始化错误信息,用于帮助用户诊断问题
|
// DLL 初始化错误信息,用于帮助用户诊断问题
|
||||||
let lastDllInitError: string | null = null
|
let lastDllInitError: string | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 extra_buffer(protobuf)中的免打扰状态
|
||||||
|
* - field 12 (tag 0x60): 值非0 = 免打扰
|
||||||
|
* 折叠状态通过 contact.flag & 0x10000000 判断
|
||||||
|
*/
|
||||||
|
function parseExtraBuffer(raw: Buffer | string | null | undefined): { isMuted: boolean } {
|
||||||
|
if (!raw) return { isMuted: false }
|
||||||
|
// execQuery 返回的 BLOB 列是十六进制字符串,需要先解码
|
||||||
|
const buf: Buffer = typeof raw === 'string' ? Buffer.from(raw, 'hex') : raw
|
||||||
|
if (buf.length === 0) return { isMuted: false }
|
||||||
|
let isMuted = false
|
||||||
|
let i = 0
|
||||||
|
const len = buf.length
|
||||||
|
|
||||||
|
const readVarint = (): number => {
|
||||||
|
let result = 0, shift = 0
|
||||||
|
while (i < len) {
|
||||||
|
const b = buf[i++]
|
||||||
|
result |= (b & 0x7f) << shift
|
||||||
|
shift += 7
|
||||||
|
if (!(b & 0x80)) break
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
while (i < len) {
|
||||||
|
const tag = readVarint()
|
||||||
|
const fieldNum = tag >>> 3
|
||||||
|
const wireType = tag & 0x07
|
||||||
|
if (wireType === 0) {
|
||||||
|
const val = readVarint()
|
||||||
|
if (fieldNum === 12 && val !== 0) isMuted = true
|
||||||
|
} else if (wireType === 2) {
|
||||||
|
const sz = readVarint()
|
||||||
|
i += sz
|
||||||
|
} else if (wireType === 5) { i += 4
|
||||||
|
} else if (wireType === 1) { i += 8
|
||||||
|
} else { break }
|
||||||
|
}
|
||||||
|
return { isMuted }
|
||||||
|
}
|
||||||
export function getLastDllInitError(): string | null {
|
export function getLastDllInitError(): string | null {
|
||||||
return lastDllInitError
|
return lastDllInitError
|
||||||
}
|
}
|
||||||
@@ -41,6 +83,7 @@ export class WcdbCore {
|
|||||||
private wcdbGetMessageTables: any = null
|
private wcdbGetMessageTables: any = null
|
||||||
private wcdbGetMessageMeta: any = null
|
private wcdbGetMessageMeta: any = null
|
||||||
private wcdbGetContact: any = null
|
private wcdbGetContact: any = null
|
||||||
|
private wcdbGetContactStatus: any = null
|
||||||
private wcdbGetMessageTableStats: any = null
|
private wcdbGetMessageTableStats: any = null
|
||||||
private wcdbGetAggregateStats: any = null
|
private wcdbGetAggregateStats: any = null
|
||||||
private wcdbGetAvailableYears: any = null
|
private wcdbGetAvailableYears: any = null
|
||||||
@@ -487,6 +530,13 @@ export class WcdbCore {
|
|||||||
// wcdb_status wcdb_get_contact(wcdb_handle handle, const char* username, char** out_json)
|
// wcdb_status wcdb_get_contact(wcdb_handle handle, const char* username, char** out_json)
|
||||||
this.wcdbGetContact = this.lib.func('int32 wcdb_get_contact(int64 handle, const char* username, _Out_ void** outJson)')
|
this.wcdbGetContact = this.lib.func('int32 wcdb_get_contact(int64 handle, const char* username, _Out_ void** outJson)')
|
||||||
|
|
||||||
|
// wcdb_status wcdb_get_contact_status(wcdb_handle handle, const char* usernames_json, char** out_json)
|
||||||
|
try {
|
||||||
|
this.wcdbGetContactStatus = this.lib.func('int32 wcdb_get_contact_status(int64 handle, const char* usernamesJson, _Out_ void** outJson)')
|
||||||
|
} catch {
|
||||||
|
this.wcdbGetContactStatus = null
|
||||||
|
}
|
||||||
|
|
||||||
// wcdb_status wcdb_get_message_table_stats(wcdb_handle handle, const char* session_id, char** out_json)
|
// wcdb_status wcdb_get_message_table_stats(wcdb_handle handle, const char* session_id, char** out_json)
|
||||||
this.wcdbGetMessageTableStats = this.lib.func('int32 wcdb_get_message_table_stats(int64 handle, const char* sessionId, _Out_ void** outJson)')
|
this.wcdbGetMessageTableStats = this.lib.func('int32 wcdb_get_message_table_stats(int64 handle, const char* sessionId, _Out_ void** outJson)')
|
||||||
|
|
||||||
@@ -1370,6 +1420,36 @@ export class WcdbCore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getContactStatus(usernames: string[]): Promise<{ success: boolean; map?: Record<string, { isFolded: boolean; isMuted: boolean }>; error?: string }> {
|
||||||
|
if (!this.ensureReady()) {
|
||||||
|
return { success: false, error: 'WCDB 未连接' }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// 分批查询,避免 SQL 过长(execQuery 不支持参数绑定,直接拼 SQL)
|
||||||
|
const BATCH = 200
|
||||||
|
const map: Record<string, { isFolded: boolean; isMuted: boolean }> = {}
|
||||||
|
for (let i = 0; i < usernames.length; i += BATCH) {
|
||||||
|
const batch = usernames.slice(i, i + BATCH)
|
||||||
|
const inList = batch.map(u => `'${u.replace(/'/g, "''")}'`).join(',')
|
||||||
|
const sql = `SELECT username, flag, extra_buffer FROM contact WHERE username IN (${inList})`
|
||||||
|
const result = await this.execQuery('contact', null, sql)
|
||||||
|
if (!result.success || !result.rows) continue
|
||||||
|
for (const row of result.rows) {
|
||||||
|
const uname: string = row.username
|
||||||
|
// 折叠:flag bit 28 (0x10000000)
|
||||||
|
const flag = parseInt(row.flag ?? '0', 10)
|
||||||
|
const isFolded = (flag & 0x10000000) !== 0
|
||||||
|
// 免打扰:extra_buffer field 12 非0
|
||||||
|
const { isMuted } = parseExtraBuffer(row.extra_buffer)
|
||||||
|
map[uname] = { isFolded, isMuted }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { success: true, map }
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getAggregateStats(sessionIds: string[], beginTimestamp: number = 0, endTimestamp: number = 0): Promise<{ success: boolean; data?: any; error?: string }> {
|
async getAggregateStats(sessionIds: string[], beginTimestamp: number = 0, endTimestamp: number = 0): Promise<{ success: boolean; data?: any; error?: string }> {
|
||||||
if (!this.ensureReady()) {
|
if (!this.ensureReady()) {
|
||||||
return { success: false, error: 'WCDB 未连接' }
|
return { success: false, error: 'WCDB 未连接' }
|
||||||
|
|||||||
@@ -290,6 +290,13 @@ export class WcdbService {
|
|||||||
return this.callWorker('getContact', { username })
|
return this.callWorker('getContact', { username })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量获取联系人 extra_buffer 状态(isFolded/isMuted)
|
||||||
|
*/
|
||||||
|
async getContactStatus(usernames: string[]): Promise<{ success: boolean; map?: Record<string, { isFolded: boolean; isMuted: boolean }>; error?: string }> {
|
||||||
|
return this.callWorker('getContactStatus', { usernames })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取聚合统计数据
|
* 获取聚合统计数据
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -87,6 +87,9 @@ if (parentPort) {
|
|||||||
case 'getContact':
|
case 'getContact':
|
||||||
result = await core.getContact(payload.username)
|
result = await core.getContact(payload.username)
|
||||||
break
|
break
|
||||||
|
case 'getContactStatus':
|
||||||
|
result = await core.getContactStatus(payload.usernames)
|
||||||
|
break
|
||||||
case 'getAggregateStats':
|
case 'getAggregateStats':
|
||||||
result = await core.getAggregateStats(payload.sessionIds, payload.beginTimestamp, payload.endTimestamp)
|
result = await core.getAggregateStats(payload.sessionIds, payload.beginTimestamp, payload.endTimestamp)
|
||||||
break
|
break
|
||||||
|
|||||||
Binary file not shown.
@@ -97,6 +97,10 @@ export function GlobalSessionMonitor() {
|
|||||||
if (!isCurrentSession && (!oldSession || newSession.lastTimestamp > oldSession.lastTimestamp)) {
|
if (!isCurrentSession && (!oldSession || newSession.lastTimestamp > oldSession.lastTimestamp)) {
|
||||||
// 这是新消息事件
|
// 这是新消息事件
|
||||||
|
|
||||||
|
// 免打扰、折叠群、折叠入口不弹通知
|
||||||
|
if (newSession.isMuted || newSession.isFolded) continue
|
||||||
|
if (newSession.username.toLowerCase().includes('placeholder_foldgroup')) continue
|
||||||
|
|
||||||
// 1. 群聊过滤自己发送的消息
|
// 1. 群聊过滤自己发送的消息
|
||||||
if (newSession.username.includes('@chatroom')) {
|
if (newSession.username.includes('@chatroom')) {
|
||||||
// 如果是自己发的消息,不弹通知
|
// 如果是自己发的消息,不弹通知
|
||||||
|
|||||||
@@ -866,6 +866,73 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Header 双 panel 滑动动画
|
||||||
|
.session-header-viewport {
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.session-header-panel {
|
||||||
|
flex: 0 0 100%;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 16px 12px;
|
||||||
|
min-height: 56px;
|
||||||
|
transition: transform 0.28s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-header {
|
||||||
|
transform: translateX(0);
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folded-header {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.folded {
|
||||||
|
.main-header { transform: translateX(-100%); }
|
||||||
|
.folded-header { transform: translateX(-100%); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.folded-view-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.folded-view-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes searchExpand {
|
@keyframes searchExpand {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -3863,4 +3930,135 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 折叠群视图 header
|
||||||
|
.folded-view-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 4px;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
&:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.folded-view-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 双 panel 滑动容器
|
||||||
|
.session-list-viewport {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
// 两个 panel 并排,宽度各 100%,通过 translateX 切换
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.session-list-panel {
|
||||||
|
flex: 0 0 100%;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: transform 0.28s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认:main 在视口内,folded 在右侧外
|
||||||
|
.main-panel {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
.folded-panel {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换到折叠群视图:两个 panel 同时左移 100%
|
||||||
|
&.folded {
|
||||||
|
.main-panel {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
.folded-panel {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
&:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 免打扰标识
|
||||||
|
.session-item {
|
||||||
|
&.muted {
|
||||||
|
.session-name {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-badges {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
.mute-icon {
|
||||||
|
color: var(--text-tertiary, #aaa);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unread-badge.muted {
|
||||||
|
background: var(--text-tertiary, #aaa);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 折叠群入口样式
|
||||||
|
.session-item.fold-entry {
|
||||||
|
background: var(--card-inner-bg, rgba(0,0,0,0.03));
|
||||||
|
|
||||||
|
.fold-entry-avatar {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--primary-color, #07c160);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-name {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||||
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2 } 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 } from 'lucide-react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { useChatStore } from '../stores/chatStore'
|
import { useChatStore } from '../stores/chatStore'
|
||||||
@@ -178,15 +178,38 @@ const SessionItem = React.memo(function SessionItem({
|
|||||||
onSelect: (session: ChatSession) => void
|
onSelect: (session: ChatSession) => void
|
||||||
formatTime: (timestamp: number) => string
|
formatTime: (timestamp: number) => string
|
||||||
}) {
|
}) {
|
||||||
// 缓存格式化的时间
|
|
||||||
const timeText = useMemo(() =>
|
const timeText = useMemo(() =>
|
||||||
formatTime(session.lastTimestamp || session.sortTimestamp),
|
formatTime(session.lastTimestamp || session.sortTimestamp),
|
||||||
[formatTime, session.lastTimestamp, session.sortTimestamp]
|
[formatTime, session.lastTimestamp, session.sortTimestamp]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const isFoldEntry = session.username.toLowerCase().includes('placeholder_foldgroup')
|
||||||
|
|
||||||
|
// 折叠入口:专属名称和图标
|
||||||
|
if (isFoldEntry) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`session-item fold-entry`}
|
||||||
|
onClick={() => onSelect(session)}
|
||||||
|
>
|
||||||
|
<div className="fold-entry-avatar">
|
||||||
|
<FolderClosed size={22} />
|
||||||
|
</div>
|
||||||
|
<div className="session-info">
|
||||||
|
<div className="session-top">
|
||||||
|
<span className="session-name">折叠的群聊</span>
|
||||||
|
</div>
|
||||||
|
<div className="session-bottom">
|
||||||
|
<span className="session-summary">{session.summary || ''}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`session-item ${isActive ? 'active' : ''}`}
|
className={`session-item ${isActive ? 'active' : ''} ${session.isMuted ? 'muted' : ''}`}
|
||||||
onClick={() => onSelect(session)}
|
onClick={() => onSelect(session)}
|
||||||
>
|
>
|
||||||
<Avatar
|
<Avatar
|
||||||
@@ -202,17 +225,19 @@ const SessionItem = React.memo(function SessionItem({
|
|||||||
</div>
|
</div>
|
||||||
<div className="session-bottom">
|
<div className="session-bottom">
|
||||||
<span className="session-summary">{session.summary || '暂无消息'}</span>
|
<span className="session-summary">{session.summary || '暂无消息'}</span>
|
||||||
{session.unreadCount > 0 && (
|
<div className="session-badges">
|
||||||
<span className="unread-badge">
|
{session.isMuted && <BellOff size={12} className="mute-icon" />}
|
||||||
{session.unreadCount > 99 ? '99+' : session.unreadCount}
|
{session.unreadCount > 0 && (
|
||||||
</span>
|
<span className={`unread-badge ${session.isMuted ? 'muted' : ''}`}>
|
||||||
)}
|
{session.unreadCount > 99 ? '99+' : session.unreadCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}, (prevProps, nextProps) => {
|
}, (prevProps, nextProps) => {
|
||||||
// 自定义比较:只在关键属性变化时重渲染
|
|
||||||
return (
|
return (
|
||||||
prevProps.session.username === nextProps.session.username &&
|
prevProps.session.username === nextProps.session.username &&
|
||||||
prevProps.session.displayName === nextProps.session.displayName &&
|
prevProps.session.displayName === nextProps.session.displayName &&
|
||||||
@@ -221,6 +246,7 @@ const SessionItem = React.memo(function SessionItem({
|
|||||||
prevProps.session.unreadCount === nextProps.session.unreadCount &&
|
prevProps.session.unreadCount === nextProps.session.unreadCount &&
|
||||||
prevProps.session.lastTimestamp === nextProps.session.lastTimestamp &&
|
prevProps.session.lastTimestamp === nextProps.session.lastTimestamp &&
|
||||||
prevProps.session.sortTimestamp === nextProps.session.sortTimestamp &&
|
prevProps.session.sortTimestamp === nextProps.session.sortTimestamp &&
|
||||||
|
prevProps.session.isMuted === nextProps.session.isMuted &&
|
||||||
prevProps.isActive === nextProps.isActive
|
prevProps.isActive === nextProps.isActive
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -288,6 +314,7 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
const [copiedField, setCopiedField] = useState<string | null>(null)
|
const [copiedField, setCopiedField] = useState<string | null>(null)
|
||||||
const [highlightedMessageKeys, setHighlightedMessageKeys] = useState<string[]>([])
|
const [highlightedMessageKeys, setHighlightedMessageKeys] = useState<string[]>([])
|
||||||
const [isRefreshingSessions, setIsRefreshingSessions] = useState(false)
|
const [isRefreshingSessions, setIsRefreshingSessions] = useState(false)
|
||||||
|
const [foldedView, setFoldedView] = useState(false) // 是否在"折叠的群聊"视图
|
||||||
const [hasInitialMessages, setHasInitialMessages] = useState(false)
|
const [hasInitialMessages, setHasInitialMessages] = useState(false)
|
||||||
const [noMessageTable, setNoMessageTable] = useState(false)
|
const [noMessageTable, setNoMessageTable] = useState(false)
|
||||||
const [fallbackDisplayName, setFallbackDisplayName] = useState<string | null>(null)
|
const [fallbackDisplayName, setFallbackDisplayName] = useState<string | null>(null)
|
||||||
@@ -995,6 +1022,11 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
|
|
||||||
// 选择会话
|
// 选择会话
|
||||||
const handleSelectSession = (session: ChatSession) => {
|
const handleSelectSession = (session: ChatSession) => {
|
||||||
|
// 点击折叠群入口,切换到折叠群视图
|
||||||
|
if (session.username.toLowerCase().includes('placeholder_foldgroup')) {
|
||||||
|
setFoldedView(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (session.username === currentSessionId) return
|
if (session.username === currentSessionId) return
|
||||||
setCurrentSession(session.username)
|
setCurrentSession(session.username)
|
||||||
setCurrentOffset(0)
|
setCurrentOffset(0)
|
||||||
@@ -1011,27 +1043,11 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
// 搜索过滤
|
// 搜索过滤
|
||||||
const handleSearch = (keyword: string) => {
|
const handleSearch = (keyword: string) => {
|
||||||
setSearchKeyword(keyword)
|
setSearchKeyword(keyword)
|
||||||
if (!Array.isArray(sessions)) {
|
|
||||||
setFilteredSessions([])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!keyword.trim()) {
|
|
||||||
setFilteredSessions(sessions)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const lower = keyword.toLowerCase()
|
|
||||||
const filtered = sessions.filter(s =>
|
|
||||||
s.displayName?.toLowerCase().includes(lower) ||
|
|
||||||
s.username.toLowerCase().includes(lower) ||
|
|
||||||
s.summary.toLowerCase().includes(lower)
|
|
||||||
)
|
|
||||||
setFilteredSessions(filtered)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 关闭搜索框
|
// 关闭搜索框
|
||||||
const handleCloseSearch = () => {
|
const handleCloseSearch = () => {
|
||||||
setSearchKeyword('')
|
setSearchKeyword('')
|
||||||
setFilteredSessions(Array.isArray(sessions) ? sessions : [])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 滚动加载更多 + 显示/隐藏回到底部按钮(优化:节流,避免频繁执行)
|
// 滚动加载更多 + 显示/隐藏回到底部按钮(优化:节流,避免频繁执行)
|
||||||
@@ -1303,23 +1319,40 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
searchKeywordRef.current = searchKeyword
|
searchKeywordRef.current = searchKeyword
|
||||||
}, [searchKeyword])
|
}, [searchKeyword])
|
||||||
|
|
||||||
|
// 普通视图:隐藏 isFolded 的群,保留 placeholder_foldgroup 入口
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!Array.isArray(sessions)) {
|
if (!Array.isArray(sessions)) {
|
||||||
setFilteredSessions([])
|
setFilteredSessions([])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const visible = sessions.filter(s => {
|
||||||
|
if (s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup')) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
if (!searchKeyword.trim()) {
|
if (!searchKeyword.trim()) {
|
||||||
setFilteredSessions(sessions)
|
setFilteredSessions(visible)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const lower = searchKeyword.toLowerCase()
|
const lower = searchKeyword.toLowerCase()
|
||||||
const filtered = sessions.filter(s =>
|
setFilteredSessions(visible.filter(s =>
|
||||||
|
s.displayName?.toLowerCase().includes(lower) ||
|
||||||
|
s.username.toLowerCase().includes(lower) ||
|
||||||
|
s.summary.toLowerCase().includes(lower)
|
||||||
|
))
|
||||||
|
}, [sessions, searchKeyword, setFilteredSessions])
|
||||||
|
|
||||||
|
// 折叠群列表(独立计算,供折叠 panel 使用)
|
||||||
|
const foldedSessions = useMemo(() => {
|
||||||
|
if (!Array.isArray(sessions)) return []
|
||||||
|
const folded = sessions.filter(s => s.isFolded)
|
||||||
|
if (!searchKeyword.trim() || !foldedView) return folded
|
||||||
|
const lower = searchKeyword.toLowerCase()
|
||||||
|
return folded.filter(s =>
|
||||||
s.displayName?.toLowerCase().includes(lower) ||
|
s.displayName?.toLowerCase().includes(lower) ||
|
||||||
s.username.toLowerCase().includes(lower) ||
|
s.username.toLowerCase().includes(lower) ||
|
||||||
s.summary.toLowerCase().includes(lower)
|
s.summary.toLowerCase().includes(lower)
|
||||||
)
|
)
|
||||||
setFilteredSessions(filtered)
|
}, [sessions, searchKeyword, foldedView])
|
||||||
}, [sessions, searchKeyword, setFilteredSessions])
|
|
||||||
|
|
||||||
|
|
||||||
// 格式化会话时间(相对时间)- 使用 useMemo 缓存,避免每次渲染都计算
|
// 格式化会话时间(相对时间)- 使用 useMemo 缓存,避免每次渲染都计算
|
||||||
@@ -1984,26 +2017,41 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
ref={sidebarRef}
|
ref={sidebarRef}
|
||||||
style={{ width: sidebarWidth, minWidth: sidebarWidth, maxWidth: sidebarWidth }}
|
style={{ width: sidebarWidth, minWidth: sidebarWidth, maxWidth: sidebarWidth }}
|
||||||
>
|
>
|
||||||
<div className="session-header">
|
<div className={`session-header session-header-viewport ${foldedView ? 'folded' : ''}`}>
|
||||||
<div className="search-row">
|
{/* 普通 header */}
|
||||||
<div className="search-box expanded">
|
<div className="session-header-panel main-header">
|
||||||
<Search size={14} />
|
<div className="search-row">
|
||||||
<input
|
<div className="search-box expanded">
|
||||||
ref={searchInputRef}
|
<Search size={14} />
|
||||||
type="text"
|
<input
|
||||||
placeholder="搜索"
|
ref={searchInputRef}
|
||||||
value={searchKeyword}
|
type="text"
|
||||||
onChange={(e) => handleSearch(e.target.value)}
|
placeholder="搜索"
|
||||||
/>
|
value={searchKeyword}
|
||||||
{searchKeyword && (
|
onChange={(e) => handleSearch(e.target.value)}
|
||||||
<button className="close-search" onClick={handleCloseSearch}>
|
/>
|
||||||
<X size={12} />
|
{searchKeyword && (
|
||||||
</button>
|
<button className="close-search" onClick={handleCloseSearch}>
|
||||||
)}
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button className="icon-btn refresh-btn" onClick={handleRefresh} disabled={isLoadingSessions || isRefreshingSessions}>
|
||||||
|
<RefreshCw size={16} className={(isLoadingSessions || isRefreshingSessions) ? 'spin' : ''} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 折叠群 header */}
|
||||||
|
<div className="session-header-panel folded-header">
|
||||||
|
<div className="folded-view-header">
|
||||||
|
<button className="icon-btn back-btn" onClick={() => setFoldedView(false)}>
|
||||||
|
<ChevronLeft size={18} />
|
||||||
|
</button>
|
||||||
|
<span className="folded-view-title">
|
||||||
|
<Users size={14} />
|
||||||
|
折叠的群聊
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button className="icon-btn refresh-btn" onClick={handleRefresh} disabled={isLoadingSessions || isRefreshingSessions}>
|
|
||||||
<RefreshCw size={16} className={(isLoadingSessions || isRefreshingSessions) ? 'spin' : ''} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2018,7 +2066,6 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
{/* ... (previous content) ... */}
|
{/* ... (previous content) ... */}
|
||||||
{isLoadingSessions ? (
|
{isLoadingSessions ? (
|
||||||
<div className="loading-sessions">
|
<div className="loading-sessions">
|
||||||
{/* ... (skeleton items) ... */}
|
|
||||||
{[1, 2, 3, 4, 5].map(i => (
|
{[1, 2, 3, 4, 5].map(i => (
|
||||||
<div key={i} className="skeleton-item">
|
<div key={i} className="skeleton-item">
|
||||||
<div className="skeleton-avatar" />
|
<div className="skeleton-avatar" />
|
||||||
@@ -2029,36 +2076,65 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : Array.isArray(filteredSessions) && filteredSessions.length > 0 ? (
|
|
||||||
<div
|
|
||||||
className="session-list"
|
|
||||||
ref={sessionListRef}
|
|
||||||
onScroll={() => {
|
|
||||||
isScrollingRef.current = true
|
|
||||||
if (sessionScrollTimeoutRef.current) {
|
|
||||||
clearTimeout(sessionScrollTimeoutRef.current)
|
|
||||||
}
|
|
||||||
sessionScrollTimeoutRef.current = window.setTimeout(() => {
|
|
||||||
isScrollingRef.current = false
|
|
||||||
sessionScrollTimeoutRef.current = null
|
|
||||||
}, 200)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{filteredSessions.map(session => (
|
|
||||||
<SessionItem
|
|
||||||
key={session.username}
|
|
||||||
session={session}
|
|
||||||
isActive={currentSessionId === session.username}
|
|
||||||
onSelect={handleSelectSession}
|
|
||||||
formatTime={formatSessionTime}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="empty-sessions">
|
<div className={`session-list-viewport ${foldedView ? 'folded' : ''}`}>
|
||||||
<MessageSquare />
|
{/* 普通会话列表 */}
|
||||||
<p>暂无会话</p>
|
<div className="session-list-panel main-panel">
|
||||||
<p className="hint">请先在数据管理页面解密数据库</p>
|
{Array.isArray(filteredSessions) && filteredSessions.length > 0 ? (
|
||||||
|
<div
|
||||||
|
className="session-list"
|
||||||
|
ref={sessionListRef}
|
||||||
|
onScroll={() => {
|
||||||
|
isScrollingRef.current = true
|
||||||
|
if (sessionScrollTimeoutRef.current) {
|
||||||
|
clearTimeout(sessionScrollTimeoutRef.current)
|
||||||
|
}
|
||||||
|
sessionScrollTimeoutRef.current = window.setTimeout(() => {
|
||||||
|
isScrollingRef.current = false
|
||||||
|
sessionScrollTimeoutRef.current = null
|
||||||
|
}, 200)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{filteredSessions.map(session => (
|
||||||
|
<SessionItem
|
||||||
|
key={session.username}
|
||||||
|
session={session}
|
||||||
|
isActive={currentSessionId === session.username}
|
||||||
|
onSelect={handleSelectSession}
|
||||||
|
formatTime={formatSessionTime}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="empty-sessions">
|
||||||
|
<MessageSquare />
|
||||||
|
<p>暂无会话</p>
|
||||||
|
<p className="hint">请先在数据管理页面解密数据库</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 折叠群列表 */}
|
||||||
|
<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}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="empty-sessions">
|
||||||
|
<Users size={32} />
|
||||||
|
<p>没有折叠的群聊</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export interface ChatSession {
|
|||||||
lastMsgSender?: string
|
lastMsgSender?: string
|
||||||
lastSenderDisplayName?: string
|
lastSenderDisplayName?: string
|
||||||
selfWxid?: string // Helper field to avoid extra API calls
|
selfWxid?: string // Helper field to avoid extra API calls
|
||||||
|
isFolded?: boolean // 是否已折叠进"折叠的群聊"
|
||||||
|
isMuted?: boolean // 是否开启免打扰
|
||||||
}
|
}
|
||||||
|
|
||||||
// 联系人
|
// 联系人
|
||||||
|
|||||||
Reference in New Issue
Block a user