修复开启应用锁时更新公告弹窗无法关闭的bug #291;修复朋友圈时间排序错乱 #290;支持日期选择器快速跳转年月;朋友圈页面性能优化

This commit is contained in:
cc
2026-02-22 14:26:41 +08:00
parent acaac507b1
commit 52c67f4d23
16 changed files with 543 additions and 64 deletions

View File

@@ -951,6 +951,10 @@ function registerIpcHandlers() {
return snsService.getTimeline(limit, offset, usernames, keyword, startTime, endTime) return snsService.getTimeline(limit, offset, usernames, keyword, startTime, endTime)
}) })
ipcMain.handle('sns:getSnsUsernames', async () => {
return snsService.getSnsUsernames()
})
ipcMain.handle('sns:debugResource', async (_, url: string) => { ipcMain.handle('sns:debugResource', async (_, url: string) => {
return snsService.debugResource(url) return snsService.debugResource(url)
}) })

View File

@@ -276,6 +276,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
sns: { sns: {
getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) => getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) =>
ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime), ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime),
getSnsUsernames: () => ipcRenderer.invoke('sns:getSnsUsernames'),
debugResource: (url: string) => ipcRenderer.invoke('sns:debugResource', url), debugResource: (url: string) => ipcRenderer.invoke('sns:debugResource', url),
proxyImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:proxyImage', payload), proxyImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:proxyImage', payload),
downloadImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:downloadImage', payload), downloadImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:downloadImage', payload),

View File

@@ -147,6 +147,18 @@ class SnsService {
return join(this.getSnsCacheDir(), `${hash}${ext}`) return join(this.getSnsCacheDir(), `${hash}${ext}`)
} }
// 获取所有发过朋友圈的用户名列表
async getSnsUsernames(): Promise<{ success: boolean; usernames?: string[]; error?: string }> {
const result = await wcdbService.execQuery('sns', null, 'SELECT DISTINCT user_name FROM SnsTimeLine')
if (!result.success || !result.rows) {
// 尝试 userName 列名
const result2 = await wcdbService.execQuery('sns', null, 'SELECT DISTINCT userName FROM SnsTimeLine')
if (!result2.success || !result2.rows) return { success: false, error: result.error || result2.error }
return { success: true, usernames: result2.rows.map((r: any) => r.userName).filter(Boolean) }
}
return { success: true, usernames: result.rows.map((r: any) => r.user_name).filter(Boolean) }
}
async getTimeline(limit: number = 20, offset: number = 0, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: SnsPost[]; error?: string }> { async getTimeline(limit: number = 20, offset: number = 0, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: SnsPost[]; error?: string }> {
const result = await wcdbService.getSnsTimeline(limit, offset, usernames, keyword, startTime, endTime) const result = await wcdbService.getSnsTimeline(limit, offset, usernames, keyword, startTime, endTime)

Binary file not shown.

View File

@@ -195,10 +195,12 @@ function App() {
if (isNotificationWindow) return // Skip updates in notification window if (isNotificationWindow) return // Skip updates in notification window
const removeUpdateListener = window.electronAPI?.app?.onUpdateAvailable?.((info: any) => { const removeUpdateListener = window.electronAPI?.app?.onUpdateAvailable?.((info: any) => {
// 发现新版本时自动打开更新弹窗 // 发现新版本时保存更新信息,锁定状态下不弹窗,解锁后再显示
if (info) { if (info) {
setUpdateInfo({ ...info, hasUpdate: true }) setUpdateInfo({ ...info, hasUpdate: true })
setShowUpdateDialog(true) if (!useAppStore.getState().isLocked) {
setShowUpdateDialog(true)
}
} }
}) })
const removeProgressListener = window.electronAPI?.app?.onDownloadProgress?.((progress: any) => { const removeProgressListener = window.electronAPI?.app?.onDownloadProgress?.((progress: any) => {
@@ -210,6 +212,13 @@ function App() {
} }
}, [setUpdateInfo, setDownloadProgress, setShowUpdateDialog, isNotificationWindow]) }, [setUpdateInfo, setDownloadProgress, setShowUpdateDialog, isNotificationWindow])
// 解锁后显示暂存的更新弹窗
useEffect(() => {
if (!isLocked && updateInfo?.hasUpdate && !showUpdateDialog && !isDownloading) {
setShowUpdateDialog(true)
}
}, [isLocked])
const handleUpdateNow = async () => { const handleUpdateNow = async () => {
setShowUpdateDialog(false) setShowUpdateDialog(false)
setIsDownloading(true) setIsDownloading(true)

View File

@@ -139,6 +139,18 @@
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
color: var(--text-primary); color: var(--text-primary);
&.clickable {
cursor: pointer;
border-radius: 6px;
padding: 2px 8px;
transition: all 0.15s;
&:hover {
background: var(--bg-hover);
color: var(--primary);
}
}
} }
} }
@@ -212,4 +224,68 @@
padding-top: 12px; padding-top: 12px;
border-top: 1px solid var(--border-color); border-top: 1px solid var(--border-color);
} }
.year-month-picker {
padding: 4px 0;
.year-selector {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
.year-label {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
.nav-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: none;
background: var(--bg-tertiary);
border-radius: 6px;
cursor: pointer;
color: var(--text-secondary);
transition: all 0.15s;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
}
}
.month-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
.month-btn {
padding: 8px 0;
border: none;
background: transparent;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
color: var(--text-secondary);
transition: all 0.15s;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
&.active {
background: var(--primary);
color: #fff;
}
}
}
}
} }

