综合更新

This commit is contained in:
cc committed 2026-07-06 18:09:09 +08:00
1 parent d733c21145
commit 147f3b7045
57 files changed
+3036 -798

No files matched your search

+4
View File
@@ -31,6 +31,10 @@ out
release
tmp/
# electron-liquid-glass 是嵌套的独立 git 仓库(发布到 npm,WeFlow 走 registry 依赖),
# 不能被外层仓库跟踪(会变成悬空 gitlink)
/packages/electron-liquid-glass/
# Database
*.db
*.db-shm
+1 -1
View File
@@ -685,7 +685,7 @@ Body 示例:
}
```
`format` 支持:`json`、`html`、`arkmejson`(兼容写法:`arkme-json`)。
`format` 支持:`json`、`html`、`arkmejson`(兼容写法:`arkme-json`)、`markdown`(兼容写法:`md`)。
### 7.6 朋友圈防删开关
+59 -97
View File
@@ -39,6 +39,12 @@ import { bizService } from './services/bizService'
import { backupService } from './services/backupService'
import { imageDownloadService } from './services/imageDownloadService'
// 屏幕采集去节流(仅影响通知玻璃的 Chromium 流回退管线;Windows 主路径为
// 原生面板渲染,不经过 Chromium 采集):默认桌面采集 CPU 预算限制在 50%,
// 会自适应压低采集帧率、显著增加回退路径下折射的跟随延迟。
// 回退采集只在通知展示的数秒内运行且分辨率已降为逻辑尺寸,放开预算不影响常态性能
app.commandLine.appendSwitch('webrtc-max-cpu-consumption-percentage', '100')
// 配置自动更新
autoUpdater.autoDownload = false
autoUpdater.autoInstallOnAppQuit = true
@@ -1268,12 +1274,14 @@ function createSplashWindow(): BrowserWindow {
const splashUrl = new URL('splash.html', process.env.VITE_DEV_SERVER_URL)
splashUrl.searchParams.set('themeId', splashThemeId)
splashUrl.searchParams.set('themeMode', splashThemeMode)
splashUrl.searchParams.set('version', app.getVersion())
splashWindow.loadURL(splashUrl.toString())
} else {
splashWindow.loadFile(join(__dirname, '../dist/splash.html'), {
query: {
themeId: splashThemeId,
themeMode: splashThemeMode
themeMode: splashThemeMode,
version: app.getVersion()
}
})
}
@@ -4553,10 +4561,10 @@ app.whenReady().then(async () => {
shouldShowMain = onboardingDone
if (!startInBackground) {
// 非静默模式下显示 Splash,提供启动反馈
// 非静默模式下显示 Splash,提供启动反馈(主题/版本号通过 URL 参数传入)
createSplashWindow()
// 等待 Splash 页面加载完成后再推送进度
// 等待 Splash 页面加载完成,确保后续 executeJavaScript 推送的进度可靠送达
if (splashWindow) {
await new Promise<void>((resolve) => {
if (splashWindow!.webContents.isLoading()) {
@@ -4565,13 +4573,9 @@ app.whenReady().then(async () => {
resolve()
}
})
splashWindow.webContents
.executeJavaScript(`setVersion(${JSON.stringify(app.getVersion())})`)
.catch(() => {})
}
}
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
const withTimeout = <T>(task: () => Promise<T>, timeoutMs: number): Promise<{ timedOut: boolean; value?: T; error?: string }> => {
return new Promise((resolve) => {
let settled = false
@@ -4597,112 +4601,29 @@ app.whenReady().then(async () => {
})
}
updateSplashProgress(5, '正在加载配置...')
// 将用户主题配置推送给 Splash 窗口
if (splashWindow && !splashWindow.isDestroyed()) {
const themeId = configService.get('themeId') || 'cloud-dancer'
const themeMode = configService.get('theme') || 'system'
splashWindow.webContents
.executeJavaScript(`applyTheme(${JSON.stringify(themeId)}, ${JSON.stringify(themeMode)})`)
.catch(() => {})
}
await delay(200)
// 设置资源路径
updateSplashProgress(12, '正在初始化...')
// 基础初始化均为毫秒级操作,直接顺序执行
updateSplashProgress(10, '正在初始化...')
const candidateResources = app.isPackaged
? join(process.resourcesPath, 'resources')
: join(app.getAppPath(), 'resources')
const fallbackResources = join(process.cwd(), 'resources')
const resourcesPath = existsSync(candidateResources) ? candidateResources : fallbackResources
const userDataPath = app.getPath('userData')
await delay(200)
// 初始化数据库服务
updateSplashProgress(20, '正在初始化...')
wcdbService.setPaths(resourcesPath, userDataPath)
wcdbService.setLogEnabled(configService.get('logEnabled') === true)
await delay(200)
// 注册 IPC 处理器
updateSplashProgress(28, '正在初始化...')
registerIpcHandlers()
if (configService.get('autoDownloadHighRes')) {
const whitelistArr = configService.get('autoDownloadWhitelist') || []
const whitelistStr = (Array.isArray(whitelistArr) && whitelistArr.length > 0)
? (whitelistArr.join('\0') + '\0\0')
: ''
imageDownloadService.startAutoDownload(whitelistStr)
}
chatService.addDbMonitorListener((type, json) => {
messagePushService.handleDbMonitorChange(type, json)
insightService.handleDbMonitorChange(type, json)
})
messagePushService.start()
insightService.start()
groupSummaryService.start()
await delay(200)
// 已完成引导时,在 Splash 阶段预热核心数据(联系人、消息库索引等)
if (onboardingDone) {
updateSplashProgress(34, '正在连接数据库...')
const connectWarmup = await withTimeout(() => chatService.connect(), 12000)
const connected = !connectWarmup.timedOut && connectWarmup.value?.success === true
if (!connected) {
const reason = connectWarmup.timedOut
? connectWarmup.error
: (connectWarmup.value?.error || connectWarmup.error || 'unknown')
console.warn('[StartupWarmup] 跳过预热,数据库连接失败:', reason)
updateSplashProgress(68, '数据库预热已跳过')
} else {
const preloadUsernames = new Set<string>()
updateSplashProgress(44, '正在预加载会话...')
const sessionsWarmup = await withTimeout(() => chatService.getSessions(), 12000)
if (!sessionsWarmup.timedOut && sessionsWarmup.value?.success && Array.isArray(sessionsWarmup.value.sessions)) {
for (const session of sessionsWarmup.value.sessions) {
const username = String((session as any)?.username || '').trim()
if (username) preloadUsernames.add(username)
}
}
updateSplashProgress(56, '正在预加载联系人...')
const contactsWarmup = await withTimeout(() => chatService.getContacts(), 15000)
if (!contactsWarmup.timedOut && contactsWarmup.value?.success && Array.isArray(contactsWarmup.value.contacts)) {
for (const contact of contactsWarmup.value.contacts) {
const username = String((contact as any)?.username || '').trim()
if (username) preloadUsernames.add(username)
}
}
updateSplashProgress(63, '正在缓存联系人头像...')
const avatarWarmupUsernames = Array.from(preloadUsernames).slice(0, 2000)
if (avatarWarmupUsernames.length > 0) {
await withTimeout(() => chatService.enrichSessionsContactInfo(avatarWarmupUsernames), 15000)
}
updateSplashProgress(68, '正在初始化消息库索引...')
await withTimeout(() => chatService.warmupMessageDbSnapshot(), 10000)
updateSplashProgress(69, '正在预加载导出统计...')
if (avatarWarmupUsernames.length > 0) {
await withTimeout(() => chatService.getExportSessionStats(avatarWarmupUsernames, { includeRelations: false }), 8000)
}
}
} else {
updateSplashProgress(68, '首次启动准备中...')
}
// 创建主窗口(不显示,由启动流程统一控制)
updateSplashProgress(70, '正在准备主窗口...')
// 提前创建主窗口(隐藏),让渲染进程加载与数据库预热并行进行
updateSplashProgress(20, '正在准备主窗口...')
ensureWeChatRequestHeaderInterceptor()
mainWindow = createWindow({ autoShow: false })
const resolvedTrayIcon = resolveAppIconPath()
try {
tray = new Tray(resolvedTrayIcon)
tray.setToolTip('WeFlow')
@@ -4746,8 +4667,38 @@ app.whenReady().then(async () => {
console.warn('[Tray] Failed to create tray icon:', e)
}
// 等待主窗口加载完成(真正耗时阶段,进度条末端呼吸光点)
updateSplashProgress(70, '正在准备主窗口...', true)
// 已完成引导时,与渲染进程加载并行预热会话列表和联系人名字/头像缓存
if (onboardingDone) {
updateSplashProgress(35, '正在连接数据库...')
const connectWarmup = await withTimeout(() => chatService.connect(), 12000)
const connected = !connectWarmup.timedOut && connectWarmup.value?.success === true
if (!connected) {
const reason = connectWarmup.timedOut
? connectWarmup.error
: (connectWarmup.value?.error || connectWarmup.error || 'unknown')
console.warn('[StartupWarmup] 跳过预热,数据库连接失败:', reason)
} else {
updateSplashProgress(55, '正在预加载会话...')
const preloadUsernames = new Set<string>()
const sessionsWarmup = await withTimeout(() => chatService.getSessions(), 12000)
if (!sessionsWarmup.timedOut && sessionsWarmup.value?.success && Array.isArray(sessionsWarmup.value.sessions)) {
for (const session of sessionsWarmup.value.sessions) {
const username = String((session as any)?.username || '').trim()
if (username) preloadUsernames.add(username)
}
}
updateSplashProgress(75, '正在缓存联系人头像...')
const avatarWarmupUsernames = Array.from(preloadUsernames).slice(0, 2000)
if (avatarWarmupUsernames.length > 0) {
await withTimeout(() => chatService.enrichSessionsContactInfo(avatarWarmupUsernames), 15000)
}
}
}
// 等待主窗口加载完成(进度条末端呼吸光点)
updateSplashProgress(90, '正在准备主窗口...', true)
await new Promise<void>((resolve) => {
if (mainWindowReady) {
resolve()
@@ -4761,7 +4712,6 @@ app.whenReady().then(async () => {
// 加载完成,收尾
updateSplashProgress(100, '启动完成')
await new Promise((resolve) => setTimeout(resolve, 250))
closeSplash()
if (!onboardingDone) {
@@ -4772,6 +4722,18 @@ app.whenReady().then(async () => {
mainWindow?.show()
}
// 依赖数据库的后台服务在窗口显示后再启动,避免与启动预热争抢数据库 worker
messagePushService.start()
insightService.start()
groupSummaryService.start()
if (configService.get('autoDownloadHighRes')) {
const whitelistArr = configService.get('autoDownloadWhitelist') || []
const whitelistStr = (Array.isArray(whitelistArr) && whitelistArr.length > 0)
? (whitelistArr.join('\0') + '\0\0')
: ''
imageDownloadService.startAutoDownload(whitelistStr)
}
// 启动时检测更新(不阻塞启动)
checkForUpdatesOnStartup()
+10 -1
View File
@@ -21,10 +21,19 @@ contextBridge.exposeInMainWorld('electronAPI', {
click: (payload: any) => ipcRenderer.send('notification-clicked', payload),
ready: () => ipcRenderer.send('notification:ready'),
resize: (width: number, height: number) => ipcRenderer.send('notification:resize', { width, height }),
// 原生玻璃面板:卡片实测几何上报 / 退场淡出 / 亮度带回调(Windows 原生模式专用)
glassRect: (payload: any) => ipcRenderer.send('notification:glassRect', payload),
glassHide: () => ipcRenderer.send('notification:glassHide'),
onLuma: (callback: (bands: any) => void) => {
const listener = (_: any, bands: any) => callback(bands)
ipcRenderer.on('notification:luma', listener)
return () => ipcRenderer.removeListener('notification:luma', listener)
},
onShow: (callback: (event: any, data: any) => void) => {
ipcRenderer.on('notification:show', callback)
return () => ipcRenderer.removeAllListeners('notification:show')
}, // 监听原本发送出来的navigate-to-session事件,跳转到具体的会话
},
// 监听原本发送出来的navigate-to-session事件,跳转到具体的会话
onNavigateToSession: (callback: (sessionId: string) => void) => {
const listener = (_: any, sessionId: string) => callback(sessionId)
ipcRenderer.on('navigate-to-session', listener)
+20 -1
View File
@@ -449,6 +449,7 @@ class ChatService {
private readonly visibilityAnomalyLogBurst = 3
private visibilityAnomalyLogState = new Map<string, { windowStart: number; total: number; suppressed: number }>()
private readonly contactLabelNameMapCacheTtlMs = 10 * 60 * 1000
private connectInFlight: Promise<{ success: boolean; error?: string }> | null = null
private contactsLoadInFlight: { mode: 'lite' | 'full'; promise: Promise<{ success: boolean; contacts?: ContactInfo[]; error?: string }> } | null = null
private contactsMemoryCache = new Map<'lite' | 'full', { scope: string; updatedAt: number; contacts: ContactInfo[] }>()
private readonly contactsMemoryCacheTtlMs = 3 * 60 * 1000
@@ -561,9 +562,27 @@ class ChatService {
}
/**
* 连接数据库
* 连接数据库(并发调用共享同一次连接过程,避免重复 open)
*/
async connect(): Promise<{ success: boolean; error?: string }> {
if (this.connected && wcdbService.isReady()) {
return { success: true }
}
if (this.connectInFlight) {
return this.connectInFlight
}
const promise = this.connectInternal()
this.connectInFlight = promise
try {
return await promise
} finally {
if (this.connectInFlight === promise) {
this.connectInFlight = null
}
}
}
private async connectInternal(): Promise<{ success: boolean; error?: string }> {
try {
const wxid = String(this.runtimeConfig?.myWxid || this.configService.get('myWxid') || '').trim()
const dbPath = String(this.runtimeConfig?.dbPath || this.configService.get('dbPath') || '').trim()
@@ -5,34 +5,10 @@ import { buildGroupNicknameIdCandidates } from '../../export/contacts/groupNickn
import { appendTransferDesc, isTransferExportContent, resolveTransferDesc } from '../../export/parsers/transferParser';
import { formatTimestamp } from '../../export/utils/timestamp';
import { parallelLimit } from '../../export/utils/parallelLimit';
import { buildMarkdownBlockquote, escapeMarkdownLinkText, escapeMarkdownText, toMarkdownUrl } from '../../export/utils/markdown';
import { ExportDisplayProfile, MediaExportItem } from '../../export/types';
import { wcdbService } from '../../wcdbService';
const escapeMarkdownText = (value: unknown): string => {
return String(value ?? '')
.replace(/\\/g, '\\\\')
.replace(/([`*_{}\[\]<>#+\-.!|])/g, '\\$1')
}
const escapeMarkdownLinkText = (value: unknown): string => {
return escapeMarkdownText(value).replace(/\r?\n/g, ' ')
}
const toMarkdownUrl = (value: string): string => {
return encodeURI(value)
.replace(/\(/g, '%28')
.replace(/\)/g, '%29')
.replace(/</g, '%3C')
.replace(/>/g, '%3E')
}
const buildMarkdownBlockquote = (value: string): string => {
return value
.split(/\r?\n/)
.map(line => `> ${line ? escapeMarkdownText(line) : ''}`)
.join('\n')
}
export class MarkdownFormatter {
constructor(private exportService: any) {}
@@ -0,0 +1,24 @@
export const escapeMarkdownText = (value: unknown): string => {
return String(value ?? '')
.replace(/\\/g, '\\\\')
.replace(/([`*_{}\[\]<>#+\-.!|])/g, '\\$1')
}
export const escapeMarkdownLinkText = (value: unknown): string => {
return escapeMarkdownText(value).replace(/\r?\n/g, ' ')
}
export const toMarkdownUrl = (value: string): string => {
return encodeURI(value)
.replace(/\(/g, '%28')
.replace(/\)/g, '%29')
.replace(/</g, '%3C')
.replace(/>/g, '%3E')
}
export const buildMarkdownBlockquote = (value: string): string => {
return value
.split(/\r?\n/)
.map(line => `> ${line ? escapeMarkdownText(line) : ''}`)
.join('\n')
}
+5 -5
View File
@@ -1453,9 +1453,9 @@ class HttpService {
}
const rawFormat = String(url.searchParams.get('format') || 'json').trim().toLowerCase()
const format = rawFormat === 'arkme-json' ? 'arkmejson' : rawFormat
if (!['json', 'html', 'arkmejson'].includes(format)) {
this.sendError(res, 400, 'Invalid format, supported: json/html/arkmejson')
const format = rawFormat === 'arkme-json' ? 'arkmejson' : rawFormat === 'md' ? 'markdown' : rawFormat
if (!['json', 'html', 'arkmejson', 'markdown'].includes(format)) {
this.sendError(res, 400, 'Invalid format, supported: json/html/arkmejson/markdown')
return
}
@@ -1466,7 +1466,7 @@ class HttpService {
const options: {
outputDir: string
format: 'json' | 'html' | 'arkmejson'
format: 'json' | 'html' | 'arkmejson' | 'markdown'
usernames?: string[]
keyword?: string
exportMedia?: boolean
@@ -1477,7 +1477,7 @@ class HttpService {
endTime?: number
} = {
outputDir,
format: format as 'json' | 'html' | 'arkmejson',
format: format as 'json' | 'html' | 'arkmejson' | 'markdown',
usernames,
keyword,
exportMedia: this.parseBooleanParam(url, ['exportMedia'], false)
+125 -23
View File
@@ -8,6 +8,7 @@ import { basename, join } from 'path'
import crypto from 'crypto'
import { WasmService } from './wasmService'
import zlib from 'zlib'
import { escapeMarkdownLinkText, escapeMarkdownText, toMarkdownUrl } from './export/utils/markdown'
export interface SnsLivePhoto {
url: string
@@ -332,6 +333,30 @@ const decodeXmlText = (text: string): string => {
.replace(/&#39;/gi, "'")
}
const normalizeSnsLocationText = (value?: string): string => (
decodeXmlText(String(value || '')).replace(/\s+/g, ' ').trim()
)
/** 解析朋友圈位置的展示文本(POI 优先,附加国家/城市信息) */
const resolveSnsLocationText = (location?: SnsLocation): string => {
if (!location) return ''
const primaryCandidates = [
normalizeSnsLocationText(location.poiName),
normalizeSnsLocationText(location.poiAddressName),
normalizeSnsLocationText(location.label),
normalizeSnsLocationText(location.poiAddress)
].filter(Boolean)
const primary = primaryCandidates[0] || ''
const region = [
normalizeSnsLocationText(location.country),
normalizeSnsLocationText(location.city)
].filter(Boolean).join(' ')
if (primary && region && !primary.includes(region)) {
return `${primary} · ${region}`
}
return primary || region
}
class SnsService {
private configService: ConfigService
private contactCache: ContactCacheService
@@ -1358,7 +1383,7 @@ class SnsService {
*/
async exportTimeline(options: {
outputDir: string
format: 'json' | 'html' | 'arkmejson'
format: 'json' | 'html' | 'arkmejson' | 'markdown'
usernames?: string[]
keyword?: string
exportMedia?: boolean
@@ -1735,6 +1760,11 @@ class SnsService {
}
recordCreatedFileBeforeWrite(outputFilePath)
await writeFile(outputFilePath, JSON.stringify(exportData, null, 2), 'utf-8')
} else if (format === 'markdown') {
outputFilePath = join(outputDir, `朋友圈导出_${timestamp}.md`)
const markdown = this.generateMarkdown(allPosts, { usernames, keyword })
recordCreatedFileBeforeWrite(outputFilePath)
await writeFile(outputFilePath, markdown, 'utf-8')
} else {
// HTML 格式
outputFilePath = join(outputDir, `朋友圈导出_${timestamp}.html`)
@@ -1752,6 +1782,99 @@ class SnsService {
}
}
/**
* 生成朋友圈 Markdown 导出文件
*/
private generateMarkdown(posts: SnsPost[], filters: { usernames?: string[]; keyword?: string }): string {
const formatTime = (ts: number) => {
const d = new Date(ts * 1000)
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日 ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
// 逐行转义正文,行尾补两个空格以在渲染时保留换行
const formatMultilineText = (text: string): string => text
.split(/\r?\n/)
.map(line => escapeMarkdownText(line))
.join(' \n')
const lines: string[] = []
lines.push('# 朋友圈导出')
lines.push('')
lines.push(`- 动态数量: ${posts.length}`)
if (filters.keyword) lines.push(`- 筛选关键词: ${escapeMarkdownText(filters.keyword)}`)
if (filters.usernames && filters.usernames.length > 0) lines.push(`- 筛选用户: ${filters.usernames.length} 人`)
lines.push(`- 导出时间: ${new Date().toLocaleString('zh-CN')}`)
lines.push('- 导出工具: WeFlow')
for (const post of posts) {
lines.push('')
lines.push('---')
lines.push('')
lines.push(`## ${escapeMarkdownText(post.nickname)} · ${formatTime(post.createTime)}`)
lines.push('')
if (post.contentDesc) {
lines.push(formatMultilineText(post.contentDesc))
lines.push('')
}
const locationText = resolveSnsLocationText(post.location)
if (locationText) {
lines.push(`📍 ${escapeMarkdownText(locationText)}`)
lines.push('')
}
const mediaLines: string[] = []
post.media.forEach((m, mi) => {
const indexSuffix = post.media.length > 1 ? ` ${mi + 1}` : ''
const localPath = (m as any).localPath as string | undefined
const isVideo = isVideoUrl(m.url)
if (localPath) {
mediaLines.push(isVideo
? `[视频${indexSuffix}](${toMarkdownUrl(localPath)})`
: `![图片${indexSuffix}](${toMarkdownUrl(localPath)})`)
} else if (m.url) {
mediaLines.push(`[${isVideo ? '视频' : '图片'}${indexSuffix}](${toMarkdownUrl(m.url)})`)
}
const livePhotoLocalPath = m.livePhoto ? (m.livePhoto as any).localPath as string | undefined : undefined
if (livePhotoLocalPath) {
mediaLines.push(`[实况视频${indexSuffix}](${toMarkdownUrl(livePhotoLocalPath)})`)
}
})
if (mediaLines.length > 0) {
// 行尾两个空格产生硬换行,让每个媒体渲染时各占一行
lines.push(mediaLines.join(' \n'))
lines.push('')
}
if (post.linkTitle && post.linkUrl) {
lines.push(`🔗 [${escapeMarkdownLinkText(post.linkTitle)}](${toMarkdownUrl(post.linkUrl)})`)
lines.push('')
}
if (post.likes.length > 0) {
lines.push(`♥ ${post.likes.map(l => escapeMarkdownText(l)).join('、')}`)
lines.push('')
}
if (post.comments.length > 0) {
lines.push('💬 评论:')
lines.push('')
for (const c of post.comments) {
const ref = c.refNickname ? ` 回复 **${escapeMarkdownText(c.refNickname)}**` : ''
lines.push(`- **${escapeMarkdownText(c.nickname)}**${ref}:${escapeMarkdownLinkText(c.content)}`)
}
lines.push('')
}
// 去掉每条动态末尾多余的空行,统一由分隔符控制间距
while (lines[lines.length - 1] === '') lines.pop()
}
lines.push('')
return lines.join('\n')
}
/**
* 生成朋友圈 HTML 导出文件
*/
@@ -1778,27 +1901,6 @@ class SnsService {
const ch = name.charAt(0)
return escapeHtml(ch || '?')
}
const normalizeLocationText = (value?: string): string => (
decodeXmlText(String(value || '')).replace(/\s+/g, ' ').trim()
)
const resolveLocationText = (location?: SnsLocation): string => {
if (!location) return ''
const primaryCandidates = [
normalizeLocationText(location.poiName),
normalizeLocationText(location.poiAddressName),
normalizeLocationText(location.label),
normalizeLocationText(location.poiAddress)
].filter(Boolean)
const primary = primaryCandidates[0] || ''
const region = [
normalizeLocationText(location.country),
normalizeLocationText(location.city)
].filter(Boolean).join(' ')
if (primary && region && !primary.includes(region)) {
return `${primary} · ${region}`
}
return primary || region
}
let filterInfo = ''
if (filters.keyword) filterInfo += `关键词: "${escapeHtml(filters.keyword)}" `
@@ -1822,7 +1924,7 @@ class SnsService {
const linkHtml = post.linkTitle && post.linkUrl
? `<a class="lk" href="${escapeHtml(post.linkUrl)}" target="_blank"><span class="lk-t">${escapeHtml(post.linkTitle)}</span><span class="lk-a">›</span></a>`
: ''
const locationText = resolveLocationText(post.location)
const locationText = resolveSnsLocationText(post.location)
const locationHtml = locationText
? `<div class="loc"><span class="loc-i">📍</span><span class="loc-t">${escapeHtml(locationText)}</span></div>`
: ''
@@ -1,7 +1,11 @@
import { Notification } from "electron";
import { avatarFileCache, AvatarFileCacheService } from "./avatarFileCacheService";
import { avatarFileCache } from "./avatarFileCacheService";
export interface LinuxNotificationData {
// 系统通知服务(Linux / macOS):走各自系统的通知中心(Linux 底层为
// D-Bus/libnotify,macOS 为通知中心),Windows 使用特制的液态玻璃通知窗口。
// 声音、勿扰模式、通知历史均由系统按用户设置管理
export interface SystemNotificationData {
sessionId?: string;
title: string;
content: string;
@@ -14,6 +18,9 @@ export interface LinuxNotificationData {
type NotificationCallback = (payload: unknown) => void;
const isSupportedPlatform =
process.platform === "linux" || process.platform === "darwin";
let notificationCallbacks: NotificationCallback[] = [];
let notificationCounter = 1;
const activeNotifications: Map<number, Notification> = new Map();
@@ -39,20 +46,20 @@ function triggerNotificationCallback(payload: unknown): void {
try {
callback(payload);
} catch (error) {
console.error("[LinuxNotification] Callback error:", error);
console.error("[SystemNotification] Callback error:", error);
}
}
}
export async function showLinuxNotification(
data: LinuxNotificationData,
export async function showSystemNotification(
data: SystemNotificationData,
): Promise<number | null> {
if (process.platform !== "linux") {
if (!isSupportedPlatform) {
return null;
}
if (!Notification.isSupported()) {
console.warn("[LinuxNotification] Notification API is not supported");
console.warn("[SystemNotification] Notification API is not supported");
return null;
}
@@ -91,12 +98,15 @@ export async function showLinuxNotification(
});
notification.on("failed", (_, error) => {
console.error("[LinuxNotification] Notification failed:", error);
console.error("[SystemNotification] Notification failed:", error);
clearNotificationState(notificationId);
});
// Linux 的部分通知服务不会自动过期,需要手动关闭兜底;
// macOS 横幅由系统自动收起并保留在通知中心,手动 close 反而会把它
// 从通知中心移除,因此不做超时关闭
const expireTimeout = data.expireTimeout ?? 5000;
if (expireTimeout > 0) {
if (process.platform === "linux" && expireTimeout > 0) {
const timer = setTimeout(() => {
const currentNotification = activeNotifications.get(notificationId);
if (currentNotification) {
@@ -109,37 +119,16 @@ export async function showLinuxNotification(
notification.show();
console.log(
`[LinuxNotification] Shown notification ${notificationId}: ${data.title}`,
`[SystemNotification] Shown notification ${notificationId}: ${data.title}`,
);
return notificationId;
} catch (error) {
console.error("[LinuxNotification] Failed to show notification:", error);
console.error("[SystemNotification] Failed to show notification:", error);
return null;
}
}
export async function closeLinuxNotification(
notificationId: number,
): Promise<void> {
const notification = activeNotifications.get(notificationId);
if (!notification) return;
notification.close();
clearNotificationState(notificationId);
}
export async function getCapabilities(): Promise<string[]> {
if (process.platform !== "linux") {
return [];
}
if (!Notification.isSupported()) {
return [];
}
return ["native-notification", "click"];
}
export function onNotificationAction(callback: NotificationCallback): void {
notificationCallbacks.push(callback);
}
@@ -153,23 +142,21 @@ export function removeNotificationCallback(
}
}
export async function initLinuxNotificationService(): Promise<void> {
if (process.platform !== "linux") {
console.log("[LinuxNotification] Not on Linux, skipping init");
export async function initSystemNotificationService(): Promise<void> {
if (!isSupportedPlatform) {
console.log("[SystemNotification] Platform uses custom window, skipping init");
return;
}
if (!Notification.isSupported()) {
console.warn("[LinuxNotification] Notification API is not supported");
console.warn("[SystemNotification] Notification API is not supported");
return;
}
const caps = await getCapabilities();
console.log("[LinuxNotification] Service initialized with native API:", caps);
console.log("[SystemNotification] Service initialized for", process.platform);
}
export async function shutdownLinuxNotificationService(): Promise<void> {
// 清理所有活动的通知
export async function shutdownSystemNotificationService(): Promise<void> {
for (const [id, notification] of activeNotifications) {
try {
notification.close();
@@ -182,5 +169,5 @@ export async function shutdownLinuxNotificationService(): Promise<void> {
await avatarFileCache.clearCache();
} catch {}
console.log("[LinuxNotification] Service shutdown complete");
console.log("[SystemNotification] Service shutdown complete");
}
+12 -12
View File
@@ -161,13 +161,18 @@ class VideoService {
return `${dbPath}::${this.cleanWxid(wxid)}`.toLowerCase()
}
/**
* 判断 dbPath 本身是否就是账号目录。
* 无论 wxid_ 开头还是自定义微信号,账号目录下必然存在 db_storage 子目录,
* 以此为判据。不可用路径子串匹配 wxid(#1128:Windows 用户名与微信号相同时,
* `C:\Users\<同名>` 会让子串匹配误判,导致视频目录解析到错误位置)。
*/
private isAccountDir(dirPath: string): boolean {
return existsSync(join(dirPath, 'db_storage'))
}
private resolveVideoBaseDir(dbPath: string, wxid: string): string {
const cleanedWxid = this.cleanWxid(wxid)
const dbPathLower = dbPath.toLowerCase()
const wxidLower = wxid.toLowerCase()
const cleanedWxidLower = cleanedWxid.toLowerCase()
const dbPathContainsWxid = dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxidLower)
if (dbPathContainsWxid) {
if (this.isAccountDir(dbPath)) {
return join(dbPath, 'msg', 'video')
}
@@ -182,12 +187,7 @@ class VideoService {
}
private getHardlinkDbPaths(dbPath: string, wxid: string, cleanedWxid: string): string[] {
const dbPathLower = dbPath.toLowerCase()
const wxidLower = wxid.toLowerCase()
const cleanedWxidLower = cleanedWxid.toLowerCase()
const dbPathContainsWxid = dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxidLower)
if (dbPathContainsWxid) {
if (this.isAccountDir(dbPath)) {
return [join(dbPath, 'db_storage', 'hardlink', 'hardlink.db')]
}
+302 -65
View File
@@ -1,14 +1,32 @@
import { BrowserWindow, ipcMain, screen } from "electron";
import { BrowserWindow, desktopCapturer, ipcMain, screen } from "electron";
import { join } from "path";
import { ConfigService } from "../services/config";
// Linux D-Bus通知服务
// 通知交付分流:Windows 走特制液态玻璃通知窗口;
// Linux / macOS 走系统通知中心(systemNotificationService)
const isLinux = process.platform === "linux";
let linuxNotificationService:
| typeof import("../services/linuxNotificationService")
const isMac = process.platform === "darwin";
const usesSystemNotifications = isLinux || isMac;
// 原生液态玻璃(Windows 专用):DXGI 零拷贝采集 + D3D11 玻璃管线 + DComp 直接上屏,
// 感知滞后中位 ~6ms(Chromium 流方案 ~77ms),渲染完全不经过 Electron 进程。
// 不可用(系统过旧 / 二进制缺失)时自动回退 Chromium 桌面流管线
type NativeGlassModule = typeof import("@hicccc77/electron-liquid-glass");
let nativeGlass: NativeGlassModule | null = null;
if (!usesSystemNotifications) {
try {
const mod: NativeGlassModule = require("@hicccc77/electron-liquid-glass");
nativeGlass = mod.isSupported() ? mod : null;
} catch {
nativeGlass = null;
}
}
let systemNotificationService:
| typeof import("../services/systemNotificationService")
| null = null;
// 用于处理通知点击的回调函数(在Linux上用于导航到会话)
// 用于处理通知点击的回调函数(系统通知平台用于导航到会话)
let onNotificationNavigate: ((payload: unknown) => void) | null = null;
export function setNotificationNavigateHandler(
@@ -20,19 +38,119 @@ export function setNotificationNavigateHandler(
let notificationWindow: BrowserWindow | null = null;
let closeTimer: NodeJS.Timeout | null = null;
// 原生玻璃面板:与通知窗口一样常驻复用(创建后隐藏待命),
// 展示期跟随渲染层上报的卡片实测矩形(notification:glassRect)
let glassPanel: import("@hicccc77/electron-liquid-glass").GlassPanel | null = null;
// 面板创建时的 dpr:原生端几何常量按 dpr 换算且仅在创建时设定,
// 显示器缩放变化后必须重建面板,否则复用旧 dpr 会算错折射几何
let glassPanelDpr = 0;
function destroyGlassPanel() {
if (glassPanel) {
try {
glassPanel.destroy();
} catch {
/* 面板已随会话销毁 */
}
glassPanel = null;
}
}
// 视觉参数换算(缺省值与渲染层 GLASS_PARAMS 一致,CSS 值 → 物理像素/比例)
function toGlassParams(payload: Record<string, number | undefined>, scale: number) {
return {
cornerRadius: (payload.cornerRadius ?? 16) * scale,
blurSigma: (payload.blurSigma ?? 2) * scale,
displacementScale: payload.displacementScale ?? 70,
aberrationIntensity: payload.aberrationIntensity ?? 1,
saturation: (payload.saturation ?? 140) / 100,
};
}
// 创建或复用玻璃面板并同步全部状态(不负责 show/hide)。
// 空闲预热与 glassRect 上报共用此路径:预热时占位几何 + 空亮度带,
// 首次上报会以实测值幂等覆盖
function ensureGlassPanel(
bounds: { x: number; y: number; width: number; height: number },
params: ReturnType<typeof toGlassParams>,
scale: number,
bands: Array<{ id: number; x: number; y: number; width: number; height: number }>,
) {
if (!nativeGlass || !notificationWindow || notificationWindow.isDestroyed()) {
return null;
}
if (glassPanel && glassPanelDpr !== scale) destroyGlassPanel();
if (!glassPanel) {
glassPanel = nativeGlass.createPanel({
...bounds,
...params,
dpr: scale,
anchorWindow: notificationWindow,
lumaBands: bands,
onLuma: (bandStats) => {
if (notificationWindow && !notificationWindow.isDestroyed()) {
notificationWindow.webContents.send("notification:luma", bandStats);
}
},
});
glassPanelDpr = scale;
} else {
glassPanel.setBounds(bounds);
glassPanel.setParams(params);
glassPanel.setLumaBands(bands);
glassPanel.anchor(notificationWindow);
}
return glassPanel;
}
// —— Chromium 桌面流回退管线(仅原生玻璃不可用时启用:系统过旧 / 二进制缺失)——
// 透明窗口内 backdrop-filter 采样不到桌面像素(Chromium 只能采样页面内容),
// 回退路径下折射源由渲染层用 getUserMedia 开启的实时桌面视频流提供。
// 主进程只负责预热并缓存屏幕采集源 ID:desktopCapturer.getSources 会同步
// 初始化 DirectX 采集管线(实测阻塞主线程 300ms+,不要缩略图也一样),
// 绝不能出现在通知弹出路径上,否则每条通知都会让整个应用卡一下。
// 通知窗口自身已设置 content protection,不会被拍进视频流造成折射回环
let cachedSourceId: string | null = null;
let sourceIdRefreshing: Promise<void> | null = null;
function refreshDesktopSourceId(): Promise<void> {
if (sourceIdRefreshing) return sourceIdRefreshing;
sourceIdRefreshing = (async () => {
try {
const display = screen.getPrimaryDisplay();
const sources = await desktopCapturer.getSources({
types: ["screen"],
thumbnailSize: { width: 0, height: 0 },
});
const source =
sources.find((s) => s.display_id === String(display.id)) ?? sources[0];
cachedSourceId = source?.id ?? null;
} catch (error) {
console.warn(
"[NotificationWindow] Failed to refresh desktop source id:",
error,
);
} finally {
sourceIdRefreshing = null;
}
})();
return sourceIdRefreshing;
}
export function destroyNotificationWindow() {
if (closeTimer) {
clearTimeout(closeTimer);
closeTimer = null;
}
lastNotificationData = null;
destroyGlassPanel();
// Linux:关闭通知服务并清理缓存(fire-and-forget,不阻塞退出)
if (isLinux && linuxNotificationService) {
linuxNotificationService.shutdownLinuxNotificationService().catch((error) => {
console.warn("[NotificationWindow] Failed to shutdown Linux notification service:", error);
// Linux/macOS:关闭系统通知服务并清理缓存(fire-and-forget,不阻塞退出)
if (usesSystemNotifications && systemNotificationService) {
systemNotificationService.shutdownSystemNotificationService().catch((error) => {
console.warn("[NotificationWindow] Failed to shutdown system notification service:", error);
});
linuxNotificationService = null;
systemNotificationService = null;
}
if (!notificationWindow || notificationWindow.isDestroyed()) {
@@ -50,6 +168,17 @@ export function destroyNotificationWindow() {
}
}
// 窗口通过 min/max 锁定尺寸,程序化调整尺寸前需要同步放宽限制。
// 尺寸未变化时直接跳过:可见状态下重复 setSize 会让 DWM
// 短暂拉伸旧帧缓冲,在通知周围闪出一圈"幽灵轮廓"
function applyWindowSize(win: BrowserWindow, width: number, height: number) {
const [currentWidth, currentHeight] = win.getSize();
if (currentWidth === width && currentHeight === height) return;
win.setMinimumSize(width, height);
win.setMaximumSize(width, height);
win.setSize(width, height);
}
export function createNotificationWindow() {
if (notificationWindow && !notificationWindow.isDestroyed()) {
return notificationWindow;
@@ -68,9 +197,12 @@ export function createNotificationWindow() {
notificationWindow = new BrowserWindow({
width: width,
height: height,
type: "toolbar", // 有助于在某些操作系统上保持置顶
type: "toolbar", // 辅助置顶(仅 Windows 走此窗口)
frame: false,
// 无边框透明窗口:不会出现 DWM 材质窗口的系统描边,
// 玻璃底下的"桌面"由原生面板或渲染层的实时桌面视频流提供
transparent: true,
hasShadow: false, // 卡片投影由 CSS 提供
resizable: false,
show: false,
alwaysOnTop: true,
@@ -85,6 +217,13 @@ export function createNotificationWindow() {
},
});
// 把窗口从屏幕采集中排除(WDA_EXCLUDEFROMCAPTURE,Win10 2004+):
// 玻璃靠采集屏幕获得折射像素,不排除自身会拍到自己形成无限回环。
// 副作用:系统截图/录屏中看不到通知窗口本体
notificationWindow.setContentProtection(true);
applyWindowSize(notificationWindow, width, height);
// notificationWindow.webContents.openDevTools({ mode: 'detach' }) // DEBUG: Force Open DevTools
notificationWindow.setIgnoreMouseEvents(true, { forward: true }); // 初始点击穿透
@@ -99,6 +238,12 @@ export function createNotificationWindow() {
console.log("[NotificationWindow] Loading URL:", loadUrl);
notificationWindow.loadURL(loadUrl);
// Chromium 会按 file:// 域持久化页面缩放(主窗口与通知窗口同域):
// 任何来源的缩放残留都会让通知按错误的逻辑尺寸排版,这里强制钉回 1
notificationWindow.webContents.on("did-finish-load", () => {
notificationWindow?.webContents.setZoomFactor(1);
});
notificationWindow.on("closed", () => {
notificationWindow = null;
});
@@ -139,9 +284,9 @@ export async function showNotification(data: any) {
}
}
// Linux 使用 D-Bus 通知
if (isLinux) {
await showLinuxNotification(data);
// Linux / macOS 走系统通知中心,仅 Windows 使用特制液态玻璃窗口
if (usesSystemNotifications) {
await showViaSystemNotification(data);
return;
}
@@ -162,24 +307,22 @@ export async function showNotification(data: any) {
}
}
// 显示Linux通知
async function showLinuxNotification(data: any) {
if (!linuxNotificationService) {
// 经系统通知中心显示(Linux / macOS)
async function showViaSystemNotification(data: any) {
if (!systemNotificationService) {
try {
linuxNotificationService =
await import("../services/linuxNotificationService");
systemNotificationService =
await import("../services/systemNotificationService");
} catch (error) {
console.error(
"[NotificationWindow] Failed to load Linux notification service:",
"[NotificationWindow] Failed to load system notification service:",
error,
);
return;
}
}
const { showLinuxNotification: showNotification } = linuxNotificationService;
const notificationData = {
systemNotificationService.showSystemNotification({
title: data.title,
content: data.content,
avatarUrl: data.avatarUrl,
@@ -188,21 +331,20 @@ async function showLinuxNotification(data: any) {
insightRecordId: data.insightRecordId,
targetRoute: data.targetRoute,
expireTimeout: 5000,
};
showNotification(notificationData);
});
}
let lastNotificationData: any = null;
async function showAndSend(win: BrowserWindow, data: any) {
lastNotificationData = data;
const config = ConfigService.getInstance();
const position = (await config.get("notificationPosition")) || "top-right";
// 更新位置
const { width: screenWidth, height: screenHeight } =
screen.getPrimaryDisplay().workAreaSize;
// 更新位置:基于工作区完整矩形(含原点偏移)定位。
// macOS 菜单栏、Windows 任务栏靠上/靠左时工作区原点不为 (0,0),
// 只用 workAreaSize 会把通知压进系统栏下面
const display = screen.getPrimaryDisplay();
const workArea = display.workArea;
const winWidth = position === "top-center" ? 280 : 344;
const winHeight = 114;
const padding = 20;
@@ -212,57 +354,81 @@ async function showAndSend(win: BrowserWindow, data: any) {
switch (position) {
case "top-center":
x = (screenWidth - winWidth) / 2;
y = padding;
x = workArea.x + (workArea.width - winWidth) / 2;
y = workArea.y + padding;
break;
case "top-right":
x = screenWidth - winWidth - padding;
y = padding;
x = workArea.x + workArea.width - winWidth - padding;
y = workArea.y + padding;
break;
case "bottom-right":
x = screenWidth - winWidth - padding;
y = screenHeight - winHeight - padding;
x = workArea.x + workArea.width - winWidth - padding;
y = workArea.y + workArea.height - winHeight - padding;
break;
case "top-left":
x = padding;
y = padding;
x = workArea.x + padding;
y = workArea.y + padding;
break;
case "bottom-left":
x = padding;
y = screenHeight - winHeight - padding;
x = workArea.x + padding;
y = workArea.y + workArea.height - winHeight - padding;
break;
}
win.setPosition(Math.floor(x), Math.floor(y));
win.setSize(winWidth, winHeight); // 确保尺寸
const winX = Math.floor(x);
const winY = Math.floor(y);
// 采集源 ID 使用启动时预热的缓存;仅当首条通知早于预热完成时补一次。
// 除此之外通知路径不触碰 desktopCapturer,弹出过程主线程零阻塞。
// 原生玻璃可用时折射由原生面板提供,渲染层不再开启 Chromium 桌面流
if (!nativeGlass && !cachedSourceId) await refreshDesktopSourceId();
const payload = {
...data,
position,
backdrop: {
native: Boolean(nativeGlass),
sourceId: nativeGlass ? null : cachedSourceId,
winX,
winY,
width: display.size.width,
height: display.size.height,
},
};
lastNotificationData = payload;
win.setPosition(winX, winY);
// 窗口高度始终沿用渲染层的实测校准值(notification:resize),
// 这里只同步宽度;反复重置高度会造成 114→实测高度的弹跳闪烁
const [, currentHeight] = win.getSize();
applyWindowSize(win, winWidth, currentHeight);
win.webContents.send("notification:show", payload);
// 设为可交互
win.setIgnoreMouseEvents(false);
win.showInactive(); // 显示但不聚焦
win.setAlwaysOnTop(true, "screen-saver"); // 最高层级
win.webContents.send("notification:show", { ...data, position });
// 自动关闭计时器通常由渲染进程管理
// 渲染进程发送 'notification:close' 来隐藏窗口
}
// 注册通知处理
export async function registerNotificationHandlers() {
// Linux: 初始化D-Bus服务
if (isLinux) {
// Linux / macOS:初始化系统通知服务
if (usesSystemNotifications) {
try {
const linuxNotificationModule =
await import("../services/linuxNotificationService");
linuxNotificationService = linuxNotificationModule;
const systemNotificationModule =
await import("../services/systemNotificationService");
systemNotificationService = systemNotificationModule;
// 初始化服务
await linuxNotificationModule.initLinuxNotificationService();
await systemNotificationModule.initSystemNotificationService();
// 在Linux上注册通知点击回调
linuxNotificationModule.onNotificationAction((payload: unknown) => {
// 注册通知点击回调(导航到会话/洞察)
systemNotificationModule.onNotificationAction((payload: unknown) => {
console.log(
"[NotificationWindow] Linux notification clicked, sessionId:",
"[NotificationWindow] System notification clicked, payload:",
payload,
);
// 如果设置了导航处理程序,则使用该处理程序;否则,回退到ipcMain方法。
@@ -278,11 +444,11 @@ export async function registerNotificationHandlers() {
});
console.log(
"[NotificationWindow] Linux notification service initialized",
"[NotificationWindow] System notification service initialized",
);
} catch (error) {
console.error(
"[NotificationWindow] Failed to initialize Linux notification service:",
"[NotificationWindow] Failed to initialize system notification service:",
error,
);
}
@@ -293,20 +459,60 @@ export async function registerNotificationHandlers() {
});
ipcMain.handle("notification:close", () => {
if (isLinux && linuxNotificationService) {
// 注册通知点击回调函数。Linux通知通过D-Bus自动关闭,但我们可以根据需要进行跟踪
if (usesSystemNotifications) {
// 系统通知由通知中心管理生命周期,无窗口可关
return;
}
// 窗口即将隐藏,玻璃面板立即消失(渲染层通常已提前发过淡出信号)
glassPanel?.hide(0);
if (notificationWindow && !notificationWindow.isDestroyed()) {
notificationWindow.hide();
notificationWindow.setIgnoreMouseEvents(true, { forward: true });
}
});
// —— 原生玻璃面板生命周期(仅 nativeGlass 可用时渲染层才会发这些消息)——
// 渲染层在卡片入场动画落定后上报实测几何(窗口本地 CSS 像素)
ipcMain.on("notification:glassRect", (_event, payload) => {
if (!nativeGlass || !notificationWindow || notificationWindow.isDestroyed()) return;
const display = screen.getDisplayMatching(notificationWindow.getBounds());
// 优先用渲染层实测的 devicePixelRatio(已含可能的页面缩放),显示器缩放兜底
const scale = payload.dpr || display.scaleFactor;
const [winX, winY] = notificationWindow.getPosition();
const toPhysical = (rect: { x: number; y: number; width: number; height: number }) => ({
x: Math.round(rect.x * scale),
y: Math.round(rect.y * scale),
width: Math.round(rect.width * scale),
height: Math.round(rect.height * scale),
});
const bounds = {
x: Math.round((winX + payload.card.x) * scale),
y: Math.round((winY + payload.card.y) * scale),
width: Math.round(payload.card.width * scale),
height: Math.round(payload.card.height * scale),
};
// 亮度带矩形是卡片本地坐标(带 id:0=整卡 1=标题行 2=正文)
const bands = (payload.bands ?? []).map(
(band: { id: number; x: number; y: number; width: number; height: number }) => ({
id: band.id,
...toPhysical(band),
}),
);
const panel = ensureGlassPanel(bounds, toGlassParams(payload, scale), scale, bands);
panel?.show(120);
});
// 渲染层退场动画开始:面板与卡片的 0.3s 渐隐同步淡出
ipcMain.on("notification:glassHide", () => {
glassPanel?.hide(240);
});
// Handle renderer ready event (fix race condition)
ipcMain.on("notification:ready", (event) => {
if (isLinux) {
// Linux不需要通知窗口,拦截通知窗口渲染
if (usesSystemNotifications) {
// 系统通知平台不需要通知窗口,拦截通知窗口渲染
return;
}
console.log("[NotificationWindow] Renderer ready, checking cached data");
@@ -323,10 +529,44 @@ export async function registerNotificationHandlers() {
}
});
// 启动空闲期预热(系统通知平台不需要):
// - 采集源 ID:getSources 初始化采集管线会同步阻塞主线程 300ms+,放到空闲期一次性完成
// (原生玻璃可用时不需要 Chromium 采集源,跳过)
// - 预创建通知窗口:首条通知免去窗口创建和渲染器冷启动
// - 预创建原生玻璃面板(隐藏待命):worker 线程、D3D 设备、着色器编译、
// DComp 窗口链全部离开首条通知的可见路径(原生端实测 ~150ms);
// 隐藏面板零渲染零采集,常驻开销可忽略
if (!usesSystemNotifications) {
setTimeout(() => {
if (!nativeGlass) refreshDesktopSourceId();
const win = createNotificationWindow();
if (nativeGlass && win) {
const scale = screen.getPrimaryDisplay().scaleFactor;
const [winX, winY] = win.getPosition();
ensureGlassPanel(
{
x: Math.round(winX * scale),
y: Math.round(winY * scale),
width: Math.round(344 * scale),
height: Math.round(96 * scale),
},
toGlassParams({}, scale),
scale,
[],
);
}
}, 3000);
if (!nativeGlass) {
// 显示器增删后源 ID 可能失效,重新预热
screen.on("display-added", () => refreshDesktopSourceId());
screen.on("display-removed", () => refreshDesktopSourceId());
}
}
// Handle resize request from renderer
ipcMain.on("notification:resize", (event, { width, height }) => {
if (isLinux) {
// Linux 通知通过D-Bus自动调整大小
if (usesSystemNotifications) {
// 系统通知由通知中心排版,无需窗口尺寸同步
return;
}
if (notificationWindow && !notificationWindow.isDestroyed()) {
@@ -342,11 +582,8 @@ export async function registerNotificationHandlers() {
// We can re-call setPosition or just let it be.
// If bottom-right, y needs to prevent overflow.
// Ideally we get current config position
const bounds = notificationWindow.getBounds();
// Check if we need to adjust Y?
// For now, let's just set the size as requested.
notificationWindow.setSize(Math.round(width), Math.round(height));
applyWindowSize(notificationWindow, Math.round(width), Math.round(height));
}
});
+35 -3
View File
@@ -9,6 +9,7 @@
"version": "5.0.0",
"hasInstallScript": true,
"dependencies": {
"@hicccc77/electron-liquid-glass": "^0.3.0",
"@vscode/sudo-prompt": "^9.3.2",
"echarts": "^6.1.0",
"echarts-for-react": "^3.0.2",
@@ -1050,6 +1051,26 @@
"integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==",
"license": "MIT"
},
"node_modules/@hicccc77/electron-liquid-glass": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@hicccc77/electron-liquid-glass/-/electron-liquid-glass-0.3.0.tgz",
"integrity": "sha512-8myTqXmQWodYDZX2z9aGsv2Dthbqg3IFIrQiNFYhAATaz1q2FG8FkPSLNNEv7VXbsQpCy8h2Rn8hcA+pjlcpVQ==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^8.5.0",
"node-gyp-build": "^4.8.4"
}
},
"node_modules/@hicccc77/electron-liquid-glass/node_modules/node-addon-api": {
"version": "8.9.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.0.tgz",
"integrity": "sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q==",
"license": "MIT",
"engines": {
"node": "^18 || ^20 || >= 21"
}
},
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
@@ -7085,9 +7106,9 @@
}
},
"node_modules/node-abi": {
"version": "4.28.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.28.0.tgz",
"integrity": "sha512-Qfp5XZL1cJDOabOT8H5gnqMTmM4NjvYzHp4I/Kt/Sl76OVkOBBHRFlPspGV0hYvMoqQsypFjT/Yp7Km0beXW9g==",
"version": "4.33.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.33.0.tgz",
"integrity": "sha512-vLBWCKb+7LWsX+TbfzWOkw0W81m377tyx3hOweBTjO43CXZnRGS1/JPWs20fr0PgZyDXk6ROYrylsEycK8raDA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -7140,6 +7161,17 @@
"node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/node-gyp-build": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
"license": "MIT",
"bin": {
"node-gyp-build": "bin.js",
"node-gyp-build-optional": "optional.js",
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/node-gyp/node_modules/isexe": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz",
+4 -1
View File
@@ -17,12 +17,13 @@
"rebuild": "electron-rebuild",
"dev": "node scripts/prepare-electron-runtime.cjs && vite",
"typecheck": "tsc --noEmit",
"build": "tsc && vite build && electron-builder",
"build": "node scripts/clean-dist-electron.cjs && tsc && vite build && electron-builder",
"preview": "vite preview",
"electron:dev": "node scripts/prepare-electron-runtime.cjs && vite --mode electron",
"electron:build": "npm run build"
},
"dependencies": {
"@hicccc77/electron-liquid-glass": "^0.3.0",
"@vscode/sudo-prompt": "^9.3.2",
"echarts": "^6.1.0",
"echarts-for-react": "^3.0.2",
@@ -264,6 +265,7 @@
"node_modules/sherpa-onnx-*/*",
"node_modules/sherpa-onnx-*/**/*",
"node_modules/ffmpeg-static/**/*",
"node_modules/@hicccc77/electron-liquid-glass/**/*",
"resources/wedecrypt/**/*.node"
],
"icon": "resources/icons/macos/icon.icns"
@@ -272,6 +274,7 @@
"brace-expansion": {
".": "^5.0.6"
},
"node-abi": ">=4.33.0",
"exceljs": {
"uuid": "11.1.1"
},
+1
View File
@@ -578,6 +578,7 @@
(function bootstrapSplash() {
var params = new URLSearchParams(window.location.search || "");
applyTheme(params.get("themeId") || "cloud-dancer", params.get("themeMode") || "system");
setVersion(params.get("version") || "");
updateProgress(0, "", false);
})();
</script>
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+55 -39
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react'
import { lazy, Suspense, useEffect, useRef, useState } from 'react'
import { Routes, Route, Navigate, useNavigate, useLocation, type Location } from 'react-router-dom'
import TitleBar from './components/TitleBar'
import Sidebar from './components/Sidebar'
@@ -6,17 +6,10 @@ import RouteGuard from './components/RouteGuard'
import WelcomePage from './pages/WelcomePage'
import HomePage from './pages/HomePage'
import ChatPage from './pages/ChatPage'
import AnalyticsPage from './pages/AnalyticsPage'
import AnalyticsWelcomePage from './pages/AnalyticsWelcomePage'
import ChatAnalyticsHubPage from './pages/ChatAnalyticsHubPage'
import AnnualReportPage from './pages/AnnualReportPage'
import AnnualReportWindow from './pages/AnnualReportWindow'
import DualReportPage from './pages/DualReportPage'
import DualReportWindow from './pages/DualReportWindow'
import AgreementPage from './pages/AgreementPage'
import GroupAnalyticsPage from './pages/GroupAnalyticsPage'
import SettingsPage from './pages/SettingsPage'
import ExportPage from './pages/Export/ExportPage'
import MyFootprintPage from './pages/MyFootprintPage'
import VideoWindow from './pages/VideoWindow'
import ImageWindow from './pages/ImageWindow'
@@ -43,6 +36,16 @@ import LockScreen from './components/LockScreen'
import { GlobalSessionMonitor } from './components/GlobalSessionMonitor'
import WindowCloseDialog from './components/WindowCloseDialog'
// 重型页面懒加载:echarts 分析页、年度/双人报告(含大字体样式)、导出页模块树,
// 从首屏 bundle 中拆出,显著缩短主窗口首次渲染时间
const AnalyticsPage = lazy(() => import('./pages/AnalyticsPage'))
const GroupAnalyticsPage = lazy(() => import('./pages/GroupAnalyticsPage'))
const AnnualReportPage = lazy(() => import('./pages/AnnualReportPage'))
const AnnualReportWindow = lazy(() => import('./pages/AnnualReportWindow'))
const DualReportPage = lazy(() => import('./pages/DualReportPage'))
const DualReportWindow = lazy(() => import('./pages/DualReportWindow'))
const ExportPage = lazy(() => import('./pages/Export/ExportPage'))
function RouteStateRedirect({ to }: { to: string }) {
const location = useLocation()
@@ -532,12 +535,20 @@ function App() {
// 独立年度报告全屏窗口
if (isAnnualReportWindow) {
return <AnnualReportWindow />
return (
<Suspense fallback={null}>
<AnnualReportWindow />
</Suspense>
)
}
// 独立双人报告全屏窗口
if (isDualReportWindow) {
return <DualReportWindow />
return (
<Suspense fallback={null}>
<DualReportWindow />
</Suspense>
)
}
// 主窗口 - 完整布局
@@ -696,38 +707,43 @@ function App() {
<Sidebar collapsed={sidebarCollapsed} />
<main className="content">
<RouteGuard>
<div className={`export-keepalive-page ${isExportRoute ? 'active' : 'hidden'}`} aria-hidden={!isExportRoute}>
<ExportPage />
</div>
{/* 独立 Suspense 边界:Export chunk 异步加载时不阻塞首屏路由渲染 */}
<Suspense fallback={null}>
<div className={`export-keepalive-page ${isExportRoute ? 'active' : 'hidden'}`} aria-hidden={!isExportRoute}>
<ExportPage />
</div>
</Suspense>
<Routes location={routeLocation}>
<Route path="/" element={<HomePage />} />
<Route path="/home" element={<HomePage />} />
<Route path="/account-management" element={<AccountManagementPage />} />
<Route path="/chat" element={<ChatPage />} />
<Suspense fallback={null}>
<Routes location={routeLocation}>
<Route path="/" element={<HomePage />} />
<Route path="/home" element={<HomePage />} />
<Route path="/account-management" element={<AccountManagementPage />} />
<Route path="/chat" element={<ChatPage />} />
<Route path="/analytics" element={<ChatAnalyticsHubPage />} />
<Route path="/analytics/private" element={<AnalyticsWelcomePage />} />
<Route path="/analytics/private/view" element={<AnalyticsPage />} />
<Route path="/analytics/group" element={<GroupAnalyticsPage />} />
<Route path="/analytics/view" element={<RouteStateRedirect to="/analytics/private/view" />} />
<Route path="/group-analytics" element={<RouteStateRedirect to="/analytics/group" />} />
<Route path="/annual-report" element={<AnnualReportPage />} />
<Route path="/annual-report/view" element={<AnnualReportWindow />} />
<Route path="/dual-report" element={<DualReportPage />} />
<Route path="/dual-report/view" element={<DualReportWindow />} />
<Route path="/footprint" element={<MyFootprintPage />} />
<Route path="/analytics" element={<ChatAnalyticsHubPage />} />
<Route path="/analytics/private" element={<AnalyticsWelcomePage />} />
<Route path="/analytics/private/view" element={<AnalyticsPage />} />
<Route path="/analytics/group" element={<GroupAnalyticsPage />} />
<Route path="/analytics/view" element={<RouteStateRedirect to="/analytics/private/view" />} />
<Route path="/group-analytics" element={<RouteStateRedirect to="/analytics/group" />} />
<Route path="/annual-report" element={<AnnualReportPage />} />
<Route path="/annual-report/view" element={<AnnualReportWindow />} />
<Route path="/dual-report" element={<DualReportPage />} />
<Route path="/dual-report/view" element={<DualReportWindow />} />
<Route path="/footprint" element={<MyFootprintPage />} />
<Route path="/export" element={<div className="export-route-anchor" aria-hidden="true" />} />
<Route path="/sns" element={<SnsPage />} />
<Route path="/insight-inbox" element={<InsightInboxPage />} />
<Route path="/biz" element={<BizPage />} />
<Route path="/contacts" element={<ContactsPage />} />
<Route path="/resources" element={<ResourcesPage />} />
<Route path="/backup" element={<BackupPage />} />
<Route path="/chat-history/:sessionId/:messageId" element={<ChatHistoryPage />} />
<Route path="/chat-history-inline/:payloadId" element={<ChatHistoryPage />} />
</Routes>
<Route path="/export" element={<div className="export-route-anchor" aria-hidden="true" />} />
<Route path="/sns" element={<SnsPage />} />
<Route path="/insight-inbox" element={<InsightInboxPage />} />
<Route path="/biz" element={<BizPage />} />
<Route path="/contacts" element={<ContactsPage />} />
<Route path="/resources" element={<ResourcesPage />} />
<Route path="/backup" element={<BackupPage />} />
<Route path="/chat-history/:sessionId/:messageId" element={<ChatHistoryPage />} />
<Route path="/chat-history-inline/:payloadId" element={<ChatHistoryPage />} />
</Routes>
</Suspense>
</RouteGuard>
</main>
</div>
+11 -6
View File
@@ -12,13 +12,18 @@
z-index: 100;
animation: fadeIn 0.2s ease-out;
.confirm-dialog {
width: 480px;
background: var(--bg-primary);
border-radius: 20px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
position: relative;
// 液态玻璃外壳:宽度与入场动画在玻璃根节点上
.confirm-dialog-glass {
width: min(480px, calc(100% - 48px));
animation: slideUp 0.2s ease-out;
}
.confirm-dialog {
width: 100%;
// 半透明背景透出玻璃折射,阴影由 LiquidGlass 提供
background: color-mix(in srgb, var(--bg-primary) 80%, transparent);
border-radius: 20px;
position: relative;
overflow: hidden;
.close-btn {
+20 -12
View File
@@ -1,4 +1,5 @@
import { X } from 'lucide-react'
import LiquidGlass from './LiquidGlass'
import './ConfirmDialog.scss'
interface ConfirmDialogProps {
@@ -14,19 +15,26 @@ export default function ConfirmDialog({ open, title, message, onConfirm, onCance
return (
<div className="confirm-dialog-overlay" onClick={onCancel}>
<div className="confirm-dialog" onClick={e => e.stopPropagation()}>
<button className="close-btn" onClick={onCancel}>
<X size={20} />
</button>
{title && <div className="dialog-title">{title}</div>}
<div className="dialog-content">
<p style={{ whiteSpace: 'pre-line' }}>{message}</p>
<LiquidGlass
className="confirm-dialog-glass"
cornerRadius={20}
displacementScale={36}
aberrationIntensity={1.5}
>
<div className="confirm-dialog" onClick={e => e.stopPropagation()}>
<button className="close-btn" onClick={onCancel}>
<X size={20} />
</button>
{title && <div className="dialog-title">{title}</div>}
<div className="dialog-content">
<p style={{ whiteSpace: 'pre-line' }}>{message}</p>
</div>
<div className="dialog-actions">
<button className="btn-cancel" onClick={onCancel}>取消</button>
<button className="btn-confirm" onClick={onConfirm}>开始获取</button>
</div>
</div>
<div className="dialog-actions">
<button className="btn-cancel" onClick={onCancel}>取消</button>
<button className="btn-confirm" onClick={onConfirm}>开始获取</button>
</div>
</div>
</LiquidGlass>
</div>
)
}
+21 -8
View File
@@ -54,18 +54,31 @@
position: absolute;
top: calc(100% + 8px);
right: 0;
background: var(--bg-secondary-solid, var(--bg-primary, var(--card-bg)));
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
backdrop-filter: none;
-webkit-backdrop-filter: none;
border: 1px solid var(--border-color);
z-index: 1000;
display: flex;
overflow: hidden;
animation: dropdownFadeIn 0.15s ease-out;
}
// 内容层底色:中心保留半透明底保证日历可读,四周 26px 渐隐露出玻璃的边缘折射带
.picker-dropdown-body {
position: relative;
display: flex;
border-radius: 16px;
overflow: hidden;
&::before {
content: '';
position: absolute;
inset: 0;
z-index: -1;
border-radius: inherit;
background-color: color-mix(in srgb, var(--bg-secondary-solid, var(--bg-primary, var(--card-bg))) 68%, transparent);
mask:
linear-gradient(to right, transparent, #000 26px, #000 calc(100% - 26px), transparent),
linear-gradient(to bottom, transparent, #000 26px, #000 calc(100% - 26px), transparent);
mask-composite: intersect;
}
}
@keyframes dropdownFadeIn {
from {
opacity: 0;
+10
View File
@@ -1,5 +1,6 @@
import { useState, useRef, useEffect } from 'react'
import { Calendar, ChevronLeft, ChevronRight, X } from 'lucide-react'
import LiquidGlass from './LiquidGlass'
import './DateRangePicker.scss'
interface DateRangePickerProps {
@@ -198,6 +199,13 @@ function DateRangePicker({ startDate, endDate, onStartDateChange, onEndDateChang
{isOpen && (
<div className="picker-dropdown">
<LiquidGlass
cornerRadius={16}
displacementScale={48}
aberrationIntensity={1.5}
blurAmount={0.25}
>
<div className="picker-dropdown-body">
<div className="quick-options">
{QUICK_OPTIONS.map(opt => (
<button key={opt.label} className="quick-option" onClick={() => handleQuickOption(opt.days)}>
@@ -246,6 +254,8 @@ function DateRangePicker({ startDate, endDate, onStartDateChange, onEndDateChang
{selectingStart ? '请选择开始日期' : '请选择结束日期'}
</div>
</div>
</div>
</LiquidGlass>
</div>
)}
</div>
+3 -4
View File
@@ -52,10 +52,9 @@ export function GlobalSessionMonitor() {
return () => { }
}, [])
// 注意:导出统计的预加载已移除。
// 之前在这里预加载所有会话的统计到前端 zustand store,会导致启动时内存从 200MB 飙升到 500+MB。
// 后端 main.ts 启动时已经通过 getExportSessionStats 将统计结果缓存在主进程内存中,
// 导出页打开时会按需拉取当前可见批次的会话统计,后端缓存命中率很高,响应速度足够快。
// 注意:导出统计的预加载已移除(前端全量预载曾导致内存从 200MB 飙升到 500+MB,
// 主进程启动预热也因拖慢启动速度被移除)。
// 导出页打开时按需拉取可见批次的会话统计,主进程有磁盘缓存兜底,响应速度足够快。
const refreshSessions = async () => {
+21 -13
View File
@@ -3,20 +3,29 @@
top: calc(100% + 10px);
right: 0;
width: 312px;
border-radius: 14px;
border: 1px solid var(--border-color);
background: none;
background-color: var(--bg-secondary-solid, #ffffff) !important;
opacity: 1;
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
mix-blend-mode: normal;
isolation: isolate;
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.2);
padding: 12px;
z-index: 1600;
}
// 内容层底色:中心保留半透明底保证日历可读,四周 26px 渐隐露出玻璃的边缘折射带
.jump-date-popover-body {
position: relative;
border-radius: 14px;
padding: 12px;
&::before {
content: '';
position: absolute;
inset: 0;
z-index: -1;
border-radius: inherit;
background-color: color-mix(in srgb, var(--bg-secondary-solid, #ffffff) 68%, transparent);
mask:
linear-gradient(to right, transparent, #000 26px, #000 calc(100% - 26px), transparent),
linear-gradient(to bottom, transparent, #000 26px, #000 calc(100% - 26px), transparent);
mask-composite: intersect;
}
}
.jump-date-popover .calendar-nav {
display: flex;
align-items: center;
@@ -49,8 +58,7 @@
height: 28px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: none;
background-color: var(--bg-secondary-solid, #ffffff) !important;
background-color: color-mix(in srgb, var(--bg-secondary-solid, #ffffff) 80%, transparent);
color: var(--text-secondary);
display: inline-flex;
align-items: center;
+10
View File
@@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react'
import { ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'
import LiquidGlass from './LiquidGlass'
import './JumpToDatePopover.scss'
interface JumpToDatePopoverProps {
@@ -185,6 +186,13 @@ const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
return (
<div className={mergedClassName} style={style} role="dialog" aria-label="跳转日期">
<LiquidGlass
cornerRadius={14}
displacementScale={48}
aberrationIntensity={1.5}
blurAmount={0.25}
>
<div className="jump-date-popover-body">
<div className="calendar-nav">
<button
className="nav-btn"
@@ -293,6 +301,8 @@ const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
))}
</div>
)}
</div>
</LiquidGlass>
</div>
)
}
@@ -0,0 +1,67 @@
import type { LensDisplacementMap } from './lensDisplacementMap'
/**
* SVG 滤镜:透镜位移折射 + 边缘色散
*
* 透镜贴图中心是中性灰(零位移),中心天然保持清晰;RGB 三通道位移量递减,
* 在折射最强的边缘自然分离出色散。
* 几何约定(Chromium 实测):feImage 的像素几何 (0,0,w,h) 与元素原点精确对齐,
* 负坐标/百分比几何解析不可靠;滤镜区域收紧为元素本身,位移不会画出卡片之外。
* scale 用 2×maxScale 还原贴图编码的几何位移,displacementScale 以 70 为基准整体缩放
*/
export default function GlassFilter({ id, map, displacementScale, aberrationIntensity }: {
id: string
map: LensDisplacementMap
displacementScale: number
aberrationIntensity: number
}) {
const scale = 2 * map.maxScale * (displacementScale / 70)
return (
<svg style={{ position: 'absolute', width: 0, height: 0, pointerEvents: 'none' }} aria-hidden="true">
<defs>
<filter id={id} x="0%" y="0%" width="100%" height="100%" colorInterpolationFilters="sRGB">
<feImage x="0" y="0" width={map.width} height={map.height} result="DISPLACEMENT_MAP" href={map.url} preserveAspectRatio="none" />
<feDisplacementMap in="SourceGraphic" in2="DISPLACEMENT_MAP" scale={scale} xChannelSelector="R" yChannelSelector="B" result="RED_DISPLACED" />
<feColorMatrix
in="RED_DISPLACED"
type="matrix"
values="1 0 0 0 0
0 0 0 0 0
0 0 0 0 0
0 0 0 1 0"
result="RED_CHANNEL"
/>
<feDisplacementMap in="SourceGraphic" in2="DISPLACEMENT_MAP" scale={scale * (1 - aberrationIntensity * 0.05)} xChannelSelector="R" yChannelSelector="B" result="GREEN_DISPLACED" />
<feColorMatrix
in="GREEN_DISPLACED"
type="matrix"
values="0 0 0 0 0
0 1 0 0 0
0 0 0 0 0
0 0 0 1 0"
result="GREEN_CHANNEL"
/>
<feDisplacementMap in="SourceGraphic" in2="DISPLACEMENT_MAP" scale={scale * (1 - aberrationIntensity * 0.1)} xChannelSelector="R" yChannelSelector="B" result="BLUE_DISPLACED" />
<feColorMatrix
in="BLUE_DISPLACED"
type="matrix"
values="0 0 0 0 0
0 0 0 0 0
0 0 1 0 0
0 0 0 1 0"
result="BLUE_CHANNEL"
/>
{/* screen 混合合并三通道:位移一致的区域无损还原原色 */}
<feBlend in="GREEN_CHANNEL" in2="BLUE_CHANNEL" mode="screen" result="GB_COMBINED" />
<feBlend in="RED_CHANNEL" in2="GB_COMBINED" mode="screen" result="RGB_COMBINED" />
<feGaussianBlur in="RGB_COMBINED" stdDeviation={Math.max(0.1, 0.5 - aberrationIntensity * 0.1)} />
</filter>
</defs>
</svg>
)
}
@@ -0,0 +1,174 @@
// 玻璃背景全局运动估计器:块匹配连续两帧的低分辨率亮度网格,
// 得到内容平移速度,用于预测采样(补偿采集链路 ~60ms 延迟,见 glassStreamRenderer)。
//
// 设计要点:
// - 置信门控:只有当位移显著优于零位移(SAD 对比)且方向与历史一致时才更新速度,
// 旋转/局部运动/画面突变等不可预测场景自动衰减到零补偿(退回普通滞后表现)
// - 亚格点精度:SAD 极值点抛物线插值,配合 EMA 平滑,避免逐帧抖动
// - 失败模式安全:估计错误的最坏结果是折射内容短暂偏移后回正,且经过模糊层视觉柔和
export const MOTION_GRID_W = 56
export const MOTION_GRID_H = 24
const SEARCH_X = 10 // 搜索半径(格):±10 格 ≈ ±76px/帧 ≈ 4500px/s
const SEARCH_Y = 6
const MAX_VELOCITY = 6 // px/ms 上限(6000px/s,超出视为误匹配)
const MAX_OFFSET = 72 // 预测偏移上限(px),防极端外推
export interface GlassMotionEstimator {
/** 输入当前帧的 RGBA 网格与时间戳,返回预测采样偏移目标(px,运动方向) */
update: (rgba: Uint8Array, timestampMs: number) => { x: number; y: number }
/** 无新帧时的衰减步(内容停止变化后把偏移收回零) */
decay: () => { x: number; y: number }
}
/** SAD 极值点抛物线亚格点插值 */
function subCell(sadPrev: number, sadBest: number, sadNext: number): number {
const denom = sadPrev - 2 * sadBest + sadNext
if (denom <= 0) return 0
return Math.max(-0.5, Math.min(0.5, (sadPrev - sadNext) / (2 * denom)))
}
export function createGlassMotionEstimator(
cellPxX: number,
cellPxY: number,
predictMs: number
): GlassMotionEstimator {
const size = MOTION_GRID_W * MOTION_GRID_H
let prev: Uint8Array | null = null
let curr: Uint8Array = new Uint8Array(size)
let prevTs = 0
let velX = 0
let velY = 0
const target = () => {
// 速度低于 60px/s 视为静止(噪声区),不做补偿
const speed = Math.hypot(velX, velY)
if (speed < 0.06) return { x: 0, y: 0 }
return {
x: Math.max(-MAX_OFFSET, Math.min(MAX_OFFSET, velX * predictMs)),
y: Math.max(-MAX_OFFSET, Math.min(MAX_OFFSET, velY * predictMs))
}
}
/** 全搜索块匹配:返回最优位移(格)与置信信息 */
const search = (prevGrid: Uint8Array) => {
let bestDx = 0
let bestDy = 0
let bestSad = Infinity
let zeroSad = 0
// 缓存 x 方向 SAD 曲线(bestDy 行),用于亚格点插值
const sadRow = new Float32Array(SEARCH_X * 2 + 1)
for (let dy = -SEARCH_Y; dy <= SEARCH_Y; dy++) {
const y0 = Math.max(0, -dy)
const y1 = MOTION_GRID_H - Math.max(0, dy)
for (let dx = -SEARCH_X; dx <= SEARCH_X; dx++) {
const x0 = Math.max(0, -dx)
const x1 = MOTION_GRID_W - Math.max(0, dx)
let sad = 0
for (let y = y0; y < y1; y++) {
const rowC = y * MOTION_GRID_W
const rowP = (y + dy) * MOTION_GRID_W + dx
for (let x = x0; x < x1; x++) {
sad += Math.abs(curr[rowC + x] - prevGrid[rowP + x])
}
}
sad /= (x1 - x0) * (y1 - y0)
if (dx === 0 && dy === 0) zeroSad = sad
if (sad < bestSad) {
bestSad = sad
bestDx = dx
bestDy = dy
}
}
}
// 最优行的 x 邻域 SAD(亚格点用),只在需要时补算
const y0 = Math.max(0, -bestDy)
const y1 = MOTION_GRID_H - Math.max(0, bestDy)
for (let i = 0; i < sadRow.length; i++) {
const dx = i - SEARCH_X
const x0 = Math.max(0, -dx)
const x1 = MOTION_GRID_W - Math.max(0, dx)
let sad = 0
for (let y = y0; y < y1; y++) {
const rowC = y * MOTION_GRID_W
const rowP = (y + bestDy) * MOTION_GRID_W + dx
for (let x = x0; x < x1; x++) {
sad += Math.abs(curr[rowC + x] - prevGrid[rowP + x])
}
}
sadRow[i] = sad / ((x1 - x0) * (y1 - y0))
}
return { bestDx, bestDy, bestSad, zeroSad, sadRow }
}
return {
update(rgba, timestampMs) {
// RGBA → 亮度网格,同时统计对比度
let min = 255
let max = 0
const next = prev ?? new Uint8Array(size)
for (let i = 0; i < size; i++) {
const p = i * 4
const luma = (rgba[p] * 54 + rgba[p + 1] * 183 + rgba[p + 2] * 19) >> 8
next[i] = luma
if (luma < min) min = luma
if (luma > max) max = luma
}
prev = curr
curr = next
const dt = timestampMs - prevTs
prevTs = timestampMs
// 帧间隔异常(首帧/长暂停/时钟跳变)或画面无结构:不更新速度
if (dt < 4 || dt > 90 || max - min < 10) {
velX *= 0.55
velY *= 0.55
return target()
}
const { bestDx, bestDy, bestSad, zeroSad, sadRow } = search(prev)
if (bestDx === 0 && bestDy === 0) {
// 零位移最优 → 置信静止
velX *= 0.35
velY *= 0.35
return target()
}
// 置信条件:位移匹配显著优于不动(否则可能是局部运动/画面突变)
if (bestSad >= zeroSad * 0.8) {
velX *= 0.55
velY *= 0.55
return target()
}
const i = bestDx + SEARCH_X
const fracX = i > 0 && i < sadRow.length - 1 ? subCell(sadRow[i - 1], sadRow[i], sadRow[i + 1]) : 0
// 匹配含义:curr[x] ≈ prev[x + dx],内容位移 = -dx(向右运动时 dx 为负)
const instVx = (-(bestDx + fracX) * cellPxX) / dt
const instVy = (-bestDy * cellPxY) / dt
if (Math.hypot(instVx, instVy) > MAX_VELOCITY) {
velX *= 0.55
velY *= 0.55
return target()
}
if (instVx * velX + instVy * velY < 0) {
// 方向反转(如拖拽折返):快速重锚定,幅度打折避免过冲
velX = instVx * 0.3
velY = instVy * 0.3
} else {
velX = velX * 0.65 + instVx * 0.35
velY = velY * 0.65 + instVy * 0.35
}
return target()
},
decay() {
velX *= 0.5
velY *= 0.5
return target()
}
}
}
@@ -0,0 +1,499 @@
// 视频流模式的 WebGL 玻璃渲染器:替代「<video> 元素 + CSS blur/saturate + SVG 位移滤镜」管线。
//
// 性能与延迟设计:
// - MediaStreamTrackProcessor 直读采集帧(无 <video> 播出缓冲,省 1~2 帧延迟),
// 帧到达立即渲染,latest-wins 丢弃积压帧;无帧到达时零渲染(桌面静止零开销)
// - 圆角矩形 SDF 及其梯度在片元着色器内解析式求值,替代 CPU 逐像素生成位移贴图
// (lensDisplacementMap)+ feImage + 3×feDisplacementMap + 2×feBlend 的滤镜图
// - 模糊降到半分辨率两趟可分离高斯(工作面积仅玻璃+边距),
// 位移、色散、饱和度合并进最终一趟片元着色,整帧只有 3 个小渲染目标
// - 透镜几何与 lensDisplacementMap/GlassFilter 保持同一套公式(bezel/单峰剖面/色散递减),
// 视觉与应用内 SVG 路径一致
// - 运动补偿预测采样(timewarp 思路):屏幕采集链路存在 ~60ms 物理延迟
// (WGC 帧池 → 跨进程 → 合成上屏,应用层无法消除),对"拖拽/滚动"这类
// 均匀平移场景,用块匹配估计背景运动矢量,把采样窗口向运动方向外推一个
// 延迟量,折射内容与真实位置的感知滞后可从 ~70ms 压到接近零;
// 不可预测运动(旋转/局部变化)由置信门控自动退回普通滞后表现
import { createGlassMotionEstimator, MOTION_GRID_W, MOTION_GRID_H } from './glassMotionEstimator'
const BLUR_MARGIN = 40
/** 预测外推时长(ms):按实测采集→上屏延迟标定,故意欠补偿留稳定余量 */
const MOTION_PREDICT_MS = 60
/** 预测偏移的逐帧平滑系数 */
const OFFSET_SMOOTH = 0.45
/** 无新帧超过该时长即进入偏移回收(内容停止运动后把预测偏移退回零) */
const IDLE_DECAY_DELAY = 90
export interface GlassStreamRendererOptions {
canvas: HTMLCanvasElement
/** 渲染器独占的视频轨(调用方负责 clone),dispose 时停止 */
track: MediaStreamTrack
/** 玻璃 CSS 尺寸与圆角 */
width: number
height: number
cornerRadius: number
/** 玻璃左上角的屏幕逻辑坐标(窗口坐标 + 页内布局偏移) */
screenX: number
screenY: number
/** 屏幕逻辑尺寸(视频帧与屏幕的映射基准,与帧实际分辨率无关) */
screenW: number
screenH: number
displacementScale: number
aberrationIntensity: number
saturation: number
/** 高斯模糊标准差(CSS px) */
blurSigma: number
/** 首帧渲染完成(用于淡入时机) */
onFirstFrame?: () => void
}
export interface GlassStreamRenderer {
dispose: () => void
}
const VERT = `#version 300 es
void main() {
vec2 pos = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);
gl_Position = vec4(pos * 2.0 - 1.0, 0.0, 1.0);
}`
/** 下采样 + 单方向高斯(半分辨率,两趟可分离) */
const BLUR_FRAG = `#version 300 es
precision highp float;
uniform sampler2D uTex;
uniform vec2 uSrcOrigin; // 采样区域原点(源纹理 UV)
uniform vec2 uSrcSpan; // 采样区域跨度(源纹理 UV)
uniform vec2 uOutSize; // 本目标像素尺寸
uniform vec2 uDir; // 模糊方向(源纹理 UV 每标准差步长)
out vec4 outColor;
void main() {
vec2 t = gl_FragCoord.xy / uOutSize;
vec2 uv = uSrcOrigin + t * uSrcSpan;
outColor = texture(uTex, uv) * 0.4026
+ (texture(uTex, uv + uDir) + texture(uTex, uv - uDir)) * 0.2442
+ (texture(uTex, uv + uDir * 2.0) + texture(uTex, uv - uDir * 2.0)) * 0.0545;
}`
/** 透镜主着色:解析式 SDF 位移 + RGB 色散 + 饱和度 */
const LENS_FRAG = `#version 300 es
precision highp float;
uniform sampler2D uBlurTex;
uniform vec2 uGlassSize; // CSS px
uniform float uDpr;
uniform float uRadius;
uniform float uBezel;
uniform float uMaxBend;
uniform float uDispFactor; // displacementScale / 70
uniform float uAberration;
uniform float uSaturation;
uniform float uMargin; // 模糊纹理相对玻璃的外扩边距(CSS px)
out vec4 outColor;
// 圆角矩形 SDF(p 以玻璃中心为原点)
float sdRoundRect(vec2 p, vec2 halfSize, float r) {
vec2 q = abs(p) - halfSize + r;
return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r;
}
// 平滑 max(q,0):法线方向在角区连续旋转,消除对角线上的方向折痕
vec2 softClamp(vec2 q, float soft) {
return 0.5 * (q + sqrt(q * q + vec2(soft * soft)));
}
vec2 toBlurUV(vec2 cssPos) {
return (cssPos + uMargin) / (uGlassSize + uMargin * 2.0);
}
void main() {
// 画布像素 → 玻璃 CSS 坐标(画布原点左上,gl_FragCoord 原点左下)
vec2 css = vec2(gl_FragCoord.x, uGlassSize.y * uDpr - gl_FragCoord.y) / uDpr;
vec2 halfSize = uGlassSize * 0.5;
vec2 p = css - halfSize;
float r = min(uRadius, min(halfSize.x, halfSize.y));
// 液态弯月面边缘(iOS 边缘处理,与原生面板/lensDisplacementMap 一致):
// 边缘带只重新分布"自己的内容"——靠边界处抻胀(拉伸)、往中心方向追赶压缩。
// 采样映射 s(d) = -d + bump(t)·bend 两端连续(bump(0)=bump(1)=0):
// 内容跨玻璃边无缝衔接(无断裂线)、平滑并入清晰中心。单射性:上升斜率
// 通过 maxBend ≤ 0.7/1.5·tp·bezel 封顶(局部拉伸 ≤ ~3.3×),
// 镜像"舌头"折叠结构上不可能;下降侧只压缩(斜率 < -1),恒单射
float depth = -sdRoundRect(p, halfSize, r);
vec2 disp = vec2(0.0);
vec2 nrm = vec2(0.0);
float bump = 0.0;
float edgeW = 0.0;
if (depth > 0.0 && depth < uBezel) {
vec2 q = abs(p) - halfSize + r;
vec2 qs = softClamp(q, max(r * 0.8, 1.0));
nrm = (qs / max(length(qs), 1e-4)) * sign(p + vec2(1e-6));
float t = depth / uBezel;
edgeW = (1.0 - t) * (1.0 - t);
// C¹ 鼓包:smoothstep 上升至 t=0.62 峰值,smoothstep 回落到 0
float u = clamp(t < 0.62 ? t / 0.62 : (1.0 - t) / 0.38, 0.0, 1.0);
bump = u * u * (3.0 - 2.0 * u);
disp = nrm * (bump * uMaxBend * min(uDispFactor, 1.0));
}
// 压缩带内做 5 tap 足迹积分(径向 + 切向拉丝):边缘带是 2~3 倍缩小映射,
// 点采样会产生锯齿/摩尔纹;径向扩散随压缩强度自适应加宽做抗锯齿。
// 色散:与 GlassFilter 相同的 RGB 位移递减(R 全量、G/B 递减)
vec3 c;
float dispLen2 = dot(disp, disp);
if (dispLen2 > 0.25) {
vec2 tangent = vec2(nrm.y, -nrm.x) * sqrt(dispLen2);
float rs = 0.05 + 0.10 * bump;
float radial[5]; radial[0] = 1.0 - 2.0 * rs; radial[1] = 1.0 - rs; radial[2] = 1.0; radial[3] = 1.0 + rs; radial[4] = 1.0 + 2.0 * rs;
float lateral[5]; lateral[0] = -0.36; lateral[1] = 0.18; lateral[2] = 0.0; lateral[3] = -0.18; lateral[4] = 0.36;
float wts[5]; wts[0] = 0.14; wts[1] = 0.22; wts[2] = 0.28; wts[3] = 0.22; wts[4] = 0.14;
c = vec3(0.0);
for (int i = 0; i < 5; ++i) {
vec2 d = disp * radial[i] + tangent * lateral[i];
c += wts[i] * vec3(
texture(uBlurTex, toBlurUV(css + d)).r,
texture(uBlurTex, toBlurUV(css + d * (1.0 - uAberration * 0.05))).g,
texture(uBlurTex, toBlurUV(css + d * (1.0 - uAberration * 0.1))).b);
}
} else {
c = texture(uBlurTex, toBlurUV(css + disp)).rgb;
}
// CSS saturate() 等效矩阵
float lum = dot(c, vec3(0.213, 0.715, 0.072));
c = mix(vec3(lum), c, uSaturation);
// 贴边宽幅边缘光(与原生面板一致,光源方向左上):受光侧提亮、背光侧微压暗,
// 权重 edgeW 在边界处最大、向内 C¹ 衰减,独立于位移鼓包——iOS 玻璃的
// "实体厚度"暗示;细锐的描边环仍由内容层负责
float lightDot = dot(nrm, vec2(-0.40, -0.92));
c = clamp(c + edgeW * (0.09 * clamp(lightDot, 0.0, 1.0) - 0.04 * clamp(-lightDot, 0.0, 1.0)), 0.0, 1.0);
outColor = vec4(c, 1.0);
}`
function compile(gl: WebGL2RenderingContext, type: number, source: string): WebGLShader | null {
const shader = gl.createShader(type)
if (!shader) return null
gl.shaderSource(shader, source)
gl.compileShader(shader)
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.warn('[LiquidGlass] shader compile failed:', gl.getShaderInfoLog(shader))
gl.deleteShader(shader)
return null
}
return shader
}
function link(gl: WebGL2RenderingContext, frag: string): WebGLProgram | null {
const vs = compile(gl, gl.VERTEX_SHADER, VERT)
const fs = compile(gl, gl.FRAGMENT_SHADER, frag)
if (!vs || !fs) return null
const program = gl.createProgram()
if (!program) return null
gl.attachShader(program, vs)
gl.attachShader(program, fs)
gl.linkProgram(program)
gl.deleteShader(vs)
gl.deleteShader(fs)
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.warn('[LiquidGlass] program link failed:', gl.getProgramInfoLog(program))
gl.deleteProgram(program)
return null
}
return program
}
/** 创建流渲染器;WebGL 不可用或轨道无效时返回 null(调用方回退 <video> 管线) */
export function createGlassStreamRenderer(options: GlassStreamRendererOptions): GlassStreamRenderer | null {
const { canvas, track } = options
const dpr = window.devicePixelRatio || 1
const gl = canvas.getContext('webgl2', {
alpha: false,
antialias: false,
depth: false,
stencil: false,
// 低延迟提示:帧到达即绘制,desynchronized 允许绕过合成器队列
desynchronized: true,
powerPreference: 'low-power'
}) as WebGL2RenderingContext | null
if (!gl) return null
const blurProgram = link(gl, BLUR_FRAG)
const lensProgram = link(gl, LENS_FRAG)
if (!blurProgram || !lensProgram) {
track.stop()
return null
}
canvas.width = Math.max(1, Math.round(options.width * dpr))
canvas.height = Math.max(1, Math.round(options.height * dpr))
// 半分辨率模糊目标(玻璃 + 外扩边距)
const blurW = Math.max(1, Math.ceil((options.width + BLUR_MARGIN * 2) / 2))
const blurH = Math.max(1, Math.ceil((options.height + BLUR_MARGIN * 2) / 2))
const screenTex = gl.createTexture()
const makeTarget = () => {
const tex = gl.createTexture()
gl.bindTexture(gl.TEXTURE_2D, tex)
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, blurW, blurH, 0, gl.RGBA, gl.UNSIGNED_BYTE, null)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
const fbo = gl.createFramebuffer()
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo)
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0)
return { tex, fbo }
}
const targetA = makeTarget()
const targetB = makeTarget()
// 运动估计降采样目标:56×24 网格(复用模糊程序做加权降采样,uDir=0 时权重和为 1)
const motionTex = gl.createTexture()
const motionFbo = gl.createFramebuffer()
gl.bindTexture(gl.TEXTURE_2D, motionTex)
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, MOTION_GRID_W, MOTION_GRID_H, 0, gl.RGBA, gl.UNSIGNED_BYTE, null)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
gl.bindFramebuffer(gl.FRAMEBUFFER, motionFbo)
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, motionTex, 0)
gl.bindFramebuffer(gl.FRAMEBUFFER, null)
const blurU = {
tex: gl.getUniformLocation(blurProgram, 'uTex'),
srcOrigin: gl.getUniformLocation(blurProgram, 'uSrcOrigin'),
srcSpan: gl.getUniformLocation(blurProgram, 'uSrcSpan'),
outSize: gl.getUniformLocation(blurProgram, 'uOutSize'),
dir: gl.getUniformLocation(blurProgram, 'uDir')
}
const lensU = {
blurTex: gl.getUniformLocation(lensProgram, 'uBlurTex'),
glassSize: gl.getUniformLocation(lensProgram, 'uGlassSize'),
dpr: gl.getUniformLocation(lensProgram, 'uDpr'),
radius: gl.getUniformLocation(lensProgram, 'uRadius'),
bezel: gl.getUniformLocation(lensProgram, 'uBezel'),
maxBend: gl.getUniformLocation(lensProgram, 'uMaxBend'),
dispFactor: gl.getUniformLocation(lensProgram, 'uDispFactor'),
aberration: gl.getUniformLocation(lensProgram, 'uAberration'),
saturation: gl.getUniformLocation(lensProgram, 'uSaturation'),
margin: gl.getUniformLocation(lensProgram, 'uMargin')
}
// 与 lensDisplacementMap 相同的几何参数(iOS 式宽幅湿边)。
// maxBend = 弯月面鼓包峰值,斜率封顶保证映射单射:
// 上升斜率 1.5/tp·maxBend/bezel ≤ 0.7(tp=0.62)⇒ maxBend = 0.289·bezel,
// 远小于模糊纹理外扩边距,足迹采样不会越界
const halfMin = Math.min(options.width, options.height) / 2
const bezel = Math.min(34, halfMin * 0.75)
const maxBend = 0.289 * bezel
let disposed = false
let firstFrame = true
let texW = 0
let texH = 0
// 玻璃区域(+模糊边距)的屏幕逻辑坐标
const regionX = options.screenX - BLUR_MARGIN
const regionY = options.screenY - BLUR_MARGIN
const regionW = options.width + BLUR_MARGIN * 2
const regionH = options.height + BLUR_MARGIN * 2
// —— 运动补偿状态:估计器输出目标偏移,渲染时平滑逼近 ——
const estimator = createGlassMotionEstimator(regionW / MOTION_GRID_W, regionH / MOTION_GRID_H, MOTION_PREDICT_MS)
const motionPixels = new Uint8Array(MOTION_GRID_W * MOTION_GRID_H * 4)
let offsetX = 0
let offsetY = 0
let targetOffsetX = 0
let targetOffsetY = 0
let decayTimer = 0
// 内容停止运动后采集不再出帧,渲染循环停摆,需要主动把预测偏移收回零,
// 否则折射内容会停在外推位置上(错位定格);首步等 IDLE_DECAY_DELAY
// 确认无新帧,后续以短步长快速滑回
const scheduleDecay = (delay = IDLE_DECAY_DELAY) => {
clearTimeout(decayTimer)
decayTimer = window.setTimeout(() => {
if (disposed) return
const t = estimator.decay()
targetOffsetX = t.x
targetOffsetY = t.y
offsetX = offsetX * 0.5 + targetOffsetX * 0.5
offsetY = offsetY * 0.5 + targetOffsetY * 0.5
drawGlass()
if (Math.abs(offsetX) > 0.5 || Math.abs(offsetY) > 0.5) scheduleDecay(40)
}, delay)
}
const uploadTexture = (source: TexImageSource, w: number, h: number) => {
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, screenTex)
if (w !== texW || h !== texH) {
texW = w
texH = h
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
}
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, gl.RGBA, gl.UNSIGNED_BYTE, source)
}
/** 用当前 screenTex 内容执行模糊 + 透镜两级绘制(可被运动偏移回收重绘复用) */
const drawGlass = () => {
// 趟1:纹理采样区域 → 半分辨率 + 水平高斯。
// 采样原点按运动预测偏移前移:显示"内容此刻应到达的位置"而非采集帧的旧位置
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, screenTex)
gl.useProgram(blurProgram)
gl.uniform1i(blurU.tex, 0)
gl.bindFramebuffer(gl.FRAMEBUFFER, targetA.fbo)
gl.viewport(0, 0, blurW, blurH)
gl.uniform2f(blurU.srcOrigin, (regionX - offsetX) / options.screenW, (regionY - offsetY) / options.screenH)
gl.uniform2f(blurU.srcSpan, regionW / options.screenW, regionH / options.screenH)
gl.uniform2f(blurU.outSize, blurW, blurH)
gl.uniform2f(blurU.dir, options.blurSigma / options.screenW, 0)
gl.drawArrays(gl.TRIANGLES, 0, 3)
// 趟2:垂直高斯(源为趟1 结果,UV 全幅,步长换算到区域跨度)
gl.bindTexture(gl.TEXTURE_2D, targetA.tex)
gl.bindFramebuffer(gl.FRAMEBUFFER, targetB.fbo)
gl.uniform2f(blurU.srcOrigin, 0, 0)
gl.uniform2f(blurU.srcSpan, 1, 1)
gl.uniform2f(blurU.dir, 0, options.blurSigma / regionH)
gl.drawArrays(gl.TRIANGLES, 0, 3)
// 趟3:透镜主着色 → 画布
gl.useProgram(lensProgram)
gl.bindTexture(gl.TEXTURE_2D, targetB.tex)
gl.bindFramebuffer(gl.FRAMEBUFFER, null)
gl.viewport(0, 0, canvas.width, canvas.height)
gl.uniform1i(lensU.blurTex, 0)
gl.uniform2f(lensU.glassSize, options.width, options.height)
gl.uniform1f(lensU.dpr, dpr)
gl.uniform1f(lensU.radius, options.cornerRadius)
gl.uniform1f(lensU.bezel, bezel)
gl.uniform1f(lensU.maxBend, maxBend)
gl.uniform1f(lensU.dispFactor, options.displacementScale / 70)
gl.uniform1f(lensU.aberration, options.aberrationIntensity)
gl.uniform1f(lensU.saturation, options.saturation / 100)
gl.uniform1f(lensU.margin, BLUR_MARGIN)
gl.drawArrays(gl.TRIANGLES, 0, 3)
// 立即提交而不等下一次合成对齐(desynchronized 画布的低延迟关键)
gl.flush()
}
/** 固定区域降采样出 56×24 亮度网格并回读,喂给运动估计器。
采样区域不随补偿偏移移动,估计的是真实屏幕运动(避免补偿反馈回路) */
const estimateMotion = (timestampMs: number) => {
gl.useProgram(blurProgram)
gl.uniform1i(blurU.tex, 0)
gl.bindFramebuffer(gl.FRAMEBUFFER, motionFbo)
gl.viewport(0, 0, MOTION_GRID_W, MOTION_GRID_H)
gl.uniform2f(blurU.srcOrigin, regionX / options.screenW, regionY / options.screenH)
gl.uniform2f(blurU.srcSpan, regionW / options.screenW, regionH / options.screenH)
gl.uniform2f(blurU.outSize, MOTION_GRID_W, MOTION_GRID_H)
// uDir=0 时高斯核权重和恰为 1,等价于纯降采样
gl.uniform2f(blurU.dir, 0, 0)
gl.drawArrays(gl.TRIANGLES, 0, 3)
// 5KB 同步回读,实测 <0.5ms;异步 PBO 会引入一帧估计延迟,得不偿失
gl.readPixels(0, 0, MOTION_GRID_W, MOTION_GRID_H, gl.RGBA, gl.UNSIGNED_BYTE, motionPixels)
const t = estimator.update(motionPixels, timestampMs)
targetOffsetX = t.x
targetOffsetY = t.y
offsetX += (targetOffsetX - offsetX) * OFFSET_SMOOTH
offsetY += (targetOffsetY - offsetY) * OFFSET_SMOOTH
scheduleDecay()
}
const render = (frame: VideoFrame | HTMLVideoElement) => {
if (disposed) return
const fw = frame instanceof HTMLVideoElement ? frame.videoWidth : frame.displayWidth
const fh = frame instanceof HTMLVideoElement ? frame.videoHeight : frame.displayHeight
if (!fw || !fh) return
// 整帧直传:采集帧是 GPU 背书的,texImage2D 走 GPU-GPU 拷贝;
// 实测 visibleRect 裁剪视图反而更慢(触发同步转换路径),不做预裁剪。
// UV 按屏幕逻辑坐标映射,帧分辨率高于逻辑尺寸时(Retina/高 DPI)采样自动降分辨率
uploadTexture(frame as TexImageSource, fw, fh)
estimateMotion(frame instanceof HTMLVideoElement ? performance.now() : frame.timestamp / 1000)
drawGlass()
if (firstFrame) {
firstFrame = false
options.onFirstFrame?.()
}
}
// —— 帧泵:MediaStreamTrackProcessor 为主源(帧到达即渲染,无播出缓冲)。
// 同轨再挂一个离屏 <video> 作首帧引子与后备:MSTP 只在画面变化时出帧,
// 静态桌面上新挂载的玻璃会一直等不到首帧;video 的 loadeddata 能立刻
// 给出当前帧,rVFC 则在 MSTP 不可用/停摆时兜底渲染 ——
let lastPumpRender = 0
const video = document.createElement('video')
video.muted = true
video.srcObject = new MediaStream([track])
const onVideoFrame = () => {
if (disposed) return
// 主源正常供帧时后备不重复渲染(同帧二次绘制无意义)
if (performance.now() - lastPumpRender > 250) render(video)
video.requestVideoFrameCallback(onVideoFrame)
}
video.addEventListener('loadeddata', () => {
if (!disposed && firstFrame) render(video)
}, { once: true })
video.requestVideoFrameCallback(onVideoFrame)
video.play().catch(() => { /* 流启动失败保持首帧前状态 */ })
const ProcessorCtor = (window as unknown as {
MediaStreamTrackProcessor?: new (init: { track: MediaStreamTrack }) => { readable: ReadableStream<VideoFrame> }
}).MediaStreamTrackProcessor
let stopPump = () => { /* MSTP 不可用时仅有 video 后备 */ }
if (ProcessorCtor) {
// MSTP 会独占消费传入轨道,供 video 后备的轨道需要单独 clone
const pumpTrack = track.clone()
const reader = new ProcessorCtor({ track: pumpTrack }).readable.getReader()
const pump = async () => {
try {
for (;;) {
const { done, value } = await reader.read()
if (done || disposed) {
value?.close()
return
}
lastPumpRender = performance.now()
render(value)
value.close()
}
} catch { /* 轨道停止时 read 抛错,正常退出 */ }
}
void pump()
stopPump = () => {
void reader.cancel().catch(() => { /* 已关闭 */ })
pumpTrack.stop()
}
}
return {
dispose: () => {
if (disposed) return
disposed = true
clearTimeout(decayTimer)
stopPump()
video.srcObject = null
track.stop()
gl.deleteTexture(screenTex)
gl.deleteTexture(targetA.tex)
gl.deleteTexture(targetB.tex)
gl.deleteTexture(motionTex)
gl.deleteFramebuffer(targetA.fbo)
gl.deleteFramebuffer(targetB.fbo)
gl.deleteFramebuffer(motionFbo)
gl.deleteProgram(blurProgram)
gl.deleteProgram(lensProgram)
}
}
}
+477
View File
@@ -0,0 +1,477 @@
import { useEffect, useId, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react'
import GlassFilter from './GlassFilter'
import { generateLensDisplacementMap } from './lensDisplacementMap'
import { createGlassStreamRenderer } from './glassStreamRenderer'
import './liquidGlass.scss'
/**
* 液态玻璃容器,移植自 liquid-glass-react v1.1.1(MIT)
* https://github.com/rdev/liquid-glass-react
*
* 相对原库的调整:
* - 移除内部 Tailwind 类名,全部改为内联样式(WeFlow 无 Tailwind 运行时)
* - 由"固定定位 + 居中平移"的悬浮布局改为常规文档流布局,定位交给调用方
* - 移除原库 overLight 的黑色压暗层:Chromium 中与 backdrop-filter 同级的
* mix-blend 图层无法参与混合、会按普通合成渲染,不透明黑层因此退化为实心黑块。
* 主题适配改由全局兼容层 liquidGlass.scss 的 CSS 变量统一控制
* (--liquid-glass-tint / --liquid-glass-shadow),色调层只使用普通 alpha 合成;
* 边缘高光/悬停辉光均为低透明度白色渐变,混合失效时也只会退化为普通高光,安全
* - 注意:根节点不能设置 isolation / opacity / mask 等会形成 backdrop root 的属性,
* 否则内部 backdrop-filter 将采样不到页面内容,玻璃会失去模糊折射
* - 鼠标追踪仅在 elasticity > 0 时监听,并用 rAF 节流;远离激活区时不触发重渲染
* - 尺寸改用 ResizeObserver 跟踪,内容变化时滤镜区域同步更新
* - 位移贴图不用原库静态 PNG(边缘位移带太窄,宽扁卡片上退化成模糊),
* 改用 lensDisplacementMap 按实际尺寸/圆角生成的透镜贴图:中心零位移完全清晰、
* 边缘全周透镜弯曲;polar/prominent 模式与 Firefox 降级逻辑不保留(固定 Chromium)
*/
/** 鼠标弹性效果的激活半径(距元素边缘的像素距离) */
const ACTIVATION_ZONE = 200
/** 像素源裁剪层向外的出血宽度:容纳模糊核的边缘采样(透镜位移只向内采样,不需要出血) */
const PIXEL_SOURCE_BLEED = 32
interface MouseState {
engaged: boolean
tx: number
ty: number
scaleX: number
scaleY: number
/** 相对元素中心的偏移百分比,用于边缘高光渐变 */
offsetX: number
offsetY: number
}
const IDLE_MOUSE: MouseState = { engaged: false, tx: 0, ty: 0, scaleX: 1, scaleY: 1, offsetX: 0, offsetY: 0 }
/** 玻璃的替代背景采样源:屏幕快照/视频流 + 玻璃所在窗口的屏幕坐标 */
export interface LiquidGlassBackdropImage {
/** 静态快照,视频流启动前的首帧兜底;可为空 */
dataUrl?: string | null
/** 屏幕的 CSS 像素尺寸(即逻辑分辨率) */
width: number
height: number
/** 玻璃所在窗口在屏幕上的位置 */
screenX: number
screenY: number
}
export interface LiquidGlassProps {
children: ReactNode
/** 位移折射强度 */
displacementScale?: number
/** 磨砂程度(0~1,映射为额外的 backdrop blur 像素) */
blurAmount?: number
/** 背景饱和度百分比 */
saturation?: number
/** 边缘色散强度 */
aberrationIntensity?: number
/** 鼠标弹性系数,默认 0(完全关闭鼠标追踪,需要弹性效果时显式开启) */
elasticity?: number
cornerRadius?: number
className?: string
padding?: string
style?: CSSProperties
/**
* 替代背景采样源。Electron 透明窗口内 backdrop-filter 不生效(Chromium 限制),
* 传入与桌面逐像素对齐的屏幕快照后,玻璃改用普通 filter 在内部现场加工快照,
* 视觉效果与真实 backdrop 采样一致
*/
backdropImage?: LiquidGlassBackdropImage
/** 屏幕实时视频流:就绪后替换静态快照,折射随桌面实时更新 */
backdropStream?: MediaStream | null
/**
* 原生玻璃模式(Windows):模糊/折射/色散由主进程的原生面板在窗口下方渲染
* (@hicccc77/electron-liquid-glass,DXGI 零拷贝 + D3D11,感知滞后中位 ~6ms),
* 组件内不再渲染折射背景层,只保留色调纱层、内容与边缘高光
*/
nativeBackdrop?: boolean
onClick?: () => void
}
/** 沿 offsetParent 链累计布局坐标:不受 transform 动画影响,可稳定对齐快照 */
function getLayoutOffset(el: HTMLElement): { x: number; y: number } {
let x = 0
let y = 0
let node: HTMLElement | null = el
while (node) {
x += node.offsetLeft
y += node.offsetTop
node = node.offsetParent as HTMLElement | null
}
return { x, y }
}
export default function LiquidGlass({
children,
displacementScale = 70,
blurAmount = 0.0625,
saturation = 140,
aberrationIntensity = 2,
elasticity = 0,
cornerRadius = 999,
className = '',
padding,
style,
backdropImage,
backdropStream,
nativeBackdrop = false,
onClick
}: LiquidGlassProps) {
const rootRef = useRef<HTMLDivElement>(null)
const rawId = useId()
// useId 可能包含 ':' 等 CSS url() 不接受的字符,需要清洗后才能用作滤镜 id
const filterId = `liquid-glass-${rawId.replace(/[^a-zA-Z0-9_-]/g, '')}`
const [isHovered, setIsHovered] = useState(false)
const [isActive, setIsActive] = useState(false)
const [glassSize, setGlassSize] = useState({ width: 270, height: 69 })
const [anchor, setAnchor] = useState({ x: 0, y: 0 })
const [mouse, setMouse] = useState<MouseState>(IDLE_MOUSE)
const videoRef = useRef<HTMLVideoElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
// 视频流出帧后才切换显示,避免黑帧闪烁
const [streamLive, setStreamLive] = useState(false)
// WebGL 渲染器初始化失败时回退 <video> + SVG 滤镜管线
const [glFailed, setGlFailed] = useState(false)
const useGlPipeline = Boolean(backdropStream) && !glFailed && !nativeBackdrop
// 透镜位移贴图按实际尺寸生成(中心零位移、边缘弯曲);
// WebGL 流管线在着色器内解析求值同一几何,无需贴图;原生模式折射在原生面板完成
const lensMap = useMemo(
() => (useGlPipeline || nativeBackdrop ? null : generateLensDisplacementMap(glassSize.width, glassSize.height, cornerRadius)),
[useGlPipeline, nativeBackdrop, glassSize.width, glassSize.height, cornerRadius]
)
// 贴图生成失败时跳过位移滤镜(模糊和材质层仍然生效)
const refractionFilter = lensMap?.url ? `url(#${filterId})` : undefined
// WebGL 流管线:MediaStreamTrackProcessor 直读帧 + 着色器一次完成模糊/位移/色散/饱和,
// 相比 <video> + CSS/SVG 滤镜省去播出缓冲与滤镜图逐帧重光栅化(延迟↓、GPU 开销↓)
useEffect(() => {
if (!useGlPipeline || !backdropImage) return
const canvas = canvasRef.current
const track = backdropStream?.getVideoTracks()[0]
if (!canvas || !track) return
// clone:同一流可能有多个玻璃实例(新旧通知过渡)与亮度采样共同消费
const renderer = createGlassStreamRenderer({
canvas,
track: track.clone(),
width: glassSize.width,
height: glassSize.height,
cornerRadius,
screenX: backdropImage.screenX + anchor.x,
screenY: backdropImage.screenY + anchor.y,
screenW: backdropImage.width,
screenH: backdropImage.height,
displacementScale,
aberrationIntensity,
saturation,
// 与回退管线的 blur(${(4 + blurAmount * 32) / 2}px) 一致
blurSigma: (4 + blurAmount * 32) / 2,
onFirstFrame: () => setStreamLive(true)
})
if (!renderer) {
setGlFailed(true)
return
}
return () => {
renderer.dispose()
setStreamLive(false)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
useGlPipeline, backdropStream, glassSize.width, glassSize.height, cornerRadius,
backdropImage?.screenX, backdropImage?.screenY, backdropImage?.width, backdropImage?.height,
anchor.x, anchor.y, displacementScale, aberrationIntensity, saturation, blurAmount
])
// 回退管线:<video> 承载流,CSS/SVG 滤镜加工
useEffect(() => {
if (useGlPipeline) return
const video = videoRef.current
if (!video || !backdropStream) return
let cancelled = false
video.srcObject = backdropStream
// 用首帧回调而非 playing 事件:真正出帧的一刻立即显示,最小化感知延迟
video.requestVideoFrameCallback(() => {
if (!cancelled) setStreamLive(true)
})
video.play().catch(() => { /* 流启动失败时保持静态快照/材质层 */ })
return () => {
cancelled = true
video.srcObject = null
setStreamLive(false)
}
}, [useGlPipeline, backdropStream])
// 跟踪自身尺寸与页面内布局位置,供 SVG 滤镜和快照对齐使用
useEffect(() => {
const el = rootRef.current
if (!el) return
const update = () => {
const width = el.offsetWidth
const height = el.offsetHeight
setGlassSize(prev => (prev.width === width && prev.height === height ? prev : { width, height }))
const offset = getLayoutOffset(el)
setAnchor(prev => (prev.x === offset.x && prev.y === offset.y ? prev : offset))
}
update()
const observer = new ResizeObserver(update)
observer.observe(el)
return () => observer.disconnect()
}, [])
// 鼠标接近时的弹性形变(rAF 节流;远离激活区时不重复 setState)
useEffect(() => {
if (elasticity <= 0) return
let rafHandle = 0
let lastEvent: MouseEvent | null = null
const compute = () => {
rafHandle = 0
const el = rootRef.current
if (!el || !lastEvent) return
const rect = el.getBoundingClientRect()
const centerX = rect.left + rect.width / 2
const centerY = rect.top + rect.height / 2
const deltaX = lastEvent.clientX - centerX
const deltaY = lastEvent.clientY - centerY
const edgeDistance = Math.hypot(
Math.max(0, Math.abs(deltaX) - rect.width / 2),
Math.max(0, Math.abs(deltaY) - rect.height / 2)
)
if (edgeDistance > ACTIVATION_ZONE) {
setMouse(prev => (prev.engaged ? IDLE_MOUSE : prev))
return
}
// 距离边缘越近效果越强
const fadeIn = 1 - edgeDistance / ACTIVATION_ZONE
const centerDistance = Math.hypot(deltaX, deltaY)
const stretch = centerDistance === 0 ? 0 : Math.min(centerDistance / 300, 1) * elasticity * fadeIn
const normalX = centerDistance === 0 ? 0 : Math.abs(deltaX / centerDistance)
const normalY = centerDistance === 0 ? 0 : Math.abs(deltaY / centerDistance)
setMouse({
engaged: true,
tx: deltaX * elasticity * 0.1 * fadeIn,
ty: deltaY * elasticity * 0.1 * fadeIn,
// 沿鼠标方向拉伸、垂直方向压缩,模拟液体
scaleX: Math.max(0.8, 1 + normalX * stretch * 0.3 - normalY * stretch * 0.15),
scaleY: Math.max(0.8, 1 + normalY * stretch * 0.3 - normalX * stretch * 0.15),
offsetX: (deltaX / rect.width) * 100,
offsetY: (deltaY / rect.height) * 100
})
}
const handleMouseMove = (event: MouseEvent) => {
lastEvent = event
if (!rafHandle) rafHandle = requestAnimationFrame(compute)
}
document.addEventListener('mousemove', handleMouseMove)
return () => {
document.removeEventListener('mousemove', handleMouseMove)
if (rafHandle) cancelAnimationFrame(rafHandle)
}
}, [elasticity])
const clickable = Boolean(onClick)
const pressed = isActive && clickable
let transform: string | undefined
if (mouse.engaged) {
transform = `translate(${mouse.tx}px, ${mouse.ty}px) ${pressed ? 'scale(0.96)' : `scaleX(${mouse.scaleX}) scaleY(${mouse.scaleY})`}`
} else if (pressed) {
transform = 'scale(0.96)'
}
const borderGradient = (midAlpha: number, peakAlpha: number) => `linear-gradient(
${135 + mouse.offsetX * 1.2}deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, ${midAlpha + Math.abs(mouse.offsetX) * 0.008}) ${Math.max(10, 33 + mouse.offsetY * 0.3)}%,
rgba(255, 255, 255, ${peakAlpha + Math.abs(mouse.offsetX) * 0.012}) ${Math.min(90, 66 + mouse.offsetY * 0.4)}%,
rgba(255, 255, 255, 0) 100%
)`
const overlayBase: CSSProperties = {
position: 'absolute',
inset: 0,
borderRadius: cornerRadius,
pointerEvents: 'none'
}
// 快照/视频与屏幕逐像素对齐:按"窗口屏幕坐标 + 玻璃布局位置"负偏移,
// 再补回裁剪层的出血宽度(像素源挂在 inset:-BLEED 的裁剪层内)
const backdropPixelSourceStyle: CSSProperties | undefined = backdropImage
? {
position: 'absolute',
top: 0,
left: 0,
width: backdropImage.width,
height: backdropImage.height,
maxWidth: 'none',
transform: `translate(${PIXEL_SOURCE_BLEED - (backdropImage.screenX + anchor.x)}px, ${PIXEL_SOURCE_BLEED - (backdropImage.screenY + anchor.y)}px)`,
pointerEvents: 'none'
}
: undefined
// 1.5px 环形高光:通过 padding + mask 挖空中心实现
// 注意不要带深色投影,浅色页面上会形成一圈突兀的黑边
const borderLayerBase: CSSProperties = {
...overlayBase,
padding: 1.5,
WebkitMask: 'linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)',
WebkitMaskComposite: 'xor',
maskComposite: 'exclude',
boxShadow: '0 0 0 0.5px rgba(255, 255, 255, 0.35) inset, 0 1px 3px rgba(255, 255, 255, 0.2) inset'
}
return (
<div
ref={rootRef}
className={`liquid-glass ${className}`.trim()}
style={{
position: 'relative',
borderRadius: cornerRadius,
cursor: clickable ? 'pointer' : undefined,
transform,
transition: 'transform 0.2s ease-out',
boxShadow: 'var(--liquid-glass-shadow)',
...style
}}
onClick={onClick}
onMouseEnter={clickable ? () => setIsHovered(true) : undefined}
onMouseLeave={clickable ? () => { setIsHovered(false); setIsActive(false) } : undefined}
onMouseDown={clickable ? () => setIsActive(true) : undefined}
onMouseUp={clickable ? () => setIsActive(false) : undefined}
>
{lensMap?.url && (
<GlassFilter
id={filterId}
map={lensMap}
displacementScale={displacementScale}
aberrationIntensity={aberrationIntensity}
/>
)}
{/* 折射背景层:常规模式用 backdrop-filter 实时采样页面;
原生模式(Windows)折射由主进程原生面板在窗口下方渲染,这里完全透明;
流模式(透明窗口)优先走 WebGL 管线:采集帧直读进着色器,
模糊/透镜位移/色散/饱和一次完成,画布尺寸即玻璃尺寸;
WebGL 不可用时回退「<video> + CSS/SVG 滤镜」结构:
外层裁剪(圆角)→ 中层挂位移滤镜 → 模糊裁剪层(玻璃+出血)→ 像素源。
提供快照时先显示、视频流出帧后无缝接管;无快照时流首帧淡入 */}
{nativeBackdrop ? null : useGlPipeline && backdropImage ? (
<span style={{ ...overlayBase, overflow: 'hidden' }}>
<canvas
ref={canvasRef}
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
opacity: streamLive ? 1 : 0,
transition: 'opacity 0.12s ease',
pointerEvents: 'none'
}}
/>
</span>
) : backdropImage ? (
<span style={{ ...overlayBase, overflow: 'hidden' }}>
<span style={{ position: 'absolute', inset: 0, filter: refractionFilter }}>
<span
style={{
position: 'absolute',
inset: -PIXEL_SOURCE_BLEED,
overflow: 'hidden',
// 快照模式模糊直接作用于像素源,同参数观感重于 backdrop 采样,减半以保留折射细节
filter: `blur(${(4 + blurAmount * 32) / 2}px) saturate(${saturation}%)`
}}
>
{backdropImage.dataUrl && !streamLive && (
<img src={backdropImage.dataUrl} alt="" style={backdropPixelSourceStyle} />
)}
{backdropStream && (
<video
ref={videoRef}
autoPlay
muted
playsInline
style={{
...backdropPixelSourceStyle,
// 首帧就绪前保持透明、就绪后快速淡入:无快照兜底时折射内容不突兀也不拖沓
opacity: streamLive ? 1 : 0,
transition: 'opacity 0.12s ease'
}}
/>
)}
</span>
</span>
</span>
) : (
<span
style={{
...overlayBase,
overflow: 'hidden',
// 与原库一致的轻模糊基线,保证玻璃通透而非磨砂
backdropFilter: `blur(${4 + blurAmount * 32}px) saturate(${saturation}%)`,
filter: refractionFilter
}}
/>
)}
{/* 主题色调层:由全局兼容层变量控制,保证深浅模式下内容可读;
变量可能被外部按背景高频连续调节(如通知的自适应纱层,采样上限 ~60Hz),
短过渡只负责抹平采样步进,不引入可感知的跟随延迟 */}
<span style={{ ...overlayBase, background: 'var(--liquid-glass-tint)', transition: 'background 0.08s linear' }} />
{/* 内容层保持清晰 */}
<div style={{ position: 'relative', zIndex: 1, padding }}>{children}</div>
{/* 边缘高光双层(screen + overlay 混合),强度由兼容层变量整体缩放 */}
<span style={{ ...borderLayerBase, zIndex: 2, mixBlendMode: 'screen', opacity: 'calc(0.2 * var(--liquid-glass-ring, 1))', background: borderGradient(0.12, 0.4) }} />
<span style={{ ...borderLayerBase, zIndex: 2, mixBlendMode: 'overlay', opacity: 'var(--liquid-glass-ring, 1)', background: borderGradient(0.32, 0.6) }} />
{/* 可点击时的悬停 / 按下辉光 */}
{clickable && (
<>
<div
style={{
...overlayBase,
zIndex: 2,
transition: 'opacity 0.2s ease-out',
opacity: isHovered || isActive ? 0.5 : 0,
backgroundImage: 'radial-gradient(circle at 50% 0%, rgba(255, 255, 255, 0.5) 0%, rgba(255, 255, 255, 0) 50%)',
mixBlendMode: 'overlay'
}}
/>
<div
style={{
...overlayBase,
zIndex: 2,
transition: 'opacity 0.2s ease-out',
opacity: isActive ? 0.5 : 0,
backgroundImage: 'radial-gradient(circle at 50% 0%, rgba(255, 255, 255, 1) 0%, rgba(255, 255, 255, 0) 80%)',
mixBlendMode: 'overlay'
}}
/>
<div
style={{
...overlayBase,
zIndex: 2,
transition: 'opacity 0.2s ease-out',
opacity: isHovered ? 0.4 : isActive ? 0.8 : 0,
backgroundImage: 'radial-gradient(circle at 50% 0%, rgba(255, 255, 255, 1) 0%, rgba(255, 255, 255, 0) 100%)',
mixBlendMode: 'overlay'
}}
/>
</>
)}
</div>
)
}
@@ -0,0 +1,102 @@
// 透镜位移贴图生成器(liquid-glass-react shader 模式思路的像素空间重写)
//
// 原版 shuding 公式在归一化坐标系里求 SDF,对宽扁卡片会把位移全部堆到四角、
// 长边中段几乎无效果。这里改为像素空间按真实圆角计算:
// 沿整个周边取弯曲带(bezel),带内用透镜剖面把采样点拉向中心,
// 中心区域零位移保持完全清晰,边缘全周产生"透过厚玻璃边看背景"的弯曲折射。
//
// 贴图尺寸=元素尺寸、放置在滤镜坐标 (0,0):Chromium 实测 feImage 像素几何
// (0,0,w,h) 与元素原点精确对齐,负坐标/百分比几何解析不可靠
/** 圆角矩形 SDF:内部为负,边界为 0(像素坐标,原点在矩形中心) */
function roundedRectSDF(x: number, y: number, halfW: number, halfH: number, radius: number): number {
const qx = Math.abs(x) - halfW + radius
const qy = Math.abs(y) - halfH + radius
return Math.min(Math.max(qx, qy), 0) + Math.hypot(Math.max(qx, 0), Math.max(qy, 0)) - radius
}
export interface LensDisplacementMap {
/** 贴图 dataURL,生成失败时为空串(此时滤镜退化为无位移) */
url: string
/** 贴图编码的最大位移像素数;SVG 滤镜 scale 取 2×maxScale 时还原几何精确的透镜折射 */
maxScale: number
/** 贴图(=元素)尺寸,feImage 的像素几何 */
width: number
height: number
}
/** 按玻璃尺寸生成透镜位移贴图,R 通道编码 X 位移、G/B 通道编码 Y 位移,0.5 为零位移 */
export function generateLensDisplacementMap(width: number, height: number, cornerRadius: number): LensDisplacementMap {
const w = Math.max(1, Math.round(width))
const h = Math.max(1, Math.round(height))
const canvas = document.createElement('canvas')
canvas.width = w
canvas.height = h
const context = canvas.getContext('2d')
if (!context) return { url: '', maxScale: 0, width: w, height: h }
const halfW = w / 2
const halfH = h / 2
const radius = Math.min(cornerRadius, halfW, halfH)
// 弯曲带只占边缘一圈(真实玻璃只有斜面边缘折射,平板中心不弯曲)。
// iOS 式宽幅湿边:带宽最多占短边一半的 75%(小药丸几乎整体是透镜,
// 大面板保留清晰中心),不超过则弯曲不会叠进中心
const bezel = Math.min(34, Math.min(halfW, halfH) * 0.75)
// 弯月面鼓包峰值,斜率封顶保证映射单射(无断裂、无折叠):
// 上升斜率 1.5/tp·maxBend/bezel ≤ 0.7(tp=0.62)⇒ maxBend = 0.289·bezel
const maxBend = 0.289 * bezel
let maxScale = 0
const rawValues: number[] = []
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
// 以玻璃中心为原点的像素坐标
const px = x + 0.5 - halfW
const py = y + 0.5 - halfH
// 距边界深度(玻璃内部为正)
const depth = -roundedRectSDF(px, py, halfW, halfH, radius)
let dx = 0
let dy = 0
if (depth > 0 && depth < bezel) {
// 液态弯月面剖面(iOS 边缘处理):位移在边界与带内缘均为 0,
// 内容跨玻璃边无缝衔接、平滑并入清晰中心;带内 smoothstep 升降
//(峰值在 t=0.62),近边界抻胀、向内压缩回正,斜率封顶无折叠
const t = depth / bezel
const u = Math.min(1, t < 0.62 ? t / 0.62 : (1 - t) / 0.38)
const hump = u * u * (3 - 2 * u)
const amount = hump * maxBend
// 法线 = 平滑化 max(q,0) 的方向(角区连续旋转,无对角线折痕)
const qx = Math.abs(px) - halfW + radius
const qy = Math.abs(py) - halfH + radius
const soft = Math.max(radius * 0.8, 1)
const sx = 0.5 * (qx + Math.hypot(qx, soft))
const sy = 0.5 * (qy + Math.hypot(qy, soft))
const len = Math.hypot(sx, sy)
if (len > 1e-4) {
// 采样点沿法线向外:近边界内容被抻胀拽入边缘带(弯月面张力观感)
dx = (sx / len) * Math.sign(px || 1) * amount
dy = (sy / len) * Math.sign(py || 1) * amount
}
}
maxScale = Math.max(maxScale, Math.abs(dx), Math.abs(dy))
rawValues.push(dx, dy)
}
}
maxScale = Math.max(maxScale, 1)
const imageData = context.createImageData(w, h)
const data = imageData.data
let rawIndex = 0
let pixelIndex = 0
const normalize = 2 * maxScale // [-max,max] 映射到 [0,1],不裁剪、无平顶色带
for (let i = 0; i < rawValues.length; i += 2) {
const r = rawValues[rawIndex++] / normalize + 0.5
const g = rawValues[rawIndex++] / normalize + 0.5
data[pixelIndex++] = Math.max(0, Math.min(255, Math.round(r * 255))) // R:X 位移
data[pixelIndex++] = Math.max(0, Math.min(255, Math.round(g * 255))) // G:Y 位移
data[pixelIndex++] = Math.max(0, Math.min(255, Math.round(g * 255))) // B:Y 位移(滤镜取 B 通道)
data[pixelIndex++] = 255
}
context.putImageData(imageData, 0, 0)
return { url: canvas.toDataURL(), maxScale, width: w, height: h }
}
@@ -0,0 +1,19 @@
// 液态玻璃全局兼容层
// 所有 LiquidGlass 实例的主题适配统一由这些变量控制:
// - --liquid-glass-tint:玻璃色调层。浅色主题用白色磨砂保证深色文字可读,
// 深色主题用暗色磨砂保证浅色文字可读(替代原库有合成缺陷的黑色 mix-blend 压暗层)
// - --liquid-glass-shadow:玻璃投影 + 1px 描边,浅色页面上靠它与背景分离
// - --liquid-glass-ring:边缘高光环强度系数(0~1)。玻璃直接叠在透明窗口背景上时
// 混合层会全强度过曝,需要调低(如独立通知窗口)
// 个别表面需要微调时,在其玻璃节点的 class 上覆盖同名变量即可
:root {
--liquid-glass-tint: rgba(255, 255, 255, 0.42);
--liquid-glass-shadow: 0 0 0 1px rgba(0, 0, 0, 0.05), 0 12px 40px rgba(0, 0, 0, 0.24);
--liquid-glass-ring: 1;
}
[data-mode="dark"] {
--liquid-glass-tint: rgba(24, 22, 20, 0.4);
--liquid-glass-shadow: 0 0 0 1px rgba(255, 255, 255, 0.07), 0 12px 40px rgba(0, 0, 0, 0.45);
}
+10 -7
View File
@@ -4,7 +4,8 @@
left: 0;
width: 100vw;
height: 100vh;
background-color: var(--bg-primary);
// 保留少量透明度,让 backdrop blur 透出模糊后的应用内容,供解锁卡片折射
background-color: color-mix(in srgb, var(--bg-primary) 86%, transparent);
z-index: 9999;
display: flex;
flex-direction: column;
@@ -14,8 +15,6 @@
-webkit-app-region: drag;
transition: all 0.5s cubic-bezier(0.22, 1, 0.36, 1);
backdrop-filter: blur(25px) saturate(180%);
background-color: var(--bg-primary);
// 让背景带一点透明度以增强毛玻璃效果
opacity: 1;
&.unlocked {
@@ -24,21 +23,25 @@
backdrop-filter: blur(0) saturate(100%);
transform: scale(1.02);
.lock-content {
.lock-card {
transform: translateY(-20px) scale(0.95);
filter: blur(10px);
opacity: 0;
}
}
// 液态玻璃卡片外壳:承载出入场动画
.lock-card {
-webkit-app-region: no-drag;
animation: fadeIn 0.5s cubic-bezier(0.4, 0, 0.2, 1);
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.lock-content {
display: flex;
flex-direction: column;
align-items: center;
width: 320px;
-webkit-app-region: no-drag;
animation: fadeIn 0.5s cubic-bezier(0.4, 0, 0.2, 1);
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
.lock-avatar {
width: 100px;
+11 -1
View File
@@ -1,5 +1,6 @@
import { useState, useEffect, useRef } from 'react'
import { ArrowRight, Fingerprint, Lock, ScanFace, ShieldCheck } from 'lucide-react'
import LiquidGlass from './LiquidGlass'
import './LockScreen.scss'
interface LockScreenProps {
@@ -99,7 +100,14 @@ export default function LockScreen({ onUnlock, avatar, useHello = false }: LockS
return (
<div className={`lock-screen ${isUnlocked ? 'unlocked' : ''}`}>
<div className="lock-content">
<div className="lock-card">
<LiquidGlass
cornerRadius={28}
padding="40px 36px"
displacementScale={48}
aberrationIntensity={1.5}
>
<div className="lock-content">
<div className="lock-avatar">
{avatar ? (
<img src={avatar} alt="User" style={{ width: '100%', height: '100%', borderRadius: '50%' }} />
@@ -138,6 +146,8 @@ export default function LockScreen({ onUnlock, avatar, useHello = false }: LockS
</form>
{error && <div className="lock-error">{error}</div>}
</div>
</LiquidGlass>
</div>
</div>
)
+35 -166
View File
@@ -1,166 +1,34 @@
// 通知卡片:玻璃视觉全部由 LiquidGlass 兼容层渲染,
// 本文件只负责留白、出入场动画与文本排版
.notification-toast-container {
position: fixed;
z-index: 9999;
width: 320px;
background: var(--bg-secondary);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--border-light);
// 浅色模式下使用完全不透明背景,并禁用毛玻璃效果
[data-mode="light"] &,
:not([data-mode]) & {
background: rgba(255, 255, 255, 1);
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
position: relative;
width: 100%;
// 四周留白:容纳卡片投影,并透出窗口底层的仿桌面背景
padding: 12px;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
opacity: 0;
transform: scale(0.95);
pointer-events: none; // Allow clicking through when hidden
transform: scale(0.96);
transition: opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1), transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
pointer-events: none;
&.visible {
opacity: 1;
transform: scale(1);
transform: none;
pointer-events: auto;
}
&.static {
position: relative !important;
width: calc(100% - 4px) !important; // Leave 2px margin for anti-aliasing saftey
height: auto !important; // Fits content
min-height: 0;
top: 0 !important;
bottom: 0 !important;
left: 0 !important;
right: 0 !important;
transform: none !important;
margin: 2px !important; // 2px centered margin
border-radius: 12px !important; // Rounded corners
// 玻璃材质与文字颜色均由 useNotificationAdaptiveTheme 按桌面背景的实际对比度
// 无级计算后写入 <html> 的 --noti-* 变量(纱层方向/浓度、分区连续文字色、光晕强度);
// 此处仅提供首帧采样前的浅色默认值。投影收敛到留白宽度内,避免被窗口矩形裁剪出脏边
--liquid-glass-shadow: var(--noti-shadow, 0 0 0 1px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.22));
--liquid-glass-tint: var(--noti-tint, rgba(255, 255, 255, 0.1));
// Disable backdrop filter
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
// 独立通知窗口:默认使用浅色模式硬编码值,确保不依赖 <html> 上的主题属性
background: #ffffff;
color: #3d3d3d;
--text-primary: #3d3d3d;
--text-secondary: #666666;
--text-tertiary: #999999;
--border-light: rgba(0, 0, 0, 0.08);
// 深色模式覆盖
[data-mode="dark"] & {
background: var(--bg-secondary-solid, #282420);
color: var(--text-primary, #F0EEE9);
--text-primary: #F0EEE9;
--text-secondary: #b3b0aa;
--text-tertiary: #807d78;
--border-light: rgba(255, 255, 255, 0.1);
}
box-shadow: none !important; // NO SHADOW
border: 1px solid var(--border-light);
display: flex;
padding: 16px;
padding-right: 32px; // Make space for close button
box-sizing: border-box;
// Force close button to be visible but transparent background
.notification-close {
opacity: 1 !important;
top: 12px;
right: 12px;
background: transparent !important; // Transparent per user request
&:hover {
color: var(--text-primary);
background: rgba(255, 255, 255, 0.1) !important; // Subtle hover effect
}
}
.notification-time {
top: 24px; // Match padding
right: 40px; // Left of close button (12px + 20px + 8px)
}
}
// Position variants
&.bottom-right {
bottom: 24px;
right: 24px;
transform: translate(0, 20px) scale(0.95);
&.visible {
transform: translate(0, 0) scale(1);
}
}
&.top-right {
top: 24px;
right: 24px;
transform: translate(0, -20px) scale(0.95);
&.visible {
transform: translate(0, 0) scale(1);
}
}
&.bottom-left {
bottom: 24px;
left: 24px;
transform: translate(0, 20px) scale(0.95);
&.visible {
transform: translate(0, 0) scale(1);
}
}
&.top-left {
top: 24px;
left: 24px;
transform: translate(0, -20px) scale(0.95);
&.visible {
transform: translate(0, 0) scale(1);
}
}
&.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;
}
color: var(--noti-title-color, #2c2c2c);
.notification-content {
display: flex;
align-items: flex-start;
gap: 12px;
padding-right: 14px; // 给右上角关闭按钮留出空间
}
.notification-avatar {
@@ -180,29 +48,34 @@
.notification-title {
font-weight: 600;
font-size: 14px;
color: var(--text-primary);
color: var(--noti-title-color, #2c2c2c);
text-shadow: var(--noti-title-halo, none);
transition: color 0.08s linear, text-shadow 0.08s linear;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%; // 允许缩放
flex: 1; // 占据剩余空间
max-width: 100%;
flex: 1;
min-width: 0; // 关键:允许 flex 子项收缩到内容以下
margin-right: 60px; // Make space for absolute time + close button
margin-right: 56px; // 给绝对定位的时间 + 关闭按钮留出空间
}
.notification-time {
font-size: 12px;
color: var(--text-tertiary);
color: var(--noti-title-tertiary, #7a7a7a);
transition: color 0.08s linear;
position: absolute;
top: 16px;
right: 36px; // Left of close button (8px + 20px + 8px)
top: 14px;
right: 40px; // 关闭按钮左侧
font-variant-numeric: tabular-nums;
}
}
.notification-body {
font-size: 13px;
color: var(--text-secondary);
color: var(--noti-body-color, #4d4d4d);
text-shadow: var(--noti-body-halo, none);
transition: color 0.08s linear, text-shadow 0.08s linear;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
@@ -215,8 +88,8 @@
.notification-close {
position: absolute;
top: 8px;
right: 8px;
top: 10px;
right: 10px;
width: 20px;
height: 20px;
border-radius: 50%;
@@ -225,18 +98,14 @@
justify-content: center;
border: none;
background: transparent;
color: var(--text-tertiary);
color: var(--noti-title-tertiary, #7a7a7a);
cursor: pointer;
opacity: 0;
transition: all 0.2s;
z-index: 2;
&:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
color: var(--noti-title-color, #2c2c2c);
background: var(--noti-close-hover-bg, rgba(0, 0, 0, 0.1));
}
}
&:hover .notification-close {
opacity: 1;
}
}
+68 -42
View File
@@ -1,7 +1,8 @@
import React, { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import React, { useEffect, useRef, useState } from 'react'
import { X } from 'lucide-react'
import { Avatar } from './Avatar'
import LiquidGlass, { type LiquidGlassBackdropImage } from './LiquidGlass'
import { renderTextWithEmoji } from '../utils/renderTextWithEmoji'
import './NotificationToast.scss'
export interface NotificationData {
@@ -21,22 +22,43 @@ interface NotificationToastProps {
onClose: () => void
onClick: (data: NotificationData) => void
duration?: number
position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center'
isStatic?: boolean
initialVisible?: boolean
/** 回退管线的屏幕几何信息(含可选桌面快照),玻璃用它对齐折射采样 */
backdropImage?: LiquidGlassBackdropImage
/** 回退管线的屏幕实时视频流,就绪后折射随桌面实时更新;原生模式下为 null */
backdropStream?: MediaStream | null
/** 原生玻璃模式(Windows):折射由主进程原生面板渲染,卡片背景透明 */
nativeBackdrop?: boolean
/** 退场动画开始的一刻触发(原生模式用来提前淡出原生面板) */
onHideStart?: () => void
}
/**
* 通知卡片:始终渲染为全局液态玻璃(LiquidGlass 兼容层),在独立通知窗口内展示。
* 折射背景分两路:Windows 原生模式由主进程的原生面板在窗口下方实时渲染
* (卡片内完全透明),原生不可用时回退为渲染层自采的桌面视频流
*/
export function NotificationToast({
data,
onClose,
onClick,
duration = 5000,
position = 'top-right',
isStatic = false,
initialVisible = false
initialVisible = false,
backdropImage,
backdropStream,
nativeBackdrop = false,
onHideStart
}: NotificationToastProps) {
const [isVisible, setIsVisible] = useState(initialVisible)
const [currentData, setCurrentData] = useState<NotificationData | null>(null)
const onHideStartRef = useRef(onHideStart)
onHideStartRef.current = onHideStart
// 任何路径(超时/关闭/点击)触发的退场都在动画开始的一刻通知外层
const beginHide = () => {
setIsVisible(false)
onHideStartRef.current?.()
}
useEffect(() => {
if (data) {
@@ -44,7 +66,7 @@ export function NotificationToast({
setIsVisible(true)
const timer = setTimeout(() => {
setIsVisible(false)
beginHide()
// clean up data after animation
setTimeout(onClose, 300)
}, duration)
@@ -53,59 +75,63 @@ export function NotificationToast({
} else {
setIsVisible(false)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data, duration, onClose])
if (!currentData) return null
const handleClose = (e: React.MouseEvent) => {
e.stopPropagation()
setIsVisible(false)
beginHide()
setTimeout(onClose, 300)
}
const handleClick = () => {
setIsVisible(false)
beginHide()
setTimeout(() => {
onClose()
onClick(currentData)
}, 300)
}
const content = (
<div
className={`notification-toast-container ${position} ${isVisible ? 'visible' : ''} ${isStatic ? 'static' : ''}`}
onClick={handleClick}
>
<div className="notification-content">
<div className="notification-avatar">
<Avatar
src={currentData.avatarUrl}
name={currentData.title}
size={40}
/>
</div>
<div className="notification-text">
<div className="notification-header">
<span className="notification-title">{currentData.title}</span>
<span className="notification-time">
{new Date(currentData.timestamp * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
return (
<div className={`notification-toast-container ${isVisible ? 'visible' : ''}`}>
<LiquidGlass
cornerRadius={16}
padding="14px 16px"
blurAmount={0}
saturation={140}
displacementScale={70}
aberrationIntensity={1}
backdropImage={backdropImage}
backdropStream={backdropStream}
nativeBackdrop={nativeBackdrop}
onClick={handleClick}
>
<div className="notification-content">
<div className="notification-avatar">
<Avatar
src={currentData.avatarUrl}
name={currentData.title}
size={40}
/>
</div>
<div className="notification-body">
{currentData.content}
<div className="notification-text">
<div className="notification-header">
<span className="notification-title">{currentData.title}</span>
<span className="notification-time">
{new Date(currentData.timestamp * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
<div className="notification-body">
{renderTextWithEmoji(currentData.content, 17)}
</div>
</div>
<button className="notification-close" onClick={handleClose}>
<X size={14} />
</button>
</div>
<button className="notification-close" onClick={handleClose}>
<X size={14} />
</button>
</div>
</LiquidGlass>
</div>
)
if (isStatic) {
return content
}
// Portal to document.body to ensure it's on top
return createPortal(content, document.body)
}
+1 -18
View File
@@ -4,7 +4,7 @@ import { Heart, ChevronRight, ImageIcon, Code, Trash2, MapPin } from 'lucide-rea
import { SnsPost, SnsLinkCardData, SnsLocation } from '../../types/sns'
import { Avatar } from '../Avatar'
import { SnsMediaGrid } from './SnsMediaGrid'
import { getEmojiPath } from 'wechat-emojis'
import { renderTextWithEmoji } from '../../utils/renderTextWithEmoji'
// Helper functions (extracted from SnsPage.tsx but simplified/reused)
const LINK_XML_URL_TAGS = ['url', 'shorturl', 'weburl', 'webpageurl', 'jumpurl']
@@ -437,23 +437,6 @@ export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDeb
})
}
// 解析微信表情
const renderTextWithEmoji = (text: string) => {
if (!text) return text
const parts = text.split(/\[(.*?)\]/g)
return parts.map((part, index) => {
if (index % 2 === 1) {
// @ts-ignore
const path = getEmojiPath(part as any)
if (path) {
return <img key={index} src={`${import.meta.env.BASE_URL}${path}`} alt={`[${part}]`} className="inline-emoji" style={{ width: 22, height: 22, verticalAlign: 'bottom', margin: '0 1px' }} />
}
return `[${part}]`
}
return part
})
}
const handleDeleteClick = (e: React.MouseEvent) => {
e.stopPropagation()
if (deleting || dbDeleted) return
+11 -8
View File
@@ -12,20 +12,23 @@
z-index: 9999;
animation: fadeIn 0.3s ease-out;
// 液态玻璃外壳:宽度与入场动画在玻璃根节点上
.update-dialog-glass {
width: min(680px, calc(100vw - 48px));
animation: slideUp 0.3s ease-out;
}
.update-dialog {
width: 680px;
background: var(--bg-secondary, #f5f5f5);
width: 100%;
border-radius: 24px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2);
overflow: hidden;
position: relative;
animation: slideUp 0.3s ease-out;
display: flex;
flex-direction: column;
/* Top Section (White/Gradient) */
/* Top Section (White/Gradient),半透明以透出玻璃折射 */
.dialog-header {
background: var(--bg-primary, #ffffff);
background: color-mix(in srgb, var(--bg-primary, #ffffff) 78%, transparent);
padding: 40px 20px 30px;
display: flex;
flex-direction: column;
@@ -72,9 +75,9 @@
}
}
/* Content Section (Light Gray) */
/* Content Section (Light Gray),半透明以透出玻璃折射 */
.dialog-content {
background: var(--bg-tertiary, #f2f2f2);
background: color-mix(in srgb, var(--bg-tertiary, #f2f2f2) 72%, transparent);
padding: 24px 40px 40px;
flex: 1;
display: flex;
+10 -2
View File
@@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react'
import { Quote, X } from 'lucide-react'
import LiquidGlass from './LiquidGlass'
import './UpdateDialog.scss'
interface UpdateInfo {
@@ -70,7 +71,13 @@ const UpdateDialog: React.FC<UpdateDialogProps> = ({
return (
<div className="update-dialog-overlay">
<div className="update-dialog">
<LiquidGlass
className="update-dialog-glass"
cornerRadius={24}
displacementScale={40}
aberrationIntensity={1.5}
>
<div className="update-dialog">
{!isDownloading && !isMandatory && (
<button className="close-btn" onClick={onClose}>
<X size={20} />
@@ -135,7 +142,8 @@ const UpdateDialog: React.FC<UpdateDialogProps> = ({
</div>
)}
</div>
</div>
</div>
</LiquidGlass>
</div>
)
}
+5 -25
View File
@@ -8,29 +8,17 @@
animation: capsuleSlideDown 0.4s cubic-bezier(0.16, 1, 0.3, 1);
user-select: none;
&:hover {
.capsule-content {
background: rgba(255, 255, 255, 0.95);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.12);
transform: scale(1.02);
}
}
// 视觉背景(毛玻璃、边框、阴影)由 LiquidGlass 提供,这里只负责内容布局
.capsule-content {
background: var(--bg-primary);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
padding: 8px 18px;
border-radius: 24px;
border: 1px solid var(--border-color);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.12);
display: flex;
align-items: center;
gap: 12px;
height: 40px;
position: relative;
overflow: hidden;
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
transition: background 0.3s cubic-bezier(0.16, 1, 0.3, 1);
.icon-wrapper {
display: flex;
@@ -112,11 +100,10 @@
}
}
// State Modifiers
// State Modifiers(改为半透明色调叠加在玻璃之上)
&.state-available {
.capsule-content {
background: var(--primary);
border-color: rgba(255, 255, 255, 0.1);
background: color-mix(in srgb, var(--primary) 82%, transparent);
color: white;
.icon-wrapper {
@@ -139,16 +126,9 @@
}
}
&.state-downloading {
.capsule-content {
background: var(--bg-primary);
}
}
&.state-error {
.capsule-content {
background: #fff1f0;
border-color: #ffa39e;
background: color-mix(in srgb, #ff4d4f 14%, transparent);
.icon-wrapper {
color: #ff4d4f;
+17 -9
View File
@@ -1,6 +1,7 @@
import React from 'react'
import { useAppStore } from '../stores/appStore'
import { Download, X, AlertCircle, Info } from 'lucide-react'
import LiquidGlass from './LiquidGlass'
import './UpdateProgressCapsule.scss'
const UpdateProgressCapsule: React.FC = () => {
@@ -102,15 +103,22 @@ const UpdateProgressCapsule: React.FC = () => {
}
return (
<div className={capsuleClass} onClick={() => setShowUpdateDialog(true)}>
<div className="capsule-content">
{content}
{!isDownloading && (
<button className="capsule-close" onClick={handleClose}>
<X size={12} />
</button>
)}
</div>
<div className={capsuleClass}>
<LiquidGlass
cornerRadius={24}
displacementScale={48}
aberrationIntensity={1.5}
onClick={() => setShowUpdateDialog(true)}
>
<div className="capsule-content">
{content}
{!isDownloading && (
<button className="capsule-close" onClick={handleClose}>
<X size={12} />
</button>
)}
</div>
</LiquidGlass>
</div>
)
}
+9 -5
View File
@@ -13,16 +13,20 @@
animation: windowCloseDialogFadeIn 0.2s ease-out;
}
.window-close-dialog {
// 液态玻璃外壳:宽度与入场动画在玻璃根节点上
.window-close-dialog-glass {
width: min(560px, 100%);
border: 1px solid color-mix(in srgb, var(--border-color) 78%, transparent);
animation: windowCloseDialogSlideUp 0.24s cubic-bezier(0.16, 1, 0.3, 1);
}
.window-close-dialog {
width: 100%;
border-radius: 24px;
// 半透明背景透出玻璃折射,边框与阴影由 LiquidGlass 提供
background:
linear-gradient(180deg, color-mix(in srgb, var(--bg-primary) 94%, white 6%) 0%, var(--bg-primary) 100%);
box-shadow: 0 28px 80px rgba(0, 0, 0, 0.32);
linear-gradient(180deg, color-mix(in srgb, var(--bg-primary) 72%, transparent) 0%, color-mix(in srgb, var(--bg-primary) 84%, transparent) 100%);
overflow: hidden;
position: relative;
animation: windowCloseDialogSlideUp 0.24s cubic-bezier(0.16, 1, 0.3, 1);
}
.window-close-dialog-header {
+15 -7
View File
@@ -1,5 +1,6 @@
import { Minimize2, Power, X } from 'lucide-react'
import { useEffect, useState } from 'react'
import LiquidGlass from './LiquidGlass'
import './WindowCloseDialog.scss'
interface WindowCloseDialogProps {
@@ -39,13 +40,19 @@ export default function WindowCloseDialog({
return (
<div className="window-close-dialog-overlay" onClick={onCancel}>
<div
className="window-close-dialog"
onClick={(event) => event.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="window-close-dialog-title"
<LiquidGlass
className="window-close-dialog-glass"
cornerRadius={24}
displacementScale={40}
aberrationIntensity={1.5}
>
<div
className="window-close-dialog"
onClick={(event) => event.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="window-close-dialog-title"
>
<button
type="button"
className="window-close-dialog-close"
@@ -118,7 +125,8 @@ export default function WindowCloseDialog({
取消
</button>
</div>
</div>
</div>
</LiquidGlass>
</div>
)
}
+1 -28
View File
@@ -9,7 +9,7 @@ import { useBatchTranscribeStore, type BatchVoiceTaskType } from '../stores/batc
import { useBatchImageDecryptStore } from '../stores/batchImageDecryptStore'
import type { ChatRecordItem, ChatSession, Message } from '../types/models'
import type { GroupSummaryRecord, GroupSummaryRecordSummary } from '../types/electron'
import { getEmojiPath } from 'wechat-emojis'
import { renderTextWithEmoji } from '../utils/renderTextWithEmoji'
import { VoiceTranscribeDialog } from '../components/VoiceTranscribeDialog'
import { LivePhotoIcon } from '../components/LivePhotoIcon'
import { AnimatedStreamingText } from '../components/AnimatedStreamingText'
@@ -10985,33 +10985,6 @@ function MessageBubble({
return content.replace(/^[a-zA-Z0-9]+@openim:\n?/, '')
}, [])
// 解析混合文本和表情
const renderTextWithEmoji = useCallback((text: string) => {
if (!text) return text
const parts = text.split(/\[(.*?)\]/g)
return parts.map((part, index) => {
// 奇数索引是捕获组的内容(即括号内的文字)
if (index % 2 === 1) {
// @ts-ignore
const path = getEmojiPath(part as any)
if (path) {
// path 例如 'assets/face/微笑.png',需要添加 base 前缀
return (
<img
key={index}
src={`${import.meta.env.BASE_URL}${path}`}
alt={`[${part}]`}
className="inline-emoji"
style={{ width: 22, height: 22, verticalAlign: 'bottom', margin: '0 1px' }}
/>
)
}
return `[${part}]`
}
return part
})
}, [])
const cleanedParsedContent = useMemo(
() => cleanMessageContent(message.parsedContent || ''),
[cleanMessageContent, message.parsedContent]
+1 -1
View File
@@ -44,7 +44,7 @@ export type TextExportFormat =
| 'weclone'
| 'sql'
export type SnsTimelineExportFormat = 'json' | 'html' | 'arkmejson'
export type SnsTimelineExportFormat = 'json' | 'html' | 'arkmejson' | 'markdown'
export type ContactsSortKey = 'messageCount' | 'latestMessageTime'
export type ContactsSortOrder = 'desc' | 'asc'
+8 -30
View File
@@ -1,7 +1,9 @@
// 卡片动画可以安全使用 opacity:回退管线的玻璃像素源自含在卡片内(视频流画布),
// 原生模式的折射面板在窗口之下、由主进程独立同步淡入淡出,均不受卡片透明度影响
@keyframes noti-enter {
0% {
opacity: 0;
transform: translateY(-20px) scale(0.96);
transform: translateY(-24px) scale(0.9);
}
100% {
@@ -22,29 +24,16 @@
}
}
// 旧卡片原地缩小淡出:不做位移,避免从新卡片背后探出边缘
@keyframes noti-exit {
0% {
opacity: 1;
transform: scale(1) translateY(0);
filter: blur(0);
transform: scale(1);
}
100% {
opacity: 0;
transform: scale(0.92) translateY(4px);
filter: blur(2px);
}
}
@keyframes noti-exit-center {
0% {
opacity: 1;
transform: translateY(0) scale(1);
}
100% {
opacity: 0;
transform: translateY(-50px) scale(0.7);
transform: scale(0.94);
}
}
@@ -56,18 +45,12 @@ body {
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;
animation: noti-enter 0.4s cubic-bezier(0.16, 1, 0.3, 1);
&.anim-center {
animation: noti-enter-center 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards;
animation: noti-enter-center 0.5s cubic-bezier(0.16, 1, 0.3, 1);
}
}
@@ -75,12 +58,7 @@ body {
// 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;
&.anim-center {
animation: noti-exit-center 0.5s cubic-bezier(0.33, 1, 0.68, 1) forwards;
}
}
+232 -87
View File
@@ -1,22 +1,40 @@
import { useEffect, useState, useRef } from 'react'
import { NotificationToast, type NotificationData } from '../components/NotificationToast'
import type { LiquidGlassBackdropImage } from '../components/LiquidGlass'
import {
useNotificationAdaptiveTheme,
useNotificationNativeAdaptiveTheme,
getLayoutRect,
NATIVE_BAND_IDS
} from './useNotificationAdaptiveTheme'
import '../components/NotificationToast.scss'
import './NotificationWindow.scss'
/** 与 NotificationToast 传给 LiquidGlass 的参数保持一致(原生面板需要同一套值) */
const GLASS_PARAMS = { cornerRadius: 16, blurSigma: 2, displacementScale: 70, aberrationIntensity: 1, saturation: 140 }
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')
// 主进程随通知下发的屏幕几何信息(尺寸 + 窗口坐标)。
// 回退管线(原生玻璃不可用时)用它把桌面视频流与屏幕逐像素对齐——
// Electron 透明窗口内 backdrop-filter 采不到桌面,渲染层自采视频流是回退路径下唯一的折射来源
const [backdrop, setBackdrop] = useState<LiquidGlassBackdropImage | undefined>(undefined)
// 原生玻璃模式(Windows):折射由主进程的原生面板在窗口下方提供,
// 渲染层不开视频流、不渲染折射画布,只负责上报卡片几何与内容层
const [nativeBackdrop, setNativeBackdrop] = useState(false)
// 屏幕实时视频流(仅回退管线使用):通知展示期间开启,玻璃折射随桌面实时更新;
// 隐藏后释放采集。原生模式下始终为 null
const [stream, setStream] = useState<MediaStream | null>(null)
const streamRef = useRef<MediaStream | null>(null)
const sourceIdRef = useRef<string | 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).
// 事件回调里需要读取"当前展示中"的通知作为过渡的旧通知,用 ref 避免重建监听
const notificationRef = useRef<NotificationData | null>(null)
// 上次上报的窗口尺寸:重复上报会触发主进程 setSize,
// 可见状态下反复设置尺寸会让 DWM 短暂拉伸旧帧缓冲,闪出一圈幽灵轮廓
const lastSizeRef = useRef<{ width: number; height: number } | null>(null)
useEffect(() => {
notificationRef.current = notification
@@ -24,7 +42,6 @@ export default function NotificationWindow() {
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)}`,
@@ -38,12 +55,20 @@ export default function NotificationWindow() {
avatarUrl: data.avatarUrl
}
// 获取位置配置
if (data.position) {
setPosition(data.position)
}
if (data.backdrop) {
setBackdrop({
width: data.backdrop.width,
height: data.backdrop.height,
screenX: data.backdrop.winX,
screenY: data.backdrop.winY
})
setNativeBackdrop(Boolean(data.backdrop.native))
sourceIdRef.current = data.backdrop.sourceId ?? null
}
// Set previous to current (ref)
if (notificationRef.current) {
setPrevNotification(notificationRef.current)
}
@@ -67,6 +92,129 @@ export default function NotificationWindow() {
}
}, [prevNotification])
// 分区无级自适应:整卡驱动玻璃纱层方向与浓度,标题行/正文按各自背后区域
// 的实际对比度连续取色(算法详见 useNotificationAdaptiveTheme)。
// 流模式从视频帧采样;原生模式吃原生面板回读的亮度带事件
useNotificationAdaptiveTheme(stream, backdrop)
useNotificationNativeAdaptiveTheme(nativeBackdrop)
const stopStream = () => {
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop())
streamRef.current = null
setStream(null)
}
}
// 回退管线(原生玻璃不可用时):通知展示期间开启屏幕采集流
// (通知窗口已被 content protection 排除,不会拍到自己);
// 全部隐藏后立即停止采集释放资源。原生模式下 sourceId 为空,永不触发
useEffect(() => {
const active = Boolean(notification || prevNotification)
if (active && !streamRef.current && sourceIdRef.current && !nativeBackdrop) {
let cancelled = false
const dpr = window.devicePixelRatio || 1
const captureW = Math.round(window.screen.width * dpr)
const captureH = Math.round(window.screen.height * dpr)
navigator.mediaDevices.getUserMedia({
audio: false,
video: {
// Electron 专有约束:按 sourceId 采集屏幕,无需用户手势
mandatory: {
chromeMediaSource: 'desktop',
chromeMediaSourceId: sourceIdRef.current,
minWidth: captureW,
maxWidth: captureW,
minHeight: captureH,
maxHeight: captureH,
maxFrameRate: 60
}
}
} as unknown as MediaStreamConstraints).then(mediaStream => {
if (cancelled) {
mediaStream.getTracks().forEach(track => track.stop())
return
}
// 提示采集调度器优先保帧率而非画质(本地渲染无编码环节,无画质代价)
mediaStream.getVideoTracks().forEach(track => { track.contentHint = 'motion' })
streamRef.current = mediaStream
setStream(mediaStream)
}).catch(error => {
console.warn('[NotificationWindow] 屏幕流获取失败,折射退化为兜底材质:', error)
})
return () => {
cancelled = true
}
}
if (!active) stopStream()
}, [notification, prevNotification, nativeBackdrop])
// 原生玻璃模式:卡片挂载后上报实测几何(窗口本地 CSS 像素 + 卡片本地亮度带),
// 主进程据此创建/复用窗口下方的原生面板;参数与 LiquidGlass 的视觉参数一致
useEffect(() => {
if (!nativeBackdrop || !notification) return
// 同一条通知内的重复上报去重(双 rAF 首测 + 120ms 复测几何通常一致):
// 跳过后主进程不会白做 setBounds/setLumaBands/anchor 原生调用,
// 也避免 setLumaBands 触发的一次带 GPU 同步等待的亮度补采。
// 局部变量随 effect 重建,新通知(即使几何相同)必然重新上报以驱动面板 show
let lastSent = ''
const report = () => {
const host = document.getElementById('notification-current')
const glassEl = host?.querySelector<HTMLElement>('.liquid-glass')
if (!glassEl) return
const card = getLayoutRect(glassEl)
if (card.width < 1 || card.height < 1) return
const bands: Array<{ id: number; x: number; y: number; width: number; height: number }> = [
{ id: NATIVE_BAND_IDS.card, x: 0, y: 0, width: card.width, height: card.height }
]
const bandEls: Array<[number, HTMLElement | null]> = [
[NATIVE_BAND_IDS.title, host?.querySelector<HTMLElement>('.notification-header') ?? null],
[NATIVE_BAND_IDS.body, host?.querySelector<HTMLElement>('.notification-body') ?? null]
]
for (const [id, el] of bandEls) {
if (!el) continue
const rect = getLayoutRect(el)
bands.push({ id, x: rect.left - card.left, y: rect.top - card.top, width: rect.width, height: rect.height })
}
const payload = {
card: { x: card.left, y: card.top, width: card.width, height: card.height },
bands,
// CSS px → 物理 px 的换算系数(devicePixelRatio 已含页面缩放,
// 主进程不能只用显示器 scaleFactor:缩放会随 file:// 域持久化)
dpr: window.devicePixelRatio || 1,
...GLASS_PARAMS
}
const key = JSON.stringify(payload)
if (key === lastSent) return
lastSent = key
window.electronAPI?.notification?.glassRect?.(payload)
}
// 双 rAF 等首次布局落定后测量;120ms 复测一次,覆盖表情图/字体就绪导致的高度变化
let raf2 = 0
const raf1 = requestAnimationFrame(() => { raf2 = requestAnimationFrame(report) })
const timer = setTimeout(report, 120)
return () => {
cancelAnimationFrame(raf1)
cancelAnimationFrame(raf2)
clearTimeout(timer)
}
}, [nativeBackdrop, notification, position])
// 兜底释放:窗口被主进程隐藏或组件卸载时,无论上层状态如何都立即停止采集,
// 确保任何路径下都不会在后台持续占用采集管线
useEffect(() => {
const onVisibilityChange = () => {
if (document.visibilityState === 'hidden') stopStream()
}
document.addEventListener('visibilitychange', onVisibilityChange)
return () => {
document.removeEventListener('visibilitychange', onVisibilityChange)
stopStream()
}
}, [])
const handleClose = () => {
setNotification(null)
setPrevNotification(null)
@@ -90,96 +238,93 @@ export default function NotificationWindow() {
}
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)
}
if (root && window.electronAPI?.notification?.resize) {
const width = position === 'top-center' ? 280 : 344
const height = Math.min(Math.ceil(root.getBoundingClientRect().height), 300)
const last = lastSizeRef.current
if (last && last.width === width && last.height === height) return
lastSizeRef.current = { width, height }
window.electronAPI.notification.resize(width, height)
}
}, 50)
return () => clearTimeout(timer)
}, [notification, prevNotification])
}, [notification, prevNotification, position])
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'
}}>
<>
<div
id="notification-root"
style={{
width: '100vw',
height: 'auto',
background: 'transparent',
position: 'relative', // Context for absolute children
overflow: 'hidden' // Prevent scrollbars during transition
}}>
{/* Previous Notification (Background / Fading Out) */}
{prevNotification && (
<div
id="notification-prev"
key={prevNotification.id}
className={position === 'top-center' ? 'anim-center' : ''}
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
{/* Previous Notification (Background / Fading Out) */}
{prevNotification && (
<div
id="notification-prev"
key={prevNotification.id}
data={prevNotification}
onClose={() => { }} // No-op for background item
onClick={() => { }}
position={position as any}
isStatic={true}
initialVisible={true}
/>
</div>
)}
className={position === 'top-center' ? 'anim-center' : ''}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
zIndex: 1,
pointerEvents: 'none' // Disable interaction on old one
}}
>
<NotificationToast
key={prevNotification.id}
data={prevNotification}
onClose={() => { }} // No-op for background item
onClick={() => { }}
initialVisible={true}
backdropImage={backdrop}
backdropStream={stream}
nativeBackdrop={nativeBackdrop}
/>
</div>
)}
{/* Current Notification (Foreground / Fading In) */}
{notification && (
<div
id="notification-current"
key={notification.id}
className={position === 'top-center' ? 'anim-center' : ''}
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={position as any}
isStatic={true}
initialVisible={true}
/>
</div>
)}
</div>
{/* Current Notification (Foreground / Fading In) */}
{notification && (
<div
id="notification-current"
key={notification.id}
className={position === 'top-center' ? 'anim-center' : ''}
style={{
position: 'relative', // Takes up space
zIndex: 2,
width: '100%'
}}
>
<NotificationToast
key={notification.id} // Ensure remount for animation
data={notification}
onClose={handleClose}
onClick={handleClick}
initialVisible={true}
backdropImage={backdrop}
backdropStream={stream}
nativeBackdrop={nativeBackdrop}
// 退场动画开始的一刻同步淡出原生面板(与卡片 0.3s 渐隐节奏匹配)
onHideStart={nativeBackdrop ? () => window.electronAPI?.notification?.glassHide?.() : undefined}
/>
</div>
)}
</div>
</>
)
}
+12 -3
View File
@@ -1,5 +1,5 @@
import { useEffect, useLayoutEffect, useState, useRef, useCallback, useMemo } from 'react'
import { RefreshCw, Search, X, Download, FolderOpen, FileJson, FileText, Image, CheckCircle, AlertCircle, Calendar, Info, Shield, ShieldOff, Loader2, Pause, Play, Square } from 'lucide-react'
import { RefreshCw, Search, X, Download, FolderOpen, FileCode, FileJson, FileText, Image, CheckCircle, AlertCircle, Calendar, Info, Shield, ShieldOff, Loader2, Pause, Play, Square } from 'lucide-react'
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
import './SnsPage.scss'
import { SnsPost } from '../types/sns'
@@ -86,7 +86,7 @@ interface SnsExportResult {
interface SnsExportRequest {
taskId: string
outputDir: string
format: 'json' | 'html' | 'arkmejson'
format: 'json' | 'html' | 'arkmejson' | 'markdown'
usernames?: string[]
keyword?: string
exportImages: boolean
@@ -204,7 +204,7 @@ export default function SnsPage() {
// 导出相关状态
const [showExportDialog, setShowExportDialog] = useState(false)
const [exportScope, setExportScope] = useState<SnsExportScope>({ kind: 'all' })
const [exportFormat, setExportFormat] = useState<'json' | 'html' | 'arkmejson'>('html')
const [exportFormat, setExportFormat] = useState<'json' | 'html' | 'arkmejson' | 'markdown'>('html')
const [exportFolder, setExportFolder] = useState('')
const [exportImages, setExportImages] = useState(false)
const [exportLivePhotos, setExportLivePhotos] = useState(false)
@@ -2406,6 +2406,15 @@ export default function SnsPage() {
<span>HTML</span>
<small>浏览器可直接查看</small>
</button>
<button
className={`format-option ${exportFormat === 'markdown' ? 'active' : ''}`}
onClick={() => setExportFormat('markdown')}
disabled={isExportLocked}
>
<FileCode size={20} />
<span>Markdown</span>
<small>纯文本,通用性强</small>
</button>
<button
className={`format-option ${exportFormat === 'json' ? 'active' : ''}`}
onClick={() => setExportFormat('json')}
+419
View File
@@ -0,0 +1,419 @@
import { useEffect } from 'react'
import type { LiquidGlassBackdropImage } from '../components/LiquidGlass'
/**
* 通知卡片的"无级自适应反色":对比度驱动,替代旧的亮度阈值二值反色。
*
* 每次采样:
* 1. 取整卡/标题行/正文三个区域的背景均值色与明暗分位数。采样来源二选一:
* - Chromium 桌面视频流(跨平台回退管线):48×16 缩略图 drawImage 采样
* - 原生玻璃面板(Windows):原生层重绘的同一帧从模糊纹理回读并推送
* (notification:luma 事件,上限 ~60Hz,桌面静止时不推送)
* 2. 按液态玻璃的实际加工链(saturate 140% → 纱层 alpha 合成)推算玻璃内的真实背景色,
* 保证判定依据与用户看到的像素一致(裸桌面色与玻璃内呈现色可能差一个档位)
* 3. 材质先补偿:纱层方向优先贴合桌面明暗(浅底白纱、深底黑纱,滞回防抖),
* 仅当贴合方向即使最大补偿也无法达标而反方向可行时才反转;
* 纱层浓度取"宽松锚点文字色恰好达标"的最小 alpha——中间调背景下玻璃自动变浊换取可读性
* 4. 文字后升级:在"宽松锚点(设计稿层级灰阶)↔ 极端锚点(近黑/纯白)"之间连续插值,
* 取达到目标对比度的最浅一档;背景充裕时保持原色,不足时无级加深/提亮
* 5. 光晕兜底:按最不利分位数背景的对比度缺口连续调节 text-shadow 强度,
* 覆盖同一区域内明暗混杂、均值失真的场景
*
* 结果写入 <html> 内联的 --noti-* 变量,由 NotificationToast.scss 消费
*/
type RGB = readonly [number, number, number]
interface TextAnchor {
/** 对比度充裕时使用的设计稿原色(保留视觉层级) */
relaxed: RGB
/** 对比度不足时插值逼近的极端色 */
strong: RGB
}
export interface BandSample {
mean: RGB
/** 区域内 luma 的 p15 / p85(gamma 域 0-255),用于评估明暗混杂时的最不利背景 */
darkTail: number
lightTail: number
}
const SAMPLE_W = 48
const SAMPLE_H = 16
/** 相邻两次采样的最小间隔:采样由视频出帧驱动,此值防止背景高频变化时空转(~30Hz) */
const MIN_SAMPLE_GAP_MS = 33
/** 兜底轮询间隔:桌面静止(视频不出帧)但通知自身布局变化时仍能刷新 */
const FALLBACK_INTERVAL_MS = 250
/** 与 NotificationToast 传给 LiquidGlass 的 saturation=140 保持一致 */
const GLASS_SATURATION = 1.4
/** WCAG AA 小字号文本的目标对比度 */
const TARGET_CONTRAST = 4.5
/** 极性切换所需的对比度优势(滞回),避免临界背景来回翻转 */
const POLARITY_MARGIN = 0.5
/** 小幅波动的指数平滑系数(0~1,越大跟随越快) */
const EMA_FACTOR = 0.6
/** 均值 luma 突变超过此值视为场景切换:跳过平滑直接采用新值 */
const SNAP_LUMA_DELTA = 24
/** 采样区域向外扩张:文字边缘的背景同样影响可读性 */
const RECT_BLEED = 4
const WHITE_VEIL: RGB = [255, 255, 255]
const DARK_VEIL: RGB = [22, 20, 18]
/** 纱层 alpha 求解范围:下限保持玻璃通透,上限防止退化成实心色块 */
const WHITE_VEIL_ALPHA: readonly [number, number] = [0.1, 0.38]
const DARK_VEIL_ALPHA: readonly [number, number] = [0.35, 0.6]
/** 文字锚点:dark = 深色文字(浅背景用),light = 浅色文字(深背景用) */
const ANCHORS: Record<'title' | 'body' | 'tertiary', { dark: TextAnchor; light: TextAnchor }> = {
title: {
dark: { relaxed: [44, 44, 44], strong: [10, 10, 10] },
light: { relaxed: [240, 238, 233], strong: [255, 255, 255] }
},
body: {
dark: { relaxed: [77, 77, 77], strong: [17, 17, 17] },
light: { relaxed: [217, 214, 208], strong: [255, 255, 255] }
},
tertiary: {
dark: { relaxed: [122, 122, 122], strong: [61, 61, 61] },
light: { relaxed: [176, 172, 166], strong: [216, 213, 207] }
}
}
const SHADOW_ON_LIGHT = '0 0 0 1px rgba(0, 0, 0, 0.04), 0 4px 12px rgba(0, 0, 0, 0.22)'
const SHADOW_ON_DARK = '0 0 0 1px rgba(255, 255, 255, 0.06), 0 4px 12px rgba(0, 0, 0, 0.4)'
// —— 颜色数学(输入输出均为 sRGB 0-255)——
const clamp01 = (v: number) => Math.min(1, Math.max(0, v))
const clamp255 = (v: number) => Math.min(255, Math.max(0, v))
const lerp = (a: number, b: number, t: number) => a + (b - a) * t
const lerpRgb = (a: RGB, b: RGB, t: number): RGB => [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t)]
const gammaLuma = (c: RGB) => 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]
const channelLinear = (v: number) => {
const s = v / 255
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4)
}
/** WCAG 相对亮度(线性光域,区别于阈值判定用的 gamma luma) */
const relativeLuminance = (c: RGB) =>
0.2126 * channelLinear(c[0]) + 0.7152 * channelLinear(c[1]) + 0.0722 * channelLinear(c[2])
/** WCAG 对比度(1~21) */
const contrastRatio = (a: RGB, b: RGB) => {
const la = relativeLuminance(a)
const lb = relativeLuminance(b)
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05)
}
/** 近似 CSS saturate():以 luma 为轴拉伸色度 */
const saturateRgb = (c: RGB, s: number): RGB => {
const l = gammaLuma(c)
return [clamp255(l + (c[0] - l) * s), clamp255(l + (c[1] - l) * s), clamp255(l + (c[2] - l) * s)]
}
/** 纱层色以 alpha 覆盖在背景上的合成结果 */
const compositeVeil = (veil: RGB, alpha: number, bg: RGB): RGB => [
lerp(bg[0], veil[0], alpha),
lerp(bg[1], veil[1], alpha),
lerp(bg[2], veil[2], alpha)
]
/** 保持色相整体缩放亮度:从均值色推算最不利分位数处的背景色 */
const scaleRgb = (c: RGB, factor: number): RGB => [clamp255(c[0] * factor), clamp255(c[1] * factor), clamp255(c[2] * factor)]
const cssRgb = (c: RGB) => `rgb(${Math.round(c[0])}, ${Math.round(c[1])}, ${Math.round(c[2])})`
const cssRgba = (c: RGB, alpha: number) =>
`rgba(${Math.round(c[0])}, ${Math.round(c[1])}, ${Math.round(c[2])}, ${alpha.toFixed(2)})`
// —— 单调量二分求解 ——
/** 在宽松↔极端锚点间找达到目标对比度的最小插值系数 t(对比度随 t 单调上升) */
function solveTextTone(anchor: TextAnchor, bg: RGB, target: number): { color: RGB; t: number } {
if (contrastRatio(anchor.relaxed, bg) >= target) return { color: anchor.relaxed, t: 0 }
if (contrastRatio(anchor.strong, bg) < target) return { color: anchor.strong, t: 1 }
let lo = 0
let hi = 1
for (let i = 0; i < 7; i++) {
const mid = (lo + hi) / 2
if (contrastRatio(lerpRgb(anchor.relaxed, anchor.strong, mid), bg) >= target) hi = mid
else lo = mid
}
return { color: lerpRgb(anchor.relaxed, anchor.strong, hi), t: hi }
}
/** 找使宽松锚点文字色达标的最小纱层 alpha(对比度随 alpha 单调上升);不可达时取上限,由文字升级接棒 */
function solveVeilAlpha(
veil: RGB,
range: readonly [number, number],
bg: RGB,
relaxedText: RGB,
target: number
): number {
const [min, max] = range
if (contrastRatio(relaxedText, compositeVeil(veil, min, bg)) >= target) return min
if (contrastRatio(relaxedText, compositeVeil(veil, max, bg)) < target) return max
let lo = min
let hi = max
for (let i = 0; i < 7; i++) {
const mid = (lo + hi) / 2
if (contrastRatio(relaxedText, compositeVeil(veil, mid, bg)) >= target) hi = mid
else lo = mid
}
return hi
}
interface BandTone {
polarity: 'dark' | 'light'
color: RGB
/** 宽松→极端锚点的插值进度,供同区域次级文字(时间等)联动取色 */
t: number
/** 最不利分位数背景下的对比度缺口(0~1),驱动光晕强度 */
deficit: number
}
/** 沿 offsetParent 链累计布局坐标:不受出入场 transform 影响,采样区域始终对准卡片落点 */
export function getLayoutRect(el: HTMLElement): { left: number; top: number; width: number; height: number } {
let left = 0
let top = 0
let node: HTMLElement | null = el
while (node) {
left += node.offsetLeft
top += node.offsetTop
node = node.offsetParent as HTMLElement | null
}
return { left, top, width: el.offsetWidth, height: el.offsetHeight }
}
/**
* 自适应反色引擎:吃三个区域的原始背景统计,产出 --noti-* CSS 变量。
* 平滑(EMA/突变跳变)、极性滞回等状态都在引擎内维护,与采样来源无关
*/
export function createAdaptiveThemeEngine() {
const ema: Partial<Record<'card' | 'title' | 'body', BandSample>> = {}
const textPolarity: Partial<Record<'title' | 'body', 'dark' | 'light'>> = {}
let veilPolarity: 'white' | 'dark' | null = null
const appliedVars: Record<string, string> = {}
const setVar = (name: string, value: string) => {
if (appliedVars[name] === value) return
appliedVars[name] = value
document.documentElement.style.setProperty(name, value)
}
/** 自适应平滑:场景切换(luma 突变)立即跳变保证响应速度,小幅波动才指数平滑抑噪 */
const smooth = (key: 'card' | 'title' | 'body', raw: BandSample | null): BandSample | null => {
if (!raw) return ema[key] ?? null
const prev = ema[key]
const next: BandSample = prev && Math.abs(gammaLuma(raw.mean) - gammaLuma(prev.mean)) <= SNAP_LUMA_DELTA
? {
mean: lerpRgb(prev.mean, raw.mean, EMA_FACTOR),
darkTail: lerp(prev.darkTail, raw.darkTail, EMA_FACTOR),
lightTail: lerp(prev.lightTail, raw.lightTail, EMA_FACTOR)
}
: raw
ema[key] = next
return next
}
/**
* 材质方向 + 纱层浓度。方向优先贴合桌面明暗(亮底白纱、暗底黑纱),
* 亮度滞回沿用旧算法验证过的 102/118 阈值;若贴合方向即使最大补偿也达不到
* 目标对比度而反方向可以,才反转方向(不用"两族取优":黑纱补偿上限更高,
* 会在双方都可达标的中间调上抢走本应保持浅色观感的场景)。
* 浓度取"宽松锚点文字色恰好达标"的最小 alpha
*/
const resolveVeil = (cardMean: RGB): { color: RGB; alpha: number } => {
const bg = saturateRgb(cardMean, GLASS_SATURATION)
const luma = gammaLuma(bg)
let next: 'white' | 'dark'
if (veilPolarity === 'white') next = luma < 102 ? 'dark' : 'white'
else if (veilPolarity === 'dark') next = luma > 118 ? 'white' : 'dark'
else next = luma >= 110 ? 'white' : 'dark'
const bestWithWhite = contrastRatio(ANCHORS.body.dark.strong, compositeVeil(WHITE_VEIL, WHITE_VEIL_ALPHA[1], bg))
const bestWithDark = contrastRatio(ANCHORS.body.light.strong, compositeVeil(DARK_VEIL, DARK_VEIL_ALPHA[1], bg))
if (next === 'white' && bestWithWhite < TARGET_CONTRAST && bestWithDark > bestWithWhite + POLARITY_MARGIN) next = 'dark'
if (next === 'dark' && bestWithDark < TARGET_CONTRAST && bestWithWhite > bestWithDark + POLARITY_MARGIN) next = 'white'
veilPolarity = next
return next === 'white'
? { color: WHITE_VEIL, alpha: solveVeilAlpha(WHITE_VEIL, WHITE_VEIL_ALPHA, bg, ANCHORS.body.dark.relaxed, TARGET_CONTRAST) }
: { color: DARK_VEIL, alpha: solveVeilAlpha(DARK_VEIL, DARK_VEIL_ALPHA, bg, ANCHORS.body.light.relaxed, TARGET_CONTRAST) }
}
const resolveBand = (key: 'title' | 'body', sample: BandSample, veil: { color: RGB; alpha: number }): BandTone => {
const glassBg = compositeVeil(veil.color, veil.alpha, saturateRgb(sample.mean, GLASS_SATURATION))
const anchors = ANCHORS[key]
const bestDark = contrastRatio(anchors.dark.strong, glassBg)
const bestLight = contrastRatio(anchors.light.strong, glassBg)
const current = textPolarity[key]
let next: 'dark' | 'light' = current ?? (bestDark >= bestLight ? 'dark' : 'light')
if (current === 'dark' && bestLight > bestDark + POLARITY_MARGIN) next = 'light'
if (current === 'light' && bestDark > bestLight + POLARITY_MARGIN) next = 'dark'
textPolarity[key] = next
const tone = solveTextTone(anchors[next], glassBg, TARGET_CONTRAST)
// 深色文字最怕暗斑、浅色文字最怕亮斑:按不利分位数亮度缩放均值色近似最不利背景
const meanLuma = Math.max(1, gammaLuma(sample.mean))
const adverseLuma = next === 'dark' ? sample.darkTail : sample.lightTail
const adverseBg = compositeVeil(
veil.color,
veil.alpha,
saturateRgb(scaleRgb(sample.mean, adverseLuma / meanLuma), GLASS_SATURATION)
)
const deficit = clamp01((TARGET_CONTRAST - contrastRatio(tone.color, adverseBg)) / TARGET_CONTRAST)
return { polarity: next, color: tone.color, t: tone.t, deficit }
}
/** 反向微光晕:深字配白晕、浅字配黑影,强度随对比度缺口无级增强 */
const haloFor = (tone: BandTone) =>
tone.polarity === 'dark'
? `0 0 2px rgba(255, 255, 255, ${(0.35 + 0.55 * tone.deficit).toFixed(2)})`
: `0 1px 3px rgba(0, 0, 0, ${(0.4 + 0.4 * tone.deficit).toFixed(2)})`
return {
/** 输入三区域原始统计(title/body 缺省时退回整卡),刷新全部 --noti-* 变量 */
apply(raw: { card: BandSample | null; title?: BandSample | null; body?: BandSample | null }) {
const cardSample = smooth('card', raw.card)
if (!cardSample) return
const veil = resolveVeil(cardSample.mean)
setVar('--noti-tint', cssRgba(veil.color, veil.alpha))
setVar('--noti-shadow', veil.color === WHITE_VEIL ? SHADOW_ON_LIGHT : SHADOW_ON_DARK)
const titleSample = smooth('title', raw.title ?? null) ?? cardSample
const bodySample = smooth('body', raw.body ?? null) ?? cardSample
const title = resolveBand('title', titleSample, veil)
setVar('--noti-title-color', cssRgb(title.color))
setVar('--noti-title-halo', haloFor(title))
const tertiaryAnchor = ANCHORS.tertiary[title.polarity]
setVar('--noti-title-tertiary', cssRgb(lerpRgb(tertiaryAnchor.relaxed, tertiaryAnchor.strong, title.t)))
setVar('--noti-close-hover-bg', title.polarity === 'dark' ? 'rgba(0, 0, 0, 0.1)' : 'rgba(255, 255, 255, 0.14)')
const body = resolveBand('body', bodySample, veil)
setVar('--noti-body-color', cssRgb(body.color))
setVar('--noti-body-halo', haloFor(body))
}
}
}
/**
* 流采样路径(跨平台回退管线):从 Chromium 桌面视频流按布局区域采样。
* 采样节奏:视频每出新帧即采样(33ms 节流,~30Hz)+ 250ms 兜底轮询
*/
export function useNotificationAdaptiveTheme(
stream: MediaStream | null,
backdrop: LiquidGlassBackdropImage | undefined
) {
useEffect(() => {
if (!stream || !backdrop) return
const video = document.createElement('video')
video.muted = true
video.srcObject = stream
const canvas = document.createElement('canvas')
canvas.width = SAMPLE_W
canvas.height = SAMPLE_H
const ctx = canvas.getContext('2d', { willReadFrequently: true })
const engine = createAdaptiveThemeEngine()
/** 窗口内 CSS 矩形 → 视频帧像素区域的均值色与明暗分位数(流分辨率与屏幕逻辑尺寸按比例映射) */
const readBand = (left: number, top: number, width: number, height: number): BandSample | null => {
if (!ctx || width < 1 || height < 1) return null
const ratioX = video.videoWidth / backdrop.width
const ratioY = video.videoHeight / backdrop.height
try {
ctx.drawImage(
video,
(backdrop.screenX + left) * ratioX,
(backdrop.screenY + top) * ratioY,
Math.max(1, width * ratioX),
Math.max(1, height * ratioY),
0, 0, SAMPLE_W, SAMPLE_H
)
const { data } = ctx.getImageData(0, 0, SAMPLE_W, SAMPLE_H)
let r = 0
let g = 0
let b = 0
const lumas: number[] = []
for (let i = 0; i < data.length; i += 4) {
r += data[i]
g += data[i + 1]
b += data[i + 2]
lumas.push(0.2126 * data[i] + 0.7152 * data[i + 1] + 0.0722 * data[i + 2])
}
const count = lumas.length
lumas.sort((x, y) => x - y)
return {
mean: [r / count, g / count, b / count],
darkTail: lumas[Math.floor(count * 0.15)],
lightTail: lumas[Math.floor(count * 0.85)]
}
} catch {
return null
}
}
const readRect = (rect: { left: number; top: number; width: number; height: number }) =>
readBand(rect.left - RECT_BLEED, rect.top - RECT_BLEED, rect.width + RECT_BLEED * 2, rect.height + RECT_BLEED * 2)
const sampleAll = () => {
if (!video.videoWidth) return
const host: ParentNode = document.getElementById('notification-current') ?? document
const glassEl = host.querySelector<HTMLElement>('.liquid-glass')
const headerEl = host.querySelector<HTMLElement>('.notification-header')
const bodyEl = host.querySelector<HTMLElement>('.notification-body')
engine.apply({
card: readRect(
glassEl ? getLayoutRect(glassEl) : { left: 0, top: 0, width: window.innerWidth, height: window.innerHeight }
),
title: headerEl ? readRect(getLayoutRect(headerEl)) : null,
body: bodyEl ? readRect(getLayoutRect(bodyEl)) : null
})
}
// 事件驱动采样:视频每出新帧立即响应(节流 33ms),桌面静止时零采样开销;
// 兜底轮询覆盖桌面静止但通知自身布局变化(尺寸校准、新旧卡片过渡)的场景
let disposed = false
let lastSampleAt = 0
let frameHandle = 0
const onFrame = () => {
if (disposed) return
const now = performance.now()
if (now - lastSampleAt >= MIN_SAMPLE_GAP_MS) {
lastSampleAt = now
sampleAll()
}
frameHandle = video.requestVideoFrameCallback(onFrame)
}
frameHandle = video.requestVideoFrameCallback(onFrame)
const fallbackTimer = setInterval(sampleAll, FALLBACK_INTERVAL_MS)
video.play().catch(() => { /* 采样失败保持当前材质 */ })
return () => {
disposed = true
clearInterval(fallbackTimer)
video.cancelVideoFrameCallback(frameHandle)
video.srcObject = null
}
}, [stream, backdrop])
}
/** 原生玻璃面板的亮度带 id 约定(与主进程 notification:glassRect 的 bands 对应) */
export const NATIVE_BAND_IDS = { card: 0, title: 1, body: 2 } as const
/**
* 原生采样路径(Windows 原生玻璃):面板在原生层重绘的同一帧从玻璃模糊纹理
* 回读三区域统计,经主进程以 notification:luma 事件转发(上限 ~60Hz,
* 桌面静止时不推送),反色判定与玻璃画面帧级同步
*/
export function useNotificationNativeAdaptiveTheme(enabled: boolean) {
useEffect(() => {
if (!enabled || !window.electronAPI?.notification?.onLuma) return
const engine = createAdaptiveThemeEngine()
const toSample = (s?: { r: number; g: number; b: number; darkTail: number; lightTail: number }): BandSample | null =>
s ? { mean: [s.r, s.g, s.b], darkTail: s.darkTail, lightTail: s.lightTail } : null
return window.electronAPI.notification.onLuma(bands => {
engine.apply({
card: toSample(bands[String(NATIVE_BAND_IDS.card)]),
title: toSample(bands[String(NATIVE_BAND_IDS.title)]),
body: toSample(bands[String(NATIVE_BAND_IDS.body)])
})
})
}, [enabled])
}
+17 -1
View File
@@ -382,6 +382,22 @@ export interface ElectronAPI {
click: (payload: string | { sessionId?: string; channel?: string; insightRecordId?: string; targetRoute?: string }) => void
ready: () => void
resize: (width: number, height: number) => void
glassRect: (payload: {
card: { x: number; y: number; width: number; height: number }
bands: Array<{ id: number; x: number; y: number; width: number; height: number }>
dpr: number
cornerRadius: number
blurSigma: number
displacementScale: number
aberrationIntensity: number
saturation: number
}) => void
glassHide: () => void
onLuma: (
callback: (
bands: Record<string, { r: number; g: number; b: number; darkTail: number; lightTail: number }>
) => void
) => () => void
onShow: (callback: (event: any, data: any) => void) => () => void
onNavigateToSession: (callback: (sessionId: string) => void) => () => void
onNavigateToRoute: (callback: (route: string) => void) => () => void
@@ -1568,7 +1584,7 @@ export interface ElectronAPI {
downloadImage: (payload: { url: string; key?: string | number }) => Promise<{ success: boolean; data?: any; contentType?: string; error?: string }>
exportTimeline: (options: {
outputDir: string
format: 'json' | 'html' | 'arkmejson'
format: 'json' | 'html' | 'arkmejson' | 'markdown'
usernames?: string[]
keyword?: string
exportImages?: boolean
+31
View File
@@ -0,0 +1,31 @@
import type { ReactNode } from 'react'
import { getEmojiPath } from 'wechat-emojis'
/**
* 把文本中的微信表情代码(如 [微笑])渲染为内联表情图片,
* 未匹配的方括号内容原样保留。size 为表情边长(px)
*/
export function renderTextWithEmoji(text: string, size = 22): ReactNode {
if (!text) return text
const parts = text.split(/\[(.*?)\]/g)
return parts.map((part, index) => {
// 奇数索引是方括号捕获组的内容
if (index % 2 === 1) {
const path = getEmojiPath(part as Parameters<typeof getEmojiPath>[0])
if (path) {
// path 例如 'assets/face/微笑.png',需要添加 base 前缀
return (
<img
key={index}
src={`${import.meta.env.BASE_URL}${path}`}
alt={`[${part}]`}
className="inline-emoji"
style={{ width: size, height: size, verticalAlign: 'bottom', margin: '0 1px' }}
/>
)
}
return `[${part}]`
}
return part
})
}
+3 -1
View File
@@ -112,7 +112,9 @@ export default defineConfig({
'exceljs',
'node-llama-cpp',
'@vscode/sudo-prompt',
'silk-wasm'
'silk-wasm',
// 原生 .node 二进制不可打包,运行时从 asarUnpack 目录解析
'@hicccc77/electron-liquid-glass'
]
}
}