一个简单的安卓岛

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

@@ -96,8 +96,8 @@ export function GlobalSessionMonitor() {
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
// 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 {
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.16) !important;
}

View File

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

View File

@@ -1,5 +1,5 @@
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 { createPortal } from 'react-dom'
import { useChatStore } from '../stores/chatStore'
@@ -377,7 +377,7 @@ const SessionItem = React.memo(function SessionItem({
return (
<div
className={`session-item ${isActive ? 'active' : ''} ${session.isMuted ? 'muted' : ''}`}
className={`session-item ${isActive ? 'active' : ''}`}
onClick={() => onSelect(session)}
>
<Avatar
@@ -394,9 +394,8 @@ const SessionItem = React.memo(function SessionItem({
<div className="session-bottom">
<span className="session-summary">{session.summary || '暂无消息'}</span>
<div className="session-badges">
{session.isMuted && <BellOff size={12} className="mute-icon" />}
{session.unreadCount > 0 && (
<span className={`unread-badge ${session.isMuted ? 'muted' : ''}`}>
<span className="unread-badge">
{session.unreadCount > 99 ? '99+' : session.unreadCount}
</span>
)}
@@ -414,7 +413,6 @@ const SessionItem = React.memo(function SessionItem({
prevProps.session.unreadCount === nextProps.session.unreadCount &&
prevProps.session.lastTimestamp === nextProps.session.lastTimestamp &&
prevProps.session.sortTimestamp === nextProps.session.sortTimestamp &&
prevProps.session.isMuted === nextProps.session.isMuted &&
prevProps.isActive === nextProps.isActive
)
})
@@ -1898,16 +1896,14 @@ function ChatPage(props: ChatPageProps) {
if (!status) return session
const nextIsFolded = status.isFolded ?? session.isFolded
const nextIsMuted = status.isMuted ?? session.isMuted
if (nextIsFolded === session.isFolded && nextIsMuted === session.isMuted) {
if (nextIsFolded === session.isFolded) {
return session
}
hasChanges = true
return {
...session,
isFolded: nextIsFolded,
isMuted: nextIsMuted
isFolded: nextIsFolded
}
})

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 {
0% {
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 {
// Ensure the body background is transparent to let the rounded corners show
background: transparent;
@@ -41,6 +65,10 @@ body {
// New notification slides in
animation: noti-enter 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
will-change: transform, opacity;
&.anim-center {
animation: noti-enter-center 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
}
#notification-prev {
@@ -51,4 +79,8 @@ body {
// Ensure it stays behind
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() {
const [notification, setNotification] = 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
// 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 })
@@ -34,6 +35,11 @@ export default function NotificationWindow() {
avatarUrl: data.avatarUrl
}
// 获取位置配置
if (data.position) {
setPosition(data.position)
}
// Set previous to current (ref)
if (notificationRef.current) {
setPrevNotification(notificationRef.current)
@@ -117,6 +123,7 @@ export default function NotificationWindow() {
<div
id="notification-prev"
key={prevNotification.id}
className={position === 'top-center' ? 'anim-center' : ''}
style={{
position: 'absolute',
top: 2, // Match padding
@@ -131,7 +138,7 @@ export default function NotificationWindow() {
data={prevNotification}
onClose={() => { }} // No-op for background item
onClick={() => { }}
position="top-right"
position={position as any}
isStatic={true}
initialVisible={true}
/>
@@ -143,6 +150,7 @@ export default function NotificationWindow() {
<div
id="notification-current"
key={notification.id}
className={position === 'top-center' ? 'anim-center' : ''}
style={{
position: 'relative', // Takes up space
zIndex: 2,
@@ -154,7 +162,7 @@ export default function NotificationWindow() {
data={notification}
onClose={handleClose}
onClick={handleClick}
position="top-right"
position={position as any}
isStatic={true}
initialVisible={true}
/>

View File

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

View File

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