This commit is contained in:
cc committed 2026-07-07 14:28:18 +08:00
1 parent 0497d6e31e
commit 4daeb59b6b
12 files changed
+207 -26

No files matched your search

+2
View File
@@ -60,6 +60,7 @@ interface ConfigSchema {
transcribeLanguages: string[]
exportDefaultConcurrency: number
exportDefaultPathStyle: 'auto' | 'posix' | 'windows'
exportDefaultDisplayNamePreference: 'group-nickname' | 'remark' | 'nickname'
analyticsExcludedUsernames: string[]
// 安全相关
@@ -218,6 +219,7 @@ export class ConfigService {
transcribeLanguages: ['zh'],
exportDefaultConcurrency: 4,
exportDefaultPathStyle: 'auto',
exportDefaultDisplayNamePreference: 'remark',
analyticsExcludedUsernames: [],
authEnabled: false,
authPassword: '',
@@ -14,9 +14,9 @@ export function getPreferredDisplayName(
case 'remark':
return remark || nickname || wxid
case 'nickname':
return nickname || wxid
return nickname || remark || wxid
default:
return nickname || wxid
return nickname || remark || wxid
}
}
@@ -25,18 +25,13 @@ export function resolveGroupNicknameByCandidates(groupNicknamesMap: Map<string,
const idCandidates = buildGroupNicknameIdCandidates(candidates)
if (idCandidates.length === 0) return ''
let resolved = ''
for (const id of idCandidates) {
const normalizedId = normalizeGroupNicknameIdentity(id)
if (!normalizedId) continue
const candidateNickname = normalizeGroupNickname(groupNicknamesMap.get(normalizedId) || '')
if (!candidateNickname) continue
if (!resolved) {
resolved = candidateNickname
continue
}
if (resolved !== candidateNickname) return ''
return candidateNickname
}
return resolved
return ''
}
@@ -332,6 +332,61 @@
margin-bottom: 10px;
}
.display-name-inline-options {
width: 100%;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
margin-bottom: 10px;
}
.display-name-option {
min-width: 0;
min-height: 56px;
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 8px 10px;
background: var(--bg-primary);
color: var(--text-primary);
text-align: left;
cursor: pointer;
transition: border-color 0.2s ease, background 0.2s ease, color 0.2s ease;
&:hover {
border-color: var(--text-tertiary);
}
&.active {
border-color: var(--primary);
background: rgba(var(--primary-rgb), 0.08);
color: var(--primary);
}
}
.display-name-option-label,
.display-name-option-desc {
display: block;
max-width: 100%;
overflow-wrap: anywhere;
}
.display-name-option-label {
font-size: 13px;
font-weight: 600;
line-height: 1.35;
}
.display-name-option-desc {
margin-top: 3px;
font-size: 11px;
line-height: 1.35;
color: var(--text-tertiary);
}
.display-name-option.active .display-name-option-desc {
color: var(--primary);
}
.concurrency-option {
border: 1px solid var(--border-color);
border-radius: 10px;
@@ -472,6 +527,11 @@
margin-bottom: 0;
}
.display-name-inline-options {
max-width: 360px;
margin-bottom: 0;
}
.format-setting-group {
grid-template-columns: 1fr;
gap: 10px;
@@ -526,6 +586,7 @@
.media-default-grid,
.media-size-limit-control,
.concurrency-inline-options,
.display-name-inline-options,
.format-grid {
max-width: none;
}
@@ -595,7 +656,8 @@
.log-toggle-line,
.media-default-grid,
.media-size-limit-control,
.concurrency-inline-options {
.concurrency-inline-options,
.display-name-inline-options {
max-width: none;
width: 100%;
}
@@ -626,6 +688,14 @@
border-radius: 8px;
}
.display-name-inline-options {
gap: 8px;
}
.display-name-option {
border-radius: 8px;
}
.format-setting-group {
grid-template-columns: 1fr;
gap: 10px;
@@ -18,6 +18,7 @@ export interface ExportDefaultsSettingsPatch {
avatars?: boolean
dateRange?: ExportDateRangeSelection
fileNamingMode?: configService.ExportFileNamingMode
displayNamePreference?: configService.ExportDisplayNamePreference
media?: configService.ExportDefaultMediaConfig
voiceAsText?: boolean
excelCompactColumns?: boolean
@@ -53,6 +54,12 @@ const exportFileNamingModeOptions: Array<{ value: configService.ExportFileNaming
{ value: 'date-range', label: '时间范围模式', desc: '示例:私聊_张三_20250101-20250331(推荐)' }
]
const exportDisplayNameOptions: Array<{ value: configService.ExportDisplayNamePreference; label: string; desc: string }> = [
{ value: 'group-nickname', label: '群昵称优先', desc: '群聊显示群昵称,缺失时回退备注/用户名' },
{ value: 'remark', label: '备注优先', desc: '有备注显示备注,否则显示用户名' },
{ value: 'nickname', label: '用户名优先', desc: '有用户名显示用户名,否则显示备注' }
]
const exportConcurrencyOptions = [1, 2, 3, 4, 5, 6] as const
const getOptionLabel = (options: ReadonlyArray<{ value: string; label: string }>, value: string) => {
@@ -120,6 +127,7 @@ export function ExportDefaultsSettingsForm({
const [exportDefaultAvatars, setExportDefaultAvatars] = useState(true)
const [exportDefaultDateRange, setExportDefaultDateRange] = useState<ExportDateRangeSelection>(() => createDefaultExportDateRangeSelection())
const [exportDefaultFileNamingMode, setExportDefaultFileNamingMode] = useState<configService.ExportFileNamingMode>('classic')
const [exportDefaultDisplayNamePreference, setExportDefaultDisplayNamePreference] = useState<configService.ExportDisplayNamePreference>('remark')
const [exportDefaultMedia, setExportDefaultMedia] = useState<configService.ExportDefaultMediaConfig>({
images: true,
videos: true,
@@ -135,11 +143,22 @@ export function ExportDefaultsSettingsForm({
useEffect(() => {
let cancelled = false
void (async () => {
const [savedFormat, savedAvatars, savedDateRange, savedFileNamingMode, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedConcurrency] = await Promise.all([
const [
savedFormat,
savedAvatars,
savedDateRange,
savedFileNamingMode,
savedDisplayNamePreference,
savedMedia,
savedVoiceAsText,
savedExcelCompactColumns,
savedConcurrency
] = await Promise.all([
configService.getExportDefaultFormat(),
configService.getExportDefaultAvatars(),
configService.getExportDefaultDateRange(),
configService.getExportDefaultFileNamingMode(),
configService.getExportDefaultDisplayNamePreference(),
configService.getExportDefaultMedia(),
configService.getExportDefaultVoiceAsText(),
configService.getExportDefaultExcelCompactColumns(),
@@ -152,6 +171,7 @@ export function ExportDefaultsSettingsForm({
setExportDefaultAvatars(savedAvatars ?? true)
setExportDefaultDateRange(resolveExportDateRangeConfig(savedDateRange))
setExportDefaultFileNamingMode(savedFileNamingMode ?? 'classic')
setExportDefaultDisplayNamePreference(savedDisplayNamePreference ?? 'remark')
setExportDefaultMedia(savedMedia ?? {
images: true,
videos: true,
@@ -441,6 +461,35 @@ export function ExportDefaultsSettingsForm({
</div>
</div>
<div className="form-group display-name-setting-group">
<div className="form-copy">
<label>消息发送者命名方式</label>
<span className="form-hint">控制群消息导出时发送者名称的优先级</span>
</div>
<div className="form-control">
<div className="display-name-inline-options" role="radiogroup" aria-label="消息发送者命名方式">
{exportDisplayNameOptions.map((option) => (
<button
key={option.value}
type="button"
className={`display-name-option ${exportDefaultDisplayNamePreference === option.value ? 'active' : ''}`}
aria-pressed={exportDefaultDisplayNamePreference === option.value}
title={option.desc}
onClick={async () => {
setExportDefaultDisplayNamePreference(option.value)
await configService.setExportDefaultDisplayNamePreference(option.value)
onDefaultsChanged?.({ displayNamePreference: option.value })
notify('已更新消息发送者命名方式', true)
}}
>
<span className="display-name-option-label">{option.label}</span>
<span className="display-name-option-desc">{option.desc}</span>
</button>
))}
</div>
</div>
</div>
<div className="form-group">
<div className="form-copy">
<label>Excel 列显示</label>
+9 -3
View File
@@ -154,14 +154,18 @@ function ExportPage() {
const sessionId = String(payload.sessionId || '').trim()
const payloadName = pickDisplayName(payload.sessionName)
const sessionRow = sessions.find(s => s.username === sessionId)
if (options.displayNamePreference === 'nickname') {
return displayNameOrFallback(sessionId, sessionRow?.nickname, payloadName, sessionRow?.remark, sessionRow?.displayName)
}
return displayNameOrFallback(sessionId, payloadName, sessionRow?.displayName, sessionRow?.remark, sessionRow?.nickname)
}, [sessions])
}, [options.displayNamePreference, sessions])
const handleExportDefaultsChanged = useCallback((patch: ExportDefaultsSettingsPatch) => {
updateOptions({
...(patch.format ? { format: patch.format as TextExportFormat } : {}),
...(patch.avatars !== undefined ? { exportAvatars: patch.avatars } : {}),
...(patch.fileNamingMode !== undefined ? { fileNamingMode: patch.fileNamingMode } : {}),
...(patch.displayNamePreference !== undefined ? { displayNamePreference: patch.displayNamePreference } : {}),
...(patch.media ? {
exportImages: patch.media.images,
exportVideos: patch.media.videos,
@@ -282,6 +286,7 @@ function ExportPage() {
}, [handleOpenSingleExportRequest, isConfigLoaded, pendingSingleExportRequest])
const handleConfirmExport = useCallback((finalOptions: any) => {
updateOptions(finalOptions)
startTask({
sessionIds: dialogState.sessionIds,
sessionNames: dialogState.sessionNames,
@@ -291,9 +296,10 @@ function ExportPage() {
options: finalOptions
})
closeDialog()
}, [dialogState, exportPath, startTask, closeDialog])
}, [dialogState, exportPath, startTask, closeDialog, updateOptions])
const handleAutomationCreateFromDialog = useCallback((finalOptions: any) => {
updateOptions(finalOptions)
setDraftAutomationPayload({
sessionIds: dialogState.sessionIds,
sessionNames: dialogState.sessionNames,
@@ -303,7 +309,7 @@ function ExportPage() {
options: finalOptions
})
closeDialog()
}, [closeDialog, dialogState, exportPath])
}, [closeDialog, dialogState, exportPath, updateOptions])
const handleAutomationSave = useCallback((task: any) => {
void addTask(task)
@@ -326,7 +326,7 @@
}
}
.file-size-limit, .concurrency-control, .path-style-control, .conflict-strategy-control {
.file-size-limit, .concurrency-control, .path-style-control, .conflict-strategy-control, .display-name-control {
margin-top: 16px;
display: flex;
align-items: center;
@@ -354,7 +354,7 @@
}
}
.path-style-control, .conflict-strategy-control {
.path-style-control, .conflict-strategy-control, .display-name-control {
flex: 1 1 100%;
margin-top: 4px;
@@ -362,7 +362,7 @@
flex: 0 0 auto;
}
.path-style-segmented, .conflict-strategy-segmented {
.path-style-segmented, .conflict-strategy-segmented, .display-name-segmented {
display: inline-flex;
min-height: 34px;
padding: 3px;
@@ -396,6 +396,23 @@
}
}
}
.display-name-control {
align-items: flex-start;
> span {
padding-top: 9px;
}
.display-name-segmented {
flex-wrap: wrap;
button {
min-width: 96px;
white-space: normal;
}
}
}
}
}
@@ -21,7 +21,7 @@ import {
type ExportDefaultDateRangeConfig
} from '../../../../utils/exportDateRange'
import type { ExportDialogState, ExportOptions, TextExportFormat } from '../../types'
import { conflictStrategyOptions, formatOptions, MAX_EXPORT_FILE_SIZE_MB_LIMIT } from '../../constants'
import { conflictStrategyOptions, displayNameOptions, formatOptions, MAX_EXPORT_FILE_SIZE_MB_LIMIT } from '../../constants'
import { formatPathBrief } from '../../utils/format'
import './ExportDialog.scss'
@@ -318,6 +318,23 @@ const ExportDialog: React.FC<ExportDialogProps> = ({
</div>
</div>
<div className="display-name-control">
<span>命名方式:</span>
<div className="display-name-segmented" title="控制导出群消息时发送者名称的优先级">
{displayNameOptions.map(item => (
<button
key={item.value}
type="button"
className={draftOptions.displayNamePreference === item.value ? 'active' : ''}
title={item.desc}
onClick={() => updateDraftOptions({ displayNamePreference: item.value })}
>
{item.label}
</button>
))}
</div>
</div>
<div className="concurrency-control">
<span>导出并发数:</span>
<select
+3 -3
View File
@@ -56,9 +56,9 @@ export const formatOptions: Array<{ value: TextExportFormat; label: string; desc
// ─── Display name options ────────────────────────────────────
export const displayNameOptions: Array<{ value: DisplayNamePreference; label: string; desc: string }> = [
{ value: 'group-nickname', label: '群昵称优先', desc: '仅群聊有效,私聊显示备注/昵称' },
{ value: 'remark', label: '备注优先', desc: '有备注显示备注,否则显示昵称' },
{ value: 'nickname', label: '微信昵称', desc: '始终显示微信昵称' }
{ value: 'group-nickname', label: '群昵称优先', desc: '群聊显示群昵称,缺失时回退备注/用户名' },
{ value: 'remark', label: '备注优先', desc: '有备注显示备注,否则显示用户名' },
{ value: 'nickname', label: '用户名优先', desc: '有用户名显示用户名,否则显示备注' }
]
export const conflictStrategyOptions: Array<{ value: ExportConflictStrategy; label: string; desc: string }> = [
+10 -2
View File
@@ -63,7 +63,8 @@ export function useExportConfig(): ExportConfigResult {
excelCompact,
txtCols,
concurrency,
fileNamingMode
fileNamingMode,
displayNamePreference
] = await Promise.all([
configService.getExportPath(),
configService.getExportWriteLayout(),
@@ -76,7 +77,8 @@ export function useExportConfig(): ExportConfigResult {
configService.getExportDefaultExcelCompactColumns(),
configService.getExportDefaultTxtColumns(),
configService.getExportDefaultConcurrency(),
configService.getExportDefaultFileNamingMode()
configService.getExportDefaultFileNamingMode(),
configService.getExportDefaultDisplayNamePreference()
])
if (!isMounted) return
@@ -124,6 +126,9 @@ export function useExportConfig(): ExportConfigResult {
if (Array.isArray(txtCols)) newOptions.txtColumns = txtCols
if (typeof concurrency === 'number' && concurrency > 0) newOptions.exportConcurrency = concurrency
if (fileNamingMode === 'classic' || fileNamingMode === 'date-range') newOptions.fileNamingMode = fileNamingMode
if (displayNamePreference === 'group-nickname' || displayNamePreference === 'remark' || displayNamePreference === 'nickname') {
newOptions.displayNamePreference = displayNamePreference
}
// Date range
setRawDateRangeConfigState(dateRange)
@@ -202,6 +207,9 @@ export function useExportConfig(): ExportConfigResult {
if (patch.fileNamingMode !== undefined) {
void configService.setExportDefaultFileNamingMode(patch.fileNamingMode)
}
if (patch.displayNamePreference !== undefined) {
void configService.setExportDefaultDisplayNamePreference(patch.displayNamePreference)
}
// Auto-derive exportMedia
next.exportMedia = (
+6 -3
View File
@@ -4,7 +4,7 @@
*/
import type { ChatSession as AppChatSession, ContactInfo } from '../../../types/models'
import type { ConversationTab, SessionRow } from '../types'
import type { ConversationTab, DisplayNamePreference, SessionRow } from '../types'
import { displayNameOrFallback, pickDisplayName } from '../../../utils/displayName'
// ─── Session type classification ─────────────────────────────
@@ -135,9 +135,12 @@ export const getSelectionScopeFromRows = (rows: SessionRow[]): import('../types'
export const resolveScopeDisplayNames = (
rows: SessionRow[],
pref: import('../types').DisplayNamePreference
pref: DisplayNamePreference
): string[] => {
return rows.map(r => displayNameOrFallback(r.username, r.remark, r.nickname, r.displayName))
return rows.map(r => {
if (pref === 'nickname') return displayNameOrFallback(r.username, r.nickname, r.remark, r.displayName)
return displayNameOrFallback(r.username, r.remark, r.nickname, r.displayName)
})
}
// ─── Name comparison helper ──────────────────────────────────
+14
View File
@@ -39,6 +39,7 @@ export const CONFIG_KEYS = {
EXPORT_DEFAULT_EXCEL_COMPACT_COLUMNS: 'exportDefaultExcelCompactColumns',
EXPORT_DEFAULT_TXT_COLUMNS: 'exportDefaultTxtColumns',
EXPORT_DEFAULT_CONCURRENCY: 'exportDefaultConcurrency',
EXPORT_DEFAULT_DISPLAY_NAME_PREFERENCE: 'exportDefaultDisplayNamePreference',
EXPORT_WRITE_LAYOUT: 'exportWriteLayout',
EXPORT_SESSION_NAME_PREFIX_ENABLED: 'exportSessionNamePrefixEnabled',
EXPORT_LAST_SESSION_RUN_MAP: 'exportLastSessionRunMap',
@@ -163,6 +164,7 @@ export interface ExportDefaultMediaConfig {
export type ExportFileNamingMode = 'classic' | 'date-range'
export type ExportPathStyle = 'auto' | 'posix' | 'windows'
export type ExportDisplayNamePreference = 'group-nickname' | 'remark' | 'nickname'
export type WindowCloseBehavior = 'ask' | 'tray' | 'quit'
export type QuoteLayout = 'quote-top' | 'quote-bottom'
@@ -640,6 +642,18 @@ export async function setExportDefaultConcurrency(concurrency: number): Promise<
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_CONCURRENCY, concurrency)
}
// 获取导出默认命名方式
export async function getExportDefaultDisplayNamePreference(): Promise<ExportDisplayNamePreference | null> {
const value = await config.get(CONFIG_KEYS.EXPORT_DEFAULT_DISPLAY_NAME_PREFERENCE)
if (value === 'group-nickname' || value === 'remark' || value === 'nickname') return value
return null
}
// 设置导出默认命名方式
export async function setExportDefaultDisplayNamePreference(preference: ExportDisplayNamePreference): Promise<void> {
await config.set(CONFIG_KEYS.EXPORT_DEFAULT_DISPLAY_NAME_PREFERENCE, preference)
}
export type ExportWriteLayout = 'A' | 'B' | 'C'
export async function getExportWriteLayout(): Promise<ExportWriteLayout> {