修复了一些问题

This commit is contained in:
李琦
2026-08-24 15:24:00 +08:00
parent 0f53edc94c
commit b43bfc4afb
39 changed files with 4282 additions and 142 deletions

25
.env.electron Normal file
View File

@@ -0,0 +1,25 @@
# Electron 生产构建环境变量(vite build --mode electron 时加载)
#
# 为什么需要:打包后的桌面端以 file:// 协议运行,没有 Vite 开发代理,
# 相对路径 /api、/ws 无法到达后端,必须配置后端绝对地址。
# 更换服务器地址后需要重新打包。
#
# 注意:Electron 本地开发(npm run dev:electron,mode=electron-dev)不会加载本文件,
# 开发时仍走 Vite 代理连本地后端,与 Web 开发体验一致。
# HTTP API 基础地址
VITE_API_BASE_URL=https://g-ws.nailaoyun.cn/api
# WebSocket 完整地址(wsManager 优先使用该配置)
VITE_WS_URL=wss://g-ws.nailaoyun.cn/ws
# 静态资源(uploads 附件/头像等)域名
VITE_API_ORIGIN=https://g-ws.nailaoyun.cn
# ---------- 本地测试打包(npm run build:electron:test)----------
# TEST_ 前缀字段:electron-test 模式构建时会去掉前缀、替代上面的正式配置,
# 打出的包连接本地后端,用于不发服务器就验证桌面端完整功能。
# 正式打包(npm run build:electron)不会读取这些字段,可放心保留。
TEST_VITE_API_BASE_URL=http://localhost:12080/api
TEST_VITE_WS_URL=ws://localhost:12080/ws
TEST_VITE_API_ORIGIN=http://localhost:12080

4
.gitignore vendored
View File

@@ -12,6 +12,10 @@ dist
dist-ssr
*.local
# Electron 构建产物
dist-electron
release
# Editor directories and files
.vscode/*
!.vscode/extensions.json

BIN
build/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

BIN
build/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

BIN
build/logo-master.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

428
electron/main.ts Normal file
View File

@@ -0,0 +1,428 @@
/**
* Electron 主进程入口
*
* 职责:
* 1. 应用生命周期管理:单实例锁、开机自启参数(--hidden)、退出行为
* 2. 主窗口创建与加载:开发环境加载 Vite Dev Server(代理仍生效),生产环境加载打包后的 dist/index.html
* 3. 桌面能力:系统托盘、任务栏未读角标/闪烁、系统通知、文件下载(另存为对话框)、外链跳系统浏览器
* 4. 应用内更新:electron-updater,更新包托管在自有服务器 /updates 目录(generic provider)
* 5. 通过 IPC 与渲染进程通信(渲染层只能访问 preload 暴露的 electronAPI,保证安全边界)
*/
import { app, BrowserWindow, Tray, Menu, ipcMain, shell, session, Notification, nativeImage } from 'electron'
// electron-updater 是 CJS 包且用 getter 定义导出,ESM 下具名导入可能解析失败,
// 必须默认导入后再解构(Node ESM 对 CJS 的默认导入即 module.exports 对象)
import electronUpdater from 'electron-updater'
import path from 'node:path'
import fs from 'node:fs'
import { fileURLToPath } from 'node:url'
const { autoUpdater } = electronUpdater
// ==================== 基础路径与环境 ====================
// 主进程以 ESM 产物运行(项目 package.json 为 "type": "module"),
// ESM 中没有全局 __dirname,需从 import.meta.url 派生(指向 dist-electron 目录)
const __dirname = path.dirname(fileURLToPath(import.meta.url))
// vite-plugin-electron 开发模式下会注入 VITE_DEV_SERVER_URL,以此区分开发/生产
const devServerUrl = process.env.VITE_DEV_SERVER_URL
const isDev = !!devServerUrl
// 静态资源目录:开发环境用项目 public/,生产环境用打包进 asar 的 dist/
const publicDir = isDev ? path.join(__dirname, '../public') : path.join(__dirname, '../dist')
const appIconPath = path.join(publicDir, 'icons/icon.png')
const trayIconPath = path.join(publicDir, 'icons/tray.png')
// ==================== 应用设置(持久化到 userData 目录) ====================
/** 应用本地设置:目前只有"关闭窗口时最小化到托盘",后续可扩展 */
interface AppSettings {
closeToTray: boolean
}
const DEFAULT_SETTINGS: AppSettings = { closeToTray: true }
let appSettings: AppSettings = { ...DEFAULT_SETTINGS }
/** 设置文件路径(userData 目录随安装用户隔离,卸载可选保留) */
function settingsFilePath(): string {
return path.join(app.getPath('userData'), 'app-settings.json')
}
/** 读取本地设置,文件不存在或损坏时回退默认值 */
function loadSettings(): AppSettings {
try {
const raw = fs.readFileSync(settingsFilePath(), 'utf-8')
return { ...DEFAULT_SETTINGS, ...JSON.parse(raw) }
} catch {
return { ...DEFAULT_SETTINGS }
}
}
/** 持久化设置(同步写,数据量极小) */
function saveSettings(settings: AppSettings): void {
try {
fs.writeFileSync(settingsFilePath(), JSON.stringify(settings, null, 2), 'utf-8')
} catch (err) {
console.error('[main] 保存设置失败:', err)
}
}
// ==================== 窗口与托盘 ====================
let mainWindow: BrowserWindow | null = null
let tray: Tray | null = null
// 标记应用是否处于"真正退出"流程(托盘退出/安装更新时置 true,绕过关闭最小化逻辑)
let isQuitting = false
// 开机自启时带上 --hidden 参数:静默启动到托盘,不弹出主窗口打扰用户
const startHidden = process.argv.includes('--hidden')
/** 创建主窗口 */
function createMainWindow(): void {
mainWindow = new BrowserWindow({
width: 1280,
height: 800,
minWidth: 960,
minHeight: 600,
show: false, // 等 ready-to-show 再显示,避免加载过程白屏闪烁
autoHideMenuBar: true,
backgroundColor: '#0f172a', // 与前端深色主题一致,减少启动闪白
icon: nativeImage.createFromPath(appIconPath),
webPreferences: {
preload: path.join(__dirname, 'preload.cjs'),
// 安全基线:开启上下文隔离、禁用渲染进程 Node 能力,
// 渲染层只能通过 preload 的 contextBridge 访问受控 API
contextIsolation: true,
nodeIntegration: false,
},
})
// 首帧渲染完成后再显示;开机自启(--hidden)时不显示,直接驻留托盘
mainWindow.once('ready-to-show', () => {
if (!startHidden) {
mainWindow?.show()
}
})
if (isDev) {
mainWindow.loadURL(devServerUrl!)
mainWindow.webContents.openDevTools({ mode: 'detach' })
} else {
// 生产环境加载打包产物;渲染层使用 hash 路由,file:// 协议下可正常工作
mainWindow.loadFile(path.join(__dirname, '../dist/index.html'))
}
// 外链(如消息里的 URL 卡片 window.open)交给系统默认浏览器打开,不在应用内新开窗口
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
if (/^https?:\/\//i.test(url)) {
shell.openExternal(url)
}
return { action: 'deny' }
})
// 安全兜底:阻止页面内导航到应用之外的地址(钓鱼链接/异常跳转),外部 http(s) 转交系统浏览器
mainWindow.webContents.on('will-navigate', (event, url) => {
const isInternal = isDev ? url.startsWith(devServerUrl!) : url.startsWith('file:')
if (!isInternal) {
event.preventDefault()
if (/^https?:\/\//i.test(url)) {
shell.openExternal(url)
}
}
})
// 窗口获得焦点时停止任务栏闪烁(配合新消息 flashFrame 提醒)
mainWindow.on('focus', () => {
mainWindow?.flashFrame(false)
})
// 关闭行为:默认最小化到托盘(IM 常驻习惯),可在设置中改为直接退出
mainWindow.on('close', (event) => {
if (!isQuitting && appSettings.closeToTray) {
event.preventDefault()
mainWindow?.hide()
}
})
mainWindow.on('closed', () => {
mainWindow = null
})
// F12 切换开发者工具(打包后保留,便于现场排查问题;应用菜单已移除,需手动注册按键)
mainWindow.webContents.on('before-input-event', (_event, input) => {
if (input.type === 'keyDown' && input.key === 'F12') {
mainWindow?.webContents.toggleDevTools()
}
})
}
/** 显示并聚焦主窗口(托盘点击/通知点击/二次启动共用) */
function showMainWindow(): void {
if (!mainWindow) {
createMainWindow()
return
}
if (mainWindow.isMinimized()) {
mainWindow.restore()
}
mainWindow.show()
mainWindow.focus()
}
/** 创建系统托盘:单击/双击显示主界面,右键菜单提供退出入口 */
function createTray(): void {
tray = new Tray(nativeImage.createFromPath(trayIconPath))
tray.setToolTip('NL-IM 即时通讯')
tray.setContextMenu(
Menu.buildFromTemplate([
{ label: '显示主界面', click: showMainWindow },
{ type: 'separator' },
{
label: '退出',
click: () => {
isQuitting = true
app.quit()
},
},
])
)
tray.on('click', showMainWindow)
tray.on('double-click', showMainWindow)
}
// ==================== 权限控制 ====================
// 渲染层需要的权限白名单:音视频通话/语音消息(media)、系统通知、全屏播放、剪贴板
const ALLOWED_PERMISSIONS = new Set([
'media',
'notifications',
'fullscreen',
'clipboard-sanitized-write',
'clipboard-read',
'pointerLock',
])
/** 按白名单放行页面权限请求(getUserMedia、Notification 等),其余一律拒绝 */
function setupPermissions(): void {
session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => {
callback(ALLOWED_PERMISSIONS.has(permission))
})
session.defaultSession.setPermissionCheckHandler((_webContents, permission) => {
return ALLOWED_PERMISSIONS.has(permission)
})
}
// ==================== 文件下载 ====================
// 渲染层发起下载时记录期望文件名,用于 will-download 时设置"另存为"默认名
// (file:// 环境下 <a download> 跨域会失效,所以统一走主进程下载)。
// 用 URL→文件名 的 Map 而不是单变量:用户连续下载多个文件时,
// 单变量会被后一次调用覆盖,导致"另存为"默认名错乱或落到错误条目上
const pendingDownloadNames = new Map<string, string>()
/** 注册下载相关 IPC 与 will-download 处理:弹系统"另存为"对话框,完成后通知渲染层 */
function setupDownloads(): void {
ipcMain.handle('download:start', (_event, url: string, fileName: string) => {
if (typeof url !== 'string' || !/^https?:\/\//i.test(url)) {
return
}
if (typeof fileName === 'string' && fileName) {
pendingDownloadNames.set(url, fileName)
}
mainWindow?.webContents.downloadURL(url)
})
session.defaultSession.on('will-download', (_event, item) => {
// 用重定向前的原始 URL(URLChain 首项)匹配发起时登记的文件名,用完即删
const originURL = item.getURLChain()[0] || item.getURL()
const fileName = pendingDownloadNames.get(originURL) || item.getFilename() || `download_${Date.now()}`
pendingDownloadNames.delete(originURL)
// 弹出系统保存对话框,默认定位到系统"下载"目录
item.setSaveDialogOptions({
title: '保存文件',
defaultPath: path.join(app.getPath('downloads'), fileName),
})
item.once('done', (_ev, state) => {
// state: completed=成功 / cancelled=用户取消 / interrupted=失败
mainWindow?.webContents.send('download:done', {
state,
fileName,
savePath: state === 'completed' ? item.getSavePath() : '',
})
})
})
// "打开所在文件夹":下载完成 toast 的快捷操作
ipcMain.handle('shell:show-item-in-folder', (_event, fullPath: string) => {
if (typeof fullPath === 'string' && fullPath) {
shell.showItemInFolder(fullPath)
}
})
}
// ==================== 应用内更新(electron-updater) ====================
/**
* 更新流程说明:
* - 更新源由 electron-builder 的 publish(generic) 配置决定,指向自有服务器 /updates 目录
* - autoDownload 关闭:发现新版本后由用户在设置弹窗中确认再下载(避免流量/打扰)
* - 下载完成后用户可"立即重启安装",或退出应用时自动安装(autoInstallOnAppQuit)
*/
function setupUpdater(): void {
autoUpdater.autoDownload = false
autoUpdater.autoInstallOnAppQuit = true
/** 统一向渲染层推送更新状态(设置弹窗/侧边栏红点据此展示) */
const sendStatus = (payload: Record<string, unknown>): void => {
mainWindow?.webContents.send('updater:status', payload)
}
autoUpdater.on('checking-for-update', () => sendStatus({ type: 'checking' }))
autoUpdater.on('update-available', (info) => sendStatus({ type: 'available', version: info.version }))
autoUpdater.on('update-not-available', () => sendStatus({ type: 'not-available' }))
autoUpdater.on('download-progress', (progress) =>
sendStatus({
type: 'downloading',
percent: progress.percent,
bytesPerSecond: progress.bytesPerSecond,
transferred: progress.transferred,
total: progress.total,
})
)
autoUpdater.on('update-downloaded', (info) => sendStatus({ type: 'downloaded', version: info.version }))
autoUpdater.on('error', (err) => sendStatus({ type: 'error', message: err?.message || String(err) }))
ipcMain.handle('updater:check', () => {
// 开发模式没有安装包上下文,electron-updater 无法工作,直接告知渲染层
if (!app.isPackaged) {
sendStatus({ type: 'dev' })
return
}
// 错误已通过 error 事件上报,这里 catch 避免未处理的 Promise 拒绝
autoUpdater.checkForUpdates().catch(() => {})
})
ipcMain.handle('updater:download', () => {
autoUpdater.downloadUpdate().catch(() => {})
})
ipcMain.handle('updater:install', () => {
// 置退出标记,绕过"关闭最小化到托盘"逻辑,否则 quitAndInstall 会被 close 拦截
isQuitting = true
autoUpdater.quitAndInstall()
})
}
// ==================== 其余 IPC(通知/角标/自启/设置) ====================
/** 注册通用 IPC 处理器 */
function setupIpc(): void {
// 应用版本号(设置弹窗"关于"区块展示)
ipcMain.handle('app:get-version', () => app.getVersion())
// 聚焦主窗口(渲染层可主动调用,如点击应用内横幅)
ipcMain.handle('win:focus', () => showMainWindow())
// 系统通知:点击通知聚焦主窗口。silent=true 是因为渲染层已有自己的提示音,避免双重响铃
ipcMain.handle('app:notify', (_event, options: { title?: string; body?: string }) => {
if (!Notification.isSupported()) {
return
}
const notification = new Notification({
title: options?.title || 'NL-IM',
body: options?.body || '',
icon: nativeImage.createFromPath(appIconPath),
silent: true,
})
notification.on('click', showMainWindow)
notification.show()
})
// 任务栏未读角标:主进程没有 DOM/Canvas,角标图由渲染层 canvas 生成后以 dataURL 传入
ipcMain.handle('win:set-badge', (_event, count: number, dataURL: string) => {
if (!mainWindow) {
return
}
if (count > 0 && dataURL) {
mainWindow.setOverlayIcon(nativeImage.createFromDataURL(dataURL), `${count} 条未读消息`)
} else {
mainWindow.setOverlayIcon(null, '')
}
})
// 任务栏闪烁:仅窗口未聚焦时闪烁,聚焦后由 focus 事件自动取消
ipcMain.handle('win:flash', () => {
if (mainWindow && !mainWindow.isFocused()) {
mainWindow.flashFrame(true)
}
})
// 开机自启查询/设置。开发模式 no-op:否则会把 electron.exe 写进系统自启项,污染开发机
ipcMain.handle('app:get-auto-launch', () => {
if (!app.isPackaged) {
return false
}
return app.getLoginItemSettings().openAtLogin
})
ipcMain.handle('app:set-auto-launch', (_event, enable: boolean) => {
if (!app.isPackaged) {
return false
}
// --hidden:开机自启时静默启动到托盘,不弹主窗口
app.setLoginItemSettings({ openAtLogin: !!enable, args: enable ? ['--hidden'] : [] })
return app.getLoginItemSettings().openAtLogin
})
// 应用设置读写(closeToTray 等)
ipcMain.handle('app:get-settings', () => appSettings)
ipcMain.handle('app:set-settings', (_event, patch: Partial<AppSettings>) => {
appSettings = { ...appSettings, ...patch }
saveSettings(appSettings)
return appSettings
})
}
// ==================== 应用启动引导 ====================
// 单实例锁:IM 应用不允许多开,二次启动时聚焦已有窗口
if (!app.requestSingleInstanceLock()) {
app.quit()
} else {
app.on('second-instance', showMainWindow)
// Windows 通知归属标识,必须与 electron-builder 的 appId 一致,否则系统通知不显示应用名/图标
app.setAppUserModelId('cn.nailaoyun.nlim')
// 移除默认应用菜单(File/Edit/View...),IM 客户端不需要
Menu.setApplicationMenu(null)
app.whenReady().then(() => {
appSettings = loadSettings()
setupPermissions()
setupIpc()
setupDownloads()
setupUpdater()
createMainWindow()
createTray()
})
// 托盘退出/系统关机等触发退出前,置标记绕过关闭拦截
app.on('before-quit', () => {
isQuitting = true
})
// 所有窗口关闭即退出(closeToTray 开启时 close 会被拦截为隐藏,不会走到这里)
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})
// macOS Dock 图标点击时恢复窗口(预留跨平台行为)
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createMainWindow()
} else {
showMainWindow()
}
})
}

