mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 07:16:51 +00:00
Merge branch 'dev' into dev
This commit is contained in:
22
.github/workflows/release.yml
vendored
22
.github/workflows/release.yml
vendored
@@ -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
|
||||||
@@ -25,16 +25,22 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
|
|||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> 如果导出聊天记录后,想深入分析聊天内容可以试试 [ChatLab](https://chatlab.fun/)
|
> 如果导出聊天记录后,想深入分析聊天内容可以试试 [ChatLab](https://chatlab.fun/)
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> 仅支持微信 **4.0** 及以上版本
|
||||||
|
|
||||||
# 加入微信交流群
|
# 加入微信交流群
|
||||||
|
|
||||||
> 🎉 扫码加入微信群,与其他 WeFlow 用户一起交流问题和使用心得。
|
> 🎉 扫码加入微信群,与其他 WeFlow 用户一起交流问题和使用心得。
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="2wm.png" alt="WeFlow 微信交流群二维码" width="220">
|
<img src="2wm.png" alt="WeFlow 微信交流群二维码(一群)" width="220" style="margin-right: 16px;">
|
||||||
|
<img src="3wm.png" alt="WeFlow 微信交流群二维码(二群)" width="220">
|
||||||
</p>
|
</p>
|
||||||
|
<p align="center">一群满了加二群</p>
|
||||||
|
|
||||||
## 主要功能
|
## 主要功能
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
301
electron/services/exportHtml.css
Normal file
301
electron/services/exportHtml.css
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--bg: #f6f7fb;
|
||||||
|
--card: #ffffff;
|
||||||
|
--text: #1f2a37;
|
||||||
|
--muted: #6b7280;
|
||||||
|
--accent: #4f46e5;
|
||||||
|
--sent: #dbeafe;
|
||||||
|
--received: #ffffff;
|
||||||
|
--border: #e5e7eb;
|
||||||
|
--shadow: 0 12px 30px rgba(15, 23, 42, 0.08);
|
||||||
|
--radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "PingFang SC", "Microsoft YaHei", system-ui, -apple-system, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
max-width: 1080px;
|
||||||
|
margin: 32px auto 60px;
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
background: var(--card);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control input,
|
||||||
|
.control select,
|
||||||
|
.control button {
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control button {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control button:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.sent .message-row {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #eef2ff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: #475569;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble {
|
||||||
|
max-width: min(70%, 720px);
|
||||||
|
background: var(--received);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.sent .bubble {
|
||||||
|
background: var(--sent);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sender-name {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-text {
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-emoji {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
vertical-align: text-bottom;
|
||||||
|
margin: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-media {
|
||||||
|
border-radius: 14px;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewable {
|
||||||
|
cursor: zoom-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-media.image,
|
||||||
|
.message-media.emoji {
|
||||||
|
max-height: 260px;
|
||||||
|
object-fit: contain;
|
||||||
|
background: #f1f5f9;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-media.emoji {
|
||||||
|
max-height: 160px;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-media.video {
|
||||||
|
max-height: 360px;
|
||||||
|
background: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-media.audio {
|
||||||
|
width: 260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(15, 23, 42, 0.7);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
z-index: 999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview.active {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview img {
|
||||||
|
max-width: min(90vw, 1200px);
|
||||||
|
max-height: 90vh;
|
||||||
|
border-radius: 18px;
|
||||||
|
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.35);
|
||||||
|
background: #0f172a;
|
||||||
|
transition: transform 0.1s ease;
|
||||||
|
cursor: zoom-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-theme="cloud-dancer"] {
|
||||||
|
--accent: #6b8cff;
|
||||||
|
--sent: #e0e7ff;
|
||||||
|
--received: #ffffff;
|
||||||
|
--border: #d8e0f7;
|
||||||
|
--bg: #f6f7fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-theme="corundum-blue"] {
|
||||||
|
--accent: #2563eb;
|
||||||
|
--sent: #dbeafe;
|
||||||
|
--received: #ffffff;
|
||||||
|
--border: #c7d2fe;
|
||||||
|
--bg: #eef2ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-theme="kiwi-green"] {
|
||||||
|
--accent: #16a34a;
|
||||||
|
--sent: #dcfce7;
|
||||||
|
--received: #ffffff;
|
||||||
|
--border: #bbf7d0;
|
||||||
|
--bg: #f0fdf4;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-theme="spicy-red"] {
|
||||||
|
--accent: #e11d48;
|
||||||
|
--sent: #ffe4e6;
|
||||||
|
--received: #ffffff;
|
||||||
|
--border: #fecdd3;
|
||||||
|
--bg: #fff1f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-theme="teal-water"] {
|
||||||
|
--accent: #0f766e;
|
||||||
|
--sent: #ccfbf1;
|
||||||
|
--received: #ffffff;
|
||||||
|
--border: #99f6e4;
|
||||||
|
--bg: #f0fdfa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.highlight {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 4px;
|
||||||
|
border-radius: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
302
electron/services/exportHtmlStyles.ts
Normal file
302
electron/services/exportHtmlStyles.ts
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
export const EXPORT_HTML_STYLES = `:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--bg: #f6f7fb;
|
||||||
|
--card: #ffffff;
|
||||||
|
--text: #1f2a37;
|
||||||
|
--muted: #6b7280;
|
||||||
|
--accent: #4f46e5;
|
||||||
|
--sent: #dbeafe;
|
||||||
|
--received: #ffffff;
|
||||||
|
--border: #e5e7eb;
|
||||||
|
--shadow: 0 12px 30px rgba(15, 23, 42, 0.08);
|
||||||
|
--radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "PingFang SC", "Microsoft YaHei", system-ui, -apple-system, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
max-width: 1080px;
|
||||||
|
margin: 32px auto 60px;
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
background: var(--card);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control input,
|
||||||
|
.control select,
|
||||||
|
.control button {
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control button {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control button:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.sent .message-row {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #eef2ff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: #475569;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble {
|
||||||
|
max-width: min(70%, 720px);
|
||||||
|
background: var(--received);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message.sent .bubble {
|
||||||
|
background: var(--sent);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sender-name {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-text {
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-emoji {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
vertical-align: text-bottom;
|
||||||
|
margin: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-media {
|
||||||
|
border-radius: 14px;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.previewable {
|
||||||
|
cursor: zoom-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-media.image,
|
||||||
|
.message-media.emoji {
|
||||||
|
max-height: 260px;
|
||||||
|
object-fit: contain;
|
||||||
|
background: #f1f5f9;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-media.emoji {
|
||||||
|
max-height: 160px;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-media.video {
|
||||||
|
max-height: 360px;
|
||||||
|
background: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-media.audio {
|
||||||
|
width: 260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(15, 23, 42, 0.7);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
z-index: 999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview.active {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview img {
|
||||||
|
max-width: min(90vw, 1200px);
|
||||||
|
max-height: 90vh;
|
||||||
|
border-radius: 18px;
|
||||||
|
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.35);
|
||||||
|
background: #0f172a;
|
||||||
|
transition: transform 0.1s ease;
|
||||||
|
cursor: zoom-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-theme="cloud-dancer"] {
|
||||||
|
--accent: #6b8cff;
|
||||||
|
--sent: #e0e7ff;
|
||||||
|
--received: #ffffff;
|
||||||
|
--border: #d8e0f7;
|
||||||
|
--bg: #f6f7fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-theme="corundum-blue"] {
|
||||||
|
--accent: #2563eb;
|
||||||
|
--sent: #dbeafe;
|
||||||
|
--received: #ffffff;
|
||||||
|
--border: #c7d2fe;
|
||||||
|
--bg: #eef2ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-theme="kiwi-green"] {
|
||||||
|
--accent: #16a34a;
|
||||||
|
--sent: #dcfce7;
|
||||||
|
--received: #ffffff;
|
||||||
|
--border: #bbf7d0;
|
||||||
|
--bg: #f0fdf4;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-theme="spicy-red"] {
|
||||||
|
--accent: #e11d48;
|
||||||
|
--sent: #ffe4e6;
|
||||||
|
--received: #ffffff;
|
||||||
|
--border: #fecdd3;
|
||||||
|
--bg: #fff1f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-theme="teal-water"] {
|
||||||
|
--accent: #0f766e;
|
||||||
|
--sent: #ccfbf1;
|
||||||
|
--received: #ffffff;
|
||||||
|
--border: #99f6e4;
|
||||||
|
--bg: #f0fdfa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.highlight {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 4px;
|
||||||
|
border-radius: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -22,6 +22,7 @@ interface ExportOptions {
|
|||||||
exportEmojis: boolean
|
exportEmojis: boolean
|
||||||
exportVoiceAsText: boolean
|
exportVoiceAsText: boolean
|
||||||
excelCompactColumns: boolean
|
excelCompactColumns: boolean
|
||||||
|
txtColumns: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ExportResult {
|
interface ExportResult {
|
||||||
@@ -34,6 +35,7 @@ interface ExportResult {
|
|||||||
type SessionLayout = 'shared' | 'per-session'
|
type SessionLayout = 'shared' | 'per-session'
|
||||||
|
|
||||||
function ExportPage() {
|
function ExportPage() {
|
||||||
|
const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content']
|
||||||
const [sessions, setSessions] = useState<ChatSession[]>([])
|
const [sessions, setSessions] = useState<ChatSession[]>([])
|
||||||
const [filteredSessions, setFilteredSessions] = useState<ChatSession[]>([])
|
const [filteredSessions, setFilteredSessions] = useState<ChatSession[]>([])
|
||||||
const [selectedSessions, setSelectedSessions] = useState<Set<string>>(new Set())
|
const [selectedSessions, setSelectedSessions] = useState<Set<string>>(new Set())
|
||||||
@@ -61,7 +63,8 @@ function ExportPage() {
|
|||||||
exportVoices: true,
|
exportVoices: true,
|
||||||
exportEmojis: true,
|
exportEmojis: true,
|
||||||
exportVoiceAsText: true,
|
exportVoiceAsText: true,
|
||||||
excelCompactColumns: true
|
excelCompactColumns: true,
|
||||||
|
txtColumns: defaultTxtColumns
|
||||||
})
|
})
|
||||||
|
|
||||||
const buildDateRangeFromPreset = (preset: string) => {
|
const buildDateRangeFromPreset = (preset: string) => {
|
||||||
@@ -125,17 +128,20 @@ function ExportPage() {
|
|||||||
savedRange,
|
savedRange,
|
||||||
savedMedia,
|
savedMedia,
|
||||||
savedVoiceAsText,
|
savedVoiceAsText,
|
||||||
savedExcelCompactColumns
|
savedExcelCompactColumns,
|
||||||
|
savedTxtColumns
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
configService.getExportDefaultFormat(),
|
configService.getExportDefaultFormat(),
|
||||||
configService.getExportDefaultDateRange(),
|
configService.getExportDefaultDateRange(),
|
||||||
configService.getExportDefaultMedia(),
|
configService.getExportDefaultMedia(),
|
||||||
configService.getExportDefaultVoiceAsText(),
|
configService.getExportDefaultVoiceAsText(),
|
||||||
configService.getExportDefaultExcelCompactColumns()
|
configService.getExportDefaultExcelCompactColumns(),
|
||||||
|
configService.getExportDefaultTxtColumns()
|
||||||
])
|
])
|
||||||
|
|
||||||
const preset = savedRange || 'today'
|
const preset = savedRange || 'today'
|
||||||
const rangeDefaults = buildDateRangeFromPreset(preset)
|
const rangeDefaults = buildDateRangeFromPreset(preset)
|
||||||
|
const txtColumns = savedTxtColumns && savedTxtColumns.length > 0 ? savedTxtColumns : defaultTxtColumns
|
||||||
|
|
||||||
setOptions((prev) => ({
|
setOptions((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -144,7 +150,8 @@ function ExportPage() {
|
|||||||
dateRange: rangeDefaults.dateRange,
|
dateRange: rangeDefaults.dateRange,
|
||||||
exportMedia: savedMedia ?? false,
|
exportMedia: savedMedia ?? false,
|
||||||
exportVoiceAsText: savedVoiceAsText ?? true,
|
exportVoiceAsText: savedVoiceAsText ?? true,
|
||||||
excelCompactColumns: savedExcelCompactColumns ?? true
|
excelCompactColumns: savedExcelCompactColumns ?? true,
|
||||||
|
txtColumns
|
||||||
}))
|
}))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('加载导出默认设置失败:', e)
|
console.error('加载导出默认设置失败:', e)
|
||||||
@@ -222,6 +229,23 @@ function ExportPage() {
|
|||||||
return date.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' })
|
return date.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleFormatChange = (format: ExportOptions['format']) => {
|
||||||
|
setOptions((prev) => {
|
||||||
|
const next = { ...prev, format }
|
||||||
|
if (format === 'html') {
|
||||||
|
return {
|
||||||
|
...next,
|
||||||
|
exportMedia: true,
|
||||||
|
exportImages: true,
|
||||||
|
exportVoices: true,
|
||||||
|
exportEmojis: true,
|
||||||
|
exportVoiceAsText: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const openExportFolder = async () => {
|
const openExportFolder = async () => {
|
||||||
if (exportFolder) {
|
if (exportFolder) {
|
||||||
await window.electronAPI.shell.openPath(exportFolder)
|
await window.electronAPI.shell.openPath(exportFolder)
|
||||||
@@ -246,6 +270,7 @@ function ExportPage() {
|
|||||||
exportEmojis: options.exportMedia && options.exportEmojis,
|
exportEmojis: options.exportMedia && options.exportEmojis,
|
||||||
exportVoiceAsText: options.exportVoiceAsText, // 即使不导出媒体,也可以导出语音转文字内容
|
exportVoiceAsText: options.exportVoiceAsText, // 即使不导出媒体,也可以导出语音转文字内容
|
||||||
excelCompactColumns: options.excelCompactColumns,
|
excelCompactColumns: options.excelCompactColumns,
|
||||||
|
txtColumns: options.txtColumns,
|
||||||
sessionLayout,
|
sessionLayout,
|
||||||
dateRange: options.useAllTime ? null : options.dateRange ? {
|
dateRange: options.useAllTime ? null : options.dateRange ? {
|
||||||
start: Math.floor(options.dateRange.start.getTime() / 1000),
|
start: Math.floor(options.dateRange.start.getTime() / 1000),
|
||||||
@@ -254,7 +279,7 @@ function ExportPage() {
|
|||||||
} : null
|
} : null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.format === 'chatlab' || options.format === 'chatlab-jsonl' || options.format === 'json' || options.format === 'excel') {
|
if (options.format === 'chatlab' || options.format === 'chatlab-jsonl' || options.format === 'json' || options.format === 'excel' || options.format === 'txt' || options.format === 'html') {
|
||||||
const result = await window.electronAPI.export.exportSessions(
|
const result = await window.electronAPI.export.exportSessions(
|
||||||
sessionList,
|
sessionList,
|
||||||
exportFolder,
|
exportFolder,
|
||||||
@@ -460,7 +485,7 @@ function ExportPage() {
|
|||||||
<div
|
<div
|
||||||
key={fmt.value}
|
key={fmt.value}
|
||||||
className={`format-card ${options.format === fmt.value ? 'active' : ''}`}
|
className={`format-card ${options.format === fmt.value ? 'active' : ''}`}
|
||||||
onClick={() => setOptions({ ...options, format: fmt.value as any })}
|
onClick={() => handleFormatChange(fmt.value as ExportOptions['format'])}
|
||||||
>
|
>
|
||||||
<fmt.icon size={24} />
|
<fmt.icon size={24} />
|
||||||
<span className="format-label">{fmt.label}</span>
|
<span className="format-label">{fmt.label}</span>
|
||||||
|
|||||||
@@ -174,6 +174,7 @@ function SettingsPage() {
|
|||||||
await configService.setTranscribeLanguages(defaultLanguages)
|
await configService.setTranscribeLanguages(defaultLanguages)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if (savedWhisperModelDir) setWhisperModelDir(savedWhisperModelDir)
|
if (savedWhisperModelDir) setWhisperModelDir(savedWhisperModelDir)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('加载配置失败:', e)
|
console.error('加载配置失败:', e)
|
||||||
@@ -1151,6 +1152,7 @@ function SettingsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1302,4 +1304,3 @@ function SettingsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default SettingsPage
|
export default SettingsPage
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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 = useCallback(async () => {
|
const loadContacts = useCallback(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,7 +239,21 @@ 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()
|
||||||
}, [loadContacts])
|
}, [loadContacts])
|
||||||
|
|
||||||
@@ -193,13 +273,33 @@ export default function SnsPage() {
|
|||||||
}, [loadContacts, loadPosts])
|
}, [loadContacts, loadPosts])
|
||||||
|
|
||||||
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' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,6 +317,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)
|
||||||
@@ -229,8 +334,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 =>
|
||||||
@@ -238,89 +342,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>
|
||||||
|
|
||||||
@@ -328,109 +465,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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ export const CONFIG_KEYS = {
|
|||||||
EXPORT_DEFAULT_DATE_RANGE: 'exportDefaultDateRange',
|
EXPORT_DEFAULT_DATE_RANGE: 'exportDefaultDateRange',
|
||||||
EXPORT_DEFAULT_MEDIA: 'exportDefaultMedia',
|
EXPORT_DEFAULT_MEDIA: 'exportDefaultMedia',
|
||||||
EXPORT_DEFAULT_VOICE_AS_TEXT: 'exportDefaultVoiceAsText',
|
EXPORT_DEFAULT_VOICE_AS_TEXT: 'exportDefaultVoiceAsText',
|
||||||
EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS: 'exportDefaultExcelCompactColumns'
|
EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS: 'exportDefaultExcelCompactColumns',
|
||||||
|
EXPORT_DEFAULT_TXT_COLUMNS: 'exportDefaultTxtColumns'
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export interface WxidConfig {
|
export interface WxidConfig {
|
||||||
@@ -340,3 +341,14 @@ export async function getExportDefaultExcelCompactColumns(): Promise<boolean | n
|
|||||||
export async function setExportDefaultExcelCompactColumns(enabled: boolean): Promise<void> {
|
export async function setExportDefaultExcelCompactColumns(enabled: boolean): Promise<void> {
|
||||||
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS, enabled)
|
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS, enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取导出默认 TXT 列配置
|
||||||
|
export async function getExportDefaultTxtColumns(): Promise<string[] | null> {
|
||||||
|
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_TXT_COLUMNS)
|
||||||
|
return Array.isArray(value) ? (value as string[]) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置导出默认 TXT 列配置
|
||||||
|
export async function setExportDefaultTxtColumns(columns: string[]): Promise<void> {
|
||||||
|
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_TXT_COLUMNS, columns)
|
||||||
|
}
|
||||||
|
|||||||
2
src/types/electron.d.ts
vendored
2
src/types/electron.d.ts
vendored
@@ -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: {
|
||||||
@@ -351,6 +352,7 @@ export interface ExportOptions {
|
|||||||
exportEmojis?: boolean
|
exportEmojis?: boolean
|
||||||
exportVoiceAsText?: boolean
|
exportVoiceAsText?: boolean
|
||||||
excelCompactColumns?: boolean
|
excelCompactColumns?: boolean
|
||||||
|
txtColumns?: string[]
|
||||||
sessionLayout?: 'shared' | 'per-session'
|
sessionLayout?: 'shared' | 'per-session'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user