update at 2026-03-03 21:19:52
This commit is contained in:
556
pxterm/src/App.vue
Normal file
556
pxterm/src/App.vue
Normal 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>
|
||||
7
pxterm/src/assets/icons/ai.svg
Normal file
7
pxterm/src/assets/icons/ai.svg
Normal 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 |
8
pxterm/src/assets/icons/move.svg
Normal file
8
pxterm/src/assets/icons/move.svg
Normal 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 |
2188
pxterm/src/components/TerminalPanel.vue
Normal file
2188
pxterm/src/components/TerminalPanel.vue
Normal file
File diff suppressed because it is too large
Load Diff
2093
pxterm/src/components/TerminalPanel.vue.bak
Normal file
2093
pxterm/src/components/TerminalPanel.vue.bak
Normal file
File diff suppressed because it is too large
Load Diff
2094
pxterm/src/components/TerminalPanel.vue.bak2
Normal file
2094
pxterm/src/components/TerminalPanel.vue.bak2
Normal file
File diff suppressed because it is too large
Load Diff
1338
pxterm/src/components/TerminalVoiceInput.vue
Normal file
1338
pxterm/src/components/TerminalVoiceInput.vue
Normal file
File diff suppressed because it is too large
Load Diff
11
pxterm/src/env.d.ts
vendored
Normal file
11
pxterm/src/env.d.ts
vendored
Normal 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
87
pxterm/src/main.ts
Normal 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
42
pxterm/src/routes.ts
Normal 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")
|
||||
}
|
||||
]
|
||||
: [])
|
||||
];
|
||||
94
pxterm/src/services/security/credentialVault.ts
Normal file
94
pxterm/src/services/security/credentialVault.ts
Normal 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;
|
||||
}
|
||||
21
pxterm/src/services/sessionEventBus.ts
Normal file
21
pxterm/src/services/sessionEventBus.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
95
pxterm/src/services/storage/db.ts
Normal file
95
pxterm/src/services/storage/db.ts
Normal 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()
|
||||
});
|
||||
}
|
||||
43
pxterm/src/services/storage/pluginFsAdapter.ts
Normal file
43
pxterm/src/services/storage/pluginFsAdapter.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
16
pxterm/src/services/transport/factory.ts
Normal file
16
pxterm/src/services/transport/factory.ts
Normal 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);
|
||||
}
|
||||
335
pxterm/src/services/transport/gatewayTransport.ts
Normal file
335
pxterm/src/services/transport/gatewayTransport.ts
Normal 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)];
|
||||
}
|
||||
}
|
||||
170
pxterm/src/services/transport/iosNativeTransport.ts
Normal file
170
pxterm/src/services/transport/iosNativeTransport.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
29
pxterm/src/services/transport/terminalTransport.ts
Normal file
29
pxterm/src/services/transport/terminalTransport.ts
Normal 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;
|
||||
}
|
||||
27
pxterm/src/stores/appStore.ts
Normal file
27
pxterm/src/stores/appStore.ts
Normal 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
|
||||
};
|
||||
});
|
||||
117
pxterm/src/stores/logStore.ts
Normal file
117
pxterm/src/stores/logStore.ts
Normal 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
|
||||
};
|
||||
});
|
||||
189
pxterm/src/stores/pluginStore.ts
Normal file
189
pxterm/src/stores/pluginStore.ts
Normal 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
|
||||
};
|
||||
});
|
||||
156
pxterm/src/stores/serverStore.test.ts
Normal file
156
pxterm/src/stores/serverStore.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
382
pxterm/src/stores/serverStore.ts
Normal file
382
pxterm/src/stores/serverStore.ts
Normal 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
|
||||
};
|
||||
});
|
||||
494
pxterm/src/stores/sessionStore.test.ts
Normal file
494
pxterm/src/stores/sessionStore.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
1119
pxterm/src/stores/sessionStore.ts
Normal file
1119
pxterm/src/stores/sessionStore.ts
Normal file
File diff suppressed because it is too large
Load Diff
91
pxterm/src/stores/settingsStore.ts
Normal file
91
pxterm/src/stores/settingsStore.ts
Normal 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
|
||||
};
|
||||
});
|
||||
94
pxterm/src/stores/voiceRecordStore.test.ts
Normal file
94
pxterm/src/stores/voiceRecordStore.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
93
pxterm/src/stores/voiceRecordStore.ts
Normal file
93
pxterm/src/stores/voiceRecordStore.ts
Normal 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
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
131
pxterm/src/types/app.ts
Normal 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;
|
||||
}
|
||||
65
pxterm/src/utils/defaults.test.ts
Normal file
65
pxterm/src/utils/defaults.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
206
pxterm/src/utils/defaults.ts
Normal file
206
pxterm/src/utils/defaults.ts
Normal 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";
|
||||
}
|
||||
60
pxterm/src/utils/dynamicImportGuard.test.ts
Normal file
60
pxterm/src/utils/dynamicImportGuard.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
118
pxterm/src/utils/dynamicImportGuard.ts
Normal file
118
pxterm/src/utils/dynamicImportGuard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
131
pxterm/src/utils/feedback.ts
Normal file
131
pxterm/src/utils/feedback.ts
Normal 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 "提示";
|
||||
}
|
||||
42
pxterm/src/utils/rememberedState.test.ts
Normal file
42
pxterm/src/utils/rememberedState.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
20
pxterm/src/utils/rememberedState.ts
Normal file
20
pxterm/src/utils/rememberedState.ts
Normal 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
17
pxterm/src/utils/time.ts
Normal 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`;
|
||||
}
|
||||
23
pxterm/src/utils/useRememberedEnumRef.ts
Normal file
23
pxterm/src/utils/useRememberedEnumRef.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
506
pxterm/src/views/ConnectView.vue
Normal file
506
pxterm/src/views/ConnectView.vue
Normal 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>
|
||||
108
pxterm/src/views/LogsView.vue
Normal file
108
pxterm/src/views/LogsView.vue
Normal 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>
|
||||
113
pxterm/src/views/PluginsView.vue
Normal file
113
pxterm/src/views/PluginsView.vue
Normal 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>
|
||||
253
pxterm/src/views/RecordsView.vue
Normal file
253
pxterm/src/views/RecordsView.vue
Normal 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>
|
||||
656
pxterm/src/views/ServerSettingsView.vue
Normal file
656
pxterm/src/views/ServerSettingsView.vue
Normal 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>
|
||||
644
pxterm/src/views/SettingsView.vue
Normal file
644
pxterm/src/views/SettingsView.vue
Normal 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>
|
||||
339
pxterm/src/views/TerminalView.vue
Normal file
339
pxterm/src/views/TerminalView.vue
Normal 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>
|
||||
Reference in New Issue
Block a user