feat: 完成 Frpilot 桌面应用核心布局与多语言支持
实现内容: - 新增多语言支持(中文/英文),包含完整翻译文件 - 适配 Windows 和 macOS 自定义标题栏,支持窗口控制与主题切换 - 新增侧边栏菜单,显示系统 CPU/内存占用与运行状态 - 配置 Tauri 窗口属性与权限,支持窗口拖拽、最大化等操作 - 新增 Pinia 状态管理,处理主题、语言与应用状态 - 新增系统统计数据获取与模拟逻辑,支持 Tauri 后端调用回退 - 新增路由配置,完成页面布局与视图组件初始化
This commit is contained in:
@@ -1,27 +1,2 @@
|
||||
# Frpilot开发提示词
|
||||
|
||||
## 背景
|
||||
|
||||
> 该项目使用Tauri2 + Vue3为主要技术栈,主要解决Frpc配置繁琐的问题,方便用户快速配置frpc版本、隧道、服务器等
|
||||
>
|
||||
> UI库:Antdv
|
||||
|
||||
## 阶段提示词
|
||||
|
||||
### 1. Layout布局
|
||||
|
||||
本项目打算做一个Frpc的可视化客户端,方便用户快速配置frpc版本、隧道、服务器等,现在帮我完成以下功能:
|
||||
|
||||
1. i18n国际化初始化配置
|
||||
- 在locales目录中写多语言翻译json文件,初步支持中文和英文
|
||||
2. TitleBar页面开发
|
||||
- 开发不同操作系统类型的自定义titlebar
|
||||
- Windows系统的TitleBar最左侧显示Frpilot标题,右侧显示语言切换、明暗切换、设置、最小化、关闭,按照顺序展示;
|
||||
- Mac系统的TitleBar最左侧显示红绿灯操作,右侧显示语言切换、明暗切换、设置、最小化、关闭,按照顺序展示;
|
||||
3. Layout页面布局开发
|
||||
- Aside侧边栏开发,在Aside.vue文件里面编写,左侧菜单初步暂定为仪表盘、服务器、隧道列表、Frpc下载
|
||||
- Aside侧边栏最底部显示CPU占用(百分比)、内存占用(百分比)、运行状态(绿色点运行正常,红色点运行异常,黄色点未启动),跟上方的菜单有间隔
|
||||
- Header顶部栏,根据操作系统类型引入components里面的WindowsTitleBar.vue或MacTitleBar.vue
|
||||
4. Layout布局的side、content区域全部采用圆角矩形、类似于悬浮磨砂玻璃效果
|
||||
|
||||
开发过程中使用到的组件尽可能全部使用Antdv库里面的,另外我引入的有tailwindcss原子化组件,开发之前尽可能先阅读一下package.json,看一下有哪些依赖可以简化代码开发
|
||||
@@ -6,10 +6,16 @@
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default",
|
||||
"core:window:default",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-unminimize",
|
||||
"core:window:allow-maximize",
|
||||
"core:window:allow-unmaximize",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-start-dragging"
|
||||
"core:window:allow-is-maximized",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-hide",
|
||||
"core:window:allow-show",
|
||||
"core:window:allow-set-focus"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -13,9 +13,12 @@
|
||||
"windows": [
|
||||
{
|
||||
"title": "Frpilot",
|
||||
"width": 800,
|
||||
"height": 500,
|
||||
"decorations": false
|
||||
"width": 1100,
|
||||
"height": 720,
|
||||
"minWidth": 900,
|
||||
"minHeight": 600,
|
||||
"decorations": false,
|
||||
"transparent": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
|
||||
+13
-4
@@ -1,14 +1,23 @@
|
||||
<template>
|
||||
<a-config-provider :locale="locale">
|
||||
<a-config-provider :locale="appStore.antdLocale" :theme="themeConfig">
|
||||
<router-view />
|
||||
</a-config-provider>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import zhCN from 'ant-design-vue/es/locale/zh_CN';
|
||||
import { theme as antdTheme } from "ant-design-vue"
|
||||
import { useAppStore } from "@/stores/app"
|
||||
|
||||
const locale = ref(zhCN)
|
||||
const appStore = useAppStore()
|
||||
|
||||
/** 跟随明暗主题切换 Ant Design Vue 的算法 */
|
||||
const themeConfig = computed(() => ({
|
||||
algorithm: appStore.isDark ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm,
|
||||
token: {
|
||||
colorPrimary: "#6366f1",
|
||||
borderRadius: 8,
|
||||
},
|
||||
}))
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
+47
-4
@@ -1,6 +1,49 @@
|
||||
@import 'tailwindcss'
|
||||
@import 'tailwindcss';
|
||||
|
||||
:root {
|
||||
/* 全局颜色 */
|
||||
--color-bg: #f5f5f5;
|
||||
/* 使用 class 方式控制暗色模式(配合 @vueuse/core 的 useDark) */
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
overflow: hidden;
|
||||
font-family: 'Inter', 'HarmonyOS Sans SC', 'PingFang SC', 'Microsoft YaHei', system-ui, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* 悬浮磨砂玻璃面板 */
|
||||
.glass-panel {
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.55);
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
backdrop-filter: blur(18px) saturate(160%);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(160%);
|
||||
box-shadow: 0 8px 32px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.dark .glass-panel {
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
background: rgba(15, 23, 42, 0.55);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* 细滚动条 */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
border-radius: 9999px;
|
||||
background: rgba(100, 116, 139, 0.35);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
@@ -1,13 +1,92 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- macOS 自定义标题栏 -->
|
||||
<div
|
||||
class="titlebar flex h-12 shrink-0 items-center justify-between pl-4 pr-2 select-none"
|
||||
data-tauri-drag-region
|
||||
>
|
||||
<!-- 左侧:红绿灯 -->
|
||||
<div class="group/lights flex items-center gap-2" data-tauri-drag-region>
|
||||
<button
|
||||
class="traffic-light bg-[#ff5f57]"
|
||||
:class="{ 'traffic-light--idle': !focused }"
|
||||
:title="t('titlebar.close')"
|
||||
@click="closeWindow()"
|
||||
>
|
||||
<svg viewBox="0 0 12 12" class="light-icon opacity-0 group-hover/lights:opacity-100">
|
||||
<path d="M3.5 3.5l5 5M8.5 3.5l-5 5" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="traffic-light bg-[#febc2e]"
|
||||
:class="{ 'traffic-light--idle': !focused }"
|
||||
:title="t('titlebar.minimize')"
|
||||
@click="minimizeWindow()"
|
||||
>
|
||||
<svg viewBox="0 0 12 12" class="light-icon opacity-0 group-hover/lights:opacity-100">
|
||||
<path d="M3 6h6" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="traffic-light bg-[#28c840]"
|
||||
:class="{ 'traffic-light--idle': !focused }"
|
||||
:title="t('titlebar.maximize')"
|
||||
@click="toggleMaximizeWindow()"
|
||||
>
|
||||
<svg viewBox="0 0 12 12" class="light-icon opacity-0 group-hover/lights:opacity-100">
|
||||
<path d="M3.5 3.5h5v5h-5z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:语言 / 主题 / 设置 / 最小化 / 关闭 -->
|
||||
<TitleBarActions @settings="emit('settings')" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n"
|
||||
import TitleBarActions from "./TitleBarActions.vue"
|
||||
import { closeWindow, minimizeWindow, toggleMaximizeWindow } from "@/utils/window"
|
||||
|
||||
const emit = defineEmits<{ settings: [] }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const focused = useWindowFocus()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
<style scoped lang="scss">
|
||||
.traffic-light {
|
||||
display: inline-flex;
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: filter 0.15s ease;
|
||||
filter: saturate(0.85);
|
||||
|
||||
&:active {
|
||||
filter: brightness(0.85);
|
||||
}
|
||||
}
|
||||
|
||||
/* 窗口失焦时红绿灯置灰(模拟 macOS 行为) */
|
||||
.traffic-light--idle {
|
||||
background-color: #cbd5e1 !important;
|
||||
}
|
||||
|
||||
.light-icon {
|
||||
height: 8px;
|
||||
width: 8px;
|
||||
stroke: rgba(0, 0, 0, 0.6);
|
||||
stroke-width: 1.4;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
fill: none;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- 语言切换 -->
|
||||
<a-dropdown placement="bottomRight" trigger="click">
|
||||
<a-button
|
||||
type="text"
|
||||
class="tb-btn"
|
||||
:title="t('titlebar.language')"
|
||||
>
|
||||
<template #icon><TranslationOutlined /></template>
|
||||
</a-button>
|
||||
<template #overlay>
|
||||
<a-menu
|
||||
:selected-keys="[appStore.language]"
|
||||
@click="onLanguageClick"
|
||||
>
|
||||
<a-menu-item
|
||||
v-for="item in languageOptions"
|
||||
:key="item.key"
|
||||
>
|
||||
{{ item.label }}
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
|
||||
<!-- 明暗切换 -->
|
||||
<a-button
|
||||
type="text"
|
||||
class="tb-btn"
|
||||
:title="appStore.isDark ? t('titlebar.lightMode') : t('titlebar.darkMode')"
|
||||
@click="appStore.toggleDark()"
|
||||
>
|
||||
<template #icon>
|
||||
<BulbFilled v-if="appStore.isDark" />
|
||||
<BulbOutlined v-else />
|
||||
</template>
|
||||
</a-button>
|
||||
|
||||
<!-- 设置 -->
|
||||
<a-button
|
||||
type="text"
|
||||
class="tb-btn"
|
||||
:title="t('titlebar.settings')"
|
||||
@click="emit('settings')"
|
||||
>
|
||||
<template #icon><SettingOutlined /></template>
|
||||
</a-button>
|
||||
|
||||
<!-- 最小化 -->
|
||||
<a-button
|
||||
type="text"
|
||||
class="tb-btn"
|
||||
:title="t('titlebar.minimize')"
|
||||
@click="minimizeWindow()"
|
||||
>
|
||||
<template #icon><MinusOutlined /></template>
|
||||
</a-button>
|
||||
|
||||
<!-- 关闭 -->
|
||||
<a-button
|
||||
type="text"
|
||||
class="tb-btn tb-btn-close"
|
||||
:title="t('titlebar.close')"
|
||||
@click="closeWindow()"
|
||||
>
|
||||
<template #icon><CloseOutlined /></template>
|
||||
</a-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
BulbFilled,
|
||||
BulbOutlined,
|
||||
CloseOutlined,
|
||||
MinusOutlined,
|
||||
SettingOutlined,
|
||||
TranslationOutlined,
|
||||
} from "@ant-design/icons-vue"
|
||||
import { useI18n } from "vue-i18n"
|
||||
import type { MenuProps } from "ant-design-vue"
|
||||
import { useAppStore } from "@/stores/app"
|
||||
import { languageOptions, type Language } from "@/i18n"
|
||||
import { closeWindow, minimizeWindow } from "@/utils/window"
|
||||
|
||||
const emit = defineEmits<{ settings: [] }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const onLanguageClick: MenuProps["onClick"] = ({ key }) => {
|
||||
appStore.setLanguage(key as Language)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.tb-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 28px;
|
||||
width: 28px;
|
||||
padding: 0;
|
||||
border-radius: 8px;
|
||||
color: rgba(15, 23, 42, 0.75);
|
||||
transition: background-color 0.2s ease, color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(15, 23, 42, 0.08) !important;
|
||||
color: rgba(15, 23, 42, 0.95);
|
||||
}
|
||||
|
||||
&-close:hover {
|
||||
background-color: #ef4444 !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
}
|
||||
|
||||
.dark .tb-btn {
|
||||
color: rgba(226, 232, 240, 0.8);
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.12) !important;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,13 +1,37 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Windows 自定义标题栏 -->
|
||||
<div
|
||||
class="titlebar flex h-12 shrink-0 items-center justify-between pl-4 pr-2 select-none"
|
||||
data-tauri-drag-region
|
||||
>
|
||||
<!-- 左侧:标题 -->
|
||||
<div class="flex items-center gap-2" data-tauri-drag-region>
|
||||
<span class="brand-logo">F</span>
|
||||
<span class="text-sm font-semibold tracking-wide">Frpilot</span>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:语言 / 主题 / 设置 / 最小化 / 关闭 -->
|
||||
<TitleBarActions @settings="emit('settings')" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import TitleBarActions from "./TitleBarActions.vue"
|
||||
|
||||
const emit = defineEmits<{ settings: [] }>()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.brand-logo {
|
||||
display: inline-flex;
|
||||
height: 22px;
|
||||
width: 22px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(135deg, #6366f1, #22d3ee);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,23 @@
|
||||
/** 操作系统类型 */
|
||||
export type Platform = "windows" | "macos" | "linux" | "unknown"
|
||||
|
||||
/**
|
||||
* 判断当前运行的操作系统类型。
|
||||
* 通过 navigator.userAgent 推断,避免额外引入 @tauri-apps/plugin-os。
|
||||
*/
|
||||
export function usePlatform() {
|
||||
const ua = (navigator.userAgent || "").toLowerCase()
|
||||
|
||||
const platform = computed<Platform>(() => {
|
||||
if (ua.includes("mac") || ua.includes("darwin")) return "macos"
|
||||
if (ua.includes("win")) return "windows"
|
||||
if (ua.includes("linux") || ua.includes("x11")) return "linux"
|
||||
return "unknown"
|
||||
})
|
||||
|
||||
const isMac = computed(() => platform.value === "macos")
|
||||
const isWindows = computed(() => platform.value === "windows")
|
||||
const isLinux = computed(() => platform.value === "linux")
|
||||
|
||||
return { platform, isMac, isWindows, isLinux }
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { invoke } from "@tauri-apps/api/core"
|
||||
import { isTauri } from "@/utils/window"
|
||||
|
||||
interface SystemInfo {
|
||||
cpu: number
|
||||
memory: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 系统资源占用(CPU / 内存 百分比)。
|
||||
*
|
||||
* 优先调用后端命令 `get_system_info`(返回 { cpu, memory }),
|
||||
* 后端尚未实现时回退到模拟数据,保证界面可用。
|
||||
*/
|
||||
export function useSystemStats(interval = 2000) {
|
||||
const cpu = ref(0)
|
||||
const memory = ref(0)
|
||||
const isMock = ref(true)
|
||||
|
||||
// 模拟数据:随机游走,避免界面显示为静态 0
|
||||
let mockCpu = 18
|
||||
let mockMem = 42
|
||||
function mock() {
|
||||
mockCpu = Math.min(95, Math.max(3, mockCpu + (Math.random() - 0.5) * 8))
|
||||
mockMem = Math.min(95, Math.max(10, mockMem + (Math.random() - 0.5) * 4))
|
||||
cpu.value = Math.round(mockCpu)
|
||||
memory.value = Math.round(mockMem)
|
||||
isMock.value = true
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (isTauri()) {
|
||||
try {
|
||||
const res = await invoke<SystemInfo>("get_system_info")
|
||||
if (res) {
|
||||
cpu.value = Math.round(res.cpu)
|
||||
memory.value = Math.round(res.memory)
|
||||
isMock.value = false
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// 后端命令未实现,回退到模拟数据
|
||||
}
|
||||
}
|
||||
mock()
|
||||
}
|
||||
|
||||
const { pause, resume } = useIntervalFn(refresh, interval, { immediate: true })
|
||||
|
||||
return { cpu, memory, isMock, refresh, pause, resume }
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { createI18n } from "vue-i18n"
|
||||
import zhCN from "./locales/zh-CN.json"
|
||||
import enUS from "./locales/en-US.json"
|
||||
|
||||
/** 支持的语言类型 */
|
||||
export type Language = "zh-CN" | "en-US"
|
||||
|
||||
/** 全部语言包 */
|
||||
export const messages = {
|
||||
"zh-CN": zhCN,
|
||||
"en-US": enUS,
|
||||
}
|
||||
|
||||
/** 可选语言列表(用于语言切换下拉) */
|
||||
export const languageOptions: { key: Language; label: string }[] = [
|
||||
{ key: "zh-CN", label: "简体中文" },
|
||||
{ key: "en-US", label: "English" },
|
||||
]
|
||||
|
||||
export const DEFAULT_LANGUAGE: Language = "zh-CN"
|
||||
|
||||
export const i18n = createI18n({
|
||||
legacy: false, // 使用组合式 API
|
||||
globalInjection: true, // 允许在模板中使用 $t
|
||||
locale: DEFAULT_LANGUAGE,
|
||||
fallbackLocale: "en-US",
|
||||
messages,
|
||||
})
|
||||
|
||||
/** 切换全局语言 */
|
||||
export function setI18nLanguage(language: Language) {
|
||||
i18n.global.locale.value = language
|
||||
}
|
||||
|
||||
/** 在非组件环境(如路由守卫)中获取翻译函数 */
|
||||
export function translate(key: string) {
|
||||
return i18n.global.t(key)
|
||||
}
|
||||
|
||||
export default i18n
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "Frpilot",
|
||||
"description": "A visual client for frpc"
|
||||
},
|
||||
"titlebar": {
|
||||
"language": "Language",
|
||||
"theme": "Theme",
|
||||
"lightMode": "Light mode",
|
||||
"darkMode": "Dark mode",
|
||||
"settings": "Settings",
|
||||
"minimize": "Minimize",
|
||||
"maximize": "Maximize",
|
||||
"restore": "Restore",
|
||||
"close": "Close"
|
||||
},
|
||||
"language": {
|
||||
"zhCN": "简体中文",
|
||||
"enUS": "English"
|
||||
},
|
||||
"menu": {
|
||||
"dashboard": "Dashboard",
|
||||
"servers": "Servers",
|
||||
"tunnels": "Tunnels",
|
||||
"download": "Download frpc"
|
||||
},
|
||||
"system": {
|
||||
"cpu": "CPU usage",
|
||||
"memory": "Memory usage",
|
||||
"status": "Status",
|
||||
"statusRunning": "Running",
|
||||
"statusError": "Error",
|
||||
"statusStopped": "Stopped"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"placeholder": "The settings page is under construction."
|
||||
},
|
||||
"common": {
|
||||
"developing": "This feature is under construction"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"app": {
|
||||
"name": "Frpilot",
|
||||
"description": "Frpc 可视化客户端"
|
||||
},
|
||||
"titlebar": {
|
||||
"language": "语言",
|
||||
"theme": "主题",
|
||||
"lightMode": "浅色模式",
|
||||
"darkMode": "深色模式",
|
||||
"settings": "设置",
|
||||
"minimize": "最小化",
|
||||
"maximize": "最大化",
|
||||
"restore": "还原",
|
||||
"close": "关闭"
|
||||
},
|
||||
"language": {
|
||||
"zhCN": "简体中文",
|
||||
"enUS": "English"
|
||||
},
|
||||
"menu": {
|
||||
"dashboard": "仪表盘",
|
||||
"servers": "服务器",
|
||||
"tunnels": "隧道列表",
|
||||
"download": "Frpc 下载"
|
||||
},
|
||||
"system": {
|
||||
"cpu": "CPU 占用",
|
||||
"memory": "内存占用",
|
||||
"status": "运行状态",
|
||||
"statusRunning": "运行正常",
|
||||
"statusError": "运行异常",
|
||||
"statusStopped": "未启动"
|
||||
},
|
||||
"settings": {
|
||||
"title": "设置",
|
||||
"placeholder": "设置页面开发中,敬请期待。"
|
||||
},
|
||||
"common": {
|
||||
"developing": "功能开发中,敬请期待"
|
||||
}
|
||||
}
|
||||
+44
-40
@@ -1,47 +1,51 @@
|
||||
<template>
|
||||
<a-space direction="vertical" :style="{ width: '100%' }" :size="[0, 48]">
|
||||
<a-layout>
|
||||
<a-layout-header :style="headerStyle">Header</a-layout-header>
|
||||
<a-layout>
|
||||
<a-layout-sider :style="siderStyle">Sider</a-layout-sider>
|
||||
<a-layout-content :style="contentStyle">Content</a-layout-content>
|
||||
</a-layout>
|
||||
<a-layout-footer :style="footerStyle">Footer</a-layout-footer>
|
||||
</a-layout>
|
||||
</a-space>
|
||||
<!-- 应用整体:背景渐变 + 悬浮面板 -->
|
||||
<div class="app-shell flex h-screen w-screen flex-col overflow-hidden">
|
||||
<!-- 顶部自定义 TitleBar -->
|
||||
<Header @settings="settingsOpen = true" />
|
||||
|
||||
<!-- 主体:侧边栏 + 内容区 -->
|
||||
<div class="flex min-h-0 flex-1 gap-3 px-3 pb-3">
|
||||
<Aside />
|
||||
|
||||
<main class="glass-panel min-w-0 flex-1 overflow-y-auto">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 设置(占位) -->
|
||||
<a-modal
|
||||
v-model:open="settingsOpen"
|
||||
:title="t('settings.title')"
|
||||
:footer="null"
|
||||
centered
|
||||
>
|
||||
<a-result status="info" :sub-title="t('settings.placeholder')" />
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { CSSProperties } from 'vue';
|
||||
const headerStyle: CSSProperties = {
|
||||
textAlign: 'center',
|
||||
color: '#fff',
|
||||
height: 64,
|
||||
paddingInline: 50,
|
||||
lineHeight: '64px',
|
||||
backgroundColor: '#7dbcea',
|
||||
};
|
||||
import { useI18n } from "vue-i18n"
|
||||
import Header from "./header/Header.vue"
|
||||
import Aside from "./aside/Aside.vue"
|
||||
|
||||
const contentStyle: CSSProperties = {
|
||||
textAlign: 'center',
|
||||
minHeight: 120,
|
||||
lineHeight: '120px',
|
||||
color: '#fff',
|
||||
backgroundColor: '#108ee9',
|
||||
};
|
||||
|
||||
const siderStyle: CSSProperties = {
|
||||
textAlign: 'center',
|
||||
lineHeight: '120px',
|
||||
color: '#fff',
|
||||
backgroundColor: '#3ba0e9',
|
||||
};
|
||||
|
||||
const footerStyle: CSSProperties = {
|
||||
textAlign: 'center',
|
||||
color: '#fff',
|
||||
backgroundColor: '#7dbcea',
|
||||
};
|
||||
const { t } = useI18n()
|
||||
const settingsOpen = ref(false)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
<style scoped lang="scss">
|
||||
.app-shell {
|
||||
background:
|
||||
radial-gradient(1200px 600px at 10% -10%, rgba(99, 102, 241, 0.28), transparent 60%),
|
||||
radial-gradient(1000px 500px at 110% 10%, rgba(34, 211, 238, 0.28), transparent 55%),
|
||||
linear-gradient(160deg, #eef2ff 0%, #f8fafc 45%, #eef7ff 100%);
|
||||
}
|
||||
|
||||
.dark .app-shell {
|
||||
background:
|
||||
radial-gradient(1200px 600px at 10% -10%, rgba(99, 102, 241, 0.35), transparent 60%),
|
||||
radial-gradient(1000px 500px at 110% 10%, rgba(14, 165, 233, 0.28), transparent 55%),
|
||||
linear-gradient(160deg, #0b1220 0%, #0f172a 45%, #111827 100%);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
<template>
|
||||
<!-- 侧边栏:磨砂玻璃悬浮面板 -->
|
||||
<aside class="glass-panel flex w-56 shrink-0 flex-col overflow-hidden">
|
||||
<!-- 菜单区 -->
|
||||
<div class="flex-1 overflow-y-auto p-2">
|
||||
<a-menu
|
||||
class="aside-menu"
|
||||
mode="inline"
|
||||
:selected-keys="selectedKeys"
|
||||
:items="menuItems"
|
||||
@click="onMenuClick"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 底部系统信息区(与菜单之间有间隔) -->
|
||||
<div class="system-panel mt-2 border-t p-3">
|
||||
<!-- CPU -->
|
||||
<div class="mb-2">
|
||||
<div class="mb-1 flex items-center justify-between text-xs">
|
||||
<span class="opacity-70">{{ t("system.cpu") }}</span>
|
||||
<span class="font-medium tabular-nums">{{ cpu }}%</span>
|
||||
</div>
|
||||
<a-progress
|
||||
:percent="cpu"
|
||||
:show-info="false"
|
||||
:stroke-color="barColor(cpu)"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 内存 -->
|
||||
<div class="mb-3">
|
||||
<div class="mb-1 flex items-center justify-between text-xs">
|
||||
<span class="opacity-70">{{ t("system.memory") }}</span>
|
||||
<span class="font-medium tabular-nums">{{ memory }}%</span>
|
||||
</div>
|
||||
<a-progress
|
||||
:percent="memory"
|
||||
:show-info="false"
|
||||
:stroke-color="barColor(memory)"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 运行状态 -->
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<span class="h-2 w-2 shrink-0 rounded-full" :class="statusDotClass" />
|
||||
<span class="opacity-70">{{ t("system.status") }}</span>
|
||||
<span class="font-medium">{{ statusText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { h } from "vue"
|
||||
import {
|
||||
ApiOutlined,
|
||||
CloudServerOutlined,
|
||||
DashboardOutlined,
|
||||
DownloadOutlined,
|
||||
} from "@ant-design/icons-vue"
|
||||
import { useI18n } from "vue-i18n"
|
||||
import type { MenuProps } from "ant-design-vue"
|
||||
import { useAppStore } from "@/stores/app"
|
||||
import { useSystemStats } from "@/composables/useSystemStats"
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const { cpu, memory } = useSystemStats()
|
||||
|
||||
const selectedKeys = computed(() => [route.path])
|
||||
|
||||
const menuItems = computed<MenuProps["items"]>(() => [
|
||||
{ key: "/dashboard", icon: () => h(DashboardOutlined), label: t("menu.dashboard") },
|
||||
{ key: "/servers", icon: () => h(CloudServerOutlined), label: t("menu.servers") },
|
||||
{ key: "/tunnels", icon: () => h(ApiOutlined), label: t("menu.tunnels") },
|
||||
{ key: "/download", icon: () => h(DownloadOutlined), label: t("menu.download") },
|
||||
])
|
||||
|
||||
const onMenuClick: MenuProps["onClick"] = ({ key }) => {
|
||||
router.push(String(key))
|
||||
}
|
||||
|
||||
/** 进度条颜色:低=绿,中=蓝,高=橙/红 */
|
||||
function barColor(value: number) {
|
||||
if (value >= 85) return "#ef4444"
|
||||
if (value >= 60) return "#f59e0b"
|
||||
return "#6366f1"
|
||||
}
|
||||
|
||||
const statusDotClass = computed(() => {
|
||||
switch (appStore.runStatus) {
|
||||
case "running":
|
||||
return "bg-green-500 shadow-[0_0_6px_#22c55e]"
|
||||
case "error":
|
||||
return "bg-red-500 shadow-[0_0_6px_#ef4444]"
|
||||
default:
|
||||
return "bg-yellow-400"
|
||||
}
|
||||
})
|
||||
|
||||
const statusText = computed(() => {
|
||||
switch (appStore.runStatus) {
|
||||
case "running":
|
||||
return t("system.statusRunning")
|
||||
case "error":
|
||||
return t("system.statusError")
|
||||
default:
|
||||
return t("system.statusStopped")
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.system-panel {
|
||||
border-color: rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.dark .system-panel {
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
/* 让 Antd 菜单透明化,融入磨砂玻璃面板 */
|
||||
.aside-menu {
|
||||
background: transparent;
|
||||
|
||||
:deep(.ant-menu-item) {
|
||||
border-radius: 10px;
|
||||
margin-inline: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&.ant-menu-inline {
|
||||
border-inline-end: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<!-- 顶部栏:按操作系统类型加载对应的自定义 TitleBar -->
|
||||
<header class="shrink-0">
|
||||
<WindowsTitleBar v-if="isWindows" @settings="emit('settings')" />
|
||||
<MacTitleBar v-else @settings="emit('settings')" />
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import WindowsTitleBar from "@/components/WindowsTitleBar.vue"
|
||||
import MacTitleBar from "@/components/MacTitleBar.vue"
|
||||
import { usePlatform } from "@/composables/usePlatform"
|
||||
|
||||
const emit = defineEmits<{ settings: [] }>()
|
||||
|
||||
const { isWindows } = usePlatform()
|
||||
</script>
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createApp } from "vue"
|
||||
import { createPinia } from "pinia"
|
||||
import App from "./App.vue"
|
||||
import "@/assets/main.css"
|
||||
// 引入 Ant Design Vue 组件库
|
||||
@@ -6,8 +7,12 @@ import Antd from "ant-design-vue"
|
||||
import "ant-design-vue/dist/reset.css"
|
||||
// 引入路由
|
||||
import router from "@/router/router"
|
||||
// 引入国际化
|
||||
import i18n from "@/i18n"
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(Antd)
|
||||
app.use(i18n)
|
||||
app.use(router)
|
||||
app.mount("#app")
|
||||
|
||||
+28
-1
@@ -6,7 +6,34 @@ const routes: RouteRecordRaw[] = [
|
||||
path: "/",
|
||||
name: "Layout",
|
||||
component: () => import("@/layout/Layout.vue"),
|
||||
}
|
||||
redirect: "/dashboard",
|
||||
children: [
|
||||
{
|
||||
path: "dashboard",
|
||||
name: "Dashboard",
|
||||
component: () => import("@/views/Dashboard.vue"),
|
||||
meta: { title: "menu.dashboard" },
|
||||
},
|
||||
{
|
||||
path: "servers",
|
||||
name: "Servers",
|
||||
component: () => import("@/views/Servers.vue"),
|
||||
meta: { title: "menu.servers" },
|
||||
},
|
||||
{
|
||||
path: "tunnels",
|
||||
name: "Tunnels",
|
||||
component: () => import("@/views/Tunnels.vue"),
|
||||
meta: { title: "menu.tunnels" },
|
||||
},
|
||||
{
|
||||
path: "download",
|
||||
name: "Download",
|
||||
component: () => import("@/views/Download.vue"),
|
||||
meta: { title: "menu.download" },
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { defineStore } from "pinia"
|
||||
import zhCN from "ant-design-vue/es/locale/zh_CN"
|
||||
import enUS from "ant-design-vue/es/locale/en_US"
|
||||
import { setI18nLanguage, type Language } from "@/i18n"
|
||||
|
||||
/** Frpc 运行状态 */
|
||||
export type RunStatus = "running" | "error" | "stopped"
|
||||
|
||||
/**
|
||||
* 全局应用配置:语言、主题、Frpc 运行状态
|
||||
*/
|
||||
export const useAppStore = defineStore("app", () => {
|
||||
// 语言(持久化到 localStorage)
|
||||
const language = useStorage<Language>("frpilot:language", "zh-CN")
|
||||
|
||||
// 明暗主题:useDark 会在 <html> 上切换 .dark 类名
|
||||
const isDark = useDark({
|
||||
storageKey: "frpilot:theme",
|
||||
selector: "html",
|
||||
attribute: "class",
|
||||
valueDark: "dark",
|
||||
valueLight: "",
|
||||
})
|
||||
const toggleDark = useToggle(isDark)
|
||||
|
||||
// Frpc 运行状态(后续由后端驱动)
|
||||
const runStatus = ref<RunStatus>("stopped")
|
||||
|
||||
// Ant Design Vue 组件库语言包
|
||||
const antdLocale = computed(() => (language.value === "zh-CN" ? zhCN : enUS))
|
||||
|
||||
// 语言变化时同步到 vue-i18n
|
||||
watch(
|
||||
language,
|
||||
(value) => setI18nLanguage(value),
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function setLanguage(value: Language) {
|
||||
language.value = value
|
||||
}
|
||||
|
||||
function toggleLanguage() {
|
||||
language.value = language.value === "zh-CN" ? "en-US" : "zh-CN"
|
||||
}
|
||||
|
||||
return {
|
||||
language,
|
||||
isDark,
|
||||
toggleDark,
|
||||
runStatus,
|
||||
antdLocale,
|
||||
setLanguage,
|
||||
toggleLanguage,
|
||||
}
|
||||
})
|
||||
Vendored
+8
@@ -11,16 +11,24 @@ export {}
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
AButton: typeof import('ant-design-vue/es')['Button']
|
||||
AConfigProvider: typeof import('ant-design-vue/es')['ConfigProvider']
|
||||
ADropdown: typeof import('ant-design-vue/es')['Dropdown']
|
||||
ALayout: typeof import('ant-design-vue/es')['Layout']
|
||||
ALayoutContent: typeof import('ant-design-vue/es')['LayoutContent']
|
||||
ALayoutFooter: typeof import('ant-design-vue/es')['LayoutFooter']
|
||||
ALayoutHeader: typeof import('ant-design-vue/es')['LayoutHeader']
|
||||
ALayoutSider: typeof import('ant-design-vue/es')['LayoutSider']
|
||||
AMenu: typeof import('ant-design-vue/es')['Menu']
|
||||
AMenuItem: typeof import('ant-design-vue/es')['MenuItem']
|
||||
AModal: typeof import('ant-design-vue/es')['Modal']
|
||||
AProgress: typeof import('ant-design-vue/es')['Progress']
|
||||
AResult: typeof import('ant-design-vue/es')['Result']
|
||||
ASpace: typeof import('ant-design-vue/es')['Space']
|
||||
MacTitleBar: typeof import('./../components/MacTitleBar.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
TitleBarActions: typeof import('./../components/TitleBarActions.vue')['default']
|
||||
WindowsTitleBar: typeof import('./../components/WindowsTitleBar.vue')['default']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Tauri 窗口控制封装。
|
||||
* 在纯浏览器环境(非 Tauri)下调用会被安全忽略,方便 `pnpm dev` 调试。
|
||||
*/
|
||||
|
||||
export function isTauri() {
|
||||
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window
|
||||
}
|
||||
|
||||
async function currentWindow() {
|
||||
if (!isTauri()) return null
|
||||
const { getCurrentWindow } = await import("@tauri-apps/api/window")
|
||||
return getCurrentWindow()
|
||||
}
|
||||
|
||||
/** 最小化窗口 */
|
||||
export async function minimizeWindow() {
|
||||
const win = await currentWindow()
|
||||
await win?.minimize()
|
||||
}
|
||||
|
||||
/** 最大化 / 还原窗口 */
|
||||
export async function toggleMaximizeWindow() {
|
||||
const win = await currentWindow()
|
||||
await win?.toggleMaximize()
|
||||
}
|
||||
|
||||
/** 关闭窗口 */
|
||||
export async function closeWindow() {
|
||||
const win = await currentWindow()
|
||||
await win?.close()
|
||||
}
|
||||
|
||||
/** 开始拖拽窗口(配合 data-tauri-drag-region 使用) */
|
||||
export async function startDragging() {
|
||||
const win = await currentWindow()
|
||||
await win?.startDragging()
|
||||
}
|
||||
|
||||
/** 是否已最大化 */
|
||||
export async function isWindowMaximized() {
|
||||
const win = await currentWindow()
|
||||
return (await win?.isMaximized()) ?? false
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col p-6">
|
||||
<h2 class="mb-4 text-lg font-semibold">{{ t("menu.dashboard") }}</h2>
|
||||
<a-result status="info" :sub-title="t('common.developing')" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n"
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col p-6">
|
||||
<h2 class="mb-4 text-lg font-semibold">{{ t("menu.download") }}</h2>
|
||||
<a-result status="info" :sub-title="t('common.developing')" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n"
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col p-6">
|
||||
<h2 class="mb-4 text-lg font-semibold">{{ t("menu.servers") }}</h2>
|
||||
<a-result status="info" :sub-title="t('common.developing')" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n"
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
@@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col p-6">
|
||||
<h2 class="mb-4 text-lg font-semibold">{{ t("menu.tunnels") }}</h2>
|
||||
<a-result status="info" :sub-title="t('common.developing')" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "vue-i18n"
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
Reference in New Issue
Block a user