-
+
+
+
+
+
+
+
+
+
+
+
-
\ No newline at end of file
+ &: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;
+}
+
diff --git a/src/components/TitleBarActions.vue b/src/components/TitleBarActions.vue
new file mode 100644
index 0000000..cd92528
--- /dev/null
+++ b/src/components/TitleBarActions.vue
@@ -0,0 +1,128 @@
+
+
+
+
+
+
+
+
+
+
+ {{ item.label }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/WindowsTitleBar.vue b/src/components/WindowsTitleBar.vue
index 2f7fe5c..b5a87b5 100644
--- a/src/components/WindowsTitleBar.vue
+++ b/src/components/WindowsTitleBar.vue
@@ -1,13 +1,37 @@
-
-
+
+
+
+
+ F
+ Frpilot
+
+
+
+
-
\ No newline at end of file
+
diff --git a/src/composables/usePlatform.ts b/src/composables/usePlatform.ts
new file mode 100644
index 0000000..efff9ff
--- /dev/null
+++ b/src/composables/usePlatform.ts
@@ -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
(() => {
+ 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 }
+}
diff --git a/src/composables/useSystemStats.ts b/src/composables/useSystemStats.ts
new file mode 100644
index 0000000..bff73fe
--- /dev/null
+++ b/src/composables/useSystemStats.ts
@@ -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("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 }
+}
diff --git a/src/i18n/index.ts b/src/i18n/index.ts
new file mode 100644
index 0000000..20542e7
--- /dev/null
+++ b/src/i18n/index.ts
@@ -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
diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json
new file mode 100644
index 0000000..13ee604
--- /dev/null
+++ b/src/i18n/locales/en-US.json
@@ -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"
+ }
+}
diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json
new file mode 100644
index 0000000..9214e31
--- /dev/null
+++ b/src/i18n/locales/zh-CN.json
@@ -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": "功能开发中,敬请期待"
+ }
+}
diff --git a/src/layout/Layout.vue b/src/layout/Layout.vue
index ee04717..64a7dd5 100644
--- a/src/layout/Layout.vue
+++ b/src/layout/Layout.vue
@@ -1,47 +1,51 @@
-
-
- Header
-
- Sider
- Content
-
- Footer
-
-
+
+
-
\ No newline at end of file
+
diff --git a/src/layout/aside/Aside.vue b/src/layout/aside/Aside.vue
index e69de29..eabbbcc 100644
--- a/src/layout/aside/Aside.vue
+++ b/src/layout/aside/Aside.vue
@@ -0,0 +1,141 @@
+
+
+
+
+
+
+
+
diff --git a/src/layout/header/Header.vue b/src/layout/header/Header.vue
new file mode 100644
index 0000000..1ae3af1
--- /dev/null
+++ b/src/layout/header/Header.vue
@@ -0,0 +1,17 @@
+
+
+
+
+
+
diff --git a/src/main.ts b/src/main.ts
index d51c813..4ee5b9c 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -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")
diff --git a/src/router/router.ts b/src/router/router.ts
index 28f2373..1a7e48c 100644
--- a/src/router/router.ts
+++ b/src/router/router.ts
@@ -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
\ No newline at end of file
+export default router
diff --git a/src/stores/app.ts b/src/stores/app.ts
new file mode 100644
index 0000000..9dbbcfb
--- /dev/null
+++ b/src/stores/app.ts
@@ -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("frpilot:language", "zh-CN")
+
+ // 明暗主题:useDark 会在 上切换 .dark 类名
+ const isDark = useDark({
+ storageKey: "frpilot:theme",
+ selector: "html",
+ attribute: "class",
+ valueDark: "dark",
+ valueLight: "",
+ })
+ const toggleDark = useToggle(isDark)
+
+ // Frpc 运行状态(后续由后端驱动)
+ const runStatus = ref("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,
+ }
+})
diff --git a/src/types/components.d.ts b/src/types/components.d.ts
index 75f5706..f6768f0 100644
--- a/src/types/components.d.ts
+++ b/src/types/components.d.ts
@@ -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']
}
}
diff --git a/src/utils/window.ts b/src/utils/window.ts
new file mode 100644
index 0000000..d589bd5
--- /dev/null
+++ b/src/utils/window.ts
@@ -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
+}
diff --git a/src/views/Dashboard.vue b/src/views/Dashboard.vue
new file mode 100644
index 0000000..e1c2436
--- /dev/null
+++ b/src/views/Dashboard.vue
@@ -0,0 +1,12 @@
+
+
+
{{ t("menu.dashboard") }}
+
+
+
+
+
diff --git a/src/views/Download.vue b/src/views/Download.vue
new file mode 100644
index 0000000..2c7d5e3
--- /dev/null
+++ b/src/views/Download.vue
@@ -0,0 +1,12 @@
+
+
+
{{ t("menu.download") }}
+
+
+
+
+
diff --git a/src/views/Servers.vue b/src/views/Servers.vue
new file mode 100644
index 0000000..8552bbd
--- /dev/null
+++ b/src/views/Servers.vue
@@ -0,0 +1,12 @@
+
+
+
{{ t("menu.servers") }}
+
+
+
+
+
diff --git a/src/views/Tunnels.vue b/src/views/Tunnels.vue
new file mode 100644
index 0000000..ccbba95
--- /dev/null
+++ b/src/views/Tunnels.vue
@@ -0,0 +1,12 @@
+
+
+
{{ t("menu.tunnels") }}
+
+
+
+
+