feat: 添加新手引导功能和侧边栏折叠功能

This commit is contained in:
Yuhang Wu
2026-10-26 11:29:43 +08:00
parent fafc87f4c6
commit 93b15ccb16
16 changed files with 520 additions and 23 deletions
+71
View File
@@ -0,0 +1,71 @@
<template>
<a-tour
:open="onboarding.active && visible"
:current="current"
:steps="steps"
:gap="{ offset: 8, radius: 10 }"
:mask="{ color: 'rgba(15, 23, 42, 0.55)' }"
@change="changeStep"
@close="onboarding.close"
/>
</template>
<script setup lang="ts">
import type { TourProps } from "ant-design-vue"
import { useI18n } from "vue-i18n"
import { useOnboardingStore } from "@/stores/onboarding"
const { t } = useI18n()
const router = useRouter()
const onboarding = useOnboardingStore()
const current = ref(0)
const visible = ref(false)
const navigating = ref(false)
const target = shallowRef<HTMLElement>()
const destinations = [
{ path: "/servers", target: "server-create", key: "servers" },
{ path: "/tunnels", target: "tunnel-create", key: "tunnels" },
{ path: "/download", target: "frp-download", key: "download" },
{ path: "/dashboard", target: "dashboard-start", key: "dashboard" },
] as const
const steps = computed<TourProps["steps"]>(() => destinations.map((step, index) => ({
title: t(`onboarding.${step.key}Title`),
description: t(`onboarding.${step.key}Description`),
target: index === current.value ? target.value : undefined,
placement: "bottom",
})))
async function changeStep(index: number) {
const destination = destinations[index]
if (!destination || navigating.value) return
navigating.value = true
visible.value = false
try {
await router.push(destination.path)
await nextTick()
current.value = index
target.value = document.querySelector<HTMLElement>(`[data-tour="${destination.target}"]`) ?? undefined
visible.value = onboarding.active
} catch (error) {
onboarding.close()
console.error("Unable to open onboarding page", error)
} finally {
navigating.value = false
}
}
watch(() => onboarding.active, (active) => {
if (active) void changeStep(0)
else visible.value = false
})
watch(() => onboarding.ready, (ready) => {
if (ready && !onboarding.dismissed) onboarding.start()
}, { immediate: true })
// Antdv 提供关闭按钮;同时允许键盘用户通过 Escape 跳过引导。
onKeyStroke("Escape", () => {
if (onboarding.active) onboarding.close()
})
</script>
+18
View File
@@ -28,6 +28,24 @@
"download": "Frp Download",
"setting": "Settings"
},
"sidebar": {
"navigation": "Main navigation",
"collapse": "Collapse navigation",
"expand": "Expand navigation"
},
"onboarding": {
"title": "Getting started",
"replay": "View guide",
"replayDescription": "Learn the complete setup and startup workflow in four steps",
"serversTitle": "1 / 4 · Configure a server",
"serversDescription": "Choose Add Server, enter your deployed frps address, port and authentication token, then enable the server. Only one server needs to be enabled at a time.",
"tunnelsTitle": "2 / 4 · Create a tunnel",
"tunnelsDescription": "Choose Add Tunnel, select a proxy type such as TCP or HTTP, and enter your local service IP and port, plus a remote port or domain. Save and enable the tunnel. Multiple tunnels can run together.",
"downloadTitle": "3 / 4 · Download Frp",
"downloadDescription": "Filter by your system and architecture, then download Frp from a version card below. You can also import an existing local frpc. Downloaded or imported versions appear in the dashboard selector.",
"dashboardTitle": "4 / 4 · Start from the dashboard",
"dashboardDescription": "After setup, select a downloaded Frpc version and click Start to connect. Check the live logs and running status below to verify the connection. You are ready to configure your connection. Replay this guide anytime in Settings → General."
},
"tray": {
"show": "Show window",
"start": "Start tunnel",
+18
View File
@@ -28,6 +28,24 @@
"download": "Frp下载",
"setting": "设置"
},
"sidebar": {
"navigation": "主导航",
"collapse": "折叠导航",
"expand": "展开导航"
},
"onboarding": {
"title": "新手引导",
"replay": "重新查看",
"replayDescription": "用四个步骤了解从配置到启动的完整流程",
"serversTitle": "1 / 4 · 配置服务器",
"serversDescription": "从「新增服务器」开始,填写已部署的 frps 地址、端口和鉴权 Token,并启用这台服务器。同一时间只需启用一台。",
"tunnelsTitle": "2 / 4 · 创建隧道",
"tunnelsDescription": "点击「新增隧道」,选择 TCP、HTTP 等代理类型,填写本地服务的 IP 和端口,以及远程端口或域名,保存并启用隧道。可以同时启用多个隧道。",
"downloadTitle": "3 / 4 · 下载 Frp",
"downloadDescription": "按当前系统和架构筛选版本,在下方版本卡片中下载 Frp;也可以通过「导入本地」使用已有的 frpc。下载或导入后,即可在仪表盘中选择该版本。",
"dashboardTitle": "4 / 4 · 仪表盘一键启动",
"dashboardDescription": "完成前面的配置后,选择已下载的 Frpc 版本,点击「一键启动」开始穿透。下方实时日志和运行状态可帮助确认连接情况。现在可以开始配置了,也可随时在「设置 → 常规」重新查看引导。"
},
"tray": {
"show": "显示窗口",
"start": "启动穿透",
+12 -3
View File
@@ -23,6 +23,7 @@
<!-- 关闭确认对话框(最小化到系统托盘 / 直接退出) -->
<CloseConfirmModal />
<OnboardingTour />
</div>
</template>
@@ -32,9 +33,12 @@ import Aside from "./aside/Aside.vue"
import UpdateModal from "@/components/UpdateModal.vue"
import CloseConfirmModal from "@/components/CloseConfirmModal.vue"
import { useUpdateStore } from "@/stores/update"
import { useOnboardingStore } from "@/stores/onboarding"
import OnboardingTour from "@/components/OnboardingTour.vue"
const router = useRouter()
const updateStore = useUpdateStore()
const onboarding = useOnboardingStore()
/**
* 需要 keep-alive 缓存的页面组件名列表(由路由 meta.isCache 决定)。
@@ -55,7 +59,12 @@ function goToSetting() {
}
// 应用启动后静默检查一次更新(已忽略的版本不会打扰用户)
onMounted(() => {
updateStore.checkForUpdate(true)
})
const stopUpdateWatch = watch(
() => onboarding.ready && onboarding.dismissed && !onboarding.active,
(canCheck) => {
if (!canCheck) return
void updateStore.checkForUpdate(true)
stopUpdateWatch()
},
)
</script>
+88 -3
View File
@@ -1,14 +1,19 @@
<template>
<!-- 侧边栏:磨砂玻璃悬浮面板 -->
<aside class="glass-panel flex w-56 shrink-0 flex-col overflow-hidden">
<aside
id="app-sidebar"
class="app-sidebar glass-panel flex shrink-0 flex-col overflow-hidden"
:class="{ 'is-collapsed': appStore.sidebarCollapsed }"
:aria-label="t('sidebar.navigation')"
>
<!-- 菜单区 -->
<div class="flex-1 overflow-y-auto p-2">
<a-menu class="aside-menu" mode="inline" :selected-keys="selectedKeys" :items="menuItems" @click="onMenuClick" />
<a-menu class="aside-menu" mode="inline" :inline-collapsed="appStore.sidebarCollapsed" :selected-keys="selectedKeys" :items="menuItems" @click="onMenuClick" />
</div>
<!-- 底部系统信息区(与菜单之间有间隔) -->
<div class="system-panel mt-2 border-t px-2 pb-2 pt-3">
<div class="px-1">
<div v-if="!appStore.sidebarCollapsed" class="px-1">
<!-- CPU -->
<div class="mb-2">
<div class="mb-1 flex items-center justify-between text-xs">
@@ -35,15 +40,38 @@
</div>
</div>
<a-tooltip v-else placement="right" :title="`${t('system.cpu')}: ${cpu}% · ${t('system.memory')}: ${memory}% · ${statusText}`">
<div class="flex h-10 items-center justify-center" tabindex="0" :aria-label="`${t('system.cpu')}: ${cpu}% · ${t('system.memory')}: ${memory}% · ${statusText}`">
<span class="h-2 w-2 rounded-full" :class="statusDotClass" />
</div>
</a-tooltip>
<!-- 设置入口(位于运行状态下方) -->
<div class="system-divider mt-3 border-t pt-2">
<a-menu
class="aside-menu"
mode="inline"
:inline-collapsed="appStore.sidebarCollapsed"
:selected-keys="settingSelectedKeys"
:items="settingItems"
@click="onMenuClick"
/>
<a-tooltip placement="right" :title="collapseLabel">
<a-button
class="sidebar-toggle"
type="text"
:aria-label="collapseLabel"
:aria-expanded="!appStore.sidebarCollapsed"
aria-controls="app-sidebar"
@click="appStore.sidebarCollapsed = !appStore.sidebarCollapsed"
>
<template #icon>
<MenuUnfoldOutlined v-if="appStore.sidebarCollapsed" />
<MenuFoldOutlined v-else />
</template>
<span v-if="!appStore.sidebarCollapsed">{{ collapseLabel }}</span>
</a-button>
</a-tooltip>
</div>
</div>
</aside>
@@ -56,6 +84,8 @@ import {
CloudServerOutlined,
DashboardOutlined,
DownloadOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
SettingOutlined,
} from "@ant-design/icons-vue"
import { useI18n } from "vue-i18n"
@@ -67,6 +97,7 @@ const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const appStore = useAppStore()
const collapseLabel = computed(() => t(appStore.sidebarCollapsed ? 'sidebar.expand' : 'sidebar.collapse'))
const { cpu, memory } = useSystemStats()
@@ -121,6 +152,60 @@ const statusText = computed(() => {
</script>
<style scoped lang="scss">
.app-sidebar {
width: 224px;
transition: width 200ms ease;
&.is-collapsed {
width: 72px;
}
}
.sidebar-toggle {
width: 100%;
height: 44px;
border-radius: 10px;
}
.sidebar-toggle:focus-visible {
outline: 2px solid #6366f1;
outline-offset: -2px;
}
.is-collapsed .aside-menu {
width: 100%;
border-inline-end: none;
:deep(.ant-menu-item) {
display: flex;
align-items: center;
justify-content: center;
padding-inline: 0 !important;
height: 44px;
line-height: 44px;
}
:deep(.ant-menu-item-icon) {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 16px;
margin: 0;
font-size: 16px;
line-height: 1;
}
:deep(.ant-menu-title-content) {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.app-sidebar {
transition: none;
}
}
.system-panel {
border-color: rgba(15, 23, 42, 0.08);
}
+17 -1
View File
@@ -10,6 +10,8 @@ import router from "@/router/router"
// 引入国际化
import i18n from "@/i18n"
import { detectPlatform } from "@/composables/usePlatform"
import { nextTick } from "vue"
import { useOnboardingStore } from "@/stores/onboarding"
/*
* macOS 的无边框窗口系统不会自动裁圆角,需要配合 transparent 窗口,
@@ -25,4 +27,18 @@ app.use(createPinia())
app.use(Antd)
app.use(i18n)
app.use(router)
app.mount("#app")
// 路由懒加载完成后再挂载,避免启动画面移除后出现空白内容区。
async function mountApp() {
await router.isReady()
app.mount("#app")
await nextTick()
// 让浏览器先绘制完整布局,再揭开启动画面并开始首次引导。
requestAnimationFrame(() => {
requestAnimationFrame(() => {
document.getElementById("startup-loader")?.remove()
useOnboardingStore().ready = true
})
})
}
void mountApp()
+3
View File
@@ -18,6 +18,8 @@ export const useAppStore = defineStore("app", () => {
// 语言(持久化到 localStorage)
const language = useStorage<Language>("frpcpilot:language", "zh-CN")
const sidebarCollapsed = useStorage("frpcpilot:sidebar-collapsed", false)
// 明暗主题:useDark 会在 <html> 上切换 .dark 类名
const isDark = useDark({
storageKey: "frpcpilot:theme",
@@ -93,6 +95,7 @@ export const useAppStore = defineStore("app", () => {
return {
language,
sidebarCollapsed,
isDark,
toggleDark,
background,
+18
View File
@@ -0,0 +1,18 @@
import { defineStore } from "pinia"
export const useOnboardingStore = defineStore("onboarding", () => {
const dismissed = useStorage("frpcpilot:onboarding-dismissed", false)
const ready = ref(false)
const active = ref(false)
function start() {
active.value = true
}
function close() {
active.value = false
dismissed.value = true
}
return { dismissed, ready, active, start, close }
})
+2
View File
@@ -46,11 +46,13 @@ declare module 'vue' {
ATag: typeof import('ant-design-vue/es')['Tag']
ATextarea: typeof import('ant-design-vue/es')['Textarea']
ATooltip: typeof import('ant-design-vue/es')['Tooltip']
ATour: typeof import('ant-design-vue/es')['Tour']
CardRadioGroup: typeof import('./../components/CardRadioGroup.vue')['default']
CloseConfirmModal: typeof import('./../components/CloseConfirmModal.vue')['default']
FrpCard: typeof import('./../components/frp/FrpCard.vue')['default']
MacTitleBar: typeof import('./../components/MacTitleBar.vue')['default']
MarkdownBlock: typeof import('./../components/MarkdownBlock.vue')['default']
OnboardingTour: typeof import('./../components/OnboardingTour.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
ServerCard: typeof import('./../components/server/ServerCard.vue')['default']
+1
View File
@@ -83,6 +83,7 @@
<!-- 快速操作 -->
<div
data-tour="dashboard-start"
class="glass-panel glass-panel-flat flex shrink-0 flex-wrap items-center justify-between gap-3 px-5 py-3"
>
<div class="flex items-center gap-3">
+1
View File
@@ -2,6 +2,7 @@
<div class="flex h-full flex-col gap-3">
<!-- 顶部操作栏(独立磨砂卡片) -->
<div
data-tour="frp-download"
class="glass-panel glass-panel-flat flex shrink-0 flex-wrap items-center justify-between gap-3 px-5 py-3"
>
<div class="flex items-center gap-2">
+1 -1
View File
@@ -7,7 +7,7 @@
<a-button :loading="loading" :icon="h(ReloadOutlined)" @click="refresh()">
{{ t("server.refresh") }}
</a-button>
<a-button type="primary" :icon="h(PlusOutlined)" @click="openCreate">
<a-button data-tour="server-create" type="primary" :icon="h(PlusOutlined)" @click="openCreate">
{{ t("server.add") }}
</a-button>
</a-space>
+1 -1
View File
@@ -7,7 +7,7 @@
<a-button :loading="loading" :icon="h(ReloadOutlined)" @click="refresh()">
{{ t("tunnel.refresh") }}
</a-button>
<a-button type="primary" :icon="h(PlusOutlined)" @click="openCreate">
<a-button data-tour="tunnel-create" type="primary" :icon="h(PlusOutlined)" @click="openCreate">
{{ t("tunnel.add") }}
</a-button>
</a-space>
+27 -13
View File
@@ -3,6 +3,31 @@
<h2 class="mb-1 text-lg font-semibold">{{ t("settings.general.title") }}</h2>
<p class="mb-5 text-xs opacity-60">{{ t("settings.general.subtitle") }}</p>
<!-- 重新开始引导:与引导弹窗一致,可在此再次触发 -->
<section class="flex items-center justify-between gap-4">
<div>
<div class="text-sm font-medium">{{ t('onboarding.title') }}</div>
<p class="mt-1 text-xs opacity-60">{{ t('onboarding.replayDescription') }}</p>
</div>
<a-button class="shrink-0" @click="onboarding.start">{{ t('onboarding.replay') }}</a-button>
</section>
<a-divider class="general-divider" />
<!-- 语言:与顶部栏语言切换共用同一状态 -->
<section>
<div class="mb-1 text-sm font-medium">{{ t("settings.general.language") }}</div>
<p class="mb-2 text-xs opacity-60">{{ t("settings.general.languageTip") }}</p>
<a-radio-group
v-model:value="appStore.language"
:options="languageRadios"
option-type="button"
button-style="solid"
/>
</section>
<a-divider class="general-divider" />
<!-- 开机自启:开关直接写入系统启动项,状态由系统决定 -->
<section>
<div class="flex items-center justify-between gap-4">
@@ -34,19 +59,6 @@
<CardRadioGroup v-model="windowStore.closeChoice" :options="choiceOptions" />
</section>
<a-divider class="general-divider" />
<!-- 语言:与顶部栏语言切换共用同一状态 -->
<section>
<div class="mb-1 text-sm font-medium">{{ t("settings.general.language") }}</div>
<p class="mb-2 text-xs opacity-60">{{ t("settings.general.languageTip") }}</p>
<a-radio-group
v-model:value="appStore.language"
:options="languageRadios"
option-type="button"
button-style="solid"
/>
</section>
</div>
</template>
@@ -56,11 +68,13 @@ import { languageOptions } from "@/i18n"
import { useAppStore } from "@/stores/app"
import { useAutostartStore } from "@/stores/autostart"
import { useWindowStore } from "@/stores/window"
import { useOnboardingStore } from "@/stores/onboarding"
const { t } = useI18n()
const appStore = useAppStore()
const autostartStore = useAutostartStore()
const windowStore = useWindowStore()
const onboarding = useOnboardingStore()
const behaviorOptions = computed(() => [
{ value: "ask" as const, label: t("closeDialog.ask"), description: t("closeDialog.askDesc") },