chore: sync local updates

This commit is contained in:
2026-05-16 19:03:04 +08:00
parent b86ce4a73a
commit 14cd94c88d
45 changed files with 2983 additions and 4127 deletions

View File

@@ -2,7 +2,7 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" />
<title>萌芽SSH</title>
<meta name="description" content="柔和渐变风格的 Web SSH 连接面板,支持多窗口终端。" />
<meta name="theme-color" content="#0f172a" />

File diff suppressed because it is too large Load Diff

View File

@@ -1,33 +1,47 @@
export function getApiBase() {
const envBase = import.meta.env.VITE_API_BASE;
if (envBase) {
return String(envBase).replace(/\/+$/, "");
}
// 默认走同源 /api更适合反向代理 + HTTPS
if (typeof window === "undefined") return "http://localhost:8080/api";
return `${window.location.origin}/api`;
}
export async function apiRequest(path, options = {}) {
const base = getApiBase();
const res = await fetch(`${base}${path}`, {
headers: {
"Content-Type": "application/json",
...(options.headers || {}),
},
...options,
});
let body = null;
try {
body = await res.json();
} catch {
body = null;
}
if (!res.ok) {
const message =
(body && body.error) || `请求失败 (${res.status} ${res.statusText})`;
throw new Error(message);
}
return body;
}
const TOKEN_KEY = "mc_auth_token";
export function getToken() {
return localStorage.getItem(TOKEN_KEY) || "";
}
export function setToken(t) {
localStorage.setItem(TOKEN_KEY, t);
}
export function clearToken() {
localStorage.removeItem(TOKEN_KEY);
}
export function getApiBase() {
const envBase = import.meta.env.VITE_API_BASE;
if (envBase) {
return String(envBase).replace(/\/+$/, "");
}
if (typeof window === "undefined") return "http://localhost:8080/api";
return `${window.location.origin}/api`;
}
export async function apiRequest(path, options = {}) {
const base = getApiBase();
const token = getToken();
const res = await fetch(`${base}${path}`, {
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(options.headers || {}),
},
...options,
});
let body = null;
try {
body = await res.json();
} catch {
body = null;
}
if (!res.ok) {
const message =
(body && body.error) || `请求失败 (${res.status} ${res.statusText})`;
throw new Error(message);
}
return body;
}

View File

@@ -0,0 +1,143 @@
<template>
<header class="app-header">
<div class="brand">
<button class="sidebar-toggle" type="button" @click="toggleSidebar"></button>
<h1>萌芽SSH</h1>
</div>
<nav class="app-nav desktop-only">
<button
v-for="item in navItems"
:key="item.panel"
class="nav-btn"
:class="{ active: activePanel === item.panel }"
@click="openPanel(item.panel)"
>
<span class="nav-dot"></span>{{ item.label }}
</button>
</nav>
<div class="header-right">
<button class="icon-btn focus-toggle desktop-only" type="button" @click="toggleFocus"></button>
</div>
</header>
</template>
<script setup>
import { useUI } from "../composables/useUI";
const { activePanel, openPanel, toggleSidebar, toggleFocus } = useUI();
const navItems = [
{ panel: "connect", label: "新建连接" },
{ panel: "ssh", label: "SSH 配置" },
{ panel: "commands",label: "快捷命令" },
{ panel: "scripts", label: "脚本管理" },
];
</script>
<style scoped>
.app-header {
height: 48px;
padding: 0 16px;
display: flex;
align-items: center;
gap: 12px;
border-bottom: 1px solid var(--border);
background: var(--bg-1);
position: sticky;
top: 0;
z-index: 30;
flex-shrink: 0;
}
.brand { display: flex; align-items: center; gap: 8px; }
.sidebar-toggle {
width: 28px;
height: 28px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg-2);
color: var(--text-2);
font-size: 14px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.sidebar-toggle:hover { border-color: var(--accent); color: var(--accent); }
.brand h1 {
font-size: 17px;
font-weight: 700;
letter-spacing: 0.04em;
background: linear-gradient(120deg, var(--accent) 0%, var(--accent-blue) 100%);
-webkit-background-clip: text;
color: transparent;
margin: 0;
white-space: nowrap;
}
.header-right { margin-left: auto; display: flex; align-items: center; gap: 6px; }
.app-nav {
display: flex;
gap: 6px;
flex-wrap: nowrap;
overflow-x: auto;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
touch-action: pan-x;
margin-left: 4px;
}
.app-nav::-webkit-scrollbar { display: none; }
.nav-btn {
border-radius: 6px;
white-space: nowrap;
flex-shrink: 0;
border: 1px solid var(--border);
background: var(--bg-2);
color: var(--text-2);
padding: 5px 12px;
font-size: 13px;
cursor: pointer;
outline: none;
display: inline-flex;
align-items: center;
gap: 6px;
transition: border-color 0.15s, color 0.15s, background 0.15s;
font-family: inherit;
}
.nav-btn:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-dim); }
.nav-btn.active { border-color: var(--accent); color: var(--accent); background: var(--accent-dim); }
.nav-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent);
opacity: 0.7;
flex-shrink: 0;
}
.icon-btn {
width: 28px;
height: 28px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg-2);
color: var(--text-2);
font-size: 14px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.icon-btn:hover { border-color: var(--accent); color: var(--accent); }
@media (max-width: 768px) {
.app-header { height: 40px; padding: 0 10px; gap: 8px; }
.brand h1 { font-size: 15px; }
}
</style>

View File