View File

@@ -26,6 +26,7 @@ function DateRangePicker({ startDate, endDate, onStartDateChange, onEndDateChang
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const [currentMonth, setCurrentMonth] = useState(new Date()) const [currentMonth, setCurrentMonth] = useState(new Date())
const [selectingStart, setSelectingStart] = useState(true) const [selectingStart, setSelectingStart] = useState(true)
const [showYearMonthPicker, setShowYearMonthPicker] = useState(false)
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
// 点击外部关闭 // 点击外部关闭
@@ -185,12 +186,38 @@ function DateRangePicker({ startDate, endDate, onStartDateChange, onEndDateChang
<button className="nav-btn" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1))}> <button className="nav-btn" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1))}>
<ChevronLeft size={16} /> <ChevronLeft size={16} />
</button> </button>
<span className="month-year">{currentMonth.getFullYear()} {MONTH_NAMES[currentMonth.getMonth()]}</span> <span className="month-year clickable" onClick={() => setShowYearMonthPicker(!showYearMonthPicker)}>
{currentMonth.getFullYear()} {MONTH_NAMES[currentMonth.getMonth()]}
</span>
<button className="nav-btn" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1))}> <button className="nav-btn" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1))}>
<ChevronRight size={16} /> <ChevronRight size={16} />
</button> </button>
</div> </div>
{renderCalendar()} {showYearMonthPicker ? (
<div className="year-month-picker">
<div className="year-selector">
<button className="nav-btn" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() - 1, currentMonth.getMonth()))}>
<ChevronLeft size={14} />
</button>
<span className="year-label">{currentMonth.getFullYear()}</span>
<button className="nav-btn" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() + 1, currentMonth.getMonth()))}>
<ChevronRight size={14} />
</button>
</div>
<div className="month-grid">
{MONTH_NAMES.map((name, i) => (
<button
key={i}
className={`month-btn ${i === currentMonth.getMonth() ? 'active' : ''}`}
onClick={() => {
setCurrentMonth(new Date(currentMonth.getFullYear(), i))
setShowYearMonthPicker(false)
}}
>{name}</button>
))}
</div>
</div>
) : renderCalendar()}
<div className="selection-hint"> <div className="selection-hint">
{selectingStart ? '请选择开始日期' : '请选择结束日期'} {selectingStart ? '请选择开始日期' : '请选择结束日期'}
</div> </div>

View File

