mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 15:25:50 +00:00
Compare commits
64 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32cdbece2c | ||
|
|
6e7e994cc6 | ||
|
|
d95040ffaf | ||
|
|
129dfbe1b6 | ||
|
|
f8afce6bfa | ||
|
|
0423f23b9c | ||
|
|
e3655631bb | ||
|
|
945802f772 | ||
|
|
be4d9b510d | ||
|
|
0853e049c8 | ||
|
|
dc12df0fcf | ||
|
|
82ba0344b9 | ||
|
|
e8babd48b6 | ||
|
|
7c0ed66dad | ||
|
|
9402483d87 | ||
|
|
650de55202 | ||
|
|
af99ab2029 | ||
|
|
87a2675236 | ||
|
|
25f1256baa | ||
|
|
f83a37e714 | ||
|
|
1aecfb369b | ||
|
|
436b090e26 | ||
|
|
c0f2620542 | ||
|
|
72e2d82158 | ||
|
|
095c8f0db6 | ||
|
|
afa3e089b1 | ||
|
|
11969ea2d4 | ||
|
|
6707be2200 | ||
|
|
f97e102dbd | ||
|
|
3637864f9a | ||
|
|
6eabd707f8 | ||
|
|
b96a47fe29 | ||
|
|
b7eb19aad6 | ||
|
|
2e41a03c96 | ||
|
|
3151f79ee7 | ||
|
|
e7c93ea2f7 | ||
|
|
f09ab1bbcc | ||
|
|
e6a0726b8d | ||
|
|
cada002587 | ||
|
|
38e87b8cbf | ||
|
|
bd94ba7b1a | ||
|
|
756ee03aa0 | ||
|
|
76aa875085 | ||
|
|
16fa8510e6 | ||
|
|
b587e6bd6f | ||
|
|
13cc3751b5 | ||
|
|
ba65c5f3ad | ||
|
|
cfd7635323 | ||
|
|
895249940c | ||
|
|
6b85d8a5f1 | ||
|
|
5c1773efac | ||
|
|
fa783159ff | ||
|
|
e85254bf98 | ||
|
|
e5f57c7359 | ||
|
|
4cbce8c38f | ||
|
|
d111513346 | ||
|
|
e2d34fc530 | ||
|
|
a1d11e4132 | ||
|
|
ac95c99541 | ||
|
|
654eb40740 | ||
|
|
bd3e9a63b7 | ||
|
|
bc9ef140f5 | ||
|
|
f864189407 | ||
|
|
f321c465d5 |
67
.github/workflows/release.yml
vendored
67
.github/workflows/release.yml
vendored
@@ -25,32 +25,73 @@ jobs:
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
run: npm ci
|
||||
|
||||
- name: Sync version with tag
|
||||
shell: bash
|
||||
run: |
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
echo "Syncing package.json version to $VERSION"
|
||||
npm version $VERSION --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Build Frontend & Type Check
|
||||
run: |
|
||||
npx tsc
|
||||
npx vite build
|
||||
|
||||
- name: Create Changelog Config
|
||||
shell: bash
|
||||
run: |
|
||||
cat <<EOF > changelog_config.json
|
||||
{
|
||||
"template": "# ${{ github.ref_name }} 更新日志\n\n{{CHANGELOG}}\n\n---\n> 此更新由系统自动构建",
|
||||
"categories": [
|
||||
{
|
||||
"title": "## 新功能",
|
||||
"filter": { "pattern": "^feat", "flags": "i" }
|
||||
},
|
||||
{
|
||||
"title": "## 修复",
|
||||
"filter": { "pattern": "^fix", "flags": "i" }
|
||||
},
|
||||
{
|
||||
"title": "## 性能与维护",
|
||||
"filter": { "pattern": "^(chore|docs|perf|refactor|ci|style|test)", "flags": "i" }
|
||||
},
|
||||
{
|
||||
"title": "## 其他改动",
|
||||
"filter": { "pattern": ".*", "flags": "i" }
|
||||
}
|
||||
],
|
||||
"ignore_labels": [],
|
||||
"commitMode": true,
|
||||
"empty_summary": "## 更新详情\n- 常规代码优化与维护"
|
||||
}
|
||||
EOF
|
||||
|
||||
- name: Build Changelog
|
||||
id: build_changelog
|
||||
uses: mikepenz/release-changelog-builder-action@v4
|
||||
uses: mikepenz/release-changelog-builder-action@v5
|
||||
with:
|
||||
configuration: "changelog_config.json"
|
||||
outputFile: "release-notes.md"
|
||||
configurationJson: |
|
||||
{
|
||||
"categories": [
|
||||
{ "title": "## 🚀 Features", "labels": ["feat", "feature"] },
|
||||
{ "title": "## 🐛 Fixes", "labels": ["fix", "bug"] },
|
||||
{ "title": "## 🧰 Maintenance", "labels": ["chore", "refactor", "docs", "perf"] }
|
||||
],
|
||||
"template": "# Release Notes\n\n{{CHANGELOG}}"
|
||||
}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Check Changelog Content
|
||||
shell: bash
|
||||
run: |
|
||||
echo "=== RELEASE NOTES START ==="
|
||||
cat release-notes.md
|
||||
echo "=== RELEASE NOTES END ==="
|
||||
|
||||
- name: Inject Configuration
|
||||
shell: bash
|
||||
run: |
|
||||
npm pkg set build.releaseInfo.releaseNotesFile=release-notes.md
|
||||
|
||||
- name: Package and Publish
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
run: npx electron-builder --publish always "-c.releaseInfo.releaseNotesFile=release-notes.md"
|
||||
run: |
|
||||
npx electron-builder --publish always
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -13,6 +13,7 @@ dist
|
||||
dist-electron
|
||||
dist-ssr
|
||||
*.local
|
||||
test/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
@@ -42,6 +43,10 @@ release
|
||||
# OS
|
||||
Thumbs.db
|
||||
|
||||
# Electron dev cache
|
||||
.electron/
|
||||
.cache/
|
||||
|
||||
|
||||
|
||||
# 忽略 Visual Studio 临时文件夹
|
||||
@@ -51,3 +56,4 @@ Thumbs.db
|
||||
*.aps
|
||||
|
||||
wcdb/
|
||||
*info
|
||||
|
||||
@@ -20,8 +20,8 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
|
||||
<a href="https://github.com/hicccc77/WeFlow/issues">
|
||||
<img src="https://img.shields.io/github/issues/hicccc77/WeFlow?style=flat-square" alt="Issues">
|
||||
</a>
|
||||
<a href="https://github.com/hicccc77/WeFlow/blob/main/LICENSE">
|
||||
<img src="https://img.shields.io/github/license/hicccc77/WeFlow?style=flat-square" alt="License">
|
||||
<a href="https://t.me/+hn3QzNc4DbA0MzNl">
|
||||
<img src="https://img.shields.io/badge/Telegram%20交流群-点击加入-0088cc?style=flat-square&logo=telegram&logoColor=0088cc&labelColor=white" alt="Telegram">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -92,7 +92,7 @@ WeFlow/
|
||||
|
||||
## 致谢
|
||||
|
||||
- [miyu](https://github.com/ILoveBingLu/miyu) 为本项目提供了基础框架
|
||||
- [密语 CipherTalk](https://github.com/ILoveBingLu/miyu) 为本项目提供了基础框架
|
||||
|
||||
|
||||
## Star History
|
||||
|
||||
@@ -62,6 +62,12 @@ function isThumbnailDat(fileName: string): boolean {
|
||||
return fileName.includes('.t.dat') || fileName.includes('_t.dat')
|
||||
}
|
||||
|
||||
function isHdDat(fileName: string): boolean {
|
||||
const lower = fileName.toLowerCase()
|
||||
const base = lower.endsWith('.dat') ? lower.slice(0, -4) : lower
|
||||
return base.endsWith('_hd') || base.endsWith('_h')
|
||||
}
|
||||
|
||||
function walkForDat(
|
||||
root: string,
|
||||
datName: string,
|
||||
@@ -101,6 +107,8 @@ function walkForDat(
|
||||
if (!isLikelyImageDatBase(baseLower)) continue
|
||||
if (!hasXVariant(baseLower)) continue
|
||||
if (!matchesDatName(lower, datName)) continue
|
||||
// 排除高清图片格式 (_hd, _h)
|
||||
if (isHdDat(lower)) continue
|
||||
matchedBases.add(baseLower)
|
||||
const isThumb = isThumbnailDat(lower)
|
||||
if (!allowThumbnail && isThumb) continue
|
||||
|
||||
@@ -15,6 +15,8 @@ import { groupAnalyticsService } from './services/groupAnalyticsService'
|
||||
import { annualReportService } from './services/annualReportService'
|
||||
import { exportService, ExportOptions } from './services/exportService'
|
||||
import { KeyService } from './services/keyService'
|
||||
import { voiceTranscribeService } from './services/voiceTranscribeService'
|
||||
|
||||
|
||||
// 配置自动更新
|
||||
autoUpdater.autoDownload = false
|
||||
@@ -381,6 +383,8 @@ function registerIpcHandlers() {
|
||||
return true
|
||||
})
|
||||
|
||||
|
||||
|
||||
// 聊天相关
|
||||
ipcMain.handle('chat:connect', async () => {
|
||||
return chatService.connect()
|
||||
@@ -390,6 +394,10 @@ function registerIpcHandlers() {
|
||||
return chatService.getSessions()
|
||||
})
|
||||
|
||||
ipcMain.handle('chat:enrichSessionsContactInfo', async (_, usernames: string[]) => {
|
||||
return chatService.enrichSessionsContactInfo(usernames)
|
||||
})
|
||||
|
||||
ipcMain.handle('chat:getMessages', async (_, sessionId: string, offset?: number, limit?: number) => {
|
||||
return chatService.getMessages(sessionId, offset, limit)
|
||||
})
|
||||
@@ -406,6 +414,10 @@ function registerIpcHandlers() {
|
||||
return chatService.getContactAvatar(username)
|
||||
})
|
||||
|
||||
ipcMain.handle('chat:getCachedMessages', async (_, sessionId: string) => {
|
||||
return chatService.getCachedSessionMessages(sessionId)
|
||||
})
|
||||
|
||||
ipcMain.handle('chat:getMyAvatarUrl', async () => {
|
||||
return chatService.getMyAvatarUrl()
|
||||
})
|
||||
@@ -427,14 +439,26 @@ function registerIpcHandlers() {
|
||||
return chatService.getImageData(sessionId, msgId)
|
||||
})
|
||||
|
||||
ipcMain.handle('chat:getVoiceData', async (_, sessionId: string, msgId: string) => {
|
||||
return chatService.getVoiceData(sessionId, msgId)
|
||||
ipcMain.handle('chat:getVoiceData', async (_, sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => {
|
||||
return chatService.getVoiceData(sessionId, msgId, createTime, serverId)
|
||||
})
|
||||
ipcMain.handle('chat:resolveVoiceCache', async (_, sessionId: string, msgId: string) => {
|
||||
return chatService.resolveVoiceCache(sessionId, msgId)
|
||||
})
|
||||
|
||||
ipcMain.handle('chat:getVoiceTranscript', async (event, sessionId: string, msgId: string) => {
|
||||
return chatService.getVoiceTranscript(sessionId, msgId, (text) => {
|
||||
event.sender.send('chat:voiceTranscriptPartial', { msgId, text })
|
||||
})
|
||||
})
|
||||
|
||||
ipcMain.handle('chat:getMessageById', async (_, sessionId: string, localId: number) => {
|
||||
return chatService.getMessageById(sessionId, localId)
|
||||
})
|
||||
|
||||
// 私聊克隆
|
||||
|
||||
|
||||
ipcMain.handle('image:decrypt', async (_, payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => {
|
||||
return imageDecryptService.decryptImage(payload)
|
||||
})
|
||||
@@ -456,8 +480,8 @@ function registerIpcHandlers() {
|
||||
})
|
||||
|
||||
// 数据分析相关
|
||||
ipcMain.handle('analytics:getOverallStatistics', async () => {
|
||||
return analyticsService.getOverallStatistics()
|
||||
ipcMain.handle('analytics:getOverallStatistics', async (_, force?: boolean) => {
|
||||
return analyticsService.getOverallStatistics(force)
|
||||
})
|
||||
|
||||
ipcMain.handle('analytics:getContactRankings', async (_, limit?: number) => {
|
||||
@@ -468,6 +492,50 @@ function registerIpcHandlers() {
|
||||
return analyticsService.getTimeDistribution()
|
||||
})
|
||||
|
||||
// 缓存管理
|
||||
ipcMain.handle('cache:clearAnalytics', async () => {
|
||||
return analyticsService.clearCache()
|
||||
})
|
||||
|
||||
ipcMain.handle('cache:clearImages', async () => {
|
||||
const imageResult = await imageDecryptService.clearCache()
|
||||
const emojiResult = chatService.clearCaches({ includeMessages: false, includeContacts: false, includeEmojis: true })
|
||||
const errors = [imageResult, emojiResult]
|
||||
.filter((result) => !result.success)
|
||||
.map((result) => result.error)
|
||||
.filter(Boolean) as string[]
|
||||
if (errors.length > 0) {
|
||||
return { success: false, error: errors.join('; ') }
|
||||
}
|
||||
return { success: true }
|
||||
})
|
||||
|
||||
ipcMain.handle('cache:clearAll', async () => {
|
||||
const [analyticsResult, imageResult] = await Promise.all([
|
||||
analyticsService.clearCache(),
|
||||
imageDecryptService.clearCache()
|
||||
])
|
||||
const chatResult = chatService.clearCaches()
|
||||
const errors = [analyticsResult, imageResult, chatResult]
|
||||
.filter((result) => !result.success)
|
||||
.map((result) => result.error)
|
||||
.filter(Boolean) as string[]
|
||||
if (errors.length > 0) {
|
||||
return { success: false, error: errors.join('; ') }
|
||||
}
|
||||
return { success: true }
|
||||
})
|
||||
|
||||
ipcMain.handle('whisper:downloadModel', async (event) => {
|
||||
return voiceTranscribeService.downloadModel((progress) => {
|
||||
event.sender.send('whisper:downloadProgress', progress)
|
||||
})
|
||||
})
|
||||
|
||||
ipcMain.handle('whisper:getModelStatus', async () => {
|
||||
return voiceTranscribeService.getModelStatus()
|
||||
})
|
||||
|
||||
// 群聊分析相关
|
||||
ipcMain.handle('groupAnalytics:getGroupChats', async () => {
|
||||
return groupAnalyticsService.getGroupChats()
|
||||
@@ -671,9 +739,11 @@ function checkForUpdatesOnStartup() {
|
||||
|
||||
app.whenReady().then(() => {
|
||||
configService = new ConfigService()
|
||||
const resourcesPath = app.isPackaged
|
||||
const candidateResources = app.isPackaged
|
||||
? join(process.resourcesPath, 'resources')
|
||||
: join(app.getAppPath(), 'resources')
|
||||
const fallbackResources = join(process.cwd(), 'resources')
|
||||
const resourcesPath = existsSync(candidateResources) ? candidateResources : fallbackResources
|
||||
const userDataPath = app.getPath('userData')
|
||||
wcdbService.setPaths(resourcesPath, userDataPath)
|
||||
wcdbService.setLogEnabled(configService.get('logEnabled') === true)
|
||||
|
||||
@@ -69,7 +69,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
ipcRenderer.invoke('wcdb:testConnection', dbPath, hexKey, wxid),
|
||||
open: (dbPath: string, hexKey: string, wxid: string) =>
|
||||
ipcRenderer.invoke('wcdb:open', dbPath, hexKey, wxid),
|
||||
close: () => ipcRenderer.invoke('wcdb:close')
|
||||
close: () => ipcRenderer.invoke('wcdb:close'),
|
||||
|
||||
},
|
||||
|
||||
// 密钥获取
|
||||
@@ -91,6 +92,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
chat: {
|
||||
connect: () => ipcRenderer.invoke('chat:connect'),
|
||||
getSessions: () => ipcRenderer.invoke('chat:getSessions'),
|
||||
enrichSessionsContactInfo: (usernames: string[]) =>
|
||||
ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames),
|
||||
getMessages: (sessionId: string, offset?: number, limit?: number) =>
|
||||
ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit),
|
||||
getLatestMessages: (sessionId: string, limit?: number) =>
|
||||
@@ -99,12 +102,23 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
getContactAvatar: (username: string) => ipcRenderer.invoke('chat:getContactAvatar', username),
|
||||
getMyAvatarUrl: () => ipcRenderer.invoke('chat:getMyAvatarUrl'),
|
||||
downloadEmoji: (cdnUrl: string, md5?: string) => ipcRenderer.invoke('chat:downloadEmoji', cdnUrl, md5),
|
||||
getCachedMessages: (sessionId: string) => ipcRenderer.invoke('chat:getCachedMessages', sessionId),
|
||||
close: () => ipcRenderer.invoke('chat:close'),
|
||||
getSessionDetail: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetail', sessionId),
|
||||
getImageData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getImageData', sessionId, msgId),
|
||||
getVoiceData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId)
|
||||
getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) =>
|
||||
ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId, createTime, serverId),
|
||||
resolveVoiceCache: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:resolveVoiceCache', sessionId, msgId),
|
||||
getVoiceTranscript: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getVoiceTranscript', sessionId, msgId),
|
||||
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => {
|
||||
const listener = (_: any, payload: { msgId: string; text: string }) => callback(payload)
|
||||
ipcRenderer.on('chat:voiceTranscriptPartial', listener)
|
||||
return () => ipcRenderer.removeListener('chat:voiceTranscriptPartial', listener)
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
|
||||
// 图片解密
|
||||
image: {
|
||||
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) =>
|
||||
@@ -134,6 +148,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
}
|
||||
},
|
||||
|
||||
// 缓存管理
|
||||
cache: {
|
||||
clearAnalytics: () => ipcRenderer.invoke('cache:clearAnalytics'),
|
||||
clearImages: () => ipcRenderer.invoke('cache:clearImages'),
|
||||
clearAll: () => ipcRenderer.invoke('cache:clearAll')
|
||||
},
|
||||
|
||||
// 群聊分析
|
||||
groupAnalytics: {
|
||||
getGroupChats: () => ipcRenderer.invoke('groupAnalytics:getGroupChats'),
|
||||
@@ -161,5 +182,16 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options),
|
||||
exportSession: (sessionId: string, outputPath: string, options: any) =>
|
||||
ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options)
|
||||
},
|
||||
|
||||
whisper: {
|
||||
downloadModel: () =>
|
||||
ipcRenderer.invoke('whisper:downloadModel'),
|
||||
getModelStatus: () =>
|
||||
ipcRenderer.invoke('whisper:getModelStatus'),
|
||||
onDownloadProgress: (callback: (payload: { modelName: string; downloadedBytes: number; totalBytes?: number; percent?: number }) => void) => {
|
||||
ipcRenderer.on('whisper:downloadProgress', (_, payload) => callback(payload))
|
||||
return () => ipcRenderer.removeAllListeners('whisper:downloadProgress')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { ConfigService } from './config'
|
||||
import { wcdbService } from './wcdbService'
|
||||
import { join } from 'path'
|
||||
import { readFile, writeFile, rm } from 'fs/promises'
|
||||
import { app } from 'electron'
|
||||
|
||||
export interface ChatStatistics {
|
||||
totalMessages: number
|
||||
@@ -253,15 +256,31 @@ class AnalyticsService {
|
||||
sessionIds: string[],
|
||||
beginTimestamp = 0,
|
||||
endTimestamp = 0,
|
||||
window?: any
|
||||
window?: any,
|
||||
force = false
|
||||
): Promise<{ success: boolean; data?: any; source?: string; error?: string }> {
|
||||
const cacheKey = this.buildAggregateCacheKey(sessionIds, beginTimestamp, endTimestamp)
|
||||
if (this.aggregateCache && this.aggregateCache.key === cacheKey) {
|
||||
|
||||
if (force) {
|
||||
if (this.aggregateCache) this.aggregateCache = null
|
||||
if (this.fallbackAggregateCache) this.fallbackAggregateCache = null
|
||||
}
|
||||
|
||||
if (!force && this.aggregateCache && this.aggregateCache.key === cacheKey) {
|
||||
if (Date.now() - this.aggregateCache.updatedAt < 5 * 60 * 1000) {
|
||||
return { success: true, data: this.aggregateCache.data, source: 'cache' }
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试从文件加载缓存
|
||||
if (!force) {
|
||||
const fileCache = await this.loadCacheFromFile()
|
||||
if (fileCache && fileCache.key === cacheKey) {
|
||||
this.aggregateCache = fileCache
|
||||
return { success: true, data: fileCache.data, source: 'file-cache' }
|
||||
}
|
||||
}
|
||||
|
||||
if (this.aggregatePromise && this.aggregatePromise.key === cacheKey) {
|
||||
return this.aggregatePromise.promise
|
||||
}
|
||||
@@ -291,7 +310,12 @@ class AnalyticsService {
|
||||
|
||||
this.aggregatePromise = { key: cacheKey, promise }
|
||||
try {
|
||||
return await promise
|
||||
const result = await promise
|
||||
// 如果计算成功,同时写入此文件缓存
|
||||
if (result.success && result.data && result.source !== 'cache') {
|
||||
this.saveCacheToFile({ key: cacheKey, data: this.aggregateCache?.data, updatedAt: Date.now() })
|
||||
}
|
||||
return result
|
||||
} finally {
|
||||
if (this.aggregatePromise && this.aggregatePromise.key === cacheKey) {
|
||||
this.aggregatePromise = null
|
||||
@@ -299,6 +323,25 @@ class AnalyticsService {
|
||||
}
|
||||
}
|
||||
|
||||
private getCacheFilePath(): string {
|
||||
return join(app.getPath('documents'), 'WeFlow', 'analytics_cache.json')
|
||||
}
|
||||
|
||||
private async loadCacheFromFile(): Promise<{ key: string; data: any; updatedAt: number } | null> {
|
||||
try {
|
||||
const raw = await readFile(this.getCacheFilePath(), 'utf-8')
|
||||
return JSON.parse(raw)
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
private async saveCacheToFile(data: any) {
|
||||
try {
|
||||
await writeFile(this.getCacheFilePath(), JSON.stringify(data))
|
||||
} catch (e) {
|
||||
console.error('保存统计缓存失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeAggregateSessions(
|
||||
sessions: Record<string, any> | undefined,
|
||||
idMap: Record<string, string> | undefined
|
||||
@@ -326,7 +369,7 @@ class AnalyticsService {
|
||||
void results
|
||||
}
|
||||
|
||||
async getOverallStatistics(): Promise<{ success: boolean; data?: ChatStatistics; error?: string }> {
|
||||
async getOverallStatistics(force = false): Promise<{ success: boolean; data?: ChatStatistics; error?: string }> {
|
||||
try {
|
||||
const conn = await this.ensureConnected()
|
||||
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
|
||||
@@ -340,7 +383,7 @@ class AnalyticsService {
|
||||
const win = BrowserWindow.getAllWindows()[0]
|
||||
this.setProgress(win, '正在执行原生数据聚合...', 30)
|
||||
|
||||
const result = await this.getAggregateWithFallback(sessionInfo.usernames, 0, 0, win)
|
||||
const result = await this.getAggregateWithFallback(sessionInfo.usernames, 0, 0, win, force)
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
return { success: false, error: result.error || '聚合统计失败' }
|
||||
@@ -458,8 +501,8 @@ class AnalyticsService {
|
||||
|
||||
const d = result.data
|
||||
|
||||
// SQLite strftime('%w') 返回 0=Sun, 1=Mon...6=Sat
|
||||
// 前端期望 1=Mon...7=Sun
|
||||
// SQLite strftime('%w') 返回 0=周日, 1=周一...6=周六
|
||||
// 前端期望 1=周一...7=周日
|
||||
const weekdayDistribution: Record<number, number> = {}
|
||||
for (const [w, count] of Object.entries(d.weekday)) {
|
||||
const sqliteW = parseInt(w, 10)
|
||||
@@ -485,6 +528,18 @@ class AnalyticsService {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
async clearCache(): Promise<{ success: boolean; error?: string }> {
|
||||
this.aggregateCache = null
|
||||
this.fallbackAggregateCache = null
|
||||
this.aggregatePromise = null
|
||||
try {
|
||||
await rm(this.getCacheFilePath(), { force: true })
|
||||
return { success: true }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const analyticsService = new AnalyticsService()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,12 @@ interface ConfigSchema {
|
||||
themeId: string
|
||||
language: string
|
||||
logEnabled: boolean
|
||||
llmModelPath: string
|
||||
whisperModelName: string
|
||||
whisperModelDir: string
|
||||
whisperDownloadSource: string
|
||||
autoTranscribeVoice: boolean
|
||||
transcribeLanguages: string[]
|
||||
}
|
||||
|
||||
export class ConfigService {
|
||||
@@ -40,7 +46,13 @@ export class ConfigService {
|
||||
theme: 'system',
|
||||
themeId: 'cloud-dancer',
|
||||
language: 'zh-CN',
|
||||
logEnabled: false
|
||||
logEnabled: false,
|
||||
llmModelPath: '',
|
||||
whisperModelName: 'base',
|
||||
whisperModelDir: '',
|
||||
whisperDownloadSource: 'tsinghua',
|
||||
autoTranscribeVoice: false,
|
||||
transcribeLanguages: ['zh']
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
84
electron/services/contactCacheService.ts
Normal file
84
electron/services/contactCacheService.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { join, dirname } from 'path'
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
|
||||
import { app } from 'electron'
|
||||
|
||||
export interface ContactCacheEntry {
|
||||
displayName?: string
|
||||
avatarUrl?: string
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export class ContactCacheService {
|
||||
private readonly cacheFilePath: string
|
||||
private cache: Record<string, ContactCacheEntry> = {}
|
||||
|
||||
constructor(cacheBasePath?: string) {
|
||||
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
|
||||
? cacheBasePath
|
||||
: join(app.getPath('documents'), 'WeFlow')
|
||||
this.cacheFilePath = join(basePath, 'contacts.json')
|
||||
this.ensureCacheDir()
|
||||
this.loadCache()
|
||||
}
|
||||
|
||||
private ensureCacheDir() {
|
||||
const dir = dirname(this.cacheFilePath)
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
private loadCache() {
|
||||
if (!existsSync(this.cacheFilePath)) return
|
||||
try {
|
||||
const raw = readFileSync(this.cacheFilePath, 'utf8')
|
||||
const parsed = JSON.parse(raw)
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
this.cache = parsed
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('ContactCacheService: 载入缓存失败', error)
|
||||
this.cache = {}
|
||||
}
|
||||
}
|
||||
|
||||
get(username: string): ContactCacheEntry | undefined {
|
||||
return this.cache[username]
|
||||
}
|
||||
|
||||
getAllEntries(): Record<string, ContactCacheEntry> {
|
||||
return { ...this.cache }
|
||||
}
|
||||
|
||||
setEntries(entries: Record<string, ContactCacheEntry>): void {
|
||||
if (Object.keys(entries).length === 0) return
|
||||
let changed = false
|
||||
for (const [username, entry] of Object.entries(entries)) {
|
||||
const existing = this.cache[username]
|
||||
if (!existing || entry.updatedAt >= existing.updatedAt) {
|
||||
this.cache[username] = entry
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
this.persist()
|
||||
}
|
||||
}
|
||||
|
||||
private persist() {
|
||||
try {
|
||||
writeFileSync(this.cacheFilePath, JSON.stringify(this.cache), 'utf8')
|
||||
} catch (error) {
|
||||
console.error('ContactCacheService: 保存缓存失败', error)
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.cache = {}
|
||||
try {
|
||||
rmSync(this.cacheFilePath, { force: true })
|
||||
} catch (error) {
|
||||
console.error('ContactCacheService: 清理缓存失败', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -181,6 +181,8 @@ class GroupAnalyticsService {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
async getGroupActiveHours(chatroomId: string, startTime?: number, endTime?: number): Promise<{ success: boolean; data?: GroupActiveHours; error?: string }> {
|
||||
try {
|
||||
const conn = await this.ensureConnected()
|
||||
|
||||
@@ -1,13 +1,45 @@
|
||||
import { app, BrowserWindow } from 'electron'
|
||||
import { basename, dirname, extname, join } from 'path'
|
||||
import { pathToFileURL } from 'url'
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from 'fs'
|
||||
import { writeFile } from 'fs/promises'
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, appendFileSync } from 'fs'
|
||||
import { writeFile, rm, readdir } from 'fs/promises'
|
||||
import crypto from 'crypto'
|
||||
import { Worker } from 'worker_threads'
|
||||
import { ConfigService } from './config'
|
||||
import { wcdbService } from './wcdbService'
|
||||
|
||||
// 获取 ffmpeg-static 的路径
|
||||
function getStaticFfmpegPath(): string | null {
|
||||
try {
|
||||
// 方法1: 直接 require ffmpeg-static
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const ffmpegStatic = require('ffmpeg-static')
|
||||
|
||||
if (typeof ffmpegStatic === 'string' && existsSync(ffmpegStatic)) {
|
||||
return ffmpegStatic
|
||||
}
|
||||
|
||||
// 方法2: 手动构建路径(开发环境)
|
||||
const devPath = join(process.cwd(), 'node_modules', 'ffmpeg-static', 'ffmpeg.exe')
|
||||
if (existsSync(devPath)) {
|
||||
return devPath
|
||||
}
|
||||
|
||||
// 方法3: 打包后的路径
|
||||
if (app.isPackaged) {
|
||||
const resourcesPath = process.resourcesPath
|
||||
const packedPath = join(resourcesPath, 'app.asar.unpacked', 'node_modules', 'ffmpeg-static', 'ffmpeg.exe')
|
||||
if (existsSync(packedPath)) {
|
||||
return packedPath
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
type DecryptResult = {
|
||||
success: boolean
|
||||
localPath?: string
|
||||
@@ -32,11 +64,45 @@ export class ImageDecryptService {
|
||||
|
||||
private logInfo(message: string, meta?: Record<string, unknown>): void {
|
||||
if (!this.configService.get('logEnabled')) return
|
||||
const timestamp = new Date().toISOString()
|
||||
const metaStr = meta ? ` ${JSON.stringify(meta)}` : ''
|
||||
const logLine = `[${timestamp}] [ImageDecrypt] ${message}${metaStr}\n`
|
||||
|
||||
// 同时输出到控制台
|
||||
if (meta) {
|
||||
console.info(message, meta)
|
||||
} else {
|
||||
console.info(message)
|
||||
}
|
||||
|
||||
// 写入日志文件
|
||||
this.writeLog(logLine)
|
||||
}
|
||||
|
||||
private logError(message: string, error?: unknown, meta?: Record<string, unknown>): void {
|
||||
if (!this.configService.get('logEnabled')) return
|
||||
const timestamp = new Date().toISOString()
|
||||
const errorStr = error ? ` Error: ${String(error)}` : ''
|
||||
const metaStr = meta ? ` ${JSON.stringify(meta)}` : ''
|
||||
const logLine = `[${timestamp}] [ImageDecrypt] ERROR: ${message}${errorStr}${metaStr}\n`
|
||||
|
||||
// 同时输出到控制台
|
||||
console.error(message, error, meta)
|
||||
|
||||
// 写入日志文件
|
||||
this.writeLog(logLine)
|
||||
}
|
||||
|
||||
private writeLog(line: string): void {
|
||||
try {
|
||||
const logDir = join(app.getPath('userData'), 'logs')
|
||||
if (!existsSync(logDir)) {
|
||||
mkdirSync(logDir, { recursive: true })
|
||||
}
|
||||
appendFileSync(join(logDir, 'wcdb.log'), line, { encoding: 'utf8' })
|
||||
} catch (err) {
|
||||
console.error('写入日志失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async resolveCachedImage(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }): Promise<DecryptResult & { hasUpdate?: boolean }> {
|
||||
@@ -81,6 +147,7 @@ export class ImageDecryptService {
|
||||
return { success: true, localPath: dataUrl || this.filePathToUrl(existing), hasUpdate }
|
||||
}
|
||||
}
|
||||
this.logInfo('未找到缓存', { md5: payload.imageMd5, datName: payload.imageDatName })
|
||||
return { success: false, error: '未找到缓存图片' }
|
||||
}
|
||||
|
||||
@@ -120,15 +187,18 @@ export class ImageDecryptService {
|
||||
payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean },
|
||||
cacheKey: string
|
||||
): Promise<DecryptResult> {
|
||||
this.logInfo('开始解密图片', { md5: payload.imageMd5, datName: payload.imageDatName, force: payload.force })
|
||||
try {
|
||||
const wxid = this.configService.get('myWxid')
|
||||
const dbPath = this.configService.get('dbPath')
|
||||
if (!wxid || !dbPath) {
|
||||
this.logError('配置缺失', undefined, { wxid: !!wxid, dbPath: !!dbPath })
|
||||
return { success: false, error: '未配置账号或数据库路径' }
|
||||
}
|
||||
|
||||
const accountDir = this.resolveAccountDir(dbPath, wxid)
|
||||
if (!accountDir) {
|
||||
this.logError('未找到账号目录', undefined, { dbPath, wxid })
|
||||
return { success: false, error: '未找到账号目录' }
|
||||
}
|
||||
|
||||
@@ -142,12 +212,16 @@ export class ImageDecryptService {
|
||||
|
||||
// 如果要求高清图但没找到,直接返回提示
|
||||
if (!datPath && payload.force) {
|
||||
this.logError('未找到高清图', undefined, { md5: payload.imageMd5, datName: payload.imageDatName })
|
||||
return { success: false, error: '未找到高清图,请在微信中点开该图片查看后重试' }
|
||||
}
|
||||
if (!datPath) {
|
||||
this.logError('未找到DAT文件', undefined, { md5: payload.imageMd5, datName: payload.imageDatName })
|
||||
return { success: false, error: '未找到图片文件' }
|
||||
}
|
||||
|
||||
this.logInfo('找到DAT文件', { datPath })
|
||||
|
||||
if (!extname(datPath).toLowerCase().includes('dat')) {
|
||||
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, datPath)
|
||||
const dataUrl = this.fileToDataUrl(datPath)
|
||||
@@ -160,6 +234,7 @@ export class ImageDecryptService {
|
||||
// 查找已缓存的解密文件
|
||||
const existing = this.findCachedOutput(cacheKey, payload.force, payload.sessionId)
|
||||
if (existing) {
|
||||
this.logInfo('找到已解密文件', { existing, isHd: this.isHdPath(existing) })
|
||||
const isHd = this.isHdPath(existing)
|
||||
// 如果要求高清但找到的是缩略图,继续解密高清图
|
||||
if (!(payload.force && !isHd)) {
|
||||
@@ -192,23 +267,45 @@ export class ImageDecryptService {
|
||||
const aesKeyRaw = this.configService.get('imageAesKey')
|
||||
const aesKey = this.resolveAesKey(aesKeyRaw)
|
||||
|
||||
const decrypted = await this.decryptDatAuto(datPath, xorKey, aesKey)
|
||||
this.logInfo('开始解密DAT文件', { datPath, xorKey, hasAesKey: !!aesKey })
|
||||
let decrypted = await this.decryptDatAuto(datPath, xorKey, aesKey)
|
||||
|
||||
const ext = this.detectImageExtension(decrypted) || '.jpg'
|
||||
// 检查是否是 wxgf 格式,如果是则尝试提取真实图片数据
|
||||
const wxgfResult = await this.unwrapWxgf(decrypted)
|
||||
decrypted = wxgfResult.data
|
||||
|
||||
const outputPath = this.getCacheOutputPathFromDat(datPath, ext, payload.sessionId)
|
||||
let ext = this.detectImageExtension(decrypted)
|
||||
|
||||
// 如果是 wxgf 格式且没检测到扩展名
|
||||
if (wxgfResult.isWxgf && !ext) {
|
||||
ext = '.hevc'
|
||||
}
|
||||
|
||||
const finalExt = ext || '.jpg'
|
||||
|
||||
const outputPath = this.getCacheOutputPathFromDat(datPath, finalExt, payload.sessionId)
|
||||
await writeFile(outputPath, decrypted)
|
||||
this.logInfo('解密成功', { outputPath, size: decrypted.length })
|
||||
|
||||
// 对于 hevc 格式,返回错误提示
|
||||
if (finalExt === '.hevc') {
|
||||
return {
|
||||
success: false,
|
||||
error: '此图片为微信新格式(wxgf),需要安装 ffmpeg 才能显示',
|
||||
isThumb: this.isThumbnailPath(datPath)
|
||||
}
|
||||
}
|
||||
const isThumb = this.isThumbnailPath(datPath)
|
||||
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, outputPath)
|
||||
if (!isThumb) {
|
||||
this.clearUpdateFlags(cacheKey, payload.imageMd5, payload.imageDatName)
|
||||
}
|
||||
const dataUrl = this.bufferToDataUrl(decrypted, ext)
|
||||
const dataUrl = this.bufferToDataUrl(decrypted, finalExt)
|
||||
const localPath = dataUrl || this.filePathToUrl(outputPath)
|
||||
this.emitCacheResolved(payload, cacheKey, localPath)
|
||||
return { success: true, localPath, isThumb }
|
||||
} catch (e) {
|
||||
this.logError('解密失败', e, { md5: payload.imageMd5, datName: payload.imageDatName })
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
@@ -233,7 +330,7 @@ export class ImageDecryptService {
|
||||
if (this.isAccountDir(entryPath)) return entryPath
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
} catch { }
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -474,7 +571,7 @@ export class ImageDecryptService {
|
||||
if (!hasUpdate) return
|
||||
this.updateFlags.set(cacheKey, true)
|
||||
this.emitImageUpdate(payload, cacheKey)
|
||||
}).catch(() => {})
|
||||
}).catch(() => { })
|
||||
}
|
||||
|
||||
private looksLikeMd5(value: string): boolean {
|
||||
@@ -856,6 +953,18 @@ export class ImageDecryptService {
|
||||
extensions: string[],
|
||||
preferHd: boolean
|
||||
): string | null {
|
||||
// 先检查并删除旧的 .hevc 文件(ffmpeg 转换失败时遗留的)
|
||||
const hevcThumb = join(dirPath, `${normalizedKey}_thumb.hevc`)
|
||||
const hevcHd = join(dirPath, `${normalizedKey}_hd.hevc`)
|
||||
try {
|
||||
if (existsSync(hevcThumb)) {
|
||||
require('fs').unlinkSync(hevcThumb)
|
||||
}
|
||||
if (existsSync(hevcHd)) {
|
||||
require('fs').unlinkSync(hevcHd)
|
||||
}
|
||||
} catch { }
|
||||
|
||||
for (const ext of extensions) {
|
||||
if (preferHd) {
|
||||
const hdPath = join(dirPath, `${normalizedKey}_hd${ext}`)
|
||||
@@ -863,6 +972,8 @@ export class ImageDecryptService {
|
||||
}
|
||||
const thumbPath = join(dirPath, `${normalizedKey}_thumb${ext}`)
|
||||
if (existsSync(thumbPath)) return thumbPath
|
||||
|
||||
// 允许返回 _hd 格式(因为它有 _hd 变体后缀)
|
||||
if (!preferHd) {
|
||||
const hdPath = join(dirPath, `${normalizedKey}_hd${ext}`)
|
||||
if (existsSync(hdPath)) return hdPath
|
||||
@@ -960,8 +1071,9 @@ export class ImageDecryptService {
|
||||
const lower = entry.toLowerCase()
|
||||
if (!lower.endsWith('.dat')) continue
|
||||
if (this.isThumbnailDat(lower)) continue
|
||||
if (!this.hasXVariant(lower.slice(0, -4))) continue
|
||||
const baseLower = lower.slice(0, -4)
|
||||
// 只排除没有 _x 变体后缀的文件(允许 _hd、_h 等所有带变体的)
|
||||
if (!this.hasXVariant(baseLower)) continue
|
||||
if (this.normalizeDatBase(baseLower) !== target) continue
|
||||
return join(dirPath, entry)
|
||||
}
|
||||
@@ -973,6 +1085,7 @@ export class ImageDecryptService {
|
||||
if (!lower.endsWith('.dat')) return false
|
||||
if (this.isThumbnailDat(lower)) return false
|
||||
const baseLower = lower.slice(0, -4)
|
||||
// 只检查是否有 _x 变体后缀(允许 _hd、_h 等所有带变体的)
|
||||
return this.hasXVariant(baseLower)
|
||||
}
|
||||
|
||||
@@ -1214,7 +1327,7 @@ export class ImageDecryptService {
|
||||
if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) return '.png'
|
||||
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return '.jpg'
|
||||
if (buffer[0] === 0x52 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x46 &&
|
||||
buffer[8] === 0x57 && buffer[9] === 0x45 && buffer[10] === 0x42 && buffer[11] === 0x50) {
|
||||
buffer[8] === 0x57 && buffer[9] === 0x45 && buffer[10] === 0x42 && buffer[11] === 0x50) {
|
||||
return '.webp'
|
||||
}
|
||||
return null
|
||||
@@ -1332,10 +1445,10 @@ export class ImageDecryptService {
|
||||
keyCount.set(key, (keyCount.get(key) || 0) + 1)
|
||||
filesChecked++
|
||||
}
|
||||
} catch {}
|
||||
} catch { }
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
} catch { }
|
||||
}
|
||||
|
||||
scanDir(dirPath)
|
||||
@@ -1354,6 +1467,159 @@ export class ImageDecryptService {
|
||||
return mostCommonKey
|
||||
}
|
||||
|
||||
/**
|
||||
* 解包 wxgf 格式
|
||||
* wxgf 是微信的图片格式,内部使用 HEVC 编码
|
||||
*/
|
||||
private async unwrapWxgf(buffer: Buffer): Promise<{ data: Buffer; isWxgf: boolean }> {
|
||||
// 检查是否是 wxgf 格式 (77 78 67 66 = "wxgf")
|
||||
if (buffer.length < 20 ||
|
||||
buffer[0] !== 0x77 || buffer[1] !== 0x78 ||
|
||||
buffer[2] !== 0x67 || buffer[3] !== 0x66) {
|
||||
return { data: buffer, isWxgf: false }
|
||||
}
|
||||
|
||||
// 先尝试搜索内嵌的传统图片签名
|
||||
for (let i = 4; i < Math.min(buffer.length - 12, 4096); i++) {
|
||||
if (buffer[i] === 0xff && buffer[i + 1] === 0xd8 && buffer[i + 2] === 0xff) {
|
||||
return { data: buffer.subarray(i), isWxgf: false }
|
||||
}
|
||||
if (buffer[i] === 0x89 && buffer[i + 1] === 0x50 &&
|
||||
buffer[i + 2] === 0x4e && buffer[i + 3] === 0x47) {
|
||||
return { data: buffer.subarray(i), isWxgf: false }
|
||||
}
|
||||
}
|
||||
|
||||
// 提取 HEVC NALU 裸流
|
||||
const hevcData = this.extractHevcNalu(buffer)
|
||||
if (!hevcData || hevcData.length < 100) {
|
||||
return { data: buffer, isWxgf: true }
|
||||
}
|
||||
|
||||
// 尝试用 ffmpeg 转换
|
||||
try {
|
||||
const jpgData = await this.convertHevcToJpg(hevcData)
|
||||
if (jpgData && jpgData.length > 0) {
|
||||
return { data: jpgData, isWxgf: false }
|
||||
}
|
||||
} catch {
|
||||
// ffmpeg 转换失败
|
||||
}
|
||||
|
||||
return { data: hevcData, isWxgf: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 wxgf 数据中提取 HEVC NALU 裸流
|
||||
*/
|
||||
private extractHevcNalu(buffer: Buffer): Buffer | null {
|
||||
const nalUnits: Buffer[] = []
|
||||
let i = 4
|
||||
|
||||
while (i < buffer.length - 4) {
|
||||
if (buffer[i] === 0x00 && buffer[i + 1] === 0x00 &&
|
||||
buffer[i + 2] === 0x00 && buffer[i + 3] === 0x01) {
|
||||
let nalStart = i
|
||||
let nalEnd = buffer.length
|
||||
|
||||
for (let j = i + 4; j < buffer.length - 3; j++) {
|
||||
if (buffer[j] === 0x00 && buffer[j + 1] === 0x00) {
|
||||
if (buffer[j + 2] === 0x01 ||
|
||||
(buffer[j + 2] === 0x00 && j + 3 < buffer.length && buffer[j + 3] === 0x01)) {
|
||||
nalEnd = j
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const nalUnit = buffer.subarray(nalStart, nalEnd)
|
||||
if (nalUnit.length > 3) {
|
||||
nalUnits.push(nalUnit)
|
||||
}
|
||||
i = nalEnd
|
||||
} else {
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
if (nalUnits.length === 0) {
|
||||
for (let j = 4; j < buffer.length - 4; j++) {
|
||||
if (buffer[j] === 0x00 && buffer[j + 1] === 0x00 &&
|
||||
buffer[j + 2] === 0x00 && buffer[j + 3] === 0x01) {
|
||||
return buffer.subarray(j)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return Buffer.concat(nalUnits)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 ffmpeg 可执行文件路径
|
||||
*/
|
||||
private getFfmpegPath(): string {
|
||||
const staticPath = getStaticFfmpegPath()
|
||||
this.logInfo('ffmpeg 路径检测', { staticPath, exists: staticPath ? existsSync(staticPath) : false })
|
||||
|
||||
if (staticPath) {
|
||||
return staticPath
|
||||
}
|
||||
|
||||
// 回退到系统 ffmpeg
|
||||
return 'ffmpeg'
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 ffmpeg 将 HEVC 裸流转换为 JPG
|
||||
*/
|
||||
private convertHevcToJpg(hevcData: Buffer): Promise<Buffer | null> {
|
||||
const ffmpeg = this.getFfmpegPath()
|
||||
this.logInfo('ffmpeg 转换开始', { ffmpegPath: ffmpeg, hevcSize: hevcData.length })
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const { spawn } = require('child_process')
|
||||
const chunks: Buffer[] = []
|
||||
const errChunks: Buffer[] = []
|
||||
|
||||
const proc = spawn(ffmpeg, [
|
||||
'-hide_banner',
|
||||
'-loglevel', 'error',
|
||||
'-f', 'hevc',
|
||||
'-i', 'pipe:0',
|
||||
'-vframes', '1',
|
||||
'-q:v', '3',
|
||||
'-f', 'mjpeg',
|
||||
'pipe:1'
|
||||
], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
windowsHide: true
|
||||
})
|
||||
|
||||
proc.stdout.on('data', (chunk: Buffer) => chunks.push(chunk))
|
||||
proc.stderr.on('data', (chunk: Buffer) => errChunks.push(chunk))
|
||||
|
||||
proc.on('close', (code: number) => {
|
||||
if (code === 0 && chunks.length > 0) {
|
||||
this.logInfo('ffmpeg 转换成功', { outputSize: Buffer.concat(chunks).length })
|
||||
resolve(Buffer.concat(chunks))
|
||||
} else {
|
||||
const errMsg = Buffer.concat(errChunks).toString()
|
||||
this.logInfo('ffmpeg 转换失败', { code, error: errMsg })
|
||||
resolve(null)
|
||||
}
|
||||
})
|
||||
|
||||
proc.on('error', (err: Error) => {
|
||||
this.logInfo('ffmpeg 进程错误', { error: err.message })
|
||||
resolve(null)
|
||||
})
|
||||
|
||||
proc.stdin.write(hevcData)
|
||||
proc.stdin.end()
|
||||
})
|
||||
}
|
||||
|
||||
// 保留原有的解密到文件方法(用于兼容)
|
||||
async decryptToFile(inputPath: string, outputPath: string, xorKey: number, aesKey?: Buffer): Promise<void> {
|
||||
const version = this.getDatVersion(inputPath)
|
||||
@@ -1378,6 +1644,71 @@ export class ImageDecryptService {
|
||||
|
||||
await writeFile(outputPath, decrypted)
|
||||
}
|
||||
|
||||
async clearCache(): Promise<{ success: boolean; error?: string }> {
|
||||
this.resolvedCache.clear()
|
||||
this.hardlinkCache.clear()
|
||||
this.pending.clear()
|
||||
this.updateFlags.clear()
|
||||
this.cacheIndexed = false
|
||||
this.cacheIndexing = null
|
||||
|
||||
const configured = this.configService.get('cachePath')
|
||||
const root = configured
|
||||
? join(configured, 'Images')
|
||||
: join(app.getPath('documents'), 'WeFlow', 'Images')
|
||||
|
||||
try {
|
||||
if (!existsSync(root)) {
|
||||
return { success: true }
|
||||
}
|
||||
const monthPattern = /^\d{4}-\d{2}$/
|
||||
const clearFilesInDir = async (dirPath: string): Promise<void> => {
|
||||
let entries: Array<{ name: string; isDirectory: () => boolean }>
|
||||
try {
|
||||
entries = await readdir(dirPath, { withFileTypes: true })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dirPath, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
await clearFilesInDir(fullPath)
|
||||
continue
|
||||
}
|
||||
try {
|
||||
await rm(fullPath, { force: true })
|
||||
} catch { }
|
||||
}
|
||||
}
|
||||
const traverse = async (dirPath: string): Promise<void> => {
|
||||
let entries: Array<{ name: string; isDirectory: () => boolean }>
|
||||
try {
|
||||
entries = await readdir(dirPath, { withFileTypes: true })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dirPath, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
if (monthPattern.test(entry.name)) {
|
||||
await clearFilesInDir(fullPath)
|
||||
} else {
|
||||
await traverse(fullPath)
|
||||
}
|
||||
continue
|
||||
}
|
||||
try {
|
||||
await rm(fullPath, { force: true })
|
||||
} catch { }
|
||||
}
|
||||
}
|
||||
await traverse(root)
|
||||
return { success: true }
|
||||
} catch (e) {
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const imageDecryptService = new ImageDecryptService()
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { app } from 'electron'
|
||||
import { join, dirname, basename } from 'path'
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
|
||||
import { existsSync, readdirSync, readFileSync, statSync, copyFileSync, mkdirSync } from 'fs'
|
||||
import { execFile, spawn } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import crypto from 'crypto'
|
||||
import os from 'os'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
@@ -57,18 +58,94 @@ export class KeyService {
|
||||
private readonly ERROR_SUCCESS = 0
|
||||
|
||||
private getDllPath(): string {
|
||||
const resourcesPath = app.isPackaged
|
||||
? join(process.resourcesPath, 'resources')
|
||||
: join(app.getAppPath(), 'resources')
|
||||
return join(resourcesPath, 'wx_key.dll')
|
||||
const isPackaged = typeof app !== 'undefined' && app ? app.isPackaged : process.env.NODE_ENV === 'production'
|
||||
|
||||
// 候选路径列表
|
||||
const candidates: string[] = []
|
||||
|
||||
// 1. 显式环境变量 (最高优先级)
|
||||
if (process.env.WX_KEY_DLL_PATH) {
|
||||
candidates.push(process.env.WX_KEY_DLL_PATH)
|
||||
}
|
||||
|
||||
if (isPackaged) {
|
||||
// 生产环境: 通常在 resources 目录下,但也可能直接在 resources 根目录
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'wx_key.dll'))
|
||||
candidates.push(join(process.resourcesPath, 'wx_key.dll'))
|
||||
} else {
|
||||
// 开发环境
|
||||
const cwd = process.cwd()
|
||||
candidates.push(join(cwd, 'resources', 'wx_key.dll'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'wx_key.dll'))
|
||||
}
|
||||
|
||||
// 检查并返回第一个存在的路径
|
||||
for (const path of candidates) {
|
||||
if (existsSync(path)) {
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
// 如果都没找到,返回最可能的路径以便报错信息有参考
|
||||
return candidates[0]
|
||||
}
|
||||
|
||||
// 检查路径是否为 UNC 路径或网络路径
|
||||
private isNetworkPath(path: string): boolean {
|
||||
// UNC 路径以 \\ 开头
|
||||
if (path.startsWith('\\\\')) {
|
||||
return true
|
||||
}
|
||||
// 检查是否为网络映射驱动器(简化检测:A: 表示驱动器)
|
||||
// 注意:这是一个启发式检测,更准确的方式需要调用 GetDriveType Windows API
|
||||
// 但对于大多数 VM 共享场景,UNC 路径检测已足够
|
||||
return false
|
||||
}
|
||||
|
||||
// 将 DLL 复制到本地临时目录
|
||||
private localizeNetworkDll(originalPath: string): string {
|
||||
try {
|
||||
const tempDir = join(os.tmpdir(), 'weflow_dll_cache')
|
||||
if (!existsSync(tempDir)) {
|
||||
mkdirSync(tempDir, { recursive: true })
|
||||
}
|
||||
const localPath = join(tempDir, 'wx_key.dll')
|
||||
|
||||
// 检查是否已经有本地副本,如果有就使用它
|
||||
if (existsSync(localPath)) {
|
||||
console.log(`使用已存在的 DLL 本地副本: ${localPath}`)
|
||||
return localPath
|
||||
}
|
||||
|
||||
console.log(`检测到网络路径 DLL,正在复制到本地: ${originalPath} -> ${localPath}`)
|
||||
copyFileSync(originalPath, localPath)
|
||||
console.log('DLL 本地化成功')
|
||||
return localPath
|
||||
} catch (e) {
|
||||
console.error('DLL 本地化失败:', e)
|
||||
// 如果本地化失败,返回原路径
|
||||
return originalPath
|
||||
}
|
||||
}
|
||||
|
||||
private ensureLoaded(): boolean {
|
||||
if (this.initialized) return true
|
||||
|
||||
let dllPath = ''
|
||||
try {
|
||||
this.koffi = require('koffi')
|
||||
const dllPath = this.getDllPath()
|
||||
if (!existsSync(dllPath)) return false
|
||||
dllPath = this.getDllPath()
|
||||
|
||||
if (!existsSync(dllPath)) {
|
||||
console.error(`wx_key.dll 不存在于路径: ${dllPath}`)
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查是否为网络路径,如果是则本地化
|
||||
if (this.isNetworkPath(dllPath)) {
|
||||
console.log('检测到网络路径,将进行本地化处理')
|
||||
dllPath = this.localizeNetworkDll(dllPath)
|
||||
}
|
||||
|
||||
this.lib = this.koffi.load(dllPath)
|
||||
this.initHook = this.lib.func('bool InitializeHook(uint32 targetPid)')
|
||||
@@ -80,7 +157,14 @@ export class KeyService {
|
||||
this.initialized = true
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error('加载 wx_key.dll 失败:', e)
|
||||
const errorMsg = e instanceof Error ? e.message : String(e)
|
||||
const errorStack = e instanceof Error ? e.stack : ''
|
||||
console.error(`加载 wx_key.dll 失败`)
|
||||
console.error(` 路径: ${dllPath}`)
|
||||
console.error(` 错误: ${errorMsg}`)
|
||||
if (errorStack) {
|
||||
console.error(` 堆栈: ${errorStack}`)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -695,33 +779,41 @@ export class KeyService {
|
||||
}
|
||||
|
||||
private getXorKey(templateFiles: string[]): number | null {
|
||||
const counts = new Map<string, number>()
|
||||
const counts = new Map<number, number>()
|
||||
const tailSignatures = [
|
||||
Buffer.from([0xFF, 0xD9]),
|
||||
Buffer.from([0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82])
|
||||
]
|
||||
for (const file of templateFiles) {
|
||||
try {
|
||||
const bytes = readFileSync(file)
|
||||
if (bytes.length < 2) continue
|
||||
const x = bytes[bytes.length - 2]
|
||||
const y = bytes[bytes.length - 1]
|
||||
const key = `${x}_${y}`
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1)
|
||||
for (const signature of tailSignatures) {
|
||||
if (bytes.length < signature.length) continue
|
||||
const tail = bytes.subarray(bytes.length - signature.length)
|
||||
const xorKey = tail[0] ^ signature[0]
|
||||
let valid = true
|
||||
for (let i = 1; i < signature.length; i++) {
|
||||
if ((tail[i] ^ xorKey) !== signature[i]) {
|
||||
valid = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if (valid) {
|
||||
counts.set(xorKey, (counts.get(xorKey) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
if (!counts.size) return null
|
||||
let mostKey = ''
|
||||
let mostCount = 0
|
||||
let bestKey: number | null = null
|
||||
let bestCount = 0
|
||||
for (const [key, count] of counts) {
|
||||
if (count > mostCount) {
|
||||
mostCount = count
|
||||
mostKey = key
|
||||
if (count > bestCount) {
|
||||
bestCount = count
|
||||
bestKey = key
|
||||
}
|
||||
}
|
||||
if (!mostKey) return null
|
||||
const [xStr, yStr] = mostKey.split('_')
|
||||
const x = Number(xStr)
|
||||
const y = Number(yStr)
|
||||
const xorKey = x ^ 0xFF
|
||||
const check = y ^ 0xD9
|
||||
return xorKey === check ? xorKey : null
|
||||
return bestKey
|
||||
}
|
||||
|
||||
private getCiphertextFromTemplate(templateFiles: string[]): Buffer | null {
|
||||
@@ -766,7 +858,17 @@ export class KeyService {
|
||||
const decipher = crypto.createDecipheriv('aes-128-ecb', key, null)
|
||||
decipher.setAutoPadding(false)
|
||||
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()])
|
||||
return decrypted[0] === 0xff && decrypted[1] === 0xd8 && decrypted[2] === 0xff
|
||||
const isJpeg = decrypted.length >= 3 && decrypted[0] === 0xff && decrypted[1] === 0xd8 && decrypted[2] === 0xff
|
||||
const isPng = decrypted.length >= 8 &&
|
||||
decrypted[0] === 0x89 &&
|
||||
decrypted[1] === 0x50 &&
|
||||
decrypted[2] === 0x4e &&
|
||||
decrypted[3] === 0x47 &&
|
||||
decrypted[4] === 0x0d &&
|
||||
decrypted[5] === 0x0a &&
|
||||
decrypted[6] === 0x1a &&
|
||||
decrypted[7] === 0x0a
|
||||
return isJpeg || isPng
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
@@ -813,17 +915,40 @@ export class KeyService {
|
||||
return buffer.subarray(0, bytesRead[0])
|
||||
}
|
||||
|
||||
private async getAesKeyFromMemory(pid: number, ciphertext: Buffer): Promise<string | null> {
|
||||
private async getAesKeyFromMemory(
|
||||
pid: number,
|
||||
ciphertext: Buffer,
|
||||
onProgress?: (current: number, total: number, message: string) => void
|
||||
): Promise<string | null> {
|
||||
if (!this.ensureKernel32()) return null
|
||||
const hProcess = this.OpenProcess(this.PROCESS_ALL_ACCESS, false, pid)
|
||||
if (!hProcess) return null
|
||||
|
||||
try {
|
||||
const regions = this.getMemoryRegions(hProcess)
|
||||
const chunkSize = 4 * 1024 * 1024
|
||||
const allRegions = this.getMemoryRegions(hProcess)
|
||||
|
||||
// 优化1: 只保留小内存区域(< 10MB)- 密钥通常在小区域,可大幅减少扫描时间
|
||||
const filteredRegions = allRegions.filter(([_, size]) => size <= 10 * 1024 * 1024)
|
||||
|
||||
// 优化2: 优先级排序 - 按大小升序,先扫描小区域(密钥通常在较小区域)
|
||||
const sortedRegions = filteredRegions.sort((a, b) => a[1] - b[1])
|
||||
|
||||
// 优化3: 计算总字节数用于精确进度报告
|
||||
const totalBytes = sortedRegions.reduce((sum, [_, size]) => sum + size, 0)
|
||||
let processedBytes = 0
|
||||
|
||||
// 优化4: 减小分块大小到 1MB(参考 wx_key 项目)
|
||||
const chunkSize = 1 * 1024 * 1024
|
||||
const overlap = 65
|
||||
for (const [baseAddress, regionSize] of regions) {
|
||||
if (regionSize > 100 * 1024 * 1024) continue
|
||||
let currentRegion = 0
|
||||
|
||||
for (const [baseAddress, regionSize] of sortedRegions) {
|
||||
currentRegion++
|
||||
const progress = totalBytes > 0 ? Math.floor((processedBytes / totalBytes) * 100) : 0
|
||||
onProgress?.(progress, 100, `扫描内存 ${progress}% (${currentRegion}/${sortedRegions.length})`)
|
||||
|
||||
// 每个区域都让出主线程,确保UI流畅
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
let offset = 0
|
||||
let trailing: Buffer | null = null
|
||||
while (offset < regionSize) {
|
||||
@@ -878,6 +1003,9 @@ export class KeyService {
|
||||
trailing = dataToScan.subarray(start < 0 ? 0 : start)
|
||||
offset += currentChunkSize
|
||||
}
|
||||
|
||||
// 更新已处理字节数
|
||||
processedBytes += regionSize
|
||||
}
|
||||
return null
|
||||
} finally {
|
||||
@@ -915,7 +1043,9 @@ export class KeyService {
|
||||
if (!pid) return { success: false, error: '未检测到微信进程' }
|
||||
|
||||
onProgress?.('正在扫描内存获取 AES 密钥...')
|
||||
const aesKey = await this.getAesKeyFromMemory(pid, ciphertext)
|
||||
const aesKey = await this.getAesKeyFromMemory(pid, ciphertext, (current, total, msg) => {
|
||||
onProgress?.(`${msg} (${current}/${total})`)
|
||||
})
|
||||
if (!aesKey) {
|
||||
return {
|
||||
success: false,
|
||||
|
||||
77
electron/services/messageCacheService.ts
Normal file
77
electron/services/messageCacheService.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { join, dirname } from 'path'
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
|
||||
import { app } from 'electron'
|
||||
|
||||
export interface SessionMessageCacheEntry {
|
||||
updatedAt: number
|
||||
messages: any[]
|
||||
}
|
||||
|
||||
export class MessageCacheService {
|
||||
private readonly cacheFilePath: string
|
||||
private cache: Record<string, SessionMessageCacheEntry> = {}
|
||||
private readonly sessionLimit = 150
|
||||
|
||||
constructor(cacheBasePath?: string) {
|
||||
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
|
||||
? cacheBasePath
|
||||
: join(app.getPath('documents'), 'WeFlow')
|
||||
this.cacheFilePath = join(basePath, 'session-messages.json')
|
||||
this.ensureCacheDir()
|
||||
this.loadCache()
|
||||
}
|
||||
|
||||
private ensureCacheDir() {
|
||||
const dir = dirname(this.cacheFilePath)
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
private loadCache() {
|
||||
if (!existsSync(this.cacheFilePath)) return
|
||||
try {
|
||||
const raw = readFileSync(this.cacheFilePath, 'utf8')
|
||||
const parsed = JSON.parse(raw)
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
this.cache = parsed
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('MessageCacheService: 载入缓存失败', error)
|
||||
this.cache = {}
|
||||
}
|
||||
}
|
||||
|
||||
get(sessionId: string): SessionMessageCacheEntry | undefined {
|
||||
return this.cache[sessionId]
|
||||
}
|
||||
|
||||
set(sessionId: string, messages: any[]): void {
|
||||
if (!sessionId) return
|
||||
const trimmed = messages.length > this.sessionLimit
|
||||
? messages.slice(-this.sessionLimit)
|
||||
: messages.slice()
|
||||
this.cache[sessionId] = {
|
||||
updatedAt: Date.now(),
|
||||
messages: trimmed
|
||||
}
|
||||
this.persist()
|
||||
}
|
||||
|
||||
private persist() {
|
||||
try {
|
||||
writeFileSync(this.cacheFilePath, JSON.stringify(this.cache), 'utf8')
|
||||
} catch (error) {
|
||||
console.error('MessageCacheService: 保存缓存失败', error)
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.cache = {}
|
||||
try {
|
||||
rmSync(this.cacheFilePath, { force: true })
|
||||
} catch (error) {
|
||||
console.error('MessageCacheService: 清理缓存失败', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
374
electron/services/voiceTranscribeService.ts
Normal file
374
electron/services/voiceTranscribeService.ts
Normal file
@@ -0,0 +1,374 @@
|
||||
import { app } from 'electron'
|
||||
import { existsSync, mkdirSync, statSync, unlinkSync, createWriteStream } from 'fs'
|
||||
import { join } from 'path'
|
||||
import * as https from 'https'
|
||||
import * as http from 'http'
|
||||
import { ConfigService } from './config'
|
||||
|
||||
// Sherpa-onnx 类型定义
|
||||
type OfflineRecognizer = any
|
||||
type OfflineStream = any
|
||||
|
||||
type ModelInfo = {
|
||||
name: string
|
||||
files: {
|
||||
model: string
|
||||
tokens: string
|
||||
}
|
||||
sizeBytes: number
|
||||
sizeLabel: string
|
||||
}
|
||||
|
||||
type DownloadProgress = {
|
||||
modelName: string
|
||||
downloadedBytes: number
|
||||
totalBytes?: number
|
||||
percent?: number
|
||||
}
|
||||
|
||||
const SENSEVOICE_MODEL: ModelInfo = {
|
||||
name: 'SenseVoiceSmall',
|
||||
files: {
|
||||
model: 'model.int8.onnx',
|
||||
tokens: 'tokens.txt'
|
||||
},
|
||||
sizeBytes: 245_000_000,
|
||||
sizeLabel: '245 MB'
|
||||
}
|
||||
|
||||
const MODEL_DOWNLOAD_URLS = {
|
||||
model: 'https://modelscope.cn/models/pengzhendong/sherpa-onnx-sense-voice-zh-en-ja-ko-yue/resolve/master/model.int8.onnx',
|
||||
tokens: 'https://modelscope.cn/models/pengzhendong/sherpa-onnx-sense-voice-zh-en-ja-ko-yue/resolve/master/tokens.txt'
|
||||
}
|
||||
|
||||
export class VoiceTranscribeService {
|
||||
private configService = new ConfigService()
|
||||
private downloadTasks = new Map<string, Promise<{ success: boolean; path?: string; error?: string }>>()
|
||||
private recognizer: OfflineRecognizer | null = null
|
||||
private isInitializing = false
|
||||
|
||||
private resolveModelDir(): string {
|
||||
const configured = this.configService.get('whisperModelDir') as string | undefined
|
||||
if (configured) return configured
|
||||
return join(app.getPath('documents'), 'WeFlow', 'models', 'sensevoice')
|
||||
}
|
||||
|
||||
private resolveModelPath(fileName: string): string {
|
||||
return join(this.resolveModelDir(), fileName)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查模型状态
|
||||
*/
|
||||
async getModelStatus(): Promise<{
|
||||
success: boolean
|
||||
exists?: boolean
|
||||
modelPath?: string
|
||||
tokensPath?: string
|
||||
sizeBytes?: number
|
||||
error?: string
|
||||
}> {
|
||||
try {
|
||||
const modelPath = this.resolveModelPath(SENSEVOICE_MODEL.files.model)
|
||||
const tokensPath = this.resolveModelPath(SENSEVOICE_MODEL.files.tokens)
|
||||
const modelExists = existsSync(modelPath)
|
||||
const tokensExists = existsSync(tokensPath)
|
||||
const exists = modelExists && tokensExists
|
||||
|
||||
if (!exists) {
|
||||
return { success: true, exists: false, modelPath, tokensPath }
|
||||
}
|
||||
|
||||
const modelSize = statSync(modelPath).size
|
||||
const tokensSize = statSync(tokensPath).size
|
||||
const totalSize = modelSize + tokensSize
|
||||
|
||||
return {
|
||||
success: true,
|
||||
exists: true,
|
||||
modelPath,
|
||||
tokensPath,
|
||||
sizeBytes: totalSize
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载模型文件
|
||||
*/
|
||||
async downloadModel(
|
||||
onProgress?: (progress: DownloadProgress) => void
|
||||
): Promise<{ success: boolean; modelPath?: string; tokensPath?: string; error?: string }> {
|
||||
const cacheKey = 'sensevoice'
|
||||
const pending = this.downloadTasks.get(cacheKey)
|
||||
if (pending) return pending
|
||||
|
||||
const task = (async () => {
|
||||
try {
|
||||
const modelDir = this.resolveModelDir()
|
||||
if (!existsSync(modelDir)) {
|
||||
mkdirSync(modelDir, { recursive: true })
|
||||
}
|
||||
|
||||
const modelPath = this.resolveModelPath(SENSEVOICE_MODEL.files.model)
|
||||
const tokensPath = this.resolveModelPath(SENSEVOICE_MODEL.files.tokens)
|
||||
|
||||
// 初始进度
|
||||
onProgress?.({
|
||||
modelName: SENSEVOICE_MODEL.name,
|
||||
downloadedBytes: 0,
|
||||
totalBytes: SENSEVOICE_MODEL.sizeBytes,
|
||||
percent: 0
|
||||
})
|
||||
|
||||
// 下载模型文件 (40%)
|
||||
console.info('[VoiceTranscribe] 开始下载模型文件...')
|
||||
await this.downloadToFile(
|
||||
MODEL_DOWNLOAD_URLS.model,
|
||||
modelPath,
|
||||
'model',
|
||||
(downloaded, total) => {
|
||||
const percent = total ? (downloaded / total) * 40 : undefined
|
||||
onProgress?.({
|
||||
modelName: SENSEVOICE_MODEL.name,
|
||||
downloadedBytes: downloaded,
|
||||
totalBytes: SENSEVOICE_MODEL.sizeBytes,
|
||||
percent
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// 下载 tokens 文件 (30%)
|
||||
console.info('[VoiceTranscribe] 开始下载 tokens 文件...')
|
||||
await this.downloadToFile(
|
||||
MODEL_DOWNLOAD_URLS.tokens,
|
||||
tokensPath,
|
||||
'tokens',
|
||||
(downloaded, total) => {
|
||||
const modelSize = existsSync(modelPath) ? statSync(modelPath).size : 0
|
||||
const percent = total ? 40 + (downloaded / total) * 30 : 40
|
||||
onProgress?.({
|
||||
modelName: SENSEVOICE_MODEL.name,
|
||||
downloadedBytes: modelSize + downloaded,
|
||||
totalBytes: SENSEVOICE_MODEL.sizeBytes,
|
||||
percent
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
console.info('[VoiceTranscribe] 模型下载完成')
|
||||
|
||||
console.info('[VoiceTranscribe] 所有文件下载完成')
|
||||
return { success: true, modelPath, tokensPath }
|
||||
} catch (error) {
|
||||
const modelPath = this.resolveModelPath(SENSEVOICE_MODEL.files.model)
|
||||
const tokensPath = this.resolveModelPath(SENSEVOICE_MODEL.files.tokens)
|
||||
try {
|
||||
if (existsSync(modelPath)) unlinkSync(modelPath)
|
||||
if (existsSync(tokensPath)) unlinkSync(tokensPath)
|
||||
} catch { }
|
||||
return { success: false, error: String(error) }
|
||||
} finally {
|
||||
this.downloadTasks.delete(cacheKey)
|
||||
}
|
||||
})()
|
||||
|
||||
this.downloadTasks.set(cacheKey, task)
|
||||
return task
|
||||
}
|
||||
|
||||
/**
|
||||
* 转写 WAV 音频数据 (后台 Worker Threads 版本)
|
||||
*/
|
||||
async transcribeWavBuffer(
|
||||
wavData: Buffer,
|
||||
onPartial?: (text: string) => void,
|
||||
languages?: string[]
|
||||
): Promise<{ success: boolean; transcript?: string; error?: string }> {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const modelPath = this.resolveModelPath(SENSEVOICE_MODEL.files.model)
|
||||
const tokensPath = this.resolveModelPath(SENSEVOICE_MODEL.files.tokens)
|
||||
|
||||
if (!existsSync(modelPath) || !existsSync(tokensPath)) {
|
||||
resolve({ success: false, error: '模型文件不存在,请先下载模型' })
|
||||
return
|
||||
}
|
||||
|
||||
// 获取配置的语言列表,如果没有传入则从配置读取
|
||||
let supportedLanguages = languages
|
||||
if (!supportedLanguages || supportedLanguages.length === 0) {
|
||||
supportedLanguages = this.configService.get('transcribeLanguages')
|
||||
// 如果配置中也没有或为空,使用默认值
|
||||
if (!supportedLanguages || supportedLanguages.length === 0) {
|
||||
supportedLanguages = ['zh', 'yue']
|
||||
}
|
||||
}
|
||||
|
||||
const { Worker } = require('worker_threads')
|
||||
// main.js 和 transcribeWorker.js 同在 dist-electron 目录下
|
||||
const workerPath = join(__dirname, 'transcribeWorker.js')
|
||||
|
||||
const worker = new Worker(workerPath, {
|
||||
workerData: {
|
||||
modelPath,
|
||||
tokensPath,
|
||||
wavData,
|
||||
sampleRate: 16000,
|
||||
languages: supportedLanguages
|
||||
}
|
||||
})
|
||||
|
||||
let finalTranscript = ''
|
||||
|
||||
worker.on('message', (msg: any) => {
|
||||
if (msg.type === 'partial') {
|
||||
onPartial?.(msg.text)
|
||||
} else if (msg.type === 'final') {
|
||||
finalTranscript = msg.text
|
||||
resolve({ success: true, transcript: finalTranscript })
|
||||
worker.terminate()
|
||||
} else if (msg.type === 'error') {
|
||||
resolve({ success: false, error: msg.error })
|
||||
worker.terminate()
|
||||
}
|
||||
})
|
||||
|
||||
worker.on('error', (err: Error) => {
|
||||
resolve({ success: false, error: String(err) })
|
||||
})
|
||||
|
||||
worker.on('exit', (code: number) => {
|
||||
if (code !== 0) {
|
||||
console.error(`[VoiceTranscribe] Worker stopped with exit code ${code}`)
|
||||
resolve({ success: false, error: `Worker exited with code ${code}` })
|
||||
}
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
resolve({ success: false, error: String(error) })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载文件
|
||||
*/
|
||||
private downloadToFile(
|
||||
url: string,
|
||||
targetPath: string,
|
||||
fileName: string,
|
||||
onProgress?: (downloaded: number, total?: number) => void,
|
||||
remainingRedirects = 5
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const protocol = url.startsWith('https') ? https : http
|
||||
console.info(`[VoiceTranscribe] 下载 ${fileName}:`, url)
|
||||
|
||||
const options = {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
||||
},
|
||||
timeout: 30000 // 30秒连接超时
|
||||
}
|
||||
|
||||
const request = protocol.get(url, options, (response) => {
|
||||
console.info(`[VoiceTranscribe] ${fileName} 响应状态:`, response.statusCode)
|
||||
|
||||
// 处理重定向
|
||||
if ([301, 302, 303, 307, 308].includes(response.statusCode || 0) && response.headers.location) {
|
||||
if (remainingRedirects <= 0) {
|
||||
reject(new Error('重定向次数过多'))
|
||||
return
|
||||
}
|
||||
console.info(`[VoiceTranscribe] 重定向到:`, response.headers.location)
|
||||
this.downloadToFile(response.headers.location, targetPath, fileName, onProgress, remainingRedirects - 1)
|
||||
.then(resolve)
|
||||
.catch(reject)
|
||||
return
|
||||
}
|
||||
|
||||
if (response.statusCode !== 200) {
|
||||
reject(new Error(`下载失败: HTTP ${response.statusCode}`))
|
||||
return
|
||||
}
|
||||
|
||||
const totalBytes = Number(response.headers['content-length'] || 0) || undefined
|
||||
let downloadedBytes = 0
|
||||
|
||||
console.info(`[VoiceTranscribe] ${fileName} 文件大小:`, totalBytes ? `${(totalBytes / 1024 / 1024).toFixed(2)} MB` : '未知')
|
||||
|
||||
const writer = createWriteStream(targetPath)
|
||||
|
||||
// 设置数据接收超时(60秒没有数据则超时)
|
||||
let lastDataTime = Date.now()
|
||||
const dataTimeout = setInterval(() => {
|
||||
if (Date.now() - lastDataTime > 60000) {
|
||||
clearInterval(dataTimeout)
|
||||
response.destroy()
|
||||
writer.close()
|
||||
reject(new Error('下载超时:60秒内未收到数据'))
|
||||
}
|
||||
}, 5000)
|
||||
|
||||
response.on('data', (chunk) => {
|
||||
lastDataTime = Date.now()
|
||||
downloadedBytes += chunk.length
|
||||
onProgress?.(downloadedBytes, totalBytes)
|
||||
})
|
||||
|
||||
response.on('error', (error) => {
|
||||
clearInterval(dataTimeout)
|
||||
try { writer.close() } catch { }
|
||||
console.error(`[VoiceTranscribe] ${fileName} 响应错误:`, error)
|
||||
reject(error)
|
||||
})
|
||||
|
||||
writer.on('error', (error) => {
|
||||
clearInterval(dataTimeout)
|
||||
try { writer.close() } catch { }
|
||||
console.error(`[VoiceTranscribe] ${fileName} 写入错误:`, error)
|
||||
reject(error)
|
||||
})
|
||||
|
||||
writer.on('finish', () => {
|
||||
clearInterval(dataTimeout)
|
||||
writer.close()
|
||||
console.info(`[VoiceTranscribe] ${fileName} 下载完成:`, targetPath)
|
||||
resolve()
|
||||
})
|
||||
|
||||
response.pipe(writer)
|
||||
})
|
||||
|
||||
request.on('timeout', () => {
|
||||
request.destroy()
|
||||
console.error(`[VoiceTranscribe] ${fileName} 连接超时`)
|
||||
reject(new Error('连接超时'))
|
||||
})
|
||||
|
||||
request.on('error', (error) => {
|
||||
console.error(`[VoiceTranscribe] ${fileName} 请求错误:`, error)
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理资源
|
||||
*/
|
||||
dispose() {
|
||||
if (this.recognizer) {
|
||||
try {
|
||||
// sherpa-onnx 的 recognizer 可能需要手动释放
|
||||
this.recognizer = null
|
||||
} catch (error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const voiceTranscribeService = new VoiceTranscribeService()
|
||||
|
||||
1340
electron/services/wcdbCore.ts
Normal file
1340
electron/services/wcdbCore.ts
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
166
electron/transcribeWorker.ts
Normal file
166
electron/transcribeWorker.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { parentPort, workerData } from 'worker_threads'
|
||||
|
||||
interface WorkerParams {
|
||||
modelPath: string
|
||||
tokensPath: string
|
||||
wavData: Buffer
|
||||
sampleRate: number
|
||||
languages?: string[]
|
||||
}
|
||||
|
||||
// 语言标记映射
|
||||
const LANGUAGE_TAGS: Record<string, string> = {
|
||||
'zh': '<|zh|>',
|
||||
'en': '<|en|>',
|
||||
'ja': '<|ja|>',
|
||||
'ko': '<|ko|>',
|
||||
'yue': '<|yue|>' // 粤语
|
||||
}
|
||||
|
||||
// 技术标签(识别语言、语速、ITN等),需要从最终文本中移除
|
||||
const TECH_TAGS = [
|
||||
'<|zh|>', '<|en|>', '<|ja|>', '<|ko|>', '<|yue|>',
|
||||
'<|nospeech|>', '<|speech|>',
|
||||
'<|itn|>', '<|wo_itn|>',
|
||||
'<|NORMAL|>'
|
||||
]
|
||||
|
||||
// 情感与事件标签映射,转换为直观的 Emoji
|
||||
const RICH_TAG_MAP: Record<string, string> = {
|
||||
'<|HAPPY|>': '😊',
|
||||
'<|SAD|>': '😔',
|
||||
'<|ANGRY|>': '😠',
|
||||
'<|NEUTRAL|>': '', // 中性情感不特别标记
|
||||
'<|FEARFUL|>': '😨',
|
||||
'<|DISGUSTED|>': '🤢',
|
||||
'<|SURPRISED|>': '😮',
|
||||
'<|BGM|>': '🎵',
|
||||
'<|Applause|>': '👏',
|
||||
'<|Laughter|>': '😂',
|
||||
'<|Cry|>': '😭',
|
||||
'<|Cough|>': ' (咳嗽) ',
|
||||
'<|Sneeze|>': ' (喷嚏) ',
|
||||
}
|
||||
|
||||
/**
|
||||
* 富文本后处理:移除技术标签,转换识别出的情感和声音事件
|
||||
*/
|
||||
function richTranscribePostProcess(text: string): string {
|
||||
if (!text) return ''
|
||||
|
||||
let processed = text
|
||||
|
||||
// 1. 转换情感和事件标签
|
||||
for (const [tag, replacement] of Object.entries(RICH_TAG_MAP)) {
|
||||
// 使用正则全局替换,不区分大小写以防不同版本差异
|
||||
const escapedTag = tag.replace(/[|<>]/g, '\\$&')
|
||||
processed = processed.replace(new RegExp(escapedTag, 'gi'), replacement)
|
||||
}
|
||||
|
||||
// 2. 移除所有剩余的技术标签
|
||||
for (const tag of TECH_TAGS) {
|
||||
const escapedTag = tag.replace(/[|<>]/g, '\\$&')
|
||||
processed = processed.replace(new RegExp(escapedTag, 'gi'), '')
|
||||
}
|
||||
|
||||
// 3. 清理多余空格并返回
|
||||
return processed.replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
// 检查识别结果是否在允许的语言列表中
|
||||
function isLanguageAllowed(result: any, allowedLanguages: string[]): boolean {
|
||||
if (!result || !result.lang) {
|
||||
// 如果没有语言信息,默认允许(或从文本开头尝试提取)
|
||||
return true
|
||||
}
|
||||
|
||||
// 如果没有指定语言或语言列表为空,默认允许中文和粤语
|
||||
if (!allowedLanguages || allowedLanguages.length === 0) {
|
||||
allowedLanguages = ['zh', 'yue']
|
||||
}
|
||||
|
||||
const langTag = result.lang
|
||||
console.log('[TranscribeWorker] 检测到语言标记:', langTag)
|
||||
|
||||
// 检查是否在允许的语言列表中
|
||||
for (const lang of allowedLanguages) {
|
||||
if (LANGUAGE_TAGS[lang] === langTag) {
|
||||
console.log('[TranscribeWorker] 语言匹配,允许:', lang)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[TranscribeWorker] 语言不在白名单中,过滤掉')
|
||||
return false
|
||||
}
|
||||
|
||||
async function run() {
|
||||
if (!parentPort) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 动态加载以捕获可能的加载错误(如 C++ 运行库缺失等)
|
||||
let sherpa: any;
|
||||
try {
|
||||
sherpa = require('sherpa-onnx-node');
|
||||
} catch (requireError) {
|
||||
parentPort.postMessage({ type: 'error', error: 'Failed to load speech engine: ' + String(requireError) });
|
||||
return;
|
||||
}
|
||||
|
||||
const { modelPath, tokensPath, wavData: rawWavData, sampleRate, languages } = workerData as WorkerParams
|
||||
const wavData = Buffer.from(rawWavData);
|
||||
// 确保有有效的语言列表,默认只允许中文
|
||||
let allowedLanguages = languages || ['zh']
|
||||
if (allowedLanguages.length === 0) {
|
||||
allowedLanguages = ['zh']
|
||||
}
|
||||
|
||||
console.log('[TranscribeWorker] 使用的语言白名单:', allowedLanguages)
|
||||
|
||||
// 1. 初始化识别器 (SenseVoiceSmall)
|
||||
const recognizerConfig = {
|
||||
modelConfig: {
|
||||
senseVoice: {
|
||||
model: modelPath,
|
||||
useInverseTextNormalization: 1
|
||||
},
|
||||
tokens: tokensPath,
|
||||
numThreads: 2,
|
||||
debug: 0
|
||||
}
|
||||
}
|
||||
const recognizer = new sherpa.OfflineRecognizer(recognizerConfig)
|
||||
|
||||
// 2. 处理音频数据 (全量识别)
|
||||
const pcmData = wavData.slice(44)
|
||||
const samples = new Float32Array(pcmData.length / 2)
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
samples[i] = pcmData.readInt16LE(i * 2) / 32768.0
|
||||
}
|
||||
|
||||
const stream = recognizer.createStream()
|
||||
stream.acceptWaveform({ sampleRate, samples })
|
||||
recognizer.decode(stream)
|
||||
const result = recognizer.getResult(stream)
|
||||
|
||||
console.log('[TranscribeWorker] 识别完成 - 结果对象:', JSON.stringify(result, null, 2))
|
||||
|
||||
// 3. 检查语言是否在白名单中
|
||||
if (isLanguageAllowed(result, allowedLanguages)) {
|
||||
const processedText = richTranscribePostProcess(result.text)
|
||||
console.log('[TranscribeWorker] 语言匹配,返回文本:', processedText)
|
||||
parentPort.postMessage({ type: 'final', text: processedText })
|
||||
} else {
|
||||
console.log('[TranscribeWorker] 语言不匹配,返回空文本')
|
||||
parentPort.postMessage({ type: 'final', text: '' })
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
parentPort.postMessage({ type: 'error', error: String(error) })
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
|
||||
4
electron/types/sherpa-onnx-node.d.ts
vendored
Normal file
4
electron/types/sherpa-onnx-node.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
declare module 'sherpa-onnx-node' {
|
||||
const content: any;
|
||||
export = content;
|
||||
}
|
||||
22
electron/types/whisper-node.d.ts
vendored
Normal file
22
electron/types/whisper-node.d.ts
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
declare module 'whisper-node' {
|
||||
export type WhisperSegment = {
|
||||
start: string
|
||||
end: string
|
||||
speech: string
|
||||
}
|
||||
|
||||
export type WhisperOptions = {
|
||||
modelName?: string
|
||||
modelPath?: string
|
||||
whisperOptions?: {
|
||||
language?: string
|
||||
gen_file_txt?: boolean
|
||||
gen_file_subtitle?: boolean
|
||||
gen_file_vtt?: boolean
|
||||
word_timestamps?: boolean
|
||||
timestamp_size?: number
|
||||
}
|
||||
}
|
||||
|
||||
export default function whisper(filePath: string, options?: WhisperOptions): Promise<WhisperSegment[]>
|
||||
}
|
||||
128
electron/wcdbWorker.ts
Normal file
128
electron/wcdbWorker.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { parentPort, workerData } from 'worker_threads'
|
||||
import { WcdbCore } from './services/wcdbCore'
|
||||
|
||||
const core = new WcdbCore()
|
||||
|
||||
if (parentPort) {
|
||||
parentPort.on('message', async (msg) => {
|
||||
const { id, type, payload } = msg
|
||||
|
||||
try {
|
||||
let result: any
|
||||
|
||||
switch (type) {
|
||||
case 'setPaths':
|
||||
core.setPaths(payload.resourcesPath, payload.userDataPath)
|
||||
result = { success: true }
|
||||
break
|
||||
case 'setLogEnabled':
|
||||
core.setLogEnabled(payload.enabled)
|
||||
result = { success: true }
|
||||
break
|
||||
case 'testConnection':
|
||||
result = await core.testConnection(payload.dbPath, payload.hexKey, payload.wxid)
|
||||
break
|
||||
case 'open':
|
||||
result = await core.open(payload.dbPath, payload.hexKey, payload.wxid)
|
||||
break
|
||||
case 'close':
|
||||
core.close()
|
||||
result = { success: true }
|
||||
break
|
||||
case 'isConnected':
|
||||
result = core.isConnected()
|
||||
break
|
||||
case 'getSessions':
|
||||
result = await core.getSessions()
|
||||
break
|
||||
case 'getMessages':
|
||||
result = await core.getMessages(payload.sessionId, payload.limit, payload.offset)
|
||||
break
|
||||
case 'getMessageCount':
|
||||
result = await core.getMessageCount(payload.sessionId)
|
||||
break
|
||||
case 'getDisplayNames':
|
||||
result = await core.getDisplayNames(payload.usernames)
|
||||
break
|
||||
case 'getAvatarUrls':
|
||||
result = await core.getAvatarUrls(payload.usernames)
|
||||
break
|
||||
case 'getGroupMemberCount':
|
||||
result = await core.getGroupMemberCount(payload.chatroomId)
|
||||
break
|
||||
case 'getGroupMemberCounts':
|
||||
result = await core.getGroupMemberCounts(payload.chatroomIds)
|
||||
break
|
||||
case 'getGroupMembers':
|
||||
result = await core.getGroupMembers(payload.chatroomId)
|
||||
break
|
||||
case 'getMessageTables':
|
||||
result = await core.getMessageTables(payload.sessionId)
|
||||
break
|
||||
case 'getMessageTableStats':
|
||||
result = await core.getMessageTableStats(payload.sessionId)
|
||||
break
|
||||
case 'getMessageMeta':
|
||||
result = await core.getMessageMeta(payload.dbPath, payload.tableName, payload.limit, payload.offset)
|
||||
break
|
||||
case 'getContact':
|
||||
result = await core.getContact(payload.username)
|
||||
break
|
||||
case 'getAggregateStats':
|
||||
result = await core.getAggregateStats(payload.sessionIds, payload.beginTimestamp, payload.endTimestamp)
|
||||
break
|
||||
case 'getAvailableYears':
|
||||
result = await core.getAvailableYears(payload.sessionIds)
|
||||
break
|
||||
case 'getAnnualReportStats':
|
||||
result = await core.getAnnualReportStats(payload.sessionIds, payload.beginTimestamp, payload.endTimestamp)
|
||||
break
|
||||
case 'getAnnualReportExtras':
|
||||
result = await core.getAnnualReportExtras(payload.sessionIds, payload.beginTimestamp, payload.endTimestamp, payload.peakDayBegin, payload.peakDayEnd)
|
||||
break
|
||||
case 'getGroupStats':
|
||||
result = await core.getGroupStats(payload.chatroomId, payload.beginTimestamp, payload.endTimestamp)
|
||||
break
|
||||
case 'openMessageCursor':
|
||||
result = await core.openMessageCursor(payload.sessionId, payload.batchSize, payload.ascending, payload.beginTimestamp, payload.endTimestamp)
|
||||
break
|
||||
case 'openMessageCursorLite':
|
||||
result = await core.openMessageCursorLite(payload.sessionId, payload.batchSize, payload.ascending, payload.beginTimestamp, payload.endTimestamp)
|
||||
break
|
||||
case 'fetchMessageBatch':
|
||||
result = await core.fetchMessageBatch(payload.cursor)
|
||||
break
|
||||
case 'closeMessageCursor':
|
||||
result = await core.closeMessageCursor(payload.cursor)
|
||||
break
|
||||
case 'execQuery':
|
||||
result = await core.execQuery(payload.kind, payload.path, payload.sql)
|
||||
break
|
||||
case 'getEmoticonCdnUrl':
|
||||
result = await core.getEmoticonCdnUrl(payload.dbPath, payload.md5)
|
||||
break
|
||||
case 'listMessageDbs':
|
||||
result = await core.listMessageDbs()
|
||||
break
|
||||
case 'listMediaDbs':
|
||||
result = await core.listMediaDbs()
|
||||
break
|
||||
case 'getMessageById':
|
||||
result = await core.getMessageById(payload.sessionId, payload.localId)
|
||||
break
|
||||
case 'getVoiceData':
|
||||
result = await core.getVoiceData(payload.sessionId, payload.createTime, payload.candidates, payload.localId, payload.svrId)
|
||||
if (!result.success) {
|
||||
console.error('[wcdbWorker] getVoiceData failed:', result.error)
|
||||
}
|
||||
break
|
||||
default:
|
||||
result = { success: false, error: `Unknown method: ${type}` }
|
||||
}
|
||||
|
||||
parentPort!.postMessage({ id, result })
|
||||
} catch (e) {
|
||||
parentPort!.postMessage({ id, error: String(e) })
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
ManifestDPIAware true
|
||||
|
||||
!include "WordFunc.nsh"
|
||||
!include "nsDialogs.nsh"
|
||||
|
||||
!macro customInit
|
||||
; 设置 DPI 感知
|
||||
@@ -16,3 +17,49 @@ ManifestDPIAware true
|
||||
StrCpy $INSTDIR "$INSTDIR\WeFlow"
|
||||
${EndIf}
|
||||
!macroend
|
||||
|
||||
; 安装完成后检测并安装 VC++ Redistributable
|
||||
!macro customInstall
|
||||
; 检查 VC++ 2015-2022 x64 是否已安装
|
||||
ReadRegStr $0 HKLM "SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64" "Installed"
|
||||
${If} $0 != "1"
|
||||
; 未安装,显示提示并下载
|
||||
MessageBox MB_YESNO|MB_ICONQUESTION "检测到系统缺少 Visual C++ 运行库,这可能导致程序无法正常运行。$\n$\n是否立即下载并安装?(约 24MB)" IDYES downloadVC IDNO skipVC
|
||||
|
||||
downloadVC:
|
||||
DetailPrint "正在下载 Visual C++ Redistributable..."
|
||||
SetOutPath "$TEMP"
|
||||
|
||||
; 从微软官方下载 VC++ Redistributable x64
|
||||
inetc::get /TIMEOUT=30000 /CAPTION "下载 Visual C++ 运行库" /BANNER "正在下载,请稍候..." \
|
||||
"https://aka.ms/vs/17/release/vc_redist.x64.exe" "$TEMP\vc_redist.x64.exe" /END
|
||||
Pop $0
|
||||
|
||||
${If} $0 == "OK"
|
||||
DetailPrint "下载完成,正在安装..."
|
||||
; 使用 ShellExecute 以管理员权限运行
|
||||
ExecShell "runas" '"$TEMP\vc_redist.x64.exe"' "/install /quiet /norestart" SW_HIDE
|
||||
; 等待安装完成
|
||||
Sleep 5000
|
||||
; 检查是否安装成功
|
||||
ReadRegStr $1 HKLM "SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64" "Installed"
|
||||
${If} $1 == "1"
|
||||
DetailPrint "Visual C++ Redistributable 安装成功"
|
||||
MessageBox MB_OK|MB_ICONINFORMATION "Visual C++ 运行库安装成功!"
|
||||
${Else}
|
||||
MessageBox MB_OK|MB_ICONEXCLAMATION "Visual C++ 运行库安装失败,您可能需要手动安装。"
|
||||
${EndIf}
|
||||
Delete "$TEMP\vc_redist.x64.exe"
|
||||
${Else}
|
||||
MessageBox MB_OK|MB_ICONEXCLAMATION "下载失败:$0$\n$\n您可以稍后手动下载安装 Visual C++ Redistributable。"
|
||||
${EndIf}
|
||||
Goto doneVC
|
||||
|
||||
skipVC:
|
||||
DetailPrint "用户跳过 Visual C++ Redistributable 安装"
|
||||
|
||||
doneVC:
|
||||
${Else}
|
||||
DetailPrint "Visual C++ Redistributable 已安装"
|
||||
${EndIf}
|
||||
!macroend
|
||||
|
||||
609
package-lock.json
generated
609
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "weflow",
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "weflow",
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^12.5.0",
|
||||
@@ -14,6 +14,8 @@
|
||||
"echarts-for-react": "^3.0.2",
|
||||
"electron-store": "^10.0.0",
|
||||
"electron-updater": "^6.3.9",
|
||||
"exceljs": "^4.4.0",
|
||||
"ffmpeg-static": "^5.3.0",
|
||||
"fzstd": "^0.1.1",
|
||||
"html2canvas": "^1.4.1",
|
||||
"jieba-wasm": "^2.2.0",
|
||||
@@ -23,6 +25,8 @@
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-router-dom": "^7.1.1",
|
||||
"sherpa-onnx-node": "^1.10.38",
|
||||
"silk-wasm": "^3.7.1",
|
||||
"wechat-emojis": "^1.0.2",
|
||||
"zustand": "^5.0.2"
|
||||
},
|
||||
@@ -344,6 +348,21 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@derhuerst/http-basic": {
|
||||
"version": "8.2.4",
|
||||
"resolved": "https://registry.npmmirror.com/@derhuerst/http-basic/-/http-basic-8.2.4.tgz",
|
||||
"integrity": "sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"caseless": "^0.12.0",
|
||||
"concat-stream": "^2.0.0",
|
||||
"http-response-object": "^3.0.1",
|
||||
"parse-cache-control": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@develar/schema-utils": {
|
||||
"version": "2.6.5",
|
||||
"resolved": "https://registry.npmmirror.com/@develar/schema-utils/-/schema-utils-2.6.5.tgz",
|
||||
@@ -1124,6 +1143,47 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@fast-csv/format": {
|
||||
"version": "4.3.5",
|
||||
"resolved": "https://registry.npmmirror.com/@fast-csv/format/-/format-4.3.5.tgz",
|
||||
"integrity": "sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "^14.0.1",
|
||||
"lodash.escaperegexp": "^4.1.2",
|
||||
"lodash.isboolean": "^3.0.3",
|
||||
"lodash.isequal": "^4.5.0",
|
||||
"lodash.isfunction": "^3.0.9",
|
||||
"lodash.isnil": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fast-csv/format/node_modules/@types/node": {
|
||||
"version": "14.18.63",
|
||||
"resolved": "https://registry.npmmirror.com/@types/node/-/node-14.18.63.tgz",
|
||||
"integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@fast-csv/parse": {
|
||||
"version": "4.3.6",
|
||||
"resolved": "https://registry.npmmirror.com/@fast-csv/parse/-/parse-4.3.6.tgz",
|
||||
"integrity": "sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "^14.0.1",
|
||||
"lodash.escaperegexp": "^4.1.2",
|
||||
"lodash.groupby": "^4.6.0",
|
||||
"lodash.isfunction": "^3.0.9",
|
||||
"lodash.isnil": "^4.0.0",
|
||||
"lodash.isundefined": "^3.0.1",
|
||||
"lodash.uniq": "^4.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fast-csv/parse/node_modules/@types/node": {
|
||||
"version": "14.18.63",
|
||||
"resolved": "https://registry.npmmirror.com/@types/node/-/node-14.18.63.tgz",
|
||||
"integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@gar/promisify": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmmirror.com/@gar/promisify/-/promisify-1.1.3.tgz",
|
||||
@@ -3564,9 +3624,7 @@
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmmirror.com/archiver/-/archiver-5.3.2.tgz",
|
||||
"integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"archiver-utils": "^2.1.0",
|
||||
"async": "^3.2.4",
|
||||
@@ -3584,9 +3642,7 @@
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/archiver-utils/-/archiver-utils-2.1.0.tgz",
|
||||
"integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"glob": "^7.1.4",
|
||||
"graceful-fs": "^4.2.0",
|
||||
@@ -3607,9 +3663,7 @@
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
@@ -3624,17 +3678,13 @@
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/archiver-utils/node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
@@ -3686,7 +3736,6 @@
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmmirror.com/async/-/async-3.2.6.tgz",
|
||||
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/async-exit-hook": {
|
||||
@@ -3730,7 +3779,6 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base64-arraybuffer": {
|
||||
@@ -3786,6 +3834,28 @@
|
||||
"node": "20.x || 22.x || 23.x || 24.x || 25.x"
|
||||
}
|
||||
},
|
||||
"node_modules/big-integer": {
|
||||
"version": "1.6.52",
|
||||
"resolved": "https://registry.npmmirror.com/big-integer/-/big-integer-1.6.52.tgz",
|
||||
"integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==",
|
||||
"license": "Unlicense",
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/binary": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/binary/-/binary-0.3.0.tgz",
|
||||
"integrity": "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffers": "~0.1.1",
|
||||
"chainsaw": "~0.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/bindings": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmmirror.com/bindings/-/bindings-1.5.0.tgz",
|
||||
@@ -3836,7 +3906,6 @@
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
@@ -3919,7 +3988,6 @@
|
||||
"version": "0.2.13",
|
||||
"resolved": "https://registry.npmmirror.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
|
||||
"integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
@@ -3929,9 +3997,25 @@
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/buffer-indexof-polyfill": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz",
|
||||
"integrity": "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/buffers": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/buffers/-/buffers-0.1.1.tgz",
|
||||
"integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==",
|
||||
"engines": {
|
||||
"node": ">=0.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/builder-util": {
|
||||
"version": "25.1.7",
|
||||
"resolved": "https://registry.npmmirror.com/builder-util/-/builder-util-25.1.7.tgz",
|
||||
@@ -4188,6 +4272,24 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/caseless": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmmirror.com/caseless/-/caseless-0.12.0.tgz",
|
||||
"integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/chainsaw": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/chainsaw/-/chainsaw-0.1.0.tgz",
|
||||
"integrity": "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==",
|
||||
"license": "MIT/X11",
|
||||
"dependencies": {
|
||||
"traverse": ">=0.3.0 <0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz",
|
||||
@@ -4413,9 +4515,7 @@
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/compress-commons/-/compress-commons-4.1.2.tgz",
|
||||
"integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"buffer-crc32": "^0.2.13",
|
||||
"crc32-stream": "^4.0.2",
|
||||
@@ -4430,9 +4530,23 @@
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz",
|
||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concat-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||
"engines": [
|
||||
"node >= 6.0"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.0.2",
|
||||
"typedarray": "^0.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/conf": {
|
||||
"version": "14.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/conf/-/conf-14.0.0.tgz",
|
||||
@@ -4596,9 +4710,7 @@
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmmirror.com/crc-32/-/crc-32-1.2.2.tgz",
|
||||
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"crc32": "bin/crc32.njs"
|
||||
},
|
||||
@@ -4610,9 +4722,7 @@
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/crc32-stream/-/crc32-stream-4.0.3.tgz",
|
||||
"integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"crc-32": "^1.2.0",
|
||||
"readable-stream": "^3.4.0"
|
||||
@@ -4652,6 +4762,12 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dayjs": {
|
||||
"version": "1.11.19",
|
||||
"resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.19.tgz",
|
||||
"integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debounce-fn": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/debounce-fn/-/debounce-fn-6.0.0.tgz",
|
||||
@@ -4981,6 +5097,45 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/duplexer2": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmmirror.com/duplexer2/-/duplexer2-0.1.4.tgz",
|
||||
"integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"readable-stream": "^2.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/duplexer2/node_modules/readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
"isarray": "~1.0.0",
|
||||
"process-nextick-args": "~2.0.0",
|
||||
"safe-buffer": "~5.1.1",
|
||||
"string_decoder": "~1.1.1",
|
||||
"util-deprecate": "~1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/duplexer2/node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/duplexer2/node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eastasianwidth": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
||||
@@ -5355,7 +5510,6 @@
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmmirror.com/env-paths/-/env-paths-2.2.1.tgz",
|
||||
"integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
@@ -5491,6 +5645,26 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/exceljs": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmmirror.com/exceljs/-/exceljs-4.4.0.tgz",
|
||||
"integrity": "sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"archiver": "^5.0.0",
|
||||
"dayjs": "^1.8.34",
|
||||
"fast-csv": "^4.3.1",
|
||||
"jszip": "^3.10.1",
|
||||
"readable-stream": "^3.6.0",
|
||||
"saxes": "^5.0.1",
|
||||
"tmp": "^0.2.0",
|
||||
"unzipper": "^0.10.11",
|
||||
"uuid": "^8.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/expand-template": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/expand-template/-/expand-template-2.0.3.tgz",
|
||||
@@ -5539,6 +5713,19 @@
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/fast-csv": {
|
||||
"version": "4.3.6",
|
||||
"resolved": "https://registry.npmmirror.com/fast-csv/-/fast-csv-4.3.6.tgz",
|
||||
"integrity": "sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fast-csv/format": "4.3.5",
|
||||
"@fast-csv/parse": "4.3.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
@@ -5578,6 +5765,47 @@
|
||||
"pend": "~1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ffmpeg-static": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/ffmpeg-static/-/ffmpeg-static-5.3.0.tgz",
|
||||
"integrity": "sha512-H+K6sW6TiIX6VGend0KQwthe+kaceeH/luE8dIZyOP35ik7ahYojDuqlTV1bOrtEwl01sy2HFNGQfi5IDJvotg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "GPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@derhuerst/http-basic": "^8.2.0",
|
||||
"env-paths": "^2.2.0",
|
||||
"https-proxy-agent": "^5.0.0",
|
||||
"progress": "^2.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/ffmpeg-static/node_modules/agent-base": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-6.0.2.tgz",
|
||||
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ffmpeg-static/node_modules/https-proxy-agent": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "6",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/file-uri-to-path": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
||||
@@ -5716,7 +5944,6 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
@@ -5734,6 +5961,47 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fstream": {
|
||||
"version": "1.0.12",
|
||||
"resolved": "https://registry.npmmirror.com/fstream/-/fstream-1.0.12.tgz",
|
||||
"integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==",
|
||||
"deprecated": "This package is no longer supported.",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.1.2",
|
||||
"inherits": "~2.0.0",
|
||||
"mkdirp": ">=0.5 0",
|
||||
"rimraf": "2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/fstream/node_modules/mkdirp": {
|
||||
"version": "0.5.6",
|
||||
"resolved": "https://registry.npmmirror.com/mkdirp/-/mkdirp-0.5.6.tgz",
|
||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.6"
|
||||
},
|
||||
"bin": {
|
||||
"mkdirp": "bin/cmd.js"
|
||||
}
|
||||
},
|
||||
"node_modules/fstream/node_modules/rimraf": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmmirror.com/rimraf/-/rimraf-2.7.1.tgz",
|
||||
"integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==",
|
||||
"deprecated": "Rimraf versions prior to v4 are no longer supported",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"glob": "^7.1.3"
|
||||
},
|
||||
"bin": {
|
||||
"rimraf": "bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz",
|
||||
@@ -5857,7 +6125,6 @@
|
||||
"resolved": "https://registry.npmmirror.com/glob/-/glob-7.2.3.tgz",
|
||||
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
|
||||
"deprecated": "Glob versions prior to v9 are no longer supported",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"fs.realpath": "^1.0.0",
|
||||
@@ -5878,7 +6145,6 @@
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
@@ -6109,6 +6375,21 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/http-response-object": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/http-response-object/-/http-response-object-3.0.2.tgz",
|
||||
"integrity": "sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "^10.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/http-response-object/node_modules/@types/node": {
|
||||
"version": "10.17.60",
|
||||
"resolved": "https://registry.npmmirror.com/@types/node/-/node-10.17.60.tgz",
|
||||
"integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/http2-wrapper": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/http2-wrapper/-/http2-wrapper-1.0.3.tgz",
|
||||
@@ -6243,7 +6524,6 @@
|
||||
"resolved": "https://registry.npmmirror.com/inflight/-/inflight-1.0.6.tgz",
|
||||
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
|
||||
"deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"once": "^1.3.0",
|
||||
@@ -6582,9 +6862,7 @@
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/lazystream/-/lazystream-1.0.1.tgz",
|
||||
"integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"readable-stream": "^2.0.5"
|
||||
},
|
||||
@@ -6596,9 +6874,7 @@
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
@@ -6613,17 +6889,13 @@
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lazystream/node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
@@ -6637,6 +6909,12 @@
|
||||
"immediate": "~3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/listenercount": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/listenercount/-/listenercount-1.0.1.tgz",
|
||||
"integrity": "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz",
|
||||
@@ -6648,17 +6926,13 @@
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
|
||||
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.difference": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmmirror.com/lodash.difference/-/lodash.difference-4.5.0.tgz",
|
||||
"integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.escaperegexp": {
|
||||
"version": "4.1.2",
|
||||
@@ -6670,9 +6944,19 @@
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmmirror.com/lodash.flatten/-/lodash.flatten-4.4.0.tgz",
|
||||
"integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.groupby": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmmirror.com/lodash.groupby/-/lodash.groupby-4.6.0.tgz",
|
||||
"integrity": "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isboolean": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
||||
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isequal": {
|
||||
"version": "4.5.0",
|
||||
@@ -6681,21 +6965,41 @@
|
||||
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isfunction": {
|
||||
"version": "3.0.9",
|
||||
"resolved": "https://registry.npmmirror.com/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz",
|
||||
"integrity": "sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isnil": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/lodash.isnil/-/lodash.isnil-4.0.0.tgz",
|
||||
"integrity": "sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isplainobject": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmmirror.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
||||
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isundefined": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/lodash.isundefined/-/lodash.isundefined-3.0.1.tgz",
|
||||
"integrity": "sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.union": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmmirror.com/lodash.union/-/lodash.union-4.6.0.tgz",
|
||||
"integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.uniq": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmmirror.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz",
|
||||
"integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/log-symbols": {
|
||||
"version": "4.1.0",
|
||||
@@ -7257,9 +7561,7 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -7406,11 +7708,15 @@
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/parse-cache-control": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/parse-cache-control/-/parse-cache-control-1.0.1.tgz",
|
||||
"integrity": "sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg=="
|
||||
},
|
||||
"node_modules/path-is-absolute": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -7595,7 +7901,6 @@
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/progress/-/progress-2.0.3.tgz",
|
||||
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
@@ -7770,9 +8075,7 @@
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmmirror.com/readdir-glob/-/readdir-glob-1.1.3.tgz",
|
||||
"integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"minimatch": "^5.1.0"
|
||||
}
|
||||
@@ -7781,9 +8084,7 @@
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
@@ -7792,9 +8093,7 @@
|
||||
"version": "5.1.6",
|
||||
"resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-5.1.6.tgz",
|
||||
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
},
|
||||
@@ -8042,6 +8341,18 @@
|
||||
"integrity": "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==",
|
||||
"license": "BlueOak-1.0.0"
|
||||
},
|
||||
"node_modules/saxes": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/saxes/-/saxes-5.0.1.tgz",
|
||||
"integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"xmlchars": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.27.0.tgz",
|
||||
@@ -8186,6 +8497,78 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/sherpa-onnx-darwin-arm64": {
|
||||
"version": "1.12.23",
|
||||
"resolved": "https://registry.npmmirror.com/sherpa-onnx-darwin-arm64/-/sherpa-onnx-darwin-arm64-1.12.23.tgz",
|
||||
"integrity": "sha512-zbjNUUH/IXhjRyRJ9mpcWVOGIVr31a/qXBPsfOYc7U8cgwcq33Vmj2OzoLYWQF6T+puqCAE4nMxFAxJvdZekhg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/sherpa-onnx-linux-x64": {
|
||||
"version": "1.12.23",
|
||||
"resolved": "https://registry.npmmirror.com/sherpa-onnx-linux-x64/-/sherpa-onnx-linux-x64-1.12.23.tgz",
|
||||
"integrity": "sha512-pUZIdDvPtyRXQDGo9R9MIBf2AFUzfgcGmutoulsEdH3hpK6JteR7Z/5pfrZIIqe/O99djAjEHK4AlwLHC2jiZw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/sherpa-onnx-node": {
|
||||
"version": "1.12.23",
|
||||
"resolved": "https://registry.npmmirror.com/sherpa-onnx-node/-/sherpa-onnx-node-1.12.23.tgz",
|
||||
"integrity": "sha512-09SRixVSjsajxeCV8Hy9R5J4IHPtw7vNgaIcEokdh/LpU7sY+e12z9uHHIMMMgNiInyGEH74wIwjLXms+W7qRA==",
|
||||
"license": "Apache-2.0",
|
||||
"optionalDependencies": {
|
||||
"sherpa-onnx-darwin-arm64": "^1.12.23",
|
||||
"sherpa-onnx-darwin-x64": "^1.12.23",
|
||||
"sherpa-onnx-linux-arm64": "^1.12.23",
|
||||
"sherpa-onnx-linux-x64": "^1.12.23",
|
||||
"sherpa-onnx-win-ia32": "^1.12.23",
|
||||
"sherpa-onnx-win-x64": "^1.12.23"
|
||||
}
|
||||
},
|
||||
"node_modules/sherpa-onnx-node/node_modules/sherpa-onnx-darwin-x64": {
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/sherpa-onnx-node/node_modules/sherpa-onnx-linux-arm64": {
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/sherpa-onnx-win-ia32": {
|
||||
"version": "1.12.23",
|
||||
"resolved": "https://registry.npmmirror.com/sherpa-onnx-win-ia32/-/sherpa-onnx-win-ia32-1.12.23.tgz",
|
||||
"integrity": "sha512-MyLsK7r6dd7paglyTgb8UHTXTEFqOzA91u6VDV64Lq8rDGuOFVYioxX7vlwmGe1A9o7VhuOPNaKcRjEPtVDhBQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/sherpa-onnx-win-x64": {
|
||||
"version": "1.12.23",
|
||||
"resolved": "https://registry.npmmirror.com/sherpa-onnx-win-x64/-/sherpa-onnx-win-x64-1.12.23.tgz",
|
||||
"integrity": "sha512-pdHEYMJiYy8+xzH2WkBVS4/hnRwqjY8FaWnjs0NBgQZnPmc/k4M+TAiauTOuFDNK4GPwFQnjwrCGx6jI9AOkOg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/signal-exit": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmmirror.com/signal-exit/-/signal-exit-3.0.7.tgz",
|
||||
@@ -8193,6 +8576,15 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/silk-wasm": {
|
||||
"version": "3.7.1",
|
||||
"resolved": "https://registry.npmmirror.com/silk-wasm/-/silk-wasm-3.7.1.tgz",
|
||||
"integrity": "sha512-mXPwLRtZxrYV3TZx41jMAeKc80wvmyrcXIcs8HctFxK15Ahz2OJQENYhNgEPeCEOdI6Mbx1NxQsqxzwc3DKerw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-concat": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/simple-concat/-/simple-concat-1.0.1.tgz",
|
||||
@@ -8731,7 +9123,6 @@
|
||||
"version": "0.2.5",
|
||||
"resolved": "https://registry.npmmirror.com/tmp/-/tmp-0.2.5.tgz",
|
||||
"integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.14"
|
||||
@@ -8761,6 +9152,15 @@
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/traverse": {
|
||||
"version": "0.3.9",
|
||||
"resolved": "https://registry.npmmirror.com/traverse/-/traverse-0.3.9.tgz",
|
||||
"integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==",
|
||||
"license": "MIT/X11",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/truncate-utf8-bytes": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz",
|
||||
@@ -8801,6 +9201,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/typedarray": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmmirror.com/typedarray/-/typedarray-0.0.6.tgz",
|
||||
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz",
|
||||
@@ -8870,6 +9276,60 @@
|
||||
"node": ">= 4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unzipper": {
|
||||
"version": "0.10.14",
|
||||
"resolved": "https://registry.npmmirror.com/unzipper/-/unzipper-0.10.14.tgz",
|
||||
"integrity": "sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"big-integer": "^1.6.17",
|
||||
"binary": "~0.3.0",
|
||||
"bluebird": "~3.4.1",
|
||||
"buffer-indexof-polyfill": "~1.0.0",
|
||||
"duplexer2": "~0.1.4",
|
||||
"fstream": "^1.0.12",
|
||||
"graceful-fs": "^4.2.2",
|
||||
"listenercount": "~1.0.1",
|
||||
"readable-stream": "~2.3.6",
|
||||
"setimmediate": "~1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/unzipper/node_modules/bluebird": {
|
||||
"version": "3.4.7",
|
||||
"resolved": "https://registry.npmmirror.com/bluebird/-/bluebird-3.4.7.tgz",
|
||||
"integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unzipper/node_modules/readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
"isarray": "~1.0.0",
|
||||
"process-nextick-args": "~2.0.0",
|
||||
"safe-buffer": "~5.1.1",
|
||||
"string_decoder": "~1.1.1",
|
||||
"util-deprecate": "~1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/unzipper/node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unzipper/node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||
@@ -8933,6 +9393,15 @@
|
||||
"base64-arraybuffer": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmmirror.com/uuid/-/uuid-8.3.2.tgz",
|
||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/verror": {
|
||||
"version": "1.10.1",
|
||||
"resolved": "https://registry.npmmirror.com/verror/-/verror-1.10.1.tgz",
|
||||
@@ -9181,6 +9650,12 @@
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xmlchars": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmmirror.com/y18n/-/y18n-5.0.8.tgz",
|
||||
@@ -9255,9 +9730,7 @@
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/zip-stream/-/zip-stream-4.1.1.tgz",
|
||||
"integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"archiver-utils": "^3.0.4",
|
||||
"compress-commons": "^4.1.2",
|
||||
@@ -9271,9 +9744,7 @@
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmmirror.com/archiver-utils/-/archiver-utils-3.0.4.tgz",
|
||||
"integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"glob": "^7.2.3",
|
||||
"graceful-fs": "^4.2.0",
|
||||
|
||||
18
package.json
18
package.json
@@ -1,16 +1,17 @@
|
||||
{
|
||||
"name": "weflow",
|
||||
"version": "1.0.1",
|
||||
"description": "WeFlow - 微信聊天记录查看工具",
|
||||
"version": "1.2.0",
|
||||
"description": "WeFlow",
|
||||
"main": "dist-electron/main.js",
|
||||
"author": "cc",
|
||||
"scripts": {
|
||||
"postinstall": "echo 'No native modules to rebuild'",
|
||||
"rebuild": "echo 'No native modules to rebuild'",
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build && electron-builder",
|
||||
"preview": "vite preview",
|
||||
"electron:dev": "vite --mode electron",
|
||||
"electron:build": "npm run build",
|
||||
"postinstall": "electron-rebuild"
|
||||
"electron:build": "npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^12.5.0",
|
||||
@@ -18,6 +19,8 @@
|
||||
"echarts-for-react": "^3.0.2",
|
||||
"electron-store": "^10.0.0",
|
||||
"electron-updater": "^6.3.9",
|
||||
"exceljs": "^4.4.0",
|
||||
"ffmpeg-static": "^5.3.0",
|
||||
"fzstd": "^0.1.1",
|
||||
"html2canvas": "^1.4.1",
|
||||
"jieba-wasm": "^2.2.0",
|
||||
@@ -27,6 +30,8 @@
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-router-dom": "^7.1.1",
|
||||
"sherpa-onnx-node": "^1.10.38",
|
||||
"silk-wasm": "^3.7.1",
|
||||
"wechat-emojis": "^1.0.2",
|
||||
"zustand": "^5.0.2"
|
||||
},
|
||||
@@ -64,6 +69,7 @@
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"differentialPackage": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"createDesktopShortcut": true,
|
||||
"unicode": true,
|
||||
@@ -95,6 +101,10 @@
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"dist-electron/**/*"
|
||||
],
|
||||
"asarUnpack": [
|
||||
"node_modules/silk-wasm/**/*",
|
||||
"node_modules/sherpa-onnx-node/**/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
BIN
resources/SDL2.dll
Normal file
BIN
resources/SDL2.dll
Normal file
Binary file not shown.
Binary file not shown.
@@ -7,6 +7,7 @@ import WelcomePage from './pages/WelcomePage'
|
||||
import HomePage from './pages/HomePage'
|
||||
import ChatPage from './pages/ChatPage'
|
||||
import AnalyticsPage from './pages/AnalyticsPage'
|
||||
import AnalyticsWelcomePage from './pages/AnalyticsWelcomePage'
|
||||
import AnnualReportPage from './pages/AnnualReportPage'
|
||||
import AnnualReportWindow from './pages/AnnualReportWindow'
|
||||
import AgreementPage from './pages/AgreementPage'
|
||||
@@ -14,6 +15,7 @@ import GroupAnalyticsPage from './pages/GroupAnalyticsPage'
|
||||
import DataManagementPage from './pages/DataManagementPage'
|
||||
import SettingsPage from './pages/SettingsPage'
|
||||
import ExportPage from './pages/ExportPage'
|
||||
|
||||
import { useAppStore } from './stores/appStore'
|
||||
import { themes, useThemeStore, type ThemeId } from './stores/themeStore'
|
||||
import * as configService from './services/config'
|
||||
@@ -307,7 +309,8 @@ function App() {
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/home" element={<HomePage />} />
|
||||
<Route path="/chat" element={<ChatPage />} />
|
||||
<Route path="/analytics" element={<AnalyticsPage />} />
|
||||
<Route path="/analytics" element={<AnalyticsWelcomePage />} />
|
||||
<Route path="/analytics/view" element={<AnalyticsPage />} />
|
||||
<Route path="/group-analytics" element={<GroupAnalyticsPage />} />
|
||||
<Route path="/annual-report" element={<AnnualReportPage />} />
|
||||
<Route path="/annual-report/view" element={<AnnualReportWindow />} />
|
||||
|
||||
73
src/components/AnimatedStreamingText.tsx
Normal file
73
src/components/AnimatedStreamingText.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import React, { memo, useEffect, useState, useRef } from 'react'
|
||||
|
||||
interface AnimatedStreamingTextProps {
|
||||
text: string
|
||||
className?: string
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export const AnimatedStreamingText = memo(({ text, className, loading }: AnimatedStreamingTextProps) => {
|
||||
const [displayedSegments, setDisplayedSegments] = useState<string[]>([])
|
||||
const prevTextRef = useRef('')
|
||||
|
||||
useEffect(() => {
|
||||
const currentText = (text || '').trim()
|
||||
const prevText = prevTextRef.current
|
||||
|
||||
if (currentText === prevText) return
|
||||
if (!currentText.startsWith(prevText) && prevText !== '') {
|
||||
// 如果不是追加而是全新的文本(比如重新识别),则重置
|
||||
setDisplayedSegments([currentText])
|
||||
prevTextRef.current = currentText
|
||||
return
|
||||
}
|
||||
|
||||
const newPart = currentText.slice(prevText.length)
|
||||
if (newPart) {
|
||||
// 将新部分作为单独的段加入,以触发动画
|
||||
setDisplayedSegments(prev => [...prev, newPart])
|
||||
}
|
||||
prevTextRef.current = currentText
|
||||
}, [text])
|
||||
|
||||
// 处理 loading 状态的显示
|
||||
if (loading && !text) {
|
||||
return <span className={className}>转写中<span className="dot-flashing">...</span></span>
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={className}>
|
||||
{displayedSegments.map((segment, index) => (
|
||||
<span key={index} className="fade-in-text">
|
||||
{segment}
|
||||
</span>
|
||||
))}
|
||||
<style>{`
|
||||
.fade-in-text {
|
||||
animation: premiumFadeIn 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
opacity: 0;
|
||||
display: inline-block;
|
||||
filter: blur(4px);
|
||||
}
|
||||
@keyframes premiumFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px) scale(0.98);
|
||||
filter: blur(4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
.dot-flashing {
|
||||
animation: blink 1s infinite;
|
||||
}
|
||||
@keyframes blink { 50% { opacity: 0; } }
|
||||
`}</style>
|
||||
</span>
|
||||
)
|
||||
})
|
||||
|
||||
AnimatedStreamingText.displayName = 'AnimatedStreamingText'
|
||||
79
src/components/Avatar.scss
Normal file
79
src/components/Avatar.scss
Normal file
@@ -0,0 +1,79 @@
|
||||
.avatar-component {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
background-color: var(--bg-tertiary, #f5f5f5);
|
||||
flex-shrink: 0;
|
||||
border-radius: 4px;
|
||||
/* Default radius */
|
||||
|
||||
&.circle {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
&.rounded {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* Image styling */
|
||||
img.avatar-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease-in-out;
|
||||
border-radius: inherit;
|
||||
|
||||
&.loaded {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&.instant {
|
||||
transition: none !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Placeholder/Letter styling */
|
||||
.avatar-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #666);
|
||||
background-color: var(--bg-tertiary, #e0e0e0);
|
||||
font-size: 1.2em;
|
||||
text-transform: uppercase;
|
||||
user-select: none;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
/* Loading Skeleton */
|
||||
.avatar-skeleton {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg,
|
||||
var(--bg-tertiary, #f0f0f0) 25%,
|
||||
var(--bg-secondary, #e0e0e0) 50%,
|
||||
var(--bg-tertiary, #f0f0f0) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
z-index: 1;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
129
src/components/Avatar.tsx
Normal file
129
src/components/Avatar.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import { User } from 'lucide-react'
|
||||
import { avatarLoadQueue } from '../utils/AvatarLoadQueue'
|
||||
import './Avatar.scss'
|
||||
|
||||
// 全局缓存已成功加载过的头像 URL,用于控制后续是否显示动画
|
||||
const loadedAvatarCache = new Set<string>()
|
||||
|
||||
interface AvatarProps {
|
||||
src?: string
|
||||
name?: string
|
||||
size?: number | string
|
||||
shape?: 'circle' | 'square' | 'rounded'
|
||||
className?: string
|
||||
lazy?: boolean
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
export const Avatar = React.memo(function Avatar({
|
||||
src,
|
||||
name,
|
||||
size = 48,
|
||||
shape = 'rounded',
|
||||
className = '',
|
||||
lazy = true,
|
||||
onClick
|
||||
}: AvatarProps) {
|
||||
// 如果 URL 已在缓存中,则直接标记为已加载,不显示骨架屏和淡入动画
|
||||
const isCached = useMemo(() => src ? loadedAvatarCache.has(src) : false, [src])
|
||||
const [imageLoaded, setImageLoaded] = useState(isCached)
|
||||
const [imageError, setImageError] = useState(false)
|
||||
const [shouldLoad, setShouldLoad] = useState(!lazy || isCached)
|
||||
const [isInQueue, setIsInQueue] = useState(false)
|
||||
const imgRef = useRef<HTMLImageElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const getAvatarLetter = (): string => {
|
||||
if (!name) return '?'
|
||||
const chars = [...name]
|
||||
return chars[0] || '?'
|
||||
}
|
||||
|
||||
// Intersection Observer for lazy loading
|
||||
useEffect(() => {
|
||||
if (!lazy || shouldLoad || isInQueue || !src || !containerRef.current || isCached) return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting && !isInQueue) {
|
||||
setIsInQueue(true)
|
||||
avatarLoadQueue.enqueue(src).then(() => {
|
||||
setShouldLoad(true)
|
||||
}).catch(() => {
|
||||
// 加载失败不要立刻显示错误,让浏览器渲染去报错
|
||||
setShouldLoad(true)
|
||||
}).finally(() => {
|
||||
setIsInQueue(false)
|
||||
})
|
||||
observer.disconnect()
|
||||
}
|
||||
})
|
||||
},
|
||||
{ rootMargin: '100px' }
|
||||
)
|
||||
|
||||
observer.observe(containerRef.current)
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [src, lazy, shouldLoad, isInQueue, isCached])
|
||||
|
||||
// Reset state when src changes
|
||||
useEffect(() => {
|
||||
const cached = src ? loadedAvatarCache.has(src) : false
|
||||
setImageLoaded(cached)
|
||||
setImageError(false)
|
||||
if (lazy && !cached) {
|
||||
setShouldLoad(false)
|
||||
setIsInQueue(false)
|
||||
} else {
|
||||
setShouldLoad(true)
|
||||
}
|
||||
}, [src, lazy])
|
||||
|
||||
// Check if image is already cached/loaded
|
||||
useEffect(() => {
|
||||
if (shouldLoad && imgRef.current?.complete && imgRef.current?.naturalWidth > 0) {
|
||||
setImageLoaded(true)
|
||||
}
|
||||
}, [src, shouldLoad])
|
||||
|
||||
const style = {
|
||||
width: typeof size === 'number' ? `${size}px` : size,
|
||||
height: typeof size === 'number' ? `${size}px` : size,
|
||||
}
|
||||
|
||||
const hasValidUrl = !!src && !imageError && shouldLoad
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`avatar-component ${shape} ${className}`}
|
||||
style={style}
|
||||
onClick={onClick}
|
||||
>
|
||||
{hasValidUrl ? (
|
||||
<>
|
||||
{!imageLoaded && <div className="avatar-skeleton" />}
|
||||
<img
|
||||
ref={imgRef}
|
||||
src={src}
|
||||
alt={name || 'avatar'}
|
||||
className={`avatar-image ${imageLoaded ? 'loaded' : ''} ${isCached ? 'instant' : ''}`}
|
||||
onLoad={() => {
|
||||
if (src) loadedAvatarCache.add(src)
|
||||
setImageLoaded(true)
|
||||
}}
|
||||
onError={() => setImageError(true)}
|
||||
loading={lazy ? "lazy" : "eager"}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="avatar-placeholder">
|
||||
{name ? <span className="avatar-letter">{getAvatarLetter()}</span> : <User size="50%" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
46
src/components/ImagePreview.scss
Normal file
46
src/components/ImagePreview.scss
Normal file
@@ -0,0 +1,46 @@
|
||||
.image-preview-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
object-fit: contain;
|
||||
transition: transform 0.15s ease-out;
|
||||
|
||||
&.dragging {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.image-preview-close {
|
||||
position: absolute;
|
||||
bottom: 40px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
backdrop-filter: blur(10px);
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
transform: translateX(-50%) scale(1.1);
|
||||
}
|
||||
}
|
||||
101
src/components/ImagePreview.tsx
Normal file
101
src/components/ImagePreview.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import React, { useState, useRef, useCallback, useEffect } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import './ImagePreview.scss'
|
||||
|
||||
interface ImagePreviewProps {
|
||||
src: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export const ImagePreview: React.FC<ImagePreviewProps> = ({ src, onClose }) => {
|
||||
const [scale, setScale] = useState(1)
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 })
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const dragStart = useRef({ x: 0, y: 0 })
|
||||
const positionStart = useRef({ x: 0, y: 0 })
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// 滚轮缩放
|
||||
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||
e.preventDefault()
|
||||
const delta = e.deltaY > 0 ? 0.9 : 1.1
|
||||
setScale(prev => Math.min(Math.max(prev * delta, 0.5), 5))
|
||||
}, [])
|
||||
|
||||
// 开始拖动
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
if (scale <= 1) return
|
||||
e.preventDefault()
|
||||
setIsDragging(true)
|
||||
dragStart.current = { x: e.clientX, y: e.clientY }
|
||||
positionStart.current = { ...position }
|
||||
}, [scale, position])
|
||||
|
||||
// 拖动中
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
||||
if (!isDragging) return
|
||||
const dx = e.clientX - dragStart.current.x
|
||||
const dy = e.clientY - dragStart.current.y
|
||||
setPosition({
|
||||
x: positionStart.current.x + dx,
|
||||
y: positionStart.current.y + dy
|
||||
})
|
||||
}, [isDragging])
|
||||
|
||||
// 结束拖动
|
||||
const handleMouseUp = useCallback(() => {
|
||||
setIsDragging(false)
|
||||
}, [])
|
||||
|
||||
// 双击重置
|
||||
const handleDoubleClick = useCallback(() => {
|
||||
setScale(1)
|
||||
setPosition({ x: 0, y: 0 })
|
||||
}, [])
|
||||
|
||||
// 点击背景关闭
|
||||
const handleOverlayClick = useCallback((e: React.MouseEvent) => {
|
||||
if (e.target === containerRef.current) {
|
||||
onClose()
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
// ESC 关闭
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [onClose])
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="image-preview-overlay"
|
||||
onClick={handleOverlayClick}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
alt="图片预览"
|
||||
className={`preview-image ${isDragging ? 'dragging' : ''}`}
|
||||
style={{
|
||||
transform: `translate(${position.x}px, ${position.y}px) scale(${scale})`,
|
||||
cursor: scale > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default'
|
||||
}}
|
||||
onWheel={handleWheel}
|
||||
onMouseDown={handleMouseDown}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
draggable={false}
|
||||
/>
|
||||
<button className="image-preview-close" onClick={onClose}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { NavLink, useLocation } from 'react-router-dom'
|
||||
import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download } from 'lucide-react'
|
||||
import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download, Bot } from 'lucide-react'
|
||||
import './Sidebar.scss'
|
||||
|
||||
function Sidebar() {
|
||||
@@ -34,6 +34,8 @@ function Sidebar() {
|
||||
<span className="nav-label">聊天</span>
|
||||
</NavLink>
|
||||
|
||||
|
||||
|
||||
{/* 私聊分析 */}
|
||||
<NavLink
|
||||
to="/analytics"
|
||||
|
||||
262
src/components/VoiceTranscribeDialog.scss
Normal file
262
src/components/VoiceTranscribeDialog.scss
Normal file
@@ -0,0 +1,262 @@
|
||||
.voice-transcribe-dialog-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.voice-transcribe-dialog {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
width: 90%;
|
||||
max-width: 480px;
|
||||
animation: slideUp 0.3s ease-out;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.close-button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
color: var(--text-secondary);
|
||||
border-radius: 6px;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.info-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 16px;
|
||||
|
||||
.info-icon {
|
||||
color: var(--primary);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.info-text {
|
||||
font-size: 15px;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.model-info {
|
||||
width: 100%;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
.model-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
|
||||
.label {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.value {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.download-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px 0;
|
||||
|
||||
.download-icon {
|
||||
.downloading-icon {
|
||||
color: var(--primary);
|
||||
animation: bounce 1s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.download-text {
|
||||
font-size: 15px;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--primary-gradient);
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.download-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
margin: 8px 0 0 0;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.complete-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px 0;
|
||||
|
||||
.complete-icon {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.complete-text {
|
||||
font-size: 15px;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.error-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
border-radius: 8px;
|
||||
color: #ef4444;
|
||||
font-size: 14px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
|
||||
button {
|
||||
flex: 1;
|
||||
padding: 12px 20px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
|
||||
&.btn-secondary {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-primary {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
160
src/components/VoiceTranscribeDialog.tsx
Normal file
160
src/components/VoiceTranscribeDialog.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Download, X, CheckCircle, AlertCircle } from 'lucide-react'
|
||||
import './VoiceTranscribeDialog.scss'
|
||||
|
||||
interface VoiceTranscribeDialogProps {
|
||||
onClose: () => void
|
||||
onDownloadComplete: () => void
|
||||
}
|
||||
|
||||
export const VoiceTranscribeDialog: React.FC<VoiceTranscribeDialogProps> = ({
|
||||
onClose,
|
||||
onDownloadComplete
|
||||
}) => {
|
||||
const [isDownloading, setIsDownloading] = useState(false)
|
||||
const [downloadProgress, setDownloadProgress] = useState(0)
|
||||
const [downloadError, setDownloadError] = useState<string | null>(null)
|
||||
const [isComplete, setIsComplete] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// 监听下载进度
|
||||
if (!window.electronAPI?.whisper?.onDownloadProgress) {
|
||||
console.warn('[VoiceTranscribeDialog] whisper API 不可用')
|
||||
return
|
||||
}
|
||||
|
||||
const removeListener = window.electronAPI.whisper.onDownloadProgress((payload) => {
|
||||
if (payload.percent !== undefined) {
|
||||
setDownloadProgress(payload.percent)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
removeListener?.()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (!window.electronAPI?.whisper?.downloadModel) {
|
||||
setDownloadError('语音转文字功能不可用')
|
||||
return
|
||||
}
|
||||
|
||||
setIsDownloading(true)
|
||||
setDownloadError(null)
|
||||
setDownloadProgress(0)
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI.whisper.downloadModel()
|
||||
|
||||
if (result?.success) {
|
||||
setIsComplete(true)
|
||||
setDownloadProgress(100)
|
||||
|
||||
// 延迟关闭弹窗并触发转写
|
||||
setTimeout(() => {
|
||||
onDownloadComplete()
|
||||
}, 1000)
|
||||
} else {
|
||||
setDownloadError(result?.error || '下载失败')
|
||||
setIsDownloading(false)
|
||||
}
|
||||
} catch (error) {
|
||||
setDownloadError(String(error))
|
||||
setIsDownloading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
if (!isDownloading && !isComplete) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="voice-transcribe-dialog-overlay" onClick={handleCancel}>
|
||||
<div className="voice-transcribe-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="dialog-header">
|
||||
<h3>语音转文字</h3>
|
||||
{!isDownloading && !isComplete && (
|
||||
<button className="close-button" onClick={onClose}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="dialog-content">
|
||||
{!isDownloading && !isComplete && (
|
||||
<>
|
||||
<div className="info-section">
|
||||
<AlertCircle size={48} className="info-icon" />
|
||||
<p className="info-text">
|
||||
首次使用语音转文字功能需要下载 AI 模型
|
||||
</p>
|
||||
<div className="model-info">
|
||||
<div className="model-item">
|
||||
<span className="label">模型名称:</span>
|
||||
<span className="value">SenseVoiceSmall</span>
|
||||
</div>
|
||||
<div className="model-item">
|
||||
<span className="label">文件大小:</span>
|
||||
<span className="value">约 240 MB</span>
|
||||
</div>
|
||||
<div className="model-item">
|
||||
<span className="label">支持语言:</span>
|
||||
<span className="value">中文、粤语、英文、日文、韩文</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{downloadError && (
|
||||
<div className="error-message">
|
||||
<AlertCircle size={16} />
|
||||
<span>{downloadError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="dialog-actions">
|
||||
<button className="btn-secondary" onClick={onClose}>
|
||||
取消
|
||||
</button>
|
||||
<button className="btn-primary" onClick={handleDownload}>
|
||||
<Download size={16} />
|
||||
<span>立即下载</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isDownloading && !isComplete && (
|
||||
<div className="download-section">
|
||||
<div className="download-icon">
|
||||
<Download size={48} className="downloading-icon" />
|
||||
</div>
|
||||
<p className="download-text">
|
||||
{downloadProgress < 1 ? '正在连接服务器...' : '正在下载模型...'}
|
||||
</p>
|
||||
<div className="progress-bar">
|
||||
<div
|
||||
className="progress-fill"
|
||||
style={{ width: `${downloadProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="progress-text">{downloadProgress.toFixed(1)}%</p>
|
||||
{downloadProgress < 1 && (
|
||||
<p className="download-hint">首次连接可能需要较长时间,请耐心等待</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isComplete && (
|
||||
<div className="complete-section">
|
||||
<CheckCircle size={48} className="complete-icon" />
|
||||
<p className="complete-text">下载完成!正在转写语音...</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { Users, Clock, MessageSquare, Send, Inbox, Calendar, Loader2, RefreshCw, User, Medal } from 'lucide-react'
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
import { useAnalyticsStore } from '../stores/analyticsStore'
|
||||
import { useThemeStore } from '../stores/themeStore'
|
||||
import './AnalyticsPage.scss'
|
||||
import './DataManagementPage.scss'
|
||||
import { Avatar } from '../components/Avatar'
|
||||
|
||||
function AnalyticsPage() {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
@@ -28,7 +30,7 @@ function AnalyticsPage() {
|
||||
|
||||
try {
|
||||
setLoadingStatus('正在统计消息数据...')
|
||||
const statsResult = await window.electronAPI.analytics.getOverallStatistics()
|
||||
const statsResult = await window.electronAPI.analytics.getOverallStatistics(forceRefresh)
|
||||
if (statsResult.success && statsResult.data) {
|
||||
setStatistics(statsResult.data)
|
||||
} else {
|
||||
@@ -55,7 +57,12 @@ function AnalyticsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { loadData() }, [])
|
||||
const location = useLocation()
|
||||
|
||||
useEffect(() => {
|
||||
const force = location.state?.forceRefresh === true
|
||||
loadData(force)
|
||||
}, [location.state])
|
||||
|
||||
const handleRefresh = () => loadData(true)
|
||||
|
||||
@@ -289,7 +296,7 @@ function AnalyticsPage() {
|
||||
<div key={contact.username} className="ranking-item">
|
||||
<span className={`rank ${index < 3 ? 'top' : ''}`}>{index + 1}</span>
|
||||
<div className="contact-avatar">
|
||||
{contact.avatarUrl ? <img src={contact.avatarUrl} alt="" /> : <div className="avatar-placeholder"><User size={20} /></div>}
|
||||
<Avatar src={contact.avatarUrl} name={contact.displayName} size={36} />
|
||||
{index < 3 && <div className={`medal medal-${index + 1}`}><Medal size={10} /></div>}
|
||||
</div>
|
||||
<div className="contact-info">
|
||||
|
||||
119
src/pages/AnalyticsWelcomePage.scss
Normal file
119
src/pages/AnalyticsWelcomePage.scss
Normal file
@@ -0,0 +1,119 @@
|
||||
.analytics-welcome-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
padding: 40px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
animation: fadeIn 0.4s ease-out;
|
||||
|
||||
.welcome-content {
|
||||
text-align: center;
|
||||
max-width: 600px;
|
||||
|
||||
.icon-wrapper {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin: 0 auto 24px;
|
||||
background: rgba(7, 193, 96, 0.1);
|
||||
border-radius: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #07c160;
|
||||
|
||||
svg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 28px;
|
||||
margin-bottom: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 40px;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.action-cards {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 30px 20px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
text-align: center;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
border-color: #07c160;
|
||||
box-shadow: 0 4px 12px rgba(7, 193, 96, 0.1);
|
||||
|
||||
.card-icon {
|
||||
color: #07c160;
|
||||
background: rgba(7, 193, 96, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
filter: grayscale(100%);
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 12px;
|
||||
background: var(--bg-tertiary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 16px;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
margin-bottom: 8px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
63
src/pages/AnalyticsWelcomePage.tsx
Normal file
63
src/pages/AnalyticsWelcomePage.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { BarChart2, History, RefreshCcw } from 'lucide-react'
|
||||
import { useAnalyticsStore } from '../stores/analyticsStore'
|
||||
import './AnalyticsWelcomePage.scss'
|
||||
|
||||
function AnalyticsWelcomePage() {
|
||||
const navigate = useNavigate()
|
||||
// 检查是否有任何缓存数据加载或基本的存储状态表明它已准备好。
|
||||
// 实际上,如果 store 没有持久化,`isLoaded` 可能会在应用刷新时重置。
|
||||
// 如果用户点击“加载缓存”但缓存为空,AnalyticsPage 的逻辑(loadData 不带 force)将尝试从后端缓存加载。
|
||||
// 如果后端缓存也为空,则会重新计算。
|
||||
|
||||
// 我们也可以检查 `lastLoadTime` 来显示“上次更新:xxx”(如果已持久化)。
|
||||
const { lastLoadTime } = useAnalyticsStore()
|
||||
|
||||
const handleLoadCache = () => {
|
||||
navigate('/analytics/view')
|
||||
}
|
||||
|
||||
const handleNewAnalysis = () => {
|
||||
navigate('/analytics/view', { state: { forceRefresh: true } })
|
||||
}
|
||||
|
||||
const formatLastTime = (ts: number | null) => {
|
||||
if (!ts) return '无记录'
|
||||
return new Date(ts).toLocaleString()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="analytics-welcome-container">
|
||||
<div className="welcome-content">
|
||||
<div className="icon-wrapper">
|
||||
<BarChart2 size={40} />
|
||||
</div>
|
||||
<h1>私聊数据分析</h1>
|
||||
<p>
|
||||
WeFlow 可以分析您的聊天记录,生成详细的统计报表。<br />
|
||||
您可以选择加载上次的分析结果(速度快),或者开始新的分析(数据最新)。
|
||||
</p>
|
||||
|
||||
<div className="action-cards">
|
||||
<button onClick={handleLoadCache}>
|
||||
<div className="card-icon">
|
||||
<History size={24} />
|
||||
</div>
|
||||
<h3>加载缓存</h3>
|
||||
<span>查看上次分析结果<br />(上次更新: {formatLastTime(lastLoadTime)})</span>
|
||||
</button>
|
||||
|
||||
<button onClick={handleNewAnalysis}>
|
||||
<div className="card-icon">
|
||||
<RefreshCcw size={24} />
|
||||
</div>
|
||||
<h3>新的分析</h3>
|
||||
<span>重新扫描并计算数据<br />(可能需要几分钟)</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AnalyticsWelcomePage
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Loader2, Download, Image, Check, X } from 'lucide-react'
|
||||
import { Loader2, Download, Image, Check, X, SlidersHorizontal } from 'lucide-react'
|
||||
import html2canvas from 'html2canvas'
|
||||
import { useThemeStore } from '../stores/themeStore'
|
||||
import './AnnualReportWindow.scss'
|
||||
@@ -249,6 +249,7 @@ function AnnualReportWindow() {
|
||||
const [fabOpen, setFabOpen] = useState(false)
|
||||
const [loadingProgress, setLoadingProgress] = useState(0)
|
||||
const [loadingStage, setLoadingStage] = useState('正在初始化...')
|
||||
const [exportMode, setExportMode] = useState<'separate' | 'long'>('separate')
|
||||
|
||||
const { currentTheme, themeMode } = useThemeStore()
|
||||
|
||||
@@ -490,7 +491,7 @@ function AnnualReportWindow() {
|
||||
}
|
||||
|
||||
// 导出整个报告为长图
|
||||
const exportFullReport = async () => {
|
||||
const exportFullReport = async (filterIds?: Set<string>) => {
|
||||
if (!containerRef.current) {
|
||||
return
|
||||
}
|
||||
@@ -516,6 +517,16 @@ function AnnualReportWindow() {
|
||||
el.style.padding = '40px 0'
|
||||
})
|
||||
|
||||
// 如果有筛选,隐藏未选中的板块
|
||||
if (filterIds) {
|
||||
const available = getAvailableSections()
|
||||
available.forEach(s => {
|
||||
if (!filterIds.has(s.id) && s.ref.current) {
|
||||
s.ref.current.style.display = 'none'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 修复词云导出问题
|
||||
const wordCloudInner = container.querySelector('.word-cloud-inner') as HTMLElement
|
||||
const wordTags = container.querySelectorAll('.word-tag') as NodeListOf<HTMLElement>
|
||||
@@ -584,7 +595,7 @@ function AnnualReportWindow() {
|
||||
|
||||
const dataUrl = outputCanvas.toDataURL('image/png')
|
||||
const link = document.createElement('a')
|
||||
link.download = `${reportData?.year}年度报告.png`
|
||||
link.download = `${reportData?.year}年度报告${filterIds ? '_自定义' : ''}.png`
|
||||
link.href = dataUrl
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
@@ -607,6 +618,13 @@ function AnnualReportWindow() {
|
||||
return
|
||||
}
|
||||
|
||||
if (exportMode === 'long') {
|
||||
setShowExportModal(false)
|
||||
await exportFullReport(selectedSections)
|
||||
setSelectedSections(new Set())
|
||||
return
|
||||
}
|
||||
|
||||
setIsExporting(true)
|
||||
setShowExportModal(false)
|
||||
|
||||
@@ -735,9 +753,12 @@ function AnnualReportWindow() {
|
||||
|
||||
{/* 浮动操作按钮 */}
|
||||
<div className={`fab-container ${fabOpen ? 'open' : ''}`}>
|
||||
<button className="fab-item" onClick={() => { setFabOpen(false); setShowExportModal(true) }} title="分模块导出">
|
||||
<button className="fab-item" onClick={() => { setFabOpen(false); setExportMode('separate'); setShowExportModal(true) }} title="分模块导出">
|
||||
<Image size={18} />
|
||||
</button>
|
||||
<button className="fab-item" onClick={() => { setFabOpen(false); setExportMode('long'); setShowExportModal(true) }} title="自定义导出长图">
|
||||
<SlidersHorizontal size={18} />
|
||||
</button>
|
||||
<button className="fab-item" onClick={() => { setFabOpen(false); exportFullReport() }} title="导出长图">
|
||||
<Download size={18} />
|
||||
</button>
|
||||
@@ -765,7 +786,7 @@ function AnnualReportWindow() {
|
||||
<div className="export-overlay" onClick={() => setShowExportModal(false)}>
|
||||
<div className="export-modal section-selector" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>选择要导出的板块</h3>
|
||||
<h3>{exportMode === 'long' ? '自定义导出长图' : '选择要导出的板块'}</h3>
|
||||
<button className="close-btn" onClick={() => setShowExportModal(false)}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
@@ -793,7 +814,7 @@ function AnnualReportWindow() {
|
||||
onClick={exportSelectedSections}
|
||||
disabled={selectedSections.size === 0}
|
||||
>
|
||||
导出 {selectedSections.size > 0 ? `(${selectedSections.size})` : ''}
|
||||
{exportMode === 'long' ? '生成长图' : '导出'} {selectedSections.size > 0 ? `(${selectedSections.size})` : ''}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -838,7 +859,7 @@ function AnnualReportWindow() {
|
||||
你发出 <span className="hl">{formatNumber(topFriend.sentCount)}</span> 条 ·
|
||||
TA发来 <span className="hl">{formatNumber(topFriend.receivedCount)}</span> 条
|
||||
</p>
|
||||
<br/>
|
||||
<br />
|
||||
<p className="hero-desc">
|
||||
在一起,就可以
|
||||
</p>
|
||||
|
||||
@@ -883,6 +883,23 @@
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
-webkit-app-region: no-drag;
|
||||
position: relative;
|
||||
|
||||
&.loading .message-list {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&.loaded .message-list {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
&.loaded .loading-overlay {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.message-list {
|
||||
@@ -898,6 +915,7 @@
|
||||
background-color: var(--bg-tertiary);
|
||||
position: relative;
|
||||
-webkit-app-region: no-drag !important;
|
||||
transition: opacity 240ms ease, transform 240ms ease;
|
||||
|
||||
// 滚动条样式
|
||||
&::-webkit-scrollbar {
|
||||
@@ -918,6 +936,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
.loading-messages.loading-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
background: rgba(10, 10, 10, 0.28);
|
||||
backdrop-filter: blur(6px);
|
||||
transition: opacity 200ms ease;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.message-list * {
|
||||
-webkit-app-region: no-drag !important;
|
||||
}
|
||||
@@ -1077,6 +1108,14 @@
|
||||
border-radius: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
// 使发送的语音消息和转文字也使用接收者的样式 (浅色)
|
||||
&.sent.voice {
|
||||
.bubble-content {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bubble-avatar {
|
||||
@@ -1108,6 +1147,7 @@
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
// 表情包消息
|
||||
@@ -1259,36 +1299,6 @@
|
||||
color: var(--text-quaternary);
|
||||
}
|
||||
|
||||
.image-preview-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
|
||||
img {
|
||||
max-width: 88vw;
|
||||
max-height: 88vh;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
.image-preview-close {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
// 语音消息
|
||||
.voice-message {
|
||||
display: flex;
|
||||
@@ -1301,8 +1311,10 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.message-bubble.sent .voice-message {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
.voice-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.voice-play-btn {
|
||||
@@ -1337,6 +1349,50 @@
|
||||
}
|
||||
}
|
||||
|
||||
.voice-waveform {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
height: 24px;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.waveform-bar {
|
||||
flex: 1;
|
||||
width: 2px;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
border-radius: 1px;
|
||||
transition: transform 0.2s ease, background 0.2s ease;
|
||||
|
||||
&.played {
|
||||
background: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.message-bubble.sent.voice .waveform-bar {
|
||||
background: rgba(0, 0, 0, 0.1); // 基色改为透明黑
|
||||
|
||||
&.played {
|
||||
background: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.voice-wave-placeholder {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 3px;
|
||||
height: 18px;
|
||||
|
||||
span {
|
||||
width: 3px;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
background: var(--text-tertiary);
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.voice-message.playing .voice-wave span {
|
||||
animation: voicePulse 0.9s ease-in-out infinite;
|
||||
}
|
||||
@@ -1389,15 +1445,35 @@
|
||||
color: #d9480f;
|
||||
}
|
||||
|
||||
.voice-transcript {
|
||||
max-width: 260px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 14px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
background: var(--card-bg);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.voice-transcript.error {
|
||||
color: #d9480f;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@keyframes voicePulse {
|
||||
0% {
|
||||
height: 6px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
50% {
|
||||
height: 16px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
height: 6px;
|
||||
opacity: 0.5;
|
||||
@@ -1432,6 +1508,7 @@
|
||||
|
||||
.quoted-text {
|
||||
color: var(--text-secondary);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1843,3 +1920,32 @@
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 语音转文字按钮样式 */
|
||||
.voice-transcribe-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
margin-left: 8px;
|
||||
border: none;
|
||||
background: var(--primary-light);
|
||||
border-radius: 50%;
|
||||
color: var(--primary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -379,29 +379,21 @@
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
svg {
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
span {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.change-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--text-tertiary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.media-options {
|
||||
@@ -471,6 +463,43 @@
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
|
||||
.select-folder-btn {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
margin-top: 12px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
|
||||
svg {
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
svg {
|
||||
color: var(--text-secondary);
|
||||
transition: color 0.2s;
|
||||
}
|
||||
}
|
||||
|
||||
.export-action {
|
||||
padding: 20px 24px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
@@ -649,9 +678,382 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.date-picker-modal {
|
||||
background: var(--card-bg);
|
||||
padding: 28px 32px;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
|
||||
min-width: 420px;
|
||||
max-width: 500px;
|
||||
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
|
||||
.quick-select {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
|
||||
.quick-btn {
|
||||
flex: 1;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.date-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 12px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.date-display-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(var(--primary-rgb), 0.05);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: rgba(var(--primary-rgb), 0.1);
|
||||
border: 1px solid var(--primary);
|
||||
}
|
||||
|
||||
.date-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.date-value {
|
||||
font-size: 15px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.date-separator {
|
||||
font-size: 14px;
|
||||
color: var(--text-tertiary);
|
||||
padding: 0 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-container {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.calendar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
padding: 0 4px;
|
||||
|
||||
.calendar-nav-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-month {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-weekdays {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.calendar-weekday {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-tertiary);
|
||||
padding: 8px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-days {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 4px;
|
||||
|
||||
.calendar-day {
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
|
||||
&.empty {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&:not(.empty):hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
&.in-range {
|
||||
background: rgba(var(--primary-rgb), 0.08);
|
||||
}
|
||||
|
||||
&.start,
|
||||
&.end {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
|
||||
&:hover {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.date-picker-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
|
||||
button {
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.confirm-btn {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
|
||||
&:hover {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes exportSpin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
// 媒体导出选项卡片样式
|
||||
.setting-subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
margin: 4px 0 12px 0;
|
||||
}
|
||||
|
||||
.media-options-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.media-switch-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.media-switch-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.media-switch-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.media-switch-desc {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.media-option-divider {
|
||||
height: 1px;
|
||||
background: var(--border-color);
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
.media-checkbox-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover:not(.disabled) {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: var(--primary);
|
||||
cursor: pointer;
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.media-checkbox-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.media-checkbox-title {
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.media-checkbox-desc {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
// Switch 开关样式
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
|
||||
input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: var(--bg-tertiary);
|
||||
transition: 0.3s;
|
||||
border-radius: 24px;
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
transition: 0.3s;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
input:checked + .slider {
|
||||
background-color: var(--primary);
|
||||
}
|
||||
|
||||
input:checked + .slider::before {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Search, Download, FolderOpen, RefreshCw, Check, Calendar, FileJson, FileText, Table, Loader2, X, ChevronDown, FileSpreadsheet, Database, FileCode, CheckCircle, XCircle, ExternalLink } from 'lucide-react'
|
||||
import { Search, Download, FolderOpen, RefreshCw, Check, Calendar, FileJson, FileText, Table, Loader2, X, ChevronDown, ChevronLeft, ChevronRight, FileSpreadsheet, Database, FileCode, CheckCircle, XCircle, ExternalLink } from 'lucide-react'
|
||||
import * as configService from '../services/config'
|
||||
import './ExportPage.scss'
|
||||
|
||||
@@ -16,6 +16,11 @@ interface ExportOptions {
|
||||
dateRange: { start: Date; end: Date } | null
|
||||
useAllTime: boolean
|
||||
exportAvatars: boolean
|
||||
exportMedia: boolean
|
||||
exportImages: boolean
|
||||
exportVoices: boolean
|
||||
exportEmojis: boolean
|
||||
exportVoiceAsText: boolean
|
||||
}
|
||||
|
||||
interface ExportResult {
|
||||
@@ -35,6 +40,9 @@ function ExportPage() {
|
||||
const [isExporting, setIsExporting] = useState(false)
|
||||
const [exportProgress, setExportProgress] = useState({ current: 0, total: 0, currentName: '' })
|
||||
const [exportResult, setExportResult] = useState<ExportResult | null>(null)
|
||||
const [showDatePicker, setShowDatePicker] = useState(false)
|
||||
const [calendarDate, setCalendarDate] = useState(new Date())
|
||||
const [selectingStart, setSelectingStart] = useState(true)
|
||||
|
||||
const [options, setOptions] = useState<ExportOptions>({
|
||||
format: 'chatlab',
|
||||
@@ -43,7 +51,12 @@ function ExportPage() {
|
||||
end: new Date()
|
||||
},
|
||||
useAllTime: true,
|
||||
exportAvatars: true
|
||||
exportAvatars: true,
|
||||
exportMedia: false,
|
||||
exportImages: true,
|
||||
exportVoices: true,
|
||||
exportEmojis: true,
|
||||
exportVoiceAsText: false
|
||||
})
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
@@ -143,13 +156,19 @@ function ExportPage() {
|
||||
const exportOptions = {
|
||||
format: options.format,
|
||||
exportAvatars: options.exportAvatars,
|
||||
exportMedia: options.exportMedia,
|
||||
exportImages: options.exportMedia && options.exportImages,
|
||||
exportVoices: options.exportMedia && options.exportVoices,
|
||||
exportEmojis: options.exportMedia && options.exportEmojis,
|
||||
exportVoiceAsText: options.exportVoiceAsText, // 独立于 exportMedia
|
||||
dateRange: options.useAllTime ? null : options.dateRange ? {
|
||||
start: Math.floor(options.dateRange.start.getTime() / 1000),
|
||||
end: Math.floor(options.dateRange.end.getTime() / 1000)
|
||||
// 将结束日期设置为当天的 23:59:59,以包含当天的所有消息
|
||||
end: Math.floor(new Date(options.dateRange.end.getFullYear(), options.dateRange.end.getMonth(), options.dateRange.end.getDate(), 23, 59, 59).getTime() / 1000)
|
||||
} : null
|
||||
}
|
||||
|
||||
if (options.format === 'chatlab' || options.format === 'chatlab-jsonl' || options.format === 'json') {
|
||||
if (options.format === 'chatlab' || options.format === 'chatlab-jsonl' || options.format === 'json' || options.format === 'excel') {
|
||||
const result = await window.electronAPI.export.exportSessions(
|
||||
sessionList,
|
||||
exportFolder,
|
||||
@@ -167,6 +186,90 @@ function ExportPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const getDaysInMonth = (date: Date) => {
|
||||
const year = date.getFullYear()
|
||||
const month = date.getMonth()
|
||||
return new Date(year, month + 1, 0).getDate()
|
||||
}
|
||||
|
||||
const getFirstDayOfMonth = (date: Date) => {
|
||||
const year = date.getFullYear()
|
||||
const month = date.getMonth()
|
||||
return new Date(year, month, 1).getDay()
|
||||
}
|
||||
|
||||
const generateCalendar = () => {
|
||||
const daysInMonth = getDaysInMonth(calendarDate)
|
||||
const firstDay = getFirstDayOfMonth(calendarDate)
|
||||
const days: (number | null)[] = []
|
||||
|
||||
for (let i = 0; i < firstDay; i++) {
|
||||
days.push(null)
|
||||
}
|
||||
|
||||
for (let i = 1; i <= daysInMonth; i++) {
|
||||
days.push(i)
|
||||
}
|
||||
|
||||
return days
|
||||
}
|
||||
|
||||
const handleDateSelect = (day: number) => {
|
||||
const year = calendarDate.getFullYear()
|
||||
const month = calendarDate.getMonth()
|
||||
const selectedDate = new Date(year, month, day)
|
||||
// 设置时间为当天的开始或结束
|
||||
selectedDate.setHours(selectingStart ? 0 : 23, selectingStart ? 0 : 59, selectingStart ? 0 : 59, selectingStart ? 0 : 999)
|
||||
|
||||
const now = new Date()
|
||||
// 如果选择的日期晚于当前时间,限制为当前时间
|
||||
if (selectedDate > now) {
|
||||
selectedDate.setTime(now.getTime())
|
||||
}
|
||||
|
||||
if (selectingStart) {
|
||||
// 选择开始日期
|
||||
const currentEnd = options.dateRange?.end || new Date()
|
||||
// 如果选择的开始日期晚于结束日期,则同时更新结束日期
|
||||
if (selectedDate > currentEnd) {
|
||||
const newEnd = new Date(selectedDate)
|
||||
newEnd.setHours(23, 59, 59, 999)
|
||||
// 确保结束日期也不晚于当前时间
|
||||
if (newEnd > now) {
|
||||
newEnd.setTime(now.getTime())
|
||||
}
|
||||
setOptions({
|
||||
...options,
|
||||
dateRange: { start: selectedDate, end: newEnd }
|
||||
})
|
||||
} else {
|
||||
setOptions({
|
||||
...options,
|
||||
dateRange: options.dateRange ? { ...options.dateRange, start: selectedDate } : { start: selectedDate, end: new Date() }
|
||||
})
|
||||
}
|
||||
setSelectingStart(false)
|
||||
} else {
|
||||
// 选择结束日期
|
||||
const currentStart = options.dateRange?.start || new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
|
||||
// 如果选择的结束日期早于开始日期,则同时更新开始日期
|
||||
if (selectedDate < currentStart) {
|
||||
const newStart = new Date(selectedDate)
|
||||
newStart.setHours(0, 0, 0, 0)
|
||||
setOptions({
|
||||
...options,
|
||||
dateRange: { start: newStart, end: selectedDate }
|
||||
})
|
||||
} else {
|
||||
setOptions({
|
||||
...options,
|
||||
dateRange: options.dateRange ? { ...options.dateRange, end: selectedDate } : { start: new Date(), end: selectedDate }
|
||||
})
|
||||
}
|
||||
setSelectingStart(true)
|
||||
}
|
||||
}
|
||||
|
||||
const formatOptions = [
|
||||
{ value: 'chatlab', label: 'ChatLab', icon: FileCode, desc: '标准格式,支持其他软件导入' },
|
||||
{ value: 'chatlab-jsonl', label: 'ChatLab JSONL', icon: FileCode, desc: '流式格式,适合大量消息' },
|
||||
@@ -281,28 +384,113 @@ function ExportPage() {
|
||||
<span>导出全部时间</span>
|
||||
</label>
|
||||
{!options.useAllTime && options.dateRange && (
|
||||
<div className="date-range">
|
||||
<div className="date-range" onClick={() => setShowDatePicker(true)}>
|
||||
<Calendar size={16} />
|
||||
<span>{formatDate(options.dateRange.start)} - {formatDate(options.dateRange.end)}</span>
|
||||
<button className="change-btn">
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
<ChevronDown size={14} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-section">
|
||||
<h3>导出头像</h3>
|
||||
<div className="time-options">
|
||||
<label className="checkbox-item">
|
||||
<h3>媒体文件</h3>
|
||||
<p className="setting-subtitle">导出图片/语音/表情并在记录内写入相对路径</p>
|
||||
<div className="media-options-card">
|
||||
<div className="media-switch-row">
|
||||
<div className="media-switch-info">
|
||||
<span className="media-switch-title">导出媒体文件</span>
|
||||
<span className="media-switch-desc">会创建子文件夹并保存媒体资源</span>
|
||||
</div>
|
||||
<label className="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={options.exportMedia}
|
||||
onChange={e => setOptions({ ...options, exportMedia: e.target.checked })}
|
||||
/>
|
||||
<span className="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="media-option-divider"></div>
|
||||
|
||||
<label className={`media-checkbox-row ${!options.exportMedia ? 'disabled' : ''}`}>
|
||||
<div className="media-checkbox-info">
|
||||
<span className="media-checkbox-title">图片</span>
|
||||
<span className="media-checkbox-desc">已有文件直接复制,缺失时尝试解密</span>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={options.exportAvatars}
|
||||
onChange={e => setOptions({ ...options, exportAvatars: e.target.checked })}
|
||||
checked={options.exportImages}
|
||||
disabled={!options.exportMedia}
|
||||
onChange={e => setOptions({ ...options, exportImages: e.target.checked })}
|
||||
/>
|
||||
<span>导出头像图片</span>
|
||||
</label>
|
||||
|
||||
<div className="media-option-divider"></div>
|
||||
|
||||
<label className={`media-checkbox-row ${!options.exportMedia ? 'disabled' : ''}`}>
|
||||
<div className="media-checkbox-info">
|
||||
<span className="media-checkbox-title">语音</span>
|
||||
<span className="media-checkbox-desc">缺失时会解码生成 MP3</span>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={options.exportVoices}
|
||||
disabled={!options.exportMedia}
|
||||
onChange={e => setOptions({ ...options, exportVoices: e.target.checked })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="media-option-divider"></div>
|
||||
|
||||
<label className="media-checkbox-row">
|
||||
<div className="media-checkbox-info">
|
||||
<span className="media-checkbox-title">语音转文字</span>
|
||||
<span className="media-checkbox-desc">将语音消息转换为文字导出(不导出语音文件)</span>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={options.exportVoiceAsText}
|
||||
onChange={e => setOptions({ ...options, exportVoiceAsText: e.target.checked })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="media-option-divider"></div>
|
||||
|
||||
<label className={`media-checkbox-row ${!options.exportMedia ? 'disabled' : ''}`}>
|
||||
<div className="media-checkbox-info">
|
||||
<span className="media-checkbox-title">表情</span>
|
||||
<span className="media-checkbox-desc">本地无缓存时尝试下载</span>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={options.exportEmojis}
|
||||
disabled={!options.exportMedia}
|
||||
onChange={e => setOptions({ ...options, exportEmojis: e.target.checked })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-section">
|
||||
<h3>头像</h3>
|
||||
<p className="setting-subtitle">可选导出头像索引,关闭则不下载头像</p>
|
||||
<div className="media-options-card">
|
||||
<div className="media-switch-row">
|
||||
<div className="media-switch-info">
|
||||
<span className="media-switch-title">导出头像</span>
|
||||
<span className="media-switch-desc">用于展示发送者头像,可能会读取或下载头像文件</span>
|
||||
</div>
|
||||
<label className="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={options.exportAvatars}
|
||||
onChange={e => setOptions({ ...options, exportAvatars: e.target.checked })}
|
||||
/>
|
||||
<span className="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -312,7 +500,26 @@ function ExportPage() {
|
||||
<FolderOpen size={16} />
|
||||
<span>{exportFolder || '未设置'}</span>
|
||||
</div>
|
||||
<p className="path-hint">可在设置页面修改导出目录</p>
|
||||
<button
|
||||
className="select-folder-btn"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const result = await window.electronAPI.dialog.openFile({
|
||||
title: '选择导出目录',
|
||||
properties: ['openDirectory']
|
||||
})
|
||||
if (!result.canceled && result.filePaths.length > 0) {
|
||||
setExportFolder(result.filePaths[0])
|
||||
await configService.setExportPath(result.filePaths[0])
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('选择目录失败:', e)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FolderOpen size={16} />
|
||||
<span>选择导出目录</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -387,6 +594,137 @@ function ExportPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 日期选择弹窗 */}
|
||||
{showDatePicker && (
|
||||
<div className="export-overlay" onClick={() => setShowDatePicker(false)}>
|
||||
<div className="date-picker-modal" onClick={e => e.stopPropagation()}>
|
||||
<h3>选择时间范围</h3>
|
||||
<p style={{ fontSize: '13px', color: 'var(--text-secondary)', margin: '8px 0 16px 0' }}>
|
||||
点击选择开始和结束日期,系统会自动调整确保时间顺序正确
|
||||
</p>
|
||||
<div className="quick-select">
|
||||
<button
|
||||
className="quick-btn"
|
||||
onClick={() => {
|
||||
const end = new Date()
|
||||
const start = new Date(end.getTime() - 7 * 24 * 60 * 60 * 1000)
|
||||
setOptions({ ...options, dateRange: { start, end } })
|
||||
}}
|
||||
>
|
||||
最近7天
|
||||
</button>
|
||||
<button
|
||||
className="quick-btn"
|
||||
onClick={() => {
|
||||
const end = new Date()
|
||||
const start = new Date(end.getTime() - 30 * 24 * 60 * 60 * 1000)
|
||||
setOptions({ ...options, dateRange: { start, end } })
|
||||
}}
|
||||
>
|
||||
最近30天
|
||||
</button>
|
||||
<button
|
||||
className="quick-btn"
|
||||
onClick={() => {
|
||||
const end = new Date()
|
||||
const start = new Date(end.getTime() - 90 * 24 * 60 * 60 * 1000)
|
||||
setOptions({ ...options, dateRange: { start, end } })
|
||||
}}
|
||||
>
|
||||
最近90天
|
||||
</button>
|
||||
</div>
|
||||
<div className="date-display">
|
||||
<div
|
||||
className={`date-display-item ${selectingStart ? 'active' : ''}`}
|
||||
onClick={() => setSelectingStart(true)}
|
||||
>
|
||||
<span className="date-label">开始日期</span>
|
||||
<span className="date-value">
|
||||
{options.dateRange?.start.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<span className="date-separator">至</span>
|
||||
<div
|
||||
className={`date-display-item ${!selectingStart ? 'active' : ''}`}
|
||||
onClick={() => setSelectingStart(false)}
|
||||
>
|
||||
<span className="date-label">结束日期</span>
|
||||
<span className="date-value">
|
||||
{options.dateRange?.end.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="calendar-container">
|
||||
<div className="calendar-header">
|
||||
<button
|
||||
className="calendar-nav-btn"
|
||||
onClick={() => setCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() - 1, 1))}
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
</button>
|
||||
<span className="calendar-month">
|
||||
{calendarDate.getFullYear()}年{calendarDate.getMonth() + 1}月
|
||||
</span>
|
||||
<button
|
||||
className="calendar-nav-btn"
|
||||
onClick={() => setCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1))}
|
||||
>
|
||||
<ChevronRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="calendar-weekdays">
|
||||
{['日', '一', '二', '三', '四', '五', '六'].map(day => (
|
||||
<div key={day} className="calendar-weekday">{day}</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="calendar-days">
|
||||
{generateCalendar().map((day, index) => {
|
||||
if (day === null) {
|
||||
return <div key={`empty-${index}`} className="calendar-day empty" />
|
||||
}
|
||||
|
||||
const currentDate = new Date(calendarDate.getFullYear(), calendarDate.getMonth(), day)
|
||||
const isStart = options.dateRange?.start.toDateString() === currentDate.toDateString()
|
||||
const isEnd = options.dateRange?.end.toDateString() === currentDate.toDateString()
|
||||
const isInRange = options.dateRange && currentDate >= options.dateRange.start && currentDate <= options.dateRange.end
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
const isFuture = currentDate > today
|
||||
|
||||
return (
|
||||
<div
|
||||
key={day}
|
||||
className={`calendar-day ${isStart ? 'start' : ''} ${isEnd ? 'end' : ''} ${isInRange ? 'in-range' : ''} ${isFuture ? 'disabled' : ''}`}
|
||||
onClick={() => !isFuture && handleDateSelect(day)}
|
||||
style={{ cursor: isFuture ? 'not-allowed' : 'pointer', opacity: isFuture ? 0.3 : 1 }}
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="date-picker-actions">
|
||||
<button className="cancel-btn" onClick={() => setShowDatePicker(false)}>
|
||||
取消
|
||||
</button>
|
||||
<button className="confirm-btn" onClick={() => setShowDatePicker(false)}>
|
||||
确定
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, User, Medal, Search, X, ChevronLeft, Copy, Check } from 'lucide-react'
|
||||
import { Avatar } from '../components/Avatar'
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
import DateRangePicker from '../components/DateRangePicker'
|
||||
import './GroupAnalyticsPage.scss'
|
||||
@@ -256,11 +257,7 @@ function GroupAnalyticsPage() {
|
||||
</button>
|
||||
<div className="modal-content">
|
||||
<div className="member-avatar large">
|
||||
{selectedMember.avatarUrl ? (
|
||||
<img src={selectedMember.avatarUrl} alt="" />
|
||||
) : (
|
||||
<div className="avatar-placeholder"><User size={48} /></div>
|
||||
)}
|
||||
<Avatar src={selectedMember.avatarUrl} name={selectedMember.displayName} size={96} />
|
||||
</div>
|
||||
<h3 className="member-display-name">{selectedMember.displayName}</h3>
|
||||
<div className="member-details">
|
||||
@@ -334,7 +331,7 @@ function GroupAnalyticsPage() {
|
||||
onClick={() => handleGroupSelect(group)}
|
||||
>
|
||||
<div className="group-avatar">
|
||||
{group.avatarUrl ? <img src={group.avatarUrl} alt="" /> : <div className="avatar-placeholder"><Users size={20} /></div>}
|
||||
<Avatar src={group.avatarUrl} name={group.displayName} size={44} />
|
||||
</div>
|
||||
<div className="group-info">
|
||||
<span className="group-name">{group.displayName}</span>
|
||||
@@ -352,7 +349,7 @@ function GroupAnalyticsPage() {
|
||||
<div className="function-menu">
|
||||
<div className="selected-group-info">
|
||||
<div className="group-avatar large">
|
||||
{selectedGroup?.avatarUrl ? <img src={selectedGroup.avatarUrl} alt="" /> : <div className="avatar-placeholder"><Users size={40} /></div>}
|
||||
<Avatar src={selectedGroup?.avatarUrl} name={selectedGroup?.displayName} size={80} />
|
||||
</div>
|
||||
<h2>{selectedGroup?.displayName}</h2>
|
||||
<p>{selectedGroup?.memberCount} 位成员</p>
|
||||
@@ -424,7 +421,7 @@ function GroupAnalyticsPage() {
|
||||
{members.map(member => (
|
||||
<div key={member.username} className="member-card" onClick={() => handleMemberClick(member)}>
|
||||
<div className="member-avatar">
|
||||
{member.avatarUrl ? <img src={member.avatarUrl} alt="" /> : <div className="avatar-placeholder"><User size={20} /></div>}
|
||||
<Avatar src={member.avatarUrl} name={member.displayName} size={48} />
|
||||
</div>
|
||||
<span className="member-name">{member.displayName}</span>
|
||||
</div>
|
||||
@@ -437,7 +434,7 @@ function GroupAnalyticsPage() {
|
||||
<div key={item.member.username} className="ranking-item">
|
||||
<span className={`rank ${index < 3 ? 'top' : ''}`}>{index + 1}</span>
|
||||
<div className="contact-avatar">
|
||||
{item.member.avatarUrl ? <img src={item.member.avatarUrl} alt="" /> : <div className="avatar-placeholder"><User size={20} /></div>}
|
||||
<Avatar src={item.member.avatarUrl} name={item.member.displayName} size={40} />
|
||||
{index < 3 && <div className={`medal medal-${index + 1}`}><Medal size={10} /></div>}
|
||||
</div>
|
||||
<div className="contact-info">
|
||||
|
||||
@@ -204,6 +204,23 @@
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 9999px;
|
||||
font-size: 14px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 10px;
|
||||
cursor: pointer;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.input-with-toggle {
|
||||
position: relative;
|
||||
display: flex;
|
||||
@@ -235,6 +252,184 @@
|
||||
}
|
||||
}
|
||||
|
||||
.whisper-section {
|
||||
background: color-mix(in srgb, var(--primary) 3%, transparent);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
margin-top: 24px;
|
||||
|
||||
label {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.whisper-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.whisper-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.whisper-status-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin: 12px 0 16px;
|
||||
padding: 10px 14px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
|
||||
.status {
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status.ok {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: #10b981;
|
||||
border: 1px solid rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
.status.warn {
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
color: #f59e0b;
|
||||
border: 1px solid rgba(245, 158, 11, 0.2);
|
||||
}
|
||||
|
||||
.path {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.whisper-progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
|
||||
.progress-bar-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--primary) 0%, var(--primary-hover) 100%);
|
||||
border-radius: 999px;
|
||||
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(90deg,
|
||||
rgba(255, 255, 255, 0) 0%,
|
||||
rgba(255, 255, 255, 0.2) 50%,
|
||||
rgba(255, 255, 255, 0) 100%);
|
||||
animation: progress-shimmer 2s infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.progress-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
|
||||
&.percent {
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btn-download-model {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
justify-content: center;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-top: 8px;
|
||||
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-hover) 100%);
|
||||
box-shadow: 0 4px 12px color-mix(in srgb, var(--primary) 20%, transparent);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 16px color-mix(in srgb, var(--primary) 30%, transparent);
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
svg {
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
&:hover svg {
|
||||
transform: translateY(2px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes progress-shimmer {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
.log-toggle-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -248,6 +443,72 @@
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.language-checkboxes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.language-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
position: relative;
|
||||
|
||||
input[type="checkbox"] {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
height: 0;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.checkbox-custom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
background: var(--bg-primary);
|
||||
border: 1.5px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
svg {
|
||||
opacity: 0;
|
||||
transform: scale(0.5);
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .checkbox-custom {
|
||||
border-color: var(--text-tertiary);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
input:checked+.checkbox-custom {
|
||||
background: color-mix(in srgb, var(--primary) 10%, transparent);
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
box-shadow: 0 4px 12px color-mix(in srgb, var(--primary) 10%, transparent);
|
||||
|
||||
svg {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
&:active .checkbox-custom {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
}
|
||||
|
||||
.switch {
|
||||
position: relative;
|
||||
width: 46px;
|
||||
@@ -287,12 +548,12 @@
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.switch-input:checked + .switch-slider {
|
||||
.switch-input:checked+.switch-slider {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.switch-input:checked + .switch-slider::before {
|
||||
.switch-input:checked+.switch-slider::before {
|
||||
transform: translateX(22px);
|
||||
background: #ffffff;
|
||||
}
|
||||
@@ -331,18 +592,33 @@
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
box-shadow: 0 2px 6px color-mix(in srgb, var(--primary) 15%, transparent);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--primary-hover);
|
||||
box-shadow: 0 4px 12px color-mix(in srgb, var(--primary) 25%, transparent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
border-color: var(--text-tertiary);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -395,6 +671,7 @@
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(-10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
@@ -402,9 +679,12 @@
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
@@ -667,8 +947,13 @@
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -767,3 +1052,168 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// wxid 输入框下拉
|
||||
.wxid-input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
padding-right: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
.wxid-dropdown-btn {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&.open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.wxid-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-top: 4px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
z-index: 100;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.wxid-option {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--primary-light);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.wxid-value {
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.wxid-time {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
// 多账号选择对话框
|
||||
.wxid-dialog-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.wxid-dialog {
|
||||
background: var(--bg-primary);
|
||||
border-radius: 16px;
|
||||
width: 400px;
|
||||
max-width: 90vw;
|
||||
max-height: 80vh;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.wxid-dialog-header {
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
|
||||
h3 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.wxid-dialog-list {
|
||||
padding: 8px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.wxid-dialog-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--primary-light);
|
||||
|
||||
.wxid-id {
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.wxid-id {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.wxid-date {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.wxid-dialog-footer {
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid var(--border-primary);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
@@ -1,28 +1,35 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useAppStore } from '../stores/appStore'
|
||||
import { useThemeStore, themes } from '../stores/themeStore'
|
||||
import { useAnalyticsStore } from '../stores/analyticsStore'
|
||||
import { dialog } from '../services/ipc'
|
||||
import * as configService from '../services/config'
|
||||
import {
|
||||
Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy,
|
||||
RotateCcw, Trash2, Save, Plug, Check, Sun, Moon,
|
||||
Palette, Database, Download, HardDrive, Info, RefreshCw
|
||||
Palette, Database, Download, HardDrive, Info, RefreshCw, ChevronDown, Mic
|
||||
} from 'lucide-react'
|
||||
import './SettingsPage.scss'
|
||||
|
||||
type SettingsTab = 'appearance' | 'database' | 'export' | 'cache' | 'about'
|
||||
type SettingsTab = 'appearance' | 'database' | 'whisper' | 'cache' | 'about'
|
||||
|
||||
const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
|
||||
{ id: 'appearance', label: '外观', icon: Palette },
|
||||
{ id: 'database', label: '数据库连接', icon: Database },
|
||||
{ id: 'export', label: '导出', icon: Download },
|
||||
{ id: 'whisper', label: '语音识别模型', icon: Mic },
|
||||
{ id: 'cache', label: '缓存', icon: HardDrive },
|
||||
{ id: 'about', label: '关于', icon: Info }
|
||||
]
|
||||
|
||||
interface WxidOption {
|
||||
wxid: string
|
||||
modifiedTime: number
|
||||
}
|
||||
|
||||
function SettingsPage() {
|
||||
const { setDbConnected, setLoading, reset } = useAppStore()
|
||||
const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore()
|
||||
const clearAnalyticsStoreCache = useAnalyticsStore((state) => state.clearCache)
|
||||
|
||||
const [activeTab, setActiveTab] = useState<SettingsTab>('appearance')
|
||||
const [decryptKey, setDecryptKey] = useState('')
|
||||
@@ -30,10 +37,18 @@ function SettingsPage() {
|
||||
const [imageAesKey, setImageAesKey] = useState('')
|
||||
const [dbPath, setDbPath] = useState('')
|
||||
const [wxid, setWxid] = useState('')
|
||||
const [wxidOptions, setWxidOptions] = useState<WxidOption[]>([])
|
||||
const [showWxidSelect, setShowWxidSelect] = useState(false)
|
||||
const wxidDropdownRef = useRef<HTMLDivElement>(null)
|
||||
const [cachePath, setCachePath] = useState('')
|
||||
const [exportPath, setExportPath] = useState('')
|
||||
const [defaultExportPath, setDefaultExportPath] = useState('')
|
||||
const [logEnabled, setLogEnabled] = useState(false)
|
||||
const [whisperModelName, setWhisperModelName] = useState('base')
|
||||
const [whisperModelDir, setWhisperModelDir] = useState('')
|
||||
const [isWhisperDownloading, setIsWhisperDownloading] = useState(false)
|
||||
const [whisperDownloadProgress, setWhisperDownloadProgress] = useState(0)
|
||||
const [whisperModelStatus, setWhisperModelStatus] = useState<{ exists: boolean; modelPath?: string; tokensPath?: string } | null>(null)
|
||||
const [autoTranscribeVoice, setAutoTranscribeVoice] = useState(false)
|
||||
const [transcribeLanguages, setTranscribeLanguages] = useState<string[]>(['zh'])
|
||||
|
||||
const [isLoading, setIsLoadingState] = useState(false)
|
||||
const [isTesting, setIsTesting] = useState(false)
|
||||
@@ -50,13 +65,28 @@ function SettingsPage() {
|
||||
const [dbKeyStatus, setDbKeyStatus] = useState('')
|
||||
const [imageKeyStatus, setImageKeyStatus] = useState('')
|
||||
const [isManualStartPrompt, setIsManualStartPrompt] = useState(false)
|
||||
const [isClearingAnalyticsCache, setIsClearingAnalyticsCache] = useState(false)
|
||||
const [isClearingImageCache, setIsClearingImageCache] = useState(false)
|
||||
const [isClearingAllCache, setIsClearingAllCache] = useState(false)
|
||||
|
||||
const isClearingCache = isClearingAnalyticsCache || isClearingImageCache || isClearingAllCache
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig()
|
||||
loadDefaultExportPath()
|
||||
loadAppVersion()
|
||||
}, [])
|
||||
|
||||
// 点击外部关闭下拉框
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (showWxidSelect && wxidDropdownRef.current && !wxidDropdownRef.current.contains(e.target as Node)) {
|
||||
setShowWxidSelect(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [showWxidSelect])
|
||||
|
||||
useEffect(() => {
|
||||
const removeDb = window.electronAPI.key.onDbKeyStatus((payload) => {
|
||||
setDbKeyStatus(payload.message)
|
||||
@@ -80,28 +110,50 @@ function SettingsPage() {
|
||||
const savedLogEnabled = await configService.getLogEnabled()
|
||||
const savedImageXorKey = await configService.getImageXorKey()
|
||||
const savedImageAesKey = await configService.getImageAesKey()
|
||||
const savedWhisperModelName = await configService.getWhisperModelName()
|
||||
const savedWhisperModelDir = await configService.getWhisperModelDir()
|
||||
const savedAutoTranscribe = await configService.getAutoTranscribeVoice()
|
||||
const savedTranscribeLanguages = await configService.getTranscribeLanguages()
|
||||
|
||||
if (savedKey) setDecryptKey(savedKey)
|
||||
if (savedPath) setDbPath(savedPath)
|
||||
if (savedWxid) setWxid(savedWxid)
|
||||
if (savedCachePath) setCachePath(savedCachePath)
|
||||
if (savedExportPath) setExportPath(savedExportPath)
|
||||
if (savedImageXorKey != null) {
|
||||
setImageXorKey(`0x${savedImageXorKey.toString(16).toUpperCase().padStart(2, '0')}`)
|
||||
}
|
||||
if (savedImageAesKey) setImageAesKey(savedImageAesKey)
|
||||
setLogEnabled(savedLogEnabled)
|
||||
setAutoTranscribeVoice(savedAutoTranscribe)
|
||||
setTranscribeLanguages(savedTranscribeLanguages)
|
||||
|
||||
// 如果语言列表为空,保存默认值
|
||||
if (!savedTranscribeLanguages || savedTranscribeLanguages.length === 0) {
|
||||
const defaultLanguages = ['zh']
|
||||
setTranscribeLanguages(defaultLanguages)
|
||||
await configService.setTranscribeLanguages(defaultLanguages)
|
||||
}
|
||||
|
||||
if (savedWhisperModelDir) setWhisperModelDir(savedWhisperModelDir)
|
||||
} catch (e) {
|
||||
console.error('加载配置失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const loadDefaultExportPath = async () => {
|
||||
|
||||
|
||||
const refreshWhisperStatus = async (modelDirValue = whisperModelDir) => {
|
||||
try {
|
||||
const downloadsPath = await window.electronAPI.app.getDownloadsPath()
|
||||
setDefaultExportPath(downloadsPath)
|
||||
} catch (e) {
|
||||
console.error('获取默认导出路径失败:', e)
|
||||
const result = await window.electronAPI.whisper?.getModelStatus()
|
||||
if (result?.success) {
|
||||
setWhisperModelStatus({
|
||||
exists: Boolean(result.exists),
|
||||
modelPath: result.modelPath,
|
||||
tokensPath: result.tokensPath
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
setWhisperModelStatus(null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +174,19 @@ function SettingsPage() {
|
||||
return () => removeListener?.()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const removeListener = window.electronAPI.whisper?.onDownloadProgress?.((payload) => {
|
||||
if (typeof payload.percent === 'number') {
|
||||
setWhisperDownloadProgress(payload.percent)
|
||||
}
|
||||
})
|
||||
return () => removeListener?.()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void refreshWhisperStatus(whisperModelDir)
|
||||
}, [whisperModelDir])
|
||||
|
||||
const handleCheckUpdate = async () => {
|
||||
setIsCheckingUpdate(true)
|
||||
setUpdateInfo(null)
|
||||
@@ -129,9 +194,9 @@ function SettingsPage() {
|
||||
const result = await window.electronAPI.app.checkForUpdates()
|
||||
if (result.hasUpdate) {
|
||||
setUpdateInfo(result)
|
||||
showMessage(`发现新版本 ${result.version}`, true)
|
||||
showMessage(`发现新版:${result.version}`, true)
|
||||
} else {
|
||||
showMessage('当前已是最新版本', true)
|
||||
showMessage('当前已是最新版', true)
|
||||
}
|
||||
} catch (e) {
|
||||
showMessage(`检查更新失败: ${e}`, false)
|
||||
@@ -168,12 +233,14 @@ function SettingsPage() {
|
||||
showMessage(`自动检测成功:${result.path}`, true)
|
||||
|
||||
const wxids = await window.electronAPI.dbPath.scanWxids(result.path)
|
||||
setWxidOptions(wxids)
|
||||
if (wxids.length === 1) {
|
||||
setWxid(wxids[0].wxid)
|
||||
await configService.setMyWxid(wxids[0].wxid)
|
||||
showMessage(`已检测到账号:${wxids[0].wxid}`, true)
|
||||
} else if (wxids.length > 1) {
|
||||
showMessage(`检测到 ${wxids.length} 个账号,请手动选择`, true)
|
||||
// 多账号时弹出选择对话框
|
||||
setShowWxidSelect(true)
|
||||
}
|
||||
} else {
|
||||
showMessage(result.error || '未能自动检测到数据库目录', false)
|
||||
@@ -204,12 +271,14 @@ function SettingsPage() {
|
||||
}
|
||||
try {
|
||||
const wxids = await window.electronAPI.dbPath.scanWxids(dbPath)
|
||||
setWxidOptions(wxids)
|
||||
if (wxids.length === 1) {
|
||||
setWxid(wxids[0].wxid)
|
||||
await configService.setMyWxid(wxids[0].wxid)
|
||||
if (!silent) showMessage(`已检测到账号:${wxids[0].wxid}`, true)
|
||||
} else if (wxids.length > 1) {
|
||||
if (!silent) showMessage(`检测到 ${wxids.length} 个账号,请手动选择`, true)
|
||||
// 多账号时弹出选择对话框
|
||||
setShowWxidSelect(true)
|
||||
} else {
|
||||
if (!silent) showMessage('未检测到账号目录,请检查路径', false)
|
||||
}
|
||||
@@ -218,6 +287,13 @@ function SettingsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectWxid = async (selectedWxid: string) => {
|
||||
setWxid(selectedWxid)
|
||||
await configService.setMyWxid(selectedWxid)
|
||||
setShowWxidSelect(false)
|
||||
showMessage(`已选择账号:${selectedWxid}`, true)
|
||||
}
|
||||
|
||||
const handleSelectCachePath = async () => {
|
||||
try {
|
||||
const result = await dialog.openFile({ title: '选择缓存目录', properties: ['openDirectory'] })
|
||||
@@ -230,19 +306,53 @@ function SettingsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectExportPath = async () => {
|
||||
|
||||
|
||||
const handleSelectWhisperModelDir = async () => {
|
||||
try {
|
||||
const result = await dialog.openFile({ title: '选择导出目录', properties: ['openDirectory'] })
|
||||
const result = await dialog.openFile({ title: '选择 Whisper 模型下载目录', properties: ['openDirectory'] })
|
||||
if (!result.canceled && result.filePaths.length > 0) {
|
||||
setExportPath(result.filePaths[0])
|
||||
await configService.setExportPath(result.filePaths[0])
|
||||
showMessage('已设置导出目录', true)
|
||||
const dir = result.filePaths[0]
|
||||
setWhisperModelDir(dir)
|
||||
await configService.setWhisperModelDir(dir)
|
||||
showMessage('已选择 Whisper 模型目录', true)
|
||||
}
|
||||
} catch (e) {
|
||||
showMessage('选择目录失败', false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleWhisperModelChange = async (value: string) => {
|
||||
setWhisperModelName(value)
|
||||
setWhisperDownloadProgress(0)
|
||||
await configService.setWhisperModelName(value)
|
||||
}
|
||||
|
||||
const handleDownloadWhisperModel = async () => {
|
||||
if (isWhisperDownloading) return
|
||||
setIsWhisperDownloading(true)
|
||||
setWhisperDownloadProgress(0)
|
||||
try {
|
||||
const result = await window.electronAPI.whisper.downloadModel()
|
||||
if (result.success) {
|
||||
setWhisperDownloadProgress(100)
|
||||
showMessage('SenseVoiceSmall 模型下载完成', true)
|
||||
await refreshWhisperStatus(whisperModelDir)
|
||||
} else {
|
||||
showMessage(result.error || '模型下载失败', false)
|
||||
}
|
||||
} catch (e) {
|
||||
showMessage(`模型下载失败: ${e}`, false)
|
||||
} finally {
|
||||
setIsWhisperDownloading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleResetWhisperModelDir = async () => {
|
||||
setWhisperModelDir('')
|
||||
await configService.setWhisperModelDir('')
|
||||
}
|
||||
|
||||
const handleAutoGetDbKey = async () => {
|
||||
if (isFetchingDbKey) return
|
||||
setIsFetchingDbKey(true)
|
||||
@@ -303,16 +413,7 @@ function SettingsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleResetExportPath = async () => {
|
||||
try {
|
||||
const downloadsPath = await window.electronAPI.app.getDownloadsPath()
|
||||
setExportPath(downloadsPath)
|
||||
await configService.setExportPath(downloadsPath)
|
||||
showMessage('已恢复为下载目录', true)
|
||||
} catch (e) {
|
||||
showMessage('恢复默认失败', false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
if (!dbPath) { showMessage('请先选择数据库目录', false); return }
|
||||
@@ -362,6 +463,9 @@ function SettingsPage() {
|
||||
} else {
|
||||
await configService.setImageAesKey('')
|
||||
}
|
||||
await configService.setWhisperModelDir(whisperModelDir)
|
||||
await configService.setAutoTranscribeVoice(autoTranscribeVoice)
|
||||
await configService.setTranscribeLanguages(transcribeLanguages)
|
||||
await configService.setOnboardingDone(true)
|
||||
|
||||
showMessage('配置保存成功,正在测试连接...', true)
|
||||
@@ -382,7 +486,7 @@ function SettingsPage() {
|
||||
}
|
||||
|
||||
const handleClearConfig = async () => {
|
||||
const confirmed = window.confirm('确定要清除当前配置吗?清除后需要重新完成首次配置。')
|
||||
const confirmed = window.confirm('确定要清除当前配置吗?清除后需要重新完成首次配置?')
|
||||
if (!confirmed) return
|
||||
setIsLoadingState(true)
|
||||
setLoading(true, '正在清除配置...')
|
||||
@@ -396,8 +500,13 @@ function SettingsPage() {
|
||||
setDbPath('')
|
||||
setWxid('')
|
||||
setCachePath('')
|
||||
setExportPath('')
|
||||
setLogEnabled(false)
|
||||
setAutoTranscribeVoice(false)
|
||||
setTranscribeLanguages(['zh'])
|
||||
setWhisperModelDir('')
|
||||
setWhisperModelStatus(null)
|
||||
setWhisperDownloadProgress(0)
|
||||
setIsWhisperDownloading(false)
|
||||
setDbConnected(false)
|
||||
await window.electronAPI.window.openOnboardingWindow()
|
||||
} catch (e) {
|
||||
@@ -431,6 +540,59 @@ function SettingsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearAnalyticsCache = async () => {
|
||||
if (isClearingCache) return
|
||||
setIsClearingAnalyticsCache(true)
|
||||
try {
|
||||
const result = await window.electronAPI.cache.clearAnalytics()
|
||||
if (result.success) {
|
||||
clearAnalyticsStoreCache()
|
||||
showMessage('已清除分析缓存', true)
|
||||
} else {
|
||||
showMessage(`清除分析缓存失败: ${result.error || '未知错误'}`, false)
|
||||
}
|
||||
} catch (e) {
|
||||
showMessage(`清除分析缓存失败: ${e}`, false)
|
||||
} finally {
|
||||
setIsClearingAnalyticsCache(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearImageCache = async () => {
|
||||
if (isClearingCache) return
|
||||
setIsClearingImageCache(true)
|
||||
try {
|
||||
const result = await window.electronAPI.cache.clearImages()
|
||||
if (result.success) {
|
||||
showMessage('已清除图片缓存', true)
|
||||
} else {
|
||||
showMessage(`清除图片缓存失败: ${result.error || '未知错误'}`, false)
|
||||
}
|
||||
} catch (e) {
|
||||
showMessage(`清除图片缓存失败: ${e}`, false)
|
||||
} finally {
|
||||
setIsClearingImageCache(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearAllCache = async () => {
|
||||
if (isClearingCache) return
|
||||
setIsClearingAllCache(true)
|
||||
try {
|
||||
const result = await window.electronAPI.cache.clearAll()
|
||||
if (result.success) {
|
||||
clearAnalyticsStoreCache()
|
||||
showMessage('已清除所有缓存', true)
|
||||
} else {
|
||||
showMessage(`清除所有缓存失败: ${result.error || '未知错误'}`, false)
|
||||
}
|
||||
} catch (e) {
|
||||
showMessage(`清除所有缓存失败: ${e}`, false)
|
||||
} finally {
|
||||
setIsClearingAllCache(false)
|
||||
}
|
||||
}
|
||||
|
||||
const renderAppearanceTab = () => (
|
||||
<div className="tab-content">
|
||||
<div className="theme-mode-toggle">
|
||||
@@ -487,6 +649,7 @@ function SettingsPage() {
|
||||
<div className="form-group">
|
||||
<label>数据库根目录</label>
|
||||
<span className="form-hint">xwechat_files 目录</span>
|
||||
<span className="form-hint" style={{ color: '#ff6b6b' }}>⚠️ 目录路径不可包含中文,如有中文请去微信-设置-存储位置点击更改,迁移至全英文目录</span>
|
||||
<input type="text" placeholder="例如: C:\Users\xxx\Documents\xwechat_files" value={dbPath} onChange={(e) => setDbPath(e.target.value)} />
|
||||
<div className="btn-row">
|
||||
<button className="btn btn-primary" onClick={handleAutoDetectPath} disabled={isDetectingPath}>
|
||||
@@ -499,7 +662,38 @@ function SettingsPage() {
|
||||
<div className="form-group">
|
||||
<label>账号 wxid</label>
|
||||
<span className="form-hint">微信账号标识</span>
|
||||
<input type="text" placeholder="例如: wxid_xxxxxx" value={wxid} onChange={(e) => setWxid(e.target.value)} />
|
||||
<div className="wxid-input-wrapper" ref={wxidDropdownRef}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="例如: wxid_xxxxxx"
|
||||
value={wxid}
|
||||
onChange={(e) => setWxid(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={`wxid-dropdown-btn ${showWxidSelect ? 'open' : ''}`}
|
||||
onClick={() => wxidOptions.length > 0 ? setShowWxidSelect(!showWxidSelect) : handleScanWxid()}
|
||||
title={wxidOptions.length > 0 ? "选择已检测到的账号" : "扫描账号"}
|
||||
>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
{showWxidSelect && wxidOptions.length > 0 && (
|
||||
<div className="wxid-dropdown">
|
||||
{wxidOptions.map((opt) => (
|
||||
<div
|
||||
key={opt.wxid}
|
||||
className={`wxid-option ${opt.wxid === wxid ? 'active' : ''}`}
|
||||
onClick={() => handleSelectWxid(opt.wxid)}
|
||||
>
|
||||
<span className="wxid-value">{opt.wxid}</span>
|
||||
<span className="wxid-time">
|
||||
{new Date(opt.modifiedTime).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => handleScanWxid()}><Search size={14} /> 扫描 wxid</button>
|
||||
</div>
|
||||
|
||||
@@ -517,16 +711,7 @@ function SettingsPage() {
|
||||
<Plug size={14} /> {isFetchingImageKey ? '获取中...' : '自动获取图片密钥'}
|
||||
</button>
|
||||
{imageKeyStatus && <div className="form-hint status-text">{imageKeyStatus}</div>}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>缓存目录 <span className="optional">(可选)</span></label>
|
||||
<span className="form-hint">留空使用默认目录</span>
|
||||
<input type="text" placeholder="留空使用默认目录" value={cachePath} onChange={(e) => setCachePath(e.target.value)} />
|
||||
<div className="btn-row">
|
||||
<button className="btn btn-secondary" onClick={handleSelectCachePath}><FolderOpen size={16} /> 浏览选择</button>
|
||||
<button className="btn btn-secondary" onClick={() => setCachePath('')}><RotateCcw size={16} /> 恢复默认</button>
|
||||
</div>
|
||||
{isFetchingImageKey && <div className="form-hint status-text">正在扫描内存,请稍候...</div>}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
@@ -561,28 +746,135 @@ function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderExportTab = () => (
|
||||
const renderWhisperTab = () => (
|
||||
<div className="tab-content">
|
||||
<div className="form-group">
|
||||
<label>导出目录</label>
|
||||
<span className="form-hint">聊天记录导出的默认保存位置</span>
|
||||
<input type="text" placeholder={defaultExportPath || '系统下载目录'} value={exportPath || defaultExportPath} onChange={(e) => setExportPath(e.target.value)} />
|
||||
<div className="btn-row">
|
||||
<button className="btn btn-secondary" onClick={handleSelectExportPath}><FolderOpen size={16} /> 浏览选择</button>
|
||||
<button className="btn btn-secondary" onClick={handleResetExportPath}><RotateCcw size={16} /> 恢复默认</button>
|
||||
<label>自动语音转文字</label>
|
||||
<span className="form-hint">语音解密后自动转写为文字(需下载模型)</span>
|
||||
<div className="log-toggle-line">
|
||||
<span className="log-status">{autoTranscribeVoice ? '已开启' : '已关闭'}</span>
|
||||
<label className="switch" htmlFor="auto-transcribe-toggle">
|
||||
<input
|
||||
id="auto-transcribe-toggle"
|
||||
className="switch-input"
|
||||
type="checkbox"
|
||||
checked={autoTranscribeVoice}
|
||||
onChange={async (e) => {
|
||||
const enabled = e.target.checked
|
||||
setAutoTranscribeVoice(enabled)
|
||||
await configService.setAutoTranscribeVoice(enabled)
|
||||
showMessage(enabled ? '已开启自动转文字' : '已关闭自动转文字', true)
|
||||
}}
|
||||
/>
|
||||
<span className="switch-slider" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>支持的语言</label>
|
||||
<span className="form-hint">选择需要识别的语言(至少选择一种)</span>
|
||||
<div className="language-checkboxes">
|
||||
{[
|
||||
{ code: 'zh', name: '中文' },
|
||||
{ code: 'yue', name: '粤语' },
|
||||
{ code: 'en', name: '英文' },
|
||||
{ code: 'ja', name: '日文' },
|
||||
{ code: 'ko', name: '韩文' }
|
||||
].map((lang) => (
|
||||
<label key={lang.code} className="language-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={transcribeLanguages.includes(lang.code)}
|
||||
onChange={async (e) => {
|
||||
const checked = e.target.checked
|
||||
let newLanguages: string[]
|
||||
|
||||
if (checked) {
|
||||
newLanguages = [...transcribeLanguages, lang.code]
|
||||
} else {
|
||||
if (transcribeLanguages.length <= 1) {
|
||||
showMessage('至少需要选择一种语言', false)
|
||||
return
|
||||
}
|
||||
newLanguages = transcribeLanguages.filter(l => l !== lang.code)
|
||||
}
|
||||
|
||||
setTranscribeLanguages(newLanguages)
|
||||
await configService.setTranscribeLanguages(newLanguages)
|
||||
showMessage(`已${checked ? '添加' : '移除'}${lang.name}`, true)
|
||||
}}
|
||||
/>
|
||||
<div className="checkbox-custom">
|
||||
<Check size={14} />
|
||||
<span>{lang.name}</span>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group whisper-section">
|
||||
<label>语音识别模型 (SenseVoiceSmall)</label>
|
||||
<span className="form-hint">基于 Sherpa-onnx,支持中、粤、英、日、韩及情感/事件识别</span>
|
||||
<span className="form-hint">模型下载目录</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="留空使用默认目录"
|
||||
value={whisperModelDir}
|
||||
onChange={(e) => setWhisperModelDir(e.target.value)}
|
||||
onBlur={() => configService.setWhisperModelDir(whisperModelDir)}
|
||||
/>
|
||||
<div className="btn-row">
|
||||
<button className="btn btn-secondary" onClick={handleSelectWhisperModelDir}><FolderOpen size={16} /> 选择目录</button>
|
||||
<button className="btn btn-secondary" onClick={handleResetWhisperModelDir}><RotateCcw size={16} /> 默认目录</button>
|
||||
</div>
|
||||
<div className="whisper-status-line">
|
||||
<span className={`status ${whisperModelStatus?.exists ? 'ok' : 'warn'}`}>
|
||||
{whisperModelStatus?.exists ? '已下载 (240 MB)' : '未下载 (240 MB)'}
|
||||
</span>
|
||||
{whisperModelStatus?.modelPath && <span className="path">{whisperModelStatus.modelPath}</span>}
|
||||
</div>
|
||||
{isWhisperDownloading ? (
|
||||
<div className="whisper-progress">
|
||||
<div className="progress-info">
|
||||
<span>正在准备模型文件...</span>
|
||||
<span className="percent">{whisperDownloadProgress.toFixed(0)}%</span>
|
||||
</div>
|
||||
<div className="progress-bar-container">
|
||||
<div className="progress-bar">
|
||||
<div className="progress-fill" style={{ width: `${whisperDownloadProgress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button className="btn btn-primary btn-download-model" onClick={handleDownloadWhisperModel}>
|
||||
<Download size={18} /> 下载模型
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderCacheTab = () => (
|
||||
<div className="tab-content">
|
||||
<p className="section-desc">管理应用缓存数据</p>
|
||||
<div className="form-group">
|
||||
<label>缓存目录 <span className="optional">(可选)</span></label>
|
||||
<span className="form-hint">留空使用默认目录</span>
|
||||
<input type="text" placeholder="留空使用默认目录" value={cachePath} onChange={(e) => setCachePath(e.target.value)} />
|
||||
<div className="btn-row">
|
||||
<button className="btn btn-secondary" onClick={handleSelectCachePath}><FolderOpen size={16} /> 浏览选择</button>
|
||||
<button className="btn btn-secondary" onClick={() => setCachePath('')}><RotateCcw size={16} /> 恢复默认</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="btn-row">
|
||||
<button className="btn btn-secondary"><Trash2 size={16} /> 清除分析缓存</button>
|
||||
<button className="btn btn-secondary"><Trash2 size={16} /> 清除图片缓存</button>
|
||||
<button className="btn btn-danger"><Trash2 size={16} /> 清除所有缓存</button>
|
||||
<button className="btn btn-secondary" onClick={handleClearAnalyticsCache} disabled={isClearingCache}>
|
||||
<Trash2 size={16} /> 清除分析缓存
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={handleClearImageCache} disabled={isClearingCache}>
|
||||
<Trash2 size={16} /> 清除图片缓存
|
||||
</button>
|
||||
<button className="btn btn-danger" onClick={handleClearAllCache} disabled={isClearingCache}>
|
||||
<Trash2 size={16} /> 清除所有缓存</button>
|
||||
</div>
|
||||
<div className="divider" />
|
||||
<p className="section-desc">清除当前配置并重新开始首次引导</p>
|
||||
@@ -607,7 +899,7 @@ function SettingsPage() {
|
||||
<div className="about-update">
|
||||
{updateInfo?.hasUpdate ? (
|
||||
<>
|
||||
<p className="update-hint">新版本 v{updateInfo.version} 可用</p>
|
||||
<p className="update-hint">新版 v{updateInfo.version} 可用</p>
|
||||
{isDownloading ? (
|
||||
<div className="download-progress">
|
||||
<div className="progress-bar">
|
||||
@@ -648,6 +940,33 @@ function SettingsPage() {
|
||||
<div className="settings-page">
|
||||
{message && <div className={`message-toast ${message.success ? 'success' : 'error'}`}>{message.text}</div>}
|
||||
|
||||
{/* 多账号选择对话框 */}
|
||||
{showWxidSelect && wxidOptions.length > 1 && (
|
||||
<div className="wxid-dialog-overlay" onClick={() => setShowWxidSelect(false)}>
|
||||
<div className="wxid-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="wxid-dialog-header">
|
||||
<h3>检测到多个微信账号</h3>
|
||||
<p>请选择要使用的账号</p>
|
||||
</div>
|
||||
<div className="wxid-dialog-list">
|
||||
{wxidOptions.map((opt) => (
|
||||
<div
|
||||
key={opt.wxid}
|
||||
className={`wxid-dialog-item ${opt.wxid === wxid ? 'active' : ''}`}
|
||||
onClick={() => handleSelectWxid(opt.wxid)}
|
||||
>
|
||||
<span className="wxid-id">{opt.wxid}</span>
|
||||
<span className="wxid-date">最后修改 {new Date(opt.modifiedTime).toLocaleString()}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="wxid-dialog-footer">
|
||||
<button className="btn btn-secondary" onClick={() => setShowWxidSelect(false)}>取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="settings-header">
|
||||
<h1>设置</h1>
|
||||
<div className="settings-actions">
|
||||
@@ -672,7 +991,7 @@ function SettingsPage() {
|
||||
<div className="settings-body">
|
||||
{activeTab === 'appearance' && renderAppearanceTab()}
|
||||
{activeTab === 'database' && renderDatabaseTab()}
|
||||
{activeTab === 'export' && renderExportTab()}
|
||||
{activeTab === 'whisper' && renderWhisperTab()}
|
||||
{activeTab === 'cache' && renderCacheTab()}
|
||||
{activeTab === 'about' && renderAboutTab()}
|
||||
</div>
|
||||
@@ -681,3 +1000,5 @@ function SettingsPage() {
|
||||
}
|
||||
|
||||
export default SettingsPage
|
||||
|
||||
|
||||
|
||||
@@ -442,6 +442,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
</button>
|
||||
</div>
|
||||
<div className="field-hint">建议选择包含 xwechat_files 的目录</div>
|
||||
<div className="field-hint" style={{ color: '#ff6b6b', marginTop: '4px' }}>⚠️ 目录路径不可包含中文,如有中文请去微信-设置-存储位置点击更改,迁移至全英文目录</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -506,6 +507,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
|
||||
{dbKeyStatus && <div className="field-hint status-text">{dbKeyStatus}</div>}
|
||||
<div className="field-hint">获取密钥会自动识别最近登录的账号</div>
|
||||
<div className="field-hint">点击自动获取后微信将重新启动,当页面提示可以登录微信了再点击登录</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -532,6 +534,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
</button>
|
||||
{imageKeyStatus && <div className="field-hint status-text">{imageKeyStatus}</div>}
|
||||
<div className="field-hint">如获取失败,请先打开朋友圈图片再重试</div>
|
||||
{isFetchingImageKey && <div className="field-hint status-text">正在扫描内存,请稍候...</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -15,8 +15,14 @@ export const CONFIG_KEYS = {
|
||||
AGREEMENT_ACCEPTED: 'agreementAccepted',
|
||||
LOG_ENABLED: 'logEnabled',
|
||||
ONBOARDING_DONE: 'onboardingDone',
|
||||
LLM_MODEL_PATH: 'llmModelPath',
|
||||
IMAGE_XOR_KEY: 'imageXorKey',
|
||||
IMAGE_AES_KEY: 'imageAesKey'
|
||||
IMAGE_AES_KEY: 'imageAesKey',
|
||||
WHISPER_MODEL_NAME: 'whisperModelName',
|
||||
WHISPER_MODEL_DIR: 'whisperModelDir',
|
||||
WHISPER_DOWNLOAD_SOURCE: 'whisperDownloadSource',
|
||||
AUTO_TRANSCRIBE_VOICE: 'autoTranscribeVoice',
|
||||
TRANSCRIBE_LANGUAGES: 'transcribeLanguages'
|
||||
} as const
|
||||
|
||||
// 获取解密密钥
|
||||
@@ -132,6 +138,50 @@ export async function setLogEnabled(enabled: boolean): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.LOG_ENABLED, enabled)
|
||||
}
|
||||
|
||||
// 获取 LLM 模型路径
|
||||
export async function getLlmModelPath(): Promise<string | null> {
|
||||
const value = await config.get(CONFIG_KEYS.LLM_MODEL_PATH)
|
||||
return (value as string) || null
|
||||
}
|
||||
|
||||
// 设置 LLM 模型路径
|
||||
export async function setLlmModelPath(path: string): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.LLM_MODEL_PATH, path)
|
||||
}
|
||||
|
||||
// 获取 Whisper 模型名称
|
||||
export async function getWhisperModelName(): Promise<string | null> {
|
||||
const value = await config.get(CONFIG_KEYS.WHISPER_MODEL_NAME)
|
||||
return (value as string) || null
|
||||
}
|
||||
|
||||
// 设置 Whisper 模型名称
|
||||
export async function setWhisperModelName(name: string): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.WHISPER_MODEL_NAME, name)
|
||||
}
|
||||
|
||||
// 获取 Whisper 模型目录
|
||||
export async function getWhisperModelDir(): Promise<string | null> {
|
||||
const value = await config.get(CONFIG_KEYS.WHISPER_MODEL_DIR)
|
||||
return (value as string) || null
|
||||
}
|
||||
|
||||
// 设置 Whisper 模型目录
|
||||
export async function setWhisperModelDir(dir: string): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.WHISPER_MODEL_DIR, dir)
|
||||
}
|
||||
|
||||
// 获取 Whisper 下载源
|
||||
export async function getWhisperDownloadSource(): Promise<string | null> {
|
||||
const value = await config.get(CONFIG_KEYS.WHISPER_DOWNLOAD_SOURCE)
|
||||
return (value as string) || null
|
||||
}
|
||||
|
||||
// 设置 Whisper 下载源
|
||||
export async function setWhisperDownloadSource(source: string): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.WHISPER_DOWNLOAD_SOURCE, source)
|
||||
}
|
||||
|
||||
// 清除所有配置
|
||||
export async function clearConfig(): Promise<void> {
|
||||
await config.clear()
|
||||
@@ -170,3 +220,26 @@ export async function getOnboardingDone(): Promise<boolean> {
|
||||
export async function setOnboardingDone(done: boolean): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.ONBOARDING_DONE, done)
|
||||
}
|
||||
|
||||
// 获取自动语音转文字开关
|
||||
export async function getAutoTranscribeVoice(): Promise<boolean> {
|
||||
const value = await config.get(CONFIG_KEYS.AUTO_TRANSCRIBE_VOICE)
|
||||
return value === true
|
||||
}
|
||||
|
||||
// 设置自动语音转文字开关
|
||||
export async function setAutoTranscribeVoice(enabled: boolean): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.AUTO_TRANSCRIBE_VOICE, enabled)
|
||||
}
|
||||
|
||||
// 获取语音转文字支持的语言列表
|
||||
export async function getTranscribeLanguages(): Promise<string[]> {
|
||||
const value = await config.get(CONFIG_KEYS.TRANSCRIBE_LANGUAGES)
|
||||
// 默认只支持中文
|
||||
return (value as string[]) || ['zh']
|
||||
}
|
||||
|
||||
// 设置语音转文字支持的语言列表
|
||||
export async function setTranscribeLanguages(languages: string[]): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.TRANSCRIBE_LANGUAGES, languages)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
|
||||
interface ChatStatistics {
|
||||
totalMessages: number
|
||||
@@ -49,22 +50,29 @@ interface AnalyticsState {
|
||||
clearCache: () => void
|
||||
}
|
||||
|
||||
export const useAnalyticsStore = create<AnalyticsState>((set) => ({
|
||||
statistics: null,
|
||||
rankings: [],
|
||||
timeDistribution: null,
|
||||
isLoaded: false,
|
||||
lastLoadTime: null,
|
||||
export const useAnalyticsStore = create<AnalyticsState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
statistics: null,
|
||||
rankings: [],
|
||||
timeDistribution: null,
|
||||
isLoaded: false,
|
||||
lastLoadTime: null,
|
||||
|
||||
setStatistics: (data) => set({ statistics: data }),
|
||||
setRankings: (data) => set({ rankings: data }),
|
||||
setTimeDistribution: (data) => set({ timeDistribution: data }),
|
||||
markLoaded: () => set({ isLoaded: true, lastLoadTime: Date.now() }),
|
||||
clearCache: () => set({
|
||||
statistics: null,
|
||||
rankings: [],
|
||||
timeDistribution: null,
|
||||
isLoaded: false,
|
||||
lastLoadTime: null
|
||||
}),
|
||||
}))
|
||||
setStatistics: (data) => set({ statistics: data }),
|
||||
setRankings: (data) => set({ rankings: data }),
|
||||
setTimeDistribution: (data) => set({ timeDistribution: data }),
|
||||
markLoaded: () => set({ isLoaded: true, lastLoadTime: Date.now() }),
|
||||
clearCache: () => set({
|
||||
statistics: null,
|
||||
rankings: [],
|
||||
timeDistribution: null,
|
||||
isLoaded: false,
|
||||
lastLoadTime: null
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: 'analytics-storage',
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
36
src/types/electron.d.ts
vendored
36
src/types/electron.d.ts
vendored
@@ -45,6 +45,7 @@ export interface ElectronAPI {
|
||||
testConnection: (dbPath: string, hexKey: string, wxid: string) => Promise<{ success: boolean; error?: string; sessionCount?: number }>
|
||||
open: (dbPath: string, hexKey: string, wxid: string) => Promise<boolean>
|
||||
close: () => Promise<boolean>
|
||||
|
||||
}
|
||||
key: {
|
||||
autoGetDbKey: () => Promise<{ success: boolean; key?: string; error?: string; logs?: string[] }>
|
||||
@@ -55,6 +56,11 @@ export interface ElectronAPI {
|
||||
chat: {
|
||||
connect: () => Promise<{ success: boolean; error?: string }>
|
||||
getSessions: () => Promise<{ success: boolean; sessions?: ChatSession[]; error?: string }>
|
||||
enrichSessionsContactInfo: (usernames: string[]) => Promise<{
|
||||
success: boolean
|
||||
contacts?: Record<string, { displayName?: string; avatarUrl?: string }>
|
||||
error?: string
|
||||
}>
|
||||
getMessages: (sessionId: string, offset?: number, limit?: number) => Promise<{
|
||||
success: boolean;
|
||||
messages?: Message[];
|
||||
@@ -88,8 +94,12 @@ export interface ElectronAPI {
|
||||
error?: string
|
||||
}>
|
||||
getImageData: (sessionId: string, msgId: string) => Promise<{ success: boolean; data?: string; error?: string }>
|
||||
getVoiceData: (sessionId: string, msgId: string) => Promise<{ success: boolean; data?: string; error?: string }>
|
||||
getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => Promise<{ success: boolean; data?: string; error?: string }>
|
||||
resolveVoiceCache: (sessionId: string, msgId: string) => Promise<{ success: boolean; hasCache: boolean; data?: string }>
|
||||
getVoiceTranscript: (sessionId: string, msgId: string) => Promise<{ success: boolean; transcript?: string; error?: string }>
|
||||
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => () => void
|
||||
}
|
||||
|
||||
image: {
|
||||
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => Promise<{ success: boolean; localPath?: string; error?: string }>
|
||||
resolveCache: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }) => Promise<{ success: boolean; localPath?: string; hasUpdate?: boolean; error?: string }>
|
||||
@@ -98,7 +108,7 @@ export interface ElectronAPI {
|
||||
onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => () => void
|
||||
}
|
||||
analytics: {
|
||||
getOverallStatistics: () => Promise<{
|
||||
getOverallStatistics: (force?: boolean) => Promise<{
|
||||
success: boolean
|
||||
data?: {
|
||||
totalMessages: number
|
||||
@@ -141,6 +151,11 @@ export interface ElectronAPI {
|
||||
}>
|
||||
onProgress: (callback: (payload: { status: string; progress: number }) => void) => () => void
|
||||
}
|
||||
cache: {
|
||||
clearAnalytics: () => Promise<{ success: boolean; error?: string }>
|
||||
clearImages: () => Promise<{ success: boolean; error?: string }>
|
||||
clearAll: () => Promise<{ success: boolean; error?: string }>
|
||||
}
|
||||
groupAnalytics: {
|
||||
getGroupChats: () => Promise<{
|
||||
success: boolean
|
||||
@@ -257,12 +272,12 @@ export interface ElectronAPI {
|
||||
fastestFriend: string
|
||||
fastestTime: number
|
||||
} | null
|
||||
topPhrases: Array<{
|
||||
phrase: string
|
||||
count: number
|
||||
}>
|
||||
}
|
||||
error?: string
|
||||
topPhrases: Array<{
|
||||
phrase: string
|
||||
count: number
|
||||
}>
|
||||
}
|
||||
error?: string
|
||||
}>
|
||||
exportImages: (payload: { baseDir: string; folderName: string; images: Array<{ name: string; dataUrl: string }> }) => Promise<{
|
||||
success: boolean
|
||||
@@ -283,6 +298,11 @@ export interface ElectronAPI {
|
||||
error?: string
|
||||
}>
|
||||
}
|
||||
whisper: {
|
||||
downloadModel: () => Promise<{ success: boolean; modelPath?: string; tokensPath?: string; error?: string }>
|
||||
getModelStatus: () => Promise<{ success: boolean; exists?: boolean; modelPath?: string; tokensPath?: string; sizeBytes?: number; error?: string }>
|
||||
onDownloadProgress: (callback: (payload: { modelName: string; downloadedBytes: number; totalBytes?: number; percent?: number }) => void) => () => void
|
||||
}
|
||||
}
|
||||
|
||||
export interface ExportOptions {
|
||||
|
||||
74
src/utils/AvatarLoadQueue.ts
Normal file
74
src/utils/AvatarLoadQueue.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
|
||||
// 全局头像加载队列管理器(限制并发,避免卡顿)
|
||||
export class AvatarLoadQueue {
|
||||
private queue: Array<{ url: string; resolve: () => void; reject: (error: Error) => void }> = []
|
||||
private loading = new Map<string, Promise<void>>()
|
||||
private activeCount = 0
|
||||
private readonly maxConcurrent = 3
|
||||
private readonly delayBetweenBatches = 10
|
||||
|
||||
private static instance: AvatarLoadQueue
|
||||
|
||||
public static getInstance(): AvatarLoadQueue {
|
||||
if (!AvatarLoadQueue.instance) {
|
||||
AvatarLoadQueue.instance = new AvatarLoadQueue()
|
||||
}
|
||||
return AvatarLoadQueue.instance
|
||||
}
|
||||
|
||||
async enqueue(url: string): Promise<void> {
|
||||
if (!url) return Promise.resolve()
|
||||
|
||||
// 核心修复:防止重复并发请求同一个 URL
|
||||
const existingPromise = this.loading.get(url)
|
||||
if (existingPromise) {
|
||||
return existingPromise
|
||||
}
|
||||
|
||||
const loadPromise = new Promise<void>((resolve, reject) => {
|
||||
this.queue.push({ url, resolve, reject })
|
||||
this.processQueue()
|
||||
})
|
||||
|
||||
this.loading.set(url, loadPromise)
|
||||
loadPromise.finally(() => {
|
||||
this.loading.delete(url)
|
||||
})
|
||||
|
||||
return loadPromise
|
||||
}
|
||||
|
||||
private async processQueue() {
|
||||
if (this.activeCount >= this.maxConcurrent || this.queue.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const task = this.queue.shift()
|
||||
if (!task) return
|
||||
|
||||
this.activeCount++
|
||||
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
this.activeCount--
|
||||
task.resolve()
|
||||
setTimeout(() => this.processQueue(), this.delayBetweenBatches)
|
||||
}
|
||||
img.onerror = () => {
|
||||
this.activeCount--
|
||||
task.reject(new Error(`Failed: ${task.url}`))
|
||||
setTimeout(() => this.processQueue(), this.delayBetweenBatches)
|
||||
}
|
||||
img.src = task.url
|
||||
|
||||
this.processQueue()
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.queue = []
|
||||
this.loading.clear()
|
||||
this.activeCount = 0
|
||||
}
|
||||
}
|
||||
|
||||
export const avatarLoadQueue = AvatarLoadQueue.getInstance()
|
||||
@@ -13,6 +13,7 @@
|
||||
},
|
||||
"include": [
|
||||
"vite.config.ts",
|
||||
"electron/**/*.ts"
|
||||
"electron/**/*.ts",
|
||||
"electron/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -10,6 +10,14 @@ export default defineConfig({
|
||||
port: 3000,
|
||||
strictPort: false // 如果3000被占用,自动尝试下一个
|
||||
},
|
||||
build: {
|
||||
commonjsOptions: {
|
||||
ignoreDynamicRequires: true
|
||||
}
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: []
|
||||
},
|
||||
plugins: [
|
||||
react(),
|
||||
electron([
|
||||
@@ -19,7 +27,14 @@ export default defineConfig({
|
||||
build: {
|
||||
outDir: 'dist-electron',
|
||||
rollupOptions: {
|
||||
external: ['better-sqlite3', 'koffi']
|
||||
external: [
|
||||
'better-sqlite3',
|
||||
'koffi',
|
||||
'fsevents',
|
||||
'whisper-node',
|
||||
'shelljs',
|
||||
'exceljs'
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,7 +45,10 @@ export default defineConfig({
|
||||
build: {
|
||||
outDir: 'dist-electron',
|
||||
rollupOptions: {
|
||||
external: ['koffi'],
|
||||
external: [
|
||||
'koffi',
|
||||
'fsevents'
|
||||
],
|
||||
output: {
|
||||
entryFileNames: 'annualReportWorker.js',
|
||||
inlineDynamicImports: true
|
||||
@@ -53,6 +71,42 @@ export default defineConfig({
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
entry: 'electron/wcdbWorker.ts',
|
||||
vite: {
|
||||
build: {
|
||||
outDir: 'dist-electron',
|
||||
rollupOptions: {
|
||||
external: [
|
||||
'better-sqlite3',
|
||||
'koffi',
|
||||
'fsevents'
|
||||
],
|
||||
output: {
|
||||
entryFileNames: 'wcdbWorker.js',
|
||||
inlineDynamicImports: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
entry: 'electron/transcribeWorker.ts',
|
||||
vite: {
|
||||
build: {
|
||||
outDir: 'dist-electron',
|
||||
rollupOptions: {
|
||||
external: [
|
||||
'sherpa-onnx-node'
|
||||
],
|
||||
output: {
|
||||
entryFileNames: 'transcribeWorker.js',
|
||||
inlineDynamicImports: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
entry: 'electron/preload.ts',
|
||||
onstart(options) {
|
||||
|
||||
Reference in New Issue
Block a user