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