Merge pull request #86 from hicccc77/dev

Dev
This commit is contained in:
cc
2026-01-23 23:46:34 +08:00
committed by GitHub
9 changed files with 1128 additions and 563 deletions

View File

@@ -39,13 +39,23 @@ jobs:
npx tsc npx tsc
npx vite build npx vite build
- name: Inject Configuration
shell: bash
run: |
npm pkg set build.releaseInfo.releaseNotes=$'仅适配微信 4.0 及以上版本\n\n修复了一些已知问题\n\n详情前往 Telegram 群查看'
- name: Package and Publish - name: Package and Publish
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
npx electron-builder --publish always npx electron-builder --publish always
- name: Update Release Notes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
cat <<EOF > release_notes.md
## 更新日志
修复了一些已知问题
## 加入我们的群
[点击加入 Telegram 群](https://t.me/+hn3QzNc4DbA0MzNl)
EOF
gh release edit "$GITHUB_REF_NAME" --notes-file release_notes.md

View File

@@ -673,6 +673,10 @@ function registerIpcHandlers() {
return chatService.getMessageById(sessionId, localId) return chatService.getMessageById(sessionId, localId)
}) })
ipcMain.handle('chat:execQuery', async (_, kind: string, path: string | null, sql: string) => {
return chatService.execQuery(kind, path, sql)
})
ipcMain.handle('sns:getTimeline', async (_, limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) => { ipcMain.handle('sns:getTimeline', async (_, limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) => {
return snsService.getTimeline(limit, offset, usernames, keyword, startTime, endTime) return snsService.getTimeline(limit, offset, usernames, keyword, startTime, endTime)
}) })

View File

@@ -118,7 +118,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
const listener = (_: any, payload: { msgId: string; text: string }) => callback(payload) const listener = (_: any, payload: { msgId: string; text: string }) => callback(payload)
ipcRenderer.on('chat:voiceTranscriptPartial', listener) ipcRenderer.on('chat:voiceTranscriptPartial', listener)
return () => ipcRenderer.removeListener('chat:voiceTranscriptPartial', listener) return () => ipcRenderer.removeListener('chat:voiceTranscriptPartial', listener)
} },
execQuery: (kind: string, path: string | null, sql: string) =>
ipcRenderer.invoke('chat:execQuery', kind, path, sql)
}, },

View File

