新增了导出联系人的功能

This commit is contained in:
xuncha
2026-01-27 19:25:34 +08:00
parent 836b0f9df4
commit f55507cd99
10 changed files with 1155 additions and 10 deletions

469
src/pages/ContactsPage.scss Normal file
View File

@@ -0,0 +1,469 @@
.contacts-page {
display: flex;
flex-direction: column;
height: 100%;
padding: 2rem;
gap: 1.5rem;
overflow: hidden;
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
h1 {
font-size: 1.75rem;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: none;
border-radius: 8px;
background: var(--bg-secondary);
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s;
&:hover:not(:disabled) {
background: var(--bg-tertiary);
color: var(--text-primary);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
svg.spin {
animation: spin 1s linear infinite;
}
}
}
.contacts-filters {
display: flex;
flex-direction: column;
gap: 1rem;
.search-bar {
position: relative;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 12px;
transition: all 0.2s;
&:focus-within {
border-color: var(--brand-primary);
box-shadow: 0 0 0 3px var(--brand-primary-alpha);
}
svg {
color: var(--text-tertiary);
flex-shrink: 0;
}
input {
flex: 1;
border: none;
background: transparent;
color: var(--text-primary);
font-size: 0.9375rem;
outline: none;
&::placeholder {
color: var(--text-tertiary);
}
}
.clear-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
border: none;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
border-radius: 4px;
transition: all 0.2s;
&:hover {
background: var(--bg-tertiary);
color: var(--text-secondary);
}
}
}
.type-filters {
display: flex;
gap: 1.5rem;
flex-wrap: wrap;
.filter-checkbox {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
user-select: none;
input[type="checkbox"] {
position: absolute;
opacity: 0;
pointer-events: none;
&:checked+.custom-checkbox {
background: var(--brand-primary);
border-color: var(--brand-primary);
&::after {
opacity: 1;
transform: scale(1);
}
}
}
.custom-checkbox {
position: relative;
width: 18px;
height: 18px;
border: 2px solid var(--border-secondary);
border-radius: 4px;
background: var(--bg-primary);
transition: all 0.2s;
&::after {
content: '';
position: absolute;
left: 4px;
top: 1px;
width: 4px;
height: 8px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg) scale(0);
opacity: 0;
transition: all 0.2s;
}
}
svg {
color: var(--text-secondary);
flex-shrink: 0;
}
span {
color: var(--text-primary);
font-size: 0.9375rem;
}
&:hover .custom-checkbox {
border-color: var(--brand-primary);
}
}
}
.contacts-count {
font-size: 0.875rem;
color: var(--text-tertiary);
}
}
.export-section {
padding: 1.5rem;
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 12px;
display: flex;
flex-direction: column;
gap: 1rem;
h3 {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
}
.export-format {
display: flex;
align-items: center;
gap: 0.75rem;
label {
font-size: 0.9375rem;
color: var(--text-secondary);
white-space: nowrap;
}
select {
padding: 0.5rem 0.75rem;
border: 1px solid var(--border-primary);
border-radius: 8px;
background: var(--bg-primary);
color: var(--text-primary);
font-size: 0.9375rem;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: var(--brand-primary);
}
&:focus {
outline: none;
border-color: var(--brand-primary);
box-shadow: 0 0 0 3px var(--brand-primary-alpha);
}
}
}
.export-options {
display: flex;
gap: 1rem;
label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: pointer;
}
span {
font-size: 0.9375rem;
color: var(--text-primary);
}
}
}
.export-folder {
.folder-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
border: 1px solid var(--border-primary);
border-radius: 8px;
background: var(--bg-primary);
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s;
font-size: 0.9375rem;
&:hover {
border-color: var(--brand-primary);
color: var(--text-primary);
}
span {
flex: 1;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
.export-action-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
border: none;
border-radius: 8px;
background: #3b82f6;
color: #ffffff;
font-size: 0.9375rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
&:hover:not(:disabled) {
background: #2563eb;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
background: #9ca3af;
}
}
}
.contacts-content {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
.loading-state,
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
color: var(--text-tertiary);
svg.spin {
animation: spin 1s linear infinite;
color: var(--brand-primary);
}
}
.contacts-list {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.5rem;
padding-right: 0.5rem;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--border-secondary);
border-radius: 3px;
&:hover {
background: var(--border-primary);
}
}
.contact-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background: var(--bg-secondary);
border: 1px solid var(--border-primary);
border-radius: 12px;
transition: all 0.2s;
&:hover {
background: var(--bg-tertiary);
border-color: var(--border-secondary);
}
.contact-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
overflow: hidden;
background: var(--bg-tertiary);
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
span {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-secondary);
}
}
.contact-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
.contact-name {
font-size: 1rem;
font-weight: 500;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.contact-remark {
font-size: 0.875rem;
color: var(--text-tertiary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.contact-type {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border-radius: 6px;
font-size: 0.8125rem;
font-weight: 500;
flex-shrink: 0;
&.friend {
background: var(--brand-primary-alpha);
color: var(--brand-primary);
}
&.group {
background: rgba(52, 211, 153, 0.1);
color: rgb(52, 211, 153);
}
&.official {
background: rgba(251, 191, 36, 0.1);
color: rgb(251, 191, 36);
}
&.other {
background: var(--bg-tertiary);
color: var(--text-tertiary);
}
svg {
flex-shrink: 0;
}
}
}
}
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

320
src/pages/ContactsPage.tsx Normal file
View File

@@ -0,0 +1,320 @@
import { useState, useEffect, useCallback } from 'react'
import { Search, RefreshCw, X, User, Users, MessageSquare, Loader2, FolderOpen, Download } from 'lucide-react'
import './ContactsPage.scss'
interface ContactInfo {
username: string
displayName: string
remark?: string
nickname?: string
avatarUrl?: string
type: 'friend' | 'group' | 'official' | 'other'
}
function ContactsPage() {
const [contacts, setContacts] = useState<ContactInfo[]>([])
const [filteredContacts, setFilteredContacts] = useState<ContactInfo[]>([])
const [isLoading, setIsLoading] = useState(true)
const [searchKeyword, setSearchKeyword] = useState('')
const [contactTypes, setContactTypes] = useState({
friends: true,
groups: true,
officials: true
})
// 导出相关状态
const [exportFormat, setExportFormat] = useState<'json' | 'csv' | 'vcf'>('json')
const [exportAvatars, setExportAvatars] = useState(true)
const [exportFolder, setExportFolder] = useState('')
const [isExporting, setIsExporting] = useState(false)
// 加载通讯录
const loadContacts = useCallback(async () => {
setIsLoading(true)
try {
const result = await window.electronAPI.chat.connect()
if (!result.success) {
console.error('连接失败:', result.error)
setIsLoading(false)
return
}
const contactsResult = await window.electronAPI.chat.getContacts()
console.log('📞 getContacts结果:', contactsResult)
if (contactsResult.success && contactsResult.contacts) {
console.log('📊 总联系人数:', contactsResult.contacts.length)
console.log('📊 按类型统计:', {
friends: contactsResult.contacts.filter(c => c.type === 'friend').length,
groups: contactsResult.contacts.filter(c => c.type === 'group').length,
officials: contactsResult.contacts.filter(c => c.type === 'official').length,
other: contactsResult.contacts.filter(c => c.type === 'other').length
})
// 获取头像URL
const usernames = contactsResult.contacts.map(c => c.username)
if (usernames.length > 0) {
const avatarResult = await window.electronAPI.chat.enrichSessionsContactInfo(usernames)
if (avatarResult.success && avatarResult.contacts) {
contactsResult.contacts.forEach(contact => {
const enriched = avatarResult.contacts?.[contact.username]
if (enriched?.avatarUrl) {
contact.avatarUrl = enriched.avatarUrl
}
})
}
}
setContacts(contactsResult.contacts)
setFilteredContacts(contactsResult.contacts)
}
} catch (e) {
console.error('加载通讯录失败:', e)
} finally {
setIsLoading(false)
}
}, [])
useEffect(() => {
loadContacts()
}, [loadContacts])
// 搜索和类型过滤
useEffect(() => {
let filtered = contacts
// 类型过滤
filtered = filtered.filter(c => {
if (c.type === 'friend' && !contactTypes.friends) return false
if (c.type === 'group' && !contactTypes.groups) return false
if (c.type === 'official' && !contactTypes.officials) return false
return true
})
// 关键词过滤
if (searchKeyword.trim()) {
const lower = searchKeyword.toLowerCase()
filtered = filtered.filter(c =>
c.displayName?.toLowerCase().includes(lower) ||
c.remark?.toLowerCase().includes(lower) ||
c.username.toLowerCase().includes(lower)
)
}
setFilteredContacts(filtered)
}, [searchKeyword, contacts, contactTypes])
const getAvatarLetter = (name: string) => {
if (!name) return '?'
return [...name][0] || '?'
}
const getContactTypeIcon = (type: string) => {
switch (type) {
case 'friend': return <User size={14} />
case 'group': return <Users size={14} />
case 'official': return <MessageSquare size={14} />
default: return <User size={14} />
}
}
const getContactTypeName = (type: string) => {
switch (type) {
case 'friend': return '好友'
case 'group': return '群聊'
case 'official': return '公众号'
default: return '其他'
}
}
// 选择导出文件夹
const selectExportFolder = async () => {
try {
const result = await window.electronAPI.dialog.openDirectory({
title: '选择导出位置'
})
if (result && !result.canceled && result.filePaths && result.filePaths.length > 0) {
setExportFolder(result.filePaths[0])
}
} catch (e) {
console.error('选择文件夹失败:', e)
}
}
// 开始导出
const startExport = async () => {
if (!exportFolder) {
alert('请先选择导出位置')
return
}
setIsExporting(true)
try {
const exportOptions = {
format: exportFormat,
exportAvatars,
contactTypes: {
friends: contactTypes.friends,
groups: contactTypes.groups,
officials: contactTypes.officials
}
}
const result = await window.electronAPI.export.exportContacts(exportFolder, exportOptions)
if (result.success) {
alert(`导出成功!共导出 ${result.successCount} 个联系人`)
} else {
alert(`导出失败:${result.error}`)
}
} catch (e) {
console.error('导出失败:', e)
alert(`导出失败:${String(e)}`)
} finally {
setIsExporting(false)
}
}
return (
<div className="contacts-page">
<div className="page-header">
<h1></h1>
<button className="icon-btn" onClick={loadContacts} disabled={isLoading}>
<RefreshCw size={18} className={isLoading ? 'spin' : ''} />
</button>
</div>
<div className="contacts-filters">
<div className="search-bar">
<Search size={16} />
<input
type="text"
placeholder="搜索联系人..."
value={searchKeyword}
onChange={e => setSearchKeyword(e.target.value)}
/>
{searchKeyword && (
<button className="clear-btn" onClick={() => setSearchKeyword('')}>
<X size={14} />
</button>
)}
</div>
<div className="type-filters">
<label className="filter-checkbox">
<input
type="checkbox"
checked={contactTypes.friends}
onChange={e => setContactTypes(prev => ({ ...prev, friends: e.target.checked }))}
/>
<div className="custom-checkbox"></div>
<User size={16} />
<span></span>
</label>
<label className="filter-checkbox">
<input
type="checkbox"
checked={contactTypes.groups}
onChange={e => setContactTypes(prev => ({ ...prev, groups: e.target.checked }))}
/>
<div className="custom-checkbox"></div>
<Users size={16} />
<span></span>
</label>
<label className="filter-checkbox">
<input
type="checkbox"
checked={contactTypes.officials}
onChange={e => setContactTypes(prev => ({ ...prev, officials: e.target.checked }))}
/>
<div className="custom-checkbox"></div>
<MessageSquare size={16} />
<span></span>
</label>
</div>
<div className="contacts-count">
{filteredContacts.length}
</div>
</div>
{/* 导出区域 */}
<div className="export-section">
<h3></h3>
<div className="export-format">
<label></label>
<select value={exportFormat} onChange={e => setExportFormat(e.target.value as 'json' | 'csv' | 'vcf')}>
<option value="json">JSON</option>
<option value="csv">CSV</option>
<option value="vcf">VCF (vCard)</option>
</select>
</div>
<div className="export-options">
<label>
<input
type="checkbox"
checked={exportAvatars}
onChange={e => setExportAvatars(e.target.checked)}
/>
<span></span>
</label>
</div>
<div className="export-folder">
<button onClick={selectExportFolder} className="folder-btn">
<FolderOpen size={16} />
<span>{exportFolder || '选择导出位置'}</span>
</button>
</div>
<button
className="export-action-btn"
onClick={startExport}
disabled={!exportFolder || isExporting}
>
<Download size={16} />
<span>{isExporting ? '导出中...' : '开始导出'}</span>
</button>
</div>
<div className="contacts-content">
{isLoading ? (
<div className="loading-state">
<Loader2 size={32} className="spin" />
<span>...</span>
</div>
) : filteredContacts.length === 0 ? (
<div className="empty-state">
<span></span>
</div>
) : (
<div className="contacts-list">
{filteredContacts.map(contact => (
<div key={contact.username} className="contact-item">
<div className="contact-avatar">
{contact.avatarUrl ? (
<img src={contact.avatarUrl} alt="" />
) : (
<span>{getAvatarLetter(contact.displayName)}</span>
)}
</div>
<div className="contact-info">
<div className="contact-name">{contact.displayName}</div>
{contact.remark && contact.remark !== contact.displayName && (
<div className="contact-remark">: {contact.remark}</div>
)}
</div>
<div className={`contact-type ${contact.type}`}>
{getContactTypeIcon(contact.type)}
<span>{getContactTypeName(contact.type)}</span>
</div>
</div>
))}
</div>
)}
</div>
</div>
)
}
export default ContactsPage