@@ -0,0 +1,114 @@
<template>
<div class="login-overlay">
<div class="login-card">
<div class="login-logo">
<span class="login-icon">🌱</span>
<h1 class="login-title">萌芽 SSH</h1>
<p class="login-sub">MengyaConnect</p>
</div>
<form class="login-form" @submit.prevent="submitLogin">
<label class="login-label">
访问密码
<input
v-model="loginPassword"
type="password"
class="login-input"
placeholder="请输入访问密码"
autofocus
autocomplete="current-password"
/>
</label>
<p v-if="loginError" class="login-error">{{ loginError }}</p>
<button type="submit" class="login-btn" :disabled="loginLoading || !loginPassword">
{{ loginLoading ? "验证中…" : "进入" }}
</button>
</form>
</div>
</div>
</template>
<script setup>
import { useAuth } from "../composables/useAuth";
const { loginPassword, loginError, loginLoading, submitLogin } = useAuth();
</script>
<style>
.login-overlay {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-1);
z-index: 9999;
padding: 16px;
}
.login-card {
width: 100%;
max-width: 340px;
padding: 2.2rem 1.8rem;
background: var(--bg-2);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.7);
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.login-logo { text-align: center; }
.login-icon { font-size: 2.2rem; }
.login-title {
margin: 0.4rem 0 0.1rem;
font-size: 1.5rem;
font-weight: 700;
color: var(--text-1);
}
.login-sub {
margin: 0;
font-size: 0.78rem;
color: var(--text-3);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.login-form { display: flex; flex-direction: column; gap: 1rem; }
.login-label { display: flex; flex-direction: column; gap: 0.4rem; font-size: 0.83rem; color: var(--text-2); }
.login-input {
width: 100%;
padding: 0.65rem 0.9rem;
background: var(--bg-1);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-1);
font-size: 1rem;
outline: none;
transition: border-color 0.2s;
box-sizing: border-box;
}
.login-input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(63, 185, 80, 0.12);
}
.login-error { margin: 0; font-size: 0.8rem; color: #ff7b72; text-align: center; }
.login-btn {
width: 100%;
padding: 0.7rem;
background: #238636;
border: 1px solid rgba(63, 185, 80, 0.5);
border-radius: 6px;
color: #fff;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s, transform 0.1s;
font-family: inherit;
}
.login-btn:hover:not(:disabled) { background: #2ea043; }
.login-btn:active:not(:disabled) { transform: scale(0.98); }
.login-btn:disabled { opacity: 0.4; cursor: not-allowed; }
</style>

View File

@@ -0,0 +1,129 @@
<template>
<div class="fab-wrap mobile-only">
<div v-show="fabOpen" class="fab-backdrop" @click="fabOpen = false"></div>
<div class="fab-actions" :class="{ open: fabOpen }">
<button class="fab-action" type="button" @click="openPanel('scripts')">
<span class="fab-action-label">脚本管理</span>
<span class="fab-action-icon"></span>
</button>
<button class="fab-action" type="button" @click="openPanel('commands')">
<span class="fab-action-label">快捷命令</span>
<span class="fab-action-icon"></span>
</button>
<button class="fab-action" type="button" @click="openPanel('ssh')">
<span class="fab-action-label">SSH 配置</span>
<span class="fab-action-icon"></span>
</button>
<button class="fab-action" type="button" @click="openPanel('connect')">
<span class="fab-action-label">新建连接</span>
<span class="fab-action-icon"></span>
</button>
</div>
<button class="fab-btn" :class="{ open: fabOpen }" type="button" @click="fabOpen = !fabOpen">
{{ fabOpen ? "" : "" }}
</button>
</div>
</template>
<script setup>
import { useUI } from "../composables/useUI";
const { fabOpen, openPanel } = useUI();
</script>
<style scoped>
.fab-wrap {
display: none;
position: fixed;
bottom: 16px;
right: 16px;
z-index: 90;
flex-direction: column;
align-items: flex-end;
gap: 10px;
}
.fab-backdrop {
position: fixed;
inset: 0;
z-index: -1;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(2px);
}
.fab-actions {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
pointer-events: none;
opacity: 0;
transform: translateY(10px);
transition: opacity 0.2s ease, transform 0.2s ease;
}
.fab-actions.open {
pointer-events: auto;
opacity: 1;
transform: translateY(0);
}
.fab-action {
display: flex;
align-items: center;
gap: 8px;
border: none;
background: none;
cursor: pointer;
padding: 0;
}
.fab-action-label {
background: var(--bg-2);
color: var(--text-1);
font-size: 13px;
padding: 6px 12px;
border-radius: 8px;
border: 1px solid var(--border);
white-space: nowrap;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
font-family: inherit;
}
.fab-action-icon {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--bg-2);
color: var(--accent);
border: 1px solid var(--border-hi);
display: flex;
align-items: center;
justify-content: center;
font-size: 17px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
flex-shrink: 0;
}
.fab-action:active .fab-action-icon { background: var(--accent-dim); }
.fab-btn {
width: 52px;
height: 52px;
border-radius: 50%;
border: 1px solid var(--border-hi);
background: var(--bg-2);
color: var(--accent);
font-size: 24px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(63, 185, 80, 0.2);
transition: transform 0.2s ease, background 0.2s ease;
flex-shrink: 0;
}
.fab-btn.open { background: var(--bg-3); transform: rotate(45deg); }
.fab-btn:active { transform: scale(0.94); }
@media (max-width: 768px) {
.fab-wrap { display: flex; }
}
</style>

View File

@@ -0,0 +1,122 @@
<template>
<div class="overlay" @click.self="handleClose">
<div class="overlay-card">
<div class="drag-handle mobile-only"></div>
<header class="overlay-header">
<h2>{{ panelTitle }}</h2>
<button class="icon-btn" type="button" @click="handleClose"></button>
</header>
<ConnectPanel v-if="activePanel === 'connect'" />
<SshPanel v-else-if="activePanel === 'ssh'" />
<CommandsPanel v-else-if="activePanel === 'commands'" />
<ScriptsPanel v-else-if="activePanel === 'scripts'" />
</div>
</div>
</template>
<script setup>
import { computed, nextTick } from "vue";
import { useUI } from "../composables/useUI";
import { useSessions } from "../composables/useSessions";
import ConnectPanel from "./panels/ConnectPanel.vue";
import SshPanel from "./panels/SshPanel.vue";
import CommandsPanel from "./panels/CommandsPanel.vue";
import ScriptsPanel from "./panels/ScriptsPanel.vue";
const { activePanel, closeOverlay } = useUI();
const { sessions, activeId } = useSessions();
const titleMap = { connect: "新建连接", ssh: "SSH 配置", commands: "快捷命令", scripts: "脚本管理" };
const panelTitle = computed(() => titleMap[activePanel.value] ?? "");
function handleClose() {
closeOverlay();
nextTick(() => {
const session = sessions.value.find((s) => s.id === activeId.value);
session?.term?.focus();
});
}
</script>
<style scoped>
.overlay {
position: absolute;
inset: 0;
display: flex;
justify-content: center;
align-items: flex-start;
padding: 16px;
background: rgba(0, 0, 0, 0.65);
z-index: 50;
backdrop-filter: blur(4px);
}
.overlay-card {
width: min(900px, 100%);
max-height: 100%;
background: var(--bg-1);
border-radius: 10px;
border: 1px solid var(--border);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.8);
display: flex;
flex-direction: column;
overflow: hidden;
}
.drag-handle {
display: none;
width: 40px;
height: 4px;
border-radius: 2px;
background: var(--text-3);
margin: 10px auto 0;
flex-shrink: 0;
}
.overlay-header {
padding: 10px 14px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.overlay-header h2 { margin: 0; font-size: 15px; font-weight: 600; color: var(--text-1); }
.icon-btn {
border: none;
background: transparent;
color: var(--text-2);
font-size: 16px;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
}
.icon-btn:hover { color: var(--text-1); background: var(--bg-3); }
:deep(.overlay-body) {
padding: 12px 14px 16px;
overflow: auto;
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
@media (max-width: 768px) {
.overlay { align-items: flex-end; padding: 0; background: rgba(0, 0, 0, 0.7); }
.overlay-card {
border-radius: 16px 16px 0 0;
border-bottom: none;
width: 100%;
max-width: 100%;
max-height: min(88vh, var(--vvh, 88vh));
animation: slideUp 0.28s cubic-bezier(0.32, 0.72, 0, 1);
}
.drag-handle { display: block; }
:deep(.overlay-body) { padding: 10px 12px 20px; }
}
@keyframes slideUp {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
</style>

View File

@@ -0,0 +1,100 @@
<template>
<div class="quick-keys" @contextmenu.prevent>
<button class="quick-key ctrl-c" @click="send('ctrl_c')" @touchend.prevent="send('ctrl_c')">C-c</button>
<button class="quick-key ctrl-z" @click="send('ctrl_z')" @touchend.prevent="send('ctrl_z')">C-z</button>
<button class="quick-key ctrl-d" @click="send('ctrl_d')" @touchend.prevent="send('ctrl_d')">C-d</button>
<button class="quick-key ctrl-l" @click="send('ctrl_l')" @touchend.prevent="send('ctrl_l')">C-l</button>
<div class="sep"></div>
<button class="quick-key" @click="send('esc')" @touchend.prevent="send('esc')">ESC</button>
<button class="quick-key" :class="{ active: shiftPending }" @click="send('shift')" @touchend.prevent="send('shift')">SHF</button>
<button class="quick-key" :class="{ active: ctrlPending }" @click="send('ctrl')" @touchend.prevent="send('ctrl')">CTL</button>
<button class="quick-key" @click="send('tab')" @touchend.prevent="send('tab')">TAB</button>
<div class="sep"></div>
<button class="quick-key" @click="send('up')" @touchend.prevent="send('up')"></button>
<button class="quick-key" @click="send('down')" @touchend.prevent="send('down')"></button>
<button class="quick-key" @click="send('left')" @touchend.prevent="send('left')"></button>
<button class="quick-key" @click="send('right')" @touchend.prevent="send('right')"></button>
<div class="sep"></div>
<button class="quick-key" @click="send('slash')" @touchend.prevent="send('slash')">/</button>
<button class="quick-key" @click="send('minus')" @touchend.prevent="send('minus')">-</button>
<button class="quick-key" @click="send('dot')" @touchend.prevent="send('dot')">.</button>
<button class="quick-key" @click="send('hash')" @touchend.prevent="send('hash')">#</button>
<button class="quick-key" @click="send('amp')" @touchend.prevent="send('amp')">&amp;</button>
<button class="quick-key enter-key" @click="send('enter')" @touchend.prevent="send('enter')"></button>
</div>
</template>
<script setup>
import { useQuickKeys } from "../composables/useQuickKeys";
const { shiftPending, ctrlPending, sendQuickKey } = useQuickKeys();
const send = sendQuickKey;
</script>
<style scoped>
.quick-keys {
display: flex;
flex-wrap: nowrap;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
touch-action: pan-x;
gap: 3px;
padding: 4px 6px;
background: var(--bg-1);
border-top: 1px solid var(--border);
scrollbar-width: none;
flex-shrink: 0;
}
.quick-keys::-webkit-scrollbar { display: none; }
.sep {
width: 1px;
background: var(--border);
margin: 4px 2px;
flex-shrink: 0;
align-self: stretch;
}
.quick-key {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
min-width: 40px;
height: 32px;
border-radius: 5px;
border: 1px solid var(--border);
background: var(--bg-2);
color: var(--text-2);
font-size: 12px;
font-family: "JetBrains Mono", ui-monospace, monospace;
white-space: nowrap;
cursor: pointer;
touch-action: manipulation;
user-select: none;
-webkit-user-select: none;
transition: background 0.1s, color 0.1s;
}
.quick-key:hover, .quick-key:active { background: var(--bg-3); color: var(--text-1); }
.quick-key.active { border-color: var(--accent); color: var(--accent); background: var(--accent-dim); }
.quick-key.ctrl-c { color: var(--danger); border-color: rgba(248, 81, 73, 0.35); }
.quick-key.ctrl-c:hover { background: rgba(248, 81, 73, 0.12); }
.quick-key.ctrl-z { color: #e3b341; border-color: rgba(210, 153, 34, 0.35); }
.quick-key.ctrl-z:hover { background: rgba(210, 153, 34, 0.1); }
.quick-key.ctrl-d { color: var(--accent-blue); border-color: var(--border-blue); }
.quick-key.ctrl-d:hover { background: rgba(88, 166, 255, 0.1); }
.quick-key.enter-key {
min-width: 48px;
border-color: var(--border-blue);
background: rgba(88, 166, 255, 0.08);
color: var(--accent-blue);
}
.quick-key.enter-key:hover { background: rgba(88, 166, 255, 0.18); }
@media (max-width: 768px) {
.quick-keys { padding: 5px 6px; gap: 4px; }
.quick-key { min-width: 44px; height: 38px; font-size: 12px; border-radius: 6px; }
.quick-key.enter-key { min-width: 52px; }
}
</style>

View File

@@ -0,0 +1,70 @@
<template>
<aside class="session-sidebar">
<h3 class="sidebar-title">会话</h3>
<button
v-for="session in sessions"
:key="session.id"
class="sidebar-tab"
:class="{ active: session.id === activeId }"
@click="setActive(session.id)"
>
<span class="dot" :class="session.status"></span>
<span class="label">{{ session.title }}</span>
<span class="close" @click.stop="closeSession(session.id)">×</span>
</button>
</aside>
</template>
<script setup>
import { useSessions } from "../composables/useSessions";
const { sessions, activeId, setActive, closeSession } = useSessions();
</script>
<style scoped>
.session-sidebar {
position: absolute;
inset: 0 auto 0 0;
width: 200px;
padding: 8px;
background: var(--bg-2);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 4px;
z-index: 30;
}
.sidebar-title {
margin: 0 0 6px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-3);
}
.sidebar-tab {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
border-radius: 6px;
border: 1px solid transparent;
background: transparent;
color: var(--text-2);
font-size: 12px;
cursor: pointer;
text-align: left;
font-family: inherit;
}
.sidebar-tab:hover { background: var(--bg-3); color: var(--text-1); }
.sidebar-tab.active { border-color: var(--border-hi); background: var(--accent-dim); color: var(--accent); }
.label { flex: 1; white-space: nowrap; text-overflow: ellipsis; overflow: hidden; }
.dot { width: 7px; height: 7px; border-radius: 50%; background: var(--text-3); flex-shrink: 0; }
.dot.ready { background: var(--accent); }
.dot.connecting { background: #e3b341; }
.dot.error { background: var(--danger); }
.dot.closed { background: var(--text-3); }
.close { font-size: 12px; color: var(--text-3); padding: 0 2px; }
.close:hover { color: var(--danger); }
</style>

View File

@@ -0,0 +1,171 @@
<template>
<section class="terminal-panel">
<div class="terminal-container">
<div v-if="sessions.length === 0" class="empty-terminal">
<div class="empty-card">
<div class="empty-icon"></div>
<h3>准备连接</h3>
<p>点击右下角 <strong></strong> 按钮<br />填写主机信息后打开终端</p>
</div>
</div>
<div
v-for="session in sessions"
:key="session.id"
class="terminal-wrapper"
v-show="session.id === activeId"
>
<div class="terminal-header">
<div class="terminal-tabs">
<button
v-for="s in sessions"
:key="s.id"
class="t-tab"
:class="{ active: s.id === activeId }"
@click="setActive(s.id)"
>
<span class="t-tab-dot" :class="s.status"></span>
<span class="t-tab-label">{{ s.title }}</span>
<span class="t-tab-close" @click.stop="closeSession(s.id)">×</span>
</button>
</div>
<span class="status-badge" :class="session.status">{{ statusLabel(session.status) }}</span>
</div>
<div class="terminal-body" :ref="(el) => setTerminalRef(session.id, el)"></div>
</div>
</div>
<QuickKeys v-if="sessions.length && !focusMode" />
</section>
</template>
<script setup>
import { useSessions } from "../composables/useSessions";
import { useUI } from "../composables/useUI";
import QuickKeys from "./QuickKeys.vue";
const { sessions, activeId, setTerminalRef, statusLabel, setActive, closeSession } = useSessions();
const { focusMode } = useUI();
</script>
<style scoped>
.terminal-panel {
flex: 1;
display: flex;
flex-direction: column;
background: var(--bg-0);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
min-height: 0;
padding-bottom: var(--keyboard-inset, 0px);
}
.terminal-container {
flex: 1;
position: relative;
background: var(--bg-0);
overflow: hidden;
min-height: 0;
}
.empty-terminal {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-0);
}
.empty-card {
padding: 28px 32px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--bg-2);
text-align: center;
max-width: 260px;
}
.empty-icon { font-size: 32px; margin-bottom: 12px; opacity: 0.5; }
.empty-card h3 { margin: 0 0 8px; font-size: 15px; color: var(--text-1); }
.empty-card p { margin: 0; font-size: 13px; color: var(--text-2); line-height: 1.6; }
.empty-card strong { color: var(--accent); }
.terminal-wrapper {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
min-height: 0;
}
.terminal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 8px 0 4px;
background: var(--bg-1);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
min-height: 30px;
gap: 6px;
}
.terminal-tabs {
display: flex;
align-items: center;
gap: 2px;
overflow-x: auto;
scrollbar-width: none;
flex: 1;
min-width: 0;
}
.terminal-tabs::-webkit-scrollbar { display: none; }
.t-tab {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 10px;
border-radius: 4px 4px 0 0;
border: 1px solid transparent;
border-bottom: none;
background: transparent;
color: var(--text-2);
font-size: 12px;
cursor: pointer;
outline: none;
white-space: nowrap;
flex-shrink: 0;
transition: background 0.12s, color 0.12s;
font-family: inherit;
}
.t-tab:hover { background: var(--bg-2); color: var(--text-1); }
.t-tab.active { background: var(--bg-0); color: var(--text-1); border-color: var(--border); }
.t-tab-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--text-3); flex-shrink: 0; }
.t-tab-dot.ready { background: var(--accent); }
.t-tab-dot.connecting { background: #e3b341; }
.t-tab-dot.error { background: var(--danger); }
.t-tab-label { max-width: 140px; white-space: nowrap; text-overflow: ellipsis; overflow: hidden; }
.t-tab-close { font-size: 11px; color: var(--text-3); padding: 0 1px; }
.t-tab-close:hover { color: var(--danger); }
.status-badge { font-size: 11px; color: var(--text-3); white-space: nowrap; flex-shrink: 0; }
.status-badge.ready { color: var(--accent); }
.status-badge.connecting { color: #e3b341; }
.status-badge.error { color: var(--danger); }
.terminal-body {
flex: 1;
padding: 4px;
min-height: 0;
overflow: hidden;
overscroll-behavior: contain;
background: var(--bg-0);
}
@media (max-width: 768px) {
.terminal-panel { border-radius: 0; border-left: none; border-right: none; }
}
</style>

View File

@@ -0,0 +1,57 @@
<template>
<div class="overlay-body">
<div class="sub-panel">
<header class="sub-panel-header">
<h3>快捷命令</h3>
<button class="btn-ghost tiny" @click="loadCommands" :disabled="commandLoading">刷新</button>
</header>
<p v-if="commandError" class="form-error small">{{ commandError }}</p>
<div class="item-list" v-if="commandList.length">
<div class="item-row" v-for="(item, index) in commandList" :key="index">
<div class="item-meta">
<strong>{{ item.alias }}</strong>
<code class="cmd-code">{{ item.command }}</code>
</div>
<div class="item-actions">
<button class="btn-accent tiny" @click="applyCommandToTerminal(item)">发送</button>
<button class="btn-ghost tiny" @click="editCommand(item, index)">编辑</button>
<button class="btn-danger tiny" @click="deleteCommand(index)">删除</button>
</div>
</div>
</div>
<p v-else class="empty-hint">暂无快捷命令可在下方新建</p>
<form class="sub-form" @submit.prevent="saveCommand">
<h4>{{ commandEditingIndex >= 0 ? "编辑快捷命令" : "新建快捷命令" }}</h4>
<div class="form-grid">
<label>
别名
<input v-model.trim="commandForm.alias" placeholder="例如 查看磁盘" />
</label>
<label>
命令
<input v-model.trim="commandForm.command" placeholder="例如 df -h" />
</label>
</div>
<div class="form-actions">
<button class="btn-primary small" type="submit" :disabled="commandSaving">
{{ commandSaving ? "保存中..." : "保存" }}
</button>
<button class="btn-secondary small" type="button" @click="resetCommandForm">重置</button>
</div>
</form>
</div>
</div>
</template>
<script setup>
import { useCommands } from "../../composables/useCommands";
const {
commandList, commandLoading, commandSaving, commandError,
commandEditingIndex, commandForm,
loadCommands, resetCommandForm, editCommand, saveCommand,
deleteCommand, applyCommandToTerminal,
} = useCommands();
</script>

View File

@@ -0,0 +1,50 @@
<template>
<div class="overlay-body">
<div class="form-grid">
<label>
主机
<input v-model.trim="form.host" placeholder="例如 192.168.1.10" />
</label>
<label>
端口
<input v-model.number="form.port" type="number" min="1" max="65535" />
</label>
<label>
用户名
<input v-model.trim="form.username" placeholder="root / ubuntu" />
</label>
<label>
认证方式
<select v-model="form.authType">
<option value="password">密码</option>
<option value="key">私钥</option>
</select>
</label>
<label v-if="form.authType === 'password'">
密码
<input v-model="form.password" type="password" placeholder="仅用于本次连接" />
</label>
<label v-else>
私钥
<textarea v-model="form.privateKey" rows="4" placeholder="粘贴 OpenSSH 私钥"></textarea>
</label>
<label v-if="form.authType === 'key'">
私钥口令
<input v-model="form.passphrase" type="password" placeholder="可留空" />
</label>
</div>
<div class="form-actions">
<button class="btn-primary" @click="createSession">连接</button>
<button class="btn-secondary" @click="resetForm">清空</button>
</div>
<p v-if="formError" class="form-error">{{ formError }}</p>
<div class="tips">
<span>{{ wsUrl }}</span>
</div>
</div>
</template>
<script setup>
import { useSessions } from "../../composables/useSessions";
const { form, formError, wsUrl, createSession, resetForm } = useSessions();
</script>

View File

@@ -0,0 +1,63 @@
<template>
<div class="overlay-body">
<div class="sub-panel">
<header class="sub-panel-header">
<h3>脚本管理</h3>
<button class="btn-ghost tiny" @click="loadScripts" :disabled="scriptLoading">刷新</button>
</header>
<p v-if="scriptError" class="form-error small">{{ scriptError }}</p>
<div class="script-layout">
<div class="script-list" v-if="scriptList.length">
<button
v-for="item in scriptList"
:key="item.name"
class="script-item"
:class="{ active: item.name === scriptSelected }"
type="button"
@click="selectScript(item.name)"
>
{{ item.name }}
</button>
</div>
<p v-else class="empty-hint">暂无脚本可直接在右侧新建</p>
<div class="script-editor">
<div class="form-grid">
<label>
名称
<input
v-model.trim="scriptForm.name"
placeholder="例如 docker-info.sh"
:disabled="!!scriptSelected"
/>
</label>
</div>
<label class="script-content-label">
内容
<textarea v-model="scriptForm.content" rows="6" placeholder="#!/bin/bash"></textarea>
</label>
<div class="form-actions">
<button class="btn-primary small" type="button" @click="saveScript" :disabled="scriptSaving">
{{ scriptSaving ? "保存中..." : scriptSelected ? "更新" : "新建" }}
</button>
<button v-if="scriptSelected" class="btn-danger small" type="button" @click="deleteScript">删除</button>
<button class="btn-secondary small" type="button" @click="resetScriptForm">重置</button>
<button class="btn-secondary small" type="button" @click="applyScriptToTerminal" :disabled="!scriptForm.content">
发送
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { useScripts } from "../../composables/useScripts";
const {
scriptList, scriptLoading, scriptSaving, scriptError, scriptSelected, scriptForm,
loadScripts, selectScript, resetScriptForm, saveScript, deleteScript, applyScriptToTerminal,
} = useScripts();
</script>

View File

@@ -0,0 +1,85 @@
<template>
<div class="overlay-body">
<div class="sub-panel">
<header class="sub-panel-header">
<h3>SSH 配置</h3>
<button class="btn-ghost tiny" @click="loadSSH" :disabled="sshLoading">刷新</button>
</header>
<p v-if="sshError" class="form-error small">{{ sshError }}</p>
<div class="item-list" v-if="sshList.length">
<div class="item-row" v-for="item in sshList" :key="item.name">
<div class="item-meta">
<div class="item-title">
<strong>{{ item.alias }}</strong>
<span class="muted">({{ item.name }})</span>
</div>
<div class="item-sub">{{ item.username }}@{{ item.host }}:{{ item.port || 22 }}</div>
</div>
<div class="item-actions">
<button class="btn-accent tiny" @click="connectWithSSH(item)">连接</button>
<button class="btn-ghost tiny" @click="editSSH(item)">编辑</button>
<button class="btn-danger tiny" @click="deleteSSH(item)">删除</button>
</div>
</div>
</div>
<p v-else class="empty-hint">暂无 SSH 配置可在下方新建</p>
<form class="sub-form" @submit.prevent="saveSSH">
<h4>{{ sshEditingName ? "编辑 SSH 配置" : "新建 SSH 配置" }}</h4>
<div class="form-grid">
<label>
标识名 (name)
<input v-model.trim="sshForm.name" :disabled="!!sshEditingName" placeholder="文件名,不含 .json" />
</label>
<label>
别名 (alias)
<input v-model.trim="sshForm.alias" placeholder="例如 生产环境" />
</label>
<label>
主机 (host)
<input v-model.trim="sshForm.host" placeholder="例如 192.168.1.10" />
</label>
<label>
端口
<input v-model.number="sshForm.port" type="number" min="1" max="65535" />
</label>
<label>
用户名
<input v-model.trim="sshForm.username" placeholder="root / ubuntu" />
</label>
<label>
密码 (可选)
<input v-model="sshForm.password" type="password" />
</label>
<label>
私钥 (可选)
<textarea v-model="sshForm.privateKey" rows="4" placeholder="粘贴 OpenSSH 私钥"></textarea>
</label>
<label>
私钥口令 (可选)
<input v-model="sshForm.passphrase" type="password" />
</label>
</div>
<div class="form-actions">
<button class="btn-primary small" type="submit" :disabled="sshSaving">
{{ sshSaving ? "保存中..." : "保存" }}
</button>
<button class="btn-secondary small" type="button" @click="resetSSHForm">重置</button>
</div>
</form>
</div>
</div>
</template>
<script setup>
import { useSSH } from "../../composables/useSSH";
import { useSessions } from "../../composables/useSessions";
const {
sshList, sshLoading, sshSaving, sshError, sshEditingName, sshForm,
loadSSH, resetSSHForm, editSSH, saveSSH, deleteSSH,
} = useSSH();
const { connectWithSSH } = useSessions();
</script>

View File

@@ -0,0 +1,49 @@
import { ref } from "vue";
import { apiRequest, setToken, clearToken, getToken } from "../api";
const isAuthenticated = ref(false);
const loginPassword = ref("");
const loginError = ref("");
const loginLoading = ref(false);
async function submitLogin() {
if (!loginPassword.value) return;
loginLoading.value = true;
loginError.value = "";
try {
const res = await apiRequest("/auth/login", {
method: "POST",
body: JSON.stringify({ password: loginPassword.value }),
});
setToken(res.data.token);
isAuthenticated.value = true;
loginPassword.value = "";
} catch (e) {
loginError.value = e.message || "密码错误,请重试";
} finally {
loginLoading.value = false;
}
}
async function checkAuth() {
const storedToken = getToken();
if (!storedToken) return;
try {
await apiRequest("/auth/verify");
isAuthenticated.value = true;
} catch {
clearToken();
isAuthenticated.value = false;
}
}
export function useAuth() {
return {
isAuthenticated,
loginPassword,
loginError,
loginLoading,
submitLogin,
checkAuth,
};
}

View File

@@ -0,0 +1,85 @@
import { useSessions } from "./useSessions";
import { useNotice } from "./useNotice";
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 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 sendImageToTerminal(file) {
const { sessions, activeId } = useSessions();
const { showNotice } = useNotice();
const session = sessions.value.find((s) => s.id === activeId.value);
if (!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 marker = `__MENGYA_CLIP_${Date.now()}__`;
const script = `cat <<'${marker}' | base64 -d > ${filename}\n${chunkBase64(base64)}\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 {
showNotice("图片粘贴失败,请重试", "error");
}
};
reader.onerror = () => showNotice("读取剪贴板图片失败", "error");
reader.readAsArrayBuffer(file);
}
function handlePaste(event) {
if (!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);
}
function setupClipboard() { document.addEventListener("paste", handlePaste); }
function teardownClipboard() { document.removeEventListener("paste", handlePaste); }
export function useClipboard() {
return { setupClipboard, teardownClipboard };
}

View File

@@ -0,0 +1,95 @@
import { reactive, ref } from "vue";
import { apiRequest } from "../api";
import { useSessions } from "./useSessions";
import { useUI } from "./useUI";
const commandList = ref([]);
const commandLoading = ref(false);
const commandSaving = ref(false);
const commandError = ref("");
const commandEditingIndex = ref(-1);
const commandForm = reactive({ alias: "", command: "" });
async function loadCommands() {
commandError.value = "";
commandLoading.value = true;
try {
const res = await apiRequest("/commands", { method: "GET" });
commandList.value = Array.isArray(res.data) ? res.data : [];
} catch (err) {
commandError.value = err.message || "加载快捷命令失败";
} finally {
commandLoading.value = false;
}
}
function resetCommandForm() {
commandEditingIndex.value = -1;
commandForm.alias = "";
commandForm.command = "";
}
function editCommand(item, index) {
commandEditingIndex.value = index;
commandForm.alias = item.alias || "";
commandForm.command = item.command || "";
}
async function saveCommand() {
commandError.value = "";
if (!commandForm.alias || !commandForm.command) {
commandError.value = "alias 和 command 为必填项";
return;
}
commandSaving.value = true;
try {
const payload = { alias: commandForm.alias, command: commandForm.command };
if (commandEditingIndex.value >= 0) {
await apiRequest(`/commands/${commandEditingIndex.value}`, {
method: "PUT", body: JSON.stringify(payload),
});
} else {
await apiRequest("/commands", { method: "POST", body: JSON.stringify(payload) });
}
await loadCommands();
resetCommandForm();
} catch (err) {
commandError.value = err.message || "保存快捷命令失败";
} finally {
commandSaving.value = false;
}
}
async function deleteCommand(index) {
commandError.value = "";
try {
await apiRequest(`/commands/${index}`, { method: "DELETE" });
if (commandEditingIndex.value === index) resetCommandForm();
await loadCommands();
} catch (err) {
commandError.value = err.message || "删除快捷命令失败";
}
}
function applyCommandToTerminal(item) {
const { sessions, activeId } = useSessions();
const { closeOverlay } = useUI();
const session = sessions.value.find((s) => s.id === activeId.value);
if (!session?.ws || session.ws.readyState !== WebSocket.OPEN) {
commandError.value = "当前没有可用终端连接";
return;
}
const cmd = item.command || "";
if (!cmd) return;
session.ws.send(JSON.stringify({ type: "input", data: `${cmd}\n` }));
closeOverlay();
}
export function useCommands() {
return {
commandList, commandLoading, commandSaving, commandError,
commandEditingIndex, commandForm,
loadCommands, resetCommandForm, editCommand, saveCommand,
deleteCommand, applyCommandToTerminal,
};
}

View File

@@ -0,0 +1,18 @@
import { reactive } from "vue";
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);
}
export function useNotice() {
return { notice, showNotice };
}

View File

@@ -0,0 +1,67 @@
import { ref } from "vue";
import { useSessions } from "./useSessions";
const shiftPending = ref(false);
const ctrlPending = ref(false);
function sendQuickKey(type) {
const { sessions, activeId } = useSessions();
const session = sessions.value.find((s) => s.id === activeId.value);
if (!session?.ws || session.ws.readyState !== WebSocket.OPEN) return;
if (type === "shift") { shiftPending.value = !shiftPending.value; return; }
if (type === "ctrl") { ctrlPending.value = !ctrlPending.value; return; }
let data = "";
switch (type) {
case "ctrl_c": data = "\x03"; break;
case "ctrl_z": data = "\x1a"; break;
case "ctrl_d": data = "\x04"; break;
case "ctrl_l": data = "\x0c"; break;
case "esc": data = "\x1b"; break;
case "tab": data = "\t"; break;
case "slash": data = "/"; break;
case "minus": data = "-"; break;
case "dot": data = "."; break;
case "hash": data = "#"; break;
case "amp": data = "&"; break;
case "up": data = "\x1b[A"; break;
case "down": data = "\x1b[B"; break;
case "right": data = "\x1b[C"; break;
case "left": data = "\x1b[D"; break;
case "enter": data = "\r"; break;
default: return;
}
if (shiftPending.value) {
if (data === "\t") data = "\x1b[Z";
else if (data === "/") data = "?";
else if (data === "-") data = "_";
else if (data === ".") data = ">";
else if (data === "\x1b[A") data = "\x1b[1;2A";
else if (data === "\x1b[B") data = "\x1b[1;2B";
else if (data === "\x1b[C") data = "\x1b[1;2C";
else if (data === "\x1b[D") data = "\x1b[1;2D";
else if (data.length === 1) data = data.toUpperCase();
}
if (ctrlPending.value) {
if (data === "\x1b[A") data = "\x1b[1;5A";
else if (data === "\x1b[B") data = "\x1b[1;5B";
else if (data === "\x1b[C") data = "\x1b[1;5C";
else if (data === "\x1b[D") data = "\x1b[1;5D";
else if (data.length === 1) {
const ch = data.toLowerCase();
if (ch >= "a" && ch <= "z") data = String.fromCharCode(ch.charCodeAt(0) - 96);
else if (ch === "/") data = "\x1f";
}
}
session.ws.send(JSON.stringify({ type: "input", data }));
shiftPending.value = false;
ctrlPending.value = false;
}
export function useQuickKeys() {
return { shiftPending, ctrlPending, sendQuickKey };
}

View File

@@ -0,0 +1,95 @@
import { reactive, ref } from "vue";
import { apiRequest } from "../api";
const sshList = ref([]);
const sshLoading = ref(false);
const sshSaving = ref(false);
const sshError = ref("");
const sshEditingName = ref("");
const sshForm = reactive({
name: "", alias: "", host: "", port: 22,
username: "", password: "", privateKey: "", passphrase: "",
});
async function loadSSH() {
sshError.value = "";
sshLoading.value = true;
try {
const res = await apiRequest("/ssh", { method: "GET" });
sshList.value = Array.isArray(res.data) ? res.data : [];
} catch (err) {
sshError.value = err.message || "加载 SSH 配置失败";
} finally {
sshLoading.value = false;
}
}
function resetSSHForm() {
sshEditingName.value = "";
Object.assign(sshForm, {
name: "", alias: "", host: "", port: 22,
username: "", password: "", privateKey: "", passphrase: "",
});
}
function editSSH(item) {
sshEditingName.value = item.name || "";
Object.assign(sshForm, {
name: item.name || "", alias: item.alias || "", host: item.host || "",
port: item.port || 22, username: item.username || "", password: item.password || "",
privateKey: item.privateKey || "", passphrase: item.passphrase || "",
});
}
async function saveSSH() {
sshError.value = "";
if (!sshForm.alias || !sshForm.host || !sshForm.username) {
sshError.value = "alias、host 和 username 为必填项";
return;
}
if (!sshEditingName.value && !sshForm.name) {
sshError.value = "新建配置时 name 为必填项";
return;
}
sshSaving.value = true;
try {
const payload = {
alias: sshForm.alias, host: sshForm.host, port: sshForm.port || 22,
username: sshForm.username, password: sshForm.password,
privateKey: sshForm.privateKey, passphrase: sshForm.passphrase,
};
if (sshEditingName.value) {
await apiRequest(`/ssh/${encodeURIComponent(sshEditingName.value)}`, {
method: "PUT", body: JSON.stringify(payload),
});
} else {
payload.name = sshForm.name;
await apiRequest("/ssh", { method: "POST", body: JSON.stringify(payload) });
}
await loadSSH();
if (!sshEditingName.value) resetSSHForm();
} catch (err) {
sshError.value = err.message || "保存 SSH 配置失败";
} finally {
sshSaving.value = false;
}
}
async function deleteSSH(item) {
sshError.value = "";
if (!item.name) return;
try {
await apiRequest(`/ssh/${encodeURIComponent(item.name)}`, { method: "DELETE" });
if (sshEditingName.value === item.name) resetSSHForm();
await loadSSH();
} catch (err) {
sshError.value = err.message || "删除 SSH 配置失败";
}
}
export function useSSH() {
return {
sshList, sshLoading, sshSaving, sshError, sshEditingName, sshForm,
loadSSH, resetSSHForm, editSSH, saveSSH, deleteSSH,
};
}

View File

@@ -0,0 +1,101 @@
import { reactive, ref } from "vue";
import { apiRequest } from "../api";
import { useSessions } from "./useSessions";
import { useUI } from "./useUI";
const scriptList = ref([]);
const scriptLoading = ref(false);
const scriptSaving = ref(false);
const scriptError = ref("");
const scriptSelected = ref("");
const scriptForm = reactive({ name: "", content: "" });
async function loadScripts() {
scriptError.value = "";
scriptLoading.value = true;
try {
const res = await apiRequest("/scripts", { method: "GET" });
scriptList.value = Array.isArray(res.data) ? res.data : [];
} catch (err) {
scriptError.value = err.message || "加载脚本列表失败";
} finally {
scriptLoading.value = false;
}
}
async function selectScript(name) {
scriptError.value = "";
scriptSelected.value = name;
try {
const res = await apiRequest(`/scripts/${encodeURIComponent(name)}`, { method: "GET" });
scriptForm.name = res.data?.name || name;
scriptForm.content = res.data?.content || "";
} catch (err) {
scriptError.value = err.message || "加载脚本内容失败";
}
}
function resetScriptForm() {
scriptSelected.value = "";
scriptForm.name = "";
scriptForm.content = "";
}
async function saveScript() {
scriptError.value = "";
if (!scriptSelected.value && !scriptForm.name) {
scriptError.value = "新建脚本时 name 为必填项";
return;
}
scriptSaving.value = true;
try {
if (scriptSelected.value) {
await apiRequest(`/scripts/${encodeURIComponent(scriptSelected.value)}`, {
method: "PUT", body: JSON.stringify({ content: scriptForm.content || "" }),
});
} else {
await apiRequest("/scripts", {
method: "POST",
body: JSON.stringify({ name: scriptForm.name, content: scriptForm.content || "" }),
});
scriptSelected.value = scriptForm.name;
}
await loadScripts();
} catch (err) {
scriptError.value = err.message || "保存脚本失败";
} finally {
scriptSaving.value = false;
}
}
async function deleteScript() {
scriptError.value = "";
if (!scriptSelected.value) return;
try {
await apiRequest(`/scripts/${encodeURIComponent(scriptSelected.value)}`, { method: "DELETE" });
await loadScripts();
resetScriptForm();
} catch (err) {
scriptError.value = err.message || "删除脚本失败";
}
}
function applyScriptToTerminal() {
const { sessions, activeId } = useSessions();
const { closeOverlay } = useUI();
const session = sessions.value.find((s) => s.id === activeId.value);
if (!session?.ws || session.ws.readyState !== WebSocket.OPEN) {
scriptError.value = "当前没有可用终端连接";
return;
}
if (!scriptForm.content) return;
session.ws.send(JSON.stringify({ type: "input", data: `${scriptForm.content}\n` }));
closeOverlay();
}
export function useScripts() {
return {
scriptList, scriptLoading, scriptSaving, scriptError, scriptSelected, scriptForm,
loadScripts, selectScript, resetScriptForm, saveScript, deleteScript, applyScriptToTerminal,
};
}

View File

@@ -0,0 +1,225 @@
import { computed, markRaw, nextTick, reactive, ref, watch } from "vue";
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { getToken } from "../api";
import { useUI } from "./useUI";
const sessions = ref([]);
const activeId = ref("");
const terminalRefs = new Map();
const form = reactive({
host: "",
port: 22,
username: "",
authType: "password",
password: "",
privateKey: "",
passphrase: "",
});
const formError = ref("");
const wsUrl = computed(() => {
const token = getToken();
const envUrl = import.meta.env.VITE_WS_URL;
if (envUrl) return token ? `${envUrl}?token=${token}` : envUrl;
if (typeof window === "undefined") {
const base = "ws://localhost:8080/api/ws/ssh";
return token ? `${base}?token=${token}` : base;
}
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const base = `${protocol}//${window.location.host}/api/ws/ssh`;
return token ? `${base}?token=${token}` : base;
});
function setTerminalRef(id, el) {
if (el) terminalRefs.set(id, el);
else terminalRefs.delete(id);
}
function statusLabel(status) {
const map = {
ready: "已连接",
connecting: "连接中",
connected: "已建立",
closing: "关闭中",
closed: "已关闭",
error: "错误",
};
return map[status] ?? "准备中";
}
function resetForm() {
Object.assign(form, {
host: "", port: 22, username: "", authType: "password",
password: "", privateKey: "", passphrase: "",
});
formError.value = "";
}
function connectWithSSH(item) {
form.host = item.host || "";
form.port = item.port || 22;
form.username = item.username || "";
if (item.privateKey) {
form.authType = "key";
form.privateKey = item.privateKey;
form.passphrase = item.passphrase || "";
form.password = "";
} else {
form.authType = "password";
form.password = item.password || "";
form.privateKey = "";
form.passphrase = "";
}
createSession();
}
function createSession() {
const { closeOverlay } = useUI();
formError.value = "";
if (!form.host || !form.username) { formError.value = "请填写主机与用户名。"; return; }
if (form.authType === "password" && !form.password) { formError.value = "请选择密码认证时请输入密码。"; return; }
if (form.authType === "key" && !form.privateKey) { formError.value = "请选择私钥认证时请输入私钥。"; return; }
const id = crypto.randomUUID();
const session = {
id,
title: `${form.username}@${form.host}:${form.port}`,
status: "connecting",
ws: null,
term: null,
fit: null,
};
sessions.value.push(session);
activeId.value = id;
const payload = {
type: "connect",
host: form.host, port: form.port, username: form.username,
password: form.authType === "password" ? form.password : "",
privateKey: form.authType === "key" ? form.privateKey : "",
passphrase: form.authType === "key" ? form.passphrase : "",
};
nextTick(() => initSession(session, payload));
closeOverlay();
}
function initSession(session, payload) {
const container = terminalRefs.get(session.id);
if (!container) return;
const term = markRaw(
new Terminal({
fontFamily: `"JetBrains Mono", "Fira Code", ui-monospace, monospace`,
fontSize: 14,
lineHeight: 1.35,
cursorBlink: true,
cursorStyle: "block",
scrollback: 8000,
smoothScrollDuration: 100,
theme: {
background: "#000000",
foreground: "#e6edf3",
cursor: "#3fb950",
cursorAccent: "#000000",
selectionBackground: "rgba(63,185,80,0.25)",
black: "#0d1117", red: "#f85149", green: "#3fb950", yellow: "#d29922",
blue: "#58a6ff", magenta: "#bc8cff", cyan: "#39c5cf", white: "#b1bac4",
brightBlack: "#484f58", brightRed: "#ff7b72", brightGreen: "#56d364",
brightYellow: "#e3b341", brightBlue: "#79c0ff", brightMagenta: "#d2a8ff",
brightCyan: "#56d4dd", brightWhite: "#cdd9e5",
},
})
);
const fitAddon = markRaw(new FitAddon());
term.loadAddon(fitAddon);
term.open(container);
fitAddon.fit();
term.focus();
term.writeln("\x1b[90m[正在建立连接...]\x1b[0m");
const ws = markRaw(new WebSocket(wsUrl.value));
session.ws = ws;
session.term = term;
session.fit = fitAddon;
const sendResize = () => {
if (ws.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
};
ws.onopen = () => {
session.status = "connected";
ws.send(JSON.stringify({ ...payload, cols: term.cols, rows: term.rows }));
};
ws.onmessage = (event) => {
let msg;
try { msg = JSON.parse(event.data); } catch { term.write(event.data); return; }
if (msg.type === "output" && msg.data) term.write(msg.data);
else if (msg.type === "status") {
session.status = msg.status || session.status;
if (msg.message) term.writeln(`\r\x1b[90m[${msg.message}]\x1b[0m`);
} else if (msg.type === "error") {
session.status = "error";
if (msg.message) term.writeln(`\r\x1b[31m[${msg.message}]\x1b[0m`);
}
};
ws.onerror = () => { session.status = "error"; term.writeln("\r\n\x1b[31m[连接错误]\x1b[0m"); };
ws.onclose = () => {
if (session.status !== "closed") session.status = "closed";
term.writeln("\r\n\x1b[90m[连接已关闭]\x1b[0m");
};
term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "input", data }));
});
term.onResize(() => sendResize());
focusSession(session);
}
function focusSession(session) {
if (!session?.term || !session?.fit) return;
nextTick(() => {
session.fit.fit();
if (session.ws?.readyState === WebSocket.OPEN) {
session.ws.send(JSON.stringify({ type: "resize", cols: session.term.cols, rows: session.term.rows }));
}
session.term.scrollToBottom();
session.term.focus();
});
}
function setActive(id) { activeId.value = id; }
function closeSession(id) {
const index = sessions.value.findIndex((s) => s.id === id);
if (index === -1) return;
const session = sessions.value[index];
if (session.ws?.readyState === WebSocket.OPEN) {
session.ws.send(JSON.stringify({ type: "close" }));
session.ws.close();
}
session.term?.dispose();
sessions.value.splice(index, 1);
if (activeId.value === id) activeId.value = sessions.value[0]?.id || "";
}
function handleWindowResize() {
const session = sessions.value.find((s) => s.id === activeId.value);
if (session) focusSession(session);
}
watch(activeId, (id) => {
const session = sessions.value.find((s) => s.id === id);
if (session) focusSession(session);
});
export function useSessions() {
return {
sessions, activeId, terminalRefs, form, formError, wsUrl,
setTerminalRef, statusLabel, resetForm,
connectWithSSH, createSession, initSession, focusSession,
setActive, closeSession, handleWindowResize,
};
}

