chore: sync

This commit is contained in:
2026-03-18 22:00:41 +08:00
parent ec28b7bceb
commit cd63f328ab
14 changed files with 5431 additions and 10 deletions

View File

@@ -41,6 +41,10 @@
</div>
</header>
<div v-if="notice.text" class="global-notice" :class="notice.type">
{{ notice.text }}
</div>
<!-- 全局专注模式小按钮始终固定在角落 -->
<button class="focus-floating" type="button" @click="toggleFocus">
{{ focusMode ? "" : "👁" }}
@@ -1175,6 +1179,114 @@ function toggleFocus() {
focusMode.value = !focusMode.value;
}
const notice = reactive({
text: "",
type: "info",
});
let noticeTimer = null;
function showNotice(text, type = "info", duration = 3200) {
notice.text = text;
notice.type = type;
if (noticeTimer) {
clearTimeout(noticeTimer);
}
noticeTimer = setTimeout(() => {
notice.text = "";
noticeTimer = null;
}, duration);
}
function isEditableTarget(target) {
if (!target || typeof target !== "object") return false;
const tag = target.tagName?.toLowerCase();
if (tag === "input" || tag === "textarea") return true;
return !!target.isContentEditable;
}
function bytesToBase64(buffer) {
const bytes = new Uint8Array(buffer);
const chunkSize = 0x8000;
let binary = "";
for (let i = 0; i < bytes.length; i += chunkSize) {
binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize));
}
return btoa(binary);
}
function chunkBase64(text, size = 76) {
const chunks = [];
for (let i = 0; i < text.length; i += size) {
chunks.push(text.slice(i, i + size));
}
return chunks.join("\n");
}
function safeFilename(name) {
return name.replace(/[^a-zA-Z0-9._-]/g, "-");
}
function formatTimestamp(date = new Date()) {
const pad = (v) => String(v).padStart(2, "0");
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(
date.getDate()
)}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
}
function sendImageToTerminal(file) {
const session = sessions.value.find((item) => item.id === activeId.value);
if (!session || !session.ws || session.ws.readyState !== WebSocket.OPEN) {
showNotice("已识别剪贴板图片,但当前没有可用终端连接", "warning");
return;
}
const maxBytes = 2 * 1024 * 1024;
if (file.size > maxBytes) {
showNotice("图片过大,已取消粘贴(限制 2MB", "warning");
return;
}
const ext = (file.type || "image/png").split("/")[1] || "png";
const filename = safeFilename(`clipboard-${formatTimestamp()}.${ext}`);
const reader = new FileReader();
reader.onload = () => {
try {
const base64 = bytesToBase64(reader.result);
const payload = chunkBase64(base64);
const marker = `__MENGYA_CLIP_${Date.now()}__`;
const script =
`cat <<'${marker}' | base64 -d > ${filename}\n` +
`${payload}\n` +
`${marker}\n`;
session.ws.send(JSON.stringify({ type: "input", data: script }));
session.term?.writeln(
`\r\n\x1b[90m[已接收剪贴板图片,保存为 ${filename}]\x1b[0m`
);
showNotice(`已发送图片到终端:${filename}`, "success");
} catch (err) {
console.error(err);
showNotice("图片粘贴失败,请重试", "error");
}
};
reader.onerror = () => {
showNotice("读取剪贴板图片失败", "error");
};
reader.readAsArrayBuffer(file);
}
function handlePaste(event) {
if (!event || !event.clipboardData) return;
if (isEditableTarget(event.target)) return;
const text = event.clipboardData.getData("text/plain");
if (text) return;
const items = Array.from(event.clipboardData.items || []);
const imageItem = items.find((item) => item.type?.startsWith("image/"));
if (!imageItem) return;
const file = imageItem.getAsFile();
if (!file) return;
event.preventDefault();
event.stopPropagation();
sendImageToTerminal(file);
}
// 根元素引用,用于动态调整高度以适配移动端虚拟键盘
const appRef = ref(null);
let viewportFitTimer = null;
@@ -1205,8 +1317,17 @@ function updateAppHeight() {
}, 140);
}
function hideSplashScreen() {
if (typeof window === "undefined") return;
const hide = window.__hideSplash;
if (typeof hide === "function") {
requestAnimationFrame(() => hide());
}
}
onMounted(() => {
window.addEventListener("resize", handleWindowResize);
document.addEventListener("paste", handlePaste);
// 移动端虚拟键盘弹出时visualViewport 会缩减,用它动态撑高度
if (window.visualViewport) {
window.visualViewport.addEventListener("resize", updateAppHeight);
@@ -1216,13 +1337,14 @@ onMounted(() => {
}
updateAppHeight();
// 尝试加载后端配置,如后端未启动仅在面板中提示错误
loadSSH();
loadCommands();
loadScripts();
Promise.allSettled([loadSSH(), loadCommands(), loadScripts()]).finally(() => {
hideSplashScreen();
});
});
onBeforeUnmount(() => {
window.removeEventListener("resize", handleWindowResize);
document.removeEventListener("paste", handlePaste);
if (viewportFitTimer) {
clearTimeout(viewportFitTimer);
viewportFitTimer = null;
@@ -1255,6 +1377,36 @@ onBeforeUnmount(() => {
"Segoe UI", sans-serif;
}
.global-notice {
position: fixed;
top: 64px;
right: 20px;
z-index: 50;
padding: 10px 14px;
border-radius: 10px;
font-size: 13px;
background: rgba(15, 23, 42, 0.92);
color: #e2e8f0;
border: 1px solid rgba(148, 163, 184, 0.3);
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.45);
backdrop-filter: blur(8px);
}
.global-notice.success {
border-color: rgba(34, 197, 94, 0.4);
color: #bbf7d0;
}
.global-notice.warning {
border-color: rgba(251, 191, 36, 0.4);
color: #fde68a;
}
.global-notice.error {
border-color: rgba(248, 113, 113, 0.45);
color: #fecaca;
}
.app-header {
height: 56px;
padding: 0 20px;

View File

@@ -1,5 +1,8 @@
import { createApp } from "vue";
import "@xterm/xterm/css/xterm.css";
import App from "./App.vue";
import { registerSW } from "virtual:pwa-register";
createApp(App).mount("#app");
registerSW({ immediate: true });