chore: remote forwarding integration

This commit is contained in:
2026-07-17 18:16:11 +08:00
parent d12c74649a
commit ec9f854136
14 changed files with 1660 additions and 77 deletions
+55 -27
View File
@@ -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
View File
@@ -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>
}
+93 -31
View File
@@ -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 {
+106 -2
View File
@@ -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">
+1 -1
View File
@@ -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)