update at 2026-03-03 21:19:52

This commit is contained in:
douboer@gmail.com
2026-03-03 21:19:52 +08:00
parent 3dc4144007
commit e4987a2d77
139 changed files with 21522 additions and 43 deletions

556
pxterm/src/App.vue Normal file
View File

@@ -0,0 +1,556 @@
<template>
<div class="app-shell" :class="{ 'keyboard-open': keyboardOpen }">
<section
class="app-canvas"
:class="{ 'without-bottom-bar': hideBottomFrame, 'bottom-frame-hidden': hideBottomFrame }"
:style="{ gridTemplateRows: hideBottomFrame ? 'minmax(0, 1fr)' : 'minmax(0, 1fr) 52px' }"
>
<main class="screen-content">
<RouterView />
</main>
<footer v-if="!hideBottomFrame" class="bottom-bar">
<button
class="icon-btn bottom-nav-btn"
type="button"
title="返回上一页"
aria-label="返回上一页"
:disabled="!canGoBack"
@click="goBack"
>
<span class="icon-mask" style="--icon: url('/icons/back.svg')" aria-hidden="true"></span>
</button>
<div class="bottom-right-actions">
<button
v-for="item in rightNavItems"
:key="item.path"
class="icon-btn bottom-nav-btn"
type="button"
:class="{ active: isActive(item.path) }"
:title="item.label"
:aria-label="item.label"
@click="goTo(item.path)"
>
<span class="icon-mask" :style="`--icon: url(${item.icon})`" aria-hidden="true"></span>
</button>
</div>
</footer>
</section>
<section class="toast-stack" :class="{ 'terminal-toast': isTerminalRoute }" aria-live="polite">
<article v-for="toast in appStore.toasts" :key="toast.id" class="toast-item" :class="`toast-${toast.level}`">
<header class="toast-title">{{ toToastTitle(toast.level) }}</header>
<p class="toast-message">{{ toast.message }}</p>
</article>
</section>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { RouterView, useRoute, useRouter } from "vue-router";
import { useSettingsStore } from "@/stores/settingsStore";
import { useAppStore } from "@/stores/appStore";
import { toToastTitle } from "@/utils/feedback";
const settingsStore = useSettingsStore();
const appStore = useAppStore();
const route = useRoute();
const router = useRouter();
const keyboardOpen = ref(false);
const formFieldFocused = ref(false);
const touchLikeDevice = ref(false);
const canGoBack = ref(false);
const inputFocusSessionActive = ref(false);
type FocusViewportSession = {
target: HTMLElement;
scrollContainer: HTMLElement | null;
};
const focusViewportSession = ref<FocusViewportSession | null>(null);
let ensureVisibleRafId: number | null = null;
let ensureVisibleRetryTimer: number | null = null;
let baselineViewportHeight = 0;
const hideGlobalBottomBar = computed(() => /^\/server\/.+\/settings$/.test(route.path));
/**
* 临时隐藏底栏策略(面向移动端):
* - 触屏设备上,只要输入控件聚焦就隐藏底栏(不强依赖 keyboardOpen 判定);
* - 兜底保留 keyboardOpen 条件,兼容部分设备触摸特征识别异常。
*/
const hideBottomBarForKeyboard = computed(
() => inputFocusSessionActive.value && (touchLikeDevice.value || keyboardOpen.value || formFieldFocused.value)
);
/**
* 底部框架层总开关:
* - 页面语义要求隐藏(如 server settings
* - 输入态临时隐藏(软键盘场景)。
* 命中任一条件时,直接移除整块底部框架与占位行。
*/
const hideBottomFrame = computed(() => hideGlobalBottomBar.value || hideBottomBarForKeyboard.value);
const pluginRuntimeEnabled = import.meta.env.VITE_ENABLE_PLUGIN_RUNTIME !== "false";
const isTerminalRoute = computed(() => route.path === "/terminal");
type BottomNavItem = { path: string; label: string; icon: string };
const recordsNavItem: BottomNavItem = { path: "/records", label: "闪念清单", icon: "/assets/icons/recordmanager.svg?v=2026022701" };
function appendRecordsNavItem(items: BottomNavItem[]): BottomNavItem[] {
if (route.path === "/records") {
return items;
}
if (items.some((item) => item.path === recordsNavItem.path)) {
return items;
}
return [...items, recordsNavItem];
}
/**
* 底部工具条统一约束设置入口config.svg始终排在最后。
* 仅调整顺序,不改变各页面实际可见按钮集合。
*/
function ensureConfigNavItemLast(items: BottomNavItem[]): BottomNavItem[] {
const index = items.findIndex((item) => item.path === "/settings");
if (index < 0 || index === items.length - 1) {
return items;
}
const next = items.slice();
const [settingsItem] = next.splice(index, 1);
if (!settingsItem) {
return items;
}
next.push(settingsItem);
return next;
}
const rightNavItems = computed<BottomNavItem[]>(() => {
let items: BottomNavItem[];
if (route.path === "/connect") {
items = appendRecordsNavItem([
{ path: "/settings", label: "全局配置", icon: "/icons/config.svg" },
{ path: "/logs", label: "日志", icon: "/icons/log.svg?v=20260227" }
]);
return ensureConfigNavItemLast(items);
}
if (route.path === "/settings") {
const items = [
{ path: "/connect", label: "服务器管理", icon: "/icons/serverlist.svg" },
{ path: "/logs", label: "日志", icon: "/icons/log.svg?v=20260227" }
];
if (pluginRuntimeEnabled) {
items.push({ path: "/plugins", label: "插件管理", icon: "/icons/plugins.svg" });
}
return ensureConfigNavItemLast(appendRecordsNavItem(items));
}
if (route.path === "/plugins") {
items = appendRecordsNavItem([
{ path: "/connect", label: "服务器管理", icon: "/icons/serverlist.svg" },
{ path: "/settings", label: "全局配置", icon: "/icons/config.svg" }
]);
return ensureConfigNavItemLast(items);
}
if (route.path === "/terminal") {
items = appendRecordsNavItem([
{ path: "/connect", label: "服务器管理", icon: "/icons/serverlist.svg" },
{ path: "/settings", label: "全局配置", icon: "/icons/config.svg" },
{ path: "/logs", label: "日志", icon: "/icons/log.svg?v=20260227" }
]);
return ensureConfigNavItemLast(items);
}
if (route.path === "/records") {
items = [
{ path: "/connect", label: "服务器管理", icon: "/icons/serverlist.svg" },
{ path: "/settings", label: "全局配置", icon: "/icons/config.svg" },
{ path: "/logs", label: "日志", icon: "/icons/log.svg?v=20260227" }
];
return ensureConfigNavItemLast(items);
}
items = appendRecordsNavItem([
{ path: "/connect", label: "服务器管理", icon: "/icons/serverlist.svg" },
{ path: "/settings", label: "全局配置", icon: "/icons/config.svg" }
]);
return ensureConfigNavItemLast(items);
});
function updateViewportLayout(): void {
/**
* 移动端软键盘弹出时visualViewport.height 会变小。
* 将根布局高度绑定到可视区域高度,确保 shell 向上收缩且光标行可见。
*/
const vv = window.visualViewport;
const viewportHeight = Math.round(vv?.height ?? window.innerHeight);
/**
* iOS 键盘弹出时Safari 可能会把 visual viewport 向下平移offsetTop > 0
* 若仅更新高度不处理 offset页面可视区域会“切到容器底部”表现为底栏被顶到顶部覆盖内容。
* 因此这里同步写入偏移量,令根容器跟随 visual viewport 对齐。
*/
const viewportOffsetTop = Math.max(0, Math.round(vv?.offsetTop ?? 0));
const viewportOffsetLeft = Math.max(0, Math.round(vv?.offsetLeft ?? 0));
const viewportWidth = Math.round(vv?.width ?? window.innerWidth);
document.documentElement.style.setProperty("--app-viewport-height", `${viewportHeight}px`);
document.documentElement.style.setProperty("--app-viewport-width", `${viewportWidth}px`);
document.documentElement.style.setProperty("--app-viewport-offset-top", `${viewportOffsetTop}px`);
document.documentElement.style.setProperty("--app-viewport-offset-left", `${viewportOffsetLeft}px`);
document.documentElement.style.setProperty("--focus-scroll-margin-top", `${Math.round(viewportHeight * 0.25)}px`);
/**
* 键盘态判定:
* - 首选“历史最大可视高度 - 当前可视高度”的收缩量;
* - 同时保留 visualViewport 与 innerHeight 差值判定作兜底;
* - 阈值取 120px过滤地址栏收展等小幅抖动。
*
* 说明:部分内核在弹键盘时会同步改写 innerHeight若只看 innerHeight-vv.height
* 可能长期为 0导致 keyboardOpen 假阴性(页面不触发输入态滚动)。
*/
const candidateBaseHeight = Math.max(window.innerHeight, viewportHeight);
if (baselineViewportHeight <= 0) {
baselineViewportHeight = candidateBaseHeight;
}
if (!formFieldFocused.value) {
baselineViewportHeight = Math.max(baselineViewportHeight, candidateBaseHeight);
}
const viewportShrink = Math.max(0, baselineViewportHeight - viewportHeight);
const innerHeightDelta = vv ? Math.max(0, window.innerHeight - vv.height) : 0;
keyboardOpen.value = viewportShrink > 120 || innerHeightDelta > 120;
if (formFieldFocused.value) {
scheduleEnsureFocusedFieldVisible();
}
}
/**
* 判断当前聚焦元素是否为会触发软键盘/编辑态的输入控件。
* 约束:
* - 仅把 input/textarea/select/contenteditable 视为“需要让位”的目标;
* - terminal 页的 xterm helper textarea 由 TerminalPanel 内部状态机接管,
* 这里必须排除,避免“全局滚动补偿”与“终端焦点控制”互相干扰;
* - 普通按钮、链接等不触发底栏隐藏。
*/
function isEditableField(target: EventTarget | null): target is HTMLElement {
if (!(target instanceof HTMLElement)) return false;
if (target.classList.contains("xterm-helper-textarea") || target.closest(".terminal-wrapper, .xterm")) {
return false;
}
if (target.isContentEditable) return true;
return target.matches("input, textarea, select");
}
/**
* 同步“是否有输入控件处于聚焦态”:
* - focusin/focusout 在切换两个 input 时会连续触发;
* - 使用微任务读取最终 activeElement避免中间态闪烁。
*/
function syncFormFieldFocusState(): void {
const active = document.activeElement;
formFieldFocused.value = isEditableField(active);
}
/**
* 向上查找“可纵向滚动容器”:
* - 优先使用最近的 overflowY=auto/scroll 容器;
* - 若找不到,后续回退到 window.scrollBy。
*/
function findScrollableAncestor(node: HTMLElement | null): HTMLElement | null {
let current = node?.parentElement ?? null;
while (current) {
const style = window.getComputedStyle(current);
const overflowY = style.overflowY;
const canScroll = /(auto|scroll|overlay)/.test(overflowY);
/**
* 这里不再要求“当前就可滚动”:
* 键盘弹出前很多容器尚未 overflow
* 若此时返回 null后续会错误回退到 window.scroll页面可能不可滚
*/
if (canScroll) {
return current;
}
current = current.parentElement;
}
return null;
}
/**
* 输入控件进入聚焦态时创建会话:
* - 标记当前目标与可滚动容器;
* - 供后续键盘动画阶段反复做可见性校正;
* - 会话只跟随焦点,不再做“滚动位置回滚”。
*/
function beginFocusViewportSession(target: HTMLElement): void {
focusViewportSession.value?.target.classList.remove("viewport-focus-target");
const scrollContainer = findScrollableAncestor(target);
inputFocusSessionActive.value = true;
target.classList.add("viewport-focus-target");
focusViewportSession.value = {
target,
scrollContainer
};
}
/**
* 若当前 activeElement 是输入控件但会话缺失/已切换目标,则重建会话。
* 目的:兜底浏览器事件顺序差异,保证后续滚动计算有基线。
*/
function ensureFocusViewportSession(): void {
const active = document.activeElement;
if (!isEditableField(active)) {
return;
}
if (focusViewportSession.value?.target === active) {
const latestContainer = findScrollableAncestor(active);
focusViewportSession.value.scrollContainer = latestContainer;
return;
}
beginFocusViewportSession(active);
}
function resolveEffectiveViewportHeight(): number {
return Math.round(window.visualViewport?.height ?? window.innerHeight);
}
/**
* 输入会话结束:
* - 清理会话状态与样式标记;
* - 不回滚滚动位置,避免多输入框切换或路由切换时出现反向跳动。
*/
function endFocusViewportSession(): void {
const session = focusViewportSession.value;
focusViewportSession.value = null;
inputFocusSessionActive.value = false;
session?.target.classList.remove("viewport-focus-target");
}
/**
* 将当前聚焦输入框滚入可视区:
* - 基于滚动容器与 visualViewport 高度计算可见窗口;
* - 键盘弹出场景将输入框顶部对齐到可见区“上到下 1/4”位置。
*/
function ensureFocusedFieldVisible(): void {
ensureFocusViewportSession();
const session = focusViewportSession.value;
if (!session) {
return;
}
if (!document.contains(session.target)) {
endFocusViewportSession();
return;
}
let container = session.scrollContainer;
if (!container || !document.contains(container)) {
container = findScrollableAncestor(session.target);
session.scrollContainer = container;
}
/**
* 键盘态兜底:
* - 某些设备 keyboardOpen 判定会短暂失真,但“触屏 + 输入聚焦”基本可视为软键盘过程;
* - 这里放宽为 likelyOpen避免因为判定误差导致完全不滚动。
*/
const likelyKeyboardOpen = keyboardOpen.value || (touchLikeDevice.value && formFieldFocused.value);
/**
* 先尝试一次原生滚动center 比 start 更稳,能在多数浏览器直接避开键盘遮挡),
* 然后再做几何微调,确保目标落在“上到下约 1/4”的稳定阅读区。
*/
if (likelyKeyboardOpen) {
session.target.scrollIntoView({ block: "center", inline: "nearest", behavior: "auto" });
}
const rect = session.target.getBoundingClientRect();
const viewportHeight = resolveEffectiveViewportHeight();
const containerRect = container?.getBoundingClientRect() ?? null;
const visibleTop = (containerRect?.top ?? 0) + 12;
const visibleBottom = Math.min(containerRect?.bottom ?? viewportHeight, viewportHeight) - 12;
if (visibleBottom - visibleTop < 8) {
return;
}
/**
* 交互策略:
* - likelyKeyboardOpen 时:输入框“顶部”对齐到可见区上到下 1/4
* - 非键盘态:仅在超出可见区时才滚动。
*/
const desiredTop = visibleTop + (visibleBottom - visibleTop) * 0.25;
const delta = rect.top - desiredTop;
const outOfVisibleRange = rect.top < visibleTop || rect.bottom > visibleBottom;
const needAdjust = likelyKeyboardOpen ? Math.abs(delta) > 3 : outOfVisibleRange;
if (!needAdjust || Math.abs(delta) < 1) {
return;
}
if (container && document.contains(container)) {
const before = container.scrollTop;
container.scrollTop += delta;
if (Math.abs(container.scrollTop - before) > 0.5) {
return;
}
}
const beforeWindowY = window.scrollY;
window.scrollBy({ top: delta, behavior: "auto" });
if (Math.abs(window.scrollY - beforeWindowY) > 0.5) {
return;
}
/**
* 最终兜底:若容器/window 都未实际移动,再次触发原生 nearest 对齐。
* 这一步主要覆盖个别 WebView 对 scrollTop 写入不生效的情况。
*/
if (likelyKeyboardOpen) {
session.target.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "auto" });
}
}
/**
* 键盘动画期会触发多次 viewport 变化,使用 RAF 合并滚动请求,
* 避免同一帧重复改 scrollTop 引发抖动。
*/
function scheduleEnsureFocusedFieldVisible(): void {
if (!formFieldFocused.value) {
return;
}
if (ensureVisibleRafId !== null) {
return;
}
ensureVisibleRafId = window.requestAnimationFrame(() => {
ensureVisibleRafId = null;
ensureFocusedFieldVisible();
});
}
/**
* 某些机型键盘展开较慢,额外做一次延迟校正,确保输入框最终可见。
*/
function scheduleEnsureFocusedFieldVisibleRetry(): void {
if (ensureVisibleRetryTimer !== null) {
window.clearTimeout(ensureVisibleRetryTimer);
}
ensureVisibleRetryTimer = window.setTimeout(() => {
ensureVisibleRetryTimer = null;
scheduleEnsureFocusedFieldVisible();
}, 180);
}
/**
* focusin 进入输入态:
* - 同步焦点状态;
* - 建立输入会话;
* - 立即 + 延迟一次可见性校正。
*/
function onFocusIn(event: FocusEvent): void {
syncFormFieldFocusState();
if (!isEditableField(event.target)) {
return;
}
beginFocusViewportSession(event.target);
scheduleEnsureFocusedFieldVisible();
scheduleEnsureFocusedFieldVisibleRetry();
}
/**
* focusout 发生时activeElement 可能还没切换完成;
* 放到微任务里读取最终焦点,保证状态稳定。
*/
function onFocusOut(): void {
queueMicrotask(() => {
syncFormFieldFocusState();
if (!formFieldFocused.value) {
endFocusViewportSession();
}
});
}
/**
* 统一“返回”语义:
* - 仅表示“返回历史上一页”;
* - 当历史栈不足length<=1时禁用返回按钮并置灰。
*/
function syncCanGoBack(): void {
if (typeof window === "undefined") {
canGoBack.value = false;
return;
}
canGoBack.value = window.history.length > 1;
}
onMounted(async () => {
touchLikeDevice.value = window.matchMedia?.("(pointer: coarse)")?.matches ?? ("ontouchstart" in window);
updateViewportLayout();
syncFormFieldFocusState();
syncCanGoBack();
window.addEventListener("resize", updateViewportLayout, { passive: true });
window.visualViewport?.addEventListener("resize", updateViewportLayout, { passive: true });
window.visualViewport?.addEventListener("scroll", updateViewportLayout, { passive: true });
document.addEventListener("focusin", onFocusIn);
document.addEventListener("focusout", onFocusOut);
window.addEventListener("popstate", syncCanGoBack);
await settingsStore.ensureBootstrapped();
});
onBeforeUnmount(() => {
window.removeEventListener("resize", updateViewportLayout);
window.visualViewport?.removeEventListener("resize", updateViewportLayout);
window.visualViewport?.removeEventListener("scroll", updateViewportLayout);
document.removeEventListener("focusin", onFocusIn);
document.removeEventListener("focusout", onFocusOut);
window.removeEventListener("popstate", syncCanGoBack);
if (ensureVisibleRafId !== null) {
window.cancelAnimationFrame(ensureVisibleRafId);
ensureVisibleRafId = null;
}
if (ensureVisibleRetryTimer !== null) {
window.clearTimeout(ensureVisibleRetryTimer);
ensureVisibleRetryTimer = null;
}
endFocusViewportSession();
});
watch(
() => settingsStore.themeVars,
(vars) => {
const root = document.documentElement;
for (const [key, value] of Object.entries(vars)) {
root.style.setProperty(key, value);
}
},
{ immediate: true, deep: true }
);
watch(
() => route.fullPath,
() => {
syncCanGoBack();
endFocusViewportSession();
},
{ immediate: true }
);
watch(keyboardOpen, (open, prev) => {
if (open) {
if (formFieldFocused.value) {
inputFocusSessionActive.value = true;
}
scheduleEnsureFocusedFieldVisible();
scheduleEnsureFocusedFieldVisibleRetry();
return;
}
if (prev) {
if (!formFieldFocused.value) {
endFocusViewportSession();
}
}
});
function isActive(path: string): boolean {
return route.path === path;
}
async function goTo(path: string): Promise<void> {
if (route.path === path) return;
await router.push(path);
}
async function goBack(): Promise<void> {
if (!canGoBack.value) {
return;
}
router.back();
}
</script>

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 12 12" fill="none">
<path
d="M3.644 1.5L1.25 10.5H2.56L3.067 8.55H5.564L6.072 10.5H7.382L4.988 1.5H3.644ZM3.365 7.523L4.315 3.873L5.266 7.523H3.365Z"
fill="white"
/>
<path d="M8.25 1.5H9.46V10.5H8.25V1.5Z" fill="white" />
</svg>

After

Width:  |  Height:  |  Size: 319 B

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="8" height="14" viewBox="0 0 8 14" fill="none">
<circle cx="2" cy="2" r="1.3" fill="white" />
<circle cx="6" cy="2" r="1.3" fill="white" />
<circle cx="2" cy="7" r="1.3" fill="white" />
<circle cx="6" cy="7" r="1.3" fill="white" />
<circle cx="2" cy="12" r="1.3" fill="white" />
<circle cx="6" cy="12" r="1.3" fill="white" />
</svg>

After

Width:  |  Height:  |  Size: 391 B

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

11
pxterm/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_GATEWAY_URL?: string;
readonly VITE_GATEWAY_TOKEN?: string;
readonly VITE_ENABLE_PLUGIN_RUNTIME?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

87
pxterm/src/main.ts Normal file
View File

@@ -0,0 +1,87 @@
import { createApp } from "vue";
import { createPinia } from "pinia";
import { createRouter, createWebHistory } from "vue-router";
import App from "./App.vue";
import { routes } from "./routes";
import { installDynamicImportRecovery } from "./utils/dynamicImportGuard";
import "./styles/main.css";
/**
* 全局禁止双指缩放:
* - iOS Safari: 拦截 gesturestart/gesturechange/gestureend
* - 触屏浏览器: 双触点 touchmove 时阻止默认缩放手势;
* - 桌面触控板: Ctrl + wheel 缩放时阻止默认行为。
*/
function installPinchZoomGuard(): void {
const options: AddEventListenerOptions = { passive: false };
const preventDefault = (event: Event): void => {
event.preventDefault();
};
const onTouchMove = (event: TouchEvent): void => {
if (event.touches.length > 1) {
event.preventDefault();
}
};
const onWheel = (event: WheelEvent): void => {
if (event.ctrlKey) {
event.preventDefault();
}
};
document.addEventListener("gesturestart", preventDefault, options);
document.addEventListener("gesturechange", preventDefault, options);
document.addEventListener("gestureend", preventDefault, options);
document.addEventListener("touchmove", onTouchMove, options);
document.addEventListener("wheel", onWheel, options);
}
/**
* 全局禁止双击放大:
* - 移动端:拦截短时间内连续 touchend双击手势
* - 桌面端:拦截 dblclick 默认缩放行为。
*/
function installDoubleTapZoomGuard(): void {
const options: AddEventListenerOptions = { passive: false };
let lastTouchEndAt = 0;
const DOUBLE_TAP_WINDOW_MS = 320;
document.addEventListener(
"touchend",
(event: TouchEvent): void => {
const now = Date.now();
if (now - lastTouchEndAt <= DOUBLE_TAP_WINDOW_MS) {
event.preventDefault();
}
lastTouchEndAt = now;
},
options
);
document.addEventListener(
"dblclick",
(event: MouseEvent): void => {
event.preventDefault();
},
options
);
}
installPinchZoomGuard();
installDoubleTapZoomGuard();
const app = createApp(App);
const pinia = createPinia();
const router = createRouter({
history: createWebHistory(),
routes
});
installDynamicImportRecovery(router);
app.use(pinia);
app.use(router);
app.mount("#app");

42
pxterm/src/routes.ts Normal file
View File

@@ -0,0 +1,42 @@
import type { RouteRecordRaw } from "vue-router";
const pluginRuntimeEnabled = import.meta.env.VITE_ENABLE_PLUGIN_RUNTIME !== "false";
export const routes: RouteRecordRaw[] = [
{
path: "/",
redirect: "/connect"
},
{
path: "/connect",
component: () => import("./views/ConnectView.vue")
},
{
path: "/server/:id/settings",
component: () => import("./views/ServerSettingsView.vue")
},
{
path: "/terminal",
component: () => import("./views/TerminalView.vue")
},
{
path: "/logs",
component: () => import("./views/LogsView.vue")
},
{
path: "/records",
component: () => import("./views/RecordsView.vue")
},
{
path: "/settings",
component: () => import("./views/SettingsView.vue")
},
...(pluginRuntimeEnabled
? [
{
path: "/plugins",
component: () => import("./views/PluginsView.vue")
}
]
: [])
];

View File

