修复了一些问题
25
.env.electron
Normal 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
@@ -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
|
After Width: | Height: | Size: 124 KiB |
BIN
build/icon.png
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
build/logo-master.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
428
electron/main.ts
Normal 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
@@ -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)
|
||||
@@ -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 -->
|
||||
|
||||
70
package.json
@@ -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
17
pnpm-workspace.yaml
Normal 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
|
After Width: | Height: | Size: 7.6 KiB |
BIN
public/icons/icon.png
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
public/icons/tray.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
public/logo.png
Normal file
|
After Width: | Height: | Size: 82 KiB |
@@ -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 |
57
scripts/cdp-screenshot.mjs
Normal 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
@@ -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('/')})`)
|
||||
42
src/App.vue
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
After Width: | Height: | Size: 82 KiB |
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
231
src/components/common/AppSettingsModal.vue
Normal 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>
|
||||
@@ -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
|
||||
|
||||
48
src/components/common/HtmlPreviewModal.vue
Normal 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>
|
||||
@@ -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`
|
||||
|
||||
63
src/composables/useAppUpdate.ts
Normal 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 }
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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')
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
|
||||
136
vite.config.ts
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||