feat(frontend): support a draggable title bar in the desktop PWA

This commit is contained in:
Zyy
2026-11-14 19:44:12 +08:00
parent d13a4a2138
commit 779ccf4e6c
7 changed files with 85 additions and 2 deletions
+2
View File
@@ -389,6 +389,8 @@ PostgreSQL/Redis 启动密码。日常校验使用 `config --quiet`
使用 Chrome、Edge 等支持 PWA 的浏览器访问管理端,可通过地址栏或浏览器菜单安装应用,安装后以独立窗口打开站点首页,沿用现有登录流程
在支持自定义标题栏的桌面浏览器中,可用窗口右上角的按钮显示或隐藏系统标题栏。隐藏后仍可拖动顶部空白区域移动窗口
安装入口要求 HTTPS,或本机的 `http://localhost`、`http://127.0.0.1`;普通 HTTP 局域网地址不满足条件。入口是否显示还取决于浏览器、访问交互及是否已安装。应用需要联网访问网关,不提供离线使用
### 启动后的运行设置
+5
View File
@@ -8,6 +8,11 @@
/>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="manifest" href="/manifest.webmanifest" />
<meta
name="theme-color"
media="(display-mode: standalone), (display-mode: window-controls-overlay)"
content="#ffffff"
/>
<title>Codex Proxy RS</title>
</head>
<body>
+1
View File
@@ -5,6 +5,7 @@
"start_url": "/",
"scope": "/",
"display": "standalone",
"display_override": ["window-controls-overlay"],
"icons": [
{
"src": "/favicon.svg",
+37
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { ZConfigProvider } from '@codex-proxy/ui'
import { useDebounceFn, useDevicePixelRatio, useEventListener, useWindowSize } from '@vueuse/core'
import { watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { RouterView } from 'vue-router'
@@ -7,11 +8,47 @@ import { applyLocale } from '@/i18n/locale'
import { uiLocale } from '@/i18n/ui'
const { locale } = useI18n({ useScope: 'global' })
const overlay = (navigator as Navigator & { windowControlsOverlay?: { getTitlebarAreaRect: () => DOMRect } }).windowControlsOverlay
if (overlay && matchMedia('(display-mode: standalone), (display-mode: window-controls-overlay)').matches) {
const root = document.documentElement
root.classList.add('pwa-window')
const { height } = useWindowSize({ type: 'outer' })
const { pixelRatio } = useDevicePixelRatio()
const rememberHeight = () => {
const viewport = window.visualViewport?.height ?? window.innerHeight
const next = viewport - overlay.getTitlebarAreaRect().bottom
const previous = Number.parseFloat(root.style.getPropertyValue('--app-height'))
if (!Number.isFinite(previous) || Math.abs(next - previous) >= 1)
root.style.setProperty('--app-height', `${next}px`)
}
// 调整外窗时立即交回 CSS;macOS 还原动画中,外窗和内容视口可能在不同帧才停止变化
watch([height, pixelRatio], () => root.style.removeProperty('--app-height'))
const settled = useDebounceFn(rememberHeight, 80)
useEventListener(window.visualViewport ?? window, 'resize', settled)
rememberHeight()
}
watch(locale, applyLocale, { immediate: true })
</script>
<template>
<ZConfigProvider :locale="uiLocale">
<div class="pwa-titlebar hidden bg-z-bg-container" aria-hidden="true" />
<RouterView />
</ZConfigProvider>
</template>
<style scoped>
@media (display-mode: window-controls-overlay) {
.pwa-titlebar {
display: block;
position: fixed;
z-index: 51;
top: env(titlebar-area-y);
left: env(titlebar-area-x);
width: env(titlebar-area-width);
height: env(titlebar-area-height);
-webkit-app-region: drag;
}
}
</style>
+2 -2
View File
@@ -78,7 +78,7 @@ watch(
<main class="relative isolate h-dvh min-w-0 flex-1 overflow-hidden">
<!-- 让主滚动区自身绘制不透明底色,浏览器可在合成线程滚动 -->
<ZScrollbar ref="pageScrollbarRef" class="[&>.z-scrollbar-hidden]:bg-z-bg-layout">
<div class="flex min-h-full min-w-0 flex-col p-4 min-[961px]:p-6">
<div class="page-slot flex min-h-full min-w-0 flex-col p-4 min-[961px]:p-6">
<RouterView v-slot="{ Component }">
<component :is="Component" class="min-h-0 flex-1" />
</RouterView>
@@ -88,7 +88,7 @@ watch(
<Teleport to="body">
<Transition name="mobile-sidebar" @after-leave="restoreFocus">
<div v-if="mobileSidebarOpen" :inert="!mobileSidebarOpen" role="presentation" class="fixed inset-0 z-50 min-[961px]:hidden" @keydown="handleKeydown">
<div v-if="mobileSidebarOpen" :inert="!mobileSidebarOpen" role="presentation" class="fixed inset-x-0 top-(--app-titlebar-inset,0px) bottom-0 z-50 min-[961px]:hidden" @keydown="handleKeydown">
<button
type="button"
tabindex="-1"
+1
View File
@@ -130,6 +130,7 @@ export const useThemeStore = defineStore(
function commitTheme(theme: Theme): void {
applyTheme(document.documentElement, theme)
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', theme.tokens['color-bg-container'])
appliedThemeSignature = themeSignature(theme)
themeRevision.value += 1
}
+37
View File
@@ -1,3 +1,40 @@
@import 'tailwindcss/theme.css' layer(theme);
@import '@codex-proxy/ui/tailwind.css';
@import 'tailwindcss/utilities.css' layer(utilities);
.pwa-window {
--app-height: calc(100dvh - env(titlebar-area-y, 0px) - env(titlebar-area-height, 0px));
--app-titlebar-inset: max(0px, 100dvh - var(--app-height));
}
.pwa-window,
.pwa-window body {
background-color: var(--z-color-bg-container);
}
.pwa-window body {
padding-top: var(--app-titlebar-inset);
}
.pwa-window .h-dvh,
.pwa-window .page-slot {
height: var(--app-height);
}
.pwa-window .min-h-dvh {
min-height: var(--app-height);
}
/* 满屏面板不能高于正文;矮窗仍由外层滚动到列表底部 */
.pwa-window .page-slot > * {
max-height: 100%;
}
.pwa-window .page-slot > .h-full {
overflow: visible;
}
/* 通知挂在 body,固定定位不会避开标题栏 */
.pwa-window article[id^='z-notification-'] {
translate: 0 var(--app-titlebar-inset);
}