@@ -0,0 +1,94 @@
import type { EncryptedCredentialPayload } from "@/types/app";
import { getSettings } from "@/services/storage/db";
const SESSION_KEY_STORAGE = "remoteconn_crypto_key_session_v2";
const PERSIST_KEY_STORAGE = "remoteconn_crypto_key_persist_v2";
const LEGACY_SESSION_KEY_STORAGE = "remoteconn_crypto_key_v1";
/**
* Web 端无法达到系统 Keychain 等级,这里采用会话密钥 + AES-GCM 做“受限存储”。
* 重点是避免明文直接落盘,并在 UI 中持续提示风险。
*/
async function getOrCreateSessionKey(): Promise<CryptoKey> {
const remember = await shouldRememberCredentialKey();
const encoded = readEncodedKey();
if (encoded) {
// 统一迁移到新 key 名,并按策略决定是否持久化。
sessionStorage.setItem(SESSION_KEY_STORAGE, encoded);
if (remember) {
localStorage.setItem(PERSIST_KEY_STORAGE, encoded);
} else {
localStorage.removeItem(PERSIST_KEY_STORAGE);
}
const raw = Uint8Array.from(atob(encoded), (s) => s.charCodeAt(0));
return await crypto.subtle.importKey("raw", raw, "AES-GCM", true, ["encrypt", "decrypt"]);
}
const raw = crypto.getRandomValues(new Uint8Array(32));
const nextEncoded = btoa(String.fromCharCode(...raw));
sessionStorage.setItem(SESSION_KEY_STORAGE, nextEncoded);
if (remember) {
localStorage.setItem(PERSIST_KEY_STORAGE, nextEncoded);
}
return await crypto.subtle.importKey("raw", raw, "AES-GCM", true, ["encrypt", "decrypt"]);
}
/**
* 读取当前凭据密钥保存策略remember 时允许跨刷新/重开保留密钥。
* 若读取设置失败,默认走 remember避免凭据“看似丢失”。
*/
async function shouldRememberCredentialKey(): Promise<boolean> {
try {
const settings = await getSettings();
return (settings?.credentialMemoryPolicy ?? "remember") === "remember";
} catch {
return true;
}
}
function readEncodedKey(): string | null {
return (
sessionStorage.getItem(SESSION_KEY_STORAGE) ??
sessionStorage.getItem(LEGACY_SESSION_KEY_STORAGE) ??
localStorage.getItem(PERSIST_KEY_STORAGE)
);
}
function encodeBase64(source: Uint8Array): string {
return btoa(String.fromCharCode(...source));
}
function decodeBase64ToArrayBuffer(source: string): ArrayBuffer {
const bytes = Uint8Array.from(atob(source), (s) => s.charCodeAt(0));
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
}
export async function encryptCredential(refId: string, value: unknown): Promise<EncryptedCredentialPayload> {
const key = await getOrCreateSessionKey();
const iv = crypto.getRandomValues(new Uint8Array(12));
const payload = new TextEncoder().encode(JSON.stringify(value));
const encrypted = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, payload));
const now = new Date().toISOString();
return {
id: `cred-${crypto.randomUUID()}`,
refId,
encrypted: encodeBase64(encrypted),
iv: encodeBase64(iv),
createdAt: now,
updatedAt: now
};
}
export async function decryptCredential<T>(payload: EncryptedCredentialPayload): Promise<T> {
const key = await getOrCreateSessionKey();
const decrypted = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: decodeBase64ToArrayBuffer(payload.iv) },
key,
decodeBase64ToArrayBuffer(payload.encrypted)
);
return JSON.parse(new TextDecoder().decode(new Uint8Array(decrypted))) as T;
}

View File

@@ -0,0 +1,21 @@
type EventName = "connected" | "disconnected" | "stdout" | "stderr" | "latency";
type Handler = (payload: unknown) => void;
const listeners = new Map<EventName, Set<Handler>>();
export function onSessionEvent(eventName: EventName, handler: Handler): () => void {
if (!listeners.has(eventName)) {
listeners.set(eventName, new Set());
}
listeners.get(eventName)!.add(handler);
return () => listeners.get(eventName)?.delete(handler);
}
export function emitSessionEvent(eventName: EventName, payload: unknown): void {
const set = listeners.get(eventName);
if (!set) return;
for (const handler of set) {
handler(payload);
}
}

View File

@@ -0,0 +1,95 @@
import Dexie, { type EntityTable } from "dexie";
import type { CredentialRef, ServerProfile, SessionLog } from "@remoteconn/shared";
import type { EncryptedCredentialPayload, GlobalSettings, VoiceRecord } from "@/types/app";
import type { PluginPackage, PluginRecord } from "@remoteconn/plugin-runtime";
interface KnownHostEntity {
key: string;
fingerprint: string;
updatedAt: string;
}
interface SettingEntity {
key: string;
value: GlobalSettings;
}
interface PluginDataEntity {
key: string;
value: unknown;
}
class RemoteConnDb extends Dexie {
public servers!: EntityTable<ServerProfile, "id">;
public credentialRefs!: EntityTable<CredentialRef, "id">;
public credentials!: EntityTable<EncryptedCredentialPayload, "id">;
public sessionLogs!: EntityTable<SessionLog, "sessionId">;
public knownHosts!: EntityTable<KnownHostEntity, "key">;
public settings!: EntityTable<SettingEntity, "key">;
public pluginPackages!: EntityTable<PluginPackage & { id: string }, "id">;
public pluginRecords!: EntityTable<PluginRecord & { id: string }, "id">;
public pluginData!: EntityTable<PluginDataEntity, "key">;
public voiceRecords!: EntityTable<VoiceRecord, "id">;
public constructor() {
super("remoteconn_db");
this.version(2).stores({
servers: "id, name, host, lastConnectedAt",
credentialRefs: "id, type, updatedAt",
credentials: "id, refId, updatedAt",
sessionLogs: "sessionId, serverId, startAt, status",
knownHosts: "key, updatedAt",
settings: "key",
pluginPackages: "id",
pluginRecords: "id, status",
pluginData: "key"
});
this.version(3).stores({
servers: "id, name, host, lastConnectedAt",
credentialRefs: "id, type, updatedAt",
credentials: "id, refId, updatedAt",
sessionLogs: "sessionId, serverId, startAt, status",
knownHosts: "key, updatedAt",
settings: "key",
pluginPackages: "id",
pluginRecords: "id, status",
pluginData: "key",
voiceRecords: "id, createdAt, serverId"
});
}
}
export const db = new RemoteConnDb();
async function ensureDbOpen(): Promise<void> {
if (!db.isOpen()) {
await db.open();
}
}
export async function getSettings(): Promise<GlobalSettings | null> {
await ensureDbOpen();
const row = await db.settings.get("global");
return row?.value ?? null;
}
export async function setSettings(value: GlobalSettings): Promise<void> {
await ensureDbOpen();
await db.settings.put({ key: "global", value });
}
export async function getKnownHosts(): Promise<Record<string, string>> {
await ensureDbOpen();
const rows = await db.knownHosts.toArray();
return Object.fromEntries(rows.map((row) => [row.key, row.fingerprint]));
}
export async function upsertKnownHost(key: string, fingerprint: string): Promise<void> {
await ensureDbOpen();
await db.knownHosts.put({
key,
fingerprint,
updatedAt: new Date().toISOString()
});
}

View File

@@ -0,0 +1,43 @@
import type { PluginFsAdapter, PluginPackage } from "@remoteconn/plugin-runtime";
import { db } from "./db";
/**
* Web 端插件存储适配:
* - 插件包与记录保存在 IndexedDB
* - 提供与插件运行时一致的读写接口
*/
export class WebPluginFsAdapter implements PluginFsAdapter {
public async listPackages(): Promise<PluginPackage[]> {
const rows = await db.pluginPackages.toArray();
return rows.map(({ id: _id, ...pkg }) => pkg);
}
public async getPackage(pluginId: string): Promise<PluginPackage | null> {
const row = await db.pluginPackages.get(pluginId);
if (!row) {
return null;
}
const { id: _id, ...pkg } = row;
return pkg;
}
public async upsertPackage(pluginPackage: PluginPackage): Promise<void> {
await db.pluginPackages.put({ id: pluginPackage.manifest.id, ...pluginPackage });
}
public async removePackage(pluginId: string): Promise<void> {
await db.pluginPackages.delete(pluginId);
}
public async readStore<T>(key: string, fallback: T): Promise<T> {
const row = await db.pluginData.get(key);
if (!row) {
return fallback;
}
return row.value as T;
}
public async writeStore<T>(key: string, value: T): Promise<void> {
await db.pluginData.put({ key, value });
}
}

View File

@@ -0,0 +1,16 @@
import type { TerminalTransport } from "./terminalTransport";
import { GatewayTransport } from "./gatewayTransport";
import { IosNativeTransport } from "./iosNativeTransport";
/**
* 统一传输工厂,屏蔽底层差异。
*/
export function createTransport(
mode: "gateway" | "ios-native",
options: { gatewayUrl: string; gatewayToken: string }
): TerminalTransport {
if (mode === "ios-native") {
return new IosNativeTransport();
}
return new GatewayTransport(options.gatewayUrl, options.gatewayToken);
}

View File

