diff --git a/electron/dualReportWorker.ts b/electron/dualReportWorker.ts index 003c82c..67e36e8 100644 --- a/electron/dualReportWorker.ts +++ b/electron/dualReportWorker.ts @@ -11,6 +11,7 @@ interface WorkerConfig { resourcesPath?: string userDataPath?: string logEnabled?: boolean + excludeWords?: string[] } const config = workerData as WorkerConfig @@ -29,6 +30,7 @@ async function run() { dbPath: config.dbPath, decryptKey: config.decryptKey, wxid: config.myWxid, + excludeWords: config.excludeWords, onProgress: (status: string, progress: number) => { parentPort?.postMessage({ type: 'dualReport:progress', diff --git a/electron/main.ts b/electron/main.ts index de1d3a6..6abaa29 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1195,6 +1195,7 @@ function registerIpcHandlers() { const logEnabled = cfg.get('logEnabled') const friendUsername = payload?.friendUsername const year = payload?.year ?? 0 + const excludeWords = cfg.get('wordCloudExcludeWords') || [] if (!friendUsername) { return { success: false, error: '缺少好友用户名' } @@ -1209,7 +1210,7 @@ function registerIpcHandlers() { return await new Promise((resolve) => { const worker = new Worker(workerPath, { - workerData: { year, friendUsername, dbPath, decryptKey, myWxid: wxid, resourcesPath, userDataPath, logEnabled } + workerData: { year, friendUsername, dbPath, decryptKey, myWxid: wxid, resourcesPath, userDataPath, logEnabled, excludeWords } }) const cleanup = () => { diff --git a/electron/services/annualReportService.ts b/electron/services/annualReportService.ts index 88d062e..86a7086 100644 --- a/electron/services/annualReportService.ts +++ b/electron/services/annualReportService.ts @@ -116,7 +116,7 @@ class AnnualReportService { } const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) const cleaned = suffixMatch ? suffixMatch[1] : trimmed - + return cleaned } @@ -499,7 +499,7 @@ class AnnualReportService { } } - this.reportProgress('加载扩展统计... (初始化)', 30, onProgress) + this.reportProgress('加载扩展统计...', 30, onProgress) const extras = await wcdbService.getAnnualReportExtras(sessionIds, actualStartTime, actualEndTime, peakDayBegin, peakDayEnd) if (extras.success && extras.data) { this.reportProgress('加载扩展统计... (解析热力图)', 32, onProgress) diff --git a/electron/services/config.ts b/electron/services/config.ts index b3d988e..7db11c2 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -42,6 +42,7 @@ interface ConfigSchema { notificationPosition: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' notificationFilterMode: 'all' | 'whitelist' | 'blacklist' notificationFilterList: string[] + wordCloudExcludeWords: string[] } export class ConfigService { @@ -94,7 +95,8 @@ export class ConfigService { notificationEnabled: true, notificationPosition: 'top-right', notificationFilterMode: 'all', - notificationFilterList: [] + notificationFilterList: [], + wordCloudExcludeWords: [] } }) } diff --git a/electron/services/dualReportService.ts b/electron/services/dualReportService.ts index 75067ff..83253e2 100644 --- a/electron/services/dualReportService.ts +++ b/electron/services/dualReportService.ts @@ -1,6 +1,7 @@ import { parentPort } from 'worker_threads' import { wcdbService } from './wcdbService' + export interface DualReportMessage { content: string isSentByMe: boolean @@ -58,6 +59,8 @@ export interface DualReportData { } | null stats: DualReportStats topPhrases: Array<{ phrase: string; count: number }> + myExclusivePhrases: Array<{ phrase: string; count: number }> + friendExclusivePhrases: Array<{ phrase: string; count: number }> heatmap?: number[][] initiative?: { initiated: number; received: number } response?: { avg: number; fastest: number; count: number } @@ -499,10 +502,11 @@ class DualReportService { dbPath: string decryptKey: string wxid: string + excludeWords?: string[] onProgress?: (status: string, progress: number) => void }): Promise<{ success: boolean; data?: DualReportData; error?: string }> { try { - const { year, friendUsername, dbPath, decryptKey, wxid, onProgress } = params + const { year, friendUsername, dbPath, decryptKey, wxid, excludeWords, onProgress } = params this.reportProgress('正在连接数据库...', 5, onProgress) const conn = await this.ensureConnectedWithConfig(dbPath, decryptKey, wxid) if (!conn.success || !conn.cleanedWxid || !conn.rawWxid) return { success: false, error: conn.error } @@ -714,11 +718,58 @@ class DualReportService { if (myTopCount >= 0) stats.myTopEmojiCount = myTopCount if (friendTopCount >= 0) stats.friendTopEmojiCount = friendTopCount - const topPhrases = (cppData.phrases || []).map((p: any) => ({ + if (friendTopCount >= 0) stats.friendTopEmojiCount = friendTopCount + + const excludeSet = new Set(excludeWords || []) + + const filterPhrases = (list: any[]) => { + return (list || []).filter((p: any) => !excludeSet.has(p.phrase)) + } + + const cleanPhrases = filterPhrases(cppData.phrases) + const cleanMyPhrases = filterPhrases(cppData.myPhrases) + const cleanFriendPhrases = filterPhrases(cppData.friendPhrases) + + const topPhrases = cleanPhrases.map((p: any) => ({ phrase: p.phrase, count: p.count })) + // 计算专属词汇:一方频繁使用而另一方很少使用的词 + const myPhraseMap = new Map() + const friendPhraseMap = new Map() + for (const p of cleanMyPhrases) { + myPhraseMap.set(p.phrase, p.count) + } + for (const p of cleanFriendPhrases) { + friendPhraseMap.set(p.phrase, p.count) + } + + // 专属词汇:该方使用占比 >= 75% 且至少出现 2 次 + const myExclusivePhrases: Array<{ phrase: string; count: number }> = [] + const friendExclusivePhrases: Array<{ phrase: string; count: number }> = [] + + for (const [phrase, myCount] of myPhraseMap) { + const friendCount = friendPhraseMap.get(phrase) || 0 + const total = myCount + friendCount + if (myCount >= 2 && total > 0 && myCount / total >= 0.75) { + myExclusivePhrases.push({ phrase, count: myCount }) + } + } + for (const [phrase, friendCount] of friendPhraseMap) { + const myCount = myPhraseMap.get(phrase) || 0 + const total = myCount + friendCount + if (friendCount >= 2 && total > 0 && friendCount / total >= 0.75) { + friendExclusivePhrases.push({ phrase, count: friendCount }) + } + } + + // 按频率排序,取前 20 + myExclusivePhrases.sort((a, b) => b.count - a.count) + friendExclusivePhrases.sort((a, b) => b.count - a.count) + if (myExclusivePhrases.length > 20) myExclusivePhrases.length = 20 + if (friendExclusivePhrases.length > 20) friendExclusivePhrases.length = 20 + const reportData: DualReportData = { year: reportYear, selfName: myName, @@ -731,6 +782,8 @@ class DualReportService { yearFirstChat, stats, topPhrases, + myExclusivePhrases, + friendExclusivePhrases, heatmap: cppData.heatmap, initiative: cppData.initiative, response: cppData.response, diff --git a/resources/wcdb_api.dll b/resources/wcdb_api.dll index c689978..cdb8b0f 100644 Binary files a/resources/wcdb_api.dll and b/resources/wcdb_api.dll differ diff --git a/src/components/ReportComponents.scss b/src/components/ReportComponents.scss index 56c4e0e..3793cd0 100644 --- a/src/components/ReportComponents.scss +++ b/src/components/ReportComponents.scss @@ -87,8 +87,8 @@ position: absolute; inset: -6%; background: - radial-gradient(circle at 35% 45%, color-mix(in srgb, var(--primary, #07C160) 12%, transparent), transparent 55%), - radial-gradient(circle at 65% 50%, color-mix(in srgb, var(--accent, #F2AA00) 10%, transparent), transparent 58%), + radial-gradient(circle at 35% 45%, rgba(var(--ar-primary-rgb, 7, 193, 96), 0.12), transparent 55%), + radial-gradient(circle at 65% 50%, rgba(var(--ar-accent-rgb, 242, 170, 0), 0.10), transparent 58%), radial-gradient(circle at 50% 65%, var(--bg-tertiary, rgba(0, 0, 0, 0.04)), transparent 60%); filter: blur(18px); border-radius: 50%; diff --git a/src/pages/AnnualReportWindow.scss b/src/pages/AnnualReportWindow.scss index e6f5632..88c7c88 100644 --- a/src/pages/AnnualReportWindow.scss +++ b/src/pages/AnnualReportWindow.scss @@ -1,7 +1,9 @@ .annual-report-window { // 使用全局主题变量,带回退值 --ar-primary: var(--primary, #07C160); + --ar-primary-rgb: var(--primary-rgb, 7, 193, 96); --ar-accent: var(--accent, #F2AA00); + --ar-accent-rgb: 242, 170, 0; --ar-text-main: var(--text-primary, #222222); --ar-text-sub: var(--text-secondary, #555555); --ar-bg-color: var(--bg-primary, #F9F8F6); @@ -53,7 +55,7 @@ .deco-circle { position: absolute; border-radius: 50%; - background: color-mix(in srgb, var(--primary) 3%, transparent); + background: rgba(var(--ar-primary-rgb), 0.03); backdrop-filter: blur(40px); -webkit-backdrop-filter: blur(40px); border: 1px solid var(--border-color); @@ -254,6 +256,11 @@ background: transparent !important; box-shadow: none !important; } + + .deco-circle { + background: transparent !important; + border: none !important; + } } .section { diff --git a/src/pages/DualReportWindow.scss b/src/pages/DualReportWindow.scss index 8ccff4b..44bee66 100644 --- a/src/pages/DualReportWindow.scss +++ b/src/pages/DualReportWindow.scss @@ -906,4 +906,79 @@ min-width: 56px; } } + + // Word Cloud Tabs + .word-cloud-section { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + } + + .word-cloud-tabs { + display: flex; + gap: 8px; + background: rgba(255, 255, 255, 0.08); + padding: 4px; + border-radius: 12px; + margin: 0 auto 32px; + border: 1px solid rgba(255, 255, 255, 0.1); + } + + .tab-item { + padding: 8px 16px; + border-radius: 8px; + border: none; + background: transparent; + color: var(--ar-text-sub); + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; + + &:hover { + color: var(--ar-text-main); + background: rgba(255, 255, 255, 0.05); + } + + &.active { + background: var(--ar-card-bg); + color: var(--ar-primary); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + font-weight: 600; + } + } + + .word-cloud-container { + width: 100%; + + &.fade-in { + animation: fadeIn 0.4s ease-out; + } + } + + .empty-state { + text-align: center; + padding: 40px 0; + color: var(--ar-text-sub); + opacity: 0.6; + font-size: 14px; + background: rgba(255, 255, 255, 0.03); + border-radius: 16px; + border: 1px dashed rgba(255, 255, 255, 0.1); + margin-top: 20px; + } + + @keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + + to { + opacity: 1; + transform: translateY(0); + } + } } \ No newline at end of file diff --git a/src/pages/DualReportWindow.tsx b/src/pages/DualReportWindow.tsx index 197a8ae..9d155b7 100644 --- a/src/pages/DualReportWindow.tsx +++ b/src/pages/DualReportWindow.tsx @@ -57,6 +57,8 @@ interface DualReportData { friendTopEmojiCount?: number } topPhrases: Array<{ phrase: string; count: number }> + myExclusivePhrases: Array<{ phrase: string; count: number }> + friendExclusivePhrases: Array<{ phrase: string; count: number }> heatmap?: number[][] initiative?: { initiated: number; received: number } response?: { avg: number; fastest: number; slowest: number; count: number } @@ -72,6 +74,7 @@ function DualReportWindow() { const [loadingProgress, setLoadingProgress] = useState(0) const [myEmojiUrl, setMyEmojiUrl] = useState(null) const [friendEmojiUrl, setFriendEmojiUrl] = useState(null) + const [activeWordCloudTab, setActiveWordCloudTab] = useState<'shared' | 'my' | 'friend'>('shared') useEffect(() => { const params = new URLSearchParams(window.location.hash.split('?')[1] || '') @@ -584,10 +587,48 @@ function DualReportWindow() { )} -
+
常用语

{yearTitle}常用语

- + +
+ + + +
+ +
+ {activeWordCloudTab === 'shared' && } + {activeWordCloudTab === 'my' && ( + reportData.myExclusivePhrases && reportData.myExclusivePhrases.length > 0 ? ( + + ) : ( +
暂无专属词汇
+ ) + )} + {activeWordCloudTab === 'friend' && ( + reportData.friendExclusivePhrases && reportData.friendExclusivePhrases.length > 0 ? ( + + ) : ( +
暂无专属词汇
+ ) + )} +
diff --git a/src/pages/SettingsPage.scss b/src/pages/SettingsPage.scss index 5b9503e..eb54f95 100644 --- a/src/pages/SettingsPage.scss +++ b/src/pages/SettingsPage.scss @@ -1279,6 +1279,7 @@ from { opacity: 0; } + to { opacity: 1; } @@ -1289,6 +1290,7 @@ opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); @@ -2097,9 +2099,77 @@ .btn-sm { padding: 4px 10px !important; font-size: 12px !important; - + + svg { width: 14px; height: 14px; } +} + +// Analysis Settings Styling +.settings-section { + h2 { + font-size: 18px; + font-weight: 600; + color: var(--text-primary); + margin: 0 0 16px; + } +} + +.setting-item { + margin-bottom: 20px; +} + +.setting-label { + display: flex; + flex-direction: column; + margin-bottom: 8px; + + span:first-child { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + } + + .setting-desc { + font-size: 13px; + color: var(--text-tertiary); + margin-top: 2px; + } +} + +.setting-control { + display: flex; + + // textarea specific + textarea.form-input { + width: 100%; + padding: 12px; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 12px; + color: var(--text-primary); + font-family: monospace; + font-size: 13px; + resize: vertical; + transition: all 0.2s; + outline: none; + + &:focus { + border-color: var(--primary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 10%, transparent); + } + + &::placeholder { + color: var(--text-tertiary); + } + } + + .button-group { + display: flex; + gap: 12px; + width: 100%; + margin-top: 12px; + } } \ No newline at end of file diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index b6a01d6..860b4f7 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -9,12 +9,12 @@ import { Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy, RotateCcw, Trash2, Plug, Check, Sun, Moon, Palette, Database, Download, HardDrive, Info, RefreshCw, ChevronDown, Mic, - ShieldCheck, Fingerprint, Lock, KeyRound, Bell, Globe + ShieldCheck, Fingerprint, Lock, KeyRound, Bell, Globe, BarChart2 } from 'lucide-react' import { Avatar } from '../components/Avatar' import './SettingsPage.scss' -type SettingsTab = 'appearance' | 'notification' | 'database' | 'models' | 'export' | 'cache' | 'api' | 'security' | 'about' +type SettingsTab = 'appearance' | 'notification' | 'database' | 'models' | 'export' | 'cache' | 'api' | 'security' | 'about' | 'analytics' const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [ { id: 'appearance', label: '外观', icon: Palette }, @@ -24,6 +24,8 @@ const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [ { id: 'export', label: '导出', icon: Download }, { id: 'cache', label: '缓存', icon: HardDrive }, { id: 'api', label: 'API 服务', icon: Globe }, + + { id: 'analytics', label: '分析', icon: BarChart2 }, { id: 'security', label: '安全', icon: ShieldCheck }, { id: 'about', label: '关于', icon: Info } ] @@ -109,6 +111,9 @@ function SettingsPage() { const [filterModeDropdownOpen, setFilterModeDropdownOpen] = useState(false) const [positionDropdownOpen, setPositionDropdownOpen] = useState(false) + const [wordCloudExcludeWords, setWordCloudExcludeWords] = useState([]) + const [excludeWordsInput, setExcludeWordsInput] = useState('') + @@ -302,6 +307,10 @@ function SettingsPage() { setNotificationFilterMode(savedNotificationFilterMode) setNotificationFilterList(savedNotificationFilterList) + const savedExcludeWords = await configService.getWordCloudExcludeWords() + setWordCloudExcludeWords(savedExcludeWords) + setExcludeWordsInput(savedExcludeWords.join('\n')) + // 如果语言列表为空,保存默认值 if (!savedTranscribeLanguages || savedTranscribeLanguages.length === 0) { const defaultLanguages = ['zh'] @@ -1863,13 +1872,13 @@ function SettingsPage() { // HTTP API 服务控制 const handleToggleApi = async () => { if (isTogglingApi) return - + // 启动时显示警告弹窗 if (!httpApiRunning) { setShowApiWarning(true) return } - + setIsTogglingApi(true) try { await window.electronAPI.http.stop() @@ -2053,6 +2062,56 @@ function SettingsPage() { } } + const renderAnalyticsTab = () => ( +
+
+

分析设置

+
+
+ 词云排除词 + 输入不需要在词云和常用语中显示的词语,用换行分隔 +
+
+