View File

@@ -0,0 +1,36 @@
import { ref } from "vue";
const activePanel = ref("");
const showSidebar = ref(false);
const focusMode = ref(false);
const fabOpen = ref(false);
function openPanel(name) {
activePanel.value = name;
fabOpen.value = false;
}
function closeOverlay() {
activePanel.value = "";
}
function toggleSidebar() {
showSidebar.value = !showSidebar.value;
}
function toggleFocus() {
focusMode.value = !focusMode.value;
}
export function useUI() {
return {
activePanel,
showSidebar,
focusMode,
fabOpen,
openPanel,
closeOverlay,
toggleSidebar,
toggleFocus,
};
}

View File

@@ -0,0 +1,61 @@
import { ref } from "vue";
import { useSessions } from "./useSessions";
const appRef = ref(null);
let viewportFitTimer = null;
function updateAppHeight() {
const { sessions, activeId, focusSession } = useSessions();
const vv = window.visualViewport;
const h = vv ? vv.height : window.innerHeight;
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");
appRef.value.style.setProperty("--vvh", h + "px");
}
requestAnimationFrame(() => {
const session = sessions.value.find((s) => s.id === activeId.value);
if (session) focusSession(session);
});
if (viewportFitTimer) clearTimeout(viewportFitTimer);
viewportFitTimer = setTimeout(() => {
const session = sessions.value.find((s) => s.id === activeId.value);
if (session) focusSession(session);
}, 140);
}
function hideSplashScreen() {
if (typeof window === "undefined") return;
const hide = window.__hideSplash;
if (typeof hide === "function") requestAnimationFrame(() => hide());
}
function setupViewport() {
if (window.visualViewport) {
window.visualViewport.addEventListener("resize", updateAppHeight);
window.visualViewport.addEventListener("scroll", updateAppHeight);
} else {
window.addEventListener("resize", updateAppHeight);
}
updateAppHeight();
}
function teardownViewport() {
if (viewportFitTimer) { clearTimeout(viewportFitTimer); viewportFitTimer = null; }
if (window.visualViewport) {
window.visualViewport.removeEventListener("resize", updateAppHeight);
window.visualViewport.removeEventListener("scroll", updateAppHeight);
} else {
window.removeEventListener("resize", updateAppHeight);
}
}
export function useViewport() {
return { appRef, updateAppHeight, hideSplashScreen, setupViewport, teardownViewport };
}

