initial: LAN IM desktop app (Electron 32 + Vue 3 + TS + SQLite)
This commit is contained in:
+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
|
||||
}
|
||||
Reference in New Issue
Block a user