@@ -3384,6 +3384,19 @@ class ChatService {
} }
return parsed return parsed
} }
async execQuery(kind: string, path: string | null, sql: string): Promise<{ success: boolean; rows?: any[]; error?: string }> {
try {
const connectResult = await this.ensureConnected()
if (!connectResult.success) {
return { success: false, error: connectResult.error || '数据库未连接' }
}
return wcdbService.execQuery(kind, path, sql)
} catch (e) {
console.error('ChatService: 执行自定义查询失败:', e)
return { success: false, error: String(e) }
}
}
} }
export const chatService = new ChatService() export const chatService = new ChatService()

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,8 @@
import { useEffect, useState, useRef, useCallback } from 'react' import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
import { RefreshCw, Heart, Search, Calendar, User, X, Filter } from 'lucide-react' import { RefreshCw, Heart, Search, Calendar, User, X, Filter, Play, ImageIcon } from 'lucide-react'
import { Avatar } from '../components/Avatar' import { Avatar } from '../components/Avatar'
import { ImagePreview } from '../components/ImagePreview' import { ImagePreview } from '../components/ImagePreview'
import JumpToDateDialog from '../components/JumpToDateDialog'
import './SnsPage.scss' import './SnsPage.scss'
interface SnsPost { interface SnsPost {
@@ -20,23 +21,21 @@ interface SnsPost {
const MediaItem = ({ url, thumb, onPreview }: { url: string, thumb: string, onPreview: () => void }) => { const MediaItem = ({ url, thumb, onPreview }: { url: string, thumb: string, onPreview: () => void }) => {
const [error, setError] = useState(false); const [error, setError] = useState(false);
if (error) {
return (
<div className="media-item error">
<span></span>
</div>
);
}
return ( return (
<div className="media-item"> <div className={`media-item ${error ? 'error' : ''}`}>
<img {!error ? (
src={thumb || url} <img
alt="" src={thumb || url}
loading="lazy" alt=""
onClick={onPreview} loading="lazy"
onError={() => setError(true)} onClick={onPreview}
/> onError={() => setError(true)}
/>
) : (
<div className="media-error-placeholder" onClick={onPreview}>
<ImageIcon size={24} style={{ opacity: 0.3 }} />
</div>
)}
</div> </div>
); );
}; };
@@ -57,32 +56,102 @@ export default function SnsPage() {
// 筛选与搜索状态 // 筛选与搜索状态
const [searchKeyword, setSearchKeyword] = useState('') const [searchKeyword, setSearchKeyword] = useState('')
const [selectedUsernames, setSelectedUsernames] = useState<string[]>([]) const [selectedUsernames, setSelectedUsernames] = useState<string[]>([])
const [startDate, setStartDate] = useState('')
const [endDate, setEndDate] = useState('')
const [isSidebarOpen, setIsSidebarOpen] = useState(true) const [isSidebarOpen, setIsSidebarOpen] = useState(true)
// 联系人列表状态 // 联系人列表状态
const [contacts, setContacts] = useState<Contact[]>([]) const [contacts, setContacts] = useState<Contact[]>([])
const [contactSearch, setContactSearch] = useState('') const [contactSearch, setContactSearch] = useState('')
const [contactsLoading, setContactsLoading] = useState(false) const [contactsLoading, setContactsLoading] = useState(false)
const [showJumpDialog, setShowJumpDialog] = useState(false)
const [jumpTargetDate, setJumpTargetDate] = useState<Date | undefined>(undefined)
const [previewImage, setPreviewImage] = useState<string | null>(null) const [previewImage, setPreviewImage] = useState<string | null>(null)
const loadPosts = useCallback(async (reset = false) => { const postsContainerRef = useRef<HTMLDivElement>(null)
const [hasNewer, setHasNewer] = useState(false)
const [loadingNewer, setLoadingNewer] = useState(false)
const postsRef = useRef<SnsPost[]>([])
const scrollAdjustmentRef = useRef<number>(0)
// 同步 posts 到 ref 供 loadPosts 使用
useEffect(() => {
postsRef.current = posts
}, [posts])
// 处理向上加载动态时的滚动位置保持
useEffect(() => {
if (scrollAdjustmentRef.current !== 0 && postsContainerRef.current) {
const container = postsContainerRef.current;
const newHeight = container.scrollHeight;
const diff = newHeight - scrollAdjustmentRef.current;
if (diff > 0) {
container.scrollTop += diff;
}
scrollAdjustmentRef.current = 0;
}
}, [posts])
const loadPosts = useCallback(async (options: { reset?: boolean, direction?: 'older' | 'newer' } = {}) => {
const { reset = false, direction = 'older' } = options
if (loadingRef.current) return if (loadingRef.current) return
loadingRef.current = true loadingRef.current = true
setLoading(true) if (direction === 'newer') setLoadingNewer(true)
else setLoading(true)
try { try {
const currentOffset = reset ? 0 : offset
const limit = 20 const limit = 20
let startTs: number | undefined = undefined
let endTs: number | undefined = undefined
// 转换日期为秒级时间戳 if (reset) {
const startTs = startDate ? Math.floor(new Date(startDate).getTime() / 1000) : undefined if (jumpTargetDate) {
const endTs = endDate ? Math.floor(new Date(endDate).getTime() / 1000) + 86399 : undefined // 包含当天 endTs = Math.floor(jumpTargetDate.getTime() / 1000) + 86399
}
} else if (direction === 'newer') {
const currentPosts = postsRef.current
if (currentPosts.length > 0) {
const topTs = currentPosts[0].createTime
console.log('[SnsPage] Fetching newer posts starts from:', topTs + 1);
const result = await window.electronAPI.sns.getTimeline(
limit,
0,
selectedUsernames,
searchKeyword,
topTs + 1,
undefined
);
if (result.success && result.timeline && result.timeline.length > 0) {
if (postsContainerRef.current) {
scrollAdjustmentRef.current = postsContainerRef.current.scrollHeight;
}
const existingIds = new Set(currentPosts.map(p => p.id));
const uniqueNewer = result.timeline.filter(p => !existingIds.has(p.id));
if (uniqueNewer.length > 0) {
setPosts(prev => [...uniqueNewer, ...prev]);
}
setHasNewer(result.timeline.length >= limit);
} else {
setHasNewer(false);
}
}
setLoadingNewer(false);
loadingRef.current = false;
return;
} else {
const currentPosts = postsRef.current
if (currentPosts.length > 0) {
endTs = currentPosts[currentPosts.length - 1].createTime - 1
}
}
const result = await window.electronAPI.sns.getTimeline( const result = await window.electronAPI.sns.getTimeline(
limit, limit,
currentOffset, 0,
selectedUsernames, selectedUsernames,
searchKeyword, searchKeyword,
startTs, startTs,
@@ -92,11 +161,24 @@ export default function SnsPage() {
if (result.success && result.timeline) { if (result.success && result.timeline) {
if (reset) { if (reset) {
setPosts(result.timeline) setPosts(result.timeline)
setOffset(limit)
setHasMore(result.timeline.length >= limit) setHasMore(result.timeline.length >= limit)
// 探测上方是否还有新动态(利用 DLL 过滤,而非底层 SQL
const topTs = result.timeline[0]?.createTime || 0;
if (topTs > 0) {
const checkResult = await window.electronAPI.sns.getTimeline(1, 0, selectedUsernames, searchKeyword, topTs + 1, undefined);
setHasNewer(!!(checkResult.success && checkResult.timeline && checkResult.timeline.length > 0));
} else {
setHasNewer(false);
}
if (postsContainerRef.current) {
postsContainerRef.current.scrollTop = 0
}
} else { } else {
setPosts(prev => [...prev, ...result.timeline!]) if (result.timeline.length > 0) {
setOffset(prev => prev + limit) setPosts(prev => [...prev, ...result.timeline!])
}
if (result.timeline.length < limit) { if (result.timeline.length < limit) {
setHasMore(false) setHasMore(false)
} }
@@ -106,9 +188,10 @@ export default function SnsPage() {
console.error('Failed to load SNS timeline:', error) console.error('Failed to load SNS timeline:', error)
} finally { } finally {
setLoading(false) setLoading(false)
setLoadingNewer(false)
loadingRef.current = false loadingRef.current = false
} }
}, [offset, selectedUsernames, searchKeyword, startDate, endDate]) }, [selectedUsernames, searchKeyword, jumpTargetDate])
// 获取联系人列表 // 获取联系人列表
const loadContacts = async () => { const loadContacts = async () => {
@@ -116,30 +199,14 @@ export default function SnsPage() {
try { try {
const result = await window.electronAPI.chat.getSessions() const result = await window.electronAPI.chat.getSessions()
if (result.success && result.sessions) { if (result.success && result.sessions) {
// 系统账号和特殊前缀
const systemAccounts = ['filehelper', 'fmessage', 'newsapp', 'weixin', 'qqmail', 'tmessage', 'floatbottle', 'medianote', 'brandsessionholder']; const systemAccounts = ['filehelper', 'fmessage', 'newsapp', 'weixin', 'qqmail', 'tmessage', 'floatbottle', 'medianote', 'brandsessionholder'];
// 初步提取并过滤联系人
const initialContacts = result.sessions const initialContacts = result.sessions
.filter((s: any) => { .filter((s: any) => {
if (!s.username) return false; if (!s.username) return false;
const u = s.username.toLowerCase(); const u = s.username.toLowerCase();
if (u.includes('@chatroom') || u.endsWith('@chatroom') || u.endsWith('@openim')) return false;
// 1. 排除群聊 (WeChat 群组以 @chatroom 结尾) if (u.startsWith('gh_')) return false;
if (u.includes('@chatroom') || u.endsWith('@chatroom') || u.endsWith('@openim')) { if (systemAccounts.includes(u) || u.includes('helper') || u.includes('sessionholder')) return false;
return false;
}
// 2. 排除公众号 (通常以 gh_ 开头)
if (u.startsWith('gh_')) {
return false;
}
// 3. 排除系统账号
if (systemAccounts.includes(u) || u.includes('helper') || u.includes('sessionholder')) {
return false;
}
return true; return true;
}) })
.map((s: any) => ({ .map((s: any) => ({
@@ -149,7 +216,6 @@ export default function SnsPage() {
})) }))
setContacts(initialContacts) setContacts(initialContacts)
// 异步进一步富化(获取更多准确的昵称和头像)
const usernames = initialContacts.map(c => c.username) const usernames = initialContacts.map(c => c.username)
const enriched = await window.electronAPI.chat.enrichSessionsContactInfo(usernames) const enriched = await window.electronAPI.chat.enrichSessionsContactInfo(usernames)
if (enriched.success && enriched.contacts) { if (enriched.success && enriched.contacts) {
@@ -173,18 +239,52 @@ export default function SnsPage() {
} }
} }
// 初始加载
useEffect(() => { useEffect(() => {
const checkSchema = async () => {
try {
const schema = await window.electronAPI.chat.execQuery('sns', null, "PRAGMA table_info(SnsTimeLine)");
console.log('[SnsPage] SnsTimeLine Schema:', schema);
if (schema.success && schema.rows) {
const columns = schema.rows.map((r: any) => r.name);
console.log('[SnsPage] Available columns:', columns);
}
} catch (e) {
console.error('[SnsPage] Failed to check schema:', e);
}
};
checkSchema();
loadContacts() loadContacts()
}, []) }, [])
useEffect(() => { useEffect(() => {
loadPosts(true) loadPosts({ reset: true })
}, [selectedUsernames, searchKeyword, startDate, endDate]) }, [selectedUsernames, searchKeyword, jumpTargetDate])
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => { const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
const { scrollTop, clientHeight, scrollHeight } = e.currentTarget const { scrollTop, clientHeight, scrollHeight } = e.currentTarget
if (scrollHeight - scrollTop - clientHeight < 200 && hasMore && !loading) {
loadPosts() // 加载更旧的动态(触底)
if (scrollHeight - scrollTop - clientHeight < 400 && hasMore && !loading && !loadingNewer) {
loadPosts({ direction: 'older' })
}
// 加载更新的动态(触顶触发)
// 这里的阈值可以保留,但主要依赖下面的 handleWheel 捕获到顶后的上划
if (scrollTop < 10 && hasNewer && !loading && !loadingNewer) {
loadPosts({ direction: 'newer' })
}
}
// 处理到顶后的手动上滚意图
const handleWheel = (e: React.WheelEvent<HTMLDivElement>) => {
const container = postsContainerRef.current
if (!container) return
// deltaY < 0 表示向上滚scrollTop === 0 表示已经在最顶端
if (e.deltaY < -20 && container.scrollTop <= 0 && hasNewer && !loading && !loadingNewer) {
console.log('[SnsPage] Wheel-up detected at top, loading newer posts...');
loadPosts({ direction: 'newer' })
} }
} }
@@ -202,6 +302,11 @@ export default function SnsPage() {
} }
const toggleUserSelection = (username: string) => { const toggleUserSelection = (username: string) => {
// 选择联系人时,如果当前有时间跳转,建议清除时间跳转以避免“跳到旧动态”的困惑
// 或者保持原样。根据用户反馈“乱跳”,我们在这里选择:
// 如果用户选择了新的一个人,而之前有时间跳转,我们重置时间跳转到最新。
setJumpTargetDate(undefined);
setSelectedUsernames(prev => { setSelectedUsernames(prev => {
if (prev.includes(username)) { if (prev.includes(username)) {
return prev.filter(u => u !== username) return prev.filter(u => u !== username)
@@ -214,8 +319,7 @@ export default function SnsPage() {
const clearFilters = () => { const clearFilters = () => {
setSearchKeyword('') setSearchKeyword('')
setSelectedUsernames([]) setSelectedUsernames([])
setStartDate('') setJumpTargetDate(undefined)
setEndDate('')
} }
const filteredContacts = contacts.filter(c => const filteredContacts = contacts.filter(c =>
@@ -223,89 +327,122 @@ export default function SnsPage() {
c.username.toLowerCase().includes(contactSearch.toLowerCase()) c.username.toLowerCase().includes(contactSearch.toLowerCase())
) )
return ( return (
<div className="sns-page"> <div className="sns-page">
<div className="sns-container"> <div className="sns-container">
{/* 侧边栏:过滤与搜索 */} {/* 侧边栏:过滤与搜索 */}
<aside className={`sns-sidebar ${isSidebarOpen ? 'open' : 'closed'}`}> <aside className={`sns-sidebar ${isSidebarOpen ? 'open' : 'closed'}`}>
<div className="sidebar-header"> <div className="sidebar-header">
<h3></h3> <div className="title-wrapper">
<Filter size={18} className="title-icon" />
<h3></h3>
</div>
<button className="toggle-btn" onClick={() => setIsSidebarOpen(false)}> <button className="toggle-btn" onClick={() => setIsSidebarOpen(false)}>
<X size={18} /> <X size={18} />
</button> </button>
</div> </div>
<div className="filter-content"> <div className="filter-content custom-scrollbar">
{/* 关键词与时间 */} {/* 1. 搜索分组 (放到最顶上) */}
<div className="filter-group"> <div className="filter-card">
<div className="filter-section"> <div className="filter-section">
<label><Search size={14} /> </label> <label><Search size={14} /> </label>
<input <div className="search-input-wrapper">
type="text" <Search size={14} className="input-icon" />
placeholder="搜索正文..."
value={searchKeyword}
onChange={e => setSearchKeyword(e.target.value)}
/>
</div>
<div className="filter-section">
<label><Calendar size={14} /> </label>
<div className="date-inputs">
<input <input
type="date" type="text"
value={startDate} placeholder="搜索动态内容..."
onChange={e => setStartDate(e.target.value)} value={searchKeyword}
/> onChange={e => setSearchKeyword(e.target.value)}
<span></span>
<input
type="date"
value={endDate}
onChange={e => setEndDate(e.target.value)}
/> />
{searchKeyword && (
<button className="clear-input" onClick={() => setSearchKeyword('')}>
<X size={14} />
</button>
)}
</div> </div>
</div> </div>
</div> </div>
{/* 联系人列表 */} {/* 2. 日期跳转 (放搜索下面) */}
<div className="contact-filter-section"> <div className="filter-card jump-date-card">
<div className="section-header"> <div className="filter-section">
<label><User size={14} /> </label> <label><Calendar size={14} /> </label>
{selectedUsernames.length > 0 && ( <button className={`jump-date-btn ${jumpTargetDate ? 'active' : ''}`} onClick={() => setShowJumpDialog(true)}>
<span className="selected-count"> {selectedUsernames.length}</span> <span className="text">
{jumpTargetDate ? jumpTargetDate.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' }) : '选择跳转日期...'}
</span>
<Calendar size={14} className="icon" />
</button>
{jumpTargetDate && (
<button className="clear-jump-date-inline" onClick={() => setJumpTargetDate(undefined)}>
</button>
)} )}
</div> </div>
<div className="contact-search"> </div>
<Search size={12} className="search-icon" />
<input
type="text" {/* 3. 联系人筛选 (放最下面,高度自适应) */}
placeholder="搜索好友..." <div className="filter-card contact-card">
value={contactSearch} <div className="contact-filter-section">
onChange={e => setContactSearch(e.target.value)} <div className="section-header">
/> <label><User size={14} /> </label>
</div> <div className="header-actions">
<div className="contact-list custom-scrollbar"> {selectedUsernames.length > 0 && (
{filteredContacts.map(contact => ( <button className="clear-selection-btn" onClick={() => setSelectedUsernames([])}></button>
<div )}
key={contact.username} {selectedUsernames.length > 0 && (
className={`contact-item ${selectedUsernames.includes(contact.username) ? 'active' : ''}`} <span className="selected-count">{selectedUsernames.length}</span>
onClick={() => toggleUserSelection(contact.username)}
>
<Avatar src={contact.avatarUrl} name={contact.displayName} size={28} shape="rounded" />
<span className="contact-name">{contact.displayName}</span>
{selectedUsernames.includes(contact.username) && (
<div className="check-mark"></div>
)} )}
</div> </div>
))} </div>
{contacts.length === 0 && !contactsLoading && ( <div className="contact-search">
<div className="empty-contacts"></div> <Search size={12} className="search-icon" />
)} <input
type="text"
placeholder="搜索好友..."
value={contactSearch}
onChange={e => setContactSearch(e.target.value)}
/>
{contactSearch && (
<X size={12} className="clear-search-icon" onClick={() => setContactSearch('')} />
)}
</div>
<div className="contact-list custom-scrollbar">
{filteredContacts.map(contact => (
<div
key={contact.username}
className={`contact-item ${selectedUsernames.includes(contact.username) ? 'active' : ''}`}
onClick={() => toggleUserSelection(contact.username)}
>
<div className="avatar-wrapper">
<Avatar src={contact.avatarUrl} name={contact.displayName} size={32} shape="rounded" />
{selectedUsernames.includes(contact.username) && (
<div className="active-badge"></div>
)}
</div>
<span className="contact-name">{contact.displayName}</span>
<div className="check-box">
{selectedUsernames.includes(contact.username) && <div className="inner-check"></div>}
</div>
</div>
))}
{filteredContacts.length === 0 && (
<div className="empty-contacts"></div>
)}
</div>
</div> </div>
</div> </div>
</div> </div>
<div className="sidebar-footer"> <div className="sidebar-footer">
<button className="clear-btn" onClick={clearFilters}></button> <button className="clear-btn" onClick={clearFilters}>
<RefreshCw size={14} />
</button>
</div> </div>
</aside> </aside>
@@ -313,109 +450,146 @@ export default function SnsPage() {
<div className="sns-header"> <div className="sns-header">
<div className="header-left"> <div className="header-left">
{!isSidebarOpen && ( {!isSidebarOpen && (
<button className="icon-btn" onClick={() => setIsSidebarOpen(true)}> <button className="icon-btn sidebar-trigger" onClick={() => setIsSidebarOpen(true)}>
<Filter size={20} /> <Filter size={20} />
</button> </button>
)} )}
<h2></h2> <h2></h2>
</div> </div>
<div className="header-right"> <div className="header-right">
<button onClick={() => loadPosts(true)} disabled={loading} className="icon-btn refresh-btn"> <button
<RefreshCw size={18} className={loading ? 'spinning' : ''} /> onClick={() => {
if (jumpTargetDate) setJumpTargetDate(undefined);
loadPosts({ reset: true });
}}
disabled={loading || loadingNewer}
className="icon-btn refresh-btn"
>
<RefreshCw size={18} className={(loading || loadingNewer) ? 'spinning' : ''} />
</button> </button>
</div> </div>
</div> </div>
<div className="sns-content" onScroll={handleScroll}> <div className="sns-content-wrapper">
{selectedUsernames.length > 0 && ( <div className="sns-content custom-scrollbar" onScroll={handleScroll} onWheel={handleWheel} ref={postsContainerRef}>
<div className="active-filters"> <div className="posts-list">
<span>: {selectedUsernames.length} </span> {loadingNewer && (
<button onClick={() => setSelectedUsernames([])} className="clear-chip-btn"></button> <div className="status-indicator loading-newer">
</div> <RefreshCw size={16} className="spinning" />
)} <span>...</span>
{posts.map(post => (
<div key={post.id} className="sns-post">
<div className="post-header">
<Avatar
src={post.avatarUrl}
name={post.nickname}
size={44}
shape="rounded"
/>
<div className="post-info">
<div className="nickname">{post.nickname}</div>
<div className="time">{formatTime(post.createTime)}</div>
</div> </div>
</div> )}
{!loadingNewer && hasNewer && (
<div className="status-indicator newer-hint" onClick={() => loadPosts({ direction: 'newer' })}>
</div>
)}
{posts.map((post, index) => {
return (
<div key={post.id} className="sns-post-row">
<div className="sns-post-wrapper">
<div className="sns-post">
<div className="post-header">
<Avatar
src={post.avatarUrl}
name={post.nickname}
size={44}
shape="rounded"
/>
<div className="post-info">
<div className="nickname">{post.nickname}</div>
<div className="time">{formatTime(post.createTime)}</div>
</div>
</div>
<div className="post-body"> <div className="post-body">
{post.contentDesc && <div className="post-text">{post.contentDesc}</div>} {post.contentDesc && <div className="post-text">{post.contentDesc}</div>}
{post.type === 15 ? ( {post.type === 15 ? (
<div className="post-video-placeholder"> <div className="post-video-placeholder">
[] <Play size={20} />
</div> <span></span>
) : post.media.length > 0 && ( </div>
<div className={`post-media-grid media-count-${Math.min(post.media.length, 9)}`}> ) : post.media.length > 0 && (
{post.media.map((m, idx) => ( <div className={`post-media-grid media-count-${Math.min(post.media.length, 9)}`}>
<MediaItem key={idx} url={m.url} thumb={m.thumb} onPreview={() => setPreviewImage(m.url)} /> {post.media.map((m, idx) => (
))} <MediaItem key={idx} url={m.url} thumb={m.thumb} onPreview={() => setPreviewImage(m.url)} />
))}
</div>
)}
</div>
{(post.likes.length > 0 || post.comments.length > 0) && (
<div className="post-footer">
{post.likes.length > 0 && (
<div className="likes-section">
<Heart size={14} className="icon" />
<span className="likes-list">
{post.likes.join('、')}
</span>
</div>
)}
{post.comments.length > 0 && (
<div className="comments-section">
{post.comments.map((c, idx) => (
<div key={idx} className="comment-item">
<span className="comment-user">{c.nickname}</span>
{c.refNickname && (
<>
<span className="reply-text"></span>
<span className="comment-user">{c.refNickname}</span>
</>
)}
<span className="comment-separator">: </span>
<span className="comment-content">{c.content}</span>
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
</div> </div>
)
})}
</div>
{loading && <div className="status-indicator loading-more">
<RefreshCw size={16} className="spinning" />
<span>...</span>
</div>}
{!hasMore && posts.length > 0 && <div className="status-indicator no-more"></div>}
{!loading && posts.length === 0 && (
<div className="no-results">
<div className="no-results-icon"><Search size={48} /></div>
<p></p>
{(selectedUsernames.length > 0 || searchKeyword) && (
<button onClick={clearFilters} className="reset-inline">
</button>
)} )}
</div> </div>
)}
{(post.likes.length > 0 || post.comments.length > 0) && ( </div>
<div className="post-footer">
{post.likes.length > 0 && (
<div className="likes-section">
<Heart size={14} className="icon" />
<span className="likes-list">
{post.likes.join('、')}
</span>
</div>
)}
{post.comments.length > 0 && (
<div className="comments-section">
{post.comments.map((c, idx) => (
<div key={idx} className="comment-item">
<span className="comment-user">{c.nickname}</span>
{c.refNickname && (
<>
<span className="reply-text"></span>
<span className="comment-user">{c.refNickname}</span>
</>
)}
<span className="comment-separator">: </span>
<span className="comment-content">{c.content}</span>
</div>
))}
</div>
)}
</div>
)}
</div>
))}
{loading && <div className="loading-more">...</div>}
{!hasMore && posts.length > 0 && <div className="no-more"></div>}
{!loading && posts.length === 0 && (
<div className="no-results">
<p></p>
{selectedUsernames.length > 0 && (
<button onClick={() => setSelectedUsernames([])} className="reset-inline">
</button>
)}
</div>
)}
</div> </div>
</main> </main>
</div> </div>
{previewImage && ( {previewImage && (
<ImagePreview src={previewImage} onClose={() => setPreviewImage(null)} /> <ImagePreview src={previewImage} onClose={() => setPreviewImage(null)} />
)} )}
<JumpToDateDialog
isOpen={showJumpDialog}
onClose={() => {
setShowJumpDialog(false)
}}
onSelect={(date) => {
setJumpTargetDate(date)
setShowJumpDialog(false)
}}
currentDate={jumpTargetDate || new Date()}
/>
</div> </div>
) )
} }

View File

@@ -100,6 +100,7 @@ export interface ElectronAPI {
resolveVoiceCache: (sessionId: string, msgId: string) => Promise<{ success: boolean; hasCache: boolean; data?: string }> resolveVoiceCache: (sessionId: string, msgId: string) => Promise<{ success: boolean; hasCache: boolean; data?: string }>
getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => Promise<{ success: boolean; transcript?: string; error?: string }> getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => Promise<{ success: boolean; transcript?: string; error?: string }>
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => () => void onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => () => void
execQuery: (kind: string, path: string | null, sql: string) => Promise<{ success: boolean; rows?: any[]; error?: string }>
} }
image: { image: {