View File

@@ -1,5 +1,7 @@
import { createApp } from "vue";
import "@xterm/xterm/css/xterm.css";
import "./styles/base.css";
import "./styles/utils.css";
import App from "./App.vue";
import { registerSW } from "virtual:pwa-register";

View File

@@ -0,0 +1,78 @@
/* ─── CSS 变量Termux/Terminus 深黑极简主题) ─────────────────── */
:root {
--bg-0: #000000;
--bg-1: #0d1117;
--bg-2: #161b22;
--bg-3: #21262d;
--border: rgba(48, 54, 61, 0.9);
--border-hi: rgba(63, 185, 80, 0.45);
--border-blue: rgba(88, 166, 255, 0.45);
--accent: #3fb950;
--accent-dim: rgba(63, 185, 80, 0.15);
--accent-blue: #58a6ff;
--danger: #f85149;
--text-1: #e6edf3;
--text-2: #8b949e;
--text-3: #484f58;
color-scheme: dark;
}
/* ─── 全局 reset ──────────────────────────────────────────────── */
html,
body,
#app {
width: 100%;
height: 100%;
overflow: hidden;
overscroll-behavior: none;
}
html,
body {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
scrollbar-width: none;
-ms-overflow-style: none;
background: var(--bg-1);
color: var(--text-1);
font-family: system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Text",
"Segoe UI", sans-serif;
}
html::-webkit-scrollbar,
body::-webkit-scrollbar {
width: 0;
height: 0;
}
/* ─── xterm 滚动条主题 ───────────────────────────────────────── */
.xterm-viewport {
overflow-y: auto !important;
overflow-x: hidden !important;
scrollbar-width: thin;
scrollbar-color: rgba(63, 185, 80, 0.4) rgba(0, 0, 0, 0.5);
-ms-overflow-style: auto;
overscroll-behavior: contain;
touch-action: pan-y;
-webkit-overflow-scrolling: touch;
}
.xterm-viewport::-webkit-scrollbar {
width: 4px;
height: 4px;
}
.xterm-viewport::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.4);
}
.xterm-viewport::-webkit-scrollbar-thumb {
border-radius: 999px;
background: rgba(63, 185, 80, 0.45);
}
.xterm-viewport::-webkit-scrollbar-thumb:hover {
background: rgba(63, 185, 80, 0.7);
}