@@ -0,0 +1,335 @@
import type { GatewayFrame, SessionState, StdinMeta } from "@remoteconn/shared";
import type { ConnectParams, TerminalTransport, TransportEvent } from "./terminalTransport";
/**
* 网关传输实现Web/小程序共用。
*/
export class GatewayTransport implements TerminalTransport {
private static readonly CONNECT_TIMEOUT_MS = 12000;
private socket: WebSocket | null = null;
private listeners = new Set<(event: TransportEvent) => void>();
private pingAt = 0;
private heartbeatTimer: number | null = null;
private state: SessionState = "idle";
public constructor(
private readonly gatewayUrl: string,
private readonly token: string
) {}
public async connect(params: ConnectParams): Promise<void> {
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
throw new Error("会话已连接");
}
this.state = "connecting";
this.socket = await new Promise<WebSocket>((resolve, reject) => {
const endpoints = this.buildEndpoints();
const reasons: string[] = [];
let index = 0;
const candidateHint = `候选地址: ${endpoints.join(", ")}`;
const tryConnect = (): void => {
const endpoint = endpoints[index];
if (!endpoint) {
reject(new Error(`无法连接网关: ${reasons.join(" | ") || "无可用网关地址"} | ${candidateHint}`));
return;
}
let settled = false;
let socket: WebSocket;
let timeoutTimer: number | null = null;
try {
socket = new WebSocket(endpoint);
} catch {
reasons.push(`地址无效: ${endpoint}`);
if (index < endpoints.length - 1) {
index += 1;
tryConnect();
return;
}
reject(new Error(`无法连接网关: ${reasons.join(" | ")} | ${candidateHint}`));
return;
}
timeoutTimer = window.setTimeout(() => {
fail(`连接超时>${GatewayTransport.CONNECT_TIMEOUT_MS}ms`);
}, GatewayTransport.CONNECT_TIMEOUT_MS);
const clearTimer = (): void => {
if (timeoutTimer !== null) {
window.clearTimeout(timeoutTimer);
timeoutTimer = null;
}
};
const fail = (reason: string): void => {
if (settled) return;
settled = true;
clearTimer();
reasons.push(`${reason}: ${endpoint}`);
try {
socket.close();
} catch {
// 忽略关闭阶段异常,继续下一个候选地址。
}
if (index < endpoints.length - 1) {
index += 1;
tryConnect();
return;
}
reject(new Error(`无法连接网关: ${reasons.join(" | ")} | ${candidateHint}`));
};
socket.onopen = () => {
if (settled) return;
settled = true;
clearTimer();
resolve(socket);
};
socket.onerror = () => fail("网络或协议错误");
socket.onclose = (event) => {
if (!settled) {
fail(`连接关闭 code=${event.code}`);
}
};
};
tryConnect();
});
this.socket.onmessage = (event) => {
const frame = JSON.parse(event.data as string) as GatewayFrame;
this.handleFrame(frame);
};
this.socket.onclose = () => {
this.stopHeartbeat();
this.state = "disconnected";
this.emit({ type: "disconnect", reason: "ws_closed" });
};
this.socket.onerror = () => {
this.stopHeartbeat();
this.state = "error";
this.emit({ type: "error", code: "WS_ERROR", message: "WebSocket 异常" });
};
const initFrame: GatewayFrame = {
type: "init",
payload: {
host: params.host,
port: params.port,
username: params.username,
...(params.clientSessionKey ? { clientSessionKey: params.clientSessionKey } : {}),
credential: params.credential,
knownHostFingerprint: params.knownHostFingerprint,
pty: { cols: params.cols, rows: params.rows }
}
};
this.sendRaw(initFrame);
this.startHeartbeat();
this.state = "auth_pending";
}
public async send(data: string, meta?: StdinMeta): Promise<void> {
this.sendRaw({
type: "stdin",
payload: {
data,
...(meta ? { meta } : {})
}
});
}
public async resize(cols: number, rows: number): Promise<void> {
this.sendRaw({ type: "resize", payload: { cols, rows } });
}
public async disconnect(reason = "manual"): Promise<void> {
this.stopHeartbeat();
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.sendRaw({ type: "control", payload: { action: "disconnect", reason } });
this.socket.close();
}
this.socket = null;
this.state = "disconnected";
}
public on(listener: (event: TransportEvent) => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
public getState(): SessionState {
return this.state;
}
private sendRaw(frame: GatewayFrame): void {
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
throw new Error("网关连接未建立");
}
this.socket.send(JSON.stringify(frame));
}
private handleFrame(frame: GatewayFrame): void {
if (frame.type === "stdout") {
this.state = "connected";
this.emit({ type: "stdout", data: frame.payload.data });
return;
}
if (frame.type === "stderr") {
this.emit({ type: "stderr", data: frame.payload.data });
return;
}
if (frame.type === "error") {
this.state = "error";
this.emit({ type: "error", code: frame.payload.code, message: frame.payload.message });
return;
}
if (frame.type === "control") {
if (frame.payload.action === "ping") {
this.sendRaw({ type: "control", payload: { action: "pong" } });
return;
}
if (frame.payload.action === "pong") {
if (this.pingAt > 0) {
this.emit({ type: "latency", data: Date.now() - this.pingAt });
}
return;
}
if (frame.payload.action === "connected") {
this.state = "connected";
this.emit({ type: "connected", fingerprint: frame.payload.fingerprint });
return;
}
if (frame.payload.action === "disconnect") {
this.state = "disconnected";
this.stopHeartbeat();
this.emit({ type: "disconnect", reason: frame.payload.reason ?? "unknown" });
}
}
}
private emit(event: TransportEvent): void {
for (const listener of this.listeners) {
listener(event);
}
}
private startHeartbeat(): void {
this.stopHeartbeat();
this.heartbeatTimer = window.setInterval(() => {
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
return;
}
this.pingAt = Date.now();
this.sendRaw({ type: "control", payload: { action: "ping" } });
}, 10000);
}
private stopHeartbeat(): void {
if (this.heartbeatTimer) {
window.clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
/**
* 统一网关地址构造(含容错候选):
* 1) 自动将 http/https 转换为 ws/wss
* 2) 页面非本机访问时,避免把 localhost 误连到客户端本机;
* 3) https 页面下,补充 wss 与去端口候选,适配反向代理场景;
* 4) 统一补全 /ws/terminal?token=...
*/
private buildEndpoints(): string[] {
const pageIsHttps = window.location.protocol === "https:";
const pageHost = window.location.hostname;
const pageProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const rawInput = this.gatewayUrl.trim();
const fallback = `${pageProtocol}//${pageHost}`;
const input = rawInput.length > 0 ? rawInput : fallback;
const candidates: string[] = [];
const pushCandidate = (next: URL): void => {
if (pageIsHttps && next.protocol === "ws:") {
return;
}
candidates.push(finalizeEndpoint(next));
};
let url: URL;
try {
const maybeUrl = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(input) ? input : `${pageProtocol}//${input}`;
url = new URL(maybeUrl);
} catch {
url = new URL(fallback);
}
if (url.protocol === "http:") url.protocol = "ws:";
if (url.protocol === "https:") url.protocol = "wss:";
const localHosts = new Set(["localhost", "127.0.0.1", "::1"]);
const pageIsLocal = localHosts.has(pageHost);
const targetIsLocal = localHosts.has(url.hostname);
if (!pageIsLocal && targetIsLocal) {
url.hostname = pageHost;
}
const finalizeEndpoint = (source: URL): string => {
const next = new URL(source.toString());
const pathname = next.pathname.replace(/\/+$/, "");
next.pathname = pathname.endsWith("/ws/terminal") ? pathname : `${pathname}/ws/terminal`.replace(/\/{2,}/g, "/");
next.search = `token=${encodeURIComponent(this.token)}`;
return next.toString();
};
// 1) 优先使用用户配置原始地址。
pushCandidate(url);
// 2) 补充同主机不同协议候选ws <-> wss
// HTTPS 页面禁止 ws://(混合内容会被浏览器直接拦截)。
if (!pageIsHttps && url.protocol === "ws:") {
const tlsUrl = new URL(url.toString());
tlsUrl.protocol = "wss:";
pushCandidate(tlsUrl);
} else if (url.protocol === "wss:") {
const plainUrl = new URL(url.toString());
if (!pageIsHttps) {
plainUrl.protocol = "ws:";
pushCandidate(plainUrl);
}
}
// 3) 远端主机时,始终补充“去端口走反向代理(80/443)”候选。
// 适配公网仅开放 443、Nginx 反代到内网端口的部署。
if (!targetIsLocal) {
const noPort = new URL(url.toString());
noPort.port = "";
pushCandidate(noPort);
if (!pageIsHttps && noPort.protocol === "ws:") {
const noPortTls = new URL(noPort.toString());
noPortTls.protocol = "wss:";
pushCandidate(noPortTls);
} else if (noPort.protocol === "wss:") {
if (!pageIsHttps) {
const noPortPlain = new URL(noPort.toString());
noPortPlain.protocol = "ws:";
pushCandidate(noPortPlain);
}
}
}
return [...new Set(candidates)];
}
}

View File

@@ -0,0 +1,170 @@
import type { SessionState, StdinMeta } from "@remoteconn/shared";
import type { ConnectParams, TerminalTransport, TransportEvent } from "./terminalTransport";
declare global {
interface Window {
Capacitor?: {
Plugins?: {
RemoteConnSSH?: {
connect(options: ConnectParams): Promise<void>;
send(options: { data: string }): Promise<void>;
resize(options: { cols: number; rows: number }): Promise<void>;
disconnect(options: { reason?: string }): Promise<void>;
addListener(
eventName: "stdout" | "stderr" | "disconnect" | "latency" | "error" | "connected",
listener: (payload: unknown) => void
): Promise<{ remove: () => void }>;
};
};
};
}
}
type NativeCredentialPayload =
| { type: "password"; password: string }
| { type: "privateKey"; privateKey: string; passphrase?: string }
| { type: "certificate"; privateKey: string; passphrase?: string; certificate: string };
interface NativeConnectPayload {
host: string;
port: number;
username: string;
knownHostFingerprint?: string;
cols: number;
rows: number;
credential: NativeCredentialPayload;
}
/**
* 将 ConnectParams 规整为仅包含 JSON 原始类型的对象。
* 目的Capacitor Bridge 在 iOS 侧会对参数做克隆/序列化,`undefined` 或代理对象可能触发 DataCloneError。
*/
function buildNativeConnectPayload(params: ConnectParams): NativeConnectPayload {
const base = {
host: String(params.host ?? ""),
port: Number(params.port ?? 22),
username: String(params.username ?? ""),
cols: Number(params.cols ?? 80),
rows: Number(params.rows ?? 24)
};
const knownHostFingerprint =
typeof params.knownHostFingerprint === "string" && params.knownHostFingerprint.trim().length > 0
? params.knownHostFingerprint.trim()
: undefined;
if (params.credential.type === "password") {
return {
...base,
...(knownHostFingerprint ? { knownHostFingerprint } : {}),
credential: {
type: "password",
password: String(params.credential.password ?? "")
}
};
}
if (params.credential.type === "privateKey") {
return {
...base,
...(knownHostFingerprint ? { knownHostFingerprint } : {}),
credential: {
type: "privateKey",
privateKey: String(params.credential.privateKey ?? ""),
...(params.credential.passphrase ? { passphrase: String(params.credential.passphrase) } : {})
}
};
}
return {
...base,
...(knownHostFingerprint ? { knownHostFingerprint } : {}),
credential: {
type: "certificate",
privateKey: String(params.credential.privateKey ?? ""),
certificate: String(params.credential.certificate ?? ""),
...(params.credential.passphrase ? { passphrase: String(params.credential.passphrase) } : {})
}
};
}
/**
* iOS 原生 SSH 传输适配。
*/
export class IosNativeTransport implements TerminalTransport {
private state: SessionState = "idle";
private listeners = new Set<(event: TransportEvent) => void>();
private disposers: Array<() => void> = [];
public async connect(params: ConnectParams): Promise<void> {
const plugin = window.Capacitor?.Plugins?.RemoteConnSSH;
if (!plugin) {
throw new Error("iOS 原生插件不可用");
}
this.state = "connecting";
const onStdout = await plugin.addListener("stdout", (payload) => {
this.state = "connected";
this.emit({ type: "stdout", data: (payload as { data: string }).data });
});
this.disposers.push(() => onStdout.remove());
const onStderr = await plugin.addListener("stderr", (payload) => {
this.emit({ type: "stderr", data: (payload as { data: string }).data });
});
this.disposers.push(() => onStderr.remove());
const onDisconnect = await plugin.addListener("disconnect", (payload) => {
this.state = "disconnected";
this.emit({ type: "disconnect", reason: (payload as { reason: string }).reason });
});
this.disposers.push(() => onDisconnect.remove());
const onLatency = await plugin.addListener("latency", (payload) => {
this.emit({ type: "latency", data: (payload as { latency: number }).latency });
});
this.disposers.push(() => onLatency.remove());
const onError = await plugin.addListener("error", (payload) => {
this.state = "error";
const error = payload as { code: string; message: string };
this.emit({ type: "error", code: error.code, message: error.message });
});
this.disposers.push(() => onError.remove());
await plugin.connect(buildNativeConnectPayload(params));
}
public async send(data: string, _meta?: StdinMeta): Promise<void> {
await window.Capacitor?.Plugins?.RemoteConnSSH?.send({ data });
}
public async resize(cols: number, rows: number): Promise<void> {
await window.Capacitor?.Plugins?.RemoteConnSSH?.resize({ cols, rows });
}
public async disconnect(reason?: string): Promise<void> {
await window.Capacitor?.Plugins?.RemoteConnSSH?.disconnect({ reason });
for (const dispose of this.disposers) {
dispose();
}
this.disposers = [];
this.state = "disconnected";
}
public on(listener: (event: TransportEvent) => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
public getState(): SessionState {
return this.state;
}
private emit(event: TransportEvent): void {
for (const listener of this.listeners) {
listener(event);
}
}
}

View File

@@ -0,0 +1,29 @@
import type { ResolvedCredential, SessionState, StdinMeta } from "@remoteconn/shared";
export type TransportEvent =
| { type: "stdout"; data: string }
| { type: "stderr"; data: string }
| { type: "latency"; data: number }
| { type: "disconnect"; reason: string }
| { type: "connected"; fingerprint?: string }
| { type: "error"; code: string; message: string };
export interface ConnectParams {
host: string;
port: number;
username: string;
clientSessionKey?: string;
credential: ResolvedCredential;
knownHostFingerprint?: string;
cols: number;
rows: number;
}
export interface TerminalTransport {
connect(params: ConnectParams): Promise<void>;
send(data: string, meta?: StdinMeta): Promise<void>;
resize(cols: number, rows: number): Promise<void>;
disconnect(reason?: string): Promise<void>;
on(listener: (event: TransportEvent) => void): () => void;
getState(): SessionState;
}

View File

@@ -0,0 +1,27 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import type { AppToast } from "@/types/app";
/**
* 全局消息中心。
*/
export const useAppStore = defineStore("app", () => {
const toasts = ref<AppToast[]>([]);
function notify(level: AppToast["level"], message: string): void {
const item: AppToast = {
id: crypto.randomUUID(),
level,
message
};
toasts.value.push(item);
window.setTimeout(() => {
toasts.value = toasts.value.filter((x) => x.id !== item.id);
}, level === "error" ? 5000 : 3000);
}
return {
toasts,
notify
};
});

View File

@@ -0,0 +1,117 @@
import { defineStore } from "pinia";
import { computed, toRaw, ref } from "vue";
import { maskHost, maskSensitive, type CommandMarker, type SessionLog } from "@remoteconn/shared";
import { db } from "@/services/storage/db";
import { nowIso } from "@/utils/time";
/**
* 会话日志存储与导出。
*/
export const useLogStore = defineStore("log", () => {
const logs = ref<SessionLog[]>([]);
const loaded = ref(false);
let bootstrapPromise: Promise<void> | null = null;
const latest = computed(() => [...logs.value].sort((a, b) => +new Date(b.startAt) - +new Date(a.startAt)).slice(0, 50));
async function ensureBootstrapped(): Promise<void> {
if (loaded.value) return;
if (bootstrapPromise) {
await bootstrapPromise;
return;
}
bootstrapPromise = (async () => {
logs.value = await db.sessionLogs.toArray();
loaded.value = true;
})();
try {
await bootstrapPromise;
} finally {
bootstrapPromise = null;
}
}
async function bootstrap(): Promise<void> {
await ensureBootstrapped();
}
async function startLog(serverId: string): Promise<string> {
const log: SessionLog = {
sessionId: `sess-${crypto.randomUUID()}`,
serverId,
startAt: nowIso(),
status: "connecting",
commandMarkers: []
};
logs.value.unshift(log);
await db.sessionLogs.put(log);
return log.sessionId;
}
/**
* Dexie/IndexedDB 使用结构化克隆写入数据Vue 响应式代理对象会触发 DataCloneError。
* 这里统一做实体快照,确保入库对象仅包含可序列化的普通 JSON 数据。
*/
function toSessionLogEntity(log: SessionLog): SessionLog {
const raw = toRaw(log);
return {
...raw,
commandMarkers: raw.commandMarkers.map((marker) => ({ ...marker }))
};
}
async function markStatus(sessionId: string, status: SessionLog["status"], error?: string): Promise<void> {
const target = logs.value.find((item) => item.sessionId === sessionId);
if (!target) return;
target.status = status;
if (status === "disconnected" || status === "error") {
target.endAt = nowIso();
}
if (error) {
target.error = error;
}
await db.sessionLogs.put(toSessionLogEntity(target));
}
async function addMarker(sessionId: string, marker: Omit<CommandMarker, "at">): Promise<void> {
const target = logs.value.find((item) => item.sessionId === sessionId);
if (!target) return;
target.commandMarkers.push({ ...marker, at: nowIso() });
await db.sessionLogs.put(toSessionLogEntity(target));
}
function exportLogs(mask = true): string {
const rows = logs.value.map((log) => {
const commands = log.commandMarkers
.map((marker) => {
const cmd = mask ? maskSensitive(marker.command) : marker.command;
return ` - [${marker.at}] ${cmd} => code:${marker.code}`;
})
.join("\n");
return [
`## ${log.sessionId} [${log.status}]`,
`- server: ${log.serverId}`,
`- start: ${log.startAt}`,
`- end: ${log.endAt ?? "--"}`,
`- error: ${mask ? maskSensitive(log.error ?? "") : log.error ?? ""}`,
`- host: ${mask ? maskHost(log.serverId) : log.serverId}`,
"- commands:",
commands || " - 无"
].join("\n");
});
return [`# RemoteConn Session Export ${nowIso()}`, "", ...rows].join("\n\n");
}
return {
logs,
latest,
ensureBootstrapped,
bootstrap,
startLog,
markStatus,
addMarker,
exportLogs
};
});

View File

@@ -0,0 +1,189 @@
import { defineStore } from "pinia";
import { computed, ref } from "vue";
import { PluginManager, type PluginPackage } from "@remoteconn/plugin-runtime";
import { WebPluginFsAdapter } from "@/services/storage/pluginFsAdapter";
import { onSessionEvent } from "@/services/sessionEventBus";
import { useSessionStore } from "./sessionStore";
import { useAppStore } from "./appStore";
/**
* 插件运行时管理。
*/
export const usePluginStore = defineStore("plugin", () => {
const runtimeLogs = ref<string[]>([]);
const initialized = ref(false);
let bootstrapPromise: Promise<void> | null = null;
const fsAdapter = new WebPluginFsAdapter();
const eventUnsubscribers: Array<() => void> = [];
const manager = new PluginManager(fsAdapter, {
getAppMeta() {
return { version: "1.0.0", platform: "web" as const };
},
session: {
async send(input) {
const sessionStore = useSessionStore();
await sessionStore.sendCommand(input, "plugin", "manual");
},
on(eventName, handler) {
return onSessionEvent(eventName, handler);
}
},
showNotice(message, level) {
const appStore = useAppStore();
appStore.notify(level, message);
}
}, {
appVersion: "1.0.0",
mountStyle(pluginId, css) {
const style = document.createElement("style");
style.dataset.pluginId = pluginId;
style.textContent = css;
document.head.append(style);
return () => style.remove();
},
logger(level, pluginId, message) {
runtimeLogs.value.unshift(`[${new Date().toLocaleTimeString("zh-CN", { hour12: false })}] [${level}] [${pluginId}] ${message}`);
if (runtimeLogs.value.length > 300) {
runtimeLogs.value.splice(300);
}
}
});
const records = computed(() => manager.listRecords());
const commands = computed(() => {
const session = useSessionStore();
return manager.listCommands(session.connected ? "connected" : "disconnected");
});
async function ensureBootstrapped(): Promise<void> {
if (initialized.value) return;
if (bootstrapPromise) {
await bootstrapPromise;
return;
}
bootstrapPromise = (async () => {
await manager.bootstrap();
await ensureSamplePlugin();
eventUnsubscribers.push(
onSessionEvent("connected", () => {
// 保持 computed 触发
runtimeLogs.value = [...runtimeLogs.value];
})
);
initialized.value = true;
})();
try {
await bootstrapPromise;
} finally {
bootstrapPromise = null;
}
}
async function bootstrap(): Promise<void> {
await ensureBootstrapped();
}
async function ensureSamplePlugin(): Promise<void> {
const packages = await fsAdapter.listPackages();
if (packages.length > 0) return;
await importPackages([
{
manifest: {
id: "codex-shortcuts",
name: "Codex Shortcuts",
version: "0.1.0",
minAppVersion: "0.1.0",
description: "提供常用 Codex 快捷命令",
entry: "main.js",
style: "styles.css",
permissions: ["commands.register", "session.write", "ui.notice"]
},
mainJs: `
module.exports = {
onload(ctx) {
ctx.commands.register({
id: "codex-doctor",
title: "Codex Doctor",
when: "connected",
async handler() {
await ctx.session.send("codex --doctor");
}
});
ctx.ui.showNotice("插件 codex-shortcuts 已加载", "info");
}
};
`.trim(),
stylesCss: `.plugin-chip[data-plugin-id="codex-shortcuts"] { border-color: rgba(95,228,255,0.7); }`
}
]);
}
async function importPackages(payload: PluginPackage[]): Promise<void> {
for (const pkg of payload) {
await manager.installPackage(pkg);
}
}
async function importJson(raw: string): Promise<void> {
const parsed = JSON.parse(raw) as PluginPackage | PluginPackage[];
const items = Array.isArray(parsed) ? parsed : [parsed];
await importPackages(items);
}
async function exportJson(): Promise<string> {
const packages = await fsAdapter.listPackages();
return JSON.stringify(packages, null, 2);
}
async function enable(pluginId: string): Promise<void> {
await manager.enable(pluginId);
}
async function disable(pluginId: string): Promise<void> {
await manager.disable(pluginId);
}
async function reload(pluginId: string): Promise<void> {
await manager.reload(pluginId);
}
async function remove(pluginId: string): Promise<void> {
await manager.remove(pluginId);
}
async function runCommand(commandId: string): Promise<void> {
await manager.runCommand(commandId);
}
function dispose(): void {
for (const off of eventUnsubscribers) {
off();
}
eventUnsubscribers.length = 0;
}
return {
runtimeLogs,
records,
commands,
ensureBootstrapped,
bootstrap,
importJson,
exportJson,
enable,
disable,
reload,
remove,
runCommand,
dispose
};
});

View File

@@ -0,0 +1,156 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createPinia, setActivePinia } from "pinia";
import type { ServerProfile } from "@/types/app";
const { dbState, dbMock } = vi.hoisted(() => {
const state = {
servers: [] as ServerProfile[]
};
const cloneServer = (server: ServerProfile): ServerProfile => ({
...server,
projectPresets: [...server.projectPresets],
tags: [...server.tags]
});
const upsertServer = (server: ServerProfile): void => {
const index = state.servers.findIndex((item) => item.id === server.id);
if (index >= 0) {
state.servers[index] = cloneServer(server);
} else {
state.servers.push(cloneServer(server));
}
};
const db = {
servers: {
toArray: vi.fn(async () => state.servers.map((item) => cloneServer(item))),
add: vi.fn(async (server: ServerProfile) => {
state.servers.push(cloneServer(server));
}),
put: vi.fn(async (server: ServerProfile) => {
upsertServer(server);
}),
bulkPut: vi.fn(async (servers: ServerProfile[]) => {
servers.forEach((server) => upsertServer(server));
}),
delete: vi.fn(async (serverId: string) => {
state.servers = state.servers.filter((item) => item.id !== serverId);
})
},
credentialRefs: {
toArray: vi.fn(async () => [])
},
credentials: {
where: vi.fn(() => ({
equals: vi.fn(() => ({
first: vi.fn(async () => null),
delete: vi.fn(async () => {})
}))
})),
put: vi.fn(async () => {})
}
};
return {
dbState: state,
dbMock: db
};
});
vi.mock("@/services/storage/db", () => ({
db: dbMock
}));
vi.mock("@/services/security/credentialVault", () => ({
decryptCredential: vi.fn(async () => ({})),
encryptCredential: vi.fn(async () => ({
id: "enc-1",
refId: "enc-1",
encrypted: "",
iv: "",
createdAt: "",
updatedAt: ""
}))
}));
import { useServerStore } from "./serverStore";
function makeServer(id: string, sortOrder?: number): ServerProfile {
return {
id,
name: id,
host: "127.0.0.1",
port: 22,
username: "root",
authType: "password",
projectPath: "~",
projectPresets: [],
tags: [],
timeoutSeconds: 20,
heartbeatSeconds: 15,
transportMode: "gateway",
...(sortOrder !== undefined ? { sortOrder } : {})
};
}
describe("serverStore", () => {
beforeEach(() => {
setActivePinia(createPinia());
dbState.servers = [];
dbMock.servers.toArray.mockClear();
dbMock.servers.add.mockClear();
dbMock.servers.put.mockClear();
dbMock.servers.bulkPut.mockClear();
dbMock.servers.delete.mockClear();
dbMock.credentialRefs.toArray.mockClear();
});
it("启动时按 sortOrder 恢复顺序并回填连续排序值", async () => {
dbState.servers = [makeServer("srv-b", 2), makeServer("srv-a"), makeServer("srv-c", 0)];
const store = useServerStore();
await store.ensureBootstrapped();
expect(store.servers.map((item) => item.id)).toEqual(["srv-c", "srv-b", "srv-a"]);
expect(dbMock.servers.bulkPut).toHaveBeenCalledTimes(1);
expect(store.servers.map((item) => item.sortOrder)).toEqual([0, 1, 2]);
});
it("支持服务器上下移动并持久化顺序", async () => {
dbState.servers = [makeServer("srv-1", 0), makeServer("srv-2", 1), makeServer("srv-3", 2)];
const store = useServerStore();
await store.ensureBootstrapped();
expect(dbMock.servers.bulkPut).toHaveBeenCalledTimes(0);
const movedDown = await store.moveServerDown("srv-1");
expect(movedDown).toBe(true);
expect(store.servers.map((item) => item.id)).toEqual(["srv-2", "srv-1", "srv-3"]);
expect(store.servers.map((item) => item.sortOrder)).toEqual([0, 1, 2]);
const movedUp = await store.moveServerUp("srv-1");
expect(movedUp).toBe(true);
expect(store.servers.map((item) => item.id)).toEqual(["srv-1", "srv-2", "srv-3"]);
const topBoundary = await store.moveServerUp("srv-1");
const bottomBoundary = await store.moveServerDown("srv-3");
expect(topBoundary).toBe(false);
expect(bottomBoundary).toBe(false);
});
it("支持按指定 id 顺序重排", async () => {
dbState.servers = [makeServer("srv-1", 0), makeServer("srv-2", 1), makeServer("srv-3", 2)];
const store = useServerStore();
await store.ensureBootstrapped();
const changed = await store.applyServerOrder(["srv-3", "srv-1", "srv-2"]);
expect(changed).toBe(true);
expect(store.servers.map((item) => item.id)).toEqual(["srv-3", "srv-1", "srv-2"]);
expect(store.servers.map((item) => item.sortOrder)).toEqual([0, 1, 2]);
const unchanged = await store.applyServerOrder(["srv-3", "srv-1", "srv-2"]);
expect(unchanged).toBe(false);
});
});

View File

@@ -0,0 +1,382 @@
import { defineStore } from "pinia";
import { computed, toRaw, ref } from "vue";
import type { CredentialRef, ResolvedCredential, ServerProfile } from "@/types/app";
import { db } from "@/services/storage/db";
import { decryptCredential, encryptCredential } from "@/services/security/credentialVault";
import { nowIso } from "@/utils/time";
interface ServerCredentialInput {
type: CredentialRef["type"];
password?: string;
privateKey?: string;
passphrase?: string;
certificate?: string;
}
/**
* 服务器与凭据管理。
*/
export const useServerStore = defineStore("server", () => {
const servers = ref<ServerProfile[]>([]);
const credentialRefs = ref<CredentialRef[]>([]);
const selectedServerId = ref<string>("");
const loaded = ref(false);
let bootstrapPromise: Promise<void> | null = null;
const selectedServer = computed(() => servers.value.find((item) => item.id === selectedServerId.value));
/**
* 规范化排序值:
* - 非数字、NaN、负值都视为“缺失排序”
* - 仅保留非负整数,避免浮点或异常值污染排序稳定性。
*/
function normalizeSortOrder(value: unknown): number | null {
if (typeof value !== "number" || !Number.isFinite(value)) {
return null;
}
const normalized = Math.floor(value);
if (normalized < 0) {
return null;
}
return normalized;
}
/**
* 按持久化排序字段恢复列表顺序:
* - 优先按 sortOrder 升序;
* - 缺失 sortOrder 的历史数据保留原始读取顺序;
* - 排序值冲突时回退到原始顺序,保证稳定排序。
*/
function sortServersByStoredOrder(input: ServerProfile[]): ServerProfile[] {
return input
.map((server, index) => ({
server,
index,
sortOrder: normalizeSortOrder(server.sortOrder)
}))
.sort((a, b) => {
if (a.sortOrder === null && b.sortOrder === null) {
return a.index - b.index;
}
if (a.sortOrder === null) {
return 1;
}
if (b.sortOrder === null) {
return -1;
}
if (a.sortOrder !== b.sortOrder) {
return a.sortOrder - b.sortOrder;
}
return a.index - b.index;
})
.map((entry) => entry.server);
}
/**
* 将当前数组顺序重写为连续 sortOrder并回写到数据库。
* 约束:
* - 不改变入参数组的相对顺序;
* - 所有项强制回填 sortOrder保证刷新后顺序稳定可恢复。
*/
async function persistServerOrder(nextServers: ServerProfile[]): Promise<void> {
const ordered = nextServers.map((server, index) => {
const entity = toServerEntity(server);
return {
...entity,
sortOrder: index
};
});
servers.value = ordered;
await db.servers.bulkPut(ordered.map((item) => toServerEntity(item)));
}
async function ensureBootstrapped(): Promise<void> {
if (loaded.value) return;
if (bootstrapPromise) {
await bootstrapPromise;
return;
}
bootstrapPromise = (async () => {
const storedServers = await db.servers.toArray();
credentialRefs.value = await db.credentialRefs.toArray();
if (storedServers.length === 0) {
const sample = buildDefaultServer();
await persistServerOrder([sample]);
} else {
const sortedServers = sortServersByStoredOrder(storedServers);
const needsPersist = sortedServers.some((server, index) => {
const current = storedServers[index];
return server.id !== current?.id || normalizeSortOrder(server.sortOrder) !== index;
});
if (needsPersist) {
await persistServerOrder(sortedServers);
} else {
servers.value = sortedServers.map((server) => toServerEntity(server));
}
}
selectedServerId.value = servers.value[0]?.id ?? "";
loaded.value = true;
})();
try {
await bootstrapPromise;
} finally {
bootstrapPromise = null;
}
}
async function bootstrap(): Promise<void> {
await ensureBootstrapped();
}
function buildDefaultServer(): ServerProfile {
return {
id: `srv-${crypto.randomUUID()}`,
name: "新服务器",
host: "",
port: 22,
username: "root",
authType: "password",
projectPath: "~/workspace",
projectPresets: ["~/workspace"],
tags: [],
timeoutSeconds: 20,
heartbeatSeconds: 15,
transportMode: "gateway",
sortOrder: 0,
lastConnectedAt: ""
};
}
/**
* 仅创建“新服务器草稿”快照,不写入列表与数据库。
* 用于“新增服务器先进入配置页,保存后再落库”的流程。
*/
function createServerDraft(): ServerProfile {
return buildDefaultServer();
}
/**
* 将服务器对象转换为可安全写入 IndexedDB 的纯数据实体。
* 目的:避免 Vue Proxy 透传到 Dexie 触发 DataCloneError。
*/
function toServerEntity(server: ServerProfile): ServerProfile {
const raw = toRaw(server);
return {
...raw,
projectPresets: [...raw.projectPresets],
tags: [...raw.tags]
};
}
async function createServer(): Promise<void> {
const sample = createServerDraft();
await persistServerOrder([sample, ...servers.value]);
selectedServerId.value = sample.id;
}
async function saveServer(server: ServerProfile): Promise<void> {
const nextServers = [...servers.value];
const index = servers.value.findIndex((item) => item.id === server.id);
if (index >= 0) {
nextServers[index] = server;
} else {
nextServers.unshift(server);
}
await persistServerOrder(nextServers);
}
async function deleteServer(serverId: string): Promise<void> {
const nextServers = servers.value.filter((item) => item.id !== serverId);
await db.servers.delete(serverId);
await persistServerOrder(nextServers);
if (selectedServerId.value === serverId) {
selectedServerId.value = servers.value[0]?.id ?? "";
}
}
/**
* 将指定服务器上移一位。
* 返回:
* - true: 已成功移动并持久化;
* - false: 不存在或已在顶部,无需移动。
*/
async function moveServerUp(serverId: string): Promise<boolean> {
const index = servers.value.findIndex((item) => item.id === serverId);
if (index <= 0) {
return false;
}
const nextServers = [...servers.value];
const previous = nextServers[index - 1];
const current = nextServers[index];
if (!previous || !current) {
return false;
}
nextServers[index - 1] = current;
nextServers[index] = previous;
await persistServerOrder(nextServers);
return true;
}
/**
* 将指定服务器下移一位。
* 返回:
* - true: 已成功移动并持久化;
* - false: 不存在或已在底部,无需移动。
*/
async function moveServerDown(serverId: string): Promise<boolean> {
const index = servers.value.findIndex((item) => item.id === serverId);
if (index < 0 || index >= servers.value.length - 1) {
return false;
}
const nextServers = [...servers.value];
const current = nextServers[index];
const next = nextServers[index + 1];
if (!current || !next) {
return false;
}
nextServers[index] = next;
nextServers[index + 1] = current;
await persistServerOrder(nextServers);
return true;
}
/**
* 按传入 ID 顺序重排服务器列表并持久化。
* 规则:
* - `orderedIds` 中不存在/重复的项会被忽略;
* - 未出现在 `orderedIds` 的服务器按原顺序追加到末尾;
* - 若顺序无变化,返回 false。
*/
async function applyServerOrder(orderedIds: string[]): Promise<boolean> {
const byId = new Map(servers.value.map((server) => [server.id, server] as const));
const seen = new Set<string>();
const head: ServerProfile[] = [];
for (const id of orderedIds) {
if (!id || seen.has(id)) {
continue;
}
const matched = byId.get(id);
if (!matched) {
continue;
}
seen.add(id);
head.push(matched);
}
const tail = servers.value.filter((server) => !seen.has(server.id));
const nextServers = [...head, ...tail];
if (
nextServers.length === servers.value.length &&
nextServers.every((server, index) => server.id === servers.value[index]?.id)
) {
return false;
}
await persistServerOrder(nextServers);
return true;
}
async function saveCredential(refId: string, payload: ServerCredentialInput): Promise<CredentialRef> {
const exists = credentialRefs.value.find((item) => item.id === refId);
const now = nowIso();
const ref: CredentialRef = {
id: refId,
type: payload.type,
secureStoreKey: `web:credential:${refId}`,
createdAt: exists?.createdAt ?? now,
updatedAt: now
};
await db.credentialRefs.put(ref);
await db.credentials.where("refId").equals(refId).delete();
const encrypted = await encryptCredential(refId, payload);
await db.credentials.put(encrypted);
const idx = credentialRefs.value.findIndex((item) => item.id === refId);
if (idx >= 0) {
credentialRefs.value[idx] = ref;
} else {
credentialRefs.value.push(ref);
}
return ref;
}
async function resolveCredential(refId: string): Promise<ResolvedCredential> {
const ref = credentialRefs.value.find((item) => item.id === refId);
if (!ref) {
throw new Error("凭据引用不存在");
}
const payload = await db.credentials.where("refId").equals(refId).first();
if (!payload) {
throw new Error("未找到凭据内容");
}
const decrypted = await decryptCredential<ServerCredentialInput>(payload);
if (ref.type === "password") {
return {
type: "password",
password: decrypted.password ?? ""
};
}
if (ref.type === "privateKey") {
return {
type: "privateKey",
privateKey: decrypted.privateKey ?? "",
passphrase: decrypted.passphrase
};
}
return {
type: "certificate",
privateKey: decrypted.privateKey ?? "",
passphrase: decrypted.passphrase,
certificate: decrypted.certificate ?? ""
};
}
async function getCredentialInput(refId: string): Promise<ServerCredentialInput | null> {
const payload = await db.credentials.where("refId").equals(refId).first();
if (!payload) {
return null;
}
return await decryptCredential<ServerCredentialInput>(payload);
}
async function markConnected(serverId: string): Promise<void> {
const target = servers.value.find((item) => item.id === serverId);
if (!target) return;
target.lastConnectedAt = nowIso();
await db.servers.put(toServerEntity(target));
}
return {
servers,
credentialRefs,
selectedServerId,
selectedServer,
ensureBootstrapped,
bootstrap,
createServerDraft,
createServer,
saveServer,
deleteServer,
moveServerUp,
moveServerDown,
applyServerOrder,
saveCredential,
resolveCredential,
getCredentialInput,
markConnected
};
});

View File

@@ -0,0 +1,494 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createPinia, setActivePinia } from "pinia";
import type { ServerProfile } from "@/types/app";
type MockTransportEvent = {
type: string;
[key: string]: unknown;
};
const {
settingsStoreMock,
serverStoreMock,
logStoreMock,
appStoreMock,
createTransportMock,
transportMock,
emitSessionEventMock,
listeners,
sessionStorageState
} = vi.hoisted(() => {
const listenersRef: { value: ((event: MockTransportEvent) => Promise<void> | void) | null } = { value: null };
const sessionStorageMap = new Map<string, string>();
const transport = {
on: vi.fn((handler: (event: MockTransportEvent) => Promise<void> | void) => {
listenersRef.value = handler;
return () => {
listenersRef.value = null;
};
}),
connect: vi.fn(async () => {}),
send: vi.fn(async () => {}),
disconnect: vi.fn(async () => {}),
resize: vi.fn(async () => {})
};
return {
settingsStoreMock: {
settings: {
autoReconnect: true,
reconnectLimit: 2,
terminalBufferMaxEntries: 5000,
terminalBufferMaxBytes: 4 * 1024 * 1024,
gatewayUrl: "ws://127.0.0.1:8787/ws/terminal",
gatewayToken: "dev-token"
},
gatewayUrl: "ws://127.0.0.1:8787/ws/terminal",
gatewayToken: "dev-token",
knownHosts: {},
verifyAndPersistHostFingerprint: vi.fn(async () => true)
},
serverStoreMock: {
servers: [] as ServerProfile[],
resolveCredential: vi.fn(async () => ({ type: "password", password: "secret" })),
markConnected: vi.fn(async () => {})
},
logStoreMock: {
startLog: vi.fn(async () => "session-log-1"),
markStatus: vi.fn(async () => {}),
addMarker: vi.fn(async () => {})
},
appStoreMock: {
notify: vi.fn()
},
createTransportMock: vi.fn(() => transport),
transportMock: transport,
emitSessionEventMock: vi.fn(),
listeners: listenersRef,
sessionStorageState: {
map: sessionStorageMap,
clear: () => sessionStorageMap.clear(),
getItem: (key: string) => sessionStorageMap.get(key) ?? null,
setItem: (key: string, value: string) => {
sessionStorageMap.set(key, value);
}
}
};
});
vi.mock("@remoteconn/shared", () => ({
allStates: () => ["idle", "connecting", "auth_pending", "connected", "reconnecting", "disconnected", "error"],
buildCodexPlan: (options: { projectPath: string; sandbox: "read-only" | "workspace-write" | "danger-full-access" }) => [
{
step: "cd",
command: `cd ${options.projectPath}`,
markerType: "cd"
},
{
step: "check",
command: "command -v codex",
markerType: "check"
},
{
step: "run",
command: `codex --sandbox ${options.sandbox}`,
markerType: "run"
}
]
}));
vi.mock("./settingsStore", () => ({
useSettingsStore: () => settingsStoreMock
}));
vi.mock("./serverStore", () => ({
useServerStore: () => serverStoreMock
}));
vi.mock("./logStore", () => ({
useLogStore: () => logStoreMock
}));
vi.mock("./appStore", () => ({
useAppStore: () => appStoreMock
}));
vi.mock("@/services/transport/factory", () => ({
createTransport: createTransportMock
}));
vi.mock("@/services/sessionEventBus", () => ({
emitSessionEvent: emitSessionEventMock
}));
vi.mock("@/utils/feedback", () => ({
formatActionError: (_prefix: string, error: unknown) => String(error),
toFriendlyDisconnectReason: (reason: string) => reason,
toFriendlyError: (message: string) => message
}));
import { useSessionStore } from "./sessionStore";
function setupWindowSessionStorage(): void {
const sessionStorage = {
getItem: (key: string) => sessionStorageState.getItem(key),
setItem: (key: string, value: string) => sessionStorageState.setItem(key, value)
};
const windowMock = {
sessionStorage,
setTimeout,
clearTimeout,
addEventListener: vi.fn(),
removeEventListener: vi.fn()
};
const documentMock = {
visibilityState: "visible",
addEventListener: vi.fn(),
removeEventListener: vi.fn()
};
Object.defineProperty(globalThis, "window", {
configurable: true,
value: windowMock
});
Object.defineProperty(globalThis, "document", {
configurable: true,
value: documentMock
});
}
describe("sessionStore", () => {
beforeEach(() => {
setActivePinia(createPinia());
sessionStorageState.clear();
listeners.value = null;
transportMock.on.mockClear();
transportMock.connect.mockClear();
transportMock.send.mockClear();
transportMock.disconnect.mockClear();
transportMock.resize.mockClear();
createTransportMock.mockClear();
emitSessionEventMock.mockClear();
appStoreMock.notify.mockClear();
logStoreMock.startLog.mockClear();
logStoreMock.markStatus.mockClear();
serverStoreMock.resolveCredential.mockClear();
serverStoreMock.markConnected.mockClear();
settingsStoreMock.settings.autoReconnect = true;
settingsStoreMock.settings.reconnectLimit = 2;
settingsStoreMock.knownHosts = {};
serverStoreMock.servers = [];
setupWindowSessionStorage();
});
it("启动时恢复快照并自动重连", async () => {
const server: ServerProfile = {
id: "srv-1",
name: "mini",
host: "127.0.0.1",
port: 22,
username: "gavin",
authType: "password",
projectPath: "~",
projectPresets: [],
tags: [],
timeoutSeconds: 20,
heartbeatSeconds: 15,
transportMode: "gateway"
};
serverStoreMock.servers = [server];
sessionStorageState.setItem(
"remoteconn_session_snapshot_v1",
JSON.stringify({
version: 2,
savedAt: Date.now(),
activeConnectionKey: "srv-1::snapshot",
lines: ["restored-line"],
currentServerId: "srv-1",
reconnectServerId: "srv-1"
})
);
const store = useSessionStore();
await store.ensureBootstrapped();
expect(createTransportMock).toHaveBeenCalledTimes(1);
expect(transportMock.connect).toHaveBeenCalledTimes(1);
expect(store.currentServerId).toBe("srv-1");
expect(store.lines).toContain("restored-line");
});
it("刷新恢复连接不受 autoReconnect 开关影响", async () => {
const server: ServerProfile = {
id: "srv-reload",
name: "reload",
host: "127.0.0.1",
port: 22,
username: "gavin",
authType: "password",
projectPath: "~",
projectPresets: [],
tags: [],
timeoutSeconds: 20,
heartbeatSeconds: 15,
transportMode: "gateway"
};
serverStoreMock.servers = [server];
settingsStoreMock.settings.autoReconnect = false;
sessionStorageState.setItem(
"remoteconn_session_snapshot_v1",
JSON.stringify({
version: 2,
savedAt: Date.now(),
activeConnectionKey: "srv-reload::snapshot",
lines: ["reloaded"],
currentServerId: "srv-reload",
reconnectServerId: "srv-reload"
})
);
const store = useSessionStore();
await store.ensureBootstrapped();
expect(transportMock.connect).toHaveBeenCalledTimes(1);
expect(store.currentServerId).toBe("srv-reload");
});
it("ios-native 已完成兼容初始化后,中文输入不重复注入兼容命令", async () => {
const server: ServerProfile = {
id: "srv-2",
name: "ios",
host: "127.0.0.1",
port: 22,
username: "gavin",
authType: "password",
projectPath: "~",
projectPresets: [],
tags: [],
timeoutSeconds: 20,
heartbeatSeconds: 15,
transportMode: "ios-native"
};
serverStoreMock.servers = [server];
const store = useSessionStore();
await store.connect(server);
expect(listeners.value).toBeTypeOf("function");
await listeners.value?.({ type: "connected" });
const shellCompatCalls = transportMock.send.mock.calls.filter((args: unknown[]) =>
String(args.at(0) ?? "").includes("setopt MULTIBYTE PRINT_EIGHT_BIT")
);
expect(shellCompatCalls).toHaveLength(1);
await store.sendInput("中文");
const shellCompatCallsAfterInput = transportMock.send.mock.calls.filter((args: unknown[]) =>
String(args.at(0) ?? "").includes("setopt MULTIBYTE PRINT_EIGHT_BIT")
);
expect(shellCompatCallsAfterInput).toHaveLength(1);
expect(transportMock.send).toHaveBeenLastCalledWith("中文", undefined);
});
it("同服务器手动重连应保留输出历史;切换服务器应隔离历史", async () => {
const serverA: ServerProfile = {
id: "srv-a",
name: "A",
host: "10.0.0.1",
port: 22,
username: "gavin",
authType: "password",
projectPath: "~",
projectPresets: [],
tags: [],
timeoutSeconds: 20,
heartbeatSeconds: 15,
transportMode: "gateway"
};
const serverB = {
...serverA,
id: "srv-b",
name: "B",
host: "10.0.0.2"
};
serverStoreMock.servers = [serverA, serverB];
const store = useSessionStore();
await store.connect(serverA);
await listeners.value?.({ type: "connected" });
await listeners.value?.({ type: "stdout", data: "history-from-a\r\n" });
expect(store.lines.join("")).toContain("history-from-a");
await store.disconnect("manual", true);
await store.connect(serverA);
await listeners.value?.({ type: "connected" });
expect(store.lines.join("")).toContain("history-from-a");
await store.connect(serverB);
await listeners.value?.({ type: "connected" });
expect(store.lines.join("")).not.toContain("history-from-a");
});
it("ws_closed 断开后应进入可续接态,并在手动断开时清除", async () => {
const server: ServerProfile = {
id: "srv-resume",
name: "resume",
host: "127.0.0.1",
port: 22,
username: "gavin",
authType: "password",
projectPath: "~",
projectPresets: [],
tags: [],
timeoutSeconds: 20,
heartbeatSeconds: 15,
transportMode: "gateway"
};
serverStoreMock.servers = [server];
settingsStoreMock.settings.autoReconnect = false;
const store = useSessionStore();
await store.connect(server);
await listeners.value?.({ type: "connected" });
expect(store.isServerResumable(server.id)).toBe(false);
await listeners.value?.({ type: "disconnect", reason: "ws_closed" });
expect(store.isServerResumable(server.id)).toBe(true);
await store.disconnect("manual", true);
expect(store.isServerResumable(server.id)).toBe(false);
});
it("Codex 预检命令回显包含 token 时不应误报目录不存在或未安装", async () => {
const server: ServerProfile = {
id: "srv-codex-ok",
name: "codex-ok",
host: "127.0.0.1",
port: 22,
username: "gavin",
authType: "password",
projectPath: "~/workspace",
projectPresets: [],
tags: [],
timeoutSeconds: 20,
heartbeatSeconds: 15,
transportMode: "gateway"
};
serverStoreMock.servers = [server];
const store = useSessionStore();
await store.connect(server);
await listeners.value?.({ type: "connected" });
const launchedPromise = store.runCodex(server.projectPath, "workspace-write");
const bootstrapCommand = String((transportMock.send.mock.calls.at(-1) ?? []).join(" "));
expect(bootstrapCommand.startsWith('sh -lc "')).toBe(true);
// 模拟 shell 回显“整条 bootstrap 命令”(包含 token 字面量),随后输出 READY。
await listeners.value?.({
type: "stdout",
data:
"__rc_codex_path_ok=1; __rc_codex_bin_ok=1; [ \"$__rc_codex_path_ok\" -eq 1 ] || printf '__RC_CODEX_DIR_MISSING__\\n'; " +
"[ \"$__rc_codex_bin_ok\" -eq 1 ] || printf '__RC_CODEX_BIN_MISSING__\\n';\r\n" +
"__RC_CODEX_READY__\r\nCodex started\r\n"
});
const launched = await launchedPromise;
expect(launched).toBe(true);
const warnMessages = appStoreMock.notify.mock.calls
.filter((args: unknown[]) => args[0] === "warn")
.map((args: unknown[]) => String(args[1] ?? ""));
expect(warnMessages.some((message) => message.includes("codex工作目录"))).toBe(false);
expect(warnMessages.some((message) => message.includes("服务器未装codex"))).toBe(false);
});
it("Codex 预检收到失败 token 行时应返回失败并提示原因", async () => {
const server: ServerProfile = {
id: "srv-codex-missing",
name: "codex-missing",
host: "127.0.0.1",
port: 22,
username: "gavin",
authType: "password",
projectPath: "~/workspace",
projectPresets: [],
tags: [],
timeoutSeconds: 20,
heartbeatSeconds: 15,
transportMode: "gateway"
};
serverStoreMock.servers = [server];
const store = useSessionStore();
await store.connect(server);
await listeners.value?.({ type: "connected" });
const launchedPromise = store.runCodex(server.projectPath, "workspace-write");
const bootstrapCommand = String((transportMock.send.mock.calls.at(-1) ?? []).join(" "));
expect(bootstrapCommand.startsWith('sh -lc "')).toBe(true);
await listeners.value?.({ type: "stdout", data: "__RC_CODEX_BIN_MISSING__\r\n" });
const launched = await launchedPromise;
expect(launched).toBe(false);
expect(appStoreMock.notify).toHaveBeenCalledWith("warn", "服务器未装codex");
});
it("命令回显包含 READY 字面量但无 READY token 行时,不应提前判定成功", async () => {
const server: ServerProfile = {
id: "srv-codex-ready-literal",
name: "codex-ready-literal",
host: "127.0.0.1",
port: 22,
username: "gavin",
authType: "password",
projectPath: "~",
projectPresets: [],
tags: [],
timeoutSeconds: 20,
heartbeatSeconds: 15,
transportMode: "gateway"
};
serverStoreMock.servers = [server];
const store = useSessionStore();
await store.connect(server);
await listeners.value?.({ type: "connected" });
const launchedPromise = store.runCodex(server.projectPath, "workspace-write");
// 仅回显脚本字面量(包含 READY token 文本,但不是独立 token 行)。
await listeners.value?.({
type: "stdout",
data:
"__rc_codex_path_ok=1; __rc_codex_bin_ok=1; if [ \"$__rc_codex_path_ok\" -eq 1 ] && [ \"$__rc_codex_bin_ok\" -eq 1 ]; " +
"then printf '__RC_CODEX_READY__\\n'; codex --sandbox workspace-write; fi\r\n"
});
// 随后给出真实失败 token 行,应返回失败并提示未安装。
await listeners.value?.({ type: "stdout", data: "__RC_CODEX_BIN_MISSING__\r\n" });
const launched = await launchedPromise;
expect(launched).toBe(false);
expect(appStoreMock.notify).toHaveBeenCalledWith("warn", "服务器未装codex");
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,91 @@
import { defineStore } from "pinia";
import { computed, ref } from "vue";
import { verifyHostKey } from "@remoteconn/shared";
import type { GlobalSettings } from "@/types/app";
import { defaultSettings, normalizeGlobalSettings, resolveGatewayUrl, resolveGatewayToken } from "@/utils/defaults";
import { getKnownHosts, getSettings, setSettings, upsertKnownHost } from "@/services/storage/db";
/**
* 设置与主题管理。
*/
export const useSettingsStore = defineStore("settings", () => {
const settings = ref<GlobalSettings>(normalizeGlobalSettings(defaultSettings));
const knownHosts = ref<Record<string, string>>({});
const loaded = ref(false);
let bootstrapPromise: Promise<void> | null = null;
const themeVars = computed(() => ({
"--bg": settings.value.uiBgColor,
"--accent": settings.value.uiAccentColor,
"--text": settings.value.uiTextColor,
"--btn": settings.value.uiBtnColor,
"--shell-bg": settings.value.shellBgColor,
"--shell-text": settings.value.shellTextColor,
"--shell-accent": settings.value.shellAccentColor
}));
async function ensureBootstrapped(): Promise<void> {
if (loaded.value) return;
if (bootstrapPromise) {
await bootstrapPromise;
return;
}
bootstrapPromise = (async () => {
settings.value = normalizeGlobalSettings(await getSettings());
knownHosts.value = await getKnownHosts();
loaded.value = true;
})();
try {
await bootstrapPromise;
} finally {
bootstrapPromise = null;
}
}
async function bootstrap(): Promise<void> {
await ensureBootstrapped();
}
async function save(next: GlobalSettings): Promise<void> {
const normalized = normalizeGlobalSettings(next);
settings.value = normalized;
await setSettings({ ...normalized });
}
/** 运行时推导网关 URL不从持久化设置读取 */
const gatewayUrl = computed(() => resolveGatewayUrl(settings.value));
/** 运行时推导网关 Token不从持久化设置读取 */
const gatewayToken = computed(() => resolveGatewayToken(settings.value));
async function verifyAndPersistHostFingerprint(hostPort: string, incomingFingerprint: string): Promise<boolean> {
const result = await verifyHostKey({
hostPort,
incomingFingerprint,
policy: settings.value.hostKeyPolicy,
knownHosts: knownHosts.value,
onConfirm: async ({ hostPort: host, fingerprint, reason }) => {
return window.confirm(`${reason}\n主机: ${host}\n指纹: ${fingerprint}\n是否信任并继续`);
}
});
if (result.accepted && result.updated[hostPort]) {
knownHosts.value = { ...result.updated };
await upsertKnownHost(hostPort, result.updated[hostPort]);
}
return result.accepted;
}
return {
settings,
knownHosts,
themeVars,
gatewayUrl,
gatewayToken,
ensureBootstrapped,
bootstrap,
save,
verifyAndPersistHostFingerprint
};
});

View File

@@ -0,0 +1,94 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createPinia, setActivePinia } from "pinia";
import type { VoiceRecord } from "@/types/app";
const { dbState, dbMock } = vi.hoisted(() => {
const state = {
voiceRecords: [] as VoiceRecord[]
};
const cloneRecord = (item: VoiceRecord): VoiceRecord => ({
...item
});
const upsertRecord = (item: VoiceRecord): void => {
const index = state.voiceRecords.findIndex((row) => row.id === item.id);
if (index >= 0) {
state.voiceRecords[index] = cloneRecord(item);
} else {
state.voiceRecords.push(cloneRecord(item));
}
};
const db = {
voiceRecords: {
toArray: vi.fn(async () => state.voiceRecords.map((row) => cloneRecord(row))),
put: vi.fn(async (item: VoiceRecord) => {
upsertRecord(item);
}),
delete: vi.fn(async (id: string) => {
state.voiceRecords = state.voiceRecords.filter((row) => row.id !== id);
})
}
};
return {
dbState: state,
dbMock: db
};
});
vi.mock("@/services/storage/db", () => ({
db: dbMock
}));
import { useVoiceRecordStore } from "./voiceRecordStore";
describe("voiceRecordStore", () => {
beforeEach(() => {
setActivePinia(createPinia());
dbState.voiceRecords = [];
dbMock.voiceRecords.toArray.mockClear();
dbMock.voiceRecords.put.mockClear();
dbMock.voiceRecords.delete.mockClear();
});
it("启动后按 createdAt 倒序输出 latest", async () => {
dbState.voiceRecords = [
{ id: "r1", content: "one", createdAt: "2026-02-27T00:00:01.000Z", serverId: "s1" },
{ id: "r2", content: "two", createdAt: "2026-02-27T00:00:03.000Z", serverId: "s1" },
{ id: "r3", content: "three", createdAt: "2026-02-27T00:00:02.000Z", serverId: "s2" }
];
const store = useVoiceRecordStore();
await store.ensureBootstrapped();
expect(store.latest.map((item) => item.id)).toEqual(["r2", "r3", "r1"]);
});
it("addRecord 写入前会 trim空文本不入库", async () => {
const store = useVoiceRecordStore();
await store.ensureBootstrapped();
const empty = await store.addRecord(" ", "s1");
expect(empty).toBeNull();
expect(dbMock.voiceRecords.put).toHaveBeenCalledTimes(0);
const created = await store.addRecord(" hello world ", "s1");
expect(created).not.toBeNull();
expect(created?.content).toBe("hello world");
expect(dbMock.voiceRecords.put).toHaveBeenCalledTimes(1);
expect(store.latest[0]?.content).toBe("hello world");
});
it("removeRecord 会更新内存并持久化删除", async () => {
dbState.voiceRecords = [{ id: "r1", content: "one", createdAt: "2026-02-27T00:00:01.000Z", serverId: "s1" }];
const store = useVoiceRecordStore();
await store.ensureBootstrapped();
await store.removeRecord("r1");
expect(dbMock.voiceRecords.delete).toHaveBeenCalledWith("r1");
expect(store.records.length).toBe(0);
});
});

View File

@@ -0,0 +1,93 @@
import { defineStore } from "pinia";
import { computed, ref, toRaw } from "vue";
import type { VoiceRecord } from "@/types/app";
import { db } from "@/services/storage/db";
import { nowIso } from "@/utils/time";
/**
* 闪念记录存储与导出。
*/
export const useVoiceRecordStore = defineStore("voiceRecord", () => {
const records = ref<VoiceRecord[]>([]);
const loaded = ref(false);
let bootstrapPromise: Promise<void> | null = null;
const latest = computed(() => [...records.value].sort((a, b) => +new Date(b.createdAt) - +new Date(a.createdAt)));
async function ensureBootstrapped(): Promise<void> {
if (loaded.value) return;
if (bootstrapPromise) {
await bootstrapPromise;
return;
}
bootstrapPromise = (async () => {
records.value = await db.voiceRecords.toArray();
loaded.value = true;
})();
try {
await bootstrapPromise;
} finally {
bootstrapPromise = null;
}
}
async function bootstrap(): Promise<void> {
await ensureBootstrapped();
}
/**
* 统一做实体快照,避免 Vue Proxy 直接写入 IndexedDB 触发 DataCloneError。
*/
function toVoiceRecordEntity(item: VoiceRecord): VoiceRecord {
const raw = toRaw(item);
return {
id: String(raw.id),
content: String(raw.content),
createdAt: String(raw.createdAt),
serverId: String(raw.serverId ?? "")
};
}
async function addRecord(content: string, serverId = ""): Promise<VoiceRecord | null> {
const normalized = content.trim();
if (!normalized) {
return null;
}
const next: VoiceRecord = {
id: `voice-${crypto.randomUUID()}`,
content: normalized,
createdAt: nowIso(),
serverId: String(serverId || "")
};
records.value.unshift(next);
await db.voiceRecords.put(toVoiceRecordEntity(next));
return next;
}
async function removeRecord(recordId: string): Promise<void> {
const nextId = String(recordId || "");
if (!nextId) return;
records.value = records.value.filter((item) => item.id !== nextId);
await db.voiceRecords.delete(nextId);
}
function exportRecords(): string {
const rows = latest.value.map((item) => {
return [`## ${item.id}`, `- createdAt: ${item.createdAt}`, `- serverId: ${item.serverId || "--"}`, `- content:`, item.content].join(
"\n"
);
});
return [`# RemoteConn Voice Records Export ${nowIso()}`, "", ...rows].join("\n\n");
}
return {
records,
latest,
ensureBootstrapped,
bootstrap,
addRecord,
removeRecord,
exportRecords
};
});

1760
pxterm/src/styles/main.css Normal file

File diff suppressed because it is too large Load Diff

131
pxterm/src/types/app.ts Normal file
View File

@@ -0,0 +1,131 @@
import type {
CommandMarker,
CredentialRef,
HostKeyPolicy,
ResolvedCredential,
ServerProfile,
SessionLog,
SessionState,
ThemePreset
} from "@remoteconn/shared";
export type { ServerProfile, CredentialRef, SessionLog, SessionState, ResolvedCredential, CommandMarker, HostKeyPolicy, ThemePreset };
/**
* \u5168\u5c40\u8bbe\u7f6e\uff08\u57df\u6536\u655b\u7248\uff09\u3002
*/
export interface GlobalSettings {
// \u2500\u2500 UI \u5916\u89c2 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
uiThemePreset: ThemePreset;
/** 界面明暗模式,影响预设色板的 dark/light 变体选择 */
uiThemeMode: "dark" | "light";
uiAccentColor: string;
uiBgColor: string;
uiTextColor: string;
uiBtnColor: string;
// \u2500\u2500 Shell \u663e\u793a \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
shellThemePreset: ThemePreset;
/** 终端明暗模式,影响终端预设色板的 dark/light 变体选择 */
shellThemeMode: "dark" | "light";
shellBgColor: string;
shellTextColor: string;
shellAccentColor: string;
shellFontFamily: string;
shellFontSize: number;
shellLineHeight: number;
unicode11: boolean;
// \u2500\u2500 \u7ec8\u7aef\u7f13\u51b2 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
terminalBufferMaxEntries: number;
terminalBufferMaxBytes: number;
// \u2500\u2500 \u8fde\u63a5\u7b56\u7565 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
autoReconnect: boolean;
reconnectLimit: number;
hostKeyPolicy: HostKeyPolicy;
credentialMemoryPolicy: "remember" | "forget";
gatewayConnectTimeoutMs: number;
waitForConnectedTimeoutMs: number;
// \u2500\u2500 \u670d\u52a1\u5668\u914d\u7f6e\u9884\u586b \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
defaultAuthType: "password" | "key";
defaultPort: number;
defaultProjectPath: string;
defaultTimeoutSeconds: number;
defaultHeartbeatSeconds: number;
defaultTransportMode: "gateway" | string;
// \u2500\u2500 \u65e5\u5fd7 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
logRetentionDays: number;
maskSecrets: boolean;
// \u2500\u2500 \u5df2\u5e9f\u5f03\u5b57\u6bb5\uff08\u517c\u5bb9\u4fdd\u7559\uff0c\u4e0b\u4e00\u4e2a\u7248\u672c\u7a97\u53e3\u5220\u9664\uff09\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
/** @deprecated \u8bf7\u4f7f\u7528 shellFontFamily */
fontFamily?: string;
/** @deprecated \u8bf7\u4f7f\u7528 shellFontSize */
fontSize?: number;
/** @deprecated \u8bf7\u4f7f\u7528 shellLineHeight */
lineHeight?: number;
/** @deprecated \u8bf7\u4f7f\u7528 uiThemePreset / shellThemePreset */
themePreset?: string;
/** @deprecated \u8bf7\u4f7f\u7528 uiAccentColor / shellAccentColor */
accentColor?: string;
/** @deprecated \u8bf7\u4f7f\u7528 uiBgColor / shellBgColor */
bgColor?: string;
/** @deprecated \u8bf7\u4f7f\u7528 uiTextColor / shellTextColor */
textColor?: string;
/** @deprecated UI \u52a8\u6548\u53c2\u6570\u5df2\u79fb\u9664\uff0c\u6682\u4fdd\u7559\u907f\u514d\u65e7\u6570\u636e\u62a5\u9519 */
liquidAlpha?: number;
/** @deprecated UI \u52a8\u6548\u53c2\u6570\u5df2\u79fb\u9664\uff0c\u6682\u4fdd\u7559\u907f\u514d\u65e7\u6570\u636e\u62a5\u9519 */
blurRadius?: number;
/** @deprecated UI \u52a8\u6548\u53c2\u6570\u5df2\u79fb\u9664\uff0c\u6682\u4fdd\u7559\u907f\u514d\u65e7\u6570\u636e\u62a5\u9519 */
motionDuration?: number;
/** @deprecated \u7f51\u5173 URL \u5df2\u4ece\u7528\u6237\u914d\u7f6e\u79fb\u9664\uff0c\u6539\u7531\u6784\u5efa\u65f6\u6ce8\u5165\u6216\u8fd0\u7ef4\u4e0b\u53d1 */
gatewayUrl?: string;
/** @deprecated \u7f51\u5173 Token \u5df2\u4ece\u7528\u6237\u914d\u7f6e\u79fb\u9664\uff0c\u6539\u7531\u6784\u5efa\u65f6\u6ce8\u5165\u6216\u8fd0\u7ef4\u4e0b\u53d1 */
gatewayToken?: string;
}
/**
* 凭据密文。
*/
export interface EncryptedCredentialPayload {
id: string;
refId: string;
encrypted: string;
iv: string;
createdAt: string;
updatedAt: string;
}
export interface AppToast {
id: string;
level: "info" | "warn" | "error";
message: string;
}
export interface SessionCommandResult {
code: number;
stdout: string;
stderr: string;
}
export interface SessionContext {
state: SessionState;
currentServerId?: string;
currentSessionId?: string;
latencyMs?: number;
connectedAt?: string;
}
/**
* 闪念记录(语音输入区 record 按钮写入)。
*/
export interface VoiceRecord {
id: string;
content: string;
createdAt: string;
serverId: string;
}

View File

@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import { defaultSettings, normalizeGlobalSettings, resolveGatewayUrl, resolveGatewayToken } from "./defaults";
describe("default settings", () => {
it("包含 UI/Shell 域前缀字段、终端缓冲阈值", () => {
expect(defaultSettings.uiBgColor.length).toBeGreaterThan(0);
expect(defaultSettings.uiAccentColor.length).toBeGreaterThan(0);
expect(defaultSettings.shellFontFamily.length).toBeGreaterThan(0);
expect(["dark", "light"]).toContain(defaultSettings.shellThemeMode);
expect(defaultSettings.shellFontSize).toBeGreaterThanOrEqual(12);
expect(defaultSettings.terminalBufferMaxBytes).toBeGreaterThan(0);
expect(defaultSettings.terminalBufferMaxEntries).toBeGreaterThan(0);
});
it("resolveGatewayUrl 返回非空字符串", () => {
expect(resolveGatewayUrl().length).toBeGreaterThan(0);
});
it("resolveGatewayToken 返回非空字符串", () => {
expect(resolveGatewayToken().length).toBeGreaterThan(0);
});
it("可将旧版 fontFamily 迁移到 shellFontFamily", () => {
const normalized = normalizeGlobalSettings({
fontFamily: "Menlo",
terminalBufferMaxBytes: Number.NaN,
terminalBufferMaxEntries: 1
});
expect(normalized.shellFontFamily).toBe("Menlo");
expect(normalized.terminalBufferMaxBytes).toBe(defaultSettings.terminalBufferMaxBytes);
expect(normalized.terminalBufferMaxEntries).toBeGreaterThanOrEqual(200);
});
it("可将旧版颜色字段迁移到域前缀字段", () => {
const normalized = normalizeGlobalSettings({
bgColor: "#112233",
textColor: "#aabbcc",
accentColor: "#ff0000"
});
expect(normalized.uiBgColor).toBe("#112233");
expect(normalized.shellBgColor).toBe("#112233");
expect(normalized.uiTextColor).toBe("#aabbcc");
expect(normalized.shellTextColor).toBe("#aabbcc");
expect(normalized.uiAccentColor).toBe("#ff0000");
expect(normalized.shellAccentColor).toBe("#ff0000");
});
it("可将旧版 credentialMemoryPolicy=session 迁移到 forget", () => {
const normalized = normalizeGlobalSettings({
credentialMemoryPolicy: "session" as "remember"
});
expect(normalized.credentialMemoryPolicy).toBe("forget");
});
it("可将旧版 themePreset 映射到新 ThemePreset", () => {
const normalized = normalizeGlobalSettings({ themePreset: "sunrise" });
expect(normalized.uiThemePreset).toBe("焰岩");
expect(normalized.shellThemePreset).toBe("焰岩");
});
it("shellThemeMode 非法值会回退到 dark", () => {
const normalized = normalizeGlobalSettings({ shellThemeMode: "invalid" as "dark" });
expect(normalized.shellThemeMode).toBe("dark");
});
});

View File

@@ -0,0 +1,206 @@
import type { GlobalSettings, ThemePreset } from "@/types/app";
import { pickShellAccentColor } from "@remoteconn/shared";
const MIN_TERMINAL_BUFFER_MAX_ENTRIES = 200;
const MAX_TERMINAL_BUFFER_MAX_ENTRIES = 50_000;
const MIN_TERMINAL_BUFFER_MAX_BYTES = 64 * 1024;
const MAX_TERMINAL_BUFFER_MAX_BYTES = 64 * 1024 * 1024;
const DEFAULT_SHELL_BG_COLOR = "#192b4d";
const DEFAULT_SHELL_TEXT_COLOR = "#e6f0ff";
function normalizeInteger(value: number, fallback: number, min: number, max: number): number {
if (!Number.isFinite(value)) {
return fallback;
}
const normalized = Math.round(value);
if (normalized < min) {
return min;
}
if (normalized > max) {
return max;
}
return normalized;
}
/**
* 推导默认网关地址:
* 1) 若显式配置了 VITE_GATEWAY_URL优先使用
* 2) 浏览器环境下根据当前站点自动推导;
* 3) 默认走同域 80/443由反向代理承接
*/
function resolveDefaultGatewayUrl(): string {
const envGateway = import.meta.env.VITE_GATEWAY_URL?.trim();
if (envGateway) {
return envGateway;
}
if (typeof window === "undefined") {
return "ws://localhost:8787";
}
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const hostname = window.location.hostname;
return `${protocol}//${hostname}`;
}
export const defaultSettings: GlobalSettings = {
// ── UI 外观 ──────────────────────────────────────────────────────────────
uiThemePreset: "tide",
uiThemeMode: "dark" as "dark" | "light",
uiAccentColor: "#5bd2ff",
uiBgColor: "#192b4d",
uiTextColor: "#e6f0ff",
uiBtnColor: "#adb9cd",
// ── Shell 显示 ────────────────────────────────────────────────────────────
shellThemePreset: "tide",
shellThemeMode: "dark" as "dark" | "light",
shellBgColor: DEFAULT_SHELL_BG_COLOR,
shellTextColor: DEFAULT_SHELL_TEXT_COLOR,
shellAccentColor: pickShellAccentColor(DEFAULT_SHELL_BG_COLOR, DEFAULT_SHELL_TEXT_COLOR),
shellFontFamily: "JetBrains Mono",
shellFontSize: 15,
shellLineHeight: 1.4,
unicode11: true,
// ── 终端缓冲 ─────────────────────────────────────────────────────────────
terminalBufferMaxEntries: 5000,
terminalBufferMaxBytes: 4 * 1024 * 1024,
// ── 连接策略 ─────────────────────────────────────────────────────────────
autoReconnect: true,
reconnectLimit: 3,
hostKeyPolicy: "strict",
credentialMemoryPolicy: "remember",
gatewayConnectTimeoutMs: 12000,
waitForConnectedTimeoutMs: 15000,
// ── 服务器配置预填 ────────────────────────────────────────────────────────
defaultAuthType: "password",
defaultPort: 22,
defaultProjectPath: "~/workspace",
defaultTimeoutSeconds: 20,
defaultHeartbeatSeconds: 15,
defaultTransportMode: "gateway",
// ── 日志 ─────────────────────────────────────────────────────────────────
logRetentionDays: 30,
maskSecrets: true
};
/**
* 全局配置归一化:
* - 为历史版本缺失字段补齐默认值;
* - 将旧版废弃字段迁移到新域前缀字段(仅首次,不覆盖已有新字段);
* - 对终端缓冲阈值做边界收敛,避免 NaN/异常值导致缓冲策略失效。
*/
export function normalizeGlobalSettings(raw: Partial<GlobalSettings> | null | undefined): GlobalSettings {
const r = raw ?? {};
const merged: GlobalSettings = {
...defaultSettings,
...r
};
// ── 旧字段迁移(取旧值兜底,不覆盖已存在的新字段)────────────────────────
// fontFamily → shellFontFamily
if (!r.shellFontFamily && r.fontFamily) {
merged.shellFontFamily = r.fontFamily;
}
// fontSize → shellFontSize
if (!r.shellFontSize && r.fontSize !== undefined) {
merged.shellFontSize = r.fontSize;
}
// lineHeight → shellLineHeight
if (!r.shellLineHeight && r.lineHeight !== undefined) {
merged.shellLineHeight = r.lineHeight;
}
// accentColor → uiAccentColor / shellAccentColor
if (!r.uiAccentColor && r.accentColor) {
merged.uiAccentColor = r.accentColor;
}
if (!r.shellAccentColor && r.accentColor) {
merged.shellAccentColor = r.accentColor;
}
// bgColor → uiBgColor / shellBgColor
if (!r.uiBgColor && r.bgColor) {
merged.uiBgColor = r.bgColor;
}
if (!r.shellBgColor && r.bgColor) {
merged.shellBgColor = r.bgColor;
}
// textColor → uiTextColor / shellTextColor
if (!r.uiTextColor && r.textColor) {
merged.uiTextColor = r.textColor;
}
if (!r.shellTextColor && r.textColor) {
merged.shellTextColor = r.textColor;
}
// themePreset → uiThemePreset / shellThemePreset仅映射合法值
const legacyThemeMap: Record<string, ThemePreset> = {
tide: "tide",
mint: "tide", // mint 无对应新预设,兜底 tide
sunrise: "焰岩" // sunrise 映射到焰岩暖色系
};
if (!r.uiThemePreset && r.themePreset) {
merged.uiThemePreset = legacyThemeMap[r.themePreset] ?? "tide";
}
if (!r.shellThemePreset && r.themePreset) {
merged.shellThemePreset = legacyThemeMap[r.themePreset] ?? "tide";
}
// shellThemeMode 非法值兜底
if (merged.shellThemeMode !== "dark" && merged.shellThemeMode !== "light") {
merged.shellThemeMode = "dark";
}
// credentialMemoryPolicy: "session" → "forget"(旧枚举值 session 对应 forget
if ((merged.credentialMemoryPolicy as string) === "session") {
merged.credentialMemoryPolicy = "forget";
}
// uiThemeMode 非法值兜底
if (merged.uiThemeMode !== "dark" && merged.uiThemeMode !== "light") {
merged.uiThemeMode = "dark";
}
// ── 数值边界收敛 ─────────────────────────────────────────────────────────
merged.terminalBufferMaxEntries = normalizeInteger(
merged.terminalBufferMaxEntries,
defaultSettings.terminalBufferMaxEntries,
MIN_TERMINAL_BUFFER_MAX_ENTRIES,
MAX_TERMINAL_BUFFER_MAX_ENTRIES
);
merged.terminalBufferMaxBytes = normalizeInteger(
merged.terminalBufferMaxBytes,
defaultSettings.terminalBufferMaxBytes,
MIN_TERMINAL_BUFFER_MAX_BYTES,
MAX_TERMINAL_BUFFER_MAX_BYTES
);
return merged;
}
/**
* 根据设置推导 gatewayUrl运行时不持久化到用户设置
* 优先级:构建时 VITE_GATEWAY_URL > 旧版持久化数据残留 > 自动推导同域地址。
*/
export function resolveGatewayUrl(settings?: Pick<GlobalSettings, "gatewayUrl">): string {
// 兼容旧版持久化数据中残留的 gatewayUrl 字段
if (settings?.gatewayUrl) {
return settings.gatewayUrl;
}
return resolveDefaultGatewayUrl();
}
/**
* 根据设置推导 gatewayToken运行时不持久化到用户设置
* 优先级:构建时 VITE_GATEWAY_TOKEN > 旧版持久化数据残留 > 开发占位符。
*/
export function resolveGatewayToken(settings?: Pick<GlobalSettings, "gatewayToken">): string {
const envToken = import.meta.env.VITE_GATEWAY_TOKEN?.trim();
if (envToken) {
return envToken;
}
// 兼容旧版持久化数据中残留的 gatewayToken 字段
if (settings?.gatewayToken) {
return settings.gatewayToken;
}
return "remoteconn-dev-token";
}

View File

@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import {
clearDynamicImportRetryMark,
isDynamicImportFailure,
shouldRetryDynamicImportReload
} from "./dynamicImportGuard";
interface MemoryStorage {
map: Map<string, string>;
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}
function createMemoryStorage(): MemoryStorage {
const map = new Map<string, string>();
return {
map,
getItem: (key: string) => map.get(key) ?? null,
setItem: (key: string, value: string) => {
map.set(key, value);
},
removeItem: (key: string) => {
map.delete(key);
}
};
}
describe("dynamicImportGuard", () => {
it("可识别 Safari 的模块脚本导入失败文案", () => {
expect(isDynamicImportFailure(new TypeError("Importing a module script failed."))).toBe(true);
});
it("可识别 Chromium 的动态模块加载失败文案", () => {
expect(isDynamicImportFailure(new Error("Failed to fetch dynamically imported module"))).toBe(true);
});
it("非动态导入错误不应误判", () => {
expect(isDynamicImportFailure(new Error("network timeout"))).toBe(false);
expect(isDynamicImportFailure("")).toBe(false);
});
it("重试窗口内仅允许一次自动刷新", () => {
const storage = createMemoryStorage();
const now = 1_000_000;
expect(shouldRetryDynamicImportReload(storage, now, 15_000)).toBe(true);
expect(shouldRetryDynamicImportReload(storage, now + 5_000, 15_000)).toBe(false);
expect(shouldRetryDynamicImportReload(storage, now + 16_000, 15_000)).toBe(true);
});
it("清理重试标记后可重新允许刷新", () => {
const storage = createMemoryStorage();
const now = 2_000_000;
expect(shouldRetryDynamicImportReload(storage, now, 15_000)).toBe(true);
clearDynamicImportRetryMark(storage);
expect(shouldRetryDynamicImportReload(storage, now + 1_000, 15_000)).toBe(true);
});
});

View File

@@ -0,0 +1,118 @@
import type { Router } from "vue-router";
const RETRY_MARK_KEY = "remoteconn:dynamic-import-retry-at";
const RETRY_WINDOW_MS = 15_000;
interface SessionStorageLike {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}
/**
* 判断是否属于“动态模块脚本加载失败”:
* - Safari 常见文案Importing a module script failed
* - Chromium 常见文案Failed to fetch dynamically imported module
* - Firefox 常见文案error loading dynamically imported module。
*/
export function isDynamicImportFailure(error: unknown): boolean {
const message = extractErrorMessage(error).toLowerCase();
if (!message) {
return false;
}
const patterns = [
"importing a module script failed",
"failed to fetch dynamically imported module",
"error loading dynamically imported module"
];
return patterns.some((pattern) => message.includes(pattern));
}
/**
* 决定是否允许本次自动刷新:
* - 15 秒窗口内仅允许一次,避免 chunk 持续不可用时陷入刷新循环;
* - 超过窗口后允许再次尝试,适配短时发布抖动场景。
*/
export function shouldRetryDynamicImportReload(
storage: SessionStorageLike,
now = Date.now(),
retryWindowMs = RETRY_WINDOW_MS
): boolean {
const raw = storage.getItem(RETRY_MARK_KEY);
const lastRetryAt = Number(raw);
if (Number.isFinite(lastRetryAt) && now - lastRetryAt < retryWindowMs) {
return false;
}
storage.setItem(RETRY_MARK_KEY, String(now));
return true;
}
/**
* 在路由成功就绪后清理重试标记:
* - 让后续真实的新一轮发布故障仍可触发一次自动恢复;
* - 避免旧标记长期驻留导致后续无法自动恢复。
*/
export function clearDynamicImportRetryMark(storage: SessionStorageLike): void {
storage.removeItem(RETRY_MARK_KEY);
}
/**
* 安装动态导入失败恢复逻辑:
* - 监听 router.onError
* - 命中动态模块加载失败时自动刷新当前目标路由;
* - sessionStorage 不可用时降级为仅输出错误,不抛出二次异常。
*/
export function installDynamicImportRecovery(router: Router): void {
router.onError((error, to) => {
if (!isDynamicImportFailure(error)) {
return;
}
const storage = safeSessionStorage();
if (!storage) {
console.error("[router] 动态模块加载失败,且 sessionStorage 不可用", error);
return;
}
if (!shouldRetryDynamicImportReload(storage)) {
clearDynamicImportRetryMark(storage);
console.error("[router] 动态模块加载失败,已达单次自动恢复上限", error);
return;
}
const fallbackUrl = `${window.location.pathname}${window.location.search}${window.location.hash}`;
const target = typeof to.fullPath === "string" && to.fullPath.length > 0 ? to.fullPath : fallbackUrl;
window.location.assign(target);
});
void router.isReady().then(() => {
const storage = safeSessionStorage();
if (storage) {
clearDynamicImportRetryMark(storage);
}
});
}
function extractErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message ?? "";
}
if (typeof error === "string") {
return error;
}
if (error && typeof error === "object" && "message" in error) {
const maybeMessage = (error as { message?: unknown }).message;
return typeof maybeMessage === "string" ? maybeMessage : "";
}
return "";
}
function safeSessionStorage(): SessionStorageLike | null {
try {
return window.sessionStorage;
} catch {
return null;
}
}

View File

@@ -0,0 +1,131 @@
function asMessage(error: unknown): string {
if (error instanceof Error) {
return String(error.message || "");
}
if (typeof error === "string") {
return error;
}
try {
return JSON.stringify(error);
} catch {
return "";
}
}
function normalizeText(input: string): string {
return input.trim().toLowerCase();
}
export function toFriendlyDisconnectReason(reason: string | undefined): string {
const raw = String(reason ?? "").trim();
if (!raw) return "连接已关闭";
const map: Record<string, string> = {
manual: "你已主动断开连接",
switch: "切换连接目标,已断开当前会话",
host_key_rejected: "主机指纹未被信任,连接已断开",
auth_failed: "认证失败,连接被服务器拒绝",
rate_limit: "连接过于频繁,请稍后重试",
shell_closed: "远端 Shell 已关闭",
connection_closed: "服务器连接已关闭",
ws_error: "网关连接异常",
ws_closed: "网关连接已断开",
ws_peer_normal_close: "客户端已关闭连接",
unknown: "连接已关闭"
};
return map[raw] ?? `连接已关闭(${raw}`;
}
export function toFriendlyConnectionError(error: unknown): string {
const message = asMessage(error);
const lower = normalizeText(message);
if (lower.includes("rate_limit") || message.includes("连接过于频繁")) {
return "连接过于频繁,请稍后重试。";
}
if (lower.includes("auth_failed") || message.includes("token 无效")) {
return "网关鉴权失败,请联系管理员检查网关令牌。";
}
if (message.includes("SSH 认证失败")) {
return "SSH 认证失败。请检查账号/凭据,若服务器仅允许公钥认证,请改用私钥方式。";
}
if (message.includes("主机指纹") && message.includes("信任")) {
return "主机指纹校验未通过,请确认主机身份后重试。";
}
if (message.includes("Timed out while waiting for handshake") || message.includes("连接超时") || lower.includes("timeout")) {
return "连接超时。请检查服务器地址、端口和网络连通性。";
}
if (message.includes("无法连接网关") || lower.includes("ws_closed") || lower.includes("websocket")) {
return "无法连接网关,请检查网关地址、服务状态与网络策略。";
}
if (message.includes("凭据读取失败")) {
return "凭据读取失败,请在服务器设置页重新保存后重试。";
}
if (!message) {
return "连接失败,请稍后重试。";
}
return message;
}
export function toFriendlyError(error: unknown): string {
const message = asMessage(error);
const lower = normalizeText(message);
if (!message) {
return "操作失败,请稍后重试。";
}
if (
lower.includes("ws_") ||
lower.includes("websocket") ||
lower.includes("auth_failed") ||
lower.includes("rate_limit") ||
message.includes("连接") ||
message.includes("网关") ||
message.includes("SSH")
) {
return toFriendlyConnectionError(message);
}
if (message.includes("密码不能为空") || message.includes("私钥内容不能为空") || message.includes("证书模式下")) {
return message;
}
if (lower.includes("json") && (lower.includes("parse") || lower.includes("unexpected token"))) {
return "配置内容不是有效的 JSON请检查格式后重试。";
}
if (message.includes("会话未连接")) {
return "当前会话未连接,请先建立连接。";
}
if (message.includes("凭据读取失败")) {
return "凭据读取失败,请在服务器设置页重新保存后重试。";
}
if (message.includes("未找到凭据内容") || message.includes("凭据引用不存在")) {
return "未找到可用凭据,请在服务器设置页重新填写并保存。";
}
return message;
}
export function formatActionError(action: string, error: unknown): string {
const detail = toFriendlyError(error);
return `${action}${detail}`;
}
export function toToastTitle(level: "info" | "warn" | "error"): string {
if (level === "error") return "错误";
if (level === "warn") return "注意";
return "提示";
}

View File

@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { readRememberedEnum, writeRememberedEnum } from "./rememberedState";
describe("rememberedState", () => {
it("可读取允许列表内的持久化值", () => {
const storage = {
getItem: (key: string) => (key === "k" ? "shell" : null)
} as unknown as Storage;
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: storage
});
const value = readRememberedEnum("k", ["ui", "shell", "log"] as const);
expect(value).toBe("shell");
});
it("读取到不在允许列表中的值时返回 null", () => {
const storage = {
getItem: () => "unknown"
} as unknown as Storage;
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: storage
});
const value = readRememberedEnum("k", ["ui", "shell", "log"] as const);
expect(value).toBeNull();
});
it("localStorage 不可用时应静默降级", () => {
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
get() {
throw new Error("storage blocked");
}
});
expect(readRememberedEnum("k", ["ui", "shell"] as const)).toBeNull();
expect(() => writeRememberedEnum("k", "ui")).not.toThrow();
});
});

