feat: 导出页面新增群昵称 备注等选择

This commit is contained in:
xuncha
2026-01-25 10:45:38 +08:00
parent 9181ac5d34
commit 4e9c81a93d
4 changed files with 241 additions and 10 deletions

View File

@@ -77,6 +77,7 @@ export interface ExportOptions {
excelCompactColumns?: boolean excelCompactColumns?: boolean
txtColumns?: string[] txtColumns?: string[]
sessionLayout?: 'shared' | 'per-session' sessionLayout?: 'shared' | 'per-session'
displayNamePreference?: 'group-nickname' | 'remark' | 'nickname'
} }
const TXT_COLUMN_DEFINITIONS: Array<{ id: string; label: string }> = [ const TXT_COLUMN_DEFINITIONS: Array<{ id: string; label: string }> = [
@@ -408,6 +409,28 @@ class ExportService {
return /^[0-9a-fA-F]+$/.test(s) return /^[0-9a-fA-F]+$/.test(s)
} }
/**
* 根据用户偏好获取显示名称
*/
private getPreferredDisplayName(
wxid: string,
nickname: string,
remark: string,
groupNickname: string,
preference: 'group-nickname' | 'remark' | 'nickname' = 'remark'
): string {
switch (preference) {
case 'group-nickname':
return groupNickname || remark || nickname || wxid
case 'remark':
return remark || nickname || wxid
case 'nickname':
return nickname || wxid
default:
return nickname || wxid
}
}
private looksLikeBase64(s: string): boolean { private looksLikeBase64(s: string): boolean {
if (s.length % 4 !== 0) return false if (s.length % 4 !== 0) return false
return /^[A-Za-z0-9+/=]+$/.test(s) return /^[A-Za-z0-9+/=]+$/.test(s)
@@ -1886,6 +1909,11 @@ class ExportService {
}) })
} }
// ========== 预加载群昵称(用于名称显示偏好) ==========
const groupNicknamesMap = isGroup
? await this.getGroupNicknamesForRoom(sessionId)
: new Map<string, string>()
// ========== 阶段3构建消息列表 ========== // ========== 阶段3构建消息列表 ==========
onProgress?.({ onProgress?.({
current: 55, current: 55,
@@ -1912,6 +1940,24 @@ class ExportService {
content = this.parseMessageContent(msg.content, msg.localType) content = this.parseMessageContent(msg.content, msg.localType)
} }
// 获取发送者信息用于名称显示
const senderWxid = msg.senderUsername
const contact = await wcdbService.getContact(senderWxid)
const senderNickname = contact.success && contact.contact?.nickName
? contact.contact.nickName
: (senderInfo.displayName || senderWxid)
const senderRemark = contact.success && contact.contact?.remark ? contact.contact.remark : ''
const senderGroupNickname = groupNicknamesMap.get(senderWxid?.toLowerCase() || '') || ''
// 使用用户偏好的显示名称
const senderDisplayName = this.getPreferredDisplayName(
senderWxid,
senderNickname,
senderRemark,
senderGroupNickname,
options.displayNamePreference || 'remark'
)
allMessages.push({ allMessages.push({
localId: allMessages.length + 1, localId: allMessages.length + 1,
createTime: msg.createTime, createTime: msg.createTime,
@@ -1921,7 +1967,7 @@ class ExportService {
content, content,
isSend: msg.isSend ? 1 : 0, isSend: msg.isSend ? 1 : 0,
senderUsername: msg.senderUsername, senderUsername: msg.senderUsername,
senderDisplayName: senderInfo.displayName, senderDisplayName,
source, source,
senderAvatarKey: msg.senderUsername senderAvatarKey: msg.senderUsername
}) })
@@ -1938,14 +1984,33 @@ class ExportService {
const { chatlab, meta } = this.getExportMeta(sessionId, sessionInfo, isGroup) const { chatlab, meta } = this.getExportMeta(sessionId, sessionInfo, isGroup)
// 获取会话的昵称和备注信息
const sessionContact = await wcdbService.getContact(sessionId)
const sessionNickname = sessionContact.success && sessionContact.contact?.nickName
? sessionContact.contact.nickName
: sessionInfo.displayName
const sessionRemark = sessionContact.success && sessionContact.contact?.remark
? sessionContact.contact.remark
: ''
const sessionGroupNickname = isGroup
? (groupNicknamesMap.get(sessionId.toLowerCase()) || '')
: ''
// 使用用户偏好的显示名称
const sessionDisplayName = this.getPreferredDisplayName(
sessionId,
sessionNickname,
sessionRemark,
sessionGroupNickname,
options.displayNamePreference || 'remark'
)
const detailedExport: any = { const detailedExport: any = {
chatlab,
meta,
session: { session: {
wxid: sessionId, wxid: sessionId,
nickname: sessionInfo.displayName, nickname: sessionNickname,
remark: sessionInfo.displayName, remark: sessionRemark,
displayName: sessionInfo.displayName, displayName: sessionDisplayName,
type: isGroup ? '群聊' : '私聊', type: isGroup ? '群聊' : '私聊',
lastTimestamp: collected.lastTime, lastTimestamp: collected.lastTime,
messageCount: allMessages.length, messageCount: allMessages.length,

View File

@@ -396,6 +396,99 @@
} }
} }
.select-field {
position: relative;
}
.select-trigger {
width: 100%;
padding: 10px 16px;
border: 1px solid var(--border-color);
border-radius: 9999px;
font-size: 14px;
background: var(--bg-primary);
color: var(--text-primary);
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: var(--text-tertiary);
}
&.open {
border-color: var(--primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent);
}
}
.select-value {
flex: 1;
min-width: 0;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.select-dropdown {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
background: color-mix(in srgb, var(--bg-primary) 85%, var(--bg-secondary));
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 6px;
box-shadow: var(--shadow-md);
z-index: 20;
max-height: 260px;
overflow-y: auto;
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
}
.select-option {
width: 100%;
text-align: left;
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
border: none;
border-radius: 10px;
background: transparent;
cursor: pointer;
transition: all 0.15s;
color: var(--text-primary);
font-size: 14px;
&:hover {
background: var(--bg-tertiary);
}
&.active {
background: color-mix(in srgb, var(--primary) 12%, transparent);
color: var(--primary);
}
}
.option-label {
font-weight: 500;
}
.option-desc {
font-size: 12px;
color: var(--text-tertiary);
}
.select-option.active .option-desc {
color: var(--primary);
}
.media-options { .media-options {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback, useRef } from '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 { 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'
@@ -23,6 +23,7 @@ interface ExportOptions {
exportVoiceAsText: boolean exportVoiceAsText: boolean
excelCompactColumns: boolean excelCompactColumns: boolean
txtColumns: string[] txtColumns: string[]
displayNamePreference: 'group-nickname' | 'remark' | 'nickname'
} }
interface ExportResult { interface ExportResult {
@@ -49,6 +50,8 @@ function ExportPage() {
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 displayNameDropdownRef = useRef<HTMLDivElement>(null)
const [options, setOptions] = useState<ExportOptions>({ const [options, setOptions] = useState<ExportOptions>({
format: 'excel', format: 'excel',
@@ -64,7 +67,8 @@ function ExportPage() {
exportEmojis: true, exportEmojis: true,
exportVoiceAsText: true, exportVoiceAsText: true,
excelCompactColumns: true, excelCompactColumns: true,
txtColumns: defaultTxtColumns txtColumns: defaultTxtColumns,
displayNamePreference: 'remark'
}) })
const buildDateRangeFromPreset = (preset: string) => { const buildDateRangeFromPreset = (preset: string) => {
@@ -189,6 +193,16 @@ function ExportPage() {
removeListener?.() removeListener?.()
} }
}, []) }, [])
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node
if (showDisplayNameSelect && displayNameDropdownRef.current && !displayNameDropdownRef.current.contains(target)) {
setShowDisplayNameSelect(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [showDisplayNameSelect])
useEffect(() => { useEffect(() => {
if (!searchKeyword.trim()) { if (!searchKeyword.trim()) {
@@ -271,6 +285,7 @@ function ExportPage() {
exportVoiceAsText: options.exportVoiceAsText, // 即使不导出媒体,也可以导出语音转文字内容 exportVoiceAsText: options.exportVoiceAsText, // 即使不导出媒体,也可以导出语音转文字内容
excelCompactColumns: options.excelCompactColumns, excelCompactColumns: options.excelCompactColumns,
txtColumns: options.txtColumns, txtColumns: options.txtColumns,
displayNamePreference: options.displayNamePreference,
sessionLayout, sessionLayout,
dateRange: options.useAllTime ? null : options.dateRange ? { dateRange: options.useAllTime ? null : options.dateRange ? {
start: Math.floor(options.dateRange.start.getTime() / 1000), start: Math.floor(options.dateRange.start.getTime() / 1000),
@@ -402,6 +417,25 @@ function ExportPage() {
{ value: 'excel', label: 'Excel', icon: FileSpreadsheet, desc: '电子表格,适合统计分析' }, { value: 'excel', label: 'Excel', icon: FileSpreadsheet, desc: '电子表格,适合统计分析' },
{ value: 'sql', label: 'PostgreSQL', icon: Database, desc: '数据库脚本,便于导入到数据库' } { value: 'sql', label: 'PostgreSQL', icon: Database, desc: '数据库脚本,便于导入到数据库' }
] ]
const displayNameOptions = [
{
value: 'group-nickname',
label: '群昵称优先',
desc: '仅群聊有效,私聊显示备注/昵称'
},
{
value: 'remark',
label: '备注优先',
desc: '有备注显示备注,否则显示昵称'
},
{
value: 'nickname',
label: '微信昵称',
desc: '始终显示微信昵称'
}
]
const displayNameOption = displayNameOptions.find(option => option.value === options.displayNamePreference)
const displayNameLabel = displayNameOption?.label || '备注优先'
return ( return (
<div className="export-page"> <div className="export-page">
@@ -516,6 +550,44 @@ function ExportPage() {
</div> </div>
</div> </div>
{/* 发送者名称显示偏好 */}
{(options.format === 'html' || options.format === 'json' || options.format === 'txt') && (
<div className="setting-section">
<h3></h3>
<p className="setting-subtitle"></p>
<div className="select-field" ref={displayNameDropdownRef}>
<button
type="button"
className={`select-trigger ${showDisplayNameSelect ? 'open' : ''}`}
onClick={() => setShowDisplayNameSelect(!showDisplayNameSelect)}
>
<span className="select-value">{displayNameLabel}</span>
<ChevronDown size={16} />
</button>
{showDisplayNameSelect && (
<div className="select-dropdown">
{displayNameOptions.map(option => (
<button
key={option.value}
type="button"
className={`select-option ${options.displayNamePreference === option.value ? 'active' : ''}`}
onClick={() => {
setOptions({
...options,
displayNamePreference: option.value as ExportOptions['displayNamePreference']
})
setShowDisplayNameSelect(false)
}}
>
<span className="option-label">{option.label}</span>
<span className="option-desc">{option.desc}</span>
</button>
))}
</div>
)}
</div>
</div>
)}
<div className="setting-section"> <div className="setting-section">
<h3></h3> <h3></h3>
<p className="setting-subtitle">//</p> <p className="setting-subtitle">//</p>

View File

@@ -354,6 +354,7 @@ export interface ExportOptions {
excelCompactColumns?: boolean excelCompactColumns?: boolean
txtColumns?: string[] txtColumns?: string[]
sessionLayout?: 'shared' | 'per-session' sessionLayout?: 'shared' | 'per-session'
displayNamePreference?: 'group-nickname' | 'remark' | 'nickname'
} }
export interface ExportProgress { export interface ExportProgress {