mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 15:25:50 +00:00
feat(sns): add my timeline shortcut
This commit is contained in:
@@ -80,6 +80,56 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.feed-my-timeline-entry {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: fit-content;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: default;
|
||||||
|
transition: color 0.2s ease, opacity 0.2s ease;
|
||||||
|
|
||||||
|
.feed-my-timeline-label {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-my-timeline-count {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.ready {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .feed-my-timeline-count {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--primary);
|
||||||
|
outline-offset: 3px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.loading {
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.68;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.feed-stats-retry {
|
.feed-stats-retry {
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useLayoutEffect, useState, useRef, useCallback } from 'react'
|
import { useEffect, useLayoutEffect, useState, useRef, useCallback, useMemo } from 'react'
|
||||||
import { RefreshCw, Search, X, Download, FolderOpen, FileJson, FileText, Image, CheckCircle, AlertCircle, Calendar, Users, Info, ChevronLeft, ChevronRight, Shield, ShieldOff } from 'lucide-react'
|
import { RefreshCw, Search, X, Download, FolderOpen, FileJson, FileText, Image, CheckCircle, AlertCircle, Calendar, Users, Info, ChevronLeft, ChevronRight, Shield, ShieldOff } from 'lucide-react'
|
||||||
import JumpToDateDialog from '../components/JumpToDateDialog'
|
import JumpToDateDialog from '../components/JumpToDateDialog'
|
||||||
import './SnsPage.scss'
|
import './SnsPage.scss'
|
||||||
@@ -21,12 +21,21 @@ interface Contact {
|
|||||||
username: string
|
username: string
|
||||||
displayName: string
|
displayName: string
|
||||||
avatarUrl?: string
|
avatarUrl?: string
|
||||||
|
remark?: string
|
||||||
|
nickname?: string
|
||||||
type?: 'friend' | 'former_friend' | 'sns_only'
|
type?: 'friend' | 'former_friend' | 'sns_only'
|
||||||
lastSessionTimestamp?: number
|
lastSessionTimestamp?: number
|
||||||
postCount?: number
|
postCount?: number
|
||||||
postCountStatus?: ContactPostCountStatus
|
postCountStatus?: ContactPostCountStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SidebarUserProfile {
|
||||||
|
wxid: string
|
||||||
|
displayName: string
|
||||||
|
alias?: string
|
||||||
|
avatarUrl?: string
|
||||||
|
}
|
||||||
|
|
||||||
interface ContactsCountProgress {
|
interface ContactsCountProgress {
|
||||||
resolved: number
|
resolved: number
|
||||||
total: number
|
total: number
|
||||||
@@ -43,6 +52,38 @@ interface SnsOverviewStats {
|
|||||||
|
|
||||||
type OverviewStatsStatus = 'loading' | 'ready' | 'error'
|
type OverviewStatsStatus = 'loading' | 'ready' | 'error'
|
||||||
|
|
||||||
|
const SIDEBAR_USER_PROFILE_CACHE_KEY = 'sidebar_user_profile_cache_v1'
|
||||||
|
|
||||||
|
const readSidebarUserProfileCache = (): SidebarUserProfile | null => {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(SIDEBAR_USER_PROFILE_CACHE_KEY)
|
||||||
|
if (!raw) return null
|
||||||
|
const parsed = JSON.parse(raw) as SidebarUserProfile
|
||||||
|
if (!parsed || typeof parsed !== 'object') return null
|
||||||
|
return {
|
||||||
|
wxid: String(parsed.wxid || '').trim(),
|
||||||
|
displayName: String(parsed.displayName || '').trim(),
|
||||||
|
alias: parsed.alias ? String(parsed.alias).trim() : undefined,
|
||||||
|
avatarUrl: parsed.avatarUrl ? String(parsed.avatarUrl).trim() : undefined
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeAccountId = (value?: string | null): string => {
|
||||||
|
const trimmed = String(value || '').trim()
|
||||||
|
if (!trimmed) return ''
|
||||||
|
if (trimmed.toLowerCase().startsWith('wxid_')) {
|
||||||
|
const match = trimmed.match(/^(wxid_[^_]+)/i)
|
||||||
|
return (match?.[1] || trimmed).toLowerCase()
|
||||||
|
}
|
||||||
|
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
|
||||||
|
return (suffixMatch ? suffixMatch[1] : trimmed).toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeNameForCompare = (value?: string | null): string => String(value || '').trim().toLowerCase()
|
||||||
|
|
||||||
export default function SnsPage() {
|
export default function SnsPage() {
|
||||||
const [posts, setPosts] = useState<SnsPost[]>([])
|
const [posts, setPosts] = useState<SnsPost[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
@@ -71,6 +112,10 @@ export default function SnsPage() {
|
|||||||
total: 0,
|
total: 0,
|
||||||
running: false
|
running: false
|
||||||
})
|
})
|
||||||
|
const [currentUserProfile, setCurrentUserProfile] = useState<SidebarUserProfile>(() => readSidebarUserProfileCache() || {
|
||||||
|
wxid: '',
|
||||||
|
displayName: ''
|
||||||
|
})
|
||||||
|
|
||||||
// UI states
|
// UI states
|
||||||
const [showJumpDialog, setShowJumpDialog] = useState(false)
|
const [showJumpDialog, setShowJumpDialog] = useState(false)
|
||||||
@@ -197,6 +242,61 @@ export default function SnsPage() {
|
|||||||
return [...input].sort(compareContactsForRanking)
|
return [...input].sort(compareContactsForRanking)
|
||||||
}, [compareContactsForRanking])
|
}, [compareContactsForRanking])
|
||||||
|
|
||||||
|
const resolvedCurrentUserContact = useMemo(() => {
|
||||||
|
const normalizedWxid = normalizeAccountId(currentUserProfile.wxid)
|
||||||
|
const normalizedAlias = normalizeAccountId(currentUserProfile.alias)
|
||||||
|
const normalizedDisplayName = normalizeNameForCompare(currentUserProfile.displayName)
|
||||||
|
|
||||||
|
if (normalizedWxid) {
|
||||||
|
const exactByUsername = contacts.find((contact) => normalizeAccountId(contact.username) === normalizedWxid)
|
||||||
|
if (exactByUsername) return exactByUsername
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedAlias) {
|
||||||
|
const exactByAliasLikeName = contacts.find((contact) => {
|
||||||
|
const candidates = [contact.displayName, contact.remark, contact.nickname].map(normalizeNameForCompare)
|
||||||
|
return candidates.includes(normalizedAlias)
|
||||||
|
})
|
||||||
|
if (exactByAliasLikeName) return exactByAliasLikeName
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!normalizedDisplayName) return null
|
||||||
|
return contacts.find((contact) => {
|
||||||
|
const candidates = [contact.displayName, contact.remark, contact.nickname].map(normalizeNameForCompare)
|
||||||
|
return candidates.includes(normalizedDisplayName)
|
||||||
|
}) || null
|
||||||
|
}, [contacts, currentUserProfile.alias, currentUserProfile.displayName, currentUserProfile.wxid])
|
||||||
|
|
||||||
|
const currentTimelineTargetContact = useMemo(() => {
|
||||||
|
const normalizedTargetUsername = String(authorTimelineTarget?.username || '').trim()
|
||||||
|
if (!normalizedTargetUsername) return null
|
||||||
|
return contacts.find((contact) => contact.username === normalizedTargetUsername) || null
|
||||||
|
}, [authorTimelineTarget, contacts])
|
||||||
|
|
||||||
|
const myTimelineCount = useMemo(() => {
|
||||||
|
if (typeof overviewStats.myPosts === 'number' && Number.isFinite(overviewStats.myPosts) && overviewStats.myPosts >= 0) {
|
||||||
|
return Math.floor(overviewStats.myPosts)
|
||||||
|
}
|
||||||
|
if (resolvedCurrentUserContact?.postCountStatus === 'ready' && typeof resolvedCurrentUserContact.postCount === 'number') {
|
||||||
|
return normalizePostCount(resolvedCurrentUserContact.postCount)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}, [normalizePostCount, overviewStats.myPosts, resolvedCurrentUserContact])
|
||||||
|
|
||||||
|
const myTimelineCountLoading = Boolean(
|
||||||
|
overviewStatsStatus === 'loading'
|
||||||
|
|| resolvedCurrentUserContact?.postCountStatus === 'loading'
|
||||||
|
)
|
||||||
|
|
||||||
|
const openCurrentUserTimeline = useCallback(() => {
|
||||||
|
if (!resolvedCurrentUserContact) return
|
||||||
|
setAuthorTimelineTarget({
|
||||||
|
username: resolvedCurrentUserContact.username,
|
||||||
|
displayName: resolvedCurrentUserContact.displayName || currentUserProfile.displayName || resolvedCurrentUserContact.username,
|
||||||
|
avatarUrl: resolvedCurrentUserContact.avatarUrl || currentUserProfile.avatarUrl
|
||||||
|
})
|
||||||
|
}, [currentUserProfile.avatarUrl, currentUserProfile.displayName, resolvedCurrentUserContact])
|
||||||
|
|
||||||
const isDefaultViewNow = useCallback(() => {
|
const isDefaultViewNow = useCallback(() => {
|
||||||
return selectedUsernamesRef.current.length === 0 && !searchKeywordRef.current.trim() && !jumpTargetDateRef.current
|
return selectedUsernamesRef.current.length === 0 && !searchKeywordRef.current.trim() && !jumpTargetDateRef.current
|
||||||
}, [])
|
}, [])
|
||||||
@@ -626,6 +726,8 @@ export default function SnsPage() {
|
|||||||
username: contact.username,
|
username: contact.username,
|
||||||
displayName: contact.displayName || contact.username,
|
displayName: contact.displayName || contact.username,
|
||||||
avatarUrl: cachedAvatarMap[contact.username]?.avatarUrl,
|
avatarUrl: cachedAvatarMap[contact.username]?.avatarUrl,
|
||||||
|
remark: contact.remark,
|
||||||
|
nickname: contact.nickname,
|
||||||
type: (contact.type === 'former_friend' ? 'former_friend' : 'friend') as 'friend' | 'former_friend',
|
type: (contact.type === 'former_friend' ? 'former_friend' : 'friend') as 'friend' | 'former_friend',
|
||||||
lastSessionTimestamp: 0,
|
lastSessionTimestamp: 0,
|
||||||
postCount: hasCachedCount ? Math.max(0, Math.floor(cachedCount)) : undefined,
|
postCount: hasCachedCount ? Math.max(0, Math.floor(cachedCount)) : undefined,
|
||||||
@@ -677,6 +779,8 @@ export default function SnsPage() {
|
|||||||
username: c.username,
|
username: c.username,
|
||||||
displayName: c.displayName,
|
displayName: c.displayName,
|
||||||
avatarUrl: c.avatarUrl,
|
avatarUrl: c.avatarUrl,
|
||||||
|
remark: c.remark,
|
||||||
|
nickname: c.nickname,
|
||||||
type: c.type === 'former_friend' ? 'former_friend' : 'friend',
|
type: c.type === 'former_friend' ? 'former_friend' : 'friend',
|
||||||
lastSessionTimestamp: Number(sessionTimestampMap.get(c.username) || 0),
|
lastSessionTimestamp: Number(sessionTimestampMap.get(c.username) || 0),
|
||||||
postCount: hasCachedCount ? Math.max(0, Math.floor(cachedCount)) : undefined,
|
postCount: hasCachedCount ? Math.max(0, Math.floor(cachedCount)) : undefined,
|
||||||
@@ -769,6 +873,39 @@ export default function SnsPage() {
|
|||||||
loadOverviewStats()
|
loadOverviewStats()
|
||||||
}, [hydrateSnsPageCache, loadContacts, loadOverviewStats])
|
}, [hydrateSnsPageCache, loadContacts, loadOverviewStats])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const syncCurrentUserProfile = async () => {
|
||||||
|
const cachedProfile = readSidebarUserProfileCache()
|
||||||
|
if (cachedProfile) {
|
||||||
|
setCurrentUserProfile((prev) => ({
|
||||||
|
wxid: cachedProfile.wxid || prev.wxid,
|
||||||
|
displayName: cachedProfile.displayName || prev.displayName,
|
||||||
|
alias: cachedProfile.alias || prev.alias,
|
||||||
|
avatarUrl: cachedProfile.avatarUrl || prev.avatarUrl
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const wxidRaw = await configService.getMyWxid()
|
||||||
|
const resolvedWxid = normalizeAccountId(wxidRaw) || String(wxidRaw || '').trim()
|
||||||
|
if (!resolvedWxid && !cachedProfile) return
|
||||||
|
setCurrentUserProfile((prev) => ({
|
||||||
|
wxid: resolvedWxid || prev.wxid,
|
||||||
|
displayName: prev.displayName || cachedProfile?.displayName || resolvedWxid || '未识别用户',
|
||||||
|
alias: prev.alias || cachedProfile?.alias,
|
||||||
|
avatarUrl: prev.avatarUrl || cachedProfile?.avatarUrl
|
||||||
|
}))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to sync current sidebar user profile:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void syncCurrentUserProfile()
|
||||||
|
const handleChange = () => { void syncCurrentUserProfile() }
|
||||||
|
window.addEventListener('wxid-changed', handleChange as EventListener)
|
||||||
|
return () => window.removeEventListener('wxid-changed', handleChange as EventListener)
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
contactsCountHydrationTokenRef.current += 1
|
contactsCountHydrationTokenRef.current += 1
|
||||||
@@ -829,6 +966,24 @@ export default function SnsPage() {
|
|||||||
<div className="feed-header">
|
<div className="feed-header">
|
||||||
<div className="feed-header-main">
|
<div className="feed-header-main">
|
||||||
<h2>朋友圈</h2>
|
<h2>朋友圈</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`feed-my-timeline-entry ${resolvedCurrentUserContact ? 'ready' : ''} ${myTimelineCountLoading ? 'loading' : ''}`}
|
||||||
|
onClick={openCurrentUserTimeline}
|
||||||
|
disabled={!resolvedCurrentUserContact}
|
||||||
|
title={resolvedCurrentUserContact
|
||||||
|
? `打开${resolvedCurrentUserContact.displayName || '我'}的朋友圈详情`
|
||||||
|
: '未在右侧联系人列表中匹配到当前账号'}
|
||||||
|
>
|
||||||
|
<span className="feed-my-timeline-label">我的朋友圈</span>
|
||||||
|
<span className="feed-my-timeline-count">
|
||||||
|
{myTimelineCount !== null
|
||||||
|
? `${myTimelineCount.toLocaleString('zh-CN')} 条`
|
||||||
|
: myTimelineCountLoading
|
||||||
|
? '...'
|
||||||
|
: '--'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
<div className={`feed-stats-line ${overviewStatsStatus}`}>
|
<div className={`feed-stats-line ${overviewStatsStatus}`}>
|
||||||
{renderOverviewStats()}
|
{renderOverviewStats()}
|
||||||
</div>
|
</div>
|
||||||
@@ -985,6 +1140,14 @@ export default function SnsPage() {
|
|||||||
<ContactSnsTimelineDialog
|
<ContactSnsTimelineDialog
|
||||||
target={authorTimelineTarget}
|
target={authorTimelineTarget}
|
||||||
onClose={closeAuthorTimeline}
|
onClose={closeAuthorTimeline}
|
||||||
|
initialTotalPosts={authorTimelineTarget?.username === resolvedCurrentUserContact?.username
|
||||||
|
? myTimelineCount
|
||||||
|
: currentTimelineTargetContact?.postCountStatus === 'ready'
|
||||||
|
? normalizePostCount(currentTimelineTargetContact.postCount)
|
||||||
|
: null}
|
||||||
|
initialTotalPostsLoading={Boolean(authorTimelineTarget?.username === resolvedCurrentUserContact?.username
|
||||||
|
? myTimelineCount === null && myTimelineCountLoading
|
||||||
|
: currentTimelineTargetContact?.postCountStatus === 'loading')}
|
||||||
isProtected={triggerInstalled === true}
|
isProtected={triggerInstalled === true}
|
||||||
onDeletePost={handlePostDelete}
|
onDeletePost={handlePostDelete}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user