mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-26 15:45:51 +00:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
503a77c7cf | ||
|
|
4452e4921c | ||
|
|
97c1aa582d | ||
|
|
076c008329 | ||
|
|
21d785dd3c | ||
|
|
348f6c81bf | ||
|
|
d5a2e2bb62 | ||
|
|
2b51e0659e | ||
|
|
3efca5e60c | ||
|
|
2f7b917f1c | ||
|
|
8623f86505 | ||
|
|
dc74641c19 | ||
|
|
db7817cc22 | ||
|
|
ada0f68182 | ||
|
|
fe806895f0 | ||
|
|
da137d0a8f | ||
|
|
93ebc3bce3 | ||
|
|
9f6e9eb9bc |
48
.github/workflows/release.yml
vendored
48
.github/workflows/release.yml
vendored
@@ -39,56 +39,10 @@ jobs:
|
||||
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@v5
|
||||
with:
|
||||
configuration: "changelog_config.json"
|
||||
outputFile: "release-notes.md"
|
||||
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
|
||||
npm pkg set build.releaseInfo.releaseNotes="修复了一些已知问题"
|
||||
|
||||
- name: Package and Publish
|
||||
env:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { app, BrowserWindow, ipcMain, nativeTheme } from 'electron'
|
||||
import { Worker } from 'worker_threads'
|
||||
import { join } from 'path'
|
||||
import { join, dirname } from 'path'
|
||||
import { autoUpdater } from 'electron-updater'
|
||||
import { readFile, writeFile, mkdir } from 'fs/promises'
|
||||
import { existsSync } from 'fs'
|
||||
@@ -13,7 +13,7 @@ import { imagePreloadService } from './services/imagePreloadService'
|
||||
import { analyticsService } from './services/analyticsService'
|
||||
import { groupAnalyticsService } from './services/groupAnalyticsService'
|
||||
import { annualReportService } from './services/annualReportService'
|
||||
import { exportService, ExportOptions } from './services/exportService'
|
||||
import { exportService, ExportOptions, ExportProgress } from './services/exportService'
|
||||
import { KeyService } from './services/keyService'
|
||||
import { voiceTranscribeService } from './services/voiceTranscribeService'
|
||||
import { videoService } from './services/videoService'
|
||||
@@ -28,6 +28,47 @@ const AUTO_UPDATE_ENABLED =
|
||||
process.env.AUTO_UPDATE_ENABLED === '1' ||
|
||||
(process.env.AUTO_UPDATE_ENABLED == null && !process.env.VITE_DEV_SERVER_URL)
|
||||
|
||||
// 使用白名单过滤 PATH,避免被第三方目录中的旧版 VC++ 运行库劫持。
|
||||
// 仅保留系统目录(Windows/System32/SysWOW64)和应用自身目录(可执行目录、resources)。
|
||||
function sanitizePathEnv() {
|
||||
// 开发模式不做裁剪,避免影响本地工具链
|
||||
if (process.env.VITE_DEV_SERVER_URL) return
|
||||
|
||||
const rawPath = process.env.PATH || process.env.Path
|
||||
if (!rawPath) return
|
||||
|
||||
const sep = process.platform === 'win32' ? ';' : ':'
|
||||
const parts = rawPath.split(sep).filter(Boolean)
|
||||
|
||||
const systemRoot = process.env.SystemRoot || process.env.WINDIR || ''
|
||||
const safePrefixes = [
|
||||
systemRoot,
|
||||
systemRoot ? join(systemRoot, 'System32') : '',
|
||||
systemRoot ? join(systemRoot, 'SysWOW64') : '',
|
||||
dirname(process.execPath),
|
||||
process.resourcesPath,
|
||||
join(process.resourcesPath || '', 'resources')
|
||||
].filter(Boolean)
|
||||
|
||||
const normalize = (p: string) => p.replace(/\\/g, '/').toLowerCase()
|
||||
const isSafe = (p: string) => {
|
||||
const np = normalize(p)
|
||||
return safePrefixes.some((prefix) => np.startsWith(normalize(prefix)))
|
||||
}
|
||||
|
||||
const filtered = parts.filter(isSafe)
|
||||
if (filtered.length !== parts.length) {
|
||||
const removed = parts.filter((p) => !isSafe(p))
|
||||
console.warn('[WeFlow] 使用白名单裁剪 PATH,移除目录:', removed)
|
||||
const nextPath = filtered.join(sep)
|
||||
process.env.PATH = nextPath
|
||||
process.env.Path = nextPath
|
||||
}
|
||||
}
|
||||
|
||||
// 启动时立即清理 PATH,后续创建的 worker 也能继承安全的环境
|
||||
sanitizePathEnv()
|
||||
|
||||
// 单例服务
|
||||
let configService: ConfigService | null = null
|
||||
|
||||
@@ -646,8 +687,13 @@ function registerIpcHandlers() {
|
||||
})
|
||||
|
||||
// 导出相关
|
||||
ipcMain.handle('export:exportSessions', async (_, sessionIds: string[], outputDir: string, options: ExportOptions) => {
|
||||
return exportService.exportSessions(sessionIds, outputDir, options)
|
||||
ipcMain.handle('export:exportSessions', async (event, sessionIds: string[], outputDir: string, options: ExportOptions) => {
|
||||
const onProgress = (progress: ExportProgress) => {
|
||||
if (!event.sender.isDestroyed()) {
|
||||
event.sender.send('export:progress', progress)
|
||||
}
|
||||
}
|
||||
return exportService.exportSessions(sessionIds, outputDir, options, onProgress)
|
||||
})
|
||||
|
||||
ipcMain.handle('export:exportSession', async (_, sessionId: string, outputPath: string, options: ExportOptions) => {
|
||||
|
||||
@@ -191,7 +191,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
exportSessions: (sessionIds: string[], outputDir: string, options: any) =>
|
||||
ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options),
|
||||
exportSession: (sessionId: string, outputPath: string, options: any) =>
|
||||
ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options)
|
||||
ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options),
|
||||
onProgress: (callback: (payload: { current: number; total: number; currentSession: string; phase: string }) => void) => {
|
||||
ipcRenderer.on('export:progress', (_, payload) => callback(payload))
|
||||
return () => ipcRenderer.removeAllListeners('export:progress')
|
||||
}
|
||||
},
|
||||
|
||||
whisper: {
|
||||
|
||||
@@ -71,6 +71,8 @@ export interface ExportOptions {
|
||||
exportVoices?: boolean
|
||||
exportEmojis?: boolean
|
||||
exportVoiceAsText?: boolean
|
||||
excelCompactColumns?: boolean
|
||||
sessionLayout?: 'shared' | 'per-session'
|
||||
}
|
||||
|
||||
interface MediaExportItem {
|
||||
@@ -407,14 +409,15 @@ class ExportService {
|
||||
private async exportMediaForMessage(
|
||||
msg: any,
|
||||
sessionId: string,
|
||||
mediaDir: string,
|
||||
mediaRootDir: string,
|
||||
mediaRelativePrefix: string,
|
||||
options: { exportImages?: boolean; exportVoices?: boolean; exportEmojis?: boolean; exportVoiceAsText?: boolean }
|
||||
): Promise<MediaExportItem | null> {
|
||||
const localType = msg.localType
|
||||
|
||||
// 图片消息
|
||||
if (localType === 3 && options.exportImages) {
|
||||
const result = await this.exportImage(msg, sessionId, mediaDir)
|
||||
const result = await this.exportImage(msg, sessionId, mediaRootDir, mediaRelativePrefix)
|
||||
if (result) {
|
||||
}
|
||||
return result
|
||||
@@ -428,13 +431,13 @@ class ExportService {
|
||||
}
|
||||
// 否则导出语音文件
|
||||
if (options.exportVoices) {
|
||||
return this.exportVoice(msg, sessionId, mediaDir)
|
||||
return this.exportVoice(msg, sessionId, mediaRootDir, mediaRelativePrefix)
|
||||
}
|
||||
}
|
||||
|
||||
// 动画表情
|
||||
if (localType === 47 && options.exportEmojis) {
|
||||
const result = await this.exportEmoji(msg, sessionId, mediaDir)
|
||||
const result = await this.exportEmoji(msg, sessionId, mediaRootDir, mediaRelativePrefix)
|
||||
if (result) {
|
||||
}
|
||||
return result
|
||||
@@ -446,9 +449,14 @@ class ExportService {
|
||||
/**
|
||||
* 导出图片文件
|
||||
*/
|
||||
private async exportImage(msg: any, sessionId: string, mediaDir: string): Promise<MediaExportItem | null> {
|
||||
private async exportImage(
|
||||
msg: any,
|
||||
sessionId: string,
|
||||
mediaRootDir: string,
|
||||
mediaRelativePrefix: string
|
||||
): Promise<MediaExportItem | null> {
|
||||
try {
|
||||
const imagesDir = path.join(mediaDir, 'media', 'images')
|
||||
const imagesDir = path.join(mediaRootDir, mediaRelativePrefix, 'images')
|
||||
if (!fs.existsSync(imagesDir)) {
|
||||
fs.mkdirSync(imagesDir, { recursive: true })
|
||||
}
|
||||
@@ -493,7 +501,7 @@ class ExportService {
|
||||
fs.writeFileSync(destPath, Buffer.from(base64Data, 'base64'))
|
||||
|
||||
return {
|
||||
relativePath: `media/images/${fileName}`,
|
||||
relativePath: path.posix.join(mediaRelativePrefix, 'images', fileName),
|
||||
kind: 'image'
|
||||
}
|
||||
} else if (sourcePath.startsWith('file://')) {
|
||||
@@ -511,7 +519,7 @@ class ExportService {
|
||||
}
|
||||
|
||||
return {
|
||||
relativePath: `media/images/${fileName}`,
|
||||
relativePath: path.posix.join(mediaRelativePrefix, 'images', fileName),
|
||||
kind: 'image'
|
||||
}
|
||||
}
|
||||
@@ -525,9 +533,14 @@ class ExportService {
|
||||
/**
|
||||
* 导出语音文件
|
||||
*/
|
||||
private async exportVoice(msg: any, sessionId: string, mediaDir: string): Promise<MediaExportItem | null> {
|
||||
private async exportVoice(
|
||||
msg: any,
|
||||
sessionId: string,
|
||||
mediaRootDir: string,
|
||||
mediaRelativePrefix: string
|
||||
): Promise<MediaExportItem | null> {
|
||||
try {
|
||||
const voicesDir = path.join(mediaDir, 'media', 'voices')
|
||||
const voicesDir = path.join(mediaRootDir, mediaRelativePrefix, 'voices')
|
||||
if (!fs.existsSync(voicesDir)) {
|
||||
fs.mkdirSync(voicesDir, { recursive: true })
|
||||
}
|
||||
@@ -539,7 +552,7 @@ class ExportService {
|
||||
// 如果已存在则跳过
|
||||
if (fs.existsSync(destPath)) {
|
||||
return {
|
||||
relativePath: `media/voices/${fileName}`,
|
||||
relativePath: path.posix.join(mediaRelativePrefix, 'voices', fileName),
|
||||
kind: 'voice'
|
||||
}
|
||||
}
|
||||
@@ -555,7 +568,7 @@ class ExportService {
|
||||
fs.writeFileSync(destPath, wavBuffer)
|
||||
|
||||
return {
|
||||
relativePath: `media/voices/${fileName}`,
|
||||
relativePath: path.posix.join(mediaRelativePrefix, 'voices', fileName),
|
||||
kind: 'voice'
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -581,9 +594,14 @@ class ExportService {
|
||||
/**
|
||||
* 导出表情文件
|
||||
*/
|
||||
private async exportEmoji(msg: any, sessionId: string, mediaDir: string): Promise<MediaExportItem | null> {
|
||||
private async exportEmoji(
|
||||
msg: any,
|
||||
sessionId: string,
|
||||
mediaRootDir: string,
|
||||
mediaRelativePrefix: string
|
||||
): Promise<MediaExportItem | null> {
|
||||
try {
|
||||
const emojisDir = path.join(mediaDir, 'media', 'emojis')
|
||||
const emojisDir = path.join(mediaRootDir, mediaRelativePrefix, 'emojis')
|
||||
if (!fs.existsSync(emojisDir)) {
|
||||
fs.mkdirSync(emojisDir, { recursive: true })
|
||||
}
|
||||
@@ -612,7 +630,7 @@ class ExportService {
|
||||
// 如果已存在则跳过
|
||||
if (fs.existsSync(destPath)) {
|
||||
return {
|
||||
relativePath: `media/emojis/${fileName}`,
|
||||
relativePath: path.posix.join(mediaRelativePrefix, 'emojis', fileName),
|
||||
kind: 'emoji'
|
||||
}
|
||||
}
|
||||
@@ -620,13 +638,13 @@ class ExportService {
|
||||
// 下载表情
|
||||
if (emojiUrl) {
|
||||
const downloaded = await this.downloadFile(emojiUrl, destPath)
|
||||
if (downloaded) {
|
||||
return {
|
||||
relativePath: `media/emojis/${fileName}`,
|
||||
kind: 'emoji'
|
||||
}
|
||||
} else {
|
||||
}
|
||||
if (downloaded) {
|
||||
return {
|
||||
relativePath: path.posix.join(mediaRelativePrefix, 'emojis', fileName),
|
||||
kind: 'emoji'
|
||||
}
|
||||
} else {
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
@@ -703,6 +721,22 @@ class ExportService {
|
||||
return '.jpg'
|
||||
}
|
||||
|
||||
private getMediaLayout(outputPath: string, options: ExportOptions): {
|
||||
exportMediaEnabled: boolean
|
||||
mediaRootDir: string
|
||||
mediaRelativePrefix: string
|
||||
} {
|
||||
const exportMediaEnabled = options.exportMedia === true &&
|
||||
Boolean(options.exportImages || options.exportVoices || options.exportEmojis)
|
||||
const outputDir = path.dirname(outputPath)
|
||||
const outputBaseName = path.basename(outputPath, path.extname(outputPath))
|
||||
const useSharedMediaLayout = options.sessionLayout === 'shared'
|
||||
const mediaRelativePrefix = useSharedMediaLayout
|
||||
? path.posix.join('media', outputBaseName)
|
||||
: 'media'
|
||||
return { exportMediaEnabled, mediaRootDir: outputDir, mediaRelativePrefix }
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载文件
|
||||
*/
|
||||
@@ -1127,29 +1161,43 @@ class ExportService {
|
||||
phase: 'exporting'
|
||||
})
|
||||
|
||||
const chatLabMessages: ChatLabMessage[] = []
|
||||
for (const msg of allMessages) {
|
||||
const memberInfo = collected.memberSet.get(msg.senderUsername)?.member || {
|
||||
platformId: msg.senderUsername,
|
||||
accountName: msg.senderUsername,
|
||||
groupNickname: undefined
|
||||
}
|
||||
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
|
||||
const mediaCache = new Map<string, MediaExportItem | null>()
|
||||
const chatLabMessages: ChatLabMessage[] = []
|
||||
for (const msg of allMessages) {
|
||||
const memberInfo = collected.memberSet.get(msg.senderUsername)?.member || {
|
||||
platformId: msg.senderUsername,
|
||||
accountName: msg.senderUsername,
|
||||
groupNickname: undefined
|
||||
}
|
||||
|
||||
let content = this.parseMessageContent(msg.content, msg.localType)
|
||||
// 如果是语音消息且开启了转文字
|
||||
if (msg.localType === 34 && options.exportVoiceAsText) {
|
||||
content = await this.transcribeVoice(sessionId, String(msg.localId))
|
||||
}
|
||||
let content = this.parseMessageContent(msg.content, msg.localType)
|
||||
if (exportMediaEnabled) {
|
||||
const mediaKey = `${msg.localType}_${msg.localId}`
|
||||
if (!mediaCache.has(mediaKey)) {
|
||||
const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
|
||||
exportImages: options.exportImages,
|
||||
exportVoices: options.exportVoices,
|
||||
exportEmojis: options.exportEmojis,
|
||||
exportVoiceAsText: options.exportVoiceAsText
|
||||
})
|
||||
mediaCache.set(mediaKey, mediaItem)
|
||||
}
|
||||
}
|
||||
if (msg.localType === 34 && options.exportVoiceAsText) {
|
||||
// 如果是语音消息且开启了转文字
|
||||
content = await this.transcribeVoice(sessionId, String(msg.localId))
|
||||
}
|
||||
|
||||
chatLabMessages.push({
|
||||
sender: msg.senderUsername,
|
||||
accountName: memberInfo.accountName,
|
||||
groupNickname: memberInfo.groupNickname,
|
||||
timestamp: msg.createTime,
|
||||
type: this.convertMessageType(msg.localType, msg.content),
|
||||
content: content
|
||||
})
|
||||
}
|
||||
chatLabMessages.push({
|
||||
sender: msg.senderUsername,
|
||||
accountName: memberInfo.accountName,
|
||||
groupNickname: memberInfo.groupNickname,
|
||||
timestamp: msg.createTime,
|
||||
type: this.convertMessageType(msg.localType, msg.content),
|
||||
content: content
|
||||
})
|
||||
}
|
||||
|
||||
const avatarMap = options.exportAvatars
|
||||
? await this.exportAvatars(
|
||||
@@ -1242,21 +1290,45 @@ class ExportService {
|
||||
phase: 'preparing'
|
||||
})
|
||||
|
||||
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
|
||||
const allMessages: any[] = []
|
||||
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
|
||||
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
|
||||
const mediaCache = new Map<string, MediaExportItem | null>()
|
||||
const allMessages: any[] = []
|
||||
|
||||
for (const msg of collected.rows) {
|
||||
const senderInfo = await this.getContactInfo(msg.senderUsername)
|
||||
const sourceMatch = /<msgsource>[\s\S]*?<\/msgsource>/i.exec(msg.content || '')
|
||||
const source = sourceMatch ? sourceMatch[0] : ''
|
||||
for (const msg of collected.rows) {
|
||||
const senderInfo = await this.getContactInfo(msg.senderUsername)
|
||||
const sourceMatch = /<msgsource>[\s\S]*?<\/msgsource>/i.exec(msg.content || '')
|
||||
const source = sourceMatch ? sourceMatch[0] : ''
|
||||
|
||||
allMessages.push({
|
||||
localId: allMessages.length + 1,
|
||||
createTime: msg.createTime,
|
||||
let content = this.parseMessageContent(msg.content, msg.localType)
|
||||
let mediaItem: MediaExportItem | null = null
|
||||
if (exportMediaEnabled) {
|
||||
const mediaKey = `${msg.localType}_${msg.localId}`
|
||||
if (mediaCache.has(mediaKey)) {
|
||||
mediaItem = mediaCache.get(mediaKey) || null
|
||||
} else {
|
||||
mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
|
||||
exportImages: options.exportImages,
|
||||
exportVoices: options.exportVoices,
|
||||
exportEmojis: options.exportEmojis,
|
||||
exportVoiceAsText: options.exportVoiceAsText
|
||||
})
|
||||
mediaCache.set(mediaKey, mediaItem)
|
||||
}
|
||||
}
|
||||
if (mediaItem) {
|
||||
content = mediaItem.relativePath
|
||||
} else if (msg.localType === 34 && options.exportVoiceAsText) {
|
||||
content = await this.transcribeVoice(sessionId, String(msg.localId))
|
||||
}
|
||||
|
||||
allMessages.push({
|
||||
localId: allMessages.length + 1,
|
||||
createTime: msg.createTime,
|
||||
formattedTime: this.formatTimestamp(msg.createTime),
|
||||
type: this.getMessageTypeName(msg.localType),
|
||||
localType: msg.localType,
|
||||
content: this.parseMessageContent(msg.content, msg.localType),
|
||||
content,
|
||||
isSend: msg.isSend ? 1 : 0,
|
||||
senderUsername: msg.senderUsername,
|
||||
senderDisplayName: senderInfo.displayName,
|
||||
@@ -1379,8 +1451,9 @@ class ExportService {
|
||||
|
||||
let currentRow = 1
|
||||
|
||||
const useCompactColumns = options.excelCompactColumns === true
|
||||
|
||||
// 第一行:会话信息标题
|
||||
worksheet.mergeCells(currentRow, 1, currentRow, 8)
|
||||
const titleCell = worksheet.getCell(currentRow, 1)
|
||||
titleCell.value = '会话信息'
|
||||
titleCell.font = { name: 'Calibri', bold: true, size: 11 }
|
||||
@@ -1436,7 +1509,9 @@ class ExportService {
|
||||
currentRow++
|
||||
|
||||
// 表头行
|
||||
const headers = ['序号', '时间', '发送者昵称', '发送者微信ID', '发送者备注', '发送者身份', '消息类型', '内容']
|
||||
const headers = useCompactColumns
|
||||
? ['序号', '时间', '发送者身份', '消息类型', '内容']
|
||||
: ['序号', '时间', '发送者昵称', '发送者微信ID', '发送者备注', '发送者身份', '消息类型', '内容']
|
||||
const headerRow = worksheet.getRow(currentRow)
|
||||
headerRow.height = 22
|
||||
|
||||
@@ -1456,19 +1531,24 @@ class ExportService {
|
||||
// 设置列宽
|
||||
worksheet.getColumn(1).width = 8 // 序号
|
||||
worksheet.getColumn(2).width = 20 // 时间
|
||||
worksheet.getColumn(3).width = 18 // 发送者昵称
|
||||
worksheet.getColumn(4).width = 25 // 发送者微信ID
|
||||
worksheet.getColumn(5).width = 18 // 发送者备注
|
||||
worksheet.getColumn(6).width = 15 // 发送者身份
|
||||
worksheet.getColumn(7).width = 12 // 消息类型
|
||||
worksheet.getColumn(8).width = 50 // 内容
|
||||
if (useCompactColumns) {
|
||||
worksheet.getColumn(3).width = 18 // 发送者身份
|
||||
worksheet.getColumn(4).width = 12 // 消息类型
|
||||
worksheet.getColumn(5).width = 50 // 内容
|
||||
} else {
|
||||
worksheet.getColumn(3).width = 18 // 发送者昵称
|
||||
worksheet.getColumn(4).width = 25 // 发送者微信ID
|
||||
worksheet.getColumn(5).width = 18 // 发送者备注
|
||||
worksheet.getColumn(6).width = 15 // 发送者身份
|
||||
worksheet.getColumn(7).width = 12 // 消息类型
|
||||
worksheet.getColumn(8).width = 50 // 内容
|
||||
}
|
||||
|
||||
// 填充数据
|
||||
const sortedMessages = collected.rows.sort((a, b) => a.createTime - b.createTime)
|
||||
|
||||
// 媒体导出设置
|
||||
const exportMediaEnabled = options.exportImages || options.exportVoices || options.exportEmojis
|
||||
const sessionDir = path.dirname(outputPath) // 会话目录,用于媒体导出
|
||||
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
|
||||
|
||||
// 媒体导出缓存
|
||||
const mediaCache = new Map<string, MediaExportItem | null>()
|
||||
@@ -1483,7 +1563,7 @@ class ExportService {
|
||||
if (mediaCache.has(mediaKey)) {
|
||||
mediaItem = mediaCache.get(mediaKey) || null
|
||||
} else {
|
||||
mediaItem = await this.exportMediaForMessage(msg, sessionId, sessionDir, {
|
||||
mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
|
||||
exportImages: options.exportImages,
|
||||
exportVoices: options.exportVoices,
|
||||
exportEmojis: options.exportEmojis,
|
||||
@@ -1541,9 +1621,12 @@ class ExportService {
|
||||
row.height = 24
|
||||
|
||||
// 确定内容:如果有媒体文件导出成功则显示相对路径,否则显示解析后的内容
|
||||
const contentValue = mediaItem
|
||||
let contentValue = mediaItem
|
||||
? mediaItem.relativePath
|
||||
: (this.parseMessageContent(msg.content, msg.localType) || '')
|
||||
if (!mediaItem && msg.localType === 34 && options.exportVoiceAsText) {
|
||||
contentValue = await this.transcribeVoice(sessionId, String(msg.localId))
|
||||
}
|
||||
|
||||
// 调试日志
|
||||
if (msg.localType === 3 || msg.localType === 47) {
|
||||
@@ -1551,15 +1634,22 @@ class ExportService {
|
||||
|
||||
worksheet.getCell(currentRow, 1).value = i + 1
|
||||
worksheet.getCell(currentRow, 2).value = this.formatTimestamp(msg.createTime)
|
||||
worksheet.getCell(currentRow, 3).value = senderNickname
|
||||
worksheet.getCell(currentRow, 4).value = senderWxid
|
||||
worksheet.getCell(currentRow, 5).value = senderRemark
|
||||
worksheet.getCell(currentRow, 6).value = senderRole
|
||||
worksheet.getCell(currentRow, 7).value = this.getMessageTypeName(msg.localType)
|
||||
worksheet.getCell(currentRow, 8).value = contentValue
|
||||
if (useCompactColumns) {
|
||||
worksheet.getCell(currentRow, 3).value = senderRole
|
||||
worksheet.getCell(currentRow, 4).value = this.getMessageTypeName(msg.localType)
|
||||
worksheet.getCell(currentRow, 5).value = contentValue
|
||||
} else {
|
||||
worksheet.getCell(currentRow, 3).value = senderNickname
|
||||
worksheet.getCell(currentRow, 4).value = senderWxid
|
||||
worksheet.getCell(currentRow, 5).value = senderRemark
|
||||
worksheet.getCell(currentRow, 6).value = senderRole
|
||||
worksheet.getCell(currentRow, 7).value = this.getMessageTypeName(msg.localType)
|
||||
worksheet.getCell(currentRow, 8).value = contentValue
|
||||
}
|
||||
|
||||
// 设置每个单元格的样式
|
||||
for (let col = 1; col <= 8; col++) {
|
||||
const maxColumns = useCompactColumns ? 5 : 8
|
||||
for (let col = 1; col <= maxColumns; col++) {
|
||||
const cell = worksheet.getCell(currentRow, col)
|
||||
cell.font = { name: 'Calibri', size: 11 }
|
||||
cell.alignment = { vertical: 'middle', wrapText: false }
|
||||
@@ -1631,9 +1721,15 @@ class ExportService {
|
||||
fs.mkdirSync(outputDir, { recursive: true })
|
||||
}
|
||||
|
||||
for (let i = 0; i < sessionIds.length; i++) {
|
||||
const sessionId = sessionIds[i]
|
||||
const sessionInfo = await this.getContactInfo(sessionId)
|
||||
const exportMediaEnabled = options.exportMedia === true &&
|
||||
Boolean(options.exportImages || options.exportVoices || options.exportEmojis)
|
||||
const sessionLayout = exportMediaEnabled
|
||||
? (options.sessionLayout ?? 'per-session')
|
||||
: 'shared'
|
||||
|
||||
for (let i = 0; i < sessionIds.length; i++) {
|
||||
const sessionId = sessionIds[i]
|
||||
const sessionInfo = await this.getContactInfo(sessionId)
|
||||
|
||||
onProgress?.({
|
||||
current: i + 1,
|
||||
@@ -1642,13 +1738,13 @@ class ExportService {
|
||||
phase: 'exporting'
|
||||
})
|
||||
|
||||
const safeName = sessionInfo.displayName.replace(/[<>:"/\\|?*]/g, '_')
|
||||
const safeName = sessionInfo.displayName.replace(/[<>:"/\\|?*]/g, '_')
|
||||
const useSessionFolder = sessionLayout === 'per-session'
|
||||
const sessionDir = useSessionFolder ? path.join(outputDir, safeName) : outputDir
|
||||
|
||||
// 为每个会话创建单独的文件夹
|
||||
const sessionDir = path.join(outputDir, safeName)
|
||||
if (!fs.existsSync(sessionDir)) {
|
||||
fs.mkdirSync(sessionDir, { recursive: true })
|
||||
}
|
||||
if (useSessionFolder && !fs.existsSync(sessionDir)) {
|
||||
fs.mkdirSync(sessionDir, { recursive: true })
|
||||
}
|
||||
|
||||
let ext = '.json'
|
||||
if (options.format === 'chatlab-jsonl') ext = '.jsonl'
|
||||
@@ -1689,4 +1785,3 @@ class ExportService {
|
||||
}
|
||||
|
||||
export const exportService = new ExportService()
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ export class KeyService {
|
||||
private ReadProcessMemory: any = null
|
||||
private MEMORY_BASIC_INFORMATION: any = null
|
||||
private TerminateProcess: any = null
|
||||
private QueryFullProcessImageNameW: any = null
|
||||
|
||||
// User32
|
||||
private EnumWindows: any = null
|
||||
@@ -194,6 +195,7 @@ export class KeyService {
|
||||
this.OpenProcess = this.kernel32.func('OpenProcess', 'HANDLE', ['uint32', 'bool', 'uint32'])
|
||||
this.CloseHandle = this.kernel32.func('CloseHandle', 'bool', ['HANDLE'])
|
||||
this.TerminateProcess = this.kernel32.func('TerminateProcess', 'bool', ['HANDLE', 'uint32'])
|
||||
this.QueryFullProcessImageNameW = this.kernel32.func('QueryFullProcessImageNameW', 'bool', ['HANDLE', 'uint32', this.koffi.out('uint16*'), this.koffi.out('uint32*')])
|
||||
this.VirtualQueryEx = this.kernel32.func('VirtualQueryEx', 'uint64', ['HANDLE', 'uint64', this.koffi.out(this.koffi.pointer(this.MEMORY_BASIC_INFORMATION)), 'uint64'])
|
||||
this.ReadProcessMemory = this.kernel32.func('ReadProcessMemory', 'bool', ['HANDLE', 'uint64', 'void*', 'uint64', this.koffi.out(this.koffi.pointer('uint64'))])
|
||||
|
||||
@@ -310,7 +312,46 @@ export class KeyService {
|
||||
}
|
||||
}
|
||||
|
||||
private async getProcessExecutablePath(pid: number): Promise<string | null> {
|
||||
if (!this.ensureKernel32()) return null
|
||||
// 0x1000 = PROCESS_QUERY_LIMITED_INFORMATION
|
||||
const hProcess = this.OpenProcess(0x1000, false, pid)
|
||||
if (!hProcess) return null
|
||||
|
||||
try {
|
||||
const sizeBuf = Buffer.alloc(4)
|
||||
sizeBuf.writeUInt32LE(1024, 0)
|
||||
const pathBuf = Buffer.alloc(1024 * 2)
|
||||
|
||||
const ret = this.QueryFullProcessImageNameW(hProcess, 0, pathBuf, sizeBuf)
|
||||
if (ret) {
|
||||
const len = sizeBuf.readUInt32LE(0)
|
||||
return pathBuf.toString('ucs2', 0, len * 2)
|
||||
}
|
||||
return null
|
||||
} catch (e) {
|
||||
console.error('获取进程路径失败:', e)
|
||||
return null
|
||||
} finally {
|
||||
this.CloseHandle(hProcess)
|
||||
}
|
||||
}
|
||||
|
||||
private async findWeChatInstallPath(): Promise<string | null> {
|
||||
// 0. 优先尝试获取正在运行的微信进程路径
|
||||
try {
|
||||
const pid = await this.findWeChatPid()
|
||||
if (pid) {
|
||||
const runPath = await this.getProcessExecutablePath(pid)
|
||||
if (runPath && existsSync(runPath)) {
|
||||
console.log('发现正在运行的微信进程,使用路径:', runPath)
|
||||
return runPath
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('尝试获取运行中微信路径失败:', e)
|
||||
}
|
||||
|
||||
// 1. Registry - Uninstall Keys
|
||||
const uninstallKeys = [
|
||||
'SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { join, dirname, basename } from 'path'
|
||||
import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, readFileSync } from 'fs'
|
||||
|
||||
// DLL 初始化错误信息,用于帮助用户诊断问题
|
||||
let lastDllInitError: string | null = null
|
||||
export function getLastDllInitError(): string | null {
|
||||
return lastDllInitError
|
||||
}
|
||||
|
||||
export class WcdbCore {
|
||||
private resourcesPath: string | null = null
|
||||
private userDataPath: string | null = null
|
||||
@@ -208,6 +214,31 @@ export class WcdbCore {
|
||||
return false
|
||||
}
|
||||
|
||||
// 关键修复:显式预加载依赖库 WCDB.dll 和 SDL2.dll
|
||||
// Windows 加载器默认不会查找子目录中的依赖,必须先将其加载到内存
|
||||
// 这可以解决部分用户因为 VC++ 运行时或 DLL 依赖问题导致的闪退
|
||||
const dllDir = dirname(dllPath)
|
||||
const wcdbCorePath = join(dllDir, 'WCDB.dll')
|
||||
if (existsSync(wcdbCorePath)) {
|
||||
try {
|
||||
this.koffi.load(wcdbCorePath)
|
||||
this.writeLog('预加载 WCDB.dll 成功')
|
||||
} catch (e) {
|
||||
console.warn('预加载 WCDB.dll 失败(可能不是致命的):', e)
|
||||
this.writeLog(`预加载 WCDB.dll 失败: ${String(e)}`)
|
||||
}
|
||||
}
|
||||
const sdl2Path = join(dllDir, 'SDL2.dll')
|
||||
if (existsSync(sdl2Path)) {
|
||||
try {
|
||||
this.koffi.load(sdl2Path)
|
||||
this.writeLog('预加载 SDL2.dll 成功')
|
||||
} catch (e) {
|
||||
console.warn('预加载 SDL2.dll 失败(可能不是致命的):', e)
|
||||
this.writeLog(`预加载 SDL2.dll 失败: ${String(e)}`)
|
||||
}
|
||||
}
|
||||
|
||||
this.lib = this.koffi.load(dllPath)
|
||||
|
||||
// 定义类型
|
||||
@@ -362,9 +393,20 @@ export class WcdbCore {
|
||||
}
|
||||
|
||||
this.initialized = true
|
||||
lastDllInitError = null
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error('WCDB 初始化异常:', e)
|
||||
const errorMsg = e instanceof Error ? e.message : String(e)
|
||||
console.error('WCDB 初始化异常:', errorMsg)
|
||||
this.writeLog(`WCDB 初始化异常: ${errorMsg}`, true)
|
||||
lastDllInitError = errorMsg
|
||||
// 检查是否是常见的 VC++ 运行时缺失错误
|
||||
if (errorMsg.includes('126') || errorMsg.includes('找不到指定的模块') ||
|
||||
errorMsg.includes('The specified module could not be found')) {
|
||||
lastDllInitError = '可能缺少 Visual C++ 运行时库。请安装 Microsoft Visual C++ Redistributable (x64)。'
|
||||
} else if (errorMsg.includes('193') || errorMsg.includes('不是有效的 Win32 应用程序')) {
|
||||
lastDllInitError = 'DLL 架构不匹配。请确保使用 64 位版本的应用程序。'
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -382,10 +424,18 @@ export class WcdbCore {
|
||||
return { success: true, sessionCount: 0 }
|
||||
}
|
||||
|
||||
// 记录当前活动连接,用于在测试结束后恢复(避免影响聊天页等正在使用的连接)
|
||||
const hadActiveConnection = this.handle !== null
|
||||
const prevPath = this.currentPath
|
||||
const prevKey = this.currentKey
|
||||
const prevWxid = this.currentWxid
|
||||
|
||||
if (!this.initialized) {
|
||||
const initOk = await this.initialize()
|
||||
if (!initOk) {
|
||||
return { success: false, error: 'WCDB 初始化失败' }
|
||||
// 返回更详细的错误信息,帮助用户诊断问题
|
||||
const detailedError = lastDllInitError || 'WCDB 初始化失败'
|
||||
return { success: false, error: detailedError }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -424,8 +474,8 @@ export class WcdbCore {
|
||||
return { success: false, error: '无效的数据库句柄' }
|
||||
}
|
||||
|
||||
// 测试成功,使用 shutdown 清理所有资源(包括测试句柄)
|
||||
// 这会中断当前活动连接,但 testConnection 本应该是独立测试
|
||||
// 测试成功:使用 shutdown 清理资源(包括测试句柄)
|
||||
// 注意:shutdown 会断开当前活动连接,因此需要在测试后尝试恢复之前的连接
|
||||
try {
|
||||
this.wcdbShutdown()
|
||||
this.handle = null
|
||||
@@ -437,6 +487,15 @@ export class WcdbCore {
|
||||
console.error('关闭测试数据库时出错:', closeErr)
|
||||
}
|
||||
|
||||
// 恢复测试前的连接(如果之前有活动连接)
|
||||
if (hadActiveConnection && prevPath && prevKey && prevWxid) {
|
||||
try {
|
||||
await this.open(prevPath, prevKey, prevWxid)
|
||||
} catch {
|
||||
// 恢复失败则保持断开,由调用方处理
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, sessionCount: 0 }
|
||||
} catch (e) {
|
||||
console.error('测试连接异常:', e)
|
||||
|
||||
@@ -58,12 +58,24 @@ export class WcdbService {
|
||||
})
|
||||
|
||||
this.worker.on('error', (err) => {
|
||||
// Worker error
|
||||
// Worker 发生错误,需要 reject 所有 pending promises
|
||||
console.error('WCDB Worker 错误:', err)
|
||||
const errorMsg = err instanceof Error ? err.message : String(err)
|
||||
for (const [id, p] of this.pending) {
|
||||
p.reject(new Error(`Worker 错误: ${errorMsg}`))
|
||||
}
|
||||
this.pending.clear()
|
||||
})
|
||||
|
||||
this.worker.on('exit', (code) => {
|
||||
// Worker 退出,需要 reject 所有 pending promises
|
||||
if (code !== 0) {
|
||||
// Worker exited with error
|
||||
console.error('WCDB Worker 异常退出,退出码:', code)
|
||||
const errorMsg = `Worker 异常退出 (退出码: ${code})。可能是 DLL 加载失败,请检查是否安装了 Visual C++ Redistributable。`
|
||||
for (const [id, p] of this.pending) {
|
||||
p.reject(new Error(errorMsg))
|
||||
}
|
||||
this.pending.clear()
|
||||
}
|
||||
this.worker = null
|
||||
})
|
||||
|
||||
6
package-lock.json
generated
6
package-lock.json
generated
@@ -8537,12 +8537,6 @@
|
||||
"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",
|
||||
|
||||
12
src/App.tsx
12
src/App.tsx
@@ -202,10 +202,22 @@ function App() {
|
||||
}
|
||||
} else {
|
||||
console.log('自动连接失败:', result.error)
|
||||
// 如果错误信息包含 VC++ 或 DLL 相关内容,不清除配置,只提示用户
|
||||
// 其他错误可能需要重新配置
|
||||
const errorMsg = result.error || ''
|
||||
if (errorMsg.includes('Visual C++') ||
|
||||
errorMsg.includes('DLL') ||
|
||||
errorMsg.includes('Worker') ||
|
||||
errorMsg.includes('126') ||
|
||||
errorMsg.includes('模块')) {
|
||||
console.warn('检测到可能的运行时依赖问题:', errorMsg)
|
||||
// 不清除配置,让用户安装 VC++ 后重试
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('自动连接出错:', e)
|
||||
// 捕获异常但不清除配置,防止循环重新引导
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -602,6 +602,87 @@
|
||||
}
|
||||
}
|
||||
|
||||
.export-layout-modal {
|
||||
background: var(--card-bg);
|
||||
padding: 28px 32px;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
|
||||
text-align: center;
|
||||
width: min(520px, 90vw);
|
||||
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.layout-subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
|
||||
.layout-options {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.layout-option-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 14px 18px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary);
|
||||
background: rgba(var(--primary-rgb), 0.08);
|
||||
}
|
||||
|
||||
&.primary {
|
||||
border-color: var(--primary);
|
||||
background: rgba(var(--primary-rgb), 0.12);
|
||||
}
|
||||
|
||||
.layout-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.layout-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
|
||||
.layout-actions {
|
||||
margin-top: 18px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.layout-cancel-btn {
|
||||
padding: 8px 20px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.export-result-modal {
|
||||
background: var(--card-bg);
|
||||
padding: 32px 40px;
|
||||
@@ -1056,4 +1137,4 @@
|
||||
input:checked + .slider::before {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ interface ExportOptions {
|
||||
exportVoices: boolean
|
||||
exportEmojis: boolean
|
||||
exportVoiceAsText: boolean
|
||||
excelCompactColumns: boolean
|
||||
}
|
||||
|
||||
interface ExportResult {
|
||||
@@ -30,6 +31,8 @@ interface ExportResult {
|
||||
error?: string
|
||||
}
|
||||
|
||||
type SessionLayout = 'shared' | 'per-session'
|
||||
|
||||
function ExportPage() {
|
||||
const [sessions, setSessions] = useState<ChatSession[]>([])
|
||||
const [filteredSessions, setFilteredSessions] = useState<ChatSession[]>([])
|
||||
@@ -43,22 +46,43 @@ function ExportPage() {
|
||||
const [showDatePicker, setShowDatePicker] = useState(false)
|
||||
const [calendarDate, setCalendarDate] = useState(new Date())
|
||||
const [selectingStart, setSelectingStart] = useState(true)
|
||||
const [showMediaLayoutPrompt, setShowMediaLayoutPrompt] = useState(false)
|
||||
|
||||
const [options, setOptions] = useState<ExportOptions>({
|
||||
format: 'chatlab',
|
||||
format: 'excel',
|
||||
dateRange: {
|
||||
start: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
|
||||
start: new Date(new Date().setHours(0, 0, 0, 0)),
|
||||
end: new Date()
|
||||
},
|
||||
useAllTime: true,
|
||||
useAllTime: false,
|
||||
exportAvatars: true,
|
||||
exportMedia: false,
|
||||
exportImages: true,
|
||||
exportVoices: true,
|
||||
exportEmojis: true,
|
||||
exportVoiceAsText: false
|
||||
exportVoiceAsText: true,
|
||||
excelCompactColumns: true
|
||||
})
|
||||
|
||||
const buildDateRangeFromPreset = (preset: string) => {
|
||||
const now = new Date()
|
||||
if (preset === 'all') {
|
||||
return { useAllTime: true, dateRange: { start: now, end: now } }
|
||||
}
|
||||
let rangeMs = 0
|
||||
if (preset === '7d') rangeMs = 7 * 24 * 60 * 60 * 1000
|
||||
if (preset === '30d') rangeMs = 30 * 24 * 60 * 60 * 1000
|
||||
if (preset === '90d') rangeMs = 90 * 24 * 60 * 60 * 1000
|
||||
if (preset === 'today' || rangeMs === 0) {
|
||||
const start = new Date(now)
|
||||
start.setHours(0, 0, 0, 0)
|
||||
return { useAllTime: false, dateRange: { start, end: now } }
|
||||
}
|
||||
const start = new Date(now.getTime() - rangeMs)
|
||||
start.setHours(0, 0, 0, 0)
|
||||
return { useAllTime: false, dateRange: { start, end: now } }
|
||||
}
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
@@ -94,10 +118,57 @@ function ExportPage() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadExportDefaults = useCallback(async () => {
|
||||
try {
|
||||
const [
|
||||
savedFormat,
|
||||
savedRange,
|
||||
savedMedia,
|
||||
savedVoiceAsText,
|
||||
savedExcelCompactColumns
|
||||
] = await Promise.all([
|
||||
configService.getExportDefaultFormat(),
|
||||
configService.getExportDefaultDateRange(),
|
||||
configService.getExportDefaultMedia(),
|
||||
configService.getExportDefaultVoiceAsText(),
|
||||
configService.getExportDefaultExcelCompactColumns()
|
||||
])
|
||||
|
||||
const preset = savedRange || 'today'
|
||||
const rangeDefaults = buildDateRangeFromPreset(preset)
|
||||
|
||||
setOptions((prev) => ({
|
||||
...prev,
|
||||
format: (savedFormat as ExportOptions['format']) || 'excel',
|
||||
useAllTime: rangeDefaults.useAllTime,
|
||||
dateRange: rangeDefaults.dateRange,
|
||||
exportMedia: savedMedia ?? false,
|
||||
exportVoiceAsText: savedVoiceAsText ?? true,
|
||||
excelCompactColumns: savedExcelCompactColumns ?? true
|
||||
}))
|
||||
} catch (e) {
|
||||
console.error('加载导出默认设置失败:', e)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadSessions()
|
||||
loadExportPath()
|
||||
}, [loadSessions, loadExportPath])
|
||||
loadExportDefaults()
|
||||
}, [loadSessions, loadExportPath, loadExportDefaults])
|
||||
|
||||
useEffect(() => {
|
||||
const removeListener = window.electronAPI.export.onProgress?.((payload) => {
|
||||
setExportProgress({
|
||||
current: payload.current,
|
||||
total: payload.total,
|
||||
currentName: payload.currentSession
|
||||
})
|
||||
})
|
||||
return () => {
|
||||
removeListener?.()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchKeyword.trim()) {
|
||||
@@ -144,7 +215,7 @@ function ExportPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const startExport = async () => {
|
||||
const runExport = async (sessionLayout: SessionLayout) => {
|
||||
if (selectedSessions.size === 0 || !exportFolder) return
|
||||
|
||||
setIsExporting(true)
|
||||
@@ -160,10 +231,12 @@ function ExportPage() {
|
||||
exportImages: options.exportMedia && options.exportImages,
|
||||
exportVoices: options.exportMedia && options.exportVoices,
|
||||
exportEmojis: options.exportMedia && options.exportEmojis,
|
||||
exportVoiceAsText: options.exportVoiceAsText, // 独立于 exportMedia
|
||||
exportVoiceAsText: options.exportVoiceAsText, // ?????????exportMedia
|
||||
excelCompactColumns: options.excelCompactColumns,
|
||||
sessionLayout,
|
||||
dateRange: options.useAllTime ? null : options.dateRange ? {
|
||||
start: Math.floor(options.dateRange.start.getTime() / 1000),
|
||||
// 将结束日期设置为当天的 23:59:59,以包含当天的所有消息
|
||||
// ?????????????????????????????????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
|
||||
}
|
||||
@@ -176,16 +249,28 @@ function ExportPage() {
|
||||
)
|
||||
setExportResult(result)
|
||||
} else {
|
||||
setExportResult({ success: false, error: `${options.format.toUpperCase()} 格式导出功能开发中...` })
|
||||
setExportResult({ success: false, error: `${options.format.toUpperCase()} ???????????????????????????...` })
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('导出失败:', e)
|
||||
console.error('????????????:', e)
|
||||
setExportResult({ success: false, error: String(e) })
|
||||
} finally {
|
||||
setIsExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const startExport = () => {
|
||||
if (selectedSessions.size === 0 || !exportFolder) return
|
||||
|
||||
if (options.exportMedia && selectedSessions.size > 1) {
|
||||
setShowMediaLayoutPrompt(true)
|
||||
return
|
||||
}
|
||||
|
||||
const layout: SessionLayout = options.exportMedia ? 'per-session' : 'shared'
|
||||
runExport(layout)
|
||||
}
|
||||
|
||||
const getDaysInMonth = (date: Date) => {
|
||||
const year = date.getFullYear()
|
||||
const month = date.getMonth()
|
||||
@@ -544,6 +629,43 @@ function ExportPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 媒体导出布局选择弹窗 */}
|
||||
{showMediaLayoutPrompt && (
|
||||
<div className="export-overlay" onClick={() => setShowMediaLayoutPrompt(false)}>
|
||||
<div className="export-layout-modal" onClick={e => e.stopPropagation()}>
|
||||
<h3>导出文件夹布局</h3>
|
||||
<p className="layout-subtitle">检测到同时导出多个会话并包含媒体文件,请选择存放方式:</p>
|
||||
<div className="layout-options">
|
||||
<button
|
||||
className="layout-option-btn primary"
|
||||
onClick={() => {
|
||||
setShowMediaLayoutPrompt(false)
|
||||
runExport('shared')
|
||||
}}
|
||||
>
|
||||
<span className="layout-title">所有会话在同一文件夹</span>
|
||||
<span className="layout-desc">媒体会按会话名归档到 media 子目录</span>
|
||||
</button>
|
||||
<button
|
||||
className="layout-option-btn"
|
||||
onClick={() => {
|
||||
setShowMediaLayoutPrompt(false)
|
||||
runExport('per-session')
|
||||
}}
|
||||
>
|
||||
<span className="layout-title">每个会话一个文件夹</span>
|
||||
<span className="layout-desc">每个会话单独包含导出文件和媒体</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="layout-actions">
|
||||
<button className="layout-cancel-btn" onClick={() => setShowMediaLayoutPrompt(false)}>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 导出进度弹窗 */}
|
||||
{isExporting && (
|
||||
<div className="export-overlay">
|
||||
|
||||
@@ -221,6 +221,100 @@
|
||||
}
|
||||
}
|
||||
|
||||
.select-field {
|
||||
position: relative;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.select-trigger {
|
||||
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);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
&.open {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.select-value {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.select-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: color-mix(in srgb, var(--bg-primary) 85%, var(--bg-secondary));
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 6px;
|
||||
box-shadow: var(--shadow-md);
|
||||
z-index: 20;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.select-option {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 10px 12px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: color-mix(in srgb, var(--primary) 12%, transparent);
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.option-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.option-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.select-option.active .option-desc {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.input-with-toggle {
|
||||
position: relative;
|
||||
display: flex;
|
||||
@@ -1096,13 +1190,15 @@
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-top: 4px;
|
||||
background: var(--bg-secondary);
|
||||
background: color-mix(in srgb, var(--bg-primary) 85%, 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;
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.wxid-option {
|
||||
@@ -1216,4 +1312,4 @@
|
||||
border-top: 1px solid var(--border-primary);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useAppStore } from '../stores/appStore'
|
||||
import { useThemeStore, themes } from '../stores/themeStore'
|
||||
import { useAnalyticsStore } from '../stores/analyticsStore'
|
||||
@@ -11,12 +11,13 @@ import {
|
||||
} from 'lucide-react'
|
||||
import './SettingsPage.scss'
|
||||
|
||||
type SettingsTab = 'appearance' | 'database' | 'whisper' | 'cache' | 'about'
|
||||
type SettingsTab = 'appearance' | 'database' | 'whisper' | 'export' | 'cache' | 'about'
|
||||
|
||||
const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
|
||||
{ id: 'appearance', label: '外观', icon: Palette },
|
||||
{ id: 'database', label: '数据库连接', icon: Database },
|
||||
{ id: 'whisper', label: '语音识别模型', icon: Mic },
|
||||
{ id: 'export', label: '导出', icon: Download },
|
||||
{ id: 'cache', label: '缓存', icon: HardDrive },
|
||||
{ id: 'about', label: '关于', icon: Info }
|
||||
]
|
||||
@@ -40,6 +41,12 @@ function SettingsPage() {
|
||||
const [wxidOptions, setWxidOptions] = useState<WxidOption[]>([])
|
||||
const [showWxidSelect, setShowWxidSelect] = useState(false)
|
||||
const wxidDropdownRef = useRef<HTMLDivElement>(null)
|
||||
const [showExportFormatSelect, setShowExportFormatSelect] = useState(false)
|
||||
const [showExportDateRangeSelect, setShowExportDateRangeSelect] = useState(false)
|
||||
const [showExportExcelColumnsSelect, setShowExportExcelColumnsSelect] = useState(false)
|
||||
const exportFormatDropdownRef = useRef<HTMLDivElement>(null)
|
||||
const exportDateRangeDropdownRef = useRef<HTMLDivElement>(null)
|
||||
const exportExcelColumnsDropdownRef = useRef<HTMLDivElement>(null)
|
||||
const [cachePath, setCachePath] = useState('')
|
||||
const [logEnabled, setLogEnabled] = useState(false)
|
||||
const [whisperModelName, setWhisperModelName] = useState('base')
|
||||
@@ -49,6 +56,11 @@ function SettingsPage() {
|
||||
const [whisperModelStatus, setWhisperModelStatus] = useState<{ exists: boolean; modelPath?: string; tokensPath?: string } | null>(null)
|
||||
const [autoTranscribeVoice, setAutoTranscribeVoice] = useState(false)
|
||||
const [transcribeLanguages, setTranscribeLanguages] = useState<string[]>(['zh'])
|
||||
const [exportDefaultFormat, setExportDefaultFormat] = useState('excel')
|
||||
const [exportDefaultDateRange, setExportDefaultDateRange] = useState('today')
|
||||
const [exportDefaultMedia, setExportDefaultMedia] = useState(false)
|
||||
const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(true)
|
||||
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
|
||||
|
||||
const [isLoading, setIsLoadingState] = useState(false)
|
||||
const [isTesting, setIsTesting] = useState(false)
|
||||
@@ -79,13 +91,23 @@ function SettingsPage() {
|
||||
// 点击外部关闭下拉框
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (showWxidSelect && wxidDropdownRef.current && !wxidDropdownRef.current.contains(e.target as Node)) {
|
||||
const target = e.target as Node
|
||||
if (showWxidSelect && wxidDropdownRef.current && !wxidDropdownRef.current.contains(target)) {
|
||||
setShowWxidSelect(false)
|
||||
}
|
||||
if (showExportFormatSelect && exportFormatDropdownRef.current && !exportFormatDropdownRef.current.contains(target)) {
|
||||
setShowExportFormatSelect(false)
|
||||
}
|
||||
if (showExportDateRangeSelect && exportDateRangeDropdownRef.current && !exportDateRangeDropdownRef.current.contains(target)) {
|
||||
setShowExportDateRangeSelect(false)
|
||||
}
|
||||
if (showExportExcelColumnsSelect && exportExcelColumnsDropdownRef.current && !exportExcelColumnsDropdownRef.current.contains(target)) {
|
||||
setShowExportExcelColumnsSelect(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [showWxidSelect])
|
||||
}, [showWxidSelect, showExportFormatSelect, showExportDateRangeSelect, showExportExcelColumnsSelect])
|
||||
|
||||
useEffect(() => {
|
||||
const removeDb = window.electronAPI.key.onDbKeyStatus((payload) => {
|
||||
@@ -114,6 +136,11 @@ function SettingsPage() {
|
||||
const savedWhisperModelDir = await configService.getWhisperModelDir()
|
||||
const savedAutoTranscribe = await configService.getAutoTranscribeVoice()
|
||||
const savedTranscribeLanguages = await configService.getTranscribeLanguages()
|
||||
const savedExportDefaultFormat = await configService.getExportDefaultFormat()
|
||||
const savedExportDefaultDateRange = await configService.getExportDefaultDateRange()
|
||||
const savedExportDefaultMedia = await configService.getExportDefaultMedia()
|
||||
const savedExportDefaultVoiceAsText = await configService.getExportDefaultVoiceAsText()
|
||||
const savedExportDefaultExcelCompactColumns = await configService.getExportDefaultExcelCompactColumns()
|
||||
|
||||
if (savedKey) setDecryptKey(savedKey)
|
||||
if (savedPath) setDbPath(savedPath)
|
||||
@@ -126,6 +153,11 @@ function SettingsPage() {
|
||||
setLogEnabled(savedLogEnabled)
|
||||
setAutoTranscribeVoice(savedAutoTranscribe)
|
||||
setTranscribeLanguages(savedTranscribeLanguages)
|
||||
setExportDefaultFormat(savedExportDefaultFormat || 'excel')
|
||||
setExportDefaultDateRange(savedExportDefaultDateRange || 'today')
|
||||
setExportDefaultMedia(savedExportDefaultMedia ?? false)
|
||||
setExportDefaultVoiceAsText(savedExportDefaultVoiceAsText ?? true)
|
||||
setExportDefaultExcelCompactColumns(savedExportDefaultExcelCompactColumns ?? true)
|
||||
|
||||
// 如果语言列表为空,保存默认值
|
||||
if (!savedTranscribeLanguages || savedTranscribeLanguages.length === 0) {
|
||||
@@ -468,15 +500,8 @@ function SettingsPage() {
|
||||
await configService.setTranscribeLanguages(transcribeLanguages)
|
||||
await configService.setOnboardingDone(true)
|
||||
|
||||
showMessage('配置保存成功,正在测试连接...', true)
|
||||
const result = await window.electronAPI.wcdb.testConnection(dbPath, decryptKey, wxid)
|
||||
|
||||
if (result.success) {
|
||||
setDbConnected(true, dbPath)
|
||||
showMessage('配置保存成功!数据库连接正常', true)
|
||||
} else {
|
||||
showMessage(result.error || '数据库连接失败,请检查配置', false)
|
||||
}
|
||||
// 保存按钮只负责持久化配置,不做连接测试/重连,避免影响聊天页的活动连接
|
||||
showMessage('配置保存成功', true)
|
||||
} catch (e) {
|
||||
showMessage(`保存配置失败: ${e}`, false)
|
||||
} finally {
|
||||
@@ -853,6 +878,205 @@ function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const exportFormatOptions = [
|
||||
{ value: 'excel', label: 'Excel', desc: '电子表格,适合统计分析' },
|
||||
{ value: 'chatlab', label: 'ChatLab', desc: '标准格式,支持其他软件导入' },
|
||||
{ value: 'chatlab-jsonl', label: 'ChatLab JSONL', desc: '流式格式,适合大量消息' },
|
||||
{ value: 'json', label: 'JSON', desc: '详细格式,包含完整消息信息' },
|
||||
{ value: 'html', label: 'HTML', desc: '网页格式,可直接浏览' },
|
||||
{ value: 'txt', label: 'TXT', desc: '纯文本,通用格式' },
|
||||
{ value: 'sql', label: 'PostgreSQL', desc: '数据库脚本,便于导入到数据库' }
|
||||
]
|
||||
const exportDateRangeOptions = [
|
||||
{ value: 'today', label: '今天' },
|
||||
{ value: '7d', label: '最近7天' },
|
||||
{ value: '30d', label: '最近30天' },
|
||||
{ value: '90d', label: '最近90天' },
|
||||
{ value: 'all', label: '全部时间' }
|
||||
]
|
||||
const exportExcelColumnOptions = [
|
||||
{ value: 'compact', label: '精简列', desc: '序号、时间、发送者身份、消息类型、内容' },
|
||||
{ value: 'full', label: '完整列', desc: '含发送者昵称/微信ID/备注' }
|
||||
]
|
||||
|
||||
const getOptionLabel = (options: { value: string; label: string }[], value: string) => {
|
||||
return options.find((option) => option.value === value)?.label ?? value
|
||||
}
|
||||
|
||||
const renderExportTab = () => {
|
||||
const exportExcelColumnsValue = exportDefaultExcelCompactColumns ? 'compact' : 'full'
|
||||
const exportFormatLabel = getOptionLabel(exportFormatOptions, exportDefaultFormat)
|
||||
const exportDateRangeLabel = getOptionLabel(exportDateRangeOptions, exportDefaultDateRange)
|
||||
const exportExcelColumnsLabel = getOptionLabel(exportExcelColumnOptions, exportExcelColumnsValue)
|
||||
|
||||
return (
|
||||
<div className="tab-content">
|
||||
<div className="form-group">
|
||||
<label>默认导出格式</label>
|
||||
<span className="form-hint">导出页面默认选中的格式</span>
|
||||
<div className="select-field" ref={exportFormatDropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`select-trigger ${showExportFormatSelect ? 'open' : ''}`}
|
||||
onClick={() => {
|
||||
setShowExportFormatSelect(!showExportFormatSelect)
|
||||
setShowExportDateRangeSelect(false)
|
||||
setShowExportExcelColumnsSelect(false)
|
||||
}}
|
||||
>
|
||||
<span className="select-value">{exportFormatLabel}</span>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
{showExportFormatSelect && (
|
||||
<div className="select-dropdown">
|
||||
{exportFormatOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`select-option ${exportDefaultFormat === option.value ? 'active' : ''}`}
|
||||
onClick={async () => {
|
||||
setExportDefaultFormat(option.value)
|
||||
await configService.setExportDefaultFormat(option.value)
|
||||
showMessage('已更新导出格式默认值', true)
|
||||
setShowExportFormatSelect(false)
|
||||
}}
|
||||
>
|
||||
<span className="option-label">{option.label}</span>
|
||||
{option.desc && <span className="option-desc">{option.desc}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>默认导出时间范围</label>
|
||||
<span className="form-hint">控制导出页面的默认时间选择</span>
|
||||
<div className="select-field" ref={exportDateRangeDropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`select-trigger ${showExportDateRangeSelect ? 'open' : ''}`}
|
||||
onClick={() => {
|
||||
setShowExportDateRangeSelect(!showExportDateRangeSelect)
|
||||
setShowExportFormatSelect(false)
|
||||
setShowExportExcelColumnsSelect(false)
|
||||
}}
|
||||
>
|
||||
<span className="select-value">{exportDateRangeLabel}</span>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
{showExportDateRangeSelect && (
|
||||
<div className="select-dropdown">
|
||||
{exportDateRangeOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`select-option ${exportDefaultDateRange === option.value ? 'active' : ''}`}
|
||||
onClick={async () => {
|
||||
setExportDefaultDateRange(option.value)
|
||||
await configService.setExportDefaultDateRange(option.value)
|
||||
showMessage('已更新默认导出时间范围', true)
|
||||
setShowExportDateRangeSelect(false)
|
||||
}}
|
||||
>
|
||||
<span className="option-label">{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>默认导出媒体文件</label>
|
||||
<span className="form-hint">控制图片/语音/表情的默认导出开关</span>
|
||||
<div className="log-toggle-line">
|
||||
<span className="log-status">{exportDefaultMedia ? '已开启' : '已关闭'}</span>
|
||||
<label className="switch" htmlFor="export-default-media">
|
||||
<input
|
||||
id="export-default-media"
|
||||
className="switch-input"
|
||||
type="checkbox"
|
||||
checked={exportDefaultMedia}
|
||||
onChange={async (e) => {
|
||||
const enabled = e.target.checked
|
||||
setExportDefaultMedia(enabled)
|
||||
await configService.setExportDefaultMedia(enabled)
|
||||
showMessage(enabled ? '已开启默认媒体导出' : '已关闭默认媒体导出', true)
|
||||
}}
|
||||
/>
|
||||
<span className="switch-slider" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>默认语音转文字</label>
|
||||
<span className="form-hint">导出时默认将语音转写为文字</span>
|
||||
<div className="log-toggle-line">
|
||||
<span className="log-status">{exportDefaultVoiceAsText ? '已开启' : '已关闭'}</span>
|
||||
<label className="switch" htmlFor="export-default-voice-as-text">
|
||||
<input
|
||||
id="export-default-voice-as-text"
|
||||
className="switch-input"
|
||||
type="checkbox"
|
||||
checked={exportDefaultVoiceAsText}
|
||||
onChange={async (e) => {
|
||||
const enabled = e.target.checked
|
||||
setExportDefaultVoiceAsText(enabled)
|
||||
await configService.setExportDefaultVoiceAsText(enabled)
|
||||
showMessage(enabled ? '已开启默认语音转文字' : '已关闭默认语音转文字', true)
|
||||
}}
|
||||
/>
|
||||
<span className="switch-slider" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Excel 列显示</label>
|
||||
<span className="form-hint">控制 Excel 导出的列字段</span>
|
||||
<div className="select-field" ref={exportExcelColumnsDropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`select-trigger ${showExportExcelColumnsSelect ? 'open' : ''}`}
|
||||
onClick={() => {
|
||||
setShowExportExcelColumnsSelect(!showExportExcelColumnsSelect)
|
||||
setShowExportFormatSelect(false)
|
||||
setShowExportDateRangeSelect(false)
|
||||
}}
|
||||
>
|
||||
<span className="select-value">{exportExcelColumnsLabel}</span>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
{showExportExcelColumnsSelect && (
|
||||
<div className="select-dropdown">
|
||||
{exportExcelColumnOptions.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={`select-option ${exportExcelColumnsValue === option.value ? 'active' : ''}`}
|
||||
onClick={async () => {
|
||||
const compact = option.value === 'compact'
|
||||
setExportDefaultExcelCompactColumns(compact)
|
||||
await configService.setExportDefaultExcelCompactColumns(compact)
|
||||
showMessage(compact ? '已启用精简列' : '已启用完整列', true)
|
||||
setShowExportExcelColumnsSelect(false)
|
||||
}}
|
||||
>
|
||||
<span className="option-label">{option.label}</span>
|
||||
{option.desc && <span className="option-desc">{option.desc}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const renderCacheTab = () => (
|
||||
<div className="tab-content">
|
||||
<p className="section-desc">管理应用缓存数据</p>
|
||||
@@ -992,6 +1216,7 @@ function SettingsPage() {
|
||||
{activeTab === 'appearance' && renderAppearanceTab()}
|
||||
{activeTab === 'database' && renderDatabaseTab()}
|
||||
{activeTab === 'whisper' && renderWhisperTab()}
|
||||
{activeTab === 'export' && renderExportTab()}
|
||||
{activeTab === 'cache' && renderCacheTab()}
|
||||
{activeTab === 'about' && renderAboutTab()}
|
||||
</div>
|
||||
@@ -1001,4 +1226,3 @@ function SettingsPage() {
|
||||
|
||||
export default SettingsPage
|
||||
|
||||
|
||||
|
||||
@@ -441,7 +441,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
<FolderOpen size={16} /> 浏览选择
|
||||
</button>
|
||||
</div>
|
||||
<div className="field-hint">建议选择包含 xwechat_files 的目录</div>
|
||||
<div className="field-hint">请选择微信-设置-存储位置对应的目录</div>
|
||||
<div className="field-hint" style={{ color: '#ff6b6b', marginTop: '4px' }}>⚠️ 目录路径不可包含中文,如有中文请去微信-设置-存储位置点击更改,迁移至全英文目录</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -507,7 +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 className="field-hint">点击自动获取后微信将重新启动,当页面提示<span style={{color: 'red'}}>hook安装成功,现在登录微信</span>后再点击登录</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -533,7 +533,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
{isFetchingImageKey ? '获取中...' : '自动获取图片密钥'}
|
||||
</button>
|
||||
{imageKeyStatus && <div className="field-hint status-text">{imageKeyStatus}</div>}
|
||||
<div className="field-hint">如获取失败,请先打开朋友圈图片再重试</div>
|
||||
<div className="field-hint">请在电脑微信中打开查看几个图片后再点击获取秘钥,如获取失败请重复以上操作</div>
|
||||
{isFetchingImageKey && <div className="field-hint status-text">正在扫描内存,请稍候...</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -22,7 +22,12 @@ export const CONFIG_KEYS = {
|
||||
WHISPER_MODEL_DIR: 'whisperModelDir',
|
||||
WHISPER_DOWNLOAD_SOURCE: 'whisperDownloadSource',
|
||||
AUTO_TRANSCRIBE_VOICE: 'autoTranscribeVoice',
|
||||
TRANSCRIBE_LANGUAGES: 'transcribeLanguages'
|
||||
TRANSCRIBE_LANGUAGES: 'transcribeLanguages',
|
||||
EXPORT_DEFAULT_FORMAT: 'exportDefaultFormat',
|
||||
EXPORT_DEFAULT_DATE_RANGE: 'exportDefaultDateRange',
|
||||
EXPORT_DEFAULT_MEDIA: 'exportDefaultMedia',
|
||||
EXPORT_DEFAULT_VOICE_AS_TEXT: 'exportDefaultVoiceAsText',
|
||||
EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS: 'exportDefaultExcelCompactColumns'
|
||||
} as const
|
||||
|
||||
// 获取解密密钥
|
||||
@@ -243,3 +248,61 @@ export async function getTranscribeLanguages(): Promise<string[]> {
|
||||
export async function setTranscribeLanguages(languages: string[]): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.TRANSCRIBE_LANGUAGES, languages)
|
||||
}
|
||||
|
||||
// 获取导出默认格式
|
||||
export async function getExportDefaultFormat(): Promise<string | null> {
|
||||
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_FORMAT)
|
||||
return (value as string) || null
|
||||
}
|
||||
|
||||
// 设置导出默认格式
|
||||
export async function setExportDefaultFormat(format: string): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_FORMAT, format)
|
||||
}
|
||||
|
||||
// 获取导出默认时间范围
|
||||
export async function getExportDefaultDateRange(): Promise<string | null> {
|
||||
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_DATE_RANGE)
|
||||
return (value as string) || null
|
||||
}
|
||||
|
||||
// 设置导出默认时间范围
|
||||
export async function setExportDefaultDateRange(range: string): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_DATE_RANGE, range)
|
||||
}
|
||||
|
||||
// 获取导出默认媒体设置
|
||||
export async function getExportDefaultMedia(): Promise<boolean | null> {
|
||||
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_MEDIA)
|
||||
if (typeof value === 'boolean') return value
|
||||
return null
|
||||
}
|
||||
|
||||
// 设置导出默认媒体设置
|
||||
export async function setExportDefaultMedia(enabled: boolean): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_MEDIA, enabled)
|
||||
}
|
||||
|
||||
// 获取导出默认语音转文字
|
||||
export async function getExportDefaultVoiceAsText(): Promise<boolean | null> {
|
||||
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_VOICE_AS_TEXT)
|
||||
if (typeof value === 'boolean') return value
|
||||
return null
|
||||
}
|
||||
|
||||
// 设置导出默认语音转文字
|
||||
export async function setExportDefaultVoiceAsText(enabled: boolean): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_VOICE_AS_TEXT, enabled)
|
||||
}
|
||||
|
||||
// 获取导出默认 Excel 列模式
|
||||
export async function getExportDefaultExcelCompactColumns(): Promise<boolean | null> {
|
||||
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS)
|
||||
if (typeof value === 'boolean') return value
|
||||
return null
|
||||
}
|
||||
|
||||
// 设置导出默认 Excel 列模式
|
||||
export async function setExportDefaultExcelCompactColumns(enabled: boolean): Promise<void> {
|
||||
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS, enabled)
|
||||
}
|
||||
|
||||
14
src/types/electron.d.ts
vendored
14
src/types/electron.d.ts
vendored
@@ -314,6 +314,7 @@ export interface ElectronAPI {
|
||||
success: boolean
|
||||
error?: string
|
||||
}>
|
||||
onProgress: (callback: (payload: ExportProgress) => void) => () => void
|
||||
}
|
||||
whisper: {
|
||||
downloadModel: () => Promise<{ success: boolean; modelPath?: string; tokensPath?: string; error?: string }>
|
||||
@@ -327,6 +328,19 @@ export interface ExportOptions {
|
||||
dateRange?: { start: number; end: number } | null
|
||||
exportMedia?: boolean
|
||||
exportAvatars?: boolean
|
||||
exportImages?: boolean
|
||||
exportVoices?: boolean
|
||||
exportEmojis?: boolean
|
||||
exportVoiceAsText?: boolean
|
||||
excelCompactColumns?: boolean
|
||||
sessionLayout?: 'shared' | 'per-session'
|
||||
}
|
||||
|
||||
export interface ExportProgress {
|
||||
current: number
|
||||
total: number
|
||||
currentSession: string
|
||||
phase: 'preparing' | 'exporting' | 'writing' | 'complete'
|
||||
}
|
||||
|
||||
export interface WxidInfo {
|
||||
|
||||
Reference in New Issue
Block a user