Merge pull request #286 from hicccc77/main

Main
This commit is contained in:
cc
2026-02-21 12:56:32 +08:00
committed by GitHub
8 changed files with 356 additions and 142 deletions

View File

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

View File

@@ -1479,13 +1479,17 @@ class ExportService {
result.localPath = thumbResult.localPath
}
// 为每条消息生成稳定且唯一的文件名前缀,避免跨日期/消息发生同名覆盖
const messageId = String(msg.localId || Date.now())
const imageKey = (imageMd5 || imageDatName || 'image').replace(/[^a-zA-Z0-9_-]/g, '')
// 从 data URL 或 file URL 获取实际路径
let sourcePath = result.localPath
if (sourcePath.startsWith('data:')) {
// 是 data URL需要保存为文件
const base64Data = sourcePath.split(',')[1]
const ext = this.getExtFromDataUrl(sourcePath)
const fileName = `${imageMd5 || imageDatName || msg.localId}${ext}`
const fileName = `${messageId}_${imageKey}${ext}`
const destPath = path.join(imagesDir, fileName)
fs.writeFileSync(destPath, Buffer.from(base64Data, 'base64'))
@@ -1501,7 +1505,7 @@ class ExportService {
// 复制文件
if (!fs.existsSync(sourcePath)) return null
const ext = path.extname(sourcePath) || '.jpg'
const fileName = `${imageMd5 || imageDatName || msg.localId}${ext}`
const fileName = `${messageId}_${imageKey}${ext}`
const destPath = path.join(imagesDir, fileName)
if (!fs.existsSync(destPath)) {
@@ -4769,4 +4773,3 @@ class ExportService {
}
export const exportService = new ExportService()

View File

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

View File

@@ -1288,6 +1288,21 @@
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 * {
-webkit-app-region: no-drag !important;
}

View File

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

View File

@@ -7,8 +7,8 @@
// 左侧联系人面板
.contacts-panel {
width: 380px;
min-width: 380px;
width: 400px;
min-width: 400px;
display: flex;
flex-direction: column;
border-right: 1px solid var(--border-color);
@@ -55,6 +55,11 @@
.spin {
animation: contactsSpin 1s linear infinite;
}
&.export-mode-btn.active {
background: var(--primary);
color: #fff;
}
}
}
@@ -231,8 +236,8 @@
padding: 12px;
border-radius: 10px;
transition: all 0.2s;
margin-bottom: 4px;
cursor: pointer;
margin-bottom: 4px;
&:hover {
background: var(--bg-hover);
@@ -242,6 +247,10 @@
background: color-mix(in srgb, var(--primary) 12%, transparent);
}
&.active {
background: var(--bg-tertiary);
}
.contact-select {
display: flex;
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 {
flex: 1;

View File

@@ -1,5 +1,7 @@
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'
interface ContactInfo {
@@ -8,7 +10,7 @@ interface ContactInfo {
remark?: string
nickname?: string
avatarUrl?: string
type: 'friend' | 'group' | 'official' | 'other'
type: 'friend' | 'group' | 'official' | 'deleted_friend' | 'other'
}
function ContactsPage() {
@@ -20,9 +22,16 @@ function ContactsPage() {
const [contactTypes, setContactTypes] = useState({
friends: 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 [exportAvatars, setExportAvatars] = useState(true)
@@ -85,6 +94,7 @@ function ContactsPage() {
if (c.type === 'friend' && !contactTypes.friends) return false
if (c.type === 'group' && !contactTypes.groups) return false
if (c.type === 'official' && !contactTypes.officials) return false
if (c.type === 'deleted_friend' && !contactTypes.deletedFriends) return false
return true
})
@@ -154,6 +164,7 @@ function ContactsPage() {
case 'friend': return <User size={14} />
case 'group': return <Users size={14} />
case 'official': return <MessageSquare size={14} />
case 'deleted_friend': return <UserX size={14} />
default: return <User size={14} />
}
}
@@ -163,6 +174,7 @@ function ContactsPage() {
case 'friend': return '好友'
case 'group': return '群聊'
case 'official': return '公众号'
case 'deleted_friend': return '已删除'
default: return '其他'
}
}
@@ -236,9 +248,18 @@ function ContactsPage() {
<div className="contacts-panel">
<div className="panel-header">
<h2></h2>
<button className="icon-btn" onClick={loadContacts} disabled={isLoading}>
<RefreshCw size={18} className={isLoading ? 'spin' : ''} />
</button>
<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}>
<RefreshCw size={18} className={isLoading ? 'spin' : ''} />
</button>
</div>
</div>
<div className="search-bar">
@@ -258,49 +279,41 @@ function ContactsPage() {
<div className="type-filters">
<label className={`filter-chip ${contactTypes.friends ? 'active' : ''}`}>
<input
type="checkbox"
checked={contactTypes.friends}
onChange={e => setContactTypes({ ...contactTypes, friends: e.target.checked })}
/>
<User size={16} />
<span></span>
<input type="checkbox" checked={contactTypes.friends} onChange={e => setContactTypes({ ...contactTypes, friends: e.target.checked })} />
<User size={16} /><span></span>
</label>
<label className={`filter-chip ${contactTypes.groups ? 'active' : ''}`}>
<input
type="checkbox"
checked={contactTypes.groups}
onChange={e => setContactTypes({ ...contactTypes, groups: e.target.checked })}
/>
<Users size={16} />
<span></span>
<input type="checkbox" checked={contactTypes.groups} onChange={e => setContactTypes({ ...contactTypes, groups: e.target.checked })} />
<Users size={16} /><span></span>
</label>
<label className={`filter-chip ${contactTypes.officials ? 'active' : ''}`}>
<input
type="checkbox"
checked={contactTypes.officials}
onChange={e => setContactTypes({ ...contactTypes, officials: e.target.checked })}
/>
<MessageSquare size={16} />
<span></span>
<input type="checkbox" checked={contactTypes.officials} onChange={e => setContactTypes({ ...contactTypes, officials: e.target.checked })} />
<MessageSquare size={16} /><span></span>
</label>
<label className={`filter-chip ${contactTypes.deletedFriends ? 'active' : ''}`}>
<input type="checkbox" checked={contactTypes.deletedFriends} onChange={e => setContactTypes({ ...contactTypes, deletedFriends: e.target.checked })} />
<UserX size={16} /><span></span>
</label>
</div>
<div className="contacts-count">
{filteredContacts.length}
</div>
<div className="selection-toolbar">
<label className="checkbox-item">
<input
type="checkbox"
checked={allFilteredSelected}
onChange={e => toggleAllFilteredSelected(e.target.checked)}
disabled={filteredContacts.length === 0}
/>
<span></span>
</label>
<span className="selection-count"> {selectedUsernames.size} {selectedInFilteredCount} / {filteredContacts.length}</span>
</div>
{exportMode && (
<div className="selection-toolbar">
<label className="checkbox-item">
<input
type="checkbox"
checked={allFilteredSelected}
onChange={e => toggleAllFilteredSelected(e.target.checked)}
disabled={filteredContacts.length === 0}
/>
<span></span>
</label>
<span className="selection-count"> {selectedUsernames.size} {selectedInFilteredCount} / {filteredContacts.length}</span>
</div>
)}
{isLoading ? (
<div className="loading-state">
@@ -314,20 +327,29 @@ function ContactsPage() {
) : (
<div className="contacts-list">
{filteredContacts.map(contact => {
const isSelected = selectedUsernames.has(contact.username)
const isChecked = selectedUsernames.has(contact.username)
const isActive = !exportMode && selectedContact?.username === contact.username
return (
<div
key={contact.username}
className={`contact-item ${isSelected ? 'selected' : ''}`}
onClick={() => toggleContactSelected(contact.username, !isSelected)}
className={`contact-item ${exportMode && isChecked ? 'selected' : ''} ${isActive ? 'active' : ''}`}
onClick={() => {
if (exportMode) {
toggleContactSelected(contact.username, !isChecked)
} else {
setSelectedContact(isActive ? null : contact)
}
}}
>
<label className="contact-select" onClick={e => e.stopPropagation()}>
<input
type="checkbox"
checked={isSelected}
onChange={e => toggleContactSelected(contact.username, e.target.checked)}
/>
</label>
{exportMode && (
<label className="contact-select" onClick={e => e.stopPropagation()}>
<input
type="checkbox"
checked={isChecked}
onChange={e => toggleContactSelected(contact.username, e.target.checked)}
/>
</label>
)}
<div className="contact-avatar">
{contact.avatarUrl ? (
<img src={contact.avatarUrl} alt="" />
@@ -352,90 +374,129 @@ function ContactsPage() {
)}
</div>
{/* 右侧:导出设置 */}
<div className="settings-panel">
<div className="panel-header">
<h2></h2>
</div>
{/* 右侧面板 */}
{exportMode ? (
<div className="settings-panel">
<div className="panel-header">
<h2></h2>
</div>
<div className="settings-content">
<div className="setting-section">
<h3></h3>
<div className="format-select" ref={formatDropdownRef}>
<button
type="button"
className={`select-trigger ${showFormatSelect ? 'open' : ''}`}
onClick={() => setShowFormatSelect(!showFormatSelect)}
>
<span className="select-value">{getOptionLabel(exportFormat)}</span>
<ChevronDown size={16} />
<div className="settings-content">
<div className="setting-section">
<h3></h3>
<div className="format-select" ref={formatDropdownRef}>
<button
type="button"
className={`select-trigger ${showFormatSelect ? 'open' : ''}`}
onClick={() => setShowFormatSelect(!showFormatSelect)}
>
<span className="select-value">{getOptionLabel(exportFormat)}</span>
<ChevronDown size={16} />
</button>
{showFormatSelect && (
<div className="select-dropdown">
{exportFormatOptions.map(option => (
<button
key={option.value}
type="button"
className={`select-option ${exportFormat === option.value ? 'active' : ''}`}
onClick={() => {
setExportFormat(option.value as 'json' | 'csv' | 'vcf')
setShowFormatSelect(false)
}}
>
<span className="option-label">{option.label}</span>
<span className="option-desc">{option.desc}</span>
</button>
))}
</div>
)}
</div>
</div>
<div className="setting-section">
<h3></h3>
<label className="checkbox-item">
<input type="checkbox" checked={exportAvatars} onChange={e => setExportAvatars(e.target.checked)} />
<span></span>
</label>
</div>
<div className="setting-section">
<h3></h3>
<div className="export-path-display">
<FolderOpen size={16} />
<span>{exportFolder || '未设置'}</span>
</div>
<button className="select-folder-btn" onClick={selectExportFolder}>
<FolderOpen size={16} />
<span></span>
</button>
{showFormatSelect && (
<div className="select-dropdown">
{exportFormatOptions.map(option => (
<button
key={option.value}
type="button"
className={`select-option ${exportFormat === option.value ? 'active' : ''}`}
onClick={() => {
setExportFormat(option.value as 'json' | 'csv' | 'vcf')
setShowFormatSelect(false)
}}
>
<span className="option-label">{option.label}</span>
<span className="option-desc">{option.desc}</span>
</button>
))}
</div>
</div>
</div>
<div className="export-action">
<button
className="export-btn"
onClick={startExport}
disabled={!exportFolder || isExporting || selectedUsernames.size === 0}
>
{isExporting ? (
<><Loader2 size={18} className="spin" /><span>...</span></>
) : (
<><Download size={18} /><span></span></>
)}
</div>
</div>
<div className="setting-section">
<h3></h3>
<label className="checkbox-item">
<input
type="checkbox"
checked={exportAvatars}
onChange={e => setExportAvatars(e.target.checked)}
/>
<span></span>
</label>
</div>
<div className="setting-section">
<h3></h3>
<div className="export-path-display">
<FolderOpen size={16} />
<span>{exportFolder || '未设置'}</span>
</div>
<button className="select-folder-btn" onClick={selectExportFolder}>
<FolderOpen size={16} />
<span></span>
</button>
</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="export-action">
<button
className="export-btn"
onClick={startExport}
disabled={!exportFolder || isExporting || selectedUsernames.size === 0}
>
{isExporting ? (
<>
<Loader2 size={18} className="spin" />
<span>...</span>
</>
) : (
<>
<Download size={18} />
<span></span>
</>
)}
</button>
<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>
) : (
<div className="settings-panel">
<div className="empty-detail">
<User size={48} />
<span></span>
</div>
</div>
)}
</div>
)
}

View File

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