feat: 宇宙超级无敌牛且帅气到爆炸的功能更新和优化

This commit is contained in:
cc
2026-02-02 22:01:22 +08:00
committed by xuncha
parent 7b832ac2ef
commit 8664ebf6f5
18 changed files with 1698 additions and 56 deletions

View File

@@ -1017,14 +1017,14 @@ function AnnualReportWindow() {
{midnightKing && (
<section className="section" ref={sectionRefs.midnightKing}>
<div className="label-text"></div>
<h2 className="hero-title"></h2>
<p className="hero-desc"></p>
<h2 className="hero-title"></h2>
<p className="hero-desc"></p>
<div className="big-stat">
<span className="stat-num">{midnightKing.count}</span>
<span className="stat-unit"></span>
</div>
<p className="hero-desc">
<span className="hl">{midnightKing.displayName}</span>
<span className="hl">{midnightKing.displayName}</span>
<br />Ta的对话占你深夜期间聊天的 <span className="gold">{midnightKing.percentage}%</span>
</p>
</section>

View File

@@ -306,18 +306,7 @@ function ChatPage(_props: ChatPageProps) {
const nextSessions = options?.silent ? mergeSessions(sessionsArray) : sessionsArray
// 确保 nextSessions 也是数组
if (Array.isArray(nextSessions)) {
// 【核心优化】检查当前会话是否有更新(通过 lastTimestamp 对比)
const currentId = currentSessionRef.current
if (currentId) {
const newSession = nextSessions.find(s => s.username === currentId)
const oldSession = sessionsRef.current.find(s => s.username === currentId)
// 如果会话存在且时间戳变大(有新消息)或者之前没有该会话
if (newSession && (!oldSession || newSession.lastTimestamp > oldSession.lastTimestamp)) {
console.log(`[Frontend] Detected update for current session ${currentId}, refreshing messages...`)
void handleIncrementalRefresh()
}
}
setSessions(nextSessions)
sessionsRef.current = nextSessions
@@ -657,30 +646,7 @@ function ChatPage(_props: ChatPageProps) {
}
}
// 监听数据库变更实时刷新
useEffect(() => {
const handleDbChange = (_event: any, data: { type: string; json: string }) => {
try {
const payload = JSON.parse(data.json)
const tableName = payload.table
// 会话列表更新(主要靠这个触发,因为 wcdb_api 已经只监控 session 了)
if (tableName === 'Session' || tableName === 'session') {
void loadSessions({ silent: true })
}
} catch (e) {
console.error('解析数据库变更通知失败:', e)
}
}
if (window.electronAPI.chat.onWcdbChange) {
const removeListener = window.electronAPI.chat.onWcdbChange(handleDbChange)
return () => {
removeListener()
}
}
return () => { }
}, [loadSessions, handleRefreshMessages])
// 加载消息
const loadMessages = async (sessionId: string, offset = 0, startTime = 0, endTime = 0) => {

View File

@@ -0,0 +1,54 @@
@keyframes noti-enter {
0% {
opacity: 0;
transform: translateY(-20px) scale(0.96);
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes noti-exit {
0% {
opacity: 1;
transform: scale(1) translateY(0);
filter: blur(0);
}
100% {
opacity: 0;
transform: scale(0.92) translateY(4px);
filter: blur(2px);
}
}
body {
// Ensure the body background is transparent to let the rounded corners show
background: transparent;
overflow: hidden;
margin: 0;
padding: 0;
}
#notification-root {
// Ensure the container allows 3D transforms
perspective: 1000px;
}
#notification-current {
// New notification slides in
animation: noti-enter 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
will-change: transform, opacity;
}
#notification-prev {
// Old notification scales out
animation: noti-exit 0.35s cubic-bezier(0.33, 1, 0.68, 1) forwards;
transform-origin: center top;
will-change: transform, opacity, filter;
// Ensure it stays behind
z-index: 0 !important;
}

View File

@@ -0,0 +1,165 @@
import { useEffect, useState, useRef } from 'react'
import { NotificationToast, type NotificationData } from '../components/NotificationToast'
import '../components/NotificationToast.scss'
import './NotificationWindow.scss'
export default function NotificationWindow() {
const [notification, setNotification] = useState<NotificationData | null>(null)
const [prevNotification, setPrevNotification] = useState<NotificationData | null>(null)
// We need a ref to access the current notification inside the callback
// without satisfying the dependency array which would recreate the listener
// Actually, setNotification(prev => ...) pattern is better, but we need the VALUE of current to set as prev.
// So we use setNotification callback: setNotification(current => { ... return newNode })
// But we need to update TWO states.
// So we use a ref to track "current displayed" for the event handler.
// Or just use functional updates, but we need to setPrev(current).
const notificationRef = useRef<NotificationData | null>(null)
useEffect(() => {
notificationRef.current = notification
}, [notification])
useEffect(() => {
const handleShow = (_event: any, data: any) => {
// data: { title, content, avatarUrl, sessionId }
const timestamp = Math.floor(Date.now() / 1000)
const newNoti: NotificationData = {
id: `noti_${timestamp}_${Math.random().toString(36).substr(2, 9)}`,
sessionId: data.sessionId,
title: data.title,
content: data.content,
timestamp: timestamp,
avatarUrl: data.avatarUrl
}
// Set previous to current (ref)
if (notificationRef.current) {
setPrevNotification(notificationRef.current)
}
setNotification(newNoti)
}
if (window.electronAPI) {
const remove = window.electronAPI.notification?.onShow?.(handleShow)
window.electronAPI.notification?.ready?.()
return () => remove?.()
}
}, [])
// Clean up prevNotification after transition
useEffect(() => {
if (prevNotification) {
const timer = setTimeout(() => {
setPrevNotification(null)
}, 400)
return () => clearTimeout(timer)
}
}, [prevNotification])
const handleClose = () => {
setNotification(null)
setPrevNotification(null)
window.electronAPI.notification?.close()
}
const handleClick = (sessionId: string) => {
window.electronAPI.notification?.click(sessionId)
setNotification(null)
setPrevNotification(null)
// Main process handles window hide/close
}
useEffect(() => {
// Measure only if we have a notification (current or prev)
if (!notification && !prevNotification) return
// Prefer measuring the NEW one
const targetId = notification ? 'notification-current' : 'notification-prev'
const timer = setTimeout(() => {
// Find the wrapper of the content
// Since we wrap them, we should measure the content inside
// But getting root is easier if size is set by relative child
const root = document.getElementById('notification-root')
if (root) {
const height = root.offsetHeight
const width = 344
if (window.electronAPI?.notification?.resize) {
const finalHeight = Math.min(height + 4, 300)
window.electronAPI.notification.resize(width, finalHeight)
}
}
}, 50)
return () => clearTimeout(timer)
}, [notification, prevNotification])
if (!notification && !prevNotification) return null
return (
<div
id="notification-root"
style={{
width: '100vw',
height: 'auto',
minHeight: '10px',
background: 'transparent',
position: 'relative', // Context for absolute children
overflow: 'hidden', // Prevent scrollbars during transition
padding: '2px', // Margin safe
boxSizing: 'border-box'
}}>
{/* Previous Notification (Background / Fading Out) */}
{prevNotification && (
<div
id="notification-prev"
key={prevNotification.id}
style={{
position: 'absolute',
top: 2, // Match padding
left: 2,
width: 'calc(100% - 4px)', // Match width logic
zIndex: 1,
pointerEvents: 'none' // Disable interaction on old one
}}
>
<NotificationToast
key={prevNotification.id}
data={prevNotification}
onClose={() => { }} // No-op for background item
onClick={() => { }}
position="top-right"
isStatic={true}
initialVisible={true}
/>
</div>
)}
{/* Current Notification (Foreground / Fading In) */}
{notification && (
<div
id="notification-current"
key={notification.id}
style={{
position: 'relative', // Takes up space
zIndex: 2,
width: '100%'
}}
>
<NotificationToast
key={notification.id} // Ensure remount for animation
data={notification}
onClose={handleClose}
onClick={handleClick}
position="top-right"
isStatic={true}
initialVisible={true}
/>
</div>
)}
</div>
)
}

View File

@@ -180,7 +180,7 @@
animation: pulse 1.5s ease-in-out infinite;
}
input {
input:not(.filter-search-box input) {
width: 100%;
padding: 10px 16px;
border: 1px solid var(--border-color);
@@ -207,6 +207,7 @@
select {
width: 100%;
padding: 10px 16px;
padding-right: 36px;
border: 1px solid var(--border-color);
border-radius: 9999px;
font-size: 14px;
@@ -214,6 +215,9 @@
color: var(--text-primary);
margin-bottom: 10px;
cursor: pointer;
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
&:focus {
outline: none;
@@ -221,6 +225,124 @@
}
}
.select-wrapper {
position: relative;
margin-bottom: 10px;
select {
margin-bottom: 0;
}
>svg {
position: absolute;
right: 14px;
top: 50%;
transform: translateY(-50%);
color: var(--text-tertiary);
pointer-events: none;
}
}
// 自定义下拉选择框
.custom-select {
position: relative;
margin-bottom: 10px;
}
.custom-select-trigger {
display: flex;
align-items: center;
justify-content: space-between;
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);
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);
}
}
.custom-select-value {
flex: 1;
}
.custom-select-arrow {
color: var(--text-tertiary);
transition: transform 0.2s ease;
&.rotate {
transform: rotate(180deg);
}
}
.custom-select-dropdown {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
background: color-mix(in srgb, var(--bg-primary) 90%, var(--bg-secondary));
border: 1px solid var(--border-color);
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
overflow: hidden;
z-index: 100;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
// 展开收起动画
opacity: 0;
visibility: hidden;
transform: translateY(-8px) scaleY(0.95);
transform-origin: top center;
transition: all 0.2s cubic-bezier(0.2, 0, 0.2, 1);
&.open {
opacity: 1;
visibility: visible;
transform: translateY(0) scaleY(1);
}
}
.custom-select-option {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
font-size: 14px;
color: var(--text-primary);
cursor: pointer;
transition: background 0.15s;
&:hover {
background: var(--bg-tertiary);
}
&.selected {
color: var(--primary);
font-weight: 500;
svg {
color: var(--primary);
}
}
svg {
flex-shrink: 0;
}
}
.select-field {
position: relative;
margin-bottom: 10px;
@@ -1264,4 +1386,173 @@
border-top: 1px solid var(--border-primary);
display: flex;
justify-content: flex-end;
}
// 通知过滤双列表容器
.notification-filter-container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-top: 12px;
}
.filter-panel {
display: flex;
flex-direction: column;
background: var(--bg-tertiary);
border-radius: 12px;
overflow: hidden;
border: 1px solid var(--border-color);
}
.filter-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 12px;
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
background: var(--bg-primary);
border-bottom: 1px solid var(--border-color);
>span {
flex-shrink: 0;
}
}
.filter-search-box {
display: flex;
align-items: center;
gap: 4px;
flex: 1;
max-width: 140px;
padding: 4px 8px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 6px;
transition: all 0.2s;
&:focus-within {
border-color: var(--primary);
background: var(--bg-primary);
}
svg {
flex-shrink: 0;
width: 12px;
height: 12px;
color: var(--text-tertiary);
}
input {
flex: 1;
min-width: 0;
border: none;
background: transparent;
font-size: 12px;
color: var(--text-primary);
outline: none;
&::placeholder {
color: var(--text-tertiary);
}
}
}
.filter-panel-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 6px;
font-size: 12px;
font-weight: 600;
color: white;
background: var(--primary);
border-radius: 10px;
}
.filter-panel-list {
flex: 1;
min-height: 200px;
max-height: 300px;
overflow-y: auto;
padding: 8px;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 3px;
}
}
.filter-panel-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
margin-bottom: 4px;
background: var(--bg-primary);
border-radius: 8px;
cursor: pointer;
transition: all 0.15s;
&:last-child {
margin-bottom: 0;
}
&:hover {
background: var(--bg-secondary);
.filter-item-action {
opacity: 1;
color: var(--primary);
}
}
&.selected {
background: color-mix(in srgb, var(--primary) 8%, var(--bg-primary));
border: 1px solid color-mix(in srgb, var(--primary) 20%, transparent);
&:hover .filter-item-action {
color: var(--danger);
}
}
.filter-item-name {
flex: 1;
font-size: 14px;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.filter-item-action {
font-size: 18px;
font-weight: 500;
color: var(--text-tertiary);
opacity: 0.5;
transition: all 0.15s;
}
}
.filter-panel-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
min-height: 100px;
font-size: 13px;
color: var(--text-tertiary);
}

