修复了一些问题,并引入了新的问题

This commit is contained in:
cc
2026-02-21 12:55:44 +08:00
parent dafde2eaba
commit 4577b4e955
7 changed files with 350 additions and 139 deletions

View File

@@ -103,7 +103,7 @@ export interface ContactInfo {
remark?: string remark?: string
nickname?: string nickname?: string
avatarUrl?: string avatarUrl?: string
type: 'friend' | 'group' | 'official' | 'other' type: 'friend' | 'group' | 'official' | 'deleted_friend' | 'other'
} }
// 表情包缓存 // 表情包缓存
@@ -603,7 +603,7 @@ class ChatService {
// 使用execQuery直接查询加密的contact.db // 使用execQuery直接查询加密的contact.db
// kind='contact', path=null表示使用已打开的contact.db // kind='contact', path=null表示使用已打开的contact.db
const contactQuery = ` const contactQuery = `
SELECT username, remark, nick_name, alias, local_type SELECT username, remark, nick_name, alias, local_type, flag
FROM contact FROM contact
` `
@@ -663,28 +663,31 @@ class ChatService {
} }
// 判断类型 - 正确规则wxid开头且有alias的是好友 // 判断类型 - 正确规则wxid开头且有alias的是好友
let type: 'friend' | 'group' | 'official' | 'other' = 'other' let type: 'friend' | 'group' | 'official' | 'deleted_friend' | 'other' = 'other'
const localType = row.local_type || 0 const localType = row.local_type || 0
const flag = Number(row.flag ?? 0)
if (username.includes('@chatroom')) { if (username.includes('@chatroom')) {
type = 'group' type = 'group'
} else if (username.startsWith('gh_')) { } else if (username.startsWith('gh_')) {
if (flag === 0) continue
type = 'official' type = 'official'
} else if (localType === 3 || localType === 4) { } else if (localType === 3 || localType === 4) {
if (flag === 0) continue
if (flag === 4) continue
type = 'official' type = 'official'
} else if (username.startsWith('wxid_') && row.alias) { } else if (username.startsWith('wxid_') && row.alias) {
// wxid开头且有alias的是好友 type = flag === 0 ? 'deleted_friend' : 'friend'
type = 'friend'
} else if (localType === 1) { } else if (localType === 1) {
// local_type=1 也是好友 type = flag === 0 ? 'deleted_friend' : 'friend'
type = 'friend'
} else if (localType === 2) { } else if (localType === 2) {
// local_type=2 是群成员但非好友,跳过 // local_type=2 是群成员但非好友,跳过
continue continue
} else if (localType === 0) { } else if (localType === 0) {
// local_type=0 可能是好友或其他,检查是否有备注或昵称 // local_type=0 可能是好友或其他,检查是否有备注或昵称
if (row.remark || row.nick_name) { if (row.remark || row.nick_name) {
type = 'friend' type = flag === 0 ? 'deleted_friend' : 'friend'
} else { } else {
continue continue
} }

View File

@@ -6,6 +6,13 @@
backdrop-filter: blur(20px); backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
border: 1px solid var(--border-light); border: 1px solid var(--border-light);
// 浅色模式下使用不透明背景,避免透明窗口中通知过于透明
[data-mode="light"] &,
:not([data-mode]) & {
background: rgba(255, 255, 255, 1);
}
border-radius: 12px; border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
padding: 12px; padding: 12px;
@@ -39,7 +46,7 @@
backdrop-filter: none !important; backdrop-filter: none !important;
-webkit-backdrop-filter: none !important; -webkit-backdrop-filter: none !important;
// Ensure background is solid // 确保背景不透明
background: var(--bg-secondary, #2c2c2c); background: var(--bg-secondary, #2c2c2c);
color: var(--text-primary, #ffffff); color: var(--text-primary, #ffffff);

View File

@@ -1288,6 +1288,21 @@
z-index: 2; z-index: 2;
} }
.empty-chat-inline {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
padding: 60px 0;
color: var(--text-tertiary);
font-size: 14px;
svg {
opacity: 0.4;
}
}
.message-list * { .message-list * {
-webkit-app-region: no-drag !important; -webkit-app-region: no-drag !important;
} }

View File

@@ -1261,6 +1261,7 @@ function ChatPage(_props: ChatPageProps) {
useEffect(() => { useEffect(() => {
if (currentSessionId && messages.length === 0 && !isLoadingMessages && !isLoadingMore) { if (currentSessionId && messages.length === 0 && !isLoadingMessages && !isLoadingMore) {
setHasInitialMessages(false)
loadMessages(currentSessionId, 0) loadMessages(currentSessionId, 0)
} }
}, [currentSessionId, messages.length, isLoadingMessages, isLoadingMore]) }, [currentSessionId, messages.length, isLoadingMessages, isLoadingMore])
@@ -1327,8 +1328,21 @@ function ChatPage(_props: ChatPageProps) {
return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}` return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`
}, []) }, [])
// 获取当前会话信息 // 获取当前会话信息(从通讯录跳转时可能不在 sessions 列表中,构造 fallback
const currentSession = Array.isArray(sessions) ? sessions.find(s => s.username === currentSessionId) : undefined const currentSession = (() => {
const found = Array.isArray(sessions) ? sessions.find(s => s.username === currentSessionId) : undefined
if (found || !currentSessionId) return found
return {
username: currentSessionId,
type: 0,
unreadCount: 0,
summary: '',
sortTimestamp: 0,
lastTimestamp: 0,
lastMsgType: 0,
displayName: currentSessionId,
} as ChatSession
})()
// 判断是否为群聊 // 判断是否为群聊
const isGroupChat = (username: string) => username.includes('@chatroom') const isGroupChat = (username: string) => username.includes('@chatroom')
@@ -2048,6 +2062,13 @@ function ChatPage(_props: ChatPageProps) {
</div> </div>
)} )}
{!isLoadingMessages && messages.length === 0 && !hasMoreMessages && (
<div className="empty-chat-inline">
<MessageSquare size={32} />
<span></span>
</div>
)}
{messages.map((msg, index) => { {messages.map((msg, index) => {
const prevMsg = index > 0 ? messages[index - 1] : undefined const prevMsg = index > 0 ? messages[index - 1] : undefined
const showDateDivider = shouldShowDateDivider(msg, prevMsg) const showDateDivider = shouldShowDateDivider(msg, prevMsg)

View File

@@ -7,8 +7,8 @@
// 左侧联系人面板 // 左侧联系人面板
.contacts-panel { .contacts-panel {
width: 380px; width: 400px;
min-width: 380px; min-width: 400px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
border-right: 1px solid var(--border-color); border-right: 1px solid var(--border-color);
@@ -55,6 +55,11 @@
.spin { .spin {
animation: contactsSpin 1s linear infinite; animation: contactsSpin 1s linear infinite;
} }
&.export-mode-btn.active {
background: var(--primary);
color: #fff;
}
} }
} }
@@ -231,8 +236,8 @@
padding: 12px; padding: 12px;
border-radius: 10px; border-radius: 10px;
transition: all 0.2s; transition: all 0.2s;
margin-bottom: 4px;
cursor: pointer; cursor: pointer;
margin-bottom: 4px;
&:hover { &:hover {
background: var(--bg-hover); background: var(--bg-hover);
@@ -242,6 +247,10 @@
background: color-mix(in srgb, var(--primary) 12%, transparent); background: color-mix(in srgb, var(--primary) 12%, transparent);
} }
&.active {
background: var(--bg-tertiary);
}
.contact-select { .contact-select {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -334,6 +343,93 @@
} }
} }
// 右侧详情面板内的联系人资料
.detail-profile {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding-bottom: 24px;
border-bottom: 1px solid var(--border-color);
margin-bottom: 20px;
.detail-avatar {
width: 80px;
height: 80px;
border-radius: 16px;
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
img { width: 100%; height: 100%; object-fit: cover; }
span { color: #fff; font-size: 28px; font-weight: 600; }
}
.detail-name {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
}
}
.detail-info-list {
margin-bottom: 24px;
.detail-row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 0;
font-size: 13px;
border-bottom: 1px solid var(--border-color);
&:last-child { border-bottom: none; }
}
.detail-label {
color: var(--text-tertiary);
min-width: 48px;
flex-shrink: 0;
}
.detail-value {
color: var(--text-primary);
word-break: break-all;
}
}
.goto-chat-btn {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px;
background: var(--primary);
color: #fff;
border: none;
border-radius: 10px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
&:hover { background: var(--primary-hover); }
}
.empty-detail {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: var(--text-tertiary);
font-size: 14px;
}
// 右侧设置面板 // 右侧设置面板
.settings-panel { .settings-panel {
flex: 1; flex: 1;

View File

@@ -1,5 +1,7 @@
import { useState, useEffect, useCallback, useRef } from 'react' import { useState, useEffect, useCallback, useRef } from 'react'
import { Search, RefreshCw, X, User, Users, MessageSquare, Loader2, FolderOpen, Download, ChevronDown } from 'lucide-react' import { useNavigate } from 'react-router-dom'
import { Search, RefreshCw, X, User, Users, MessageSquare, Loader2, FolderOpen, Download, ChevronDown, MessageCircle, UserX } from 'lucide-react'
import { useChatStore } from '../stores/chatStore'
import './ContactsPage.scss' import './ContactsPage.scss'
interface ContactInfo { interface ContactInfo {
@@ -8,7 +10,7 @@ interface ContactInfo {
remark?: string remark?: string
nickname?: string nickname?: string
avatarUrl?: string avatarUrl?: string
type: 'friend' | 'group' | 'official' | 'other' type: 'friend' | 'group' | 'official' | 'deleted_friend' | 'other'
} }
function ContactsPage() { function ContactsPage() {
@@ -20,9 +22,16 @@ function ContactsPage() {
const [contactTypes, setContactTypes] = useState({ const [contactTypes, setContactTypes] = useState({
friends: true, friends: true,
groups: true, groups: true,
officials: true officials: true,
deletedFriends: false
}) })
// 导出模式与查看详情
const [exportMode, setExportMode] = useState(false)
const [selectedContact, setSelectedContact] = useState<ContactInfo | null>(null)
const navigate = useNavigate()
const { setCurrentSession } = useChatStore()
// 导出相关状态 // 导出相关状态
const [exportFormat, setExportFormat] = useState<'json' | 'csv' | 'vcf'>('json') const [exportFormat, setExportFormat] = useState<'json' | 'csv' | 'vcf'>('json')
const [exportAvatars, setExportAvatars] = useState(true) const [exportAvatars, setExportAvatars] = useState(true)
@@ -85,6 +94,7 @@ function ContactsPage() {
if (c.type === 'friend' && !contactTypes.friends) return false if (c.type === 'friend' && !contactTypes.friends) return false
if (c.type === 'group' && !contactTypes.groups) return false if (c.type === 'group' && !contactTypes.groups) return false
if (c.type === 'official' && !contactTypes.officials) return false if (c.type === 'official' && !contactTypes.officials) return false
if (c.type === 'deleted_friend' && !contactTypes.deletedFriends) return false
return true return true
}) })
@@ -154,6 +164,7 @@ function ContactsPage() {
case 'friend': return <User size={14} /> case 'friend': return <User size={14} />
case 'group': return <Users size={14} /> case 'group': return <Users size={14} />
case 'official': return <MessageSquare size={14} /> case 'official': return <MessageSquare size={14} />
case 'deleted_friend': return <UserX size={14} />
default: return <User size={14} /> default: return <User size={14} />
} }
} }
@@ -163,6 +174,7 @@ function ContactsPage() {
case 'friend': return '好友' case 'friend': return '好友'
case 'group': return '群聊' case 'group': return '群聊'
case 'official': return '公众号' case 'official': return '公众号'
case 'deleted_friend': return '已删除'
default: return '其他' default: return '其他'
} }
} }
@@ -236,10 +248,19 @@ function ContactsPage() {
<div className="contacts-panel"> <div className="contacts-panel">
<div className="panel-header"> <div className="panel-header">
<h2></h2> <h2></h2>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<button
className={`icon-btn export-mode-btn ${exportMode ? 'active' : ''}`}
onClick={() => { setExportMode(!exportMode); setSelectedContact(null) }}
title={exportMode ? '退出导出模式' : '进入导出模式'}
>
<Download size={18} />
</button>
<button className="icon-btn" onClick={loadContacts} disabled={isLoading}> <button className="icon-btn" onClick={loadContacts} disabled={isLoading}>
<RefreshCw size={18} className={isLoading ? 'spin' : ''} /> <RefreshCw size={18} className={isLoading ? 'spin' : ''} />
</button> </button>
</div> </div>
</div>
<div className="search-bar"> <div className="search-bar">
<Search size={16} /> <Search size={16} />
@@ -258,37 +279,28 @@ function ContactsPage() {
<div className="type-filters"> <div className="type-filters">
<label className={`filter-chip ${contactTypes.friends ? 'active' : ''}`}> <label className={`filter-chip ${contactTypes.friends ? 'active' : ''}`}>
<input <input type="checkbox" checked={contactTypes.friends} onChange={e => setContactTypes({ ...contactTypes, friends: e.target.checked })} />
type="checkbox" <User size={16} /><span></span>
checked={contactTypes.friends}
onChange={e => setContactTypes({ ...contactTypes, friends: e.target.checked })}
/>
<User size={16} />
<span></span>
</label> </label>
<label className={`filter-chip ${contactTypes.groups ? 'active' : ''}`}> <label className={`filter-chip ${contactTypes.groups ? 'active' : ''}`}>
<input <input type="checkbox" checked={contactTypes.groups} onChange={e => setContactTypes({ ...contactTypes, groups: e.target.checked })} />
type="checkbox" <Users size={16} /><span></span>
checked={contactTypes.groups}
onChange={e => setContactTypes({ ...contactTypes, groups: e.target.checked })}
/>
<Users size={16} />
<span></span>
</label> </label>
<label className={`filter-chip ${contactTypes.officials ? 'active' : ''}`}> <label className={`filter-chip ${contactTypes.officials ? 'active' : ''}`}>
<input <input type="checkbox" checked={contactTypes.officials} onChange={e => setContactTypes({ ...contactTypes, officials: e.target.checked })} />
type="checkbox" <MessageSquare size={16} /><span></span>
checked={contactTypes.officials} </label>
onChange={e => setContactTypes({ ...contactTypes, officials: e.target.checked })} <label className={`filter-chip ${contactTypes.deletedFriends ? 'active' : ''}`}>
/> <input type="checkbox" checked={contactTypes.deletedFriends} onChange={e => setContactTypes({ ...contactTypes, deletedFriends: e.target.checked })} />
<MessageSquare size={16} /> <UserX size={16} /><span></span>
<span></span>
</label> </label>
</div> </div>
<div className="contacts-count"> <div className="contacts-count">
{filteredContacts.length} {filteredContacts.length}
</div> </div>
{exportMode && (
<div className="selection-toolbar"> <div className="selection-toolbar">
<label className="checkbox-item"> <label className="checkbox-item">
<input <input
@@ -301,6 +313,7 @@ function ContactsPage() {
</label> </label>
<span className="selection-count"> {selectedUsernames.size} {selectedInFilteredCount} / {filteredContacts.length}</span> <span className="selection-count"> {selectedUsernames.size} {selectedInFilteredCount} / {filteredContacts.length}</span>
</div> </div>
)}
{isLoading ? ( {isLoading ? (
<div className="loading-state"> <div className="loading-state">
@@ -314,20 +327,29 @@ function ContactsPage() {
) : ( ) : (
<div className="contacts-list"> <div className="contacts-list">
{filteredContacts.map(contact => { {filteredContacts.map(contact => {
const isSelected = selectedUsernames.has(contact.username) const isChecked = selectedUsernames.has(contact.username)
const isActive = !exportMode && selectedContact?.username === contact.username
return ( return (
<div <div
key={contact.username} key={contact.username}
className={`contact-item ${isSelected ? 'selected' : ''}`} className={`contact-item ${exportMode && isChecked ? 'selected' : ''} ${isActive ? 'active' : ''}`}
onClick={() => toggleContactSelected(contact.username, !isSelected)} onClick={() => {
if (exportMode) {
toggleContactSelected(contact.username, !isChecked)
} else {
setSelectedContact(isActive ? null : contact)
}
}}
> >
{exportMode && (
<label className="contact-select" onClick={e => e.stopPropagation()}> <label className="contact-select" onClick={e => e.stopPropagation()}>
<input <input
type="checkbox" type="checkbox"
checked={isSelected} checked={isChecked}
onChange={e => toggleContactSelected(contact.username, e.target.checked)} onChange={e => toggleContactSelected(contact.username, e.target.checked)}
/> />
</label> </label>
)}
<div className="contact-avatar"> <div className="contact-avatar">
{contact.avatarUrl ? ( {contact.avatarUrl ? (
<img src={contact.avatarUrl} alt="" /> <img src={contact.avatarUrl} alt="" />
@@ -352,7 +374,8 @@ function ContactsPage() {
)} )}
</div> </div>
{/* 右侧:导出设置 */} {/* 右侧面板 */}
{exportMode ? (
<div className="settings-panel"> <div className="settings-panel">
<div className="panel-header"> <div className="panel-header">
<h2></h2> <h2></h2>
@@ -394,11 +417,7 @@ function ContactsPage() {
<div className="setting-section"> <div className="setting-section">
<h3></h3> <h3></h3>
<label className="checkbox-item"> <label className="checkbox-item">
<input <input type="checkbox" checked={exportAvatars} onChange={e => setExportAvatars(e.target.checked)} />
type="checkbox"
checked={exportAvatars}
onChange={e => setExportAvatars(e.target.checked)}
/>
<span></span> <span></span>
</label> </label>
</div> </div>
@@ -423,19 +442,61 @@ function ContactsPage() {
disabled={!exportFolder || isExporting || selectedUsernames.size === 0} disabled={!exportFolder || isExporting || selectedUsernames.size === 0}
> >
{isExporting ? ( {isExporting ? (
<> <><Loader2 size={18} className="spin" /><span>...</span></>
<Loader2 size={18} className="spin" />
<span>...</span>
</>
) : ( ) : (
<> <><Download size={18} /><span></span></>
<Download size={18} />
<span></span>
</>
)} )}
</button> </button>
</div> </div>
</div> </div>
) : selectedContact ? (
<div className="settings-panel">
<div className="panel-header">
<h2></h2>
</div>
<div className="settings-content">
<div className="detail-profile">
<div className="detail-avatar">
{selectedContact.avatarUrl ? (
<img src={selectedContact.avatarUrl} alt="" />
) : (
<span>{getAvatarLetter(selectedContact.displayName)}</span>
)}
</div>
<div className="detail-name">{selectedContact.displayName}</div>
<div className={`contact-type ${selectedContact.type}`}>
{getContactTypeIcon(selectedContact.type)}
<span>{getContactTypeName(selectedContact.type)}</span>
</div>
</div>
<div className="detail-info-list">
<div className="detail-row"><span className="detail-label"></span><span className="detail-value">{selectedContact.username}</span></div>
<div className="detail-row"><span className="detail-label"></span><span className="detail-value">{selectedContact.nickname || selectedContact.displayName}</span></div>
{selectedContact.remark && <div className="detail-row"><span className="detail-label"></span><span className="detail-value">{selectedContact.remark}</span></div>}
<div className="detail-row"><span className="detail-label"></span><span className="detail-value">{getContactTypeName(selectedContact.type)}</span></div>
</div>
<button
className="goto-chat-btn"
onClick={() => {
setCurrentSession(selectedContact.username)
navigate('/chat')
}}
>
<MessageCircle size={18} />
<span></span>
</button>
</div>
</div>
) : (
<div className="settings-panel">
<div className="empty-detail">
<User size={48} />
<span></span>
</div>
</div>
)}
</div> </div>
) )
} }

View File

@@ -1,9 +1,11 @@
import { useEffect, useState, useRef } from 'react' import { useEffect, useState, useRef } from 'react'
import { NotificationToast, type NotificationData } from '../components/NotificationToast' import { NotificationToast, type NotificationData } from '../components/NotificationToast'
import { useThemeStore } from '../stores/themeStore'
import '../components/NotificationToast.scss' import '../components/NotificationToast.scss'
import './NotificationWindow.scss' import './NotificationWindow.scss'
export default function NotificationWindow() { export default function NotificationWindow() {
const { currentTheme, themeMode } = useThemeStore()
const [notification, setNotification] = useState<NotificationData | null>(null) const [notification, setNotification] = useState<NotificationData | null>(null)
const [prevNotification, setPrevNotification] = useState<NotificationData | null>(null) const [prevNotification, setPrevNotification] = useState<NotificationData | null>(null)
@@ -17,6 +19,12 @@ export default function NotificationWindow() {
const notificationRef = useRef<NotificationData | null>(null) const notificationRef = useRef<NotificationData | null>(null)
// 应用主题到通知窗口
useEffect(() => {
document.documentElement.setAttribute('data-theme', currentTheme)
document.documentElement.setAttribute('data-mode', themeMode)
}, [currentTheme, themeMode])
useEffect(() => { useEffect(() => {
notificationRef.current = notification notificationRef.current = notification
}, [notification]) }, [notification])