View File

@@ -0,0 +1,196 @@
/* ─── 按钮系统 ──────────────────────────────────────────────────── */
.btn-primary,
.btn-secondary,
.btn-ghost,
.btn-accent,
.btn-danger {
border-radius: 6px;
border: 1px solid transparent;
padding: 6px 14px;
font-size: 13px;
cursor: pointer;
outline: none;
white-space: nowrap;
transition: opacity 0.15s, background 0.15s, border-color 0.15s;
font-family: inherit;
}
.btn-primary {
background: #238636;
border-color: rgba(63, 185, 80, 0.6);
color: #fff;
}
.btn-primary:hover:not(:disabled) { background: #2ea043; }
.btn-primary:disabled { opacity: 0.45; cursor: not-allowed; }
.btn-accent {
background: var(--accent-dim);
border-color: var(--border-hi);
color: var(--accent);
}
.btn-accent:hover { background: rgba(63, 185, 80, 0.25); }
.btn-secondary {
background: var(--bg-2);
border-color: var(--border);
color: var(--text-1);
}
.btn-secondary:hover { border-color: var(--text-2); }
.btn-ghost {
background: transparent;
border-color: var(--border);
color: var(--text-2);
}
.btn-ghost:hover { border-color: var(--text-2); color: var(--text-1); }
.btn-ghost:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-danger {
background: rgba(248, 81, 73, 0.1);
border-color: rgba(248, 81, 73, 0.4);
color: #ff7b72;
}
.btn-danger:hover { background: rgba(248, 81, 73, 0.2); }
.btn-primary.small,
.btn-secondary.small,
.btn-ghost.small,
.btn-accent.small,
.btn-danger.small { padding: 5px 10px; font-size: 12px; }
.tiny { padding: 4px 8px !important; font-size: 11px !important; border-radius: 5px !important; }
/* ─── 表单元素 ───────────────────────────────────────────────────── */
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 10px 12px;
margin-bottom: 12px;
}
label {
font-size: 12px;
color: var(--text-2);
display: flex;
flex-direction: column;
gap: 4px;
}
input,
select,
textarea {
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg-2);
color: var(--text-1);
padding: 7px 9px;
font-size: 13px;
outline: none;
transition: border-color 0.15s;
font-family: inherit;
}
input:focus,
select:focus,
textarea:focus {
border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(63, 185, 80, 0.15);
}
textarea { resize: vertical; }
.form-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 8px;
}
.form-error {
color: #ff7b72;
font-size: 12px;
margin: 2px 0 4px;
}
.form-error.small { font-size: 11px; }
.tips {
font-size: 11px;
color: var(--text-3);
word-break: break-all;
margin-top: 4px;
}
/* ─── 列表通用样式 ──────────────────────────────────────────────── */
.item-list { display: flex; flex-direction: column; gap: 5px; margin-bottom: 10px; }
.item-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 10px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg-2);
gap: 8px;
}
.item-meta { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.item-title { display: flex; align-items: center; gap: 6px; font-size: 13px; }
.item-sub { font-size: 12px; color: var(--text-2); }
.muted { font-size: 11px; color: var(--text-3); }
.item-actions { display: flex; gap: 4px; flex-shrink: 0; flex-wrap: wrap; }
.cmd-code {
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 11px;
padding: 2px 6px;
border-radius: 4px;
background: var(--bg-0);
border: 1px solid var(--border);
color: var(--text-2);
word-break: break-all;
}
.empty-hint { font-size: 12px; color: var(--text-3); margin: 4px 0 12px; }
/* ─── 子面板 ─────────────────────────────────────────────────────── */
.sub-panel { display: flex; flex-direction: column; gap: 8px; }
.sub-panel-header { display: flex; align-items: center; justify-content: space-between; }
.sub-panel-header h3 { margin: 0; font-size: 14px; font-weight: 600; color: var(--text-1); }
.sub-form h4 { margin: 4px 0 8px; font-size: 13px; color: var(--text-2); }
/* ─── 脚本管理布局 ──────────────────────────────────────────────── */
.script-layout {
display: grid;
grid-template-columns: minmax(110px, 180px) minmax(0, 1fr);
gap: 10px;
}
.script-list { display: flex; flex-direction: column; gap: 4px; }
.script-item {
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg-2);
color: var(--text-2);
padding: 6px 10px;
text-align: left;
font-size: 12px;
cursor: pointer;
transition: background 0.12s, color 0.12s;
font-family: inherit;
}
.script-item:hover { background: var(--bg-3); color: var(--text-1); }
.script-item.active { border-color: var(--accent); color: var(--accent); background: var(--accent-dim); }
.script-editor { display: flex; flex-direction: column; gap: 8px; }
.script-content-label textarea { min-height: 140px; font-family: "JetBrains Mono", ui-monospace, monospace; }
/* ─── 移动端响应 ─────────────────────────────────────────────────── */
@media (max-width: 768px) {
.item-row { flex-wrap: wrap; }
.item-actions { width: 100%; justify-content: flex-end; margin-top: 4px; }
.script-layout { grid-template-columns: 1fr; }
.form-grid { grid-template-columns: 1fr; }
}