mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-24 23:06:51 +00:00
新增启动页面;修复转发表情包无法索引的问题;修复群回复中消息溢出错误;修复群消息中消息类型判定错误
This commit is contained in:
154
electron/main.ts
154
electron/main.ts
@@ -82,6 +82,8 @@ let configService: ConfigService | null = null
|
|||||||
// 协议窗口实例
|
// 协议窗口实例
|
||||||
let agreementWindow: BrowserWindow | null = null
|
let agreementWindow: BrowserWindow | null = null
|
||||||
let onboardingWindow: BrowserWindow | null = null
|
let onboardingWindow: BrowserWindow | null = null
|
||||||
|
// Splash 启动窗口
|
||||||
|
let splashWindow: BrowserWindow | null = null
|
||||||
const keyService = new KeyService()
|
const keyService = new KeyService()
|
||||||
|
|
||||||
let mainWindowReady = false
|
let mainWindowReady = false
|
||||||
@@ -122,9 +124,10 @@ function createWindow(options: { autoShow?: boolean } = {}) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 窗口准备好后显示
|
// 窗口准备好后显示
|
||||||
|
// Splash 模式下不在这里 show,由启动流程统一控制
|
||||||
win.once('ready-to-show', () => {
|
win.once('ready-to-show', () => {
|
||||||
mainWindowReady = true
|
mainWindowReady = true
|
||||||
if (autoShow || shouldShowMain) {
|
if (autoShow && !splashWindow) {
|
||||||
win.show()
|
win.show()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -250,6 +253,73 @@ function createAgreementWindow() {
|
|||||||
return agreementWindow
|
return agreementWindow
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 Splash 启动窗口
|
||||||
|
* 使用纯 HTML 页面,不依赖 React,确保极速显示
|
||||||
|
*/
|
||||||
|
function createSplashWindow(): BrowserWindow {
|
||||||
|
const isDev = !!process.env.VITE_DEV_SERVER_URL
|
||||||
|
const iconPath = isDev
|
||||||
|
? join(__dirname, '../public/icon.ico')
|
||||||
|
: join(process.resourcesPath, 'icon.ico')
|
||||||
|
|
||||||
|
splashWindow = new BrowserWindow({
|
||||||
|
width: 760,
|
||||||
|
height: 460,
|
||||||
|
resizable: false,
|
||||||
|
frame: false,
|
||||||
|
transparent: true,
|
||||||
|
backgroundColor: '#00000000',
|
||||||
|
hasShadow: false,
|
||||||
|
center: true,
|
||||||
|
skipTaskbar: false,
|
||||||
|
icon: iconPath,
|
||||||
|
webPreferences: {
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false
|
||||||
|
// 不需要 preload —— 通过 executeJavaScript 单向推送进度
|
||||||
|
},
|
||||||
|
show: false
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isDev) {
|
||||||
|
splashWindow.loadURL(`${process.env.VITE_DEV_SERVER_URL}splash.html`)
|
||||||
|
} else {
|
||||||
|
splashWindow.loadFile(join(__dirname, '../dist/splash.html'))
|
||||||
|
}
|
||||||
|
|
||||||
|
splashWindow.once('ready-to-show', () => {
|
||||||
|
splashWindow?.show()
|
||||||
|
})
|
||||||
|
|
||||||
|
splashWindow.on('closed', () => {
|
||||||
|
splashWindow = null
|
||||||
|
})
|
||||||
|
|
||||||
|
return splashWindow
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 向 Splash 窗口发送进度更新
|
||||||
|
*/
|
||||||
|
function updateSplashProgress(percent: number, text: string, indeterminate = false) {
|
||||||
|
if (splashWindow && !splashWindow.isDestroyed()) {
|
||||||
|
splashWindow.webContents
|
||||||
|
.executeJavaScript(`updateProgress(${percent}, ${JSON.stringify(text)}, ${indeterminate})`)
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭 Splash 窗口
|
||||||
|
*/
|
||||||
|
function closeSplash() {
|
||||||
|
if (splashWindow && !splashWindow.isDestroyed()) {
|
||||||
|
splashWindow.close()
|
||||||
|
splashWindow = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建首次引导窗口
|
* 创建首次引导窗口
|
||||||
*/
|
*/
|
||||||
@@ -1508,26 +1578,70 @@ function checkForUpdatesOnStartup() {
|
|||||||
}, 3000)
|
}, 3000)
|
||||||
}
|
}
|
||||||
|
|
||||||
app.whenReady().then(() => {
|
app.whenReady().then(async () => {
|
||||||
|
// 立即创建 Splash 窗口,确保用户尽快看到反馈
|
||||||
|
createSplashWindow()
|
||||||
|
|
||||||
|
// 等待 Splash 页面加载完成后再推送进度
|
||||||
|
if (splashWindow) {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
if (splashWindow!.webContents.isLoading()) {
|
||||||
|
splashWindow!.webContents.once('did-finish-load', () => resolve())
|
||||||
|
} else {
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
splashWindow.webContents
|
||||||
|
.executeJavaScript(`setVersion(${JSON.stringify(app.getVersion())})`)
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
|
|
||||||
|
// 初始化配置服务
|
||||||
|
updateSplashProgress(5, '正在加载配置...')
|
||||||
configService = new ConfigService()
|
configService = new ConfigService()
|
||||||
|
|
||||||
|
// 将用户主题配置推送给 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(10, '正在初始化...')
|
||||||
const candidateResources = app.isPackaged
|
const candidateResources = app.isPackaged
|
||||||
? join(process.resourcesPath, 'resources')
|
? join(process.resourcesPath, 'resources')
|
||||||
: join(app.getAppPath(), 'resources')
|
: join(app.getAppPath(), 'resources')
|
||||||
const fallbackResources = join(process.cwd(), 'resources')
|
const fallbackResources = join(process.cwd(), 'resources')
|
||||||
const resourcesPath = existsSync(candidateResources) ? candidateResources : fallbackResources
|
const resourcesPath = existsSync(candidateResources) ? candidateResources : fallbackResources
|
||||||
const userDataPath = app.getPath('userData')
|
const userDataPath = app.getPath('userData')
|
||||||
|
await delay(200)
|
||||||
|
|
||||||
|
// 初始化数据库服务
|
||||||
|
updateSplashProgress(18, '正在初始化...')
|
||||||
wcdbService.setPaths(resourcesPath, userDataPath)
|
wcdbService.setPaths(resourcesPath, userDataPath)
|
||||||
wcdbService.setLogEnabled(configService.get('logEnabled') === true)
|
wcdbService.setLogEnabled(configService.get('logEnabled') === true)
|
||||||
|
await delay(200)
|
||||||
|
|
||||||
|
// 注册 IPC 处理器
|
||||||
|
updateSplashProgress(25, '正在初始化...')
|
||||||
registerIpcHandlers()
|
registerIpcHandlers()
|
||||||
|
await delay(200)
|
||||||
|
|
||||||
|
// 检查配置状态
|
||||||
const onboardingDone = configService.get('onboardingDone')
|
const onboardingDone = configService.get('onboardingDone')
|
||||||
shouldShowMain = onboardingDone === true
|
shouldShowMain = onboardingDone === true
|
||||||
mainWindow = createWindow({ autoShow: shouldShowMain })
|
|
||||||
|
|
||||||
if (!onboardingDone) {
|
// 创建主窗口(不显示,由启动流程统一控制)
|
||||||
createOnboardingWindow()
|
updateSplashProgress(30, '正在加载界面...')
|
||||||
}
|
mainWindow = createWindow({ autoShow: false })
|
||||||
|
|
||||||
// 解决朋友圈图片无法加载问题(添加 Referer)
|
// 配置网络服务
|
||||||
session.defaultSession.webRequest.onBeforeSendHeaders(
|
session.defaultSession.webRequest.onBeforeSendHeaders(
|
||||||
{
|
{
|
||||||
urls: ['*://*.qpic.cn/*', '*://*.wx.qq.com/*']
|
urls: ['*://*.qpic.cn/*', '*://*.wx.qq.com/*']
|
||||||
@@ -1538,7 +1652,31 @@ app.whenReady().then(() => {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// 启动时检测更新
|
// 等待主窗口加载完成(真正耗时阶段,进度条末端呼吸光点)
|
||||||
|
updateSplashProgress(30, '正在加载界面...', true)
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
if (mainWindowReady) {
|
||||||
|
resolve()
|
||||||
|
} else {
|
||||||
|
mainWindow!.once('ready-to-show', () => {
|
||||||
|
mainWindowReady = true
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 加载完成,收尾
|
||||||
|
updateSplashProgress(100, '启动完成')
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||||
|
closeSplash()
|
||||||
|
|
||||||
|
if (!onboardingDone) {
|
||||||
|
createOnboardingWindow()
|
||||||
|
} else {
|
||||||
|
mainWindow?.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动时检测更新(不阻塞启动)
|
||||||
checkForUpdatesOnStartup()
|
checkForUpdatesOnStartup()
|
||||||
|
|
||||||
app.on('activate', () => {
|
app.on('activate', () => {
|
||||||
|
|||||||
@@ -991,12 +991,34 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.warn(`[ChatService] 表情包数据库未命中: md5=${msg.emojiMd5}, db=${dbPath}`)
|
console.warn(`[ChatService] 表情包数据库未命中: md5=${msg.emojiMd5}, db=${dbPath}`)
|
||||||
|
// 数据库未命中时,尝试从本地 emoji 缓存目录查找(转发的表情包只有 md5,无 CDN URL)
|
||||||
|
this.findEmojiInLocalCache(msg)
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`[ChatService] 恢复表情包失败: md5=${msg.emojiMd5}`, e)
|
console.error(`[ChatService] 恢复表情包失败: md5=${msg.emojiMd5}`, e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从本地 WeFlow emoji 缓存目录按 md5 查找文件
|
||||||
|
*/
|
||||||
|
private findEmojiInLocalCache(msg: Message): void {
|
||||||
|
if (!msg.emojiMd5) return
|
||||||
|
const cacheDir = this.getEmojiCacheDir()
|
||||||
|
if (!existsSync(cacheDir)) return
|
||||||
|
|
||||||
|
const extensions = ['.gif', '.png', '.webp', '.jpg', '.jpeg']
|
||||||
|
for (const ext of extensions) {
|
||||||
|
const filePath = join(cacheDir, `${msg.emojiMd5}${ext}`)
|
||||||
|
if (existsSync(filePath)) {
|
||||||
|
msg.emojiLocalPath = filePath
|
||||||
|
// 同步写入内存缓存,避免重复查找
|
||||||
|
emojiCache.set(msg.emojiMd5, filePath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查找 emoticon.db 路径
|
* 查找 emoticon.db 路径
|
||||||
*/
|
*/
|
||||||
@@ -1338,6 +1360,9 @@ class ChatService {
|
|||||||
chatRecordList = type49Info.chatRecordList
|
chatRecordList = type49Info.chatRecordList
|
||||||
transferPayerUsername = type49Info.transferPayerUsername
|
transferPayerUsername = type49Info.transferPayerUsername
|
||||||
transferReceiverUsername = type49Info.transferReceiverUsername
|
transferReceiverUsername = type49Info.transferReceiverUsername
|
||||||
|
// 引用消息(appmsg type=57)的 quotedContent/quotedSender
|
||||||
|
if (type49Info.quotedContent !== undefined) quotedContent = type49Info.quotedContent
|
||||||
|
if (type49Info.quotedSender !== undefined) quotedSender = type49Info.quotedSender
|
||||||
} else if (localType === 244813135921 || (content && content.includes('<type>57</type>'))) {
|
} else if (localType === 244813135921 || (content && content.includes('<type>57</type>'))) {
|
||||||
const quoteInfo = this.parseQuoteMessage(content)
|
const quoteInfo = this.parseQuoteMessage(content)
|
||||||
quotedContent = quoteInfo.content
|
quotedContent = quoteInfo.content
|
||||||
@@ -1381,6 +1406,8 @@ class ChatService {
|
|||||||
chatRecordList = chatRecordList || type49Info.chatRecordList
|
chatRecordList = chatRecordList || type49Info.chatRecordList
|
||||||
transferPayerUsername = transferPayerUsername || type49Info.transferPayerUsername
|
transferPayerUsername = transferPayerUsername || type49Info.transferPayerUsername
|
||||||
transferReceiverUsername = transferReceiverUsername || type49Info.transferReceiverUsername
|
transferReceiverUsername = transferReceiverUsername || type49Info.transferReceiverUsername
|
||||||
|
if (!quotedContent && type49Info.quotedContent !== undefined) quotedContent = type49Info.quotedContent
|
||||||
|
if (!quotedSender && type49Info.quotedSender !== undefined) quotedSender = type49Info.quotedSender
|
||||||
}
|
}
|
||||||
|
|
||||||
messages.push({
|
messages.push({
|
||||||
@@ -1549,7 +1576,17 @@ class ChatService {
|
|||||||
|
|
||||||
private parseType49(content: string): string {
|
private parseType49(content: string): string {
|
||||||
const title = this.extractXmlValue(content, 'title')
|
const title = this.extractXmlValue(content, 'title')
|
||||||
const type = this.extractXmlValue(content, 'type')
|
// 从 appmsg 直接子节点提取 type,避免匹配到 refermsg 内部的 <type>
|
||||||
|
let type = ''
|
||||||
|
const appmsgMatch = /<appmsg[\s\S]*?>([\s\S]*?)<\/appmsg>/i.exec(content)
|
||||||
|
if (appmsgMatch) {
|
||||||
|
const inner = appmsgMatch[1]
|
||||||
|
.replace(/<refermsg[\s\S]*?<\/refermsg>/gi, '')
|
||||||
|
.replace(/<patMsg[\s\S]*?<\/patMsg>/gi, '')
|
||||||
|
const typeMatch = /<type>([\s\S]*?)<\/type>/i.exec(inner)
|
||||||
|
if (typeMatch) type = typeMatch[1].trim()
|
||||||
|
}
|
||||||
|
if (!type) type = this.extractXmlValue(content, 'type')
|
||||||
const normalized = content.toLowerCase()
|
const normalized = content.toLowerCase()
|
||||||
const locationLabel =
|
const locationLabel =
|
||||||
this.extractXmlAttribute(content, 'location', 'label') ||
|
this.extractXmlAttribute(content, 'location', 'label') ||
|
||||||
@@ -1964,6 +2001,8 @@ class ChatService {
|
|||||||
*/
|
*/
|
||||||
private parseType49Message(content: string): {
|
private parseType49Message(content: string): {
|
||||||
xmlType?: string
|
xmlType?: string
|
||||||
|
quotedContent?: string
|
||||||
|
quotedSender?: string
|
||||||
linkTitle?: string
|
linkTitle?: string
|
||||||
linkUrl?: string
|
linkUrl?: string
|
||||||
linkThumb?: string
|
linkThumb?: string
|
||||||
@@ -2008,8 +2047,20 @@ class ChatService {
|
|||||||
try {
|
try {
|
||||||
if (!content) return {}
|
if (!content) return {}
|
||||||
|
|
||||||
// 提取 appmsg 中的 type
|
// 提取 appmsg 直接子节点的 type,避免匹配到 refermsg 内部的 <type>
|
||||||
const xmlType = this.extractXmlValue(content, 'type')
|
// 先尝试从 <appmsg>...</appmsg> 块内提取,再用正则跳过嵌套标签
|
||||||
|
let xmlType = ''
|
||||||
|
const appmsgMatch = /<appmsg[\s\S]*?>([\s\S]*?)<\/appmsg>/i.exec(content)
|
||||||
|
if (appmsgMatch) {
|
||||||
|
// 在 appmsg 内容中,找第一个 <type> 但跳过在子元素内部的(如 refermsg > type)
|
||||||
|
// 策略:去掉所有嵌套块(refermsg、patMsg 等),再提取 type
|
||||||
|
const appmsgInner = appmsgMatch[1]
|
||||||
|
.replace(/<refermsg[\s\S]*?<\/refermsg>/gi, '')
|
||||||
|
.replace(/<patMsg[\s\S]*?<\/patMsg>/gi, '')
|
||||||
|
const typeMatch = /<type>([\s\S]*?)<\/type>/i.exec(appmsgInner)
|
||||||
|
if (typeMatch) xmlType = typeMatch[1].trim()
|
||||||
|
}
|
||||||
|
if (!xmlType) xmlType = this.extractXmlValue(content, 'type')
|
||||||
if (!xmlType) return {}
|
if (!xmlType) return {}
|
||||||
|
|
||||||
const result: any = { xmlType }
|
const result: any = { xmlType }
|
||||||
@@ -2126,6 +2177,12 @@ class ChatService {
|
|||||||
result.appMsgKind = 'transfer'
|
result.appMsgKind = 'transfer'
|
||||||
} else if (xmlType === '87') {
|
} else if (xmlType === '87') {
|
||||||
result.appMsgKind = 'announcement'
|
result.appMsgKind = 'announcement'
|
||||||
|
} else if (xmlType === '57') {
|
||||||
|
// 引用回复消息,解析 refermsg
|
||||||
|
result.appMsgKind = 'quote'
|
||||||
|
const quoteInfo = this.parseQuoteMessage(content)
|
||||||
|
result.quotedContent = quoteInfo.content
|
||||||
|
result.quotedSender = quoteInfo.sender
|
||||||
} else if ((xmlType === '5' || xmlType === '49') && (sourceUsername?.startsWith('gh_') || appName?.includes('公众号') || sourceName)) {
|
} else if ((xmlType === '5' || xmlType === '49') && (sourceUsername?.startsWith('gh_') || appName?.includes('公众号') || sourceName)) {
|
||||||
result.appMsgKind = 'official-link'
|
result.appMsgKind = 'official-link'
|
||||||
} else if (url) {
|
} else if (url) {
|
||||||
|
|||||||
249
public/splash.html
Normal file
249
public/splash.html
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>WeFlow</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
background: transparent;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft YaHei', sans-serif;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-app-region: drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
.splash {
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
border-radius: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 品牌区 */
|
||||||
|
.brand {
|
||||||
|
padding: 48px 52px 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 18px;
|
||||||
|
animation: fadeIn 0.4s ease both;
|
||||||
|
}
|
||||||
|
.logo {
|
||||||
|
width: 56px; height: 56px;
|
||||||
|
border-radius: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.app-name {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
.app-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 5px;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer { flex: 1; }
|
||||||
|
|
||||||
|
/* 底部进度区 */
|
||||||
|
.bottom {
|
||||||
|
padding: 0 48px 40px;
|
||||||
|
animation: fadeIn 0.4s ease 0.1s both;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 进度条轨道 */
|
||||||
|
.progress-track {
|
||||||
|
width: 100%;
|
||||||
|
height: 2px;
|
||||||
|
border-radius: 2px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 进度条填充 */
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
width: 0%;
|
||||||
|
border-radius: 2px;
|
||||||
|
position: relative;
|
||||||
|
transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 扫光:只在有进度时显示,不循环 */
|
||||||
|
.progress-fill::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0; left: 0;
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.5) 50%, transparent 100%);
|
||||||
|
animation: sweep 1.2s ease-out forwards;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 等待阶段:进度条末端呼吸光点 */
|
||||||
|
.progress-fill.waiting::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -1px; right: -2px;
|
||||||
|
width: 6px; height: 4px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: inherit;
|
||||||
|
filter: blur(2px);
|
||||||
|
animation: pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.progress-text {
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.38;
|
||||||
|
}
|
||||||
|
.version {
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(4px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
@keyframes sweep {
|
||||||
|
0% { opacity: 0; transform: translateX(-100%); }
|
||||||
|
20% { opacity: 1; }
|
||||||
|
80% { opacity: 1; }
|
||||||
|
100% { opacity: 0; transform: translateX(100%); }
|
||||||
|
}
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 0.4; transform: scaleX(1); }
|
||||||
|
50% { opacity: 1; transform: scaleX(1.8); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="splash" id="splash">
|
||||||
|
<div class="brand">
|
||||||
|
<img class="logo" src="./logo.png" alt="WeFlow" />
|
||||||
|
<div class="brand-text">
|
||||||
|
<div class="app-name" id="appName">WeFlow</div>
|
||||||
|
<div class="app-desc" id="appDesc">微信聊天记录管理工具</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="spacer"></div>
|
||||||
|
|
||||||
|
<div class="bottom">
|
||||||
|
<div class="progress-track" id="progressTrack">
|
||||||
|
<div class="progress-fill" id="progressFill"></div>
|
||||||
|
</div>
|
||||||
|
<div class="bottom-row">
|
||||||
|
<div class="progress-text" id="progressText">正在启动...</div>
|
||||||
|
<div class="version" id="versionText"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
var themes = {
|
||||||
|
'cloud-dancer': {
|
||||||
|
light: { primary: '#8B7355', bg: '#F0EEE9', bgEnd: '#E5E1DA', text: '#3d3d3d', desc: '#8B7355' },
|
||||||
|
dark: { primary: '#C9A86C', bg: '#1a1816', bgEnd: '#252220', text: '#F0EEE9', desc: '#C9A86C' }
|
||||||
|
},
|
||||||
|
'corundum-blue': {
|
||||||
|
light: { primary: '#4A6670', bg: '#E8EEF0', bgEnd: '#D8E4E8', text: '#3d3d3d', desc: '#4A6670' },
|
||||||
|
dark: { primary: '#6A9AAA', bg: '#141a1c', bgEnd: '#1e2a2e', text: '#E0EEF2', desc: '#6A9AAA' }
|
||||||
|
},
|
||||||
|
'kiwi-green': {
|
||||||
|
light: { primary: '#7A9A5C', bg: '#E8F0E4', bgEnd: '#D8E8D2', text: '#3d3d3d', desc: '#7A9A5C' },
|
||||||
|
dark: { primary: '#9ABA7C', bg: '#161a14', bgEnd: '#222a1e', text: '#E8F0E4', desc: '#9ABA7C' }
|
||||||
|
},
|
||||||
|
'spicy-red': {
|
||||||
|
light: { primary: '#8B4049', bg: '#F0E8E8', bgEnd: '#E8D8D8', text: '#3d3d3d', desc: '#8B4049' },
|
||||||
|
dark: { primary: '#C06068', bg: '#1a1416', bgEnd: '#261e20', text: '#F2E8EA', desc: '#C06068' }
|
||||||
|
},
|
||||||
|
'teal-water': {
|
||||||
|
light: { primary: '#5A8A8A', bg: '#E4F0F0', bgEnd: '#D2E8E8', text: '#3d3d3d', desc: '#5A8A8A' },
|
||||||
|
dark: { primary: '#7ABAAA', bg: '#121a1a', bgEnd: '#1a2626', text: '#E0F2EE', desc: '#7ABAAA' }
|
||||||
|
},
|
||||||
|
'blossom-dream': {
|
||||||
|
light: { primary: '#D4849A', primaryEnd: '#D4849A', bg: '#FCF9FB', bgMid: '#F8F2F8', bgEnd: '#F2F6FB', text: '#2E2633', desc: '#D4849A' },
|
||||||
|
dark: { primary: '#C670C3', primaryEnd: '#8A60C0', bg: '#120B16', bgMid: '#1A1020', bgEnd: '#0E0B18', text: '#F2EAF4', desc: '#C670C3' }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function applyTheme(themeId, mode) {
|
||||||
|
var t = themes[themeId] || themes['cloud-dancer'];
|
||||||
|
var isDark = mode === 'dark';
|
||||||
|
if (mode === 'system') isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
var c = isDark ? t.dark : t.light;
|
||||||
|
|
||||||
|
var el = document.getElementById('splash');
|
||||||
|
var fill = document.getElementById('progressFill');
|
||||||
|
|
||||||
|
if (themeId === 'blossom-dream') {
|
||||||
|
if (isDark) {
|
||||||
|
// 深色
|
||||||
|
el.style.background =
|
||||||
|
'radial-gradient(ellipse 60% 50% at 100% 0%, ' + c.primary + '28 0%, transparent 70%), ' +
|
||||||
|
'linear-gradient(150deg, ' + c.bg + ' 0%, ' + c.bgMid + ' 45%, ' + c.bgEnd + ' 100%)';
|
||||||
|
} else {
|
||||||
|
// 浅色
|
||||||
|
el.style.background = 'linear-gradient(150deg, ' + c.bg + ' 0%, ' + c.bgMid + ' 45%, ' + c.bgEnd + ' 100%)';
|
||||||
|
}
|
||||||
|
// 进度条
|
||||||
|
fill.style.background = 'linear-gradient(90deg, ' + c.primary + ' 0%, ' + c.primaryEnd + ' 100%)';
|
||||||
|
} else {
|
||||||
|
if (isDark) {
|
||||||
|
el.style.background =
|
||||||
|
'radial-gradient(ellipse 60% 50% at 100% 0%, ' + c.primary + '22 0%, transparent 70%), ' +
|
||||||
|
'linear-gradient(145deg, ' + c.bg + ' 0%, ' + c.bgEnd + ' 100%)';
|
||||||
|
} else {
|
||||||
|
el.style.background = 'linear-gradient(150deg, ' + c.bg + ' 0%, ' + c.bgEnd + ' 100%)';
|
||||||
|
}
|
||||||
|
fill.style.background = c.primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('appName').style.color = c.text;
|
||||||
|
document.getElementById('appDesc').style.color = c.desc;
|
||||||
|
document.getElementById('progressText').style.color = c.text;
|
||||||
|
document.getElementById('versionText').style.color = c.text;
|
||||||
|
document.getElementById('progressTrack').style.background = c.primary + (isDark ? '25' : '18');
|
||||||
|
}
|
||||||
|
|
||||||
|
// percent: 实际进度值;waiting: 是否处于等待阶段
|
||||||
|
function updateProgress(percent, text, waiting) {
|
||||||
|
var fill = document.getElementById('progressFill');
|
||||||
|
var label = document.getElementById('progressText');
|
||||||
|
|
||||||
|
if (fill) {
|
||||||
|
fill.style.width = percent + '%';
|
||||||
|
if (waiting) {
|
||||||
|
fill.classList.add('waiting');
|
||||||
|
} else {
|
||||||
|
fill.classList.remove('waiting');
|
||||||
|
// 触发扫光:重置动画
|
||||||
|
fill.style.animation = 'none';
|
||||||
|
fill.offsetHeight;
|
||||||
|
fill.style.animation = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (label && text) label.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setVersion(ver) {
|
||||||
|
var el = document.getElementById('versionText');
|
||||||
|
if (el) el.textContent = 'v' + ver;
|
||||||
|
}
|
||||||
|
|
||||||
|
applyTheme('cloud-dancer', 'light');
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
42
src/App.scss
42
src/App.scss
@@ -4,6 +4,48 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
animation: appFadeIn 0.35s ease-out;
|
animation: appFadeIn 0.35s ease-out;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 繁花如梦:底色层(::before)+ 光晕层(::after)分离,避免 blur 吃掉边缘
|
||||||
|
[data-theme="blossom-dream"] .app-container {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ::before 纯底色,不模糊
|
||||||
|
[data-theme="blossom-dream"] .app-container::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: -2;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ::after 光晕层,模糊叠加在底色上
|
||||||
|
[data-theme="blossom-dream"] .app-container::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: -1;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 55% 45% at 15% 20%, var(--blossom-pink) 0%, transparent 70%),
|
||||||
|
radial-gradient(ellipse 50% 40% at 85% 75%, var(--blossom-peach) 0%, transparent 65%),
|
||||||
|
radial-gradient(ellipse 45% 50% at 80% 10%, var(--blossom-blue) 0%, transparent 60%);
|
||||||
|
filter: blur(80px);
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 深色模式光晕更克制
|
||||||
|
[data-theme="blossom-dream"][data-mode="dark"] .app-container::after {
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 55% 45% at 15% 20%, var(--blossom-pink) 0%, transparent 70%),
|
||||||
|
radial-gradient(ellipse 50% 40% at 85% 75%, var(--blossom-purple) 0%, transparent 65%),
|
||||||
|
radial-gradient(ellipse 45% 50% at 80% 10%, var(--blossom-blue) 0%, transparent 60%);
|
||||||
|
filter: blur(100px);
|
||||||
|
opacity: 0.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.window-drag-region {
|
.window-drag-region {
|
||||||
|
|||||||
@@ -7,10 +7,12 @@
|
|||||||
-webkit-backdrop-filter: blur(20px);
|
-webkit-backdrop-filter: blur(20px);
|
||||||
border: 1px solid var(--border-light);
|
border: 1px solid var(--border-light);
|
||||||
|
|
||||||
// 浅色模式下使用不透明背景,避免透明窗口中通知过于透明
|
// 浅色模式下使用完全不透明背景,并禁用毛玻璃效果
|
||||||
[data-mode="light"] &,
|
[data-mode="light"] &,
|
||||||
:not([data-mode]) & {
|
:not([data-mode]) & {
|
||||||
background: rgba(255, 255, 255, 1);
|
background: rgba(255, 255, 255, 1);
|
||||||
|
backdrop-filter: none;
|
||||||
|
-webkit-backdrop-filter: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
@@ -46,10 +48,16 @@
|
|||||||
backdrop-filter: none !important;
|
backdrop-filter: none !important;
|
||||||
-webkit-backdrop-filter: none !important;
|
-webkit-backdrop-filter: none !important;
|
||||||
|
|
||||||
// 确保背景不透明
|
// 确保背景完全不透明(通知是独立窗口,透明背景会穿透)
|
||||||
background: var(--bg-secondary, #2c2c2c);
|
background: var(--bg-secondary-solid, var(--bg-secondary, #2c2c2c));
|
||||||
color: var(--text-primary, #ffffff);
|
color: var(--text-primary, #ffffff);
|
||||||
|
|
||||||
|
// 浅色模式强制完全不透明白色背景
|
||||||
|
[data-mode="light"] &,
|
||||||
|
:not([data-mode]) & {
|
||||||
|
background: #ffffff !important;
|
||||||
|
}
|
||||||
|
|
||||||
box-shadow: none !important; // NO SHADOW
|
box-shadow: none !important; // NO SHADOW
|
||||||
border: 1px solid var(--border-light, rgba(255, 255, 255, 0.1));
|
border: 1px solid var(--border-light, rgba(255, 255, 255, 0.1));
|
||||||
|
|
||||||
|
|||||||
@@ -103,4 +103,31 @@
|
|||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 繁花如梦主题:侧边栏毛玻璃 + 激活项用主品牌色
|
||||||
|
[data-theme="blossom-dream"] .sidebar {
|
||||||
|
background: rgba(255, 255, 255, 0.6);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border-right: 1px solid rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="blossom-dream"][data-mode="dark"] .sidebar {
|
||||||
|
background: rgba(34, 30, 36, 0.75);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border-right: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 激活项:主品牌色纵向微渐变
|
||||||
|
[data-theme="blossom-dream"] .nav-item.active {
|
||||||
|
background: linear-gradient(180deg, #D4849A 0%, #C4748A 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 深色激活项:用藕粉色,背景深灰底 + 粉色文字/图标(高阶玩法)
|
||||||
|
[data-theme="blossom-dream"][data-mode="dark"] .nav-item.active {
|
||||||
|
background: rgba(209, 158, 187, 0.15);
|
||||||
|
color: #D19EBB;
|
||||||
|
border: 1px solid rgba(209, 158, 187, 0.2);
|
||||||
}
|
}
|
||||||
@@ -10,6 +10,12 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 繁花如梦:标题栏毛玻璃
|
||||||
|
[data-theme="blossom-dream"] .title-bar {
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
}
|
||||||
|
|
||||||
.title-logo {
|
.title-logo {
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
|
|||||||
@@ -2243,6 +2243,18 @@
|
|||||||
.quoted-text {
|
.quoted-text {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
|
|
||||||
|
.quoted-type-label {
|
||||||
|
font-style: italic;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quoted-emoji-image {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
vertical-align: middle;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2897,7 +2909,6 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 8px 12px;
|
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
|
||||||
|
|||||||
@@ -2780,6 +2780,31 @@ const voiceTranscriptCache = new Map<string, string>()
|
|||||||
const senderAvatarCache = new Map<string, { avatarUrl?: string; displayName?: string }>()
|
const senderAvatarCache = new Map<string, { avatarUrl?: string; displayName?: string }>()
|
||||||
const senderAvatarLoading = new Map<string, Promise<{ avatarUrl?: string; displayName?: string } | null>>()
|
const senderAvatarLoading = new Map<string, Promise<{ avatarUrl?: string; displayName?: string } | null>>()
|
||||||
|
|
||||||
|
// 引用消息中的动画表情组件
|
||||||
|
function QuotedEmoji({ cdnUrl, md5 }: { cdnUrl: string; md5?: string }) {
|
||||||
|
const cacheKey = md5 || cdnUrl
|
||||||
|
const [localPath, setLocalPath] = useState<string | undefined>(() => emojiDataUrlCache.get(cacheKey))
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (localPath || loading || error) return
|
||||||
|
setLoading(true)
|
||||||
|
window.electronAPI.chat.downloadEmoji(cdnUrl, md5).then((result: { success: boolean; localPath?: string }) => {
|
||||||
|
if (result.success && result.localPath) {
|
||||||
|
emojiDataUrlCache.set(cacheKey, result.localPath)
|
||||||
|
setLocalPath(result.localPath)
|
||||||
|
} else {
|
||||||
|
setError(true)
|
||||||
|
}
|
||||||
|
}).catch(() => setError(true)).finally(() => setLoading(false))
|
||||||
|
}, [cdnUrl, md5, cacheKey, localPath, loading, error])
|
||||||
|
|
||||||
|
if (error || (!loading && !localPath)) return <span className="quoted-type-label">[动画表情]</span>
|
||||||
|
if (loading) return <span className="quoted-type-label">[动画表情]</span>
|
||||||
|
return <img src={localPath} alt="动画表情" className="quoted-emoji-image" />
|
||||||
|
}
|
||||||
|
|
||||||
// 消息气泡组件
|
// 消息气泡组件
|
||||||
function MessageBubble({
|
function MessageBubble({
|
||||||
message,
|
message,
|
||||||
@@ -2901,7 +2926,7 @@ function MessageBubble({
|
|||||||
// 从缓存获取表情包 data URL
|
// 从缓存获取表情包 data URL
|
||||||
const cacheKey = message.emojiMd5 || message.emojiCdnUrl || ''
|
const cacheKey = message.emojiMd5 || message.emojiCdnUrl || ''
|
||||||
const [emojiLocalPath, setEmojiLocalPath] = useState<string | undefined>(
|
const [emojiLocalPath, setEmojiLocalPath] = useState<string | undefined>(
|
||||||
() => emojiDataUrlCache.get(cacheKey)
|
() => emojiDataUrlCache.get(cacheKey) || message.emojiLocalPath
|
||||||
)
|
)
|
||||||
const imageCacheKey = message.imageMd5 || message.imageDatName || `local:${message.localId}`
|
const imageCacheKey = message.imageMd5 || message.imageDatName || `local:${message.localId}`
|
||||||
const [imageLocalPath, setImageLocalPath] = useState<string | undefined>(
|
const [imageLocalPath, setImageLocalPath] = useState<string | undefined>(
|
||||||
@@ -3036,10 +3061,15 @@ function MessageBubble({
|
|||||||
// 自动下载表情包
|
// 自动下载表情包
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (emojiLocalPath) return
|
if (emojiLocalPath) return
|
||||||
|
// 后端已从本地缓存找到文件(转发表情包无 CDN URL 的情况)
|
||||||
|
if (isEmoji && message.emojiLocalPath && !emojiLocalPath) {
|
||||||
|
setEmojiLocalPath(message.emojiLocalPath)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (isEmoji && message.emojiCdnUrl && !emojiLoading && !emojiError) {
|
if (isEmoji && message.emojiCdnUrl && !emojiLoading && !emojiError) {
|
||||||
downloadEmoji()
|
downloadEmoji()
|
||||||
}
|
}
|
||||||
}, [isEmoji, message.emojiCdnUrl, emojiLocalPath, emojiLoading, emojiError])
|
}, [isEmoji, message.emojiCdnUrl, message.emojiLocalPath, emojiLocalPath, emojiLoading, emojiError])
|
||||||
|
|
||||||
const requestImageDecrypt = useCallback(async (forceUpdate = false, silent = false) => {
|
const requestImageDecrypt = useCallback(async (forceUpdate = false, silent = false) => {
|
||||||
if (!isImage) return
|
if (!isImage) return
|
||||||
@@ -3971,11 +4001,13 @@ function MessageBubble({
|
|||||||
// 通话消息
|
// 通话消息
|
||||||
if (isCall) {
|
if (isCall) {
|
||||||
return (
|
return (
|
||||||
<div className="call-message">
|
<div className="bubble-content">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<div className="call-message">
|
||||||
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z" />
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
</svg>
|
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z" />
|
||||||
<span>{message.parsedContent || '[通话]'}</span>
|
</svg>
|
||||||
|
<span>{message.parsedContent || '[通话]'}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -4043,11 +4075,39 @@ function MessageBubble({
|
|||||||
const replyText = q('title') || cleanMessageContent(message.parsedContent) || ''
|
const replyText = q('title') || cleanMessageContent(message.parsedContent) || ''
|
||||||
const referContent = q('refermsg > content') || ''
|
const referContent = q('refermsg > content') || ''
|
||||||
const referSender = q('refermsg > displayname') || ''
|
const referSender = q('refermsg > displayname') || ''
|
||||||
|
const referType = q('refermsg > type') || ''
|
||||||
|
|
||||||
|
// 根据被引用消息类型渲染对应内容
|
||||||
|
const renderReferContent = () => {
|
||||||
|
// 动画表情:解析嵌套 XML 提取 cdnurl 渲染
|
||||||
|
if (referType === '47') {
|
||||||
|
try {
|
||||||
|
const innerDoc = new DOMParser().parseFromString(referContent, 'text/xml')
|
||||||
|
const cdnUrl = innerDoc.querySelector('emoji')?.getAttribute('cdnurl') || ''
|
||||||
|
const md5 = innerDoc.querySelector('emoji')?.getAttribute('md5') || ''
|
||||||
|
if (cdnUrl) return <QuotedEmoji cdnUrl={cdnUrl} md5={md5} />
|
||||||
|
} catch { /* 解析失败降级 */ }
|
||||||
|
return <span className="quoted-type-label">[动画表情]</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
// 各类型名称映射
|
||||||
|
const typeLabels: Record<string, string> = {
|
||||||
|
'3': '图片', '34': '语音', '43': '视频',
|
||||||
|
'49': '链接', '50': '通话', '10000': '系统消息', '10002': '撤回消息',
|
||||||
|
}
|
||||||
|
if (referType && typeLabels[referType]) {
|
||||||
|
return <span className="quoted-type-label">[{typeLabels[referType]}]</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
// 普通文本或未知类型
|
||||||
|
return <>{renderTextWithEmoji(cleanMessageContent(referContent))}</>
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bubble-content">
|
<div className="bubble-content">
|
||||||
<div className="quoted-message">
|
<div className="quoted-message">
|
||||||
{referSender && <span className="quoted-sender">{referSender}</span>}
|
{referSender && <span className="quoted-sender">{referSender}</span>}
|
||||||
<span className="quoted-text">{renderTextWithEmoji(cleanMessageContent(referContent))}</span>
|
<span className="quoted-text">{renderReferContent()}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="message-text">{renderTextWithEmoji(cleanMessageContent(replyText))}</div>
|
<div className="message-text">{renderTextWithEmoji(cleanMessageContent(replyText))}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -4143,6 +4203,22 @@ function MessageBubble({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (kind === 'quote') {
|
||||||
|
// 引用回复消息(appMsgKind='quote',xmlType=57)
|
||||||
|
const replyText = message.linkTitle || q('title') || cleanMessageContent(message.parsedContent) || ''
|
||||||
|
const referContent = message.quotedContent || q('refermsg > content') || ''
|
||||||
|
const referSender = message.quotedSender || q('refermsg > displayname') || ''
|
||||||
|
return (
|
||||||
|
<div className="bubble-content">
|
||||||
|
<div className="quoted-message">
|
||||||
|
{referSender && <span className="quoted-sender">{referSender}</span>}
|
||||||
|
<span className="quoted-text">{renderTextWithEmoji(cleanMessageContent(referContent))}</span>
|
||||||
|
</div>
|
||||||
|
<div className="message-text">{renderTextWithEmoji(cleanMessageContent(replyText))}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (kind === 'red-packet') {
|
if (kind === 'red-packet') {
|
||||||
// 专属红包卡片
|
// 专属红包卡片
|
||||||
const greeting = (() => {
|
const greeting = (() => {
|
||||||
@@ -4347,6 +4423,44 @@ function MessageBubble({
|
|||||||
console.error('解析 AppMsg 失败:', e)
|
console.error('解析 AppMsg 失败:', e)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 引用回复消息 (type=57),防止被误判为链接
|
||||||
|
if (appMsgType === '57') {
|
||||||
|
const replyText = parsedDoc?.querySelector('title')?.textContent?.trim() || cleanMessageContent(message.parsedContent) || ''
|
||||||
|
const referContent = parsedDoc?.querySelector('refermsg > content')?.textContent?.trim() || ''
|
||||||
|
const referSender = parsedDoc?.querySelector('refermsg > displayname')?.textContent?.trim() || ''
|
||||||
|
const referType = parsedDoc?.querySelector('refermsg > type')?.textContent?.trim() || ''
|
||||||
|
|
||||||
|
const renderReferContent2 = () => {
|
||||||
|
if (referType === '47') {
|
||||||
|
try {
|
||||||
|
const innerDoc = new DOMParser().parseFromString(referContent, 'text/xml')
|
||||||
|
const cdnUrl = innerDoc.querySelector('emoji')?.getAttribute('cdnurl') || ''
|
||||||
|
const md5 = innerDoc.querySelector('emoji')?.getAttribute('md5') || ''
|
||||||
|
if (cdnUrl) return <QuotedEmoji cdnUrl={cdnUrl} md5={md5} />
|
||||||
|
} catch { /* 解析失败降级 */ }
|
||||||
|
return <span className="quoted-type-label">[动画表情]</span>
|
||||||
|
}
|
||||||
|
const typeLabels: Record<string, string> = {
|
||||||
|
'3': '图片', '34': '语音', '43': '视频',
|
||||||
|
'49': '链接', '50': '通话', '10000': '系统消息', '10002': '撤回消息',
|
||||||
|
}
|
||||||
|
if (referType && typeLabels[referType]) {
|
||||||
|
return <span className="quoted-type-label">[{typeLabels[referType]}]</span>
|
||||||
|
}
|
||||||
|
return <>{renderTextWithEmoji(cleanMessageContent(referContent))}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bubble-content">
|
||||||
|
<div className="quoted-message">
|
||||||
|
{referSender && <span className="quoted-sender">{referSender}</span>}
|
||||||
|
<span className="quoted-text">{renderReferContent2()}</span>
|
||||||
|
</div>
|
||||||
|
<div className="message-text">{renderTextWithEmoji(cleanMessageContent(replyText))}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// 群公告消息 (type=87)
|
// 群公告消息 (type=87)
|
||||||
if (appMsgType === '87') {
|
if (appMsgType === '87') {
|
||||||
const announcementText = textAnnouncement || desc || '群公告'
|
const announcementText = textAnnouncement || desc || '群公告'
|
||||||
@@ -4579,7 +4693,7 @@ function MessageBubble({
|
|||||||
if (isEmoji) {
|
if (isEmoji) {
|
||||||
// ... (keep existing emoji logic)
|
// ... (keep existing emoji logic)
|
||||||
// 没有 cdnUrl 或加载失败,显示占位符
|
// 没有 cdnUrl 或加载失败,显示占位符
|
||||||
if (!message.emojiCdnUrl || emojiError) {
|
if ((!message.emojiCdnUrl && !message.emojiLocalPath) || emojiError) {
|
||||||
return (
|
return (
|
||||||
<div className="emoji-unavailable">
|
<div className="emoji-unavailable">
|
||||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
.blob-1 {
|
.blob-1 {
|
||||||
width: 400px;
|
width: 400px;
|
||||||
height: 400px;
|
height: 400px;
|
||||||
background: rgba(139, 115, 85, 0.25);
|
background: rgba(var(--primary-rgb), 0.25);
|
||||||
top: -100px;
|
top: -100px;
|
||||||
left: -50px;
|
left: -50px;
|
||||||
animation-duration: 25s;
|
animation-duration: 25s;
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
.blob-2 {
|
.blob-2 {
|
||||||
width: 350px;
|
width: 350px;
|
||||||
height: 350px;
|
height: 350px;
|
||||||
background: rgba(139, 115, 85, 0.15);
|
background: rgba(var(--primary-rgb), 0.15);
|
||||||
bottom: -50px;
|
bottom: -50px;
|
||||||
right: -50px;
|
right: -50px;
|
||||||
animation-duration: 30s;
|
animation-duration: 30s;
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
margin: 0 0 16px;
|
margin: 0 0 16px;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
letter-spacing: -2px;
|
letter-spacing: -2px;
|
||||||
background: linear-gradient(135deg, var(--text-primary) 0%, rgba(139, 115, 85, 0.8) 100%);
|
background: linear-gradient(135deg, var(--primary) 0%, rgba(var(--primary-rgb), 0.6) 100%);
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
-webkit-background-clip: text;
|
-webkit-background-clip: text;
|
||||||
-webkit-text-fill-color: transparent;
|
-webkit-text-fill-color: transparent;
|
||||||
|
|||||||
@@ -939,8 +939,16 @@ function SettingsPage() {
|
|||||||
<div className="theme-grid">
|
<div className="theme-grid">
|
||||||
{themes.map((theme) => (
|
{themes.map((theme) => (
|
||||||
<div key={theme.id} className={`theme-card ${currentTheme === theme.id ? 'active' : ''}`} onClick={() => setTheme(theme.id)}>
|
<div key={theme.id} className={`theme-card ${currentTheme === theme.id ? 'active' : ''}`} onClick={() => setTheme(theme.id)}>
|
||||||
<div className="theme-preview" style={{ background: effectiveMode === 'dark' ? 'linear-gradient(135deg, #1a1a1a 0%, #2a2a2a 100%)' : `linear-gradient(135deg, ${theme.bgColor} 0%, ${theme.bgColor}dd 100%)` }}>
|
<div className="theme-preview" style={{
|
||||||
<div className="theme-accent" style={{ background: theme.primaryColor }} />
|
background: effectiveMode === 'dark'
|
||||||
|
? (theme.id === 'blossom-dream' ? 'linear-gradient(150deg, #151316 0%, #1A1620 50%, #131018 100%)' : 'linear-gradient(135deg, #1a1a1a 0%, #2a2a2a 100%)')
|
||||||
|
: (theme.id === 'blossom-dream' ? `linear-gradient(150deg, ${theme.bgColor} 0%, #F8F2F8 45%, #F2F6FB 100%)` : `linear-gradient(135deg, ${theme.bgColor} 0%, ${theme.bgColor}dd 100%)`)
|
||||||
|
}}>
|
||||||
|
<div className="theme-accent" style={{
|
||||||
|
background: theme.accentColor
|
||||||
|
? `linear-gradient(135deg, ${theme.primaryColor} 0%, ${theme.accentColor} 100%)`
|
||||||
|
: theme.primaryColor
|
||||||
|
}} />
|
||||||
</div>
|
</div>
|
||||||
<div className="theme-info">
|
<div className="theme-info">
|
||||||
<span className="theme-name">{theme.name}</span>
|
<span className="theme-name">{theme.name}</span>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import { persist } from 'zustand/middleware'
|
import { persist } from 'zustand/middleware'
|
||||||
|
|
||||||
export type ThemeId = 'cloud-dancer' | 'corundum-blue' | 'kiwi-green' | 'spicy-red' | 'teal-water'
|
export type ThemeId = 'cloud-dancer' | 'corundum-blue' | 'kiwi-green' | 'spicy-red' | 'teal-water' | 'blossom-dream'
|
||||||
export type ThemeMode = 'light' | 'dark' | 'system'
|
export type ThemeMode = 'light' | 'dark' | 'system'
|
||||||
|
|
||||||
export interface ThemeInfo {
|
export interface ThemeInfo {
|
||||||
@@ -10,6 +10,8 @@ export interface ThemeInfo {
|
|||||||
description: string
|
description: string
|
||||||
primaryColor: string
|
primaryColor: string
|
||||||
bgColor: string
|
bgColor: string
|
||||||
|
// 可选副色,用于多彩主题的渐变预览
|
||||||
|
accentColor?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const themes: ThemeInfo[] = [
|
export const themes: ThemeInfo[] = [
|
||||||
@@ -20,6 +22,14 @@ export const themes: ThemeInfo[] = [
|
|||||||
primaryColor: '#8B7355',
|
primaryColor: '#8B7355',
|
||||||
bgColor: '#F0EEE9'
|
bgColor: '#F0EEE9'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'blossom-dream',
|
||||||
|
name: '繁花如梦',
|
||||||
|
description: '晨曦花境 · 夜阑幽梦',
|
||||||
|
primaryColor: '#D4849A',
|
||||||
|
bgColor: '#FCF9FB',
|
||||||
|
accentColor: '#FFBE98'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'corundum-blue',
|
id: 'corundum-blue',
|
||||||
name: '刚玉蓝',
|
name: '刚玉蓝',
|
||||||
|
|||||||
@@ -153,6 +153,43 @@
|
|||||||
--sent-card-bg: var(--primary);
|
--sent-card-bg: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 繁花如梦 - 浅色(晨曦花境)
|
||||||
|
[data-theme="blossom-dream"][data-mode="light"],
|
||||||
|
[data-theme="blossom-dream"]:not([data-mode]) {
|
||||||
|
// 三色定义(供伪元素光晕使用,饱和度提高以便在底色上可见)
|
||||||
|
--blossom-pink: #F0A0B8;
|
||||||
|
--blossom-peach: #FFB07A;
|
||||||
|
--blossom-blue: #90B8E0;
|
||||||
|
|
||||||
|
// 主品牌色:Pantone 粉晶 Rose Quartz
|
||||||
|
--primary: #D4849A;
|
||||||
|
--primary-rgb: 212, 132, 154;
|
||||||
|
--primary-hover: #C4748A;
|
||||||
|
--primary-light: rgba(212, 132, 154, 0.12);
|
||||||
|
|
||||||
|
// 背景三层:主背景最深(相对),面板次之,卡片最白
|
||||||
|
--bg-primary: #F5EDF2;
|
||||||
|
--bg-secondary: rgba(255, 255, 255, 0.82);
|
||||||
|
--bg-tertiary: rgba(212, 132, 154, 0.06);
|
||||||
|
--bg-hover: rgba(212, 132, 154, 0.09);
|
||||||
|
|
||||||
|
// 文字:提高对比度,主色接近纯黑只带微弱紫调
|
||||||
|
--text-primary: #1E1A22;
|
||||||
|
--text-secondary: #6B5F70;
|
||||||
|
--text-tertiary: #9A8A9E;
|
||||||
|
// 边框:粉色半透明,有存在感但不强硬
|
||||||
|
--border-color: rgba(212, 132, 154, 0.18);
|
||||||
|
|
||||||
|
--bg-gradient: linear-gradient(150deg, #F5EDF2 0%, #F0EAF6 50%, #EAF0F8 100%);
|
||||||
|
--primary-gradient: linear-gradient(135deg, #D4849A 0%, #E8A8B8 100%);
|
||||||
|
|
||||||
|
// 卡片:高不透明度白,与背景形成明显层次
|
||||||
|
--card-bg: rgba(255, 255, 255, 0.88);
|
||||||
|
--card-inner-bg: rgba(255, 255, 255, 0.95);
|
||||||
|
|
||||||
|
--sent-card-bg: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 深色主题 ====================
|
// ==================== 深色主题 ====================
|
||||||
|
|
||||||
// 云上舞白 - 深色
|
// 云上舞白 - 深色
|
||||||
@@ -163,6 +200,7 @@
|
|||||||
--primary-light: rgba(201, 168, 108, 0.15);
|
--primary-light: rgba(201, 168, 108, 0.15);
|
||||||
--bg-primary: #1a1816;
|
--bg-primary: #1a1816;
|
||||||
--bg-secondary: rgba(40, 36, 32, 0.9);
|
--bg-secondary: rgba(40, 36, 32, 0.9);
|
||||||
|
--bg-secondary-solid: #282420;
|
||||||
--bg-tertiary: rgba(255, 255, 255, 0.05);
|
--bg-tertiary: rgba(255, 255, 255, 0.05);
|
||||||
--bg-hover: rgba(255, 255, 255, 0.08);
|
--bg-hover: rgba(255, 255, 255, 0.08);
|
||||||
--text-primary: #F0EEE9;
|
--text-primary: #F0EEE9;
|
||||||
@@ -184,6 +222,7 @@
|
|||||||
--primary-light: rgba(106, 154, 170, 0.15);
|
--primary-light: rgba(106, 154, 170, 0.15);
|
||||||
--bg-primary: #141a1c;
|
--bg-primary: #141a1c;
|
||||||
--bg-secondary: rgba(30, 40, 44, 0.9);
|
--bg-secondary: rgba(30, 40, 44, 0.9);
|
||||||
|
--bg-secondary-solid: #1e282c;
|
||||||
--bg-tertiary: rgba(255, 255, 255, 0.05);
|
--bg-tertiary: rgba(255, 255, 255, 0.05);
|
||||||
--bg-hover: rgba(255, 255, 255, 0.08);
|
--bg-hover: rgba(255, 255, 255, 0.08);
|
||||||
--text-primary: #E8EEF0;
|
--text-primary: #E8EEF0;
|
||||||
@@ -205,6 +244,7 @@
|
|||||||
--primary-light: rgba(154, 186, 124, 0.15);
|
--primary-light: rgba(154, 186, 124, 0.15);
|
||||||
--bg-primary: #161a14;
|
--bg-primary: #161a14;
|
||||||
--bg-secondary: rgba(34, 42, 30, 0.9);
|
--bg-secondary: rgba(34, 42, 30, 0.9);
|
||||||
|
--bg-secondary-solid: #222a1e;
|
||||||
--bg-tertiary: rgba(255, 255, 255, 0.05);
|
--bg-tertiary: rgba(255, 255, 255, 0.05);
|
||||||
--bg-hover: rgba(255, 255, 255, 0.08);
|
--bg-hover: rgba(255, 255, 255, 0.08);
|
||||||
--text-primary: #E8F0E4;
|
--text-primary: #E8F0E4;
|
||||||
@@ -226,6 +266,7 @@
|
|||||||
--primary-light: rgba(192, 96, 104, 0.15);
|
--primary-light: rgba(192, 96, 104, 0.15);
|
||||||
--bg-primary: #1a1416;
|
--bg-primary: #1a1416;
|
||||||
--bg-secondary: rgba(42, 32, 34, 0.9);
|
--bg-secondary: rgba(42, 32, 34, 0.9);
|
||||||
|
--bg-secondary-solid: #2a2022;
|
||||||
--bg-tertiary: rgba(255, 255, 255, 0.05);
|
--bg-tertiary: rgba(255, 255, 255, 0.05);
|
||||||
--bg-hover: rgba(255, 255, 255, 0.08);
|
--bg-hover: rgba(255, 255, 255, 0.08);
|
||||||
--text-primary: #F0E8E8;
|
--text-primary: #F0E8E8;
|
||||||
@@ -247,6 +288,7 @@
|
|||||||
--primary-light: rgba(122, 186, 170, 0.15);
|
--primary-light: rgba(122, 186, 170, 0.15);
|
||||||
--bg-primary: #121a1a;
|
--bg-primary: #121a1a;
|
||||||
--bg-secondary: rgba(28, 42, 42, 0.9);
|
--bg-secondary: rgba(28, 42, 42, 0.9);
|
||||||
|
--bg-secondary-solid: #1c2a2a;
|
||||||
--bg-tertiary: rgba(255, 255, 255, 0.05);
|
--bg-tertiary: rgba(255, 255, 255, 0.05);
|
||||||
--bg-hover: rgba(255, 255, 255, 0.08);
|
--bg-hover: rgba(255, 255, 255, 0.08);
|
||||||
--text-primary: #E4F0F0;
|
--text-primary: #E4F0F0;
|
||||||
@@ -260,6 +302,43 @@
|
|||||||
--sent-card-bg: var(--primary);
|
--sent-card-bg: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 繁花如梦 - 深色(夜阑幽梦)
|
||||||
|
[data-theme="blossom-dream"][data-mode="dark"] {
|
||||||
|
// 光晕色(供伪元素使用,降低饱和度避免刺眼)
|
||||||
|
--blossom-pink: #C670C3;
|
||||||
|
--blossom-purple: #5F4B8B;
|
||||||
|
--blossom-blue: #3A2A50;
|
||||||
|
|
||||||
|
// 主品牌色:藕粉/烟紫粉,降饱和度不刺眼
|
||||||
|
--primary: #D19EBB;
|
||||||
|
--primary-rgb: 209, 158, 187;
|
||||||
|
--primary-hover: #DDB0C8;
|
||||||
|
--primary-light: rgba(209, 158, 187, 0.15);
|
||||||
|
|
||||||
|
// 背景三层:极深黑灰底(去掉紫薯色),面板略浅,卡片再浅一级
|
||||||
|
--bg-primary: #151316;
|
||||||
|
--bg-secondary: rgba(34, 30, 36, 0.92);
|
||||||
|
--bg-secondary-solid: #221E24;
|
||||||
|
--bg-tertiary: rgba(255, 255, 255, 0.04);
|
||||||
|
--bg-hover: rgba(209, 158, 187, 0.1);
|
||||||
|
|
||||||
|
// 文字
|
||||||
|
--text-primary: #F0EAF4;
|
||||||
|
--text-secondary: #A898AE;
|
||||||
|
--text-tertiary: #6A5870;
|
||||||
|
// 边框:极细白色内发光,剥离层级
|
||||||
|
--border-color: rgba(255, 255, 255, 0.07);
|
||||||
|
|
||||||
|
--bg-gradient: linear-gradient(150deg, #151316 0%, #1A1620 50%, #131018 100%);
|
||||||
|
--primary-gradient: linear-gradient(135deg, #D19EBB 0%, #A878A8 100%);
|
||||||
|
|
||||||
|
// 卡片:比面板更亮一档,用深灰而非紫色
|
||||||
|
--card-bg: rgba(42, 38, 46, 0.92);
|
||||||
|
--card-inner-bg: rgba(52, 48, 56, 0.96);
|
||||||
|
|
||||||
|
--sent-card-bg: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
// 重置样式
|
// 重置样式
|
||||||
* {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user