View File

@@ -0,0 +1,20 @@
export function readRememberedEnum<T extends string>(
storageKey: string,
allowedValues: readonly T[]
): T | null {
try {
const raw = localStorage.getItem(storageKey);
if (!raw) return null;
return allowedValues.includes(raw as T) ? (raw as T) : null;
} catch {
return null;
}
}
export function writeRememberedEnum(storageKey: string, value: string): void {
try {
localStorage.setItem(storageKey, value);
} catch {
// 忽略本地存储不可用场景(如隐私模式限制)
}
}

17
pxterm/src/utils/time.ts Normal file
View File

@@ -0,0 +1,17 @@
export function nowIso(): string {
return new Date().toISOString();
}
export function formatDateTime(input: string | Date): string {
const date = input instanceof Date ? input : new Date(input);
return date.toLocaleString("zh-CN", { hour12: false });
}
export function formatDurationMs(ms: number): string {
if (!Number.isFinite(ms) || ms <= 0) return "0s";
const seconds = Math.round(ms / 1000);
const m = Math.floor(seconds / 60);
const s = seconds % 60;
if (m <= 0) return `${s}s`;
return `${m}m ${s}s`;
}

View File

@@ -0,0 +1,23 @@
import { onMounted, watch, type Ref } from "vue";
import { readRememberedEnum, writeRememberedEnum } from "@/utils/rememberedState";
interface UseRememberedEnumRefOptions<T extends string> {
storageKey: string;
allowedValues: readonly T[];
target: Ref<T>;
}
export function useRememberedEnumRef<T extends string>(options: UseRememberedEnumRefOptions<T>): void {
const { storageKey, allowedValues, target } = options;
onMounted(() => {
const saved = readRememberedEnum(storageKey, allowedValues);
if (saved) {
target.value = saved;
}
});
watch(target, (value) => {
writeRememberedEnum(storageKey, value);
});
}

