feat: support batch-select SNS contacts

This commit is contained in:
aits2026
2026-03-10 11:01:54 +08:00
parent 2d4a5fc62f
commit d4915e1a62
3 changed files with 243 additions and 40 deletions

View File

@@ -1,5 +1,5 @@
import React from 'react' import React from 'react'
import { Search, User, X, Loader2 } from 'lucide-react' import { Search, User, X, Loader2, CheckSquare, Square, Download } from 'lucide-react'
import { Avatar } from '../Avatar' import { Avatar } from '../Avatar'
interface Contact { interface Contact {
@@ -25,7 +25,12 @@ interface SnsFilterPanelProps {
setContactSearch: (val: string) => void setContactSearch: (val: string) => void
loading?: boolean loading?: boolean
contactsCountProgress?: ContactsCountProgress contactsCountProgress?: ContactsCountProgress
selectedContactUsernames: string[]
activeContactUsername?: string
onOpenContactTimeline: (contact: Contact) => void onOpenContactTimeline: (contact: Contact) => void
onToggleContactSelected: (contact: Contact) => void
onClearSelectedContacts: () => void
onExportSelectedContacts: () => void
} }
export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
@@ -37,12 +42,21 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
setContactSearch, setContactSearch,
loading, loading,
contactsCountProgress, contactsCountProgress,
onOpenContactTimeline selectedContactUsernames,
activeContactUsername,
onOpenContactTimeline,
onToggleContactSelected,
onClearSelectedContacts,
onExportSelectedContacts
}) => { }) => {
const filteredContacts = contacts.filter(c => const filteredContacts = contacts.filter(c =>
(c.displayName || '').toLowerCase().includes(contactSearch.toLowerCase()) || (c.displayName || '').toLowerCase().includes(contactSearch.toLowerCase()) ||
c.username.toLowerCase().includes(contactSearch.toLowerCase()) c.username.toLowerCase().includes(contactSearch.toLowerCase())
) )
const selectedContactLookup = React.useMemo(
() => new Set(selectedContactUsernames),
[selectedContactUsernames]
)
const clearFilters = () => { const clearFilters = () => {
setSearchKeyword('') setSearchKeyword('')
@@ -122,14 +136,34 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
</div> </div>
)} )}
<div className="contact-interaction-hint">
</div>
<div className="contact-list-scroll"> <div className="contact-list-scroll">
{filteredContacts.map(contact => { {filteredContacts.map(contact => {
const isPostCountReady = contact.postCountStatus === 'ready' const isPostCountReady = contact.postCountStatus === 'ready'
const isSelected = selectedContactLookup.has(contact.username)
const isActive = activeContactUsername === contact.username
return ( return (
<div <div
key={contact.username} key={contact.username}
className="contact-row" className={`contact-row${isSelected ? ' is-selected' : ''}${isActive ? ' is-active' : ''}`}
>
<button
type="button"
className={`contact-select-btn${isSelected ? ' checked' : ''}`}
onClick={() => onToggleContactSelected(contact)}
title={isSelected ? `取消选择 ${contact.displayName}` : `选择 ${contact.displayName}`}
aria-pressed={isSelected}
>
{isSelected ? <CheckSquare size={16} /> : <Square size={16} />}
</button>
<button
type="button"
className="contact-main-btn"
onClick={() => onOpenContactTimeline(contact)} onClick={() => onOpenContactTimeline(contact)}
title={`查看 ${contact.displayName} 的朋友圈`}
> >
<Avatar src={contact.avatarUrl} name={contact.displayName} size={36} shape="rounded" /> <Avatar src={contact.avatarUrl} name={contact.displayName} size={36} shape="rounded" />
<div className="contact-meta"> <div className="contact-meta">
@@ -144,6 +178,7 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
</span> </span>
)} )}
</div> </div>
</button>
</div> </div>
) )
})} })}
@@ -151,6 +186,19 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
<div className="empty-state">{getEmptyStateText()}</div> <div className="empty-state">{getEmptyStateText()}</div>
)} )}
</div> </div>
{selectedContactUsernames.length > 0 && (
<div className="contact-batch-bar">
<span className="contact-batch-summary"> {selectedContactUsernames.length} </span>
<button type="button" className="contact-batch-btn" onClick={onClearSelectedContacts}>
</button>
<button type="button" className="contact-batch-btn primary" onClick={onExportSelectedContacts}>
<Download size={14} />
<span></span>
</button>
</div>
)}
</div> </div>
</div> </div>
</aside> </aside>

