feat(remote): serial/USB/USBIP remote forwarding with virtual serial port
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useApprovalStore } from '@/stores/remote'
|
||||
|
||||
const store = useApprovalStore()
|
||||
const remember = ref(false)
|
||||
|
||||
const queue = computed(() => store.queue)
|
||||
const current = computed(() => queue.value[0])
|
||||
|
||||
async function reply(ok: boolean) {
|
||||
if (!current.value) return
|
||||
await store.reply(current.value.requestId, ok, remember.value)
|
||||
remember.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="current" class="approval-mask">
|
||||
<div class="approval-card">
|
||||
<header>
|
||||
<strong>{{ current.peerName }}</strong> 请求{{
|
||||
current.kind === 'terminal' ? '打开终端' :
|
||||
current.kind === 'forward' ? '建立端口转发' :
|
||||
'附加 USB / 串口'
|
||||
}}
|
||||
</header>
|
||||
<div class="detail">{{ current.detail }}</div>
|
||||
<div class="warn">
|
||||
⚠ 本次操作在 <b>你的本机</b> 上执行; 对方只能看到你授权范围内的内容。
|
||||
</div>
|
||||
<footer>
|
||||
<label class="remember">
|
||||
<input type="checkbox" v-model="remember" />
|
||||
24h 内自动允许同类型请求
|
||||
</label>
|
||||
<div class="spacer" />
|
||||
<button class="btn-reject" @click="reply(false)">拒绝</button>
|
||||
<button class="btn-allow" @click="reply(true)">允许</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.approval-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.approval-card {
|
||||
width: 420px;
|
||||
background: var(--el-bg-color);
|
||||
border-radius: 12px;
|
||||
padding: 18px 20px;
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
header { font-size: 15px; margin-bottom: 8px; }
|
||||
.detail { font-size: 13px; color: var(--el-text-color-regular); padding: 8px 0; }
|
||||
.warn { font-size: 12px; color: var(--el-color-warning); background: rgba(230, 162, 60, 0.08); padding: 8px 10px; border-radius: 6px; }
|
||||
footer { display: flex; align-items: center; margin-top: 12px; gap: 8px; }
|
||||
.remember { font-size: 12px; display: flex; align-items: center; gap: 4px; color: var(--el-text-color-secondary); }
|
||||
.spacer { flex: 1; }
|
||||
.btn-reject, .btn-allow {
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
.btn-reject { background: var(--el-fill-color-light); color: var(--el-text-color-regular); }
|
||||
.btn-allow { background: var(--el-color-primary); color: #fff; }
|
||||
.btn-allow:hover { filter: brightness(1.05); }
|
||||
</style>
|
||||
@@ -0,0 +1,211 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import type { DeviceView, ForwardSessionInfo, ForwardDirection } from '@/api'
|
||||
import { useForwardStore } from '@/stores/remote'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const props = defineProps<{ peer: DeviceView | null }>()
|
||||
const store = useForwardStore()
|
||||
|
||||
const direction = ref<ForwardDirection>('self-out')
|
||||
const listenPort = ref(5180)
|
||||
const targetHost = ref('127.0.0.1')
|
||||
const targetPort = ref(80)
|
||||
const ttlSec = ref(3600)
|
||||
const creating = ref(false)
|
||||
|
||||
async function refresh() { await store.refresh() }
|
||||
onMounted(refresh)
|
||||
|
||||
// 当前会话 = 我方为 client (我发起的) + 对方为 client (我接收的) 中所有涉及当前 peer 的
|
||||
const myClient = computed(() => store.client.filter(s => s.peerId === props.peer?.deviceId))
|
||||
const peerClient = computed(() => store.server.filter(s => s.peerId === props.peer?.deviceId))
|
||||
|
||||
function directionLabel(d: ForwardDirection) {
|
||||
return d === 'self-out' ? '我方代理对方' : '对方代理我方'
|
||||
}
|
||||
|
||||
async function createForward() {
|
||||
if (!props.peer) return
|
||||
if (!Number.isInteger(listenPort.value) || listenPort.value < 1 || listenPort.value > 65535) {
|
||||
ElMessage.warning('监听端口非法')
|
||||
return
|
||||
}
|
||||
if (!Number.isInteger(targetPort.value) || targetPort.value < 1 || targetPort.value > 65535) {
|
||||
ElMessage.warning('目标端口非法')
|
||||
return
|
||||
}
|
||||
creating.value = true
|
||||
const r = await store.open(props.peer.deviceId, {
|
||||
direction: direction.value,
|
||||
listenPort: listenPort.value,
|
||||
targetHost: targetHost.value || '127.0.0.1',
|
||||
targetPort: targetPort.value,
|
||||
ttlSec: ttlSec.value,
|
||||
})
|
||||
creating.value = false
|
||||
if (!r.ok) {
|
||||
ElMessage.error('转发失败: ' + (r.reason || ''))
|
||||
return
|
||||
}
|
||||
ElMessage.success('已建立转发')
|
||||
}
|
||||
|
||||
async function stop(s: ForwardSessionInfo) {
|
||||
await store.close(s.sessionId)
|
||||
ElMessage.success('已关闭')
|
||||
}
|
||||
|
||||
function fmtBytes(n: number) {
|
||||
if (n < 1024) return `${n} B`
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
|
||||
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`
|
||||
return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`
|
||||
}
|
||||
|
||||
function ttlLabel(expiresAt: number) {
|
||||
const ms = expiresAt - Date.now()
|
||||
if (ms <= 0) return '已到期'
|
||||
const sec = Math.round(ms / 1000)
|
||||
if (sec < 60) return `${sec}s`
|
||||
if (sec < 3600) return `${Math.round(sec / 60)}m`
|
||||
return `${(sec / 3600).toFixed(1)}h`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="forward-panel">
|
||||
<div class="create-box">
|
||||
<h3>新建端口转发</h3>
|
||||
<div class="dir-row">
|
||||
<label class="dir-label">方向</label>
|
||||
<div class="dir-toggle">
|
||||
<button :class="{ active: direction === 'self-out' }" @click="direction = 'self-out'">
|
||||
我方代理对方
|
||||
</button>
|
||||
<button :class="{ active: direction === 'self-in' }" @click="direction = 'self-in'">
|
||||
对方代理我方
|
||||
</button>
|
||||
</div>
|
||||
<span class="dir-hint">
|
||||
<template v-if="direction === 'self-out'">我开 127.0.0.1 端口 ↔ 对方的目标端口</template>
|
||||
<template v-else>对方开 127.0.0.1 端口 ↔ 我方的目标端口</template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<template v-if="direction === 'self-out'">
|
||||
<label>我方监听</label>
|
||||
<el-input v-model.number="listenPort" size="small" style="width: 120px">
|
||||
<template #prepend>127.0.0.1:</template>
|
||||
</el-input>
|
||||
<span class="arrow">→</span>
|
||||
<label>对方目标</label>
|
||||
<el-input v-model="targetHost" placeholder="127.0.0.1" size="small" style="width: 130px" />
|
||||
<span>:</span>
|
||||
<el-input v-model.number="targetPort" placeholder="80" size="small" style="width: 80px" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<label>对方监听</label>
|
||||
<el-input v-model.number="listenPort" size="small" style="width: 120px">
|
||||
<template #prepend>127.0.0.1:</template>
|
||||
</el-input>
|
||||
<span class="arrow">→</span>
|
||||
<label>我方目标</label>
|
||||
<el-input v-model="targetHost" placeholder="127.0.0.1" size="small" style="width: 130px" />
|
||||
<span>:</span>
|
||||
<el-input v-model.number="targetPort" placeholder="80" size="small" style="width: 80px" />
|
||||
</template>
|
||||
<span>TTL</span>
|
||||
<el-input v-model.number="ttlSec" placeholder="秒" size="small" style="width: 80px" />
|
||||
<el-button type="primary" :loading="creating" size="small" @click="createForward" :disabled="!peer">
|
||||
建立
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>我发起的 ({{ myClient.length }})</h3>
|
||||
<p class="section-hint">我方代理对方的服务 (self-out) / 把我的服务暴露给对方 (self-in)。任何一方都能关闭。</p>
|
||||
<el-table v-if="myClient.length" :data="myClient" size="small" stripe>
|
||||
<el-table-column label="方向" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="row.direction === 'self-out' ? 'primary' : 'success'">{{ directionLabel(row.direction) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="入口">
|
||||
<template #default="{ row }">
|
||||
<code>127.0.0.1:{{ row.listenPort }}</code>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="目标">
|
||||
<template #default="{ row }">
|
||||
<code>{{ row.targetHost }}:{{ row.targetPort }}</code>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="in/out" width="160">
|
||||
<template #default="{ row }">{{ fmtBytes(row.bytesIn) }} / {{ fmtBytes(row.bytesOut) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="剩余" width="80">
|
||||
<template #default="{ row }">{{ ttlLabel(row.expiresAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" link type="danger" @click="stop(row)">关闭</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-else description="暂无我发起的转发" :image-size="50" />
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3>对方发起的 ({{ peerClient.length }})</h3>
|
||||
<p class="section-hint">对方正在使用 / 暴露的转发. 你也可以关闭.</p>
|
||||
<el-table v-if="peerClient.length" :data="peerClient" size="small" stripe>
|
||||
<el-table-column label="方向" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="row.direction === 'self-out' ? 'primary' : 'success'">{{ directionLabel(row.direction) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="入口">
|
||||
<template #default="{ row }">
|
||||
<code>127.0.0.1:{{ row.listenPort }}</code>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="目标">
|
||||
<template #default="{ row }">
|
||||
<code>{{ row.targetHost }}:{{ row.targetPort }}</code>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="in/out" width="160">
|
||||
<template #default="{ row }">{{ fmtBytes(row.bytesIn) }} / {{ fmtBytes(row.bytesOut) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" link type="danger" @click="stop(row)">关闭</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-else description="对方没发起任何转发" :image-size="50" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.forward-panel { padding: 16px; overflow-y: auto; height: 100%; }
|
||||
.create-box { background: var(--el-fill-color-light); padding: 14px; border-radius: 8px; margin-bottom: 16px; }
|
||||
.create-box h3, .section h3 { font-size: 14px; margin: 0 0 8px 0; }
|
||||
.section { margin-bottom: 16px; }
|
||||
.section-hint { font-size: 12px; color: var(--el-text-color-secondary); margin: 0 0 8px 0; }
|
||||
|
||||
.dir-row { display: flex; align-items: center; gap: 12px; margin-bottom: 10px; flex-wrap: wrap; }
|
||||
.dir-label { font-size: 12px; color: var(--el-text-color-secondary); width: 32px; }
|
||||
.dir-toggle { display: flex; background: var(--el-bg-color); border: 1px solid var(--el-border-color-lighter); border-radius: 6px; padding: 2px; }
|
||||
.dir-toggle button { background: transparent; border: none; padding: 5px 12px; font-size: 13px; border-radius: 4px; cursor: pointer; color: var(--el-text-color-regular); }
|
||||
.dir-toggle button.active { background: var(--el-color-primary); color: #fff; font-weight: 500; }
|
||||
.dir-hint { font-size: 12px; color: var(--el-text-color-secondary); }
|
||||
|
||||
.form-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
.form-row label { font-size: 12px; color: var(--el-text-color-secondary); margin-right: 4px; }
|
||||
.form-row .arrow { font-size: 16px; color: var(--el-color-primary); margin: 0 6px; }
|
||||
</style>
|
||||
@@ -0,0 +1,193 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
|
||||
// @ts-ignore
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
// @ts-ignore
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
// @ts-ignore
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links'
|
||||
// @ts-ignore
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
import type { DeviceView } from '@/api'
|
||||
import { useTerminalStore } from '@/stores/remote'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const props = defineProps<{ peer: DeviceView; active?: boolean }>()
|
||||
const emit = defineEmits<{ (e: 'closed'): void }>()
|
||||
|
||||
const store = useTerminalStore()
|
||||
|
||||
const hostEl = ref<HTMLDivElement | null>(null)
|
||||
const loading = ref(false)
|
||||
let term: Terminal | null = null
|
||||
let fit: FitAddon | null = null
|
||||
let ro: ResizeObserver | null = null
|
||||
|
||||
const sessionId = ref<string | null>(null)
|
||||
const sessionInfo = ref<{ shell: string; rows: number; cols: number; readOnly: boolean } | null>(null)
|
||||
const state = ref<'idle' | 'connecting' | 'open' | 'closed'>('idle')
|
||||
|
||||
let outputQueue: Uint8Array[] = []
|
||||
let flushTimer: any = null
|
||||
let outputPollTimer: any = null
|
||||
|
||||
function b64ToBytes(b64: string): Uint8Array {
|
||||
const bin = atob(b64)
|
||||
const out = new Uint8Array(bin.length)
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
|
||||
return out
|
||||
}
|
||||
|
||||
function flushOutput() {
|
||||
flushTimer = null
|
||||
if (!term || outputQueue.length === 0) return
|
||||
let total = 0
|
||||
for (const b of outputQueue) total += b.length
|
||||
if (total === 0) { outputQueue = []; return }
|
||||
const combined = new Uint8Array(total)
|
||||
let off = 0
|
||||
for (const b of outputQueue) { combined.set(b, off); off += b.length }
|
||||
outputQueue = []
|
||||
try { term.write(combined) } catch (e) { console.warn('[terminal] write failed', e) }
|
||||
}
|
||||
|
||||
function scheduleFlush() {
|
||||
if (flushTimer) return
|
||||
flushTimer = setTimeout(flushOutput, 16)
|
||||
}
|
||||
|
||||
async function openSession() {
|
||||
if (!props.peer) return
|
||||
loading.value = true
|
||||
const r = await store.open(props.peer.deviceId, { rows: 24, cols: 80 })
|
||||
loading.value = false
|
||||
if (!r.ok) {
|
||||
ElMessage.error('打开终端失败: ' + (r.reason || ''))
|
||||
return
|
||||
}
|
||||
sessionId.value = r.sessionId!
|
||||
state.value = 'connecting'
|
||||
await nextTick()
|
||||
initTerm(r.sessionId!)
|
||||
}
|
||||
|
||||
function initTerm(sid: string) {
|
||||
if (!hostEl.value) {
|
||||
console.warn('[terminal] hostEl not ready, retry')
|
||||
setTimeout(() => initTerm(sid), 50)
|
||||
return
|
||||
}
|
||||
term = new Terminal({
|
||||
fontSize: 13,
|
||||
fontFamily: 'Consolas, "Courier New", monospace',
|
||||
theme: { background: '#1e1e1e', foreground: '#d4d4d4', cursor: '#d4d4d4', selectionBackground: '#264f78' },
|
||||
cursorBlink: true,
|
||||
convertEol: true,
|
||||
scrollback: 5000,
|
||||
})
|
||||
fit = new FitAddon()
|
||||
term.loadAddon(fit)
|
||||
term.loadAddon(new WebLinksAddon())
|
||||
term.open(hostEl.value)
|
||||
requestAnimationFrame(() => { try { fit?.fit() } catch {} })
|
||||
|
||||
term.onData((data: string) => {
|
||||
if (!sessionId.value) return
|
||||
const b64 = btoa(unescape(encodeURIComponent(data)))
|
||||
store.input(sessionId.value, b64)
|
||||
})
|
||||
|
||||
ro = new ResizeObserver(() => { try { fit?.fit() } catch {} })
|
||||
ro.observe(hostEl.value)
|
||||
|
||||
state.value = 'open'
|
||||
|
||||
// 启动轮询: 从 store 拿缓冲, 写到 xterm. 轮询兜底, 万一 watch 没触发也能跑
|
||||
if (outputPollTimer) clearInterval(outputPollTimer)
|
||||
outputPollTimer = setInterval(() => {
|
||||
if (!sessionId.value || !term) return
|
||||
const sid = sessionId.value
|
||||
const buf = store.buffers[sid]
|
||||
if (!buf || buf.length === 0) return
|
||||
for (const b64 of buf) {
|
||||
try { outputQueue.push(b64ToBytes(b64)) } catch {}
|
||||
}
|
||||
store.buffers[sid] = []
|
||||
scheduleFlush()
|
||||
}, 30)
|
||||
}
|
||||
|
||||
watch(() => sessionInfo.value, (s) => {
|
||||
if (s && term) {
|
||||
try { term.resize(s.cols, s.rows) } catch {}
|
||||
ElMessage.success(`已连接到 ${s.shell}`)
|
||||
}
|
||||
})
|
||||
|
||||
// tab 切回时 refit
|
||||
watch(() => props.active, (a) => {
|
||||
if (a && term && fit) requestAnimationFrame(() => { try { fit?.fit() } catch {} })
|
||||
})
|
||||
|
||||
function closeSession(reason?: string) {
|
||||
if (sessionId.value) {
|
||||
store.close(sessionId.value, reason)
|
||||
sessionId.value = null
|
||||
}
|
||||
if (term) { try { term.dispose() } catch {} ; term = null }
|
||||
state.value = 'closed'
|
||||
emit('closed')
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (outputPollTimer) clearInterval(outputPollTimer)
|
||||
if (flushTimer) clearTimeout(flushTimer)
|
||||
if (ro) ro.disconnect()
|
||||
if (term) { try { term.dispose() } catch {} }
|
||||
closeSession('component-unmount')
|
||||
})
|
||||
|
||||
defineExpose({ openSession, closeSession })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="term-panel">
|
||||
<div class="term-header">
|
||||
<span class="peer-name">{{ peer?.name }}</span>
|
||||
<span class="state" :class="state">{{
|
||||
state === 'idle' ? '未连接' :
|
||||
state === 'connecting' ? '连接中…' :
|
||||
state === 'open' ? `已连接 · ${sessionInfo?.shell || 'shell'}` :
|
||||
'已关闭'
|
||||
}}</span>
|
||||
<div class="spacer" />
|
||||
<el-button v-if="state === 'idle'" type="primary" :loading="loading" size="small" @click="openSession">
|
||||
打开终端
|
||||
</el-button>
|
||||
<el-button v-else size="small" @click="closeSession">关闭</el-button>
|
||||
</div>
|
||||
<div ref="hostEl" class="term-host" />
|
||||
<div v-if="state === 'idle'" class="term-empty">
|
||||
<div class="empty-tip">
|
||||
点击 "打开终端" 在 <b>{{ peer?.name }}</b> 上启动一个本地 shell 会话。
|
||||
<br><br>
|
||||
<small>对方需要先在弹窗中同意; 24h 内可设置自动允许。</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.term-panel { display: flex; flex-direction: column; height: 100%; background: #1e1e1e; }
|
||||
.term-header { display: flex; align-items: center; gap: 8px; padding: 8px 12px; background: #2d2d2d; border-bottom: 1px solid #1a1a1a; color: #d4d4d4; }
|
||||
.peer-name { font-weight: 600; }
|
||||
.state { font-size: 12px; padding: 2px 8px; border-radius: 8px; background: #555; color: #ccc; }
|
||||
.state.open { background: #2d7a2d; color: #fff; }
|
||||
.state.connecting { background: #7a6d2d; color: #fff; }
|
||||
.state.closed { background: #7a2d2d; color: #fff; }
|
||||
.spacer { flex: 1; }
|
||||
.term-host { flex: 1; min-height: 0; padding: 4px; overflow: hidden; }
|
||||
.term-host :deep(.xterm) { height: 100%; }
|
||||
.term-empty { position: absolute; inset: 40px 0 0 0; display: flex; align-items: center; justify-content: center; pointer-events: none; color: #888; text-align: center; padding: 24px; }
|
||||
.empty-tip { background: #2d2d2d; border: 1px solid #444; padding: 16px 24px; border-radius: 8px; pointer-events: auto; }
|
||||
</style>
|
||||
@@ -0,0 +1,579 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch, nextTick, h } from 'vue'
|
||||
import type { DeviceView, UsbDeviceInfo, UsbSessionInfo, UsbKind, UsbDirection, UsbAttachConfig, UsbEndpointInfo } from '@/api'
|
||||
import { useUsbStore } from '@/stores/remote'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const props = defineProps<{ peer: DeviceView | null }>()
|
||||
const store = useUsbStore()
|
||||
|
||||
type Mode = 'serial' | 'usb' | 'usbip'
|
||||
const mode = ref<Mode>('serial')
|
||||
const direction = ref<UsbDirection>('self-out')
|
||||
const scanning = ref(false)
|
||||
const peerDevices = ref<UsbDeviceInfo[]>([])
|
||||
const myDevices = ref<UsbDeviceInfo[]>([])
|
||||
const selectedBus = ref<string>('')
|
||||
const baudRate = ref(115200)
|
||||
const creating = ref(false)
|
||||
|
||||
// USB 模式专属
|
||||
const usbIfaceNum = ref(0)
|
||||
const detachKern = ref(true)
|
||||
|
||||
const hexEl = ref<HTMLDivElement | null>(null)
|
||||
const hexBuf = ref<{ ascii: string; hex: string; ts: number }[]>([])
|
||||
const HEX_LIMIT = 200
|
||||
|
||||
// USB 操作历史
|
||||
const usbLog = ref<{ ts: number; tag: string; ok: boolean; text: string; hex?: string }[]>([])
|
||||
const MAX_USB_LOG = 80
|
||||
|
||||
// 当前 active 客户端 session (只展示一个)
|
||||
const myClientSession = computed<UsbSessionInfo | undefined>(() =>
|
||||
store.sessions.find(s => s.peerId === props.peer?.deviceId && s.side === 'client')
|
||||
)
|
||||
|
||||
// 当前对端发起的 session (server side, 展示我方被控情况)
|
||||
const serverSession = computed<UsbSessionInfo | undefined>(() =>
|
||||
store.sessions.find(s => s.peerId === props.peer?.deviceId && s.side === 'server')
|
||||
)
|
||||
|
||||
// 不同 mode 的设备过滤
|
||||
const visibleDevices = computed<UsbDeviceInfo[]>(() => {
|
||||
const all = direction.value === 'self-out' ? peerDevices.value : myDevices.value
|
||||
return all.filter(d => d.kind === mode.value)
|
||||
})
|
||||
|
||||
const serverSerialBytes = ref<{ direction: 'in' | 'out'; data: string; ts: number }[]>([])
|
||||
|
||||
async function scan() {
|
||||
if (!props.peer) return
|
||||
scanning.value = true
|
||||
if (direction.value === 'self-out') {
|
||||
const r = await store.list(props.peer.deviceId)
|
||||
scanning.value = false
|
||||
if (!r.ok) { ElMessage.error('列出对端设备失败: ' + (r.reason || '')); peerDevices.value = []; return }
|
||||
peerDevices.value = r.devices || []
|
||||
} else {
|
||||
const r = await store.listLocal()
|
||||
scanning.value = false
|
||||
if (!r.ok) {
|
||||
ElMessage.error('列出本机设备失败: ' + (r.reason || ''))
|
||||
myDevices.value = []
|
||||
return
|
||||
}
|
||||
myDevices.value = r.devices || []
|
||||
}
|
||||
// 选第一个匹配的 mode
|
||||
const first = visibleDevices.value[0]
|
||||
if (first && !visibleDevices.value.find(d => d.busId === selectedBus.value)) {
|
||||
selectedBus.value = first.busId
|
||||
}
|
||||
}
|
||||
|
||||
async function attach() {
|
||||
if (!props.peer || !selectedBus.value) return
|
||||
if (myClientSession.value) {
|
||||
ElMessage.warning('已有活跃 session, 请先关闭')
|
||||
return
|
||||
}
|
||||
|
||||
let finalBaudRate = baudRate.value
|
||||
|
||||
// 串口模式: 弹窗选波特率
|
||||
if (isSerial.value) {
|
||||
const BAUD_OPTIONS = [9600, 19200, 38400, 57600, 74880, 115200, 230400, 460800, 921600]
|
||||
const selected = ref(BAUD_OPTIONS.indexOf(baudRate.value) >= 0 ? baudRate.value : 115200)
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
h('div', [
|
||||
h('p', { style: 'margin-bottom:10px' }, '选择本机虚拟串口的波特率'),
|
||||
h('el-select', {
|
||||
modelValue: selected.value,
|
||||
'onUpdate:modelValue': (v: number) => { selected.value = v },
|
||||
style: 'width:160px',
|
||||
}, BAUD_OPTIONS.map(b => h('el-option', { key: b, label: String(b), value: b }))),
|
||||
]),
|
||||
'创建虚拟串口',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
customClass: 'baud-box',
|
||||
}
|
||||
)
|
||||
finalBaudRate = selected.value
|
||||
} catch {
|
||||
return // 取消
|
||||
}
|
||||
}
|
||||
|
||||
creating.value = true
|
||||
const config = buildConfig(finalBaudRate)
|
||||
if (!config) { creating.value = false; return }
|
||||
const r = await store.attach(props.peer.deviceId, {
|
||||
direction: direction.value,
|
||||
busId: selectedBus.value,
|
||||
config,
|
||||
})
|
||||
creating.value = false
|
||||
if (!r.ok) {
|
||||
ElMessage.error('附加失败: ' + (r.reason || ''))
|
||||
return
|
||||
}
|
||||
ElMessage.success('已附加')
|
||||
await nextTick()
|
||||
hexEl.value?.scrollTo({ top: 0 })
|
||||
usbLog.value.unshift({ ts: Date.now(), tag: 'attach', ok: true, text: `${mode.value} attach OK` })
|
||||
while (usbLog.value.length > MAX_USB_LOG) usbLog.value.pop()
|
||||
}
|
||||
|
||||
function buildConfig(br: number): UsbAttachConfig | null {
|
||||
if (mode.value === 'serial') {
|
||||
return {
|
||||
kind: 'serial',
|
||||
baudRate: br,
|
||||
dataBits: 8,
|
||||
stopBits: 1,
|
||||
parity: 'none',
|
||||
createVirtual: true,
|
||||
}
|
||||
}
|
||||
if (mode.value === 'usb') {
|
||||
return { kind: 'usb', interfaceNumber: usbIfaceNum.value, detachKernelDriver: detachKern.value }
|
||||
}
|
||||
if (mode.value === 'usbip') {
|
||||
return { kind: 'usbip' }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function detach(s: UsbSessionInfo) {
|
||||
await store.detach(s.sessionId)
|
||||
ElMessage.success('已关闭')
|
||||
}
|
||||
|
||||
async function sendText() {
|
||||
if (!myClientSession.value || myClientSession.value.kind !== 'serial') return
|
||||
const sid = myClientSession.value.sessionId
|
||||
const text = prompt('输入要发送的文本 (UTF-8):', 'AT\r\n')
|
||||
if (text === null) return
|
||||
const buf = new TextEncoder().encode(text)
|
||||
let bin = ''
|
||||
for (const b of buf) bin += String.fromCharCode(b)
|
||||
await store.serialSend(sid, btoa(bin))
|
||||
ElMessage.success(`已发送 ${buf.length} 字节`)
|
||||
}
|
||||
|
||||
// ===== USB 字节桥面板 =====
|
||||
|
||||
const reqType = ref<'standard' | 'class' | 'vendor' | 'reserved'>('standard')
|
||||
const recipient = ref<'device' | 'interface' | 'endpoint' | 'other'>('device')
|
||||
const directionBit = ref<'host-to-device' | 'device-to-host'>('device-to-host')
|
||||
const bRequest = ref(0)
|
||||
const wValue = ref(0)
|
||||
const wIndex = ref(0)
|
||||
const ctrlData = ref('') // hex 文本
|
||||
const ctrlLength = ref(64)
|
||||
|
||||
const bulkEndpoint = ref(0x81)
|
||||
const bulkData = ref('') // hex 文本
|
||||
const bulkLength = ref(64)
|
||||
const bulkTimeout = ref(5000)
|
||||
const bulkDirection = ref<'in' | 'out'>('in')
|
||||
|
||||
function hexToBase64(hex: string): string {
|
||||
const clean = hex.replace(/[^0-9a-fA-F]/g, '')
|
||||
const padded = clean.length % 2 ? '0' + clean : clean
|
||||
let bin = ''
|
||||
for (let i = 0; i < padded.length; i += 2) {
|
||||
bin += String.fromCharCode(parseInt(padded.substr(i, 2), 16))
|
||||
}
|
||||
return btoa(bin)
|
||||
}
|
||||
|
||||
function base64ToHex(b64: string): string {
|
||||
if (!b64) return ''
|
||||
try {
|
||||
const bin = atob(b64)
|
||||
let hex = ''
|
||||
for (let i = 0; i < bin.length; i++) {
|
||||
hex += bin.charCodeAt(i).toString(16).padStart(2, '0') + ' '
|
||||
}
|
||||
return hex.trim()
|
||||
} catch { return '' }
|
||||
}
|
||||
|
||||
function buildRequestType(): number {
|
||||
// bmRequestType: bit7 = direction, bits 6-5 = type, bits 4-0 = recipient
|
||||
let rt = 0
|
||||
if (directionBit.value === 'host-to-device') rt |= 0x00
|
||||
else rt |= 0x80
|
||||
if (reqType.value === 'standard') rt |= 0x00
|
||||
else if (reqType.value === 'class') rt |= 0x20
|
||||
else if (reqType.value === 'vendor') rt |= 0x40
|
||||
else if (reqType.value === 'reserved') rt |= 0x60
|
||||
if (recipient.value === 'device') rt |= 0x00
|
||||
else if (recipient.value === 'interface') rt |= 0x01
|
||||
else if (recipient.value === 'endpoint') rt |= 0x02
|
||||
else if (recipient.value === 'other') rt |= 0x03
|
||||
return rt
|
||||
}
|
||||
|
||||
async function doCtrlIn() {
|
||||
if (!myClientSession.value) return
|
||||
const setup = { requestType: buildRequestType(), request: bRequest.value, value: wValue.value, index: wIndex.value }
|
||||
usbLog.value.unshift({ ts: Date.now(), tag: 'ctrlIn', ok: true, text: `req=0x${bRequest.value.toString(16).padStart(2, '0')} len=${ctrlLength.value}` })
|
||||
const r = await store.ctrlIn(myClientSession.value.sessionId, setup, ctrlLength.value)
|
||||
const hex = r.dataBase64 ? base64ToHex(r.dataBase64) : ''
|
||||
usbLog.value.unshift({ ts: Date.now(), tag: 'ctrlIn', ok: r.ok, text: `status=${r.status}`, hex })
|
||||
while (usbLog.value.length > MAX_USB_LOG) usbLog.value.pop()
|
||||
if (hex) {
|
||||
hexBuf.value.unshift({ ascii: '', hex, ts: Date.now() })
|
||||
while (hexBuf.value.length > HEX_LIMIT) hexBuf.value.pop()
|
||||
}
|
||||
}
|
||||
|
||||
async function doCtrlOut() {
|
||||
if (!myClientSession.value) return
|
||||
const setup = { requestType: buildRequestType(), request: bRequest.value, value: wValue.value, index: wIndex.value }
|
||||
const dataB64 = ctrlData.value.trim() ? hexToBase64(ctrlData.value) : undefined
|
||||
usbLog.value.unshift({ ts: Date.now(), tag: 'ctrlOut', ok: true, text: `req=0x${bRequest.value.toString(16).padStart(2, '0')} data=${dataB64 ? ctrlData.value : '<none>'}` })
|
||||
const r = await store.ctrlOut(myClientSession.value.sessionId, setup, dataB64)
|
||||
usbLog.value.unshift({ ts: Date.now(), tag: 'ctrlOut', ok: r.ok, text: `written=${r.status} bytes` })
|
||||
while (usbLog.value.length > MAX_USB_LOG) usbLog.value.pop()
|
||||
}
|
||||
|
||||
async function doBulkTransfer() {
|
||||
if (!myClientSession.value) return
|
||||
if (bulkDirection.value === 'in') {
|
||||
usbLog.value.unshift({ ts: Date.now(), tag: 'bulkIn', ok: true, text: `EP=0x${bulkEndpoint.value.toString(16)} len=${bulkLength.value}` })
|
||||
const r = await store.bulkIn(myClientSession.value.sessionId, bulkEndpoint.value, bulkLength.value, bulkTimeout.value)
|
||||
const hex = r.dataBase64 ? base64ToHex(r.dataBase64) : ''
|
||||
usbLog.value.unshift({ ts: Date.now(), tag: 'bulkIn', ok: r.ok, text: `status=${r.status}`, hex })
|
||||
if (hex) {
|
||||
hexBuf.value.unshift({ ascii: '', hex, ts: Date.now() })
|
||||
while (hexBuf.value.length > HEX_LIMIT) hexBuf.value.pop()
|
||||
}
|
||||
} else {
|
||||
const b64 = bulkData.value.trim() ? hexToBase64(bulkData.value) : ''
|
||||
usbLog.value.unshift({ ts: Date.now(), tag: 'bulkOut', ok: true, text: `EP=0x${bulkEndpoint.value.toString(16)} data=${bulkData.value}` })
|
||||
const r = await store.bulkOut(myClientSession.value.sessionId, bulkEndpoint.value, b64)
|
||||
usbLog.value.unshift({ ts: Date.now(), tag: 'bulkOut', ok: r.ok, text: `written=${r.status} bytes` })
|
||||
}
|
||||
while (usbLog.value.length > MAX_USB_LOG) usbLog.value.pop()
|
||||
}
|
||||
|
||||
// ===== 串口 hex 显示 =====
|
||||
function pushHex(base64: string) {
|
||||
if (!base64) return
|
||||
const bin = atob(base64)
|
||||
let hex = ''
|
||||
let ascii = ''
|
||||
for (let i = 0; i < bin.length; i++) {
|
||||
const c = bin.charCodeAt(i)
|
||||
hex += c.toString(16).padStart(2, '0') + ' '
|
||||
ascii += c >= 0x20 && c < 0x7f ? bin[i] : '.'
|
||||
if ((i + 1) % 16 === 0) {
|
||||
hexBuf.value.unshift({ ascii, hex: hex.trimEnd(), ts: Date.now() })
|
||||
hex = ''; ascii = ''
|
||||
}
|
||||
}
|
||||
if (hex) hexBuf.value.unshift({ ascii, hex: hex.trimEnd(), ts: Date.now() })
|
||||
while (hexBuf.value.length > HEX_LIMIT) hexBuf.value.pop()
|
||||
}
|
||||
|
||||
watch(() => store.buffers, (b) => {
|
||||
if (!myClientSession.value) return
|
||||
const sid = myClientSession.value.sessionId
|
||||
const buf = b[sid]
|
||||
if (!buf || buf.length === 0) return
|
||||
const all = buf.join('')
|
||||
b[sid] = []
|
||||
pushHex(all)
|
||||
}, { deep: true })
|
||||
|
||||
function clearHex() { hexBuf.value = [] }
|
||||
function clearUsbLog() { usbLog.value = [] }
|
||||
|
||||
async function refresh() { await store.refresh() }
|
||||
onMounted(refresh)
|
||||
|
||||
function directionLabel(d: UsbDirection) {
|
||||
return d === 'self-out' ? '我用对方的' : '对方用我的'
|
||||
}
|
||||
function modeLabel(m: Mode) {
|
||||
return m === 'serial' ? '串口' : m === 'usb' ? 'USB 字节桥' : 'USB/IP (Linux)'
|
||||
}
|
||||
|
||||
const isSerial = computed(() => mode.value === 'serial')
|
||||
const isUsb = computed(() => mode.value === 'usb')
|
||||
const isUsbip = computed(() => mode.value === 'usbip')
|
||||
|
||||
const endpoints = computed<UsbEndpointInfo[]>(() => myClientSession.value?.info?.endpoints || [])
|
||||
const vcomPath = computed<string | undefined>(() => myClientSession.value?.info?.userVirtualPath)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="usb-panel">
|
||||
<!-- 方向 + 模式 -->
|
||||
<div class="dir-row">
|
||||
<span class="dir-label">方向</span>
|
||||
<div class="dir-toggle">
|
||||
<button :class="{ active: direction === 'self-out' }" @click="direction = 'self-out'">我用对方的</button>
|
||||
<button :class="{ active: direction === 'self-in' }" @click="direction = 'self-in'">对方用我的</button>
|
||||
</div>
|
||||
<span class="dir-label" style="margin-left: 8px">类型</span>
|
||||
<div class="dir-toggle">
|
||||
<button :class="{ active: mode === 'serial' }" @click="mode = 'serial'">串口</button>
|
||||
<button :class="{ active: mode === 'usb' }" @click="mode = 'usb'">USB</button>
|
||||
<button :class="{ active: mode === 'usbip' }" @click="mode = 'usbip'">USB/IP</button>
|
||||
</div>
|
||||
<el-button size="small" :loading="scanning" @click="scan" :disabled="!peer">
|
||||
{{ direction === 'self-out' ? '拉取对端' : '刷新本机' }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="isUsbip" class="hint">
|
||||
USB/IP 是 Linux 内核自带的真透明 USB 透传 (设备出现在本机 lsusb).
|
||||
macOS/Windows 默认不支持. Linux 用户需手动装 <code>usbip</code> 包 + 加载 <code>vhci_hcd</code> 模块.
|
||||
设备端需先 <code>usbip bind -b <busid></code>.
|
||||
</div>
|
||||
|
||||
<div v-if="!visibleDevices.length" class="empty-tip">
|
||||
<p v-if="!peer">请先在 sidebar 选择一台设备</p>
|
||||
<p v-else>暂无可用的 {{ modeLabel(mode) }} 设备</p>
|
||||
<small v-if="direction === 'self-in' && mode === 'serial'">Linux 用户如未看到 /dev/ttyUSB*, 需要在 dialout 组.</small>
|
||||
</div>
|
||||
|
||||
<el-table v-else :data="visibleDevices" size="small" highlight-current-row @row-click="(row: UsbDeviceInfo) => selectedBus = row.busId">
|
||||
<el-table-column prop="busId" label="设备" width="220" />
|
||||
<el-table-column label="VID:PID" width="120">
|
||||
<template #default="{ row }">
|
||||
<code>{{ row.vid.toString(16).padStart(4,'0') }}:{{ row.pid.toString(16).padStart(4,'0') }}</code>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="product" label="产品" />
|
||||
<el-table-column label="操作" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="primary" :disabled="!!myClientSession" @click.stop="() => { selectedBus = row.busId; baudRate = row.baudRate || 9600; attach() }">
|
||||
附加
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div v-if="isSerial && visibleDevices.length" class="baud-row">
|
||||
<span>默认波特率</span>
|
||||
<el-select v-model.number="baudRate" size="small" style="width: 120px">
|
||||
<el-option label="9600" :value="9600" />
|
||||
<el-option label="19200" :value="19200" />
|
||||
<el-option label="38400" :value="38400" />
|
||||
<el-option label="57600" :value="57600" />
|
||||
<el-option label="74880" :value="74880" />
|
||||
<el-option label="115200" :value="115200" />
|
||||
<el-option label="230400" :value="230400" />
|
||||
<el-option label="460800" :value="460800" />
|
||||
<el-option label="921600" :value="921600" />
|
||||
</el-select>
|
||||
<small style="color:var(--el-text-color-secondary)">点击附加后弹窗确认</small>
|
||||
</div>
|
||||
|
||||
<div v-if="isUsb && visibleDevices.length" class="baud-row">
|
||||
<span>Interface #</span>
|
||||
<el-input-number v-model="usbIfaceNum" :min="0" :max="15" size="small" controls-position="right" style="width: 90px" />
|
||||
<el-checkbox v-model="detachKern" style="margin-left: 8px">Linux 自动 detach 内核驱动</el-checkbox>
|
||||
</div>
|
||||
|
||||
<!-- 已有 session: 显示状态 + 操作 -->
|
||||
<div v-if="myClientSession" class="attach-box">
|
||||
<div class="attach-row">
|
||||
<span>已附加: <code>{{ myClientSession.busId }}</code> · {{ directionLabel(myClientSession.direction) }} · <code>{{ modeLabel(myClientSession.kind) }}</code></span>
|
||||
<span>流量: in {{ myClientSession.bytesIn }}B / out {{ myClientSession.bytesOut }}B</span>
|
||||
<div class="spacer" />
|
||||
<el-button v-if="isSerial && !vcomPath" size="small" @click="sendText">发送文本</el-button>
|
||||
<el-button v-if="isSerial && !vcomPath" size="small" @click="clearHex">清屏</el-button>
|
||||
<el-button v-if="isUsb" size="small" @click="clearUsbLog">清日志</el-button>
|
||||
<el-button size="small" type="danger" @click="detach(myClientSession)">关闭</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 虚拟串口: 醒目的路径展示 -->
|
||||
<div v-if="isSerial && vcomPath" class="vcom-banner">
|
||||
<div class="vcom-banner-row">
|
||||
<span class="vcom-banner-label">本机虚拟串口 (PuTTY / Arduino IDE / screen 直接打开):</span>
|
||||
</div>
|
||||
<div class="vcom-banner-row">
|
||||
<code class="vcom-banner-path">{{ vcomPath }}</code>
|
||||
<small class="vcom-hint">波特率 {{ baudRate }}, 8N1</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 串口: hex 流 -->
|
||||
<div v-if="isSerial" ref="hexEl" class="hex-view">
|
||||
<div v-if="!hexBuf.length" class="hex-empty">等待串口数据…</div>
|
||||
<div v-for="(line, i) in hexBuf" :key="i" class="hex-line">
|
||||
<span class="hex-bytes">{{ line.hex }}</span>
|
||||
<span class="hex-ascii">{{ line.ascii }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- USB 字节桥: endpoint 列表 + 控制/批量表单 -->
|
||||
<div v-if="isUsb" class="usb-bridge">
|
||||
<div v-if="endpoints.length" class="ep-list">
|
||||
<strong>Endpoints:</strong>
|
||||
<code v-for="ep in endpoints" :key="ep.endpointNumber" class="ep">
|
||||
EP{{ ep.endpointNumber.toString(16).toUpperCase() }} ({{ ep.direction === 'in' ? 'IN' : 'OUT' }}, {{ ep.transferType }}, {{ ep.packetSize }}B)
|
||||
</code>
|
||||
</div>
|
||||
<div v-else class="ep-list"><em>无 endpoint (可能需要先选择 interface)</em></div>
|
||||
|
||||
<!-- 控制传输 -->
|
||||
<div class="usb-section">
|
||||
<h4>控制传输 (Control Transfer)</h4>
|
||||
<div class="ctrl-row">
|
||||
<el-select v-model="directionBit" size="small" style="width: 130px">
|
||||
<el-option label="device-to-host (IN)" value="device-to-host" />
|
||||
<el-option label="host-to-device (OUT)" value="host-to-device" />
|
||||
</el-select>
|
||||
<el-select v-model="reqType" size="small" style="width: 90px">
|
||||
<el-option label="standard" value="standard" />
|
||||
<el-option label="class" value="class" />
|
||||
<el-option label="vendor" value="vendor" />
|
||||
</el-select>
|
||||
<el-select v-model="recipient" size="small" style="width: 90px">
|
||||
<el-option label="device" value="device" />
|
||||
<el-option label="interface" value="interface" />
|
||||
<el-option label="endpoint" value="endpoint" />
|
||||
<el-option label="other" value="other" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="ctrl-row">
|
||||
<span>request</span>
|
||||
<el-input-number v-model="bRequest" :min="0" :max="255" size="small" controls-position="right" style="width: 100px" />
|
||||
<span>wValue</span>
|
||||
<el-input-number v-model="wValue" :min="0" :max="65535" size="small" controls-position="right" style="width: 120px" />
|
||||
<span>wIndex</span>
|
||||
<el-input-number v-model="wIndex" :min="0" :max="65535" size="small" controls-position="right" style="width: 120px" />
|
||||
</div>
|
||||
<div v-if="directionBit === 'host-to-device'" class="ctrl-row">
|
||||
<span>data (hex)</span>
|
||||
<el-input v-model="ctrlData" size="small" placeholder="01 02 03 ..." style="flex: 1" />
|
||||
</div>
|
||||
<div v-else class="ctrl-row">
|
||||
<span>length</span>
|
||||
<el-input-number v-model="ctrlLength" :min="1" :max="4096" size="small" controls-position="right" style="width: 100px" />
|
||||
</div>
|
||||
<div class="ctrl-row">
|
||||
<el-button size="small" type="primary" @click="directionBit === 'host-to-device' ? doCtrlOut() : doCtrlIn()">
|
||||
发送
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 批量传输 -->
|
||||
<div class="usb-section">
|
||||
<h4>批量 / 中断 传输 (Bulk / Interrupt)</h4>
|
||||
<div class="ctrl-row">
|
||||
<el-radio-group v-model="bulkDirection" size="small">
|
||||
<el-radio-button label="in" value="in">IN (读)</el-radio-button>
|
||||
<el-radio-button label="out" value="out">OUT (写)</el-radio-button>
|
||||
</el-radio-group>
|
||||
<span>EP</span>
|
||||
<el-input-number v-model="bulkEndpoint" :min="0" :max="255" size="small" controls-position="right" style="width: 100px" />
|
||||
<span v-if="bulkDirection === 'in'">length</span>
|
||||
<el-input-number v-if="bulkDirection === 'in'" v-model="bulkLength" :min="1" :max="65536" size="small" controls-position="right" style="width: 100px" />
|
||||
<span>timeout(ms)</span>
|
||||
<el-input-number v-model="bulkTimeout" :min="100" :max="60000" :step="500" size="small" controls-position="right" style="width: 120px" />
|
||||
</div>
|
||||
<div v-if="bulkDirection === 'out'" class="ctrl-row">
|
||||
<span>data (hex)</span>
|
||||
<el-input v-model="bulkData" size="small" placeholder="01 02 03 ..." style="flex: 1" />
|
||||
</div>
|
||||
<div class="ctrl-row">
|
||||
<el-button size="small" type="primary" @click="doBulkTransfer">发送</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 日志 -->
|
||||
<div class="usb-log">
|
||||
<h4>调用日志</h4>
|
||||
<div class="log-list">
|
||||
<div v-for="(l, i) in usbLog" :key="i" class="log-line" :class="{ ok: l.ok, fail: !l.ok }">
|
||||
<span class="log-ts">{{ new Date(l.ts).toLocaleTimeString() }}</span>
|
||||
<span class="log-tag">{{ l.tag }}</span>
|
||||
<span class="log-text">{{ l.text }}</span>
|
||||
<pre v-if="l.hex" class="log-hex">{{ l.hex }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 对端发起的 session -->
|
||||
<div v-if="serverSession" class="server-box">
|
||||
<strong>对方正在使用我的设备:</strong>
|
||||
<code>{{ serverSession.busId }}</code> · {{ directionLabel('self-in') }} · {{ modeLabel(serverSession.kind) }}
|
||||
<span style="margin-left: 8px">流量: in {{ serverSession.bytesIn }}B / out {{ serverSession.bytesOut }}B</span>
|
||||
<div class="spacer" />
|
||||
<el-button size="small" type="danger" @click="detach(serverSession)">停止</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.usb-panel { padding: 16px; overflow-y: auto; height: 100%; }
|
||||
.dir-row { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; flex-wrap: wrap; }
|
||||
.dir-label { font-size: 12px; color: var(--el-text-color-secondary); }
|
||||
.dir-toggle { display: flex; background: var(--el-fill-color-light); border-radius: 6px; padding: 2px; }
|
||||
.dir-toggle button { background: transparent; border: none; padding: 5px 12px; font-size: 13px; border-radius: 4px; cursor: pointer; color: var(--el-text-color-regular); }
|
||||
.dir-toggle button.active { background: var(--el-color-primary); color: #fff; font-weight: 500; }
|
||||
.hint { background: var(--el-fill-color-light); padding: 8px 12px; border-radius: 6px; font-size: 12px; color: var(--el-text-color-secondary); margin-bottom: 12px; }
|
||||
.hint code { background: #1e1e1e10; padding: 1px 4px; border-radius: 3px; font-size: 11px; }
|
||||
.empty-tip { padding: 24px; text-align: center; color: var(--el-text-color-secondary); }
|
||||
.empty-tip small { display: block; margin-top: 8px; font-size: 11px; }
|
||||
.baud-row { display: flex; align-items: center; gap: 8px; margin: 8px 0; font-size: 12px; color: var(--el-text-color-secondary); flex-wrap: wrap; }
|
||||
.vcom-row { margin: 8px 0; padding: 8px 12px; background: var(--el-color-primary-light-9); border: 1px solid var(--el-color-primary-light-5); border-radius: 6px; }
|
||||
.vcom-row .vcom-options { display: flex; align-items: center; gap: 8px; margin-top: 6px; font-size: 12px; color: var(--el-text-color-secondary); }
|
||||
.vcom-banner { background: var(--el-color-success-light-9); border-top: 1px solid var(--el-color-success-light-5); padding: 12px 16px; }
|
||||
.vcom-banner-row { display: flex; align-items: center; gap: 12px; margin-bottom: 6px; }
|
||||
.vcom-banner-row:last-child { margin-bottom: 0; }
|
||||
.vcom-banner-label { font-size: 12px; color: var(--el-text-color-secondary); font-weight: 500; }
|
||||
.vcom-banner-path { font-family: ui-monospace, Consolas, monospace; font-size: 16px; font-weight: 600; color: var(--el-color-success); background: #fff; padding: 4px 10px; border-radius: 4px; border: 1px solid var(--el-color-success-light-5); user-select: all; }
|
||||
.vcom-hint { color: var(--el-text-color-secondary); font-size: 11px; }
|
||||
.attach-box { margin-top: 16px; border: 1px solid var(--el-border-color-lighter); border-radius: 8px; overflow: hidden; }
|
||||
.attach-row { display: flex; align-items: center; gap: 12px; padding: 8px 12px; background: var(--el-fill-color-light); font-size: 12px; flex-wrap: wrap; }
|
||||
.attach-row .spacer { flex: 1; }
|
||||
.server-box { margin-top: 12px; padding: 8px 12px; background: var(--el-color-warning-light-9); border: 1px solid var(--el-color-warning-light-5); border-radius: 6px; display: flex; align-items: center; font-size: 12px; }
|
||||
.server-box .spacer { flex: 1; }
|
||||
|
||||
.hex-view {
|
||||
font-family: ui-monospace, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
background: #0d0d0d;
|
||||
color: #d4d4d4;
|
||||
height: 280px;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
.hex-empty { color: #888; text-align: center; padding: 24px; }
|
||||
.hex-line { display: flex; gap: 12px; line-height: 1.4; }
|
||||
.hex-bytes { color: #79c0ff; min-width: 360px; word-break: break-all; }
|
||||
.hex-ascii { color: #ffa657; }
|
||||
|
||||
.usb-bridge { padding: 12px; background: var(--el-bg-color); }
|
||||
.ep-list { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; font-size: 12px; }
|
||||
.ep { background: var(--el-fill-color-light); padding: 2px 6px; border-radius: 3px; font-size: 11px; }
|
||||
.usb-section { border-top: 1px solid var(--el-border-color-lighter); padding-top: 12px; margin-top: 12px; }
|
||||
.usb-section h4 { margin: 0 0 8px 0; font-size: 13px; font-weight: 600; }
|
||||
.ctrl-row { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; flex-wrap: wrap; font-size: 12px; }
|
||||
.usb-log { margin-top: 16px; border-top: 1px solid var(--el-border-color-lighter); padding-top: 12px; }
|
||||
.usb-log h4 { margin: 0 0 8px 0; font-size: 13px; font-weight: 600; }
|
||||
.log-list { font-family: ui-monospace, Consolas, monospace; font-size: 11px; max-height: 200px; overflow-y: auto; background: #0d0d0d; padding: 6px; border-radius: 4px; }
|
||||
.log-line { display: flex; gap: 8px; padding: 2px 0; line-height: 1.4; }
|
||||
.log-line.fail { color: #f97583; }
|
||||
.log-line.ok { color: #85e89d; }
|
||||
.log-ts { color: #888; }
|
||||
.log-tag { color: #79c0ff; min-width: 70px; }
|
||||
.log-text { color: #d4d4d4; flex: 1; word-break: break-word; }
|
||||
.log-hex { margin: 2px 0 2px 80px; color: #79c0ff; white-space: pre-wrap; word-break: break-all; font-size: 11px; }
|
||||
</style>
|
||||
@@ -0,0 +1,274 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { TerminalSessionInfo, ForwardSessionInfo, UsbDeviceInfo, UsbSessionInfo, UsbKind, UsbAttachConfig, ApprovalRequestView, AuditEntry } from '@/api'
|
||||
|
||||
interface TerminalChannelState {
|
||||
buffer: string[] // 字节 base64 数组, UI 端自行解码
|
||||
lastWrite: number
|
||||
openedAt: number
|
||||
shell: string
|
||||
rows: number
|
||||
cols: number
|
||||
readOnly: boolean
|
||||
closed: boolean
|
||||
}
|
||||
|
||||
export const useTerminalStore = defineStore('terminal', () => {
|
||||
// 客户端: sessionId -> peerId / 状态
|
||||
const sessions = ref<Record<string, { peerId: string; shell: string; rows: number; cols: number; readOnly: boolean; openedAt: number; closed: boolean }>>({})
|
||||
// 字节缓冲: sessionId -> base64 字符串数组 (UI 渲染时一次性 concat -> utf8 -> xterm.write)
|
||||
const buffers = ref<Record<string, string[]>>({})
|
||||
|
||||
async function open(peerId: string, opts?: { rows?: number; cols?: number; readOnly?: boolean }) {
|
||||
const r = await window.api.terminalOpen(peerId, opts)
|
||||
if (r.ok && r.sessionId) {
|
||||
sessions.value[r.sessionId] = {
|
||||
peerId,
|
||||
shell: '',
|
||||
rows: opts?.rows ?? 24,
|
||||
cols: opts?.cols ?? 80,
|
||||
readOnly: !!opts?.readOnly,
|
||||
openedAt: Date.now(),
|
||||
closed: false,
|
||||
}
|
||||
buffers.value[r.sessionId] = []
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
function onOpened(p: { sessionId: string; peerId: string; shell: string; rows: number; cols: number }) {
|
||||
const s = sessions.value[p.sessionId]
|
||||
if (!s) return
|
||||
s.shell = p.shell; s.rows = p.rows; s.cols = p.cols
|
||||
}
|
||||
|
||||
function onOutput(p: { sessionId: string; peerId: string; data: string }) {
|
||||
const buf = buffers.value[p.sessionId]
|
||||
if (!buf) buffers.value[p.sessionId] = [p.data]
|
||||
else buf.push(p.data)
|
||||
}
|
||||
|
||||
function onClosed(p: { sessionId: string; peerId: string; reason?: string }) {
|
||||
const s = sessions.value[p.sessionId]
|
||||
if (s) s.closed = true
|
||||
}
|
||||
|
||||
function takeBuffer(sessionId: string): string {
|
||||
const buf = buffers.value[sessionId]
|
||||
if (!buf) return ''
|
||||
const out = buf.join('')
|
||||
buffers.value[sessionId] = []
|
||||
return out
|
||||
}
|
||||
|
||||
function appendBuffer(sessionId: string, chunk: string) {
|
||||
const buf = buffers.value[sessionId] || (buffers.value[sessionId] = [])
|
||||
buf.push(chunk)
|
||||
}
|
||||
|
||||
async function input(sessionId: string, dataBase64: string) {
|
||||
return window.api.terminalInput(sessionId, dataBase64)
|
||||
}
|
||||
|
||||
async function resize(sessionId: string, rows: number, cols: number) {
|
||||
return window.api.terminalResize(sessionId, rows, cols)
|
||||
}
|
||||
|
||||
async function close(sessionId: string, reason?: string) {
|
||||
return window.api.terminalClose(sessionId, reason)
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const r = await window.api.terminalListSessions()
|
||||
// 仅补充 server 端已知 (本机被控) 状态, 不覆盖 client 端 (UI 已建)
|
||||
return r
|
||||
}
|
||||
|
||||
const list = computed(() => Object.entries(sessions.value).map(([id, s]) => ({ sessionId: id, ...s })))
|
||||
|
||||
return {
|
||||
sessions, buffers,
|
||||
open, onOpened, onOutput, onClosed,
|
||||
takeBuffer, appendBuffer,
|
||||
input, resize, close, refresh,
|
||||
list,
|
||||
}
|
||||
})
|
||||
|
||||
export const useForwardStore = defineStore('forward', () => {
|
||||
// 我发起的 (clientSessions): 我方是 TCP server (self-out) 或 TCP client (self-in)
|
||||
const client = ref<ForwardSessionInfo[]>([])
|
||||
// 对方发起的 (serverSessions): 我方是 TCP client (self-out) 或 TCP server (self-in)
|
||||
const server = ref<ForwardSessionInfo[]>([])
|
||||
|
||||
async function refresh() {
|
||||
const r = await window.api.forwardListSessions()
|
||||
client.value = r.client || []
|
||||
server.value = r.server || []
|
||||
}
|
||||
|
||||
async function open(peerId: string, args: { direction?: 'self-out' | 'self-in'; listenPort: number; targetHost: string; targetPort: number; ttlSec?: number }) {
|
||||
const r = await window.api.forwardOpen(peerId, args)
|
||||
if (r.ok) await refresh()
|
||||
return r
|
||||
}
|
||||
|
||||
async function close(sessionId: string) {
|
||||
const r = await window.api.forwardClose(sessionId)
|
||||
await refresh()
|
||||
return r
|
||||
}
|
||||
|
||||
const sessions = computed(() => [...client.value, ...server.value])
|
||||
|
||||
function onOpened(_info: any) { /* no-op */ }
|
||||
|
||||
function onClosed(_info: any) {
|
||||
// 局部更新; 完整刷新由 forward:opened/closed 事件触发, 这里 best-effort
|
||||
const idx1 = client.value.findIndex(s => s.sessionId === _info.sessionId)
|
||||
if (idx1 >= 0) {
|
||||
const cur = client.value[idx1]
|
||||
client.value[idx1] = { ...cur, bytesIn: _info.bytesIn, bytesOut: _info.bytesOut }
|
||||
}
|
||||
const idx2 = server.value.findIndex(s => s.sessionId === _info.sessionId)
|
||||
if (idx2 >= 0) {
|
||||
const cur = server.value[idx2]
|
||||
server.value[idx2] = { ...cur, bytesIn: _info.bytesIn, bytesOut: _info.bytesOut }
|
||||
}
|
||||
}
|
||||
|
||||
return { client, server, sessions, refresh, open, close, onOpened, onClosed }
|
||||
})
|
||||
|
||||
export const useUsbStore = defineStore('usb', () => {
|
||||
const devicesByPeer = ref<Record<string, UsbDeviceInfo[]>>({})
|
||||
const myLocalDevices = ref<UsbDeviceInfo[]>([])
|
||||
const client = ref<UsbSessionInfo[]>([]) // 我发起的
|
||||
const server = ref<UsbSessionInfo[]>([]) // 对方发起的
|
||||
const sessions = computed(() => [...client.value, ...server.value])
|
||||
// sessionId -> base64 字符串缓冲 (串口 hex 显示用)
|
||||
const buffers = ref<Record<string, string[]>>({})
|
||||
|
||||
async function list(peerId: string) {
|
||||
const r = await window.api.usbList(peerId)
|
||||
if (r.ok && r.devices) devicesByPeer.value[peerId] = r.devices
|
||||
return r
|
||||
}
|
||||
|
||||
async function listLocal() {
|
||||
const r = await window.api.usbListLocal()
|
||||
if (r.ok && r.devices) myLocalDevices.value = r.devices
|
||||
return r
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const r = await window.api.usbListSessions()
|
||||
if (Array.isArray(r)) {
|
||||
client.value = r
|
||||
server.value = []
|
||||
} else {
|
||||
client.value = r.client || []
|
||||
server.value = r.server || []
|
||||
}
|
||||
}
|
||||
|
||||
async function attach(peerId: string, args: { busId: string; direction?: 'self-out' | 'self-in'; config: UsbAttachConfig }) {
|
||||
const r = await window.api.usbAttach(peerId, args.busId, { direction: args.direction, config: args.config })
|
||||
if (r.ok) await refresh()
|
||||
return r
|
||||
}
|
||||
|
||||
// 串口: 发送字节
|
||||
async function serialSend(sessionId: string, dataBase64: string) {
|
||||
return window.api.usbSerialSend(sessionId, dataBase64)
|
||||
}
|
||||
|
||||
// USB: 控制传输
|
||||
async function ctrlOut(sessionId: string, setup: { requestType: number; request: number; value: number; index: number }, dataBase64?: string) {
|
||||
return window.api.usbCtrlOut(sessionId, setup, dataBase64)
|
||||
}
|
||||
async function ctrlIn(sessionId: string, setup: { requestType: number; request: number; value: number; index: number }, length: number) {
|
||||
const r = await window.api.usbCtrlIn(sessionId, setup, length)
|
||||
return { ok: r.ok, dataBase64: r.dataBase64, status: r.status }
|
||||
}
|
||||
|
||||
// USB: 批量传输
|
||||
async function bulkOut(sessionId: string, endpoint: number, dataBase64: string) {
|
||||
return window.api.usbBulkOut(sessionId, endpoint, dataBase64)
|
||||
}
|
||||
async function bulkIn(sessionId: string, endpoint: number, length: number, timeoutMs?: number) {
|
||||
const r = await window.api.usbBulkIn(sessionId, endpoint, length, timeoutMs)
|
||||
return { ok: r.ok, dataBase64: r.dataBase64, status: r.status }
|
||||
}
|
||||
|
||||
async function detach(sessionId: string) {
|
||||
const r = await window.api.usbDetach(sessionId)
|
||||
await refresh()
|
||||
return r
|
||||
}
|
||||
|
||||
function onOutput(p: { sessionId: string; peerId: string; data: string }) {
|
||||
const buf = buffers.value[p.sessionId] || (buffers.value[p.sessionId] = [])
|
||||
buf.push(p.data)
|
||||
}
|
||||
|
||||
function takeBuffer(sessionId: string): string {
|
||||
const buf = buffers.value[sessionId]
|
||||
if (!buf) return ''
|
||||
const out = buf.join('')
|
||||
buffers.value[sessionId] = []
|
||||
return out
|
||||
}
|
||||
|
||||
function onClosed(_info: any) {
|
||||
const upd = (arr: typeof client.value) => {
|
||||
const idx = arr.findIndex(s => s.sessionId === _info.sessionId)
|
||||
if (idx >= 0) {
|
||||
const cur = arr[idx]
|
||||
arr[idx] = { ...cur, bytesIn: _info.bytesIn, bytesOut: _info.bytesOut }
|
||||
}
|
||||
}
|
||||
upd(client.value)
|
||||
upd(server.value)
|
||||
}
|
||||
|
||||
return {
|
||||
devicesByPeer, myLocalDevices, client, server, sessions, buffers,
|
||||
list, listLocal, refresh, attach,
|
||||
serialSend, ctrlOut, ctrlIn, bulkOut, bulkIn,
|
||||
detach, onOutput, takeBuffer, onClosed,
|
||||
}
|
||||
})
|
||||
|
||||
export const useApprovalStore = defineStore('approval', () => {
|
||||
const queue = ref<ApprovalRequestView[]>([])
|
||||
|
||||
async function refresh() {
|
||||
queue.value = await window.api.remoteApprovalList()
|
||||
}
|
||||
|
||||
function add(req: ApprovalRequestView) {
|
||||
queue.value = [...queue.value, req]
|
||||
}
|
||||
|
||||
async function reply(requestId: string, ok: boolean, remember = false) {
|
||||
await window.api.remoteApprovalReply(requestId, ok, remember)
|
||||
queue.value = queue.value.filter(r => r.requestId !== requestId)
|
||||
}
|
||||
|
||||
return { queue, refresh, add, reply }
|
||||
})
|
||||
|
||||
export const useAuditStore = defineStore('audit', () => {
|
||||
const entries = ref<AuditEntry[]>([])
|
||||
|
||||
async function refresh(limit = 200, since?: number) {
|
||||
entries.value = await window.api.auditList({ limit, since })
|
||||
}
|
||||
|
||||
async function prune(days?: number) {
|
||||
return window.api.auditPrune(days ? days * 24 * 3600 * 1000 : undefined)
|
||||
}
|
||||
|
||||
return { entries, refresh, prune }
|
||||
})
|
||||
Reference in New Issue
Block a user