View File

@@ -0,0 +1,506 @@
<template>
<section class="page-root server-manager-page">
<div class="page-toolbar server-manager-toolbar">
<div class="toolbar-left">
<button class="icon-btn" type="button" title="新增服务器" aria-label="新增服务器" @click="create">
<span class="icon-mask" style="--icon: url('/icons/create.svg')" aria-hidden="true"></span>
</button>
<button
class="icon-btn"
type="button"
title="删除已选服务器"
aria-label="删除已选服务器"
:disabled="selectedServerIds.length === 0"
@click="remove"
>
<span class="icon-mask" style="--icon: url('/icons/delete.svg')" aria-hidden="true"></span>
</button>
<button
class="icon-btn"
type="button"
:title="isAllSelected ? '取消全选服务器' : '全选服务器'"
:aria-label="isAllSelected ? '取消全选服务器' : '全选服务器'"
:disabled="!serverStore.servers.length"
@click="toggleSelectAllServers"
>
<span class="icon-mask" style="--icon: url('/icons/selectall.svg')" aria-hidden="true"></span>
</button>
</div>
<div class="toolbar-spacer"></div>
<h2 class="page-title">我的服务器</h2>
</div>
<div class="server-manager-content">
<div class="server-search-wrap">
<div class="server-search-shell">
<input v-model="searchKeyword" class="server-search-input" type="search" placeholder="搜索服务器" />
<button class="server-search-btn" type="button" title="搜索服务器" aria-label="搜索服务器">
<span class="icon-mask" style="--icon: url('/icons/search.svg')" aria-hidden="true"></span>
</button>
</div>
</div>
<div class="server-list-scroll surface-scroll">
<div class="server-list-stack">
<article
v-for="item in filteredServers"
:key="item.id"
class="server-list-row"
:class="{
active: item.id === serverStore.selectedServerId,
'is-dragging': draggingServerId === item.id,
'is-drag-over': dragOverServerId === item.id
}"
:data-server-id="item.id"
:style="dragRowStyle(item.id)"
>
<div class="server-row-check" @click.stop>
<input
:id="`server-check-${item.id}`"
class="server-check-input"
type="checkbox"
:checked="selectedServerIds.includes(item.id)"
@change="onServerCheckChanged(item.id, $event)"
/>
</div>
<div class="server-info server-info-clickable" @click="openServerSettings(item.id)">
<div class="server-info-top">
<p class="server-name">{{ item.name }}</p>
<div class="server-row-actions" @click.stop>
<button
class="server-ai-btn"
type="button"
:disabled="isConnectActionBlocked(item.id)"
title="AI 快速启动"
aria-label="AI 快速启动"
@click.stop="openCodexForServer(item)"
>
<span class="icon-mask server-ai-icon" :style="{ '--icon': `url(${aiIcon})` }" aria-hidden="true"></span>
</button>
<button
class="connect-icon-btn"
:class="{
'is-connected': isServerConnected(item.id) || isServerResumable(item.id),
'is-connecting': isServerConnecting(item.id)
}"
type="button"
:disabled="isConnectActionBlocked(item.id)"
:title="connectButtonTitle(item.id)"
:aria-label="connectButtonTitle(item.id)"
@click.stop="quickConnect(item)"
>
<span class="icon-mask" style="--icon: url('/icons/connect.svg')" aria-hidden="true"></span>
</button>
<button
class="server-move-btn"
type="button"
:disabled="!canDragReorder(item.id)"
title="拖拽手柄调整顺序"
aria-label="拖拽调整顺序"
@pointerdown.stop="onMoveHandlePointerDown(item.id, $event)"
>
<span class="icon-mask server-move-icon" :style="{ '--icon': `url(${moveIcon})` }" aria-hidden="true"></span>
</button>
</div>
</div>
<div class="server-info-meta">
<p class="server-main">{{ item.username }}@{{ item.host }}:{{ item.port }}</p>
<p class="server-auth">{{ item.authType }}</p>
</div>
<p class="server-recent">最近连接: {{ formatLastConnected(item.lastConnectedAt) }}</p>
<div v-if="resolvedTags(item).length > 0" class="server-tags">
<span v-for="tag in resolvedTags(item)" :key="`${item.id}-${tag}`" class="server-tag">{{ tag }}</span>
</div>
</div>
</article>
<p v-if="filteredServers.length === 0" class="server-empty-tip">暂无匹配服务器</p>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import type { ServerProfile } from "@/types/app";
import { useServerStore } from "@/stores/serverStore";
import { useSessionStore } from "@/stores/sessionStore";
import { useAppStore } from "@/stores/appStore";
import { formatActionError } from "@/utils/feedback";
const serverStore = useServerStore();
const sessionStore = useSessionStore();
const appStore = useAppStore();
const router = useRouter();
const selectedServerIds = ref<string[]>([]);
const searchKeyword = ref("");
const connectingServerId = ref("");
const waitingConnectStates = new Set(["connecting", "auth_pending", "reconnecting"]);
const draggingServerId = ref("");
const dragOverServerId = ref("");
const dragPointerId = ref<number | null>(null);
const dragStartClientX = ref(0);
const dragStartClientY = ref(0);
const dragOffsetX = ref(0);
const dragOffsetY = ref(0);
const aiIcon = "/assets/icons/ai.svg";
const moveIcon = "/assets/icons/move.svg";
onMounted(async () => {
await Promise.all([serverStore.ensureBootstrapped(), sessionStore.ensureBootstrapped()]);
});
onBeforeUnmount(() => {
teardownPointerDragListeners();
resetDragState();
});
const filteredServers = computed(() => {
// 服务器管理页保留搜索框,并按名称/地址/用户/标签进行过滤。
const keyword = searchKeyword.value.trim().toLowerCase();
if (!keyword) return serverStore.servers;
return serverStore.servers.filter((item) => {
const haystack = [item.name, item.host, item.username, String(item.port), item.authType, resolvedTags(item).join(" ")]
.join(" ")
.toLowerCase();
return haystack.includes(keyword);
});
});
const isAllSelected = computed(
() =>
serverStore.servers.length > 0 &&
selectedServerIds.value.length === serverStore.servers.length &&
serverStore.servers.every((item) => selectedServerIds.value.includes(item.id))
);
watch(
() => serverStore.servers.map((item) => item.id),
(ids) => {
selectedServerIds.value = selectedServerIds.value.filter((id) => ids.includes(id));
},
{ immediate: true }
);
async function create(): Promise<void> {
selectedServerIds.value = [];
await router.push("/server/new/settings");
}
async function remove(): Promise<void> {
const targets = [...selectedServerIds.value];
if (targets.length === 0) return;
if (!window.confirm(`确认删除已选服务器(${targets.length} 台)吗?`)) return;
for (const serverId of targets) {
await serverStore.deleteServer(serverId);
}
selectedServerIds.value = [];
appStore.notify("info", `已删除 ${targets.length} 台服务器`);
}
function toggleServerChecked(serverId: string, checked: boolean): void {
if (checked) {
if (!selectedServerIds.value.includes(serverId)) {
selectedServerIds.value = [...selectedServerIds.value, serverId];
}
return;
}
selectedServerIds.value = selectedServerIds.value.filter((id) => id !== serverId);
}
function onServerCheckChanged(serverId: string, event: Event): void {
const target = event.target as HTMLInputElement | null;
toggleServerChecked(serverId, target?.checked ?? false);
}
function toggleSelectAllServers(): void {
if (isAllSelected.value) {
selectedServerIds.value = [];
return;
}
selectedServerIds.value = serverStore.servers.map((item) => item.id);
}
async function quickConnect(server: ServerProfile): Promise<void> {
if (isConnectActionBlocked(server.id)) return;
setActiveServer(server.id);
// 已连接同一服务器:直接进入终端复用现有会话,不做断开重连。
if (isServerConnected(server.id)) {
await router.push("/terminal");
return;
}
connectingServerId.value = server.id;
try {
appStore.notify("info", `正在连接: ${server.username}@${server.host}:${server.port}`);
const connectTask = sessionStore.connect({
...server,
projectPresets: [...server.projectPresets],
tags: [...server.tags]
});
await router.push("/terminal");
await connectTask;
} catch (error) {
appStore.notify("error", formatActionError("连接失败", error));
} finally {
connectingServerId.value = "";
}
}
async function openCodexForServer(server: ServerProfile): Promise<void> {
if (isConnectActionBlocked(server.id)) return;
setActiveServer(server.id);
if (isServerConnected(server.id)) {
await router.push({ path: "/terminal", query: { openCodex: "1" } });
return;
}
connectingServerId.value = server.id;
try {
appStore.notify("info", `正在连接: ${server.username}@${server.host}:${server.port}`);
const connectTask = sessionStore.connect({
...server,
projectPresets: [...server.projectPresets],
tags: [...server.tags]
});
await router.push({ path: "/terminal", query: { openCodex: "1" } });
await connectTask;
} catch (error) {
appStore.notify("error", formatActionError("连接失败", error));
} finally {
connectingServerId.value = "";
}
}
function canDragReorder(serverId: string): boolean {
return serverStore.servers.some((item) => item.id === serverId);
}
function resetDragState(): void {
draggingServerId.value = "";
dragOverServerId.value = "";
dragPointerId.value = null;
dragStartClientX.value = 0;
dragStartClientY.value = 0;
dragOffsetX.value = 0;
dragOffsetY.value = 0;
}
/**
* 仅在当前被拖拽的卡片上注入位移变量,用于视觉跟手。
*/
function dragRowStyle(serverId: string): Record<string, string> | undefined {
if (draggingServerId.value !== serverId) {
return undefined;
}
return {
"--drag-x": `${dragOffsetX.value}px`,
"--drag-y": `${dragOffsetY.value}px`
};
}
function teardownPointerDragListeners(): void {
if (typeof window === "undefined") {
return;
}
window.removeEventListener("pointermove", onPointerDragMove);
window.removeEventListener("pointerup", onPointerDragUp);
window.removeEventListener("pointercancel", onPointerDragCancel);
if (typeof document !== "undefined") {
document.removeEventListener("pointermove", onPointerDragMove, true);
document.removeEventListener("pointerup", onPointerDragUp, true);
document.removeEventListener("pointercancel", onPointerDragCancel, true);
}
}
function setupPointerDragListeners(): void {
if (typeof window === "undefined") {
return;
}
teardownPointerDragListeners();
// 部分 WebView/浏览器对 window 冒泡阶段的 pointer 事件投递不稳定,这里同时监听
// window 与 document(capture) 做兼容兜底,确保拖拽过程可持续接收 move/up。
window.addEventListener("pointermove", onPointerDragMove, { passive: false });
window.addEventListener("pointerup", onPointerDragUp, { passive: false });
window.addEventListener("pointercancel", onPointerDragCancel, { passive: false });
if (typeof document !== "undefined") {
document.addEventListener("pointermove", onPointerDragMove, { passive: false, capture: true });
document.addEventListener("pointerup", onPointerDragUp, { passive: false, capture: true });
document.addEventListener("pointercancel", onPointerDragCancel, { passive: false, capture: true });
}
}
function onMoveHandlePointerDown(serverId: string, event: PointerEvent): void {
if (!canDragReorder(serverId)) {
return;
}
event.preventDefault();
draggingServerId.value = serverId;
dragOverServerId.value = "";
dragPointerId.value = event.pointerId;
dragStartClientX.value = event.clientX;
dragStartClientY.value = event.clientY;
dragOffsetX.value = 0;
dragOffsetY.value = 0;
setupPointerDragListeners();
}
function resolveCardIdByPoint(clientX: number, clientY: number): string {
if (typeof document === "undefined") {
return "";
}
const target = document.elementFromPoint(clientX, clientY) as HTMLElement | null;
const card = target?.closest<HTMLElement>("[data-server-id]");
if (card?.dataset.serverId && card.dataset.serverId !== draggingServerId.value) {
return card.dataset.serverId;
}
// elementFromPoint 在少数环境可能命中浮层/伪元素,兜底按 y 坐标命中行元素。
const rows = Array.from(document.querySelectorAll<HTMLElement>(".server-list-row[data-server-id]"));
for (const row of rows) {
if (row.dataset.serverId === draggingServerId.value) {
continue;
}
const rect = row.getBoundingClientRect();
if (clientY >= rect.top && clientY <= rect.bottom) {
return row.dataset.serverId ?? "";
}
}
return "";
}
function onPointerDragMove(event: PointerEvent): void {
if (!draggingServerId.value || dragPointerId.value !== event.pointerId) {
return;
}
event.preventDefault();
// 记录手势位移,让卡片产生“跟手移动”的视觉反馈。
dragOffsetX.value = event.clientX - dragStartClientX.value;
dragOffsetY.value = event.clientY - dragStartClientY.value;
const targetServerId = resolveCardIdByPoint(event.clientX, event.clientY);
if (!targetServerId || targetServerId === draggingServerId.value) {
dragOverServerId.value = "";
return;
}
dragOverServerId.value = targetServerId;
}
async function applyReorderByIds(sourceServerId: string, targetServerId: string): Promise<void> {
const nextIds = serverStore.servers.map((item) => item.id);
const sourceIndex = nextIds.indexOf(sourceServerId);
const targetIndex = nextIds.indexOf(targetServerId);
if (sourceIndex < 0 || targetIndex < 0) {
return;
}
// 拖拽落到目标项时:
// - 向下拖拽:插入到目标项后方;
// - 向上拖拽:插入到目标项前方。
// 这样可避免“拖到相邻下一项却无变化”的问题。
nextIds.splice(sourceIndex, 1);
const normalizedTargetIndex = nextIds.indexOf(targetServerId);
if (normalizedTargetIndex < 0) {
return;
}
const insertIndex = sourceIndex < targetIndex ? normalizedTargetIndex + 1 : normalizedTargetIndex;
nextIds.splice(insertIndex, 0, sourceServerId);
try {
await serverStore.applyServerOrder(nextIds);
} catch (error) {
appStore.notify("error", formatActionError("调整服务器顺序失败", error));
}
}
async function onPointerDragUp(event: PointerEvent): Promise<void> {
if (dragPointerId.value !== event.pointerId) {
return;
}
event.preventDefault();
const sourceServerId = draggingServerId.value;
const targetServerId = dragOverServerId.value || resolveCardIdByPoint(event.clientX, event.clientY);
teardownPointerDragListeners();
resetDragState();
if (!sourceServerId || !targetServerId || sourceServerId === targetServerId) {
return;
}
await applyReorderByIds(sourceServerId, targetServerId);
}
function onPointerDragCancel(event: PointerEvent): void {
if (dragPointerId.value !== event.pointerId) {
return;
}
event.preventDefault();
teardownPointerDragListeners();
resetDragState();
}
function isServerConnected(serverId: string): boolean {
return sessionStore.state === "connected" && sessionStore.currentServerId === serverId;
}
function isServerResumable(serverId: string): boolean {
return sessionStore.isServerResumable(serverId);
}
function isServerConnecting(serverId: string): boolean {
return waitingConnectStates.has(sessionStore.state) && sessionStore.currentServerId === serverId;
}
function isConnectActionBlocked(serverId: string): boolean {
if (isServerConnected(serverId)) {
return false;
}
if (connectingServerId.value) {
return true;
}
return waitingConnectStates.has(sessionStore.state);
}
function connectButtonTitle(serverId: string): string {
if (isServerConnected(serverId)) {
return "进入当前会话";
}
if (isServerResumable(serverId)) {
return "恢复会话";
}
if (isServerConnecting(serverId)) {
return "连接中";
}
return "连接服务器";
}
function setActiveServer(serverId: string): void {
serverStore.selectedServerId = serverId;
}
async function openServerSettings(serverId: string): Promise<void> {
if (!serverId) return;
setActiveServer(serverId);
await router.push(`/server/${encodeURIComponent(serverId)}/settings`);
}
function formatLastConnected(lastConnectedAt?: string): string {
return lastConnectedAt || "无连接";
}
function resolvedTags(server: ServerProfile): string[] {
if (server.tags.length > 0) return server.tags;
// 对齐原型示例:老数据未配置 tags 时,按服务器名称提供展示级回退标签。
if (server.name.includes("生产")) return ["prod", "beijing"];
if (server.name.includes("测试")) return ["test", "杭州"];
return [];
}
</script>

