diff --git a/src/components/Sns/SnsFilterPanel.tsx b/src/components/Sns/SnsFilterPanel.tsx index b7e6a49..654d543 100644 --- a/src/components/Sns/SnsFilterPanel.tsx +++ b/src/components/Sns/SnsFilterPanel.tsx @@ -1,5 +1,5 @@ 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' interface Contact { @@ -25,7 +25,12 @@ interface SnsFilterPanelProps { setContactSearch: (val: string) => void loading?: boolean contactsCountProgress?: ContactsCountProgress + selectedContactUsernames: string[] + activeContactUsername?: string onOpenContactTimeline: (contact: Contact) => void + onToggleContactSelected: (contact: Contact) => void + onClearSelectedContacts: () => void + onExportSelectedContacts: () => void } export const SnsFilterPanel: React.FC = ({ @@ -37,12 +42,21 @@ export const SnsFilterPanel: React.FC = ({ setContactSearch, loading, contactsCountProgress, - onOpenContactTimeline + selectedContactUsernames, + activeContactUsername, + onOpenContactTimeline, + onToggleContactSelected, + onClearSelectedContacts, + onExportSelectedContacts }) => { const filteredContacts = contacts.filter(c => (c.displayName || '').toLowerCase().includes(contactSearch.toLowerCase()) || c.username.toLowerCase().includes(contactSearch.toLowerCase()) ) + const selectedContactLookup = React.useMemo( + () => new Set(selectedContactUsernames), + [selectedContactUsernames] + ) const clearFilters = () => { setSearchKeyword('') @@ -122,35 +136,69 @@ export const SnsFilterPanel: React.FC = ({ )} +
+ 点左侧可多选下载,点右侧可查看单人详情 +
+
{filteredContacts.map(contact => { const isPostCountReady = contact.postCountStatus === 'ready' + const isSelected = selectedContactLookup.has(contact.username) + const isActive = activeContactUsername === contact.username return ( -
onOpenContactTimeline(contact)} - > - -
- {contact.displayName} +
+ +
-
- {isPostCountReady ? ( - {Math.max(0, Math.floor(Number(contact.postCount || 0)))}条 - ) : ( - - - - )} -
-
) })} {filteredContacts.length === 0 && (
{getEmptyStateText()}
)}
+ + {selectedContactUsernames.length > 0 && ( +
+ 已选 {selectedContactUsernames.length} 人 + + +
+ )}
diff --git a/src/pages/SnsPage.scss b/src/pages/SnsPage.scss index d6d24ad..486c23a 100644 --- a/src/pages/SnsPage.scss +++ b/src/pages/SnsPage.scss @@ -1211,6 +1211,13 @@ 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 { flex: 1; overflow-y: auto; @@ -1218,23 +1225,75 @@ display: flex; flex-direction: column; gap: 0; - /* Remove gap to allow borders to merge */ .contact-row { display: flex; align-items: center; - gap: 12px; - padding: 10px; - border-radius: var(--sns-border-radius-md); - cursor: pointer; - transition: background 0.2s ease, transform 0.2s ease; - border: 1px solid transparent; + gap: 8px; margin-bottom: 4px; + border-radius: var(--sns-border-radius-md); + transition: transform 0.2s ease; &:hover { - background: var(--hover-bg); transform: translateX(2px); - z-index: 10; + } + + &.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 { + background: var(--hover-bg); + } } .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 { text-align: center; color: var(--text-tertiary); diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx index 52ca0b7..23ad458 100644 --- a/src/pages/SnsPage.tsx +++ b/src/pages/SnsPage.tsx @@ -63,6 +63,7 @@ interface SnsOverviewStats { } type OverviewStatsStatus = 'loading' | 'ready' | 'error' +type SnsExportScope = { kind: 'all' } | { kind: 'selected'; usernames: string[] } const SIDEBAR_USER_PROFILE_CACHE_KEY = 'sidebar_user_profile_cache_v1' @@ -123,6 +124,7 @@ export default function SnsPage() { total: 0, running: false }) + const [selectedContactUsernames, setSelectedContactUsernames] = useState([]) const [currentUserProfile, setCurrentUserProfile] = useState(() => readSidebarUserProfileCache() || { wxid: '', displayName: '' @@ -140,6 +142,7 @@ export default function SnsPage() { // 导出相关状态 const [showExportDialog, setShowExportDialog] = useState(false) + const [exportScope, setExportScope] = useState({ kind: 'all' }) const [exportFormat, setExportFormat] = useState<'json' | 'html' | 'arkmejson'>('html') const [exportFolder, setExportFolder] = useState('') const [exportImages, setExportImages] = useState(false) @@ -186,6 +189,13 @@ export default function SnsPage() { useEffect(() => { contactsRef.current = 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(() => { overviewStatsRef.current = overviewStats }, [overviewStats]) @@ -376,6 +386,14 @@ export default function SnsPage() { return contacts.find((contact) => contact.username === normalizedTargetUsername) || null }, [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(() => { if (resolvedCurrentUserContact?.postCountStatus === 'ready' && typeof resolvedCurrentUserContact.postCount === 'number') { return normalizePostCount(resolvedCurrentUserContact.postCount) @@ -389,6 +407,10 @@ export default function SnsPage() { : overviewStatsStatus === 'loading' || contactsLoading ) + const canStartExport = Boolean(exportFolder) && !isExporting && ( + exportScope.kind === 'all' || exportScope.usernames.length > 0 + ) + const openCurrentUserTimeline = useCallback(() => { if (!resolvedCurrentUserContact) return setAuthorTimelineTarget({ @@ -561,6 +583,15 @@ export default function SnsPage() { 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 { reset = false, direction = 'older' } = options 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) => { setPosts(prev => { const next = prev.filter(p => p.id !== postId) @@ -1264,13 +1312,7 @@ export default function SnsPage() {