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
+11
View File
@@ -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
}
+12
View File
@@ -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>
+172
View File
@@ -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>
+106
View File
@@ -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
}
+231
View File
@@ -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>
+406
View File
@@ -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>
+227
View File
@@ -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>
+16
View File
@@ -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')
+42
View File
@@ -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,
}
})
+104
View File
@@ -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,
}
})
+14
View File
@@ -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 }
})
+75
View File
@@ -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; }
+53
View File
@@ -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)
}
+20
View File
@@ -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"]
}