feat: 添加新手引导功能和侧边栏折叠功能
This commit is contained in:
@@ -71,6 +71,150 @@ Layout布局content区域的磨砂玻璃卡片圆角样式我要改动一下:
|
|||||||
- 设置页面里面也有一些没有随主题改变颜色
|
- 设置页面里面也有一些没有随主题改变颜色
|
||||||
- 整体全部检查并且修复一下
|
- 整体全部检查并且修复一下
|
||||||
|
|
||||||
|
#### 2.7 侧边栏折叠和漫游式引导
|
||||||
|
|
||||||
|
- 侧边导航栏在设置下面增加一个折叠/展开的图标,点击用于控制侧边导航栏的折叠和展开
|
||||||
|
- 侧边导航栏折叠之后,需要注意折叠之后的图标居中显示,并且鼠标放上去之后的hover效果显示的时候,图标也要在hover效果里面居中
|
||||||
|
- 官方参考代码如下:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<template>
|
||||||
|
<div style="width: 256px">
|
||||||
|
<a-button type="primary" style="margin-bottom: 16px" @click="toggleCollapsed">
|
||||||
|
<MenuUnfoldOutlined v-if="state.collapsed" />
|
||||||
|
<MenuFoldOutlined v-else />
|
||||||
|
</a-button>
|
||||||
|
<a-menu
|
||||||
|
v-model:openKeys="state.openKeys"
|
||||||
|
v-model:selectedKeys="state.selectedKeys"
|
||||||
|
mode="inline"
|
||||||
|
theme="dark"
|
||||||
|
:inline-collapsed="state.collapsed"
|
||||||
|
:items="items"
|
||||||
|
></a-menu>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { reactive, watch, h } from 'vue';
|
||||||
|
import {
|
||||||
|
MenuFoldOutlined,
|
||||||
|
MenuUnfoldOutlined,
|
||||||
|
PieChartOutlined,
|
||||||
|
MailOutlined,
|
||||||
|
DesktopOutlined,
|
||||||
|
InboxOutlined,
|
||||||
|
AppstoreOutlined,
|
||||||
|
} from '@ant-design/icons-vue';
|
||||||
|
const state = reactive({
|
||||||
|
collapsed: false,
|
||||||
|
selectedKeys: ['1'],
|
||||||
|
openKeys: ['sub1'],
|
||||||
|
preOpenKeys: ['sub1'],
|
||||||
|
});
|
||||||
|
const items = reactive([
|
||||||
|
{
|
||||||
|
key: '1',
|
||||||
|
icon: () => h(PieChartOutlined),
|
||||||
|
label: 'Option 1',
|
||||||
|
title: 'Option 1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '2',
|
||||||
|
icon: () => h(DesktopOutlined),
|
||||||
|
label: 'Option 2',
|
||||||
|
title: 'Option 2',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '3',
|
||||||
|
icon: () => h(InboxOutlined),
|
||||||
|
label: 'Option 3',
|
||||||
|
title: 'Option 3',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sub1',
|
||||||
|
icon: () => h(MailOutlined),
|
||||||
|
label: 'Navigation One',
|
||||||
|
title: 'Navigation One',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: '5',
|
||||||
|
label: 'Option 5',
|
||||||
|
title: 'Option 5',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '6',
|
||||||
|
label: 'Option 6',
|
||||||
|
title: 'Option 6',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '7',
|
||||||
|
label: 'Option 7',
|
||||||
|
title: 'Option 7',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '8',
|
||||||
|
label: 'Option 8',
|
||||||
|
title: 'Option 8',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sub2',
|
||||||
|
icon: () => h(AppstoreOutlined),
|
||||||
|
label: 'Navigation Two',
|
||||||
|
title: 'Navigation Two',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: '9',
|
||||||
|
label: 'Option 9',
|
||||||
|
title: 'Option 9',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '10',
|
||||||
|
label: 'Option 10',
|
||||||
|
title: 'Option 10',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sub3',
|
||||||
|
label: 'Submenu',
|
||||||
|
title: 'Submenu',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: '11',
|
||||||
|
label: 'Option 11',
|
||||||
|
title: 'Option 11',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '12',
|
||||||
|
label: 'Option 12',
|
||||||
|
title: 'Option 12',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
watch(
|
||||||
|
() => state.openKeys,
|
||||||
|
(_val, oldVal) => {
|
||||||
|
state.preOpenKeys = oldVal;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const toggleCollapsed = () => {
|
||||||
|
state.collapsed = !state.collapsed;
|
||||||
|
state.openKeys = state.collapsed ? [] : state.preOpenKeys;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
- 第一次进入应用时,使用Antdv的漫游式引导组件做一个引导操作
|
||||||
|
- 漫游式引导的目的是帮助用户快速上手FrpcPilot的功能使用流程
|
||||||
|
- 配置服务器、创建隧道、下载Frp、仪表盘一键启动这是主要的四步流程,你可以按照这个来做引导
|
||||||
|
- 打开应用时,当主界面未显示完整(还是白屏的时候),做一个启动加载动画,显示“FrpcPilot正在启动中”
|
||||||
|
- 加载动画可以是一圈正在转圈的圆点,圆点大小要有变化,颜色在加载的过程中也可以有变化,最好是做成svg动画的形式,或者你有更好的方案也可以采用
|
||||||
|
|
||||||
|
开发过程中使用到的组件尽可能全部使用Antdv库里面的,另外我引入的有tailwindcss原子化组件、auto-import和components依赖可以简化引入,开发之前尽可能先阅读一下package.json,看一下有哪些依赖可以简化代码开发
|
||||||
|
|
||||||
### 3. 功能开发
|
### 3. 功能开发
|
||||||
|
|
||||||
#### 3.1 开发服务器配置功能
|
#### 3.1 开发服务器配置功能
|
||||||
@@ -189,3 +333,27 @@ Layout布局content区域的磨砂玻璃卡片圆角样式我要改动一下:
|
|||||||
- 其他与项目名称有关的也修改一下
|
- 其他与项目名称有关的也修改一下
|
||||||
|
|
||||||
先扫描一下有哪些需要修改的,列一个清单出来,经过我的确认之后再开始修改
|
先扫描一下有哪些需要修改的,列一个清单出来,经过我的确认之后再开始修改
|
||||||
|
|
||||||
|
#### 4.2 Frpc下载功能修改
|
||||||
|
|
||||||
|
保持Frpc下载主要功能不变,现在需要对检索结果进行过滤方面的修改,具体如下:
|
||||||
|
|
||||||
|
- 去掉当前的两个开关选项“所有架构frpc”和“所有架构frps”
|
||||||
|
- 换成一个下拉列表,列表值为frp的所有不同架构分类,命名方式为:系统类型+下划线+架构,例如:windows_amd64、darwin_amd64、linux_amd64、linux_arm等等,默认值为当前运行环境操作系统类型和架构,最顶上有一个“全部”选项用来显示所有
|
||||||
|
- 如果是选中“全部”,排列顺序为按照frp版本,从高到低显示同一个版本不同系统和架构的frp,再显示低版本的
|
||||||
|
- 修改过程中保持主要的下载功能不变,包括加速方案
|
||||||
|
- 左侧菜单栏改成Frp下载
|
||||||
|
|
||||||
|
开发过程中使用到的组件尽可能全部使用Antdv库里面的,另外我引入的有tailwindcss原子化组件、auto-import和components依赖可以简化引入,开发之前尽可能先阅读一下package.json,看一下有哪些依赖可以简化代码开发
|
||||||
|
|
||||||
|
#### 4.3 程序运行数据目录
|
||||||
|
|
||||||
|
当前软件运行的数据在我这台电脑上都在:C:\Users\OrBit-0297\AppData\Roaming\FrpcPilot这个目录里面,我想要修改为数据保存在软件安装所在目录,例如我这台电脑安装在:D:\software\FrpcPilot,按照以下要求修改数据保存的目录:
|
||||||
|
|
||||||
|
- 数据库文件、下载的frp全部保存在软件安装所在目录里面,并做好分类,例如:db数据库文件放在data目录里面,下载的frp放在frp目录里面,运行frpc生成的toml文件放在run目录里面
|
||||||
|
- 程序运行时生成的其他文件等也放在软件安装目录下面
|
||||||
|
- 我这样做的目的是假如安装位置不在C盘,就尽量减少C盘的占用,如果本身就安装在C盘的某个位置,上面说的那些文件保存路径也是以软件安装目录为准,避免放在默认目录里面
|
||||||
|
- 顺便准备一份迁移脚本,在下一个版本升级的时候便于老版本用户无感迁移之前的数据,注意新安装用户不需要使用这个迁移脚本
|
||||||
|
- 我想到的方案是脚本,如果你有更好的方案的话,那就采用你的方案,如果没有,就按照我的要求来做,同时你需要考虑到我遗漏的地方
|
||||||
|
- 数据迁移之后,可能还需要处理数据库中保存的frp软件目录所在位置
|
||||||
|
- 迁移脚本需要考虑不同操作系统之间的差别
|
||||||
|
|||||||
+74
-1
@@ -1,13 +1,86 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>FrpcPilot</title>
|
<title>FrpcPilot</title>
|
||||||
|
<!-- 首屏样式内联,脚本和样式资源尚未加载时也能显示启动状态。 -->
|
||||||
|
<style>
|
||||||
|
#startup-loader {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 20px;
|
||||||
|
background: radial-gradient(ellipse at 50% 40%, #e0e7ff, #f8fafc 70%);
|
||||||
|
color: #334155;
|
||||||
|
font: 500 15px/1.6 'Segoe UI', 'Microsoft YaHei', sans-serif;
|
||||||
|
}
|
||||||
|
#startup-loader[data-theme="dark"] {
|
||||||
|
background: radial-gradient(ellipse at 50% 40%, #1e1b4b, #0f172a 70%);
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
#startup-loader svg { width: 76px; height: 76px; overflow: visible; }
|
||||||
|
#startup-loader .startup-ring {
|
||||||
|
transform-origin: 40px 40px;
|
||||||
|
animation: startup-orbit 3.6s linear infinite;
|
||||||
|
}
|
||||||
|
#startup-loader circle {
|
||||||
|
fill: #6366f1;
|
||||||
|
transform-box: fill-box;
|
||||||
|
transform-origin: center;
|
||||||
|
animation: startup-dot 1.6s ease-in-out infinite;
|
||||||
|
animation-delay: var(--delay);
|
||||||
|
}
|
||||||
|
@keyframes startup-orbit { to { transform: rotate(360deg); } }
|
||||||
|
@keyframes startup-dot {
|
||||||
|
0%, 100% { transform: scale(.55); opacity: .35; fill: #6366f1; }
|
||||||
|
50% { transform: scale(1.1); opacity: 1; fill: #06b6d4; }
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
#startup-loader:not([data-theme="light"]) {
|
||||||
|
background: radial-gradient(ellipse at 50% 40%, #1e1b4b, #0f172a 70%);
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
#startup-loader .startup-ring, #startup-loader circle { animation: none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
<div id="startup-loader" role="status" aria-live="polite" aria-busy="true" data-tauri-drag-region>
|
||||||
|
<svg viewBox="0 0 80 80" aria-hidden="true">
|
||||||
|
<g class="startup-ring">
|
||||||
|
<circle cx="40" cy="12" r="5" style="--delay: -1.4s" />
|
||||||
|
<circle cx="60" cy="20" r="5" style="--delay: -1.2s" />
|
||||||
|
<circle cx="68" cy="40" r="5" style="--delay: -1s" />
|
||||||
|
<circle cx="60" cy="60" r="5" style="--delay: -.8s" />
|
||||||
|
<circle cx="40" cy="68" r="5" style="--delay: -.6s" />
|
||||||
|
<circle cx="20" cy="60" r="5" style="--delay: -.4s" />
|
||||||
|
<circle cx="12" cy="40" r="5" style="--delay: -.2s" />
|
||||||
|
<circle cx="20" cy="20" r="5" style="--delay: 0s" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<span id="startup-label">FrpcPilot正在启动中</span>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
try {
|
||||||
|
const savedTheme = localStorage.getItem('frpcpilot:theme');
|
||||||
|
if (savedTheme === 'dark' || savedTheme === 'light') {
|
||||||
|
document.getElementById('startup-loader').dataset.theme = savedTheme;
|
||||||
|
}
|
||||||
|
if (localStorage.getItem('frpcpilot:language') === 'en-US') {
|
||||||
|
document.getElementById('startup-label').textContent = 'FrpcPilot is starting';
|
||||||
|
document.documentElement.lang = 'en';
|
||||||
|
}
|
||||||
|
} catch { /* 存储不可用时仍显示默认启动画面。 */ }
|
||||||
|
</script>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -28,6 +28,24 @@
|
|||||||
"download": "Frp Download",
|
"download": "Frp Download",
|
||||||
"setting": "Settings"
|
"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": {
|
"tray": {
|
||||||
"show": "Show window",
|
"show": "Show window",
|
||||||
"start": "Start tunnel",
|
"start": "Start tunnel",
|
||||||
|
|||||||
@@ -28,6 +28,24 @@
|
|||||||
"download": "Frp下载",
|
"download": "Frp下载",
|
||||||
"setting": "设置"
|
"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": {
|
"tray": {
|
||||||
"show": "显示窗口",
|
"show": "显示窗口",
|
||||||
"start": "启动穿透",
|
"start": "启动穿透",
|
||||||
|
|||||||
+12
-3
@@ -23,6 +23,7 @@
|
|||||||
|
|
||||||
<!-- 关闭确认对话框(最小化到系统托盘 / 直接退出) -->
|
<!-- 关闭确认对话框(最小化到系统托盘 / 直接退出) -->
|
||||||
<CloseConfirmModal />
|
<CloseConfirmModal />
|
||||||
|
<OnboardingTour />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -32,9 +33,12 @@ import Aside from "./aside/Aside.vue"
|
|||||||
import UpdateModal from "@/components/UpdateModal.vue"
|
import UpdateModal from "@/components/UpdateModal.vue"
|
||||||
import CloseConfirmModal from "@/components/CloseConfirmModal.vue"
|
import CloseConfirmModal from "@/components/CloseConfirmModal.vue"
|
||||||
import { useUpdateStore } from "@/stores/update"
|
import { useUpdateStore } from "@/stores/update"
|
||||||
|
import { useOnboardingStore } from "@/stores/onboarding"
|
||||||
|
import OnboardingTour from "@/components/OnboardingTour.vue"
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const updateStore = useUpdateStore()
|
const updateStore = useUpdateStore()
|
||||||
|
const onboarding = useOnboardingStore()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 需要 keep-alive 缓存的页面组件名列表(由路由 meta.isCache 决定)。
|
* 需要 keep-alive 缓存的页面组件名列表(由路由 meta.isCache 决定)。
|
||||||
@@ -55,7 +59,12 @@ function goToSetting() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 应用启动后静默检查一次更新(已忽略的版本不会打扰用户)
|
// 应用启动后静默检查一次更新(已忽略的版本不会打扰用户)
|
||||||
onMounted(() => {
|
const stopUpdateWatch = watch(
|
||||||
updateStore.checkForUpdate(true)
|
() => onboarding.ready && onboarding.dismissed && !onboarding.active,
|
||||||
})
|
(canCheck) => {
|
||||||
|
if (!canCheck) return
|
||||||
|
void updateStore.checkForUpdate(true)
|
||||||
|
stopUpdateWatch()
|
||||||
|
},
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
<template>
|
<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">
|
<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>
|
||||||
|
|
||||||
<!-- 底部系统信息区(与菜单之间有间隔) -->
|
<!-- 底部系统信息区(与菜单之间有间隔) -->
|
||||||
<div class="system-panel mt-2 border-t px-2 pb-2 pt-3">
|
<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 -->
|
<!-- CPU -->
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<div class="mb-1 flex items-center justify-between text-xs">
|
<div class="mb-1 flex items-center justify-between text-xs">
|
||||||
@@ -35,15 +40,38 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="system-divider mt-3 border-t pt-2">
|
||||||
<a-menu
|
<a-menu
|
||||||
class="aside-menu"
|
class="aside-menu"
|
||||||
mode="inline"
|
mode="inline"
|
||||||
|
:inline-collapsed="appStore.sidebarCollapsed"
|
||||||
:selected-keys="settingSelectedKeys"
|
:selected-keys="settingSelectedKeys"
|
||||||
:items="settingItems"
|
:items="settingItems"
|
||||||
@click="onMenuClick"
|
@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>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -56,6 +84,8 @@ import {
|
|||||||
CloudServerOutlined,
|
CloudServerOutlined,
|
||||||
DashboardOutlined,
|
DashboardOutlined,
|
||||||
DownloadOutlined,
|
DownloadOutlined,
|
||||||
|
MenuFoldOutlined,
|
||||||
|
MenuUnfoldOutlined,
|
||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
} from "@ant-design/icons-vue"
|
} from "@ant-design/icons-vue"
|
||||||
import { useI18n } from "vue-i18n"
|
import { useI18n } from "vue-i18n"
|
||||||
@@ -67,6 +97,7 @@ const { t } = useI18n()
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
|
const collapseLabel = computed(() => t(appStore.sidebarCollapsed ? 'sidebar.expand' : 'sidebar.collapse'))
|
||||||
|
|
||||||
const { cpu, memory } = useSystemStats()
|
const { cpu, memory } = useSystemStats()
|
||||||
|
|
||||||
@@ -121,6 +152,60 @@ const statusText = computed(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<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 {
|
.system-panel {
|
||||||
border-color: rgba(15, 23, 42, 0.08);
|
border-color: rgba(15, 23, 42, 0.08);
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-1
@@ -10,6 +10,8 @@ import router from "@/router/router"
|
|||||||
// 引入国际化
|
// 引入国际化
|
||||||
import i18n from "@/i18n"
|
import i18n from "@/i18n"
|
||||||
import { detectPlatform } from "@/composables/usePlatform"
|
import { detectPlatform } from "@/composables/usePlatform"
|
||||||
|
import { nextTick } from "vue"
|
||||||
|
import { useOnboardingStore } from "@/stores/onboarding"
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* macOS 的无边框窗口系统不会自动裁圆角,需要配合 transparent 窗口,
|
* macOS 的无边框窗口系统不会自动裁圆角,需要配合 transparent 窗口,
|
||||||
@@ -25,4 +27,18 @@ app.use(createPinia())
|
|||||||
app.use(Antd)
|
app.use(Antd)
|
||||||
app.use(i18n)
|
app.use(i18n)
|
||||||
app.use(router)
|
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()
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export const useAppStore = defineStore("app", () => {
|
|||||||
// 语言(持久化到 localStorage)
|
// 语言(持久化到 localStorage)
|
||||||
const language = useStorage<Language>("frpcpilot:language", "zh-CN")
|
const language = useStorage<Language>("frpcpilot:language", "zh-CN")
|
||||||
|
|
||||||
|
const sidebarCollapsed = useStorage("frpcpilot:sidebar-collapsed", false)
|
||||||
|
|
||||||
// 明暗主题:useDark 会在 <html> 上切换 .dark 类名
|
// 明暗主题:useDark 会在 <html> 上切换 .dark 类名
|
||||||
const isDark = useDark({
|
const isDark = useDark({
|
||||||
storageKey: "frpcpilot:theme",
|
storageKey: "frpcpilot:theme",
|
||||||
@@ -93,6 +95,7 @@ export const useAppStore = defineStore("app", () => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
language,
|
language,
|
||||||
|
sidebarCollapsed,
|
||||||
isDark,
|
isDark,
|
||||||
toggleDark,
|
toggleDark,
|
||||||
background,
|
background,
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
})
|
||||||
Vendored
+2
@@ -46,11 +46,13 @@ declare module 'vue' {
|
|||||||
ATag: typeof import('ant-design-vue/es')['Tag']
|
ATag: typeof import('ant-design-vue/es')['Tag']
|
||||||
ATextarea: typeof import('ant-design-vue/es')['Textarea']
|
ATextarea: typeof import('ant-design-vue/es')['Textarea']
|
||||||
ATooltip: typeof import('ant-design-vue/es')['Tooltip']
|
ATooltip: typeof import('ant-design-vue/es')['Tooltip']
|
||||||
|
ATour: typeof import('ant-design-vue/es')['Tour']
|
||||||
CardRadioGroup: typeof import('./../components/CardRadioGroup.vue')['default']
|
CardRadioGroup: typeof import('./../components/CardRadioGroup.vue')['default']
|
||||||
CloseConfirmModal: typeof import('./../components/CloseConfirmModal.vue')['default']
|
CloseConfirmModal: typeof import('./../components/CloseConfirmModal.vue')['default']
|
||||||
FrpCard: typeof import('./../components/frp/FrpCard.vue')['default']
|
FrpCard: typeof import('./../components/frp/FrpCard.vue')['default']
|
||||||
MacTitleBar: typeof import('./../components/MacTitleBar.vue')['default']
|
MacTitleBar: typeof import('./../components/MacTitleBar.vue')['default']
|
||||||
MarkdownBlock: typeof import('./../components/MarkdownBlock.vue')['default']
|
MarkdownBlock: typeof import('./../components/MarkdownBlock.vue')['default']
|
||||||
|
OnboardingTour: typeof import('./../components/OnboardingTour.vue')['default']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
ServerCard: typeof import('./../components/server/ServerCard.vue')['default']
|
ServerCard: typeof import('./../components/server/ServerCard.vue')['default']
|
||||||
|
|||||||
@@ -83,6 +83,7 @@
|
|||||||
|
|
||||||
<!-- 快速操作 -->
|
<!-- 快速操作 -->
|
||||||
<div
|
<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"
|
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">
|
<div class="flex items-center gap-3">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
<div class="flex h-full flex-col gap-3">
|
<div class="flex h-full flex-col gap-3">
|
||||||
<!-- 顶部操作栏(独立磨砂卡片) -->
|
<!-- 顶部操作栏(独立磨砂卡片) -->
|
||||||
<div
|
<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"
|
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">
|
<div class="flex items-center gap-2">
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<a-button :loading="loading" :icon="h(ReloadOutlined)" @click="refresh()">
|
<a-button :loading="loading" :icon="h(ReloadOutlined)" @click="refresh()">
|
||||||
{{ t("server.refresh") }}
|
{{ t("server.refresh") }}
|
||||||
</a-button>
|
</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") }}
|
{{ t("server.add") }}
|
||||||
</a-button>
|
</a-button>
|
||||||
</a-space>
|
</a-space>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<a-button :loading="loading" :icon="h(ReloadOutlined)" @click="refresh()">
|
<a-button :loading="loading" :icon="h(ReloadOutlined)" @click="refresh()">
|
||||||
{{ t("tunnel.refresh") }}
|
{{ t("tunnel.refresh") }}
|
||||||
</a-button>
|
</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") }}
|
{{ t("tunnel.add") }}
|
||||||
</a-button>
|
</a-button>
|
||||||
</a-space>
|
</a-space>
|
||||||
|
|||||||
@@ -3,6 +3,31 @@
|
|||||||
<h2 class="mb-1 text-lg font-semibold">{{ t("settings.general.title") }}</h2>
|
<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>
|
<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>
|
<section>
|
||||||
<div class="flex items-center justify-between gap-4">
|
<div class="flex items-center justify-between gap-4">
|
||||||
@@ -34,19 +59,6 @@
|
|||||||
<CardRadioGroup v-model="windowStore.closeChoice" :options="choiceOptions" />
|
<CardRadioGroup v-model="windowStore.closeChoice" :options="choiceOptions" />
|
||||||
</section>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -56,11 +68,13 @@ import { languageOptions } from "@/i18n"
|
|||||||
import { useAppStore } from "@/stores/app"
|
import { useAppStore } from "@/stores/app"
|
||||||
import { useAutostartStore } from "@/stores/autostart"
|
import { useAutostartStore } from "@/stores/autostart"
|
||||||
import { useWindowStore } from "@/stores/window"
|
import { useWindowStore } from "@/stores/window"
|
||||||
|
import { useOnboardingStore } from "@/stores/onboarding"
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
const autostartStore = useAutostartStore()
|
const autostartStore = useAutostartStore()
|
||||||
const windowStore = useWindowStore()
|
const windowStore = useWindowStore()
|
||||||
|
const onboarding = useOnboardingStore()
|
||||||
|
|
||||||
const behaviorOptions = computed(() => [
|
const behaviorOptions = computed(() => [
|
||||||
{ value: "ask" as const, label: t("closeDialog.ask"), description: t("closeDialog.askDesc") },
|
{ value: "ask" as const, label: t("closeDialog.ask"), description: t("closeDialog.askDesc") },
|
||||||
|
|||||||
Reference in New Issue
Block a user