initial: LAN IM desktop app (Electron 32 + Vue 3 + TS + SQLite)
This commit is contained in:
+12
@@ -0,0 +1,12 @@
|
|||||||
|
node_modules/
|
||||||
|
out/
|
||||||
|
dist/
|
||||||
|
dist-Electron/
|
||||||
|
release/
|
||||||
|
*.log
|
||||||
|
*.tsbuildinfo
|
||||||
|
.DS_Store
|
||||||
|
data/
|
||||||
|
!data/.gitkeep
|
||||||
|
.cache/
|
||||||
|
.vscode/
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# LocalNetMsg Agent Guide
|
||||||
|
|
||||||
|
LAN instant-messaging desktop app. Stack: **Electron 32 + Vue 3 + TypeScript + Vite + Pinia + Element Plus + SQLite (better-sqlite3)**. Three independent processes built by `electron-vite`.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
| Task | Command |
|
||||||
|
|---|---|
|
||||||
|
| Dev (Electron + HMR) | `npm run dev` |
|
||||||
|
| Build (no installer) | `npm run build` |
|
||||||
|
| Typecheck main+preload | `npm run typecheck:node` |
|
||||||
|
| Typecheck renderer | `npm run typecheck:web` |
|
||||||
|
| Typecheck both | `npm run typecheck` |
|
||||||
|
| Windows installer | `npm run package:win` → `dist-Electron\LocalNetMsg-0.1.0-Setup.exe` |
|
||||||
|
| macOS / Linux | `npm run package:mac` / `npm run package:linux` |
|
||||||
|
| Unpacked dir only | `npm run package:dir` |
|
||||||
|
|
||||||
|
All `package:*` scripts bake in `ELECTRON_MIRROR` + `ELECTRON_BUILDER_BINARIES_MIRROR` (npmmirror.com). Do not remove unless on the open internet.
|
||||||
|
|
||||||
|
No test suite. Verify changes via `npm run typecheck` + manual `npm run dev`.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
main/ Electron main process (Node) — entry: index.ts
|
||||||
|
discovery.ts UDP broadcast beacon (multi-NIC, per-interface subnet bcast)
|
||||||
|
chat-server.ts WebSocketServer on 0.0.0.0:47900
|
||||||
|
chat-client.ts Outgoing WS with backoff reconnect
|
||||||
|
file-server.ts HTTP /upload + /file, default cap 100GB
|
||||||
|
db.ts SQLite via better-sqlite3 (data/app.db)
|
||||||
|
settings.ts electron-store wrapper (~/AppData/Roaming/local-net-msg/config.json)
|
||||||
|
notify.ts System Notification + Windows overlay badge
|
||||||
|
ipc.ts All ipcMain.handle() wiring
|
||||||
|
protocol.ts MessageEnvelope, WsFrame, DeviceInfo types
|
||||||
|
preload/ contextBridge exposing typed API to renderer
|
||||||
|
renderer/ Vue 3 SPA (Vite root)
|
||||||
|
src/
|
||||||
|
main.ts createApp + Pinia + ElementPlus (full import) + zhCn
|
||||||
|
App.vue Global event listeners (device:*, message:*, settings:*)
|
||||||
|
components/ Sidebar, ChatView, MessageInput, MessageItem, SettingsView, MarkdownView
|
||||||
|
stores/ Pinia: device.ts, message.ts, session.ts
|
||||||
|
api.ts IPC typings (mirrors preload)
|
||||||
|
utils/ format.ts (colorFor, initialsOf, formatSize, ...)
|
||||||
|
electron.vite.config.ts Three builds → out/{main,preload,renderer}/
|
||||||
|
```
|
||||||
|
|
||||||
|
Renderer alias: `@` → `src/renderer/src`.
|
||||||
|
|
||||||
|
## Network protocol
|
||||||
|
|
||||||
|
UDP discovery on **47800**, chat WS on **47900**, file HTTP on **47901**. All three bind `0.0.0.0` and auto-shift on conflict.
|
||||||
|
|
||||||
|
`DeviceInfo` carries `address` (chosen from the first non-internal IPv4 of the responding NIC) plus `chatPort`/`filePort`. The chat server **sends no broadcast** of its own — discovery comes purely from UDP. Receiver uses `rinfo.address` (real source IP), never the payload's `self.address`.
|
||||||
|
|
||||||
|
## Hard-won pitfalls (don't repeat these)
|
||||||
|
|
||||||
|
1. **`chat-client` MUST have an `on('error')` listener.** Node EventEmitter throws `ERR_UNHANDLED_ERROR` and kills the main process if `'error'` fires with no listener. The WS `'error'` event is followed by `'close'` which triggers reconnect. Don't try to "fix" by emitting on the client; just `console.warn` and move on.
|
||||||
|
|
||||||
|
2. **Drag-drop file path.** Electron 32 removed `File.path`. Use `webUtils.getPathForFile(file)` from `electron` — exposed in `src/preload/index.ts` as `getPathForFile(file)`. Callers must read it via `window.api.getPathForFile(f)` BEFORE the await boundary.
|
||||||
|
|
||||||
|
3. **Native rebuild.** `postinstall` runs `electron-builder install-app-deps` to rebuild `better-sqlite3` against the bundled Electron ABI. After `npm i` or upgrading Electron, this must run. If you forget, sqlite open will throw on launch.
|
||||||
|
|
||||||
|
4. **`mkdir` for Windows reserved names.** Output dir is `dist-Electron\` (escaped; `dist\electron` chokes on Windows). Don't rename without verifying the build still produces a valid path.
|
||||||
|
|
||||||
|
5. **`nsis` is `oneClick: false`.** Installer asks for install path + creates Desktop/Start-Menu shortcuts. Permissions are user-scoped.
|
||||||
|
|
||||||
|
6. **Renderer global drag highlight.** Track `dragenter`/`dragleave` with a depth counter (not a boolean) — child elements fire leave/enter pairs. See `MessageInput.vue`'s `dragDepth`.
|
||||||
|
|
||||||
|
7. **Try not to leak Electron internals to UI.** Chat-client WS state stays in main; renderer only sees discovery online/offline. Adding a "chat not connected" pill in the UI was reverted for a reason — keep the surface area to two states (在线/离线).
|
||||||
|
|
||||||
|
8. **File conflict naming.** Append `_N` before the first `.`: `archive.tar.gz` → `archive_1.tar.gz`, `.bashrc` → `.bashrc_1`. Logic in `file-server.ts` `targetPath()`.
|
||||||
|
|
||||||
|
## Configuration knobs
|
||||||
|
|
||||||
|
- `LNM_MAX_FILE_SIZE=<bytes>` env var overrides the 100 GB default in `file-server.ts`. Useful for testing the limit path.
|
||||||
|
- `deviceName`, `downloadDir`, `notifications`, `autoStart` live in `~/AppData/Roaming/local-net-msg/config.json` via `electron-store`. Edit the file directly while dev is stopped.
|
||||||
|
- `data/app.db` — SQLite DB. DELETE to reset all devices/messages. Auto-migrates on next launch.
|
||||||
|
|
||||||
|
## Packaging gotcha
|
||||||
|
|
||||||
|
`release/` contains an `app.asar` Windows cannot delete while the previous app instance holds a file handle. Build now writes to `dist-Electron\` to dodge this. If you ever switch back, kill the running app first or you'll get EPERM.
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# LocalNetMsg
|
||||||
|
|
||||||
|
局域网即时通讯工具,飞书/微信聊天风格。基于 Electron + Vue 3 + TypeScript。
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
- 局域网自动发现 (UDP 广播)
|
||||||
|
- 1 对 1 私聊,消息持久化 (SQLite)
|
||||||
|
- 富文本消息:Markdown / 代码高亮 / @提醒 / 表情
|
||||||
|
- 图片消息:粘贴板、拖拽、本地选择
|
||||||
|
- 文件传输:任意类型,大文件流式上传
|
||||||
|
- 飞书式三栏 UI,消息气泡、时间分组、发送状态
|
||||||
|
- 系统托盘最小化,后台新消息系统级通知
|
||||||
|
- 接收文件按 `设置目录/YYYY-MM/` 自动归档
|
||||||
|
|
||||||
|
## 开发
|
||||||
|
|
||||||
|
环境要求: Node.js >= 20
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev # 启动 Electron + 热更新
|
||||||
|
```
|
||||||
|
|
||||||
|
第一次启动时:
|
||||||
|
- 默认接收目录: `~/Documents/LocalNetMsg`
|
||||||
|
- 默认设备名: 操作系统主机名
|
||||||
|
- 两个端口自动分配 (UDP 发现 47800 / WebSocket 47900 / 文件 47901)
|
||||||
|
|
||||||
|
要测试多设备,在同一局域网的不同机器上各跑一个实例即可。
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── main/ Electron 主进程
|
||||||
|
│ ├── index.ts 入口: 窗口、托盘、IPC
|
||||||
|
│ ├── discovery.ts UDP 局域网发现
|
||||||
|
│ ├── chat-server WebSocket 服务端
|
||||||
|
│ ├── chat-client WebSocket 客户端
|
||||||
|
│ ├── file-server HTTP 文件服务
|
||||||
|
│ ├── db.ts SQLite 持久化
|
||||||
|
│ ├── settings.ts electron-store
|
||||||
|
│ ├── notify.ts 系统通知
|
||||||
|
│ └── protocol.ts 消息协议定义
|
||||||
|
├── preload/ contextBridge 桥接
|
||||||
|
└── renderer/ Vue 3 渲染层
|
||||||
|
├── src/
|
||||||
|
│ ├── views/ ChatView, SettingsView
|
||||||
|
│ ├── components/ Sidebar, MessageList, MessageItem, MessageInput
|
||||||
|
│ ├── stores/ Pinia
|
||||||
|
│ ├── api/ IPC 桥接
|
||||||
|
│ └── utils/ markdown, format
|
||||||
|
└── index.html
|
||||||
|
```
|
||||||
|
|
||||||
|
## 配置
|
||||||
|
|
||||||
|
`~/AppData/Roaming/local-net-msg/config.json` (electron-store):
|
||||||
|
- `deviceName`: 本机在网络中的显示名
|
||||||
|
- `downloadDir`: 接收文件保存目录
|
||||||
|
- `notifications`: 是否启用系统通知
|
||||||
|
|
||||||
|
## 端口
|
||||||
|
|
||||||
|
| 用途 | 端口 | 协议 |
|
||||||
|
|---|---|---|
|
||||||
|
| 局域网发现 | 47800 | UDP |
|
||||||
|
| 聊天 | 47900 | WebSocket |
|
||||||
|
| 文件传输 | 47901 | HTTP |
|
||||||
|
|
||||||
|
被占用时自动顺延。
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import { resolve } from 'node:path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
main: {
|
||||||
|
plugins: [externalizeDepsPlugin()],
|
||||||
|
build: {
|
||||||
|
outDir: 'out/main',
|
||||||
|
rollupOptions: {
|
||||||
|
input: { index: resolve(__dirname, 'src/main/index.ts') }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
preload: {
|
||||||
|
plugins: [externalizeDepsPlugin()],
|
||||||
|
build: {
|
||||||
|
outDir: 'out/preload',
|
||||||
|
rollupOptions: {
|
||||||
|
input: { index: resolve(__dirname, 'src/preload/index.ts') }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
renderer: {
|
||||||
|
root: resolve(__dirname, 'src/renderer'),
|
||||||
|
resolve: {
|
||||||
|
alias: { '@': resolve(__dirname, 'src/renderer/src') }
|
||||||
|
},
|
||||||
|
plugins: [vue()],
|
||||||
|
build: {
|
||||||
|
outDir: 'out/renderer',
|
||||||
|
rollupOptions: {
|
||||||
|
input: { index: resolve(__dirname, 'src/renderer/index.html') }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
server: { port: 5173 }
|
||||||
|
}
|
||||||
|
})
|
||||||
Generated
+6953
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,94 @@
|
|||||||
|
{
|
||||||
|
"name": "local-net-msg",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "局域网即时通讯 (Electron + Vue 3) - 飞书风格",
|
||||||
|
"main": "out/main/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "electron-vite dev",
|
||||||
|
"build": "electron-vite build",
|
||||||
|
"preview": "electron-vite preview",
|
||||||
|
"start": "electron-vite preview",
|
||||||
|
|
||||||
|
"typecheck:node": "tsc --noEmit -p tsconfig.node.json",
|
||||||
|
"typecheck:web": "vue-tsc --noEmit -p src/renderer/tsconfig.json",
|
||||||
|
"typecheck": "npm run typecheck:node && npm run typecheck:web",
|
||||||
|
|
||||||
|
"package:win": "cross-env ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ electron-vite build && electron-builder --win --x64",
|
||||||
|
"package:mac": "cross-env ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ electron-vite build && electron-builder --mac",
|
||||||
|
"package:linux": "cross-env ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ electron-vite build && electron-builder --linux",
|
||||||
|
"package:dir": "cross-env ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ electron-vite build && electron-builder --dir",
|
||||||
|
|
||||||
|
"postinstall": "electron-builder install-app-deps"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@element-plus/icons-vue": "^2.3.1",
|
||||||
|
"better-sqlite3": "^11.5.0",
|
||||||
|
"electron-store": "^8.2.0",
|
||||||
|
"element-plus": "^2.8.4",
|
||||||
|
"highlight.js": "^11.10.0",
|
||||||
|
"markdown-it": "^14.3.0",
|
||||||
|
"pinia": "^2.2.4",
|
||||||
|
"ws": "^8.18.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/better-sqlite3": "^7.6.11",
|
||||||
|
"@types/markdown-it": "^14.1.2",
|
||||||
|
"@types/node": "^22.7.5",
|
||||||
|
"@types/ws": "^8.5.12",
|
||||||
|
"@vitejs/plugin-vue": "^5.1.4",
|
||||||
|
"cross-env": "^7.0.3",
|
||||||
|
"electron": "^32.1.2",
|
||||||
|
"electron-builder": "^24.13.3",
|
||||||
|
"electron-vite": "^2.3.0",
|
||||||
|
"typescript": "^5.6.2",
|
||||||
|
"vite": "^5.4.8",
|
||||||
|
"vue": "^3.5.10",
|
||||||
|
"vue-router": "^4.4.5",
|
||||||
|
"vue-tsc": "^2.1.6"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"appId": "com.localnetmsg.app",
|
||||||
|
"productName": "LocalNetMsg",
|
||||||
|
"copyright": "Copyright © 2026",
|
||||||
|
"directories": {
|
||||||
|
"output": "dist-Electron",
|
||||||
|
"buildResources": "resources"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"out/**/*",
|
||||||
|
"package.json"
|
||||||
|
],
|
||||||
|
"extraResources": [
|
||||||
|
{
|
||||||
|
"from": "resources/icon.png",
|
||||||
|
"to": "icon.png"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"win": {
|
||||||
|
"icon": "resources/icon.png",
|
||||||
|
"target": [
|
||||||
|
{ "target": "nsis", "arch": ["x64"] }
|
||||||
|
],
|
||||||
|
"artifactName": "${productName}-${version}-Setup.${ext}"
|
||||||
|
},
|
||||||
|
"nsis": {
|
||||||
|
"oneClick": false,
|
||||||
|
"perMachine": false,
|
||||||
|
"allowElevation": true,
|
||||||
|
"allowToChangeInstallationDirectory": true,
|
||||||
|
"createDesktopShortcut": true,
|
||||||
|
"createStartMenuShortcut": true,
|
||||||
|
"shortcutName": "LocalNetMsg"
|
||||||
|
},
|
||||||
|
"mac": {
|
||||||
|
"icon": "resources/icon.png",
|
||||||
|
"target": "dmg",
|
||||||
|
"category": "public.app-category.social-networking"
|
||||||
|
},
|
||||||
|
"linux": {
|
||||||
|
"icon": "resources/icon.png",
|
||||||
|
"target": ["AppImage"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
@@ -0,0 +1,28 @@
|
|||||||
|
// 把候选 logo 处理成 app icon:
|
||||||
|
// - 转 PNG (白色背景填充为透明)
|
||||||
|
// - 缩放到 1024x1024 (electron-builder 推荐)
|
||||||
|
// - 顺便输出 256x256 给 renderer 用
|
||||||
|
const sharp = require('sharp')
|
||||||
|
const path = require('node:path')
|
||||||
|
|
||||||
|
const SRC = path.resolve(__dirname, '..', 'resources', 'logo-candidate_004.jpg')
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const out1024 = path.resolve(__dirname, '..', 'resources', 'icon.png')
|
||||||
|
const out256 = path.resolve(__dirname, '..', 'resources', 'icon-256.png')
|
||||||
|
|
||||||
|
// 缩放到 1024x1024, JPEG 白色背景保持 (Windows 图标习惯)
|
||||||
|
await sharp(SRC)
|
||||||
|
.resize(1024, 1024, { fit: 'cover', position: 'center' })
|
||||||
|
.png({ quality: 95 })
|
||||||
|
.toFile(out1024)
|
||||||
|
|
||||||
|
await sharp(SRC)
|
||||||
|
.resize(256, 256, { fit: 'cover', position: 'center' })
|
||||||
|
.png()
|
||||||
|
.toFile(out256)
|
||||||
|
|
||||||
|
console.log('Generated:', out1024, out256)
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(e => { console.error(e); process.exit(1) })
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
// 主动连接到对端的 WebSocket (每发现一个设备就连一次)
|
||||||
|
import WebSocket from 'ws'
|
||||||
|
import { EventEmitter } from 'node:events'
|
||||||
|
import type { DeviceInfo, WsFrame } from './protocol'
|
||||||
|
|
||||||
|
export interface ChatClientEvents {
|
||||||
|
open: (peer: DeviceInfo) => void
|
||||||
|
close: (peer: DeviceInfo) => void
|
||||||
|
error: (peer: DeviceInfo, err: Error) => void
|
||||||
|
message: (peer: DeviceInfo, frame: WsFrame) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Entry {
|
||||||
|
peer: DeviceInfo
|
||||||
|
ws: WebSocket | null
|
||||||
|
retryTimer: NodeJS.Timeout | null
|
||||||
|
backoff: number
|
||||||
|
alive: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ChatClient extends EventEmitter {
|
||||||
|
private entries = new Map<string, Entry>()
|
||||||
|
private self: DeviceInfo
|
||||||
|
|
||||||
|
constructor(self: DeviceInfo) {
|
||||||
|
super()
|
||||||
|
this.self = self
|
||||||
|
}
|
||||||
|
|
||||||
|
// 由主进程在 Discovery.found / updated 时调用
|
||||||
|
connectTo(peer: DeviceInfo) {
|
||||||
|
if (peer.deviceId === this.self.deviceId) return
|
||||||
|
if (peer.address === this.self.address && peer.chatPort === this.self.chatPort) return
|
||||||
|
let e = this.entries.get(peer.deviceId)
|
||||||
|
if (e) {
|
||||||
|
const endpointChanged = e.peer.address !== peer.address || e.peer.chatPort !== peer.chatPort
|
||||||
|
e.peer = peer
|
||||||
|
if (e.ws && e.ws.readyState === WebSocket.OPEN) return
|
||||||
|
// 已有重试计划? 尊重 backoff, 别让 UDP 3s 心跳把指数回退干掉
|
||||||
|
if (e.retryTimer) {
|
||||||
|
// 但地址/端口变了 -> 立即重连到新端点
|
||||||
|
if (endpointChanged) {
|
||||||
|
clearTimeout(e.retryTimer)
|
||||||
|
e.retryTimer = null
|
||||||
|
try { e.ws?.close() } catch {}
|
||||||
|
e.ws = null
|
||||||
|
e.backoff = 1000
|
||||||
|
this.scheduleConnect(e, 0)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.scheduleConnect(e, e.backoff)
|
||||||
|
} else {
|
||||||
|
e = { peer, ws: null, retryTimer: null, backoff: 1000, alive: true }
|
||||||
|
this.entries.set(peer.deviceId, e)
|
||||||
|
this.scheduleConnect(e, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 强制重连: 立刻关掉旧 ws 并立刻重试
|
||||||
|
reconnect(deviceId: string) {
|
||||||
|
const e = this.entries.get(deviceId)
|
||||||
|
if (!e) return
|
||||||
|
if (e.retryTimer) clearTimeout(e.retryTimer)
|
||||||
|
try { e.ws?.close() } catch {}
|
||||||
|
e.ws = null
|
||||||
|
e.backoff = 1000
|
||||||
|
this.scheduleConnect(e, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(deviceId: string) {
|
||||||
|
const e = this.entries.get(deviceId)
|
||||||
|
if (!e) return
|
||||||
|
e.alive = false
|
||||||
|
if (e.retryTimer) clearTimeout(e.retryTimer)
|
||||||
|
try { e.ws?.close() } catch {}
|
||||||
|
this.entries.delete(deviceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleConnect(e: Entry, delay: number) {
|
||||||
|
if (!e.alive) return
|
||||||
|
if (e.retryTimer) clearTimeout(e.retryTimer)
|
||||||
|
e.retryTimer = setTimeout(() => this.doConnect(e), delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
private doConnect(e: Entry) {
|
||||||
|
if (!e.alive) return
|
||||||
|
const { peer } = e
|
||||||
|
const url = `ws://${peer.address}:${peer.chatPort}`
|
||||||
|
const ws = new WebSocket(url, { handshakeTimeout: 5000 })
|
||||||
|
e.ws = ws
|
||||||
|
ws.on('open', () => {
|
||||||
|
ws.send(JSON.stringify({ type: 'hello', from: this.self }))
|
||||||
|
e.backoff = 2000
|
||||||
|
this.emit('open', e.peer)
|
||||||
|
})
|
||||||
|
ws.on('message', (raw) => {
|
||||||
|
try {
|
||||||
|
const frame = JSON.parse(raw.toString('utf8')) as WsFrame
|
||||||
|
// 更新对端地址 (可能 ip 变了)
|
||||||
|
if (frame.type === 'hello' && frame.from) {
|
||||||
|
e.peer = { ...peer, ...frame.from, address: peer.address }
|
||||||
|
}
|
||||||
|
this.emit('message', e.peer, frame)
|
||||||
|
} catch {}
|
||||||
|
})
|
||||||
|
ws.on('close', () => {
|
||||||
|
e.ws = null
|
||||||
|
this.emit('close', e.peer)
|
||||||
|
if (e.alive) this.scheduleConnect(e, e.backoff)
|
||||||
|
e.backoff = Math.min(e.backoff * 2, 30_000)
|
||||||
|
})
|
||||||
|
ws.on('error', (err: Error) => {
|
||||||
|
this.emit('error', e.peer, err)
|
||||||
|
// error 之后会触发 close, close 里调度重连
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
send(frame: WsFrame, deviceId?: string) {
|
||||||
|
if (deviceId) {
|
||||||
|
const e = this.entries.get(deviceId)
|
||||||
|
if (e?.ws && e.ws.readyState === WebSocket.OPEN) {
|
||||||
|
e.ws.send(JSON.stringify(frame))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// 广播给所有已连接的对端
|
||||||
|
let any = false
|
||||||
|
for (const e of this.entries.values()) {
|
||||||
|
if (e.ws && e.ws.readyState === WebSocket.OPEN) {
|
||||||
|
e.ws.send(JSON.stringify(frame))
|
||||||
|
any = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return any
|
||||||
|
}
|
||||||
|
|
||||||
|
isOpen(deviceId: string) {
|
||||||
|
const e = this.entries.get(deviceId)
|
||||||
|
return !!(e?.ws && e.ws.readyState === WebSocket.OPEN)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拿所有对端的当前 WS 状态
|
||||||
|
getStatus(): Record<string, 'open' | 'closed' | 'connecting'> {
|
||||||
|
const out: Record<string, 'open' | 'closed' | 'connecting'> = {}
|
||||||
|
for (const [id, e] of this.entries) {
|
||||||
|
if (e.ws && e.ws.readyState === WebSocket.OPEN) out[id] = 'open'
|
||||||
|
else if (e.ws && e.ws.readyState === WebSocket.CONNECTING) out[id] = 'connecting'
|
||||||
|
else out[id] = 'closed'
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
for (const e of this.entries.values()) {
|
||||||
|
e.alive = false
|
||||||
|
if (e.retryTimer) clearTimeout(e.retryTimer)
|
||||||
|
try { e.ws?.close() } catch {}
|
||||||
|
}
|
||||||
|
this.entries.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
// WebSocket 服务端: 监听来自其它设备的连接, 接收消息/ping/recall
|
||||||
|
import { WebSocketServer, WebSocket } from 'ws'
|
||||||
|
import { EventEmitter } from 'node:events'
|
||||||
|
import type { WsFrame, DeviceInfo, MessageEnvelope } from './protocol'
|
||||||
|
import { PROTOCOL_VERSION } from './protocol'
|
||||||
|
|
||||||
|
export interface ChatServerEvents {
|
||||||
|
connect: (ws: WebSocket, peer: DeviceInfo) => void
|
||||||
|
disconnect: (deviceId: string) => void
|
||||||
|
message: (from: DeviceInfo, msg: MessageEnvelope) => void
|
||||||
|
recall: (from: DeviceInfo, messageId: string) => void
|
||||||
|
typing: (from: DeviceInfo) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ChatServer extends EventEmitter {
|
||||||
|
private wss: WebSocketServer
|
||||||
|
private port: number
|
||||||
|
// 一个 device 可能多个连接 (重连), 只信任最新 hello 的
|
||||||
|
private sockets = new Map<string, WebSocket>()
|
||||||
|
|
||||||
|
constructor(port: number) {
|
||||||
|
super()
|
||||||
|
this.port = port
|
||||||
|
this.wss = new WebSocketServer({ port, host: '0.0.0.0' })
|
||||||
|
}
|
||||||
|
|
||||||
|
getPort(): number {
|
||||||
|
return this.wss.address() as { port: number } | null as any
|
||||||
|
}
|
||||||
|
|
||||||
|
start(): Promise<number> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.wss.once('error', reject)
|
||||||
|
this.wss.once('listening', () => {
|
||||||
|
const addr = this.wss.address()
|
||||||
|
const realPort = typeof addr === 'object' && addr ? addr.port : this.port
|
||||||
|
this.port = realPort
|
||||||
|
this.wss.on('connection', (ws, req) => this.onConnection(ws, req))
|
||||||
|
resolve(realPort)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private onConnection(ws: WebSocket, _req: any) {
|
||||||
|
let peer: DeviceInfo | null = null
|
||||||
|
|
||||||
|
ws.on('message', (raw) => {
|
||||||
|
let frame: WsFrame
|
||||||
|
try {
|
||||||
|
frame = JSON.parse(raw.toString('utf8'))
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch (frame.type) {
|
||||||
|
case 'hello':
|
||||||
|
if (frame.from && frame.from.deviceId) {
|
||||||
|
peer = frame.from
|
||||||
|
// 版本/协议不匹配 -> 关闭
|
||||||
|
if ((peer as any).version && (peer as any).version !== PROTOCOL_VERSION) {
|
||||||
|
ws.close(4001, 'protocol mismatch')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const old = this.sockets.get(peer.deviceId)
|
||||||
|
if (old && old !== ws) {
|
||||||
|
try { old.close(4000, 'replaced') } catch {}
|
||||||
|
}
|
||||||
|
this.sockets.set(peer.deviceId, ws)
|
||||||
|
this.emit('connect', ws, peer)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'ping':
|
||||||
|
this.sendTo(ws, { type: 'pong', ts: frame.ts })
|
||||||
|
break
|
||||||
|
case 'message':
|
||||||
|
if (peer) this.emit('message', peer, frame.payload)
|
||||||
|
break
|
||||||
|
case 'recall':
|
||||||
|
if (peer) this.emit('recall', peer, frame.messageId)
|
||||||
|
break
|
||||||
|
case 'typing':
|
||||||
|
if (peer) this.emit('typing', peer)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ws.on('close', () => {
|
||||||
|
if (peer) {
|
||||||
|
// 只有当这个 ws 仍是该 device 的代表 socket 才触发 disconnect
|
||||||
|
if (this.sockets.get(peer.deviceId) === ws) {
|
||||||
|
this.sockets.delete(peer.deviceId)
|
||||||
|
this.emit('disconnect', peer.deviceId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ws.on('error', () => { /* swallow */ })
|
||||||
|
}
|
||||||
|
|
||||||
|
sendTo(ws: WebSocket, frame: WsFrame) {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(frame))
|
||||||
|
}
|
||||||
|
|
||||||
|
sendToDevice(deviceId: string, frame: WsFrame): boolean {
|
||||||
|
const ws = this.sockets.get(deviceId)
|
||||||
|
if (!ws) return false
|
||||||
|
this.sendTo(ws, frame)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop() {
|
||||||
|
for (const ws of this.sockets.values()) {
|
||||||
|
try { ws.close(1001, 'server shutdown') } catch {}
|
||||||
|
}
|
||||||
|
await new Promise<void>(r => this.wss.close(() => r()))
|
||||||
|
}
|
||||||
|
}
|
||||||
+226
@@ -0,0 +1,226 @@
|
|||||||
|
import Database from 'better-sqlite3'
|
||||||
|
import { paths } from './paths'
|
||||||
|
import type { MessageBody, MessageType } from './protocol'
|
||||||
|
import { existsSync, mkdirSync } from 'node:fs'
|
||||||
|
import { dirname } from 'node:path'
|
||||||
|
|
||||||
|
if (!existsSync(dirname(paths.db))) mkdirSync(dirname(paths.db), { recursive: true })
|
||||||
|
|
||||||
|
export const db: Database.Database = new Database(paths.db)
|
||||||
|
db.pragma('journal_mode = WAL')
|
||||||
|
db.pragma('foreign_keys = ON')
|
||||||
|
|
||||||
|
// 设备: 持久化最近一次见到的设备信息, 便于离线后回看
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS devices (
|
||||||
|
device_id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
hostname TEXT,
|
||||||
|
platform TEXT,
|
||||||
|
app_version TEXT,
|
||||||
|
last_ip TEXT,
|
||||||
|
last_seen INTEGER NOT NULL,
|
||||||
|
ignored INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
|
message_id TEXT PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
from_id TEXT NOT NULL,
|
||||||
|
to_id TEXT NOT NULL,
|
||||||
|
ts INTEGER NOT NULL,
|
||||||
|
body_json TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'delivered',
|
||||||
|
-- 引用方向, 用于查询某个会话 (1对1)
|
||||||
|
conversation_key TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_conv_ts
|
||||||
|
ON messages (conversation_key, ts);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS unread (
|
||||||
|
device_id TEXT PRIMARY KEY,
|
||||||
|
count INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
`)
|
||||||
|
|
||||||
|
// 迁移: 给老库补 ignored 列 (用 try/catch 包住, 已存在会抛)
|
||||||
|
try { db.exec(`ALTER TABLE devices ADD COLUMN ignored INTEGER NOT NULL DEFAULT 0`) } catch {}
|
||||||
|
|
||||||
|
// conversation_key: 任意两个 deviceId 排序后 join, 1对1
|
||||||
|
export function conversationKey(a: string, b: string): string {
|
||||||
|
return [a, b].sort().join('::')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设备
|
||||||
|
export interface DeviceRow {
|
||||||
|
device_id: string
|
||||||
|
name: string
|
||||||
|
hostname: string | null
|
||||||
|
platform: string | null
|
||||||
|
app_version: string | null
|
||||||
|
last_ip: string | null
|
||||||
|
last_seen: number
|
||||||
|
ignored: number // 0/1
|
||||||
|
}
|
||||||
|
|
||||||
|
const upsertDeviceStmt = db.prepare(`
|
||||||
|
INSERT INTO devices (device_id, name, hostname, platform, app_version, last_ip, last_seen)
|
||||||
|
VALUES (@device_id, @name, @hostname, @platform, @app_version, @last_ip, @last_seen)
|
||||||
|
ON CONFLICT(device_id) DO UPDATE SET
|
||||||
|
name = excluded.name,
|
||||||
|
hostname = excluded.hostname,
|
||||||
|
platform = excluded.platform,
|
||||||
|
app_version = excluded.app_version,
|
||||||
|
last_ip = excluded.last_ip,
|
||||||
|
last_seen = excluded.last_seen
|
||||||
|
`)
|
||||||
|
|
||||||
|
export function upsertDevice(d: DeviceRow) {
|
||||||
|
upsertDeviceStmt.run(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listDevices(): DeviceRow[] {
|
||||||
|
return db.prepare(`SELECT * FROM devices ORDER BY last_seen DESC`).all() as DeviceRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDevice(id: string): DeviceRow | undefined {
|
||||||
|
return db.prepare(`SELECT * FROM devices WHERE device_id = ?`).get(id) as DeviceRow | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setDeviceIgnored(deviceId: string, ignored: boolean) {
|
||||||
|
db.prepare(`UPDATE devices SET ignored = ? WHERE device_id = ?`).run(ignored ? 1 : 0, deviceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listIgnoredDevices(): DeviceRow[] {
|
||||||
|
return db.prepare(`SELECT * FROM devices WHERE ignored = 1 ORDER BY last_seen DESC`).all() as DeviceRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除设备本地记录 (DB 行)。下次 UDP announce 会作为新设备重新入
|
||||||
|
export function deleteDevice(deviceId: string): boolean {
|
||||||
|
const r = db.prepare(`DELETE FROM devices WHERE device_id = ?`).run(deviceId)
|
||||||
|
db.prepare(`DELETE FROM unread WHERE device_id = ?`).run(deviceId)
|
||||||
|
return r.changes > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteConversation(selfId: string, peerId: string): number {
|
||||||
|
const key = conversationKey(selfId, peerId)
|
||||||
|
const r = db.prepare(`DELETE FROM messages WHERE conversation_key = ?`).run(key)
|
||||||
|
db.prepare(`DELETE FROM unread WHERE device_id = ?`).run(peerId)
|
||||||
|
return r.changes
|
||||||
|
}
|
||||||
|
|
||||||
|
// 消息
|
||||||
|
export interface MessageRow {
|
||||||
|
message_id: string
|
||||||
|
type: MessageType
|
||||||
|
from_id: string
|
||||||
|
to_id: string
|
||||||
|
ts: number
|
||||||
|
body_json: string
|
||||||
|
status: string
|
||||||
|
conversation_key: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertMsgStmt = db.prepare(`
|
||||||
|
INSERT OR REPLACE INTO messages
|
||||||
|
(message_id, type, from_id, to_id, ts, body_json, status, conversation_key)
|
||||||
|
VALUES
|
||||||
|
(@message_id, @type, @from_id, @to_id, @ts, @body_json, @status, @conversation_key)
|
||||||
|
`)
|
||||||
|
|
||||||
|
export function insertMessage(args: {
|
||||||
|
messageId: string
|
||||||
|
type: MessageType
|
||||||
|
fromId: string
|
||||||
|
toId: string
|
||||||
|
ts: number
|
||||||
|
body: MessageBody
|
||||||
|
status?: string
|
||||||
|
}) {
|
||||||
|
const row: MessageRow = {
|
||||||
|
message_id: args.messageId,
|
||||||
|
type: args.type,
|
||||||
|
from_id: args.fromId,
|
||||||
|
to_id: args.toId,
|
||||||
|
ts: args.ts,
|
||||||
|
body_json: JSON.stringify(args.body),
|
||||||
|
status: args.status ?? 'delivered',
|
||||||
|
conversation_key: conversationKey(args.fromId, args.toId),
|
||||||
|
}
|
||||||
|
insertMsgStmt.run(row)
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMessage(id: string): MessageRow | undefined {
|
||||||
|
return db.prepare(`SELECT * FROM messages WHERE message_id = ?`).get(id) as MessageRow | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listMessages(peerId: string, selfId: string, limit = 200, before?: number): MessageRow[] {
|
||||||
|
const key = conversationKey(selfId, peerId)
|
||||||
|
if (before) {
|
||||||
|
return db.prepare(
|
||||||
|
`SELECT * FROM messages WHERE conversation_key = ? AND ts < ? ORDER BY ts DESC LIMIT ?`
|
||||||
|
).all(key, before, limit).reverse() as MessageRow[]
|
||||||
|
}
|
||||||
|
return db.prepare(
|
||||||
|
`SELECT * FROM messages WHERE conversation_key = ? ORDER BY ts DESC LIMIT ?`
|
||||||
|
).all(key, limit).reverse() as MessageRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 待发送 (离线时缓存, 上线后由 flushPendingFor 触发重发)
|
||||||
|
export function listPendingMessages(peerId: string, selfId: string): MessageRow[] {
|
||||||
|
const key = conversationKey(selfId, peerId)
|
||||||
|
return db.prepare(
|
||||||
|
`SELECT * FROM messages
|
||||||
|
WHERE conversation_key = ? AND status = 'pending'
|
||||||
|
ORDER BY ts ASC`
|
||||||
|
).all(key) as MessageRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listAllPending(selfId: string): MessageRow[] {
|
||||||
|
return db.prepare(
|
||||||
|
`SELECT * FROM messages WHERE status = 'pending' AND from_id = ? ORDER BY ts ASC`
|
||||||
|
).all(selfId) as MessageRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateMessageStatus(messageId: string, status: string) {
|
||||||
|
db.prepare(`UPDATE messages SET status = ? WHERE message_id = ?`).run(status, messageId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上传完成后回写 savedPath / hash 到已存在的消息体 (进度条场景: 先显示气泡, 后台上传完再填充)
|
||||||
|
export function updateMessageBody(messageId: string, patch: Record<string, any>) {
|
||||||
|
const row = getMessage(messageId)
|
||||||
|
if (!row) return
|
||||||
|
const body = JSON.parse(row.body_json)
|
||||||
|
const next = { ...body, ...patch }
|
||||||
|
db.prepare(`UPDATE messages SET body_json = ? WHERE message_id = ?`).run(JSON.stringify(next), messageId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteMessage(messageId: string) {
|
||||||
|
db.prepare(`DELETE FROM messages WHERE message_id = ?`).run(messageId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未读
|
||||||
|
export function incrUnread(deviceId: string) {
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO unread (device_id, count) VALUES (?, 1)
|
||||||
|
ON CONFLICT(device_id) DO UPDATE SET count = count + 1
|
||||||
|
`).run(deviceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearUnread(deviceId: string) {
|
||||||
|
db.prepare(`DELETE FROM unread WHERE device_id = ?`).run(deviceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUnread(): Record<string, number> {
|
||||||
|
const rows = db.prepare(`SELECT device_id, count FROM unread`).all() as { device_id: string; count: number }[]
|
||||||
|
const out: Record<string, number> = {}
|
||||||
|
for (const r of rows) out[r.device_id] = r.count
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
export function totalUnread(): number {
|
||||||
|
const r = db.prepare(`SELECT COALESCE(SUM(count), 0) AS s FROM unread`).get() as { s: number }
|
||||||
|
return r.s
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
// UDP 局域网发现
|
||||||
|
// 1. 绑 0.0.0.0 收所有接口的广播
|
||||||
|
// 2. 按所有非内部接口的子网广播地址 + 255.255.255.255 全发 (兼容多网卡)
|
||||||
|
// 3. 接收方用 rinfo.address 拿真实源 IP, 跨子网也能连
|
||||||
|
|
||||||
|
import dgram from 'node:dgram'
|
||||||
|
import os from 'node:os'
|
||||||
|
import { EventEmitter } from 'node:events'
|
||||||
|
import { DEFAULT_PORTS } from './protocol'
|
||||||
|
import type { DeviceInfo } from './protocol'
|
||||||
|
import { getSettings } from './settings'
|
||||||
|
|
||||||
|
export interface DiscoveryEvents {
|
||||||
|
found: (device: DeviceInfo) => void
|
||||||
|
lost: (deviceId: string) => void
|
||||||
|
updated: (device: DeviceInfo) => void
|
||||||
|
interface: (iface: NetInterface) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NetInterface {
|
||||||
|
name: string
|
||||||
|
address: string
|
||||||
|
netmask: string
|
||||||
|
broadcast: string
|
||||||
|
family: 'IPv4' | 'IPv6'
|
||||||
|
internal: boolean
|
||||||
|
mac: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// IP <-> 32-bit 整数
|
||||||
|
function ipToInt(ip: string): number {
|
||||||
|
const parts = ip.split('.').map(p => parseInt(p, 10))
|
||||||
|
if (parts.length !== 4 || parts.some(n => isNaN(n) || n < 0 || n > 255)) return 0
|
||||||
|
return ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0
|
||||||
|
}
|
||||||
|
function intToIp(n: number): string {
|
||||||
|
return [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff].join('.')
|
||||||
|
}
|
||||||
|
function broadcastOf(address: string, netmask: string): string {
|
||||||
|
const a = ipToInt(address)
|
||||||
|
const m = ipToInt(netmask)
|
||||||
|
if (!a || !m) return ''
|
||||||
|
return intToIp((a | (~m >>> 0)) >>> 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 列出所有非内部 IPv4 接口
|
||||||
|
export function listNetInterfaces(): NetInterface[] {
|
||||||
|
const out: NetInterface[] = []
|
||||||
|
const all = os.networkInterfaces()
|
||||||
|
for (const [name, list] of Object.entries(all)) {
|
||||||
|
if (!list) continue
|
||||||
|
for (const i of list) {
|
||||||
|
if (i.family !== 'IPv4') continue
|
||||||
|
out.push({
|
||||||
|
name,
|
||||||
|
address: i.address,
|
||||||
|
netmask: i.netmask,
|
||||||
|
broadcast: i.internal ? '' : broadcastOf(i.address, i.netmask),
|
||||||
|
family: 'IPv4',
|
||||||
|
internal: i.internal,
|
||||||
|
mac: i.mac,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取第一个非内部 IPv4 接口, 仅用于 self.address 展示
|
||||||
|
export function pickPreferredAddress(): NetInterface | null {
|
||||||
|
const all = listNetInterfaces().filter(i => !i.internal)
|
||||||
|
return all[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Discovery extends EventEmitter {
|
||||||
|
private socket: dgram.Socket
|
||||||
|
private port: number
|
||||||
|
private self: DeviceInfo
|
||||||
|
private timer: NodeJS.Timeout | null = null
|
||||||
|
private watchdog: NodeJS.Timeout | null = null
|
||||||
|
private peers = new Map<string, { info: DeviceInfo; lastSeen: number }>()
|
||||||
|
private fastPhaseUntil = 0
|
||||||
|
private destroyed = false
|
||||||
|
private recentBinds: string[] = [] // 最近收到广播的本机接口地址
|
||||||
|
|
||||||
|
constructor(self: DeviceInfo) {
|
||||||
|
super()
|
||||||
|
this.self = self
|
||||||
|
this.socket = dgram.createSocket({ type: 'udp4', reuseAddr: true })
|
||||||
|
this.port = DEFAULT_PORTS.discovery
|
||||||
|
}
|
||||||
|
|
||||||
|
async start() {
|
||||||
|
let bound = false
|
||||||
|
let attempts = 0
|
||||||
|
while (!bound && attempts < 10) {
|
||||||
|
try {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const onErr = (e: Error) => { this.socket.removeListener('error', onErr); reject(e) }
|
||||||
|
this.socket.once('error', onErr)
|
||||||
|
this.socket.bind(this.port, '0.0.0.0', () => {
|
||||||
|
this.socket.removeListener('error', onErr)
|
||||||
|
try { this.socket.setBroadcast(true); resolve() } catch (e) { reject(e) }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
bound = true
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e && (e.code === 'EADDRINUSE' || e.code === 'EACCES')) {
|
||||||
|
attempts++
|
||||||
|
this.port++
|
||||||
|
this.socket.close()
|
||||||
|
this.socket = dgram.createSocket({ type: 'udp4', reuseAddr: true })
|
||||||
|
} else {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!bound) throw new Error('无法绑定发现端口')
|
||||||
|
|
||||||
|
// 设置 self.address 为首选接口地址
|
||||||
|
const pref = pickPreferredAddress()
|
||||||
|
this.self.address = pref?.address || '127.0.0.1'
|
||||||
|
|
||||||
|
this.fastPhaseUntil = Date.now() + 5_000
|
||||||
|
this.broadcast()
|
||||||
|
this.reannounceInterfaces()
|
||||||
|
|
||||||
|
this.timer = setInterval(() => this.broadcast(), 3000)
|
||||||
|
this.watchdog = setInterval(() => this.checkStale(), 5000)
|
||||||
|
this.socket.on('message', (buf, rinfo) => this.onMessage(buf, rinfo))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 周期性重新宣告 self.address (用户在设置里改了首选接口)
|
||||||
|
reannounceInterfaces() {
|
||||||
|
const pref = pickPreferredAddress()
|
||||||
|
if (pref) this.self.address = pref.address
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收集所有要发的目标地址
|
||||||
|
// - 255.255.255.255 兜底
|
||||||
|
// - 127.0.0.1 同机自测
|
||||||
|
// - 每个非内部接口的子网广播
|
||||||
|
private getBroadcastTargets(): string[] {
|
||||||
|
const set = new Set<string>()
|
||||||
|
set.add('255.255.255.255')
|
||||||
|
set.add('127.0.0.1')
|
||||||
|
for (const i of listNetInterfaces()) {
|
||||||
|
if (i.internal) continue
|
||||||
|
if (i.broadcast) set.add(i.broadcast)
|
||||||
|
}
|
||||||
|
return Array.from(set)
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildPacket(): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
v: 1,
|
||||||
|
type: 'announce',
|
||||||
|
device: this.self,
|
||||||
|
name: getSettings().deviceName || this.self.name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private broadcast() {
|
||||||
|
if (this.destroyed) return
|
||||||
|
// 先校正 self.address
|
||||||
|
this.reannounceInterfaces()
|
||||||
|
const pkt = Buffer.from(this.buildPacket())
|
||||||
|
for (const t of this.getBroadcastTargets()) {
|
||||||
|
this.socket.send(pkt, DEFAULT_PORTS.discovery, t, () => { /* swallow */ })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private onMessage(buf: Buffer, rinfo: dgram.RemoteInfo) {
|
||||||
|
try {
|
||||||
|
const text = buf.toString('utf8')
|
||||||
|
if (text.length > 8192) return
|
||||||
|
const data = JSON.parse(text)
|
||||||
|
if (data?.type !== 'announce' || !data.device) return
|
||||||
|
const d = data.device as DeviceInfo
|
||||||
|
if (d.deviceId === this.self.deviceId) {
|
||||||
|
// 收到自己的广播, 校正自己的地址为实际看到的源 IP
|
||||||
|
this.self.address = rinfo.address
|
||||||
|
if (!this.recentBinds.includes(rinfo.address)) {
|
||||||
|
this.recentBinds.push(rinfo.address)
|
||||||
|
// 给上层一个事件, 让 settings UI 高亮该接口
|
||||||
|
const m = listNetInterfaces().find(i => i.address === rinfo.address)
|
||||||
|
if (m) this.emit('interface', m)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d.address = rinfo.address
|
||||||
|
d.ts = Date.now()
|
||||||
|
|
||||||
|
const existed = this.peers.get(d.deviceId)
|
||||||
|
if (existed) {
|
||||||
|
existed.info = d
|
||||||
|
existed.lastSeen = Date.now()
|
||||||
|
this.emit('updated', d)
|
||||||
|
} else {
|
||||||
|
this.peers.set(d.deviceId, { info: d, lastSeen: Date.now() })
|
||||||
|
this.emit('found', d)
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private checkStale() {
|
||||||
|
const now = Date.now()
|
||||||
|
for (const [id, p] of this.peers) {
|
||||||
|
if (now - p.lastSeen > 15_000) {
|
||||||
|
this.peers.delete(id)
|
||||||
|
this.emit('lost', id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getPeers(): DeviceInfo[] {
|
||||||
|
return Array.from(this.peers.values()).map(p => p.info)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户主动删了设备: 从 in-memory peers Map 移除, 下次 UDP 包到会作为 'found' 重新 emit
|
||||||
|
forget(deviceId: string) {
|
||||||
|
this.peers.delete(deviceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop() {
|
||||||
|
this.destroyed = true
|
||||||
|
if (this.timer) clearInterval(this.timer)
|
||||||
|
if (this.watchdog) clearInterval(this.watchdog)
|
||||||
|
await new Promise<void>(r => this.socket.close(() => r()))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,437 @@
|
|||||||
|
// HTTP 文件服务
|
||||||
|
// POST /upload 接收对端发来的文件, 按 日期年月 归档
|
||||||
|
// GET /file/:id 返回已接收的文件 (给发送方做预览, 也供前端自己打开)
|
||||||
|
// GET /health 健康检查
|
||||||
|
//
|
||||||
|
// 设计原则:
|
||||||
|
// - 全部基于 Node 内置 http, 不引第三方框架
|
||||||
|
// - 流式写入, 内存占用 < 单文件大小
|
||||||
|
// - 大小限制可配 (默认 2GB)
|
||||||
|
|
||||||
|
import { createServer, IncomingMessage, ServerResponse } from 'node:http'
|
||||||
|
import { EventEmitter } from 'node:events'
|
||||||
|
import { createWriteStream, existsSync, statSync, mkdirSync } from 'node:fs'
|
||||||
|
import { createReadStream } from 'node:fs'
|
||||||
|
import { mkdir, stat, unlink, rename } from 'node:fs/promises'
|
||||||
|
import { join, extname, resolve, basename } from 'node:path'
|
||||||
|
import { createHash, randomUUID } from 'node:crypto'
|
||||||
|
import { getSettings } from './settings'
|
||||||
|
|
||||||
|
export interface FileServerEvents {
|
||||||
|
received: [info: { fileId: string; name: string; size: number; mime: string; savedPath: string; hash: string; fromAddr: string }]
|
||||||
|
progress: [info: { fileId: string; sent: number; total: number; fromAddr: string }]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单文件大小上限, 默认 100GB (LAN 局域网传大视频常见, 不限太死)
|
||||||
|
// 覆盖方式: 环境变量 LNM_MAX_FILE_SIZE=<bytes>
|
||||||
|
function resolveMaxSize(): number {
|
||||||
|
const fromEnv = process.env.LNM_MAX_FILE_SIZE
|
||||||
|
if (fromEnv) {
|
||||||
|
const n = Number(fromEnv)
|
||||||
|
if (Number.isFinite(n) && n > 0) return n
|
||||||
|
}
|
||||||
|
return 100 * 1024 * 1024 * 1024 // 100GB
|
||||||
|
}
|
||||||
|
const MAX_SIZE = resolveMaxSize()
|
||||||
|
|
||||||
|
function fmtSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`
|
||||||
|
const units = ['KB', 'MB', 'GB', 'TB']
|
||||||
|
let n = bytes / 1024, i = 0
|
||||||
|
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++ }
|
||||||
|
return `${n.toFixed(n < 10 ? 1 : 0)} ${units[i]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTP header 必须是 ASCII; 上传前 encodeURIComponent, 这里安全解码 (兼容老版本未编码的纯 ASCII 头)
|
||||||
|
function safeDecode(s: string | undefined): string {
|
||||||
|
if (!s) return ''
|
||||||
|
try {
|
||||||
|
const decoded = decodeURIComponent(s)
|
||||||
|
// decode 后含 % 说明确实是编码过; 否则是裸 ASCII
|
||||||
|
return decoded
|
||||||
|
} catch {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 流式计算 SHA-256 hex (上传时, 一边传一边 hash, 不预读阻塞)
|
||||||
|
function sha256Buf(buf: Buffer): string {
|
||||||
|
return createHash('sha256').update(buf).digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FileServer extends EventEmitter {
|
||||||
|
private server = createServer((req, res) => this.handle(req, res))
|
||||||
|
private port: number
|
||||||
|
private root: string
|
||||||
|
|
||||||
|
constructor(port: number, root: string) {
|
||||||
|
super()
|
||||||
|
this.port = port
|
||||||
|
this.root = root
|
||||||
|
}
|
||||||
|
|
||||||
|
start(): Promise<number> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.server.once('error', reject)
|
||||||
|
this.server.listen(this.port, '0.0.0.0', () => {
|
||||||
|
const addr = this.server.address()
|
||||||
|
const p = typeof addr === 'object' && addr ? addr.port : this.port
|
||||||
|
this.port = p
|
||||||
|
resolve(p)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文件落地路径: <root>/YYYY-MM/<原名>, 重名时在扩展名前加 _1, _2, ...
|
||||||
|
private targetPath(name: string): string {
|
||||||
|
const d = new Date()
|
||||||
|
const sub = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
||||||
|
const dir = join(this.root, sub)
|
||||||
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
||||||
|
|
||||||
|
// 清洗非法字符 + 截断
|
||||||
|
const safe = name.replace(/[\\/:*?"<>|\x00-\x1f]/g, '_').slice(0, 200)
|
||||||
|
|
||||||
|
// 分离 stem / ext (按第一个 .)
|
||||||
|
// archive.tar.gz -> stem="archive" ext=".tar.gz" -> archive_1.tar.gz
|
||||||
|
// .bashrc -> stem=".bashrc" ext="" -> .bashrc_1
|
||||||
|
// README -> stem="README" ext="" -> README_1
|
||||||
|
const dotIdx = safe.indexOf('.')
|
||||||
|
const stem = dotIdx > 0 ? safe.slice(0, dotIdx) : safe
|
||||||
|
const ext = dotIdx > 0 ? safe.slice(dotIdx) : ''
|
||||||
|
|
||||||
|
// 冲突自增: base.apk -> base_1.apk -> base_2.apk ...
|
||||||
|
let candidate = safe
|
||||||
|
let n = 1
|
||||||
|
while (existsSync(join(dir, candidate))) {
|
||||||
|
candidate = `${stem}_${n}${ext}`
|
||||||
|
n++
|
||||||
|
if (n > 9999) {
|
||||||
|
// 兜底: 实在太多重名, 加时间戳
|
||||||
|
candidate = `${stem}_${Date.now()}${ext}`
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return join(dir, candidate)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handle(req: IncomingMessage, res: ServerResponse) {
|
||||||
|
try {
|
||||||
|
const url = req.url || '/'
|
||||||
|
if (url === '/health' && req.method === 'GET') {
|
||||||
|
this.json(res, 200, { ok: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (url === '/upload' && req.method === 'POST') {
|
||||||
|
await this.handleUpload(req, res)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (url.startsWith('/file/') && req.method === 'GET') {
|
||||||
|
await this.handleGetFile(url, res)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.json(res, 404, { ok: false, error: 'not found' })
|
||||||
|
} catch (e: any) {
|
||||||
|
this.json(res, 500, { ok: false, error: e?.message || 'server error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private json(res: ServerResponse, code: number, data: any) {
|
||||||
|
res.statusCode = code
|
||||||
|
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
||||||
|
res.setHeader('Access-Control-Allow-Origin', '*')
|
||||||
|
res.end(JSON.stringify(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleUpload(req: IncomingMessage, res: ServerResponse) {
|
||||||
|
const fileId = req.headers['x-file-id']?.toString() || randomUUID()
|
||||||
|
// HTTP header 是 ASCII, 非 ASCII 字符 (中文文件名等) 发送前 encodeURIComponent
|
||||||
|
const name = safeDecode(req.headers['x-file-name']?.toString()) || 'file'
|
||||||
|
const size = Number(req.headers['x-file-size'] || '0') || 0
|
||||||
|
const mime = safeDecode(req.headers['x-file-mime']?.toString()) || 'application/octet-stream'
|
||||||
|
|
||||||
|
if (size > MAX_SIZE) {
|
||||||
|
this.json(res, 413, { ok: false, error: `文件 ${fmtSize(size)} 超过限制 ${fmtSize(MAX_SIZE)}` })
|
||||||
|
req.resume()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 先写 .tmp: 校验通过再 rename 到最终路径, 中途崩了也不会留半拉文件
|
||||||
|
const finalTarget = this.targetPath(name)
|
||||||
|
const tmpDir = join(this.root, '.tmp')
|
||||||
|
await mkdir(tmpDir, { recursive: true })
|
||||||
|
const tmpTarget = join(tmpDir, `${fileId}.part`)
|
||||||
|
|
||||||
|
let written = 0
|
||||||
|
const ws = createWriteStream(tmpTarget)
|
||||||
|
let aborted = false
|
||||||
|
const limit = Math.max(MAX_SIZE, size || 0) // 至少覆盖声明的大小
|
||||||
|
const hash = createHash('sha256')
|
||||||
|
|
||||||
|
await new Promise<void>((resolveP, rejectP) => {
|
||||||
|
const fromAddr = req.socket.remoteAddress || ''
|
||||||
|
let lastEmit = 0
|
||||||
|
req.on('data', (chunk: Buffer) => {
|
||||||
|
written += chunk.length
|
||||||
|
hash.update(chunk)
|
||||||
|
if (written > limit) {
|
||||||
|
aborted = true
|
||||||
|
ws.destroy()
|
||||||
|
req.destroy()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 节流: 每 ~100ms 一次 progress 事件, 避免刷屏
|
||||||
|
const now = Date.now()
|
||||||
|
if (now - lastEmit >= 100) {
|
||||||
|
lastEmit = now
|
||||||
|
this.emit('progress', { fileId, sent: written, total: size || written, fromAddr })
|
||||||
|
}
|
||||||
|
// 手动写, 处理 backpressure
|
||||||
|
if (!ws.write(chunk)) {
|
||||||
|
req.pause()
|
||||||
|
ws.once('drain', () => req.resume())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
req.on('aborted', () => { aborted = true; ws.destroy() })
|
||||||
|
ws.on('error', (e) => { req.destroy(); rejectP(e) })
|
||||||
|
ws.on('finish', () => resolveP())
|
||||||
|
req.on('end', () => {
|
||||||
|
// 强制 emit 一次最终 100% (最后一字节可能因 100ms 节流被吞掉)
|
||||||
|
this.emit('progress', { fileId, sent: written, total: size || written, fromAddr })
|
||||||
|
ws.end()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (aborted) {
|
||||||
|
try { await unlink(tmpTarget) } catch {}
|
||||||
|
this.json(res, 413, { ok: false, error: `文件过大, 已超过 ${fmtSize(limit)}` })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const actualHash = hash.digest('hex')
|
||||||
|
// 不预校验: TCP 已经保证传输无误, 接收端的 hash 跟随响应回 sender, sender 自比对
|
||||||
|
|
||||||
|
// 校验通过: 搬到最终位置
|
||||||
|
try {
|
||||||
|
await mkdir(resolve(finalTarget, '..'), { recursive: true })
|
||||||
|
await rename(tmpTarget, finalTarget)
|
||||||
|
} catch (e: any) {
|
||||||
|
try { await unlink(tmpTarget) } catch {}
|
||||||
|
this.json(res, 500, { ok: false, error: `保存失败: ${e.message}` })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit('received', {
|
||||||
|
fileId,
|
||||||
|
name,
|
||||||
|
size: written,
|
||||||
|
mime,
|
||||||
|
savedPath: finalTarget,
|
||||||
|
hash: actualHash,
|
||||||
|
fromAddr: req.socket.remoteAddress || '',
|
||||||
|
})
|
||||||
|
|
||||||
|
this.json(res, 200, {
|
||||||
|
ok: true,
|
||||||
|
fileId,
|
||||||
|
savedPath: finalTarget,
|
||||||
|
size: written,
|
||||||
|
hash: actualHash,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleGetFile(url: string, res: ServerResponse) {
|
||||||
|
// /file/?path=<绝对路径> (从 savedPath 直接拿)
|
||||||
|
const qIdx = url.indexOf('?')
|
||||||
|
if (qIdx < 0) {
|
||||||
|
this.json(res, 400, { ok: false, error: 'missing path query' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const params = new URLSearchParams(url.slice(qIdx + 1))
|
||||||
|
const p = params.get('path')
|
||||||
|
if (!p) {
|
||||||
|
this.json(res, 400, { ok: false, error: 'missing path' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 安全: 必须是 root 下的路径
|
||||||
|
const resolved = resolve(p)
|
||||||
|
const rootResolved = resolve(this.root)
|
||||||
|
if (!resolved.startsWith(rootResolved)) {
|
||||||
|
this.json(res, 403, { ok: false, error: 'forbidden' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!existsSync(resolved)) {
|
||||||
|
this.json(res, 404, { ok: false, error: 'not found' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const st = statSync(resolved)
|
||||||
|
if (!st.isFile()) {
|
||||||
|
this.json(res, 400, { ok: false, error: 'not a file' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res.statusCode = 200
|
||||||
|
res.setHeader('Content-Length', String(st.size))
|
||||||
|
res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(basename(resolved))}"`)
|
||||||
|
res.setHeader('Access-Control-Allow-Origin', '*')
|
||||||
|
createReadStream(resolved).pipe(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop() {
|
||||||
|
await new Promise<void>(r => this.server.close(() => r()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动时清理孤立 .tmp (进程崩 / 断电后留下的, 24h 前的)
|
||||||
|
async cleanupStaleTmp() {
|
||||||
|
const tmpDir = join(this.root, '.tmp')
|
||||||
|
if (!existsSync(tmpDir)) return
|
||||||
|
let cleaned = 0
|
||||||
|
const cutoff = Date.now() - 24 * 3600_000
|
||||||
|
try {
|
||||||
|
const { readdir } = await import('node:fs/promises')
|
||||||
|
const files = await readdir(tmpDir)
|
||||||
|
for (const f of files) {
|
||||||
|
if (!f.endsWith('.part')) continue
|
||||||
|
const full = join(tmpDir, f)
|
||||||
|
try {
|
||||||
|
const st = await stat(full)
|
||||||
|
if (st.mtimeMs < cutoff) {
|
||||||
|
await unlink(full)
|
||||||
|
cleaned++
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
if (cleaned > 0) console.log(`[file-server] cleaned ${cleaned} stale .tmp files`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========= 客户端 (本机向对端发送文件) =========
|
||||||
|
|
||||||
|
import { request as httpRequest } from 'node:http'
|
||||||
|
|
||||||
|
export interface UploadOptions {
|
||||||
|
host: string
|
||||||
|
port: number
|
||||||
|
fileId: string
|
||||||
|
name: string
|
||||||
|
size: number
|
||||||
|
mime: string
|
||||||
|
filePath: string
|
||||||
|
onProgress?: (sent: number, total: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uploadFile(opts: UploadOptions): Promise<{ ok: boolean; savedPath?: string; size?: number; error?: string; hash?: string; receiverHash?: string }> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
// 不再预读算 hash - 直接开上传, 边发边 hash; 接收端也边写边 hash
|
||||||
|
// 完整性"对账"在响应里: receiver 返回它算出的 hash, sender 比对
|
||||||
|
const senderHash = createHash('sha256')
|
||||||
|
let lastProgressEmit = 0
|
||||||
|
let timedOut = false
|
||||||
|
const req = httpRequest({
|
||||||
|
host: opts.host,
|
||||||
|
port: opts.port,
|
||||||
|
method: 'POST',
|
||||||
|
path: '/upload',
|
||||||
|
// socket 闲置 X 秒就重置 (LAN 正常情况不会闲置), 防止对端崩了我们这边永远挂着
|
||||||
|
timeout: 60_000,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/octet-stream',
|
||||||
|
'Content-Length': String(opts.size),
|
||||||
|
'X-File-Id': opts.fileId,
|
||||||
|
'X-File-Name': encodeURIComponent(opts.name),
|
||||||
|
'X-File-Size': String(opts.size),
|
||||||
|
'X-File-Mime': encodeURIComponent(opts.mime),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
req.on('timeout', () => {
|
||||||
|
timedOut = true
|
||||||
|
req.destroy(new Error('socket idle timeout (60s)'))
|
||||||
|
})
|
||||||
|
req.on('error', (e) => resolve({ ok: false, error: timedOut ? '上传超时, 对端无响应' : e.message }))
|
||||||
|
req.on('response', (res) => {
|
||||||
|
const chunks: Buffer[] = []
|
||||||
|
res.on('data', c => chunks.push(c))
|
||||||
|
res.on('end', () => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
||||||
|
const senderHex = senderHash.digest('hex')
|
||||||
|
resolve({ ...data, hash: senderHex })
|
||||||
|
} catch (e: any) {
|
||||||
|
resolve({ ok: false, error: e.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const stream = createReadStream(opts.filePath)
|
||||||
|
let sent = 0
|
||||||
|
stream.on('data', (chunk: Buffer | string) => {
|
||||||
|
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
||||||
|
senderHash.update(buf)
|
||||||
|
sent += buf.length
|
||||||
|
// 节流: 第一个 chunk 立即发 (让进度条立刻动起来), 之后每 ~80ms 一次
|
||||||
|
const now = Date.now()
|
||||||
|
if (now - lastProgressEmit >= 80 || sent >= opts.size) {
|
||||||
|
lastProgressEmit = now
|
||||||
|
opts.onProgress?.(sent, opts.size)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
stream.on('error', (e) => { req.destroy(); resolve({ ok: false, error: e.message }) })
|
||||||
|
// 强制 emit 最终 100% (最后一字节可能因 80ms 节流被吞掉, 客户端进度卡 99%)
|
||||||
|
stream.on('end', () => opts.onProgress?.(sent, opts.size))
|
||||||
|
stream.pipe(req)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在小文件场景下也可直接发送 Buffer (粘贴的剪贴板图片)
|
||||||
|
export function uploadBuffer(opts: {
|
||||||
|
host: string
|
||||||
|
port: number
|
||||||
|
fileId: string
|
||||||
|
name: string
|
||||||
|
mime: string
|
||||||
|
data: Buffer
|
||||||
|
onProgress?: (sent: number, total: number) => void
|
||||||
|
}): Promise<{ ok: boolean; savedPath?: string; size?: number; error?: string; hash?: string }> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
// 内存里算一次 hash 没问题 (粘贴图片本身就几 MB), 一并放到 header 供 receiver 预校验
|
||||||
|
const fileHash = sha256Buf(opts.data)
|
||||||
|
const req = httpRequest({
|
||||||
|
host: opts.host,
|
||||||
|
port: opts.port,
|
||||||
|
method: 'POST',
|
||||||
|
path: '/upload',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/octet-stream',
|
||||||
|
'Content-Length': String(opts.data.length),
|
||||||
|
'X-File-Id': opts.fileId,
|
||||||
|
'X-File-Name': encodeURIComponent(opts.name),
|
||||||
|
'X-File-Size': String(opts.data.length),
|
||||||
|
'X-File-Mime': encodeURIComponent(opts.mime),
|
||||||
|
'X-File-Hash': fileHash,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
req.on('error', (e) => resolve({ ok: false, error: e.message }))
|
||||||
|
req.on('response', (res) => {
|
||||||
|
const chunks: Buffer[] = []
|
||||||
|
res.on('data', c => chunks.push(c))
|
||||||
|
res.on('end', () => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
||||||
|
resolve({ ...data, hash: fileHash })
|
||||||
|
} catch (e: any) { resolve({ ok: false, error: e.message }) }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
let sent = 0
|
||||||
|
const step = Math.max(1, Math.floor(opts.data.length / 50))
|
||||||
|
const tick = () => { opts.onProgress?.(sent, opts.data.length) }
|
||||||
|
req.write(opts.data, () => { sent = opts.data.length; tick() })
|
||||||
|
// 进度近似: 一次性写入基本瞬间完成, 每 step 触发一次
|
||||||
|
if (opts.onProgress) {
|
||||||
|
const id = setInterval(() => {
|
||||||
|
sent = Math.min(opts.data.length, sent + step)
|
||||||
|
tick()
|
||||||
|
if (sent >= opts.data.length) clearInterval(id)
|
||||||
|
}, 30)
|
||||||
|
req.on('close', () => clearInterval(id))
|
||||||
|
}
|
||||||
|
req.end()
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// 程序内使用的图标
|
||||||
|
// 优先级: 打包资源 resources/icon.png -> 用户数据目录 tray.png -> 程序生成
|
||||||
|
import { deflateSync } from 'node:zlib'
|
||||||
|
import { writeFileSync, existsSync } from 'node:fs'
|
||||||
|
import { nativeImage, app } from 'electron'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
|
||||||
|
function crc32(buf: Buffer): number {
|
||||||
|
let c = 0xffffffff
|
||||||
|
for (let i = 0; i < buf.length; i++) {
|
||||||
|
c ^= buf[i]
|
||||||
|
for (let k = 0; k < 8; k++) c = (c >>> 1) ^ (0xedb88320 & -(c & 1))
|
||||||
|
}
|
||||||
|
return (c ^ 0xffffffff) >>> 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function chunk(type: string, data: Buffer): Buffer {
|
||||||
|
const len = Buffer.alloc(4)
|
||||||
|
len.writeUInt32BE(data.length, 0)
|
||||||
|
const t = Buffer.from(type, 'ascii')
|
||||||
|
const crc = Buffer.alloc(4)
|
||||||
|
crc.writeUInt32BE(crc32(Buffer.concat([t, data])), 0)
|
||||||
|
return Buffer.concat([len, t, data, crc])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeSquarePng(size: number, color: [number, number, number, number]): Buffer {
|
||||||
|
const [r, g, b, a] = color
|
||||||
|
const w = size, h = size
|
||||||
|
const raw = Buffer.alloc((w * 4 + 1) * h)
|
||||||
|
const radius = Math.max(2, Math.floor(size * 0.18))
|
||||||
|
for (let y = 0; y < h; y++) {
|
||||||
|
raw[y * (w * 4 + 1)] = 0
|
||||||
|
for (let x = 0; x < w; x++) {
|
||||||
|
let inside = true
|
||||||
|
if ((x < radius && y < radius) ||
|
||||||
|
(x >= w - radius && y < radius) ||
|
||||||
|
(x < radius && y >= h - radius) ||
|
||||||
|
(x >= w - radius && y >= h - radius)) {
|
||||||
|
const cx = x < radius ? radius : w - radius - 0.5
|
||||||
|
const cy = y < radius ? radius : h - radius - 0.5
|
||||||
|
const dx = x - cx, dy = y - cy
|
||||||
|
inside = (dx * dx + dy * dy) <= (radius - 0.5) * (radius - 0.5)
|
||||||
|
}
|
||||||
|
const o = y * (w * 4 + 1) + 1 + x * 4
|
||||||
|
if (inside) {
|
||||||
|
raw[o] = r; raw[o + 1] = g; raw[o + 2] = b; raw[o + 3] = a
|
||||||
|
} else {
|
||||||
|
raw[o] = 0; raw[o + 1] = 0; raw[o + 2] = 0; raw[o + 3] = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const sig = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
||||||
|
const ihdr = Buffer.alloc(13)
|
||||||
|
ihdr.writeUInt32BE(w, 0)
|
||||||
|
ihdr.writeUInt32BE(h, 4)
|
||||||
|
ihdr[8] = 8
|
||||||
|
ihdr[9] = 6
|
||||||
|
ihdr[10] = 0
|
||||||
|
ihdr[11] = 0
|
||||||
|
ihdr[12] = 0
|
||||||
|
const idat = deflateSync(raw)
|
||||||
|
return Buffer.concat([sig, chunk('IHDR', ihdr), chunk('IDAT', idat), chunk('IEND', Buffer.alloc(0))])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureIconFile(path: string, size = 64) {
|
||||||
|
const png = makeSquarePng(size, [51, 112, 255, 255])
|
||||||
|
writeFileSync(path, png)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析资源图标路径:
|
||||||
|
// 1) 打包后: process.resourcesPath/icon.png
|
||||||
|
// 2) 开发态: 项目根/resources/icon.png
|
||||||
|
// 3) 都没有: 程序生成一个
|
||||||
|
export function resolveResourceIcon(): string | null {
|
||||||
|
// 打包后 resources 在 process.resourcesPath
|
||||||
|
const p1 = join(process.resourcesPath || '', 'icon.png')
|
||||||
|
if (existsSync(p1)) return p1
|
||||||
|
// 开发态: 项目根
|
||||||
|
const p2 = join(app.getAppPath(), 'resources', 'icon.png')
|
||||||
|
if (existsSync(p2)) return p2
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appIconNativeImage() {
|
||||||
|
const p = resolveResourceIcon()
|
||||||
|
if (p) {
|
||||||
|
const img = nativeImage.createFromPath(p)
|
||||||
|
if (!img.isEmpty()) return img
|
||||||
|
}
|
||||||
|
return nativeImage.createFromBuffer(makeSquarePng(256, [51, 112, 255, 255]))
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
// 主进程入口
|
||||||
|
import { app, dialog, BrowserWindow } from 'electron'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { existsSync, writeFileSync, readFileSync } from 'node:fs'
|
||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
|
import { hostname, platform } from 'node:os'
|
||||||
|
|
||||||
|
import { paths } from './paths'
|
||||||
|
import { getSettings, ensureDownloadDir } from './settings'
|
||||||
|
import { ensureIconFile } from './icons'
|
||||||
|
import { createMainWindow, broadcastToRenderer } from './window'
|
||||||
|
import { createTray } from './tray'
|
||||||
|
import { Discovery } from './discovery'
|
||||||
|
import { ChatServer } from './chat-server'
|
||||||
|
import { ChatClient } from './chat-client'
|
||||||
|
import { FileServer } from './file-server'
|
||||||
|
import { registerIpc, bindNetworkContext } from './ipc'
|
||||||
|
import { listDevices, totalUnread, getUnread } from './db'
|
||||||
|
import { setBadgeCount } from './notify'
|
||||||
|
import { DEFAULT_PORTS, PROTOCOL_VERSION } from './protocol'
|
||||||
|
import type { DeviceInfo } from './protocol'
|
||||||
|
|
||||||
|
const gotLock = app.requestSingleInstanceLock()
|
||||||
|
if (!gotLock) {
|
||||||
|
app.quit()
|
||||||
|
} else {
|
||||||
|
;(global as any).__quitting = false
|
||||||
|
app.on('second-instance', () => {
|
||||||
|
const w = BrowserWindow.getAllWindows()[0]
|
||||||
|
if (w) { if (w.isMinimized()) w.restore(); w.show(); w.focus() }
|
||||||
|
})
|
||||||
|
|
||||||
|
app.whenReady().then(main).catch((e) => {
|
||||||
|
console.error('startup error', e)
|
||||||
|
dialog.showErrorBox('启动失败', String(e?.stack || e))
|
||||||
|
app.quit()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
app.on('window-all-closed', () => {
|
||||||
|
if (!(global as any).__quitting) {
|
||||||
|
// 不退出, 隐藏到托盘
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.on('before-quit', () => { (global as any).__quitting = true })
|
||||||
|
app.on('will-quit', async () => { /* 网络资源由各模块 stop */ })
|
||||||
|
|
||||||
|
let stopFns: Array<() => Promise<void> | void> = []
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
ensureIconFile(paths.iconPng, 32)
|
||||||
|
ensureDownloadDir()
|
||||||
|
setBadgeCount(totalUnread())
|
||||||
|
|
||||||
|
const self = buildSelf()
|
||||||
|
const discovery = new Discovery(self)
|
||||||
|
const chatServer = new ChatServer(DEFAULT_PORTS.chat)
|
||||||
|
const chatClient = new ChatClient(self)
|
||||||
|
const fileServer = new FileServer(DEFAULT_PORTS.file, ensureDownloadDir())
|
||||||
|
|
||||||
|
const chatPort = await chatServer.start()
|
||||||
|
const filePort = await fileServer.start()
|
||||||
|
self.chatPort = chatPort
|
||||||
|
self.filePort = filePort
|
||||||
|
// 后台清理 24h+ 残留 .tmp (不阻塞启动)
|
||||||
|
fileServer.cleanupStaleTmp().catch(() => {})
|
||||||
|
stopFns.push(() => chatServer.stop(), () => chatClient.stop(), () => discovery.stop(), () => fileServer.stop())
|
||||||
|
|
||||||
|
await discovery.start()
|
||||||
|
bindNetworkContext({ self, discovery, chatServer, chatClient, fileServer })
|
||||||
|
|
||||||
|
// 已入库的设备: 启动后主动尝试连接
|
||||||
|
for (const d of listDevices()) {
|
||||||
|
if (d.device_id === self.deviceId) continue
|
||||||
|
chatClient.connectTo({
|
||||||
|
deviceId: d.device_id,
|
||||||
|
name: d.name,
|
||||||
|
hostname: d.hostname || '',
|
||||||
|
platform: d.platform || '',
|
||||||
|
appVersion: d.app_version || '',
|
||||||
|
address: d.last_ip || '127.0.0.1',
|
||||||
|
chatPort: DEFAULT_PORTS.chat,
|
||||||
|
filePort: DEFAULT_PORTS.file,
|
||||||
|
version: PROTOCOL_VERSION,
|
||||||
|
ts: d.last_seen,
|
||||||
|
} as DeviceInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
createMainWindow()
|
||||||
|
createTray()
|
||||||
|
registerIpc()
|
||||||
|
|
||||||
|
setTimeout(pushInitialState, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSelf(): DeviceInfo {
|
||||||
|
const idFile = join(paths.appData, 'device.id')
|
||||||
|
let id: string
|
||||||
|
if (existsSync(idFile)) id = readFileSync(idFile, 'utf8').trim()
|
||||||
|
else { id = randomUUID(); writeFileSync(idFile, id, 'utf8') }
|
||||||
|
const s = getSettings()
|
||||||
|
return {
|
||||||
|
deviceId: id,
|
||||||
|
name: s.deviceName || hostname() || 'User',
|
||||||
|
hostname: hostname() || '',
|
||||||
|
platform: platform(),
|
||||||
|
version: PROTOCOL_VERSION,
|
||||||
|
appVersion: app.getVersion(),
|
||||||
|
address: '0.0.0.0',
|
||||||
|
chatPort: 0,
|
||||||
|
filePort: 0,
|
||||||
|
ts: Date.now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushInitialState() {
|
||||||
|
broadcastToRenderer('boot:ready', {
|
||||||
|
settings: getSettings(),
|
||||||
|
unread: getUnread(),
|
||||||
|
})
|
||||||
|
}
|
||||||
+805
@@ -0,0 +1,805 @@
|
|||||||
|
// IPC 处理: 渲染进程 -> 主进程 的所有调用
|
||||||
|
import { ipcMain, dialog, shell, BrowserWindow, app } from 'electron'
|
||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
|
import { existsSync, statSync, readFileSync } from 'node:fs'
|
||||||
|
import { basename } from 'node:path'
|
||||||
|
import { promises as fsp } from 'node:fs'
|
||||||
|
|
||||||
|
import { getSettings, updateSettings, ensureDownloadDir } from './settings'
|
||||||
|
import {
|
||||||
|
db, upsertDevice, listDevices, listMessages, insertMessage, getMessage,
|
||||||
|
updateMessageStatus, updateMessageBody, deleteMessage, conversationKey, incrUnread, clearUnread,
|
||||||
|
getUnread, totalUnread, listPendingMessages, listAllPending,
|
||||||
|
setDeviceIgnored, listIgnoredDevices, deleteConversation
|
||||||
|
} from './db'
|
||||||
|
import { Discovery, listNetInterfaces, type NetInterface } from './discovery'
|
||||||
|
import { ChatServer } from './chat-server'
|
||||||
|
import { ChatClient } from './chat-client'
|
||||||
|
import { FileServer, uploadFile, uploadBuffer } from './file-server'
|
||||||
|
import { broadcastToRenderer, focusMainWindow } from './window'
|
||||||
|
import { rebuildMenu } from './tray'
|
||||||
|
import { notify, setBadgeCount } from './notify'
|
||||||
|
import type { DeviceInfo, MessageEnvelope, MessageBody, ImageMessage, FileMessage, TextMessage, SystemMessage } from './protocol'
|
||||||
|
|
||||||
|
interface NetCtx {
|
||||||
|
self: DeviceInfo
|
||||||
|
discovery: Discovery
|
||||||
|
chatServer: ChatServer
|
||||||
|
chatClient: ChatClient
|
||||||
|
fileServer: FileServer
|
||||||
|
}
|
||||||
|
|
||||||
|
let ctx: NetCtx | null = null
|
||||||
|
|
||||||
|
// 接收端: 收到的 WS metadata 帧但还没拿到文件本体, 启动 N 秒期待 timer
|
||||||
|
// 任一进度事件或 received 完成事件取消; 到了 -> mark failed 并删消息, 避免气泡永远 "receiving"
|
||||||
|
const PENDING_FILE_TIMEOUT_MS = 60_000
|
||||||
|
const pendingMetaTimers = new Map<string, { timer: NodeJS.Timeout; messageId: string; fromDeviceId: string }>()
|
||||||
|
|
||||||
|
function armPendingMetaTimer(fileId: string, messageId: string, fromDeviceId: string) {
|
||||||
|
disarmPendingMetaTimer(fileId)
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
pendingMetaTimers.delete(fileId)
|
||||||
|
console.warn(`[ipc] file ${fileId} timeout: metadata arrived but no upload in ${PENDING_FILE_TIMEOUT_MS / 1000}s, marking failed`)
|
||||||
|
try { deleteMessage(messageId) } catch {}
|
||||||
|
broadcastToRenderer('message:recalled', { messageId, conversationKey: '' })
|
||||||
|
// 也告诉 sender: 这文件永远来不了 (如果是 sender 在线, 它能 mark failed)
|
||||||
|
ctx?.chatClient.send({ type: 'fileFailed', fileId, messageId, reason: 'timeout' } as any, fromDeviceId)
|
||||||
|
}, PENDING_FILE_TIMEOUT_MS)
|
||||||
|
pendingMetaTimers.set(fileId, { timer, messageId, fromDeviceId })
|
||||||
|
}
|
||||||
|
|
||||||
|
function disarmPendingMetaTimer(fileId: string) {
|
||||||
|
const t = pendingMetaTimers.get(fileId)
|
||||||
|
if (t) {
|
||||||
|
clearTimeout(t.timer)
|
||||||
|
pendingMetaTimers.delete(fileId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bindNetworkContext(c: NetCtx) {
|
||||||
|
ctx = c
|
||||||
|
// 网络事件 -> 渲染进程
|
||||||
|
c.discovery.on('found', (d) => {
|
||||||
|
upsertDevice({
|
||||||
|
device_id: d.deviceId, name: d.name, hostname: d.hostname, platform: d.platform,
|
||||||
|
app_version: d.appVersion, last_ip: d.address, last_seen: Date.now(), ignored: 0
|
||||||
|
})
|
||||||
|
broadcastToRenderer('device:found', d)
|
||||||
|
c.chatClient.connectTo(d)
|
||||||
|
})
|
||||||
|
c.discovery.on('updated', (d: DeviceInfo) => {
|
||||||
|
upsertDevice({
|
||||||
|
device_id: d.deviceId, name: d.name, hostname: d.hostname, platform: d.platform,
|
||||||
|
app_version: d.appVersion, last_ip: d.address, last_seen: Date.now(), ignored: 0
|
||||||
|
})
|
||||||
|
broadcastToRenderer('device:updated', d)
|
||||||
|
c.chatClient.connectTo(d)
|
||||||
|
})
|
||||||
|
c.discovery.on('lost', (id) => {
|
||||||
|
broadcastToRenderer('device:lost', { deviceId: id })
|
||||||
|
})
|
||||||
|
|
||||||
|
c.chatServer.on('message', (from, env) => {
|
||||||
|
// 同一 messageId 可能来 2 次:
|
||||||
|
// 1) 元数据帧 (savedPath=null, 文件正在被上传) -> 气泡先占位
|
||||||
|
// 2) 完成帧 (savedPath 已填, 上传校验通过) -> 气泡出"打开/位置"
|
||||||
|
// 用 upsert 处理: 已存在就更新 body, 不存在就插入
|
||||||
|
const existing = getMessage(env.messageId)
|
||||||
|
if (existing) {
|
||||||
|
// 第二次帧: 合并 body (主要是 fill savedPath / hash)
|
||||||
|
updateMessageBody(env.messageId, env.body as any)
|
||||||
|
const merged = { ...JSON.parse(existing.body_json), ...(env.body as any) }
|
||||||
|
// 文件已落盘 -> 接收完成
|
||||||
|
const wasReady = existing.status === 'delivered'
|
||||||
|
if (!wasReady && (env.body as any).savedPath) {
|
||||||
|
updateMessageStatus(env.messageId, 'delivered')
|
||||||
|
incrUnread(env.fromDeviceId)
|
||||||
|
updateBadge()
|
||||||
|
notifyIfNeeded(from, env)
|
||||||
|
}
|
||||||
|
broadcastToRenderer('message:received', { ...env, body: merged, status: wasReady ? existing.status : 'delivered' })
|
||||||
|
} else {
|
||||||
|
// 第一次帧: 新插入
|
||||||
|
const fileOrImage = env.type === 'file' || env.type === 'image'
|
||||||
|
const ready = fileOrImage ? Boolean((env.body as any).savedPath) : true
|
||||||
|
insertMessage({
|
||||||
|
messageId: env.messageId,
|
||||||
|
type: env.type,
|
||||||
|
fromId: env.fromDeviceId,
|
||||||
|
toId: env.toDeviceId,
|
||||||
|
ts: env.ts,
|
||||||
|
body: env.body,
|
||||||
|
status: ready ? 'delivered' : 'receiving',
|
||||||
|
})
|
||||||
|
broadcastToRenderer('message:received', env)
|
||||||
|
// 已落盘 -> 算接收完成: bump unread + notify + ack
|
||||||
|
if (ready) {
|
||||||
|
incrUnread(env.fromDeviceId)
|
||||||
|
updateBadge()
|
||||||
|
notifyIfNeeded(from, env)
|
||||||
|
} else {
|
||||||
|
// 元数据先到, 文件本体还没到 -> 期待 timer
|
||||||
|
const fid = (env.body as any)?.fileId
|
||||||
|
if (fid) armPendingMetaTimer(fid, env.messageId, env.fromDeviceId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ACK 只在 ready 时回 (waiting 没意义, 还会让 sender 把状态过早升级 delivered)
|
||||||
|
const ready = env.type === 'text' || env.type === 'system' || Boolean((env.body as any).savedPath)
|
||||||
|
if (ready) {
|
||||||
|
ctx!.chatClient.send(
|
||||||
|
{ type: 'ack', messageId: env.messageId, status: 'delivered' },
|
||||||
|
from.deviceId
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// 还在传 -> 回个 "received" 给 sender, 让它把状态从 sending -> sent
|
||||||
|
ctx!.chatClient.send(
|
||||||
|
{ type: 'ack', messageId: env.messageId, status: 'sent' },
|
||||||
|
from.deviceId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// chat-client 收到的所有 ws 帧 (含 ack / hello / 未来的 read 等)
|
||||||
|
c.chatClient.on('message', (peer, frame) => {
|
||||||
|
if (frame.type === 'ack') {
|
||||||
|
const messageId = frame.messageId
|
||||||
|
if (!messageId) return
|
||||||
|
const row = getMessage(messageId)
|
||||||
|
if (!row) return
|
||||||
|
if (row.from_id !== ctx!.self.deviceId) return
|
||||||
|
if (row.to_id !== peer.deviceId) return
|
||||||
|
if (row.status === 'delivered' || row.status === 'read') return
|
||||||
|
let nextStatus: string
|
||||||
|
if (frame.status === 'read') nextStatus = 'read'
|
||||||
|
else if (frame.status === 'sent') nextStatus = 'sent'
|
||||||
|
else nextStatus = 'delivered'
|
||||||
|
updateMessageStatus(messageId, nextStatus)
|
||||||
|
broadcastToRenderer('message:statusChanged', { messageId, status: nextStatus })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
c.chatServer.on('connect', (_ws, peer) => {
|
||||||
|
broadcastToRenderer('device:online', { deviceId: peer.deviceId, address: peer.address })
|
||||||
|
// 对端上线 -> 触发离线消息 flush
|
||||||
|
flushPendingFor(peer.deviceId)
|
||||||
|
})
|
||||||
|
|
||||||
|
// chat-client WS 状态不广播给渲染层, 渲染层只看 discovery 的 online/offline
|
||||||
|
// 重连由 chat-client 后台 backoff 自动处理, 对用户透明
|
||||||
|
// 但 error 事件必须挂监听, 否则 Node.js 抛 ERR_UNHANDLED_ERROR 杀进程
|
||||||
|
c.chatClient.on('error', (peer, err) => {
|
||||||
|
console.warn(`[chat-client] ws error to ${peer.name} (${peer.address}:${peer.chatPort}): ${err.message}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
c.chatServer.on('disconnect', (id) => {
|
||||||
|
broadcastToRenderer('device:offline', { deviceId: id })
|
||||||
|
})
|
||||||
|
|
||||||
|
c.chatServer.on('typing', (from) => {
|
||||||
|
broadcastToRenderer('message:typing', { fromDeviceId: from.deviceId })
|
||||||
|
})
|
||||||
|
|
||||||
|
// 接收端 HTTP 上传进度 -> 广播给本机 renderer (接收方的气泡显示进度)
|
||||||
|
c.fileServer.on('progress', (info: { fileId: string; sent: number; total: number; fromAddr: string }) => {
|
||||||
|
// 任何进度都说明文件确实在传, 取消期待 timer
|
||||||
|
disarmPendingMetaTimer(info.fileId)
|
||||||
|
broadcastToRenderer('message:progress', { toDeviceId: '', fileId: info.fileId, sent: info.sent, total: info.total, direction: 'recv' })
|
||||||
|
})
|
||||||
|
c.fileServer.on('received', (info: { fileId: string; name: string; size: number; mime: string; savedPath: string; hash: string; fromAddr: string }) => {
|
||||||
|
disarmPendingMetaTimer(info.fileId)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyIfNeeded(from: DeviceInfo, env: MessageEnvelope) {
|
||||||
|
if (env.type === 'system') return
|
||||||
|
if (env.fromDeviceId === ctx?.self.deviceId) return
|
||||||
|
const w = BrowserWindow.getAllWindows()[0]
|
||||||
|
if (w && w.isFocused()) return
|
||||||
|
let title = from.name
|
||||||
|
let body = ''
|
||||||
|
if (env.type === 'text') body = (env.body as TextMessage).content.slice(0, 80)
|
||||||
|
else if (env.type === 'image') body = '[图片]'
|
||||||
|
else if (env.type === 'file') body = `[文件] ${(env.body as FileMessage).name}`
|
||||||
|
if (!body) return
|
||||||
|
notify({
|
||||||
|
title, body,
|
||||||
|
onClick: () => { focusMainWindow(); broadcastToRenderer('message:focus', { fromDeviceId: from.deviceId }) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBadge() {
|
||||||
|
setBadgeCount(totalUnread())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 业务动作 =====
|
||||||
|
|
||||||
|
async function deliverAndStore(env: MessageEnvelope): Promise<{ ok: boolean; reason?: string; status?: string }> {
|
||||||
|
if (!ctx) return { ok: false, reason: 'no ctx' }
|
||||||
|
// 1. 落库 (先按 pending 入库, 发送成功改 sent)
|
||||||
|
insertMessage({
|
||||||
|
messageId: env.messageId,
|
||||||
|
type: env.type,
|
||||||
|
fromId: env.fromDeviceId,
|
||||||
|
toId: env.toDeviceId,
|
||||||
|
ts: env.ts,
|
||||||
|
body: env.body,
|
||||||
|
status: 'pending',
|
||||||
|
})
|
||||||
|
// 2. 通过 ws 发送
|
||||||
|
const sent = ctx.chatClient.send({ type: 'message', payload: env, receivedAt: Date.now() }, env.toDeviceId)
|
||||||
|
if (!sent) {
|
||||||
|
// 对方不在线, 保持 pending, 等 flush
|
||||||
|
return { ok: false, reason: 'peer offline', status: 'pending' }
|
||||||
|
}
|
||||||
|
// 3. 标记 sent (本地认为已送出; 真实送达需要 ack 协议, 见 deliverAndStoreAck)
|
||||||
|
updateMessageStatus(env.messageId, 'sent')
|
||||||
|
// 注意: 这里不广播 statusChanged. 由 IPC handler 拿到 status 后通过 message:local 一次性发给 renderer,
|
||||||
|
// 避免 race (statusChanged 先到, 消息还没进 store, updateStatus 找不到)
|
||||||
|
return { ok: true, status: 'sent' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对端上线 / 连接建立后, 把缓存的待发消息重试一次
|
||||||
|
async function flushPendingFor(peerId: string) {
|
||||||
|
if (!ctx) return
|
||||||
|
const rows = listPendingMessages(peerId, ctx.self.deviceId)
|
||||||
|
let count = 0
|
||||||
|
// 1) 队列里的 pending 消息 (完全没发出去过的)
|
||||||
|
for (const row of rows) {
|
||||||
|
const body = JSON.parse(row.body_json)
|
||||||
|
const env: MessageEnvelope = {
|
||||||
|
messageId: row.message_id,
|
||||||
|
type: row.type as any,
|
||||||
|
fromDeviceId: row.from_id,
|
||||||
|
toDeviceId: row.to_id,
|
||||||
|
ts: row.ts,
|
||||||
|
body,
|
||||||
|
}
|
||||||
|
const sent = ctx.chatClient.send({ type: 'message', payload: env, receivedAt: Date.now() }, peerId)
|
||||||
|
if (sent) {
|
||||||
|
updateMessageStatus(env.messageId, 'sent')
|
||||||
|
broadcastToRenderer('message:statusChanged', { messageId: env.messageId, status: 'sent' })
|
||||||
|
count++
|
||||||
|
} else {
|
||||||
|
// 仍未连上, 留给下次重试
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 2) 上传完但 WS update 帧没送达的消息 (savedPath 已填, status 还在 'sent')
|
||||||
|
// 实际是 WS metadata 已发, 接收方在等 savedPath; 重连后重发完整 envelope 让他出 "打开/位置"
|
||||||
|
for (const row of rows) {
|
||||||
|
const body = JSON.parse(row.body_json)
|
||||||
|
const fileBody = body as any
|
||||||
|
if ((row.type === 'file' || row.type === 'image') && fileBody.savedPath) {
|
||||||
|
const env: MessageEnvelope = {
|
||||||
|
messageId: row.message_id,
|
||||||
|
type: row.type as any,
|
||||||
|
fromDeviceId: row.from_id,
|
||||||
|
toDeviceId: row.to_id,
|
||||||
|
ts: row.ts,
|
||||||
|
body,
|
||||||
|
}
|
||||||
|
const sentUpdate = ctx.chatClient.send({ type: 'message', payload: env, receivedAt: Date.now() }, peerId)
|
||||||
|
if (sentUpdate) {
|
||||||
|
// 触发 renderer 同步一下 body (保险 — 让对端的 uploaded bubble 也再被 forced update)
|
||||||
|
broadcastToRenderer('message:statusChanged', { messageId: row.message_id, status: 'sent' })
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (count > 0) console.log(`[ipc] flushed ${count} pending/update messages to ${peerId.slice(0, 8)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动时也尝试一次 flush (万一启动时对方刚好就绪)
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!ctx) return
|
||||||
|
for (const row of listAllPending(ctx.self.deviceId)) {
|
||||||
|
if (ctx.chatClient.isOpen(row.to_id)) {
|
||||||
|
flushPendingFor(row.to_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 1500)
|
||||||
|
|
||||||
|
export function registerIpc() {
|
||||||
|
// ---- 设备 / 自身 ----
|
||||||
|
ipcMain.handle('self:info', () => ctx?.self)
|
||||||
|
|
||||||
|
ipcMain.handle('device:list', () => {
|
||||||
|
const rows = listDevices()
|
||||||
|
const online = new Set(ctx?.discovery.getPeers().map(p => p.deviceId) || [])
|
||||||
|
return rows.map(r => ({
|
||||||
|
deviceId: r.device_id,
|
||||||
|
name: r.name,
|
||||||
|
hostname: r.hostname || '',
|
||||||
|
platform: r.platform || '',
|
||||||
|
appVersion: r.app_version || '',
|
||||||
|
address: r.last_ip || '',
|
||||||
|
online: online.has(r.device_id),
|
||||||
|
lastSeen: r.last_seen,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('device:triggerScan', () => {
|
||||||
|
// 触发一次密集广播 (通过重启 discovery timer 的快相)
|
||||||
|
if (!ctx) return false
|
||||||
|
;(ctx.discovery as any).fastPhaseUntil = Date.now() + 3_000
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- 消息 ----
|
||||||
|
ipcMain.handle('message:list', (_e, peerId: string) => {
|
||||||
|
if (!ctx) return []
|
||||||
|
const rows = listMessages(peerId, ctx.self.deviceId, 500)
|
||||||
|
return rows.map(rowToEnvelope)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('message:sendText', async (_e, args: { toDeviceId: string; content: string }) => {
|
||||||
|
if (!ctx) return { ok: false, reason: 'no ctx' }
|
||||||
|
const env: MessageEnvelope = {
|
||||||
|
messageId: randomUUID(),
|
||||||
|
type: 'text',
|
||||||
|
fromDeviceId: ctx.self.deviceId,
|
||||||
|
toDeviceId: args.toDeviceId,
|
||||||
|
ts: Date.now(),
|
||||||
|
body: {
|
||||||
|
type: 'text',
|
||||||
|
messageId: '', ts: 0, fromDeviceId: ctx.self.deviceId, toDeviceId: args.toDeviceId,
|
||||||
|
content: args.content,
|
||||||
|
} as TextMessage,
|
||||||
|
}
|
||||||
|
;(env.body as TextMessage).messageId = env.messageId
|
||||||
|
;(env.body as TextMessage).ts = env.ts
|
||||||
|
;(env.body as TextMessage).fromDeviceId = env.fromDeviceId
|
||||||
|
;(env.body as TextMessage).toDeviceId = env.toDeviceId
|
||||||
|
const r = await deliverAndStore(env)
|
||||||
|
broadcastToRenderer('message:local', { ...env, status: r.status || 'pending' })
|
||||||
|
return r
|
||||||
|
})
|
||||||
|
|
||||||
|
// 上传文件到对端 (从渲染进程拿到本地路径) -> 发送 file/image 类型消息
|
||||||
|
// 流程: 立刻广播气泡 + 发 WS 元数据 (让对端秒显示) -> 后台上传 -> 完事后回填 savedPath + 再发 WS 更新
|
||||||
|
ipcMain.handle('message:sendFile', async (_e, args: {
|
||||||
|
toDeviceId: string
|
||||||
|
localPath: string
|
||||||
|
name?: string
|
||||||
|
mime?: string
|
||||||
|
asImage?: boolean
|
||||||
|
onProgress?: boolean
|
||||||
|
}) => {
|
||||||
|
if (!ctx) return { ok: false, reason: 'no ctx' }
|
||||||
|
const peer = ctx.discovery.getPeers().find(p => p.deviceId === args.toDeviceId)
|
||||||
|
|| listDevices().find(d => d.device_id === args.toDeviceId) as any
|
||||||
|
if (!peer) return { ok: false, reason: 'peer not found' }
|
||||||
|
if (!existsSync(args.localPath)) return { ok: false, reason: 'file not exist' }
|
||||||
|
const stat = statSync(args.localPath)
|
||||||
|
const name = args.name || basename(args.localPath)
|
||||||
|
const mime = args.mime || 'application/octet-stream'
|
||||||
|
const fileId = randomUUID()
|
||||||
|
const messageId = randomUUID()
|
||||||
|
const peerInfo: any = peer
|
||||||
|
|
||||||
|
// 1. 构造初始 envelope (savedPath=null, hash=null) - 气泡先占位, 进度条 0%
|
||||||
|
const baseBody = {
|
||||||
|
type: args.asImage ? 'image' : 'file',
|
||||||
|
messageId, ts: Date.now(),
|
||||||
|
fromDeviceId: ctx.self.deviceId, toDeviceId: args.toDeviceId,
|
||||||
|
fileId, name, size: stat.size, mime,
|
||||||
|
savedPath: null as string | null,
|
||||||
|
hash: null as string | null,
|
||||||
|
localPath: args.localPath, // retry 时复用
|
||||||
|
}
|
||||||
|
const env: MessageEnvelope = {
|
||||||
|
messageId,
|
||||||
|
type: args.asImage ? 'image' : 'file',
|
||||||
|
fromDeviceId: ctx.self.deviceId,
|
||||||
|
toDeviceId: args.toDeviceId,
|
||||||
|
ts: Date.now(),
|
||||||
|
body: baseBody as any,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 落库 + 广播 (sender 气泡立刻出现, status='sending')
|
||||||
|
const initialStatus = ctx.chatClient.isOpen(args.toDeviceId) ? 'sending' : 'pending'
|
||||||
|
insertMessage({
|
||||||
|
messageId, type: env.type, fromId: env.fromDeviceId, toId: env.toDeviceId,
|
||||||
|
ts: env.ts, body: env.body, status: initialStatus,
|
||||||
|
})
|
||||||
|
broadcastToRenderer('message:local', { ...env, status: initialStatus })
|
||||||
|
|
||||||
|
// 3. 如果对端在线, 立刻发 WS metadata (让对端秒显示气泡)
|
||||||
|
if (initialStatus === 'sending') {
|
||||||
|
const sentMeta = ctx.chatClient.send({ type: 'message', payload: env, receivedAt: Date.now() }, args.toDeviceId)
|
||||||
|
if (sentMeta) {
|
||||||
|
updateMessageStatus(messageId, 'sent')
|
||||||
|
broadcastToRenderer('message:statusChanged', { messageId, status: 'sent' })
|
||||||
|
} else {
|
||||||
|
// 一瞬间连不上 -> 退回 pending, 等 flush
|
||||||
|
updateMessageStatus(messageId, 'pending')
|
||||||
|
broadcastToRenderer('message:statusChanged', { messageId, status: 'pending' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 立刻返回 renderer (不等上传, 几百 ms 内必到)
|
||||||
|
const ackP: { ok: boolean; reason?: string; status?: string; messageId: string } = {
|
||||||
|
ok: true, messageId, status: initialStatus,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 后台跑上传 + 完成后回填
|
||||||
|
;(async () => {
|
||||||
|
const host = peerInfo.address || peerInfo.last_ip
|
||||||
|
const uploadResult = await uploadFile({
|
||||||
|
host, port: peerInfo.filePort,
|
||||||
|
fileId, name, size: stat.size, mime,
|
||||||
|
filePath: args.localPath,
|
||||||
|
onProgress: (sent, total) => {
|
||||||
|
broadcastToRenderer('message:progress', { toDeviceId: args.toDeviceId, fileId, sent, total })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!uploadResult.ok) {
|
||||||
|
updateMessageStatus(messageId, 'failed')
|
||||||
|
broadcastToRenderer('message:statusChanged', { messageId, status: 'failed' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 完整性对账: receiver-hash 是 server 返回的 (接收端流式 hash), sender-hash 是 uploadResult.hash
|
||||||
|
// (sender 上传时一起算的). 不匹配 -> TCP 出 bug 了, 极小概率, warn 即可
|
||||||
|
const receiverHash = (uploadResult as any).hash
|
||||||
|
if (receiverHash && uploadResult.hash && receiverHash !== uploadResult.hash) {
|
||||||
|
console.warn(`[sendFile] hash mismatch (sender=${uploadResult.hash.slice(0, 8)}, receiver=${receiverHash.slice(0, 8)})`)
|
||||||
|
}
|
||||||
|
// 回填 savedPath + hash 到消息体 (sender 那边气泡获得"打开/位置")
|
||||||
|
updateMessageBody(messageId, { savedPath: uploadResult.savedPath, hash: uploadResult.hash })
|
||||||
|
const filledEnv: MessageEnvelope = {
|
||||||
|
...env,
|
||||||
|
body: { ...baseBody, savedPath: uploadResult.savedPath, hash: uploadResult.hash } as any,
|
||||||
|
}
|
||||||
|
broadcastToRenderer('message:local', { ...filledEnv, status: 'sent' })
|
||||||
|
// 再发一次 WS, 这次带 savedPath (对端气泡更新, 出现"打开")
|
||||||
|
if (ctx!.chatClient.isOpen(args.toDeviceId)) {
|
||||||
|
ctx!.chatClient.send({ type: 'message', payload: filledEnv, receivedAt: Date.now() }, args.toDeviceId)
|
||||||
|
}
|
||||||
|
// 不用再 broadcast statusChanged: 上一帧的 status='sent' 已经是终态之一, 等 ack 升级 delivered
|
||||||
|
void fileId // suppress unused warning
|
||||||
|
})().catch((e: any) => {
|
||||||
|
console.warn('[sendFile] background upload failed:', e?.message)
|
||||||
|
updateMessageStatus(messageId, 'failed')
|
||||||
|
broadcastToRenderer('message:statusChanged', { messageId, status: 'failed' })
|
||||||
|
})
|
||||||
|
|
||||||
|
return ackP
|
||||||
|
})
|
||||||
|
|
||||||
|
// 上传剪贴板/前端拿到的 Buffer (粘贴图片) — 同样: 先广播气泡, 后台上传
|
||||||
|
ipcMain.handle('message:sendBuffer', async (_e, args: {
|
||||||
|
toDeviceId: string
|
||||||
|
dataBase64: string
|
||||||
|
name: string
|
||||||
|
mime: string
|
||||||
|
}) => {
|
||||||
|
if (!ctx) return { ok: false, reason: 'no ctx' }
|
||||||
|
const peer = ctx.discovery.getPeers().find(p => p.deviceId === args.toDeviceId)
|
||||||
|
|| listDevices().find(d => d.device_id === args.toDeviceId) as any
|
||||||
|
if (!peer) return { ok: false, reason: 'peer not found' }
|
||||||
|
const peerInfo: any = peer
|
||||||
|
const buf = Buffer.from(args.dataBase64, 'base64')
|
||||||
|
const fileId = randomUUID()
|
||||||
|
const messageId = randomUUID()
|
||||||
|
|
||||||
|
const baseBody: ImageMessage = {
|
||||||
|
type: 'image',
|
||||||
|
messageId, ts: Date.now(),
|
||||||
|
fromDeviceId: ctx.self.deviceId, toDeviceId: args.toDeviceId,
|
||||||
|
fileId, name: args.name, size: buf.length, mime: args.mime,
|
||||||
|
thumbDataUrl: buf.length < 200_000 ? `data:${args.mime};base64,${args.dataBase64}` : undefined,
|
||||||
|
dataBase64: args.dataBase64,
|
||||||
|
}
|
||||||
|
const env: MessageEnvelope = {
|
||||||
|
messageId, type: 'image',
|
||||||
|
fromDeviceId: ctx.self.deviceId, toDeviceId: args.toDeviceId,
|
||||||
|
ts: Date.now(),
|
||||||
|
body: baseBody,
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialStatus = ctx.chatClient.isOpen(args.toDeviceId) ? 'sending' : 'pending'
|
||||||
|
insertMessage({
|
||||||
|
messageId, type: env.type, fromId: env.fromDeviceId, toId: env.toDeviceId,
|
||||||
|
ts: env.ts, body: env.body, status: initialStatus,
|
||||||
|
})
|
||||||
|
broadcastToRenderer('message:local', { ...env, status: initialStatus })
|
||||||
|
|
||||||
|
if (initialStatus === 'sending') {
|
||||||
|
const sentMeta = ctx.chatClient.send({ type: 'message', payload: env, receivedAt: Date.now() }, args.toDeviceId)
|
||||||
|
if (sentMeta) {
|
||||||
|
updateMessageStatus(messageId, 'sent')
|
||||||
|
broadcastToRenderer('message:statusChanged', { messageId, status: 'sent' })
|
||||||
|
} else {
|
||||||
|
updateMessageStatus(messageId, 'pending')
|
||||||
|
broadcastToRenderer('message:statusChanged', { messageId, status: 'pending' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ackP: { ok: boolean; reason?: string; status?: string; messageId: string } = {
|
||||||
|
ok: true, messageId, status: initialStatus,
|
||||||
|
}
|
||||||
|
|
||||||
|
;(async () => {
|
||||||
|
const host = peerInfo.address || peerInfo.last_ip
|
||||||
|
const uploadResult = await uploadBuffer({
|
||||||
|
host, port: peerInfo.filePort,
|
||||||
|
fileId, name: args.name, mime: args.mime, data: buf,
|
||||||
|
onProgress: (sent, total) => {
|
||||||
|
broadcastToRenderer('message:progress', { toDeviceId: args.toDeviceId, fileId, sent, total })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!uploadResult.ok) {
|
||||||
|
updateMessageStatus(messageId, 'failed')
|
||||||
|
broadcastToRenderer('message:statusChanged', { messageId, status: 'failed' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updateMessageBody(messageId, { savedPath: uploadResult.savedPath, hash: uploadResult.hash })
|
||||||
|
const filledEnv: MessageEnvelope = {
|
||||||
|
...env,
|
||||||
|
body: { ...baseBody, savedPath: uploadResult.savedPath as any, hash: uploadResult.hash as any },
|
||||||
|
}
|
||||||
|
broadcastToRenderer('message:local', { ...filledEnv, status: 'sent' })
|
||||||
|
if (ctx!.chatClient.isOpen(args.toDeviceId)) {
|
||||||
|
ctx!.chatClient.send({ type: 'message', payload: filledEnv, receivedAt: Date.now() }, args.toDeviceId)
|
||||||
|
}
|
||||||
|
void fileId
|
||||||
|
})().catch((e: any) => {
|
||||||
|
console.warn('[sendBuffer] background upload failed:', e?.message)
|
||||||
|
updateMessageStatus(messageId, 'failed')
|
||||||
|
broadcastToRenderer('message:statusChanged', { messageId, status: 'failed' })
|
||||||
|
})
|
||||||
|
|
||||||
|
return ackP
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('message:recall', async (_e, messageId: string) => {
|
||||||
|
if (!ctx) return { ok: false }
|
||||||
|
const row = getMessage(messageId)
|
||||||
|
if (!row) return { ok: false, reason: 'no message' }
|
||||||
|
if (row.from_id !== ctx.self.deviceId) return { ok: false, reason: 'not your message' }
|
||||||
|
// 删除本地
|
||||||
|
deleteMessage(messageId)
|
||||||
|
// 通知对端
|
||||||
|
ctx.chatClient.send({ type: 'recall', messageId, ts: Date.now() }, row.to_id)
|
||||||
|
broadcastToRenderer('message:recalled', { messageId, conversationKey: row.conversation_key })
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
// 手动触发: 对所有当前已连接的对端 flush 一次 pending
|
||||||
|
ipcMain.handle('message:flushPending', async () => {
|
||||||
|
if (!ctx) return { ok: false, flushed: 0 }
|
||||||
|
let totalFlushed = 0
|
||||||
|
// 收集所有 to_id 集合
|
||||||
|
const targets = new Set<string>()
|
||||||
|
for (const row of listAllPending(ctx.self.deviceId)) targets.add(row.to_id)
|
||||||
|
for (const peerId of targets) {
|
||||||
|
const before = listPendingMessages(peerId, ctx.self.deviceId).length
|
||||||
|
await flushPendingFor(peerId)
|
||||||
|
const after = listPendingMessages(peerId, ctx.self.deviceId).length
|
||||||
|
totalFlushed += before - after
|
||||||
|
}
|
||||||
|
return { ok: true, flushed: totalFlushed }
|
||||||
|
})
|
||||||
|
|
||||||
|
// 手动重发某条消息 (status 任意 -> pending -> 尝试发送)
|
||||||
|
ipcMain.handle('message:retry', async (_e, messageId: string) => {
|
||||||
|
if (!ctx) return { ok: false, reason: 'no ctx' }
|
||||||
|
const row = getMessage(messageId)
|
||||||
|
if (!row) return { ok: false, reason: 'no message' }
|
||||||
|
if (row.from_id !== ctx.self.deviceId) return { ok: false, reason: 'not your message' }
|
||||||
|
const body = JSON.parse(row.body_json)
|
||||||
|
|
||||||
|
const setStatus = (status: string) => {
|
||||||
|
updateMessageStatus(messageId, status)
|
||||||
|
broadcastToRenderer('message:statusChanged', { messageId, status })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查对端连接
|
||||||
|
const peer = ctx.discovery.getPeers().find(p => p.deviceId === row.to_id)
|
||||||
|
if (!peer) {
|
||||||
|
setStatus('pending')
|
||||||
|
return { ok: false, reason: 'peer offline', status: 'pending' }
|
||||||
|
}
|
||||||
|
if (!ctx.chatClient.isOpen(row.to_id)) {
|
||||||
|
setStatus('pending')
|
||||||
|
return { ok: false, reason: '等待连接恢复', status: 'pending' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据类型处理
|
||||||
|
if (row.type === 'text') {
|
||||||
|
setStatus('sending')
|
||||||
|
const env: MessageEnvelope = {
|
||||||
|
messageId: row.message_id,
|
||||||
|
type: 'text',
|
||||||
|
fromDeviceId: row.from_id,
|
||||||
|
toDeviceId: row.to_id,
|
||||||
|
ts: row.ts,
|
||||||
|
body,
|
||||||
|
}
|
||||||
|
const sent = ctx.chatClient.send({ type: 'message', payload: env, receivedAt: Date.now() }, row.to_id)
|
||||||
|
if (sent) {
|
||||||
|
setStatus('sent')
|
||||||
|
return { ok: true, status: 'sent' }
|
||||||
|
}
|
||||||
|
setStatus('pending')
|
||||||
|
return { ok: false, reason: 'send failed', status: 'pending' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.type === 'file' || row.type === 'image') {
|
||||||
|
const localPath = body.localPath as string | undefined
|
||||||
|
const dataBase64 = body.dataBase64 as string | undefined
|
||||||
|
if (!localPath && !dataBase64) {
|
||||||
|
return { ok: false, reason: 'no local file' }
|
||||||
|
}
|
||||||
|
setStatus('sending')
|
||||||
|
const fileId = body.fileId
|
||||||
|
const name = body.name
|
||||||
|
const size = body.size
|
||||||
|
const mime = body.mime
|
||||||
|
let uploadResult
|
||||||
|
if (localPath) {
|
||||||
|
uploadResult = await uploadFile({
|
||||||
|
host: peer.address,
|
||||||
|
port: peer.filePort,
|
||||||
|
fileId, name, size, mime,
|
||||||
|
filePath: localPath,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const buf = Buffer.from(dataBase64!, 'base64')
|
||||||
|
uploadResult = await uploadBuffer({
|
||||||
|
host: peer.address,
|
||||||
|
port: peer.filePort,
|
||||||
|
fileId, name, mime, data: buf,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (!uploadResult.ok) {
|
||||||
|
setStatus('failed')
|
||||||
|
return { ok: false, reason: uploadResult.error || 'upload failed', status: 'failed' }
|
||||||
|
}
|
||||||
|
// 重新组装消息
|
||||||
|
const env: MessageEnvelope = {
|
||||||
|
messageId: row.message_id,
|
||||||
|
type: row.type as any,
|
||||||
|
fromDeviceId: row.from_id,
|
||||||
|
toDeviceId: row.to_id,
|
||||||
|
ts: row.ts,
|
||||||
|
body: { ...body, savedPath: uploadResult.savedPath },
|
||||||
|
}
|
||||||
|
const sent = ctx.chatClient.send({ type: 'message', payload: env, receivedAt: Date.now() }, row.to_id)
|
||||||
|
if (sent) {
|
||||||
|
setStatus('sent')
|
||||||
|
// 持久化最新的 savedPath
|
||||||
|
const newBodyJson = JSON.stringify({ ...body, savedPath: uploadResult.savedPath })
|
||||||
|
db.prepare(`UPDATE messages SET body_json = ? WHERE message_id = ?`).run(newBodyJson, messageId)
|
||||||
|
return { ok: true, status: 'sent' }
|
||||||
|
}
|
||||||
|
setStatus('pending')
|
||||||
|
return { ok: false, reason: 'send failed', status: 'pending' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: false, reason: 'unsupported type' }
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('message:typing', (_e, toDeviceId: string) => {
|
||||||
|
if (!ctx) return
|
||||||
|
ctx.chatClient.send({ type: 'typing', from: ctx.self.deviceId, ts: Date.now() }, toDeviceId)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- 未读 ----
|
||||||
|
ipcMain.handle('unread:list', () => getUnread())
|
||||||
|
ipcMain.handle('unread:clear', (_e, deviceId: string) => {
|
||||||
|
clearUnread(deviceId)
|
||||||
|
updateBadge()
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- 设置 ----
|
||||||
|
ipcMain.handle('settings:get', () => getSettings())
|
||||||
|
ipcMain.handle('settings:set', (_e, patch: any) => {
|
||||||
|
const ns = updateSettings(patch)
|
||||||
|
ensureDownloadDir()
|
||||||
|
if (patch.deviceName) {
|
||||||
|
ctx!.self.name = patch.deviceName
|
||||||
|
// 通知前端并重启 broadcast 频次
|
||||||
|
}
|
||||||
|
rebuildMenu()
|
||||||
|
broadcastToRenderer('settings:changed', ns)
|
||||||
|
return ns
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('settings:chooseDownloadDir', async () => {
|
||||||
|
const w = BrowserWindow.getFocusedWindow() || BrowserWindow.getAllWindows()[0]
|
||||||
|
if (!w) return null
|
||||||
|
const r = await dialog.showOpenDialog(w, { properties: ['openDirectory', 'createDirectory'] })
|
||||||
|
if (r.canceled || !r.filePaths[0]) return null
|
||||||
|
updateSettings({ downloadDir: r.filePaths[0] })
|
||||||
|
ensureDownloadDir()
|
||||||
|
rebuildMenu()
|
||||||
|
broadcastToRenderer('settings:changed', getSettings())
|
||||||
|
return r.filePaths[0]
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- 文件操作 ----
|
||||||
|
ipcMain.handle('file:openInFolder', async (_e, p: string) => {
|
||||||
|
if (!p) return false
|
||||||
|
try {
|
||||||
|
await shell.showItemInFolder(p)
|
||||||
|
return true
|
||||||
|
} catch { return false }
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('file:open', async (_e, p: string) => {
|
||||||
|
if (!p) return false
|
||||||
|
try {
|
||||||
|
const err = await shell.openPath(p)
|
||||||
|
return !err
|
||||||
|
} catch { return false }
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('file:readBase64', async (_e, p: string) => {
|
||||||
|
try {
|
||||||
|
const buf = await fsp.readFile(p)
|
||||||
|
return buf.toString('base64')
|
||||||
|
} catch { return null }
|
||||||
|
})
|
||||||
|
|
||||||
|
// 通过 dialog 让用户选文件 (发送用)
|
||||||
|
ipcMain.handle('file:pick', async (_e, opts?: { image?: boolean }) => {
|
||||||
|
const w = BrowserWindow.getFocusedWindow() || BrowserWindow.getAllWindows()[0]
|
||||||
|
if (!w) return null
|
||||||
|
const r = await dialog.showOpenDialog(w, {
|
||||||
|
properties: ['openFile'],
|
||||||
|
filters: opts?.image
|
||||||
|
? [{ name: '图片', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'] }]
|
||||||
|
: undefined,
|
||||||
|
})
|
||||||
|
if (r.canceled || !r.filePaths[0]) return null
|
||||||
|
const p = r.filePaths[0]
|
||||||
|
const st = statSync(p)
|
||||||
|
return { path: p, name: basename(p), size: st.size }
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- 应用控制 ----
|
||||||
|
ipcMain.handle('app:hide', () => {
|
||||||
|
const w = BrowserWindow.getAllWindows()[0]
|
||||||
|
w?.hide()
|
||||||
|
})
|
||||||
|
ipcMain.handle('app:version', () => app.getVersion())
|
||||||
|
ipcMain.handle('app:platform', () => process.platform)
|
||||||
|
|
||||||
|
// ---- 网络接口 (只读, 全网卡通用) ----
|
||||||
|
ipcMain.handle('network:listInterfaces', (): NetInterface[] => {
|
||||||
|
return listNetInterfaces()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- 开机自启 ----
|
||||||
|
ipcMain.handle('autostart:get', () => {
|
||||||
|
if (!app.isPackaged) return false
|
||||||
|
try { return app.getLoginItemSettings().openAtLogin } catch { return false }
|
||||||
|
})
|
||||||
|
ipcMain.handle('autostart:set', (_e, enabled: boolean) => {
|
||||||
|
if (!app.isPackaged) return false
|
||||||
|
try {
|
||||||
|
app.setLoginItemSettings({
|
||||||
|
openAtLogin: !!enabled,
|
||||||
|
path: process.execPath,
|
||||||
|
})
|
||||||
|
return app.getLoginItemSettings().openAtLogin
|
||||||
|
} catch (e: any) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowToEnvelope(row: any): MessageEnvelope {
|
||||||
|
return {
|
||||||
|
messageId: row.message_id,
|
||||||
|
type: row.type,
|
||||||
|
fromDeviceId: row.from_id,
|
||||||
|
toDeviceId: row.to_id,
|
||||||
|
ts: row.ts,
|
||||||
|
body: JSON.parse(row.body_json),
|
||||||
|
status: row.status,
|
||||||
|
} as any
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// 系统通知 + 任务栏徽标
|
||||||
|
import { Notification, nativeImage, BrowserWindow, app } from 'electron'
|
||||||
|
import { existsSync } from 'node:fs'
|
||||||
|
import { getSettings } from './settings'
|
||||||
|
import { paths } from './paths'
|
||||||
|
import { appIconNativeImage } from './icons'
|
||||||
|
|
||||||
|
export function notify(opts: {
|
||||||
|
title: string
|
||||||
|
body: string
|
||||||
|
silent?: boolean
|
||||||
|
data?: any
|
||||||
|
onClick?: () => void
|
||||||
|
}) {
|
||||||
|
const s = getSettings()
|
||||||
|
if (!s.notifications) return
|
||||||
|
if (!Notification.isSupported()) return
|
||||||
|
const icon = existsSync(paths.iconPng) ? nativeImage.createFromPath(paths.iconPng) : appIconNativeImage()
|
||||||
|
const n = new Notification({
|
||||||
|
title: opts.title,
|
||||||
|
body: opts.body,
|
||||||
|
silent: opts.silent ?? !s.sound,
|
||||||
|
icon,
|
||||||
|
})
|
||||||
|
n.on('click', () => { opts.onClick?.() })
|
||||||
|
n.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setBadgeCount(count: number) {
|
||||||
|
try { app.setBadgeCount(count) } catch {}
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
const wins = BrowserWindow.getAllWindows()
|
||||||
|
if (!wins.length) return
|
||||||
|
const w = wins[0]
|
||||||
|
if (count > 0) {
|
||||||
|
const img = makeBadgeImage(count)
|
||||||
|
try { w.setOverlayIcon(img, `${count} 条未读`) } catch {}
|
||||||
|
} else {
|
||||||
|
try { w.setOverlayIcon(null, '') } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeBadgeImage(count: number): Electron.NativeImage {
|
||||||
|
const text = count > 99 ? '99+' : String(count)
|
||||||
|
const fontSize = text.length >= 3 ? 7 : text.length === 2 ? 9 : 10
|
||||||
|
const svg = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<circle cx="8" cy="8" r="7" fill="#ff3b30" stroke="#ffffff" stroke-width="1"/>
|
||||||
|
<text x="8" y="11" text-anchor="middle" font-family="Arial,sans-serif" font-weight="bold" font-size="${fontSize}" fill="#ffffff">${text}</text>
|
||||||
|
</svg>`
|
||||||
|
return nativeImage.createFromDataURL('data:image/svg+xml;base64,' + Buffer.from(svg).toString('base64'))
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { app } from 'electron'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { existsSync, mkdirSync } from 'node:fs'
|
||||||
|
|
||||||
|
const isDev = !app.isPackaged
|
||||||
|
|
||||||
|
export const paths = {
|
||||||
|
userData: app.getPath('userData'),
|
||||||
|
documents: app.getPath('documents'),
|
||||||
|
downloads: app.getPath('downloads'),
|
||||||
|
// 应用数据根目录
|
||||||
|
appData: (() => {
|
||||||
|
const p = join(app.getPath('userData'), 'data')
|
||||||
|
if (!existsSync(p)) mkdirSync(p, { recursive: true })
|
||||||
|
return p
|
||||||
|
})(),
|
||||||
|
// SQLite 数据库
|
||||||
|
db: (() => {
|
||||||
|
const p = join(app.getPath('userData'), 'data', 'app.db')
|
||||||
|
return p
|
||||||
|
})(),
|
||||||
|
// 托盘图标
|
||||||
|
iconPng: (() => {
|
||||||
|
const p = join(app.getPath('userData'), 'data', 'tray.png')
|
||||||
|
return p
|
||||||
|
})(),
|
||||||
|
// 默认接收目录: ~/Documents/LocalNetMsg
|
||||||
|
defaultDownloadDir: (() => {
|
||||||
|
const p = join(app.getPath('documents'), 'LocalNetMsg')
|
||||||
|
if (!existsSync(p)) mkdirSync(p, { recursive: true })
|
||||||
|
return p
|
||||||
|
})(),
|
||||||
|
}
|
||||||
|
|
||||||
|
export { isDev }
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
// 跨进程共享的消息协议定义
|
||||||
|
// main / preload / renderer 都引用同一份
|
||||||
|
|
||||||
|
export const PROTOCOL_VERSION = 1
|
||||||
|
|
||||||
|
// 局域网发现 / WebSocket / 文件传输 端口 (被占用自动顺延)
|
||||||
|
export const DEFAULT_PORTS = {
|
||||||
|
discovery: 47800,
|
||||||
|
chat: 47900,
|
||||||
|
file: 47901,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
// ============ 设备 ============
|
||||||
|
|
||||||
|
export interface DeviceInfo {
|
||||||
|
deviceId: string // uuid, 每次启动可重新生成
|
||||||
|
name: string // 用户可改
|
||||||
|
hostname: string
|
||||||
|
platform: NodeJS.Platform
|
||||||
|
version: number // 协议版本
|
||||||
|
appVersion: string // 应用版本
|
||||||
|
address: string // IPv4, e.g. 192.168.1.10
|
||||||
|
chatPort: number
|
||||||
|
filePort: number
|
||||||
|
ts: number // 上次心跳时间
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ WebSocket 协议 ============
|
||||||
|
|
||||||
|
export type WsFrame =
|
||||||
|
| { type: 'hello'; from: DeviceInfo }
|
||||||
|
| { type: 'ping'; ts: number }
|
||||||
|
| { type: 'pong'; ts: number }
|
||||||
|
| { type: 'message'; payload: MessageEnvelope; receivedAt: number }
|
||||||
|
| { type: 'ack'; messageId: string; status: 'sent' | 'delivered' | 'read' }
|
||||||
|
| { type: 'recall'; messageId: string; ts: number }
|
||||||
|
| { type: 'typing'; from: string; ts: number }
|
||||||
|
|
||||||
|
// ============ 消息 ============
|
||||||
|
|
||||||
|
export type MessageType = 'text' | 'image' | 'file' | 'system'
|
||||||
|
|
||||||
|
interface BaseMessage {
|
||||||
|
messageId: string
|
||||||
|
type: MessageType
|
||||||
|
fromDeviceId: string
|
||||||
|
toDeviceId: string
|
||||||
|
ts: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TextMessage extends BaseMessage {
|
||||||
|
type: 'text'
|
||||||
|
content: string // Markdown 原文
|
||||||
|
mentions?: Mention[] // @ 提醒
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageMessage extends BaseMessage {
|
||||||
|
type: 'image'
|
||||||
|
fileId: string // 唯一文件 id
|
||||||
|
name: string
|
||||||
|
size: number
|
||||||
|
mime: string
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
thumbDataUrl?: string // 小于 200KB 的内嵌缩略图
|
||||||
|
savedPath?: string // 接收端落地路径 (上传完才有, 否则 undefined 表示还在传)
|
||||||
|
hash?: string // SHA-256 hex
|
||||||
|
dataBase64?: string // 粘贴图片 buffer (供 retry)
|
||||||
|
localPath?: string // 本地源 (供 image-as-file 的 retry)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileMessage extends BaseMessage {
|
||||||
|
type: 'file'
|
||||||
|
fileId: string
|
||||||
|
name: string
|
||||||
|
size: number
|
||||||
|
mime: string
|
||||||
|
savedPath?: string // 接收端落地路径 (上传完才有)
|
||||||
|
hash?: string // SHA-256 hex
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SystemMessage extends BaseMessage {
|
||||||
|
type: 'system'
|
||||||
|
content: string // e.g. "对方已撤回一条消息"
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MessageBody = TextMessage | ImageMessage | FileMessage | SystemMessage
|
||||||
|
|
||||||
|
// 发送时的封装 (在 WebSocket 上是 payload 字段, 落库时 flatten)
|
||||||
|
export interface MessageEnvelope {
|
||||||
|
messageId: string
|
||||||
|
type: MessageType
|
||||||
|
fromDeviceId: string
|
||||||
|
toDeviceId: string
|
||||||
|
ts: number
|
||||||
|
body: MessageBody
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Mention {
|
||||||
|
deviceId: string
|
||||||
|
name: string
|
||||||
|
offset: number
|
||||||
|
length: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 文件传输 (HTTP) ============
|
||||||
|
|
||||||
|
// POST /upload
|
||||||
|
// Headers: X-File-Id, X-File-Name, X-File-Size, X-File-Mime
|
||||||
|
// Body: raw binary
|
||||||
|
export interface FileUploadResponse {
|
||||||
|
ok: boolean
|
||||||
|
fileId: string
|
||||||
|
savedPath: string
|
||||||
|
size: number
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /file/:fileId -> 返回已接收的文件二进制 (供发送方下载自己刚发的预览等)
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import Store from 'electron-store'
|
||||||
|
import { hostname as osHostname } from 'node:os'
|
||||||
|
import { existsSync, mkdirSync } from 'node:fs'
|
||||||
|
import { paths } from './paths'
|
||||||
|
|
||||||
|
export interface Settings {
|
||||||
|
deviceName: string
|
||||||
|
downloadDir: string
|
||||||
|
notifications: boolean
|
||||||
|
sound: boolean
|
||||||
|
autoStart: boolean
|
||||||
|
theme: 'light' | 'dark'
|
||||||
|
/** 用户选择的对外面板/广播里展示的网卡接口名 (空 = 自动取第一个非内部) */
|
||||||
|
preferredInterface: string
|
||||||
|
/** 备选: 用户也可指定精确地址 (避免接口名漂移) */
|
||||||
|
preferredAddress: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const store = new Store<Settings>({
|
||||||
|
name: 'config',
|
||||||
|
cwd: paths.userData,
|
||||||
|
defaults: {
|
||||||
|
deviceName: osHostname() || 'User',
|
||||||
|
downloadDir: paths.defaultDownloadDir,
|
||||||
|
notifications: true,
|
||||||
|
sound: true,
|
||||||
|
autoStart: false,
|
||||||
|
theme: 'light',
|
||||||
|
preferredInterface: '',
|
||||||
|
preferredAddress: '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export function getSettings(): Settings {
|
||||||
|
return store.store
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateSettings(patch: Partial<Settings>): Settings {
|
||||||
|
for (const [k, v] of Object.entries(patch)) {
|
||||||
|
if (v !== undefined) store.set(k as keyof Settings, v)
|
||||||
|
}
|
||||||
|
return store.store
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureDownloadDir(): string {
|
||||||
|
const dir = store.get('downloadDir') || paths.defaultDownloadDir
|
||||||
|
if (!existsSync(dir)) {
|
||||||
|
mkdirSync(dir, { recursive: true })
|
||||||
|
}
|
||||||
|
store.set('downloadDir', dir)
|
||||||
|
return dir
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { Tray, Menu, nativeImage } from 'electron'
|
||||||
|
import { existsSync } from 'node:fs'
|
||||||
|
import { paths } from './paths'
|
||||||
|
import { ensureIconFile, appIconNativeImage, resolveResourceIcon } from './icons'
|
||||||
|
import { toggleMainWindow, focusMainWindow } from './window'
|
||||||
|
import { getSettings } from './settings'
|
||||||
|
|
||||||
|
let tray: Tray | null = null
|
||||||
|
|
||||||
|
export function createTray() {
|
||||||
|
const resourceIcon = resolveResourceIcon()
|
||||||
|
let img
|
||||||
|
if (resourceIcon) {
|
||||||
|
img = nativeImage.createFromPath(resourceIcon)
|
||||||
|
if (img.isEmpty()) img = appIconNativeImage()
|
||||||
|
} else {
|
||||||
|
if (!existsSync(paths.iconPng)) ensureIconFile(paths.iconPng, 32)
|
||||||
|
img = nativeImage.createFromPath(paths.iconPng)
|
||||||
|
}
|
||||||
|
tray = new Tray(img)
|
||||||
|
tray.setToolTip('LocalNetMsg')
|
||||||
|
rebuildMenu()
|
||||||
|
tray.on('click', () => toggleMainWindow())
|
||||||
|
tray.on('double-click', () => focusMainWindow())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rebuildMenu() {
|
||||||
|
if (!tray) return
|
||||||
|
const s = getSettings()
|
||||||
|
const menu = Menu.buildFromTemplate([
|
||||||
|
{ label: `${s.deviceName}`, enabled: false },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ label: '显示主窗口', click: () => focusMainWindow() },
|
||||||
|
{ label: '设置...', click: () => focusMainWindow() },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ label: '退出', click: () => { (global as any).__quitting = true; require('electron').app.quit() } },
|
||||||
|
])
|
||||||
|
tray!.setContextMenu(menu)
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { BrowserWindow, screen } from 'electron'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { isDev } from './paths'
|
||||||
|
import { appIconNativeImage } from './icons'
|
||||||
|
|
||||||
|
let mainWindow: BrowserWindow | null = null
|
||||||
|
|
||||||
|
export function createMainWindow() {
|
||||||
|
const display = screen.getPrimaryDisplay()
|
||||||
|
const w = Math.min(1200, Math.round(display.workAreaSize.width * 0.8))
|
||||||
|
const h = Math.min(820, Math.round(display.workAreaSize.height * 0.85))
|
||||||
|
|
||||||
|
mainWindow = new BrowserWindow({
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
minWidth: 880,
|
||||||
|
minHeight: 600,
|
||||||
|
show: false,
|
||||||
|
title: 'LocalNetMsg',
|
||||||
|
backgroundColor: '#f5f5f7',
|
||||||
|
icon: appIconNativeImage(),
|
||||||
|
autoHideMenuBar: true,
|
||||||
|
webPreferences: {
|
||||||
|
preload: join(__dirname, '../preload/index.js'),
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
sandbox: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isDev && process.env.ELECTRON_RENDERER_URL) {
|
||||||
|
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)
|
||||||
|
} else {
|
||||||
|
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
|
||||||
|
}
|
||||||
|
|
||||||
|
mainWindow.once('ready-to-show', () => mainWindow?.show())
|
||||||
|
|
||||||
|
// 关闭按钮 = 最小化到托盘
|
||||||
|
mainWindow.on('close', (e) => {
|
||||||
|
if (!(global as any).__quitting) {
|
||||||
|
e.preventDefault()
|
||||||
|
mainWindow?.hide()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return mainWindow
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMainWindow(): BrowserWindow | null {
|
||||||
|
return mainWindow
|
||||||
|
}
|
||||||
|
|
||||||
|
export function focusMainWindow() {
|
||||||
|
if (!mainWindow) return
|
||||||
|
if (mainWindow.isMinimized()) mainWindow.restore()
|
||||||
|
if (!mainWindow.isVisible()) mainWindow.show()
|
||||||
|
mainWindow.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleMainWindow() {
|
||||||
|
if (!mainWindow) return
|
||||||
|
if (mainWindow.isVisible() && mainWindow.isFocused()) {
|
||||||
|
mainWindow.hide()
|
||||||
|
} else {
|
||||||
|
focusMainWindow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function broadcastToRenderer(channel: string, payload: any) {
|
||||||
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
|
mainWindow.webContents.send(channel, payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// preload: 把主进程能力通过 contextBridge 暴露给渲染进程
|
||||||
|
import { contextBridge, ipcRenderer, webUtils } from 'electron'
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('api', {
|
||||||
|
platform: process.platform,
|
||||||
|
versions: process.versions,
|
||||||
|
|
||||||
|
// self / 设备
|
||||||
|
self: () => ipcRenderer.invoke('self:info'),
|
||||||
|
listDevices: () => ipcRenderer.invoke('device:list'),
|
||||||
|
triggerScan: () => ipcRenderer.invoke('device:triggerScan'),
|
||||||
|
|
||||||
|
// 消息
|
||||||
|
listMessages: (peerId: string) => ipcRenderer.invoke('message:list', peerId),
|
||||||
|
sendText: (toDeviceId: string, content: string) => ipcRenderer.invoke('message:sendText', { toDeviceId, content }),
|
||||||
|
sendFile: (toDeviceId: string, localPath: string, opts?: { name?: string; mime?: string; asImage?: boolean; withProgress?: boolean }) =>
|
||||||
|
ipcRenderer.invoke('message:sendFile', { toDeviceId, localPath, ...(opts || {}) }),
|
||||||
|
sendBuffer: (toDeviceId: string, dataBase64: string, name: string, mime: string) =>
|
||||||
|
ipcRenderer.invoke('message:sendBuffer', { toDeviceId, dataBase64, name, mime }),
|
||||||
|
recall: (messageId: string) => ipcRenderer.invoke('message:recall', messageId),
|
||||||
|
retry: (messageId: string) => ipcRenderer.invoke('message:retry', messageId),
|
||||||
|
typing: (toDeviceId: string) => ipcRenderer.invoke('message:typing', toDeviceId),
|
||||||
|
|
||||||
|
// 未读
|
||||||
|
unreadList: () => ipcRenderer.invoke('unread:list'),
|
||||||
|
clearUnread: (deviceId: string) => ipcRenderer.invoke('unread:clear', deviceId),
|
||||||
|
|
||||||
|
// 设置
|
||||||
|
getSettings: () => ipcRenderer.invoke('settings:get'),
|
||||||
|
setSettings: (patch: any) => ipcRenderer.invoke('settings:set', patch),
|
||||||
|
chooseDownloadDir: () => ipcRenderer.invoke('settings:chooseDownloadDir'),
|
||||||
|
|
||||||
|
// 文件
|
||||||
|
openInFolder: (p: string) => ipcRenderer.invoke('file:openInFolder', p),
|
||||||
|
open: (p: string) => ipcRenderer.invoke('file:open', p),
|
||||||
|
readBase64: (p: string) => ipcRenderer.invoke('file:readBase64', p),
|
||||||
|
pickFile: (opts?: { image?: boolean }) => ipcRenderer.invoke('file:pick', opts),
|
||||||
|
|
||||||
|
// 拖拽文件 -> 真实磁盘路径 (Electron 32+ 必须用 webUtils.getPathForFile)
|
||||||
|
getPathForFile: (file: File): string => {
|
||||||
|
try { return webUtils.getPathForFile(file) || '' } catch { return '' }
|
||||||
|
},
|
||||||
|
|
||||||
|
// 应用
|
||||||
|
hide: () => ipcRenderer.invoke('app:hide'),
|
||||||
|
appVersion: () => ipcRenderer.invoke('app:version'),
|
||||||
|
|
||||||
|
// 开机自启
|
||||||
|
getAutoStart: () => ipcRenderer.invoke('autostart:get'),
|
||||||
|
setAutoStart: (enabled: boolean) => ipcRenderer.invoke('autostart:set', enabled),
|
||||||
|
|
||||||
|
// 网络接口 (只读)
|
||||||
|
listInterfaces: () => ipcRenderer.invoke('network:listInterfaces'),
|
||||||
|
|
||||||
|
// 事件订阅: 主进程 webContents.send('xxx', payload) 会在此处派发
|
||||||
|
on(channel: string, cb: (payload: any) => void) {
|
||||||
|
const listener = (_e: any, payload: any) => cb(payload)
|
||||||
|
ipcRenderer.on(channel, listener)
|
||||||
|
return () => ipcRenderer.removeListener(channel, listener)
|
||||||
|
},
|
||||||
|
|
||||||
|
// 离线消息: 启动后让主进程把 pending 重新尝试一遍
|
||||||
|
flushPending: () => ipcRenderer.invoke('message:flushPending'),
|
||||||
|
})
|
||||||
Vendored
+11
@@ -0,0 +1,11 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module '*.vue' {
|
||||||
|
import type { DefineComponent } from 'vue'
|
||||||
|
const c: DefineComponent<{}, {}, any>
|
||||||
|
export default c
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Window {
|
||||||
|
api: import('./src/api').LnmApi
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; img-src 'self' data: http://127.0.0.1:* http://localhost:*; connect-src 'self' ws://* http://*;" />
|
||||||
|
<title>LocalNetMsg</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref, computed, watch, nextTick, onUnmounted } from 'vue'
|
||||||
|
import { useDeviceStore } from '@/stores/device'
|
||||||
|
import { useMessageStore } from '@/stores/message'
|
||||||
|
import { useSessionStore } from '@/stores/session'
|
||||||
|
import Sidebar from '@/components/Sidebar.vue'
|
||||||
|
import ChatView from '@/components/ChatView.vue'
|
||||||
|
import SettingsView from '@/components/SettingsView.vue'
|
||||||
|
import { formatTime } from '@/utils/format'
|
||||||
|
import type { DeviceView, MessageView, Settings } from '@/api'
|
||||||
|
|
||||||
|
const device = useDeviceStore()
|
||||||
|
const message = useMessageStore()
|
||||||
|
const session = useSessionStore()
|
||||||
|
|
||||||
|
const showSettings = ref(false)
|
||||||
|
const imageViewer = ref<string | null>(null)
|
||||||
|
|
||||||
|
const activeDevice = computed<DeviceView | null>(() => {
|
||||||
|
if (!session.activePeerId) return null
|
||||||
|
return device.devices.find(d => d.deviceId === session.activePeerId) || null
|
||||||
|
})
|
||||||
|
|
||||||
|
function showToast(text: string) {
|
||||||
|
const el = document.createElement('div')
|
||||||
|
el.className = 'toast'
|
||||||
|
el.textContent = text
|
||||||
|
document.body.appendChild(el)
|
||||||
|
setTimeout(() => el.remove(), 2200)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await device.loadSelf()
|
||||||
|
if (device.self) message.setSelf(device.self.deviceId)
|
||||||
|
await device.refresh()
|
||||||
|
// 启动后尝试一次 flush pending
|
||||||
|
setTimeout(() => { window.api.flushPending() }, 1500)
|
||||||
|
|
||||||
|
window.api.on('boot:ready', () => { device.refresh() })
|
||||||
|
window.api.on('device:found', (d: DeviceView) => {
|
||||||
|
const idx = device.devices.findIndex(x => x.deviceId === d.deviceId)
|
||||||
|
const merged: DeviceView = { ...(device.devices[idx] || ({} as DeviceView)), ...d, online: true, lastSeen: Date.now() }
|
||||||
|
if (idx >= 0) device.devices[idx] = merged
|
||||||
|
else device.devices.push(merged)
|
||||||
|
})
|
||||||
|
window.api.on('device:updated', (d: DeviceView) => {
|
||||||
|
const idx = device.devices.findIndex(x => x.deviceId === d.deviceId)
|
||||||
|
if (idx >= 0) device.devices[idx] = { ...device.devices[idx], ...d, online: true, lastSeen: Date.now() }
|
||||||
|
})
|
||||||
|
window.api.on('device:lost', ({ deviceId }: { deviceId: string }) => {
|
||||||
|
const d = device.devices.find(x => x.deviceId === deviceId)
|
||||||
|
if (d) d.online = false
|
||||||
|
})
|
||||||
|
window.api.on('device:online', ({ deviceId }: { deviceId: string }) => {
|
||||||
|
const d = device.devices.find(x => x.deviceId === deviceId)
|
||||||
|
if (d) d.online = true
|
||||||
|
})
|
||||||
|
window.api.on('device:offline', ({ deviceId }: { deviceId: string }) => {
|
||||||
|
const d = device.devices.find(x => x.deviceId === deviceId)
|
||||||
|
if (d) d.online = false
|
||||||
|
})
|
||||||
|
window.api.on('message:received', (env: MessageView) => {
|
||||||
|
const fromId = env.fromDeviceId
|
||||||
|
// 接收也用 upsert: WS metadata 帧 + (后台上传完后) WS update 帧, 同 messageId 来两次
|
||||||
|
// 第二次带 savedPath, 气泡自动有"打开/位置"
|
||||||
|
const fid = (env.body as any)?.fileId
|
||||||
|
if (fid && !(fid in message.progressByFileId)) {
|
||||||
|
message.setProgress(fid, 0, (env.body as any)?.size || 0)
|
||||||
|
}
|
||||||
|
message.upsert(env)
|
||||||
|
|
||||||
|
// 仅在 savedPath 已 ready 时算接收完成, 否则不提示/加未读 (等真正拿到文件再算)
|
||||||
|
if (!(env.body as any)?.savedPath) {
|
||||||
|
// 文件还在路上: 仅显示气泡占位, 等下一帧再算 unread
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const isActive = fromId === session.activePeerId
|
||||||
|
if (isActive) {
|
||||||
|
if (device.unread[fromId]) {
|
||||||
|
const next = { ...device.unread }
|
||||||
|
delete next[fromId]
|
||||||
|
device.unread = next
|
||||||
|
}
|
||||||
|
window.api.clearUnread(fromId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
device.refresh().then(() => {
|
||||||
|
const sender = device.devices.find(d => d.deviceId === fromId)
|
||||||
|
const name = sender?.name || '新消息'
|
||||||
|
let body = ''
|
||||||
|
if (env.type === 'text') body = (env.body as any).content?.slice(0, 60) || ''
|
||||||
|
else if (env.type === 'image') body = '[图片]'
|
||||||
|
else if (env.type === 'file') body = `[文件] ${(env.body as any).name || ''}`
|
||||||
|
if (body) showToast(`${name}: ${body}`)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
window.api.on('message:local', (env: MessageView) => {
|
||||||
|
// env 里的 status 是后端给的, 通常是 'pending' (离线) 或 'sent' (已发送)
|
||||||
|
const fid = (env.body as any)?.fileId
|
||||||
|
const savedPath = (env.body as any)?.savedPath
|
||||||
|
// 只在初次广播 (savedPath=null) 或第一次见到这个 fileId 时初始化进度
|
||||||
|
// 第二次广播 (上传完后 savedPath 已 fill) 不要误重生, 否则 setProgress 刚 delete at 100%, 又被 0/size 覆盖
|
||||||
|
if (fid && !savedPath && !(fid in message.progressByFileId)) {
|
||||||
|
message.setProgress(fid, 0, (env.body as any)?.size || 0)
|
||||||
|
}
|
||||||
|
// 用 upsert: 同一个 messageId 可能来多次 (第一次 status='sending'/savedPath=null, 第二次已 fill)
|
||||||
|
message.upsert({ ...env, status: env.status || 'pending' })
|
||||||
|
})
|
||||||
|
window.api.on('message:recalled', ({ messageId }: { messageId: string }) => {
|
||||||
|
message.remove(messageId)
|
||||||
|
})
|
||||||
|
window.api.on('message:progress', (e: any) => {
|
||||||
|
// { toDeviceId, fileId, sent, total }
|
||||||
|
message.setProgress(e.fileId, e.sent, e.total)
|
||||||
|
})
|
||||||
|
window.api.on('message:statusChanged', ({ messageId, status }: { messageId: string; status: string }) => {
|
||||||
|
// 终态才清 progress: 'sent' 在新流程里出现得太早 (WS metadata 已发, 但上传还在后台跑)
|
||||||
|
// 让 setProgress 在 100% 时自动清, 避免误清
|
||||||
|
if (status === 'delivered' || status === 'failed') {
|
||||||
|
for (const arr of Object.values(message.byPeer) as any[]) {
|
||||||
|
const m = arr.find((x: any) => x.messageId === messageId)
|
||||||
|
if (m?.body?.fileId) message.clearProgress(m.body.fileId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
message.updateStatus(messageId, status)
|
||||||
|
})
|
||||||
|
window.api.on('message:focus', ({ fromDeviceId }: { fromDeviceId: string }) => {
|
||||||
|
session.setActive(fromDeviceId)
|
||||||
|
})
|
||||||
|
window.api.on('settings:changed', (s: Settings) => { device.settings = s })
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => session.activePeerId, async (id) => {
|
||||||
|
if (!id) return
|
||||||
|
// 立即同步清 (不等异步), 保证角标立刻消失
|
||||||
|
if (device.unread[id]) {
|
||||||
|
const next = { ...device.unread }
|
||||||
|
delete next[id]
|
||||||
|
device.unread = next
|
||||||
|
}
|
||||||
|
await message.ensureLoaded(id)
|
||||||
|
await window.api.clearUnread(id)
|
||||||
|
})
|
||||||
|
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
const t = e.target as HTMLElement
|
||||||
|
if (t && t.tagName === 'IMG' && (t as any).dataset?.viewer) {
|
||||||
|
imageViewer.value = (t as HTMLImageElement).src
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="app-shell">
|
||||||
|
<Sidebar @open-settings="showSettings = true" />
|
||||||
|
<ChatView
|
||||||
|
v-if="activeDevice"
|
||||||
|
:peer="activeDevice"
|
||||||
|
@open-image="imageViewer = $event"
|
||||||
|
/>
|
||||||
|
<ChatView
|
||||||
|
v-else
|
||||||
|
:peer="null"
|
||||||
|
@open-image="imageViewer = $event"
|
||||||
|
/>
|
||||||
|
<SettingsView v-if="showSettings" @close="showSettings = false" />
|
||||||
|
<div v-if="imageViewer" class="image-viewer" @click="imageViewer = null">
|
||||||
|
<img :src="imageViewer" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
// 与主进程 preload 暴露的 window.api 类型一一对应
|
||||||
|
export type LnmApi = {
|
||||||
|
platform: string
|
||||||
|
versions: Record<string, string | undefined>
|
||||||
|
|
||||||
|
self: () => Promise<DeviceSelf | null>
|
||||||
|
listDevices: () => Promise<DeviceView[]>
|
||||||
|
triggerScan: () => Promise<boolean>
|
||||||
|
|
||||||
|
listMessages: (peerId: string) => Promise<MessageView[]>
|
||||||
|
sendText: (toDeviceId: string, content: string) => Promise<{ ok: boolean; reason?: string }>
|
||||||
|
sendFile: (toDeviceId: string, localPath: string, opts?: { name?: string; mime?: string; asImage?: boolean; withProgress?: boolean }) => Promise<{ ok: boolean; reason?: string }>
|
||||||
|
sendBuffer: (toDeviceId: string, dataBase64: string, name: string, mime: string) => Promise<{ ok: boolean; reason?: string }>
|
||||||
|
recall: (messageId: string) => Promise<{ ok: boolean; reason?: string }>
|
||||||
|
retry: (messageId: string) => Promise<{ ok: boolean; reason?: string; status?: string }>
|
||||||
|
typing: (toDeviceId: string) => Promise<void>
|
||||||
|
|
||||||
|
unreadList: () => Promise<Record<string, number>>
|
||||||
|
clearUnread: (deviceId: string) => Promise<boolean>
|
||||||
|
|
||||||
|
getSettings: () => Promise<Settings>
|
||||||
|
setSettings: (patch: Partial<Settings>) => Promise<Settings>
|
||||||
|
chooseDownloadDir: () => Promise<string | null>
|
||||||
|
|
||||||
|
openInFolder: (p: string) => Promise<boolean>
|
||||||
|
open: (p: string) => Promise<boolean>
|
||||||
|
readBase64: (p: string) => Promise<string | null>
|
||||||
|
pickFile: (opts?: { image?: boolean }) => Promise<{ path: string; name: string; size: number } | null>
|
||||||
|
getPathForFile: (file: File) => string
|
||||||
|
|
||||||
|
hide: () => Promise<void>
|
||||||
|
appVersion: () => Promise<string>
|
||||||
|
|
||||||
|
getAutoStart: () => Promise<boolean>
|
||||||
|
setAutoStart: (enabled: boolean) => Promise<boolean>
|
||||||
|
|
||||||
|
listInterfaces: () => Promise<NetInterface[]>
|
||||||
|
|
||||||
|
on: (channel: string, cb: (payload: any) => void) => () => void
|
||||||
|
|
||||||
|
// 触发主进程对所有 pending 消息做一次 flush 尝试
|
||||||
|
flushPending: () => Promise<{ ok: boolean; flushed: number }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceSelf {
|
||||||
|
deviceId: string
|
||||||
|
name: string
|
||||||
|
hostname: string
|
||||||
|
platform: string
|
||||||
|
appVersion: string
|
||||||
|
address: string
|
||||||
|
chatPort: number
|
||||||
|
filePort: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceView {
|
||||||
|
deviceId: string
|
||||||
|
name: string
|
||||||
|
hostname: string
|
||||||
|
platform: string
|
||||||
|
appVersion: string
|
||||||
|
address: string
|
||||||
|
online: boolean
|
||||||
|
lastSeen: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MessageType = 'text' | 'image' | 'file' | 'system'
|
||||||
|
|
||||||
|
export interface MessageView {
|
||||||
|
messageId: string
|
||||||
|
type: MessageType
|
||||||
|
fromDeviceId: string
|
||||||
|
toDeviceId: string
|
||||||
|
ts: number
|
||||||
|
body: any
|
||||||
|
status?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Settings {
|
||||||
|
deviceName: string
|
||||||
|
downloadDir: string
|
||||||
|
notifications: boolean
|
||||||
|
sound: boolean
|
||||||
|
autoStart: boolean
|
||||||
|
theme: 'light' | 'dark'
|
||||||
|
preferredInterface?: string
|
||||||
|
preferredAddress?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NetInterface {
|
||||||
|
name: string
|
||||||
|
address: string
|
||||||
|
netmask: string
|
||||||
|
broadcast: string
|
||||||
|
family: 'IPv4' | 'IPv6'
|
||||||
|
internal: boolean
|
||||||
|
mac: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 事件 payload
|
||||||
|
export interface ProgressEvent {
|
||||||
|
toDeviceId: string
|
||||||
|
fileId: string
|
||||||
|
sent: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, ref, watch, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useDeviceStore } from '@/stores/device'
|
||||||
|
import { useMessageStore } from '@/stores/message'
|
||||||
|
import { useSessionStore } from '@/stores/session'
|
||||||
|
import MessageItem from './MessageItem.vue'
|
||||||
|
import MessageInput from './MessageInput.vue'
|
||||||
|
import type { DeviceView } from '@/api'
|
||||||
|
import { initialsOf, colorFor } from '@/utils/format'
|
||||||
|
import { ElAvatar, ElButton, ElEmpty, ElIcon, ElScrollbar, ElMessage } from 'element-plus'
|
||||||
|
import { ChatLineRound, Promotion, UploadFilled } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
|
const props = defineProps<{ peer: DeviceView | null }>()
|
||||||
|
const emit = defineEmits<{ (e: 'open-image', url: string): void }>()
|
||||||
|
|
||||||
|
const device = useDeviceStore()
|
||||||
|
const message = useMessageStore()
|
||||||
|
const session = useSessionStore()
|
||||||
|
|
||||||
|
const self = computed(() => device.self)
|
||||||
|
const messages = computed(() => session.activePeerId ? (message.byPeer[session.activePeerId] || []) : [])
|
||||||
|
const typingPeer = computed(() => session.activePeerId ? session.peerTyping[session.activePeerId] : false)
|
||||||
|
|
||||||
|
const bodyEl = ref<{ scrollTo: (opts: { top: number }) => void } | null>(null)
|
||||||
|
function scrollToBottom() {
|
||||||
|
nextTick(() => {
|
||||||
|
if (bodyEl.value) bodyEl.value.scrollTo({ top: 999999 })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => session.activePeerId, () => scrollToBottom())
|
||||||
|
watch(() => messages.value.length, () => scrollToBottom())
|
||||||
|
|
||||||
|
onMounted(() => scrollToBottom())
|
||||||
|
onUnmounted(() => {})
|
||||||
|
|
||||||
|
// 全局拖拽: 拖到聊天区任意位置, 把文件转给 composer 处理
|
||||||
|
// (composer 自己也有 drop, 这个是兜底, 避免 inner 元素吞掉事件)
|
||||||
|
const globalDragDepth = ref(0)
|
||||||
|
const isGlobalDragging = computed(() => globalDragDepth.value > 0)
|
||||||
|
function hasFiles(e: DragEvent) {
|
||||||
|
const t = e.dataTransfer?.types
|
||||||
|
if (!t) return false
|
||||||
|
for (let i = 0; i < t.length; i++) if (t[i] === 'Files') return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
function onGlobalDragEnter(e: DragEvent) {
|
||||||
|
if (!hasFiles(e) || !props.peer) return
|
||||||
|
e.preventDefault()
|
||||||
|
globalDragDepth.value++
|
||||||
|
}
|
||||||
|
function onGlobalDragOver(e: DragEvent) {
|
||||||
|
if (!hasFiles(e) || !props.peer) return
|
||||||
|
e.preventDefault()
|
||||||
|
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
|
||||||
|
}
|
||||||
|
function onGlobalDragLeave(e: DragEvent) {
|
||||||
|
if (!hasFiles(e) || !props.peer) return
|
||||||
|
e.preventDefault()
|
||||||
|
globalDragDepth.value = Math.max(0, globalDragDepth.value - 1)
|
||||||
|
}
|
||||||
|
async function onGlobalDrop(e: DragEvent) {
|
||||||
|
if (!hasFiles(e) || !props.peer) return
|
||||||
|
e.preventDefault()
|
||||||
|
globalDragDepth.value = 0
|
||||||
|
const files = e.dataTransfer?.files
|
||||||
|
if (!files || files.length === 0) return
|
||||||
|
let imgN = 0, fileN = 0
|
||||||
|
for (const file of Array.from(files)) {
|
||||||
|
if (file.type.startsWith('image/')) {
|
||||||
|
imgN++
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => {
|
||||||
|
const dataUrl = reader.result as string
|
||||||
|
const b64 = dataUrl.split(',')[1]
|
||||||
|
window.api.sendBuffer(props.peer!.deviceId, b64, file.name, file.type)
|
||||||
|
.then(r => { if (!r.ok) ElMessage.warning('图片发送失败: ' + (r.reason || '')) })
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
} else {
|
||||||
|
fileN++
|
||||||
|
const fpath = window.api.getPathForFile(file)
|
||||||
|
if (fpath) {
|
||||||
|
window.api.sendFile(props.peer!.deviceId, fpath, { asImage: false, withProgress: false })
|
||||||
|
.then(r => { if (!r.ok) ElMessage.warning('发送失败: ' + (r.reason || '')) })
|
||||||
|
} else {
|
||||||
|
ElMessage.warning(`无法获取路径: ${file.name}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fileN + imgN > 1) ElMessage.info(`已接收 ${imgN} 张图片, ${fileN} 个文件`)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="chat-main" v-if="peer">
|
||||||
|
<header class="chat-header">
|
||||||
|
<el-avatar
|
||||||
|
:size="40"
|
||||||
|
shape="square"
|
||||||
|
:style="{ background: colorFor(peer.deviceId), color: '#fff', fontWeight: 600 }"
|
||||||
|
>
|
||||||
|
{{ initialsOf(peer.name) }}
|
||||||
|
</el-avatar>
|
||||||
|
<div class="header-info">
|
||||||
|
<div class="header-name">{{ peer.name }}</div>
|
||||||
|
<div class="header-meta">
|
||||||
|
<span class="status-dot" :class="{ online: peer.online }"></span>
|
||||||
|
<template v-if="peer.online">在线 · {{ peer.address }}</template>
|
||||||
|
<template v-else>离线</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<el-scrollbar ref="bodyEl" class="chat-body">
|
||||||
|
<div class="chat-body-inner">
|
||||||
|
<MessageItem
|
||||||
|
v-for="(m, i) in messages"
|
||||||
|
:key="m.messageId"
|
||||||
|
:msg="m"
|
||||||
|
:prev="messages[i - 1]"
|
||||||
|
:next="messages[i + 1]"
|
||||||
|
:peer="peer"
|
||||||
|
:self="self!"
|
||||||
|
@open-image="emit('open-image', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</el-scrollbar>
|
||||||
|
|
||||||
|
<div class="typing-indicator">{{ typingPeer ? `${peer.name} 正在输入…` : '' }}</div>
|
||||||
|
|
||||||
|
<MessageInput :peer="peer" />
|
||||||
|
|
||||||
|
<!-- 全局拖拽遮罩 (覆盖整个聊天区) -->
|
||||||
|
<div
|
||||||
|
v-if="isGlobalDragging"
|
||||||
|
class="global-drop-overlay"
|
||||||
|
@dragenter="onGlobalDragEnter"
|
||||||
|
@dragover="onGlobalDragOver"
|
||||||
|
@dragleave="onGlobalDragLeave"
|
||||||
|
@drop="onGlobalDrop"
|
||||||
|
>
|
||||||
|
<el-icon :size="48" color="#fff"><UploadFilled /></el-icon>
|
||||||
|
<div class="global-drop-hint">松开发送到 {{ peer.name }}</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<main class="chat-main chat-main-empty" v-else>
|
||||||
|
<el-empty :image-size="120" description="选择一个设备开始聊天">
|
||||||
|
<template #image>
|
||||||
|
<el-icon :size="80" color="#c9cdd4"><ChatLineRound /></el-icon>
|
||||||
|
</template>
|
||||||
|
<el-button type="primary" :icon="Promotion" @click="device.triggerScan()">扫描局域网</el-button>
|
||||||
|
</el-empty>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chat-main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
background: var(--el-bg-color-page);
|
||||||
|
min-height: 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.chat-main-empty {
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.chat-header {
|
||||||
|
height: 60px;
|
||||||
|
padding: 0 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
background: var(--el-bg-color);
|
||||||
|
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.header-info { display: flex; flex-direction: column; min-width: 0; }
|
||||||
|
.header-name { font-size: 15px; font-weight: 600; color: var(--el-text-color-primary); line-height: 1.2; }
|
||||||
|
.header-meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
margin-top: 2px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--el-text-color-placeholder);
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.status-dot.online { background: var(--el-color-success); }
|
||||||
|
|
||||||
|
.chat-body { flex: 1; min-height: 0; }
|
||||||
|
.chat-body-inner { padding: 16px 20px 0; }
|
||||||
|
.typing-indicator {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
padding: 0 22px 6px;
|
||||||
|
height: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-drop-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(51, 112, 255, 0.92);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
z-index: 50;
|
||||||
|
animation: drop-fade-in 0.15s ease-out;
|
||||||
|
}
|
||||||
|
.global-drop-hint {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
@keyframes drop-fade-in {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import MarkdownIt from 'markdown-it'
|
||||||
|
import hljs from 'highlight.js'
|
||||||
|
import 'highlight.js/styles/atom-one-light.css'
|
||||||
|
|
||||||
|
// 用 any 简化 (markdown-it 的 cjs 导出 + @types 配合不友好)
|
||||||
|
const md: any = new (MarkdownIt as any)({
|
||||||
|
html: false,
|
||||||
|
linkify: true,
|
||||||
|
breaks: true,
|
||||||
|
typographer: true,
|
||||||
|
highlight(str: string, lang: string): string {
|
||||||
|
if (lang && hljs.getLanguage(lang)) {
|
||||||
|
try {
|
||||||
|
const out = hljs.highlight(str, { language: lang, ignoreIllegals: true } as any).value
|
||||||
|
return `<pre class="hljs"><code>${out}</code></pre>`
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return `<pre class="hljs"><code>${md.utils.escapeHtml(str)}</code></pre>`
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const defaultLinkOpen = md.renderer.rules.link_open
|
||||||
|
|| function (tokens: any[], idx: number, options: any, _env: any, self: any): string {
|
||||||
|
return self.renderToken(tokens, idx, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
md.renderer.rules.link_open = function (
|
||||||
|
tokens: any[], idx: number, options: any, env: any, self: any
|
||||||
|
): string {
|
||||||
|
const token = tokens[idx]
|
||||||
|
const hrefIndex = token.attrIndex('href')
|
||||||
|
if (hrefIndex >= 0) {
|
||||||
|
const href = token.attrs![hrefIndex][1]
|
||||||
|
if (/^https?:\/\//i.test(href)) {
|
||||||
|
token.attrSet('target', '_blank')
|
||||||
|
token.attrSet('rel', 'noopener noreferrer')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defaultLinkOpen(tokens, idx, options, env, self)
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
source: string
|
||||||
|
mentions?: Record<string, string>
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const html = computed(() => {
|
||||||
|
if (!props.source) return ''
|
||||||
|
let h: string = md.render(props.source)
|
||||||
|
if (props.mentions) {
|
||||||
|
h = h.replace(/@([一-龥\w\- ]{1,30})/g, (m: string, name: string) => {
|
||||||
|
const id = Object.entries(props.mentions!).find(([_, n]) => n === name)?.[0]
|
||||||
|
return id
|
||||||
|
? `<span class="at-mention" data-id="${id}">@${name}</span>`
|
||||||
|
: `<span class="at-mention">@${name}</span>`
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
h = h.replace(/@([一-龥\w\- ]{1,30})/g, '<span class="at-mention">@$1</span>')
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="md" v-html="html"></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.md {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.65;
|
||||||
|
color: inherit;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.md > :first-child { margin-top: 0; }
|
||||||
|
.md > :last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
|
.md p { margin: 0 0 6px; }
|
||||||
|
.md p:last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
|
.md h1, .md h2, .md h3, .md h4 {
|
||||||
|
margin: 10px 0 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.md h1 { font-size: 18px; }
|
||||||
|
.md h2 { font-size: 16px; }
|
||||||
|
.md h3 { font-size: 15px; }
|
||||||
|
.md h4 { font-size: 14px; }
|
||||||
|
|
||||||
|
.md ul, .md ol { margin: 4px 0 6px; padding-left: 22px; }
|
||||||
|
.md li { margin: 2px 0; }
|
||||||
|
.md li > p { margin: 0; }
|
||||||
|
|
||||||
|
.md blockquote {
|
||||||
|
margin: 6px 0;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-left: 3px solid var(--el-color-primary-light-5);
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
border-radius: 0 4px 4px 0;
|
||||||
|
}
|
||||||
|
.md blockquote > :first-child { margin-top: 0; }
|
||||||
|
.md blockquote > :last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
|
.md hr {
|
||||||
|
border: none;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--el-border-color-lighter);
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md code {
|
||||||
|
font-family: ui-monospace, "JetBrains Mono", Consolas, Menlo, monospace;
|
||||||
|
font-size: 12.5px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
color: var(--el-color-danger);
|
||||||
|
}
|
||||||
|
.md pre code {
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md pre {
|
||||||
|
margin: 6px 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fafbfc;
|
||||||
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
|
overflow-x: auto;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.md pre code { color: #383a42; }
|
||||||
|
|
||||||
|
.md table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 6px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
width: auto;
|
||||||
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.md th, .md td {
|
||||||
|
padding: 6px 12px;
|
||||||
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.md th {
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.md tr:nth-child(even) td { background: rgba(0, 0, 0, 0.015); }
|
||||||
|
|
||||||
|
.md a {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.md a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.md strong { font-weight: 600; color: var(--el-text-color-primary); }
|
||||||
|
.md em { font-style: italic; }
|
||||||
|
.md del { color: var(--el-text-color-secondary); }
|
||||||
|
|
||||||
|
.md .at-mention {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
background: var(--el-color-primary-light-9);
|
||||||
|
padding: 0 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.msg.self .md .at-mention {
|
||||||
|
color: #fff;
|
||||||
|
background: rgba(255, 255, 255, 0.25);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
|
import type { DeviceView } from '@/api'
|
||||||
|
import { useDeviceStore } from '@/stores/device'
|
||||||
|
import { ElButton, ElTooltip, ElIcon, ElMessage } from 'element-plus'
|
||||||
|
import { Link, Picture, Promotion, ChatLineRound, UploadFilled } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
|
const props = defineProps<{ peer: DeviceView }>()
|
||||||
|
const device = useDeviceStore()
|
||||||
|
|
||||||
|
const text = ref('')
|
||||||
|
const inputEl = ref<HTMLTextAreaElement | null>(null)
|
||||||
|
|
||||||
|
interface PendingImage { id: string; dataBase64: string; name: string; mime: string; size: number; preview: string }
|
||||||
|
const pendingImages = ref<PendingImage[]>([])
|
||||||
|
const uploading = ref(false)
|
||||||
|
|
||||||
|
// 拖拽状态
|
||||||
|
const dragDepth = ref(0) // 用 depth 计数避免子元素进出时误判
|
||||||
|
const isDragging = computed(() => dragDepth.value > 0)
|
||||||
|
|
||||||
|
function autoSize() {
|
||||||
|
if (!inputEl.value) return
|
||||||
|
inputEl.value.style.height = 'auto'
|
||||||
|
inputEl.value.style.height = Math.min(240, inputEl.value.scrollHeight) + 'px'
|
||||||
|
}
|
||||||
|
|
||||||
|
function toast(t: string, type: 'info' | 'warning' = 'info') {
|
||||||
|
if (type === 'warning') ElMessage.warning(t)
|
||||||
|
else ElMessage.info(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function send() {
|
||||||
|
if (uploading.value) return
|
||||||
|
const t = text.value.trim()
|
||||||
|
if (!t && pendingImages.value.length === 0) return
|
||||||
|
uploading.value = true
|
||||||
|
try {
|
||||||
|
if (t) {
|
||||||
|
const r = await window.api.sendText(props.peer.deviceId, t)
|
||||||
|
if (!r.ok) toast('发送失败: ' + (r.reason || ''), 'warning')
|
||||||
|
}
|
||||||
|
for (const img of pendingImages.value) {
|
||||||
|
const r = await window.api.sendBuffer(props.peer.deviceId, img.dataBase64, img.name, img.mime)
|
||||||
|
if (!r.ok) toast('图片发送失败: ' + (r.reason || ''), 'warning')
|
||||||
|
}
|
||||||
|
text.value = ''
|
||||||
|
pendingImages.value = []
|
||||||
|
autoSize()
|
||||||
|
} finally {
|
||||||
|
uploading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pickFile() {
|
||||||
|
const f = await window.api.pickFile()
|
||||||
|
if (!f) return
|
||||||
|
await sendLocalFile(f.path, f.name, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendLocalFile(localPath: string, name: string, asImage: boolean) {
|
||||||
|
if (!localPath) {
|
||||||
|
toast('无法获取文件路径', 'warning')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const r = await window.api.sendFile(props.peer.deviceId, localPath, { asImage, withProgress: false })
|
||||||
|
if (!r.ok) toast('发送失败: ' + (r.reason || ''), 'warning')
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePending(id: string) {
|
||||||
|
pendingImages.value = pendingImages.value.filter(x => x.id !== id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onPaste(e: ClipboardEvent) {
|
||||||
|
const items = e.clipboardData?.items
|
||||||
|
if (!items) return
|
||||||
|
for (const it of items as any) {
|
||||||
|
if (it.kind === 'file' && it.type.startsWith('image/')) {
|
||||||
|
const blob = it.getAsFile()
|
||||||
|
if (!blob) continue
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => {
|
||||||
|
const dataUrl = reader.result as string
|
||||||
|
const b64 = dataUrl.split(',')[1]
|
||||||
|
const id = Math.random().toString(36).slice(2)
|
||||||
|
pendingImages.value.push({
|
||||||
|
id, dataBase64: b64,
|
||||||
|
name: `pasted-${Date.now()}.${(blob.type.split('/')[1] || 'png')}`,
|
||||||
|
mime: blob.type, size: blob.size, preview: dataUrl
|
||||||
|
})
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(blob)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拖拽支持 - 用 depth 计数避免冒泡问题
|
||||||
|
function onDragEnter(e: DragEvent) {
|
||||||
|
if (!hasFiles(e)) return
|
||||||
|
e.preventDefault()
|
||||||
|
dragDepth.value++
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragOver(e: DragEvent) {
|
||||||
|
if (!hasFiles(e)) return
|
||||||
|
e.preventDefault()
|
||||||
|
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragLeave(e: DragEvent) {
|
||||||
|
if (!hasFiles(e)) return
|
||||||
|
e.preventDefault()
|
||||||
|
dragDepth.value = Math.max(0, dragDepth.value - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasFiles(e: DragEvent): boolean {
|
||||||
|
const types = e.dataTransfer?.types
|
||||||
|
if (!types) return false
|
||||||
|
for (let i = 0; i < types.length; i++) {
|
||||||
|
if (types[i] === 'Files') return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDrop(e: DragEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
dragDepth.value = 0
|
||||||
|
const files = e.dataTransfer?.files
|
||||||
|
if (!files || files.length === 0) return
|
||||||
|
|
||||||
|
let imageCount = 0
|
||||||
|
let fileCount = 0
|
||||||
|
for (const file of Array.from(files)) {
|
||||||
|
if (file.type.startsWith('image/')) {
|
||||||
|
imageCount++
|
||||||
|
await readImageAsPending(file)
|
||||||
|
} else {
|
||||||
|
fileCount++
|
||||||
|
const fpath = window.api.getPathForFile(file)
|
||||||
|
if (fpath) {
|
||||||
|
sendLocalFile(fpath, file.name, false)
|
||||||
|
} else {
|
||||||
|
toast(`无法获取路径: ${file.name}`, 'warning')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fileCount + imageCount > 1) {
|
||||||
|
toast(`已接收 ${imageCount} 张图片, ${fileCount} 个文件`, 'info')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readImageAsPending(file: File): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => {
|
||||||
|
const dataUrl = reader.result as string
|
||||||
|
const b64 = dataUrl.split(',')[1]
|
||||||
|
const id = Math.random().toString(36).slice(2)
|
||||||
|
pendingImages.value.push({
|
||||||
|
id, dataBase64: b64, name: file.name, mime: file.type, size: file.size, preview: dataUrl
|
||||||
|
})
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
reader.onerror = () => { toast(`读取图片失败: ${file.name}`, 'warning'); resolve() }
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => autoSize())
|
||||||
|
onUnmounted(() => {})
|
||||||
|
|
||||||
|
let typingTimer: any = null
|
||||||
|
function onInput() {
|
||||||
|
autoSize()
|
||||||
|
if (typingTimer) return
|
||||||
|
window.api.typing(props.peer.deviceId)
|
||||||
|
typingTimer = setTimeout(() => { typingTimer = null }, 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
send()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="composer"
|
||||||
|
:class="{ 'is-dragging': isDragging }"
|
||||||
|
@drop="onDrop"
|
||||||
|
@dragenter="onDragEnter"
|
||||||
|
@dragover="onDragOver"
|
||||||
|
@dragleave="onDragLeave"
|
||||||
|
>
|
||||||
|
<!-- 拖拽遮罩 -->
|
||||||
|
<div v-if="isDragging" class="drop-overlay">
|
||||||
|
<el-icon :size="40" color="#fff"><UploadFilled /></el-icon>
|
||||||
|
<div class="drop-hint">松开发送文件到 {{ peer.name }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="composer-toolbar">
|
||||||
|
<el-tooltip content="发送文件" placement="top">
|
||||||
|
<el-button :icon="Link" size="small" plain @click="pickFile" />
|
||||||
|
</el-tooltip>
|
||||||
|
<el-tooltip content="表情 (待实现)" placement="top" :disabled="true">
|
||||||
|
<el-button :icon="ChatLineRound" size="small" plain disabled />
|
||||||
|
</el-tooltip>
|
||||||
|
<div style="flex: 1"></div>
|
||||||
|
<span class="composer-hint">支持拖拽 / 粘贴图片 · Markdown · Enter 发送 · Shift+Enter 换行</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="composer-input-wrap">
|
||||||
|
<div v-if="pendingImages.length" class="composer-pending">
|
||||||
|
<div v-for="img in pendingImages" :key="img.id" class="pending-item">
|
||||||
|
<img :src="img.preview" />
|
||||||
|
<span class="x" @click="removePending(img.id)" title="移除">×</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
ref="inputEl"
|
||||||
|
class="composer-input"
|
||||||
|
v-model="text"
|
||||||
|
@keydown="onKeyDown"
|
||||||
|
@paste="onPaste"
|
||||||
|
@input="onInput"
|
||||||
|
:placeholder="`发送消息到 ${peer.name}…`"
|
||||||
|
rows="1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="composer-footer">
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
:icon="Promotion"
|
||||||
|
:loading="uploading"
|
||||||
|
:disabled="!text.trim() && !pendingImages.length"
|
||||||
|
@click="send"
|
||||||
|
>
|
||||||
|
发送
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.composer {
|
||||||
|
background: var(--el-bg-color);
|
||||||
|
border-top: 1px solid var(--el-border-color-lighter);
|
||||||
|
padding: 8px 16px 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.composer.is-dragging {
|
||||||
|
background: var(--el-color-primary-light-9);
|
||||||
|
border-top-color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
.composer.is-dragging .composer-input-wrap {
|
||||||
|
border-color: var(--el-color-primary);
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 0 0 3px var(--el-color-primary-light-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(51, 112, 255, 0.92);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
z-index: 10;
|
||||||
|
pointer-events: none;
|
||||||
|
border-radius: 0;
|
||||||
|
animation: drop-fade-in 0.15s ease-out;
|
||||||
|
}
|
||||||
|
.drop-hint {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
@keyframes drop-fade-in {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.composer-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
.composer-input-wrap {
|
||||||
|
background: var(--el-fill-color-blank);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--el-border-color-light);
|
||||||
|
transition: border 0.15s;
|
||||||
|
}
|
||||||
|
.composer-input-wrap:focus-within {
|
||||||
|
border-color: var(--el-color-primary);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.composer-input {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 60px;
|
||||||
|
max-height: 240px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
padding: 8px 10px;
|
||||||
|
resize: none;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
user-select: text;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.composer-pending {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 10px 0;
|
||||||
|
}
|
||||||
|
.pending-item {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
background: var(--el-bg-color);
|
||||||
|
border: 1px solid var(--el-border-color-light);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
.pending-item img { width: 48px; height: 48px; object-fit: cover; border-radius: 4px; display: block; }
|
||||||
|
.pending-item .x {
|
||||||
|
position: absolute;
|
||||||
|
top: -6px; right: -6px;
|
||||||
|
width: 18px; height: 18px;
|
||||||
|
background: #1f2329;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.pending-item .x:hover { background: var(--el-color-danger); }
|
||||||
|
.composer-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import type { MessageView, DeviceView, DeviceSelf } from '@/api'
|
||||||
|
import { initialsOf, colorFor, formatTime, formatSize } from '@/utils/format'
|
||||||
|
import MarkdownView from './MarkdownView.vue'
|
||||||
|
import { ElAvatar, ElButton, ElMessage, ElProgress } from 'element-plus'
|
||||||
|
import { useMessageStore } from '@/stores/message'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
msg: MessageView
|
||||||
|
prev?: MessageView
|
||||||
|
next?: MessageView
|
||||||
|
peer: DeviceView
|
||||||
|
self: DeviceSelf
|
||||||
|
}>()
|
||||||
|
const emit = defineEmits<{ (e: 'open-image', url: string): void }>()
|
||||||
|
|
||||||
|
const messageStore = useMessageStore()
|
||||||
|
|
||||||
|
const api = window.api
|
||||||
|
|
||||||
|
const isSelf = computed(() => props.msg.fromDeviceId === props.self.deviceId)
|
||||||
|
|
||||||
|
// 进度条: 文件/图片 + 在传中 (sender: pending/sending/sent, receiver: receiving), 100% 自动消失
|
||||||
|
const progress = computed(() => {
|
||||||
|
const fid = (props.msg.body as any)?.fileId
|
||||||
|
if (!fid) return null
|
||||||
|
const st = props.msg.status
|
||||||
|
// sender: pending/sending/sent 都在传中; delivered 后等下次刷新就清掉了
|
||||||
|
// receiver: 没 status 字段, 有进度记录就显示
|
||||||
|
if (isSelf.value) {
|
||||||
|
if (st && !['pending', 'sending', 'sent'].includes(st)) return null
|
||||||
|
}
|
||||||
|
const p = messageStore.progressByFileId[fid]
|
||||||
|
if (!p || !p.total) return null
|
||||||
|
return p
|
||||||
|
})
|
||||||
|
const progressPct = computed(() => {
|
||||||
|
const p = progress.value
|
||||||
|
if (!p) return 0
|
||||||
|
return Math.min(100, Math.round((p.sent / p.total) * 100))
|
||||||
|
})
|
||||||
|
// 实时速率 (MB/s, 1 位小数)
|
||||||
|
const speedMBps = computed(() => {
|
||||||
|
const p = progress.value
|
||||||
|
if (!p || !p.speed || p.speed <= 0) return '0'
|
||||||
|
const mbps = p.speed / (1024 * 1024)
|
||||||
|
return mbps.toFixed(mbps < 10 ? 2 : 1)
|
||||||
|
})
|
||||||
|
const senderName = computed(() => isSelf.value ? props.self.name : props.peer.name)
|
||||||
|
const avatarColor = computed(() => isSelf.value ? colorFor(props.self.deviceId) : colorFor(props.peer.deviceId))
|
||||||
|
const avatarText = computed(() => initialsOf(senderName.value))
|
||||||
|
|
||||||
|
const showTimeDivider = computed(() => {
|
||||||
|
if (!props.prev) return true
|
||||||
|
return props.msg.ts - props.prev.ts > 5 * 60_000
|
||||||
|
})
|
||||||
|
const showName = computed(() => {
|
||||||
|
if (isSelf.value) return false
|
||||||
|
if (!props.prev) return true
|
||||||
|
return props.prev.fromDeviceId !== props.msg.fromDeviceId
|
||||||
|
})
|
||||||
|
const isConsecutive = computed(() => {
|
||||||
|
if (!props.prev) return false
|
||||||
|
if (props.prev.fromDeviceId !== props.msg.fromDeviceId) return false
|
||||||
|
if (props.msg.ts - props.prev.ts > 60_000) return false
|
||||||
|
if (props.prev.type === 'system' || props.msg.type === 'system') return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
const text = computed(() => (props.msg.body as any).content || '')
|
||||||
|
const imageUrl = computed(() => {
|
||||||
|
if (props.msg.type !== 'image') return ''
|
||||||
|
return (props.msg.body as any).thumbDataUrl || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusText = computed(() => {
|
||||||
|
switch (props.msg.status) {
|
||||||
|
case 'pending': return '待对方上线'
|
||||||
|
case 'sending': return '发送中'
|
||||||
|
case 'sent': return '已发送'
|
||||||
|
case 'delivered': return '已送达'
|
||||||
|
case 'failed': return '失败'
|
||||||
|
default: return props.msg.status || ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const canRetry = computed(() => {
|
||||||
|
if (!isSelf.value) return false
|
||||||
|
return props.msg.status === 'failed' || props.msg.status === 'pending'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 自己发的文件: 本地源 = localPath (拖拽/选择的真实路径)
|
||||||
|
// 对方发的文件: 本地 = savedPath (本机接收后保存的路径)
|
||||||
|
const localFilePath = computed(() => {
|
||||||
|
return isSelf.value
|
||||||
|
? ((props.msg.body as any).localPath || '')
|
||||||
|
: ((props.msg.body as any).savedPath || '')
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasLocalFile = computed(() => Boolean(localFilePath.value))
|
||||||
|
|
||||||
|
async function openLocal() {
|
||||||
|
if (!localFilePath.value) return
|
||||||
|
const ok = await api.open(localFilePath.value)
|
||||||
|
if (!ok) ElMessage.warning('文件不存在或无法打开')
|
||||||
|
}
|
||||||
|
async function revealLocal() {
|
||||||
|
if (!localFilePath.value) return
|
||||||
|
const ok = await api.openInFolder(localFilePath.value)
|
||||||
|
if (!ok) ElMessage.warning('文件不存在或无法定位')
|
||||||
|
}
|
||||||
|
|
||||||
|
const retrying = ref(false)
|
||||||
|
async function onRetry() {
|
||||||
|
if (retrying.value) return
|
||||||
|
retrying.value = true
|
||||||
|
try {
|
||||||
|
const r = await api.retry(props.msg.messageId)
|
||||||
|
if (!r.ok) {
|
||||||
|
ElMessage.warning(r.reason || '重试失败')
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error('重试失败: ' + (e?.message || e))
|
||||||
|
} finally {
|
||||||
|
retrying.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<template v-if="showTimeDivider">
|
||||||
|
<div class="msg-time-divider">{{ formatTime(msg.ts, 'smart') }}</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-if="msg.type === 'system'" class="msg system">
|
||||||
|
<span class="system-bubble">{{ (msg.body as any).content }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="msg"
|
||||||
|
:class="{
|
||||||
|
self: isSelf,
|
||||||
|
grouped: isConsecutive,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<!-- 头像: 同人连续消息时折叠 -->
|
||||||
|
<div class="msg-avatar-col">
|
||||||
|
<el-avatar
|
||||||
|
v-if="!isConsecutive"
|
||||||
|
:size="36"
|
||||||
|
shape="square"
|
||||||
|
:style="{ background: avatarColor, color: '#fff', fontWeight: 600 }"
|
||||||
|
>
|
||||||
|
{{ avatarText }}
|
||||||
|
</el-avatar>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="msg-col">
|
||||||
|
<div v-if="showName" class="msg-name">{{ senderName }}</div>
|
||||||
|
|
||||||
|
<div v-if="msg.type === 'text'" class="bubble-wrap">
|
||||||
|
<div class="bubble text">
|
||||||
|
<MarkdownView :source="text" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="msg.type === 'image'" class="bubble-wrap">
|
||||||
|
<div class="bubble image">
|
||||||
|
<img
|
||||||
|
v-if="imageUrl"
|
||||||
|
:src="imageUrl"
|
||||||
|
data-viewer="1"
|
||||||
|
@click="emit('open-image', imageUrl)"
|
||||||
|
/>
|
||||||
|
<div v-else class="image-fallback">
|
||||||
|
<div class="image-fallback-name">[图片] {{ (msg.body as any).name }}</div>
|
||||||
|
<div class="image-fallback-meta">{{ formatSize((msg.body as any).size) }}</div>
|
||||||
|
<div v-if="hasLocalFile" class="image-fallback-actions">
|
||||||
|
<el-button size="small" link @click="openLocal">打开</el-button>
|
||||||
|
<el-button size="small" link @click="revealLocal">在文件夹中显示</el-button>
|
||||||
|
</div>
|
||||||
|
<el-progress
|
||||||
|
v-if="progress"
|
||||||
|
:percentage="progressPct"
|
||||||
|
:stroke-width="4"
|
||||||
|
:format="() => ''"
|
||||||
|
class="msg-progress"
|
||||||
|
/>
|
||||||
|
<div v-if="progress" class="msg-progress-meta">
|
||||||
|
<span>{{ formatSize(progress.sent) }} / {{ formatSize(progress.total) }}</span>
|
||||||
|
<span class="msg-progress-speed">{{ speedMBps }} MB/s</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="msg.type === 'file'" class="bubble-wrap">
|
||||||
|
<div class="bubble file-card">
|
||||||
|
<div class="file-row">
|
||||||
|
<div class="file-icon">
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="file-info">
|
||||||
|
<div class="file-name">{{ (msg.body as any).name }}</div>
|
||||||
|
<div class="file-meta">{{ formatSize((msg.body as any).size) }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="hasLocalFile" class="file-actions">
|
||||||
|
<el-button size="small" link @click="openLocal">打开</el-button>
|
||||||
|
<el-button size="small" link @click="revealLocal">位置</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-progress
|
||||||
|
v-if="progress"
|
||||||
|
:percentage="progressPct"
|
||||||
|
:stroke-width="3"
|
||||||
|
:format="() => ''"
|
||||||
|
class="msg-progress"
|
||||||
|
/>
|
||||||
|
<div v-if="progress" class="msg-progress-meta">
|
||||||
|
<span>{{ formatSize(progress.sent) }} / {{ formatSize(progress.total) }}</span>
|
||||||
|
<span class="msg-progress-speed">{{ speedMBps }} MB/s</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isSelf && msg.status" class="msg-status" :class="msg.status">
|
||||||
|
<span>{{ statusText }}</span>
|
||||||
|
<el-button
|
||||||
|
v-if="canRetry"
|
||||||
|
link
|
||||||
|
size="small"
|
||||||
|
:loading="retrying"
|
||||||
|
@click="onRetry"
|
||||||
|
>
|
||||||
|
重试
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.msg {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0 20px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.msg.self { flex-direction: row-reverse; }
|
||||||
|
.msg.grouped { margin-top: 2px; }
|
||||||
|
|
||||||
|
.msg-avatar-col {
|
||||||
|
width: 36px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding-top: 2px;
|
||||||
|
}
|
||||||
|
.msg-col {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-width: min(70%, 720px);
|
||||||
|
min-width: 40px;
|
||||||
|
}
|
||||||
|
.msg.self .msg-col { align-items: flex-end; }
|
||||||
|
|
||||||
|
.msg-name {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
padding: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-wrap { display: flex; min-width: 0; }
|
||||||
|
.msg.self .bubble-wrap { justify-content: flex-end; }
|
||||||
|
|
||||||
|
.bubble {
|
||||||
|
position: relative;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
line-height: 1.55;
|
||||||
|
font-size: 14px;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
min-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 对方: 白底 + 浅灰边 */
|
||||||
|
.bubble:not(.self-style) {
|
||||||
|
background: var(--el-bg-color);
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
|
}
|
||||||
|
.msg.self .bubble {
|
||||||
|
background: var(--el-color-primary-light-9);
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
border: 1px solid var(--el-color-primary-light-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 图片 */
|
||||||
|
.bubble.image {
|
||||||
|
padding: 4px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
.bubble.image img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 320px;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: block;
|
||||||
|
cursor: zoom-in;
|
||||||
|
}
|
||||||
|
.image-fallback { padding: 6px 10px; min-width: 220px; }
|
||||||
|
.image-fallback-name { font-weight: 500; }
|
||||||
|
.image-fallback-meta { font-size: 12px; color: var(--el-text-color-secondary); margin-top: 2px; }
|
||||||
|
.image-fallback-actions { margin-top: 4px; }
|
||||||
|
|
||||||
|
/* 文件卡片: 上下两层 (上 横排: icon/info/actions; 下 进度条 + 元信息) */
|
||||||
|
.bubble.file-card {
|
||||||
|
display: block;
|
||||||
|
min-width: 240px;
|
||||||
|
max-width: 360px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
.file-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.file-icon {
|
||||||
|
width: 36px; height: 36px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--el-color-primary-light-9);
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.file-info { flex: 1; min-width: 0; }
|
||||||
|
.file-name { font-size: 13px; font-weight: 500; word-break: break-all; }
|
||||||
|
.file-meta { font-size: 11px; color: var(--el-text-color-secondary); margin-top: 2px; }
|
||||||
|
.file-actions { display: flex; gap: 4px; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.msg-progress {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.msg-progress :deep(.el-progress-bar__outer) {
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
}
|
||||||
|
.msg-progress-meta {
|
||||||
|
margin-top: 4px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.msg-progress-speed {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 系统消息 */
|
||||||
|
.msg.system { justify-content: center; padding: 4px 20px; }
|
||||||
|
.system-bubble {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-status {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
margin-top: 2px;
|
||||||
|
padding: 0 2px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.msg-status.failed { color: var(--el-color-danger); }
|
||||||
|
.msg-status.pending { color: var(--el-color-warning); }
|
||||||
|
.msg-status.sending { color: var(--el-text-color-secondary); }
|
||||||
|
.msg-status.delivered { color: var(--el-color-success); }
|
||||||
|
.msg-status :deep(.el-button) {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 0 4px;
|
||||||
|
height: 18px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-time-divider {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
margin: 16px 0 8px;
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, onMounted, computed } from 'vue'
|
||||||
|
import { useDeviceStore } from '@/stores/device'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import type { NetInterface } from '@/api'
|
||||||
|
|
||||||
|
const emit = defineEmits<{ (e: 'close'): void }>()
|
||||||
|
const device = useDeviceStore()
|
||||||
|
|
||||||
|
interface FormState {
|
||||||
|
deviceName: string
|
||||||
|
downloadDir: string
|
||||||
|
notifications: boolean
|
||||||
|
sound: boolean
|
||||||
|
autoStart: boolean
|
||||||
|
theme: 'light' | 'dark'
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = ref<FormState>({
|
||||||
|
deviceName: '',
|
||||||
|
downloadDir: '',
|
||||||
|
notifications: true,
|
||||||
|
sound: true,
|
||||||
|
autoStart: false,
|
||||||
|
theme: 'light',
|
||||||
|
})
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const saving = ref(false)
|
||||||
|
const autoStartAvailable = ref(false)
|
||||||
|
const dirty = ref(false)
|
||||||
|
const initialAutoStart = ref(false)
|
||||||
|
const formRef = ref()
|
||||||
|
|
||||||
|
const ifaces = ref<NetInterface[]>([])
|
||||||
|
const loadingIfaces = ref(false)
|
||||||
|
|
||||||
|
watch(() => device.settings, (s) => {
|
||||||
|
if (s) hydrate(s)
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
initialAutoStart.value = await window.api.getAutoStart()
|
||||||
|
autoStartAvailable.value = true
|
||||||
|
} catch {
|
||||||
|
autoStartAvailable.value = false
|
||||||
|
}
|
||||||
|
await loadIfaces()
|
||||||
|
loading.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
function hydrate(s: any) {
|
||||||
|
form.value = {
|
||||||
|
...s,
|
||||||
|
autoStart: initialAutoStart.value,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(form, () => { dirty.value = true }, { deep: true })
|
||||||
|
|
||||||
|
async function loadIfaces() {
|
||||||
|
loadingIfaces.value = true
|
||||||
|
try {
|
||||||
|
ifaces.value = await window.api.listInterfaces()
|
||||||
|
} catch {
|
||||||
|
ifaces.value = []
|
||||||
|
} finally {
|
||||||
|
loadingIfaces.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pickDir() {
|
||||||
|
const p = await window.api.chooseDownloadDir()
|
||||||
|
if (p) form.value.downloadDir = p
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
if (autoStartAvailable.value && form.value.autoStart !== initialAutoStart.value) {
|
||||||
|
const ok = await window.api.setAutoStart(form.value.autoStart)
|
||||||
|
if (ok) initialAutoStart.value = form.value.autoStart
|
||||||
|
else form.value.autoStart = initialAutoStart.value
|
||||||
|
}
|
||||||
|
const r = await window.api.setSettings({
|
||||||
|
deviceName: form.value.deviceName,
|
||||||
|
downloadDir: form.value.downloadDir,
|
||||||
|
notifications: form.value.notifications,
|
||||||
|
sound: form.value.sound,
|
||||||
|
theme: form.value.theme,
|
||||||
|
autoStart: form.value.autoStart,
|
||||||
|
} as any)
|
||||||
|
device.settings = r
|
||||||
|
dirty.value = false
|
||||||
|
ElMessage.success('设置已保存')
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error('保存失败: ' + (e?.message || e))
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formLabelWidth = '90px'
|
||||||
|
|
||||||
|
const externalIfaces = computed(() => ifaces.value.filter(i => !i.internal))
|
||||||
|
const currentAddress = computed(() => device.self?.address || '-')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="settings-mask" @click.self="$emit('close')">
|
||||||
|
<div class="settings-modal">
|
||||||
|
<header class="settings-header">
|
||||||
|
设置
|
||||||
|
<button class="icon-btn" @click="$emit('close')">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div class="settings-body" v-loading="loading">
|
||||||
|
<el-form
|
||||||
|
ref="formRef"
|
||||||
|
:model="form"
|
||||||
|
label-position="left"
|
||||||
|
:label-width="formLabelWidth"
|
||||||
|
>
|
||||||
|
<el-form-item label="设备名称">
|
||||||
|
<el-input v-model="form.deviceName" placeholder="局域网中显示的名称" maxlength="32" show-word-limit />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="接收目录">
|
||||||
|
<el-input v-model="form.downloadDir" readonly>
|
||||||
|
<template #append>
|
||||||
|
<el-button @click="pickDir">选择…</el-button>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="系统通知">
|
||||||
|
<el-switch v-model="form.notifications" />
|
||||||
|
<span class="form-hint">收到新消息时显示系统通知</span>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="提示音">
|
||||||
|
<el-switch v-model="form.sound" />
|
||||||
|
<span class="form-hint">消息到达时播放提示音</span>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="开机自启">
|
||||||
|
<el-switch v-model="form.autoStart" :disabled="!autoStartAvailable" />
|
||||||
|
<span class="form-hint">
|
||||||
|
<template v-if="!autoStartAvailable">仅在打包后可用</template>
|
||||||
|
<template v-else>登录系统时自动启动 LocalNetMsg</template>
|
||||||
|
</span>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<el-divider content-position="left">网络</el-divider>
|
||||||
|
|
||||||
|
<div class="net-section">
|
||||||
|
<div class="net-help">
|
||||||
|
扫描会自动按所有非内部网卡发广播,无需配置。
|
||||||
|
下方只读展示当前检测到的网络接口,作为诊断用。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!ifaces.length && !loadingIfaces" class="net-empty">
|
||||||
|
未检测到网络接口
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="net-list">
|
||||||
|
<div
|
||||||
|
v-for="i in ifaces"
|
||||||
|
:key="i.name + ':' + i.address"
|
||||||
|
class="net-row"
|
||||||
|
:class="{ 'is-loopback': i.internal }"
|
||||||
|
>
|
||||||
|
<span class="net-name">{{ i.name }}</span>
|
||||||
|
<span class="net-addr">{{ i.address }}</span>
|
||||||
|
<span class="net-meta">
|
||||||
|
<span v-if="i.internal">回环</span>
|
||||||
|
<span v-else>/ {{ i.netmask }} · 广播 {{ i.broadcast }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="net-actions">
|
||||||
|
<el-button link size="small" @click="loadIfaces" :loading="loadingIfaces">
|
||||||
|
刷新
|
||||||
|
</el-button>
|
||||||
|
<span class="net-current">本机: <code>{{ currentAddress }}</code></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-divider />
|
||||||
|
|
||||||
|
<div class="meta-block">
|
||||||
|
<div class="meta-row">
|
||||||
|
<span class="meta-label">设备 ID</span>
|
||||||
|
<code class="meta-value">{{ device.self?.deviceId }}</code>
|
||||||
|
</div>
|
||||||
|
<div class="meta-row">
|
||||||
|
<span class="meta-label">版本</span>
|
||||||
|
<span class="meta-value">v{{ device.self?.appVersion }} · {{ device.self?.platform }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="settings-footer">
|
||||||
|
<span class="dirty-hint">
|
||||||
|
<span v-if="dirty">● 有未保存的修改</span>
|
||||||
|
<span v-else>✓ 已是最新</span>
|
||||||
|
</span>
|
||||||
|
<el-button @click="$emit('close')">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="saving" :disabled="!dirty" @click="save">
|
||||||
|
保存
|
||||||
|
</el-button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.settings-mask {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.35);
|
||||||
|
z-index: 100;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.settings-modal {
|
||||||
|
background: var(--el-bg-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
width: 540px;
|
||||||
|
max-height: 82vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.12);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.settings-header {
|
||||||
|
padding: 14px 20px;
|
||||||
|
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.settings-body {
|
||||||
|
padding: 16px 20px;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.settings-footer {
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-top: 1px solid var(--el-border-color-lighter);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.dirty-hint {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
.icon-btn {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.icon-btn:hover { background: var(--el-fill-color-light); }
|
||||||
|
|
||||||
|
.form-hint {
|
||||||
|
margin-left: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.net-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.net-help {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
line-height: 1.6;
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.net-empty {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.net-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.net-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 90px 1fr auto;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: var(--el-bg-color);
|
||||||
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.net-row.is-loopback {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
.net-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
font-family: ui-monospace, Consolas, monospace;
|
||||||
|
}
|
||||||
|
.net-addr {
|
||||||
|
font-family: ui-monospace, Consolas, monospace;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
}
|
||||||
|
.net-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-family: ui-monospace, Consolas, monospace;
|
||||||
|
}
|
||||||
|
.net-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.net-current {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
.net-current code {
|
||||||
|
font-family: ui-monospace, Consolas, monospace;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-block { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.meta-row { display: flex; align-items: center; gap: 12px; font-size: 13px; }
|
||||||
|
.meta-label { width: 90px; color: var(--el-text-color-secondary); }
|
||||||
|
.meta-value { color: var(--el-text-color-primary); }
|
||||||
|
code.meta-value { font-family: ui-monospace, Consolas, monospace; font-size: 12px; }
|
||||||
|
|
||||||
|
:deep(.el-form-item) { margin-bottom: 16px; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, onMounted } from 'vue'
|
||||||
|
import { useDeviceStore } from '@/stores/device'
|
||||||
|
import { useSessionStore } from '@/stores/session'
|
||||||
|
import { useMessageStore } from '@/stores/message'
|
||||||
|
import { initialsOf, colorFor, formatTime } from '@/utils/format'
|
||||||
|
import { ElAvatar, ElButton, ElTooltip, ElEmpty, ElIcon, ElTag } from 'element-plus'
|
||||||
|
import { Refresh, Setting, UserFilled } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
|
const device = useDeviceStore()
|
||||||
|
const session = useSessionStore()
|
||||||
|
const message = useMessageStore()
|
||||||
|
defineEmits<{ (e: 'open-settings'): void }>()
|
||||||
|
|
||||||
|
const self = computed(() => device.self)
|
||||||
|
|
||||||
|
function pick(d: { deviceId: string }) {
|
||||||
|
session.setActive(d.deviceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSelf(id: string) {
|
||||||
|
return self.value?.deviceId === id
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalUnread = computed(() => Object.values(device.unread).reduce((a, b) => a + b, 0))
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!self.value) device.loadSelf().then(() => {
|
||||||
|
if (self.value) message.setSelf(self.value.deviceId)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<aside class="sidebar">
|
||||||
|
<header class="sidebar-header">
|
||||||
|
<el-avatar
|
||||||
|
:size="36"
|
||||||
|
:style="{ background: colorFor(self?.deviceId || 'me'), color: '#fff', fontWeight: 600 }"
|
||||||
|
shape="square"
|
||||||
|
:src="undefined"
|
||||||
|
>
|
||||||
|
{{ initialsOf(self?.name || '我') }}
|
||||||
|
</el-avatar>
|
||||||
|
<div class="sidebar-title">
|
||||||
|
<div class="self-name">{{ self?.name || '我' }}</div>
|
||||||
|
<div class="self-meta">本机</div>
|
||||||
|
</div>
|
||||||
|
<el-tooltip content="扫描局域网" placement="bottom">
|
||||||
|
<el-button
|
||||||
|
:icon="Refresh"
|
||||||
|
circle
|
||||||
|
:loading="device.scanning"
|
||||||
|
@click="device.triggerScan()"
|
||||||
|
/>
|
||||||
|
</el-tooltip>
|
||||||
|
<el-tooltip content="设置" placement="bottom">
|
||||||
|
<el-button :icon="Setting" circle @click="$emit('open-settings')" />
|
||||||
|
</el-tooltip>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<el-scrollbar class="sidebar-body">
|
||||||
|
<div class="device-section-title">
|
||||||
|
<span>附近设备</span>
|
||||||
|
<el-tag v-if="totalUnread > 0" type="danger" size="small" round>
|
||||||
|
{{ totalUnread }} 未读
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="device.devices.length">
|
||||||
|
<div
|
||||||
|
v-for="d in device.devices"
|
||||||
|
:key="d.deviceId"
|
||||||
|
class="device-item"
|
||||||
|
:class="{ active: session.activePeerId === d.deviceId }"
|
||||||
|
@click="pick(d)"
|
||||||
|
>
|
||||||
|
<el-avatar
|
||||||
|
:size="40"
|
||||||
|
shape="square"
|
||||||
|
:style="{ background: colorFor(d.deviceId), color: '#fff', fontWeight: 600 }"
|
||||||
|
>
|
||||||
|
{{ initialsOf(d.name) }}
|
||||||
|
</el-avatar>
|
||||||
|
|
||||||
|
<div class="device-info">
|
||||||
|
<div class="device-name">
|
||||||
|
{{ d.name }}
|
||||||
|
<span v-if="d.online" class="online-pill">在线</span>
|
||||||
|
</div>
|
||||||
|
<div class="device-meta">
|
||||||
|
<template v-if="d.online">{{ d.address }}</template>
|
||||||
|
<template v-else>离线 · {{ formatTime(d.lastSeen) }}</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span
|
||||||
|
v-if="device.unread[d.deviceId]"
|
||||||
|
class="unread-pill"
|
||||||
|
>{{ device.unread[d.deviceId] > 99 ? '99+' : device.unread[d.deviceId] }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-empty
|
||||||
|
v-else
|
||||||
|
description="暂无设备"
|
||||||
|
:image-size="80"
|
||||||
|
>
|
||||||
|
<template #image>
|
||||||
|
<el-icon :size="48" color="#c9cdd4"><UserFilled /></el-icon>
|
||||||
|
</template>
|
||||||
|
<el-button type="primary" plain :icon="Refresh" @click="device.triggerScan()">
|
||||||
|
扫描局域网
|
||||||
|
</el-button>
|
||||||
|
</el-empty>
|
||||||
|
</el-scrollbar>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sidebar {
|
||||||
|
width: 280px;
|
||||||
|
min-width: 240px;
|
||||||
|
background: var(--el-bg-color);
|
||||||
|
border-right: 1px solid var(--el-border-color-lighter);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
height: 60px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.self-name { font-size: 14px; font-weight: 600; color: var(--el-text-color-primary); line-height: 1.2; }
|
||||||
|
.self-meta { font-size: 11px; color: var(--el-text-color-secondary); margin-top: 2px; }
|
||||||
|
.sidebar-title { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
|
.sidebar-body { flex: 1; padding: 4px 0 12px; }
|
||||||
|
|
||||||
|
.device-section-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 16px 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 2px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: background 0.12s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.device-item:hover { background: var(--el-fill-color-light); }
|
||||||
|
.device-item.active {
|
||||||
|
background: var(--el-color-primary-light-9);
|
||||||
|
}
|
||||||
|
.device-item.active::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: -8px; top: 8px; bottom: 8px;
|
||||||
|
width: 3px;
|
||||||
|
background: var(--el-color-primary);
|
||||||
|
border-radius: 0 2px 2px 0;
|
||||||
|
}
|
||||||
|
.device-badge :deep(.el-badge__content) { transform: translate(2px, -2px); }
|
||||||
|
|
||||||
|
.device-info { flex: 1; min-width: 0; }
|
||||||
|
.device-name {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.online-pill {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
background: var(--el-color-success-light-9);
|
||||||
|
color: var(--el-color-success);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.device-meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.unread-pill {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
right: 14px;
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0 5px;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: var(--el-color-danger);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 18px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 0 0 2px var(--el-bg-color);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import ElementPlus from 'element-plus'
|
||||||
|
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||||
|
import * as ElementPlusIcons from '@element-plus/icons-vue'
|
||||||
|
import 'element-plus/dist/index.css'
|
||||||
|
import App from './App.vue'
|
||||||
|
import './style.css'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
app.use(createPinia())
|
||||||
|
app.use(ElementPlus, { locale: zhCn })
|
||||||
|
for (const [name, comp] of Object.entries(ElementPlusIcons)) {
|
||||||
|
app.component(name, comp as any)
|
||||||
|
}
|
||||||
|
app.mount('#app')
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import type { DeviceView, DeviceSelf, Settings } from '@/api'
|
||||||
|
|
||||||
|
export const useDeviceStore = defineStore('device', () => {
|
||||||
|
const self = ref<DeviceSelf | null>(null)
|
||||||
|
const devices = ref<DeviceView[]>([])
|
||||||
|
const settings = ref<Settings | null>(null)
|
||||||
|
const unread = ref<Record<string, number>>({})
|
||||||
|
const scanning = ref(false)
|
||||||
|
const lastScanAt = ref(0)
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
const [d, s, u] = await Promise.all([
|
||||||
|
window.api.listDevices(),
|
||||||
|
window.api.getSettings(),
|
||||||
|
window.api.unreadList(),
|
||||||
|
])
|
||||||
|
devices.value = d
|
||||||
|
settings.value = s
|
||||||
|
unread.value = u
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSelf() {
|
||||||
|
self.value = await window.api.self()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function triggerScan() {
|
||||||
|
scanning.value = true
|
||||||
|
lastScanAt.value = Date.now()
|
||||||
|
await window.api.triggerScan()
|
||||||
|
setTimeout(() => { scanning.value = false }, 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onlineDevices = computed(() => devices.value.filter(d => d.online))
|
||||||
|
|
||||||
|
return {
|
||||||
|
self, devices, settings, unread, scanning, lastScanAt,
|
||||||
|
refresh, loadSelf, triggerScan,
|
||||||
|
onlineDevices,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import type { MessageView } from '@/api'
|
||||||
|
|
||||||
|
export interface FileProgress {
|
||||||
|
sent: number
|
||||||
|
total: number
|
||||||
|
ts: number // 上次更新的时间戳
|
||||||
|
speed: number // bytes/s
|
||||||
|
}
|
||||||
|
|
||||||
|
// 每个 peer 一组消息, key = peerId
|
||||||
|
export const useMessageStore = defineStore('message', () => {
|
||||||
|
const byPeer = ref<Record<string, MessageView[]>>({})
|
||||||
|
const loaded = ref<Record<string, boolean>>({})
|
||||||
|
const loading = ref<Record<string, boolean>>({})
|
||||||
|
// fileId -> { sent, total }, 给 MessageItem 显示进度条
|
||||||
|
const progressByFileId = ref<Record<string, FileProgress>>({})
|
||||||
|
|
||||||
|
async function ensureLoaded(peerId: string) {
|
||||||
|
if (loaded.value[peerId] || loading.value[peerId]) return
|
||||||
|
loading.value[peerId] = true
|
||||||
|
const list = await window.api.listMessages(peerId)
|
||||||
|
byPeer.value[peerId] = list
|
||||||
|
loaded.value[peerId] = true
|
||||||
|
loading.value[peerId] = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function append(msg: MessageView) {
|
||||||
|
// 落到对应 peer 的桶里 (自己发出 / 收到 都放进对方桶)
|
||||||
|
const peerId = msg.fromDeviceId === selfId.value ? msg.toDeviceId : msg.fromDeviceId
|
||||||
|
if (!byPeer.value[peerId]) byPeer.value[peerId] = []
|
||||||
|
// 去重
|
||||||
|
const arr = byPeer.value[peerId]
|
||||||
|
if (arr.some(m => m.messageId === msg.messageId)) return
|
||||||
|
arr.push(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已存在则替换 (以 messageId 为 key), 不存在则 push
|
||||||
|
function upsert(msg: MessageView) {
|
||||||
|
const peerId = msg.fromDeviceId === selfId.value ? msg.toDeviceId : msg.fromDeviceId
|
||||||
|
if (!byPeer.value[peerId]) byPeer.value[peerId] = []
|
||||||
|
const arr = byPeer.value[peerId]
|
||||||
|
const i = arr.findIndex(m => m.messageId === msg.messageId)
|
||||||
|
if (i >= 0) {
|
||||||
|
arr[i] = { ...arr[i], ...msg }
|
||||||
|
} else {
|
||||||
|
arr.push(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStatus(messageId: string, status: string) {
|
||||||
|
for (const arr of Object.values(byPeer.value)) {
|
||||||
|
const m = arr.find(x => x.messageId === messageId)
|
||||||
|
if (m) { m.status = status; return }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(messageId: string) {
|
||||||
|
for (const peerId of Object.keys(byPeer.value)) {
|
||||||
|
const arr = byPeer.value[peerId]
|
||||||
|
const i = arr.findIndex(x => x.messageId === messageId)
|
||||||
|
if (i >= 0) arr.splice(i, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setProgress(fileId: string, sent: number, total: number) {
|
||||||
|
if (!fileId) return
|
||||||
|
const now = Date.now()
|
||||||
|
const cur = progressByFileId.value[fileId]
|
||||||
|
// 全传完 (>= 99.99% 算完成, 容忍单个字节误差 / size=0 文件边缘 case) -> 自动清除
|
||||||
|
if (total > 0 && sent >= total) {
|
||||||
|
console.debug(`[setProgress] ${fileId.slice(0, 8)} 100% (${sent}/${total}) -> delete`)
|
||||||
|
delete progressByFileId.value[fileId]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 速率: 这次 - 上次的差 (字节/ms)
|
||||||
|
let speed = 0
|
||||||
|
if (cur && cur.sent !== undefined && now > cur.ts) {
|
||||||
|
const dt = now - cur.ts
|
||||||
|
const ds = sent - cur.sent
|
||||||
|
if (dt > 0 && ds >= 0) speed = (ds / dt) * 1000 // bytes/s
|
||||||
|
}
|
||||||
|
// 防御: 不创建 sent=0 entry (这是首次广播时的占位, 但若此后一直收不到 progress 事件,
|
||||||
|
// 就会停在 0%. 改为不存, 等真实进度来了再创建)
|
||||||
|
if (sent > 0 || cur) {
|
||||||
|
progressByFileId.value[fileId] = { sent, total, ts: now, speed }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearProgress(fileId: string) {
|
||||||
|
if (!fileId) return
|
||||||
|
delete progressByFileId.value[fileId]
|
||||||
|
}
|
||||||
|
|
||||||
|
const selfId = ref('')
|
||||||
|
function setSelf(id: string) { selfId.value = id }
|
||||||
|
|
||||||
|
return {
|
||||||
|
byPeer, loaded, loading, progressByFileId,
|
||||||
|
ensureLoaded, append, upsert, updateStatus, remove, setSelf,
|
||||||
|
setProgress, clearProgress,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
export const useSessionStore = defineStore('session', () => {
|
||||||
|
const activePeerId = ref<string | null>(null)
|
||||||
|
const peerTyping = ref<Record<string, boolean>>({})
|
||||||
|
|
||||||
|
function setActive(id: string | null) { activePeerId.value = id }
|
||||||
|
function setTyping(deviceId: string, v: boolean) {
|
||||||
|
peerTyping.value[deviceId] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
return { activePeerId, peerTyping, setActive, setTyping }
|
||||||
|
})
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
/* 全局变量 / 滚动条 / 杂项
|
||||||
|
大部分组件样式由 Element Plus 提供,这里只放领域特定的部分 */
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
html, body, #app {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
user-select: none;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, textarea {
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 允许消息内容文本选择 */
|
||||||
|
.bubble, .composer-input { user-select: text; }
|
||||||
|
|
||||||
|
/* 滚动条 */
|
||||||
|
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--el-border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: var(--el-border-color-darker); }
|
||||||
|
|
||||||
|
/* App 主布局 */
|
||||||
|
.app-shell {
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
width: 100vw;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 系统级 toast (本地内联元素) */
|
||||||
|
.toast {
|
||||||
|
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
|
||||||
|
background: rgba(0, 0, 0, 0.78); color: #fff;
|
||||||
|
padding: 8px 16px; border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
z-index: 9999;
|
||||||
|
animation: toast-in 0.2s;
|
||||||
|
}
|
||||||
|
@keyframes toast-in {
|
||||||
|
from { opacity: 0; transform: translate(-50%, 8px); }
|
||||||
|
to { opacity: 1; transform: translate(-50%, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 图片查看器 */
|
||||||
|
.image-viewer {
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.85);
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
cursor: zoom-out;
|
||||||
|
}
|
||||||
|
.image-viewer img { max-width: 92%; max-height: 92%; object-fit: contain; }
|
||||||
|
|
||||||
|
/* 微调 Element Plus 在暗色背景下的对比 */
|
||||||
|
.el-message-box, .el-message { z-index: 10000 !important; }
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// 工具: 颜色, 时间格式, 头像首字母
|
||||||
|
export function initialsOf(name: string): string {
|
||||||
|
if (!name) return '?'
|
||||||
|
// 取第一个非空白字符; 中文取首字
|
||||||
|
const trimmed = name.trim()
|
||||||
|
if (!trimmed) return '?'
|
||||||
|
// 中文 unicode 范围粗判
|
||||||
|
const first = trimmed[0]
|
||||||
|
return first.toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
const PALETTE = [
|
||||||
|
'#3370ff', '#0fc6c2', '#ff9a00', '#f54a45',
|
||||||
|
'#7b61ff', '#34c759', '#ff375f', '#5e5ce6',
|
||||||
|
'#30b0c7', '#ff9f0a', '#bf5af2', '#ff6482',
|
||||||
|
]
|
||||||
|
|
||||||
|
export function colorFor(seed: string): string {
|
||||||
|
let h = 0
|
||||||
|
for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) >>> 0
|
||||||
|
return PALETTE[h % PALETTE.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTime(ts: number, opts: 'time' | 'date' | 'datetime' | 'smart' = 'time'): string {
|
||||||
|
const d = new Date(ts)
|
||||||
|
const now = new Date()
|
||||||
|
const sameDay = d.toDateString() === now.toDateString()
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
if (opts === 'time') return `${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||||
|
if (opts === 'date') return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||||
|
if (opts === 'datetime') return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||||
|
// smart: 今天只时间, 昨天 "昨天 HH:mm", 更早 日期
|
||||||
|
const diff = Math.floor((now.getTime() - d.getTime()) / 86400000)
|
||||||
|
if (diff === 0 && sameDay) return `${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||||
|
if (diff === 1) return `昨天 ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||||
|
if (diff < 7) {
|
||||||
|
const wk = ['日', '一', '二', '三', '四', '五', '六'][d.getDay()]
|
||||||
|
return `周${wk} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||||
|
}
|
||||||
|
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return bytes + ' B'
|
||||||
|
const units = ['KB', 'MB', 'GB', 'TB']
|
||||||
|
let n = bytes / 1024, i = 0
|
||||||
|
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++ }
|
||||||
|
return n.toFixed(n < 10 ? 1 : 0) + ' ' + units[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isImage(mime: string): boolean {
|
||||||
|
return /^image\//i.test(mime)
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"strict": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"types": [],
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": { "@/*": ["src/*"] }
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "env.d.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.node.json" },
|
||||||
|
{ "path": "./src/renderer/tsconfig.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"types": ["node", "electron-vite/node"]
|
||||||
|
},
|
||||||
|
"include": ["src/main/**/*.ts", "src/preload/**/*.ts", "electron.vite.config.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user