mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 15:25:50 +00:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3c17010c1 | ||
|
|
2389aaf314 | ||
|
|
4f1dd7a5fb | ||
|
|
4b203a93b6 | ||
|
|
f219b1a580 | ||
|
|
004ee5bbf0 | ||
|
|
5640db9cbd | ||
|
|
52b26533a2 | ||
|
|
d334a214a4 | ||
|
|
1aab8dfc4e | ||
|
|
e56ee1ff4a | ||
|
|
0393e7aff7 | ||
|
|
c988e4accf | ||
|
|
63ac715792 | ||
|
|
fe0e2e6592 | ||
|
|
ca1a386146 |
@@ -959,6 +959,10 @@ function registerIpcHandlers() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 导出相关
|
// 导出相关
|
||||||
|
ipcMain.handle('export:getExportStats', async (_, sessionIds: string[], options: any) => {
|
||||||
|
return exportService.getExportStats(sessionIds, options)
|
||||||
|
})
|
||||||
|
|
||||||
ipcMain.handle('export:exportSessions', async (event, sessionIds: string[], outputDir: string, options: ExportOptions) => {
|
ipcMain.handle('export:exportSessions', async (event, sessionIds: string[], outputDir: string, options: ExportOptions) => {
|
||||||
const onProgress = (progress: ExportProgress) => {
|
const onProgress = (progress: ExportProgress) => {
|
||||||
if (!event.sender.isDestroyed()) {
|
if (!event.sender.isDestroyed()) {
|
||||||
|
|||||||
@@ -239,6 +239,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
|
|
||||||
// 导出
|
// 导出
|
||||||
export: {
|
export: {
|
||||||
|
getExportStats: (sessionIds: string[], options: any) =>
|
||||||
|
ipcRenderer.invoke('export:getExportStats', sessionIds, options),
|
||||||
exportSessions: (sessionIds: string[], outputDir: string, options: any) =>
|
exportSessions: (sessionIds: string[], outputDir: string, options: any) =>
|
||||||
ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options),
|
ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options),
|
||||||
exportSession: (sessionId: string, outputPath: string, options: any) =>
|
exportSession: (sessionId: string, outputPath: string, options: any) =>
|
||||||
|
|||||||
@@ -117,10 +117,13 @@ class ChatService {
|
|||||||
private voiceWavCache = new Map<string, Buffer>()
|
private voiceWavCache = new Map<string, Buffer>()
|
||||||
private voiceTranscriptCache = new Map<string, string>()
|
private voiceTranscriptCache = new Map<string, string>()
|
||||||
private voiceTranscriptPending = new Map<string, Promise<{ success: boolean; transcript?: string; error?: string }>>()
|
private voiceTranscriptPending = new Map<string, Promise<{ success: boolean; transcript?: string; error?: string }>>()
|
||||||
|
private transcriptCacheLoaded = false
|
||||||
|
private transcriptCacheDirty = false
|
||||||
|
private transcriptFlushTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
private mediaDbsCache: string[] | null = null
|
private mediaDbsCache: string[] | null = null
|
||||||
private mediaDbsCacheTime = 0
|
private mediaDbsCacheTime = 0
|
||||||
private readonly mediaDbsCacheTtl = 300000 // 5分钟
|
private readonly mediaDbsCacheTtl = 300000 // 5分钟
|
||||||
private readonly voiceCacheMaxEntries = 50
|
private readonly voiceWavCacheMaxEntries = 50
|
||||||
// 缓存 media.db 的表结构信息
|
// 缓存 media.db 的表结构信息
|
||||||
private mediaDbSchemaCache = new Map<string, {
|
private mediaDbSchemaCache = new Map<string, {
|
||||||
voiceTable: string
|
voiceTable: string
|
||||||
@@ -2187,19 +2190,32 @@ class ChatService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 清理拍一拍消息
|
* 清理拍一拍消息
|
||||||
* 格式示例: 我拍了拍 "梨绒" ງ໐໐໓ ຖiງht620000wxid_...
|
* 格式示例:
|
||||||
|
* 纯文本: 我拍了拍 "梨绒" ງ໐໐໓ ຖiງht620000wxid_...
|
||||||
|
* XML: <msg><appmsg...><title>"有幸"拍了拍"浩天空"相信未来!</title>...</msg>
|
||||||
*/
|
*/
|
||||||
private cleanPatMessage(content: string): string {
|
private cleanPatMessage(content: string): string {
|
||||||
if (!content) return '[拍一拍]'
|
if (!content) return '[拍一拍]'
|
||||||
|
|
||||||
// 1. 尝试匹配标准的 "A拍了拍B" 格式
|
// 1. 优先从 XML <title> 标签提取内容
|
||||||
// 这里的正则比较宽泛,为了兼容不同的语言环境
|
const titleMatch = /<title>([\s\S]*?)<\/title>/i.exec(content)
|
||||||
|
if (titleMatch) {
|
||||||
|
const title = titleMatch[1]
|
||||||
|
.replace(/<!\[CDATA\[/g, '')
|
||||||
|
.replace(/\]\]>/g, '')
|
||||||
|
.trim()
|
||||||
|
if (title) {
|
||||||
|
return `[拍一拍] ${title}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 尝试匹配标准的 "A拍了拍B" 格式
|
||||||
const match = /^(.+?拍了拍.+?)(?:[\r\n]|$|ງ|wxid_)/.exec(content)
|
const match = /^(.+?拍了拍.+?)(?:[\r\n]|$|ງ|wxid_)/.exec(content)
|
||||||
if (match) {
|
if (match) {
|
||||||
return `[拍一拍] ${match[1].trim()}`
|
return `[拍一拍] ${match[1].trim()}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 如果匹配失败,尝试清理掉疑似的 garbage (wxid, 乱码)
|
// 3. 如果匹配失败,尝试清理掉疑似的 garbage (wxid, 乱码)
|
||||||
let cleaned = content.replace(/wxid_[a-zA-Z0-9_-]+/g, '') // 移除 wxid
|
let cleaned = content.replace(/wxid_[a-zA-Z0-9_-]+/g, '') // 移除 wxid
|
||||||
cleaned = cleaned.replace(/[ງ໐໓ຖiht]+/g, ' ') // 移除已知的乱码字符
|
cleaned = cleaned.replace(/[ງ໐໓ຖiht]+/g, ' ') // 移除已知的乱码字符
|
||||||
cleaned = cleaned.replace(/\d{6,}/g, '') // 移除长数字
|
cleaned = cleaned.replace(/\d{6,}/g, '') // 移除长数字
|
||||||
@@ -3498,6 +3514,8 @@ class ChatService {
|
|||||||
): Promise<{ success: boolean; transcript?: string; error?: string }> {
|
): Promise<{ success: boolean; transcript?: string; error?: string }> {
|
||||||
const startTime = Date.now()
|
const startTime = Date.now()
|
||||||
|
|
||||||
|
// 确保磁盘缓存已加载
|
||||||
|
this.loadTranscriptCacheIfNeeded()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let msgCreateTime = createTime
|
let msgCreateTime = createTime
|
||||||
@@ -3625,18 +3643,76 @@ class ChatService {
|
|||||||
|
|
||||||
private cacheVoiceWav(cacheKey: string, wavData: Buffer): void {
|
private cacheVoiceWav(cacheKey: string, wavData: Buffer): void {
|
||||||
this.voiceWavCache.set(cacheKey, wavData)
|
this.voiceWavCache.set(cacheKey, wavData)
|
||||||
if (this.voiceWavCache.size > this.voiceCacheMaxEntries) {
|
if (this.voiceWavCache.size > this.voiceWavCacheMaxEntries) {
|
||||||
const oldestKey = this.voiceWavCache.keys().next().value
|
const oldestKey = this.voiceWavCache.keys().next().value
|
||||||
if (oldestKey) this.voiceWavCache.delete(oldestKey)
|
if (oldestKey) this.voiceWavCache.delete(oldestKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 获取持久化转写缓存文件路径 */
|
||||||
|
private getTranscriptCachePath(): string {
|
||||||
|
const cachePath = this.configService.get('cachePath')
|
||||||
|
const base = cachePath || join(app.getPath('documents'), 'WeFlow')
|
||||||
|
return join(base, 'Voices', 'transcripts.json')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 首次访问时从磁盘加载转写缓存 */
|
||||||
|
private loadTranscriptCacheIfNeeded(): void {
|
||||||
|
if (this.transcriptCacheLoaded) return
|
||||||
|
this.transcriptCacheLoaded = true
|
||||||
|
try {
|
||||||
|
const filePath = this.getTranscriptCachePath()
|
||||||
|
if (existsSync(filePath)) {
|
||||||
|
const raw = readFileSync(filePath, 'utf-8')
|
||||||
|
const data = JSON.parse(raw) as Record<string, string>
|
||||||
|
for (const [k, v] of Object.entries(data)) {
|
||||||
|
if (typeof v === 'string') this.voiceTranscriptCache.set(k, v)
|
||||||
|
}
|
||||||
|
console.log(`[Transcribe] 从磁盘加载了 ${this.voiceTranscriptCache.size} 条转写缓存`)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Transcribe] 加载转写缓存失败:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将转写缓存持久化到磁盘(防抖 3 秒) */
|
||||||
|
private scheduleTranscriptFlush(): void {
|
||||||
|
if (this.transcriptFlushTimer) return
|
||||||
|
this.transcriptFlushTimer = setTimeout(() => {
|
||||||
|
this.transcriptFlushTimer = null
|
||||||
|
this.flushTranscriptCache()
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 立即写入转写缓存到磁盘 */
|
||||||
|
flushTranscriptCache(): void {
|
||||||
|
if (!this.transcriptCacheDirty) return
|
||||||
|
try {
|
||||||
|
const filePath = this.getTranscriptCachePath()
|
||||||
|
const dir = dirname(filePath)
|
||||||
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
||||||
|
const obj: Record<string, string> = {}
|
||||||
|
for (const [k, v] of this.voiceTranscriptCache) obj[k] = v
|
||||||
|
writeFileSync(filePath, JSON.stringify(obj), 'utf-8')
|
||||||
|
this.transcriptCacheDirty = false
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Transcribe] 写入转写缓存失败:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private cacheVoiceTranscript(cacheKey: string, transcript: string): void {
|
private cacheVoiceTranscript(cacheKey: string, transcript: string): void {
|
||||||
this.voiceTranscriptCache.set(cacheKey, transcript)
|
this.voiceTranscriptCache.set(cacheKey, transcript)
|
||||||
if (this.voiceTranscriptCache.size > this.voiceCacheMaxEntries) {
|
this.transcriptCacheDirty = true
|
||||||
const oldestKey = this.voiceTranscriptCache.keys().next().value
|
this.scheduleTranscriptFlush()
|
||||||
if (oldestKey) this.voiceTranscriptCache.delete(oldestKey)
|
}
|
||||||
}
|
|
||||||
|
/**
|
||||||
|
* 检查某个语音消息是否已有缓存的转写结果
|
||||||
|
*/
|
||||||
|
hasTranscriptCache(sessionId: string, msgId: string, createTime?: number): boolean {
|
||||||
|
this.loadTranscriptCacheIfNeeded()
|
||||||
|
const cacheKey = this.getVoiceCacheKey(sessionId, msgId, createTime)
|
||||||
|
return this.voiceTranscriptCache.has(cacheKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -25,83 +25,87 @@ body {
|
|||||||
|
|
||||||
.page {
|
.page {
|
||||||
max-width: 1080px;
|
max-width: 1080px;
|
||||||
margin: 32px auto 60px;
|
margin: 0 auto;
|
||||||
padding: 0 20px;
|
padding: 8px 20px;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
background: var(--card);
|
background: var(--card);
|
||||||
border-radius: var(--radius);
|
border-radius: 12px;
|
||||||
box-shadow: var(--shadow);
|
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06);
|
||||||
padding: 24px;
|
padding: 12px 20px;
|
||||||
margin-bottom: 24px;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-size: 24px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin: 0 0 8px;
|
margin: 0;
|
||||||
|
display: inline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta {
|
.meta {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
display: flex;
|
display: inline;
|
||||||
flex-wrap: wrap;
|
margin-left: 12px;
|
||||||
gap: 12px;
|
}
|
||||||
|
|
||||||
|
.meta span {
|
||||||
|
margin-right: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls {
|
.controls {
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
|
||||||
gap: 16px;
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.control {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.control label {
|
.controls input,
|
||||||
font-size: 13px;
|
.controls button {
|
||||||
color: var(--muted);
|
border-radius: 8px;
|
||||||
}
|
|
||||||
|
|
||||||
.control input,
|
|
||||||
.control select,
|
|
||||||
.control button {
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
padding: 10px 12px;
|
padding: 6px 10px;
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.control button {
|
.controls input[type="search"] {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls input[type="datetime-local"] {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls button {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: transform 0.1s ease;
|
padding: 6px 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.control button:active {
|
.controls button:active {
|
||||||
transform: scale(0.98);
|
transform: scale(0.98);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats {
|
.stats {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
display: flex;
|
margin-left: auto;
|
||||||
align-items: flex-end;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-list {
|
.message-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 18px;
|
gap: 12px;
|
||||||
|
padding: 4px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message {
|
.message {
|
||||||
@@ -248,50 +252,11 @@ body {
|
|||||||
cursor: zoom-out;
|
cursor: zoom-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
body[data-theme="cloud-dancer"] {
|
|
||||||
--accent: #6b8cff;
|
|
||||||
--sent: #e0e7ff;
|
|
||||||
--received: #ffffff;
|
|
||||||
--border: #d8e0f7;
|
|
||||||
--bg: #f6f7fb;
|
|
||||||
}
|
|
||||||
|
|
||||||
body[data-theme="corundum-blue"] {
|
|
||||||
--accent: #2563eb;
|
|
||||||
--sent: #dbeafe;
|
|
||||||
--received: #ffffff;
|
|
||||||
--border: #c7d2fe;
|
|
||||||
--bg: #eef2ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
body[data-theme="kiwi-green"] {
|
|
||||||
--accent: #16a34a;
|
|
||||||
--sent: #dcfce7;
|
|
||||||
--received: #ffffff;
|
|
||||||
--border: #bbf7d0;
|
|
||||||
--bg: #f0fdf4;
|
|
||||||
}
|
|
||||||
|
|
||||||
body[data-theme="spicy-red"] {
|
|
||||||
--accent: #e11d48;
|
|
||||||
--sent: #ffe4e6;
|
|
||||||
--received: #ffffff;
|
|
||||||
--border: #fecdd3;
|
|
||||||
--bg: #fff1f2;
|
|
||||||
}
|
|
||||||
|
|
||||||
body[data-theme="teal-water"] {
|
|
||||||
--accent: #0f766e;
|
|
||||||
--sent: #ccfbf1;
|
|
||||||
--received: #ffffff;
|
|
||||||
--border: #99f6e4;
|
|
||||||
--bg: #f0fdfa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.highlight {
|
.highlight {
|
||||||
outline: 2px solid var(--accent);
|
outline: 2px solid var(--accent);
|
||||||
outline-offset: 4px;
|
outline-offset: 4px;
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
|
transition: outline-color 0.3s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
@@ -300,32 +265,29 @@ body[data-theme="teal-water"] {
|
|||||||
padding: 40px;
|
padding: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Virtual Scroll */
|
/* Scroll Container */
|
||||||
.virtual-scroll-container {
|
.scroll-container {
|
||||||
height: calc(100vh - 180px);
|
flex: 1;
|
||||||
/* Adjust based on header height */
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
position: relative;
|
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
margin-top: 20px;
|
margin-top: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.virtual-scroll-spacer {
|
.scroll-container::-webkit-scrollbar {
|
||||||
opacity: 0;
|
width: 6px;
|
||||||
pointer-events: none;
|
|
||||||
width: 1px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.virtual-scroll-content {
|
.scroll-container::-webkit-scrollbar-thumb {
|
||||||
position: absolute;
|
background: #c1c1c1;
|
||||||
top: 0;
|
border-radius: 3px;
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-list {
|
.load-sentinel {
|
||||||
/* Override message-list to be inside virtual scroll */
|
height: 1px;
|
||||||
display: block;
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -25,83 +25,87 @@ body {
|
|||||||
|
|
||||||
.page {
|
.page {
|
||||||
max-width: 1080px;
|
max-width: 1080px;
|
||||||
margin: 32px auto 60px;
|
margin: 0 auto;
|
||||||
padding: 0 20px;
|
padding: 8px 20px;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
background: var(--card);
|
background: var(--card);
|
||||||
border-radius: var(--radius);
|
border-radius: 12px;
|
||||||
box-shadow: var(--shadow);
|
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06);
|
||||||
padding: 24px;
|
padding: 12px 20px;
|
||||||
margin-bottom: 24px;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-size: 24px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin: 0 0 8px;
|
margin: 0;
|
||||||
|
display: inline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta {
|
.meta {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
display: flex;
|
display: inline;
|
||||||
flex-wrap: wrap;
|
margin-left: 12px;
|
||||||
gap: 12px;
|
}
|
||||||
|
|
||||||
|
.meta span {
|
||||||
|
margin-right: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls {
|
.controls {
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
|
||||||
gap: 16px;
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.control {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.control label {
|
.controls input,
|
||||||
font-size: 13px;
|
.controls button {
|
||||||
color: var(--muted);
|
border-radius: 8px;
|
||||||
}
|
|
||||||
|
|
||||||
.control input,
|
|
||||||
.control select,
|
|
||||||
.control button {
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
padding: 10px 12px;
|
padding: 6px 10px;
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.control button {
|
.controls input[type="search"] {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls input[type="datetime-local"] {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls button {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: transform 0.1s ease;
|
padding: 6px 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.control button:active {
|
.controls button:active {
|
||||||
transform: scale(0.98);
|
transform: scale(0.98);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats {
|
.stats {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
display: flex;
|
margin-left: auto;
|
||||||
align-items: flex-end;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-list {
|
.message-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 18px;
|
gap: 12px;
|
||||||
|
padding: 4px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message {
|
.message {
|
||||||
@@ -248,50 +252,11 @@ body {
|
|||||||
cursor: zoom-out;
|
cursor: zoom-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
body[data-theme="cloud-dancer"] {
|
|
||||||
--accent: #6b8cff;
|
|
||||||
--sent: #e0e7ff;
|
|
||||||
--received: #ffffff;
|
|
||||||
--border: #d8e0f7;
|
|
||||||
--bg: #f6f7fb;
|
|
||||||
}
|
|
||||||
|
|
||||||
body[data-theme="corundum-blue"] {
|
|
||||||
--accent: #2563eb;
|
|
||||||
--sent: #dbeafe;
|
|
||||||
--received: #ffffff;
|
|
||||||
--border: #c7d2fe;
|
|
||||||
--bg: #eef2ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
body[data-theme="kiwi-green"] {
|
|
||||||
--accent: #16a34a;
|
|
||||||
--sent: #dcfce7;
|
|
||||||
--received: #ffffff;
|
|
||||||
--border: #bbf7d0;
|
|
||||||
--bg: #f0fdf4;
|
|
||||||
}
|
|
||||||
|
|
||||||
body[data-theme="spicy-red"] {
|
|
||||||
--accent: #e11d48;
|
|
||||||
--sent: #ffe4e6;
|
|
||||||
--received: #ffffff;
|
|
||||||
--border: #fecdd3;
|
|
||||||
--bg: #fff1f2;
|
|
||||||
}
|
|
||||||
|
|
||||||
body[data-theme="teal-water"] {
|
|
||||||
--accent: #0f766e;
|
|
||||||
--sent: #ccfbf1;
|
|
||||||
--received: #ffffff;
|
|
||||||
--border: #99f6e4;
|
|
||||||
--bg: #f0fdfa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.highlight {
|
.highlight {
|
||||||
outline: 2px solid var(--accent);
|
outline: 2px solid var(--accent);
|
||||||
outline-offset: 4px;
|
outline-offset: 4px;
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
|
transition: outline-color 0.3s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
@@ -299,4 +264,32 @@ body[data-theme="teal-water"] {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
padding: 40px;
|
padding: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Scroll Container */
|
||||||
|
.scroll-container {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--bg);
|
||||||
|
margin-top: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-container::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-container::-webkit-scrollbar-thumb {
|
||||||
|
background: #c1c1c1;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-sentinel {
|
||||||
|
height: 1px;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -105,19 +105,166 @@ class GroupAnalyticsService {
|
|||||||
/**
|
/**
|
||||||
* 从 DLL 获取群成员的群昵称
|
* 从 DLL 获取群成员的群昵称
|
||||||
*/
|
*/
|
||||||
private async getGroupNicknamesForRoom(chatroomId: string): Promise<Map<string, string>> {
|
private async getGroupNicknamesForRoom(chatroomId: string, candidates: string[] = []): Promise<Map<string, string>> {
|
||||||
try {
|
try {
|
||||||
const result = await wcdbService.getGroupNicknames(chatroomId)
|
const escapedChatroomId = chatroomId.replace(/'/g, "''")
|
||||||
if (result.success && result.nicknames) {
|
const sql = `SELECT ext_buffer FROM chat_room WHERE username='${escapedChatroomId}' LIMIT 1`
|
||||||
return new Map(Object.entries(result.nicknames))
|
const result = await wcdbService.execQuery('contact', null, sql)
|
||||||
|
if (!result.success || !result.rows || result.rows.length === 0) {
|
||||||
|
return new Map<string, string>()
|
||||||
}
|
}
|
||||||
return new Map<string, string>()
|
|
||||||
|
const extBuffer = this.decodeExtBuffer((result.rows[0] as any).ext_buffer)
|
||||||
|
if (!extBuffer) return new Map<string, string>()
|
||||||
|
return this.parseGroupNicknamesFromExtBuffer(extBuffer, candidates)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('getGroupNicknamesForRoom error:', e)
|
console.error('getGroupNicknamesForRoom error:', e)
|
||||||
return new Map<string, string>()
|
return new Map<string, string>()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private looksLikeHex(s: string): boolean {
|
||||||
|
if (s.length % 2 !== 0) return false
|
||||||
|
return /^[0-9a-fA-F]+$/.test(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
private looksLikeBase64(s: string): boolean {
|
||||||
|
if (s.length % 4 !== 0) return false
|
||||||
|
return /^[A-Za-z0-9+/=]+$/.test(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
private decodeExtBuffer(value: unknown): Buffer | null {
|
||||||
|
if (!value) return null
|
||||||
|
if (Buffer.isBuffer(value)) return value
|
||||||
|
if (value instanceof Uint8Array) return Buffer.from(value)
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const raw = value.trim()
|
||||||
|
if (!raw) return null
|
||||||
|
|
||||||
|
if (this.looksLikeHex(raw)) {
|
||||||
|
try { return Buffer.from(raw, 'hex') } catch { }
|
||||||
|
}
|
||||||
|
if (this.looksLikeBase64(raw)) {
|
||||||
|
try { return Buffer.from(raw, 'base64') } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
try { return Buffer.from(raw, 'hex') } catch { }
|
||||||
|
try { return Buffer.from(raw, 'base64') } catch { }
|
||||||
|
try { return Buffer.from(raw, 'utf8') } catch { }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private readVarint(buffer: Buffer, offset: number, limit: number = buffer.length): { value: number; next: number } | null {
|
||||||
|
let value = 0
|
||||||
|
let shift = 0
|
||||||
|
let pos = offset
|
||||||
|
while (pos < limit && shift <= 53) {
|
||||||
|
const byte = buffer[pos]
|
||||||
|
value += (byte & 0x7f) * Math.pow(2, shift)
|
||||||
|
pos += 1
|
||||||
|
if ((byte & 0x80) === 0) return { value, next: pos }
|
||||||
|
shift += 7
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private isLikelyMemberId(value: string): boolean {
|
||||||
|
const id = String(value || '').trim()
|
||||||
|
if (!id) return false
|
||||||
|
if (id.includes('@chatroom')) return false
|
||||||
|
if (id.length < 4 || id.length > 80) return false
|
||||||
|
return /^[A-Za-z][A-Za-z0-9_.@-]*$/.test(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private isLikelyNickname(value: string): boolean {
|
||||||
|
const cleaned = this.normalizeGroupNickname(value)
|
||||||
|
if (!cleaned) return false
|
||||||
|
if (/^wxid_[a-z0-9_]+$/i.test(cleaned)) return false
|
||||||
|
if (cleaned.includes('@chatroom')) return false
|
||||||
|
if (!/[\u4E00-\u9FFF\u3400-\u4DBF\w]/.test(cleaned)) return false
|
||||||
|
if (cleaned.length === 1) {
|
||||||
|
const code = cleaned.charCodeAt(0)
|
||||||
|
const isCjk = code >= 0x3400 && code <= 0x9fff
|
||||||
|
if (!isCjk) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseGroupNicknamesFromExtBuffer(buffer: Buffer, candidates: string[] = []): Map<string, string> {
|
||||||
|
const nicknameMap = new Map<string, string>()
|
||||||
|
if (!buffer || buffer.length === 0) return nicknameMap
|
||||||
|
|
||||||
|
try {
|
||||||
|
const candidateSet = new Set(this.buildIdCandidates(candidates).map((id) => id.toLowerCase()))
|
||||||
|
|
||||||
|
for (let i = 0; i < buffer.length - 2; i += 1) {
|
||||||
|
if (buffer[i] !== 0x0a) continue
|
||||||
|
|
||||||
|
const idLenInfo = this.readVarint(buffer, i + 1)
|
||||||
|
if (!idLenInfo) continue
|
||||||
|
const idLen = idLenInfo.value
|
||||||
|
if (!Number.isFinite(idLen) || idLen <= 0 || idLen > 96) continue
|
||||||
|
|
||||||
|
const idStart = idLenInfo.next
|
||||||
|
const idEnd = idStart + idLen
|
||||||
|
if (idEnd > buffer.length) continue
|
||||||
|
|
||||||
|
const memberId = buffer.toString('utf8', idStart, idEnd).trim()
|
||||||
|
if (!this.isLikelyMemberId(memberId)) continue
|
||||||
|
|
||||||
|
const memberIdLower = memberId.toLowerCase()
|
||||||
|
if (candidateSet.size > 0 && !candidateSet.has(memberIdLower)) {
|
||||||
|
i = idEnd - 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const cursor = idEnd
|
||||||
|
if (cursor >= buffer.length || buffer[cursor] !== 0x12) {
|
||||||
|
i = idEnd - 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const nickLenInfo = this.readVarint(buffer, cursor + 1)
|
||||||
|
if (!nickLenInfo) {
|
||||||
|
i = idEnd - 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const nickLen = nickLenInfo.value
|
||||||
|
if (!Number.isFinite(nickLen) || nickLen <= 0 || nickLen > 128) {
|
||||||
|
i = idEnd - 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const nickStart = nickLenInfo.next
|
||||||
|
const nickEnd = nickStart + nickLen
|
||||||
|
if (nickEnd > buffer.length) {
|
||||||
|
i = idEnd - 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawNick = buffer.toString('utf8', nickStart, nickEnd)
|
||||||
|
const nickname = this.normalizeGroupNickname(rawNick.replace(/[\x00-\x1F\x7F]/g, '').trim())
|
||||||
|
if (!this.isLikelyNickname(nickname)) {
|
||||||
|
i = nickEnd - 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!nicknameMap.has(memberId)) nicknameMap.set(memberId, nickname)
|
||||||
|
if (!nicknameMap.has(memberIdLower)) nicknameMap.set(memberIdLower, nickname)
|
||||||
|
i = nickEnd - 1
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse chat_room.ext_buffer:', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nicknameMap
|
||||||
|
}
|
||||||
|
|
||||||
private escapeCsvValue(value: string): string {
|
private escapeCsvValue(value: string): string {
|
||||||
if (value == null) return ''
|
if (value == null) return ''
|
||||||
const str = String(value)
|
const str = String(value)
|
||||||
@@ -127,14 +274,54 @@ class GroupAnalyticsService {
|
|||||||
return str
|
return str
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeGroupNickname(value: string, wxid: string, fallback: string): string {
|
private normalizeGroupNickname(value: string): string {
|
||||||
const trimmed = (value || '').trim()
|
const trimmed = (value || '').trim()
|
||||||
if (!trimmed) return fallback
|
if (!trimmed) return ''
|
||||||
if (/^["'@]+$/.test(trimmed)) return fallback
|
if (/^["'@]+$/.test(trimmed)) return ''
|
||||||
if (trimmed.toLowerCase() === (wxid || '').toLowerCase()) return fallback
|
|
||||||
return trimmed
|
return trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildIdCandidates(values: Array<string | undefined | null>): string[] {
|
||||||
|
const set = new Set<string>()
|
||||||
|
for (const rawValue of values) {
|
||||||
|
const raw = String(rawValue || '').trim()
|
||||||
|
if (!raw) continue
|
||||||
|
set.add(raw)
|
||||||
|
const cleaned = this.cleanAccountDirName(raw)
|
||||||
|
if (cleaned && cleaned !== raw) {
|
||||||
|
set.add(cleaned)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(set)
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveGroupNicknameByCandidates(groupNicknames: Map<string, string>, candidates: string[]): string {
|
||||||
|
const idCandidates = this.buildIdCandidates(candidates)
|
||||||
|
if (idCandidates.length === 0) return ''
|
||||||
|
|
||||||
|
for (const id of idCandidates) {
|
||||||
|
const exact = this.normalizeGroupNickname(groupNicknames.get(id) || '')
|
||||||
|
if (exact) return exact
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const id of idCandidates) {
|
||||||
|
const lower = id.toLowerCase()
|
||||||
|
let found = ''
|
||||||
|
let matched = 0
|
||||||
|
for (const [key, value] of groupNicknames.entries()) {
|
||||||
|
if (String(key || '').toLowerCase() !== lower) continue
|
||||||
|
const normalized = this.normalizeGroupNickname(value || '')
|
||||||
|
if (!normalized) continue
|
||||||
|
found = normalized
|
||||||
|
matched += 1
|
||||||
|
if (matched > 1) return ''
|
||||||
|
}
|
||||||
|
if (matched === 1 && found) return found
|
||||||
|
}
|
||||||
|
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
private sanitizeWorksheetName(name: string): string {
|
private sanitizeWorksheetName(name: string): string {
|
||||||
const cleaned = (name || '').replace(/[*?:\\/\\[\\]]/g, '_').trim()
|
const cleaned = (name || '').replace(/[*?:\\/\\[\\]]/g, '_').trim()
|
||||||
const limited = cleaned.slice(0, 31)
|
const limited = cleaned.slice(0, 31)
|
||||||
@@ -219,15 +406,24 @@ class GroupAnalyticsService {
|
|||||||
return { success: false, error: result.error || '获取群成员失败' }
|
return { success: false, error: result.error || '获取群成员失败' }
|
||||||
}
|
}
|
||||||
|
|
||||||
const members = result.members as { username: string; avatarUrl?: string }[]
|
const members = result.members as Array<{
|
||||||
|
username: string
|
||||||
|
avatarUrl?: string
|
||||||
|
originalName?: string
|
||||||
|
}>
|
||||||
const usernames = members.map((m) => m.username).filter(Boolean)
|
const usernames = members.map((m) => m.username).filter(Boolean)
|
||||||
|
|
||||||
const [displayNames, groupNicknames] = await Promise.all([
|
const displayNamesPromise = wcdbService.getDisplayNames(usernames)
|
||||||
wcdbService.getDisplayNames(usernames),
|
|
||||||
this.getGroupNicknamesForRoom(chatroomId)
|
|
||||||
])
|
|
||||||
|
|
||||||
const contactMap = new Map<string, { remark?: string; nickName?: string; alias?: string }>()
|
const contactMap = new Map<string, {
|
||||||
|
remark?: string
|
||||||
|
nickName?: string
|
||||||
|
alias?: string
|
||||||
|
username?: string
|
||||||
|
userName?: string
|
||||||
|
encryptUsername?: string
|
||||||
|
encryptUserName?: string
|
||||||
|
}>()
|
||||||
const concurrency = 6
|
const concurrency = 6
|
||||||
await this.parallelLimit(usernames, concurrency, async (username) => {
|
await this.parallelLimit(usernames, concurrency, async (username) => {
|
||||||
const contactResult = await wcdbService.getContact(username)
|
const contactResult = await wcdbService.getContact(username)
|
||||||
@@ -236,13 +432,29 @@ class GroupAnalyticsService {
|
|||||||
contactMap.set(username, {
|
contactMap.set(username, {
|
||||||
remark: contact.remark || '',
|
remark: contact.remark || '',
|
||||||
nickName: contact.nickName || contact.nick_name || '',
|
nickName: contact.nickName || contact.nick_name || '',
|
||||||
alias: contact.alias || ''
|
alias: contact.alias || '',
|
||||||
|
username: contact.username || '',
|
||||||
|
userName: contact.userName || contact.user_name || '',
|
||||||
|
encryptUsername: contact.encryptUsername || contact.encrypt_username || '',
|
||||||
|
encryptUserName: contact.encryptUserName || ''
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
contactMap.set(username, { remark: '', nickName: '', alias: '' })
|
contactMap.set(username, { remark: '', nickName: '', alias: '' })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const displayNames = await displayNamesPromise
|
||||||
|
const nicknameCandidates = this.buildIdCandidates([
|
||||||
|
...members.map((m) => m.username),
|
||||||
|
...members.map((m) => m.originalName),
|
||||||
|
...Array.from(contactMap.values()).map((c) => c?.username),
|
||||||
|
...Array.from(contactMap.values()).map((c) => c?.userName),
|
||||||
|
...Array.from(contactMap.values()).map((c) => c?.encryptUsername),
|
||||||
|
...Array.from(contactMap.values()).map((c) => c?.encryptUserName),
|
||||||
|
...Array.from(contactMap.values()).map((c) => c?.alias)
|
||||||
|
])
|
||||||
|
const groupNicknames = await this.getGroupNicknamesForRoom(chatroomId, nicknameCandidates)
|
||||||
|
|
||||||
const myWxid = this.cleanAccountDirName(this.configService.get('myWxid') || '')
|
const myWxid = this.cleanAccountDirName(this.configService.get('myWxid') || '')
|
||||||
const data: GroupMember[] = members.map((m) => {
|
const data: GroupMember[] = members.map((m) => {
|
||||||
const wxid = m.username || ''
|
const wxid = m.username || ''
|
||||||
@@ -251,13 +463,20 @@ class GroupAnalyticsService {
|
|||||||
const nickname = contact?.nickName || ''
|
const nickname = contact?.nickName || ''
|
||||||
const remark = contact?.remark || ''
|
const remark = contact?.remark || ''
|
||||||
const alias = contact?.alias || ''
|
const alias = contact?.alias || ''
|
||||||
const rawGroupNickname = groupNicknames.get(wxid.toLowerCase()) || ''
|
|
||||||
const normalizedWxid = this.cleanAccountDirName(wxid)
|
const normalizedWxid = this.cleanAccountDirName(wxid)
|
||||||
const groupNickname = this.normalizeGroupNickname(
|
const lookupCandidates = this.buildIdCandidates([
|
||||||
rawGroupNickname,
|
wxid,
|
||||||
normalizedWxid === myWxid ? myWxid : wxid,
|
m.originalName,
|
||||||
''
|
contact?.username,
|
||||||
)
|
contact?.userName,
|
||||||
|
contact?.encryptUsername,
|
||||||
|
contact?.encryptUserName,
|
||||||
|
alias
|
||||||
|
])
|
||||||
|
if (normalizedWxid === myWxid) {
|
||||||
|
lookupCandidates.push(myWxid)
|
||||||
|
}
|
||||||
|
const groupNickname = this.resolveGroupNicknameByCandidates(groupNicknames, lookupCandidates)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
username: wxid,
|
username: wxid,
|
||||||
@@ -418,18 +637,27 @@ class GroupAnalyticsService {
|
|||||||
return { success: false, error: membersResult.error || '获取群成员失败' }
|
return { success: false, error: membersResult.error || '获取群成员失败' }
|
||||||
}
|
}
|
||||||
|
|
||||||
const members = membersResult.members as { username: string; avatarUrl?: string }[]
|
const members = membersResult.members as Array<{
|
||||||
|
username: string
|
||||||
|
avatarUrl?: string
|
||||||
|
originalName?: string
|
||||||
|
}>
|
||||||
if (members.length === 0) {
|
if (members.length === 0) {
|
||||||
return { success: false, error: '群成员为空' }
|
return { success: false, error: '群成员为空' }
|
||||||
}
|
}
|
||||||
|
|
||||||
const usernames = members.map((m) => m.username).filter(Boolean)
|
const usernames = members.map((m) => m.username).filter(Boolean)
|
||||||
const [displayNames, groupNicknames] = await Promise.all([
|
const displayNamesPromise = wcdbService.getDisplayNames(usernames)
|
||||||
wcdbService.getDisplayNames(usernames),
|
|
||||||
this.getGroupNicknamesForRoom(chatroomId)
|
|
||||||
])
|
|
||||||
|
|
||||||
const contactMap = new Map<string, { remark?: string; nickName?: string; alias?: string }>()
|
const contactMap = new Map<string, {
|
||||||
|
remark?: string
|
||||||
|
nickName?: string
|
||||||
|
alias?: string
|
||||||
|
username?: string
|
||||||
|
userName?: string
|
||||||
|
encryptUsername?: string
|
||||||
|
encryptUserName?: string
|
||||||
|
}>()
|
||||||
const concurrency = 6
|
const concurrency = 6
|
||||||
await this.parallelLimit(usernames, concurrency, async (username) => {
|
await this.parallelLimit(usernames, concurrency, async (username) => {
|
||||||
const result = await wcdbService.getContact(username)
|
const result = await wcdbService.getContact(username)
|
||||||
@@ -438,7 +666,11 @@ class GroupAnalyticsService {
|
|||||||
contactMap.set(username, {
|
contactMap.set(username, {
|
||||||
remark: contact.remark || '',
|
remark: contact.remark || '',
|
||||||
nickName: contact.nickName || contact.nick_name || '',
|
nickName: contact.nickName || contact.nick_name || '',
|
||||||
alias: contact.alias || ''
|
alias: contact.alias || '',
|
||||||
|
username: contact.username || '',
|
||||||
|
userName: contact.userName || contact.user_name || '',
|
||||||
|
encryptUsername: contact.encryptUsername || contact.encrypt_username || '',
|
||||||
|
encryptUserName: contact.encryptUserName || ''
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
contactMap.set(username, { remark: '', nickName: '', alias: '' })
|
contactMap.set(username, { remark: '', nickName: '', alias: '' })
|
||||||
@@ -453,6 +685,18 @@ class GroupAnalyticsService {
|
|||||||
const rows: string[][] = [infoTitleRow, infoRow, metaRow, header]
|
const rows: string[][] = [infoTitleRow, infoRow, metaRow, header]
|
||||||
const myWxid = this.cleanAccountDirName(this.configService.get('myWxid') || '')
|
const myWxid = this.cleanAccountDirName(this.configService.get('myWxid') || '')
|
||||||
|
|
||||||
|
const displayNames = await displayNamesPromise
|
||||||
|
const nicknameCandidates = this.buildIdCandidates([
|
||||||
|
...members.map((m) => m.username),
|
||||||
|
...members.map((m) => m.originalName),
|
||||||
|
...Array.from(contactMap.values()).map((c) => c?.username),
|
||||||
|
...Array.from(contactMap.values()).map((c) => c?.userName),
|
||||||
|
...Array.from(contactMap.values()).map((c) => c?.encryptUsername),
|
||||||
|
...Array.from(contactMap.values()).map((c) => c?.encryptUserName),
|
||||||
|
...Array.from(contactMap.values()).map((c) => c?.alias)
|
||||||
|
])
|
||||||
|
const groupNicknames = await this.getGroupNicknamesForRoom(chatroomId, nicknameCandidates)
|
||||||
|
|
||||||
for (const member of members) {
|
for (const member of members) {
|
||||||
const wxid = member.username
|
const wxid = member.username
|
||||||
const normalizedWxid = this.cleanAccountDirName(wxid || '')
|
const normalizedWxid = this.cleanAccountDirName(wxid || '')
|
||||||
@@ -460,13 +704,20 @@ class GroupAnalyticsService {
|
|||||||
const fallbackName = displayNames.success && displayNames.map ? (displayNames.map[wxid] || '') : ''
|
const fallbackName = displayNames.success && displayNames.map ? (displayNames.map[wxid] || '') : ''
|
||||||
const nickName = contact?.nickName || fallbackName || ''
|
const nickName = contact?.nickName || fallbackName || ''
|
||||||
const remark = contact?.remark || ''
|
const remark = contact?.remark || ''
|
||||||
const rawGroupNickname = groupNicknames.get(wxid.toLowerCase()) || ''
|
|
||||||
const alias = contact?.alias || ''
|
const alias = contact?.alias || ''
|
||||||
const groupNickname = this.normalizeGroupNickname(
|
const lookupCandidates = this.buildIdCandidates([
|
||||||
rawGroupNickname,
|
wxid,
|
||||||
normalizedWxid === myWxid ? myWxid : wxid,
|
member.originalName,
|
||||||
''
|
contact?.username,
|
||||||
)
|
contact?.userName,
|
||||||
|
contact?.encryptUsername,
|
||||||
|
contact?.encryptUserName,
|
||||||
|
alias
|
||||||
|
])
|
||||||
|
if (normalizedWxid === myWxid) {
|
||||||
|
lookupCandidates.push(myWxid)
|
||||||
|
}
|
||||||
|
const groupNickname = this.resolveGroupNicknameByCandidates(groupNicknames, lookupCandidates)
|
||||||
|
|
||||||
rows.push([nickName, remark, groupNickname, wxid, alias])
|
rows.push([nickName, remark, groupNickname, wxid, alias])
|
||||||
}
|
}
|
||||||
|
|||||||
11
src/App.scss
11
src/App.scss
@@ -6,6 +6,17 @@
|
|||||||
animation: appFadeIn 0.35s ease-out;
|
animation: appFadeIn 0.35s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.window-drag-region {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 150px; // 预留系统最小化/最大化/关闭按钮区域
|
||||||
|
height: 40px;
|
||||||
|
-webkit-app-region: drag;
|
||||||
|
pointer-events: auto;
|
||||||
|
z-index: 2000;
|
||||||
|
}
|
||||||
|
|
||||||
.main-layout {
|
.main-layout {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import UpdateDialog from './components/UpdateDialog'
|
|||||||
import UpdateProgressCapsule from './components/UpdateProgressCapsule'
|
import UpdateProgressCapsule from './components/UpdateProgressCapsule'
|
||||||
import LockScreen from './components/LockScreen'
|
import LockScreen from './components/LockScreen'
|
||||||
import { GlobalSessionMonitor } from './components/GlobalSessionMonitor'
|
import { GlobalSessionMonitor } from './components/GlobalSessionMonitor'
|
||||||
|
import { BatchTranscribeGlobal } from './components/BatchTranscribeGlobal'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -345,6 +346,7 @@ function App() {
|
|||||||
// 主窗口 - 完整布局
|
// 主窗口 - 完整布局
|
||||||
return (
|
return (
|
||||||
<div className="app-container">
|
<div className="app-container">
|
||||||
|
<div className="window-drag-region" aria-hidden="true" />
|
||||||
{isLocked && (
|
{isLocked && (
|
||||||
<LockScreen
|
<LockScreen
|
||||||
onUnlock={() => setLocked(false)}
|
onUnlock={() => setLocked(false)}
|
||||||
@@ -360,6 +362,9 @@ function App() {
|
|||||||
{/* 全局会话监听与通知 */}
|
{/* 全局会话监听与通知 */}
|
||||||
<GlobalSessionMonitor />
|
<GlobalSessionMonitor />
|
||||||
|
|
||||||
|
{/* 全局批量转写进度浮窗 */}
|
||||||
|
<BatchTranscribeGlobal />
|
||||||
|
|
||||||
{/* 用户协议弹窗 */}
|
{/* 用户协议弹窗 */}
|
||||||
{showAgreement && !agreementLoading && (
|
{showAgreement && !agreementLoading && (
|
||||||
<div className="agreement-overlay">
|
<div className="agreement-overlay">
|
||||||
|
|||||||
102
src/components/BatchTranscribeGlobal.tsx
Normal file
102
src/components/BatchTranscribeGlobal.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { Loader2, X, CheckCircle, XCircle, AlertCircle } from 'lucide-react'
|
||||||
|
import { useBatchTranscribeStore } from '../stores/batchTranscribeStore'
|
||||||
|
import '../styles/batchTranscribe.scss'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全局批量转写进度浮窗 + 结果弹窗
|
||||||
|
* 挂载在 App 层,切换页面时不会消失
|
||||||
|
*/
|
||||||
|
export const BatchTranscribeGlobal: React.FC = () => {
|
||||||
|
const {
|
||||||
|
isBatchTranscribing,
|
||||||
|
progress,
|
||||||
|
showToast,
|
||||||
|
showResult,
|
||||||
|
result,
|
||||||
|
sessionName,
|
||||||
|
setShowToast,
|
||||||
|
setShowResult
|
||||||
|
} = useBatchTranscribeStore()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 批量转写进度浮窗(非阻塞) */}
|
||||||
|
{showToast && isBatchTranscribing && createPortal(
|
||||||
|
<div className="batch-progress-toast">
|
||||||
|
<div className="batch-progress-toast-header">
|
||||||
|
<div className="batch-progress-toast-title">
|
||||||
|
<Loader2 size={14} className="spin" />
|
||||||
|
<span>批量转写中{sessionName ? `(${sessionName})` : ''}</span>
|
||||||
|
</div>
|
||||||
|
<button className="batch-progress-toast-close" onClick={() => setShowToast(false)} title="最小化">
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="batch-progress-toast-body">
|
||||||
|
<div className="progress-text">
|
||||||
|
<span>{progress.current} / {progress.total}</span>
|
||||||
|
<span className="progress-percent">
|
||||||
|
{progress.total > 0
|
||||||
|
? Math.round((progress.current / progress.total) * 100)
|
||||||
|
: 0}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="progress-bar">
|
||||||
|
<div
|
||||||
|
className="progress-fill"
|
||||||
|
style={{
|
||||||
|
width: `${progress.total > 0
|
||||||
|
? (progress.current / progress.total) * 100
|
||||||
|
: 0}%`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 批量转写结果对话框 */}
|
||||||
|
{showResult && createPortal(
|
||||||
|
<div className="batch-modal-overlay" onClick={() => setShowResult(false)}>
|
||||||
|
<div className="batch-modal-content batch-result-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="batch-modal-header">
|
||||||
|
<CheckCircle size={20} />
|
||||||
|
<h3>转写完成</h3>
|
||||||
|
</div>
|
||||||
|
<div className="batch-modal-body">
|
||||||
|
<div className="result-summary">
|
||||||
|
<div className="result-item success">
|
||||||
|
<CheckCircle size={18} />
|
||||||
|
<span className="label">成功:</span>
|
||||||
|
<span className="value">{result.success} 条</span>
|
||||||
|
</div>
|
||||||
|
{result.fail > 0 && (
|
||||||
|
<div className="result-item fail">
|
||||||
|
<XCircle size={18} />
|
||||||
|
<span className="label">失败:</span>
|
||||||
|
<span className="value">{result.fail} 条</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{result.fail > 0 && (
|
||||||
|
<div className="result-tip">
|
||||||
|
<AlertCircle size={16} />
|
||||||
|
<span>部分语音转写失败,可能是语音文件损坏或网络问题</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="batch-modal-footer">
|
||||||
|
<button className="btn-primary" onClick={() => setShowResult(false)}>
|
||||||
|
确定
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2616,42 +2616,14 @@
|
|||||||
&:hover:not(:disabled) {
|
&:hover:not(:disabled) {
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
}
|
}
|
||||||
|
&.transcribing {
|
||||||
|
color: var(--primary-color);
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 批量转写模态框基础样式
|
// 批量转写模态框基础样式(共享样式在 styles/batchTranscribe.scss)
|
||||||
.batch-modal-overlay {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
backdrop-filter: blur(4px);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 10000;
|
|
||||||
animation: batchFadeIn 0.2s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.batch-modal-content {
|
|
||||||
background: var(--bg-primary);
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
|
||||||
max-height: 90vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
animation: batchSlideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes batchFadeIn {
|
|
||||||
from { opacity: 0; }
|
|
||||||
to { opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes batchSlideUp {
|
|
||||||
from { opacity: 0; transform: translateY(20px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
|
|
||||||
// 批量转写确认对话框
|
// 批量转写确认对话框
|
||||||
.batch-confirm-modal {
|
.batch-confirm-modal {
|
||||||
@@ -2845,187 +2817,3 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 批量转写进度对话框
|
|
||||||
.batch-progress-modal {
|
|
||||||
width: 420px;
|
|
||||||
max-width: 90vw;
|
|
||||||
|
|
||||||
.batch-modal-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 1.5rem;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
|
|
||||||
svg { color: var(--primary-color); }
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.batch-modal-body {
|
|
||||||
padding: 1.5rem;
|
|
||||||
|
|
||||||
.progress-info {
|
|
||||||
.progress-text {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
|
|
||||||
.progress-percent {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--primary-color);
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-bar {
|
|
||||||
height: 8px;
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
border-radius: 4px;
|
|
||||||
overflow: hidden;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
|
|
||||||
.progress-fill {
|
|
||||||
height: 100%;
|
|
||||||
background: linear-gradient(90deg, var(--primary-color), var(--primary-color));
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: width 0.3s ease;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.batch-tip {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0.75rem;
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
border-radius: 8px;
|
|
||||||
|
|
||||||
span {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 批量转写结果对话框
|
|
||||||
.batch-result-modal {
|
|
||||||
width: 420px;
|
|
||||||
max-width: 90vw;
|
|
||||||
|
|
||||||
.batch-modal-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 1.5rem;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
|
|
||||||
svg { color: #4caf50; }
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.batch-modal-body {
|
|
||||||
padding: 1.5rem;
|
|
||||||
|
|
||||||
.result-summary {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.75rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
|
|
||||||
.result-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
|
|
||||||
svg { flex-shrink: 0; }
|
|
||||||
|
|
||||||
.label {
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.value {
|
|
||||||
margin-left: auto;
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.success {
|
|
||||||
svg { color: #4caf50; }
|
|
||||||
.value { color: #4caf50; }
|
|
||||||
}
|
|
||||||
|
|
||||||
&.fail {
|
|
||||||
svg { color: #f44336; }
|
|
||||||
.value { color: #f44336; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-tip {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.75rem;
|
|
||||||
background: rgba(255, 152, 0, 0.1);
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid rgba(255, 152, 0, 0.3);
|
|
||||||
|
|
||||||
svg {
|
|
||||||
flex-shrink: 0;
|
|
||||||
margin-top: 2px;
|
|
||||||
color: #ff9800;
|
|
||||||
}
|
|
||||||
|
|
||||||
span {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.batch-modal-footer {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
padding: 1rem 1.5rem;
|
|
||||||
border-top: 1px solid var(--border-color);
|
|
||||||
|
|
||||||
button {
|
|
||||||
padding: 0.5rem 1.5rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
border: none;
|
|
||||||
|
|
||||||
&.btn-primary {
|
|
||||||
background: var(--primary-color);
|
|
||||||
color: white;
|
|
||||||
&:hover { opacity: 0.9; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||||
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, XCircle, Copy, Check } from 'lucide-react'
|
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, Copy, Check, Download, BarChart3 } from 'lucide-react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { useChatStore } from '../stores/chatStore'
|
import { useChatStore } from '../stores/chatStore'
|
||||||
|
import { useBatchTranscribeStore } from '../stores/batchTranscribeStore'
|
||||||
import type { ChatSession, Message } from '../types/models'
|
import type { ChatSession, Message } from '../types/models'
|
||||||
import { getEmojiPath } from 'wechat-emojis'
|
import { getEmojiPath } from 'wechat-emojis'
|
||||||
import { VoiceTranscribeDialog } from '../components/VoiceTranscribeDialog'
|
import { VoiceTranscribeDialog } from '../components/VoiceTranscribeDialog'
|
||||||
@@ -116,6 +118,8 @@ const SessionItem = React.memo(function SessionItem({
|
|||||||
|
|
||||||
|
|
||||||
function ChatPage(_props: ChatPageProps) {
|
function ChatPage(_props: ChatPageProps) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isConnected,
|
isConnected,
|
||||||
isConnecting,
|
isConnecting,
|
||||||
@@ -175,17 +179,13 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
const [showVoiceTranscribeDialog, setShowVoiceTranscribeDialog] = useState(false)
|
const [showVoiceTranscribeDialog, setShowVoiceTranscribeDialog] = useState(false)
|
||||||
const [pendingVoiceTranscriptRequest, setPendingVoiceTranscriptRequest] = useState<{ sessionId: string; messageId: string } | null>(null)
|
const [pendingVoiceTranscriptRequest, setPendingVoiceTranscriptRequest] = useState<{ sessionId: string; messageId: string } | null>(null)
|
||||||
|
|
||||||
// 批量语音转文字相关状态
|
// 批量语音转文字相关状态(进度/结果 由全局 store 管理)
|
||||||
const [isBatchTranscribing, setIsBatchTranscribing] = useState(false)
|
const { isBatchTranscribing, progress: batchTranscribeProgress, showToast: showBatchProgress, startTranscribe, updateProgress, finishTranscribe, setShowToast: setShowBatchProgress } = useBatchTranscribeStore()
|
||||||
const [batchTranscribeProgress, setBatchTranscribeProgress] = useState({ current: 0, total: 0 })
|
|
||||||
const [showBatchConfirm, setShowBatchConfirm] = useState(false)
|
const [showBatchConfirm, setShowBatchConfirm] = useState(false)
|
||||||
const [batchVoiceCount, setBatchVoiceCount] = useState(0)
|
const [batchVoiceCount, setBatchVoiceCount] = useState(0)
|
||||||
const [batchVoiceMessages, setBatchVoiceMessages] = useState<Message[] | null>(null)
|
const [batchVoiceMessages, setBatchVoiceMessages] = useState<Message[] | null>(null)
|
||||||
const [batchVoiceDates, setBatchVoiceDates] = useState<string[]>([])
|
const [batchVoiceDates, setBatchVoiceDates] = useState<string[]>([])
|
||||||
const [batchSelectedDates, setBatchSelectedDates] = useState<Set<string>>(new Set())
|
const [batchSelectedDates, setBatchSelectedDates] = useState<Set<string>>(new Set())
|
||||||
const [showBatchProgress, setShowBatchProgress] = useState(false)
|
|
||||||
const [showBatchResult, setShowBatchResult] = useState(false)
|
|
||||||
const [batchResult, setBatchResult] = useState({ success: 0, fail: 0 })
|
|
||||||
|
|
||||||
// 联系人信息加载控制
|
// 联系人信息加载控制
|
||||||
const isEnrichingRef = useRef(false)
|
const isEnrichingRef = useRef(false)
|
||||||
@@ -1231,7 +1231,7 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const voiceMessages = result.messages
|
const voiceMessages: Message[] = result.messages
|
||||||
if (voiceMessages.length === 0) {
|
if (voiceMessages.length === 0) {
|
||||||
alert('当前会话没有语音消息')
|
alert('当前会话没有语音消息')
|
||||||
return
|
return
|
||||||
@@ -1248,6 +1248,24 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
setShowBatchConfirm(true)
|
setShowBatchConfirm(true)
|
||||||
}, [sessions, currentSessionId, isBatchTranscribing])
|
}, [sessions, currentSessionId, isBatchTranscribing])
|
||||||
|
|
||||||
|
const handleExportCurrentSession = useCallback(() => {
|
||||||
|
if (!currentSessionId) return
|
||||||
|
navigate('/export', {
|
||||||
|
state: {
|
||||||
|
preselectSessionIds: [currentSessionId]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [currentSessionId, navigate])
|
||||||
|
|
||||||
|
const handleGroupAnalytics = useCallback(() => {
|
||||||
|
if (!currentSessionId || !isGroupChat(currentSessionId)) return
|
||||||
|
navigate('/group-analytics', {
|
||||||
|
state: {
|
||||||
|
preselectGroupIds: [currentSessionId]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [currentSessionId, navigate])
|
||||||
|
|
||||||
// 确认批量转写
|
// 确认批量转写
|
||||||
const confirmBatchTranscribe = useCallback(async () => {
|
const confirmBatchTranscribe = useCallback(async () => {
|
||||||
if (!currentSessionId) return
|
if (!currentSessionId) return
|
||||||
@@ -1280,16 +1298,13 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
const session = sessions.find(s => s.username === currentSessionId)
|
const session = sessions.find(s => s.username === currentSessionId)
|
||||||
if (!session) return
|
if (!session) return
|
||||||
|
|
||||||
setIsBatchTranscribing(true)
|
startTranscribe(voiceMessages.length, session.displayName || session.username)
|
||||||
setShowBatchProgress(true)
|
|
||||||
setBatchTranscribeProgress({ current: 0, total: voiceMessages.length })
|
|
||||||
|
|
||||||
// 检查模型状态
|
// 检查模型状态
|
||||||
const modelStatus = await window.electronAPI.whisper.getModelStatus()
|
const modelStatus = await window.electronAPI.whisper.getModelStatus()
|
||||||
if (!modelStatus?.exists) {
|
if (!modelStatus?.exists) {
|
||||||
alert('SenseVoice 模型未下载,请先在设置中下载模型')
|
alert('SenseVoice 模型未下载,请先在设置中下载模型')
|
||||||
setIsBatchTranscribing(false)
|
finishTranscribe(0, 0)
|
||||||
setShowBatchProgress(false)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1319,15 +1334,12 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
if (result.success) successCount++
|
if (result.success) successCount++
|
||||||
else failCount++
|
else failCount++
|
||||||
completedCount++
|
completedCount++
|
||||||
setBatchTranscribeProgress({ current: completedCount, total: voiceMessages.length })
|
updateProgress(completedCount, voiceMessages.length)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsBatchTranscribing(false)
|
finishTranscribe(successCount, failCount)
|
||||||
setShowBatchProgress(false)
|
}, [sessions, currentSessionId, batchSelectedDates, batchVoiceMessages, startTranscribe, updateProgress, finishTranscribe])
|
||||||
setBatchResult({ success: successCount, fail: failCount })
|
|
||||||
setShowBatchResult(true)
|
|
||||||
}, [sessions, currentSessionId, batchSelectedDates, batchVoiceMessages])
|
|
||||||
|
|
||||||
// 批量转写:按日期的消息数量
|
// 批量转写:按日期的消息数量
|
||||||
const batchCountByDate = useMemo(() => {
|
const batchCountByDate = useMemo(() => {
|
||||||
@@ -1474,11 +1486,34 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="header-actions">
|
<div className="header-actions">
|
||||||
|
{isGroupChat(currentSession.username) && (
|
||||||
|
<button
|
||||||
|
className="icon-btn group-analytics-btn"
|
||||||
|
onClick={handleGroupAnalytics}
|
||||||
|
title="群聊分析"
|
||||||
|
>
|
||||||
|
<BarChart3 size={18} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
className="icon-btn batch-transcribe-btn"
|
className="icon-btn export-session-btn"
|
||||||
onClick={handleBatchTranscribe}
|
onClick={handleExportCurrentSession}
|
||||||
disabled={isBatchTranscribing || !currentSessionId}
|
disabled={!currentSessionId}
|
||||||
title={isBatchTranscribing ? `批量转写中 (${batchTranscribeProgress.current}/${batchTranscribeProgress.total})` : '批量语音转文字'}
|
title="导出当前会话"
|
||||||
|
>
|
||||||
|
<Download size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`icon-btn batch-transcribe-btn${isBatchTranscribing ? ' transcribing' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (isBatchTranscribing) {
|
||||||
|
setShowBatchProgress(true)
|
||||||
|
} else {
|
||||||
|
handleBatchTranscribe()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!currentSessionId}
|
||||||
|
title={isBatchTranscribing ? `批量转写中 (${batchTranscribeProgress.current}/${batchTranscribeProgress.total}),点击查看进度` : '批量语音转文字'}
|
||||||
>
|
>
|
||||||
{isBatchTranscribing ? (
|
{isBatchTranscribing ? (
|
||||||
<Loader2 size={18} className="spin" />
|
<Loader2 size={18} className="spin" />
|
||||||
@@ -1813,84 +1848,6 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 批量转写进度对话框 */}
|
|
||||||
{showBatchProgress && createPortal(
|
|
||||||
<div className="batch-modal-overlay">
|
|
||||||
<div className="batch-modal-content batch-progress-modal" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<div className="batch-modal-header">
|
|
||||||
<Loader2 size={20} className="spin" />
|
|
||||||
<h3>正在转写...</h3>
|
|
||||||
</div>
|
|
||||||
<div className="batch-modal-body">
|
|
||||||
<div className="progress-info">
|
|
||||||
<div className="progress-text">
|
|
||||||
<span>已完成 {batchTranscribeProgress.current} / {batchTranscribeProgress.total} 条</span>
|
|
||||||
<span className="progress-percent">
|
|
||||||
{batchTranscribeProgress.total > 0
|
|
||||||
? Math.round((batchTranscribeProgress.current / batchTranscribeProgress.total) * 100)
|
|
||||||
: 0}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="progress-bar">
|
|
||||||
<div
|
|
||||||
className="progress-fill"
|
|
||||||
style={{
|
|
||||||
width: `${batchTranscribeProgress.total > 0
|
|
||||||
? (batchTranscribeProgress.current / batchTranscribeProgress.total) * 100
|
|
||||||
: 0}%`
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="batch-tip">
|
|
||||||
<span>转写过程中可以继续使用其他功能</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>,
|
|
||||||
document.body
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 批量转写结果对话框 */}
|
|
||||||
{showBatchResult && createPortal(
|
|
||||||
<div className="batch-modal-overlay" onClick={() => setShowBatchResult(false)}>
|
|
||||||
<div className="batch-modal-content batch-result-modal" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<div className="batch-modal-header">
|
|
||||||
<CheckCircle size={20} />
|
|
||||||
<h3>转写完成</h3>
|
|
||||||
</div>
|
|
||||||
<div className="batch-modal-body">
|
|
||||||
<div className="result-summary">
|
|
||||||
<div className="result-item success">
|
|
||||||
<CheckCircle size={18} />
|
|
||||||
<span className="label">成功:</span>
|
|
||||||
<span className="value">{batchResult.success} 条</span>
|
|
||||||
</div>
|
|
||||||
{batchResult.fail > 0 && (
|
|
||||||
<div className="result-item fail">
|
|
||||||
<XCircle size={18} />
|
|
||||||
<span className="label">失败:</span>
|
|
||||||
<span className="value">{batchResult.fail} 条</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{batchResult.fail > 0 && (
|
|
||||||
<div className="result-tip">
|
|
||||||
<AlertCircle size={16} />
|
|
||||||
<span>部分语音转写失败,可能是语音文件损坏或网络问题</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="batch-modal-footer">
|
|
||||||
<button className="btn-primary" onClick={() => setShowBatchResult(false)}>
|
|
||||||
确定
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>,
|
|
||||||
document.body
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
|
||||||
|
import { useLocation } from 'react-router-dom'
|
||||||
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 { 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 * as configService from '../services/config'
|
||||||
import './ExportPage.scss'
|
import './ExportPage.scss'
|
||||||
@@ -38,6 +39,7 @@ interface ExportResult {
|
|||||||
type SessionLayout = 'shared' | 'per-session'
|
type SessionLayout = 'shared' | 'per-session'
|
||||||
|
|
||||||
function ExportPage() {
|
function ExportPage() {
|
||||||
|
const location = useLocation()
|
||||||
const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content']
|
const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content']
|
||||||
const [sessions, setSessions] = useState<ChatSession[]>([])
|
const [sessions, setSessions] = useState<ChatSession[]>([])
|
||||||
const [filteredSessions, setFilteredSessions] = useState<ChatSession[]>([])
|
const [filteredSessions, setFilteredSessions] = useState<ChatSession[]>([])
|
||||||
@@ -46,14 +48,36 @@ function ExportPage() {
|
|||||||
const [searchKeyword, setSearchKeyword] = useState('')
|
const [searchKeyword, setSearchKeyword] = useState('')
|
||||||
const [exportFolder, setExportFolder] = useState<string>('')
|
const [exportFolder, setExportFolder] = useState<string>('')
|
||||||
const [isExporting, setIsExporting] = useState(false)
|
const [isExporting, setIsExporting] = useState(false)
|
||||||
const [exportProgress, setExportProgress] = useState({ current: 0, total: 0, currentName: '' })
|
const [exportProgress, setExportProgress] = useState({ current: 0, total: 0, currentName: '', phaseLabel: '', phaseProgress: 0, phaseTotal: 0 })
|
||||||
const [exportResult, setExportResult] = useState<ExportResult | null>(null)
|
const [exportResult, setExportResult] = useState<ExportResult | null>(null)
|
||||||
const [showDatePicker, setShowDatePicker] = useState(false)
|
const [showDatePicker, setShowDatePicker] = useState(false)
|
||||||
const [calendarDate, setCalendarDate] = useState(new Date())
|
const [calendarDate, setCalendarDate] = useState(new Date())
|
||||||
const [selectingStart, setSelectingStart] = useState(true)
|
const [selectingStart, setSelectingStart] = useState(true)
|
||||||
const [showMediaLayoutPrompt, setShowMediaLayoutPrompt] = useState(false)
|
const [showMediaLayoutPrompt, setShowMediaLayoutPrompt] = useState(false)
|
||||||
const [showDisplayNameSelect, setShowDisplayNameSelect] = useState(false)
|
const [showDisplayNameSelect, setShowDisplayNameSelect] = useState(false)
|
||||||
|
const [showPreExportDialog, setShowPreExportDialog] = useState(false)
|
||||||
|
const [preExportStats, setPreExportStats] = useState<{
|
||||||
|
totalMessages: number; voiceMessages: number; cachedVoiceCount: number;
|
||||||
|
needTranscribeCount: number; mediaMessages: number; estimatedSeconds: number
|
||||||
|
} | null>(null)
|
||||||
|
const [isLoadingStats, setIsLoadingStats] = useState(false)
|
||||||
|
const [pendingLayout, setPendingLayout] = useState<SessionLayout>('shared')
|
||||||
|
const exportStartTime = useRef<number>(0)
|
||||||
|
const [elapsedSeconds, setElapsedSeconds] = useState(0)
|
||||||
const displayNameDropdownRef = useRef<HTMLDivElement>(null)
|
const displayNameDropdownRef = useRef<HTMLDivElement>(null)
|
||||||
|
const preselectAppliedRef = useRef(false)
|
||||||
|
|
||||||
|
const preselectSessionIds = useMemo(() => {
|
||||||
|
const state = location.state as { preselectSessionIds?: unknown; preselectSessionId?: unknown } | null
|
||||||
|
const rawList = Array.isArray(state?.preselectSessionIds)
|
||||||
|
? state?.preselectSessionIds
|
||||||
|
: (typeof state?.preselectSessionId === 'string' ? [state.preselectSessionId] : [])
|
||||||
|
|
||||||
|
return rawList
|
||||||
|
.filter((item): item is string => typeof item === 'string')
|
||||||
|
.map(item => item.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
}, [location.state])
|
||||||
|
|
||||||
const [options, setOptions] = useState<ExportOptions>({
|
const [options, setOptions] = useState<ExportOptions>({
|
||||||
format: 'excel',
|
format: 'excel',
|
||||||
@@ -68,7 +92,7 @@ function ExportPage() {
|
|||||||
exportVoices: true,
|
exportVoices: true,
|
||||||
exportVideos: true,
|
exportVideos: true,
|
||||||
exportEmojis: true,
|
exportEmojis: true,
|
||||||
exportVoiceAsText: true,
|
exportVoiceAsText: false,
|
||||||
excelCompactColumns: true,
|
excelCompactColumns: true,
|
||||||
txtColumns: defaultTxtColumns,
|
txtColumns: defaultTxtColumns,
|
||||||
displayNamePreference: 'remark',
|
displayNamePreference: 'remark',
|
||||||
@@ -159,7 +183,7 @@ function ExportPage() {
|
|||||||
useAllTime: rangeDefaults.useAllTime,
|
useAllTime: rangeDefaults.useAllTime,
|
||||||
dateRange: rangeDefaults.dateRange,
|
dateRange: rangeDefaults.dateRange,
|
||||||
exportMedia: savedMedia ?? false,
|
exportMedia: savedMedia ?? false,
|
||||||
exportVoiceAsText: savedVoiceAsText ?? true,
|
exportVoiceAsText: savedVoiceAsText ?? false,
|
||||||
excelCompactColumns: savedExcelCompactColumns ?? true,
|
excelCompactColumns: savedExcelCompactColumns ?? true,
|
||||||
txtColumns,
|
txtColumns,
|
||||||
exportConcurrency: savedConcurrency ?? 2
|
exportConcurrency: savedConcurrency ?? 2
|
||||||
@@ -175,6 +199,24 @@ function ExportPage() {
|
|||||||
loadExportDefaults()
|
loadExportDefaults()
|
||||||
}, [loadSessions, loadExportPath, loadExportDefaults])
|
}, [loadSessions, loadExportPath, loadExportDefaults])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
preselectAppliedRef.current = false
|
||||||
|
}, [location.key, preselectSessionIds])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (preselectAppliedRef.current) return
|
||||||
|
if (sessions.length === 0 || preselectSessionIds.length === 0) return
|
||||||
|
|
||||||
|
const exists = new Set(sessions.map(session => session.username))
|
||||||
|
const matched = preselectSessionIds.filter(id => exists.has(id))
|
||||||
|
preselectAppliedRef.current = true
|
||||||
|
|
||||||
|
if (matched.length > 0) {
|
||||||
|
setSelectedSessions(new Set(matched))
|
||||||
|
setSearchKeyword('')
|
||||||
|
}
|
||||||
|
}, [sessions, preselectSessionIds])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleChange = () => {
|
const handleChange = () => {
|
||||||
setSelectedSessions(new Set())
|
setSelectedSessions(new Set())
|
||||||
@@ -189,17 +231,30 @@ function ExportPage() {
|
|||||||
}, [loadSessions])
|
}, [loadSessions])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const removeListener = window.electronAPI.export.onProgress?.((payload: { current: number; total: number; currentSession: string; phase: string }) => {
|
const removeListener = window.electronAPI.export.onProgress?.((payload: { current: number; total: number; currentSession: string; phase: string; phaseProgress?: number; phaseTotal?: number; phaseLabel?: string }) => {
|
||||||
setExportProgress({
|
setExportProgress({
|
||||||
current: payload.current,
|
current: payload.current,
|
||||||
total: payload.total,
|
total: payload.total,
|
||||||
currentName: payload.currentSession
|
currentName: payload.currentSession,
|
||||||
|
phaseLabel: payload.phaseLabel || '',
|
||||||
|
phaseProgress: payload.phaseProgress || 0,
|
||||||
|
phaseTotal: payload.phaseTotal || 0
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
return () => {
|
return () => {
|
||||||
removeListener?.()
|
removeListener?.()
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// 导出计时器
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isExporting) return
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setElapsedSeconds(Math.floor((Date.now() - exportStartTime.current) / 1000))
|
||||||
|
}, 1000)
|
||||||
|
return () => clearInterval(timer)
|
||||||
|
}, [isExporting])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
const target = event.target as Node
|
const target = event.target as Node
|
||||||
@@ -260,8 +315,7 @@ function ExportPage() {
|
|||||||
exportImages: true,
|
exportImages: true,
|
||||||
exportVoices: true,
|
exportVoices: true,
|
||||||
exportVideos: true,
|
exportVideos: true,
|
||||||
exportEmojis: true,
|
exportEmojis: true
|
||||||
exportVoiceAsText: true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return next
|
return next
|
||||||
@@ -278,8 +332,10 @@ function ExportPage() {
|
|||||||
if (selectedSessions.size === 0 || !exportFolder) return
|
if (selectedSessions.size === 0 || !exportFolder) return
|
||||||
|
|
||||||
setIsExporting(true)
|
setIsExporting(true)
|
||||||
setExportProgress({ current: 0, total: selectedSessions.size, currentName: '' })
|
setExportProgress({ current: 0, total: selectedSessions.size, currentName: '', phaseLabel: '', phaseProgress: 0, phaseTotal: 0 })
|
||||||
setExportResult(null)
|
setExportResult(null)
|
||||||
|
exportStartTime.current = Date.now()
|
||||||
|
setElapsedSeconds(0)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const sessionList = Array.from(selectedSessions)
|
const sessionList = Array.from(selectedSessions)
|
||||||
@@ -322,9 +378,41 @@ function ExportPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const startExport = () => {
|
const startExport = async () => {
|
||||||
if (selectedSessions.size === 0 || !exportFolder) return
|
if (selectedSessions.size === 0 || !exportFolder) return
|
||||||
|
|
||||||
|
// 先获取预估统计
|
||||||
|
setIsLoadingStats(true)
|
||||||
|
setShowPreExportDialog(true)
|
||||||
|
try {
|
||||||
|
const sessionList = Array.from(selectedSessions)
|
||||||
|
const exportOptions = {
|
||||||
|
format: options.format,
|
||||||
|
exportVoiceAsText: options.exportVoiceAsText,
|
||||||
|
exportMedia: options.exportMedia,
|
||||||
|
exportImages: options.exportMedia && options.exportImages,
|
||||||
|
exportVoices: options.exportMedia && options.exportVoices,
|
||||||
|
exportVideos: options.exportMedia && options.exportVideos,
|
||||||
|
exportEmojis: options.exportMedia && options.exportEmojis,
|
||||||
|
dateRange: options.useAllTime ? null : options.dateRange ? {
|
||||||
|
start: Math.floor(options.dateRange.start.getTime() / 1000),
|
||||||
|
end: Math.floor(new Date(options.dateRange.end.getFullYear(), options.dateRange.end.getMonth(), options.dateRange.end.getDate(), 23, 59, 59).getTime() / 1000)
|
||||||
|
} : null
|
||||||
|
}
|
||||||
|
const stats = await window.electronAPI.export.getExportStats(sessionList, exportOptions)
|
||||||
|
setPreExportStats(stats)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('获取导出统计失败:', e)
|
||||||
|
setPreExportStats(null)
|
||||||
|
} finally {
|
||||||
|
setIsLoadingStats(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmExport = () => {
|
||||||
|
setShowPreExportDialog(false)
|
||||||
|
setPreExportStats(null)
|
||||||
|
|
||||||
if (options.exportMedia && selectedSessions.size > 1) {
|
if (options.exportMedia && selectedSessions.size > 1) {
|
||||||
setShowMediaLayoutPrompt(true)
|
setShowMediaLayoutPrompt(true)
|
||||||
return
|
return
|
||||||
@@ -814,6 +902,71 @@ function ExportPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 导出前预估弹窗 */}
|
||||||
|
{showPreExportDialog && (
|
||||||
|
<div className="export-overlay">
|
||||||
|
<div className="export-layout-modal" onClick={e => e.stopPropagation()}>
|
||||||
|
<h3>导出预估</h3>
|
||||||
|
{isLoadingStats ? (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '24px 0', justifyContent: 'center' }}>
|
||||||
|
<Loader2 size={20} className="spin" />
|
||||||
|
<span style={{ fontSize: 14, color: 'var(--text-secondary)' }}>正在统计消息...</span>
|
||||||
|
</div>
|
||||||
|
) : preExportStats ? (
|
||||||
|
<div style={{ padding: '12px 0' }}>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px 24px', fontSize: 14 }}>
|
||||||
|
<div>
|
||||||
|
<span style={{ color: 'var(--text-secondary)' }}>会话数</span>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 18, marginTop: 2 }}>{selectedSessions.size}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span style={{ color: 'var(--text-secondary)' }}>总消息</span>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 18, marginTop: 2 }}>{preExportStats.totalMessages.toLocaleString()}</div>
|
||||||
|
</div>
|
||||||
|
{options.exportVoiceAsText && preExportStats.voiceMessages > 0 && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<span style={{ color: 'var(--text-secondary)' }}>语音消息</span>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 18, marginTop: 2 }}>{preExportStats.voiceMessages}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span style={{ color: 'var(--text-secondary)' }}>已有缓存</span>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 18, marginTop: 2, color: 'var(--primary)' }}>{preExportStats.cachedVoiceCount}</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{options.exportVoiceAsText && preExportStats.needTranscribeCount > 0 && (
|
||||||
|
<div style={{ marginTop: 16, padding: '10px 12px', background: 'var(--bg-tertiary)', borderRadius: 8, fontSize: 13 }}>
|
||||||
|
<span style={{ color: 'var(--text-warning, #e6a23c)' }}>⚠</span>
|
||||||
|
{' '}需要转写 <b>{preExportStats.needTranscribeCount}</b> 条语音,预计耗时约 <b>{preExportStats.estimatedSeconds > 60
|
||||||
|
? `${Math.round(preExportStats.estimatedSeconds / 60)} 分钟`
|
||||||
|
: `${preExportStats.estimatedSeconds} 秒`
|
||||||
|
}</b>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{options.exportVoiceAsText && preExportStats.voiceMessages > 0 && preExportStats.needTranscribeCount === 0 && (
|
||||||
|
<div style={{ marginTop: 16, padding: '10px 12px', background: 'var(--bg-tertiary)', borderRadius: 8, fontSize: 13 }}>
|
||||||
|
<span style={{ color: 'var(--text-success, #67c23a)' }}>✓</span>
|
||||||
|
{' '}所有 {preExportStats.voiceMessages} 条语音已有转写缓存,无需重新转写
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p style={{ fontSize: 14, color: 'var(--text-secondary)', padding: '16px 0' }}>统计信息获取失败,仍可继续导出</p>
|
||||||
|
)}
|
||||||
|
<div className="layout-actions" style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 8 }}>
|
||||||
|
<button className="layout-cancel-btn" onClick={() => { setShowPreExportDialog(false); setPreExportStats(null) }}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button className="layout-option-btn primary" onClick={confirmExport} disabled={isLoadingStats}>
|
||||||
|
<span className="layout-title">开始导出</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 导出进度弹窗 */}
|
{/* 导出进度弹窗 */}
|
||||||
{isExporting && (
|
{isExporting && (
|
||||||
<div className="export-overlay">
|
<div className="export-overlay">
|
||||||
@@ -823,13 +976,31 @@ function ExportPage() {
|
|||||||
</div>
|
</div>
|
||||||
<h3>正在导出</h3>
|
<h3>正在导出</h3>
|
||||||
<p className="progress-text">{exportProgress.currentName}</p>
|
<p className="progress-text">{exportProgress.currentName}</p>
|
||||||
|
{exportProgress.phaseLabel && (
|
||||||
|
<p className="progress-phase-label" style={{ fontSize: 13, color: 'var(--text-secondary)', margin: '4px 0 8px' }}>
|
||||||
|
{exportProgress.phaseLabel}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{exportProgress.phaseTotal > 0 && (
|
||||||
|
<div className="progress-bar" style={{ marginBottom: 8 }}>
|
||||||
|
<div
|
||||||
|
className="progress-fill"
|
||||||
|
style={{ width: `${(exportProgress.phaseProgress / exportProgress.phaseTotal) * 100}%`, background: 'var(--primary-light, #79bbff)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="progress-bar">
|
<div className="progress-bar">
|
||||||
<div
|
<div
|
||||||
className="progress-fill"
|
className="progress-fill"
|
||||||
style={{ width: `${(exportProgress.current / exportProgress.total) * 100}%` }}
|
style={{ width: `${exportProgress.total > 0 ? (exportProgress.current / exportProgress.total) * 100 : 0}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="progress-count">{exportProgress.current} / {exportProgress.total}</p>
|
<p className="progress-count">
|
||||||
|
{exportProgress.current} / {exportProgress.total} 个会话
|
||||||
|
<span style={{ marginLeft: 12, fontSize: 12, color: 'var(--text-secondary)' }}>
|
||||||
|
{elapsedSeconds > 0 && `已用 ${elapsedSeconds >= 60 ? `${Math.floor(elapsedSeconds / 60)}分${elapsedSeconds % 60}秒` : `${elapsedSeconds}秒`}`}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||||
|
import { useLocation } from 'react-router-dom'
|
||||||
import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, User, Medal, Search, X, ChevronLeft, Copy, Check, Download } from 'lucide-react'
|
import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, User, Medal, Search, X, ChevronLeft, Copy, Check, Download } from 'lucide-react'
|
||||||
import { Avatar } from '../components/Avatar'
|
import { Avatar } from '../components/Avatar'
|
||||||
import ReactECharts from 'echarts-for-react'
|
import ReactECharts from 'echarts-for-react'
|
||||||
@@ -30,6 +31,7 @@ interface GroupMessageRank {
|
|||||||
type AnalysisFunction = 'members' | 'ranking' | 'activeHours' | 'mediaStats'
|
type AnalysisFunction = 'members' | 'ranking' | 'activeHours' | 'mediaStats'
|
||||||
|
|
||||||
function GroupAnalyticsPage() {
|
function GroupAnalyticsPage() {
|
||||||
|
const location = useLocation()
|
||||||
const [groups, setGroups] = useState<GroupChatInfo[]>([])
|
const [groups, setGroups] = useState<GroupChatInfo[]>([])
|
||||||
const [filteredGroups, setFilteredGroups] = useState<GroupChatInfo[]>([])
|
const [filteredGroups, setFilteredGroups] = useState<GroupChatInfo[]>([])
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
@@ -58,11 +60,28 @@ function GroupAnalyticsPage() {
|
|||||||
const [sidebarWidth, setSidebarWidth] = useState(300)
|
const [sidebarWidth, setSidebarWidth] = useState(300)
|
||||||
const [isResizing, setIsResizing] = useState(false)
|
const [isResizing, setIsResizing] = useState(false)
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const preselectAppliedRef = useRef(false)
|
||||||
|
|
||||||
|
const preselectGroupIds = useMemo(() => {
|
||||||
|
const state = location.state as { preselectGroupIds?: unknown; preselectGroupId?: unknown } | null
|
||||||
|
const rawList = Array.isArray(state?.preselectGroupIds)
|
||||||
|
? state.preselectGroupIds
|
||||||
|
: (typeof state?.preselectGroupId === 'string' ? [state.preselectGroupId] : [])
|
||||||
|
|
||||||
|
return rawList
|
||||||
|
.filter((item): item is string => typeof item === 'string')
|
||||||
|
.map(item => item.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
}, [location.state])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadGroups()
|
loadGroups()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
preselectAppliedRef.current = false
|
||||||
|
}, [location.key, preselectGroupIds])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (searchQuery) {
|
if (searchQuery) {
|
||||||
setFilteredGroups(groups.filter(g => g.displayName.toLowerCase().includes(searchQuery.toLowerCase())))
|
setFilteredGroups(groups.filter(g => g.displayName.toLowerCase().includes(searchQuery.toLowerCase())))
|
||||||
@@ -71,6 +90,20 @@ function GroupAnalyticsPage() {
|
|||||||
}
|
}
|
||||||
}, [searchQuery, groups])
|
}, [searchQuery, groups])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (preselectAppliedRef.current) return
|
||||||
|
if (groups.length === 0 || preselectGroupIds.length === 0) return
|
||||||
|
|
||||||
|
const matchedGroup = groups.find(group => preselectGroupIds.includes(group.username))
|
||||||
|
preselectAppliedRef.current = true
|
||||||
|
|
||||||
|
if (matchedGroup) {
|
||||||
|
setSelectedGroup(matchedGroup)
|
||||||
|
setSelectedFunction(null)
|
||||||
|
setSearchQuery('')
|
||||||
|
}
|
||||||
|
}, [groups, preselectGroupIds])
|
||||||
|
|
||||||
// 拖动调整宽度
|
// 拖动调整宽度
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleMouseMove = (e: MouseEvent) => {
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ function SettingsPage() {
|
|||||||
const [exportDefaultFormat, setExportDefaultFormat] = useState('excel')
|
const [exportDefaultFormat, setExportDefaultFormat] = useState('excel')
|
||||||
const [exportDefaultDateRange, setExportDefaultDateRange] = useState('today')
|
const [exportDefaultDateRange, setExportDefaultDateRange] = useState('today')
|
||||||
const [exportDefaultMedia, setExportDefaultMedia] = useState(false)
|
const [exportDefaultMedia, setExportDefaultMedia] = useState(false)
|
||||||
const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(true)
|
const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false)
|
||||||
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
|
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
|
||||||
const [exportDefaultConcurrency, setExportDefaultConcurrency] = useState(2)
|
const [exportDefaultConcurrency, setExportDefaultConcurrency] = useState(2)
|
||||||
|
|
||||||
@@ -293,7 +293,7 @@ function SettingsPage() {
|
|||||||
setExportDefaultFormat(savedExportDefaultFormat || 'excel')
|
setExportDefaultFormat(savedExportDefaultFormat || 'excel')
|
||||||
setExportDefaultDateRange(savedExportDefaultDateRange || 'today')
|
setExportDefaultDateRange(savedExportDefaultDateRange || 'today')
|
||||||
setExportDefaultMedia(savedExportDefaultMedia ?? false)
|
setExportDefaultMedia(savedExportDefaultMedia ?? false)
|
||||||
setExportDefaultVoiceAsText(savedExportDefaultVoiceAsText ?? true)
|
setExportDefaultVoiceAsText(savedExportDefaultVoiceAsText ?? false)
|
||||||
setExportDefaultExcelCompactColumns(savedExportDefaultExcelCompactColumns ?? true)
|
setExportDefaultExcelCompactColumns(savedExportDefaultExcelCompactColumns ?? true)
|
||||||
setExportDefaultConcurrency(savedExportDefaultConcurrency ?? 2)
|
setExportDefaultConcurrency(savedExportDefaultConcurrency ?? 2)
|
||||||
|
|
||||||
|
|||||||
65
src/stores/batchTranscribeStore.ts
Normal file
65
src/stores/batchTranscribeStore.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
|
||||||
|
export interface BatchTranscribeState {
|
||||||
|
/** 是否正在批量转写 */
|
||||||
|
isBatchTranscribing: boolean
|
||||||
|
/** 转写进度 */
|
||||||
|
progress: { current: number; total: number }
|
||||||
|
/** 是否显示进度浮窗 */
|
||||||
|
showToast: boolean
|
||||||
|
/** 是否显示结果弹窗 */
|
||||||
|
showResult: boolean
|
||||||
|
/** 转写结果 */
|
||||||
|
result: { success: number; fail: number }
|
||||||
|
/** 当前转写的会话名 */
|
||||||
|
sessionName: string
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
startTranscribe: (total: number, sessionName: string) => void
|
||||||
|
updateProgress: (current: number, total: number) => void
|
||||||
|
finishTranscribe: (success: number, fail: number) => void
|
||||||
|
setShowToast: (show: boolean) => void
|
||||||
|
setShowResult: (show: boolean) => void
|
||||||
|
reset: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useBatchTranscribeStore = create<BatchTranscribeState>((set) => ({
|
||||||
|
isBatchTranscribing: false,
|
||||||
|
progress: { current: 0, total: 0 },
|
||||||
|
showToast: false,
|
||||||
|
showResult: false,
|
||||||
|
result: { success: 0, fail: 0 },
|
||||||
|
sessionName: '',
|
||||||
|
|
||||||
|
startTranscribe: (total, sessionName) => set({
|
||||||
|
isBatchTranscribing: true,
|
||||||
|
showToast: true,
|
||||||
|
progress: { current: 0, total },
|
||||||
|
showResult: false,
|
||||||
|
result: { success: 0, fail: 0 },
|
||||||
|
sessionName
|
||||||
|
}),
|
||||||
|
|
||||||
|
updateProgress: (current, total) => set({
|
||||||
|
progress: { current, total }
|
||||||
|
}),
|
||||||
|
|
||||||
|
finishTranscribe: (success, fail) => set({
|
||||||
|
isBatchTranscribing: false,
|
||||||
|
showToast: false,
|
||||||
|
showResult: true,
|
||||||
|
result: { success, fail }
|
||||||
|
}),
|
||||||
|
|
||||||
|
setShowToast: (show) => set({ showToast: show }),
|
||||||
|
setShowResult: (show) => set({ showResult: show }),
|
||||||
|
|
||||||
|
reset: () => set({
|
||||||
|
isBatchTranscribing: false,
|
||||||
|
progress: { current: 0, total: 0 },
|
||||||
|
showToast: false,
|
||||||
|
showResult: false,
|
||||||
|
result: { success: 0, fail: 0 },
|
||||||
|
sessionName: ''
|
||||||
|
})
|
||||||
|
}))
|
||||||
238
src/styles/batchTranscribe.scss
Normal file
238
src/styles/batchTranscribe.scss
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
// 批量转写 - 共享基础样式(overlay / modal-content / animations)
|
||||||
|
// 被 ChatPage.scss 和 BatchTranscribeGlobal.tsx 同时使用
|
||||||
|
|
||||||
|
.batch-modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 10000;
|
||||||
|
animation: batchFadeIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-modal-content {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
animation: batchSlideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes batchFadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes batchSlideUp {
|
||||||
|
from { opacity: 0; transform: translateY(20px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量转写进度浮窗(非阻塞 toast)
|
||||||
|
.batch-progress-toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 24px;
|
||||||
|
right: 24px;
|
||||||
|
width: 320px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
z-index: 10000;
|
||||||
|
animation: batchToastSlideIn 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.batch-progress-toast-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
|
||||||
|
.batch-progress-toast-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
|
||||||
|
svg { color: var(--primary-color); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-progress-toast-close {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-progress-toast-body {
|
||||||
|
padding: 12px 14px;
|
||||||
|
|
||||||
|
.progress-text {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
|
||||||
|
.progress-percent {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--primary-color);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
height: 6px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, var(--primary-color), var(--primary-color));
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes batchToastSlideIn {
|
||||||
|
from { opacity: 0; transform: translateY(16px) scale(0.96); }
|
||||||
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量转写结果对话框
|
||||||
|
.batch-result-modal {
|
||||||
|
width: 420px;
|
||||||
|
max-width: 90vw;
|
||||||
|
|
||||||
|
.batch-modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
|
||||||
|
svg { color: #4caf50; }
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-modal-body {
|
||||||
|
padding: 1.5rem;
|
||||||
|
|
||||||
|
.result-summary {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
.result-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
|
||||||
|
svg { flex-shrink: 0; }
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.success {
|
||||||
|
svg { color: #4caf50; }
|
||||||
|
.value { color: #4caf50; }
|
||||||
|
}
|
||||||
|
|
||||||
|
&.fail {
|
||||||
|
svg { color: #f44336; }
|
||||||
|
.value { color: #f44336; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-tip {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: rgba(255, 152, 0, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(255, 152, 0, 0.3);
|
||||||
|
|
||||||
|
svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
color: #ff9800;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-modal-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 0.5rem 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
border: none;
|
||||||
|
|
||||||
|
&.btn-primary {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
&:hover { opacity: 0.9; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/types/electron.d.ts
vendored
14
src/types/electron.d.ts
vendored
@@ -403,6 +403,15 @@ export interface ElectronAPI {
|
|||||||
onProgress: (callback: (payload: { status: string; progress: number }) => void) => () => void
|
onProgress: (callback: (payload: { status: string; progress: number }) => void) => () => void
|
||||||
}
|
}
|
||||||
export: {
|
export: {
|
||||||
|
getExportStats: (sessionIds: string[], options: any) => Promise<{
|
||||||
|
totalMessages: number
|
||||||
|
voiceMessages: number
|
||||||
|
cachedVoiceCount: number
|
||||||
|
needTranscribeCount: number
|
||||||
|
mediaMessages: number
|
||||||
|
estimatedSeconds: number
|
||||||
|
sessions: Array<{ sessionId: string; displayName: string; totalCount: number; voiceCount: number }>
|
||||||
|
}>
|
||||||
exportSessions: (sessionIds: string[], outputDir: string, options: ExportOptions) => Promise<{
|
exportSessions: (sessionIds: string[], outputDir: string, options: ExportOptions) => Promise<{
|
||||||
success: boolean
|
success: boolean
|
||||||
successCount?: number
|
successCount?: number
|
||||||
@@ -494,7 +503,10 @@ export interface ExportProgress {
|
|||||||
current: number
|
current: number
|
||||||
total: number
|
total: number
|
||||||
currentSession: string
|
currentSession: string
|
||||||
phase: 'preparing' | 'exporting' | 'writing' | 'complete'
|
phase: 'preparing' | 'exporting' | 'exporting-media' | 'exporting-voice' | 'writing' | 'complete'
|
||||||
|
phaseProgress?: number
|
||||||
|
phaseTotal?: number
|
||||||
|
phaseLabel?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WxidInfo {
|
export interface WxidInfo {
|
||||||
|
|||||||
Reference in New Issue
Block a user