mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 07:16:51 +00:00
feat: 宇宙超级无敌牛且帅气到爆炸的功能更新和优化
This commit is contained in:
258
src/components/GlobalSessionMonitor.tsx
Normal file
258
src/components/GlobalSessionMonitor.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useChatStore } from '../stores/chatStore'
|
||||
import type { ChatSession } from '../types/models'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
export function GlobalSessionMonitor() {
|
||||
const navigate = useNavigate()
|
||||
const {
|
||||
sessions,
|
||||
setSessions,
|
||||
currentSessionId,
|
||||
appendMessages,
|
||||
messages
|
||||
} = useChatStore()
|
||||
|
||||
const sessionsRef = useRef(sessions)
|
||||
|
||||
// 保持 ref 同步
|
||||
useEffect(() => {
|
||||
sessionsRef.current = sessions
|
||||
}, [sessions])
|
||||
|
||||
// 去重辅助函数:获取消息 key
|
||||
const getMessageKey = (msg: any) => {
|
||||
if (msg.localId && msg.localId > 0) return `l:${msg.localId}`
|
||||
return `t:${msg.createTime}:${msg.sortSeq || 0}:${msg.serverId || 0}`
|
||||
}
|
||||
|
||||
// 处理数据库变更
|
||||
useEffect(() => {
|
||||
const handleDbChange = (_event: any, data: { type: string; json: string }) => {
|
||||
try {
|
||||
const payload = JSON.parse(data.json)
|
||||
const tableName = payload.table
|
||||
|
||||
// 只关注 Session 表
|
||||
if (tableName === 'Session' || tableName === 'session') {
|
||||
refreshSessions()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('解析数据库变更失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
if (window.electronAPI.chat.onWcdbChange) {
|
||||
const removeListener = window.electronAPI.chat.onWcdbChange(handleDbChange)
|
||||
return () => {
|
||||
removeListener()
|
||||
}
|
||||
}
|
||||
return () => { }
|
||||
}, []) // 空依赖数组 - 主要是静态的
|
||||
|
||||
const refreshSessions = async () => {
|
||||
try {
|
||||
const result = await window.electronAPI.chat.getSessions()
|
||||
if (result.success && result.sessions && Array.isArray(result.sessions)) {
|
||||
const newSessions = result.sessions as ChatSession[]
|
||||
const oldSessions = sessionsRef.current
|
||||
|
||||
// 1. 检测变更并通知
|
||||
checkForNewMessages(oldSessions, newSessions)
|
||||
|
||||
// 2. 更新 store
|
||||
setSessions(newSessions)
|
||||
|
||||
// 3. 如果在活跃会话中,增量刷新消息
|
||||
const currentId = useChatStore.getState().currentSessionId
|
||||
if (currentId) {
|
||||
const currentSessionNew = newSessions.find(s => s.username === currentId)
|
||||
const currentSessionOld = oldSessions.find(s => s.username === currentId)
|
||||
|
||||
if (currentSessionNew && (!currentSessionOld || currentSessionNew.lastTimestamp > currentSessionOld.lastTimestamp)) {
|
||||
void handleActiveSessionRefresh(currentId)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('全局会话刷新失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const checkForNewMessages = async (oldSessions: ChatSession[], newSessions: ChatSession[]) => {
|
||||
const oldMap = new Map(oldSessions.map(s => [s.username, s]))
|
||||
|
||||
for (const newSession of newSessions) {
|
||||
const oldSession = oldMap.get(newSession.username)
|
||||
|
||||
// 条件: 新会话或时间戳更新
|
||||
const isCurrentSession = newSession.username === useChatStore.getState().currentSessionId
|
||||
|
||||
if (!isCurrentSession && (!oldSession || newSession.lastTimestamp > oldSession.lastTimestamp)) {
|
||||
// 这是新消息事件
|
||||
|
||||
// 1. 群聊过滤自己发送的消息
|
||||
if (newSession.username.includes('@chatroom')) {
|
||||
// 如果是自己发的消息,不弹通知
|
||||
// 注意:lastMsgSender 需要后端支持返回
|
||||
// 使用宽松比较以处理 wxid_ 前缀差异
|
||||
if (newSession.lastMsgSender && newSession.selfWxid) {
|
||||
const sender = newSession.lastMsgSender.replace(/^wxid_/, '');
|
||||
const self = newSession.selfWxid.replace(/^wxid_/, '');
|
||||
|
||||
// 使用主进程日志打印,方便用户查看
|
||||
const debugInfo = {
|
||||
type: 'NotificationFilter',
|
||||
username: newSession.username,
|
||||
lastMsgSender: newSession.lastMsgSender,
|
||||
selfWxid: newSession.selfWxid,
|
||||
senderClean: sender,
|
||||
selfClean: self,
|
||||
match: sender === self
|
||||
};
|
||||
|
||||
if (window.electronAPI.log?.debug) {
|
||||
window.electronAPI.log.debug(debugInfo);
|
||||
} else {
|
||||
console.log('[NotificationFilter]', debugInfo);
|
||||
}
|
||||
|
||||
if (sender === self) {
|
||||
if (window.electronAPI.log?.debug) {
|
||||
window.electronAPI.log.debug('[NotificationFilter] Filtered own message');
|
||||
} else {
|
||||
console.log('[NotificationFilter] Filtered own message');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
const missingInfo = {
|
||||
type: 'NotificationFilter Missing info',
|
||||
lastMsgSender: newSession.lastMsgSender,
|
||||
selfWxid: newSession.selfWxid
|
||||
};
|
||||
if (window.electronAPI.log?.debug) {
|
||||
window.electronAPI.log.debug(missingInfo);
|
||||
} else {
|
||||
console.log('[NotificationFilter] Missing info:', missingInfo);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let title = newSession.displayName || newSession.username
|
||||
let avatarUrl = newSession.avatarUrl
|
||||
let content = newSession.summary || '[新消息]'
|
||||
|
||||
if (newSession.username.includes('@chatroom')) {
|
||||
// 1. 群聊过滤自己发送的消息
|
||||
// 辅助函数:清理 wxid 后缀 (如 _8602)
|
||||
const cleanWxid = (id: string) => {
|
||||
if (!id) return '';
|
||||
const trimmed = id.trim();
|
||||
// 仅移除末尾的 _xxxx (4位字母数字)
|
||||
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/);
|
||||
return suffixMatch ? suffixMatch[1] : trimmed;
|
||||
}
|
||||
|
||||
if (newSession.lastMsgSender && newSession.selfWxid) {
|
||||
const senderClean = cleanWxid(newSession.lastMsgSender);
|
||||
const selfClean = cleanWxid(newSession.selfWxid);
|
||||
const match = senderClean === selfClean;
|
||||
|
||||
if (match) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 群聊显示发送者名字 (放在内容中: "Name: Message")
|
||||
// 标题保持为群聊名称 (title 变量)
|
||||
if (newSession.lastSenderDisplayName) {
|
||||
content = `${newSession.lastSenderDisplayName}: ${content}`
|
||||
}
|
||||
}
|
||||
|
||||
// 修复 "Random User" 的逻辑 (缺少具体信息)
|
||||
// 如果标题看起来像 wxid 或没有头像,尝试获取信息
|
||||
const needsEnrichment = !newSession.displayName || !newSession.avatarUrl || newSession.displayName === newSession.username
|
||||
|
||||
if (needsEnrichment && newSession.username) {
|
||||
try {
|
||||
// 尝试丰富或获取联系人详情
|
||||
const contact = await window.electronAPI.chat.getContact(newSession.username)
|
||||
if (contact) {
|
||||
if (contact.remark || contact.nickname) {
|
||||
title = contact.remark || contact.nickname
|
||||
}
|
||||
if (contact.avatarUrl) {
|
||||
avatarUrl = contact.avatarUrl
|
||||
}
|
||||
} else {
|
||||
// 如果不在缓存/数据库中
|
||||
const enrichResult = await window.electronAPI.chat.enrichSessionsContactInfo([newSession.username])
|
||||
if (enrichResult.success && enrichResult.contacts) {
|
||||
const enrichedContact = enrichResult.contacts[newSession.username]
|
||||
if (enrichedContact) {
|
||||
if (enrichedContact.displayName) {
|
||||
title = enrichedContact.displayName
|
||||
}
|
||||
if (enrichedContact.avatarUrl) {
|
||||
avatarUrl = enrichedContact.avatarUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
// 如果仍然没有有效名称,再尝试一次获取
|
||||
if (title === newSession.username || title.startsWith('wxid_')) {
|
||||
const retried = await window.electronAPI.chat.getContact(newSession.username)
|
||||
if (retried) {
|
||||
title = retried.remark || retried.nickname || title
|
||||
avatarUrl = retried.avatarUrl || avatarUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('获取通知的联系人信息失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 最终检查:如果标题仍是 wxid 格式,则跳过通知(避免显示乱跳用户)
|
||||
// 群聊例外,因为群聊 username 包含 @chatroom
|
||||
const isGroupChat = newSession.username.includes('@chatroom')
|
||||
const isWxidTitle = title.startsWith('wxid_') && title === newSession.username
|
||||
if (isWxidTitle && !isGroupChat) {
|
||||
console.warn('[NotificationFilter] 跳过无法识别的用户通知:', newSession.username)
|
||||
continue
|
||||
}
|
||||
|
||||
// 调用 IPC 以显示独立窗口通知
|
||||
window.electronAPI.notification?.show({
|
||||
title: title,
|
||||
content: content,
|
||||
avatarUrl: avatarUrl,
|
||||
sessionId: newSession.username
|
||||
})
|
||||
|
||||
// 我们不再为 Toast 设置本地状态
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleActiveSessionRefresh = async (sessionId: string) => {
|
||||
// 从 ChatPage 复制/调整的逻辑,以保持集中
|
||||
const state = useChatStore.getState()
|
||||
const lastMsg = state.messages[state.messages.length - 1]
|
||||
const minTime = lastMsg?.createTime || 0
|
||||
|
||||
try {
|
||||
const result = await (window.electronAPI.chat as any).getNewMessages(sessionId, minTime)
|
||||
if (result.success && result.messages && result.messages.length > 0) {
|
||||
appendMessages(result.messages, false) // 追加到末尾
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('后台活跃会话刷新失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 此组件不再渲染 UI
|
||||
return null
|
||||
}
|
||||
200
src/components/NotificationToast.scss
Normal file
200
src/components/NotificationToast.scss
Normal file
@@ -0,0 +1,200 @@
|
||||
.notification-toast-container {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
width: 320px;
|
||||
background: var(--bg-secondary);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
|
||||
padding: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
pointer-events: none; // Allow clicking through when hidden
|
||||
|
||||
&.visible {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
&.static {
|
||||
position: relative !important;
|
||||
width: calc(100% - 4px) !important; // Leave 2px margin for anti-aliasing saftey
|
||||
height: auto !important; // Fits content
|
||||
min-height: 0;
|
||||
top: 0 !important;
|
||||
bottom: 0 !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
transform: none !important;
|
||||
margin: 2px !important; // 2px centered margin
|
||||
border-radius: 12px !important; // Rounded corners
|
||||
|
||||
|
||||
// Disable backdrop filter
|
||||
backdrop-filter: none !important;
|
||||
-webkit-backdrop-filter: none !important;
|
||||
|
||||
// Ensure background is solid
|
||||
background: var(--bg-secondary, #2c2c2c);
|
||||
color: var(--text-primary, #ffffff);
|
||||
|
||||
box-shadow: none !important; // NO SHADOW
|
||||
border: 1px solid var(--border-light, rgba(255, 255, 255, 0.1));
|
||||
|
||||
display: flex;
|
||||
padding: 16px;
|
||||
padding-right: 32px; // Make space for close button
|
||||
box-sizing: border-box;
|
||||
|
||||
// Force close button to be visible but transparent background
|
||||
.notification-close {
|
||||
opacity: 1 !important;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
background: transparent !important; // Transparent per user request
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.1) !important; // Subtle hover effect
|
||||
}
|
||||
}
|
||||
|
||||
.notification-time {
|
||||
top: 24px; // Match padding
|
||||
right: 40px; // Left of close button (12px + 20px + 8px)
|
||||
}
|
||||
}
|
||||
|
||||
// Position variants
|
||||
&.bottom-right {
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
transform: translate(0, 20px) scale(0.95);
|
||||
|
||||
&.visible {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
&.top-right {
|
||||
top: 24px;
|
||||
right: 24px;
|
||||
transform: translate(0, -20px) scale(0.95);
|
||||
|
||||
&.visible {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
&.bottom-left {
|
||||
bottom: 24px;
|
||||
left: 24px;
|
||||
transform: translate(0, 20px) scale(0.95);
|
||||
|
||||
&.visible {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
&.top-left {
|
||||
top: 24px;
|
||||
left: 24px;
|
||||
transform: translate(0, -20px) scale(0.95);
|
||||
|
||||
&.visible {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.16) !important;
|
||||
}
|
||||
|
||||
.notification-content {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.notification-avatar {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.notification-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.notification-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
|
||||
.notification-title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%; // 允许缩放
|
||||
flex: 1; // 占据剩余空间
|
||||
min-width: 0; // 关键:允许 flex 子项收缩到内容以下
|
||||
margin-right: 60px; // Make space for absolute time + close button
|
||||
}
|
||||
|
||||
.notification-time {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 36px; // Left of close button (8px + 20px + 8px)
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
.notification-body {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
|
||||
.notification-close {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .notification-close {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
108
src/components/NotificationToast.tsx
Normal file
108
src/components/NotificationToast.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X } from 'lucide-react'
|
||||
import { Avatar } from './Avatar'
|
||||
import './NotificationToast.scss'
|
||||
|
||||
export interface NotificationData {
|
||||
id: string
|
||||
sessionId: string
|
||||
avatarUrl?: string
|
||||
title: string
|
||||
content: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
interface NotificationToastProps {
|
||||
data: NotificationData | null
|
||||
onClose: () => void
|
||||
onClick: (sessionId: string) => void
|
||||
duration?: number
|
||||
position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'
|
||||
isStatic?: boolean
|
||||
initialVisible?: boolean
|
||||
}
|
||||
|
||||
export function NotificationToast({
|
||||
data,
|
||||
onClose,
|
||||
onClick,
|
||||
duration = 5000,
|
||||
position = 'top-right',
|
||||
isStatic = false,
|
||||
initialVisible = false
|
||||
}: NotificationToastProps) {
|
||||
const [isVisible, setIsVisible] = useState(initialVisible)
|
||||
const [currentData, setCurrentData] = useState<NotificationData | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setCurrentData(data)
|
||||
setIsVisible(true)
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setIsVisible(false)
|
||||
// clean up data after animation
|
||||
setTimeout(onClose, 300)
|
||||
}, duration)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
} else {
|
||||
setIsVisible(false)
|
||||
}
|
||||
}, [data, duration, onClose])
|
||||
|
||||
if (!currentData) return null
|
||||
|
||||
const handleClose = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setIsVisible(false)
|
||||
setTimeout(onClose, 300)
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
setIsVisible(false)
|
||||
setTimeout(() => {
|
||||
onClose()
|
||||
onClick(currentData.sessionId)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const content = (
|
||||
<div
|
||||
className={`notification-toast-container ${position} ${isVisible ? 'visible' : ''} ${isStatic ? 'static' : ''}`}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<div className="notification-content">
|
||||
<div className="notification-avatar">
|
||||
<Avatar
|
||||
src={currentData.avatarUrl}
|
||||
name={currentData.title}
|
||||
size={40}
|
||||
/>
|
||||
</div>
|
||||
<div className="notification-text">
|
||||
<div className="notification-header">
|
||||
<span className="notification-title">{currentData.title}</span>
|
||||
<span className="notification-time">
|
||||
{new Date(currentData.timestamp * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="notification-body">
|
||||
{currentData.content}
|
||||
</div>
|
||||
</div>
|
||||
<button className="notification-close" onClick={handleClose}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (isStatic) {
|
||||
return content
|
||||
}
|
||||
|
||||
// Portal to document.body to ensure it's on top
|
||||
return createPortal(content, document.body)
|
||||
}
|
||||
Reference in New Issue
Block a user