feat: 支持按键分离事件与键盘捕获,并优化UI
- 后端与前端新增 keyAction 参数,支持按键按下/抬起独立事件,保持向下兼容 - 前端控制栏支持 pointerdown/pointerup 长按,新增键盘捕获输入框,映射浏览器按键到 Android KeyEvent - 重构顶栏布局:用户区右置并添加在线指示器、状态文本省略,优化窄屏响应式表现 - 控制栏录制按钮组防拆行,分辨率/帧率选择器自适应收缩,全局样式增强 - 修复 AdminAuthFilter 中 doFilter 被异常捕获导致误返回 401 的问题 - 移除管理后台中用户 admin 角色及相关 UI 与 API - 更新信令前端环境代理地址,修正路由名称大小写
This commit is contained in:
@@ -27,10 +27,7 @@ async function onLogout() {
|
||||
<template v-else>
|
||||
<header class="topbar">
|
||||
<div class="brand">WebRTC 网页远程控制</div>
|
||||
<div class="user">
|
||||
<span class="user-name">{{ store.username || '已登录' }}</span>
|
||||
<button class="btn ghost" @click="onLogout">退出</button>
|
||||
</div>
|
||||
|
||||
<div class="status" :class="{ ok: store.rtcConnected }">{{ store.statusText }}</div>
|
||||
<div class="badges">
|
||||
<span class="badge" :class="{ on: store.signalingConnected }">信令</span>
|
||||
@@ -38,6 +35,12 @@ async function onLogout() {
|
||||
<span class="badge" :class="{ on: store.rtcConnected }">P2P</span>
|
||||
<span class="badge" :class="{ on: store.dataChannelOpen }">通道</span>
|
||||
</div>
|
||||
<div class="user">
|
||||
<span class="user-name" :title="store.username || '已登录'">
|
||||
{{ store.username || '已登录' }}
|
||||
</span>
|
||||
<button class="btn ghost" type="button" @click="onLogout">退出</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="layout">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import { store, sendKey, sendResolutionChange, toggleRecording } from '../store/controllerStore';
|
||||
import { store, sendKeyDown, sendKeyUp, sendResolutionChange, toggleRecording } from '../store/controllerStore';
|
||||
|
||||
// Android KeyEvent 键值(与被控端 SystemInputUtils 注入一致)
|
||||
const keys = [
|
||||
@@ -29,9 +29,69 @@ const selectedResolution = ref(0);
|
||||
const fpsOptions = ref([15, 24, 30, 60]);
|
||||
const selectedFps = ref(0); // 0 表示尚未同步到被控端当前帧率
|
||||
|
||||
function press(code) {
|
||||
function pressDown(code) {
|
||||
if (!store.dataChannelOpen) return;
|
||||
sendKey(code);
|
||||
sendKeyDown(code);
|
||||
}
|
||||
function pressUp(code) {
|
||||
if (!store.dataChannelOpen) return;
|
||||
sendKeyUp(code);
|
||||
}
|
||||
|
||||
// ----- 键盘捕获输入(真实键盘打字) -----
|
||||
// 将浏览器 KeyboardEvent.key / .code 映射为 Android KeyEvent 键值。
|
||||
// 覆盖字母、数字、空格、回车、退格、制表、方向键、修饰键与常用符号。
|
||||
const KEY_MAP = {
|
||||
// 字母
|
||||
a: 29, b: 30, c: 31, d: 32, e: 33, f: 34, g: 35, h: 36, i: 37, j: 38, k: 39,
|
||||
l: 40, m: 41, n: 42, o: 43, p: 44, q: 45, r: 46, s: 47, t: 48, u: 49, v: 50,
|
||||
w: 51, x: 52, y: 53, z: 54,
|
||||
// 数字(主键盘)
|
||||
'0': 7, '1': 8, '2': 9, '3': 10, '4': 11, '5': 12, '6': 13, '7': 14, '8': 15, '9': 16,
|
||||
// 控制键
|
||||
Enter: 66, Backspace: 67, Tab: 61, Space: 62, Delete: 112, Escape: 111,
|
||||
ArrowLeft: 21, ArrowUp: 19, ArrowRight: 22, ArrowDown: 20,
|
||||
Home: 3, End: 123, PageUp: 92, PageDown: 93, Insert: 124,
|
||||
'`': 68, '-': 69, '=': 70, '[': 71, ']': 72, '\\': 73, ';': 74, '\'': 75, ',': 55, '.': 56, '/': 76,
|
||||
};
|
||||
// 修饰键(按下保持,直到抬起)
|
||||
const MODIFIER_MAP = {
|
||||
Shift: 59, Control: 113, Alt: 57, Meta: 117,
|
||||
};
|
||||
|
||||
const keyInputRef = ref(null);
|
||||
const keyLog = ref('');
|
||||
|
||||
function lookupKeyCode(e) {
|
||||
// 优先使用 e.code(如物理键位置稳定),再回退到 e.key。
|
||||
if (MODIFIER_MAP[e.key]) return { code: MODIFIER_MAP[e.key], modifier: true };
|
||||
if (KEY_MAP[e.key] !== undefined) return { code: KEY_MAP[e.key], modifier: false };
|
||||
if (KEY_MAP[e.code]) return { code: KEY_MAP[e.code], modifier: false };
|
||||
return null;
|
||||
}
|
||||
|
||||
function onKeyInputDown(e) {
|
||||
if (!store.dataChannelOpen) return;
|
||||
const m = lookupKeyCode(e);
|
||||
if (!m) return;
|
||||
e.preventDefault();
|
||||
sendKeyDown(m.code);
|
||||
}
|
||||
|
||||
function onKeyInputUp(e) {
|
||||
if (!store.dataChannelOpen) return;
|
||||
const m = lookupKeyCode(e);
|
||||
if (!m) return;
|
||||
e.preventDefault();
|
||||
sendKeyUp(m.code);
|
||||
}
|
||||
|
||||
function focusKeyInput() {
|
||||
keyInputRef.value && keyInputRef.value.focus();
|
||||
}
|
||||
// 点击区域任意位置自动聚焦输入框,方便直接用键盘打字
|
||||
function onCaptureClick() {
|
||||
focusKeyInput();
|
||||
}
|
||||
|
||||
function onResolutionChange() {
|
||||
@@ -100,15 +160,22 @@ watch(() => store.currentResolution, (res) => {
|
||||
|
||||
<span class="record-sep"></span>
|
||||
|
||||
<button
|
||||
class="record-btn"
|
||||
:class="{ recording: store.recording }"
|
||||
:disabled="!store.rtcConnected"
|
||||
@click="toggleRecording"
|
||||
>
|
||||
{{ store.recording ? '停止录制' : '录制' }}
|
||||
</button>
|
||||
<span class="record-status" v-if="store.recordStatus">{{ store.recordStatus }}</span>
|
||||
<!-- 录制按钮与状态包成一组,保证换行时二者始终同进同退、不被拆散 -->
|
||||
<div class="record-group">
|
||||
<button
|
||||
class="record-btn"
|
||||
:class="{ recording: store.recording }"
|
||||
:disabled="!store.rtcConnected"
|
||||
@click="toggleRecording"
|
||||
>
|
||||
{{ store.recording ? '停止录制' : '录制' }}
|
||||
</button>
|
||||
<span
|
||||
class="record-status"
|
||||
v-if="store.recordStatus"
|
||||
:title="store.recordStatus"
|
||||
>{{ store.recordStatus }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -116,33 +183,71 @@ watch(() => store.currentResolution, (res) => {
|
||||
:key="k.code"
|
||||
class="key-btn"
|
||||
:disabled="!store.dataChannelOpen"
|
||||
@click="press(k.code)"
|
||||
@pointerdown.prevent="pressDown(k.code)"
|
||||
@pointerup.prevent="pressUp(k.code)"
|
||||
@pointerleave.prevent="pressUp(k.code)"
|
||||
@pointercancel.prevent="pressUp(k.code)"
|
||||
>
|
||||
{{ k.label }}
|
||||
</button>
|
||||
|
||||
<div class="keyboard-capture" @click="onCaptureClick">
|
||||
<label class="kb-label">键盘输入:</label>
|
||||
<input
|
||||
ref="keyInputRef"
|
||||
class="kb-input"
|
||||
type="text"
|
||||
placeholder="点击此处后可直接用键盘打字"
|
||||
:disabled="!store.dataChannelOpen"
|
||||
@keydown="onKeyInputDown"
|
||||
@keyup="onKeyInputUp"
|
||||
@blur="keyLog = ''"
|
||||
/>
|
||||
<span class="kb-hint">支持字母/数字/符号/方向键/修饰键组合</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.record-sep {
|
||||
display: inline-block;
|
||||
width: 1px;
|
||||
height: 22px;
|
||||
margin: 0 10px;
|
||||
margin: 0 4px;
|
||||
background: #3a3f4b;
|
||||
vertical-align: middle;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 录制按钮 + 状态作为一个不可拆分的整体参与换行 */
|
||||
.record-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: nowrap;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.record-btn {
|
||||
padding: 6px 14px;
|
||||
/* flex-shrink:0 + nowrap 是"永不换行"的关键:
|
||||
作为 flex 子项默认可被压缩到小于文字宽度,从而把"停止录制"挤成两行。 */
|
||||
flex: 0 0 auto;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
word-break: keep-all;
|
||||
min-width: fit-content;
|
||||
padding: 8px 16px;
|
||||
border: 1px solid #3a3f4b;
|
||||
border-radius: 6px;
|
||||
background: #2b6cff;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
line-height: 1.2;
|
||||
cursor: pointer;
|
||||
transition: background-color .15s ease, border-color .15s ease;
|
||||
}
|
||||
|
||||
.record-btn:hover:not(:disabled) { background: #1f5ce0; }
|
||||
|
||||
.record-btn:disabled {
|
||||
background: #3a3f4b;
|
||||
color: #888;
|
||||
@@ -151,12 +256,68 @@ watch(() => store.currentResolution, (res) => {
|
||||
|
||||
.record-btn.recording {
|
||||
background: #e5484d;
|
||||
border-color: #e5484d;
|
||||
}
|
||||
|
||||
.record-btn.recording:hover:not(:disabled) { background: #cf3b40; }
|
||||
|
||||
.record-status {
|
||||
margin-left: 8px;
|
||||
/* 空间不足时由状态文字收缩并省略,保证按钮文字完整、二者同处一行 */
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 180px;
|
||||
font-size: 13px;
|
||||
color: #ffb020;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* 换行后竖线分隔符失去意义,反而造成视觉噪点 */
|
||||
@media (max-width: 640px) {
|
||||
.record-sep { display: none; }
|
||||
}
|
||||
|
||||
/* ----- 键盘捕获输入 ----- */
|
||||
.keyboard-capture {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.kb-label {
|
||||
font-size: 14px;
|
||||
color: #cfd3dc;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.kb-input {
|
||||
flex: 1 1 240px;
|
||||
min-width: 200px;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid #3a3f4b;
|
||||
border-radius: 6px;
|
||||
background: #1b1e24;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.kb-input:focus {
|
||||
border-color: #2b6cff;
|
||||
}
|
||||
|
||||
.kb-input:disabled {
|
||||
background: #23262d;
|
||||
color: #888;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.kb-hint {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -227,7 +227,10 @@ export class WebRtcController {
|
||||
|
||||
sendTouch(x, y) { return this.sendControlMessage({ action: 1, x, y }); } // TOUCH
|
||||
sendSwipe(x1, y1, x2, y2, duration) { return this.sendControlMessage({ action: 2, x1, y1, x2, y2, duration }); } // SWIPE
|
||||
sendKey(keyCode) { return this.sendControlMessage({ action: 3, keyCode, keyAction: 0 }); } // KEY
|
||||
// KEY:keyAction 0=按下 1=抬起;不传/负数表示“按下并立即抬起”的一次性点击(兼容旧行为)。
|
||||
sendKey(keyCode) { return this.sendControlMessage({ action: 3, keyCode, keyAction: -1 }); } // KEY(按下+抬起一次性点击)
|
||||
sendKeyDown(keyCode) { return this.sendControlMessage({ action: 3, keyCode, keyAction: 0 }); } // KEY DOWN
|
||||
sendKeyUp(keyCode) { return this.sendControlMessage({ action: 3, keyCode, keyAction: 1 }); } // KEY UP
|
||||
sendLongPress(x, y) { return this.sendControlMessage({ action: 4, x, y }); } // LONG_PRESS
|
||||
sendMotionEvent(action, x, y) { return this.sendControlMessage({ action: 5, motionAction: action, x, y }); } // MOTION_EVENT
|
||||
sendResolutionChange(width, height, fps) { return this.sendControlMessage({ action: 6, width, height, fps }); } // SET_RESOLUTION
|
||||
|
||||
@@ -426,6 +426,8 @@ export function sendSwipe(x1, y1, x2, y2, duration) { return webrtc && webrtc.se
|
||||
export function sendLongPress(x, y) { return webrtc && webrtc.sendLongPress(x, y); }
|
||||
export function sendMotionEvent(action, x, y) { return webrtc && webrtc.sendMotionEvent(action, x, y); }
|
||||
export function sendKey(keyCode) { return webrtc && webrtc.sendKey(keyCode); }
|
||||
export function sendKeyDown(keyCode) { return webrtc && webrtc.sendKeyDown(keyCode); }
|
||||
export function sendKeyUp(keyCode) { return webrtc && webrtc.sendKeyUp(keyCode); }
|
||||
export function sendResolutionChange(width, height, fps) { return webrtc && webrtc.sendResolutionChange(width, height, fps); }
|
||||
|
||||
function parsePayload(payload) {
|
||||
|
||||
@@ -39,10 +39,17 @@ body {
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.brand { font-weight: 700; font-size: 16px; letter-spacing: .5px; }
|
||||
.status { color: var(--muted); font-size: 13px; }
|
||||
.brand { font-weight: 700; font-size: 16px; letter-spacing: .5px; flex-shrink: 0; }
|
||||
.status {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.status.ok { color: var(--accent-2); }
|
||||
.badges { margin-left: auto; display: flex; gap: 8px; }
|
||||
.badges { margin-left: auto; display: flex; gap: 8px; flex-shrink: 0; }
|
||||
.badge {
|
||||
font-size: 12px;
|
||||
padding: 3px 9px;
|
||||
@@ -53,6 +60,45 @@ body {
|
||||
}
|
||||
.badge.on { background: rgba(34,197,94,.15); color: var(--accent-2); border-color: rgba(34,197,94,.4); }
|
||||
|
||||
/* 顶栏右侧用户区:与 badges 之间用分隔线区隔,避免视觉上和状态徽标混作一团 */
|
||||
.user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding-left: 16px;
|
||||
margin-left: 4px;
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
/* 名字与退出按钮之间留出更明显的间距,避免误点 */
|
||||
.user .btn.ghost { margin-left: 5px; }
|
||||
/* 在线小圆点独立于文本容器,避免用户名过长时被 overflow:hidden 裁掉 */
|
||||
.user::before {
|
||||
content: '';
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-2);
|
||||
box-shadow: 0 0 0 3px rgba(34,197,94,.16);
|
||||
}
|
||||
.user-name {
|
||||
max-width: 160px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* 窄屏下优先保住用户区与徽标,隐藏次要的状态文案 */
|
||||
@media (max-width: 720px) {
|
||||
.topbar { gap: 10px; padding: 10px 12px; }
|
||||
.status { display: none; }
|
||||
.user { padding-left: 10px; }
|
||||
.user-name { max-width: 92px; }
|
||||
}
|
||||
|
||||
.layout { display: flex; flex: 1; min-height: 0; }
|
||||
.sidebar {
|
||||
width: 320px;
|
||||
@@ -94,6 +140,29 @@ body {
|
||||
.btn.secondary { background: var(--panel-2); border: 1px solid var(--border); color: var(--text); }
|
||||
.btn.danger { background: var(--danger); }
|
||||
|
||||
/* 幽灵按钮:用于顶栏等行内场景,需覆盖 .btn 的 width:100% 与实心背景 */
|
||||
.btn.ghost {
|
||||
width: auto;
|
||||
flex-shrink: 0;
|
||||
padding: 6px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
color: var(--muted);
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 7px;
|
||||
transition: color .15s ease, border-color .15s ease, background-color .15s ease;
|
||||
}
|
||||
.btn.ghost:hover {
|
||||
filter: none;
|
||||
color: var(--danger);
|
||||
background: rgba(239,68,68,.1);
|
||||
border-color: rgba(239,68,68,.45);
|
||||
}
|
||||
.btn.ghost:active { background: rgba(239,68,68,.18); }
|
||||
.btn.ghost:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
|
||||
.section-title { font-size: 13px; font-weight: 600; margin: 18px 0 10px; color: var(--text); }
|
||||
.hint { font-size: 12px; color: var(--muted); line-height: 1.5; }
|
||||
.error { color: var(--danger); font-size: 12px; margin-top: 8px; }
|
||||
@@ -146,9 +215,16 @@ body {
|
||||
border-top: 1px solid var(--border);
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
/* min-width:0 允许在窄容器下真正收缩,避免内容溢出盖住左侧 ConnectionPanel */
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
.key-btn {
|
||||
min-width: 84px;
|
||||
/* 用 flex 基准替代固定 min-width,空间不足时可等比收缩而不是撑破容器 */
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
background: var(--panel-2);
|
||||
@@ -156,6 +232,7 @@ body {
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
.key-btn:hover { border-color: var(--accent); }
|
||||
@@ -165,9 +242,24 @@ body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
/* 作为 .control-bar 的 flex 子项,默认 min-width:auto 会拒绝收缩导致溢出 */
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
flex-wrap: wrap;
|
||||
row-gap: 8px;
|
||||
}
|
||||
.resolution-label {
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.resolution-label { font-size: 13px; color: var(--text); font-weight: 600; }
|
||||
.resolution-select {
|
||||
/* 允许收缩到 72px,超出部分由 text-overflow 处理,避免把整行撑破 */
|
||||
flex: 0 1 auto;
|
||||
min-width: 72px;
|
||||
max-width: 100%;
|
||||
padding: 9px 11px;
|
||||
background: var(--panel-2);
|
||||
border: 1px solid var(--border);
|
||||
@@ -176,10 +268,36 @@ body {
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.resolution-select:focus { border-color: var(--accent); }
|
||||
.resolution-select:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.resolution-current { font-size: 12px; color: var(--muted); }
|
||||
.resolution-current {
|
||||
/* 始终保留可见:空间不足时靠自身收缩+省略号,而不是整体隐藏 */
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* 窄屏渐进降级:所有元素保持可见,仅通过收紧间距/字号与
|
||||
文本省略来适配,必要时换行,绝不隐藏内容。 */
|
||||
@media (max-width: 1100px) {
|
||||
.control-bar { gap: 6px; padding: 10px; }
|
||||
.key-btn { padding: 9px 11px; }
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.key-btn { padding: 8px 10px; font-size: 12px; }
|
||||
.resolution-current { font-size: 11px; }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.control-bar { justify-content: flex-start; }
|
||||
.resolution-row { width: 100%; }
|
||||
.key-btn { flex: 1 1 auto; min-width: 64px; }
|
||||
}
|
||||
|
||||
.footer { background: var(--panel); border-top: 1px solid var(--border); }
|
||||
.stats-bar {
|
||||
|
||||
Reference in New Issue
Block a user