mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-24 23:06:51 +00:00
Merge pull request #260 from hicccc77/dev
解决年度报告导出失败 #252;集成WechatVisualization的功能并支持词云排除 #259
This commit is contained in:
@@ -11,6 +11,7 @@ interface WorkerConfig {
|
|||||||
resourcesPath?: string
|
resourcesPath?: string
|
||||||
userDataPath?: string
|
userDataPath?: string
|
||||||
logEnabled?: boolean
|
logEnabled?: boolean
|
||||||
|
excludeWords?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = workerData as WorkerConfig
|
const config = workerData as WorkerConfig
|
||||||
@@ -29,6 +30,7 @@ async function run() {
|
|||||||
dbPath: config.dbPath,
|
dbPath: config.dbPath,
|
||||||
decryptKey: config.decryptKey,
|
decryptKey: config.decryptKey,
|
||||||
wxid: config.myWxid,
|
wxid: config.myWxid,
|
||||||
|
excludeWords: config.excludeWords,
|
||||||
onProgress: (status: string, progress: number) => {
|
onProgress: (status: string, progress: number) => {
|
||||||
parentPort?.postMessage({
|
parentPort?.postMessage({
|
||||||
type: 'dualReport:progress',
|
type: 'dualReport:progress',
|
||||||
|
|||||||
@@ -1195,6 +1195,7 @@ function registerIpcHandlers() {
|
|||||||
const logEnabled = cfg.get('logEnabled')
|
const logEnabled = cfg.get('logEnabled')
|
||||||
const friendUsername = payload?.friendUsername
|
const friendUsername = payload?.friendUsername
|
||||||
const year = payload?.year ?? 0
|
const year = payload?.year ?? 0
|
||||||
|
const excludeWords = cfg.get('wordCloudExcludeWords') || []
|
||||||
|
|
||||||
if (!friendUsername) {
|
if (!friendUsername) {
|
||||||
return { success: false, error: '缺少好友用户名' }
|
return { success: false, error: '缺少好友用户名' }
|
||||||
@@ -1209,7 +1210,7 @@ function registerIpcHandlers() {
|
|||||||
|
|
||||||
return await new Promise((resolve) => {
|
return await new Promise((resolve) => {
|
||||||
const worker = new Worker(workerPath, {
|
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 = () => {
|
const cleanup = () => {
|
||||||
|
|||||||
@@ -499,7 +499,7 @@ class AnnualReportService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.reportProgress('加载扩展统计... (初始化)', 30, onProgress)
|
this.reportProgress('加载扩展统计...', 30, onProgress)
|
||||||
const extras = await wcdbService.getAnnualReportExtras(sessionIds, actualStartTime, actualEndTime, peakDayBegin, peakDayEnd)
|
const extras = await wcdbService.getAnnualReportExtras(sessionIds, actualStartTime, actualEndTime, peakDayBegin, peakDayEnd)
|
||||||
if (extras.success && extras.data) {
|
if (extras.success && extras.data) {
|
||||||
this.reportProgress('加载扩展统计... (解析热力图)', 32, onProgress)
|
this.reportProgress('加载扩展统计... (解析热力图)', 32, onProgress)
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ interface ConfigSchema {
|
|||||||
notificationPosition: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'
|
notificationPosition: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'
|
||||||
notificationFilterMode: 'all' | 'whitelist' | 'blacklist'
|
notificationFilterMode: 'all' | 'whitelist' | 'blacklist'
|
||||||
notificationFilterList: string[]
|
notificationFilterList: string[]
|
||||||
|
wordCloudExcludeWords: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ConfigService {
|
export class ConfigService {
|
||||||
@@ -94,7 +95,8 @@ export class ConfigService {
|
|||||||
notificationEnabled: true,
|
notificationEnabled: true,
|
||||||
notificationPosition: 'top-right',
|
notificationPosition: 'top-right',
|
||||||
notificationFilterMode: 'all',
|
notificationFilterMode: 'all',
|
||||||
notificationFilterList: []
|
notificationFilterList: [],
|
||||||
|
wordCloudExcludeWords: []
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { parentPort } from 'worker_threads'
|
import { parentPort } from 'worker_threads'
|
||||||
import { wcdbService } from './wcdbService'
|
import { wcdbService } from './wcdbService'
|
||||||
|
|
||||||
|
|
||||||
export interface DualReportMessage {
|
export interface DualReportMessage {
|
||||||
content: string
|
content: string
|
||||||
isSentByMe: boolean
|
isSentByMe: boolean
|
||||||
@@ -58,6 +59,8 @@ export interface DualReportData {
|
|||||||
} | null
|
} | null
|
||||||
stats: DualReportStats
|
stats: DualReportStats
|
||||||
topPhrases: Array<{ phrase: string; count: number }>
|
topPhrases: Array<{ phrase: string; count: number }>
|
||||||
|
myExclusivePhrases: Array<{ phrase: string; count: number }>
|
||||||
|
friendExclusivePhrases: Array<{ phrase: string; count: number }>
|
||||||
heatmap?: number[][]
|
heatmap?: number[][]
|
||||||
initiative?: { initiated: number; received: number }
|
initiative?: { initiated: number; received: number }
|
||||||
response?: { avg: number; fastest: number; count: number }
|
response?: { avg: number; fastest: number; count: number }
|
||||||
@@ -499,10 +502,11 @@ class DualReportService {
|
|||||||
dbPath: string
|
dbPath: string
|
||||||
decryptKey: string
|
decryptKey: string
|
||||||
wxid: string
|
wxid: string
|
||||||
|
excludeWords?: string[]
|
||||||
onProgress?: (status: string, progress: number) => void
|
onProgress?: (status: string, progress: number) => void
|
||||||
}): Promise<{ success: boolean; data?: DualReportData; error?: string }> {
|
}): Promise<{ success: boolean; data?: DualReportData; error?: string }> {
|
||||||
try {
|
try {
|
||||||
const { year, friendUsername, dbPath, decryptKey, wxid, onProgress } = params
|
const { year, friendUsername, dbPath, decryptKey, wxid, excludeWords, onProgress } = params
|
||||||
this.reportProgress('正在连接数据库...', 5, onProgress)
|
this.reportProgress('正在连接数据库...', 5, onProgress)
|
||||||
const conn = await this.ensureConnectedWithConfig(dbPath, decryptKey, wxid)
|
const conn = await this.ensureConnectedWithConfig(dbPath, decryptKey, wxid)
|
||||||
if (!conn.success || !conn.cleanedWxid || !conn.rawWxid) return { success: false, error: conn.error }
|
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 (myTopCount >= 0) stats.myTopEmojiCount = myTopCount
|
||||||
if (friendTopCount >= 0) stats.friendTopEmojiCount = friendTopCount
|
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,
|
phrase: p.phrase,
|
||||||
count: p.count
|
count: p.count
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// 计算专属词汇:一方频繁使用而另一方很少使用的词
|
||||||
|
const myPhraseMap = new Map<string, number>()
|
||||||
|
const friendPhraseMap = new Map<string, number>()
|
||||||
|
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 = {
|
const reportData: DualReportData = {
|
||||||
year: reportYear,
|
year: reportYear,
|
||||||
selfName: myName,
|
selfName: myName,
|
||||||
@@ -731,6 +782,8 @@ class DualReportService {
|
|||||||
yearFirstChat,
|
yearFirstChat,
|
||||||
stats,
|
stats,
|
||||||
topPhrases,
|
topPhrases,
|
||||||
|
myExclusivePhrases,
|
||||||
|
friendExclusivePhrases,
|
||||||
heatmap: cppData.heatmap,
|
heatmap: cppData.heatmap,
|
||||||
initiative: cppData.initiative,
|
initiative: cppData.initiative,
|
||||||
response: cppData.response,
|
response: cppData.response,
|
||||||
|
|||||||
Binary file not shown.
@@ -87,8 +87,8 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
inset: -6%;
|
inset: -6%;
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at 35% 45%, color-mix(in srgb, var(--primary, #07C160) 12%, transparent), transparent 55%),
|
radial-gradient(circle at 35% 45%, rgba(var(--ar-primary-rgb, 7, 193, 96), 0.12), transparent 55%),
|
||||||
radial-gradient(circle at 65% 50%, color-mix(in srgb, var(--accent, #F2AA00) 10%, transparent), transparent 58%),
|
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%);
|
radial-gradient(circle at 50% 65%, var(--bg-tertiary, rgba(0, 0, 0, 0.04)), transparent 60%);
|
||||||
filter: blur(18px);
|
filter: blur(18px);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
.annual-report-window {
|
.annual-report-window {
|
||||||
// 使用全局主题变量,带回退值
|
// 使用全局主题变量,带回退值
|
||||||
--ar-primary: var(--primary, #07C160);
|
--ar-primary: var(--primary, #07C160);
|
||||||
|
--ar-primary-rgb: var(--primary-rgb, 7, 193, 96);
|
||||||
--ar-accent: var(--accent, #F2AA00);
|
--ar-accent: var(--accent, #F2AA00);
|
||||||
|
--ar-accent-rgb: 242, 170, 0;
|
||||||
--ar-text-main: var(--text-primary, #222222);
|
--ar-text-main: var(--text-primary, #222222);
|
||||||
--ar-text-sub: var(--text-secondary, #555555);
|
--ar-text-sub: var(--text-secondary, #555555);
|
||||||
--ar-bg-color: var(--bg-primary, #F9F8F6);
|
--ar-bg-color: var(--bg-primary, #F9F8F6);
|
||||||
@@ -53,7 +55,7 @@
|
|||||||
.deco-circle {
|
.deco-circle {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: color-mix(in srgb, var(--primary) 3%, transparent);
|
background: rgba(var(--ar-primary-rgb), 0.03);
|
||||||
backdrop-filter: blur(40px);
|
backdrop-filter: blur(40px);
|
||||||
-webkit-backdrop-filter: blur(40px);
|
-webkit-backdrop-filter: blur(40px);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
@@ -254,6 +256,11 @@
|
|||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
box-shadow: none !important;
|
box-shadow: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.deco-circle {
|
||||||
|
background: transparent !important;
|
||||||
|
border: none !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.section {
|
.section {
|
||||||
|
|||||||
@@ -906,4 +906,79 @@
|
|||||||
min-width: 56px;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -57,6 +57,8 @@ interface DualReportData {
|
|||||||
friendTopEmojiCount?: number
|
friendTopEmojiCount?: number
|
||||||
}
|
}
|
||||||
topPhrases: Array<{ phrase: string; count: number }>
|
topPhrases: Array<{ phrase: string; count: number }>
|
||||||
|
myExclusivePhrases: Array<{ phrase: string; count: number }>
|
||||||
|
friendExclusivePhrases: Array<{ phrase: string; count: number }>
|
||||||
heatmap?: number[][]
|
heatmap?: number[][]
|
||||||
initiative?: { initiated: number; received: number }
|
initiative?: { initiated: number; received: number }
|
||||||
response?: { avg: number; fastest: number; slowest: number; count: number }
|
response?: { avg: number; fastest: number; slowest: number; count: number }
|
||||||
@@ -72,6 +74,7 @@ function DualReportWindow() {
|
|||||||
const [loadingProgress, setLoadingProgress] = useState(0)
|
const [loadingProgress, setLoadingProgress] = useState(0)
|
||||||
const [myEmojiUrl, setMyEmojiUrl] = useState<string | null>(null)
|
const [myEmojiUrl, setMyEmojiUrl] = useState<string | null>(null)
|
||||||
const [friendEmojiUrl, setFriendEmojiUrl] = useState<string | null>(null)
|
const [friendEmojiUrl, setFriendEmojiUrl] = useState<string | null>(null)
|
||||||
|
const [activeWordCloudTab, setActiveWordCloudTab] = useState<'shared' | 'my' | 'friend'>('shared')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
|
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
|
||||||
@@ -584,10 +587,48 @@ function DualReportWindow() {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<section className="section">
|
<section className="section word-cloud-section">
|
||||||
<div className="label-text">常用语</div>
|
<div className="label-text">常用语</div>
|
||||||
<h2 className="hero-title">{yearTitle}常用语</h2>
|
<h2 className="hero-title">{yearTitle}常用语</h2>
|
||||||
<ReportWordCloud words={reportData.topPhrases} />
|
|
||||||
|
<div className="word-cloud-tabs">
|
||||||
|
<button
|
||||||
|
className={`tab-item ${activeWordCloudTab === 'shared' ? 'active' : ''}`}
|
||||||
|
onClick={() => setActiveWordCloudTab('shared')}
|
||||||
|
>
|
||||||
|
共用词汇
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`tab-item ${activeWordCloudTab === 'my' ? 'active' : ''}`}
|
||||||
|
onClick={() => setActiveWordCloudTab('my')}
|
||||||
|
>
|
||||||
|
我的专属
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`tab-item ${activeWordCloudTab === 'friend' ? 'active' : ''}`}
|
||||||
|
onClick={() => setActiveWordCloudTab('friend')}
|
||||||
|
>
|
||||||
|
TA的专属
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`word-cloud-container fade-in ${activeWordCloudTab}`}>
|
||||||
|
{activeWordCloudTab === 'shared' && <ReportWordCloud words={reportData.topPhrases} />}
|
||||||
|
{activeWordCloudTab === 'my' && (
|
||||||
|
reportData.myExclusivePhrases && reportData.myExclusivePhrases.length > 0 ? (
|
||||||
|
<ReportWordCloud words={reportData.myExclusivePhrases} />
|
||||||
|
) : (
|
||||||
|
<div className="empty-state">暂无专属词汇</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{activeWordCloudTab === 'friend' && (
|
||||||
|
reportData.friendExclusivePhrases && reportData.friendExclusivePhrases.length > 0 ? (
|
||||||
|
<ReportWordCloud words={reportData.friendExclusivePhrases} />
|
||||||
|
) : (
|
||||||
|
<div className="empty-state">暂无专属词汇</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="section">
|
<section className="section">
|
||||||
|
|||||||
@@ -1279,6 +1279,7 @@
|
|||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
@@ -1289,6 +1290,7 @@
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(10px);
|
transform: translateY(10px);
|
||||||
}
|
}
|
||||||
|
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
@@ -2098,8 +2100,76 @@
|
|||||||
padding: 4px 10px !important;
|
padding: 4px 10px !important;
|
||||||
font-size: 12px !important;
|
font-size: 12px !important;
|
||||||
|
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
width: 14px;
|
width: 14px;
|
||||||
height: 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,12 +9,12 @@ import {
|
|||||||
Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy,
|
Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy,
|
||||||
RotateCcw, Trash2, Plug, Check, Sun, Moon,
|
RotateCcw, Trash2, Plug, Check, Sun, Moon,
|
||||||
Palette, Database, Download, HardDrive, Info, RefreshCw, ChevronDown, Mic,
|
Palette, Database, Download, HardDrive, Info, RefreshCw, ChevronDown, Mic,
|
||||||
ShieldCheck, Fingerprint, Lock, KeyRound, Bell, Globe
|
ShieldCheck, Fingerprint, Lock, KeyRound, Bell, Globe, BarChart2
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Avatar } from '../components/Avatar'
|
import { Avatar } from '../components/Avatar'
|
||||||
import './SettingsPage.scss'
|
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 }[] = [
|
const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
|
||||||
{ id: 'appearance', label: '外观', icon: Palette },
|
{ id: 'appearance', label: '外观', icon: Palette },
|
||||||
@@ -24,6 +24,8 @@ const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
|
|||||||
{ id: 'export', label: '导出', icon: Download },
|
{ id: 'export', label: '导出', icon: Download },
|
||||||
{ id: 'cache', label: '缓存', icon: HardDrive },
|
{ id: 'cache', label: '缓存', icon: HardDrive },
|
||||||
{ id: 'api', label: 'API 服务', icon: Globe },
|
{ id: 'api', label: 'API 服务', icon: Globe },
|
||||||
|
|
||||||
|
{ id: 'analytics', label: '分析', icon: BarChart2 },
|
||||||
{ id: 'security', label: '安全', icon: ShieldCheck },
|
{ id: 'security', label: '安全', icon: ShieldCheck },
|
||||||
{ id: 'about', label: '关于', icon: Info }
|
{ id: 'about', label: '关于', icon: Info }
|
||||||
]
|
]
|
||||||
@@ -109,6 +111,9 @@ function SettingsPage() {
|
|||||||
const [filterModeDropdownOpen, setFilterModeDropdownOpen] = useState(false)
|
const [filterModeDropdownOpen, setFilterModeDropdownOpen] = useState(false)
|
||||||
const [positionDropdownOpen, setPositionDropdownOpen] = useState(false)
|
const [positionDropdownOpen, setPositionDropdownOpen] = useState(false)
|
||||||
|
|
||||||
|
const [wordCloudExcludeWords, setWordCloudExcludeWords] = useState<string[]>([])
|
||||||
|
const [excludeWordsInput, setExcludeWordsInput] = useState('')
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -302,6 +307,10 @@ function SettingsPage() {
|
|||||||
setNotificationFilterMode(savedNotificationFilterMode)
|
setNotificationFilterMode(savedNotificationFilterMode)
|
||||||
setNotificationFilterList(savedNotificationFilterList)
|
setNotificationFilterList(savedNotificationFilterList)
|
||||||
|
|
||||||
|
const savedExcludeWords = await configService.getWordCloudExcludeWords()
|
||||||
|
setWordCloudExcludeWords(savedExcludeWords)
|
||||||
|
setExcludeWordsInput(savedExcludeWords.join('\n'))
|
||||||
|
|
||||||
// 如果语言列表为空,保存默认值
|
// 如果语言列表为空,保存默认值
|
||||||
if (!savedTranscribeLanguages || savedTranscribeLanguages.length === 0) {
|
if (!savedTranscribeLanguages || savedTranscribeLanguages.length === 0) {
|
||||||
const defaultLanguages = ['zh']
|
const defaultLanguages = ['zh']
|
||||||
@@ -2053,6 +2062,56 @@ function SettingsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const renderAnalyticsTab = () => (
|
||||||
|
<div className="tab-content">
|
||||||
|
<div className="settings-section">
|
||||||
|
<h2>分析设置</h2>
|
||||||
|
<div className="setting-item">
|
||||||
|
<div className="setting-label">
|
||||||
|
<span>词云排除词</span>
|
||||||
|
<span className="setting-desc">输入不需要在词云和常用语中显示的词语,用换行分隔</span>
|
||||||
|
</div>
|
||||||
|
<div className="setting-control" style={{ flexDirection: 'column', alignItems: 'flex-start', gap: '8px' }}>
|
||||||
|
<textarea
|
||||||
|
className="form-input"
|
||||||
|
style={{ width: '100%', height: '200px', fontFamily: 'monospace' }}
|
||||||
|
value={excludeWordsInput}
|
||||||
|
onChange={(e) => setExcludeWordsInput(e.target.value)}
|
||||||
|
placeholder="例如:
|
||||||
|
第一个词
|
||||||
|
第二个词
|
||||||
|
第三个词"
|
||||||
|
/>
|
||||||
|
<div className="button-group">
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={async () => {
|
||||||
|
const words = excludeWordsInput.split('\n').map(w => w.trim()).filter(w => w.length > 0)
|
||||||
|
// 去重
|
||||||
|
const uniqueWords = Array.from(new Set(words))
|
||||||
|
await configService.setWordCloudExcludeWords(uniqueWords)
|
||||||
|
setWordCloudExcludeWords(uniqueWords)
|
||||||
|
setExcludeWordsInput(uniqueWords.join('\n'))
|
||||||
|
// Show success toast or feedback if needed (optional)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
保存排除列表
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setExcludeWordsInput(wordCloudExcludeWords.join('\n'))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
重置
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
const renderSecurityTab = () => (
|
const renderSecurityTab = () => (
|
||||||
<div className="tab-content">
|
<div className="tab-content">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
@@ -2242,6 +2301,7 @@ function SettingsPage() {
|
|||||||
{activeTab === 'export' && renderExportTab()}
|
{activeTab === 'export' && renderExportTab()}
|
||||||
{activeTab === 'cache' && renderCacheTab()}
|
{activeTab === 'cache' && renderCacheTab()}
|
||||||
{activeTab === 'api' && renderApiTab()}
|
{activeTab === 'api' && renderApiTab()}
|
||||||
|
{activeTab === 'analytics' && renderAnalyticsTab()}
|
||||||
{activeTab === 'security' && renderSecurityTab()}
|
{activeTab === 'security' && renderSecurityTab()}
|
||||||
{activeTab === 'about' && renderAboutTab()}
|
{activeTab === 'about' && renderAboutTab()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -44,7 +44,10 @@ export const CONFIG_KEYS = {
|
|||||||
NOTIFICATION_ENABLED: 'notificationEnabled',
|
NOTIFICATION_ENABLED: 'notificationEnabled',
|
||||||
NOTIFICATION_POSITION: 'notificationPosition',
|
NOTIFICATION_POSITION: 'notificationPosition',
|
||||||
NOTIFICATION_FILTER_MODE: 'notificationFilterMode',
|
NOTIFICATION_FILTER_MODE: 'notificationFilterMode',
|
||||||
NOTIFICATION_FILTER_LIST: 'notificationFilterList'
|
NOTIFICATION_FILTER_LIST: 'notificationFilterList',
|
||||||
|
|
||||||
|
// 词云
|
||||||
|
WORD_CLOUD_EXCLUDE_WORDS: 'wordCloudExcludeWords'
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export interface WxidConfig {
|
export interface WxidConfig {
|
||||||
@@ -465,3 +468,14 @@ export async function getNotificationFilterList(): Promise<string[]> {
|
|||||||
export async function setNotificationFilterList(list: string[]): Promise<void> {
|
export async function setNotificationFilterList(list: string[]): Promise<void> {
|
||||||
await config.set(CONFIG_KEYS.NOTIFICATION_FILTER_LIST, list)
|
await config.set(CONFIG_KEYS.NOTIFICATION_FILTER_LIST, list)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取词云排除词列表
|
||||||
|
export async function getWordCloudExcludeWords(): Promise<string[]> {
|
||||||
|
const value = await config.get(CONFIG_KEYS.WORD_CLOUD_EXCLUDE_WORDS)
|
||||||
|
return Array.isArray(value) ? value : []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置词云排除词列表
|
||||||
|
export async function setWordCloudExcludeWords(words: string[]): Promise<void> {
|
||||||
|
await config.set(CONFIG_KEYS.WORD_CLOUD_EXCLUDE_WORDS, words)
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@
|
|||||||
[data-theme="cloud-dancer"][data-mode="light"],
|
[data-theme="cloud-dancer"][data-mode="light"],
|
||||||
[data-theme="cloud-dancer"]:not([data-mode]) {
|
[data-theme="cloud-dancer"]:not([data-mode]) {
|
||||||
--primary: #8B7355;
|
--primary: #8B7355;
|
||||||
|
--primary-rgb: 139, 115, 85;
|
||||||
--primary-hover: #7A6548;
|
--primary-hover: #7A6548;
|
||||||
--primary-light: rgba(139, 115, 85, 0.1);
|
--primary-light: rgba(139, 115, 85, 0.1);
|
||||||
--bg-primary: #F0EEE9;
|
--bg-primary: #F0EEE9;
|
||||||
@@ -64,6 +65,7 @@
|
|||||||
[data-theme="corundum-blue"][data-mode="light"],
|
[data-theme="corundum-blue"][data-mode="light"],
|
||||||
[data-theme="corundum-blue"]:not([data-mode]) {
|
[data-theme="corundum-blue"]:not([data-mode]) {
|
||||||
--primary: #4A6670;
|
--primary: #4A6670;
|
||||||
|
--primary-rgb: 74, 102, 112;
|
||||||
--primary-hover: #3D565E;
|
--primary-hover: #3D565E;
|
||||||
--primary-light: rgba(74, 102, 112, 0.1);
|
--primary-light: rgba(74, 102, 112, 0.1);
|
||||||
--bg-primary: #E8EEF0;
|
--bg-primary: #E8EEF0;
|
||||||
@@ -83,6 +85,7 @@
|
|||||||
[data-theme="kiwi-green"][data-mode="light"],
|
[data-theme="kiwi-green"][data-mode="light"],
|
||||||
[data-theme="kiwi-green"]:not([data-mode]) {
|
[data-theme="kiwi-green"]:not([data-mode]) {
|
||||||
--primary: #7A9A5C;
|
--primary: #7A9A5C;
|
||||||
|
--primary-rgb: 122, 154, 92;
|
||||||
--primary-hover: #6A8A4C;
|
--primary-hover: #6A8A4C;
|
||||||
--primary-light: rgba(122, 154, 92, 0.1);
|
--primary-light: rgba(122, 154, 92, 0.1);
|
||||||
--bg-primary: #E8F0E4;
|
--bg-primary: #E8F0E4;
|
||||||
@@ -102,6 +105,7 @@
|
|||||||
[data-theme="spicy-red"][data-mode="light"],
|
[data-theme="spicy-red"][data-mode="light"],
|
||||||
[data-theme="spicy-red"]:not([data-mode]) {
|
[data-theme="spicy-red"]:not([data-mode]) {
|
||||||
--primary: #8B4049;
|
--primary: #8B4049;
|
||||||
|
--primary-rgb: 139, 64, 73;
|
||||||
--primary-hover: #7A3540;
|
--primary-hover: #7A3540;
|
||||||
--primary-light: rgba(139, 64, 73, 0.1);
|
--primary-light: rgba(139, 64, 73, 0.1);
|
||||||
--bg-primary: #F0E8E8;
|
--bg-primary: #F0E8E8;
|
||||||
@@ -121,6 +125,7 @@
|
|||||||
[data-theme="teal-water"][data-mode="light"],
|
[data-theme="teal-water"][data-mode="light"],
|
||||||
[data-theme="teal-water"]:not([data-mode]) {
|
[data-theme="teal-water"]:not([data-mode]) {
|
||||||
--primary: #5A8A8A;
|
--primary: #5A8A8A;
|
||||||
|
--primary-rgb: 90, 138, 138;
|
||||||
--primary-hover: #4A7A7A;
|
--primary-hover: #4A7A7A;
|
||||||
--primary-light: rgba(90, 138, 138, 0.1);
|
--primary-light: rgba(90, 138, 138, 0.1);
|
||||||
--bg-primary: #E4F0F0;
|
--bg-primary: #E4F0F0;
|
||||||
@@ -141,6 +146,7 @@
|
|||||||
// 云上舞白 - 深色
|
// 云上舞白 - 深色
|
||||||
[data-theme="cloud-dancer"][data-mode="dark"] {
|
[data-theme="cloud-dancer"][data-mode="dark"] {
|
||||||
--primary: #C9A86C;
|
--primary: #C9A86C;
|
||||||
|
--primary-rgb: 201, 168, 108;
|
||||||
--primary-hover: #D9B87C;
|
--primary-hover: #D9B87C;
|
||||||
--primary-light: rgba(201, 168, 108, 0.15);
|
--primary-light: rgba(201, 168, 108, 0.15);
|
||||||
--bg-primary: #1a1816;
|
--bg-primary: #1a1816;
|
||||||
@@ -159,6 +165,7 @@
|
|||||||
// 刚玉蓝 - 深色
|
// 刚玉蓝 - 深色
|
||||||
[data-theme="corundum-blue"][data-mode="dark"] {
|
[data-theme="corundum-blue"][data-mode="dark"] {
|
||||||
--primary: #6A9AAA;
|
--primary: #6A9AAA;
|
||||||
|
--primary-rgb: 106, 154, 170;
|
||||||
--primary-hover: #7AAABA;
|
--primary-hover: #7AAABA;
|
||||||
--primary-light: rgba(106, 154, 170, 0.15);
|
--primary-light: rgba(106, 154, 170, 0.15);
|
||||||
--bg-primary: #141a1c;
|
--bg-primary: #141a1c;
|
||||||
@@ -177,6 +184,7 @@
|
|||||||
// 冰猕猴桃汁绿 - 深色
|
// 冰猕猴桃汁绿 - 深色
|
||||||
[data-theme="kiwi-green"][data-mode="dark"] {
|
[data-theme="kiwi-green"][data-mode="dark"] {
|
||||||
--primary: #9ABA7C;
|
--primary: #9ABA7C;
|
||||||
|
--primary-rgb: 154, 186, 124;
|
||||||
--primary-hover: #AACA8C;
|
--primary-hover: #AACA8C;
|
||||||
--primary-light: rgba(154, 186, 124, 0.15);
|
--primary-light: rgba(154, 186, 124, 0.15);
|
||||||
--bg-primary: #161a14;
|
--bg-primary: #161a14;
|
||||||
@@ -195,6 +203,7 @@
|
|||||||
// 辛辣红 - 深色
|
// 辛辣红 - 深色
|
||||||
[data-theme="spicy-red"][data-mode="dark"] {
|
[data-theme="spicy-red"][data-mode="dark"] {
|
||||||
--primary: #C06068;
|
--primary: #C06068;
|
||||||
|
--primary-rgb: 192, 96, 104;
|
||||||
--primary-hover: #D07078;
|
--primary-hover: #D07078;
|
||||||
--primary-light: rgba(192, 96, 104, 0.15);
|
--primary-light: rgba(192, 96, 104, 0.15);
|
||||||
--bg-primary: #1a1416;
|
--bg-primary: #1a1416;
|
||||||
@@ -213,6 +222,7 @@
|
|||||||
// 明水鸭色 - 深色
|
// 明水鸭色 - 深色
|
||||||
[data-theme="teal-water"][data-mode="dark"] {
|
[data-theme="teal-water"][data-mode="dark"] {
|
||||||
--primary: #7ABAAA;
|
--primary: #7ABAAA;
|
||||||
|
--primary-rgb: 122, 186, 170;
|
||||||
--primary-hover: #8ACABA;
|
--primary-hover: #8ACABA;
|
||||||
--primary-light: rgba(122, 186, 170, 0.15);
|
--primary-light: rgba(122, 186, 170, 0.15);
|
||||||
--bg-primary: #121a1a;
|
--bg-primary: #121a1a;
|
||||||
|
|||||||
7
src/types/electron.d.ts
vendored
7
src/types/electron.d.ts
vendored
@@ -397,17 +397,16 @@ export interface ElectronAPI {
|
|||||||
myTopEmojiMd5?: string
|
myTopEmojiMd5?: string
|
||||||
friendTopEmojiMd5?: string
|
friendTopEmojiMd5?: string
|
||||||
myTopEmojiUrl?: string
|
myTopEmojiUrl?: string
|
||||||
friendTopEmojiUrl?: string
|
|
||||||
myTopEmojiCount?: number
|
|
||||||
friendTopEmojiCount?: number
|
|
||||||
}
|
|
||||||
topPhrases: Array<{ phrase: string; count: number }>
|
topPhrases: Array<{ phrase: string; count: number }>
|
||||||
|
myExclusivePhrases: Array<{ phrase: string; count: number }>
|
||||||
|
friendExclusivePhrases: Array<{ phrase: string; count: number }>
|
||||||
heatmap?: number[][]
|
heatmap?: number[][]
|
||||||
initiative?: { initiated: number; received: number }
|
initiative?: { initiated: number; received: number }
|
||||||
response?: { avg: number; fastest: number; count: number }
|
response?: { avg: number; fastest: number; count: number }
|
||||||
monthly?: Record<string, number>
|
monthly?: Record<string, number>
|
||||||
streak?: { days: number; startDate: string; endDate: string }
|
streak?: { days: number; startDate: string; endDate: string }
|
||||||
}
|
}
|
||||||
|
}
|
||||||
error?: string
|
error?: string
|
||||||
}>
|
}>
|
||||||
onProgress: (callback: (payload: { status: string; progress: number }) => void) => () => void
|
onProgress: (callback: (payload: { status: string; progress: number }) => void) => () => void
|
||||||
|
|||||||
Reference in New Issue
Block a user