chore: sync
This commit is contained in:
5
mengyaconnect-frontend/.env.development
Normal file
5
mengyaconnect-frontend/.env.development
Normal file
@@ -0,0 +1,5 @@
|
||||
# 本地开发:前端 5173 → Vite proxy → 后端 8080
|
||||
# 留空即走同源代理(vite.config.js 里已配置 /api proxy)
|
||||
# 如果你想跳过代理直连,取消注释下面两行
|
||||
# VITE_API_BASE=http://localhost:8080/api
|
||||
# VITE_WS_URL=ws://localhost:8080/api/ws/ssh
|
||||
@@ -1,3 +1,3 @@
|
||||
VITE_API_BASE=https://ssh.api.shumengya.top/api
|
||||
VITE_WS_URL=wss://ssh.api.shumengya.top/api/ws/ssh
|
||||
|
||||
VITE_API_BASE=https://ssh.api.shumengya.top/api
|
||||
VITE_WS_URL=wss://ssh.api.shumengya.top/api/ws/ssh
|
||||
|
||||
|
||||
@@ -5,9 +5,285 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>萌芽SSH</title>
|
||||
<meta name="description" content="柔和渐变风格的 Web SSH 连接面板,支持多窗口终端。" />
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/logo192.png" />
|
||||
<link rel="apple-touch-icon" sizes="192x192" href="/logo192.png" />
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--splash-bg: radial-gradient(
|
||||
circle at top,
|
||||
#1f2937 0,
|
||||
#020617 55%,
|
||||
#000 100%
|
||||
);
|
||||
--splash-ink: #e2e8f0;
|
||||
--splash-accent: #22c55e;
|
||||
--splash-accent-soft: rgba(34, 197, 94, 0.35);
|
||||
--splash-glow: rgba(56, 189, 248, 0.18);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: #020617;
|
||||
}
|
||||
|
||||
#splash-screen {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
background: var(--splash-bg);
|
||||
overflow: hidden;
|
||||
transition: opacity 0.45s ease, visibility 0.45s ease;
|
||||
}
|
||||
|
||||
#splash-screen::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -20%;
|
||||
background: radial-gradient(
|
||||
circle at 20% 20%,
|
||||
rgba(34, 197, 94, 0.18),
|
||||
transparent 55%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 80% 10%,
|
||||
rgba(56, 189, 248, 0.2),
|
||||
transparent 60%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 70% 80%,
|
||||
rgba(14, 116, 144, 0.25),
|
||||
transparent 65%
|
||||
);
|
||||
opacity: 0.9;
|
||||
animation: splashPulse 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
#splash-screen::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 70vmax;
|
||||
height: 70vmax;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
var(--splash-glow),
|
||||
transparent 65%
|
||||
);
|
||||
animation: splashGlow 7.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.splash-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 24px;
|
||||
color: var(--splash-ink);
|
||||
font-family: "Avenir Next", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
|
||||
.splash-logo {
|
||||
position: relative;
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.splash-logo img {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-radius: 22px;
|
||||
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.45),
|
||||
0 0 30px rgba(34, 197, 94, 0.25);
|
||||
animation: logoFloat 3.6s ease-in-out infinite;
|
||||
background: rgba(15, 23, 42, 0.4);
|
||||
}
|
||||
|
||||
.splash-ring {
|
||||
position: absolute;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--splash-accent-soft);
|
||||
animation: ringPulse 3.6s ease-out infinite;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.splash-ring.ring-1 {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.splash-ring.ring-2 {
|
||||
width: 170px;
|
||||
height: 170px;
|
||||
animation-delay: 1.2s;
|
||||
}
|
||||
|
||||
.splash-ring.ring-3 {
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
animation-delay: 2.4s;
|
||||
}
|
||||
|
||||
.splash-title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
.splash-subtitle {
|
||||
font-size: 14px;
|
||||
color: #86efac;
|
||||
letter-spacing: 0.3em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.splash-dots {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.splash-dots span {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--splash-accent);
|
||||
box-shadow: 0 0 12px rgba(34, 197, 94, 0.55);
|
||||
animation: dotBounce 1.1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.splash-dots span:nth-child(2) {
|
||||
animation-delay: 0.15s;
|
||||
}
|
||||
|
||||
.splash-dots span:nth-child(3) {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
#splash-screen.is-hidden {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
body.splash-active #app {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes logoFloat {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ringPulse {
|
||||
0% {
|
||||
transform: scale(0.65);
|
||||
opacity: 0.45;
|
||||
}
|
||||
70% {
|
||||
opacity: 0.15;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.15);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dotBounce {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(0.7);
|
||||
opacity: 0.6;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes splashPulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.75;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.03);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes splashGlow {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.4;
|
||||
transform: translate(-10%, -5%) scale(0.95);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
transform: translate(10%, 5%) scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#splash-screen::before,
|
||||
#splash-screen::after,
|
||||
.splash-logo img,
|
||||
.splash-ring,
|
||||
.splash-dots span {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<body class="splash-active">
|
||||
<div id="splash-screen" aria-live="polite">
|
||||
<div class="splash-content">
|
||||
<div class="splash-logo">
|
||||
<span class="splash-ring ring-1"></span>
|
||||
<span class="splash-ring ring-2"></span>
|
||||
<span class="splash-ring ring-3"></span>
|
||||
<img src="/logo192.png" alt="萌芽SSH Logo" />
|
||||
</div>
|
||||
<div class="splash-title">萌芽SSH</div>
|
||||
<div class="splash-subtitle">加载中</div>
|
||||
<div class="splash-dots" aria-hidden="true">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="app"></div>
|
||||
<script>
|
||||
window.__hideSplash = () => {
|
||||
const splash = document.getElementById("splash-screen");
|
||||
if (!splash) return;
|
||||
splash.classList.add("is-hidden");
|
||||
document.body.classList.remove("splash-active");
|
||||
window.setTimeout(() => {
|
||||
splash.remove();
|
||||
}, 500);
|
||||
};
|
||||
</script>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
4919
mengyaconnect-frontend/package-lock.json
generated
4919
mengyaconnect-frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.1.2",
|
||||
"vite": "^5.4.10"
|
||||
"vite": "^5.4.10",
|
||||
"vite-plugin-pwa": "^0.21.0"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
mengyaconnect-frontend/public/favicon.ico
Normal file
BIN
mengyaconnect-frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
BIN
mengyaconnect-frontend/public/logo.png
Normal file
BIN
mengyaconnect-frontend/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.0 MiB |
BIN
mengyaconnect-frontend/public/logo192.png
Normal file
BIN
mengyaconnect-frontend/public/logo192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
BIN
mengyaconnect-frontend/public/logo512.png
Normal file
BIN
mengyaconnect-frontend/public/logo512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 361 KiB |
BIN
mengyaconnect-frontend/public/rounded-image (2).png
Normal file
BIN
mengyaconnect-frontend/public/rounded-image (2).png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.7 MiB |
@@ -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 });
|
||||
|
||||
@@ -1,8 +1,37 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import { VitePWA } from "vite-plugin-pwa";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
plugins: [
|
||||
vue(),
|
||||
VitePWA({
|
||||
registerType: "autoUpdate",
|
||||
includeAssets: ["favicon.ico", "logo.png", "logo192.png", "logo512.png"],
|
||||
manifest: {
|
||||
name: "萌芽SSH",
|
||||
short_name: "萌芽SSH",
|
||||
description: "柔和渐变风格的 Web SSH 连接面板,支持多窗口终端。",
|
||||
start_url: "/",
|
||||
display: "standalone",
|
||||
theme_color: "#0f172a",
|
||||
background_color: "#020617",
|
||||
icons: [
|
||||
{
|
||||
src: "logo192.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
src: "logo512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "maskable",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
server: {
|
||||
host: true,
|
||||
port: 5173,
|
||||
|
||||
Reference in New Issue
Block a user