feat: 支持按键分离事件与键盘捕获,并优化UI

- 后端与前端新增 keyAction 参数,支持按键按下/抬起独立事件,保持向下兼容
- 前端控制栏支持 pointerdown/pointerup 长按,新增键盘捕获输入框,映射浏览器按键到 Android KeyEvent
- 重构顶栏布局:用户区右置并添加在线指示器、状态文本省略,优化窄屏响应式表现
- 控制栏录制按钮组防拆行,分辨率/帧率选择器自适应收缩,全局样式增强
- 修复 AdminAuthFilter 中 doFilter 被异常捕获导致误返回 401 的问题
- 移除管理后台中用户 admin 角色及相关 UI 与 API
- 更新信令前端环境代理地址,修正路由名称大小写
This commit is contained in:
2026-08-03 11:09:37 +08:00
parent e6ed7c8e3e
commit 78aa3d3c71
16 changed files with 374 additions and 79 deletions

View File

@@ -53,7 +53,7 @@ public class AccessibilityInputUtils implements InputExecutor {
} }
@Override @Override
public void injectKeyEvent(int keyCode) { public void injectKeyEvent(int keyCode, int keyAction) {
KeyboardAccessibilityService svc = getService(); KeyboardAccessibilityService svc = getService();
if (svc == null) { if (svc == null) {
Log.w(TAG, "Accessibility service not running, cannot inject key"); Log.w(TAG, "Accessibility service not running, cannot inject key");

View File

@@ -133,8 +133,13 @@ public class InputCommandHandler {
private void handleKey(ControlMessage command) { private void handleKey(ControlMessage command) {
int keyCode = command.getKeyCode(); int keyCode = command.getKeyCode();
Log.d(TAG, "Key press: " + keyCode); if (keyCode == 0) {
inputExecutor.injectKeyEvent(keyCode); Log.w(TAG, "Ignoring KEY command with keyCode=0");
return;
}
int keyAction = command.getKeyAction();
Log.d(TAG, "Key press: keyCode=" + keyCode + ", keyAction=" + keyAction);
inputExecutor.injectKeyEvent(keyCode, keyAction);
} }
private void handleSwipe(ControlMessage command) { private void handleSwipe(ControlMessage command) {

View File

@@ -7,7 +7,12 @@ public interface InputExecutor {
void injectTap(int x, int y); void injectTap(int x, int y);
void injectLongPress(int x, int y); void injectLongPress(int x, int y);
void injectSwipe(int x1, int y1, int x2, int y2, long duration); void injectSwipe(int x1, int y1, int x2, int y2, long duration);
void injectKeyEvent(int keyCode); /**
* 注入按键事件。
* @param keyCode Android KeyEvent 键值(如 KeyEvent.KEYCODE_A
* @param keyAction 动作0=ACTION_DOWN1=ACTION_UP传 -1 表示“按下并立即抬起”的一次性点击(兼容旧行为)。
*/
void injectKeyEvent(int keyCode, int keyAction);
void injectMotionEvent(int action, int x, int y); void injectMotionEvent(int action, int x, int y);
/** /**

View File

@@ -31,7 +31,7 @@ public class RootShellInputUtils implements InputExecutor {
} }
@Override @Override
public void injectKeyEvent(int keyCode) { public void injectKeyEvent(int keyCode, int keyAction) {
executeRootCommand(String.format(Locale.US, "input keyevent %d", keyCode)); executeRootCommand(String.format(Locale.US, "input keyevent %d", keyCode));
} }

View File

@@ -25,7 +25,7 @@ public class ShellInputUtils implements InputExecutor {
} }
@Override @Override
public void injectKeyEvent(int keyCode) { public void injectKeyEvent(int keyCode, int keyAction) {
executeShellCommand(String.format(Locale.US, "input keyevent %d", keyCode)); executeShellCommand(String.format(Locale.US, "input keyevent %d", keyCode));
} }

View File

@@ -2,6 +2,7 @@ package com.ttstd.controlled.input;
import android.os.SystemClock; import android.os.SystemClock;
import android.util.Log; import android.util.Log;
import android.util.SparseLongArray;
import android.view.InputDevice; import android.view.InputDevice;
import android.view.InputEvent; import android.view.InputEvent;
import android.view.KeyEvent; import android.view.KeyEvent;
@@ -21,6 +22,7 @@ public class SystemInputUtils implements InputExecutor {
private static final int INJECT_INPUT_EVENT_MODE_ASYNC = 0; private static final int INJECT_INPUT_EVENT_MODE_ASYNC = 0;
private static final int INJECT_INPUT_EVENT_MODE_WAIT_FOR_FINISH = 2; private static final int INJECT_INPUT_EVENT_MODE_WAIT_FOR_FINISH = 2;
private long mDownTime; private long mDownTime;
private final SparseLongArray mKeyDownTimes = new SparseLongArray();
public SystemInputUtils() { public SystemInputUtils() {
try { try {
@@ -69,10 +71,28 @@ public class SystemInputUtils implements InputExecutor {
} }
@Override @Override
public void injectKeyEvent(int keyCode) { public void injectKeyEvent(int keyCode, int keyAction) {
if (mInputManager == null || mInjectInputEventMethod == null) {
return;
}
long now = SystemClock.uptimeMillis(); long now = SystemClock.uptimeMillis();
if (keyAction < 0) {
// 向后兼容:未指定动作时执行“按下并立即抬起”的一次性点击
injectKeyEvent(now, now, KeyEvent.ACTION_DOWN, keyCode); injectKeyEvent(now, now, KeyEvent.ACTION_DOWN, keyCode);
injectKeyEvent(now, SystemClock.uptimeMillis(), KeyEvent.ACTION_UP, keyCode); injectKeyEvent(now, now, KeyEvent.ACTION_UP, keyCode);
return;
}
int action = (keyAction == 0) ? KeyEvent.ACTION_DOWN : KeyEvent.ACTION_UP;
long downTime;
if (action == KeyEvent.ACTION_DOWN) {
downTime = now;
mKeyDownTimes.put(keyCode, downTime);
} else {
// 抬起时复用按下时刻,保证同一次按键周期内 downTime 一致(用于长按/组合键识别)
downTime = mKeyDownTimes.get(keyCode, now);
mKeyDownTimes.delete(keyCode);
}
injectKeyEvent(downTime, now, action, keyCode);
} }
@Override @Override

View File

@@ -27,10 +27,7 @@ async function onLogout() {
<template v-else> <template v-else>
<header class="topbar"> <header class="topbar">
<div class="brand">WebRTC 网页远程控制</div> <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="status" :class="{ ok: store.rtcConnected }">{{ store.statusText }}</div>
<div class="badges"> <div class="badges">
<span class="badge" :class="{ on: store.signalingConnected }">信令</span> <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.rtcConnected }">P2P</span>
<span class="badge" :class="{ on: store.dataChannelOpen }">通道</span> <span class="badge" :class="{ on: store.dataChannelOpen }">通道</span>
</div> </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> </header>
<main class="layout"> <main class="layout">

View File

@@ -1,6 +1,6 @@
<script setup> <script setup>
import { ref, watch } from 'vue'; 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 注入一致) // Android KeyEvent 键值(与被控端 SystemInputUtils 注入一致)
const keys = [ const keys = [
@@ -29,9 +29,69 @@ const selectedResolution = ref(0);
const fpsOptions = ref([15, 24, 30, 60]); const fpsOptions = ref([15, 24, 30, 60]);
const selectedFps = ref(0); // 0 表示尚未同步到被控端当前帧率 const selectedFps = ref(0); // 0 表示尚未同步到被控端当前帧率
function press(code) { function pressDown(code) {
if (!store.dataChannelOpen) return; 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() { function onResolutionChange() {
@@ -100,6 +160,8 @@ watch(() => store.currentResolution, (res) => {
<span class="record-sep"></span> <span class="record-sep"></span>
<!-- 录制按钮与状态包成一组保证换行时二者始终同进同退不被拆散 -->
<div class="record-group">
<button <button
class="record-btn" class="record-btn"
:class="{ recording: store.recording }" :class="{ recording: store.recording }"
@@ -108,7 +170,12 @@ watch(() => store.currentResolution, (res) => {
> >
{{ store.recording ? '停止录制' : '录制' }} {{ store.recording ? '停止录制' : '录制' }}
</button> </button>
<span class="record-status" v-if="store.recordStatus">{{ store.recordStatus }}</span> <span
class="record-status"
v-if="store.recordStatus"
:title="store.recordStatus"
>{{ store.recordStatus }}</span>
</div>
</div> </div>
<button <button
@@ -116,33 +183,71 @@ watch(() => store.currentResolution, (res) => {
:key="k.code" :key="k.code"
class="key-btn" class="key-btn"
:disabled="!store.dataChannelOpen" :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 }} {{ k.label }}
</button> </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> </div>
</template> </template>
<style scoped> <style scoped>
.record-sep { .record-sep {
display: inline-block;
width: 1px; width: 1px;
height: 22px; height: 22px;
margin: 0 10px; margin: 0 4px;
background: #3a3f4b; 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 { .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: 1px solid #3a3f4b;
border-radius: 6px; border-radius: 6px;
background: #2b6cff; background: #2b6cff;
color: #fff; color: #fff;
font-size: 14px; font-size: 14px;
line-height: 1.2;
cursor: pointer; cursor: pointer;
transition: background-color .15s ease, border-color .15s ease;
} }
.record-btn:hover:not(:disabled) { background: #1f5ce0; }
.record-btn:disabled { .record-btn:disabled {
background: #3a3f4b; background: #3a3f4b;
color: #888; color: #888;
@@ -151,12 +256,68 @@ watch(() => store.currentResolution, (res) => {
.record-btn.recording { .record-btn.recording {
background: #e5484d; background: #e5484d;
border-color: #e5484d;
} }
.record-btn.recording:hover:not(:disabled) { background: #cf3b40; }
.record-status { .record-status {
margin-left: 8px; /* 空间不足时由状态文字收缩并省略,保证按钮文字完整、二者同处一行 */
flex: 0 1 auto;
min-width: 0;
max-width: 180px;
font-size: 13px; font-size: 13px;
color: #ffb020; color: #ffb020;
white-space: nowrap; 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> </style>

View File

@@ -227,7 +227,10 @@ export class WebRtcController {
sendTouch(x, y) { return this.sendControlMessage({ action: 1, x, y }); } // TOUCH 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 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 // KEYkeyAction 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 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 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 sendResolutionChange(width, height, fps) { return this.sendControlMessage({ action: 6, width, height, fps }); } // SET_RESOLUTION

View File

@@ -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 sendLongPress(x, y) { return webrtc && webrtc.sendLongPress(x, y); }
export function sendMotionEvent(action, x, y) { return webrtc && webrtc.sendMotionEvent(action, 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 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); } export function sendResolutionChange(width, height, fps) { return webrtc && webrtc.sendResolutionChange(width, height, fps); }
function parsePayload(payload) { function parsePayload(payload) {

View File

@@ -39,10 +39,17 @@ body {
background: var(--panel); background: var(--panel);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
.brand { font-weight: 700; font-size: 16px; letter-spacing: .5px; } .brand { font-weight: 700; font-size: 16px; letter-spacing: .5px; flex-shrink: 0; }
.status { color: var(--muted); font-size: 13px; } .status {
color: var(--muted);
font-size: 13px;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.status.ok { color: var(--accent-2); } .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 { .badge {
font-size: 12px; font-size: 12px;
padding: 3px 9px; 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); } .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; } .layout { display: flex; flex: 1; min-height: 0; }
.sidebar { .sidebar {
width: 320px; width: 320px;
@@ -94,6 +140,29 @@ body {
.btn.secondary { background: var(--panel-2); border: 1px solid var(--border); color: var(--text); } .btn.secondary { background: var(--panel-2); border: 1px solid var(--border); color: var(--text); }
.btn.danger { background: var(--danger); } .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); } .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; } .hint { font-size: 12px; color: var(--muted); line-height: 1.5; }
.error { color: var(--danger); font-size: 12px; margin-top: 8px; } .error { color: var(--danger); font-size: 12px; margin-top: 8px; }
@@ -146,9 +215,16 @@ body {
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
flex-wrap: wrap; flex-wrap: wrap;
justify-content: center; justify-content: center;
align-items: center;
/* min-width:0 允许在窄容器下真正收缩,避免内容溢出盖住左侧 ConnectionPanel */
min-width: 0;
max-width: 100%;
overflow-x: hidden;
} }
.key-btn { .key-btn {
min-width: 84px; /* 用 flex 基准替代固定 min-width空间不足时可等比收缩而不是撑破容器 */
flex: 0 1 auto;
min-width: 0;
padding: 10px 14px; padding: 10px 14px;
border-radius: 8px; border-radius: 8px;
background: var(--panel-2); background: var(--panel-2);
@@ -156,6 +232,7 @@ body {
color: var(--text); color: var(--text);
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
white-space: nowrap;
cursor: pointer; cursor: pointer;
} }
.key-btn:hover { border-color: var(--accent); } .key-btn:hover { border-color: var(--accent); }
@@ -165,9 +242,24 @@ body {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; 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 { .resolution-select {
/* 允许收缩到 72px超出部分由 text-overflow 处理,避免把整行撑破 */
flex: 0 1 auto;
min-width: 72px;
max-width: 100%;
padding: 9px 11px; padding: 9px 11px;
background: var(--panel-2); background: var(--panel-2);
border: 1px solid var(--border); border: 1px solid var(--border);
@@ -176,10 +268,36 @@ body {
font-size: 13px; font-size: 13px;
outline: none; outline: none;
cursor: pointer; cursor: pointer;
text-overflow: ellipsis;
} }
.resolution-select:focus { border-color: var(--accent); } .resolution-select:focus { border-color: var(--accent); }
.resolution-select:disabled { opacity: .5; cursor: not-allowed; } .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); } .footer { background: var(--panel); border-top: 1px solid var(--border); }
.stats-bar { .stats-bar {

View File

@@ -60,17 +60,24 @@ public class AdminAuthFilter extends OncePerRequestFilter {
writeUnauthorized(response, "管理员令牌为空"); writeUnauthorized(response, "管理员令牌为空");
return; return;
} }
AdminAuthPrincipal principal;
try { try {
Map<String, Object> claims = adminService.verifyToken(token); Map<String, Object> claims = adminService.verifyToken(token);
String sub = com.ttstd.signaling.security.JwtService.claimAsString(claims, "sub"); String sub = com.ttstd.signaling.security.JwtService.claimAsString(claims, "sub");
AdminAuthPrincipal principal = new AdminAuthPrincipal(sub); principal = new AdminAuthPrincipal(sub);
request.setAttribute("adminPrincipal", principal);
filterChain.doFilter(request, response);
} catch (AuthException e) { } catch (AuthException e) {
writeUnauthorized(response, e.getMessage()); writeUnauthorized(response, e.getPublicMessage());
return;
} catch (Exception e) { } catch (Exception e) {
writeUnauthorized(response, "令牌校验失败"); writeUnauthorized(response, "令牌校验失败");
return;
} }
// 令牌校验通过后才放行。注意doFilter 必须在 try/catch 之外,
// 否则下游 Controller/Service 抛出的业务异常(如密码强度不足的 400
// 会被误捕获并改写成 401导致前端清除令牌、管理员被强制下线。
request.setAttribute("adminPrincipal", principal);
filterChain.doFilter(request, response);
} }
private void writeUnauthorized(HttpServletResponse response, String message) throws IOException { private void writeUnauthorized(HttpServletResponse response, String message) throws IOException {

View File

@@ -4,7 +4,7 @@
# 【代理目标】Vite dev server 把 /api/** 转发到这里(仅开发态生效,浏览器不直接访问)。 # 【代理目标】Vite dev server 把 /api/** 转发到这里(仅开发态生效,浏览器不直接访问)。
# 后端 Spring Boot 地址,按需改成本机或远程信令服务器。 # 后端 Spring Boot 地址,按需改成本机或远程信令服务器。
VITE_API_TARGET=http://localhost:8080 VITE_API_TARGET=http://192.168.5.224:8080
# 【请求基地址】留空表示走相对路径 /api同源经上面的代理转发——推荐保持留空。 # 【请求基地址】留空表示走相对路径 /api同源经上面的代理转发——推荐保持留空。
# 仅在需要浏览器直连后端、绕过代理时才填绝对地址,例如: # 仅在需要浏览器直连后端、绕过代理时才填绝对地址,例如:

View File

@@ -130,7 +130,6 @@ export interface UserAccountView {
status: AccountStatus; status: AccountStatus;
statusReason: string | null; statusReason: string | null;
statusUntil: number | null; statusUntil: number | null;
admin: boolean;
totpEnabled: boolean; totpEnabled: boolean;
createdAt: number; createdAt: number;
lastLoginAt: number | null; lastLoginAt: number | null;
@@ -158,7 +157,6 @@ export function getUsers(query: UserQuery = {}): Promise<UserAccountView[]> {
export function createUser(payload: { export function createUser(payload: {
username: string; username: string;
password: string; password: string;
admin?: boolean;
}): Promise<{ success: boolean; message?: string; user?: UserAccountView }> { }): Promise<{ success: boolean; message?: string; user?: UserAccountView }> {
return request.post('/api/admin/users', payload) as unknown as Promise<{ return request.post('/api/admin/users', payload) as unknown as Promise<{
success: boolean; success: boolean;
@@ -182,12 +180,6 @@ export function resetUserPassword(
}) as unknown as Promise<{ success: boolean; message?: string }>; }) as unknown as Promise<{ success: boolean; message?: string }>;
} }
export function setUserAdmin(userId: string, admin: boolean): Promise<{ success: boolean }> {
return request.post(`/api/admin/users/${encodeURIComponent(userId)}/admin`, {
admin,
}) as unknown as Promise<{ success: boolean }>;
}
/** 封禁账号durationSeconds 省略或 <=0 表示永久。 */ /** 封禁账号durationSeconds 省略或 <=0 表示永久。 */
export function banUser( export function banUser(
userId: string, userId: string,

View File

@@ -41,8 +41,8 @@ request.interceptors.response.use(
if (status === 401) { if (status === 401) {
localStorage.removeItem('admin_token'); localStorage.removeItem('admin_token');
ElMessage.error('登录已失效,请重新登录'); ElMessage.error('登录已失效,请重新登录');
if (router.currentRoute.value.name !== 'Login') { if (router.currentRoute.value.name !== 'login') {
router.replace({ name: 'Login' }); router.replace({ name: 'login' });
} }
} else { } else {
ElMessage.error(error.response?.data?.message || error.message || '请求失败'); ElMessage.error(error.response?.data?.message || error.message || '请求失败');

View File

@@ -31,9 +31,6 @@
<el-table-column prop="username" label="用户名" min-width="150"> <el-table-column prop="username" label="用户名" min-width="150">
<template #default="{ row }"> <template #default="{ row }">
<span class="link-user" @click="openBindings(row)">{{ row.username }}</span> <span class="link-user" @click="openBindings(row)">{{ row.username }}</span>
<el-tag v-if="row.admin" type="danger" size="small" style="margin-left: 6px">
管理员
</el-tag>
<el-tag v-if="row.deviceCount > 0" type="info" size="small" style="margin-left: 6px"> <el-tag v-if="row.deviceCount > 0" type="info" size="small" style="margin-left: 6px">
{{ row.deviceCount }} 台设备 {{ row.deviceCount }} 台设备
</el-tag> </el-tag>
@@ -96,9 +93,6 @@
<el-dropdown-item command="bindings">查看绑定设备</el-dropdown-item> <el-dropdown-item command="bindings">查看绑定设备</el-dropdown-item>
<el-dropdown-item command="sessions">查看会话</el-dropdown-item> <el-dropdown-item command="sessions">查看会话</el-dropdown-item>
<el-dropdown-item command="reset">重置密码</el-dropdown-item> <el-dropdown-item command="reset">重置密码</el-dropdown-item>
<el-dropdown-item command="admin">
{{ row.admin ? '取消管理员' : '设为管理员' }}
</el-dropdown-item>
<el-dropdown-item command="delete" divided>删除账号</el-dropdown-item> <el-dropdown-item command="delete" divided>删除账号</el-dropdown-item>
</el-dropdown-menu> </el-dropdown-menu>
</template> </template>
@@ -118,9 +112,6 @@
<el-form-item label="密码" prop="password"> <el-form-item label="密码" prop="password">
<el-input v-model="createForm.password" type="password" show-password /> <el-input v-model="createForm.password" type="password" show-password />
</el-form-item> </el-form-item>
<el-form-item label="管理员">
<el-switch v-model="createForm.admin" />
</el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="createVisible = false">取消</el-button> <el-button @click="createVisible = false">取消</el-button>
@@ -211,7 +202,6 @@ import {
createUser, createUser,
deleteUser, deleteUser,
resetUserPassword, resetUserPassword,
setUserAdmin,
banUser, banUser,
unbanUser, unbanUser,
kickUser, kickUser,
@@ -249,7 +239,7 @@ watch(statusFilter, load);
// ---------- 添加用户 ---------- // ---------- 添加用户 ----------
const createVisible = ref(false); const createVisible = ref(false);
const createFormRef = ref<FormInstance>(); const createFormRef = ref<FormInstance>();
const createForm = ref({ username: '', password: '', admin: false }); const createForm = ref({ username: '', password: '' });
const createRules: FormRules = { const createRules: FormRules = {
username: [ username: [
{ required: true, message: '请输入用户名', trigger: 'blur' }, { required: true, message: '请输入用户名', trigger: 'blur' },
@@ -262,7 +252,7 @@ const createRules: FormRules = {
}; };
function openCreate() { function openCreate() {
createForm.value = { username: '', password: '', admin: false }; createForm.value = { username: '', password: '' };
createVisible.value = true; createVisible.value = true;
} }
@@ -328,19 +318,6 @@ async function handleDelete(row: UserAccountView) {
await load(); await load();
} }
async function handleToggleAdmin(row: UserAccountView) {
const next = !row.admin;
const ok = await ElMessageBox.confirm(
`确定${next ? '授予' : '取消'}${row.username}」的管理员权限?`,
'权限调整',
{ type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' },
).catch(() => false);
if (!ok) return;
await setUserAdmin(row.userId, next);
ElMessage.success('权限已更新');
await load();
}
// ---------- 重置密码 ---------- // ---------- 重置密码 ----------
const resetVisible = ref(false); const resetVisible = ref(false);
const newPassword = ref(''); const newPassword = ref('');
@@ -366,6 +343,9 @@ async function submitReset() {
} else { } else {
ElMessage.error(resp.message || '重置失败'); ElMessage.error(resp.message || '重置失败');
} }
} catch {
// 错误提示已由 request 响应拦截器统一弹出,这里仅吞掉异常,
// 避免未捕获的 Promise rejection并保证弹窗保持打开以便重试。
} finally { } finally {
submitting.value = false; submitting.value = false;
} }
@@ -418,7 +398,6 @@ function handleCommand(cmd: string, row: UserAccountView) {
if (cmd === 'bindings') openBindings(row); if (cmd === 'bindings') openBindings(row);
else if (cmd === 'sessions') openSessions(row); else if (cmd === 'sessions') openSessions(row);
else if (cmd === 'reset') openReset(row); else if (cmd === 'reset') openReset(row);
else if (cmd === 'admin') handleToggleAdmin(row);
else if (cmd === 'delete') handleDelete(row); else if (cmd === 'delete') handleDelete(row);
} }