Files
remoteconn-gitea/apps/web/src/App.vue
2026-03-21 18:57:10 +08:00

617 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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" :class="{ 'terminal-bottom-bar': isTerminalRoute }">
<button
class="icon-btn bottom-nav-btn"
type="button"
title="返回上一页"
aria-label="返回上一页"
:disabled="!canGoBack"
@click="goBack"
>
<span class="icon-mask" style="--icon: url(&quot;/icons/back.svg&quot;)" 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";
import {
applyThemeChromeColor,
resolveThemeChromeColor,
type ThemeChromeDocument
} from "@/utils/themeChrome";
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"
};
const aboutNavItem: BottomNavItem = {
path: "/about",
label: "关于",
icon: "/icons/about.svg"
};
function appendRecordsNavItem(items: BottomNavItem[]): BottomNavItem[] {
if (route.path === "/records") {
return items;
}
if (items.some((item) => item.path === recordsNavItem.path)) {
return items;
}
return [...items, recordsNavItem];
}
function appendAboutNavItem(items: BottomNavItem[]): BottomNavItem[] {
if (route.path.startsWith("/about")) {
return items;
}
if (items.some((item) => item.path === aboutNavItem.path)) {
return items;
}
return [...items, aboutNavItem];
}
/**
* 底部工具条统一约束关于入口about.svg始终排在最后。
* 仅调整顺序,不改变各页面实际可见按钮集合。
*/
function ensureAboutNavItemLast(items: BottomNavItem[]): BottomNavItem[] {
const index = items.findIndex((item) => item.path === aboutNavItem.path);
if (index < 0 || index === items.length - 1) {
return items;
}
const next = items.slice();
const [aboutItem] = next.splice(index, 1);
if (!aboutItem) {
return items;
}
next.push(aboutItem);
return next;
}
const rightNavItems = computed<BottomNavItem[]>(() => {
let items: BottomNavItem[];
if (route.path === "/connect") {
items = appendAboutNavItem(
appendRecordsNavItem([
{ path: "/settings", label: "全局配置", icon: "/icons/config.svg" },
{ path: "/logs", label: "日志", icon: "/icons/log.svg?v=20260227" }
])
);
return ensureAboutNavItemLast(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 ensureAboutNavItemLast(appendAboutNavItem(appendRecordsNavItem(items)));
}
if (route.path === "/plugins") {
items = appendAboutNavItem(
appendRecordsNavItem([
{ path: "/connect", label: "服务器管理", icon: "/icons/serverlist.svg" },
{ path: "/settings", label: "全局配置", icon: "/icons/config.svg" }
])
);
return ensureAboutNavItemLast(items);
}
if (route.path === "/terminal") {
items = appendAboutNavItem(
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 ensureAboutNavItemLast(items);
}
if (route.path === "/records") {
items = appendAboutNavItem([
{ path: "/connect", label: "服务器管理", icon: "/icons/serverlist.svg" },
{ path: "/settings", label: "全局配置", icon: "/icons/config.svg" },
{ path: "/logs", label: "日志", icon: "/icons/log.svg?v=20260227" }
]);
return ensureAboutNavItemLast(items);
}
if (route.path.startsWith("/about")) {
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 ensureAboutNavItemLast(items);
}
items = appendAboutNavItem(
appendRecordsNavItem([
{ path: "/connect", label: "服务器管理", icon: "/icons/serverlist.svg" },
{ path: "/settings", label: "全局配置", icon: "/icons/config.svg" }
])
);
return ensureAboutNavItemLast(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);
}
/**
* 同步浏览器/宿主顶栏颜色,避免顶部工具栏上方区域停留在旧主题色。
*/
applyThemeChromeColor(
resolveThemeChromeColor(vars),
document as unknown as ThemeChromeDocument<HTMLMetaElement>
);
},
{ 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 {
if (path === "/about") {
return route.path.startsWith("/about");
}
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>