View File

@@ -9,14 +9,16 @@ 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
ShieldCheck, Fingerprint, Lock, KeyRound, Bell
} from 'lucide-react'
import { Avatar } from '../components/Avatar'
import './SettingsPage.scss'
type SettingsTab = 'appearance' | 'database' | 'whisper' | 'export' | 'cache' | 'security' | 'about'
type SettingsTab = 'appearance' | 'notification' | 'database' | 'whisper' | 'export' | 'cache' | 'security' | 'about'
const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
{ id: 'appearance', label: '外观', icon: Palette },
{ id: 'notification', label: '通知', icon: Bell },
{ id: 'database', label: '数据库连接', icon: Database },
{ id: 'whisper', label: '语音识别模型', icon: Mic },
{ id: 'export', label: '导出', icon: Download },
@@ -25,6 +27,7 @@ const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
{ id: 'about', label: '关于', icon: Info }
]
interface WxidOption {
wxid: string
modifiedTime: number
@@ -83,6 +86,18 @@ function SettingsPage() {
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
const [exportDefaultConcurrency, setExportDefaultConcurrency] = useState(2)
const [notificationEnabled, setNotificationEnabled] = useState(true)
const [notificationPosition, setNotificationPosition] = useState<'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'>('top-right')
const [notificationFilterMode, setNotificationFilterMode] = useState<'all' | 'whitelist' | 'blacklist'>('all')
const [notificationFilterList, setNotificationFilterList] = useState<string[]>([])
const [filterSearchKeyword, setFilterSearchKeyword] = useState('')
const [filterModeDropdownOpen, setFilterModeDropdownOpen] = useState(false)
const [positionDropdownOpen, setPositionDropdownOpen] = useState(false)
const [isLoading, setIsLoadingState] = useState(false)
const [isTesting, setIsTesting] = useState(false)
const [isDetectingPath, setIsDetectingPath] = useState(false)
@@ -167,6 +182,24 @@ function SettingsPage() {
}
}, [])
// 点击外部关闭自定义下拉框
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement
if (!target.closest('.custom-select')) {
setFilterModeDropdownOpen(false)
setPositionDropdownOpen(false)
}
}
if (filterModeDropdownOpen || positionDropdownOpen) {
document.addEventListener('click', handleClickOutside)
}
return () => {
document.removeEventListener('click', handleClickOutside)
}
}, [filterModeDropdownOpen, positionDropdownOpen])
const loadConfig = async () => {
try {
const savedKey = await configService.getDecryptKey()
@@ -188,6 +221,11 @@ function SettingsPage() {
const savedExportDefaultExcelCompactColumns = await configService.getExportDefaultExcelCompactColumns()
const savedExportDefaultConcurrency = await configService.getExportDefaultConcurrency()
const savedNotificationEnabled = await configService.getNotificationEnabled()
const savedNotificationPosition = await configService.getNotificationPosition()
const savedNotificationFilterMode = await configService.getNotificationFilterMode()
const savedNotificationFilterList = await configService.getNotificationFilterList()
const savedAuthEnabled = await configService.getAuthEnabled()
const savedAuthUseHello = await configService.getAuthUseHello()
setAuthEnabled(savedAuthEnabled)
@@ -221,6 +259,11 @@ function SettingsPage() {
setExportDefaultExcelCompactColumns(savedExportDefaultExcelCompactColumns ?? true)
setExportDefaultConcurrency(savedExportDefaultConcurrency ?? 2)
setNotificationEnabled(savedNotificationEnabled)
setNotificationPosition(savedNotificationPosition)
setNotificationFilterMode(savedNotificationFilterMode)
setNotificationFilterList(savedNotificationFilterList)
// 如果语言列表为空,保存默认值
if (!savedTranscribeLanguages || savedTranscribeLanguages.length === 0) {
const defaultLanguages = ['zh']
@@ -842,6 +885,245 @@ function SettingsPage() {
</div>
)
const renderNotificationTab = () => {
const { sessions } = useChatStore.getState()
// 获取已过滤会话的信息
const getSessionInfo = (username: string) => {
const session = sessions.find(s => s.username === username)
return {
displayName: session?.displayName || username,
avatarUrl: session?.avatarUrl || ''
}
}
// 添加会话到过滤列表
const handleAddToFilterList = async (username: string) => {
if (notificationFilterList.includes(username)) return
const newList = [...notificationFilterList, username]
setNotificationFilterList(newList)
await configService.setNotificationFilterList(newList)
showMessage('已添加到过滤列表', true)
}
// 从过滤列表移除会话
const handleRemoveFromFilterList = async (username: string) => {
const newList = notificationFilterList.filter(u => u !== username)
setNotificationFilterList(newList)
await configService.setNotificationFilterList(newList)
showMessage('已从过滤列表移除', true)
}
// 过滤掉已在列表中的会话,并根据搜索关键字过滤
const availableSessions = sessions.filter(s => {
if (notificationFilterList.includes(s.username)) return false
if (filterSearchKeyword) {
const keyword = filterSearchKeyword.toLowerCase()
const displayName = (s.displayName || '').toLowerCase()
const username = s.username.toLowerCase()
return displayName.includes(keyword) || username.includes(keyword)
}
return true
})
return (
<div className="tab-content">
<div className="form-group">
<label></label>
<span className="form-hint"></span>
<div className="log-toggle-line">
<span className="log-status">{notificationEnabled ? '已开启' : '已关闭'}</span>
<label className="switch" htmlFor="notification-enabled-toggle">
<input
id="notification-enabled-toggle"
className="switch-input"
type="checkbox"
checked={notificationEnabled}
onChange={async (e) => {
const val = e.target.checked
setNotificationEnabled(val)
await configService.setNotificationEnabled(val)
showMessage(val ? '已开启通知' : '已关闭通知', true)
}}
/>
<span className="switch-slider" />
</label>
</div>
</div>
<div className="form-group">
<label></label>
<span className="form-hint"></span>
<div className="custom-select">
<div
className={`custom-select-trigger ${positionDropdownOpen ? 'open' : ''}`}
onClick={() => setPositionDropdownOpen(!positionDropdownOpen)}
>
<span className="custom-select-value">
{notificationPosition === 'top-right' ? '右上角' :
notificationPosition === 'bottom-right' ? '右下角' :
notificationPosition === 'top-left' ? '左上角' : '左下角'}
</span>
<ChevronDown size={14} className={`custom-select-arrow ${positionDropdownOpen ? 'rotate' : ''}`} />
</div>
<div className={`custom-select-dropdown ${positionDropdownOpen ? 'open' : ''}`}>
{[
{ value: 'top-right', label: '右上角' },
{ value: 'bottom-right', label: '右下角' },
{ value: 'top-left', label: '左上角' },
{ value: 'bottom-left', label: '左下角' }
].map(option => (
<div
key={option.value}
className={`custom-select-option ${notificationPosition === option.value ? 'selected' : ''}`}
onClick={async () => {
const val = option.value as 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'
setNotificationPosition(val)
setPositionDropdownOpen(false)
await configService.setNotificationPosition(val)
showMessage('通知位置已更新', true)
}}
>
{option.label}
{notificationPosition === option.value && <Check size={14} />}
</div>
))}
</div>
</div>
</div>
<div className="form-group">
<label></label>
<span className="form-hint"></span>
<div className="custom-select">
<div
className={`custom-select-trigger ${filterModeDropdownOpen ? 'open' : ''}`}
onClick={() => setFilterModeDropdownOpen(!filterModeDropdownOpen)}
>
<span className="custom-select-value">
{notificationFilterMode === 'all' ? '接收所有通知' :
notificationFilterMode === 'whitelist' ? '仅接收白名单' : '屏蔽黑名单'}
</span>
<ChevronDown size={14} className={`custom-select-arrow ${filterModeDropdownOpen ? 'rotate' : ''}`} />
</div>
<div className={`custom-select-dropdown ${filterModeDropdownOpen ? 'open' : ''}`}>
{[
{ value: 'all', label: '接收所有通知' },
{ value: 'whitelist', label: '仅接收白名单' },
{ value: 'blacklist', label: '屏蔽黑名单' }
].map(option => (
<div
key={option.value}
className={`custom-select-option ${notificationFilterMode === option.value ? 'selected' : ''}`}
onClick={async () => {
const val = option.value as 'all' | 'whitelist' | 'blacklist'
setNotificationFilterMode(val)
setFilterModeDropdownOpen(false)
await configService.setNotificationFilterMode(val)
showMessage(
val === 'all' ? '已设为接收所有通知' :
val === 'whitelist' ? '已设为仅接收白名单通知' : '已设为屏蔽黑名单通知',
true
)
}}
>
{option.label}
{notificationFilterMode === option.value && <Check size={14} />}
</div>
))}
</div>
</div>
</div>
{notificationFilterMode !== 'all' && (
<div className="form-group">
<label>{notificationFilterMode === 'whitelist' ? '白名单会话' : '黑名单会话'}</label>
<span className="form-hint">
{notificationFilterMode === 'whitelist'
? '点击左侧会话添加到白名单,点击右侧会话从白名单移除'
: '点击左侧会话添加到黑名单,点击右侧会话从黑名单移除'}
</span>
<div className="notification-filter-container">
{/* 可选会话列表 */}
<div className="filter-panel">
<div className="filter-panel-header">
<span></span>
<div className="filter-search-box">
<Search size={14} />
<input
type="text"
placeholder="搜索会话..."
value={filterSearchKeyword}
onChange={(e) => setFilterSearchKeyword(e.target.value)}
/>
</div>
</div>
<div className="filter-panel-list">
{availableSessions.length > 0 ? (
availableSessions.map(session => (
<div
key={session.username}
className="filter-panel-item"
onClick={() => handleAddToFilterList(session.username)}
>
<Avatar
src={session.avatarUrl}
name={session.displayName || session.username}
size={28}
/>
<span className="filter-item-name">{session.displayName || session.username}</span>
<span className="filter-item-action">+</span>
</div>
))
) : (
<div className="filter-panel-empty">
{filterSearchKeyword ? '没有匹配的会话' : '暂无可添加的会话'}
</div>
)}
</div>
</div>
{/* 已选会话列表 */}
<div className="filter-panel">
<div className="filter-panel-header">
<span>{notificationFilterMode === 'whitelist' ? '白名单' : '黑名单'}</span>
{notificationFilterList.length > 0 && (
<span className="filter-panel-count">{notificationFilterList.length}</span>
)}
</div>
<div className="filter-panel-list">
{notificationFilterList.length > 0 ? (
notificationFilterList.map(username => {
const info = getSessionInfo(username)
return (
<div
key={username}
className="filter-panel-item selected"
onClick={() => handleRemoveFromFilterList(username)}
>
<Avatar
src={info.avatarUrl}
name={info.displayName}
size={28}
/>
<span className="filter-item-name">{info.displayName}</span>
<span className="filter-item-action">×</span>
</div>
)
})
) : (
<div className="filter-panel-empty"></div>
)}
</div>
</div>
</div>
</div>
)}
</div>
)
}
const renderDatabaseTab = () => (
<div className="tab-content">
<div className="form-group">
@@ -1674,6 +1956,7 @@ function SettingsPage() {
<div className="settings-body">
{activeTab === 'appearance' && renderAppearanceTab()}
{activeTab === 'notification' && renderNotificationTab()}
{activeTab === 'database' && renderDatabaseTab()}
{activeTab === 'whisper' && renderWhisperTab()}
{activeTab === 'export' && renderExportTab()}