diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 6a8a177..be1a653 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -1335,6 +1335,55 @@ class ExportService { return this.formatPlainExportContent(content, localType, { exportVoiceAsText: false }, undefined, myWxid, senderWxid, isSend) } + private extractHtmlLinkCard(content: string, localType: number): { title: string; url: string } | null { + if (!content) return null + + const normalized = this.normalizeAppMessageContent(content) + const isAppMessage = localType === 49 || normalized.includes('') + if (!isAppMessage) return null + + const subType = this.extractXmlValue(normalized, 'type') + if (subType && subType !== '5' && subType !== '49') return null + + const url = this.normalizeHtmlLinkUrl(this.extractXmlValue(normalized, 'url')) + if (!url) return null + + const title = this.extractXmlValue(normalized, 'title') || this.extractXmlValue(normalized, 'des') || url + return { title, url } + } + + private normalizeHtmlLinkUrl(rawUrl: string): string { + const value = (rawUrl || '').trim() + if (!value) return '' + + const parseHttpUrl = (candidate: string): string => { + try { + const parsed = new URL(candidate) + if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { + return parsed.toString() + } + } catch { + return '' + } + return '' + } + + if (value.startsWith('//')) { + return parseHttpUrl(`https:${value}`) + } + + const direct = parseHttpUrl(value) + if (direct) return direct + + const hasScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(value) + const isDomainLike = /^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}(?:[/:?#].*)?$/.test(value) + if (!hasScheme && isDomainLike) { + return parseHttpUrl(`https://${value}`) + } + + return '' + } + /** * 导出媒体文件到指定目录 */ @@ -4343,6 +4392,8 @@ class ExportService { } } + const linkCard = this.extractHtmlLinkCard(msg.content, msg.localType) + let mediaHtml = '' if (mediaItem?.kind === 'image') { const mediaPath = this.escapeAttribute(encodeURI(mediaItem.relativePath)) @@ -4357,9 +4408,11 @@ class ExportService { mediaHtml = `` } - const textHtml = textContent - ? `
${this.renderTextWithEmoji(textContent).replace(/\r?\n/g, '
')}
` - : '' + const textHtml = linkCard + ? `
${this.renderTextWithEmoji(linkCard.title).replace(/\r?\n/g, '
')}
` + : (textContent + ? `
${this.renderTextWithEmoji(textContent).replace(/\r?\n/g, '
')}
` + : '') const senderNameHtml = isGroup ? `
${this.escapeHtml(senderName)}
` : '' diff --git a/src/pages/GroupAnalyticsPage.scss b/src/pages/GroupAnalyticsPage.scss index 7dfc40f..b9cd651 100644 --- a/src/pages/GroupAnalyticsPage.scss +++ b/src/pages/GroupAnalyticsPage.scss @@ -802,21 +802,180 @@ display: flex; flex-direction: column; gap: 6px; + position: relative; - span { + > span { font-size: 12px; color: var(--text-secondary); } + } - select { - width: 100%; - border: 1px solid var(--border-color); - border-radius: 10px; + .select-trigger { + width: 100%; + padding: 10px 12px; + border: 1px solid var(--border-color); + border-radius: 9999px; + 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: 30; + max-height: 280px; + 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: 13px; + + &:hover { background: var(--bg-tertiary); - color: var(--text-primary); - font-size: 13px; - padding: 8px 10px; + } + + &.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-secondary); + line-height: 1.4; + } + + .member-select-trigger-value { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + } + + .member-select-dropdown { + padding: 8px; + } + + .member-select-search { + display: flex; + align-items: center; + gap: 8px; + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 7px 9px; + margin-bottom: 8px; + background: var(--bg-tertiary); + + svg { + color: var(--text-tertiary); + flex-shrink: 0; + } + + input { + flex: 1; + min-width: 0; + border: none; + background: transparent; outline: none; + color: var(--text-primary); + font-size: 12px; + } + } + + .member-select-options { + display: flex; + flex-direction: column; + gap: 4px; + } + + .member-select-empty { + padding: 10px 8px; + text-align: center; + font-size: 12px; + color: var(--text-tertiary); + } + + .member-select-option { + display: grid; + grid-template-columns: 28px 1fr; + gap: 8px; + align-items: center; + padding: 8px 10px; + + .member-option-main { + display: block; + font-size: 13px; + font-weight: 500; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .member-option-meta { + grid-column: 2 / 3; + font-size: 11px; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &.active { + .member-option-main, + .member-option-meta { + color: var(--primary); + } } } @@ -866,36 +1025,62 @@ gap: 12px; } - .member-export-switch { + .member-export-chip-group { display: flex; - align-items: center; - justify-content: space-between; - font-size: 13px; - color: var(--text-primary); - - input { - width: 16px; - height: 16px; - cursor: pointer; - } + flex-direction: column; + gap: 8px; } - .member-export-checkboxes { - display: grid; - gap: 10px; - grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + .chip-group-label { + font-size: 12px; + color: var(--text-secondary); + } - label { - display: flex; - align-items: center; - gap: 8px; - font-size: 13px; + .member-export-chip-list { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + .export-filter-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 14px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 10px; + cursor: pointer; + user-select: none; + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); + transition: all 0.2s ease; + white-space: nowrap; + + &:hover { + background: var(--bg-hover); + border-color: var(--text-tertiary); color: var(--text-primary); + transform: translateY(-1px); + } - input { - width: 16px; - height: 16px; - cursor: pointer; + &.active { + background: var(--primary-light); + border-color: var(--primary); + color: var(--primary); + } + + &.disabled, + &:disabled { + opacity: 0.45; + cursor: not-allowed; + transform: none; + + &:hover { + background: var(--bg-secondary); + border-color: var(--border-color); + color: var(--text-secondary); } } } diff --git a/src/pages/GroupAnalyticsPage.tsx b/src/pages/GroupAnalyticsPage.tsx index 6f889ca..05bf1af 100644 --- a/src/pages/GroupAnalyticsPage.tsx +++ b/src/pages/GroupAnalyticsPage.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef, useCallback, useMemo } from 'react' import { useLocation } from 'react-router-dom' -import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, Medal, Search, X, ChevronLeft, Copy, Check, Download } from 'lucide-react' +import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, Medal, Search, X, ChevronLeft, Copy, Check, Download, ChevronDown } from 'lucide-react' import { Avatar } from '../components/Avatar' import ReactECharts from 'echarts-for-react' import DateRangePicker from '../components/DateRangePicker' @@ -44,6 +44,12 @@ interface MemberMessageExportOptions { displayNamePreference: 'group-nickname' | 'remark' | 'nickname' } +interface MemberExportFormatOption { + value: MemberExportFormat + label: string + desc: string +} + function GroupAnalyticsPage() { const location = useLocation() const [groups, setGroups] = useState([]) @@ -78,6 +84,13 @@ function GroupAnalyticsPage() { // 成员详情弹框 const [selectedMember, setSelectedMember] = useState(null) const [copiedField, setCopiedField] = useState(null) + const [showMemberSelect, setShowMemberSelect] = useState(false) + const [showFormatSelect, setShowFormatSelect] = useState(false) + const [showDisplayNameSelect, setShowDisplayNameSelect] = useState(false) + const [memberSearchKeyword, setMemberSearchKeyword] = useState('') + const memberSelectDropdownRef = useRef(null) + const formatDropdownRef = useRef(null) + const displayNameDropdownRef = useRef(null) // 时间范围 const [startDate, setStartDate] = useState('') @@ -102,15 +115,50 @@ function GroupAnalyticsPage() { .filter(Boolean) }, [location.state]) - const memberExportFormatOptions = useMemo>(() => ([ - { value: 'excel', label: 'Excel' }, - { value: 'txt', label: 'TXT' }, - { value: 'json', label: 'JSON' }, - { value: 'chatlab', label: 'ChatLab' }, - { value: 'chatlab-jsonl', label: 'ChatLab JSONL' }, - { value: 'html', label: 'HTML' }, - { value: 'weclone', label: 'WeClone CSV' } + const memberExportFormatOptions = useMemo(() => ([ + { value: 'excel', label: 'Excel', desc: '电子表格,适合统计分析' }, + { value: 'txt', label: 'TXT', desc: '纯文本,通用格式' }, + { value: 'json', label: 'JSON', desc: '详细格式,包含完整消息信息' }, + { value: 'chatlab', label: 'ChatLab', desc: '标准格式,支持其他软件导入' }, + { value: 'chatlab-jsonl', label: 'ChatLab JSONL', desc: '流式格式,适合大量消息' }, + { value: 'html', label: 'HTML', desc: '网页格式,可直接浏览' }, + { value: 'weclone', label: 'WeClone CSV', desc: 'WeClone 兼容字段格式(CSV)' } ]), []) + const displayNameOptions = useMemo>(() => ([ + { value: 'group-nickname', label: '群昵称优先', desc: '仅群聊有效,私聊显示备注/昵称' }, + { value: 'remark', label: '备注优先', desc: '有备注显示备注,否则显示昵称' }, + { value: 'nickname', label: '微信昵称', desc: '始终显示微信昵称' } + ]), []) + const selectedExportMember = useMemo( + () => members.find(member => member.username === selectedExportMemberUsername) || null, + [members, selectedExportMemberUsername] + ) + const selectedFormatOption = useMemo( + () => memberExportFormatOptions.find(option => option.value === memberExportOptions.format) || memberExportFormatOptions[0], + [memberExportFormatOptions, memberExportOptions.format] + ) + const selectedDisplayNameOption = useMemo( + () => displayNameOptions.find(option => option.value === memberExportOptions.displayNamePreference) || displayNameOptions[0], + [displayNameOptions, memberExportOptions.displayNamePreference] + ) + const filteredMemberOptions = useMemo(() => { + const keyword = memberSearchKeyword.trim().toLowerCase() + if (!keyword) return members + return members.filter(member => { + const fields = [ + member.username, + member.displayName, + member.nickname, + member.remark, + member.alias + ] + return fields.some(field => String(field || '').toLowerCase().includes(keyword)) + }) + }, [memberSearchKeyword, members]) const loadExportPath = useCallback(async () => { try { @@ -169,6 +217,23 @@ function GroupAnalyticsPage() { } }, [members, selectedExportMemberUsername]) + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as Node + if (showMemberSelect && memberSelectDropdownRef.current && !memberSelectDropdownRef.current.contains(target)) { + setShowMemberSelect(false) + } + if (showFormatSelect && formatDropdownRef.current && !formatDropdownRef.current.contains(target)) { + setShowFormatSelect(false) + } + if (showDisplayNameSelect && displayNameDropdownRef.current && !displayNameDropdownRef.current.contains(target)) { + setShowDisplayNameSelect(false) + } + } + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, [showDisplayNameSelect, showFormatSelect, showMemberSelect]) + useEffect(() => { if (preselectAppliedRef.current) return if (groups.length === 0 || preselectGroupIds.length === 0) return @@ -232,6 +297,10 @@ function GroupAnalyticsPage() { setSelectedGroup(group) setSelectedFunction(null) setSelectedExportMemberUsername('') + setMemberSearchKeyword('') + setShowMemberSelect(false) + setShowFormatSelect(false) + setShowDisplayNameSelect(false) } } @@ -718,32 +787,100 @@ function GroupAnalyticsPage() { ) : ( <>
-
导出目录
@@ -756,79 +893,105 @@ function GroupAnalyticsPage() {
- -
- - - - - - -
- + 导出媒体文件 + +
+
+ 媒体类型 +
+ + + + +
+
+
+ 附加选项 +
+ + +
+
+
+ 显示名称规则 + + {showDisplayNameSelect && ( +
+ {displayNameOptions.map(option => ( + + ))} +
+ )} +