一个简单的安卓岛

This commit is contained in:
cc
2026-03-15 11:42:41 +08:00
parent 998b2ce3d7
commit d6b95036b5
10 changed files with 83 additions and 22 deletions

View File

@@ -47,7 +47,7 @@ interface ConfigSchema {
// 通知 // 通知
notificationEnabled: boolean notificationEnabled: boolean
notificationPosition: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' notificationPosition: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center'
notificationFilterMode: 'all' | 'whitelist' | 'blacklist' notificationFilterMode: 'all' | 'whitelist' | 'blacklist'
notificationFilterList: string[] notificationFilterList: string[]
wordCloudExcludeWords: string[] wordCloudExcludeWords: string[]

View File

@@ -132,7 +132,7 @@ async function showAndSend(win: BrowserWindow, data: any) {
// 更新位置 // 更新位置
const { width: screenWidth, height: screenHeight } = screen.getPrimaryDisplay().workAreaSize const { width: screenWidth, height: screenHeight } = screen.getPrimaryDisplay().workAreaSize
const winWidth = 344 const winWidth = position === 'top-center' ? 280 : 344
const winHeight = 114 const winHeight = 114
const padding = 20 const padding = 20
@@ -140,6 +140,10 @@ async function showAndSend(win: BrowserWindow, data: any) {
let y = 0 let y = 0
switch (position) { switch (position) {
case 'top-center':
x = (screenWidth - winWidth) / 2
y = padding
break
case 'top-right': case 'top-right':
x = screenWidth - winWidth - padding x = screenWidth - winWidth - padding
y = padding y = padding
@@ -166,7 +170,7 @@ async function showAndSend(win: BrowserWindow, data: any) {
win.showInactive() // 显示但不聚焦 win.showInactive() // 显示但不聚焦
win.setAlwaysOnTop(true, 'screen-saver') // 最高层级 win.setAlwaysOnTop(true, 'screen-saver') // 最高层级
win.webContents.send('notification:show', data) win.webContents.send('notification:show', { ...data, position })
// 自动关闭计时器通常由渲染进程管理 // 自动关闭计时器通常由渲染进程管理
// 渲染进程发送 'notification:close' 来隐藏窗口 // 渲染进程发送 'notification:close' 来隐藏窗口

View File

@@ -96,8 +96,8 @@ export function GlobalSessionMonitor() {
if (!isCurrentSession && (!oldSession || newSession.lastTimestamp > oldSession.lastTimestamp)) { if (!isCurrentSession && (!oldSession || newSession.lastTimestamp > oldSession.lastTimestamp)) {
// 这是新消息事件 // 这是新消息事件
// 免打扰、折叠群、折叠入口不弹通知 // 折叠群、折叠入口不弹通知
if (newSession.isMuted || newSession.isFolded) continue if (newSession.isFolded) continue
if (newSession.username.toLowerCase().includes('placeholder_foldgroup')) continue if (newSession.username.toLowerCase().includes('placeholder_foldgroup')) continue
// 1. 群聊过滤自己发送的消息 // 1. 群聊过滤自己发送的消息

View File

@@ -134,6 +134,25 @@
} }
} }
&.top-center {
top: 24px;
left: 50%;
transform: translate(-50%, -20px) scale(0.95);
&.visible {
transform: translate(-50%, 0) scale(1);
}
// 灵动岛样式
border-radius: 40px !important;
padding: 12px 16px;
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.2);
&.static {
border-radius: 40px !important;
}
}
&:hover { &:hover {
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.16) !important; box-shadow: 0 12px 48px rgba(0, 0, 0, 0.16) !important;
} }

View File

@@ -18,7 +18,7 @@ interface NotificationToastProps {
onClose: () => void onClose: () => void
onClick: (sessionId: string) => void onClick: (sessionId: string) => void
duration?: number duration?: number
position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center'
isStatic?: boolean isStatic?: boolean
initialVisible?: boolean initialVisible?: boolean
} }

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react' import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed, UserCheck, Crown, Aperture } from 'lucide-react' import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, Users, FolderClosed, UserCheck, Crown, Aperture } from 'lucide-react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { useChatStore } from '../stores/chatStore' import { useChatStore } from '../stores/chatStore'
@@ -377,7 +377,7 @@ const SessionItem = React.memo(function SessionItem({
return ( return (
<div <div
className={`session-item ${isActive ? 'active' : ''} ${session.isMuted ? 'muted' : ''}`} className={`session-item ${isActive ? 'active' : ''}`}
onClick={() => onSelect(session)} onClick={() => onSelect(session)}
> >
<Avatar <Avatar
@@ -394,9 +394,8 @@ const SessionItem = React.memo(function SessionItem({
<div className="session-bottom"> <div className="session-bottom">
<span className="session-summary">{session.summary || '暂无消息'}</span> <span className="session-summary">{session.summary || '暂无消息'}</span>
<div className="session-badges"> <div className="session-badges">
{session.isMuted && <BellOff size={12} className="mute-icon" />}
{session.unreadCount > 0 && ( {session.unreadCount > 0 && (
<span className={`unread-badge ${session.isMuted ? 'muted' : ''}`}> <span className="unread-badge">
{session.unreadCount > 99 ? '99+' : session.unreadCount} {session.unreadCount > 99 ? '99+' : session.unreadCount}
</span> </span>
)} )}
@@ -414,7 +413,6 @@ const SessionItem = React.memo(function SessionItem({
prevProps.session.unreadCount === nextProps.session.unreadCount && prevProps.session.unreadCount === nextProps.session.unreadCount &&
prevProps.session.lastTimestamp === nextProps.session.lastTimestamp && prevProps.session.lastTimestamp === nextProps.session.lastTimestamp &&
prevProps.session.sortTimestamp === nextProps.session.sortTimestamp && prevProps.session.sortTimestamp === nextProps.session.sortTimestamp &&
prevProps.session.isMuted === nextProps.session.isMuted &&
prevProps.isActive === nextProps.isActive prevProps.isActive === nextProps.isActive
) )
}) })
@@ -1898,16 +1896,14 @@ function ChatPage(props: ChatPageProps) {
if (!status) return session if (!status) return session
const nextIsFolded = status.isFolded ?? session.isFolded const nextIsFolded = status.isFolded ?? session.isFolded
const nextIsMuted = status.isMuted ?? session.isMuted if (nextIsFolded === session.isFolded) {
if (nextIsFolded === session.isFolded && nextIsMuted === session.isMuted) {
return session return session
} }
hasChanges = true hasChanges = true
return { return {
...session, ...session,
isFolded: nextIsFolded, isFolded: nextIsFolded
isMuted: nextIsMuted
} }
}) })

View File

@@ -10,6 +10,18 @@
} }
} }
@keyframes noti-enter-center {
0% {
opacity: 0;
transform: translateY(-50px) scale(0.7);
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes noti-exit { @keyframes noti-exit {
0% { 0% {
opacity: 1; opacity: 1;
@@ -24,6 +36,18 @@
} }
} }
@keyframes noti-exit-center {
0% {
opacity: 1;
transform: translateY(0) scale(1);
}
100% {
opacity: 0;
transform: translateY(-50px) scale(0.7);
}
}
body { body {
// Ensure the body background is transparent to let the rounded corners show // Ensure the body background is transparent to let the rounded corners show
background: transparent; background: transparent;
@@ -41,6 +65,10 @@ body {
// New notification slides in // New notification slides in
animation: noti-enter 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards; animation: noti-enter 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
will-change: transform, opacity; will-change: transform, opacity;
&.anim-center {
animation: noti-enter-center 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
} }
#notification-prev { #notification-prev {
@@ -51,4 +79,8 @@ body {
// Ensure it stays behind // Ensure it stays behind
z-index: 0 !important; z-index: 0 !important;
&.anim-center {
animation: noti-exit-center 0.5s cubic-bezier(0.33, 1, 0.68, 1) forwards;
}
} }

View File

@@ -6,8 +6,9 @@ import './NotificationWindow.scss'
export default function NotificationWindow() { export default function NotificationWindow() {
const [notification, setNotification] = useState<NotificationData | null>(null) const [notification, setNotification] = useState<NotificationData | null>(null)
const [prevNotification, setPrevNotification] = useState<NotificationData | null>(null) const [prevNotification, setPrevNotification] = useState<NotificationData | null>(null)
const [position, setPosition] = useState<string>('top-right')
// We need a ref to access the current notification inside the callback // We need a ref to access the current notification inside the callback
// without satisfying the dependency array which would recreate the listener // 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. // 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 }) // So we use setNotification callback: setNotification(current => { ... return newNode })
@@ -34,6 +35,11 @@ export default function NotificationWindow() {
avatarUrl: data.avatarUrl avatarUrl: data.avatarUrl
} }
// 获取位置配置
if (data.position) {
setPosition(data.position)
}
// Set previous to current (ref) // Set previous to current (ref)
if (notificationRef.current) { if (notificationRef.current) {
setPrevNotification(notificationRef.current) setPrevNotification(notificationRef.current)
@@ -117,6 +123,7 @@ export default function NotificationWindow() {
<div <div
id="notification-prev" id="notification-prev"
key={prevNotification.id} key={prevNotification.id}
className={position === 'top-center' ? 'anim-center' : ''}
style={{ style={{
position: 'absolute', position: 'absolute',
top: 2, // Match padding top: 2, // Match padding
@@ -131,7 +138,7 @@ export default function NotificationWindow() {
data={prevNotification} data={prevNotification}
onClose={() => { }} // No-op for background item onClose={() => { }} // No-op for background item
onClick={() => { }} onClick={() => { }}
position="top-right" position={position as any}
isStatic={true} isStatic={true}
initialVisible={true} initialVisible={true}
/> />
@@ -143,6 +150,7 @@ export default function NotificationWindow() {
<div <div
id="notification-current" id="notification-current"
key={notification.id} key={notification.id}
className={position === 'top-center' ? 'anim-center' : ''}
style={{ style={{
position: 'relative', // Takes up space position: 'relative', // Takes up space
zIndex: 2, zIndex: 2,
@@ -154,7 +162,7 @@ export default function NotificationWindow() {
data={notification} data={notification}
onClose={handleClose} onClose={handleClose}
onClick={handleClick} onClick={handleClick}
position="top-right" position={position as any}
isStatic={true} isStatic={true}
initialVisible={true} initialVisible={true}
/> />

View File

@@ -1,6 +1,6 @@
.settings-modal-overlay { .settings-modal-overlay {
position: fixed; position: fixed;
top: 41px; top: 0;
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;

View File

@@ -102,7 +102,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
const [transcribeLanguages, setTranscribeLanguages] = useState<string[]>(['zh']) const [transcribeLanguages, setTranscribeLanguages] = useState<string[]>(['zh'])
const [notificationEnabled, setNotificationEnabled] = useState(true) const [notificationEnabled, setNotificationEnabled] = useState(true)
const [notificationPosition, setNotificationPosition] = useState<'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'>('top-right') const [notificationPosition, setNotificationPosition] = useState<'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center'>('top-right')
const [notificationFilterMode, setNotificationFilterMode] = useState<'all' | 'whitelist' | 'blacklist'>('all') const [notificationFilterMode, setNotificationFilterMode] = useState<'all' | 'whitelist' | 'blacklist'>('all')
const [notificationFilterList, setNotificationFilterList] = useState<string[]>([]) const [notificationFilterList, setNotificationFilterList] = useState<string[]>([])
const [filterSearchKeyword, setFilterSearchKeyword] = useState('') const [filterSearchKeyword, setFilterSearchKeyword] = useState('')
@@ -1102,12 +1102,14 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
<span className="custom-select-value"> <span className="custom-select-value">
{notificationPosition === 'top-right' ? '右上角' : {notificationPosition === 'top-right' ? '右上角' :
notificationPosition === 'bottom-right' ? '右下角' : notificationPosition === 'bottom-right' ? '右下角' :
notificationPosition === 'top-left' ? '左上角' : '左下角'} notificationPosition === 'top-left' ? '左上角' :
notificationPosition === 'top-center' ? '中间上方' : '左下角'}
</span> </span>
<ChevronDown size={14} className={`custom-select-arrow ${positionDropdownOpen ? 'rotate' : ''}`} /> <ChevronDown size={14} className={`custom-select-arrow ${positionDropdownOpen ? 'rotate' : ''}`} />
</div> </div>
<div className={`custom-select-dropdown ${positionDropdownOpen ? 'open' : ''}`}> <div className={`custom-select-dropdown ${positionDropdownOpen ? 'open' : ''}`}>
{[ {[
{ value: 'top-center', label: '中间上方' },
{ value: 'top-right', label: '右上角' }, { value: 'top-right', label: '右上角' },
{ value: 'bottom-right', label: '右下角' }, { value: 'bottom-right', label: '右下角' },
{ value: 'top-left', label: '左上角' }, { value: 'top-left', label: '左上角' },
@@ -1117,7 +1119,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
key={option.value} key={option.value}
className={`custom-select-option ${notificationPosition === option.value ? 'selected' : ''}`} className={`custom-select-option ${notificationPosition === option.value ? 'selected' : ''}`}
onClick={async () => { onClick={async () => {
const val = option.value as 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' const val = option.value as 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center'
setNotificationPosition(val) setNotificationPosition(val)
setPositionDropdownOpen(false) setPositionDropdownOpen(false)
await configService.setNotificationPosition(val) await configService.setNotificationPosition(val)