diff --git a/mengyaconnect-frontend/src/App.vue b/mengyaconnect-frontend/src/App.vue
index fae0d63..1c5d2cd 100644
--- a/mengyaconnect-frontend/src/App.vue
+++ b/mengyaconnect-frontend/src/App.vue
@@ -82,9 +82,63 @@
>
+
+
+ 选区
+
+ {{ selectionSizeText(session) }}
+
+
+
+
+
+
+
+
+ 原生复制
+
+
-
@@ -976,6 +1030,11 @@ function createSession() {
ws: null,
term: null,
fit: null,
+ viewport: null,
+ touchState: null,
+ selectionActive: false,
+ fontSize: 14,
+ nativeTextMode: false,
};
sessions.value.push(session);
@@ -998,15 +1057,18 @@ function createSession() {
function initSession(session, payload) {
const container = terminalRefs.get(session.id);
if (!container) return;
+ session.fontSize = getTerminalFontSize();
+ session.nativeTextMode = false;
const term = markRaw(
new Terminal({
fontFamily: `"JetBrains Mono", "Fira Code", "Cascadia Code", monospace`,
- fontSize: 14,
+ fontSize: session.fontSize,
lineHeight: 1.4,
cursorBlink: true,
scrollback: 8000,
- smoothScrollDuration: 140,
+ // 移动端拖动历史输出时,平滑滚动会显得黏滞,直接关闭更跟手。
+ smoothScrollDuration: 0,
scrollSensitivity: 1.15,
fastScrollSensitivity: 4,
theme: {
@@ -1040,6 +1102,8 @@ function initSession(session, payload) {
term.focus();
term.writeln("\x1b[90m[正在建立连接...]\x1b[0m");
+ session.viewport = term.element?.querySelector(".xterm-viewport") || null;
+
const ws = markRaw(new WebSocket(wsUrl.value));
session.ws = ws;
session.term = term;
@@ -1108,14 +1172,70 @@ function initSession(session, payload) {
}
});
+ term.onSelectionChange(() => {
+ session.selectionActive = term.hasSelection();
+ });
+
term.onResize(() => sendResize());
focusSession(session);
}
+function getTerminalFontSize() {
+ if (typeof window === "undefined") return 14;
+ return window.matchMedia("(max-width: 768px)").matches ? 11 : 14;
+}
+
+function syncTerminalFontSize(session) {
+ if (!session?.term) return;
+ const nextSize = getTerminalFontSize();
+ if (session.fontSize === nextSize) return;
+ session.fontSize = nextSize;
+ session.term.options.fontSize = nextSize;
+ session.fit?.fit();
+ if (session.ws?.readyState === WebSocket.OPEN) {
+ session.ws.send(
+ JSON.stringify({
+ type: "resize",
+ cols: session.term.cols,
+ rows: session.term.rows,
+ })
+ );
+ }
+}
+
+function setNativeCopyMode(session, enabled) {
+ const term = session?.term;
+ if (!term) return;
+ session.nativeTextMode = enabled;
+ session.selectionActive = false;
+ term.clearSelection();
+ term.options.screenReaderMode = enabled;
+ if (term.element) {
+ term.element.classList.toggle("native-terminal-copy-mode", enabled);
+ }
+ if (!enabled) {
+ const selection = document.getSelection?.();
+ selection?.removeAllRanges?.();
+ }
+}
+
+function toggleNativeCopyMode(session, force) {
+ const next = typeof force === "boolean" ? force : !session.nativeTextMode;
+ if (!session?.term || next === session.nativeTextMode) return;
+ setNativeCopyMode(session, next);
+ if (next) {
+ blurTerminal(session);
+ showNotice("已进入原生复制模式,长按文字后点复制", "info", 2200);
+ } else {
+ focusSession(session);
+ }
+}
+
function focusSession(session) {
if (!session || !session.term || !session.fit) return;
nextTick(() => {
+ syncTerminalFontSize(session);
session.fit.fit();
if (session.ws?.readyState === WebSocket.OPEN) {
session.ws.send(
@@ -1126,11 +1246,290 @@ function focusSession(session) {
})
);
}
- session.term.scrollToBottom();
session.term.focus();
});
}
+function getTerminalTouchState(session) {
+ if (!session.touchState) {
+ session.touchState = {
+ timer: null,
+ mode: "idle",
+ touched: false,
+ moved: false,
+ longPress: false,
+ startX: 0,
+ startY: 0,
+ lastY: 0,
+ rowHeight: 0,
+ bodyRect: null,
+ bodyPaddingTop: 0,
+ bodyPaddingBottom: 0,
+ selectionStartRow: 0,
+ selectionEndRow: 0,
+ };
+ }
+ return session.touchState;
+}
+
+function clearTerminalTouchTimer(state) {
+ if (state?.timer) {
+ clearTimeout(state.timer);
+ state.timer = null;
+ }
+}
+
+function getVisibleBufferRow(session, clientY) {
+ if (!session.term || !session.touchState?.bodyRect) return null;
+ const state = session.touchState;
+ const rowHeight = state.rowHeight || 1;
+ const y = clientY - state.bodyRect.top - state.bodyPaddingTop;
+ const row = Math.floor(y / rowHeight);
+ const clamped = Math.max(0, Math.min(session.term.rows - 1, row));
+ return session.term.buffer.active.viewportY + clamped;
+}
+
+function getWrappedSelectionRange(term, bufferRow) {
+ const buffer = term?.buffer?.active;
+ if (!buffer) return null;
+ let start = bufferRow;
+ let end = bufferRow;
+ while (start > 0) {
+ const line = buffer.getLine(start);
+ const prev = buffer.getLine(start - 1);
+ if (!line?.isWrapped || !prev) break;
+ start -= 1;
+ }
+ while (end < buffer.length - 1) {
+ const next = buffer.getLine(end + 1);
+ if (!next?.isWrapped) break;
+ end += 1;
+ }
+ return { start, end };
+}
+
+function applyTerminalSelection(session, startRow, endRow) {
+ const term = session.term;
+ if (!term) return;
+ const startRange = getWrappedSelectionRange(term, startRow);
+ const endRange = getWrappedSelectionRange(term, endRow);
+ if (!startRange || !endRange) return;
+ const start = Math.min(startRange.start, endRange.start);
+ const end = Math.max(startRange.end, endRange.end);
+ term.clearSelection();
+ term.selectLines(start, end);
+ session.selectionActive = true;
+}
+
+function blurTerminal(session) {
+ session.term?.blur();
+ const activeElement = document.activeElement;
+ if (activeElement && typeof activeElement.blur === "function") {
+ activeElement.blur();
+ }
+}
+
+function getNativeSelectionText(session) {
+ const term = session?.term;
+ if (!term?.element) return "";
+ const selection = document.getSelection?.();
+ if (!selection) return "";
+ const text = selection.toString();
+ if (!text) return "";
+ const anchorNode = selection.anchorNode;
+ const focusNode = selection.focusNode;
+ if (!term.element.contains(anchorNode) && !term.element.contains(focusNode)) {
+ return "";
+ }
+ return text;
+}
+
+function selectionSizeText(session) {
+ const state = session.touchState;
+ if (!state) return "";
+ const top = Math.min(state.selectionStartRow, state.selectionEndRow);
+ const bottom = Math.max(state.selectionStartRow, state.selectionEndRow);
+ return `${bottom - top + 1} 行`;
+}
+
+function extendTerminalSelection(session, direction) {
+ const term = session.term;
+ const state = session.touchState;
+ if (!term || !state) return;
+ blurTerminal(session);
+ const buffer = term.buffer.active;
+ let top = Math.min(state.selectionStartRow, state.selectionEndRow);
+ let bottom = Math.max(state.selectionStartRow, state.selectionEndRow);
+
+ if (direction === "up") {
+ top = Math.max(0, top - 1);
+ } else if (direction === "down") {
+ bottom = Math.min(buffer.length - 1, bottom + 1);
+ } else {
+ return;
+ }
+
+ state.selectionStartRow = top;
+ state.selectionEndRow = bottom;
+ applyTerminalSelection(session, top, bottom);
+}
+
+function shrinkTerminalSelection(session) {
+ const term = session.term;
+ const state = session.touchState;
+ if (!term || !state) return;
+ blurTerminal(session);
+ let top = Math.min(state.selectionStartRow, state.selectionEndRow);
+ let bottom = Math.max(state.selectionStartRow, state.selectionEndRow);
+ if (bottom - top <= 0) return;
+ const shrinkFromTop = state.startY < (state.bodyRect?.top || 0) + (state.bodyRect?.height || 0) / 2;
+ if (shrinkFromTop) {
+ top += 1;
+ } else {
+ bottom -= 1;
+ }
+ state.selectionStartRow = top;
+ state.selectionEndRow = bottom;
+ applyTerminalSelection(session, top, bottom);
+}
+
+async function copyTerminalSelection(session) {
+ const term = session.term;
+ if (!term) return false;
+ blurTerminal(session);
+ const text = session.nativeTextMode ? getNativeSelectionText(session) : term.getSelection();
+ if (!text) return false;
+ try {
+ if (navigator.clipboard?.writeText) {
+ await navigator.clipboard.writeText(text);
+ } else {
+ const textarea = document.createElement("textarea");
+ textarea.value = text;
+ textarea.setAttribute("readonly", "readonly");
+ textarea.style.position = "fixed";
+ textarea.style.left = "-9999px";
+ textarea.style.top = "0";
+ document.body.appendChild(textarea);
+ textarea.select();
+ const ok = document.execCommand("copy");
+ textarea.remove();
+ if (!ok) throw new Error("copy command failed");
+ }
+ showNotice("已复制终端输出", "success");
+ return true;
+ } catch (err) {
+ console.error(err);
+ showNotice("复制失败,请重试", "error");
+ return false;
+ }
+}
+
+function clearTerminalSelection(session) {
+ blurTerminal(session);
+ if (session.nativeTextMode) {
+ setNativeCopyMode(session, false);
+ }
+ session.term?.clearSelection();
+ session.selectionActive = false;
+ if (session.touchState) {
+ session.touchState.selectionStartRow = 0;
+ session.touchState.selectionEndRow = 0;
+ }
+}
+
+function handleTerminalTouchStart(session, event) {
+ const touch = event.touches?.[0];
+ if (!touch || !session.term) return;
+ const state = getTerminalTouchState(session);
+ const body = terminalRefs.get(session.id);
+ if (!body) return;
+ clearTerminalTouchTimer(state);
+ const rect = body.getBoundingClientRect();
+ const style = window.getComputedStyle(body);
+ const paddingTop = parseFloat(style.paddingTop) || 0;
+ const paddingBottom = parseFloat(style.paddingBottom) || 0;
+ const usableHeight = Math.max(1, rect.height - paddingTop - paddingBottom);
+ state.touched = true;
+ state.moved = false;
+ state.longPress = false;
+ state.mode = "pending";
+ state.startX = touch.clientX;
+ state.startY = touch.clientY;
+ state.lastY = touch.clientY;
+ state.rowHeight = usableHeight / Math.max(1, session.term.rows);
+ state.bodyRect = rect;
+ state.bodyPaddingTop = paddingTop;
+ state.bodyPaddingBottom = paddingBottom;
+
+ state.timer = window.setTimeout(async () => {
+ if (!state.touched || state.moved || !session.term) return;
+ clearTerminalTouchTimer(state);
+ state.mode = "select";
+ state.longPress = true;
+ const bufferRow = getVisibleBufferRow(session, state.startY);
+ if (bufferRow == null) return;
+ state.selectionStartRow = bufferRow;
+ state.selectionEndRow = bufferRow;
+ applyTerminalSelection(session, bufferRow, bufferRow);
+ showNotice("已选中终端内容,点“复制”即可复制", "info", 2200);
+ }, 520);
+}
+
+function handleTerminalTouchMove(session, event) {
+ const touch = event.touches?.[0];
+ if (!touch || !session.term) return;
+ const state = session.touchState;
+ if (!state || !state.touched) return;
+
+ const dx = touch.clientX - state.startX;
+ const dy = touch.clientY - state.startY;
+ if (state.mode === "select") {
+ const bufferRow = getVisibleBufferRow(session, touch.clientY);
+ if (bufferRow == null) return;
+ state.selectionEndRow = bufferRow;
+ applyTerminalSelection(session, state.selectionStartRow, state.selectionEndRow);
+ event.preventDefault();
+ return;
+ }
+
+ if (!state.moved && Math.hypot(dx, dy) > 8) {
+ state.moved = true;
+ state.mode = "scroll";
+ clearTerminalTouchTimer(state);
+ }
+
+ if (state.mode !== "scroll") return;
+ const viewport = session.viewport;
+ if (!viewport) return;
+ event.preventDefault();
+ viewport.scrollTop += state.lastY - touch.clientY;
+ state.lastY = touch.clientY;
+}
+
+function handleTerminalTouchEnd(session) {
+ const state = session.touchState;
+ if (!state) return;
+ clearTerminalTouchTimer(state);
+ const shouldFocus = state.touched && state.mode === "pending" && !state.moved;
+ state.touched = false;
+ state.moved = false;
+ state.longPress = false;
+ state.mode = "idle";
+ if (shouldFocus) {
+ focusSession(session);
+ }
+}
+
+function handleTerminalTouchCancel(session) {
+ const state = session.touchState;
+ if (!state) return;
+ clearTerminalTouchTimer(state);
+ state.touched = false;
+ state.moved = false;
+ state.longPress = false;
+ state.mode = "idle";
+}
+
function setActive(id) {
activeId.value = id;
}
@@ -1153,6 +1552,7 @@ function closeSession(id) {
function handleWindowResize() {
const session = sessions.value.find((item) => item.id === activeId.value);
if (!session) return;
+ syncTerminalFontSize(session);
focusSession(session);
}
@@ -1287,6 +1687,20 @@ function handlePaste(event) {
sendImageToTerminal(file);
}
+function isMobileTouchInput() {
+ if (typeof window === "undefined") return false;
+ return window.matchMedia("(pointer: coarse)").matches;
+}
+
+function handleAppPointerDown(event) {
+ if (!isMobileTouchInput()) return;
+ if ("pointerType" in event && event.pointerType !== "touch") return;
+ const target = event.target;
+ if (!(target instanceof Element)) return;
+ if (!target.closest("button")) return;
+ event.preventDefault();
+}
+
// 根元素引用,用于动态调整高度以适配移动端虚拟键盘
const appRef = ref(null);
let viewportFitTimer = null;
@@ -1294,14 +1708,11 @@ let viewportFitTimer = null;
function updateAppHeight() {
const vv = window.visualViewport;
const h = vv ? vv.height : window.innerHeight;
- // 部分移动端浏览器键盘弹出时高度不缩,但会覆盖页面;用 padding-bottom 兜底
- const keyboard =
- vv && typeof vv.height === "number"
- ? Math.max(0, window.innerHeight - vv.height - (vv.offsetTop || 0))
- : 0;
if (appRef.value) {
appRef.value.style.height = h + "px";
- appRef.value.style.setProperty("--keyboard-inset", keyboard + "px");
+ // 这里已经直接跟随 visualViewport.height,不再额外预留整块键盘高度,
+ // 否则手机端会在快捷键栏和系统键盘之间留下很大的空白。
+ appRef.value.style.setProperty("--keyboard-inset", "0px");
}
// 触发布局变化后,让终端重新适配可视区域
requestAnimationFrame(() => {
@@ -1328,6 +1739,7 @@ function hideSplashScreen() {
onMounted(() => {
window.addEventListener("resize", handleWindowResize);
document.addEventListener("paste", handlePaste);
+ document.addEventListener("pointerdown", handleAppPointerDown, true);
// 移动端虚拟键盘弹出时,visualViewport 会缩减,用它动态撑高度
if (window.visualViewport) {
window.visualViewport.addEventListener("resize", updateAppHeight);
@@ -1345,6 +1757,7 @@ onMounted(() => {
onBeforeUnmount(() => {
window.removeEventListener("resize", handleWindowResize);
document.removeEventListener("paste", handlePaste);
+ document.removeEventListener("pointerdown", handleAppPointerDown, true);
if (viewportFitTimer) {
clearTimeout(viewportFitTimer);
viewportFitTimer = null;
@@ -1673,8 +2086,6 @@ onBeforeUnmount(() => {
0 30px 80px rgba(15, 23, 42, 0.95),
0 0 40px rgba(56, 189, 248, 0.12);
overflow: hidden;
- /* 虚拟键盘覆盖时,给底部留白避免遮挡终端最后几行 */
- padding-bottom: var(--keyboard-inset, 0px);
min-height: 0;
}
@@ -1815,6 +2226,13 @@ onBeforeUnmount(() => {
color: #e5e7eb;
}
+.terminal-header-actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-left: auto;
+}
+
.terminal-header .status-text {
color: #9ca3af;
}
@@ -1825,6 +2243,86 @@ onBeforeUnmount(() => {
min-height: 0;
overflow: hidden;
overscroll-behavior: contain;
+ position: relative;
+}
+
+.terminal-touch-overlay {
+ position: absolute;
+ inset: 0;
+ z-index: 5;
+ display: none;
+ background: transparent;
+ touch-action: none;
+ user-select: none;
+ -webkit-user-select: none;
+}
+
+.native-terminal-copy-mode {
+ cursor: text;
+ user-select: text;
+ -webkit-user-select: text;
+}
+
+.native-terminal-copy-mode .xterm-screen canvas {
+ opacity: 0 !important;
+ pointer-events: none !important;
+}
+
+.native-terminal-copy-mode .xterm-accessibility:not(.debug) {
+ pointer-events: auto !important;
+ color: #e5e7eb !important;
+}
+
+.native-terminal-copy-mode .xterm-accessibility-tree {
+ user-select: text !important;
+ -webkit-user-select: text !important;
+ color: #e5e7eb !important;
+ font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", monospace;
+ font-size: 11px;
+ line-height: 1.4;
+}
+
+.native-terminal-copy-mode .xterm-accessibility-tree * {
+ user-select: text !important;
+ -webkit-user-select: text !important;
+}
+
+.native-terminal-copy-mode .xterm-accessibility-tree *::selection {
+ background: rgba(59, 130, 246, 0.35);
+ color: #fff;
+}
+
+.terminal-selection-bar {
+ position: absolute;
+ left: 50%;
+ bottom: 10px;
+ transform: translateX(-50%);
+ z-index: 12;
+ display: none;
+ align-items: center;
+ gap: 6px;
+ max-width: calc(100% - 16px);
+ padding: 6px 8px;
+ border-radius: 16px;
+ background: rgba(2, 6, 23, 0.9);
+ border: 1px solid rgba(59, 130, 246, 0.35);
+ box-shadow: 0 12px 32px rgba(15, 23, 42, 0.55);
+ backdrop-filter: blur(12px);
+ font-size: 11px;
+ color: #dbeafe;
+ flex-wrap: wrap;
+ justify-content: center;
+}
+
+.native-selection-bar {
+ gap: 8px;
+}
+
+.selection-size {
+ color: #93c5fd;
+ font-variant-numeric: tabular-nums;
+ font-size: 10px;
+ padding: 0 4px;
}
.quick-keys {
@@ -2192,6 +2690,42 @@ textarea {
.overlay {
padding: 10px;
}
+
+ .terminal-header {
+ gap: 8px;
+ }
+
+ .terminal-header-actions {
+ gap: 6px;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ }
+
+ .terminal-header-actions .secondary.tiny {
+ padding: 4px 7px;
+ font-size: 11px;
+ }
+
+ .terminal-touch-overlay {
+ display: block;
+ }
+
+ .native-terminal-copy-mode .xterm-screen canvas {
+ display: none !important;
+ }
+
+ .native-terminal-copy-mode .xterm-accessibility:not(.debug) {
+ inset: 0 !important;
+ }
+
+ .terminal-selection-bar {
+ display: flex;
+ }
+
+ .terminal-selection-bar .secondary.tiny {
+ padding: 4px 7px;
+ min-width: 0;
+ }
}