chore: release 2.3.5

This commit is contained in:
ILoveBingLu
2026-04-03 16:36:17 +08:00
parent e0db6729c2
commit 5e27646e9f
13 changed files with 706 additions and 206 deletions

View File

@@ -82,6 +82,16 @@ jobs:
runs-on: windows-latest
environment: 软件发布
needs: prepare-meta
env:
GH_TOKEN: ${{ secrets.GH_TOKEN }}
AI_API_KEY: ${{ secrets.AI_API_KEY }}
AI_API_URL: ${{ vars.AI_API_URL }}
AI_MODEL: ${{ vars.AI_MODEL }}
FORCE_UPDATE_MIN_VERSION: ${{ vars.FORCE_UPDATE_MIN_VERSION }}
FORCE_UPDATE_BLOCKED_VERSIONS: ${{ vars.FORCE_UPDATE_BLOCKED_VERSIONS }}
FORCE_UPDATE_TITLE: ${{ vars.FORCE_UPDATE_TITLE }}
FORCE_UPDATE_MESSAGE: ${{ vars.FORCE_UPDATE_MESSAGE }}
FORCE_UPDATE_RELEASE_NOTES: ${{ vars.FORCE_UPDATE_RELEASE_NOTES }}
steps:
- name: Checkout
uses: actions/checkout@v5
@@ -98,6 +108,15 @@ jobs:
- name: Rebuild native modules
run: npx electron-rebuild
- name: Download release metadata
uses: actions/download-artifact@v4
with:
name: release-meta
path: release
- name: Generate embedded release body
run: npm run build:release-body
- name: Build app
run: npm run build:ci

1
.gitignore vendored
View File

@@ -28,6 +28,7 @@ dist-electron
# Build output
out
release
.tmp/release-announcement.json
# Database
*.db

View File