View File

@@ -1211,6 +1211,13 @@
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.contact-interaction-hint {
padding: 10px 16px 0;
font-size: 11px;
line-height: 1.5;
color: var(--text-tertiary);
}
.contact-list-scroll { .contact-list-scroll {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
@@ -1218,23 +1225,75 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0; gap: 0;
/* Remove gap to allow borders to merge */
.contact-row { .contact-row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 8px;
padding: 10px;
border-radius: var(--sns-border-radius-md);
cursor: pointer;
transition: background 0.2s ease, transform 0.2s ease;
border: 1px solid transparent;
margin-bottom: 4px; margin-bottom: 4px;
border-radius: var(--sns-border-radius-md);
transition: transform 0.2s ease;
&:hover {
transform: translateX(2px);
}
&.is-selected .contact-main-btn {
background: rgba(var(--primary-rgb), 0.06);
border-color: color-mix(in srgb, var(--primary) 20%, var(--border-color));
}
&.is-active .contact-main-btn {
background: rgba(var(--primary-rgb), 0.12);
border-color: color-mix(in srgb, var(--primary) 48%, var(--border-color));
box-shadow: inset 0 0 0 1px rgba(var(--primary-rgb), 0.18);
}
&.is-active .contact-name {
color: var(--text-primary);
}
.contact-select-btn {
width: 32px;
height: 32px;
flex-shrink: 0;
border: none;
background: transparent;
border-radius: 8px;
color: var(--text-tertiary);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.2s ease, color 0.2s ease;
&:hover {
background: rgba(var(--primary-rgb), 0.1);
color: var(--primary);
}
&.checked {
color: var(--primary);
}
}
.contact-main-btn {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-radius: var(--sns-border-radius-md);
border: 1px solid transparent;
background: transparent;
cursor: pointer;
transition: background 0.2s ease, border-color 0.2s ease;
text-align: left;
&:hover { &:hover {
background: var(--hover-bg); background: var(--hover-bg);
transform: translateX(2px); }
z-index: 10;
} }
.contact-meta { .contact-meta {
@@ -1282,6 +1341,51 @@
} }
} }
.contact-batch-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px 14px;
border-top: 1px solid var(--border-color);
background: color-mix(in srgb, var(--bg-primary) 86%, var(--bg-secondary));
}
.contact-batch-summary {
flex: 1;
min-width: 0;
font-size: 12px;
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
}
.contact-batch-btn {
border: 1px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-secondary);
border-radius: var(--sns-border-radius-md);
height: 32px;
padding: 0 10px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
font-size: 12px;
cursor: pointer;
transition: border-color 0.2s ease, background 0.2s ease, color 0.2s ease;
&:hover {
border-color: var(--text-tertiary);
background: var(--hover-bg);
color: var(--text-primary);
}
&.primary {
border-color: color-mix(in srgb, var(--primary) 35%, var(--border-color));
background: rgba(var(--primary-rgb), 0.12);
color: var(--primary);
}
}
.empty-state { .empty-state {
text-align: center; text-align: center;
color: var(--text-tertiary); color: var(--text-tertiary);

View File

@@ -63,6 +63,7 @@ interface SnsOverviewStats {
} }
type OverviewStatsStatus = 'loading' | 'ready' | 'error' type OverviewStatsStatus = 'loading' | 'ready' | 'error'
type SnsExportScope = { kind: 'all' } | { kind: 'selected'; usernames: string[] }
const SIDEBAR_USER_PROFILE_CACHE_KEY = 'sidebar_user_profile_cache_v1' const SIDEBAR_USER_PROFILE_CACHE_KEY = 'sidebar_user_profile_cache_v1'
@@ -123,6 +124,7 @@ export default function SnsPage() {
total: 0, total: 0,
running: false running: false
}) })
const [selectedContactUsernames, setSelectedContactUsernames] = useState<string[]>([])
const [currentUserProfile, setCurrentUserProfile] = useState<SidebarUserProfile>(() => readSidebarUserProfileCache() || { const [currentUserProfile, setCurrentUserProfile] = useState<SidebarUserProfile>(() => readSidebarUserProfileCache() || {
wxid: '', wxid: '',
displayName: '' displayName: ''
@@ -140,6 +142,7 @@ export default function SnsPage() {
// 导出相关状态 // 导出相关状态
const [showExportDialog, setShowExportDialog] = useState(false) const [showExportDialog, setShowExportDialog] = useState(false)
const [exportScope, setExportScope] = useState<SnsExportScope>({ kind: 'all' })
const [exportFormat, setExportFormat] = useState<'json' | 'html' | 'arkmejson'>('html') const [exportFormat, setExportFormat] = useState<'json' | 'html' | 'arkmejson'>('html')
const [exportFolder, setExportFolder] = useState('') const [exportFolder, setExportFolder] = useState('')
const [exportImages, setExportImages] = useState(false) const [exportImages, setExportImages] = useState(false)
@@ -186,6 +189,13 @@ export default function SnsPage() {
useEffect(() => { useEffect(() => {
contactsRef.current = contacts contactsRef.current = contacts
}, [contacts]) }, [contacts])
useEffect(() => {
const contactLookup = new Set(contacts.map((contact) => contact.username))
setSelectedContactUsernames((prev) => {
const next = prev.filter((username) => contactLookup.has(username))
return next.length === prev.length ? prev : next
})
}, [contacts])
useEffect(() => { useEffect(() => {
overviewStatsRef.current = overviewStats overviewStatsRef.current = overviewStats
}, [overviewStats]) }, [overviewStats])
@@ -376,6 +386,14 @@ export default function SnsPage() {
return contacts.find((contact) => contact.username === normalizedTargetUsername) || null return contacts.find((contact) => contact.username === normalizedTargetUsername) || null
}, [authorTimelineTarget, contacts]) }, [authorTimelineTarget, contacts])
const exportSelectedContactsSummary = useMemo(() => {
if (exportScope.kind !== 'selected' || exportScope.usernames.length === 0) return ''
const contactMap = new Map(contacts.map((contact) => [contact.username, contact]))
const names = exportScope.usernames.map((username) => contactMap.get(username)?.displayName || username)
if (names.length <= 2) return names.join('、')
return `${names.slice(0, 2).join('、')}${names.length} 位联系人`
}, [contacts, exportScope])
const myTimelineCount = useMemo(() => { const myTimelineCount = useMemo(() => {
if (resolvedCurrentUserContact?.postCountStatus === 'ready' && typeof resolvedCurrentUserContact.postCount === 'number') { if (resolvedCurrentUserContact?.postCountStatus === 'ready' && typeof resolvedCurrentUserContact.postCount === 'number') {
return normalizePostCount(resolvedCurrentUserContact.postCount) return normalizePostCount(resolvedCurrentUserContact.postCount)
@@ -389,6 +407,10 @@ export default function SnsPage() {
: overviewStatsStatus === 'loading' || contactsLoading : overviewStatsStatus === 'loading' || contactsLoading
) )
const canStartExport = Boolean(exportFolder) && !isExporting && (
exportScope.kind === 'all' || exportScope.usernames.length > 0
)
const openCurrentUserTimeline = useCallback(() => { const openCurrentUserTimeline = useCallback(() => {
if (!resolvedCurrentUserContact) return if (!resolvedCurrentUserContact) return
setAuthorTimelineTarget({ setAuthorTimelineTarget({
@@ -561,6 +583,15 @@ export default function SnsPage() {
const exportDateRangeLabel = useMemo(() => getExportDateRangeLabel(exportDateRangeSelection), [exportDateRangeSelection]) const exportDateRangeLabel = useMemo(() => getExportDateRangeLabel(exportDateRangeSelection), [exportDateRangeSelection])
const openExportDialog = useCallback((scope: SnsExportScope) => {
setExportScope(scope)
setExportResult(null)
setExportProgress(null)
setExportDateRangeSelection(createExportDateRangeSelectionFromPreset('all'))
setIsExportDateRangeDialogOpen(false)
setShowExportDialog(true)
}, [])
const loadPosts = useCallback(async (options: { reset?: boolean, direction?: 'older' | 'newer' } = {}) => { const loadPosts = useCallback(async (options: { reset?: boolean, direction?: 'older' | 'newer' } = {}) => {
const { reset = false, direction = 'older' } = options const { reset = false, direction = 'older' } = options
if (loadingRef.current) return if (loadingRef.current) return
@@ -1040,6 +1071,23 @@ export default function SnsPage() {
}) })
}, []) }, [])
const toggleContactSelected = useCallback((contact: Contact) => {
setSelectedContactUsernames((prev) => (
prev.includes(contact.username)
? prev.filter((username) => username !== contact.username)
: [...prev, contact.username]
))
}, [])
const clearSelectedContacts = useCallback(() => {
setSelectedContactUsernames([])
}, [])
const openSelectedContactsExport = useCallback(() => {
if (selectedContactUsernames.length === 0) return
openExportDialog({ kind: 'selected', usernames: [...selectedContactUsernames] })
}, [openExportDialog, selectedContactUsernames])
const handlePostDelete = useCallback((postId: string, username: string) => { const handlePostDelete = useCallback((postId: string, username: string) => {
setPosts(prev => { setPosts(prev => {
const next = prev.filter(p => p.id !== postId) const next = prev.filter(p => p.id !== postId)
@@ -1264,13 +1312,7 @@ export default function SnsPage() {
<Shield size={20} /> <Shield size={20} />
</button> </button>
<button <button
onClick={() => { onClick={() => openExportDialog({ kind: 'all' })}
setExportResult(null)
setExportProgress(null)
setExportDateRangeSelection(createExportDateRangeSelectionFromPreset('all'))
setIsExportDateRangeDialogOpen(false)
setShowExportDialog(true)
}}
className="icon-btn export-btn" className="icon-btn export-btn"
title="导出朋友圈" title="导出朋友圈"
> >
@@ -1377,7 +1419,12 @@ export default function SnsPage() {
setContactSearch={setContactSearch} setContactSearch={setContactSearch}
loading={contactsLoading} loading={contactsLoading}
contactsCountProgress={contactsCountProgress} contactsCountProgress={contactsCountProgress}
selectedContactUsernames={selectedContactUsernames}
activeContactUsername={authorTimelineTarget?.username}
onOpenContactTimeline={openContactTimeline} onOpenContactTimeline={openContactTimeline}
onToggleContactSelected={toggleContactSelected}
onClearSelectedContacts={clearSelectedContacts}
onExportSelectedContacts={openSelectedContactsExport}
/> />
{/* Dialogs and Overlays */} {/* Dialogs and Overlays */}
@@ -1522,9 +1569,12 @@ export default function SnsPage() {
<div className="export-dialog-body"> <div className="export-dialog-body">
{/* 筛选条件提示 */} {/* 筛选条件提示 */}
{searchKeyword && ( {(searchKeyword || exportScope.kind === 'selected') && (
<div className="export-filter-info"> <div className="export-filter-info">
<span className="filter-badge"></span> <span className="filter-badge"></span>
{exportScope.kind === 'selected' && (
<span className="filter-tag">: {exportSelectedContactsSummary}</span>
)}
{searchKeyword && <span className="filter-tag">: "{searchKeyword}"</span>} {searchKeyword && <span className="filter-tag">: "{searchKeyword}"</span>}
</div> </div>
)} )}
@@ -1650,7 +1700,7 @@ export default function SnsPage() {
{/* 同步提示 */} {/* 同步提示 */}
<div className="export-sync-hint"> <div className="export-sync-hint">
<Info size={14} /> <Info size={14} />
<span></span> <span>{exportScope.kind === 'selected' ? '将同步主页面的关键词搜索,并仅导出所选联系人' : '将同步主页面的关键词搜索'}</span>
</div> </div>
{/* 进度条 */} {/* 进度条 */}
@@ -1677,7 +1727,7 @@ export default function SnsPage() {
</button> </button>
<button <button
className="export-start-btn" className="export-start-btn"
disabled={!exportFolder || isExporting} disabled={!canStartExport}
onClick={async () => { onClick={async () => {
setIsExporting(true) setIsExporting(true)
setExportProgress({ current: 0, total: 0, status: '准备导出...' }) setExportProgress({ current: 0, total: 0, status: '准备导出...' })
@@ -1692,6 +1742,7 @@ export default function SnsPage() {
const result = await window.electronAPI.sns.exportTimeline({ const result = await window.electronAPI.sns.exportTimeline({
outputDir: exportFolder, outputDir: exportFolder,
format: exportFormat, format: exportFormat,
usernames: exportScope.kind === 'selected' ? exportScope.usernames : undefined,
keyword: searchKeyword || undefined, keyword: searchKeyword || undefined,
exportImages, exportImages,
exportLivePhotos, exportLivePhotos,