initial: LAN IM desktop app (Electron 32 + Vue 3 + TS + SQLite)

This commit is contained in:
2026-07-15 18:31:13 +08:00
commit 07c7260a3c
43 changed files with 12320 additions and 0 deletions
+226
View File
@@ -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
}