79
electron/preload.ts Normal file
View File

@@ -0,0 +1,79 @@
/**
* Electron 预加载脚本
*
* 作用:在上下文隔离(contextIsolation)环境下,通过 contextBridge 向渲染进程暴露
* 受控的桌面能力 API(window.electronAPI)。渲染进程无法直接访问 Node/Electron,
* 只能调用这里明确列出的方法,保证安全边界。
*
* 渲染层对应的类型声明见 src/types/electron.d.ts(两边需保持同步)。
*/
import { contextBridge, ipcRenderer } from 'electron'
import type { IpcRendererEvent } from 'electron'
/** 订阅主进程事件的通用封装:返回取消订阅函数,方便组件卸载时清理 */
function subscribe<T>(channel: string, callback: (payload: T) => void): () => void {
const listener = (_event: IpcRendererEvent, payload: T) => callback(payload)
ipcRenderer.on(channel, listener)
return () => {
ipcRenderer.removeListener(channel, listener)
}
}
const electronAPI = {
/** 当前运行平台(win32/darwin/linux),渲染层可用于平台差异化处理 */
platform: process.platform,
// ---------- 应用信息 ----------
/** 获取应用版本号(package.json version,设置弹窗展示用) */
getVersion: (): Promise<string> => ipcRenderer.invoke('app:get-version'),
// ---------- 窗口 ----------
/** 显示并聚焦主窗口 */
focusWindow: (): Promise<void> => ipcRenderer.invoke('win:focus'),
/** 设置任务栏未读角标;count=0 清除。角标图片由渲染层 canvas 生成(主进程无 DOM) */
setBadge: (count: number, dataURL?: string): Promise<void> =>
ipcRenderer.invoke('win:set-badge', count, dataURL || ''),
/** 请求任务栏闪烁提醒(仅窗口未聚焦时生效,聚焦后自动停止) */
flashFrame: (): Promise<void> => ipcRenderer.invoke('win:flash'),
// ---------- 系统通知 ----------
/** 发送系统级通知(点击通知会显示并聚焦主窗口) */
notify: (options: { title: string; body: string }): Promise<void> =>
ipcRenderer.invoke('app:notify', options),
// ---------- 开机自启与应用设置 ----------
/** 查询是否已设置开机自启 */
getAutoLaunch: (): Promise<boolean> => ipcRenderer.invoke('app:get-auto-launch'),
/** 设置开机自启,返回设置后的实际状态 */
setAutoLaunch: (enable: boolean): Promise<boolean> => ipcRenderer.invoke('app:set-auto-launch', enable),
/** 读取应用本地设置(closeToTray 等) */
getAppSettings: (): Promise<{ closeToTray: boolean }> => ipcRenderer.invoke('app:get-settings'),
/** 增量更新应用本地设置,返回更新后的完整设置 */
setAppSettings: (patch: Partial<{ closeToTray: boolean }>): Promise<{ closeToTray: boolean }> =>
ipcRenderer.invoke('app:set-settings', patch),
// ---------- 文件下载 ----------
/** 下载文件:走主进程弹系统"另存为"对话框(file:// 下 <a download> 跨域失效的替代方案) */
downloadFile: (url: string, fileName: string): Promise<void> =>
ipcRenderer.invoke('download:start', url, fileName),
/** 订阅下载完成事件(state: completed/cancelled/interrupted),返回取消订阅函数 */
onDownloadDone: (
callback: (payload: { state: string; fileName: string; savePath: string }) => void
): (() => void) => subscribe('download:done', callback),
/** 在系统文件管理器中显示指定文件(下载完成 toast 的"打开文件夹"操作) */
showItemInFolder: (fullPath: string): Promise<void> =>
ipcRenderer.invoke('shell:show-item-in-folder', fullPath),
// ---------- 应用内更新 ----------
/** 检查更新(结果通过 onUpdateStatus 推送) */
checkForUpdate: (): Promise<void> => ipcRenderer.invoke('updater:check'),
/** 开始下载更新包(进度通过 onUpdateStatus 推送) */
downloadUpdate: (): Promise<void> => ipcRenderer.invoke('updater:download'),
/** 退出并安装已下载的更新 */
quitAndInstall: (): Promise<void> => ipcRenderer.invoke('updater:install'),
/** 订阅更新状态事件(checking/available/not-available/downloading/downloaded/error/dev) */
onUpdateStatus: (callback: (payload: Record<string, unknown>) => void): (() => void) =>
subscribe('updater:status', callback),
}
contextBridge.exposeInMainWorld('electronAPI', electronAPI)

View File

@@ -2,7 +2,7 @@
<html lang="zh-CN" class="dark">
<head>
<meta charset="UTF-8">
<link rel="icon" type="image/svg+xml" href="/vite.svg">
<link rel="icon" type="image/png" href="/favicon.png">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NL-IM 即时通讯系统</title>
<!-- FontAwesome -->

View File

@@ -1,30 +1,80 @@
{
"name": "nl-im-vue-ts",
"version": "1.0.0",
"description": "NL-IM 即时通讯 PC 客户端",
"author": "NL-IM Team",
"type": "module",
"main": "dist-electron/main.mjs",
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@ant-design/icons-vue": "^7.0.0",
"@fortawesome/fontawesome-free": "^7.1.0",
"axios": "^1.6.0",
"flv.js": "^1.6.2",
"pinia": "^2.1.0",
"vue": "^3.4.0",
"vue-router": "^4.2.0"
"preview": "vite preview",
"dev:electron": "vite --mode electron-dev",
"build:electron": "vite build --mode electron && electron-builder",
"build:electron:test": "vite build --mode electron-test && electron-builder --dir",
"icons": "node scripts/generate-icons.mjs"
},
"devDependencies": {
"@ant-design/icons-vue": "^7.0.0",
"@fortawesome/fontawesome-free": "^7.1.0",
"@types/node": "^26.2.0",
"@vitejs/plugin-vue": "^5.0.0",
"@vueuse/core": "^10.7.0",
"autoprefixer": "^10.4.0",
"axios": "^1.6.0",
"electron": "^43.3.0",
"electron-builder": "^26.15.3",
"flv.js": "^1.6.2",
"pinia": "^2.1.0",
"postcss": "^8.4.0",
"sass": "^1.94.2",
"tailwindcss": "^3.4.18",
"typescript": "^5.3.3",
"vite": "^5.0.0",
"vite-plugin-electron": "^1.1.1",
"vue": "^3.4.0",
"vue-router": "^4.2.0",
"vue-tsc": "^1.8.8"
},
"build": {
"appId": "cn.nailaoyun.nlim",
"productName": "NL-IM",
"asar": true,
"directories": {
"output": "release",
"buildResources": "build"
},
"files": [
"dist/**",
"dist-electron/**"
],
"win": {
"icon": "build/icon.ico",
"target": [
{
"target": "nsis",
"arch": [
"x64"
]
}
],
"artifactName": "${productName}-Setup-${version}.${ext}"
},
"nsis": {
"oneClick": false,
"perMachine": false,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true,
"createStartMenuShortcut": true
},
"publish": [
{
"provider": "generic",
"url": "https://g-ws.nailaoyun.cn/updates/"
}
]
},
"dependencies": {
"electron-updater": "^6.8.9"
}
}

2153
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

17
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,17 @@
# pnpm 项目级设置(pnpm 10+ 不再读取 package.json 的 pnpm 字段)
#
# allowBuilds:允许执行构建脚本的依赖白名单(pnpm 11 起替代 onlyBuiltDependencies)——
# - electron:postinstall 下载运行时二进制,不放行则桌面端无法启动
# - esbuild:postinstall 安装平台二进制,不放行则 Vite 构建不可用
# - vue-demi / @parcel/watcher:@vueuse、sass 的运行时兼容与文件监听依赖
# - electron-winstaller:electron-builder 的传递依赖
allowBuilds:
electron: true
electron-winstaller: true
esbuild: true
vue-demi: true
'@parcel/watcher': true
# 运行 pnpm run/exec 前不强制校验依赖状态
# (避免历史安装记录的"跳过构建"状态导致所有 pnpm 命令被阻塞)
verifyDepsBeforeRun: false

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

BIN
public/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

BIN
public/icons/tray.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

BIN
public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

View File

@@ -1,2 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.666 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,57 @@
/**
* CDP 截屏小工具:连接正在运行的 Electron/Chromium 远程调试端口,截取页面渲染结果
* 用途:打包后无头验证渲染层 UI(登录页 logo、页面是否白屏等),无需人工肉眼确认
* 用法:node scripts/cdp-screenshot.mjs <debug端口> <输出png路径>
* 依赖:Node 22+(内置全局 WebSocket),无需安装任何包
*/
import { writeFileSync } from 'node:fs'
const port = process.argv[2] || '9223'
const outFile = process.argv[3] || 'screenshot.png'
// 从调试端口拿到页面目标的 WebSocket 调试地址
const targets = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json()
const page = targets.find((t) => t.type === 'page')
if (!page) {
console.error('未找到 page 类型的调试目标')
process.exit(1)
}
const ws = new WebSocket(page.webSocketDebuggerUrl)
let msgId = 0
/** 发送 CDP 命令并等待对应 id 的响应 */
function send(method, params = {}) {
const id = ++msgId
return new Promise((resolve, reject) => {
const onMessage = (event) => {
const data = JSON.parse(event.data)
if (data.id === id) {
ws.removeEventListener('message', onMessage)
data.error ? reject(new Error(data.error.message)) : resolve(data.result)
}
}
ws.addEventListener('message', onMessage)
ws.send(JSON.stringify({ id, method, params }))
})
}
ws.addEventListener('open', async () => {
try {
// 等一帧渲染稳定后截屏
await send('Page.enable')
const { data } = await send('Page.captureScreenshot', { format: 'png' })
writeFileSync(outFile, Buffer.from(data, 'base64'))
console.log(`截图已保存: ${outFile}`)
// 顺带收集页面控制台报错,辅助排查
const { result } = await send('Runtime.evaluate', {
expression: 'document.title + " | " + location.hash',
returnByValue: true,
})
console.log(`页面状态: ${result.value}`)
} catch (err) {
console.error('CDP 操作失败:', err.message)
process.exitCode = 1
} finally {
ws.close()
}
})

