feat: 完成 Frpilot 桌面应用核心布局与多语言支持

实现内容:
- 新增多语言支持(中文/英文),包含完整翻译文件
- 适配 Windows 和 macOS 自定义标题栏,支持窗口控制与主题切换
- 新增侧边栏菜单,显示系统 CPU/内存占用与运行状态
- 配置 Tauri 窗口属性与权限,支持窗口拖拽、最大化等操作
- 新增 Pinia 状态管理,处理主题、语言与应用状态
- 新增系统统计数据获取与模拟逻辑,支持 Tauri 后端调用回退
- 新增路由配置,完成页面布局与视图组件初始化
This commit is contained in:
Yuhang Wu
2026-10-12 17:54:05 +08:00
parent b885919b1d
commit 9ca10fdd2f
25 changed files with 907 additions and 92 deletions
+14 -5
View File
@@ -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>
<style lang="scss" scoped></style>
+48 -5
View File
@@ -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;
}
+83 -4
View File
@@ -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);
</style>
&: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>
+128
View File
@@ -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>
+29 -5
View File
@@ -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>
<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>
+23
View File
@@ -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 }
}
+51
View File
@@ -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 }
}
+40
View File
@@ -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
+42
View File
@@ -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"
}
}
+42
View File
@@ -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
View File
@@ -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>
+141
View File
@@ -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>
+17
View File
@@ -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>
+5
View File
@@ -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")
+29 -2
View File
@@ -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({
@@ -14,4 +41,4 @@ const router = createRouter({
routes,
})
export default router
export default router
+56
View File
@@ -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,
}
})
+8
View File
@@ -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']
}
}
+44
View File
@@ -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
}
+12
View File
@@ -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>
+12
View File
@@ -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>
+12
View File
@@ -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>
+12
View File
@@ -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>