@@ -7,7 +7,7 @@
**一款现代化的微信聊天记录查看与分析工具**
[![License](https://img.shields.io/badge/license-CC--BY--NC--SA--4.0-blue.svg)](LICENSE)
[![Version](https://img.shields.io/badge/version-2.3.4-green.svg)](package.json)
[![Version](https://img.shields.io/badge/version-2.3.5-green.svg)](package.json)
[![Platform](https://img.shields.io/badge/platform-Windows-0078D6.svg?logo=windows)]()
[![Electron](https://img.shields.io/badge/Electron-39-47848F.svg?logo=electron)]()
[![React](https://img.shields.io/badge/React-19-61DAFB.svg?logo=react)]()

View File

@@ -94,6 +94,64 @@ let welcomeWindow: BrowserWindow | null = null
let chatHistoryWindow: BrowserWindow | null = null
const allowDevTools = !!process.env.VITE_DEV_SERVER_URL
type ReleaseAnnouncementPayload = {
version: string
releaseBody?: string
releaseNotes?: string
generatedAt?: string
}
function getReleaseAnnouncementPath(): string {
const isDev = !!process.env.VITE_DEV_SERVER_URL
return isDev
? join(__dirname, '../.tmp/release-announcement.json')
: join(process.resourcesPath, 'release-announcement.json')
}
function syncPackagedReleaseAnnouncement() {
if (!configService) return
const announcementPath = getReleaseAnnouncementPath()
if (!existsSync(announcementPath)) {
return
}
try {
const raw = readFileSync(announcementPath, 'utf8')
const payload = JSON.parse(raw) as ReleaseAnnouncementPayload
if (!payload || typeof payload !== 'object') return
const version = String(payload.version || '').trim()
if (!version || version !== app.getVersion()) return
const releaseBody = String(payload.releaseBody || '').trim()
const releaseNotes = String(payload.releaseNotes || '').trim()
const storedVersion = configService.get('releaseAnnouncementVersion')
const storedBody = configService.get('releaseAnnouncementBody')
const storedNotes = configService.get('releaseAnnouncementNotes')
if (
storedVersion === version &&
storedBody === releaseBody &&
storedNotes === releaseNotes
) {
return
}
configService.set('releaseAnnouncementVersion', version)
configService.set('releaseAnnouncementBody', releaseBody)
configService.set('releaseAnnouncementNotes', releaseNotes)
logService?.info('ReleaseAnnouncement', '已同步本地版本公告', {
version,
hasBody: Boolean(releaseBody),
hasNotes: Boolean(releaseNotes)
})
} catch (error) {
logService?.warn('ReleaseAnnouncement', '同步本地版本公告失败', { error: String(error) })
}
}
/**
* 获取当前主题的 URL 查询参数
* 用于子窗口加载时传递主题,防止闪烁
@@ -203,6 +261,7 @@ function createWindow() {
dbService = new DatabaseService()
logService = new LogService(configService)
syncPackagedReleaseAnnouncement()
mcpProxyService.setLogger(logService)
autoUpdater.logger = {
info(message: string) {

View File

@@ -30,6 +30,10 @@ interface ConfigSchema {
themeMode: string
appIcon: string
language: string
releaseAnnouncementVersion: string
releaseAnnouncementBody: string
releaseAnnouncementNotes: string
releaseAnnouncementSeenVersion: string
// 协议相关
agreementVersion: number
@@ -99,6 +103,10 @@ const defaults: ConfigSchema = {
themeMode: 'light',
appIcon: 'default',
language: 'zh-CN',
releaseAnnouncementVersion: '',
releaseAnnouncementBody: '',
releaseAnnouncementNotes: '',
releaseAnnouncementSeenVersion: '',
sttLanguages: ['zh'],
sttModelType: 'int8',
sttMode: 'cpu', // 默认使用 CPU 模式

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "ciphertalk",
"version": "2.3.3",
"version": "2.3.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ciphertalk",
"version": "2.3.3",
"version": "2.3.5",
"hasInstallScript": true,
"license": "CC-BY-NC-SA-4.0",
"dependencies": {

View File

@@ -1,19 +1,20 @@
{
"name": "ciphertalk",
"version": "2.3.4",
"version": "2.3.5",
"description": "密语 - 微信聊天记录查看工具",
"author": "ILoveBingLu",
"license": "CC-BY-NC-SA-4.0",
"main": "dist-electron/main.js",
"scripts": {
"dev": "vite",
"prebuild": "node scripts/update-readme-version.js",
"prebuild": "node scripts/update-readme-version.js && node scripts/prepare-release-announcement.js",
"build": "tsc && vite build && electron-builder && node scripts/add-size-to-yml.js",
"build:ci": "tsc && vite build && electron-builder --publish never && node scripts/add-size-to-yml.js",
"build:ci": "node scripts/prepare-release-announcement.js && tsc && vite build && electron-builder --publish never && node scripts/add-size-to-yml.js",
"build:mcp": "tsc && vite build",
"build:force-update-manifest": "node scripts/generate-force-update-manifest.js",
"build:release-context": "node scripts/generate-release-context.js",
"build:release-body": "node scripts/generate-release-body.js",
"build:release-announcement": "node scripts/prepare-release-announcement.js",
"notify:telegram": "node scripts/send-telegram-release.js",
"mcp": "node scripts/mcp-runner.js",
"mcp:probe": "node scripts/mcp-probe.js",
@@ -95,7 +96,7 @@
"repo": "CipherTalk"
},
"win": {
"icon": "public/xinnian.ico",
"icon": "public/icon.ico",
"target": "nsis",
"requestedExecutionLevel": "asInvoker"
},
@@ -112,9 +113,9 @@
"language": "2052",
"displayLanguageSelector": false,
"include": "installer.nsh",
"installerIcon": "public/xinnian.ico",
"uninstallerIcon": "public/xinnian.ico",
"installerHeaderIcon": "public/xinnian.ico",
"installerIcon": "public/icon.ico",
"uninstallerIcon": "public/icon.ico",
"installerHeaderIcon": "public/icon.ico",
"perMachine": false,
"allowElevation": true,
"installerSidebar": null,
@@ -143,6 +144,10 @@
{
"from": "public/xinnian.ico",
"to": "xinnian.ico"
},
{
"from": ".tmp/release-announcement.json",
"to": "release-announcement.json"
}
],
"extraFiles": [

View File

@@ -10,7 +10,7 @@ if (!fs.existsSync(ymlPath)) {
}
// 读取 yml 内容
let content = fs.readFileSync(ymlPath, 'utf-8')
const content = fs.readFileSync(ymlPath, 'utf-8')
const lines = content.split('\n')
// 从 yml 中提取文件名
@@ -32,10 +32,11 @@ if (!fs.existsSync(exePath)) {
const stats = fs.statSync(exePath)
const size = stats.size
// 找到 files 块内第一个 sha512 行,在其后插入 size
// electron-builder 新版本已经会生成 files[0].size这里只在缺失时补齐避免写出重复键
const newLines = []
let inFiles = false
let sizeAdded = false
let fileItemIndent = ''
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
@@ -43,11 +44,36 @@ for (let i = 0; i < lines.length; i++) {
if (line.startsWith('files:')) {
inFiles = true
fileItemIndent = ''
continue
}
if (!inFiles) {
continue
}
const trimmed = line.trim()
const indent = line.match(/^\s*/)?.[0] || ''
if (trimmed.startsWith('- ')) {
fileItemIndent = `${indent} `
continue
}
if (trimmed.startsWith('size:')) {
console.log('latest.yml 已包含 size跳过写入')
process.exit(0)
}
// 离开 files 块
if (trimmed && !line.startsWith(' ') && !line.startsWith('\t')) {
inFiles = false
continue
}
// 在 files 块内的第一个 sha512 后添加 size
if (inFiles && !sizeAdded && line.trim().startsWith('sha512:')) {
newLines.push(` size: ${size}`)
if (!sizeAdded && trimmed.startsWith('sha512:')) {
newLines.push(`${fileItemIndent || ' '}size: ${size}`)
sizeAdded = true
inFiles = false
}

View File

@@ -0,0 +1,81 @@
const fs = require('fs')
const path = require('path')
const rootDir = path.resolve(__dirname, '..')
const releaseDir = path.join(rootDir, 'release')
const tempDir = path.join(rootDir, '.tmp')
const bodyPath = path.join(releaseDir, 'release-body.md')
const forceUpdatePath = path.join(releaseDir, 'force-update.json')
const outputPath = path.join(tempDir, 'release-announcement.json')
const packageJsonPath = path.join(rootDir, 'package.json')
function readJsonIfExists(filePath) {
if (!fs.existsSync(filePath)) return null
try {
return JSON.parse(fs.readFileSync(filePath, 'utf8'))
} catch (error) {
console.warn(`[ReleaseAnnouncement] 读取 JSON 失败: ${filePath}`, String(error))
return null
}
}
function readTextIfExists(filePath) {
if (!fs.existsSync(filePath)) return ''
try {
return fs.readFileSync(filePath, 'utf8').trim()
} catch (error) {
console.warn(`[ReleaseAnnouncement] 读取文本失败: ${filePath}`, String(error))
return ''
}
}
function buildFallbackBody(version, releaseNotes) {
const normalizedNotes = String(releaseNotes || '').trim()
const overview = normalizedNotes
? normalizedNotes
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => (line.startsWith('-') || line.startsWith('*') ? line : `- ${line}`))
.join('\n')
: '- 本次版本已完成发布,详细内容将在后续发布说明中补充。'
return [
`## CipherTalk v${version}`,
'',
'### 概览',
overview,
'',
'### 感谢贡献者',
'- 感谢每一位使用与反馈的用户',
'',
'### 相关提交与 PR',
'- 详见本次发布记录',
''
].join('\n')
}
function main() {
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'))
const version = String(pkg.version || '').trim()
if (!version) {
throw new Error('package.json 中未找到 version')
}
const releaseBody = readTextIfExists(bodyPath)
const forceUpdate = readJsonIfExists(forceUpdatePath) || {}
const releaseNotes = String(forceUpdate.releaseNotes || '').trim()
const payload = {
version,
releaseBody: releaseBody || buildFallbackBody(version, releaseNotes),
releaseNotes: releaseNotes || releaseBody || '',
generatedAt: new Date().toISOString()
}
fs.mkdirSync(tempDir, { recursive: true })
fs.writeFileSync(outputPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8')
console.log(`[ReleaseAnnouncement] 已生成 ${outputPath}`)
}
main()

View File

@@ -1,87 +1,134 @@
//更新说明!!!
import { Package, Image, Mic, Filter, Send, Aperture } from 'lucide-react'
import { ReactNode } from 'react'
import { Aperture, Package, Send, Sparkles, Wand2 } from 'lucide-react'
import './WhatsNewModal.scss'
interface WhatsNewModalProps {
onClose: () => void
version: string
onClose: () => void
version: string
releaseBody?: string
releaseNotes?: string
}
function WhatsNewModal({ onClose, version }: WhatsNewModalProps) {
const updates = [
{
icon: <Package size={20} />,
title: '优化',
desc: '优化html导出。'
},
{
icon: <Package size={20} />,
title: '优化',
desc: '优化最小化至托盘功能。'
}
// {
// icon: <Image size={20} />,
// title: '聊天内图片',
// desc: '支持查看谷歌标准实况图片(iOS端与大疆等实况图片,发送后实况暂不支持)。'
// }
// {
// icon: <Mic size={20} />,
// title: '语音导出',
// desc: '支持将语音消息解码为 WAV 格式导出,含转写文字。'
// },
// {
// icon: <Filter size={20} />,
// title: '新增',
// desc: '新增API端点等功能。'
// },
// {
// icon: <Aperture size={20} />,
// title: '朋友圈',
// desc: '优化样式!'
// }
]
type UpdateItem = {
icon: ReactNode
title: string
desc: string
}
const handleTelegram = () => {
window.electronAPI?.shell?.openExternal?.('https://t.me/+p7YzmRMBm-gzNzJl')
function inferTitle(text: string): string {
if (/[修复|稳定|兼容|解决]/.test(text)) return '修复'
if (/[优化|提升|改进|性能]/.test(text)) return '优化'
if (/[新增|支持|加入|开放]/.test(text)) return '新增'
return '更新'
}
function inferIcon(text: string): ReactNode {
if (/[界面|动画|视觉|样式|体验]/.test(text)) return <Aperture size={20} />
if (/[新增|支持|加入|开放]/.test(text)) return <Sparkles size={20} />
if (/[优化|提升|改进|性能]/.test(text)) return <Wand2 size={20} />
return <Package size={20} />
}
function parseAnnouncementText(content?: string): UpdateItem[] {
if (!content?.trim()) return []
const lines = content
.split(/\r?\n/)
.map(line => line.trim())
.filter(line => line && !/^#+\s*/.test(line) && !/^\d+\.\s*$/.test(line))
.map(line => line.replace(/^[-*•]\s*/, ''))
.filter(Boolean)
.slice(0, 5)
return lines.map((line) => ({
icon: inferIcon(line),
title: inferTitle(line),
desc: line
}))
}
function buildFallbackUpdates(version: string): UpdateItem[] {
return [
{
icon: <Sparkles size={20} />,
title: '版本上线',
desc: `已切换到 ${version},界面与功能会自动按当前版本展示最新内容。`
},
{
icon: <Wand2 size={20} />,
title: '体验优化',
desc: '我们会持续打磨性能、细节和稳定性,无需再为这条欢迎信息手动改文案。'
},
{
icon: <Package size={20} />,
title: '自动适配',
desc: '如果发布说明存在,这里会优先自动展示本次更新要点。'
}
]
}
return (
<div className="whats-new-overlay">
<div className="whats-new-modal">
<div className="modal-header">
<span className="version-tag"> {version}</span>
<h2></h2>
<p></p>
</div>
function buildHeadline(version: string, updates: UpdateItem[]) {
if (updates.length > 0) {
return {
title: `密语 ${version} 已就绪`,
subtitle: '以下是这次版本自动整理出的更新重点'
}
}
<div className="modal-content">
<div className="update-list">
{updates.map((item, index) => (
<div className="update-item" key={index}>
<div className="item-icon">
{item.icon}
</div>
<div className="item-info">
<h3>{item.title}</h3>
<p>{item.desc}</p>
</div>
</div>
))}
</div>
</div>
return {
title: `欢迎使用密语 ${version}`,
subtitle: '当前版本已安装完成,以下内容会根据版本自动展示'
}
}
<div className="modal-footer">
<button className="telegram-btn" onClick={handleTelegram}>
<Send size={16} />
Telegram
</button>
<button className="start-btn" onClick={onClose}>
</button>
</div>
</div>
function WhatsNewModal({ onClose, version, releaseBody, releaseNotes }: WhatsNewModalProps) {
const notesUpdates = parseAnnouncementText(releaseNotes)
const bodyUpdates = parseAnnouncementText(releaseBody)
const parsedUpdates = notesUpdates.length > 0 ? notesUpdates : bodyUpdates
const items = parsedUpdates.length > 0 ? parsedUpdates : buildFallbackUpdates(version)
const headline = buildHeadline(version, parsedUpdates)
const handleTelegram = () => {
window.electronAPI?.shell?.openExternal?.('https://t.me/+p7YzmRMBm-gzNzJl')
}
return (
<div className="whats-new-overlay">
<div className="whats-new-modal">
<div className="modal-header">
<span className="version-tag"> {version}</span>
<h2>{headline.title}</h2>
<p>{headline.subtitle}</p>
</div>
)
<div className="modal-content">
<div className="update-list">
{items.map((item, index) => (
<div className="update-item" key={`${item.title}-${index}`}>
<div className="item-icon">
{item.icon}
</div>
<div className="item-info">
<h3>{item.title}</h3>
<p>{item.desc}</p>
</div>
</div>
))}
</div>
</div>
<div className="modal-footer">
<button className="telegram-btn" onClick={handleTelegram}>
<Send size={16} />
Telegram
</button>
<button className="start-btn" onClick={onClose}>
使
</button>
</div>
</div>
</div>
)
}
export default WhatsNewModal

View File

@@ -27,6 +27,8 @@ function HomePage() {
// 新版本弹窗状态
const [showWhatsNew, setShowWhatsNew] = useState(false)
const [currentVersion, setCurrentVersion] = useState('')
const [releaseBody, setReleaseBody] = useState('')
const [releaseNotes, setReleaseNotes] = useState('')
useEffect(() => {
checkNewVersion()
@@ -61,20 +63,32 @@ function HomePage() {
const checkNewVersion = async () => {
try {
// 获取当前应用版本
const version = await window.electronAPI.app.getVersion()
setCurrentVersion(version)
// 获取上次查看的版本
const lastSeenVersion = localStorage.getItem('lastSeenVersion')
const [
announcementVersion,
announcementBody,
announcementNotes,
seenVersion
] = await Promise.all([
window.electronAPI.config.get('releaseAnnouncementVersion'),
window.electronAPI.config.get('releaseAnnouncementBody'),
window.electronAPI.config.get('releaseAnnouncementNotes'),
window.electronAPI.config.get('releaseAnnouncementSeenVersion')
])
// 简单的版本比较逻辑:如果版本不同且未记录,或者是新安装,则显示
// 为了防止每次开发时版本号不变也弹,这里只在版本确实不同时弹
// 注意:这里假设版本号格式为 x.y.z
const normalizedAnnouncementVersion = String(announcementVersion || '').trim()
const normalizedBody = String(announcementBody || '').trim()
const normalizedNotes = String(announcementNotes || '').trim()
const normalizedSeenVersion = String(seenVersion || '').trim()
if (version !== lastSeenVersion) {
// 如果是全新安装(没有 lastSeenVersion也显示
// 实际上这通常用于引导新用户
if (normalizedAnnouncementVersion === version) {
setReleaseBody(normalizedBody)
setReleaseNotes(normalizedNotes)
}
if (normalizedAnnouncementVersion === version && normalizedSeenVersion !== version) {
setShowWhatsNew(true)
}
} catch (e) {
@@ -85,7 +99,7 @@ function HomePage() {
const handleCloseWhatsNew = () => {
setShowWhatsNew(false)
if (currentVersion) {
localStorage.setItem('lastSeenVersion', currentVersion)
window.electronAPI.config.set('releaseAnnouncementSeenVersion', currentVersion)
}
}
@@ -119,6 +133,8 @@ function HomePage() {
{showWhatsNew && (
<WhatsNewModal
version={currentVersion}
releaseBody={releaseBody}
releaseNotes={releaseNotes}
onClose={handleCloseWhatsNew}
/>
)}

View File

@@ -1,108 +1,279 @@
// 确保启动屏窗口背景透明(让 Electron 的 transparent: true 生效)
html,
body {
background: transparent !important;
overflow: hidden !important; // 隐藏滚动条
overflow: hidden !important;
}
.splash-page {
width: calc(100% - 24px); // 减去外边距
height: calc(100vh - 24px); // 减去外边距
margin: 12px; // 添加外边距,让圆角更明显
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #F0EEE9 0%, #E8E6E1 100%);
color: #3d3d3d;
overflow: hidden;
position: relative;
border-radius: 24px; // 大圆角
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.15); // 添加阴影效果
// 入场动画
animation: fadeIn 0.4s ease-out;
width: calc(100% - 24px);
height: calc(100vh - 24px);
margin: 12px;
overflow: hidden;
border-radius: 28px;
background:
radial-gradient(circle at top left, rgba(255, 226, 184, 0.72), transparent 38%),
radial-gradient(circle at 85% 18%, rgba(255, 153, 111, 0.24), transparent 26%),
linear-gradient(145deg, #fbf5ea 0%, #f3eadc 38%, #ece2d4 100%);
box-shadow:
0 28px 70px rgba(71, 43, 20, 0.18),
inset 0 1px 0 rgba(255, 255, 255, 0.55);
animation: splash-enter 0.55s cubic-bezier(0.22, 1, 0.36, 1);
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: radial-gradient(circle at 50% 50%, rgba(139, 115, 85, 0.05) 0%, transparent 70%);
inset: 0;
background:
linear-gradient(125deg, rgba(255, 255, 255, 0.28), transparent 30%, transparent 70%, rgba(122, 71, 35, 0.08)),
repeating-linear-gradient(
135deg,
rgba(122, 71, 35, 0.03) 0,
rgba(122, 71, 35, 0.03) 1px,
transparent 1px,
transparent 18px
);
pointer-events: none;
}
// 退出动画类
&.fade-out {
animation: fadeOut 0.3s ease-in forwards;
animation: splash-exit 0.28s ease-in forwards;
}
}
.splash-content {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 44px 42px 34px;
}
.splash-orb {
position: absolute;
border-radius: 999px;
filter: blur(8px);
opacity: 0.8;
pointer-events: none;
}
.splash-orb-left {
top: 48px;
left: 56px;
width: 180px;
height: 180px;
background: radial-gradient(circle, rgba(255, 255, 255, 0.92) 0%, rgba(255, 232, 197, 0.16) 72%, transparent 100%);
animation: float-orb 6s ease-in-out infinite;
}
.splash-orb-right {
right: 38px;
bottom: 54px;
width: 240px;
height: 240px;
background: radial-gradient(circle, rgba(222, 140, 80, 0.24) 0%, rgba(222, 140, 80, 0.08) 52%, transparent 100%);
animation: float-orb 7.5s ease-in-out infinite reverse;
}
.splash-brand {
display: flex;
align-items: center;
gap: 22px;
position: relative;
z-index: 1;
}
.splash-logo-shell {
position: relative;
width: 110px;
height: 110px;
display: grid;
place-items: center;
flex: 0 0 auto;
border-radius: 32px;
background: linear-gradient(160deg, rgba(255, 255, 255, 0.74), rgba(247, 233, 210, 0.64));
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.92),
0 18px 40px rgba(116, 72, 36, 0.14);
backdrop-filter: blur(10px);
}
.splash-logo-glow {
position: absolute;
inset: 16px;
border-radius: 22px;
background: radial-gradient(circle, rgba(255, 196, 129, 0.42) 0%, transparent 72%);
animation: logo-breathe 3.2s ease-in-out infinite;
}
.splash-logo-image,
.splash-logo-fallback {
position: relative;
z-index: 1;
}
.splash-logo-image {
width: 74px;
height: 74px;
object-fit: contain;
filter: drop-shadow(0 10px 20px rgba(122, 71, 35, 0.14));
animation: logo-drift 3.8s ease-in-out infinite;
}
.splash-logo-fallback {
width: 74px;
height: 74px;
display: grid;
place-items: center;
font-size: 28px;
font-weight: 700;
color: #81512d;
letter-spacing: 4px;
}
.splash-copy {
display: flex;
flex-direction: column;
gap: 8px;
color: #49311f;
h1 {
margin: 0;
font-size: 36px;
line-height: 1;
font-weight: 700;
letter-spacing: 0.06em;
}
p {
margin: 0;
font-size: 14px;
color: rgba(73, 49, 31, 0.68);
letter-spacing: 0.04em;
}
}
.splash-eyebrow {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.28em;
text-transform: uppercase;
color: rgba(129, 81, 45, 0.72);
}
.splash-status {
display: flex;
flex-direction: column;
gap: 16px;
position: relative;
z-index: 1;
padding-top: 18px;
}
.splash-status::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: min(220px, 34vw);
height: 1px;
background: linear-gradient(90deg, rgba(120, 77, 44, 0.28), rgba(120, 77, 44, 0));
}
.splash-status-row {
display: flex;
align-items: center;
gap: 10px;
min-height: 20px;
}
.splash-status-dot {
width: 9px;
height: 9px;
flex: 0 0 auto;
border-radius: 999px;
background: linear-gradient(135deg, #ff9d54, #d96b2d);
box-shadow: 0 0 0 6px rgba(217, 107, 45, 0.12);
animation: status-pulse 1.8s ease-in-out infinite;
}
.splash-status-text {
font-size: 14px;
font-weight: 600;
color: rgba(73, 49, 31, 0.82);
animation: status-fade 220ms ease-out;
}
.splash-progress-track {
position: relative;
height: 4px;
overflow: hidden;
border-radius: 999px;
background: rgba(108, 69, 39, 0.12);
max-width: 260px;
}
.splash-progress-bar {
position: absolute;
inset: 0;
width: 42%;
border-radius: inherit;
background: linear-gradient(90deg, rgba(255, 161, 98, 0) 0%, #ffb36b 22%, #d96b2d 78%, rgba(217, 107, 45, 0) 100%);
animation: progress-slide 1.8s cubic-bezier(0.4, 0, 0.2, 1) infinite;
}
@media (max-width: 560px) {
.splash-page {
width: calc(100% - 16px);
height: calc(100vh - 16px);
margin: 8px;
border-radius: 22px;
}
.splash-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 32px;
z-index: 1;
padding: 28px 24px 24px;
}
.splash-logo {
display: flex;
align-items: center;
justify-content: center;
.logo-icon {
font-size: 36px;
font-weight: 700;
letter-spacing: 2px;
color: #8B7355;
background: linear-gradient(135deg, #8B7355 0%, #A68B5B 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
// 每2秒翻转一次
animation: flip 2s ease-in-out infinite;
}
// logo 图片每2秒翻转一次
img {
width: 100px;
height: 100px;
object-fit: contain;
animation: flip 2s ease-in-out infinite;
}
.splash-brand {
gap: 16px;
align-items: flex-start;
}
.splash-text {
display: flex;
align-items: center;
gap: 12px;
font-size: 15px;
font-weight: 500;
color: #666666;
.splash-logo-shell {
width: 92px;
height: 92px;
border-radius: 24px;
}
.spin {
animation: spin 1s linear infinite;
color: #8B7355;
}
.splash-logo-image,
.splash-logo-fallback {
width: 62px;
height: 62px;
}
.splash-copy h1 {
font-size: 28px;
}
.splash-progress-track {
max-width: 180px;
}
}
// 入场淡入动画
@keyframes fadeIn {
@keyframes splash-enter {
from {
opacity: 0;
transform: scale(0.95);
transform: scale(0.97) translateY(10px);
}
to {
opacity: 1;
transform: scale(1);
transform: scale(1) translateY(0);
}
}
// 退出淡出动画
@keyframes fadeOut {
@keyframes splash-exit {
from {
opacity: 1;
transform: scale(1);
@@ -110,34 +281,76 @@ body {
to {
opacity: 0;
transform: scale(0.95);
transform: scale(0.985) translateY(8px);
}
}
// 翻转动画每2秒翻转360度中间有停顿
@keyframes flip {
@keyframes float-orb {
0%,
25% {
transform: rotateY(0deg);
100% {
transform: translate3d(0, 0, 0);
}
50% {
transform: rotateY(180deg);
}
75%,
100% {
transform: rotateY(360deg);
transform: translate3d(10px, -12px, 0);
}
}
@keyframes spin {
@keyframes logo-breathe {
0%,
100% {
transform: scale(0.94);
opacity: 0.6;
}
50% {
transform: scale(1.04);
opacity: 1;
}
}
@keyframes logo-drift {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-4px);
}
}
@keyframes status-pulse {
0%,
100% {
transform: scale(1);
box-shadow: 0 0 0 6px rgba(217, 107, 45, 0.12);
}
50% {
transform: scale(1.08);
box-shadow: 0 0 0 9px rgba(217, 107, 45, 0.08);
}
}
@keyframes status-fade {
from {
transform: rotate(0deg);
opacity: 0;
transform: translateY(4px);
}
to {
transform: rotate(360deg);
opacity: 1;
transform: translateY(0);
}
}
}
@keyframes progress-slide {
0% {
transform: translateX(-135%);
}
100% {
transform: translateX(330%);
}
}

View File

@@ -1,15 +1,17 @@
import { useEffect, useState } from 'react'
import { Loader2 } from 'lucide-react'
import './SplashPage.scss'
import { useThemeStore } from '../stores/themeStore'
const loadingMessages = [
'正在校验本地环境',
'正在连接数据库',
'正在整理聊天索引'
]
function SplashPage() {
const [fadeOut, setFadeOut] = useState(false)
const appIcon = useThemeStore(state => state.appIcon)
const [messageIndex, setMessageIndex] = useState(0)
useEffect(() => {
// 等待入场动画完成后再通知主进程(入场动画 0.4s + 额外停留 0.6s = 1s
const readyTimer = setTimeout(() => {
try {
// @ts-ignore - splashReady 方法在运行时可用
@@ -19,37 +21,61 @@ function SplashPage() {
}
}, 1000)
// 监听淡出事件
const messageTimer = setInterval(() => {
setMessageIndex((prev) => (prev + 1) % loadingMessages.length)
}, 1600)
const cleanup = window.electronAPI?.window?.onSplashFadeOut?.(() => {
setFadeOut(true)
})
return () => {
clearTimeout(readyTimer)
clearInterval(messageTimer)
cleanup?.()
}
}, [])
return (
<div className={`splash-page ${fadeOut ? 'fade-out' : ''}`}>
<div className="splash-orb splash-orb-left" />
<div className="splash-orb splash-orb-right" />
<div className="splash-content">
<div className="splash-logo">
{/* 尝试加载logo图片如果不存在则显示文字 */}
<img
src={appIcon === 'xinnian' ? "./xinnian.png" : "./logo.png"}
alt="密语"
onError={(e) => {
// 如果图片加载失败隐藏img显示文字
e.currentTarget.style.display = 'none'
const textEl = e.currentTarget.nextElementSibling as HTMLElement
if (textEl) textEl.style.display = 'block'
}}
/>
<div className="logo-icon" style={{ display: 'none' }}></div>
<div className="splash-brand">
<div className="splash-logo-shell">
<div className="splash-logo-glow" />
<img
className="splash-logo-image"
src="./logo.png"
alt="密语"
onError={(e) => {
e.currentTarget.style.display = 'none'
const textEl = e.currentTarget.nextElementSibling as HTMLElement | null
if (textEl) textEl.style.display = 'grid'
}}
/>
<div className="splash-logo-fallback" style={{ display: 'none' }}></div>
</div>
<div className="splash-copy">
<span className="splash-eyebrow">CipherTalk</span>
<h1></h1>
<p></p>
</div>
</div>
<div className="splash-text">
<Loader2 size={20} className="spin" />
<span>...</span>
<div className="splash-status">
<div className="splash-status-row">
<span className="splash-status-dot" />
<span key={loadingMessages[messageIndex]} className="splash-status-text">
{loadingMessages[messageIndex]}
</span>
</div>
<div className="splash-progress-track" aria-hidden="true">
<div className="splash-progress-bar" />
</div>
</div>
</div>
</div>
@@ -57,4 +83,3 @@ function SplashPage() {
}
export default SplashPage