327
scripts/generate-icons.mjs Normal file
View File

@@ -0,0 +1,327 @@
/**
* 全套 Logo/图标生成流水线(无第三方依赖,纯 Node 实现 PNG 解码、缩放、编码与 ICO 封装)
*
* 输入:build/logo-master.png(品牌主视觉源图,奶酪聊天气泡 + 年糕崽崽小猫,正方形大图)
*
* 输出(按用途划分):
* - build/icon.png 256 Electron 打包图标(png 备用)
* - build/icon.ico 16/24/32/48/64/128/256 多尺寸 Windows 图标(安装包/exe/快捷方式)
* - public/icons/icon.png 256 桌面端窗口图标 / 系统通知图标(主进程运行时读取)
* - public/icons/tray.png 32 桌面端系统托盘图标
* - public/favicon.png 64 网站浏览器标签页图标
* - public/logo.png 256 稳定 URL 的通用 logo(后台/外部页面可直接引用 /logo.png)
* - src/assets/logo.png 256 经 Vite 打包的应用内 logo(登录页、Web 通知图标)
* - ../nl-im-uniapp/src/static/logo.png 512 移动端 App 图标源图(HBuilderX 生成各尺寸时用,保留直角)
*
* 说明:除 uniapp 源图保留直角外,其余图标统一加 22% 圆角遮罩(带抗锯齿),
* 在任务栏/托盘/标签页中呈现现代应用图标观感。
*
* 用法:node scripts/generate-icons.mjs(package.json 已配置 npm run icons)
* 更换品牌视觉时只需替换 build/logo-master.png 后重跑本脚本。
*/
import { deflateSync, inflateSync } from 'node:zlib'
import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const masterFile = join(projectRoot, 'build', 'logo-master.png')
// ---------- CRC32(PNG 块校验必需,Node zlib 没有直接暴露,自行实现查表版) ----------
const CRC_TABLE = (() => {
const table = new Int32Array(256)
for (let n = 0; n < 256; n++) {
let c = n
for (let k = 0; k < 8; k++) {
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
}
table[n] = c
}
return table
})()
function crc32(buf) {
let c = -1
for (let i = 0; i < buf.length; i++) {
c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8)
}
return (c ^ -1) >>> 0
}
// ---------- PNG 解码(支持 8bit RGB/RGBA、非隔行,覆盖常见生成图格式) ----------
/** 解码 PNG 为 RGBA 像素数据 */
function decodePng(buf) {
const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
for (let i = 0; i < 8; i++) {
if (buf[i] !== signature[i]) throw new Error('不是有效的 PNG 文件')
}
let pos = 8
let width = 0
let height = 0
let bitDepth = 0
let colorType = 0
let interlace = 0
const idatParts = []
while (pos < buf.length) {
const len = buf.readUInt32BE(pos)
const type = buf.toString('ascii', pos + 4, pos + 8)
const data = buf.subarray(pos + 8, pos + 8 + len)
if (type === 'IHDR') {
width = data.readUInt32BE(0)
height = data.readUInt32BE(4)
bitDepth = data[8]
colorType = data[9]
interlace = data[12]
} else if (type === 'IDAT') {
idatParts.push(data)
} else if (type === 'IEND') {
break
}
pos += 12 + len
}
if (bitDepth !== 8 || (colorType !== 2 && colorType !== 6) || interlace !== 0) {
throw new Error(`暂不支持的 PNG 格式(bitDepth=${bitDepth} colorType=${colorType} interlace=${interlace}),请提供 8bit RGB/RGBA 非隔行 PNG`)
}
const bpp = colorType === 6 ? 4 : 3
const stride = width * bpp
const raw = inflateSync(Buffer.concat(idatParts))
const out = Buffer.alloc(width * height * 4)
// 每行首字节是滤波器类型,按 PNG 规范逐字节还原(Sub/Up/Average/Paeth)
let prevRow = Buffer.alloc(stride)
for (let y = 0; y < height; y++) {
const filter = raw[y * (stride + 1)]
const row = raw.subarray(y * (stride + 1) + 1, y * (stride + 1) + 1 + stride)
for (let i = 0; i < stride; i++) {
const a = i >= bpp ? row[i - bpp] : 0 // 左侧同通道
const b = prevRow[i] // 上方
const c = i >= bpp ? prevRow[i - bpp] : 0 // 左上
let v = row[i]
switch (filter) {
case 0:
break
case 1:
v = (v + a) & 0xff
break
case 2:
v = (v + b) & 0xff
break
case 3:
v = (v + ((a + b) >> 1)) & 0xff
break
case 4: {
// Paeth 预测器:取最接近 p=a+b-c 的邻居
const p = a + b - c
const pa = Math.abs(p - a)
const pb = Math.abs(p - b)
const pc = Math.abs(p - c)
const pr = pa <= pb && pa <= pc ? a : pb <= pc ? b : c
v = (v + pr) & 0xff
break
}
default:
throw new Error(`未知的 PNG 滤波器类型: ${filter}`)
}
row[i] = v
}
prevRow = row
for (let x = 0; x < width; x++) {
const si = x * bpp
const di = (y * width + x) * 4
out[di] = row[si]
out[di + 1] = row[si + 1]
out[di + 2] = row[si + 2]
out[di + 3] = bpp === 4 ? row[si + 3] : 255
}
}
return { width, height, data: out }
}
// ---------- PNG 编码(8bit RGBA,无隔行扫描) ----------
/** 组装一个 PNG 数据块:长度 + 类型 + 数据 + CRC */
function pngChunk(type, data) {
const typeBuf = Buffer.from(type, 'ascii')
const lenBuf = Buffer.alloc(4)
lenBuf.writeUInt32BE(data.length)
const crcBuf = Buffer.alloc(4)
crcBuf.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])))
return Buffer.concat([lenBuf, typeBuf, data, crcBuf])
}
/** 将 RGBA 像素数据编码为 PNG 文件 Buffer */
function encodePng(width, height, rgba) {
const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
const ihdr = Buffer.alloc(13)
ihdr.writeUInt32BE(width, 0)
ihdr.writeUInt32BE(height, 4)
ihdr[8] = 8 // 位深 8bit
ihdr[9] = 6 // 颜色类型 6 = RGBA
const raw = Buffer.alloc((width * 4 + 1) * height)
for (let y = 0; y < height; y++) {
raw[y * (width * 4 + 1)] = 0 // 滤波器类型 0 = None
rgba.copy(raw, y * (width * 4 + 1) + 1, y * width * 4, (y + 1) * width * 4)
}
return Buffer.concat([
signature,
pngChunk('IHDR', ihdr),
pngChunk('IDAT', deflateSync(raw, { level: 9 })),
pngChunk('IEND', Buffer.alloc(0)),
])
}
// ---------- 图像处理 ----------
/** 中心裁剪为正方形(源图若已是正方形则原样返回) */
function cropSquare(img) {
const { width, height, data } = img
if (width === height) return img
const size = Math.min(width, height)
const ox = Math.floor((width - size) / 2)
const oy = Math.floor((height - size) / 2)
const out = Buffer.alloc(size * size * 4)
for (let y = 0; y < size; y++) {
data.copy(out, y * size * 4, ((y + oy) * width + ox) * 4, ((y + oy) * width + ox + size) * 4)
}
return { width: size, height: size, data: out }
}
/**
* 面积平均法缩放(box filter):
* 缩小图标时每个目标像素取源区域的加权平均,比近邻采样平滑得多,小尺寸下依然清晰
*/
function resizeRgba(src, sw, sh, tw, th) {
const dst = Buffer.alloc(tw * th * 4)
const xr = sw / tw
const yr = sh / th
for (let ty = 0; ty < th; ty++) {
const y0 = ty * yr
const y1 = y0 + yr
for (let tx = 0; tx < tw; tx++) {
const x0 = tx * xr
const x1 = x0 + xr
let r = 0
let g = 0
let b = 0
let a = 0
let area = 0
for (let sy = Math.floor(y0); sy < y1 && sy < sh; sy++) {
const wy = Math.min(sy + 1, y1) - Math.max(sy, y0)
if (wy <= 0) continue
for (let sx = Math.floor(x0); sx < x1 && sx < sw; sx++) {
const wx = Math.min(sx + 1, x1) - Math.max(sx, x0)
if (wx <= 0) continue
const w = wx * wy
const i = (sy * sw + sx) * 4
r += src[i] * w
g += src[i + 1] * w
b += src[i + 2] * w
a += src[i + 3] * w
area += w
}
}
const di = (ty * tw + tx) * 4
dst[di] = Math.round(r / area)
dst[di + 1] = Math.round(g / area)
dst[di + 2] = Math.round(b / area)
dst[di + 3] = Math.round(a / area)
}
}
return dst
}
/** 圆角矩形有符号距离场:返回值 <0 表示在内部,用于逐像素计算覆盖率实现抗锯齿 */
function roundedRectDist(x, y, cx, cy, halfW, halfH, r) {
const dx = Math.abs(x - cx) - (halfW - r)
const dy = Math.abs(y - cy) - (halfH - r)
const ax = Math.max(dx, 0)
const ay = Math.max(dy, 0)
return Math.hypot(ax, ay) + Math.min(Math.max(dx, dy), 0) - r
}
/** 应用圆角遮罩(半径 = 尺寸 * ratio,边缘 1px 平滑抗锯齿),现代应用图标观感 */
function applyRoundedMask(data, size, ratio = 0.22) {
const r = size * ratio
const half = size / 2
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const dist = roundedRectDist(x + 0.5, y + 0.5, half, half, half, half, r)
const coverage = dist <= -0.5 ? 1 : dist >= 0.5 ? 0 : 0.5 - dist
if (coverage < 1) {
const i = (y * size + x) * 4
data[i + 3] = Math.round(data[i + 3] * coverage)
}
}
}
}
// ---------- ICO 封装 ----------
/**
* 将多张 PNG 打包为 .ico 容器(ICO 支持直接内嵌 PNG 数据,Windows Vista+ 原生支持)
* 为什么自己生成:electron-builder 的 png→ico 原生转换工具在部分环境会崩溃,
* 直接提供 icon.ico 可让打包器跳过转换步骤
*/
function encodeIco(pngBuffers) {
const count = pngBuffers.length
const header = Buffer.alloc(6)
header.writeUInt16LE(0, 0) // 保留位
header.writeUInt16LE(1, 2) // 类型:1 = 图标
header.writeUInt16LE(count, 4)
const entries = []
const images = []
let offset = 6 + 16 * count
for (const { size, data } of pngBuffers) {
const entry = Buffer.alloc(16)
entry[0] = size >= 256 ? 0 : size // 0 表示 256
entry[1] = size >= 256 ? 0 : size
entry[2] = 0 // 调色板色数(真彩色为 0)
entry[3] = 0 // 保留位
entry.writeUInt16LE(1, 4) // 颜色平面数
entry.writeUInt16LE(32, 6) // 位深
entry.writeUInt32LE(data.length, 8) // 数据字节数
entry.writeUInt32LE(offset, 12) // 数据偏移
entries.push(entry)
images.push(data)
offset += data.length
}
return Buffer.concat([header, ...entries, ...images])
}
// ---------- 主流程 ----------
if (!existsSync(masterFile)) {
console.error(`未找到源图 ${masterFile},请先放置品牌主视觉 PNG(正方形大图,建议 1024x1024)`)
process.exit(1)
}
const master = cropSquare(decodePng(readFileSync(masterFile)))
console.log(`源图 ${masterFile} (${master.width}x${master.height})`)
/** 生成指定尺寸的 RGBA(可选圆角遮罩) */
function renderSize(size, rounded) {
const data = resizeRgba(master.data, master.width, master.height, size, size)
if (rounded) {
applyRoundedMask(data, size)
}
return data
}
/** 输出单个 PNG 文件 */
function writePng(file, size, rounded) {
mkdirSync(dirname(file), { recursive: true })
writeFileSync(file, encodePng(size, size, renderSize(size, rounded)))
console.log(`已生成 ${file} (${size}x${size}${rounded ? ' 圆角' : ''})`)
}
// 桌面端 / 网站 / 通用(统一圆角)
writePng(join(projectRoot, 'build', 'icon.png'), 256, true)
writePng(join(projectRoot, 'public', 'icons', 'icon.png'), 256, true)
writePng(join(projectRoot, 'public', 'icons', 'tray.png'), 32, true)
writePng(join(projectRoot, 'public', 'favicon.png'), 64, true)
writePng(join(projectRoot, 'public', 'logo.png'), 256, true)
writePng(join(projectRoot, 'src', 'assets', 'logo.png'), 256, true)
// 移动端 App 图标源图(保留直角,HBuilderX/应用商店会按平台规范自行裁切圆角)
writePng(join(projectRoot, '..', 'nl-im-uniapp', 'src', 'static', 'logo.png'), 512, false)
// Windows 多尺寸 ICO(exe/安装包/快捷方式共用)
const icoSizes = [16, 24, 32, 48, 64, 128, 256]
const icoFile = join(projectRoot, 'build', 'icon.ico')
writeFileSync(icoFile, encodeIco(icoSizes.map((size) => ({ size, data: encodePng(size, size, renderSize(size, true)) }))))
console.log(`已生成 ${icoFile} (${icoSizes.join('/')})`)

