diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bbe70d8..9482455 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,13 +39,23 @@ jobs: npx tsc 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 env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - npx electron-builder --publish always \ No newline at end of file + npx electron-builder --publish always + + - name: Update Release Notes + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + cat < release_notes.md + ## 更新日志 + 修复了一些已知问题 + + ## 加入我们的群 + [点击加入 Telegram 群](https://t.me/+hn3QzNc4DbA0MzNl) + EOF + + gh release edit "$GITHUB_REF_NAME" --notes-file release_notes.md \ No newline at end of file diff --git a/electron/main.ts b/electron/main.ts index c59b487..95a06c4 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -673,6 +673,10 @@ function registerIpcHandlers() { 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) => { return snsService.getTimeline(limit, offset, usernames, keyword, startTime, endTime) }) diff --git a/electron/preload.ts b/electron/preload.ts index 6b02eba..6fa3c36 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -118,7 +118,9 @@ contextBridge.exposeInMainWorld('electronAPI', { const listener = (_: any, payload: { msgId: string; text: string }) => callback(payload) ipcRenderer.on('chat:voiceTranscriptPartial', listener) return () => ipcRenderer.removeListener('chat:voiceTranscriptPartial', listener) - } + }, + execQuery: (kind: string, path: string | null, sql: string) => + ipcRenderer.invoke('chat:execQuery', kind, path, sql) }, diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 057927b..362e95c 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -3367,6 +3367,19 @@ class ChatService { } 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() diff --git a/resources/wcdb_api.dll b/resources/wcdb_api.dll index 09af338..2692201 100644 Binary files a/resources/wcdb_api.dll and b/resources/wcdb_api.dll differ diff --git a/src/pages/SnsPage.scss b/src/pages/SnsPage.scss index 005e2b4..2bcac8e 100644 --- a/src/pages/SnsPage.scss +++ b/src/pages/SnsPage.scss @@ -10,47 +10,63 @@ } .sns-sidebar { - width: 280px; + width: 300px; background: var(--bg-secondary); border-right: 1px solid var(--border-color); display: flex; flex-direction: column; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); flex-shrink: 0; + z-index: 10; &.closed { width: 0; opacity: 0; + transform: translateX(-100%); pointer-events: none; } .sidebar-header { - padding: 16px 20px; + padding: 18px 20px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--border-color); - h3 { - margin: 0; - font-size: 16px; - font-weight: 600; + .title-wrapper { + display: flex; + align-items: center; + gap: 8px; + color: var(--text-primary); + + .title-icon { + color: var(--accent-color); + } + + h3 { + margin: 0; + font-size: 15px; + font-weight: 600; + letter-spacing: 0.5px; + } } .toggle-btn { - background: none; - border: none; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); color: var(--text-secondary); cursor: pointer; - padding: 4px; + padding: 5px; display: flex; align-items: center; justify-content: center; - border-radius: 4px; + border-radius: 6px; + transition: all 0.2s; &:hover { background: var(--hover-bg); - color: var(--text-primary); + color: var(--accent-color); + border-color: var(--accent-color); } } } @@ -58,93 +74,114 @@ .filter-content { flex: 1; overflow-y: auto; + padding: 16px; display: flex; flex-direction: column; + gap: 16px; - /* 自定义滚动条 */ - &::-webkit-scrollbar { - width: 4px; - } - - &::-webkit-scrollbar-thumb { - background: var(--border-color); - border-radius: 10px; - } - } - - .filter-group { - padding-bottom: 4px; - border-bottom: 1px solid var(--border-color); - } - - .filter-section { - padding: 10px 20px; - - label { - display: flex; - align-items: center; - gap: 6px; - font-size: 13px; - color: var(--text-secondary); - margin-bottom: 8px; - font-weight: 500; - } - - input[type="text"] { - width: 100%; - background: var(--bg-tertiary); + .filter-card { + background: var(--bg-primary); + border-radius: 12px; border: 1px solid var(--border-color); - border-radius: 6px; - padding: 8px 12px; - color: var(--text-primary); - font-size: 13px; - outline: none; + padding: 14px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.02); + transition: transform 0.2s, box-shadow 0.2s; - &:focus { - border-color: var(--accent-color); + &:hover { + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.04); } - } - .date-inputs { - display: flex; - flex-direction: column; - gap: 6px; + &.jump-date-card { + .jump-date-btn { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 10px; + padding: 10px 14px; + color: var(--text-secondary); + font-size: 13px; + cursor: pointer; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; + overflow: hidden; - input[type="date"] { - background: var(--bg-tertiary); - border: 1px solid var(--border-color); - border-radius: 6px; - padding: 6px 10px; - color: var(--text-primary); - font-size: 12px; - outline: none; - width: 100%; + &.active { + border-color: var(--accent-color); + color: var(--text-primary); + font-weight: 500; + background: rgba(var(--accent-color-rgb), 0.05); - &:focus { - border-color: var(--accent-color); + .icon { + color: var(--accent-color); + opacity: 1; + } + } + + &:hover { + border-color: var(--accent-color); + background: var(--bg-primary); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(var(--accent-color-rgb), 0.08); + } + + &:active { + transform: translateY(0); + } + + .text { + flex: 1; + text-align: left; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .icon { + opacity: 0.5; + transition: all 0.2s; + margin-left: 8px; + } + } + + .clear-jump-date-inline { + width: 100%; + margin-top: 10px; + background: rgba(var(--accent-color-rgb), 0.06); + border: 1px dashed rgba(var(--accent-color-rgb), 0.3); + color: var(--accent-color); + font-size: 12px; + cursor: pointer; + text-align: center; + padding: 6px; + border-radius: 8px; + transition: all 0.2s; + font-weight: 500; + + &:hover { + background: var(--accent-color); + color: white; + border-style: solid; + } } } - span { - font-size: 11px; - color: var(--text-tertiary); - text-align: center; + &.contact-card { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; // 改为 0 以支持 flex 压缩 + padding: 0; + overflow: hidden; } } - } - .contact-filter-section { - flex: 1; - display: flex; - flex-direction: column; - min-height: 200px; - padding: 12px 0 0 0; - .section-header { - padding: 0 20px 8px 20px; - display: flex; - justify-content: space-between; - align-items: center; + + .filter-section { + margin-bottom: 20px; label { display: flex; @@ -152,118 +189,294 @@ gap: 6px; font-size: 13px; color: var(--text-secondary); - font-weight: 500; - } + margin-bottom: 10px; + font-weight: 600; - .selected-count { - font-size: 11px; - background: var(--accent-color); - color: white; - padding: 1px 6px; - border-radius: 10px; - } - } - - .contact-search { - margin: 0 20px 10px 20px; - position: relative; - - .search-icon { - position: absolute; - left: 10px; - top: 50%; - transform: translateY(-50%); - color: var(--text-tertiary); - } - - input { - width: 100%; - background: var(--bg-tertiary); - border: 1px solid var(--border-color); - border-radius: 4px; - padding: 6px 10px 6px 28px; - font-size: 12px; - color: var(--text-primary); - outline: none; - - &:focus { - border-color: var(--accent-color); + svg { + color: var(--accent-color); + opacity: 0.8; } } - } - .contact-list { - flex: 1; - overflow-y: auto; - padding: 0 10px; - - .contact-item { + .search-input-wrapper { + position: relative; display: flex; align-items: center; - padding: 8px 10px; - border-radius: 6px; - cursor: pointer; - transition: all 0.2s; - gap: 10px; - margin-bottom: 2px; - position: relative; - &:hover { - background: var(--hover-bg); + .input-icon { + position: absolute; + left: 12px; + color: var(--text-tertiary); + pointer-events: none; } - &.active { - background: rgba(var(--accent-color-rgb), 0.1); + input { + width: 100%; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 10px; + padding: 10px 10px 10px 36px; + color: var(--text-primary); + font-size: 13px; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); - .contact-name { - color: var(--accent-color); - font-weight: 600; + &::placeholder { + color: var(--text-tertiary); + opacity: 0.6; + } + + &:focus { + outline: none; + border-color: var(--accent-color); + background: var(--bg-primary); + box-shadow: 0 0 0 4px rgba(var(--accent-color-rgb), 0.1); } } - .contact-name { - flex: 1; - font-size: 13px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - color: var(--text-secondary); - } + .clear-input { + position: absolute; + right: 8px; + background: none; + border: none; + color: var(--text-tertiary); + cursor: pointer; + padding: 4px; + display: flex; + border-radius: 50%; + transition: all 0.2s; - .check-mark { - color: var(--accent-color); - font-size: 12px; - font-weight: bold; + &:hover { + color: var(--text-secondary); + background: var(--hover-bg); + transform: rotate(90deg); + } + } + } + } + + .contact-filter-section { + flex: 1; + display: flex; + flex-direction: column; + + .section-header { + padding: 16px 16px 1px 16px; + display: flex; + justify-content: space-between; + align-items: center; + + .header-actions { + display: flex; + align-items: center; + gap: 8px; + + .clear-selection-btn { + background: none; + border: none; + color: var(--text-tertiary); + font-size: 11px; + cursor: pointer; + padding: 2px 6px; + border-radius: 4px; + transition: all 0.2s; + + &:hover { + color: var(--accent-color); + background: rgba(var(--accent-color-rgb), 0.1); + } + } + + .selected-count { + font-size: 10px; + background: var(--accent-color); + color: white; + min-width: 18px; + height: 18px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + font-weight: bold; + } } } - .empty-contacts { - text-align: center; - padding: 20px; - font-size: 12px; - color: var(--text-tertiary); + .contact-search { + padding: 0 16px 12px 16px; + position: relative; + display: flex; + align-items: center; + + .search-icon { + position: absolute; + left: 26px; + color: var(--text-tertiary); + pointer-events: none; + z-index: 1; + opacity: 0.6; + } + + input { + width: 100%; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 10px; + padding: 8px 30px 8px 30px; + font-size: 12px; + color: var(--text-primary); + transition: all 0.2s; + + &:focus { + outline: none; + border-color: var(--accent-color); + background: var(--bg-primary); + } + } + + .clear-search-icon { + position: absolute; + right: 24px; + color: var(--text-tertiary); + cursor: pointer; + padding: 4px; + border-radius: 50%; + transition: all 0.2s; + + &:hover { + color: var(--text-secondary); + background: var(--hover-bg); + } + } + } + + .contact-list { + flex: 1; + overflow-y: auto; + padding: 4px 8px; + margin: 0 4px 8px 4px; + + .contact-item { + display: flex; + align-items: center; + padding: 8px 12px; + border-radius: 10px; + cursor: pointer; + gap: 12px; + margin-bottom: 2px; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; + + &:hover { + background: var(--hover-bg); + transform: translateX(2px); + } + + &.active { + background: rgba(var(--accent-color-rgb), 0.08); + + .contact-name { + color: var(--accent-color); + font-weight: 600; + } + + .check-box { + border-color: var(--accent-color); + background: var(--accent-color); + + .inner-check { + transform: scale(1); + } + } + } + + .avatar-wrapper { + position: relative; + display: flex; + + .active-badge { + position: absolute; + bottom: -1px; + right: -1px; + width: 10px; + height: 10px; + background: var(--accent-color); + border: 2px solid var(--bg-secondary); + border-radius: 50%; + animation: badge-pop 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); + } + } + + .contact-name { + flex: 1; + font-size: 13px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--text-secondary); + transition: color 0.2s; + } + + .check-box { + width: 16px; + height: 16px; + border: 2px solid var(--border-color); + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; + + .inner-check { + width: 8px; + height: 8px; + border-radius: 1px; + background: white; + transform: scale(0); + transition: transform 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275); + } + } + } + + .empty-contacts { + padding: 32px 16px; + text-align: center; + font-size: 13px; + color: var(--text-tertiary); + font-style: italic; + } } } } .sidebar-footer { - padding: 16px 20px; + padding: 16px; border-top: 1px solid var(--border-color); .clear-btn { width: 100%; - padding: 8px; - background: transparent; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 10px; + background: var(--bg-tertiary); border: 1px solid var(--border-color); color: var(--text-secondary); - border-radius: 6px; + border-radius: 8px; cursor: pointer; font-size: 13px; + font-weight: 500; transition: all 0.2s; &:hover { - background: var(--hover-bg); - color: var(--text-primary); + background: var(--accent-color); + color: white; + border-color: var(--accent-color); + box-shadow: 0 4px 10px rgba(var(--accent-color-rgb), 0.2); + } + + &:active { + transform: scale(0.98); } } } @@ -274,25 +487,40 @@ display: flex; flex-direction: column; min-width: 0; + background: var(--bg-primary); .sns-header { display: flex; align-items: center; justify-content: space-between; - padding: 0 20px; - height: 60px; + padding: 0 24px; + height: 64px; border-bottom: 1px solid var(--border-color); background: var(--bg-secondary); + backdrop-filter: blur(10px); + z-index: 5; .header-left { display: flex; align-items: center; - gap: 12px; + gap: 16px; h2 { margin: 0; font-size: 18px; - font-weight: 600; + font-weight: 700; + color: var(--text-primary); + } + + .sidebar-trigger { + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 8px; + + &:hover { + background: var(--hover-bg); + color: var(--accent-color); + } } } @@ -302,15 +530,21 @@ color: var(--text-secondary); cursor: pointer; padding: 8px; - border-radius: 4px; + border-radius: 8px; transition: all 0.2s; display: flex; align-items: center; justify-content: center; &:hover { - background: var(--hover-bg); color: var(--text-primary); + background: var(--hover-bg); + } + + &.refresh-btn { + &:hover { + color: var(--accent-color); + } } } @@ -319,249 +553,364 @@ } } + .sns-content-wrapper { + flex: 1; + display: flex; + overflow: hidden; + position: relative; + } + + .sns-content { flex: 1; overflow-y: auto; - padding: 24px; + padding: 24px 0; scroll-behavior: smooth; - .active-filters { + .active-filters-bar { max-width: 680px; - margin: 0 auto 16px auto; + margin: 0 auto 24px auto; display: flex; align-items: center; justify-content: space-between; - background: rgba(var(--accent-color-rgb), 0.05); + background: rgba(var(--accent-color-rgb), 0.08); border: 1px solid rgba(var(--accent-color-rgb), 0.2); - padding: 8px 16px; - border-radius: 8px; + padding: 10px 16px; + border-radius: 10px; font-size: 13px; color: var(--accent-color); + .filter-info { + display: flex; + align-items: center; + gap: 8px; + font-weight: 500; + } + .clear-chip-btn { - background: none; + background: var(--accent-color); border: none; - color: var(--text-tertiary); + color: white; cursor: pointer; - font-size: 12px; - text-decoration: underline; + font-size: 11px; + padding: 4px 10px; + border-radius: 4px; + font-weight: 600; &:hover { - color: var(--text-secondary); + background: var(--accent-color-hover); } } } - .sns-post { - background: var(--bg-secondary); - border-radius: 12px; - padding: 20px; - margin-bottom: 24px; - max-width: 680px; - margin-left: auto; - margin-right: auto; - border: 1px solid var(--border-color); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05); + .posts-list { + display: flex; + flex-direction: column; + gap: 32px; + } - .post-header { - display: flex; - align-items: center; - margin-bottom: 14px; + .sns-post-row { + display: flex; + width: 100%; + max-width: 800px; + position: relative; + } - .post-info { - margin-left: 12px; + } - .nickname { - font-weight: 600; - margin-bottom: 2px; - color: var(--accent-color); - } + .sns-post-wrapper { + width: 100%; + padding: 0 20px; + } - .time { - font-size: 12px; - color: var(--text-tertiary); + .sns-post { + background: var(--bg-secondary); + border-radius: 16px; + padding: 24px; + border: 1px solid var(--border-color); + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.03); + transition: transform 0.2s; + + &:hover { + transform: translateY(-2px); + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.06); + } + + .post-header { + display: flex; + align-items: center; + margin-bottom: 18px; + + .post-info { + margin-left: 14px; + + .nickname { + font-size: 15px; + font-weight: 700; + margin-bottom: 4px; + color: var(--accent-color); + } + + .time { + font-size: 12px; + color: var(--text-tertiary); + display: flex; + align-items: center; + gap: 4px; + + &::before { + content: ''; + width: 4px; + height: 4px; + border-radius: 50%; + background: currentColor; + opacity: 0.5; } } } + } - .post-body { - margin-bottom: 16px; + .post-body { + margin-bottom: 20px; - .post-text { - margin-bottom: 12px; - white-space: pre-wrap; - line-height: 1.6; - font-size: 15px; - word-break: break-word; - } + .post-text { + margin-bottom: 14px; + white-space: pre-wrap; + line-height: 1.7; + font-size: 15px; + color: var(--text-primary); + word-break: break-word; + } - .post-media-grid { - display: grid; - gap: 4px; - width: fit-content; - max-width: 100%; + .post-media-grid { + display: grid; + gap: 6px; + width: fit-content; + max-width: 100%; - &.media-count-1 { - grid-template-columns: 1fr; - - .media-item { - max-width: 400px; - aspect-ratio: unset; - } - - img { - height: auto; - max-height: 500px; - } - } - - &.media-count-2, - &.media-count-4 { - grid-template-columns: repeat(2, 1fr); - } - - &.media-count-3, - &.media-count-5, - &.media-count-6, - &.media-count-7, - &.media-count-8, - &.media-count-9 { - grid-template-columns: repeat(3, 1fr); - } + &.media-count-1 { + grid-template-columns: 1fr; .media-item { - aspect-ratio: 1; + width: 320px; + height: 240px; + max-width: 100%; + border-radius: 12px; + aspect-ratio: auto; background: var(--bg-tertiary); - border-radius: 4px; - overflow: hidden; + border: 1px solid var(--border-color); + } + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + } + + &.media-count-2, + &.media-count-4 { + grid-template-columns: repeat(2, 1fr); + } + + &.media-count-3, + &.media-count-5, + &.media-count-6, + &.media-count-7, + &.media-count-8, + &.media-count-9 { + grid-template-columns: repeat(3, 1fr); + } + + .media-item { + width: 160px; // 多图模式下项固定大小(或由 grid 控制,但确保有高度) + height: 160px; + aspect-ratio: 1; + background: var(--bg-tertiary); + border-radius: 6px; + overflow: hidden; + border: 1px solid var(--border-color); + position: relative; + + img { + width: 100%; + height: 100%; + object-fit: cover; + cursor: zoom-in; + } + + .media-error-placeholder { + position: absolute; + inset: 0; display: flex; align-items: center; justify-content: center; - - &.error { - background: transparent; - border: 1px dashed var(--border-color); - color: var(--text-tertiary); - font-size: 12px; - min-width: 100px; - min-height: 100px; - } - - img { - width: 100%; - height: 100%; - object-fit: cover; - cursor: pointer; - transition: opacity 0.2s; - - &:hover { - opacity: 0.85; - } - } + background: var(--bg-deep); + color: var(--text-tertiary); + cursor: default; } } - - .post-video-placeholder { - display: inline-flex; - align-items: center; - background: rgba(var(--accent-color-rgb), 0.1); - color: var(--accent-color); - padding: 6px 12px; - border-radius: 6px; - font-size: 14px; - font-weight: 500; - margin-bottom: 8px; - } } - .post-footer { - background: var(--bg-tertiary); - border-radius: 6px; - padding: 10px 12px; - font-size: 13.5px; + .post-video-placeholder { + display: inline-flex; + align-items: center; + gap: 10px; + background: rgba(var(--accent-color-rgb), 0.08); + color: var(--accent-color); + padding: 10px 18px; + border-radius: 12px; + font-size: 14px; + font-weight: 600; + border: 1px solid rgba(var(--accent-color-rgb), 0.1); + cursor: pointer; - .likes-section { - display: flex; - align-items: flex-start; - color: var(--accent-color); - padding-bottom: 8px; - border-bottom: 1px solid rgba(0, 0, 0, 0.05); - margin-bottom: 8px; - - &:last-child { - padding-bottom: 0; - border-bottom: none; - margin-bottom: 0; - } - - .icon { - margin-top: 3.5px; - margin-right: 8px; - flex-shrink: 0; - } - - .likes-list { - line-height: 1.5; - } - } - - .comments-section { - .comment-item { - margin-bottom: 6px; - line-height: 1.5; - - &:last-child { - margin-bottom: 0; - } - - .comment-user { - color: var(--accent-color); - font-weight: 600; - cursor: pointer; - - &:hover { - text-decoration: underline; - } - } - - .reply-text { - color: var(--text-tertiary); - margin: 0 4px; - font-size: 13px; - } - - .comment-separator { - color: var(--text-secondary); - margin-left: -2px; - margin-right: 4px; - } - - .comment-content { - color: var(--text-secondary); - } - } + &:hover { + background: rgba(var(--accent-color-rgb), 0.12); } } } - .loading-more, - .no-more, - .no-results { - text-align: center; - padding: 40px 20px; - color: var(--text-tertiary); - font-size: 14px; + .post-footer { + background: var(--bg-tertiary); + border-radius: 10px; + padding: 14px; + position: relative; - .reset-inline { - margin-top: 12px; - background: var(--accent-color); - color: white; - border: none; - padding: 8px 20px; - border-radius: 6px; - cursor: pointer; + &::after { + content: ''; + position: absolute; + top: -8px; + left: 20px; + border-left: 8px solid transparent; + border-right: 8px solid transparent; + border-bottom: 8px solid var(--bg-tertiary); + } + + .likes-section { + display: flex; + align-items: flex-start; + color: var(--accent-color); + padding-bottom: 10px; + border-bottom: 1px solid rgba(0, 0, 0, 0.05); + margin-bottom: 10px; font-size: 13px; - box-shadow: 0 2px 6px rgba(var(--accent-color-rgb), 0.3); + + &:last-child { + padding-bottom: 0; + border-bottom: none; + margin-bottom: 0; + } + + .icon { + margin-top: 3px; + margin-right: 10px; + flex-shrink: 0; + opacity: 0.8; + } + + .likes-list { + line-height: 1.6; + font-weight: 500; + } + } + + .comments-section { + .comment-item { + margin-bottom: 8px; + line-height: 1.6; + font-size: 13px; + + &:last-child { + margin-bottom: 0; + } + + .comment-user { + color: var(--accent-color); + font-weight: 700; + cursor: pointer; + + &:hover { + text-decoration: underline; + } + } + + .reply-text { + color: var(--text-tertiary); + margin: 0 6px; + font-size: 12px; + } + + .comment-content { + color: var(--text-secondary); + } + } + } + } + } + + .status-indicator { + text-align: center; + padding: 40px; + color: var(--text-tertiary); + font-size: 14px; + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + + &.loading-more, + &.loading-newer { + color: var(--accent-color); + } + + &.newer-hint { + background: rgba(var(--accent-color-rgb), 0.08); + padding: 12px; + border-radius: 12px; + cursor: pointer; + border: 1px dashed rgba(var(--accent-color-rgb), 0.2); + transition: all 0.2s; + margin-bottom: 16px; + + &:hover { + background: rgba(var(--accent-color-rgb), 0.15); + border-style: solid; + transform: translateY(-2px); + } + } + } + + .no-results { + text-align: center; + padding: 80px 20px; + color: var(--text-tertiary); + + .no-results-icon { + margin-bottom: 20px; + opacity: 0.2; + } + + p { + font-size: 16px; + margin-bottom: 24px; + } + + .reset-inline { + background: var(--accent-color); + color: white; + border: none; + padding: 10px 24px; + border-radius: 10px; + cursor: pointer; + font-size: 14px; + font-weight: 600; + box-shadow: 0 4px 15px rgba(var(--accent-color-rgb), 0.3); + transition: all 0.2s; + + &:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(var(--accent-color-rgb), 0.4); } } } @@ -576,4 +925,16 @@ to { transform: rotate(360deg); } +} + +@keyframes badge-pop { + from { + transform: scale(0); + opacity: 0; + } + + to { + transform: scale(1); + opacity: 1; + } } \ No newline at end of file diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx index 04bf2d1..1a8bc23 100644 --- a/src/pages/SnsPage.tsx +++ b/src/pages/SnsPage.tsx @@ -1,7 +1,8 @@ -import { useEffect, useState, useRef, useCallback } from 'react' -import { RefreshCw, Heart, Search, Calendar, User, X, Filter } from 'lucide-react' +import { useEffect, useState, useRef, useCallback, useMemo } from 'react' +import { RefreshCw, Heart, Search, Calendar, User, X, Filter, Play, ImageIcon } from 'lucide-react' import { Avatar } from '../components/Avatar' import { ImagePreview } from '../components/ImagePreview' +import JumpToDateDialog from '../components/JumpToDateDialog' import './SnsPage.scss' interface SnsPost { @@ -20,23 +21,21 @@ interface SnsPost { const MediaItem = ({ url, thumb, onPreview }: { url: string, thumb: string, onPreview: () => void }) => { const [error, setError] = useState(false); - if (error) { - return ( -
- 无法加载 -
- ); - } - return ( -
- setError(true)} - /> +
+ {!error ? ( + setError(true)} + /> + ) : ( +
+ +
+ )}
); }; @@ -57,32 +56,102 @@ export default function SnsPage() { // 筛选与搜索状态 const [searchKeyword, setSearchKeyword] = useState('') const [selectedUsernames, setSelectedUsernames] = useState([]) - const [startDate, setStartDate] = useState('') - const [endDate, setEndDate] = useState('') const [isSidebarOpen, setIsSidebarOpen] = useState(true) // 联系人列表状态 const [contacts, setContacts] = useState([]) const [contactSearch, setContactSearch] = useState('') const [contactsLoading, setContactsLoading] = useState(false) + const [showJumpDialog, setShowJumpDialog] = useState(false) + const [jumpTargetDate, setJumpTargetDate] = useState(undefined) const [previewImage, setPreviewImage] = useState(null) - const loadPosts = useCallback(async (reset = false) => { + const postsContainerRef = useRef(null) + + const [hasNewer, setHasNewer] = useState(false) + const [loadingNewer, setLoadingNewer] = useState(false) + const postsRef = useRef([]) + const scrollAdjustmentRef = useRef(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 + loadingRef.current = true - setLoading(true) + if (direction === 'newer') setLoadingNewer(true) + else setLoading(true) try { - const currentOffset = reset ? 0 : offset const limit = 20 + let startTs: number | undefined = undefined + let endTs: number | undefined = undefined - // 转换日期为秒级时间戳 - const startTs = startDate ? Math.floor(new Date(startDate).getTime() / 1000) : undefined - const endTs = endDate ? Math.floor(new Date(endDate).getTime() / 1000) + 86399 : undefined // 包含当天 + if (reset) { + if (jumpTargetDate) { + 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( limit, - currentOffset, + 0, selectedUsernames, searchKeyword, startTs, @@ -92,11 +161,24 @@ export default function SnsPage() { if (result.success && result.timeline) { if (reset) { setPosts(result.timeline) - setOffset(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 { - setPosts(prev => [...prev, ...result.timeline!]) - setOffset(prev => prev + limit) + if (result.timeline.length > 0) { + setPosts(prev => [...prev, ...result.timeline!]) + } if (result.timeline.length < limit) { setHasMore(false) } @@ -106,9 +188,10 @@ export default function SnsPage() { console.error('Failed to load SNS timeline:', error) } finally { setLoading(false) + setLoadingNewer(false) loadingRef.current = false } - }, [offset, selectedUsernames, searchKeyword, startDate, endDate]) + }, [selectedUsernames, searchKeyword, jumpTargetDate]) // 获取联系人列表 const loadContacts = async () => { @@ -116,30 +199,14 @@ export default function SnsPage() { try { const result = await window.electronAPI.chat.getSessions() if (result.success && result.sessions) { - // 系统账号和特殊前缀 const systemAccounts = ['filehelper', 'fmessage', 'newsapp', 'weixin', 'qqmail', 'tmessage', 'floatbottle', 'medianote', 'brandsessionholder']; - - // 初步提取并过滤联系人 const initialContacts = result.sessions .filter((s: any) => { if (!s.username) return false; const u = s.username.toLowerCase(); - - // 1. 排除群聊 (WeChat 群组以 @chatroom 结尾) - if (u.includes('@chatroom') || u.endsWith('@chatroom') || u.endsWith('@openim')) { - return false; - } - - // 2. 排除公众号 (通常以 gh_ 开头) - if (u.startsWith('gh_')) { - return false; - } - - // 3. 排除系统账号 - if (systemAccounts.includes(u) || u.includes('helper') || u.includes('sessionholder')) { - return false; - } - + 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) => ({ @@ -149,7 +216,6 @@ export default function SnsPage() { })) setContacts(initialContacts) - // 异步进一步富化(获取更多准确的昵称和头像) const usernames = initialContacts.map(c => c.username) const enriched = await window.electronAPI.chat.enrichSessionsContactInfo(usernames) if (enriched.success && enriched.contacts) { @@ -173,18 +239,52 @@ export default function SnsPage() { } } + // 初始加载 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() }, []) useEffect(() => { - loadPosts(true) - }, [selectedUsernames, searchKeyword, startDate, endDate]) + loadPosts({ reset: true }) + }, [selectedUsernames, searchKeyword, jumpTargetDate]) const handleScroll = (e: React.UIEvent) => { 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) => { + 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) => { + // 选择联系人时,如果当前有时间跳转,建议清除时间跳转以避免“跳到旧动态”的困惑 + // 或者保持原样。根据用户反馈“乱跳”,我们在这里选择: + // 如果用户选择了新的一个人,而之前有时间跳转,我们重置时间跳转到最新。 + setJumpTargetDate(undefined); + setSelectedUsernames(prev => { if (prev.includes(username)) { return prev.filter(u => u !== username) @@ -214,8 +319,7 @@ export default function SnsPage() { const clearFilters = () => { setSearchKeyword('') setSelectedUsernames([]) - setStartDate('') - setEndDate('') + setJumpTargetDate(undefined) } const filteredContacts = contacts.filter(c => @@ -223,89 +327,122 @@ export default function SnsPage() { c.username.toLowerCase().includes(contactSearch.toLowerCase()) ) + + return (
{/* 侧边栏:过滤与搜索 */} @@ -313,109 +450,146 @@ export default function SnsPage() {
{!isSidebarOpen && ( - )} -

朋友圈

+

社交动态

-
-
- {selectedUsernames.length > 0 && ( -
- 筛选中: {selectedUsernames.length} 位好友 - -
- )} - - {posts.map(post => ( -
-
- -
-
{post.nickname}
-
{formatTime(post.createTime)}
+
+
+
+ {loadingNewer && ( +
+ + 正在检查更新的动态...
-
+ )} + {!loadingNewer && hasNewer && ( +
loadPosts({ direction: 'newer' })}> + 查看更新的动态 +
+ )} + {posts.map((post, index) => { + return ( +
+
+
+
+ +
+
{post.nickname}
+
{formatTime(post.createTime)}
+
+
-
- {post.contentDesc &&
{post.contentDesc}
} +
+ {post.contentDesc &&
{post.contentDesc}
} - {post.type === 15 ? ( -
- [视频] -
- ) : post.media.length > 0 && ( -
- {post.media.map((m, idx) => ( - setPreviewImage(m.url)} /> - ))} + {post.type === 15 ? ( +
+ + 视频动态 +
+ ) : post.media.length > 0 && ( +
+ {post.media.map((m, idx) => ( + setPreviewImage(m.url)} /> + ))} +
+ )} +
+ + {(post.likes.length > 0 || post.comments.length > 0) && ( +
+ {post.likes.length > 0 && ( +
+ + + {post.likes.join('、')} + +
+ )} + + {post.comments.length > 0 && ( +
+ {post.comments.map((c, idx) => ( +
+ {c.nickname} + {c.refNickname && ( + <> + 回复 + {c.refNickname} + + )} + : + {c.content} +
+ ))} +
+ )} +
+ )} +
+
+ ) + })} +
+ + {loading &&
+ + 正在加载更多... +
} + {!hasMore && posts.length > 0 &&
已经到底啦
} + {!loading && posts.length === 0 && ( +
+
+

未找到相关动态

+ {(selectedUsernames.length > 0 || searchKeyword) && ( + )}
- - {(post.likes.length > 0 || post.comments.length > 0) && ( -
- {post.likes.length > 0 && ( -
- - - {post.likes.join('、')} - -
- )} - - {post.comments.length > 0 && ( -
- {post.comments.map((c, idx) => ( -
- {c.nickname} - {c.refNickname && ( - <> - 回复 - {c.refNickname} - - )} - : - {c.content} -
- ))} -
- )} -
- )} -
- ))} - - {loading &&
加载中...
} - {!hasMore && posts.length > 0 &&
没有更多了
} - {!loading && posts.length === 0 && ( -
-

没有找到符合条件的朋友圈

- {selectedUsernames.length > 0 && ( - - )} -
- )} + )} +
{previewImage && ( setPreviewImage(null)} /> )} + { + setShowJumpDialog(false) + }} + onSelect={(date) => { + setJumpTargetDate(date) + setShowJumpDialog(false) + }} + currentDate={jumpTargetDate || new Date()} + />
) } diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 7893cf7..feea2cc 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -100,6 +100,7 @@ export interface ElectronAPI { 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 }> 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: {