mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 07:16:51 +00:00
feat: 宇宙超级无敌牛且帅气到爆炸的功能更新和优化
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
54
src/pages/NotificationWindow.scss
Normal file
54
src/pages/NotificationWindow.scss
Normal 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;
|
||||
}
|
||||
165
src/pages/NotificationWindow.tsx
Normal file
165
src/pages/NotificationWindow.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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()}
|
||||
|
||||
Reference in New Issue
Block a user