@@ -75,6 +75,18 @@
font-size: 15px; font-size: 15px;
font-weight: 600; font-weight: 600;
color: var(--text-primary); color: var(--text-primary);
&.clickable {
cursor: pointer;
border-radius: 6px;
padding: 2px 8px;
transition: all 0.15s;
&:hover {
background: var(--bg-hover);
color: var(--primary);
}
}
} }
.nav-btn { .nav-btn {
@@ -97,6 +109,70 @@
} }
} }
} }
.year-month-picker {
padding: 4px 0;
.year-selector {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
.year-label {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
.nav-btn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s;
&:hover {
background: var(--bg-hover);
border-color: var(--primary);
color: var(--primary);
}
}
}
.month-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
.month-btn {
padding: 10px 0;
border: none;
background: transparent;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
color: var(--text-secondary);
transition: all 0.15s;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
&.active {
background: var(--primary);
color: #fff;
}
}
}
}
} }
.calendar-grid { .calendar-grid {

View File

@@ -24,6 +24,7 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
const isValidDate = (d: any) => d instanceof Date && !isNaN(d.getTime()) const isValidDate = (d: any) => d instanceof Date && !isNaN(d.getTime())
const [calendarDate, setCalendarDate] = useState(isValidDate(currentDate) ? new Date(currentDate) : new Date()) const [calendarDate, setCalendarDate] = useState(isValidDate(currentDate) ? new Date(currentDate) : new Date())
const [selectedDate, setSelectedDate] = useState(new Date(currentDate)) const [selectedDate, setSelectedDate] = useState(new Date(currentDate))
const [showYearMonthPicker, setShowYearMonthPicker] = useState(false)
if (!isOpen) return null if (!isOpen) return null
@@ -137,7 +138,7 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
> >
<ChevronLeft size={18} /> <ChevronLeft size={18} />
</button> </button>
<span className="current-month"> <span className="current-month clickable" onClick={() => setShowYearMonthPicker(!showYearMonthPicker)}>
{calendarDate.getFullYear()}{calendarDate.getMonth() + 1} {calendarDate.getFullYear()}{calendarDate.getMonth() + 1}
</span> </span>
<button <button
@@ -148,6 +149,31 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
</button> </button>
</div> </div>
{showYearMonthPicker ? (
<div className="year-month-picker">
<div className="year-selector">
<button className="nav-btn" onClick={() => setCalendarDate(new Date(calendarDate.getFullYear() - 1, calendarDate.getMonth(), 1))}>
<ChevronLeft size={16} />
</button>
<span className="year-label">{calendarDate.getFullYear()}</span>
<button className="nav-btn" onClick={() => setCalendarDate(new Date(calendarDate.getFullYear() + 1, calendarDate.getMonth(), 1))}>
<ChevronRight size={16} />
</button>
</div>
<div className="month-grid">
{['一月','二月','三月','四月','五月','六月','七月','八月','九月','十月','十一月','十二月'].map((name, i) => (
<button
key={i}
className={`month-btn ${i === calendarDate.getMonth() ? 'active' : ''}`}
onClick={() => {
setCalendarDate(new Date(calendarDate.getFullYear(), i, 1))
setShowYearMonthPicker(false)
}}
>{name}</button>
))}
</div>
</div>
) : (
<div className={`calendar-grid ${loadingDates ? 'loading' : ''}`}> <div className={`calendar-grid ${loadingDates ? 'loading' : ''}`}>
{loadingDates && ( {loadingDates && (
<div className="calendar-loading"> <div className="calendar-loading">
@@ -174,6 +200,7 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
))} ))}
</div> </div>
</div> </div>
)}
</div> </div>
<div className="quick-options"> <div className="quick-options">

View File

@@ -138,7 +138,7 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
/> />
<Search size={14} className="search-icon" /> <Search size={14} className="search-icon" />
{contactSearch && ( {contactSearch && (
<X size={14} className="clear-icon" onClick={() => setContactSearch('')} style={{ right: 8, top: 8, position: 'absolute', cursor: 'pointer', color: 'var(--text-tertiary)' }} /> <X size={14} className="clear-icon" onClick={() => setContactSearch('')} />
)} )}
</div> </div>

View File

