chore: remote forwarding integration
This commit is contained in:
+55
-27
@@ -1,18 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, computed, watch, nextTick, onUnmounted } from 'vue'
|
||||
import { onMounted, ref, computed, watch, onUnmounted } from 'vue'
|
||||
import { useDeviceStore } from '@/stores/device'
|
||||
import { useMessageStore } from '@/stores/message'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { useTerminalStore, useForwardStore, useUsbStore, useApprovalStore } from '@/stores/remote'
|
||||
import Sidebar from '@/components/Sidebar.vue'
|
||||
import ChatView from '@/components/ChatView.vue'
|
||||
import SettingsView from '@/components/SettingsView.vue'
|
||||
import ContextMenu from '@/components/ContextMenu.vue'
|
||||
import ApprovalDialog from '@/components/remote/ApprovalDialog.vue'
|
||||
import { formatTime } from '@/utils/format'
|
||||
import type { DeviceView, MessageView, Settings } from '@/api'
|
||||
|
||||
const device = useDeviceStore()
|
||||
const message = useMessageStore()
|
||||
const session = useSessionStore()
|
||||
const terminal = useTerminalStore()
|
||||
const forward = useForwardStore()
|
||||
const usb = useUsbStore()
|
||||
const approval = useApprovalStore()
|
||||
|
||||
const showSettings = ref(false)
|
||||
const imageViewer = ref<string | null>(null)
|
||||
@@ -30,6 +36,8 @@ function showToast(text: string) {
|
||||
setTimeout(() => el.remove(), 2200)
|
||||
}
|
||||
|
||||
const unsubs: Array<() => void> = []
|
||||
|
||||
onMounted(async () => {
|
||||
await device.loadSelf()
|
||||
if (device.self) message.setSelf(device.self.deviceId)
|
||||
@@ -37,34 +45,34 @@ onMounted(async () => {
|
||||
// 启动后尝试一次 flush pending
|
||||
setTimeout(() => { window.api.flushPending() }, 1500)
|
||||
|
||||
window.api.on('boot:ready', () => { device.refresh() })
|
||||
window.api.on('device:found', (d: DeviceView) => {
|
||||
unsubs.push(window.api.on('boot:ready', () => { device.refresh() }))
|
||||
unsubs.push(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) => {
|
||||
}))
|
||||
unsubs.push(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 }) => {
|
||||
}))
|
||||
unsubs.push(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 }) => {
|
||||
}))
|
||||
unsubs.push(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 }) => {
|
||||
}))
|
||||
unsubs.push(window.api.on('device:offline', ({ deviceId }: { deviceId: string }) => {
|
||||
const d = device.devices.find(x => x.deviceId === deviceId)
|
||||
if (d) d.online = false
|
||||
})
|
||||
}))
|
||||
// 我们 outgoing WS 接通/断开 — 让 chat header 能区分 "discovery 在线" 和 "WS 在线"
|
||||
window.api.on('device:wsState', ({ deviceId, wsOpen }: { deviceId: string; wsOpen: boolean }) => {
|
||||
unsubs.push(window.api.on('device:wsState', ({ deviceId, wsOpen }: { deviceId: string; wsOpen: boolean }) => {
|
||||
device.setWsOpen(deviceId, wsOpen)
|
||||
})
|
||||
window.api.on('message:received', (env: MessageView) => {
|
||||
}))
|
||||
unsubs.push(window.api.on('message:received', (env: MessageView) => {
|
||||
const fromId = env.fromDeviceId
|
||||
// 接收也用 upsert: WS metadata 帧 + (后台上传完后) WS update 帧, 同 messageId 来两次
|
||||
// 第二次带 savedPath, 气泡自动有"打开/位置"
|
||||
@@ -107,8 +115,8 @@ onMounted(async () => {
|
||||
else if (env.type === 'file') body = `[文件] ${(env.body as any).name || ''}`
|
||||
if (body) showToast(`${name}: ${body}`)
|
||||
})
|
||||
})
|
||||
window.api.on('message:local', (env: MessageView) => {
|
||||
}))
|
||||
unsubs.push(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
|
||||
@@ -119,15 +127,15 @@ onMounted(async () => {
|
||||
}
|
||||
// 用 upsert: 同一个 messageId 可能来多次 (第一次 status='sending'/savedPath=null, 第二次已 fill)
|
||||
message.upsert({ ...env, status: env.status || 'pending' })
|
||||
})
|
||||
window.api.on('message:recalled', ({ messageId }: { messageId: string }) => {
|
||||
}))
|
||||
unsubs.push(window.api.on('message:recalled', ({ messageId }: { messageId: string }) => {
|
||||
message.remove(messageId)
|
||||
})
|
||||
window.api.on('message:progress', (e: any) => {
|
||||
}))
|
||||
unsubs.push(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 }) => {
|
||||
}))
|
||||
unsubs.push(window.api.on('message:statusChanged', ({ messageId, status }: { messageId: string; status: string }) => {
|
||||
// 终态才清 progress: 'sent' 在新流程里出现得太早 (WS metadata 已发, 但上传还在后台跑)
|
||||
// 让 setProgress 在 100% 时自动清, 避免误清
|
||||
if (status === 'delivered' || status === 'failed') {
|
||||
@@ -137,11 +145,30 @@ onMounted(async () => {
|
||||
}
|
||||
}
|
||||
message.updateStatus(messageId, status)
|
||||
})
|
||||
window.api.on('message:focus', ({ fromDeviceId }: { fromDeviceId: string }) => {
|
||||
}))
|
||||
unsubs.push(window.api.on('message:focus', ({ fromDeviceId }: { fromDeviceId: string }) => {
|
||||
session.setActive(fromDeviceId)
|
||||
})
|
||||
window.api.on('settings:changed', (s: Settings) => { device.settings = s })
|
||||
}))
|
||||
unsubs.push(window.api.on('settings:changed', (s: Settings) => { device.settings = s }))
|
||||
|
||||
// 远程事件订阅
|
||||
unsubs.push(window.api.on('remote:terminal:output', (p) => terminal.onOutput(p)))
|
||||
unsubs.push(window.api.on('remote:terminal:opened', (p) => terminal.onOpened(p)))
|
||||
unsubs.push(window.api.on('remote:terminal:closed', (p) => terminal.onClosed(p)))
|
||||
unsubs.push(window.api.on('remote:forward:opened', (p) => forward.onOpened(p)))
|
||||
unsubs.push(window.api.on('remote:forward:closed', (p) => forward.onClosed(p)))
|
||||
unsubs.push(window.api.on('remote:usb:opened', () => { /* no-op; refresh in component */ }))
|
||||
unsubs.push(window.api.on('remote:usb:closed', (p) => usb.onClosed(p)))
|
||||
unsubs.push(window.api.on('remote:usb:output', (p) => usb.onOutput(p)))
|
||||
unsubs.push(window.api.on('remote:usb:error', (p) => { /* surface via console for now */ console.warn('[usb]', p) }))
|
||||
unsubs.push(window.api.on('remote:approval:requested', (req) => approval.add(req)))
|
||||
|
||||
// 启动时拉取当前 approval 队列 (防止刚启动就出现未读请求)
|
||||
approval.refresh().catch(() => {})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
for (const u of unsubs) try { u() } catch {}
|
||||
})
|
||||
|
||||
watch(() => session.activePeerId, async (id) => {
|
||||
@@ -190,5 +217,6 @@ document.addEventListener('click', (e) => {
|
||||
<img :src="imageViewer" />
|
||||
</div>
|
||||
<ContextMenu />
|
||||
<ApprovalDialog />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+180
-3
@@ -1,5 +1,7 @@
|
||||
// 与主进程 preload 暴露的 window.api 类型一一对应
|
||||
export type LnmApi = {
|
||||
export type LnmApi = LnmApiBase & LnmApiExtra
|
||||
|
||||
export type LnmApiBase = {
|
||||
platform: string
|
||||
versions: Record<string, string | undefined>
|
||||
|
||||
@@ -41,7 +43,6 @@ export type LnmApi = {
|
||||
|
||||
on: (channel: string, cb: (payload: any) => void) => () => void
|
||||
|
||||
// 触发主进程对所有 pending 消息做一次 flush 尝试
|
||||
flushPending: () => Promise<{ ok: boolean; flushed: number }>
|
||||
}
|
||||
|
||||
@@ -88,6 +89,12 @@ export interface Settings {
|
||||
theme: 'light' | 'dark'
|
||||
preferredInterface?: string
|
||||
preferredAddress?: string
|
||||
remoteEnabled?: boolean
|
||||
remoteAllowPeers?: Record<string, { terminal?: boolean; forward?: boolean; usb?: boolean }>
|
||||
forwardDefaultTtlSec?: number
|
||||
forwardMaxBytesPerSec?: number
|
||||
terminalReadOnlyByDefault?: boolean
|
||||
auditRetentionDays?: number
|
||||
}
|
||||
|
||||
export interface NetInterface {
|
||||
@@ -109,10 +116,180 @@ export interface ProgressEvent {
|
||||
}
|
||||
|
||||
// 引用回复快照 (与 main/protocol.ts 的 ReplyRef 保持一致)
|
||||
// 仅携带足够渲染作者 + 内容预览的字段 — 原消息被撤回/删除也不影响这条引用
|
||||
export interface ReplyRef {
|
||||
messageId: string
|
||||
authorName: string
|
||||
type: MessageType
|
||||
preview: string
|
||||
}
|
||||
|
||||
// 远程: 终端
|
||||
export interface TerminalSessionInfo {
|
||||
sessionId: string
|
||||
peerId: string
|
||||
shell: string
|
||||
rows: number
|
||||
cols: number
|
||||
readOnly: boolean
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
// 远程: 端口转发
|
||||
export type ForwardDirection = 'self-out' | 'self-in'
|
||||
|
||||
export interface ForwardSessionInfo {
|
||||
sessionId: string
|
||||
peerId: string
|
||||
direction: ForwardDirection
|
||||
listenPort?: number
|
||||
targetHost: string
|
||||
targetPort: number
|
||||
bytesIn: number
|
||||
bytesOut: number
|
||||
expiresAt: number
|
||||
side: 'client' | 'server'
|
||||
}
|
||||
|
||||
// 远程: USB / 串口 (3 种模式: serial 字节流 / usb libusb 字节桥 / usbip 真透传)
|
||||
export type UsbDirection = 'self-out' | 'self-in'
|
||||
export type UsbKind = 'serial' | 'usb' | 'usbip'
|
||||
|
||||
export interface UsbDeviceInfo {
|
||||
busId: string
|
||||
vid: number
|
||||
pid: number
|
||||
deviceClass: number
|
||||
deviceSubclass: number
|
||||
product?: string
|
||||
manufacturer?: string
|
||||
serialNumber?: string
|
||||
kind: UsbKind
|
||||
serialPath?: string
|
||||
baudRate?: number
|
||||
}
|
||||
|
||||
export interface UsbEndpointInfo {
|
||||
endpointNumber: number
|
||||
direction: 'in' | 'out'
|
||||
transferType: 'control' | 'bulk' | 'interrupt' | 'isochronous'
|
||||
packetSize: number
|
||||
}
|
||||
|
||||
export interface UsbAttachedInfo {
|
||||
kind: UsbKind
|
||||
endpoints?: UsbEndpointInfo[]
|
||||
serialPath?: string
|
||||
userVirtualPath?: string // 本机虚拟串口路径 (createVirtual=true 时填)
|
||||
vid?: number
|
||||
pid?: number
|
||||
product?: string
|
||||
manufacturer?: string
|
||||
serialNumber?: string
|
||||
}
|
||||
|
||||
export type UsbAttachConfig =
|
||||
// serial: 字节流转发
|
||||
| {
|
||||
kind: 'serial'
|
||||
baudRate?: number
|
||||
dataBits?: 5 | 6 | 7 | 8
|
||||
stopBits?: 1 | 2
|
||||
parity?: 'none' | 'even' | 'odd' | 'mark' | 'space'
|
||||
/** 在本机创建一个虚拟串口, 桥接到远端的真实串口 — 用户可在 PuTTY/Arduino IDE 打开该路径 */
|
||||
createVirtual?: boolean
|
||||
/** 可选: 用户指定的虚拟串口名 (Windows: "COM5"; Linux/macOS: 留空自动分配) */
|
||||
virtualName?: string
|
||||
}
|
||||
// usb: libusb 字节桥
|
||||
| {
|
||||
kind: 'usb'
|
||||
configurationValue?: number
|
||||
interfaceNumber?: number
|
||||
detachKernelDriver?: boolean
|
||||
}
|
||||
// usbip: Linux only, 真透明透传 (需内核模块)
|
||||
| {
|
||||
kind: 'usbip'
|
||||
}
|
||||
|
||||
export interface UsbSessionInfo {
|
||||
sessionId: string
|
||||
peerId: string
|
||||
direction: UsbDirection
|
||||
busId: string
|
||||
kind: UsbKind
|
||||
info?: UsbAttachedInfo
|
||||
bytesIn: number
|
||||
bytesOut: number
|
||||
createdAt: number
|
||||
side: 'client' | 'server'
|
||||
}
|
||||
|
||||
export interface UsbTransferResult {
|
||||
ok: boolean
|
||||
data?: number[] // bytes (UI 解码为 Uint8Array, 因为 renderer 端没 Buffer)
|
||||
status?: number
|
||||
}
|
||||
|
||||
// 远程: 授权请求
|
||||
export interface ApprovalRequestView {
|
||||
requestId: string
|
||||
peerId: string
|
||||
peerName: string
|
||||
kind: 'terminal' | 'forward' | 'usb'
|
||||
detail: string
|
||||
ts: number
|
||||
}
|
||||
|
||||
// 远程: 审计
|
||||
export interface AuditEntry {
|
||||
id: number
|
||||
ts: number
|
||||
action: string
|
||||
source_device: string | null
|
||||
target_device: string | null
|
||||
session_id: string | null
|
||||
payload_json: string | null
|
||||
result: string
|
||||
bytes_in: number
|
||||
bytes_out: number
|
||||
}
|
||||
|
||||
// 扩展 API
|
||||
export type LnmApiExtra = {
|
||||
terminalOpen: (peerId: string, opts?: { rows?: number; cols?: number; readOnly?: boolean }) =>
|
||||
Promise<{ ok: boolean; reason?: string; sessionId?: string }>
|
||||
terminalInput: (sessionId: string, dataBase64: string) => Promise<boolean>
|
||||
terminalResize: (sessionId: string, rows: number, cols: number) => Promise<boolean>
|
||||
terminalClose: (sessionId: string, reason?: string) => Promise<boolean>
|
||||
terminalListSessions: () => Promise<{ client: TerminalSessionInfo[]; server: TerminalSessionInfo[] }>
|
||||
|
||||
forwardOpen: (peerId: string, args: { direction?: ForwardDirection; listenPort: number; targetHost: string; targetPort: number; ttlSec?: number }) =>
|
||||
Promise<{ ok: boolean; reason?: string; sessionId?: string }>
|
||||
forwardClose: (sessionId: string) => Promise<boolean>
|
||||
forwardListSessions: () => Promise<{ client: ForwardSessionInfo[]; server: ForwardSessionInfo[] }>
|
||||
|
||||
usbList: (peerId: string) => Promise<{ ok: boolean; reason?: string; devices?: UsbDeviceInfo[] }>
|
||||
usbListLocal: () => Promise<{ ok: boolean; reason?: string; devices?: UsbDeviceInfo[] }>
|
||||
usbAttach: (peerId: string, busId: string, opts: { direction?: UsbDirection; config: UsbAttachConfig }) =>
|
||||
Promise<{ ok: boolean; reason?: string; sessionId?: string }>
|
||||
// 串口: 发送字节
|
||||
usbSerialSend: (sessionId: string, dataBase64: string) => Promise<boolean>
|
||||
// USB: 控制传输
|
||||
usbCtrlOut: (sessionId: string, setup: { requestType: number; request: number; value: number; index: number }, dataBase64?: string) =>
|
||||
Promise<{ ok: boolean; status?: number }>
|
||||
usbCtrlIn: (sessionId: string, setup: { requestType: number; request: number; value: number; index: number }, length: number) =>
|
||||
Promise<{ ok: boolean; dataBase64?: string; status?: number }>
|
||||
// USB: 批量传输
|
||||
usbBulkOut: (sessionId: string, endpoint: number, dataBase64: string) =>
|
||||
Promise<{ ok: boolean; status?: number }>
|
||||
usbBulkIn: (sessionId: string, endpoint: number, length: number, timeoutMs?: number) =>
|
||||
Promise<{ ok: boolean; dataBase64?: string; status?: number }>
|
||||
usbDetach: (sessionId: string) => Promise<{ ok: boolean; reason?: string }>
|
||||
usbListSessions: () => Promise<{ client: UsbSessionInfo[]; server: UsbSessionInfo[] }>
|
||||
|
||||
remoteApprovalList: () => Promise<ApprovalRequestView[]>
|
||||
remoteApprovalReply: (requestId: string, ok: boolean, remember?: boolean) => Promise<boolean>
|
||||
auditList: (args?: { limit?: number; since?: number; sessionId?: string }) => Promise<AuditEntry[]>
|
||||
auditPrune: (olderThanMs?: number) => Promise<number>
|
||||
}
|
||||
@@ -5,10 +5,13 @@ import { useMessageStore } from '@/stores/message'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import MessageItem from './MessageItem.vue'
|
||||
import MessageInput from './MessageInput.vue'
|
||||
import TerminalPanel from './remote/TerminalPanel.vue'
|
||||
import ForwardPanel from './remote/ForwardPanel.vue'
|
||||
import UsbPanel from './remote/UsbPanel.vue'
|
||||
import type { DeviceView } from '@/api'
|
||||
import { initialsOf, colorFor, formatTime } from '@/utils/format'
|
||||
import { ElAvatar, ElButton, ElEmpty, ElIcon, ElScrollbar, ElMessage } from 'element-plus'
|
||||
import { ChatLineRound, Promotion, UploadFilled } from '@element-plus/icons-vue'
|
||||
import { ChatLineRound, Promotion, UploadFilled, Position, Connection, Cellphone } from '@element-plus/icons-vue'
|
||||
|
||||
const props = defineProps<{ peer: DeviceView | null }>()
|
||||
const emit = defineEmits<{ (e: 'open-image', url: string): void }>()
|
||||
@@ -17,6 +20,11 @@ const device = useDeviceStore()
|
||||
const message = useMessageStore()
|
||||
const session = useSessionStore()
|
||||
|
||||
type Tab = 'chat' | 'terminal' | 'forward' | 'usb'
|
||||
const activeTab = ref<Tab>('chat')
|
||||
// 切到别的设备时回到 chat tab
|
||||
watch(() => session.activePeerId, () => { activeTab.value = 'chat' })
|
||||
|
||||
const self = computed(() => device.self)
|
||||
const messages = computed(() => session.activePeerId ? (message.byPeer[session.activePeerId] || []) : [])
|
||||
const typingPeer = computed(() => session.activePeerId ? session.peerTyping[session.activePeerId] : false)
|
||||
@@ -145,40 +153,64 @@ async function onGlobalDrop(e: DragEvent) {
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="header-tabs">
|
||||
<button :class="{ active: activeTab === 'chat' }" @click="activeTab = 'chat'">
|
||||
<el-icon><ChatLineRound /></el-icon><span>聊天</span>
|
||||
</button>
|
||||
<button :class="{ active: activeTab === 'terminal' }" @click="activeTab = 'terminal'" :disabled="!peer.online">
|
||||
<el-icon><Position /></el-icon><span>终端</span>
|
||||
</button>
|
||||
<button :class="{ active: activeTab === 'forward' }" @click="activeTab = 'forward'" :disabled="!peer.online">
|
||||
<el-icon><Connection /></el-icon><span>转发</span>
|
||||
</button>
|
||||
<button :class="{ active: activeTab === 'usb' }" @click="activeTab = 'usb'" :disabled="!peer.online">
|
||||
<el-icon><Cellphone /></el-icon><span>USB</span>
|
||||
</button>
|
||||
</nav>
|
||||
</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)"
|
||||
@scroll-to-message="onScrollToMessage"
|
||||
/>
|
||||
<!-- chat tab -->
|
||||
<template v-if="activeTab === 'chat'">
|
||||
<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)"
|
||||
@scroll-to-message="onScrollToMessage"
|
||||
/>
|
||||
</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>
|
||||
</el-scrollbar>
|
||||
</template>
|
||||
|
||||
<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>
|
||||
<!-- terminal tab -->
|
||||
<TerminalPanel v-else-if="activeTab === 'terminal'" :peer="peer" :active="activeTab === 'terminal'" />
|
||||
<!-- forward tab -->
|
||||
<ForwardPanel v-else-if="activeTab === 'forward'" :peer="peer" />
|
||||
<!-- usb tab -->
|
||||
<UsbPanel v-else-if="activeTab === 'usb'" :peer="peer" />
|
||||
</main>
|
||||
<main class="chat-main chat-main-empty" v-else>
|
||||
<el-empty :image-size="120" description="选择一个设备开始聊天">
|
||||
@@ -246,6 +278,36 @@ async function onGlobalDrop(e: DragEvent) {
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.header-tabs {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 8px;
|
||||
padding: 3px;
|
||||
}
|
||||
.header-tabs button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 5px 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-regular);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.header-tabs button:hover:not(:disabled) { color: var(--el-text-color-primary); }
|
||||
.header-tabs button.active {
|
||||
background: var(--el-bg-color);
|
||||
color: var(--el-color-primary);
|
||||
font-weight: 500;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
.header-tabs button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
.chat-body { flex: 1; min-height: 0; }
|
||||
.chat-body-inner { padding: 16px 20px 0; }
|
||||
.typing-indicator {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, computed } from 'vue'
|
||||
import { useDeviceStore } from '@/stores/device'
|
||||
import { useAuditStore } from '@/stores/remote'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { NetInterface } from '@/api'
|
||||
import type { NetInterface, AuditEntry } from '@/api'
|
||||
|
||||
const emit = defineEmits<{ (e: 'close'): void }>()
|
||||
const device = useDeviceStore()
|
||||
const audit = useAuditStore()
|
||||
|
||||
interface FormState {
|
||||
deviceName: string
|
||||
@@ -14,6 +16,11 @@ interface FormState {
|
||||
sound: boolean
|
||||
autoStart: boolean
|
||||
theme: 'light' | 'dark'
|
||||
remoteEnabled: boolean
|
||||
forwardDefaultTtlSec: number
|
||||
forwardMaxBytesPerSec: number
|
||||
terminalReadOnlyByDefault: boolean
|
||||
auditRetentionDays: number
|
||||
}
|
||||
|
||||
const form = ref<FormState>({
|
||||
@@ -23,6 +30,11 @@ const form = ref<FormState>({
|
||||
sound: true,
|
||||
autoStart: false,
|
||||
theme: 'light',
|
||||
remoteEnabled: true,
|
||||
forwardDefaultTtlSec: 3600,
|
||||
forwardMaxBytesPerSec: 10 * 1024 * 1024,
|
||||
terminalReadOnlyByDefault: false,
|
||||
auditRetentionDays: 30,
|
||||
})
|
||||
|
||||
const loading = ref(true)
|
||||
@@ -40,6 +52,7 @@ watch(() => device.settings, (s) => {
|
||||
|
||||
onMounted(async () => {
|
||||
await loadIfaces()
|
||||
await audit.refresh(100)
|
||||
loading.value = false
|
||||
})
|
||||
|
||||
@@ -52,6 +65,11 @@ function hydrate(s: any) {
|
||||
sound: s.sound ?? true,
|
||||
autoStart: s.autoStart ?? false,
|
||||
theme: s.theme ?? 'light',
|
||||
remoteEnabled: s.remoteEnabled ?? true,
|
||||
forwardDefaultTtlSec: s.forwardDefaultTtlSec ?? 3600,
|
||||
forwardMaxBytesPerSec: s.forwardMaxBytesPerSec ?? 10 * 1024 * 1024,
|
||||
terminalReadOnlyByDefault: s.terminalReadOnlyByDefault ?? false,
|
||||
auditRetentionDays: s.auditRetentionDays ?? 30,
|
||||
}
|
||||
hydrating.value = false
|
||||
}
|
||||
@@ -84,6 +102,11 @@ async function save() {
|
||||
sound: form.value.sound,
|
||||
theme: form.value.theme,
|
||||
autoStart: form.value.autoStart,
|
||||
remoteEnabled: form.value.remoteEnabled,
|
||||
forwardDefaultTtlSec: form.value.forwardDefaultTtlSec,
|
||||
forwardMaxBytesPerSec: form.value.forwardMaxBytesPerSec,
|
||||
terminalReadOnlyByDefault: form.value.terminalReadOnlyByDefault,
|
||||
auditRetentionDays: form.value.auditRetentionDays,
|
||||
})
|
||||
device.settings = r
|
||||
dirty.value = false
|
||||
@@ -95,7 +118,30 @@ async function save() {
|
||||
}
|
||||
}
|
||||
|
||||
const formLabelWidth = '90px'
|
||||
async function pruneAudit() {
|
||||
const n = await audit.prune()
|
||||
ElMessage.success(`已清理 ${n} 条审计`)
|
||||
await audit.refresh(100)
|
||||
}
|
||||
|
||||
function fmtTime(ts: number) {
|
||||
return new Date(ts).toLocaleString()
|
||||
}
|
||||
|
||||
function actionLabel(a: string) {
|
||||
return ({
|
||||
'terminal.open': '终端 打开',
|
||||
'terminal.exit': '终端 退出',
|
||||
'terminal.close': '终端 关闭',
|
||||
'forward.open': '转发 开启',
|
||||
'forward.close': '转发 关闭',
|
||||
'usb.list': 'USB 列表',
|
||||
'usb.attach': 'USB 附加',
|
||||
'usb.detach': 'USB 分离',
|
||||
} as Record<string, string>)[a] || a
|
||||
}
|
||||
|
||||
const formLabelWidth = '110px'
|
||||
|
||||
const externalIfaces = computed(() => ifaces.value.filter(i => !i.internal))
|
||||
const currentAddress = computed(() => device.self?.address || '-')
|
||||
@@ -147,6 +193,64 @@ const currentAddress = computed(() => device.self?.address || '-')
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-divider content-position="left">远程 (终端 / 转发 / USB)</el-divider>
|
||||
|
||||
<el-form label-position="left" :label-width="formLabelWidth">
|
||||
<el-form-item label="启用远程">
|
||||
<el-switch v-model="form.remoteEnabled" />
|
||||
<span class="form-hint">关闭后所有远程请求一律拒绝 (对方仍可尝试连接)</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="默认 TTL">
|
||||
<el-input-number v-model="form.forwardDefaultTtlSec" :min="30" :max="86400" />
|
||||
<span class="form-hint">秒, 默认 1h, 最大 24h</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="速率上限">
|
||||
<el-input-number v-model="form.forwardMaxBytesPerSec" :min="0" :step="1024 * 1024" />
|
||||
<span class="form-hint">bytes/s, 0 = 不限</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="终端默认只读">
|
||||
<el-switch v-model="form.terminalReadOnlyByDefault" />
|
||||
<span class="form-hint">默认对方只能看不能输入</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="审计保留">
|
||||
<el-input-number v-model="form.auditRetentionDays" :min="1" :max="365" />
|
||||
<span class="form-hint">天, 启动时自动清理</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-divider content-position="left">最近审计</el-divider>
|
||||
|
||||
<div class="audit-section">
|
||||
<el-button link size="small" @click="audit.refresh(100)">刷新</el-button>
|
||||
<el-button link size="small" type="danger" @click="pruneAudit">清理过期</el-button>
|
||||
<el-table v-if="audit.entries.length" :data="audit.entries" size="small" max-height="260" stripe>
|
||||
<el-table-column label="时间" width="160">
|
||||
<template #default="{ row }">{{ fmtTime(row.ts) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="动作" width="120">
|
||||
<template #default="{ row }">{{ actionLabel(row.action) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="来源" width="180">
|
||||
<template #default="{ row }">
|
||||
<code>{{ (row.source_device || '').slice(0, 8) }}</code>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="目标" width="180">
|
||||
<template #default="{ row }">
|
||||
<code>{{ (row.target_device || '').slice(0, 8) }}</code>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="结果" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.result === 'ok' ? 'success' : row.result === 'denied' ? 'danger' : 'info'" size="small">
|
||||
{{ row.result }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-else description="暂无审计记录" :image-size="60" />
|
||||
</div>
|
||||
|
||||
<el-divider content-position="left">网络</el-divider>
|
||||
|
||||
<div class="net-section">
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { DeviceView } from '@/api'
|
||||
const device = useDeviceStore()
|
||||
const session = useSessionStore()
|
||||
const message = useMessageStore()
|
||||
defineEmits<{ (e: 'open-settings'): void }>()
|
||||
const emit = defineEmits<{ (e: 'open-settings'): void }>()
|
||||
|
||||
const self = computed(() => device.self)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user