mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-24 23:06:51 +00:00
@@ -18,6 +18,7 @@ import { KeyService } from './services/keyService'
|
||||
import { voiceTranscribeService } from './services/voiceTranscribeService'
|
||||
import { videoService } from './services/videoService'
|
||||
import { snsService } from './services/snsService'
|
||||
import { contactExportService } from './services/contactExportService'
|
||||
|
||||
|
||||
// 配置自动更新
|
||||
@@ -625,11 +626,15 @@ function registerIpcHandlers() {
|
||||
})
|
||||
|
||||
ipcMain.handle('chat:getContact', async (_, username: string) => {
|
||||
return chatService.getContact(username)
|
||||
return await chatService.getContact(username)
|
||||
})
|
||||
|
||||
ipcMain.handle('chat:getContactAvatar', async (_, username: string) => {
|
||||
return chatService.getContactAvatar(username)
|
||||
return await chatService.getContactAvatar(username)
|
||||
})
|
||||
|
||||
ipcMain.handle('chat:getContacts', async () => {
|
||||
return await chatService.getContacts()
|
||||
})
|
||||
|
||||
ipcMain.handle('chat:getCachedMessages', async (_, sessionId: string) => {
|
||||
@@ -710,6 +715,10 @@ function registerIpcHandlers() {
|
||||
return exportService.exportSessionToChatLab(sessionId, outputPath, options)
|
||||
})
|
||||
|
||||
ipcMain.handle('export:exportContacts', async (_, outputDir: string, options: any) => {
|
||||
return contactExportService.exportContacts(outputDir, options)
|
||||
})
|
||||
|
||||
// 数据分析相关
|
||||
ipcMain.handle('analytics:getOverallStatistics', async (_, force?: boolean) => {
|
||||
return analyticsService.getOverallStatistics(force)
|
||||
|
||||
@@ -120,7 +120,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
return () => ipcRenderer.removeListener('chat:voiceTranscriptPartial', listener)
|
||||
},
|
||||
execQuery: (kind: string, path: string | null, sql: string) =>
|
||||
ipcRenderer.invoke('chat:execQuery', kind, path, sql)
|
||||
ipcRenderer.invoke('chat:execQuery', kind, path, sql),
|
||||
getContacts: () => ipcRenderer.invoke('chat:getContacts')
|
||||
},
|
||||
|
||||
|
||||
@@ -194,6 +195,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options),
|
||||
exportSession: (sessionId: string, outputPath: string, options: any) =>
|
||||
ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options),
|
||||
exportContacts: (outputDir: string, options: any) =>
|
||||
ipcRenderer.invoke('export:exportContacts', outputDir, options),
|
||||
onProgress: (callback: (payload: { current: number; total: number; currentSession: string; phase: string }) => void) => {
|
||||
ipcRenderer.on('export:progress', (_, payload) => callback(payload))
|
||||
return () => ipcRenderer.removeAllListeners('export:progress')
|
||||
|
||||
@@ -67,6 +67,15 @@ export interface Contact {
|
||||
nickName: string
|
||||
}
|
||||
|
||||
export interface ContactInfo {
|
||||
username: string
|
||||
displayName: string
|
||||
remark?: string
|
||||
nickname?: string
|
||||
avatarUrl?: string
|
||||
type: 'friend' | 'group' | 'official' | 'other'
|
||||
}
|
||||
|
||||
// 表情包缓存
|
||||
const emojiCache: Map<string, string> = new Map()
|
||||
const emojiDownloading: Map<string, Promise<string | null>> = new Map()
|
||||
@@ -328,7 +337,7 @@ class ChatService {
|
||||
const cached = this.avatarCache.get(username)
|
||||
// 如果缓存有效且有头像,直接使用;如果没有头像,也需要重新尝试获取
|
||||
// 额外检查:如果头像是无效的 hex 格式(以 ffd8 开头),也需要重新获取
|
||||
const isValidAvatar = cached?.avatarUrl &&
|
||||
const isValidAvatar = cached?.avatarUrl &&
|
||||
!cached.avatarUrl.includes('base64,ffd8') // 检测错误的 hex 格式
|
||||
if (cached && now - cached.updatedAt < this.avatarCacheTtlMs && isValidAvatar) {
|
||||
result[username] = {
|
||||
@@ -494,6 +503,153 @@ class ChatService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取通讯录列表
|
||||
*/
|
||||
async getContacts(): Promise<{ success: boolean; contacts?: ContactInfo[]; error?: string }> {
|
||||
try {
|
||||
const connectResult = await this.ensureConnected()
|
||||
if (!connectResult.success) {
|
||||
return { success: false, error: connectResult.error }
|
||||
}
|
||||
|
||||
// 使用execQuery直接查询加密的contact.db
|
||||
// kind='contact', path=null表示使用已打开的contact.db
|
||||
const contactQuery = `
|
||||
SELECT username, remark, nick_name, alias, local_type
|
||||
FROM contact
|
||||
`
|
||||
|
||||
console.log('查询contact.db...')
|
||||
const contactResult = await wcdbService.execQuery('contact', null, contactQuery)
|
||||
|
||||
if (!contactResult.success || !contactResult.rows) {
|
||||
console.error('查询联系人失败:', contactResult.error)
|
||||
return { success: false, error: contactResult.error || '查询联系人失败' }
|
||||
}
|
||||
|
||||
console.log('查询到', contactResult.rows.length, '条联系人记录')
|
||||
const rows = contactResult.rows as Record<string, any>[]
|
||||
|
||||
// 调试:显示前5条数据样本
|
||||
console.log('📋 前5条数据样本:')
|
||||
rows.slice(0, 5).forEach((row, idx) => {
|
||||
console.log(` ${idx + 1}. username: ${row.username}, local_type: ${row.local_type}, remark: ${row.remark || '无'}, nick_name: ${row.nick_name || '无'}`)
|
||||
})
|
||||
|
||||
// 调试:统计local_type分布
|
||||
const localTypeStats = new Map<number, number>()
|
||||
rows.forEach(row => {
|
||||
const lt = row.local_type || 0
|
||||
localTypeStats.set(lt, (localTypeStats.get(lt) || 0) + 1)
|
||||
})
|
||||
console.log('📊 local_type分布:', Object.fromEntries(localTypeStats))
|
||||
|
||||
// 获取会话表的最后联系时间用于排序
|
||||
const lastContactTimeMap = new Map<string, number>()
|
||||
const sessionResult = await wcdbService.getSessions()
|
||||
if (sessionResult.success && sessionResult.sessions) {
|
||||
for (const session of sessionResult.sessions as any[]) {
|
||||
const username = session.username || session.user_name || session.userName || ''
|
||||
const timestamp = session.sort_timestamp || session.sortTimestamp || 0
|
||||
if (username && timestamp) {
|
||||
lastContactTimeMap.set(username, timestamp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 转换为ContactInfo
|
||||
const contacts: (ContactInfo & { lastContactTime: number })[] = []
|
||||
|
||||
for (const row of rows) {
|
||||
const username = row.username || ''
|
||||
|
||||
// 过滤系统账号和特殊账号 - 完全复制cipher的逻辑
|
||||
if (!username) continue
|
||||
if (username === 'filehelper' || username === 'fmessage' || username === 'floatbottle' ||
|
||||
username === 'medianote' || username === 'newsapp' || username.startsWith('fake_') ||
|
||||
username === 'weixin' || username === 'qmessage' || username === 'qqmail' ||
|
||||
username === 'tmessage' || username.startsWith('wxid_') === false &&
|
||||
username.includes('@') === false && username.startsWith('gh_') === false &&
|
||||
/^[a-zA-Z0-9_-]+$/.test(username) === false) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 判断类型 - 正确规则:wxid开头且有alias的是好友
|
||||
let type: 'friend' | 'group' | 'official' | 'other' = 'other'
|
||||
const localType = row.local_type || 0
|
||||
|
||||
if (username.includes('@chatroom')) {
|
||||
type = 'group'
|
||||
} else if (username.startsWith('gh_')) {
|
||||
type = 'official'
|
||||
} else if (localType === 3 || localType === 4) {
|
||||
type = 'official'
|
||||
} else if (username.startsWith('wxid_') && row.alias) {
|
||||
// wxid开头且有alias的是好友
|
||||
type = 'friend'
|
||||
} else if (localType === 1) {
|
||||
// local_type=1 也是好友
|
||||
type = 'friend'
|
||||
} else if (localType === 2) {
|
||||
// local_type=2 是群成员但非好友,跳过
|
||||
continue
|
||||
} else if (localType === 0) {
|
||||
// local_type=0 可能是好友或其他,检查是否有备注或昵称
|
||||
if (row.remark || row.nick_name) {
|
||||
type = 'friend'
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
// 其他未知类型,跳过
|
||||
continue
|
||||
}
|
||||
|
||||
const displayName = row.remark || row.nick_name || row.alias || username
|
||||
|
||||
contacts.push({
|
||||
username,
|
||||
displayName,
|
||||
remark: row.remark || undefined,
|
||||
nickname: row.nick_name || undefined,
|
||||
avatarUrl: undefined,
|
||||
type,
|
||||
lastContactTime: lastContactTimeMap.get(username) || 0
|
||||
})
|
||||
}
|
||||
|
||||
console.log('过滤后得到', contacts.length, '个有效联系人')
|
||||
console.log('📊 按类型统计:', {
|
||||
friends: contacts.filter(c => c.type === 'friend').length,
|
||||
groups: contacts.filter(c => c.type === 'group').length,
|
||||
officials: contacts.filter(c => c.type === 'official').length,
|
||||
other: contacts.filter(c => c.type === 'other').length
|
||||
})
|
||||
|
||||
// 按最近联系时间排序
|
||||
contacts.sort((a, b) => {
|
||||
const timeA = a.lastContactTime || 0
|
||||
const timeB = b.lastContactTime || 0
|
||||
if (timeA && timeB) {
|
||||
return timeB - timeA
|
||||
}
|
||||
if (timeA && !timeB) return -1
|
||||
if (!timeA && timeB) return 1
|
||||
return a.displayName.localeCompare(b.displayName, 'zh-CN')
|
||||
})
|
||||
|
||||
// 移除临时的lastContactTime字段
|
||||
const result = contacts.map(({ lastContactTime, ...rest }) => rest)
|
||||
|
||||
console.log('返回', result.length, '个联系人')
|
||||
return { success: true, contacts: result }
|
||||
} catch (e) {
|
||||
console.error('ChatService: 获取通讯录失败:', e)
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取消息列表(支持跨多个数据库合并,已优化)
|
||||
*/
|
||||
@@ -3098,7 +3254,7 @@ class ChatService {
|
||||
|
||||
private resolveAccountDir(dbPath: string, wxid: string): string | null {
|
||||
const normalized = dbPath.replace(/[\\\\/]+$/, '')
|
||||
|
||||
|
||||
// 如果 dbPath 本身指向 db_storage 目录下的文件(如某个 .db 文件)
|
||||
// 则向上回溯到账号目录
|
||||
if (basename(normalized).toLowerCase() === 'db_storage') {
|
||||
@@ -3108,14 +3264,14 @@ class ChatService {
|
||||
if (basename(dir).toLowerCase() === 'db_storage') {
|
||||
return dirname(dir)
|
||||
}
|
||||
|
||||
|
||||
// 否则,dbPath 应该是数据库根目录(如 xwechat_files)
|
||||
// 账号目录应该是 {dbPath}/{wxid}
|
||||
const accountDirWithWxid = join(normalized, wxid)
|
||||
if (existsSync(accountDirWithWxid)) {
|
||||
return accountDirWithWxid
|
||||
}
|
||||
|
||||
|
||||
// 兜底:返回 dbPath 本身(可能 dbPath 已经是账号目录)
|
||||
return normalized
|
||||
}
|
||||
|
||||
159
electron/services/contactExportService.ts
Normal file
159
electron/services/contactExportService.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import { chatService } from './chatService'
|
||||
|
||||
interface ContactExportOptions {
|
||||
format: 'json' | 'csv' | 'vcf'
|
||||
exportAvatars: boolean
|
||||
contactTypes: {
|
||||
friends: boolean
|
||||
groups: boolean
|
||||
officials: boolean
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 联系人导出服务
|
||||
*/
|
||||
class ContactExportService {
|
||||
/**
|
||||
* 导出联系人
|
||||
*/
|
||||
async exportContacts(
|
||||
outputDir: string,
|
||||
options: ContactExportOptions
|
||||
): Promise<{ success: boolean; successCount?: number; error?: string }> {
|
||||
try {
|
||||
// 获取所有联系人
|
||||
const contactsResult = await chatService.getContacts()
|
||||
if (!contactsResult.success || !contactsResult.contacts) {
|
||||
return { success: false, error: contactsResult.error || '获取联系人失败' }
|
||||
}
|
||||
|
||||
let contacts = contactsResult.contacts
|
||||
|
||||
// 根据类型过滤
|
||||
contacts = contacts.filter(c => {
|
||||
if (c.type === 'friend' && !options.contactTypes.friends) return false
|
||||
if (c.type === 'group' && !options.contactTypes.groups) return false
|
||||
if (c.type === 'official' && !options.contactTypes.officials) return false
|
||||
return true
|
||||
})
|
||||
|
||||
if (contacts.length === 0) {
|
||||
return { success: false, error: '没有符合条件的联系人' }
|
||||
}
|
||||
|
||||
// 确保输出目录存在
|
||||
if (!fs.existsSync(outputDir)) {
|
||||
fs.mkdirSync(outputDir, { recursive: true })
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5)
|
||||
let outputPath: string
|
||||
|
||||
switch (options.format) {
|
||||
case 'json':
|
||||
outputPath = path.join(outputDir, `contacts_${timestamp}.json`)
|
||||
await this.exportToJSON(contacts, outputPath)
|
||||
break
|
||||
case 'csv':
|
||||
outputPath = path.join(outputDir, `contacts_${timestamp}.csv`)
|
||||
await this.exportToCSV(contacts, outputPath)
|
||||
break
|
||||
case 'vcf':
|
||||
outputPath = path.join(outputDir, `contacts_${timestamp}.vcf`)
|
||||
await this.exportToVCF(contacts, outputPath)
|
||||
break
|
||||
default:
|
||||
return { success: false, error: '不支持的导出格式' }
|
||||
}
|
||||
|
||||
return { success: true, successCount: contacts.length }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出为JSON格式
|
||||
*/
|
||||
private async exportToJSON(contacts: any[], outputPath: string): Promise<void> {
|
||||
const data = {
|
||||
exportedAt: new Date().toISOString(),
|
||||
count: contacts.length,
|
||||
contacts: contacts.map(c => ({
|
||||
username: c.username,
|
||||
displayName: c.displayName,
|
||||
remark: c.remark,
|
||||
nickname: c.nickname,
|
||||
type: c.type
|
||||
}))
|
||||
}
|
||||
fs.writeFileSync(outputPath, JSON.stringify(data, null, 2), 'utf-8')
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出为CSV格式
|
||||
*/
|
||||
private async exportToCSV(contacts: any[], outputPath: string): Promise<void> {
|
||||
const headers = ['用户名', '显示名称', '备注', '昵称', '类型']
|
||||
const rows = contacts.map(c => [
|
||||
c.username || '',
|
||||
c.displayName || '',
|
||||
c.remark || '',
|
||||
c.nickname || '',
|
||||
this.getTypeLabel(c.type)
|
||||
])
|
||||
|
||||
const csvContent = [
|
||||
headers.join(','),
|
||||
...rows.map(row => row.map(cell => `"${cell}"`).join(','))
|
||||
].join('\n')
|
||||
|
||||
fs.writeFileSync(outputPath, '\uFEFF' + csvContent, 'utf-8') // 添加BOM以支持Excel
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出为VCF格式(vCard)
|
||||
*/
|
||||
private async exportToVCF(contacts: any[], outputPath: string): Promise<void> {
|
||||
const vcards = contacts
|
||||
.filter(c => c.type === 'friend') // VCF通常只用于个人联系人
|
||||
.map(c => {
|
||||
const lines = ['BEGIN:VCARD', 'VERSION:3.0']
|
||||
|
||||
// 全名
|
||||
lines.push(`FN:${c.displayName || c.username}`)
|
||||
|
||||
// 昵称
|
||||
if (c.nickname) {
|
||||
lines.push(`NICKNAME:${c.nickname}`)
|
||||
}
|
||||
|
||||
// 备注
|
||||
if (c.remark) {
|
||||
lines.push(`NOTE:${c.remark}`)
|
||||
}
|
||||
|
||||
// 微信ID
|
||||
lines.push(`X-WECHAT-ID:${c.username}`)
|
||||
|
||||
lines.push('END:VCARD')
|
||||
return lines.join('\r\n')
|
||||
})
|
||||
|
||||
fs.writeFileSync(outputPath, vcards.join('\r\n\r\n'), 'utf-8')
|
||||
}
|
||||
|
||||
private getTypeLabel(type: string): string {
|
||||
switch (type) {
|
||||
case 'friend': return '好友'
|
||||
case 'group': return '群聊'
|
||||
case 'official': return '公众号'
|
||||
default: return '其他'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const contactExportService = new ContactExportService()
|
||||
9803
package-lock.json
generated
Normal file
9803
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@ import SettingsPage from './pages/SettingsPage'
|
||||
import ExportPage from './pages/ExportPage'
|
||||
import VideoWindow from './pages/VideoWindow'
|
||||
import SnsPage from './pages/SnsPage'
|
||||
import ContactsPage from './pages/ContactsPage'
|
||||
|
||||
import { useAppStore } from './stores/appStore'
|
||||
import { themes, useThemeStore, type ThemeId } from './stores/themeStore'
|
||||
@@ -344,6 +345,7 @@ function App() {
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/export" element={<ExportPage />} />
|
||||
<Route path="/sns" element={<SnsPage />} />
|
||||
<Route path="/contacts" element={<ContactsPage />} />
|
||||
</Routes>
|
||||
</RouteGuard>
|
||||
</main>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { NavLink, useLocation } from 'react-router-dom'
|
||||
import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download, Bot, Aperture } from 'lucide-react'
|
||||
import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download, Bot, Aperture, UserCircle } from 'lucide-react'
|
||||
import './Sidebar.scss'
|
||||
|
||||
function Sidebar() {
|
||||
@@ -44,7 +44,15 @@ function Sidebar() {
|
||||
<span className="nav-label">朋友圈</span>
|
||||
</NavLink>
|
||||
|
||||
|
||||
{/* 通讯录 */}
|
||||
<NavLink
|
||||
to="/contacts"
|
||||
className={`nav-item ${isActive('/contacts') ? 'active' : ''}`}
|
||||
title={collapsed ? '通讯录' : undefined}
|
||||
>
|
||||
<span className="nav-icon"><UserCircle size={20} /></span>
|
||||
<span className="nav-label">通讯录</span>
|
||||
</NavLink>
|
||||
|
||||
{/* 私聊分析 */}
|
||||
<NavLink
|
||||
|
||||
544
src/pages/ContactsPage.scss
Normal file
544
src/pages/ContactsPage.scss
Normal file
@@ -0,0 +1,544 @@
|
||||
.contacts-page {
|
||||
display: flex;
|
||||
height: calc(100% + 48px);
|
||||
margin: -24px;
|
||||
background: var(--bg-primary);
|
||||
overflow: hidden;
|
||||
|
||||
// 左侧联系人面板
|
||||
.contacts-panel {
|
||||
width: 380px;
|
||||
min-width: 380px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--border-color);
|
||||
background: var(--card-bg);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
|
||||
h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: contactsSpin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 16px 20px;
|
||||
padding: 10px 14px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
svg {
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: none;
|
||||
outline: none;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--text-tertiary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.type-filters {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 0 20px 12px;
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
/* Allow horizontal scroll if needed on very small screens */
|
||||
|
||||
/* Hide scrollbar */
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.filter-checkbox {
|
||||
display: flex;
|
||||
/* Changed to flex with padding */
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
padding: 6px 12px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--primary);
|
||||
cursor: pointer;
|
||||
opacity: 1;
|
||||
/* Make visible */
|
||||
position: static;
|
||||
/* Make static */
|
||||
pointer-events: auto;
|
||||
/* Enable pointer events */
|
||||
}
|
||||
|
||||
svg {
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
margin-left: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.contacts-count {
|
||||
padding: 0 20px 12px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 14px;
|
||||
|
||||
.spin {
|
||||
animation: contactsSpin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.contacts-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 12px 12px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--text-tertiary);
|
||||
border-radius: 3px;
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
.contact-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
transition: all 0.2s;
|
||||
margin-bottom: 4px;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.contact-avatar {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
span {
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.contact-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.contact-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.contact-remark {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.contact-type {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.friend {
|
||||
background: rgba(var(--primary-rgb), 0.1);
|
||||
color: var(--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);
|
||||
}
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 右侧设置面板
|
||||
.settings-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px 24px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--text-tertiary);
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.setting-section {
|
||||
margin-bottom: 28px;
|
||||
|
||||
h3 {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.format-select {
|
||||
position: relative;
|
||||
/* margin-bottom 移到 .setting-section */
|
||||
|
||||
.select-trigger {
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 9999px;
|
||||
/* Rounded pill shape */
|
||||
font-size: 14px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
&.open {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.select-value {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.select-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: color-mix(in srgb, var(--bg-primary) 85%, var(--bg-secondary));
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 6px;
|
||||
box-shadow: var(--shadow-md);
|
||||
z-index: 20;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.select-option {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 10px 12px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: color-mix(in srgb, var(--primary) 12%, transparent);
|
||||
color: var(--primary);
|
||||
|
||||
.option-desc {
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.option-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.option-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
|
||||
input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: var(--primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.export-path-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 12px;
|
||||
|
||||
svg {
|
||||
color: var(--primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
span {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.select-folder-btn {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
|
||||
svg {
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
svg {
|
||||
color: var(--text-secondary);
|
||||
transition: color 0.2s;
|
||||
}
|
||||
}
|
||||
|
||||
.export-action {
|
||||
padding: 20px 24px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.export-btn {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 14px 24px;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: contactsSpin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes contactsSpin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
384
src/pages/ContactsPage.tsx
Normal file
384
src/pages/ContactsPage.tsx
Normal file
@@ -0,0 +1,384 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { Search, RefreshCw, X, User, Users, MessageSquare, Loader2, FolderOpen, Download, ChevronDown } 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 [showFormatSelect, setShowFormatSelect] = useState(false)
|
||||
const formatDropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// 加载通讯录
|
||||
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])
|
||||
|
||||
// 点击外部关闭下拉菜单
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Node
|
||||
if (showFormatSelect && formatDropdownRef.current && !formatDropdownRef.current.contains(target)) {
|
||||
setShowFormatSelect(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [showFormatSelect])
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
const exportFormatOptions = [
|
||||
{ value: 'json', label: 'JSON', desc: '详细格式,包含完整联系人信息' },
|
||||
{ value: 'csv', label: 'CSV (Excel)', desc: '电子表格格式,适合Excel查看' },
|
||||
{ value: 'vcf', label: 'VCF (vCard)', desc: '标准名片格式,支持导入手机' }
|
||||
]
|
||||
|
||||
const getOptionLabel = (value: string) => {
|
||||
return exportFormatOptions.find(opt => opt.value === value)?.label || value
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="contacts-page">
|
||||
{/* 左侧:联系人列表 */}
|
||||
<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>
|
||||
|
||||
<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({ ...contactTypes, friends: e.target.checked })}
|
||||
/>
|
||||
<User size={16} />
|
||||
<span>好友</span>
|
||||
</label>
|
||||
<label className="filter-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={contactTypes.groups}
|
||||
onChange={e => setContactTypes({ ...contactTypes, groups: e.target.checked })}
|
||||
/>
|
||||
<Users size={16} />
|
||||
<span>群聊</span>
|
||||
</label>
|
||||
<label className="filter-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={contactTypes.officials}
|
||||
onChange={e => setContactTypes({ ...contactTypes, officials: e.target.checked })}
|
||||
/>
|
||||
<MessageSquare size={16} />
|
||||
<span>公众号</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="contacts-count">
|
||||
共 {filteredContacts.length} 个联系人
|
||||
</div>
|
||||
|
||||
{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 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} />
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="export-action">
|
||||
<button
|
||||
className="export-btn"
|
||||
onClick={startExport}
|
||||
disabled={!exportFolder || isExporting}
|
||||
>
|
||||
{isExporting ? (
|
||||
<>
|
||||
<Loader2 size={18} className="spin" />
|
||||
<span>导出中...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download size={18} />
|
||||
<span>开始导出</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ContactsPage
|
||||
12
src/types/electron.d.ts
vendored
12
src/types/electron.d.ts
vendored
@@ -1,4 +1,4 @@
|
||||
import type { ChatSession, Message, Contact } from './models'
|
||||
import type { ChatSession, Message, Contact, ContactInfo } from './models'
|
||||
|
||||
export interface ElectronAPI {
|
||||
window: {
|
||||
@@ -76,6 +76,11 @@ export interface ElectronAPI {
|
||||
}>
|
||||
getContact: (username: string) => Promise<Contact | null>
|
||||
getContactAvatar: (username: string) => Promise<{ avatarUrl?: string; displayName?: string } | null>
|
||||
getContacts: () => Promise<{
|
||||
success: boolean
|
||||
contacts?: ContactInfo[]
|
||||
error?: string
|
||||
}>
|
||||
getMyAvatarUrl: () => Promise<{ success: boolean; avatarUrl?: string; error?: string }>
|
||||
downloadEmoji: (cdnUrl: string, md5?: string) => Promise<{ success: boolean; localPath?: string; error?: string }>
|
||||
close: () => Promise<boolean>
|
||||
@@ -315,6 +320,11 @@ export interface ElectronAPI {
|
||||
success: boolean
|
||||
error?: string
|
||||
}>
|
||||
exportContacts: (outputDir: string, options: { format: 'json' | 'csv' | 'vcf'; exportAvatars: boolean; contactTypes: { friends: boolean; groups: boolean; officials: boolean } }) => Promise<{
|
||||
success: boolean
|
||||
successCount?: number
|
||||
error?: string
|
||||
}>
|
||||
onProgress: (callback: (payload: ExportProgress) => void) => () => void
|
||||
}
|
||||
whisper: {
|
||||
|
||||
@@ -23,6 +23,15 @@ export interface Contact {
|
||||
smallHeadUrl: string
|
||||
}
|
||||
|
||||
export interface ContactInfo {
|
||||
username: string
|
||||
displayName: string
|
||||
remark?: string
|
||||
nickname?: string
|
||||
avatarUrl?: string
|
||||
type: 'friend' | 'group' | 'official' | 'other'
|
||||
}
|
||||
|
||||
// 消息
|
||||
export interface Message {
|
||||
localId: number
|
||||
|
||||
Reference in New Issue
Block a user