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

38
AGENTS.md Normal file
View File

@@ -0,0 +1,38 @@
# Repository Guidelines
## Project Structure & Module Organization
- `mengyaconnect-backend/` Go (Gin) HTTP + WebSocket SSH bridge. Most logic in `main.go`; config helpers in `config.go`. Persistent data under `data/` (`data/ssh/*.json`, `data/command/command.json`, `data/script/`).
- `mengyaconnect-frontend/` Vite + Vue 3 SPA. Entry at `index.html`; UI in `src/App.vue`; build output in `dist/`.
- `debug-logs/` local logs, not part of app runtime.
## Build, Test, and Development Commands
Backend:
- `cd mengyaconnect-backend && go run .` Run API server (default `:8080`).
- `go build -o mengyaconnect-backend` Build local binary.
- `go fmt ./...` Format Go code.
- `go test ./...` Run Go tests (if present).
Frontend:
- `cd mengyaconnect-frontend && npm install` Install dependencies.
- `npm run dev` Start Vite dev server (`http://localhost:5173`).
- `npm run build` Build production assets to `dist/`.
- `npm run preview` Preview the production build.
Optional container:
- `cd mengyaconnect-backend && docker-compose up --build` Run backend in Docker (host `2431` -> container `8080`).
## Coding Style & Naming Conventions
- Go: follow `gofmt` output; prefer `camelCase` for unexported identifiers and `PascalCase` for exported ones.
- Vue: keep 2-space indentation, double quotes, and semicolons as used in `src/App.vue`. Use `<script setup>` for new components and keep UI code in `mengyaconnect-frontend/src/`.
## Testing Guidelines
- Backend uses Gos standard testing (`*_test.go`). No dedicated lint/test tooling beyond `go test`.
- Frontend has no test runner configured; if you add one, add an npm script and document it here.
## Commit & Pull Request Guidelines
- Commit history is minimal; one commit uses Conventional Commits (`chore: ...`). Prefer `type: short summary` for consistency and keep commits scoped.
- PRs should include: a clear summary, testing notes (commands run), and screenshots for UI changes. Call out any changes to env/config or persisted data.
## Security & Configuration Tips
- Do not commit real SSH credentials or production data in `mengyaconnect-backend/data/`; sanitize sample files.
- Key env vars: backend `PORT`, `DATA_DIR`, `ALLOWED_ORIGINS`; frontend `VITE_WS_URL`/`VITE_WS_PORT`.

View 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

View File

@@ -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>

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

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 });

View File

@@ -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,