mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 07:16:51 +00:00
feat(export): optimize dialog defaults and option cards
This commit is contained in:
@@ -935,15 +935,15 @@
|
|||||||
.format-grid {
|
.format-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.format-card {
|
.format-card {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 82px;
|
min-height: 0;
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 10px;
|
padding: 8px 10px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -956,13 +956,14 @@
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
line-height: 1.35;
|
||||||
}
|
}
|
||||||
|
|
||||||
.format-desc {
|
.format-desc {
|
||||||
margin-top: 3px;
|
margin-top: 1px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
line-height: 1.4;
|
line-height: 1.35;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
@@ -1031,10 +1032,22 @@
|
|||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid rgba(var(--primary-rgb), 0.35);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
span {
|
span {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -1048,11 +1061,6 @@
|
|||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
input {
|
|
||||||
accent-color: var(--primary);
|
|
||||||
margin: 0 0 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
border-color: var(--primary);
|
border-color: var(--primary);
|
||||||
background: rgba(var(--primary-rgb), 0.08);
|
background: rgba(var(--primary-rgb), 0.08);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { memo, useCallback, useEffect, useMemo, useRef, useState, type UIEvent } from 'react'
|
||||||
import { useLocation } from 'react-router-dom'
|
import { useLocation } from 'react-router-dom'
|
||||||
import { TableVirtuoso } from 'react-virtuoso'
|
import { TableVirtuoso } from 'react-virtuoso'
|
||||||
import {
|
import {
|
||||||
@@ -11,8 +11,11 @@ import {
|
|||||||
FolderOpen,
|
FolderOpen,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
AlertTriangle,
|
||||||
|
ClipboardList,
|
||||||
MessageSquareText,
|
MessageSquareText,
|
||||||
Mic,
|
Mic,
|
||||||
|
RefreshCw,
|
||||||
Search,
|
Search,
|
||||||
Square,
|
Square,
|
||||||
Video,
|
Video,
|
||||||
@@ -224,12 +227,48 @@ const getAvatarLetter = (name: string): string => {
|
|||||||
return [...name][0] || '?'
|
return [...name][0] || '?'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const matchesContactTab = (contact: ContactInfo, tab: ConversationTab): boolean => {
|
||||||
|
if (tab === 'private') return contact.type === 'friend'
|
||||||
|
if (tab === 'group') return contact.type === 'group'
|
||||||
|
if (tab === 'official') return contact.type === 'official'
|
||||||
|
return contact.type === 'former_friend'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getContactTypeName = (type: ContactInfo['type']): string => {
|
||||||
|
if (type === 'friend') return '好友'
|
||||||
|
if (type === 'group') return '群聊'
|
||||||
|
if (type === 'official') return '公众号'
|
||||||
|
if (type === 'former_friend') return '曾经的好友'
|
||||||
|
return '其他'
|
||||||
|
}
|
||||||
|
|
||||||
const createTaskId = (): string => `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
const createTaskId = (): string => `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||||
const CONTACT_ENRICH_TIMEOUT_MS = 7000
|
const CONTACT_ENRICH_TIMEOUT_MS = 7000
|
||||||
const EXPORT_SNS_STATS_CACHE_STALE_MS = 12 * 60 * 60 * 1000
|
const EXPORT_SNS_STATS_CACHE_STALE_MS = 12 * 60 * 60 * 1000
|
||||||
const EXPORT_AVATAR_RECHECK_INTERVAL_MS = 24 * 60 * 60 * 1000
|
const EXPORT_AVATAR_RECHECK_INTERVAL_MS = 24 * 60 * 60 * 1000
|
||||||
const EXPORT_AVATAR_ENRICH_BATCH_SIZE = 80
|
const EXPORT_AVATAR_ENRICH_BATCH_SIZE = 80
|
||||||
|
const CONTACTS_LIST_VIRTUAL_ROW_HEIGHT = 76
|
||||||
|
const CONTACTS_LIST_VIRTUAL_OVERSCAN = 10
|
||||||
|
const DEFAULT_CONTACTS_LOAD_TIMEOUT_MS = 3000
|
||||||
type SessionDataSource = 'cache' | 'network' | null
|
type SessionDataSource = 'cache' | 'network' | null
|
||||||
|
type ContactsDataSource = 'cache' | 'network' | null
|
||||||
|
|
||||||
|
interface ContactsLoadSession {
|
||||||
|
requestId: string
|
||||||
|
startedAt: number
|
||||||
|
attempt: number
|
||||||
|
timeoutMs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContactsLoadIssue {
|
||||||
|
kind: 'timeout' | 'error'
|
||||||
|
title: string
|
||||||
|
message: string
|
||||||
|
reason: string
|
||||||
|
errorDetail?: string
|
||||||
|
occurredAt: number
|
||||||
|
elapsedMs: number
|
||||||
|
}
|
||||||
|
|
||||||
const withTimeout = async <T,>(promise: Promise<T>, timeoutMs: number): Promise<T | null> => {
|
const withTimeout = async <T,>(promise: Promise<T>, timeoutMs: number): Promise<T | null> => {
|
||||||
let timer: ReturnType<typeof setTimeout> | null = null
|
let timer: ReturnType<typeof setTimeout> | null = null
|
||||||
@@ -480,6 +519,23 @@ function ExportPage() {
|
|||||||
const [searchKeyword, setSearchKeyword] = useState('')
|
const [searchKeyword, setSearchKeyword] = useState('')
|
||||||
const [activeTab, setActiveTab] = useState<ConversationTab>('private')
|
const [activeTab, setActiveTab] = useState<ConversationTab>('private')
|
||||||
const [selectedSessions, setSelectedSessions] = useState<Set<string>>(new Set())
|
const [selectedSessions, setSelectedSessions] = useState<Set<string>>(new Set())
|
||||||
|
const [contactsList, setContactsList] = useState<ContactInfo[]>([])
|
||||||
|
const [isContactsListLoading, setIsContactsListLoading] = useState(true)
|
||||||
|
const [contactsDataSource, setContactsDataSource] = useState<ContactsDataSource>(null)
|
||||||
|
const [contactsUpdatedAt, setContactsUpdatedAt] = useState<number | null>(null)
|
||||||
|
const [avatarCacheUpdatedAt, setAvatarCacheUpdatedAt] = useState<number | null>(null)
|
||||||
|
const [contactsListScrollTop, setContactsListScrollTop] = useState(0)
|
||||||
|
const [contactsListViewportHeight, setContactsListViewportHeight] = useState(480)
|
||||||
|
const [contactsLoadTimeoutMs, setContactsLoadTimeoutMs] = useState(DEFAULT_CONTACTS_LOAD_TIMEOUT_MS)
|
||||||
|
const [contactsLoadSession, setContactsLoadSession] = useState<ContactsLoadSession | null>(null)
|
||||||
|
const [contactsLoadIssue, setContactsLoadIssue] = useState<ContactsLoadIssue | null>(null)
|
||||||
|
const [showContactsDiagnostics, setShowContactsDiagnostics] = useState(false)
|
||||||
|
const [contactsDiagnosticTick, setContactsDiagnosticTick] = useState(Date.now())
|
||||||
|
const [contactsAvatarEnrichProgress, setContactsAvatarEnrichProgress] = useState({
|
||||||
|
loaded: 0,
|
||||||
|
total: 0,
|
||||||
|
running: false
|
||||||
|
})
|
||||||
|
|
||||||
const [exportFolder, setExportFolder] = useState('')
|
const [exportFolder, setExportFolder] = useState('')
|
||||||
const [writeLayout, setWriteLayout] = useState<configService.ExportWriteLayout>('A')
|
const [writeLayout, setWriteLayout] = useState<configService.ExportWriteLayout>('A')
|
||||||
@@ -536,6 +592,12 @@ function ExportPage() {
|
|||||||
const preselectAppliedRef = useRef(false)
|
const preselectAppliedRef = useRef(false)
|
||||||
const exportCacheScopeRef = useRef('default')
|
const exportCacheScopeRef = useRef('default')
|
||||||
const exportCacheScopeReadyRef = useRef(false)
|
const exportCacheScopeReadyRef = useRef(false)
|
||||||
|
const contactsLoadVersionRef = useRef(0)
|
||||||
|
const contactsLoadAttemptRef = useRef(0)
|
||||||
|
const contactsLoadTimeoutTimerRef = useRef<number | null>(null)
|
||||||
|
const contactsLoadTimeoutMsRef = useRef(DEFAULT_CONTACTS_LOAD_TIMEOUT_MS)
|
||||||
|
const contactsAvatarCacheRef = useRef<Record<string, configService.ContactsAvatarCacheEntry>>({})
|
||||||
|
const contactsListRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const ensureExportCacheScope = useCallback(async (): Promise<string> => {
|
const ensureExportCacheScope = useCallback(async (): Promise<string> => {
|
||||||
if (exportCacheScopeReadyRef.current) {
|
if (exportCacheScopeReadyRef.current) {
|
||||||
@@ -564,6 +626,331 @@ function ExportPage() {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const value = await configService.getContactsLoadTimeoutMs()
|
||||||
|
if (!cancelled) {
|
||||||
|
setContactsLoadTimeoutMs(value)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('读取通讯录超时配置失败:', error)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
contactsLoadTimeoutMsRef.current = contactsLoadTimeoutMs
|
||||||
|
}, [contactsLoadTimeoutMs])
|
||||||
|
|
||||||
|
const applyEnrichedContactsToList = useCallback((enrichedMap: Record<string, { displayName?: string; avatarUrl?: string }>) => {
|
||||||
|
if (!enrichedMap || Object.keys(enrichedMap).length === 0) return
|
||||||
|
setContactsList(prev => {
|
||||||
|
let changed = false
|
||||||
|
const next = prev.map(contact => {
|
||||||
|
const enriched = enrichedMap[contact.username]
|
||||||
|
if (!enriched) return contact
|
||||||
|
const displayName = enriched.displayName || contact.displayName
|
||||||
|
const avatarUrl = enriched.avatarUrl || contact.avatarUrl
|
||||||
|
if (displayName === contact.displayName && avatarUrl === contact.avatarUrl) {
|
||||||
|
return contact
|
||||||
|
}
|
||||||
|
changed = true
|
||||||
|
return {
|
||||||
|
...contact,
|
||||||
|
displayName,
|
||||||
|
avatarUrl
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return changed ? next : prev
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const enrichContactsListInBackground = useCallback(async (
|
||||||
|
sourceContacts: ContactInfo[],
|
||||||
|
loadVersion: number,
|
||||||
|
scopeKey: string
|
||||||
|
) => {
|
||||||
|
const sourceByUsername = new Map<string, ContactInfo>()
|
||||||
|
for (const contact of sourceContacts) {
|
||||||
|
if (!contact.username) continue
|
||||||
|
sourceByUsername.set(contact.username, contact)
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
const usernames = sourceContacts
|
||||||
|
.map(contact => contact.username)
|
||||||
|
.filter(Boolean)
|
||||||
|
.filter((username) => {
|
||||||
|
const currentContact = sourceByUsername.get(username)
|
||||||
|
if (!currentContact) return false
|
||||||
|
const cacheEntry = contactsAvatarCacheRef.current[username]
|
||||||
|
if (!cacheEntry || !cacheEntry.avatarUrl) {
|
||||||
|
return !currentContact.avatarUrl
|
||||||
|
}
|
||||||
|
if (currentContact.avatarUrl && currentContact.avatarUrl !== cacheEntry.avatarUrl) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const checkedAt = cacheEntry.checkedAt || 0
|
||||||
|
return now - checkedAt >= EXPORT_AVATAR_RECHECK_INTERVAL_MS
|
||||||
|
})
|
||||||
|
|
||||||
|
const total = usernames.length
|
||||||
|
setContactsAvatarEnrichProgress({
|
||||||
|
loaded: 0,
|
||||||
|
total,
|
||||||
|
running: total > 0
|
||||||
|
})
|
||||||
|
if (total === 0) return
|
||||||
|
|
||||||
|
for (let i = 0; i < total; i += EXPORT_AVATAR_ENRICH_BATCH_SIZE) {
|
||||||
|
if (contactsLoadVersionRef.current !== loadVersion) return
|
||||||
|
const batch = usernames.slice(i, i + EXPORT_AVATAR_ENRICH_BATCH_SIZE)
|
||||||
|
if (batch.length === 0) continue
|
||||||
|
|
||||||
|
try {
|
||||||
|
const avatarResult = await window.electronAPI.chat.enrichSessionsContactInfo(batch)
|
||||||
|
if (contactsLoadVersionRef.current !== loadVersion) return
|
||||||
|
if (avatarResult.success && avatarResult.contacts) {
|
||||||
|
applyEnrichedContactsToList(avatarResult.contacts)
|
||||||
|
for (const [username, enriched] of Object.entries(avatarResult.contacts)) {
|
||||||
|
const prev = sourceByUsername.get(username)
|
||||||
|
if (!prev) continue
|
||||||
|
sourceByUsername.set(username, {
|
||||||
|
...prev,
|
||||||
|
displayName: enriched.displayName || prev.displayName,
|
||||||
|
avatarUrl: enriched.avatarUrl || prev.avatarUrl
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const batchContacts = batch
|
||||||
|
.map(username => sourceByUsername.get(username))
|
||||||
|
.filter((contact): contact is ContactInfo => Boolean(contact))
|
||||||
|
const upsertResult = upsertAvatarCacheFromContacts(
|
||||||
|
contactsAvatarCacheRef.current,
|
||||||
|
batchContacts,
|
||||||
|
{ markCheckedUsernames: batch }
|
||||||
|
)
|
||||||
|
contactsAvatarCacheRef.current = upsertResult.avatarEntries
|
||||||
|
if (upsertResult.updatedAt) {
|
||||||
|
setAvatarCacheUpdatedAt(upsertResult.updatedAt)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('导出页分批补全头像失败:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
const loaded = Math.min(i + batch.length, total)
|
||||||
|
setContactsAvatarEnrichProgress({
|
||||||
|
loaded,
|
||||||
|
total,
|
||||||
|
running: loaded < total
|
||||||
|
})
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
void configService.setContactsAvatarCache(scopeKey, contactsAvatarCacheRef.current).catch((error) => {
|
||||||
|
console.error('写入导出页头像缓存失败:', error)
|
||||||
|
})
|
||||||
|
}, [applyEnrichedContactsToList])
|
||||||
|
|
||||||
|
const loadContactsList = useCallback(async (options?: { scopeKey?: string }) => {
|
||||||
|
const scopeKey = options?.scopeKey || await ensureExportCacheScope()
|
||||||
|
const loadVersion = contactsLoadVersionRef.current + 1
|
||||||
|
contactsLoadVersionRef.current = loadVersion
|
||||||
|
contactsLoadAttemptRef.current += 1
|
||||||
|
const startedAt = Date.now()
|
||||||
|
const timeoutMs = contactsLoadTimeoutMsRef.current
|
||||||
|
const requestId = `export-contacts-${startedAt}-${contactsLoadAttemptRef.current}`
|
||||||
|
setContactsLoadSession({
|
||||||
|
requestId,
|
||||||
|
startedAt,
|
||||||
|
attempt: contactsLoadAttemptRef.current,
|
||||||
|
timeoutMs
|
||||||
|
})
|
||||||
|
setContactsLoadIssue(null)
|
||||||
|
setShowContactsDiagnostics(false)
|
||||||
|
if (contactsLoadTimeoutTimerRef.current) {
|
||||||
|
window.clearTimeout(contactsLoadTimeoutTimerRef.current)
|
||||||
|
contactsLoadTimeoutTimerRef.current = null
|
||||||
|
}
|
||||||
|
const timeoutTimerId = window.setTimeout(() => {
|
||||||
|
if (contactsLoadVersionRef.current !== loadVersion) return
|
||||||
|
const elapsedMs = Date.now() - startedAt
|
||||||
|
setContactsLoadIssue({
|
||||||
|
kind: 'timeout',
|
||||||
|
title: '联系人列表加载超时',
|
||||||
|
message: `等待超过 ${timeoutMs}ms,联系人列表仍未返回。`,
|
||||||
|
reason: 'chat.getContacts 长时间未返回,可能是数据库查询繁忙或连接异常。',
|
||||||
|
occurredAt: Date.now(),
|
||||||
|
elapsedMs
|
||||||
|
})
|
||||||
|
}, timeoutMs)
|
||||||
|
contactsLoadTimeoutTimerRef.current = timeoutTimerId
|
||||||
|
|
||||||
|
setIsContactsListLoading(true)
|
||||||
|
setContactsAvatarEnrichProgress({
|
||||||
|
loaded: 0,
|
||||||
|
total: 0,
|
||||||
|
running: false
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const contactsResult = await window.electronAPI.chat.getContacts()
|
||||||
|
if (contactsLoadVersionRef.current !== loadVersion) return
|
||||||
|
|
||||||
|
if (contactsResult.success && contactsResult.contacts) {
|
||||||
|
if (contactsLoadTimeoutTimerRef.current === timeoutTimerId) {
|
||||||
|
window.clearTimeout(contactsLoadTimeoutTimerRef.current)
|
||||||
|
contactsLoadTimeoutTimerRef.current = null
|
||||||
|
}
|
||||||
|
const contactsWithAvatarCache = mergeAvatarCacheIntoContacts(
|
||||||
|
contactsResult.contacts,
|
||||||
|
contactsAvatarCacheRef.current
|
||||||
|
)
|
||||||
|
setContactsList(contactsWithAvatarCache)
|
||||||
|
syncContactTypeCounts(contactsWithAvatarCache)
|
||||||
|
setContactsDataSource('network')
|
||||||
|
setContactsUpdatedAt(Date.now())
|
||||||
|
setContactsLoadIssue(null)
|
||||||
|
setIsContactsListLoading(false)
|
||||||
|
|
||||||
|
const upsertResult = upsertAvatarCacheFromContacts(
|
||||||
|
contactsAvatarCacheRef.current,
|
||||||
|
contactsWithAvatarCache,
|
||||||
|
{ prune: true }
|
||||||
|
)
|
||||||
|
contactsAvatarCacheRef.current = upsertResult.avatarEntries
|
||||||
|
if (upsertResult.updatedAt) {
|
||||||
|
setAvatarCacheUpdatedAt(upsertResult.updatedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
void configService.setContactsAvatarCache(scopeKey, contactsAvatarCacheRef.current).catch((error) => {
|
||||||
|
console.error('写入导出页头像缓存失败:', error)
|
||||||
|
})
|
||||||
|
void configService.setContactsListCache(
|
||||||
|
scopeKey,
|
||||||
|
contactsWithAvatarCache.map(contact => ({
|
||||||
|
username: contact.username,
|
||||||
|
displayName: contact.displayName,
|
||||||
|
remark: contact.remark,
|
||||||
|
nickname: contact.nickname,
|
||||||
|
type: contact.type
|
||||||
|
}))
|
||||||
|
).catch((error) => {
|
||||||
|
console.error('写入导出页通讯录缓存失败:', error)
|
||||||
|
})
|
||||||
|
void enrichContactsListInBackground(contactsWithAvatarCache, loadVersion, scopeKey)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsedMs = Date.now() - startedAt
|
||||||
|
setContactsLoadIssue({
|
||||||
|
kind: 'error',
|
||||||
|
title: '联系人列表加载失败',
|
||||||
|
message: '联系人接口返回失败,未拿到联系人列表。',
|
||||||
|
reason: 'chat.getContacts 返回 success=false。',
|
||||||
|
errorDetail: contactsResult.error || '未知错误',
|
||||||
|
occurredAt: Date.now(),
|
||||||
|
elapsedMs
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载导出页联系人失败:', error)
|
||||||
|
const elapsedMs = Date.now() - startedAt
|
||||||
|
setContactsLoadIssue({
|
||||||
|
kind: 'error',
|
||||||
|
title: '联系人列表加载失败',
|
||||||
|
message: '联系人请求执行异常。',
|
||||||
|
reason: '调用 chat.getContacts 发生异常。',
|
||||||
|
errorDetail: String(error),
|
||||||
|
occurredAt: Date.now(),
|
||||||
|
elapsedMs
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
if (contactsLoadTimeoutTimerRef.current === timeoutTimerId) {
|
||||||
|
window.clearTimeout(contactsLoadTimeoutTimerRef.current)
|
||||||
|
contactsLoadTimeoutTimerRef.current = null
|
||||||
|
}
|
||||||
|
if (contactsLoadVersionRef.current === loadVersion) {
|
||||||
|
setIsContactsListLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [ensureExportCacheScope, enrichContactsListInBackground, syncContactTypeCounts])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isExportRoute) return
|
||||||
|
let cancelled = false
|
||||||
|
void (async () => {
|
||||||
|
const scopeKey = await ensureExportCacheScope()
|
||||||
|
if (cancelled) return
|
||||||
|
try {
|
||||||
|
const [cacheItem, avatarCacheItem] = await Promise.all([
|
||||||
|
configService.getContactsListCache(scopeKey),
|
||||||
|
configService.getContactsAvatarCache(scopeKey)
|
||||||
|
])
|
||||||
|
const avatarCacheMap = avatarCacheItem?.avatars || {}
|
||||||
|
contactsAvatarCacheRef.current = avatarCacheMap
|
||||||
|
setAvatarCacheUpdatedAt(avatarCacheItem?.updatedAt || null)
|
||||||
|
if (!cancelled && cacheItem && Array.isArray(cacheItem.contacts) && cacheItem.contacts.length > 0) {
|
||||||
|
const cachedContacts: ContactInfo[] = cacheItem.contacts.map(contact => ({
|
||||||
|
...contact,
|
||||||
|
avatarUrl: avatarCacheMap[contact.username]?.avatarUrl
|
||||||
|
}))
|
||||||
|
setContactsList(cachedContacts)
|
||||||
|
syncContactTypeCounts(cachedContacts)
|
||||||
|
setContactsDataSource('cache')
|
||||||
|
setContactsUpdatedAt(cacheItem.updatedAt || null)
|
||||||
|
setIsContactsListLoading(false)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('读取导出页联系人缓存失败:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cancelled) {
|
||||||
|
void loadContactsList({ scopeKey })
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [isExportRoute, ensureExportCacheScope, loadContactsList, syncContactTypeCounts])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isExportRoute) return
|
||||||
|
contactsLoadVersionRef.current += 1
|
||||||
|
setContactsAvatarEnrichProgress({
|
||||||
|
loaded: 0,
|
||||||
|
total: 0,
|
||||||
|
running: false
|
||||||
|
})
|
||||||
|
}, [isExportRoute])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (contactsLoadTimeoutTimerRef.current) {
|
||||||
|
window.clearTimeout(contactsLoadTimeoutTimerRef.current)
|
||||||
|
contactsLoadTimeoutTimerRef.current = null
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (contactsLoadTimeoutTimerRef.current) {
|
||||||
|
window.clearTimeout(contactsLoadTimeoutTimerRef.current)
|
||||||
|
contactsLoadTimeoutTimerRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!contactsLoadIssue || contactsList.length > 0) return
|
||||||
|
if (!(isContactsListLoading && contactsLoadIssue.kind === 'timeout')) return
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
setContactsDiagnosticTick(Date.now())
|
||||||
|
}, 500)
|
||||||
|
return () => window.clearInterval(timer)
|
||||||
|
}, [contactsList.length, isContactsListLoading, contactsLoadIssue])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
tasksRef.current = tasks
|
tasksRef.current = tasks
|
||||||
}, [tasks])
|
}, [tasks])
|
||||||
@@ -1035,28 +1422,39 @@ function ExportPage() {
|
|||||||
const openExportDialog = (payload: Omit<ExportDialogState, 'open'>) => {
|
const openExportDialog = (payload: Omit<ExportDialogState, 'open'>) => {
|
||||||
setExportDialog({ open: true, ...payload })
|
setExportDialog({ open: true, ...payload })
|
||||||
|
|
||||||
if (payload.scope === 'sns') {
|
setOptions(prev => {
|
||||||
setOptions(prev => ({
|
const nextDateRange = prev.dateRange ?? (() => {
|
||||||
|
const now = new Date()
|
||||||
|
const start = new Date(now)
|
||||||
|
start.setHours(0, 0, 0, 0)
|
||||||
|
return { start, end: now }
|
||||||
|
})()
|
||||||
|
|
||||||
|
const next: ExportOptions = {
|
||||||
...prev,
|
...prev,
|
||||||
format: prev.format === 'json' || prev.format === 'html' ? prev.format : 'html'
|
useAllTime: true,
|
||||||
}))
|
dateRange: nextDateRange
|
||||||
return
|
}
|
||||||
|
|
||||||
|
if (payload.scope === 'sns') {
|
||||||
|
next.format = prev.format === 'json' || prev.format === 'html' ? prev.format : 'html'
|
||||||
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payload.scope === 'content' && payload.contentType) {
|
if (payload.scope === 'content' && payload.contentType) {
|
||||||
if (payload.contentType === 'text') {
|
if (payload.contentType === 'text') {
|
||||||
setOptions(prev => ({ ...prev, exportMedia: false }))
|
next.exportMedia = false
|
||||||
} else {
|
} else {
|
||||||
setOptions(prev => ({
|
next.exportMedia = true
|
||||||
...prev,
|
next.exportImages = payload.contentType === 'image'
|
||||||
exportMedia: true,
|
next.exportVoices = payload.contentType === 'voice'
|
||||||
exportImages: payload.contentType === 'image',
|
next.exportVideos = payload.contentType === 'video'
|
||||||
exportVoices: payload.contentType === 'voice',
|
next.exportEmojis = payload.contentType === 'emoji'
|
||||||
exportVideos: payload.contentType === 'video',
|
|
||||||
exportEmojis: payload.contentType === 'emoji'
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return next
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const closeExportDialog = () => {
|
const closeExportDialog = () => {
|
||||||
@@ -1492,6 +1890,120 @@ function ExportPage() {
|
|||||||
return '公众号'
|
return '公众号'
|
||||||
}, [activeTab])
|
}, [activeTab])
|
||||||
|
|
||||||
|
const filteredContacts = useMemo(() => {
|
||||||
|
const keyword = searchKeyword.trim().toLowerCase()
|
||||||
|
return contactsList
|
||||||
|
.filter((contact) => {
|
||||||
|
if (!matchesContactTab(contact, activeTab)) return false
|
||||||
|
if (!keyword) return true
|
||||||
|
return (
|
||||||
|
(contact.displayName || '').toLowerCase().includes(keyword) ||
|
||||||
|
(contact.remark || '').toLowerCase().includes(keyword) ||
|
||||||
|
contact.username.toLowerCase().includes(keyword)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.sort((a, b) => (a.displayName || a.username).localeCompare(b.displayName || b.username, 'zh-Hans-CN'))
|
||||||
|
}, [contactsList, activeTab, searchKeyword])
|
||||||
|
|
||||||
|
const contactsUpdatedAtLabel = useMemo(() => {
|
||||||
|
if (!contactsUpdatedAt) return ''
|
||||||
|
return new Date(contactsUpdatedAt).toLocaleString()
|
||||||
|
}, [contactsUpdatedAt])
|
||||||
|
|
||||||
|
const avatarCacheUpdatedAtLabel = useMemo(() => {
|
||||||
|
if (!avatarCacheUpdatedAt) return ''
|
||||||
|
return new Date(avatarCacheUpdatedAt).toLocaleString()
|
||||||
|
}, [avatarCacheUpdatedAt])
|
||||||
|
|
||||||
|
const contactsAvatarCachedCount = useMemo(() => {
|
||||||
|
return contactsList.reduce((count, contact) => (
|
||||||
|
contact.avatarUrl ? count + 1 : count
|
||||||
|
), 0)
|
||||||
|
}, [contactsList])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!contactsListRef.current) return
|
||||||
|
contactsListRef.current.scrollTop = 0
|
||||||
|
setContactsListScrollTop(0)
|
||||||
|
}, [activeTab, searchKeyword])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const node = contactsListRef.current
|
||||||
|
if (!node) return
|
||||||
|
const updateViewportHeight = () => {
|
||||||
|
setContactsListViewportHeight(Math.max(node.clientHeight, CONTACTS_LIST_VIRTUAL_ROW_HEIGHT))
|
||||||
|
}
|
||||||
|
updateViewportHeight()
|
||||||
|
const observer = new ResizeObserver(() => updateViewportHeight())
|
||||||
|
observer.observe(node)
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [filteredContacts.length, isContactsListLoading])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const maxScroll = Math.max(0, filteredContacts.length * CONTACTS_LIST_VIRTUAL_ROW_HEIGHT - contactsListViewportHeight)
|
||||||
|
if (contactsListScrollTop <= maxScroll) return
|
||||||
|
setContactsListScrollTop(maxScroll)
|
||||||
|
if (contactsListRef.current) {
|
||||||
|
contactsListRef.current.scrollTop = maxScroll
|
||||||
|
}
|
||||||
|
}, [filteredContacts.length, contactsListViewportHeight, contactsListScrollTop])
|
||||||
|
|
||||||
|
const { startIndex: contactStartIndex, endIndex: contactEndIndex } = useMemo(() => {
|
||||||
|
if (filteredContacts.length === 0) {
|
||||||
|
return { startIndex: 0, endIndex: 0 }
|
||||||
|
}
|
||||||
|
const baseStart = Math.floor(contactsListScrollTop / CONTACTS_LIST_VIRTUAL_ROW_HEIGHT)
|
||||||
|
const visibleCount = Math.ceil(contactsListViewportHeight / CONTACTS_LIST_VIRTUAL_ROW_HEIGHT)
|
||||||
|
const nextStart = Math.max(0, baseStart - CONTACTS_LIST_VIRTUAL_OVERSCAN)
|
||||||
|
const nextEnd = Math.min(filteredContacts.length, nextStart + visibleCount + CONTACTS_LIST_VIRTUAL_OVERSCAN * 2)
|
||||||
|
return {
|
||||||
|
startIndex: nextStart,
|
||||||
|
endIndex: nextEnd
|
||||||
|
}
|
||||||
|
}, [filteredContacts.length, contactsListViewportHeight, contactsListScrollTop])
|
||||||
|
|
||||||
|
const visibleContacts = useMemo(() => {
|
||||||
|
return filteredContacts.slice(contactStartIndex, contactEndIndex)
|
||||||
|
}, [filteredContacts, contactStartIndex, contactEndIndex])
|
||||||
|
|
||||||
|
const onContactsListScroll = useCallback((event: UIEvent<HTMLDivElement>) => {
|
||||||
|
setContactsListScrollTop(event.currentTarget.scrollTop)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const contactsIssueElapsedMs = useMemo(() => {
|
||||||
|
if (!contactsLoadIssue) return 0
|
||||||
|
if (isContactsListLoading && contactsLoadSession) {
|
||||||
|
return Math.max(contactsLoadIssue.elapsedMs, contactsDiagnosticTick - contactsLoadSession.startedAt)
|
||||||
|
}
|
||||||
|
return contactsLoadIssue.elapsedMs
|
||||||
|
}, [contactsDiagnosticTick, isContactsListLoading, contactsLoadIssue, contactsLoadSession])
|
||||||
|
|
||||||
|
const contactsDiagnosticsText = useMemo(() => {
|
||||||
|
if (!contactsLoadIssue || !contactsLoadSession) return ''
|
||||||
|
return [
|
||||||
|
`请求ID: ${contactsLoadSession.requestId}`,
|
||||||
|
`请求序号: 第 ${contactsLoadSession.attempt} 次`,
|
||||||
|
`阈值配置: ${contactsLoadSession.timeoutMs}ms`,
|
||||||
|
`当前状态: ${contactsLoadIssue.kind === 'timeout' ? '超时等待中' : '请求失败'}`,
|
||||||
|
`累计耗时: ${(contactsIssueElapsedMs / 1000).toFixed(1)}s`,
|
||||||
|
`发生时间: ${new Date(contactsLoadIssue.occurredAt).toLocaleString()}`,
|
||||||
|
'阶段: chat.getContacts',
|
||||||
|
`原因: ${contactsLoadIssue.reason}`,
|
||||||
|
`错误详情: ${contactsLoadIssue.errorDetail || '无'}`
|
||||||
|
].join('\n')
|
||||||
|
}, [contactsIssueElapsedMs, contactsLoadIssue, contactsLoadSession])
|
||||||
|
|
||||||
|
const copyContactsDiagnostics = useCallback(async () => {
|
||||||
|
if (!contactsDiagnosticsText) return
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(contactsDiagnosticsText)
|
||||||
|
alert('诊断信息已复制')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('复制诊断信息失败:', error)
|
||||||
|
alert('复制失败,请手动复制诊断信息')
|
||||||
|
}
|
||||||
|
}, [contactsDiagnosticsText])
|
||||||
|
|
||||||
const sessionContactsUpdatedAtLabel = useMemo(() => {
|
const sessionContactsUpdatedAtLabel = useMemo(() => {
|
||||||
if (!sessionContactsUpdatedAt) return ''
|
if (!sessionContactsUpdatedAt) return ''
|
||||||
return new Date(sessionContactsUpdatedAt).toLocaleString()
|
return new Date(sessionContactsUpdatedAt).toLocaleString()
|
||||||
@@ -1797,7 +2309,7 @@ function ExportPage() {
|
|||||||
<input
|
<input
|
||||||
value={searchKeyword}
|
value={searchKeyword}
|
||||||
onChange={(event) => setSearchKeyword(event.target.value)}
|
onChange={(event) => setSearchKeyword(event.target.value)}
|
||||||
placeholder={`搜索${activeTabLabel}会话...`}
|
placeholder={`搜索${activeTabLabel}联系人...`}
|
||||||
/>
|
/>
|
||||||
{searchKeyword && (
|
{searchKeyword && (
|
||||||
<button className="clear-search" onClick={() => setSearchKeyword('')}>
|
<button className="clear-search" onClick={() => setSearchKeyword('')}>
|
||||||
@@ -1805,72 +2317,123 @@ function ExportPage() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<button className="secondary-btn" onClick={() => void loadContactsList()} disabled={isContactsListLoading}>
|
||||||
<button className="secondary-btn" onClick={toggleSelectAllVisible}>
|
<RefreshCw size={14} className={isContactsListLoading ? 'spin' : ''} />
|
||||||
{visibleSelectedCount > 0 && visibleSelectedCount === visibleSessions.length ? '取消全选' : '全选当前'}
|
刷新
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{selectedCount > 0 && (
|
|
||||||
<div className="selected-batch-actions">
|
|
||||||
<span>已选中 {selectedCount} 个会话</span>
|
|
||||||
<button className="primary-btn" onClick={openBatchExport}>
|
|
||||||
<Download size={14} /> 导出
|
|
||||||
</button>
|
|
||||||
<button className="secondary-btn" onClick={clearSelection}>清空</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="table-cache-meta">
|
<div className="table-cache-meta">
|
||||||
{sessionContactsUpdatedAt && (
|
|
||||||
<span className="meta-item">
|
<span className="meta-item">
|
||||||
{sessionDataSource === 'cache' ? '缓存' : '最新'} · 更新于 {sessionContactsUpdatedAtLabel}
|
共 {filteredContacts.length} / {contactsList.length} 个联系人
|
||||||
|
</span>
|
||||||
|
{contactsUpdatedAt && (
|
||||||
|
<span className="meta-item">
|
||||||
|
{contactsDataSource === 'cache' ? '缓存' : '最新'} · 更新于 {contactsUpdatedAtLabel}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{sessions.length > 0 && (
|
{contactsList.length > 0 && (
|
||||||
<span className="meta-item">
|
<span className="meta-item">
|
||||||
头像缓存 {sessionAvatarCachedCount}/{sessions.length}
|
头像缓存 {contactsAvatarCachedCount}/{contactsList.length}
|
||||||
{sessionAvatarUpdatedAtLabel ? ` · 更新于 ${sessionAvatarUpdatedAtLabel}` : ''}
|
{avatarCacheUpdatedAtLabel ? ` · 更新于 ${avatarCacheUpdatedAtLabel}` : ''}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{(isLoading || isSessionEnriching) && sessions.length > 0 && (
|
{(isContactsListLoading || contactsAvatarEnrichProgress.running) && contactsList.length > 0 && (
|
||||||
<span className="meta-item syncing">后台同步中...</span>
|
<span className="meta-item syncing">后台同步中...</span>
|
||||||
)}
|
)}
|
||||||
|
{contactsAvatarEnrichProgress.running && (
|
||||||
|
<span className="meta-item syncing">
|
||||||
|
头像补全中 {contactsAvatarEnrichProgress.loaded}/{contactsAvatarEnrichProgress.total}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!showInitialSkeleton && (isLoading || isSessionEnriching) && (
|
{contactsList.length > 0 && (isContactsListLoading || contactsAvatarEnrichProgress.running) && (
|
||||||
<div className="table-stage-hint">
|
<div className="table-stage-hint">
|
||||||
<Loader2 size={14} className="spin" />
|
<Loader2 size={14} className="spin" />
|
||||||
{isLoading ? '导出板块数据加载中…' : '正在补充头像…'}
|
{isContactsListLoading ? '联系人列表同步中…' : '正在补充头像…'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="table-wrap">
|
<div className="table-wrap">
|
||||||
{showInitialSkeleton ? (
|
{contactsList.length === 0 && contactsLoadIssue ? (
|
||||||
<div className="table-skeleton-list">
|
<div className="load-issue-state">
|
||||||
{Array.from({ length: 8 }).map((_, rowIndex) => (
|
<div className="issue-card">
|
||||||
<div key={`skeleton-row-${rowIndex}`} className="table-skeleton-item">
|
<div className="issue-title">
|
||||||
<span className="skeleton-shimmer skeleton-dot"></span>
|
<AlertTriangle size={18} />
|
||||||
<span className="skeleton-shimmer skeleton-avatar"></span>
|
<span>{contactsLoadIssue.title}</span>
|
||||||
<span className="skeleton-shimmer skeleton-line w-30"></span>
|
|
||||||
<span className="skeleton-shimmer skeleton-line w-12"></span>
|
|
||||||
<span className="skeleton-shimmer skeleton-line w-12"></span>
|
|
||||||
<span className="skeleton-shimmer skeleton-line w-12"></span>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
<p className="issue-message">{contactsLoadIssue.message}</p>
|
||||||
|
<p className="issue-reason">{contactsLoadIssue.reason}</p>
|
||||||
|
<ul className="issue-hints">
|
||||||
|
<li>可能原因1:数据库当前仍在执行高开销查询(例如导出页后台统计)。</li>
|
||||||
|
<li>可能原因2:contact.db 数据量较大,首次查询时间过长。</li>
|
||||||
|
<li>可能原因3:数据库连接状态异常或 IPC 调用卡住。</li>
|
||||||
|
</ul>
|
||||||
|
<div className="issue-actions">
|
||||||
|
<button className="issue-btn primary" onClick={() => void loadContactsList()}>
|
||||||
|
<RefreshCw size={14} />
|
||||||
|
<span>重试加载</span>
|
||||||
|
</button>
|
||||||
|
<button className="issue-btn" onClick={() => setShowContactsDiagnostics(prev => !prev)}>
|
||||||
|
<ClipboardList size={14} />
|
||||||
|
<span>{showContactsDiagnostics ? '收起诊断详情' : '查看诊断详情'}</span>
|
||||||
|
</button>
|
||||||
|
<button className="issue-btn" onClick={copyContactsDiagnostics}>
|
||||||
|
<span>复制诊断信息</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{showContactsDiagnostics && (
|
||||||
|
<pre className="issue-diagnostics">{contactsDiagnosticsText}</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : isContactsListLoading && contactsList.length === 0 ? (
|
||||||
|
<div className="loading-state">
|
||||||
|
<Loader2 size={32} className="spin" />
|
||||||
|
<span>联系人加载中...</span>
|
||||||
|
</div>
|
||||||
|
) : filteredContacts.length === 0 ? (
|
||||||
|
<div className="empty-state">
|
||||||
|
<span>暂无联系人</span>
|
||||||
</div>
|
</div>
|
||||||
) : visibleSessions.length === 0 ? (
|
|
||||||
<div className="table-state">暂无会话</div>
|
|
||||||
) : (
|
) : (
|
||||||
<TableVirtuoso
|
<div className="contacts-list" ref={contactsListRef} onScroll={onContactsListScroll}>
|
||||||
className="table-virtuoso"
|
<div
|
||||||
data={visibleSessions}
|
className="contacts-list-virtual"
|
||||||
fixedHeaderContent={renderTableHeader}
|
style={{ height: filteredContacts.length * CONTACTS_LIST_VIRTUAL_ROW_HEIGHT }}
|
||||||
computeItemKey={(_, session) => session.username}
|
>
|
||||||
itemContent={(_, session) => renderRowCells(session)}
|
{visibleContacts.map((contact, idx) => {
|
||||||
overscan={420}
|
const absoluteIndex = contactStartIndex + idx
|
||||||
/>
|
const top = absoluteIndex * CONTACTS_LIST_VIRTUAL_ROW_HEIGHT
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={contact.username}
|
||||||
|
className="contact-row"
|
||||||
|
style={{ transform: `translateY(${top}px)` }}
|
||||||
|
>
|
||||||
|
<div className="contact-item">
|
||||||
|
<div className="contact-avatar">
|
||||||
|
{contact.avatarUrl ? (
|
||||||
|
<img src={contact.avatarUrl} alt="" loading="lazy" />
|
||||||
|
) : (
|
||||||
|
<span>{getAvatarLetter(contact.displayName)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="contact-info">
|
||||||
|
<div className="contact-name">{contact.displayName}</div>
|
||||||
|
<div className="contact-remark">{contact.username}</div>
|
||||||
|
</div>
|
||||||
|
<div className={`contact-type ${contact.type}`}>
|
||||||
|
<span>{getContactTypeName(contact.type)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1994,18 +2557,23 @@ function ExportPage() {
|
|||||||
|
|
||||||
<div className="dialog-section">
|
<div className="dialog-section">
|
||||||
<h4>发送者名称显示</h4>
|
<h4>发送者名称显示</h4>
|
||||||
<div className="display-name-options">
|
<div className="display-name-options" role="radiogroup" aria-label="发送者名称显示">
|
||||||
{displayNameOptions.map(option => (
|
{displayNameOptions.map(option => {
|
||||||
<label key={option.value} className={`display-name-item ${options.displayNamePreference === option.value ? 'active' : ''}`}>
|
const isActive = options.displayNamePreference === option.value
|
||||||
<input
|
return (
|
||||||
type="radio"
|
<button
|
||||||
checked={options.displayNamePreference === option.value}
|
key={option.value}
|
||||||
onChange={() => setOptions(prev => ({ ...prev, displayNamePreference: option.value }))}
|
type="button"
|
||||||
/>
|
role="radio"
|
||||||
|
aria-checked={isActive}
|
||||||
|
className={`display-name-item ${isActive ? 'active' : ''}`}
|
||||||
|
onClick={() => setOptions(prev => ({ ...prev, displayNamePreference: option.value }))}
|
||||||
|
>
|
||||||
<span>{option.label}</span>
|
<span>{option.label}</span>
|
||||||
<small>{option.desc}</small>
|
<small>{option.desc}</small>
|
||||||
</label>
|
</button>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user