View File

@@ -33,7 +33,7 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
import Toast from '@/components/common/Toast.vue'
import CallWindow from '@/components/call/CallWindow.vue'
import GroupCallWindow from '@/components/chat/GroupCallWindow.vue'
@@ -41,17 +41,38 @@ import GroupCallIncoming from '@/components/chat/GroupCallIncoming.vue'
import { useWebRTCStore } from '@/stores/webrtc'
import { useChatStore } from '@/stores/chat'
import { useMomentStore } from '@/stores/moment'
import { useConversationStore } from '@/stores/conversation'
import { useToastStore } from '@/stores/toast'
import { useGroupWebRTC } from '@/composables/useGroupWebRTC'
import { useAppUpdate } from '@/composables/useAppUpdate'
import { wsManager } from '@/api/websocket'
import { isElectron, generateBadgeDataURL } from '@/utils/platform'
import * as systemApi from '@/api/modules/system'
import type { MomentNotifPayload } from '@/types/moment'
const webrtcStore = useWebRTCStore()
const chatStore = useChatStore()
const momentStore = useMomentStore()
const conversationStore = useConversationStore()
const toastStore = useToastStore()
const groupWebRTC = useGroupWebRTC()
const isMobile = ref(window.innerWidth < 768)
// 桌面端下载完成事件的取消订阅函数(组件卸载时清理)
let offDownloadDone: (() => void) | null = null
// ==================== Electron 桌面端集成 ====================
if (isElectron()) {
// 任务栏未读角标:会话总未读数变化时同步到系统任务栏(角标图由 canvas 生成)
watch(
() => conversationStore.totalUnread,
(count) => {
window.electronAPI!.setBadge(count, count > 0 ? generateBadgeDataURL(count) : '')
},
{ immediate: true }
)
}
const localStream = computed(() => webrtcStore.webrtc?.localStream ?? null)
const remoteStream = computed(() => webrtcStore.webrtc?.remoteStream ?? null)
@@ -86,6 +107,23 @@ onMounted(() => {
wsManager.onMomentNotification(handleMomentNotification)
// 检查后端健康状态
checkBackendHealth()
// Electron 桌面端:下载完成提示 + 启动后静默检查一次更新
if (isElectron()) {
offDownloadDone = window.electronAPI!.onDownloadDone((payload) => {
if (payload.state === 'completed') {
toastStore.showToast('下载完成', 'success', 6000, payload.fileName, {
label: '打开文件夹',
handler: () => window.electronAPI!.showItemInFolder(payload.savePath),
})
} else if (payload.state === 'interrupted') {
toastStore.showToast('下载失败', 'error', 5000, payload.fileName)
}
// cancelled(用户取消另存为)不提示,避免打扰
})
// 初始化更新状态订阅并延迟静默检查(发现新版本时设置入口显示红点)
useAppUpdate().init()
}
})
onUnmounted(() => {
@@ -93,5 +131,7 @@ onUnmounted(() => {
wsManager.offSignal(webrtcStore.webrtc.handleSignaling)
// 移除朋友圈通知处理器
wsManager.offMomentNotification(handleMomentNotification)
// 清理桌面端下载完成订阅
offDownloadDone?.()
})
</script>

View File

@@ -1,5 +1,6 @@
import axios, { type AxiosInstance, type AxiosResponse, type InternalAxiosRequestConfig } from 'axios'
import type { ApiResponse } from './types'
import { API_BASE_URL } from '@/config/app'
/**
* 扩展 AxiosInstance 类型,修正返回值类型
@@ -14,8 +15,10 @@ interface CustomAxiosInstance extends Omit<AxiosInstance, 'get' | 'post' | 'put'
}
// 创建axios实例
// baseURL 取自全局配置:Web 环境默认相对路径 /api(同源或开发代理);
// Electron 打包环境由 .env.electron 注入后端绝对地址(file:// 下相对路径不可用)
const axiosInstance: AxiosInstance = axios.create({
baseURL: '/api',
baseURL: API_BASE_URL,
timeout: 30000,
headers: {
'Content-Type': 'application/json',

View File

@@ -13,6 +13,8 @@ export type MessageHandler = (message: ChatMessage) => void
export type SignalHandler = (message: ChatMessage) => void
export type MomentNotifHandler = (payload: MomentNotifPayload) => void
export type ReadReceiptHandler = (data: { message_ids?: number[]; room_id?: string }) => void
// 消息 extra 更新事件(URL 预览卡片异步抓取完成后服务端补推)
export type MessageExtraUpdateHandler = (data: { id?: number; room_id?: string; extra?: string }) => void
/**
* WebSocketManager
@@ -31,6 +33,7 @@ class WebSocketManager {
private signalHandlers: SignalHandler[] = []
private momentNotifHandlers: MomentNotifHandler[] = []
private readReceiptHandlers: ReadReceiptHandler[] = []
private messageExtraUpdateHandlers: MessageExtraUpdateHandler[] = []
private reconnectAttempts = 0
private maxReconnectAttempts = 10
// 重连采用指数退避:baseDelay * 2^n,封顶 maxReconnectDelay,
@@ -39,6 +42,9 @@ class WebSocketManager {
private maxReconnectDelay = 30000
// 是否主动断开(登出场景):主动断开后不允许自动重连
private intentionalClose = false
// 在途的 connect Promise:握手期间的重复 connect 直接复用它,
// 避免重建连接把正在握手的 socket 关掉、并把上一个调用方的 Promise 误判为失败
private connectPromise: Promise<void> | null = null
// 用于通知去重的缓存(存储最近处理过的通知ID)
private recentMomentNotifs: Set<string> = new Set()
private notifCacheTimeout = 5000 // 5秒内相同通知视为重复
@@ -76,18 +82,35 @@ class WebSocketManager {
* 调用方马上读 clientId 拿到 null,导致绑定被跳过、回声去重失效
*/
connect(userId: string): Promise<void> {
return new Promise((resolve, reject) => {
if (this.ws?.readyState === WebSocket.OPEN) {
resolve()
return
}
if (this.ws?.readyState === WebSocket.OPEN) {
return Promise.resolve()
}
// 握手进行中:复用同一个在途 Promise。
// 若此时重建连接,会把正在握手的 socket 关掉,其 onclose 将把上一个调用方的
// Promise 误判为失败(如自动重连与手动重连并发时弹出假的"重连失败"提示)
if (this.connectPromise && this.ws?.readyState === WebSocket.CONNECTING) {
return this.connectPromise
}
const promise = new Promise<void>((resolve, reject) => {
this.userId = userId
this.intentionalClose = false
const wsUrl = this.getWebSocketUrl()
try {
this.ws = new WebSocket(wsUrl)
// 替换前先关掉未完成/残留的旧 socket,避免同时挂着两条连接;
// 旧 socket 的回调有下方的"实例守卫",其延迟关闭事件不会影响新连接
if (this.ws) {
try {
this.ws.close()
} catch {
// 忽略关闭异常
}
}
// 用局部变量捕获本次创建的实例,事件回调先做实例守卫(this.ws !== socket 即忽略):
// 否则旧连接延迟触发的 onclose 会把新连接的 this.ws 误置为 null 并多排一次重连
const socket = new WebSocket(wsUrl)
this.ws = socket
// resolve/reject 只允许触发一次
let settled = false
@@ -103,14 +126,18 @@ class WebSocketManager {
}
}
this.ws.onopen = () => {
socket.onopen = () => {
// 实例守卫:仅当前活跃连接可重置重连计数、进入就绪流程
if (this.ws !== socket) return
console.log('✅ WebSocket connected')
this.reconnectAttempts = 0
// 兜底:连上后 3 秒内未收到 clientId 也放行,避免服务端异常时 Promise 永久挂起
clientIdTimer = window.setTimeout(settleResolve, 3000)
}
this.ws.onmessage = (event) => {
socket.onmessage = (event) => {
// 实例守卫:旧连接的迟到消息不处理,防止旧 clientId 覆盖新连接的
if (this.ws !== socket) return
try {
// 处理可能的多行JSON或消息分割
const data = event.data.toString().trim()
@@ -136,6 +163,11 @@ class WebSocketManager {
this.handleReadReceipt(payload.data as { message_ids?: number[]; room_id?: string })
}
// 处理消息 extra 更新(URL 卡片异步就绪后补推,前端刷新对应消息的卡片)
if (payload.request_type === 'message_extra_update' && payload.data) {
this.handleMessageExtraUpdate(payload.data as { id?: number; room_id?: string; extra?: string })
}
// 处理朋友圈通知
if (payload.request_type === 'moment_notification' && payload.data) {
this.handleMomentNotification(payload.data as MomentNotifPayload)
@@ -155,7 +187,8 @@ class WebSocketManager {
}
}
this.ws.onerror = (error) => {
// onerror 只结算本次连接自己的 Promise(错误必然属于本 socket 实例),无需实例守卫
socket.onerror = (error) => {
console.error('WebSocket error:', error)
if (clientIdTimer !== null) {
window.clearTimeout(clientIdTimer)
@@ -167,8 +200,19 @@ class WebSocketManager {
}
}
this.ws.onclose = () => {
socket.onclose = () => {
console.log('WebSocket closed')
// 连接在就绪前被关闭(含被新连接替换)时结算 Promise,避免调用方永久挂起
if (clientIdTimer !== null) {
window.clearTimeout(clientIdTimer)
clientIdTimer = null
}
if (!settled) {
settled = true
reject(new Error('WebSocket 已关闭'))
}
// 实例守卫:旧连接延迟关闭不得清空新连接的 this.ws、更不能触发额外重连
if (this.ws !== socket) return
this.ws = null
// 主动断开(登出)不重连;异常断开按指数退避自动重连
if (!this.intentionalClose) {
@@ -179,6 +223,15 @@ class WebSocketManager {
reject(error)
}
})
this.connectPromise = promise
// settle 后清掉引用,后续 connect 才会发起新的连接尝试;
// 用 then 的两个分支处理(而非 finally 链),避免派生出未捕获的拒绝
promise.then(
() => { if (this.connectPromise === promise) this.connectPromise = null },
() => { if (this.connectPromise === promise) this.connectPromise = null }
)
return promise
}
/**
@@ -191,6 +244,9 @@ class WebSocketManager {
disconnect() {
// 标记为主动断开,阻止 onclose 里的自动重连把连接又拉起来
this.intentionalClose = true
// 丢弃在途的连接尝试引用:其 Promise 会随 socket 关闭而 reject(属预期),
// 但不能让登出后紧接着的 connect 复用这个注定失败的 Promise
this.connectPromise = null
if (this.ws) {
this.ws.close()
this.ws = null
@@ -328,6 +384,32 @@ class WebSocketManager {
this.readReceiptHandlers.forEach(handler => handler(data))
}
/**
* 添加消息 extra 更新处理器(URL 卡片异步补推)
*/
onMessageExtraUpdate(handler: MessageExtraUpdateHandler) {
if (!this.messageExtraUpdateHandlers.includes(handler)) {
this.messageExtraUpdateHandlers.push(handler)
}
}
/**
* 移除消息 extra 更新处理器(与 onMessageExtraUpdate 对称,供组件卸载时清理)
*/
offMessageExtraUpdate(handler: MessageExtraUpdateHandler) {
const index = this.messageExtraUpdateHandlers.indexOf(handler)
if (index > -1) {
this.messageExtraUpdateHandlers.splice(index, 1)
}
}
/**
* 内部处理消息 extra 更新事件
*/
private handleMessageExtraUpdate(data: { id?: number; room_id?: string; extra?: string }) {
this.messageExtraUpdateHandlers.forEach(handler => handler(data))
}
/**
* 内部处理服务端错误推送(消息被拒收/禁言/会话不合法等)
* 用动态 import 取 toastStore:wsManager 是模块级单例,模块加载时 pinia

BIN
src/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

View File

@@ -18,6 +18,7 @@ import { computed } from 'vue'
import type { ChatMessage } from '@/types/api'
import { formatSize } from '@/utils/format'
import { resolveImageUrl } from '@/utils/image'
import { downloadViaElectron } from '@/utils/platform'
const props = defineProps<{
message: ChatMessage
@@ -45,6 +46,10 @@ function downloadFile() {
if (!fileUrl.value) {
return
}
// Electron 桌面端走主进程下载(系统"另存为"对话框),Web 保持 <a download>
if (downloadViaElectron(fileUrl.value, fileName.value)) {
return
}
const a = document.createElement('a')
a.href = fileUrl.value
a.download = fileName.value

View File

@@ -1,9 +1,47 @@
<template>
<div>
<!-- 文本主体:长文本折叠展示(内容先经 parseText 整体转义,再链接化,杜绝注入) -->
<div
v-html="parseText(message.content)"
v-html="parseText(displayContent)"
class="whitespace-pre-wrap break-words leading-relaxed min-h-[20px]"
></div>
<!-- 展开/收起入口(仅长文本出现) -->
<button
v-if="collapsible"
class="mt-1 text-xs font-medium opacity-80 hover:opacity-100 hover:underline transition"
:class="message.isSelf ? 'text-indigo-100' : 'text-blue-300'"
@click.stop="expanded = !expanded"
>
{{ expanded ? '收起' : `展开全文(共 ${contentLength} 字)` }}
</button>
<!-- HTML 内容卡片:识别为 HTML 时出现。
正常 HTML 点击打开沙箱预览;命中注入风险特征则禁止预览并警示 -->
<div
v-if="htmlInfo.isHtml"
class="mt-2 rounded-lg overflow-hidden border transition flex items-center gap-3 p-3"
:class="htmlInfo.risky
? 'bg-red-500/10 border-red-500/30 cursor-not-allowed'
: 'bg-black/20 border-white/10 cursor-pointer hover:bg-black/30'"
@click="handleHtmlCardClick"
>
<div
class="w-9 h-9 rounded-lg shrink-0 flex items-center justify-center"
:class="htmlInfo.risky ? 'bg-red-500/20 text-red-400' : 'bg-indigo-500/20 text-indigo-300'"
>
<i :class="htmlInfo.risky ? 'fas fa-exclamation-triangle' : 'fas fa-code'"></i>
</div>
<div class="flex-1 min-w-0">
<div class="font-bold text-xs" :class="htmlInfo.risky ? 'text-red-300' : 'text-indigo-200'">
网页内容(HTML)
</div>
<div class="text-[10px] mt-0.5" :class="htmlInfo.risky ? 'text-red-400/80' : 'text-gray-400'">
{{ htmlInfo.risky ? '检测到脚本/注入风险,已禁止预览' : '点击预览 · 沙箱模式,脚本不会执行' }}
</div>
</div>
<i v-if="!htmlInfo.risky" class="fas fa-chevron-right text-gray-500 text-xs shrink-0"></i>
</div>
<!-- URL 卡片 -->
<div
v-if="extra?.url && extra?.title"
@@ -24,22 +62,59 @@
</div>
</div>
</div>
<!-- HTML 沙箱预览模态框 -->
<HtmlPreviewModal v-if="showHtmlPreview" :html="message.content" @close="showHtmlPreview = false" />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref } from 'vue'
import type { ChatMessage } from '@/types/api'
import { parseText } from '@/utils/format'
import { detectHtmlContent, isLongText, truncateForCollapse } from '@/utils/htmlContent'
import { useToastStore } from '@/stores/toast'
import HtmlPreviewModal from '@/components/common/HtmlPreviewModal.vue'
const props = defineProps<{
message: ChatMessage
}>()
const toastStore = useToastStore()
const extra = computed(() => {
return typeof props.message.extra === 'object' ? props.message.extra : {}
})
// ========== 长文本自动折叠 ==========
const expanded = ref(false)
const contentLength = computed(() => (props.message.content || '').length)
// 是否达到折叠标准(阈值统一收敛在 utils/htmlContent 中,与移动端一致)
const collapsible = computed(() => isLongText(props.message.content || ''))
// 折叠态只渲染截断内容:超长消息(几十 KB)全量渲染会拖慢列表滚动
const displayContent = computed(() => {
const content = props.message.content || ''
if (!collapsible.value || expanded.value) return content
return truncateForCollapse(content)
})
// ========== HTML 识别与安全预览 ==========
const showHtmlPreview = ref(false)
// 识别消息是否为 HTML 内容并给出注入风险判定
const htmlInfo = computed(() => detectHtmlContent(props.message.content || ''))
/**
* 点击 HTML 卡片:正常 HTML 打开沙箱预览模态框;
* 有注入风险的内容禁止预览,仅提示原因(正文本身始终以转义文本展示,不会执行)
*/
function handleHtmlCardClick() {
if (htmlInfo.value.risky) {
toastStore.warning('该内容包含脚本或注入风险,已禁止预览')
return
}
showHtmlPreview.value = true
}
/**
* 打开链接卡片的 URL
* 协议白名单:仅允许 http/https,防止 javascript: 等伪协议被点击执行(XSS)
@@ -49,4 +124,3 @@ function openUrl(url: string) {
window.open(url, '_blank', 'noopener,noreferrer')
}
</script>

View File

@@ -0,0 +1,231 @@
<template>
<Teleport to="body">
<div
v-if="show"
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm"
@click.self="emit('close')"
>
<div class="w-[440px] max-w-[92vw] bg-panel border border-gray-700/60 rounded-2xl shadow-2xl overflow-hidden">
<!-- 头部 -->
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-700/50">
<div class="text-white font-medium flex items-center gap-2.5">
<i class="fas fa-cog text-primary"></i>
<span>桌面端设置</span>
</div>
<button
class="w-8 h-8 flex items-center justify-center rounded-lg text-gray-500 hover:text-white hover:bg-white/10 transition"
@click="emit('close')"
>
<i class="fas fa-times"></i>
</button>
</div>
<div class="p-6 space-y-6 max-h-[70vh] overflow-y-auto custom-scrollbar">
<!-- 通用设置 -->
<div>
<div class="text-xs text-gray-500 uppercase tracking-wider mb-3">通用</div>
<div class="space-y-4">
<!-- 开机自启开关 -->
<div class="flex items-center justify-between gap-4">
<div class="min-w-0">
<div class="text-sm text-gray-200">开机自启动</div>
<div class="text-xs text-gray-500 mt-0.5">系统启动后自动运行并驻留托盘</div>
</div>
<button
class="relative w-11 h-6 rounded-full transition-colors shrink-0"
:class="autoLaunch ? 'bg-primary' : 'bg-gray-600'"
:disabled="switching"
@click="toggleAutoLaunch"
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform"
:class="autoLaunch ? 'translate-x-5' : ''"
></span>
</button>
</div>
<!-- 关闭行为开关 -->
<div class="flex items-center justify-between gap-4">
<div class="min-w-0">
<div class="text-sm text-gray-200">关闭窗口时最小化到托盘</div>
<div class="text-xs text-gray-500 mt-0.5">关闭后应用继续在后台接收消息,从托盘退出</div>
</div>
<button
class="relative w-11 h-6 rounded-full transition-colors shrink-0"
:class="closeToTray ? 'bg-primary' : 'bg-gray-600'"
@click="toggleCloseToTray"
>
<span
class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform"
:class="closeToTray ? 'translate-x-5' : ''"
></span>
</button>
</div>
</div>
</div>
<!-- 关于与更新 -->
<div>
<div class="text-xs text-gray-500 uppercase tracking-wider mb-3">关于与更新</div>
<div class="bg-black/20 rounded-xl p-4 space-y-3 border border-white/5">
<div class="flex items-center justify-between">
<span class="text-sm text-gray-400">当前版本</span>
<span class="text-sm text-gray-200 font-mono">v{{ appUpdate.currentVersion.value || '-' }}</span>
</div>
<!-- 检查中 -->
<div v-if="statusType === 'checking'" class="flex items-center gap-2 text-sm text-gray-400">
<i class="fas fa-circle-notch fa-spin text-primary"></i>
<span>正在检查更新...</span>
</div>
<!-- 发现新版本 -->
<template v-else-if="statusType === 'available'">
<div class="flex items-center gap-2 text-sm text-gray-200">
<span class="w-2 h-2 bg-red-500 rounded-full"></span>
<span>发现新版本 <span class="font-mono text-primary">v{{ appUpdate.status.value?.version }}</span></span>
</div>
<button
class="w-full py-2 rounded-xl bg-primary hover:bg-indigo-600 text-white text-sm transition"
@click="appUpdate.download()"
>
<i class="fas fa-download mr-1.5"></i>下载更新
</button>
</template>
<!-- 下载中 -->
<template v-else-if="statusType === 'downloading'">
<div class="flex items-center justify-between text-xs text-gray-400">
<span>正在下载新版本...</span>
<span>{{ downloadPercent }}%<span v-if="downloadSpeed"> · {{ downloadSpeed }}</span></span>
</div>
<div class="w-full h-2 bg-gray-700 rounded-full overflow-hidden">
<div
class="h-full bg-primary rounded-full transition-all duration-300"
:style="{ width: downloadPercent + '%' }"
></div>
</div>
</template>
<!-- 下载完成待安装 -->
<template v-else-if="statusType === 'downloaded'">
<div class="flex items-center gap-2 text-sm text-gray-200">
<i class="fas fa-check-circle text-success"></i>
<span>新版本 <span class="font-mono">v{{ appUpdate.status.value?.version }}</span> 已就绪</span>
</div>
<button
class="w-full py-2 rounded-xl bg-primary hover:bg-indigo-600 text-white text-sm transition"
@click="appUpdate.install()"
>
<i class="fas fa-rotate-right mr-1.5"></i>立即重启安装
</button>
<div class="text-xs text-gray-500 text-center">也可以稍后退出应用时自动安装</div>
</template>
<!-- 已是最新 / 出错 / 开发模式 / 初始 -->
<template v-else>
<div v-if="statusType === 'not-available'" class="flex items-center gap-2 text-sm text-gray-400">
<i class="fas fa-check-circle text-success"></i>
<span>已是最新版本</span>
</div>
<div v-else-if="statusType === 'error'" class="flex items-start gap-2 text-sm text-red-400">
<i class="fas fa-exclamation-circle mt-0.5"></i>
<span class="break-all">检查更新失败:{{ appUpdate.status.value?.message || '未知错误' }}</span>
</div>
<div v-else-if="statusType === 'dev'" class="text-sm text-gray-500">
开发模式下更新功能不可用,请使用打包安装版验证
</div>
<button
class="w-full py-2 rounded-xl bg-gray-700 hover:bg-gray-600 text-white text-sm transition"
@click="appUpdate.check()"
>
<i class="fas fa-sync-alt mr-1.5"></i>检查更新
</button>
</template>
</div>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
/**
* 桌面端设置弹窗(仅 Electron 环境使用)
*
* 功能:
* 1. 开机自启开关(走主进程 setLoginItemSettings)
* 2. 关闭窗口行为开关(最小化到托盘 / 直接退出,持久化在主进程 userData)
* 3. 检查更新 / 下载进度 / 重启安装(electron-updater 全流程 UI)
*/
import { ref, computed, watch } from 'vue'
import { useAppUpdate } from '@/composables/useAppUpdate'
import { useToastStore } from '@/stores/toast'
interface Props {
show: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<{
(e: 'close'): void
}>()
const toastStore = useToastStore()
const appUpdate = useAppUpdate()
// 本地开关状态(打开弹窗时从主进程读取,保证与系统实际状态一致)
const autoLaunch = ref(false)
const closeToTray = ref(true)
// 自启设置写注册表有耗时,防止连点
const switching = ref(false)
/** 当前更新状态类型(简化模板判断) */
const statusType = computed(() => appUpdate.status.value?.type)
/** 下载进度百分比(整数展示) */
const downloadPercent = computed(() => Math.floor(appUpdate.status.value?.percent || 0))
/** 下载速度(MB/s 展示) */
const downloadSpeed = computed(() => {
const bps = appUpdate.status.value?.bytesPerSecond
if (!bps) return ''
return `${(bps / 1024 / 1024).toFixed(1)} MB/s`
})
// 每次打开弹窗时刷新开关状态(自启状态可能被外部修改,如系统设置/杀毒软件)
watch(
() => props.show,
async (visible) => {
if (!visible || !window.electronAPI) return
autoLaunch.value = await window.electronAPI.getAutoLaunch()
const settings = await window.electronAPI.getAppSettings()
closeToTray.value = settings.closeToTray
},
{ immediate: true }
)
/** 切换开机自启:以主进程返回的实际状态为准(可能被系统策略拒绝) */
async function toggleAutoLaunch() {
if (!window.electronAPI || switching.value) return
switching.value = true
try {
const target = !autoLaunch.value
const actual = await window.electronAPI.setAutoLaunch(target)
autoLaunch.value = actual
if (actual !== target) {
// 开发模式或被系统拒绝时,主进程返回的状态与期望不一致
toastStore.warning('设置未生效', '开发模式下不可用,或已被系统策略阻止')
}
} finally {
switching.value = false
}
}
/** 切换关闭窗口行为(最小化到托盘 / 直接退出) */
async function toggleCloseToTray() {
if (!window.electronAPI) return
const settings = await window.electronAPI.setAppSettings({ closeToTray: !closeToTray.value })
closeToTray.value = settings.closeToTray
}
</script>

View File

@@ -221,6 +221,7 @@
import { ref, onMounted, watch } from 'vue'
import * as attachmentApi from '@/api/modules/attachment'
import type { Attachment } from '@/types/api'
import { downloadViaElectron } from '@/utils/platform'
const props = defineProps<{
show: boolean
@@ -300,6 +301,10 @@ function previewVideo(attachment: Attachment) {
// 下载文件
function downloadFile(attachment: Attachment) {
// Electron 桌面端走主进程下载(系统"另存为"对话框),Web 保持 <a download>
if (downloadViaElectron(attachment.file_url, attachment.file_name)) {
return
}
const link = document.createElement('a')
link.href = attachment.file_url
link.download = attachment.file_name

View File

@@ -0,0 +1,48 @@
<template>
<!-- Teleport 到 body:气泡容器有 overflow-hidden,且列表项可能带 transform,
固定定位在其内部会被裁剪/错位,必须挂到 body 层 -->
<Teleport to="body">
<div
class="fixed inset-0 bg-black/80 z-[100] flex items-center justify-center p-4 backdrop-blur-sm"
@click.self="$emit('close')"
>
<div class="bg-panel rounded-xl w-[85vw] max-w-5xl h-[85vh] overflow-hidden shadow-2xl border border-gray-700 flex flex-col">
<!-- 标题栏 -->
<div class="p-4 border-b border-gray-700 font-bold bg-gray-800/50 flex justify-between items-center shrink-0">
<span><i class="fas fa-code mr-2 text-primary"></i>网页内容预览</span>
<div class="flex items-center gap-3">
<span class="text-xs text-gray-500 font-normal">
<i class="fas fa-shield-alt mr-1"></i>沙箱模式 · 脚本已禁用
</span>
<i class="fas fa-times cursor-pointer hover:text-white text-gray-500" @click="$emit('close')"></i>
</div>
</div>
<!-- 沙箱渲染区:sandbox 空值 = 全部限制(禁脚本/禁表单/禁弹窗/禁同源访问),
srcdoc 内容另经 sanitizeHtmlForPreview 净化,双保险 -->
<iframe
:srcdoc="sanitized"
sandbox=""
class="flex-1 w-full bg-white border-0"
title="HTML内容预览"
></iframe>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { sanitizeHtmlForPreview } from '@/utils/htmlContent'
const props = defineProps<{
/** 原始 HTML 内容(消息正文) */
html: string
}>()
defineEmits<{
close: []
}>()
// 预览内容:净化后再交给沙箱 iframe 渲染
const sanitized = computed(() => sanitizeHtmlForPreview(props.html))
</script>

View File

@@ -231,6 +231,7 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { downloadViaElectron } from '@/utils/platform'
interface Props {
src?: string
@@ -411,6 +412,10 @@ function toggleFullscreen() {
function download() {
if (!currentSrc.value) return
// Electron 桌面端走主进程下载(系统"另存为"对话框),Web 保持 <a download>
if (downloadViaElectron(currentSrc.value, `video_${Date.now()}.mp4`)) {
return
}
const a = document.createElement('a')
a.href = currentSrc.value
a.download = `video_${Date.now()}.mp4`

View File

@@ -0,0 +1,63 @@
/**
* 应用内更新状态管理(仅 Electron 桌面端有效)
*
* 为什么用模块级单例状态:更新状态需要在多处共享——
* 侧边栏设置入口的"新版本红点"和设置弹窗里的完整更新 UI 要看到同一份数据,
* 且订阅主进程事件只应注册一次,所以状态提升到模块作用域而不是组件内。
*/
import { ref, computed } from 'vue'
import { isElectron } from '@/utils/platform'
import type { UpdateStatusPayload } from '@/types/electron'
/** 最近一次更新状态(null 表示尚未检查过) */
const status = ref<UpdateStatusPayload | null>(null)
/** 当前应用版本号 */
const currentVersion = ref('')
/** 防止重复初始化(App.vue 挂载时调用一次) */
let initialized = false
export function useAppUpdate() {
/** 是否存在可用新版本(用于设置入口红点:发现新版本/下载中/待安装都算) */
const hasUpdate = computed(() => {
const type = status.value?.type
return type === 'available' || type === 'downloading' || type === 'downloaded'
})
/**
* 初始化:读取版本号、订阅主进程更新状态、启动后延迟静默检查一次。
* 延迟几秒是为了避开启动期的登录校验/WS 建连高峰。
*/
function init(): void {
if (initialized || !isElectron()) {
return
}
initialized = true
const api = window.electronAPI!
api.getVersion().then((v) => {
currentVersion.value = v
})
api.onUpdateStatus((payload) => {
status.value = payload
})
setTimeout(() => {
api.checkForUpdate()
}, 8000)
}
/** 手动检查更新(设置弹窗按钮触发) */
function check(): void {
window.electronAPI?.checkForUpdate()
}
/** 确认下载更新包 */
function download(): void {
window.electronAPI?.downloadUpdate()
}
/** 退出并安装已下载的更新 */
function install(): void {
window.electronAPI?.quitAndInstall()
}
return { status, currentVersion, hasUpdate, init, check, download, install }
}

View File

@@ -1,4 +1,4 @@
import { createRouter, createWebHistory } from 'vue-router'
import { createRouter, createWebHistory, createWebHashHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
@@ -57,8 +57,14 @@ const routes: RouteRecordRaw[] = [
},
]
// Electron 桌面端(preload 注入 electronAPI,或以 file:// 协议加载)必须用 hash 路由:
// file 协议下 HTML5 History 无法工作,刷新/直达路由会白屏找不到文件。
// Web 浏览器环境保持原有 History 模式,URL 不带 #,部署行为不变。
const isDesktopShell =
typeof window !== 'undefined' && (!!window.electronAPI || window.location.protocol === 'file:')
const router = createRouter({
history: createWebHistory(),
history: isDesktopShell ? createWebHashHistory() : createWebHistory(),
routes,
})

View File

@@ -11,10 +11,23 @@ export const useConversationStore = defineStore('conversation', () => {
const conversations = ref<Conversation[]>([])
const loading = ref(false)
// 计算总未读数
const totalUnread = computed(() =>
conversations.value.reduce((acc, c) => acc + (c.is_muted ? 0 : c.unread_count), 0)
)
/**
* 判断会话是否免打扰——未读数累加、任务栏角标、侧边栏红点共用的统一口径:
* 群聊只看会话上的 is_muted;私聊免打扰通过右键菜单更新的是联系人(contact.is_muted),
* 不会同步回会话行,因此私聊优先取联系人设置、会话设置兜底——
* 若各处口径不一致,会出现"私聊免打扰后角标已静音、会话行红点却还在累加"的错位
*/
function isConvMuted(conv: Conversation): boolean {
if (conv.is_group) return !!conv.is_muted
const chatStore = useChatStore()
const contact = chatStore.contacts.find(ct => ct.user_id === conv.target_id || ct.id === conv.target_id)
return !!(contact?.is_muted ?? conv.is_muted)
}
// 计算总未读数(任务栏角标、侧边栏红点共用),免打扰会话不计入
const totalUnread = computed(() => {
return conversations.value.reduce((acc, c) => acc + (isConvMuted(c) ? 0 : (c.unread_count || 0)), 0)
})
/**
* 初始化加载会话列表
@@ -144,7 +157,9 @@ export const useConversationStore = defineStore('conversation', () => {
conv.last_message = summary
conv.last_time = now
// 如果不是自己发送的,且不是当前选中的聊天,且未设置免打扰,则增加未读数
if (!isSelf && !isCurrentChat && !conv.is_muted) {
// 免打扰判定必须走 isConvMuted 统一口径(私聊优先看联系人设置):
// 只看 conv.is_muted 的话,私聊通过右键菜单免打扰后红点仍会持续累加
if (!isSelf && !isCurrentChat && !isConvMuted(conv)) {
conv.unread_count = (conv.unread_count || 0) + 1
}
} else {

90
src/types/electron.d.ts vendored Normal file
View File

@@ -0,0 +1,90 @@
/**
* Electron 桌面端 API 类型声明
*
* window.electronAPI 由 electron/preload.ts 通过 contextBridge 注入,
* 仅在 Electron 环境下存在,Web 浏览器环境为 undefined。
* 本文件与 electron/preload.ts 的实现需保持同步。
*/
/** 应用内更新状态载荷(主进程 updater:status 事件) */
export interface UpdateStatusPayload {
/**
* checking=检查中 available=发现新版本 not-available=已是最新
* downloading=下载中 downloaded=下载完成待安装 error=出错 dev=开发模式不可用
*/
type: 'checking' | 'available' | 'not-available' | 'downloading' | 'downloaded' | 'error' | 'dev'
/** 新版本号(available/downloaded 时携带) */
version?: string
/** 下载进度百分比 0-100(downloading 时携带) */
percent?: number
/** 下载速度 bytes/s */
bytesPerSecond?: number
/** 已下载字节数 */
transferred?: number
/** 总字节数 */
total?: number
/** 错误信息(error 时携带) */
message?: string
}
/** 文件下载完成载荷(主进程 download:done 事件) */
export interface DownloadDonePayload {
/** completed=成功 cancelled=用户取消 interrupted=失败 */
state: 'completed' | 'cancelled' | 'interrupted' | string
fileName: string
/** 成功时的本地保存路径 */
savePath: string
}
/** 应用本地设置 */
export interface DesktopAppSettings {
/** 关闭窗口时最小化到托盘(false 则直接退出) */
closeToTray: boolean
}
/** preload 暴露的桌面能力集合 */
export interface ElectronAPI {
/** 当前运行平台(win32/darwin/linux) */
platform: string
/** 获取应用版本号 */
getVersion(): Promise<string>
/** 显示并聚焦主窗口 */
focusWindow(): Promise<void>
/** 设置任务栏未读角标;count=0 清除 */
setBadge(count: number, dataURL?: string): Promise<void>
/** 请求任务栏闪烁提醒(窗口未聚焦时生效) */
flashFrame(): Promise<void>
/** 发送系统通知(点击通知聚焦主窗口) */
notify(options: { title: string; body: string }): Promise<void>
/** 查询开机自启状态 */
getAutoLaunch(): Promise<boolean>
/** 设置开机自启,返回设置后的实际状态 */
setAutoLaunch(enable: boolean): Promise<boolean>
/** 读取应用本地设置 */
getAppSettings(): Promise<DesktopAppSettings>
/** 增量更新应用本地设置 */
setAppSettings(patch: Partial<DesktopAppSettings>): Promise<DesktopAppSettings>
/** 通过主进程下载文件(弹系统"另存为"对话框) */
downloadFile(url: string, fileName: string): Promise<void>
/** 订阅下载完成事件,返回取消订阅函数 */
onDownloadDone(callback: (payload: DownloadDonePayload) => void): () => void
/** 在文件管理器中显示指定文件 */
showItemInFolder(fullPath: string): Promise<void>
/** 检查更新(结果经 onUpdateStatus 推送) */
checkForUpdate(): Promise<void>
/** 下载更新包 */
downloadUpdate(): Promise<void>
/** 退出并安装更新 */
quitAndInstall(): Promise<void>
/** 订阅更新状态事件,返回取消订阅函数 */
onUpdateStatus(callback: (payload: UpdateStatusPayload) => void): () => void
}
declare global {
interface Window {
/** Electron 桌面端 API(仅 Electron 环境存在) */
electronAPI?: ElectronAPI
}
}
export {}

131
src/utils/htmlContent.ts Normal file
View File

@@ -0,0 +1,131 @@
/**
* HTML 内容识别与安全处理工具
* 场景:聊天文本消息可能是一段 HTML 源码(网页代码、AI 生成的页面等)。
* 1. detectHtmlContent —— 识别文本是否为 HTML 内容,并检测注入风险特征
* 2. sanitizeHtmlForPreview —— 预览前的兜底净化(与 iframe sandbox 组成双保险)
* 3. isLongText / truncateForCollapse —— 长文本自动折叠的统一阈值与截断
*/
export interface HtmlDetectResult {
/** 是否识别为 HTML 内容 */
isHtml: boolean
/** 是否命中注入风险特征(script/内联事件/伪协议等),命中则禁止预览 */
risky: boolean
}
/**
* 常见 HTML 标签白名单:用于区分"真的是 HTML 源码"和
* 聊天里随手打出的尖括号(如数学不等式 a<b、颜文字 <3 等)
*/
const COMMON_TAGS = new Set([
'html', 'head', 'body', 'title', 'meta', 'link', 'style', 'script',
'div', 'span', 'p', 'a', 'img', 'ul', 'ol', 'li', 'table', 'thead', 'tbody', 'tr', 'td', 'th',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'section', 'header', 'footer', 'nav',
'article', 'aside', 'main', 'form', 'input', 'button', 'select', 'option',
'textarea', 'label', 'br', 'hr', 'strong', 'em', 'b', 'i', 'u', 'small',
'code', 'pre', 'blockquote', 'iframe', 'video', 'audio', 'canvas', 'svg'
])
/**
* 注入风险特征:命中任意一条即判定为有风险。
* 预览用的 iframe 本身已禁脚本(sandbox),这里的判定用于
* 在气泡上明确警示用户、并直接禁止打开预览入口
*/
const RISK_PATTERNS: RegExp[] = [
/<script[\s>]/i, // 脚本标签
/\son[a-z]+\s*=/i, // 内联事件属性(onerror/onclick...)
/javascript\s*:/i, // javascript: 伪协议
/vbscript\s*:/i, // vbscript: 伪协议
/<iframe[\s>]/i, // 内嵌框架(可加载任意外部页面)
/<(object|embed|applet)[\s>]/i, // 插件类标签
/<meta[^>]+http-equiv/i, // meta 刷新/跳转
/<base[\s>]/i, // 篡改基准 URL
/<form[\s>]/i, // 表单(钓鱼提交)
/expression\s*\(/i, // CSS expression(旧 IE 可执行 JS)
/data\s*:\s*text\/html/i // data:text/html 内嵌文档
]
/**
* 识别文本是否为 HTML 内容并检测注入风险
* 判定规则(满足其一视为 HTML):
* 1. 以 <!DOCTYPE html> 或 <html> 开头 —— 完整文档
* 2. 命中白名单标签 >= 3 次,且存在至少一对成对标签 —— HTML 片段
* (双条件收紧口径,避免把普通聊天文本误判成 HTML)
*/
export function detectHtmlContent(text: string): HtmlDetectResult {
if (!text) return { isHtml: false, risky: false }
const trimmed = text.trim()
let isHtml = /^<!doctype\s+html/i.test(trimmed) || /^<html[\s>]/i.test(trimmed)
if (!isHtml) {
const tagMatches = trimmed.match(/<\/?([a-z][a-z0-9]*)(\s[^<>]*)?\/?>/gi) || []
let knownCount = 0
for (const m of tagMatches) {
const name = m.replace(/^<\/?/, '').match(/^[a-z][a-z0-9]*/i)?.[0]?.toLowerCase()
if (name && COMMON_TAGS.has(name)) knownCount++
}
const hasPairedTag = /<([a-z][a-z0-9]*)(\s[^<>]*)?>[\s\S]*<\/\1\s*>/i.test(trimmed)
isHtml = knownCount >= 3 && hasPairedTag
}
if (!isHtml) return { isHtml: false, risky: false }
return { isHtml: true, risky: RISK_PATTERNS.some((p) => p.test(trimmed)) }
}
/**
* 预览前的兜底净化:移除脚本/框架/插件/表单类标签、内联事件属性和伪协议链接。
* 说明:预览 iframe 已用 sandbox 禁脚本,此处是纵深防御的第二层,
* 防止未来渲染方式变更(或 sandbox 被误删)时暴露注入面
*/
export function sanitizeHtmlForPreview(html: string): string {
let out = html
// 成对的危险标签连内容一起移除(script 的内容就是代码,必须整块删)
out = out.replace(/<script[\s\S]*?<\/script\s*>/gi, '')
out = out.replace(/<(iframe|object|embed|applet)[\s\S]*?<\/\1\s*>/gi, '')
// 自闭合/未闭合的危险标签
out = out.replace(/<(script|iframe|object|embed|applet|base)[^>]*\/?>/gi, '')
// 内联事件属性(三种写法:双引号/单引号/无引号)
out = out.replace(/\son[a-z]+\s*=\s*"[^"]*"/gi, '')
out = out.replace(/\son[a-z]+\s*=\s*'[^']*'/gi, '')
out = out.replace(/\son[a-z]+\s*=\s*[^\s>]+/gi, '')
// href/src/action 上的 javascript:/vbscript: 伪协议置空
out = out.replace(/(href|src|action)\s*=\s*(["']?)\s*(javascript|vbscript)\s*:[^"'>\s]*\2/gi, '$1=$2#$2')
return out
}
/** 长文本折叠阈值:超过任一阈值即出现"展开全文"入口 */
export const LONG_TEXT_CHAR_LIMIT = 600
export const LONG_TEXT_LINE_LIMIT = 12
/** 折叠态展示的内容上限(字符/行数取先命中者) */
const COLLAPSED_CHARS = 500
const COLLAPSED_LINES = 10
/** 判断文本是否达到"长文本"标准(需要折叠) */
export function isLongText(text: string): boolean {
if (!text) return false
if (text.length > LONG_TEXT_CHAR_LIMIT) return true
let lines = 1
for (let i = 0; i < text.length; i++) {
if (text[i] === '\n') {
lines++
if (lines > LONG_TEXT_LINE_LIMIT) return true
}
}
return false
}
/**
* 生成折叠态展示的截断文本:
* 先按行数截(保留前 COLLAPSED_LINES 行),再按字符数截,末尾补省略号
*/
export function truncateForCollapse(text: string): string {
let out = text
const lines = out.split('\n')
if (lines.length > COLLAPSED_LINES) {
out = lines.slice(0, COLLAPSED_LINES).join('\n')
}
if (out.length > COLLAPSED_CHARS) {
out = out.slice(0, COLLAPSED_CHARS)
}
return `${out}...`
}

71
src/utils/platform.ts Normal file
View File

@@ -0,0 +1,71 @@
/**
* 运行平台判断与桌面端辅助工具
*
* isElectron:判断当前是否运行在 Electron 壳内(preload 注入了 window.electronAPI)。
* 渲染层所有桌面能力调用前都应先做此判断,保证同一套代码在 Web 浏览器中行为不变。
*/
import { API_ORIGIN } from '@/config/app'
/** 是否运行在 Electron 桌面端环境 */
export function isElectron(): boolean {
return typeof window !== 'undefined' && !!window.electronAPI
}
/**
* 桌面端文件下载桥接
*
* Electron 打包环境下页面是 file:// 协议,<a download> 对跨域资源会退化为页面跳转、
* 无法正常保存,所以统一改走主进程下载(弹系统"另存为"对话框,完成后有 toast 提示)。
*
* @returns true 表示已被桌面端接管,调用方无需再走浏览器 <a download> 逻辑
*/
export function downloadViaElectron(url: string, fileName: string): boolean {
if (!isElectron() || !url) {
return false
}
// 相对路径(如 /uploads/...)按后端资源域名 API_ORIGIN 解析为绝对地址:
// 打包后页面是 file:// 协议,若用 location.href 解析会得到 file:///uploads/...,
// 主进程只接受 http(s) 会静默丢弃导致下载无效。API_ORIGIN 打包时由 .env.electron
// 注入服务器地址;开发环境回退当前页面 origin(Dev Server),仍走 Vite 代理到本地后端
const absolute = /^https?:\/\//i.test(url)
? url
: new URL(url, API_ORIGIN || window.location.href).toString()
// 解析结果仍非 http(s)(异常场景兜底):不"假接管",
// 返回 false 交回调用方的浏览器 <a download> 降级逻辑
if (!/^https?:\/\//i.test(absolute)) {
return false
}
window.electronAPI!.downloadFile(absolute, fileName)
return true
}
/**
* 生成任务栏未读角标图片(红底白字圆形徽标)
*
* 为什么在渲染层生成:Windows 的 setOverlayIcon 需要一张位图,
* 而主进程没有 DOM/Canvas 能力,所以由渲染层用 canvas 画好后以 dataURL 传给主进程。
*/
export function generateBadgeDataURL(count: number): string {
const size = 32
const canvas = document.createElement('canvas')
canvas.width = size
canvas.height = size
const ctx = canvas.getContext('2d')
if (!ctx) {
return ''
}
// 红色圆形底
ctx.beginPath()
ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2)
ctx.fillStyle = '#ef4444'
ctx.fill()
// 白色未读数文字(超过 99 显示 99+,避免溢出)
const text = count > 99 ? '99+' : String(count)
ctx.fillStyle = '#ffffff'
ctx.font = `bold ${text.length >= 3 ? 13 : 18}px "Segoe UI", system-ui, sans-serif`
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
// +1 微调基线,视觉上更居中
ctx.fillText(text, size / 2, size / 2 + 1)
return canvas.toDataURL('image/png')
}

View File

@@ -15,7 +15,9 @@
@click="currentTab = 'chat'"
>
<i class="fas fa-comment-dots text-xl"></i>
<div v-if="chatStore.totalUnread > 0" class="absolute top-2 right-2 w-2 h-2 bg-red-500 rounded-full animate-pulse"></div>
<!-- 未读口径统一用 conversationStore(免打扰会话不计入),与会话列表角标、任务栏角标一致;
原来用 chatStore(联系人 unread 简单求和)会与任务栏角标在同屏出现矛盾 -->
<div v-if="conversationStore.totalUnread > 0" class="absolute top-2 right-2 w-2 h-2 bg-red-500 rounded-full animate-pulse"></div>
</div>
<div
@@ -97,12 +99,29 @@
<i class="fas fa-robot text-xl"></i>
</div>
<div
class="mt-auto mb-2 text-gray-500 hover:text-red-500 cursor-pointer transition transform hover:scale-110 w-10 h-10 flex items-center justify-center rounded-xl hover:bg-white/5"
@click="handleLogout"
title="退出登录"
>
<i class="fas fa-sign-out-alt text-xl"></i>
<!-- 底部按钮组:用单一 mt-auto 容器把设置和退出一起推到底部。
若两个按钮各自带 mt-auto,flex 剩余空间会被两个 auto margin 均分、按钮组被拆开;
Web 环境下设置按钮不渲染,只剩退出按钮,布局同样正确 -->
<div class="mt-auto flex flex-col items-center gap-6">
<!-- 桌面端设置(仅 Electron 环境显示):开机自启、托盘行为、检查更新 -->
<div
v-if="isDesktop"
class="mb-2 relative text-gray-500 hover:text-primary cursor-pointer transition transform hover:scale-110 w-10 h-10 flex items-center justify-center rounded-xl hover:bg-white/5"
@click="showAppSettingsModal = true"
title="桌面端设置"
>
<i class="fas fa-cog text-xl"></i>
<!-- 有可用新版本时显示红点提示 -->
<span v-if="appUpdate.hasUpdate.value" class="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full"></span>
</div>
<div
class="mb-2 text-gray-500 hover:text-red-500 cursor-pointer transition transform hover:scale-110 w-10 h-10 flex items-center justify-center rounded-xl hover:bg-white/5"
@click="handleLogout"
title="退出登录"
>
<i class="fas fa-sign-out-alt text-xl"></i>
</div>
</div>
</div>
@@ -727,6 +746,9 @@
<!-- AI 与机器人管理弹窗(仅管理员 id=1 可打开) -->
<AIBotManageModal :show="showAIManageModal" @close="showAIManageModal = false" />
<!-- 桌面端设置弹窗(仅 Electron 环境) -->
<AppSettingsModal v-if="isDesktop" :show="showAppSettingsModal" @close="showAppSettingsModal = false" />
</div>
</template>
@@ -771,6 +793,11 @@ import SelectContactsModal from '@/components/chat/SelectContactsModal.vue'
import GroupInfoPanel from '@/components/chat/GroupInfoPanel.vue'
import GroupChatPanel from '@/components/chat/GroupChatPanel.vue'
import AIBotManageModal from '@/components/common/AIBotManageModal.vue'
import AppSettingsModal from '@/components/common/AppSettingsModal.vue'
import { isElectron } from '@/utils/platform'
// 品牌 logo:Web 浏览器通知的图标(Electron 通知图标由主进程提供,不走这里)
import logoUrl from '@/assets/logo.png'
import { useAppUpdate } from '@/composables/useAppUpdate'
import GroupCallBanner from '@/components/chat/GroupCallBanner.vue'
import UserInfoCard from '@/components/common/UserInfoCard.vue'
import MomentPanel from '@/components/moment/MomentPanel.vue'
@@ -814,6 +841,11 @@ const showProfileModal = ref(false)
const showCallHistoryModal = ref(false)
// AI 与机器人管理弹窗:仅管理员(id=1)可打开
const showAIManageModal = ref(false)
// 桌面端设置弹窗(仅 Electron 环境有入口)
const isDesktop = isElectron()
const showAppSettingsModal = ref(false)
// 应用内更新状态:为侧边栏设置入口提供"新版本红点"
const appUpdate = useAppUpdate()
const isAdminUser = computed(() => authStore.user?.id === '1')
const callHistory = ref<ChatMessage[]>([])
const loadingCallHistory = ref(false)
@@ -1317,6 +1349,22 @@ async function openCallHistory() {
}
}
/**
* 处理消息 extra 更新推送(URL 卡片异步抓取完成后服务端补推):
* 按 room_id + id 在已加载消息中定位,替换其 extra,文本气泡下的卡片响应式出现。
* 消息不在已加载列表时直接忽略——后续拉历史时 DB 里已是带卡片数据的新 extra
*/
function handleMessageExtraUpdate(data: { id?: number; room_id?: string; extra?: string }) {
const roomId = data.room_id
if (!roomId || !data.id) return
const messages = chatStore.messages[roomId]
if (!messages?.length) return
const target = messages.find(m => m.id === data.id)
if (!target) return
// 与 receive_message 的解析约定一致:string 经 parseMessageExtra 转对象后存入
target.extra = parseMessageExtra(data.extra)
}
function handleReadReceipt(data: { message_ids?: number[]; room_id?: string }) {
const roomId = data.room_id
const ids = data.message_ids || []
@@ -1563,19 +1611,29 @@ function processMessageAfterConversation(message: ChatMessage, roomId: string) {
chatStore.updateContactLastMsg(contact.id, getMsgSummary(message), Date.now())
}
// 会话免打扰判定(提前到未读角标累加之前,角标/通知/提示音/toast 共用一份):
// 群聊只看会话设置(这里的 contact 是"发送者"的联系人条目,
// 发送者的联系人免打扰与群会话免打扰是两码事);
// 私聊优先联系人设置(右键菜单免打扰更新的是 contactStore),会话设置兜底
const conv = conversationStore.conversations.find(c => c.room_id === roomId)
const isMutedConv = conv?.is_group ? !!conv.is_muted : !!(contact?.is_muted ?? conv?.is_muted)
if (!message.isSelf) {
if (!isCurrentChat) {
// 联系人列表未读角标(好友才有条目);会话列表未读已由 handleMessageUpdate 处理
if (contact) {
// 联系人列表未读角标(好友才有条目):免打扰会话不累加,
// 与会话列表未读(handleMessageUpdate/totalUnread)的排除口径保持一致
if (contact && !isMutedConv) {
chatStore.incrementUnread(contact.id)
}
} else {
// 正盯着当前会话收到对方消息,立即回执已读,
// 对方端气泡实时显示"已读"(原先只在加载历史时上报,此场景漏掉了)。
// 标签页在后台时不能立即上报——人根本没在看,误报"已读"是回执语义错误,
// 先积攒起来,等 visibilitychange 恢复可见时统一补报
// 用户没在看时不能立即上报——误报"已读"是回执语义错误,
// 先积攒起来,等恢复查看(可见/聚焦)时统一补报。
// 判定必须区分平台:Electron 窗口被遮挡时 document.hidden 仍为 false,
// 得看窗口焦点;Web 标签页则看可见性(见 isViewingNow)
if (message.id) {
if (document.hidden) {
if (!isViewingNow()) {
pendingReadMessageIds.add(message.id)
} else {
messageApi.markMessagesRead([message.id]).catch(() => {})
@@ -1592,21 +1650,30 @@ function processMessageAfterConversation(message: ChatMessage, roomId: string) {
// 发送者显示名:好友取备注/昵称,非好友回退用户ID
const senderName = contact?.remark_name || contact?.user?.name || message.sender_user_id || '未知用户'
// 浏览器原生通知(仅在标签页隐藏时)
if ('Notification' in window && Notification.permission === 'granted' && document.hidden) {
// 桌面通知(免打扰会话不弹通知、不闪任务栏,与未读数/角标排除口径一致):
// - Electron:窗口未聚焦即提醒(桌面端窗口"失焦但可见"时 document.hidden 为 false,
// 不能沿用浏览器的判断),走主进程系统通知(点击可聚焦主窗口),并触发任务栏闪烁
// - Web 浏览器:保持原逻辑,仅标签页隐藏时弹浏览器通知
if (isDesktop) {
if (!document.hasFocus() && !isMutedConv) {
window.electronAPI!.notify({ title: senderName, body: getMsgSummary(message) })
window.electronAPI!.flashFrame()
}
} else if ('Notification' in window && Notification.permission === 'granted' && document.hidden && !isMutedConv) {
new Notification(senderName, {
body: getMsgSummary(message),
icon: '/favicon.ico'
icon: logoUrl
})
}
// 未读提示音(非当前会话且未免打扰)
if (!isCurrentChat && !contact?.is_muted) {
// 未读提示音(非当前会话且未免打扰)——原先查发送者的 contact.is_muted,
// 群聊场景判断的是错误对象,统一改用会话免打扰口径
if (!isCurrentChat && !isMutedConv) {
playNotificationSound()
}
// 不在会话tab时显示toast通知
if (currentTab.value !== 'chat') {
// 不在会话tab时显示toast通知(免打扰会话不弹,与上方桌面通知/提示音的排除口径一致)
if (currentTab.value !== 'chat' && !isMutedConv) {
toastStore.showToast(
'收到新消息',
'info',
@@ -2581,27 +2648,43 @@ onMounted(async () => {
const isValid = await authStore.checkAuth()
if (!isValid) { router.push('/login'); return }
if (authStore.user) {
await wsManager.connect(authStore.user.id)
// 绑定 ClientID 和 UserID
const clientId = wsManager.getClientId()
if (clientId) {
try {
await systemApi.bindClient({ user_id: authStore.user.id, client_id: clientId })
console.log('✅ Client bound successfully')
} catch (error) {
console.warn('⚠️ Failed to bind client:', error)
}
}
wsManager.onMessage(handleWebSocketMessage)
wsManager.onReadReceipt(handleReadReceipt)
// 1v1 通话信令改由 App.vue 全局注册(离开聊天页也要能收来电),此处不再注册
// 朋友圈通知已在 App.vue 中统一注册,避免重复
}
// 先加载联系人与会话数据、再建立 WebSocket 连接:
// 免打扰判定(isConvMuted)依赖 contacts/conversations,
// 若先连接,离线积压消息会在数据就绪前涌入,私聊免打扰在该窗口内会失效
// (两个加载函数内部均已捕获异常,失败不会阻断后续初始化)
await loadContacts()
await conversationStore.loadConversations()
if (authStore.user) {
// 先注册消息处理器再发起连接:注册本身不依赖连接成功。
// 若放在 connect 之后,初次握手失败抛错会跳过注册,
// 自动重连恢复的连接将"收得到消息但 UI 毫无反应"
wsManager.onMessage(handleWebSocketMessage)
wsManager.onReadReceipt(handleReadReceipt)
// URL 卡片异步补推:消息先秒发,卡片就绪后服务端推 extra 更新
wsManager.onMessageExtraUpdate(handleMessageExtraUpdate)
// 1v1 通话信令改由 App.vue 全局注册(离开聊天页也要能收来电),此处不再注册
// 朋友圈通知已在 App.vue 中统一注册,避免重复
// 初次连接失败不中断页面初始化:onclose 已排了自动重连,
// 这里捕获异常继续加载联系人/会话等数据,连接恢复后消息自然续上
try {
await wsManager.connect(authStore.user.id)
// 绑定 ClientID 和 UserID
const clientId = wsManager.getClientId()
if (clientId) {
try {
await systemApi.bindClient({ user_id: authStore.user.id, client_id: clientId })
console.log('✅ Client bound successfully')
} catch (error) {
console.warn('⚠️ Failed to bind client:', error)
}
}
} catch (error) {
console.warn('⚠️ WebSocket 初次连接失败,等待自动重连:', error)
}
}
await prefetchUnreadMessages()
// 获取朋友圈未读数
momentStore.fetchUnreadCount()
@@ -2637,15 +2720,28 @@ onMounted(async () => {
// 标签页恢复可见时补报后台期间积攒的已读回执
document.addEventListener('visibilitychange', flushPendingReadReceipts)
// Electron 窗口被遮挡后重新聚焦不触发 visibilitychange,须补 focus 监听;
// Web 下与 visibilitychange 幂等共存(flush 内部有查看状态守卫),无副作用
window.addEventListener('focus', flushPendingReadReceipts)
})
// 后台标签页期间收到的当前会话消息ID,等页面恢复可见时统一补报已读。
// 切换会话只可能发生在可见状态(而可见瞬间就会 flush),集合里不会残留跨会话的ID
// 后台/失焦期间收到的当前会话消息ID,等恢复查看时统一补报已读。
// 切换会话只可能发生在查看状态(而恢复查看瞬间就会 flush),集合里不会残留跨会话的ID
const pendingReadMessageIds = new Set<number>()
/** 页面恢复可见时补报积攒的已读回执 */
/**
* 用户当前是否"正在查看"页面(已读回执的判定依据)
* Electron:窗口被其他窗口遮挡时 document.hidden 仍为 false,必须看窗口焦点,
* 否则失焦时上报已读、同时还在弹系统通知,对方看到"已读"而用户并未查看;
* Web:保持标签页可见性判断(失焦但可见的标签页用户确实可能在看)
*/
function isViewingNow(): boolean {
return isDesktop ? document.hasFocus() : !document.hidden
}
/** 恢复查看(页面可见/窗口聚焦)时补报积攒的已读回执 */
function flushPendingReadReceipts() {
if (document.hidden || pendingReadMessageIds.size === 0) return
if (!isViewingNow() || pendingReadMessageIds.size === 0) return
const ids = Array.from(pendingReadMessageIds)
pendingReadMessageIds.clear()
messageApi.markMessagesRead(ids).catch(() => {})
@@ -2673,12 +2769,14 @@ onUnmounted(() => {
wsManager.offMessage(handleWebSocketMessage)
// 已读回执处理器此前只注册不注销,ChatView 每次重挂载都会泄漏一个闭包,补上对称清理
wsManager.offReadReceipt(handleReadReceipt)
wsManager.offMessageExtraUpdate(handleMessageExtraUpdate)
// 注意:1v1 信令已移到 App.vue 全局,这里不再 offSignal(否则会把全局监听误删)
// offMomentNotification 之前传的是 momentStore.handleWsNotification,但 App.vue 注册的是自己的
// wrapper 函数,引用不一致 → 是个删不掉任何东西的 no-op 死代码,已移除
window.removeEventListener('resize', handleViewportResize)
window.removeEventListener('app-tab-change', handleTabChangeEvent)
document.removeEventListener('visibilitychange', flushPendingReadReceipts)
window.removeEventListener('focus', flushPendingReadReceipts)
stopConnectionCheck()
})
</script>

View File

@@ -1,6 +1,10 @@
<template>
<div class="fixed inset-0 bg-black/90 z-[100] flex items-center justify-center p-4 backdrop-blur-md">
<div class="bg-panel p-8 rounded-3xl w-full max-w-2xl border border-gray-700 shadow-2xl hover:shadow-primary/20 transition-all duration-500">
<!-- 品牌 logo:奶酪聊天气泡 + 年糕崽崽,经 Vite 打包引入(兼容 Web 与 Electron file:// 环境) -->
<div class="flex justify-center mb-4">
<img :src="logoUrl" alt="NL-IM" class="w-20 h-20 select-none pointer-events-none drop-shadow-[0_8px_24px_rgba(255,176,32,0.35)]" draggable="false" />
</div>
<h1 class="text-3xl font-light mb-2 text-center text-white tracking-wider">
NL-IM <span class="text-primary font-bold">V2</span>
</h1>
@@ -177,6 +181,8 @@ import { useToastStore } from '@/stores/toast'
import * as authApi from '@/api/modules/auth'
import * as qrcodeApi from '@/api/modules/qrcode'
import { isValidEmail, isValidPhone, checkPasswordStrength } from '@/utils/validate'
// 品牌 logo 经 Vite 资源管线引入,构建后生成带 hash 的相对路径,Electron 打包环境同样可用
import logoUrl from '@/assets/logo.png'
const router = useRouter()
const authStore = useAuthStore()

18
src/vite-env.d.ts vendored
View File

@@ -6,3 +6,21 @@ declare module '*.vue' {
export default component
}
/**
* 环境变量类型声明
* Web 构建可全部不配置(走相对路径 + Vite 代理);
* Electron 生产构建由 .env.electron 提供后端绝对地址
*/
interface ImportMetaEnv {
/** HTTP API 基础地址,默认 /api */
readonly VITE_API_BASE_URL?: string
/** WebSocket 完整地址,默认按当前页面 host 拼接 */
readonly VITE_WS_URL?: string
/** 静态资源(uploads)域名,默认当前页面 origin */
readonly VITE_API_ORIGIN?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

View File

@@ -4,8 +4,9 @@
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
"allowSyntheticDefaultImports": true,
"types": ["node"]
},
"include": ["vite.config.ts"]
"include": ["vite.config.ts", "electron/**/*.ts"]
}

View File

@@ -1,42 +1,104 @@
import { defineConfig } from 'vite'
import { defineConfig, loadEnv, type PluginOption } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
import electron from 'vite-plugin-electron/simple'
// https://vitejs.dev/config/
export default defineConfig(({ mode }) => ({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
// 生产构建剔除 console.log/debugger:
// 开发期的调试日志(WebRTC/WS 链路较多)不应带到线上,
// 用 esbuild drop 在构建期统一剥离,保留 console.error/warn 便于线上排障
esbuild: mode === 'production'
? { pure: ['console.log', 'console.debug', 'console.info'], drop: ['debugger'] }
: {},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:12080',
// target: 'https://g-ws.nailaoyun.cn',
changeOrigin: true,
},
'/ws': {
target: 'ws://localhost:12080',
// target: 'wss://g-ws.nailaoyun.cn',
ws: true,
changeOrigin: true,
},
'/upload': {
target: 'http://localhost:12080',
// target: 'https://g-ws.nailaoyun.cn',
ws: true,
changeOrigin: true,
},
},
},
}))
export default defineConfig(({ mode }) => {
// Electron 相关模式:
// - electron-dev:本地开发(npm run dev:electron),插件自动拉起 Electron 加载 Vite Dev Server
// - electron:生产构建(npm run build:electron),加载 .env.electron 注入线上后端地址
// - electron-test:本地测试打包(npm run build:electron:test),
// 复用 .env.electron 中 TEST_ 前缀字段,打出"连本地后端"的包,便于不发服务器验证桌面端功能
const isElectron = mode === 'electron' || mode === 'electron-dev' || mode === 'electron-test'
// 需要打成 file:// 安装包的模式(electron-dev 走 Dev Server,不算)
const isElectronBuild = mode === 'electron' || mode === 'electron-test'
// electron-test 模式的环境变量注入:
// Vite 按精确模式名只会找 .env.electron-test(不存在),所以这里手动从 .env.electron
// 读出 TEST_VITE_* 字段,去掉 TEST_ 前缀写入 process.env——
// process.env 中 VITE_ 前缀变量优先级高于 env 文件,构建时即被打进包里
if (mode === 'electron-test') {
const testEnv = loadEnv('electron', __dirname, 'TEST_VITE_')
for (const [key, value] of Object.entries(testEnv)) {
process.env[key.replace(/^TEST_/, '')] = value
}
}
// 主进程输出 ESM(.mjs):项目 package.json 是 "type": "module",
// vite-plugin-electron 1.x 会按该字段强制主进程走 ESM(Electron 28+ 原生支持);
// preload 输出 CJS(.cjs):沙箱化 preload 只支持 CommonJS。
// electron-updater 保持 external,由 electron-builder 打包 node_modules 提供
const electronPlugin = (): PluginOption =>
electron({
main: {
entry: 'electron/main.ts',
vite: {
build: {
outDir: 'dist-electron',
emptyOutDir: false,
rollupOptions: {
external: ['electron', 'electron-updater'],
output: { format: 'es', entryFileNames: '[name].mjs' },
},
},
},
},
preload: {
input: 'electron/preload.ts',
vite: {
build: {
outDir: 'dist-electron',
emptyOutDir: false,
rollupOptions: {
external: ['electron'],
output: { format: 'cjs', entryFileNames: '[name].cjs' },
},
},
},
},
})
return {
// 仅 Electron 模式挂载主进程构建插件,Web 开发/构建完全不受影响
plugins: [vue(), ...(isElectron ? [electronPlugin()] : [])],
// Electron 打包产物(含本地测试包)用 file:// 加载 dist/index.html,资源必须用相对路径;
// Web 构建保持绝对路径 /
base: isElectronBuild ? './' : '/',
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
// 生产构建剔除 console.log/debugger:
// 开发期的调试日志(WebRTC/WS 链路较多)不应带到线上,
// 用 esbuild drop 在构建期统一剥离,保留 console.error/warn 便于线上排障
// (electron 模式同为生产构建,一并处理;electron-test 是本地调试包,保留完整日志方便排查)
esbuild:
mode === 'production' || mode === 'electron'
? { pure: ['console.log', 'console.debug', 'console.info'], drop: ['debugger'] }
: {},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:12080',
// target: 'https://g-ws.nailaoyun.cn',
changeOrigin: true,
},
'/ws': {
target: 'ws://localhost:12080',
// target: 'wss://g-ws.nailaoyun.cn',
ws: true,
changeOrigin: true,
},
'/upload': {
target: 'http://localhost:12080',
// target: 'https://g-ws.nailaoyun.cn',
ws: true,
changeOrigin: true,
},
},
},
}
})