chore: sync
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user