@@ -955,6 +955,18 @@
font-size: 15px; font-size: 15px;
font-weight: 600; font-weight: 600;
color: var(--text-primary); color: var(--text-primary);
&.clickable {
cursor: pointer;
border-radius: 6px;
padding: 2px 8px;
transition: all 0.15s;
&:hover {
background: var(--bg-hover);
color: var(--primary);
}
}
} }
} }
@@ -1015,6 +1027,70 @@
} }
} }
.year-month-picker {
padding: 4px 0;
.year-selector {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
.year-label {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
.calendar-nav-btn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
cursor: pointer;
color: var(--text-secondary);
transition: all 0.2s;
&:hover {
background: var(--bg-hover);
border-color: var(--primary);
color: var(--primary);
}
}
}
.month-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
.month-btn {
padding: 10px 0;
border: none;
background: transparent;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
color: var(--text-secondary);
transition: all 0.15s;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
&.active {
background: var(--primary);
color: #fff;
}
}
}
}
.date-picker-actions { .date-picker-actions {
display: flex; display: flex;
gap: 12px; gap: 12px;

View File

@@ -53,6 +53,7 @@ function ExportPage() {
const [showDatePicker, setShowDatePicker] = useState(false) const [showDatePicker, setShowDatePicker] = useState(false)
const [calendarDate, setCalendarDate] = useState(new Date()) const [calendarDate, setCalendarDate] = useState(new Date())
const [selectingStart, setSelectingStart] = useState(true) const [selectingStart, setSelectingStart] = useState(true)
const [showYearMonthPicker, setShowYearMonthPicker] = useState(false)
const [showMediaLayoutPrompt, setShowMediaLayoutPrompt] = useState(false) const [showMediaLayoutPrompt, setShowMediaLayoutPrompt] = useState(false)
const [showDisplayNameSelect, setShowDisplayNameSelect] = useState(false) const [showDisplayNameSelect, setShowDisplayNameSelect] = useState(false)
const [showPreExportDialog, setShowPreExportDialog] = useState(false) const [showPreExportDialog, setShowPreExportDialog] = useState(false)
@@ -1047,7 +1048,7 @@ function ExportPage() {
{/* 日期选择弹窗 */} {/* 日期选择弹窗 */}
{showDatePicker && ( {showDatePicker && (
<div className="export-overlay" onClick={() => setShowDatePicker(false)}> <div className="export-overlay" onClick={() => { setShowDatePicker(false); setShowYearMonthPicker(false) }}>
<div className="date-picker-modal" onClick={e => e.stopPropagation()}> <div className="date-picker-modal" onClick={e => e.stopPropagation()}>
<h3></h3> <h3></h3>
<p style={{ fontSize: '13px', color: 'var(--text-secondary)', margin: '8px 0 16px 0' }}> <p style={{ fontSize: '13px', color: 'var(--text-secondary)', margin: '8px 0 16px 0' }}>
@@ -1122,7 +1123,7 @@ function ExportPage() {
> >
<ChevronLeft size={18} /> <ChevronLeft size={18} />
</button> </button>
<span className="calendar-month"> <span className="calendar-month clickable" onClick={() => setShowYearMonthPicker(!showYearMonthPicker)}>
{calendarDate.getFullYear()}{calendarDate.getMonth() + 1} {calendarDate.getFullYear()}{calendarDate.getMonth() + 1}
</span> </span>
<button <button
@@ -1132,6 +1133,32 @@ function ExportPage() {
<ChevronRight size={18} /> <ChevronRight size={18} />
</button> </button>
</div> </div>
{showYearMonthPicker ? (
<div className="year-month-picker">
<div className="year-selector">
<button className="calendar-nav-btn" onClick={() => setCalendarDate(new Date(calendarDate.getFullYear() - 1, calendarDate.getMonth(), 1))}>
<ChevronLeft size={16} />
</button>
<span className="year-label">{calendarDate.getFullYear()}</span>
<button className="calendar-nav-btn" onClick={() => setCalendarDate(new Date(calendarDate.getFullYear() + 1, calendarDate.getMonth(), 1))}>
<ChevronRight size={16} />
</button>
</div>
<div className="month-grid">
{['一月','二月','三月','四月','五月','六月','七月','八月','九月','十月','十一月','十二月'].map((name, i) => (
<button
key={i}
className={`month-btn ${i === calendarDate.getMonth() ? 'active' : ''}`}
onClick={() => {
setCalendarDate(new Date(calendarDate.getFullYear(), i, 1))
setShowYearMonthPicker(false)
}}
>{name}</button>
))}
</div>
</div>
) : (
<>
<div className="calendar-weekdays"> <div className="calendar-weekdays">
{['日', '一', '二', '三', '四', '五', '六'].map(day => ( {['日', '一', '二', '三', '四', '五', '六'].map(day => (
<div key={day} className="calendar-weekday">{day}</div> <div key={day} className="calendar-weekday">{day}</div>
@@ -1163,12 +1190,14 @@ function ExportPage() {
) )
})} })}
</div> </div>
</>
)}
</div> </div>
<div className="date-picker-actions"> <div className="date-picker-actions">
<button className="cancel-btn" onClick={() => setShowDatePicker(false)}> <button className="cancel-btn" onClick={() => { setShowDatePicker(false); setShowYearMonthPicker(false) }}>
</button> </button>
<button className="confirm-btn" onClick={() => setShowDatePicker(false)}> <button className="confirm-btn" onClick={() => { setShowDatePicker(false); setShowYearMonthPicker(false) }}>
</button> </button>
</div> </div>

View File

@@ -24,8 +24,6 @@
.sns-main-viewport { .sns-main-viewport {
flex: 1; flex: 1;
overflow-y: scroll; overflow-y: scroll;
/* Always show scrollbar track for stability */
scroll-behavior: smooth;
position: relative; position: relative;
display: flex; display: flex;
justify-content: center; justify-content: center;
@@ -106,6 +104,7 @@
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.2s; transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.2s;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.02); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.02);
position: relative; position: relative;
overflow: hidden;
&:hover { &:hover {
transform: translateY(-2px); transform: translateY(-2px);
@@ -148,6 +147,8 @@
.post-author-info { .post-author-info {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-width: 0;
overflow: hidden;
.author-name { .author-name {
font-size: 15px; font-size: 15px;
@@ -168,6 +169,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
flex-shrink: 0;
} }
.debug-btn { .debug-btn {
@@ -694,6 +696,17 @@
top: 8px; top: 8px;
color: var(--text-tertiary); color: var(--text-tertiary);
pointer-events: none; pointer-events: none;
display: none;
}
.clear-icon {
position: absolute;
right: 28px;
top: 8px;
color: var(--text-tertiary);
cursor: pointer;
display: flex;
align-items: center;
} }
} }
@@ -1309,6 +1322,18 @@
font-size: 15px; font-size: 15px;
font-weight: 600; font-weight: 600;
color: var(--text-primary); color: var(--text-primary);
&.clickable {
cursor: pointer;
border-radius: 6px;
padding: 2px 8px;
transition: all 0.15s;
&:hover {
background: var(--bg-hover);
color: var(--primary);
}
}
} }
.nav-btn { .nav-btn {
@@ -1384,6 +1409,70 @@
} }
} }
} }
.year-month-picker {
padding: 4px 0;
.year-selector {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
.year-label {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
.nav-btn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s;
&:hover {
background: var(--bg-hover);
border-color: var(--primary);
color: var(--primary);
}
}
}
.month-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
.month-btn {
padding: 10px 0;
border: none;
background: transparent;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
color: var(--text-secondary);
transition: all 0.15s;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
&.active {
background: var(--primary);
color: #fff;
}
}
}
}
} }
.quick-options { .quick-options {

View File

@@ -1,4 +1,4 @@
import { useEffect, useState, useRef, useCallback } from 'react' import { useEffect, useLayoutEffect, useState, useRef, useCallback } from 'react'
import { RefreshCw, Search, X, Download, FolderOpen, FileJson, FileText, Image, CheckCircle, AlertCircle, Calendar, Users, Info, ChevronLeft, ChevronRight } from 'lucide-react' import { RefreshCw, Search, X, Download, FolderOpen, FileJson, FileText, Image, CheckCircle, AlertCircle, Calendar, Users, Info, ChevronLeft, ChevronRight } from 'lucide-react'
import { ImagePreview } from '../components/ImagePreview' import { ImagePreview } from '../components/ImagePreview'
import JumpToDateDialog from '../components/JumpToDateDialog' import JumpToDateDialog from '../components/JumpToDateDialog'
@@ -11,6 +11,7 @@ interface Contact {
username: string username: string
displayName: string displayName: string
avatarUrl?: string avatarUrl?: string
type?: 'friend' | 'former_friend' | 'sns_only'
} }
export default function SnsPage() { export default function SnsPage() {
@@ -45,28 +46,29 @@ export default function SnsPage() {
const [exportResult, setExportResult] = useState<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; error?: string } | null>(null) const [exportResult, setExportResult] = useState<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; error?: string } | null>(null)
const [refreshSpin, setRefreshSpin] = useState(false) const [refreshSpin, setRefreshSpin] = useState(false)
const [calendarPicker, setCalendarPicker] = useState<{ field: 'start' | 'end'; month: Date } | null>(null) const [calendarPicker, setCalendarPicker] = useState<{ field: 'start' | 'end'; month: Date } | null>(null)
const [showYearMonthPicker, setShowYearMonthPicker] = useState(false)
const postsContainerRef = useRef<HTMLDivElement>(null) const postsContainerRef = useRef<HTMLDivElement>(null)
const [hasNewer, setHasNewer] = useState(false) const [hasNewer, setHasNewer] = useState(false)
const [loadingNewer, setLoadingNewer] = useState(false) const [loadingNewer, setLoadingNewer] = useState(false)
const postsRef = useRef<SnsPost[]>([]) const postsRef = useRef<SnsPost[]>([])
const scrollAdjustmentRef = useRef<number>(0) const scrollAdjustmentRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null)
// Sync posts ref // Sync posts ref
useEffect(() => { useEffect(() => {
postsRef.current = posts postsRef.current = posts
}, [posts]) }, [posts])
// Maintain scroll position when loading newer posts // 在 DOM 更新后、浏览器绘制前同步调整滚动位置,防止向上加载时页面跳动
useEffect(() => { useLayoutEffect(() => {
if (scrollAdjustmentRef.current !== 0 && postsContainerRef.current) { const snapshot = scrollAdjustmentRef.current;
if (snapshot && postsContainerRef.current) {
const container = postsContainerRef.current; const container = postsContainerRef.current;
const newHeight = container.scrollHeight; const addedHeight = container.scrollHeight - snapshot.scrollHeight;
const diff = newHeight - scrollAdjustmentRef.current; if (addedHeight > 0) {
if (diff > 0) { container.scrollTop = snapshot.scrollTop + addedHeight;
container.scrollTop += diff;
} }
scrollAdjustmentRef.current = 0; scrollAdjustmentRef.current = null;
} }
}, [posts]) }, [posts])
@@ -104,14 +106,17 @@ export default function SnsPage() {
if (result.success && result.timeline && result.timeline.length > 0) { if (result.success && result.timeline && result.timeline.length > 0) {
if (postsContainerRef.current) { if (postsContainerRef.current) {
scrollAdjustmentRef.current = postsContainerRef.current.scrollHeight; scrollAdjustmentRef.current = {
scrollHeight: postsContainerRef.current.scrollHeight,
scrollTop: postsContainerRef.current.scrollTop
};
} }
const existingIds = new Set(currentPosts.map((p: SnsPost) => p.id)); const existingIds = new Set(currentPosts.map((p: SnsPost) => p.id));
const uniqueNewer = result.timeline.filter((p: SnsPost) => !existingIds.has(p.id)); const uniqueNewer = result.timeline.filter((p: SnsPost) => !existingIds.has(p.id));
if (uniqueNewer.length > 0) { if (uniqueNewer.length > 0) {
setPosts(prev => [...uniqueNewer, ...prev]); setPosts(prev => [...uniqueNewer, ...prev].sort((a, b) => b.createTime - a.createTime));
} }
setHasNewer(result.timeline.length >= limit); setHasNewer(result.timeline.length >= limit);
} else { } else {
@@ -157,7 +162,7 @@ export default function SnsPage() {
} }
} else { } else {
if (result.timeline.length > 0) { if (result.timeline.length > 0) {
setPosts(prev => [...prev, ...result.timeline!]) setPosts(prev => [...prev, ...result.timeline!].sort((a, b) => b.createTime - a.createTime))
} }
if (result.timeline.length < limit) { if (result.timeline.length < limit) {
setHasMore(false) setHasMore(false)
@@ -173,45 +178,59 @@ export default function SnsPage() {
} }
}, [selectedUsernames, searchKeyword, jumpTargetDate]) }, [selectedUsernames, searchKeyword, jumpTargetDate])
// Load Contacts // Load Contacts(合并好友+曾经好友+朋友圈发布者enrichSessionsContactInfo 补充头像)
const loadContacts = useCallback(async () => { const loadContacts = useCallback(async () => {
setContactsLoading(true) setContactsLoading(true)
try { try {
const result = await window.electronAPI.chat.getSessions() // 并行获取联系人列表和朋友圈发布者列表
if (result.success && result.sessions) { const [contactsResult, snsResult] = await Promise.all([
const systemAccounts = ['filehelper', 'fmessage', 'newsapp', 'weixin', 'qqmail', 'tmessage', 'floatbottle', 'medianote', 'brandsessionholder']; window.electronAPI.chat.getContacts(),
const initialContacts = result.sessions window.electronAPI.sns.getSnsUsernames()
.filter((s: any) => { ])
if (!s.username) return false;
const u = s.username.toLowerCase();
if (u.includes('@chatroom') || u.endsWith('@chatroom') || u.endsWith('@openim')) return false;
if (u.startsWith('gh_')) return false;
if (systemAccounts.includes(u) || u.includes('helper') || u.includes('sessionholder')) return false;
return true;
})
.map((s: any) => ({
username: s.username,
displayName: s.displayName || s.username,
avatarUrl: s.avatarUrl
}))
setContacts(initialContacts)
const usernames = initialContacts.map((c: { username: string }) => c.username) // 以联系人为基础,按 username 去重
const enriched = await window.electronAPI.chat.enrichSessionsContactInfo(usernames) const contactMap = new Map<string, Contact>()
if (enriched.success && enriched.contacts) {
setContacts(prev => prev.map(c => { // 好友和曾经的好友
const extra = enriched.contacts![c.username] if (contactsResult.success && contactsResult.contacts) {
if (extra) { for (const c of contactsResult.contacts) {
return { if (c.type === 'friend' || c.type === 'former_friend') {
...c, contactMap.set(c.username, {
displayName: extra.displayName || c.displayName, username: c.username,
avatarUrl: extra.avatarUrl || c.avatarUrl displayName: c.displayName,
} avatarUrl: c.avatarUrl,
} type: c.type === 'former_friend' ? 'former_friend' : 'friend'
return c })
})) }
} }
} }
// 朋友圈发布者(补充不在联系人列表中的用户)
if (snsResult.success && snsResult.usernames) {
for (const u of snsResult.usernames) {
if (!contactMap.has(u)) {
contactMap.set(u, { username: u, displayName: u, type: 'sns_only' })
}
}
}
const allUsernames = Array.from(contactMap.keys())
// 用 enrichSessionsContactInfo 统一补充头像和显示名
if (allUsernames.length > 0) {
const enriched = await window.electronAPI.chat.enrichSessionsContactInfo(allUsernames)
if (enriched.success && enriched.contacts) {
for (const [username, extra] of Object.entries(enriched.contacts) as [string, { displayName?: string; avatarUrl?: string }][]) {
const c = contactMap.get(username)
if (c) {
c.displayName = extra.displayName || c.displayName
c.avatarUrl = extra.avatarUrl || c.avatarUrl
}
}
}
}
setContacts(Array.from(contactMap.values()))
} catch (error) { } catch (error) {
console.error('Failed to load contacts:', error) console.error('Failed to load contacts:', error)
} finally { } finally {
@@ -336,7 +355,12 @@ export default function SnsPage() {
)} )}
{!hasMore && posts.length > 0 && ( {!hasMore && posts.length > 0 && (
<div className="status-indicator no-more"></div> <div className="status-indicator no-more">{
selectedUsernames.length === 1 &&
contacts.find(c => c.username === selectedUsernames[0])?.type === 'former_friend'
? '在时间的长河里刻舟求剑'
: '或许过往已无可溯洄,但好在还有可以与你相遇的明天'
}</div>
)} )}
{!loading && posts.length === 0 && ( {!loading && posts.length === 0 && (
@@ -655,14 +679,14 @@ export default function SnsPage() {
{/* 日期选择弹窗 */} {/* 日期选择弹窗 */}
{calendarPicker && ( {calendarPicker && (
<div className="calendar-overlay" onClick={() => setCalendarPicker(null)}> <div className="calendar-overlay" onClick={() => { setCalendarPicker(null); setShowYearMonthPicker(false) }}>
<div className="calendar-modal" onClick={e => e.stopPropagation()}> <div className="calendar-modal" onClick={e => e.stopPropagation()}>
<div className="calendar-header"> <div className="calendar-header">
<div className="title-area"> <div className="title-area">
<Calendar size={18} /> <Calendar size={18} />
<h3>{calendarPicker.field === 'start' ? '开始' : '结束'}</h3> <h3>{calendarPicker.field === 'start' ? '开始' : '结束'}</h3>
</div> </div>
<button className="close-btn" onClick={() => setCalendarPicker(null)}> <button className="close-btn" onClick={() => { setCalendarPicker(null); setShowYearMonthPicker(false) }}>
<X size={18} /> <X size={18} />
</button> </button>
</div> </div>
@@ -671,13 +695,39 @@ export default function SnsPage() {
<button className="nav-btn" onClick={() => setCalendarPicker(prev => prev ? { ...prev, month: new Date(prev.month.getFullYear(), prev.month.getMonth() - 1, 1) } : null)}> <button className="nav-btn" onClick={() => setCalendarPicker(prev => prev ? { ...prev, month: new Date(prev.month.getFullYear(), prev.month.getMonth() - 1, 1) } : null)}>
<ChevronLeft size={18} /> <ChevronLeft size={18} />
</button> </button>
<span className="current-month"> <span className="current-month clickable" onClick={() => setShowYearMonthPicker(!showYearMonthPicker)}>
{calendarPicker.month.getFullYear()}{calendarPicker.month.getMonth() + 1} {calendarPicker.month.getFullYear()}{calendarPicker.month.getMonth() + 1}
</span> </span>
<button className="nav-btn" onClick={() => setCalendarPicker(prev => prev ? { ...prev, month: new Date(prev.month.getFullYear(), prev.month.getMonth() + 1, 1) } : null)}> <button className="nav-btn" onClick={() => setCalendarPicker(prev => prev ? { ...prev, month: new Date(prev.month.getFullYear(), prev.month.getMonth() + 1, 1) } : null)}>
<ChevronRight size={18} /> <ChevronRight size={18} />
</button> </button>
</div> </div>
{showYearMonthPicker ? (
<div className="year-month-picker">
<div className="year-selector">
<button className="nav-btn" onClick={() => setCalendarPicker(prev => prev ? { ...prev, month: new Date(prev.month.getFullYear() - 1, prev.month.getMonth(), 1) } : null)}>
<ChevronLeft size={16} />
</button>
<span className="year-label">{calendarPicker.month.getFullYear()}</span>
<button className="nav-btn" onClick={() => setCalendarPicker(prev => prev ? { ...prev, month: new Date(prev.month.getFullYear() + 1, prev.month.getMonth(), 1) } : null)}>
<ChevronRight size={16} />
</button>
</div>
<div className="month-grid">
{['一月','二月','三月','四月','五月','六月','七月','八月','九月','十月','十一月','十二月'].map((name, i) => (
<button
key={i}
className={`month-btn ${i === calendarPicker.month.getMonth() ? 'active' : ''}`}
onClick={() => {
setCalendarPicker(prev => prev ? { ...prev, month: new Date(prev.month.getFullYear(), i, 1) } : null)
setShowYearMonthPicker(false)
}}
>{name}</button>
))}
</div>
</div>
) : (
<>
<div className="calendar-weekdays"> <div className="calendar-weekdays">
{['日', '一', '二', '三', '四', '五', '六'].map(d => <div key={d} className="weekday">{d}</div>)} {['日', '一', '二', '三', '四', '五', '六'].map(d => <div key={d} className="weekday">{d}</div>)}
</div> </div>
@@ -710,6 +760,8 @@ export default function SnsPage() {
}) })
})()} })()}
</div> </div>
</>
)}
</div> </div>
<div className="quick-options"> <div className="quick-options">
<button onClick={() => { <button onClick={() => {
@@ -733,7 +785,7 @@ export default function SnsPage() {
}}>{calendarPicker.field === 'start' ? '三个月前' : '一个月前'}</button> }}>{calendarPicker.field === 'start' ? '三个月前' : '一个月前'}</button>
</div> </div>
<div className="dialog-footer"> <div className="dialog-footer">
<button className="cancel-btn" onClick={() => setCalendarPicker(null)}></button> <button className="cancel-btn" onClick={() => { setCalendarPicker(null); setShowYearMonthPicker(false) }}></button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -503,6 +503,7 @@ export interface ElectronAPI {
}) => Promise<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; error?: string }> }) => Promise<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; error?: string }>
onExportProgress: (callback: (payload: { current: number; total: number; status: string }) => void) => () => void onExportProgress: (callback: (payload: { current: number; total: number; status: string }) => void) => () => void
selectExportDir: () => Promise<{ canceled: boolean; filePath?: string }> selectExportDir: () => Promise<{ canceled: boolean; filePath?: string }>
getSnsUsernames: () => Promise<{ success: boolean; usernames?: string[]; error?: string }>
} }
llama: { llama: {
loadModel: (modelPath: string) => Promise<boolean> loadModel: (modelPath: string) => Promise<boolean>

View File

@@ -32,7 +32,7 @@ export interface ContactInfo {
remark?: string remark?: string
nickname?: string nickname?: string
avatarUrl?: string avatarUrl?: string
type: 'friend' | 'group' | 'official' | 'other' type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
} }
// 消息 // 消息