View File

@@ -0,0 +1,108 @@
<template>
<section class="page-root logs-page">
<div class="page-toolbar">
<div class="toolbar-left">
<button
class="icon-btn"
type="button"
title="返回上一页"
aria-label="返回上一页"
:disabled="!canGoBack"
@click="goBack"
>
<span class="icon-mask" style="--icon: url('/icons/back.svg')" aria-hidden="true"></span>
</button>
</div>
<div class="toolbar-spacer"></div>
<h2 class="page-title">日志</h2>
</div>
<article class="surface-panel">
<div class="actions">
<button class="btn" @click="download">导出脱敏日志</button>
<span class="settings-save-status"> {{ totalLogs }} </span>
</div>
<div class="surface-scroll list-stack">
<article v-for="item in pagedLogs" :key="item.sessionId" class="log-item">
<div class="item-title">{{ item.sessionId }} · {{ item.status }}</div>
<div class="item-sub">server: {{ item.serverId }}</div>
<div class="item-sub">{{ item.startAt }} -> {{ item.endAt ?? '--' }}</div>
<div class="item-sub">commands: {{ item.commandMarkers.length }} · error: {{ item.error ?? '-' }}</div>
</article>
<p v-if="pagedLogs.length === 0" class="server-empty-tip">暂无日志</p>
</div>
<div class="records-pagination">
<button class="btn" type="button" :disabled="currentPage <= 1" @click="currentPage -= 1">上一页</button>
<span class="records-pagination-text"> {{ currentPage }} / {{ totalPages }} </span>
<button class="btn" type="button" :disabled="currentPage >= totalPages" @click="currentPage += 1">下一页</button>
</div>
</article>
</section>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { useLogStore } from "@/stores/logStore";
const logStore = useLogStore();
const router = useRouter();
const canGoBack = ref(false);
const pageSize = 15;
const currentPage = ref(1);
const sortedLogs = computed(() => [...logStore.logs].sort((a, b) => +new Date(b.startAt) - +new Date(a.startAt)));
const totalLogs = computed(() => sortedLogs.value.length);
const totalPages = computed(() => Math.max(1, Math.ceil(totalLogs.value / pageSize)));
const pagedLogs = computed(() => {
const start = (currentPage.value - 1) * pageSize;
const end = start + pageSize;
return sortedLogs.value.slice(start, end);
});
/**
* 统一“返回”语义:仅允许返回历史上一页。
*/
function syncCanGoBack(): void {
if (typeof window === "undefined") {
canGoBack.value = false;
return;
}
canGoBack.value = window.history.length > 1;
}
onMounted(async () => {
syncCanGoBack();
window.addEventListener("popstate", syncCanGoBack);
await logStore.ensureBootstrapped();
});
onBeforeUnmount(() => {
window.removeEventListener("popstate", syncCanGoBack);
});
watch(totalPages, (nextPages) => {
if (currentPage.value > nextPages) {
currentPage.value = nextPages;
}
});
function download(): void {
const content = logStore.exportLogs(true);
const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `remoteconn-logs-${Date.now()}.txt`;
document.body.append(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
async function goBack(): Promise<void> {
if (!canGoBack.value) {
return;
}
router.back();
}
</script>

View File

@@ -0,0 +1,113 @@
<template>
<section class="page-root plugins-page">
<div class="page-toolbar">
<div class="toolbar-left">
<button
class="icon-btn"
type="button"
title="返回上一页"
aria-label="返回上一页"
:disabled="!canGoBack"
@click="goBack"
>
<span class="icon-mask" style="--icon: url('/icons/back.svg')" aria-hidden="true"></span>
</button>
</div>
<div class="toolbar-spacer"></div>
<h2 class="page-title">插件管理</h2>
</div>
<article class="surface-panel surface-scroll">
<h3>插件列表</h3>
<div class="list-stack">
<article v-for="item in pluginStore.records" :key="item.id" class="plugin-item">
<div class="item-title">{{ item.id }} · {{ item.status }}</div>
<div class="item-sub">errorCount: {{ item.errorCount }} · {{ item.lastError || '-' }}</div>
<div class="actions">
<button class="btn" @click="pluginStore.enable(item.id)">启用</button>
<button class="btn" @click="pluginStore.disable(item.id)">禁用</button>
<button class="btn" @click="pluginStore.reload(item.id)">重载</button>
<button class="btn danger" @click="pluginStore.remove(item.id)">移除</button>
</div>
</article>
</div>
<h3>导入插件 JSON</h3>
<textarea v-model="pluginJson" class="textarea" rows="8" placeholder='[{"manifest":...,"mainJs":"...","stylesCss":"..."}]' />
<div class="actions">
<button class="btn" @click="importPlugin">导入</button>
<button class="btn" @click="exportPlugin">导出全部</button>
</div>
<h3>运行时日志</h3>
<pre class="log-box">{{ pluginStore.runtimeLogs.join('\n') }}</pre>
</article>
</section>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { usePluginStore } from "@/stores/pluginStore";
import { useAppStore } from "@/stores/appStore";
import { formatActionError } from "@/utils/feedback";
const pluginStore = usePluginStore();
const appStore = useAppStore();
const router = useRouter();
const canGoBack = ref(false);
const pluginJson = ref("");
/**
* 统一“返回”语义:仅允许返回历史上一页。
*/
function syncCanGoBack(): void {
if (typeof window === "undefined") {
canGoBack.value = false;
return;
}
canGoBack.value = window.history.length > 1;
}
onMounted(async () => {
syncCanGoBack();
window.addEventListener("popstate", syncCanGoBack);
await pluginStore.ensureBootstrapped();
});
onBeforeUnmount(() => {
window.removeEventListener("popstate", syncCanGoBack);
});
async function importPlugin(): Promise<void> {
if (!pluginJson.value.trim()) return;
try {
await pluginStore.importJson(pluginJson.value);
pluginJson.value = "";
appStore.notify("info", "插件导入成功");
} catch (error) {
appStore.notify("error", formatActionError("导入失败", error));
}
}
async function exportPlugin(): Promise<void> {
const raw = await pluginStore.exportJson();
const blob = new Blob([raw], { type: "application/json;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `remoteconn-plugins-${Date.now()}.json`;
document.body.append(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
async function goBack(): Promise<void> {
if (!canGoBack.value) {
return;
}
router.back();
}
</script>

View File

@@ -0,0 +1,253 @@
<template>
<section class="page-root records-page">
<div class="page-toolbar records-toolbar">
<div class="toolbar-left">
<button
class="icon-btn"
type="button"
title="返回上一页"
aria-label="返回上一页"
:disabled="!canGoBack"
@click="goBack"
>
<span class="icon-mask" style="--icon: url('/icons/back.svg')" aria-hidden="true"></span>
</button>
</div>
<div class="toolbar-spacer"></div>
<h2 class="page-title">闪念清单</h2>
</div>
<article class="surface-panel records-panel">
<div class="actions records-actions">
<button class="btn" type="button" @click="download">导出闪念</button>
<span class="settings-save-status"> {{ totalRecords }} </span>
</div>
<div class="surface-scroll records-list-scroll" @pointerdown="closeSwipedRecord">
<ul v-if="pagedRecords.length > 0" class="records-list">
<li
v-for="item in pagedRecords"
:key="item.id"
class="records-item-shell"
:class="{ 'records-item-shell-delete-visible': isDeleteVisible(item.id) }"
>
<button
class="records-item-delete-mobile"
type="button"
title="删除闪念"
aria-label="删除闪念"
@pointerdown.stop
@click.stop="removeRecord(item.id)"
>
删除
</button>
<article
class="records-item"
:style="swipeItemStyle(item.id)"
@pointerdown="onRecordPointerDown(item.id, $event)"
@pointermove="onRecordPointerMove(item.id, $event)"
@pointerup="onRecordPointerUp(item.id, $event)"
@pointercancel="onRecordPointerCancel(item.id, $event)"
@lostpointercapture="onRecordLostPointerCapture(item.id, $event)"
>
<header class="records-item-header">
<time class="records-item-time">{{ formatRecordTime(item.createdAt) }}</time>
</header>
<p class="records-item-content">{{ item.content }}</p>
</article>
</li>
</ul>
<p v-else class="records-empty-tip">暂无闪念记录</p>
</div>
<div class="records-pagination">
<button class="btn" type="button" :disabled="currentPage <= 1" @click="currentPage -= 1">上一页</button>
<span class="records-pagination-text"> {{ currentPage }} / {{ totalPages }} </span>
<button class="btn" type="button" :disabled="currentPage >= totalPages" @click="currentPage += 1">下一页</button>
</div>
</article>
</section>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { useVoiceRecordStore } from "@/stores/voiceRecordStore";
import { useAppStore } from "@/stores/appStore";
const voiceRecordStore = useVoiceRecordStore();
const appStore = useAppStore();
const router = useRouter();
const canGoBack = ref(false);
const pageSize = 15;
const currentPage = ref(1);
const totalRecords = computed(() => voiceRecordStore.latest.length);
const totalPages = computed(() => Math.max(1, Math.ceil(totalRecords.value / pageSize)));
const pagedRecords = computed(() => {
const start = (currentPage.value - 1) * pageSize;
const end = start + pageSize;
return voiceRecordStore.latest.slice(start, end);
});
// 删除按钮可见宽度(像素)。
const MOBILE_DELETE_WIDTH_PX = 72;
// 内容卡片与删除按钮之间的固定间隙(像素)。
const MOBILE_DELETE_GAP_PX = 1;
// 左滑完全展开后的总位移 = 按钮宽度 + 间隙。
const MOBILE_SWIPE_OFFSET_PX = MOBILE_DELETE_WIDTH_PX + MOBILE_DELETE_GAP_PX;
const MOBILE_OPEN_THRESHOLD_PX = 36;
const swipedRecordId = ref("");
const draggingRecordId = ref("");
let dragPointerId: number | null = null;
let dragStartX = 0;
let dragBaseX = 0;
const dragTranslateX = ref(0);
/**
* 统一“返回”语义:仅允许返回历史上一页。
*/
function syncCanGoBack(): void {
if (typeof window === "undefined") {
canGoBack.value = false;
return;
}
canGoBack.value = window.history.length > 1;
}
function closeSwipedRecord(): void {
if (!draggingRecordId.value) {
swipedRecordId.value = "";
}
}
function swipeItemStyle(recordId: string): { transform: string } {
if (draggingRecordId.value === recordId) {
return { transform: `translateX(${dragTranslateX.value}px)` };
}
if (swipedRecordId.value === recordId) {
return { transform: `translateX(-${MOBILE_SWIPE_OFFSET_PX}px)` };
}
return { transform: "translateX(0px)" };
}
/**
* 删除按钮仅在滑动过程中或已滑开状态下可见,避免默认态透出。
*/
function isDeleteVisible(recordId: string): boolean {
if (swipedRecordId.value === recordId) {
return true;
}
if (draggingRecordId.value !== recordId) {
return false;
}
return dragTranslateX.value < 0;
}
function clampNumber(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
function onRecordPointerDown(recordId: string, event: PointerEvent): void {
if (event.button !== 0) {
return;
}
dragPointerId = event.pointerId;
draggingRecordId.value = recordId;
dragStartX = event.clientX;
dragBaseX = swipedRecordId.value === recordId ? -MOBILE_SWIPE_OFFSET_PX : 0;
dragTranslateX.value = dragBaseX;
if (swipedRecordId.value && swipedRecordId.value !== recordId) {
swipedRecordId.value = "";
}
const target = event.currentTarget;
if (target instanceof HTMLElement) {
try {
target.setPointerCapture(event.pointerId);
} catch {
// iOS 某些版本 pointer capture 可能失败,失败时由 pointerup/cancel 兜底收尾。
}
}
}
function onRecordPointerMove(recordId: string, event: PointerEvent): void {
if (dragPointerId !== event.pointerId || draggingRecordId.value !== recordId) {
return;
}
const deltaX = event.clientX - dragStartX;
dragTranslateX.value = clampNumber(dragBaseX + deltaX, -MOBILE_SWIPE_OFFSET_PX, 0);
}
function finishSwipeGesture(recordId: string, event: PointerEvent): void {
if (dragPointerId !== event.pointerId || draggingRecordId.value !== recordId) {
return;
}
const shouldOpen = dragTranslateX.value <= -MOBILE_OPEN_THRESHOLD_PX;
swipedRecordId.value = shouldOpen ? recordId : "";
draggingRecordId.value = "";
dragPointerId = null;
dragTranslateX.value = 0;
}
function onRecordPointerUp(recordId: string, event: PointerEvent): void {
finishSwipeGesture(recordId, event);
}
function onRecordPointerCancel(recordId: string, event: PointerEvent): void {
finishSwipeGesture(recordId, event);
}
function onRecordLostPointerCapture(recordId: string, event: PointerEvent): void {
finishSwipeGesture(recordId, event);
}
async function removeRecord(recordId: string): Promise<void> {
await voiceRecordStore.removeRecord(recordId);
swipedRecordId.value = "";
appStore.notify("info", "已删除闪念记录");
}
function formatRecordTime(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleString("zh-CN", { hour12: false });
}
function download(): void {
const content = voiceRecordStore.exportRecords();
const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `remoteconn-records-${Date.now()}.txt`;
document.body.append(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
async function goBack(): Promise<void> {
if (!canGoBack.value) {
return;
}
router.back();
}
onMounted(async () => {
syncCanGoBack();
window.addEventListener("popstate", syncCanGoBack);
await voiceRecordStore.ensureBootstrapped();
});
onBeforeUnmount(() => {
window.removeEventListener("popstate", syncCanGoBack);
});
watch(totalPages, (nextPages) => {
if (currentPage.value > nextPages) {
currentPage.value = nextPages;
}
});
</script>

View File

@@ -0,0 +1,656 @@
<template>
<section class="page-root server-settings-page">
<div class="server-settings-layout">
<div class="page-toolbar server-settings-topbar">
<button
class="icon-btn"
type="button"
title="返回上一页"
aria-label="返回上一页"
:disabled="!canGoBack"
@click="goBack"
>
<img src="/icons/back.svg" alt="返回上一页" />
</button>
<div class="toolbar-spacer"></div>
<h2 class="page-title">服务器设置</h2>
</div>
<article v-if="ready" class="server-settings-content surface-scroll">
<div class="server-settings-form">
<div class="field-grid">
<label class="field">
<span>名称</span>
<input v-model="form.name" class="input" />
</label>
<label class="field">
<span>主机</span>
<input v-model="form.host" class="input" />
</label>
<label class="field">
<span>端口</span>
<input v-model.number="form.port" class="input" type="number" min="1" max="65535" />
</label>
<label class="field">
<span>用户名</span>
<input v-model="form.username" class="input" />
</label>
<label class="field">
<span>认证方式</span>
<select v-model="form.authType" class="input">
<option value="password">密码</option>
<option value="privateKey">私钥</option>
<option value="certificate">证书</option>
</select>
</label>
<label class="field">
<span>传输方式</span>
<select v-model="form.transportMode" class="input">
<option value="gateway">网关</option>
<option value="ios-native">iOS 原生</option>
</select>
</label>
<label class="field wide">
<span>codex工作目录</span>
<input v-model="form.projectPath" class="input" placeholder="~/workspace/project" />
</label>
<label class="field wide">
<span>标签逗号分隔</span>
<input
v-model="tagText"
class="input"
placeholder="prod,beijing"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
data-lpignore="true"
data-1p-ignore="true"
@blur="syncTags"
/>
<div v-if="orderedTags.length > 0" class="server-tag-order-list">
<div v-for="(tag, index) in orderedTags" :key="`tag-order-${tag}-${index}`" class="server-tag-order-item">
<span class="server-tag-order-text">{{ tag }}</span>
<div class="server-tag-order-actions">
<button
class="server-tag-order-btn"
type="button"
:disabled="index === 0"
title="标签上移"
aria-label="标签上移"
@click.prevent="moveTagUp(index)"
>
上移
</button>
<button
class="server-tag-order-btn"
type="button"
:disabled="index === orderedTags.length - 1"
title="标签下移"
aria-label="标签下移"
@click.prevent="moveTagDown(index)"
>
下移
</button>
</div>
</div>
</div>
</label>
</div>
<h3 class="server-settings-section-title">认证参数</h3>
<div class="field-grid">
<label v-if="form.authType === 'password'" class="field wide">
<span>密码</span>
<input v-model="credential.password" class="input" type="password" autocomplete="new-password" />
</label>
<template v-else>
<label class="field wide">
<span>私钥内容</span>
<textarea
v-model="credential.privateKey"
class="textarea"
rows="5"
:placeholder="hasPersistedPrivateKey ? '' : '粘贴 OpenSSH 私钥'"
/>
<p v-if="isPrivateKeyMaskedInput" class="item-sub">
已保存私钥当前为掩码回显直接粘贴新私钥可覆盖当前值
</p>
</label>
<label class="field">
<span>passphrase</span>
<input v-model="credential.passphrase" class="input" type="password" autocomplete="new-password" />
</label>
<label v-if="form.authType === 'certificate'" class="field">
<span>证书内容</span>
<textarea v-model="credential.certificate" class="textarea" rows="3" />
<p v-if="hasPersistedCertificate && !(credential.certificate ?? '').trim()" class="item-sub">
已保存证书内容出于安全原因不回显明文留空将沿用填写新证书将覆盖
</p>
</label>
</template>
</div>
</div>
</article>
<article v-else class="server-settings-content surface-scroll">
<p class="item-sub">未找到服务器请返回上一页或使用右下角导航按钮重新选择</p>
</article>
<div class="server-settings-bottom bottom-bar">
<button
class="icon-btn"
type="button"
title="返回上一页"
aria-label="返回上一页"
:disabled="!canGoBack"
@click="goBack"
>
<span class="icon-mask" style="--icon: url('/icons/back.svg')" aria-hidden="true"></span>
</button>
<div class="bottom-right-actions">
<button
class="icon-btn"
type="button"
:disabled="isConnecting"
title="使用当前配置连接"
aria-label="使用当前配置连接"
@click="connect"
>
<span class="icon-mask" style="--icon: url('/icons/connect.svg')" aria-hidden="true"></span>
</button>
<button
class="icon-btn"
type="button"
:disabled="isConnecting"
title="保存服务器配置"
aria-label="保存服务器配置"
@click="saveWithFeedback"
>
<span class="icon-mask" style="--icon: url('/icons/save.svg')" aria-hidden="true"></span>
</button>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref, toRaw, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import type { ServerProfile } from "@/types/app";
import { useServerStore } from "@/stores/serverStore";
import { useSessionStore } from "@/stores/sessionStore";
import { useAppStore } from "@/stores/appStore";
import { formatActionError, toFriendlyError } from "@/utils/feedback";
const NEW_SERVER_ROUTE_ID = "new";
type CredentialInput = {
password: string;
privateKey: string;
passphrase: string;
certificate: string;
};
const serverStore = useServerStore();
const sessionStore = useSessionStore();
const appStore = useAppStore();
const route = useRoute();
const router = useRouter();
const ready = ref(false);
const tagText = ref("");
const isConnecting = ref(false);
const canGoBack = ref(false);
const initialServerSnapshot = ref<ServerProfile | null>(null);
const initialCredentialSnapshot = ref<CredentialInput | null>(null);
const persistedCredentialSnapshot = ref<CredentialInput | null>(null);
const PRIVATE_KEY_MASK = "●●●●●●●●●●●●●●●●";
const form = reactive<ServerProfile>({
id: "",
name: "",
host: "",
port: 22,
username: "root",
authType: "password",
projectPath: "~/workspace",
projectPresets: [],
tags: [],
timeoutSeconds: 20,
heartbeatSeconds: 15,
transportMode: "gateway"
});
const credential = reactive<{
password?: string;
privateKey?: string;
passphrase?: string;
certificate?: string;
}>({
password: "",
privateKey: "",
passphrase: "",
certificate: ""
});
/**
* Vue Router 已对 path param 做过解码,这里不能再次 decodeURIComponent
* 否则含 `%` 的历史服务器 ID 会被二次解码,导致匹配不到服务器。
*/
const serverId = computed(() => {
const raw = route.params.id;
if (Array.isArray(raw)) {
return String(raw[0] ?? "");
}
return String(raw ?? "");
});
const isCreateMode = computed(() => serverId.value === NEW_SERVER_ROUTE_ID);
const serverWatchKey = computed(() => (isCreateMode.value ? "" : serverStore.servers.map((item) => item.id).join(",")));
const hasCreateDraftChanges = computed(() => {
if (!ready.value || !isCreateMode.value) return false;
if (!initialServerSnapshot.value || !initialCredentialSnapshot.value) return false;
const currentServer = buildServerSnapshot();
const currentCredential = buildCredentialSnapshot();
return (
createServerSignature(currentServer) !== createServerSignature(initialServerSnapshot.value) ||
createCredentialSignature(currentCredential) !== createCredentialSignature(initialCredentialSnapshot.value)
);
});
const hasPersistedPrivateKey = computed(() => Boolean(persistedCredentialSnapshot.value?.privateKey?.trim()));
const hasPersistedCertificate = computed(() => Boolean(persistedCredentialSnapshot.value?.certificate?.trim()));
const isPrivateKeyMaskedInput = computed(() => {
return hasPersistedPrivateKey.value && (credential.privateKey ?? "").trim() === PRIVATE_KEY_MASK;
});
const orderedTags = computed(() => parseTags(tagText.value));
watch(
[() => serverId.value, () => serverWatchKey.value],
async ([id]) => {
await loadServer(id);
},
{ immediate: true }
);
/**
* 统一“返回”语义:仅允许返回历史上一页。
*/
function syncCanGoBack(): void {
if (typeof window === "undefined") {
canGoBack.value = false;
return;
}
canGoBack.value = window.history.length > 1;
}
onMounted(() => {
syncCanGoBack();
window.addEventListener("popstate", syncCanGoBack);
});
onBeforeUnmount(() => {
window.removeEventListener("popstate", syncCanGoBack);
});
function parseTags(value: string): string[] {
return value
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
function createEmptyCredential(): CredentialInput {
return {
password: "",
privateKey: "",
passphrase: "",
certificate: ""
};
}
function normalizeCredentialInput(next: Partial<CredentialInput> | null | undefined): CredentialInput {
return {
password: next?.password ?? "",
privateKey: next?.privateKey ?? "",
passphrase: next?.passphrase ?? "",
certificate: next?.certificate ?? ""
};
}
function assignCredential(next: Partial<CredentialInput> | null | undefined): void {
Object.assign(credential, {
password: next?.password ?? "",
privateKey: next?.privateKey ?? "",
passphrase: next?.passphrase ?? "",
certificate: next?.certificate ?? ""
});
}
function normalizeServerSnapshot(server: ServerProfile): ServerProfile {
return {
...server,
projectPresets: [...server.projectPresets],
tags: [...server.tags]
};
}
function createServerSignature(server: ServerProfile): string {
return JSON.stringify({
id: server.id,
name: server.name,
host: server.host,
port: server.port,
username: server.username,
authType: server.authType,
projectPath: server.projectPath,
projectPresets: [...server.projectPresets],
tags: [...server.tags],
timeoutSeconds: server.timeoutSeconds,
heartbeatSeconds: server.heartbeatSeconds,
transportMode: server.transportMode,
lastConnectedAt: server.lastConnectedAt ?? ""
});
}
function buildCredentialSnapshot(): CredentialInput {
return {
password: credential.password ?? "",
privateKey: credential.privateKey ?? "",
passphrase: credential.passphrase ?? "",
certificate: credential.certificate ?? ""
};
}
function createCredentialSignature(value: CredentialInput): string {
return JSON.stringify({
password: value.password,
privateKey: value.privateKey,
passphrase: value.passphrase,
certificate: value.certificate
});
}
function markPristine(server: ServerProfile, credentialSnapshot: CredentialInput): void {
initialServerSnapshot.value = normalizeServerSnapshot(server);
initialCredentialSnapshot.value = { ...credentialSnapshot };
}
function fillFormFromServer(server: ServerProfile): void {
Object.assign(form, normalizeServerSnapshot(server));
tagText.value = server.tags.join(",");
}
async function loadServer(id: string): Promise<void> {
ready.value = false;
if (!id) return;
// 兜底确保服务器列表已加载,避免首屏/直达路由时出现“未找到服务器”的误判。
await serverStore.ensureBootstrapped();
if (id === NEW_SERVER_ROUTE_ID) {
const draft = serverStore.createServerDraft();
fillFormFromServer(draft);
const emptyCredential = createEmptyCredential();
persistedCredentialSnapshot.value = null;
assignCredential(emptyCredential);
markPristine(draft, emptyCredential);
ready.value = true;
return;
}
/**
* 容错匹配:
* 1) 主路径按服务器 id 精确匹配;
* 2) 兼容历史链接中的编码差异decode/encode 变体);
* 3) 兼容极少量旧数据把 name 用作路由参数的情况。
*/
const idCandidates = new Set<string>([id]);
try {
idCandidates.add(decodeURIComponent(id));
} catch {
// 忽略非法编码输入,继续用原始值匹配。
}
try {
idCandidates.add(encodeURIComponent(id));
} catch {
// encode 出错的概率极低,忽略即可。
}
const target =
serverStore.servers.find((item) => idCandidates.has(item.id)) ??
serverStore.servers.find((item) => idCandidates.has(item.name)) ??
serverStore.servers.find((item) => item.id === serverStore.selectedServerId);
if (!target) {
return;
}
fillFormFromServer(target);
try {
const saved = await serverStore.getCredentialInput(target.id);
const normalizedSaved = normalizeCredentialInput(saved);
persistedCredentialSnapshot.value = normalizedSaved;
/**
* 安全约束:私钥/证书内容不在表单中回显给用户,
* 仅在用户显式输入新内容时才覆盖;留空表示沿用已保存内容。
*/
assignCredential({
password: normalizedSaved.password,
privateKey: normalizedSaved.privateKey?.trim() ? PRIVATE_KEY_MASK : "",
passphrase: normalizedSaved.passphrase,
certificate: ""
});
} catch (error) {
// 会话密钥丢失或密文损坏时,仍允许用户进入设置页并重新录入凭据。
persistedCredentialSnapshot.value = null;
assignCredential(createEmptyCredential());
appStore.notify("warn", `凭据读取失败:${toFriendlyError(error)}`);
}
serverStore.selectedServerId = target.id;
markPristine(buildServerSnapshot(), buildCredentialSnapshot());
if (id !== target.id) {
// 使用标准 id 回写路由,避免后续刷新再次命中异常参数。
await router.replace(`/server/${encodeURIComponent(target.id)}/settings`);
}
ready.value = true;
}
function syncTags(): void {
form.tags = parseTags(tagText.value);
}
function applyOrderedTags(tags: string[]): void {
const normalized = tags.map((item) => item.trim()).filter(Boolean);
tagText.value = normalized.join(",");
form.tags = [...normalized];
}
function moveTagUp(index: number): void {
const tags = [...orderedTags.value];
if (index <= 0 || index >= tags.length) {
return;
}
const current = tags[index];
const previous = tags[index - 1];
if (current === undefined || previous === undefined) {
return;
}
tags[index - 1] = current;
tags[index] = previous;
applyOrderedTags(tags);
}
function moveTagDown(index: number): void {
const tags = [...orderedTags.value];
if (index < 0 || index >= tags.length - 1) {
return;
}
const current = tags[index];
const next = tags[index + 1];
if (current === undefined || next === undefined) {
return;
}
tags[index] = next;
tags[index + 1] = current;
applyOrderedTags(tags);
}
function validateCredential(): string | null {
const privateKeyTrimmed = (credential.privateKey ?? "").trim();
const privateKeyMasked = hasPersistedPrivateKey.value && privateKeyTrimmed === PRIVATE_KEY_MASK;
const hasInputPrivateKey = Boolean(privateKeyTrimmed) && !privateKeyMasked;
const hasSavedPrivateKey = Boolean(persistedCredentialSnapshot.value?.privateKey?.trim());
const hasInputCertificate = Boolean(credential.certificate?.trim());
const hasSavedCertificate = Boolean(persistedCredentialSnapshot.value?.certificate?.trim());
const hasEffectivePrivateKey = hasInputPrivateKey || hasSavedPrivateKey;
const hasEffectiveCertificate = hasInputCertificate || hasSavedCertificate;
if (form.authType === "password") {
return credential.password?.trim() ? null : "密码不能为空";
}
if (form.authType === "privateKey") {
return hasEffectivePrivateKey ? null : "私钥内容不能为空";
}
if (!hasEffectivePrivateKey) {
return "证书模式下私钥内容不能为空";
}
if (!hasEffectiveCertificate) {
return "证书模式下证书内容不能为空";
}
return null;
}
/**
* 组装“最终落库凭据”:
* - 私钥/证书输入框留空时,沿用已保存值(用于“安全不回显”场景);
* - 用户显式输入新内容时,优先使用新值覆盖。
*/
function buildCredentialPayloadForSave(authType: ServerProfile["authType"], draft: CredentialInput): CredentialInput {
const persisted = persistedCredentialSnapshot.value ?? createEmptyCredential();
const merged: CredentialInput = { ...draft };
const privateKeyTrimmed = merged.privateKey.trim();
const privateKeyMasked = Boolean(persisted.privateKey?.trim()) && privateKeyTrimmed === PRIVATE_KEY_MASK;
if ((authType === "privateKey" || authType === "certificate") && (!privateKeyTrimmed || privateKeyMasked)) {
merged.privateKey = persisted.privateKey ?? "";
}
if (authType === "certificate" && !merged.certificate.trim()) {
merged.certificate = persisted.certificate ?? "";
}
return merged;
}
/**
* 将响应式表单对象转换为纯数据快照,避免把 Vue Proxy 写入 IndexedDB/桥接层。
*/
function buildServerSnapshot(): ServerProfile {
const raw = toRaw(form);
const tags = parseTags(tagText.value);
return {
...raw,
projectPresets: [...raw.projectPresets],
tags
};
}
async function save(showToast = true): Promise<boolean> {
if (!ready.value || !form.id) return false;
if (isCreateMode.value && !hasCreateDraftChanges.value) {
if (showToast) {
appStore.notify("info", "配置无改动,未新增服务器");
}
return false;
}
const credentialError = validateCredential();
if (credentialError) {
throw new Error(`保存失败:${credentialError}`);
}
const serverSnapshot = buildServerSnapshot();
const credentialSnapshot = buildCredentialSnapshot();
const credentialPayload = buildCredentialPayloadForSave(serverSnapshot.authType, credentialSnapshot);
syncTags();
await serverStore.saveServer(serverSnapshot);
serverStore.selectedServerId = serverSnapshot.id;
await serverStore.saveCredential(serverSnapshot.id, {
type: serverSnapshot.authType,
password: credentialPayload.password,
privateKey: credentialPayload.privateKey,
passphrase: credentialPayload.passphrase,
certificate: credentialPayload.certificate
});
persistedCredentialSnapshot.value = { ...credentialPayload };
markPristine(serverSnapshot, credentialSnapshot);
if (isCreateMode.value) {
await router.replace(`/server/${encodeURIComponent(serverSnapshot.id)}/settings`);
}
if (showToast) {
appStore.notify("info", "服务器配置已保存");
}
return true;
}
async function saveWithFeedback(): Promise<void> {
try {
await save(true);
} catch (error) {
appStore.notify("error", formatActionError("保存失败", error));
}
}
async function connect(): Promise<void> {
if (!ready.value || isConnecting.value) return;
isConnecting.value = true;
try {
await sessionStore.ensureBootstrapped();
const createModeBeforeSave = isCreateMode.value;
const saved = await save(false);
if (createModeBeforeSave && !saved) {
throw new Error("新增服务器配置无改动,请先填写配置后再连接");
}
const serverSnapshot = buildServerSnapshot();
appStore.notify("info", `正在连接: ${serverSnapshot.username}@${serverSnapshot.host}:${serverSnapshot.port}`);
const connectTask = sessionStore.connect(serverSnapshot);
await router.push("/terminal");
await connectTask;
} catch (error) {
appStore.notify("error", formatActionError("连接失败", error));
} finally {
isConnecting.value = false;
}
}
async function confirmLeaveForCreateDraft(): Promise<boolean> {
if (!isCreateMode.value || !hasCreateDraftChanges.value) {
return true;
}
const shouldSave = window.confirm("检测到新增服务器配置已改动,是否保存后返回?");
if (!shouldSave) {
return true;
}
try {
await save(true);
return true;
} catch (error) {
appStore.notify("error", formatActionError("保存失败", error));
return false;
}
}
async function goBack(): Promise<void> {
if (!canGoBack.value) {
return;
}
const canLeave = await confirmLeaveForCreateDraft();
if (!canLeave) {
return;
}
router.back();
}
</script>

View File

@@ -0,0 +1,644 @@
<template>
<section class="page-root settings-page">
<div class="page-toolbar">
<div class="toolbar-left">
<button
class="icon-btn"
type="button"
title="返回上一页"
aria-label="返回上一页"
:disabled="!canGoBack"
@click="goBack"
>
<span class="icon-mask" style="--icon: url('/icons/back.svg')" aria-hidden="true"></span>
</button>
</div>
<div class="toolbar-spacer"></div>
<h2 class="page-title">全局配置</h2>
<div class="toolbar-right settings-save-ops">
<span class="settings-save-status">{{ saveStatusText }}</span>
<button class="btn primary" type="button" :disabled="manualSaving" @click="saveNow">
{{ manualSaving ? "保存中..." : "保存设置" }}
</button>
</div>
</div>
<!-- Tab 导航 -->
<nav class="settings-tabs">
<button
v-for="tab in tabs"
:key="tab.id"
class="settings-tab-btn"
:class="{ active: activeTab === tab.id }"
@click="activeTab = tab.id"
>{{ tab.label }}</button>
</nav>
<!-- 用户界面 Tab -->
<article v-if="activeTab === 'ui'" class="surface-panel surface-scroll">
<h4>用户界面</h4>
<div class="field-grid">
<label class="field">
<span>模式</span>
<select v-model="draft.uiThemeMode" class="input">
<option value="dark">深色</option>
<option value="light">浅色</option>
</select>
</label>
<label class="field">
<span>主题</span>
<select v-model="draft.uiThemePreset" class="input">
<option value="tide">tide默认</option>
<option value="暮砂">暮砂</option>
<option value="霓潮">霓潮</option>
<option value="苔暮">苔暮</option>
<option value="焰岩">焰岩</option>
<option value="岩陶">岩陶</option>
<option value="靛雾">靛雾</option>
<option value="绛霓">绛霓</option>
<option value="玫蓝">玫蓝</option>
<option value="珊湾">珊湾</option>
<option value="苔荧">苔荧</option>
<option value="铜暮">铜暮</option>
<option value="炽潮">炽潮</option>
<option value="藕夜">藕夜</option>
<option value="沙海">沙海</option>
<option value="珀岚">珀岚</option>
<option value="炫虹">炫虹</option>
<option value="鎏霓">鎏霓</option>
<option value="珊汐">珊汐</option>
<option value="黛苔">黛苔</option>
<option value="霜绯">霜绯</option>
</select>
</label>
<label class="field">
<span>强调色</span>
<input v-model="draft.uiAccentColor" class="input" type="color" />
</label>
<label class="field">
<span>背景色</span>
<input v-model="draft.uiBgColor" class="input" type="color" />
</label>
<label class="field">
<span>文本色</span>
<input v-model="draft.uiTextColor" class="input" type="color" />
</label>
<label class="field">
<span>按钮色</span>
<input v-model="draft.uiBtnColor" class="input" type="color" />
</label>
</div>
</article>
<!-- Shell Tab -->
<article v-if="activeTab === 'shell'" class="surface-panel surface-scroll">
<h4>显示设置</h4>
<section class="shell-style-preview" :style="shellPreviewStyle" aria-label="终端样式预览">
<pre class="shell-style-preview-content">{{ shellPreviewDemoText }}</pre>
</section>
<div class="field-grid">
<label class="field">
<span>模式</span>
<select v-model="draft.shellThemeMode" class="input">
<option value="dark">深色</option>
<option value="light">浅色</option>
</select>
</label>
<label class="field">
<span>主题</span>
<select v-model="draft.shellThemePreset" class="input">
<option value="tide">tide默认</option>
<option value="暮砂">暮砂</option>
<option value="霓潮">霓潮</option>
<option value="苔暮">苔暮</option>
<option value="焰岩">焰岩</option>
<option value="岩陶">岩陶</option>
<option value="靛雾">靛雾</option>
<option value="绛霓">绛霓</option>
<option value="玫蓝">玫蓝</option>
<option value="珊湾">珊湾</option>
<option value="苔荧">苔荧</option>
<option value="铜暮">铜暮</option>
<option value="炽潮">炽潮</option>
<option value="藕夜">藕夜</option>
<option value="沙海">沙海</option>
<option value="珀岚">珀岚</option>
<option value="炫虹">炫虹</option>
<option value="鎏霓">鎏霓</option>
<option value="珊汐">珊汐</option>
<option value="黛苔">黛苔</option>
<option value="霜绯">霜绯</option>
</select>
</label>
<label class="field">
<span>背景色</span>
<input v-model="draft.shellBgColor" class="input" type="color" />
</label>
<label class="field">
<span>前景色</span>
<input v-model="draft.shellTextColor" class="input" type="color" />
</label>
<label class="field">
<span>强调色/光标色</span>
<input v-model="draft.shellAccentColor" class="input" type="color" />
</label>
<label class="field">
<span>字体</span>
<select v-model="fontFamilySelect" class="input" @change="onFontSelectChange">
<option v-for="f in availableFonts" :key="f" :value="f">{{ f }}</option>
<option value="__custom__">自定义</option>
</select>
<input
v-if="fontFamilySelect === '__custom__'"
v-model="draft.shellFontFamily"
class="input"
placeholder="输入字体名称"
/>
</label>
<label class="field">
<span>字号</span>
<input v-model.number="draft.shellFontSize" class="input" type="number" min="12" max="22" />
</label>
<label class="field">
<span>行高</span>
<input v-model.number="draft.shellLineHeight" class="input" type="number" step="0.1" min="1" max="2" />
</label>
<label class="field">
<span>宽字符支持</span>
<select v-model="draft.unicode11" class="input">
<option :value="true">启用</option>
<option :value="false">禁用</option>
</select>
</label>
</div>
<h4>终端缓冲</h4>
<div class="field-grid">
<label class="field">
<span>缓冲行数上限</span>
<input v-model.number="draft.terminalBufferMaxEntries" class="input" type="number" step="100" min="200" />
</label>
<label class="field">
<span>缓冲字节上限</span>
<input v-model.number="draft.terminalBufferMaxBytes" class="input" type="number" step="65536" min="65536" />
</label>
</div>
</article>
<!-- 连接策略 Tab -->
<article v-if="activeTab === 'connection'" class="surface-panel surface-scroll">
<h4>重连和安全</h4>
<div class="field-grid">
<label class="field">
<span>自动重连</span>
<select v-model="draft.autoReconnect" class="input">
<option :value="true">开启</option>
<option :value="false">关闭</option>
</select>
</label>
<label class="field">
<span>重连次数上限</span>
<input v-model.number="draft.reconnectLimit" class="input" type="number" min="0" max="10" />
</label>
<label class="field">
<span>主机指纹策略</span>
<select v-model="draft.hostKeyPolicy" class="input">
<option value="strict">strict拒绝未知指纹</option>
<option value="warn">warn提示</option>
<option value="accept">accept自动接受</option>
</select>
</label>
<label class="field">
<span>凭据记忆策略</span>
<select v-model="draft.credentialMemoryPolicy" class="input">
<option value="remember">remember进程内保留</option>
<option value="forget">forget每次提示</option>
</select>
</label>
<label class="field">
<span>连接超时毫秒</span>
<input v-model.number="draft.gatewayConnectTimeoutMs" class="input" type="number" min="3000" step="1000" />
</label>
<label class="field">
<span>等待就绪超时毫秒</span>
<input v-model.number="draft.waitForConnectedTimeoutMs" class="input" type="number" min="3000" step="1000" />
</label>
</div>
<h4>服务器默认配置</h4>
<div class="field-grid">
<label class="field">
<span>默认认证方式</span>
<select v-model="draft.defaultAuthType" class="input">
<option value="password">密码</option>
<option value="key">密钥</option>
</select>
</label>
<label class="field">
<span>默认 SSH 端口</span>
<input v-model.number="draft.defaultPort" class="input" type="number" min="1" max="65535" />
</label>
<label class="field">
<span>默认项目路径</span>
<input v-model="draft.defaultProjectPath" class="input" placeholder="~/workspace" />
</label>
<label class="field">
<span>默认连接超时</span>
<input v-model.number="draft.defaultTimeoutSeconds" class="input" type="number" min="5" />
</label>
<label class="field">
<span>默认心跳间隔</span>
<input v-model.number="draft.defaultHeartbeatSeconds" class="input" type="number" min="5" />
</label>
</div>
</article>
<!-- 日志 Tab -->
<article v-if="activeTab === 'log'" class="surface-panel surface-scroll">
<h4>日志设置</h4>
<div class="field-grid">
<label class="field">
<span>日志保留天数</span>
<input v-model.number="draft.logRetentionDays" class="input" type="number" min="1" max="365" />
</label>
<label class="field">
<span>日志脱敏</span>
<select v-model="draft.maskSecrets" class="input">
<option :value="true">开启推荐</option>
<option :value="false">关闭</option>
</select>
</label>
</div>
</article>
</section>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, nextTick, reactive, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { useSettingsStore } from "@/stores/settingsStore";
import { useRememberedEnumRef } from "@/utils/useRememberedEnumRef";
import { getThemeVariant, getShellVariant, pickBtnColor } from "@remoteconn/shared";
const settingsStore = useSettingsStore();
const router = useRouter();
const canGoBack = ref(false);
const tabs = [
{ id: "ui", label: "界面" },
{ id: "shell", label: "终端" },
{ id: "connection", label: "连接" },
{ id: "log", label: "日志" }
] as const;
type TabId = (typeof tabs)[number]["id"];
const activeTab = ref<TabId>("ui");
const SETTINGS_ACTIVE_TAB_KEY = "remoteconn.settings.activeTab";
const TAB_IDS = tabs.map((tab) => tab.id) as TabId[];
useRememberedEnumRef({
storageKey: SETTINGS_ACTIVE_TAB_KEY,
allowedValues: TAB_IDS,
target: activeTab
});
const draft = reactive({ ...settingsStore.settings });
// ── 初始化完成标志:防止 bootstrap 前的 draft 变动触发自动保存或预设联动 ──
const initialized = ref(false);
// ── 主题预设/模式联动:选择预设或切换明暗后自动覆写对应颜色字段(仅初始化后生效) ──
function applyUiPreset(): void {
const v = getThemeVariant(draft.uiThemePreset, draft.uiThemeMode);
draft.uiBgColor = v.bg;
draft.uiTextColor = v.text;
draft.uiAccentColor = v.accent;
draft.uiBtnColor = pickBtnColor(v.bg, v.text);
}
watch(
() => draft.uiThemePreset,
() => {
if (!initialized.value) return;
applyUiPreset();
}
);
watch(
() => draft.uiThemeMode,
() => {
if (!initialized.value) return;
applyUiPreset();
}
);
watch(
() => draft.shellThemePreset,
(preset) => {
if (!initialized.value) return;
const v = getShellVariant(preset, draft.shellThemeMode);
draft.shellBgColor = v.bg;
draft.shellTextColor = v.text;
draft.shellAccentColor = v.cursor;
}
);
watch(
() => draft.shellThemeMode,
(mode) => {
if (!initialized.value) return;
const v = getShellVariant(draft.shellThemePreset, mode);
draft.shellBgColor = v.bg;
draft.shellTextColor = v.text;
draft.shellAccentColor = v.cursor;
}
);
// ── 自动保存draft 任意字段变更后防抖写入持久化(仅初始化后生效) ──────────
let saveTimer: ReturnType<typeof setTimeout> | null = null;
const manualSaving = ref(false);
const saveStatus = ref<"idle" | "saved" | "error">("idle");
const saveStatusText = computed(() => {
if (manualSaving.value) return "正在保存";
if (saveStatus.value === "saved") return "已保存";
if (saveStatus.value === "error") return "保存失败";
return "自动保存已开启";
});
/**
* 规范化预览字体族字符串:
* - 字体名包含空格时必须加引号(如 "PingFang SC"),否则 CSS 会拆成多个 family token
* - 用户可能输入逗号分隔列表(如 ui-monospace, -apple-system此时保持原样
* - 始终追加一组稳定的 monospace 回退,避免“看起来没生效”但实际回落到比例字体。
*/
function resolvePreviewFontFamily(raw: string): string {
const value = String(raw ?? "").trim();
const fallback = '"SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", monospace';
if (!value) {
return fallback;
}
if (value.includes(",")) {
return `${value}, ${fallback}`;
}
const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
if (/\s/.test(value)) {
return `"${escaped}", ${fallback}`;
}
return `${escaped}, ${fallback}`;
}
/**
* 终端样式预览:
* - 仅做视觉回显,不引入 xterm 运行时,保持轻量;
* - 与草稿配置实时联动,确保用户调参时立刻看到效果。
*/
const shellPreviewStyle = computed(() => ({
width: "100%",
// 高度采用 hug内容自适应不再固定 160px。
height: "auto",
minHeight: "unset",
maxHeight: "unset",
flex: "0 0 auto",
padding: "8px 10px",
borderRadius: "10px",
border: "1px solid rgba(141, 187, 255, 0.38)",
overflow: "visible",
// 使用 backgroundColor 而非 background避免覆盖 CSS 中的轻量纹理层。
backgroundColor: draft.shellBgColor,
color: draft.shellTextColor,
fontFamily: resolvePreviewFontFamily(draft.shellFontFamily),
// 预览层仅做 UI 展示,限制范围避免历史异常值导致内容塌缩不可见。
fontSize: `${Math.min(22, Math.max(12, Number(draft.shellFontSize) || 15))}px`,
lineHeight: String(Math.min(2, Math.max(1, Number(draft.shellLineHeight) || 1.4)))
}));
/**
* 终端预览 Demo 文本:
* - 覆盖提示符、登录信息、常见命令、目录列表、错误信息、JSON、长路径与中英混排
* - 仅用于样式观感,不参与任何会话逻辑。
*/
const shellPreviewDemoText = computed(() =>
[
"Last Login: Wea Feb 25 13:38:27 2026 from 115.193.12.66",
"gavin mini ~ % ls -la",
"drwxr-xr-x 4 gavin staff 128 Feb 25 15:20 workspace",
"gavin mini ~ % npm run test && npm run lint",
"zsh: command not found: codexx",
"{\"state\":\"connected\",\"latencyMs\":12,\"retry\":0,\"transport\":\"gateway\"}",
`Unicode11: ${draft.unicode11 ? "on" : "off"} | 中文 ABC 123 |_END_`
].join("\n")
);
async function persistDraftNow(trackStatus = false): Promise<void> {
if (!initialized.value) return;
if (saveTimer) {
clearTimeout(saveTimer);
saveTimer = null;
}
if (trackStatus) {
manualSaving.value = true;
}
try {
await settingsStore.save({ ...draft });
saveStatus.value = "saved";
} catch {
saveStatus.value = "error";
} finally {
if (trackStatus) {
manualSaving.value = false;
}
}
}
function schedulePersistDraft(): void {
if (!initialized.value) return;
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(async () => {
await persistDraftNow(false);
}, 400);
}
watch(
draft,
() => {
schedulePersistDraft();
},
{ deep: true }
);
function onPageVisibilityChange(): void {
if (document.visibilityState === "hidden") {
void persistDraftNow(false);
}
}
function onPageHide(): void {
void persistDraftNow(false);
}
/**
* 统一“返回”语义:仅允许返回历史上一页。
*/
function syncCanGoBack(): void {
if (typeof window === "undefined") {
canGoBack.value = false;
return;
}
canGoBack.value = window.history.length > 1;
}
async function saveNow(): Promise<void> {
await persistDraftNow(true);
}
// ── 字体候选(中英常用,固定 16 项)────────────────────────────
const CANDIDATE_FONTS = [
"JetBrains Mono",
"Fira Code",
"Cascadia Mono",
"Source Code Pro",
"IBM Plex Mono",
"SF Mono",
"Menlo",
"Monaco",
"Consolas",
"PingFang SC",
"Hiragino Sans GB",
"Microsoft YaHei",
"Noto Sans Mono CJK SC",
"Noto Sans CJK SC",
"Source Han Sans SC",
"Sarasa Mono SC",
"WenQuanYi Micro Hei",
];
/**
* iOS / iPadOS 字体白名单:
* - Safari/WebView 对本地字体探测能力有限,容易把可用字体误判为不可用;
* - 因此 iOS 上使用“稳定白名单”直接展示,减少“只有两个字体”的误判。
*/
const IOS_SAFE_FONTS = [
"ui-monospace",
"SF Mono",
"Menlo",
"Monaco",
"Courier",
"Courier New",
"PingFang SC",
"Hiragino Sans GB",
"-apple-system",
];
const availableFonts = ref<string[]>(CANDIDATE_FONTS);
const fontFamilySelect = ref<string>("__custom__");
/**
* 判断是否 iOS / iPadOS 运行环境:
* - iPadOS 新版可能上报为 MacIntel + 触控点;
* - 这里统一归并到 iOS 分支,使用稳定字体白名单。
*/
function isIosLike(): boolean {
if (typeof navigator === "undefined") {
return false;
}
const ua = String(navigator.userAgent ?? "");
const isClassicIos = /iPhone|iPad|iPod/i.test(ua);
const isIpadOsDesktopUa = navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1;
return isClassicIos || isIpadOsDesktopUa;
}
/**
* 基于 Canvas 文本宽度差异判断字体是否可用:
* - 同一段探测文本在“目标字体 + 基线字体”下宽度若与基线不同,视为字体存在;
* - 该方法无需额外依赖,兼容主流浏览器;
* - 仅用于 UI 候选过滤,不参与终端渲染链路。
*/
function isFontInstalled(fontName: string): boolean {
if (typeof document === "undefined") {
return false;
}
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d");
if (!context) {
return false;
}
const probeText = "mmmmmmmmmmlliWW中文";
const size = "72px";
const baseFamilies = ["monospace", "sans-serif", "serif"];
const baseWidths = new Map<string, number>();
for (const base of baseFamilies) {
context.font = `${size} ${base}`;
baseWidths.set(base, context.measureText(probeText).width);
}
for (const base of baseFamilies) {
context.font = `${size} "${fontName}", ${base}`;
const width = context.measureText(probeText).width;
const baseWidth = baseWidths.get(base);
if (baseWidth !== undefined && Math.abs(width - baseWidth) > 0.01) {
return true;
}
}
return false;
}
/**
* 过滤本机可用字体:
* - 未安装字体不显示在下拉中,避免“选择后看起来无变化”的假象;
* - 若检测结果为空,保留完整候选作为兜底(例如极端 WebView 限制场景)。
*/
function resolveAvailableFonts(): string[] {
if (isIosLike()) {
return IOS_SAFE_FONTS;
}
const installed = CANDIDATE_FONTS.filter((font) => isFontInstalled(font));
return installed.length > 0 ? installed : CANDIDATE_FONTS;
}
function syncFontSelect(): void {
const cur = draft.shellFontFamily;
fontFamilySelect.value = availableFonts.value.includes(cur) ? cur : "__custom__";
}
function onFontSelectChange(): void {
if (fontFamilySelect.value !== "__custom__") {
draft.shellFontFamily = fontFamilySelect.value;
}
}
onMounted(async () => {
syncCanGoBack();
await settingsStore.ensureBootstrapped();
Object.assign(draft, settingsStore.settings);
// 等待 Object.assign 触发的所有 watcher 在 initialized=false 状态下执行完毕,
// 再开启自动保存和预设联动,避免初始化时的 uiThemePreset 变化覆盖已保存的自定义颜色。
await nextTick();
// 仅展示本机可用字体,减少“选择无效(实际回退)”的误解。
availableFonts.value = resolveAvailableFonts();
syncFontSelect();
// 标记初始化完成,之后的 draft 变更才触发自动保存和预设联动
initialized.value = true;
document.addEventListener("visibilitychange", onPageVisibilityChange);
window.addEventListener("pagehide", onPageHide);
window.addEventListener("popstate", syncCanGoBack);
});
onBeforeUnmount(() => {
document.removeEventListener("visibilitychange", onPageVisibilityChange);
window.removeEventListener("pagehide", onPageHide);
window.removeEventListener("popstate", syncCanGoBack);
void persistDraftNow(false);
});
async function goBack(): Promise<void> {
if (!canGoBack.value) {
return;
}
router.back();
}
</script>

View File

@@ -0,0 +1,339 @@
<template>
<section class="page-root terminal-page">
<div class="page-toolbar terminal-toolbar">
<div class="toolbar-left">
<button
class="icon-btn"
type="button"
title="AI 快速启动"
aria-label="AI 快速启动"
@click="openCodexDialog"
>
<span class="icon-mask" style="--icon: url('/icons/codex.svg')" aria-hidden="true"></span>
</button>
<button
class="icon-btn"
type="button"
title="清屏"
aria-label="清屏"
@click="sessionStore.clearTerminal"
>
<span class="icon-mask" style="--icon: url('/icons/clear.svg')" aria-hidden="true"></span>
</button>
</div>
<div class="toolbar-spacer"></div>
<div class="terminal-toolbar-actions">
<h2 class="page-title terminal-title">{{ terminalTitle }}</h2>
<span class="state-chip" :class="`state-${sessionStore.state}`">{{ sessionStore.state }}</span>
<span class="state-chip">{{ sessionStore.latencyMs }}ms</span>
<span class="terminal-toolbar-divider" aria-hidden="true"></span>
<button
class="terminal-connection-switch"
:class="connectionActionIsReconnect ? 'is-reconnect' : 'is-disconnect'"
:disabled="connectionActionDisabled"
:aria-label="connectionActionIsReconnect ? '重连' : '断开'"
@click="handleConnectionAction"
>
<span class="terminal-connection-switch-label">{{
connectionActionIsReconnect ? "重连" : "断开"
}}</span>
<span class="terminal-connection-switch-knob" aria-hidden="true"></span>
</button>
</div>
</div>
<article class="terminal-surface">
<section class="surface-panel terminal-card">
<Suspense>
<template #default>
<AsyncTerminalPanel />
</template>
<template #fallback>
<div class="terminal-loading">正在初始化终端</div>
</template>
</Suspense>
<div v-if="pluginRuntimeEnabled" class="plugin-chips">
<button
v-for="commandItem in pluginCommands"
:key="commandItem.id"
class="plugin-chip"
:data-plugin-id="commandItem.id.split(':')[0]"
@click="runPluginCommand(commandItem.id)"
>
{{ commandItem.title }}
</button>
</div>
</section>
</article>
<div v-if="codexDialogOpen" class="codex-dialog-mask" @click.self="closeCodexDialog">
<div class="codex-dialog" role="dialog" aria-modal="true" aria-label="AI 快速启动">
<h3 class="codex-dialog-title">AI 快速启动</h3>
<p class="codex-dialog-hint">点击按钮自动切换项目目录启动AICoding</p>
<section class="ai-launch-card">
<h4 class="ai-launch-card-title">Codex</h4>
<div class="ai-launch-actions">
<button
v-for="option in codexSandboxOptions"
:key="option.value"
class="btn"
type="button"
@click="runCodexCommand(option.value)"
>
{{ option.label }}
</button>
</div>
</section>
<section class="ai-launch-card">
<h4 class="ai-launch-card-title">Copilot</h4>
<div class="ai-launch-actions">
<button class="btn" type="button" @click="runCopilotCommand('copilot')">copilot</button>
<button class="btn" type="button" @click="runCopilotCommand('copilot --experimental')">
copilot --experimental
</button>
<button class="btn" type="button" @click="runCopilotCommand('copilot --allow-all')">
copilot --allow-all
</button>
</div>
</section>
<div class="codex-dialog-actions">
<button class="btn" type="button" @click="closeCodexDialog">关闭</button>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { computed, defineAsyncComponent, onBeforeUnmount, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import type { ServerProfile } from "@/types/app";
import { useSessionStore } from "@/stores/sessionStore";
import { useServerStore } from "@/stores/serverStore";
import { useAppStore } from "@/stores/appStore";
import "xterm/css/xterm.css";
import { formatActionError } from "@/utils/feedback";
import { buildCdCommand } from "@remoteconn/shared";
const AsyncTerminalPanel = defineAsyncComponent(() => import("@/components/TerminalPanel.vue"));
type PluginCommandItem = {
id: string;
title: string;
};
type PluginStoreLike = {
commands: PluginCommandItem[];
ensureBootstrapped: () => Promise<void>;
runCommand: (commandId: string) => Promise<void>;
};
const sessionStore = useSessionStore();
const serverStore = useServerStore();
const appStore = useAppStore();
const route = useRoute();
const router = useRouter();
const pluginRuntimeEnabled = import.meta.env.VITE_ENABLE_PLUGIN_RUNTIME !== "false";
const pluginStore = ref<PluginStoreLike | null>(null);
const reconnectStates = new Set(["idle", "disconnected", "error"]);
const waitingConnectStates = new Set(["connecting", "auth_pending", "reconnecting"]);
const codexDialogOpen = ref(false);
type CopilotCommand = "copilot" | "copilot --experimental" | "copilot --allow-all";
const codexSandboxOptions = [
{ value: "read-only", label: "codex --sandbox read-only" },
{ value: "workspace-write", label: "codex --sandbox workspace-write" },
{ value: "danger-full-access", label: "codex --sandbox danger-full-access" }
] as const;
const terminalTitle = computed(() => serverStore.selectedServer?.name ?? "remoteconn");
const connectionActionIsReconnect = computed(() => reconnectStates.has(sessionStore.state));
const pluginCommands = computed(() => pluginStore.value?.commands ?? []);
onMounted(async () => {
await Promise.all([serverStore.ensureBootstrapped(), sessionStore.ensureBootstrapped()]);
if (route.query.openCodex === "1") {
codexDialogOpen.value = true;
await router.replace({ path: "/terminal", query: {} });
}
if (!pluginRuntimeEnabled) {
return;
}
const { usePluginStore } = await import("@/stores/pluginStore");
pluginStore.value = usePluginStore() as unknown as PluginStoreLike;
await pluginStore.value.ensureBootstrapped();
});
onBeforeUnmount(() => {
sessionStore.cancelReconnect("leave_terminal_page");
});
async function runPluginCommand(commandId: string): Promise<void> {
if (!pluginStore.value) {
return;
}
await pluginStore.value.runCommand(commandId);
}
/**
* 断开态时需要有可重连的目标服务器,避免“重连”按钮点击后无效。
* 优先使用当前会话记录的服务器 ID其次使用当前选中的服务器。
*/
const connectionActionDisabled = computed(() => {
if (!connectionActionIsReconnect.value) {
return false;
}
return !resolveReconnectServer();
});
function openCodexDialog(): void {
codexDialogOpen.value = true;
}
function closeCodexDialog(): void {
codexDialogOpen.value = false;
}
async function runCodexCommand(sandbox: "read-only" | "workspace-write" | "danger-full-access"): Promise<void> {
// 交互要求:点击命令按钮后立即关闭窗口,执行结果通过 toast 和终端输出反馈。
codexDialogOpen.value = false;
await runCodex(sandbox);
}
async function runCodex(sandbox: "read-only" | "workspace-write" | "danger-full-access"): Promise<boolean> {
try {
const server = await ensureConnectedForAi();
if (!server) {
return false;
}
const launched = await sessionStore.runCodex(server.projectPath, sandbox);
return launched;
} catch (error) {
appStore.notify("error", formatActionError("Codex 启动失败", error));
return false;
}
}
async function runCopilotCommand(command: CopilotCommand): Promise<void> {
// 交互要求:点击命令按钮后立即关闭窗口,执行结果通过 toast 和终端输出反馈。
codexDialogOpen.value = false;
await runCopilot(command);
}
async function runCopilot(command: CopilotCommand): Promise<boolean> {
try {
const server = await ensureConnectedForAi();
if (!server) {
return false;
}
/**
* 固定命令枚举 + 项目目录切换:
* 1) 确保启动位置与 Codex 一致;
* 2) 不接受用户自由输入,避免命令注入。
*/
const runCommand = `${buildCdCommand(server.projectPath)} && ${command}`;
await sessionStore.sendCommand(runCommand, "manual", "run");
return true;
} catch (error) {
appStore.notify("error", formatActionError("Copilot 启动失败", error));
return false;
}
}
/**
* 构造可重连目标:
* 1) 当前会话绑定的服务器优先;
* 2) 若会话 ID 丢失,则退化到当前选中服务器;
* 3) 返回纯数据快照,避免把响应式对象直接传入会话层。
*/
function resolveReconnectServer(): ServerProfile | null {
const targetId = sessionStore.currentServerId || serverStore.selectedServerId;
const target = serverStore.servers.find((item) => item.id === targetId) ?? serverStore.selectedServer;
if (!target) {
return null;
}
return {
...target,
projectPresets: [...target.projectPresets],
tags: [...target.tags]
};
}
/**
* 等待会话进入 connected
* 1) 连接链路是异步事件驱动connect() 返回时可能仍在 auth_pending
* 2) 这里用轻量轮询等待最终状态,避免“刚点连接就发命令”触发会话未连接;
* 3) 明确超时与失败态,避免无限等待。
*/
function waitForConnected(timeoutMs = 15_000): Promise<void> {
if (sessionStore.state === "connected") {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
const startedAt = Date.now();
const check = (): void => {
if (sessionStore.state === "connected") {
resolve();
return;
}
if (!waitingConnectStates.has(sessionStore.state)) {
reject(new Error(`连接未就绪,当前状态: ${sessionStore.state}`));
return;
}
if (Date.now() - startedAt > timeoutMs) {
reject(new Error("等待会话连接超时"));
return;
}
window.setTimeout(check, 120);
};
check();
});
}
/**
* AI 启动前自动确保连接可用:
* - 断开态:先按“重连”逻辑自动重连;
* - 连接中:直接等待 connected
* - 已连接:直接返回当前目标服务器。
*/
async function ensureConnectedForAi(): Promise<ServerProfile | null> {
const target = resolveReconnectServer();
if (!target) {
appStore.notify("warn", "未找到可连接的服务器");
return null;
}
if (sessionStore.state === "connected") {
return target;
}
if (!waitingConnectStates.has(sessionStore.state)) {
appStore.notify("info", `正在连接: ${target.username}@${target.host}:${target.port}`);
await sessionStore.connect(target);
}
await waitForConnected();
return target;
}
async function handleConnectionAction(): Promise<void> {
if (connectionActionIsReconnect.value) {
const target = resolveReconnectServer();
if (!target) {
appStore.notify("warn", "未找到可重连的服务器");
return;
}
try {
appStore.notify("info", `正在重连: ${target.username}@${target.host}:${target.port}`);
await sessionStore.connect(target);
} catch (error) {
appStore.notify("error", formatActionError("重连失败", error));
}
return;
}
await sessionStore.disconnect("manual", true);
}
</script>