Compare commits

...
4 Commits
Author SHA1 Message Date
Yuhang Wu 57b4d7be5a feat: 为Frpc下载页面添加运行时缓存,避免重复请求触发限流
扩展路由元信息类型以支持页面缓存开关,在Layout中实现基于路由元的keep-alive逻辑,为Download组件设置对应名称和缓存标识,仅在应用运行时缓存页面,刷新仍需手动触发更新。
2026-10-16 13:44:20 +08:00
Yuhang Wu 5c1ce2fa5c feat: 新增常规设置页面,支持关闭行为与语言切换
新增常规设置路由和页面,实现关闭行为(每次询问/保持选择)与应用语言的持久化配置;封装卡片式单选组组件复用在关闭弹窗和设置页;修改关闭逻辑,根据关闭行为决定是否显示确认弹窗,同步更新多语言文案和类型定义。
2026-10-16 11:38:00 +08:00
Yuhang Wu dbde020d7e feat: 新增磨砂玻璃透明度设置功能 2026-10-16 11:21:52 +08:00
Yuhang Wu aba606a570 feat: 新增6个背景选项并修复系统托盘图标重复问题
- 新增bg4-bg9共6个背景图片及对应配置
- 修复热更新时系统托盘图标重复叠加的问题
- 更新中英文文案及开发文档相关说明
2026-10-16 11:02:36 +08:00
26 changed files with 579 additions and 110 deletions
+30
View File
@@ -4,6 +4,36 @@
--- ---
## v1.2.0(2026-10-16)
### ✨ 新增功能
- **常规设置页面**:设置页新增「常规」子页面(位于设置菜单首位),集中管理基础行为:
- **关闭行为**:新增「每次询问 / 保持以上选择」单选组(默认「每次询问」)。
- *每次询问*:每次关闭都弹出对话框,由用户现场选择处理方式;
- *保持以上选择*:记住所选关闭方式并持久生效,之后关闭不再弹窗。
- **语言切换**:可直接在设置页切换界面语言,与顶部栏的语言切换保持同步。
- 关闭确认对话框同步新增「关闭行为」选项,与常规设置页内容一致,用户可随时再次修改。
- **磨砂玻璃透明度**:外观设置新增「磨砂玻璃透明度」,可分别调节**卡片透明度**与**弹窗透明度**(0 – 100,数值越大越透明),并提供实时预览,设置持久化保存。
- **更多窗口背景**:背景图片由 2 张扩充至 9 张(新增「背景三」~「背景九」),可在外观设置中自由挑选。
- **Frpc 下载页缓存**:下载页新增运行时缓存,应用运行期间**首次进入**才拉取 GitHub 版本列表,之后切换回该页面不再重复请求,避免触发接口限流;需要获取最新列表时手动点击「刷新」即可。
### 🎨 优化
- 新增卡片式单选组组件(`CardRadioGroup`),关闭弹窗与常规设置页复用,统一选项样式与交互。
- 关闭弹窗内的选项改为临时草稿状态,点击「确定」后才写回设置,避免误触单选框又取消时被改掉配置。
### 🐛 修复
- 修复开发模式下热更新 / 页面重载时系统托盘图标重复叠加的问题:创建托盘前按固定 id 清理残留图标,并在热更新时释放旧实例的监听。
- 修复暗色主题下普通(非组件库)文本仍显示为浏览器默认黑色的问题,文本颜色现随明暗主题正确切换。
### 🧱 工程
- 应用版本升级至 **v1.2.0**。
- 路由表新增 `isCache` 元信息,并补充 `RouteMeta` 类型声明(页面标题 `title` 与缓存开关 `isCache`),按需使用 `keep-alive` 缓存页面组件(仅运行时生效,不做持久化)。
- 更新开发文档相关说明。
## v1.1.0(2026-10-14) ## v1.1.0(2026-10-14)
### ✨ 新增功能 ### ✨ 新增功能
+40
View File
@@ -55,6 +55,22 @@ Layout布局content区域的磨砂玻璃卡片圆角样式我要改动一下:
- 隧道列表卡片、新建隧道弹窗的背景效果都改成透明磨砂玻璃效果 - 隧道列表卡片、新建隧道弹窗的背景效果都改成透明磨砂玻璃效果
- Frpc下载列表卡片的背景效果都改成透明磨砂玻璃效果 - Frpc下载列表卡片的背景效果都改成透明磨砂玻璃效果
#### 2.5 外观设置新增卡片透明度和弹窗透明度修改
- 在外观设置新增两个进度条,用于拖动控制卡片磨砂玻璃效果的透明度和弹窗磨砂玻璃效果的透明度
- 拖动进度条设置透明度的时候可以实时看到效果
#### 2.6 明暗主题切换,部分字体颜色没有跟随主题变化
- 切换暗色主题之后,部分字体颜色没有随主题切换
- 仪表盘页面里面,系统信息卡片、CPU和内存占用卡片、frpc版本、实时日志这些卡片里面的字体应该没有随主题变化
- 服务器页面里面,服务器三个字没有变化颜色
- 隧道列表页面里面,隧道列表三个字没有改变颜色
- Frpc卸载页面里面,Frpc下载、所有架构Frpc、所有架构Frps这些没有改变颜色
- 侧边栏的CPU/内存占用,状态文字没有改变颜色
- 设置页面里面也有一些没有随主题改变颜色
- 整体全部检查并且修复一下
### 3. 功能开发 ### 3. 功能开发
#### 3.1 开发服务器配置功能 #### 3.1 开发服务器配置功能
@@ -139,3 +155,27 @@ Layout布局content区域的磨砂玻璃卡片圆角样式我要改动一下:
- 点击Header栏里面的关闭按钮,目前是直接退出整个程序,现在改成先出现一个对话框,对话框内有两个单选项分别是最小化到系统托盘、直接退出,如果选择最小化到系统托盘,则保持程序运行状态,隐藏到系统托盘,如果是直接退出,则停止穿透并退出软件(Mac和Windows同步) - 点击Header栏里面的关闭按钮,目前是直接退出整个程序,现在改成先出现一个对话框,对话框内有两个单选项分别是最小化到系统托盘、直接退出,如果选择最小化到系统托盘,则保持程序运行状态,隐藏到系统托盘,如果是直接退出,则停止穿透并退出软件(Mac和Windows同步)
开发过程中使用到的组件尽可能全部使用Antdv库里面的,另外我引入的有tailwindcss原子化组件、auto-import和components依赖可以简化引入,开发之前尽可能先阅读一下package.json,看一下有哪些依赖可以简化代码开发 开发过程中使用到的组件尽可能全部使用Antdv库里面的,另外我引入的有tailwindcss原子化组件、auto-import和components依赖可以简化引入,开发之前尽可能先阅读一下package.json,看一下有哪些依赖可以简化代码开发
#### 3.8 系统托盘图标BUG修改
- 开发阶段每次修改代码热更新之后,系统托盘图标就会新增一个
- 需要修复确保只有一个系统托盘图标
#### 3.9 新增常规设置
- 设置页增加一个子页面-常规,第一项设置内容是关闭行为设置
- 目前的关闭行为弹窗分为最小化到系统托盘和直接退出两种,需要增加一个单选组,里面是每次询问和保持以上选择(默认每次询问)
- 每次询问即每次关闭都需要弹窗供用户选择,保持以上选择则是保持用户选择的关闭方式,持久生效
- 常规设置页面需要同步关闭弹窗里面的内容,使用户可以再次修改设置
- 常规设置页面再增加一个语言切换设置,保持跟Header栏同步
开发过程中使用到的组件尽可能全部使用Antdv库里面的,另外我引入的有tailwindcss原子化组件、auto-import和components依赖可以简化引入,开发之前尽可能先阅读一下package.json,看一下有哪些依赖可以简化代码开发
#### 3.10 Frpc下载页面增加缓存
目前每次进入Frpc下载页面都会重新去获取Frpc的下载列表,在路由表增加一个meta属性用于开关页面是否缓存isCache
- 使用keep-alive缓存页面组件,避免每次进入都会去请求GitHub,避免触发限流
- 不需要做持久化缓存,只需要在应用运行时生效即可
- 应用运行时只有在第一次进入Frpc下载页面的时候采取请求GitHub,后面再切换回Frpc下载页面的时候,因为缓存了组件,所以不必再次获取GitHub,需要刷新的时候由用户手动点击刷新按钮
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "frpilot", "name": "frpilot",
"private": true, "private": true,
"version": "1.1.0", "version": "1.2.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 359 KiB

After

Width:  |  Height:  |  Size: 339 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 684 KiB

+19 -10
View File
@@ -13,6 +13,12 @@ body,
/* 窗口背景色与渐变(供 html / #app 复用,避免重复定义) */ /* 窗口背景色与渐变(供 html / #app 复用,避免重复定义) */
:root { :root {
/* 全局文字颜色:普通 HTML 文本继承使用(Antd 组件由 CSS-in-JS 自行着色,不受影响) */
--app-text-color: rgba(15, 23, 42, 0.88);
/* 磨砂玻璃透明度:由「外观设置」实时写入 <html>,默认值与初始外观保持一致 */
--app-card-alpha: 0.45;
--app-card-alpha-hover: 0.62;
--app-modal-alpha: 0.62;
--app-bg-color: #eef2ff; --app-bg-color: #eef2ff;
--app-bg-gradient: --app-bg-gradient:
radial-gradient(1200px 600px at 10% -10%, rgba(99, 102, 241, 0.28), transparent 60%), radial-gradient(1200px 600px at 10% -10%, rgba(99, 102, 241, 0.28), transparent 60%),
@@ -21,6 +27,7 @@ body,
} }
html.dark { html.dark {
--app-text-color: rgba(226, 232, 240, 0.92);
--app-bg-color: #0b1220; --app-bg-color: #0b1220;
--app-bg-gradient: --app-bg-gradient:
radial-gradient(1200px 600px at 10% -10%, rgba(99, 102, 241, 0.35), transparent 60%), radial-gradient(1200px 600px at 10% -10%, rgba(99, 102, 241, 0.35), transparent 60%),
@@ -85,6 +92,8 @@ body {
overflow: hidden; overflow: hidden;
font-family: 'Inter', 'HarmonyOS Sans SC', 'PingFang SC', 'Microsoft YaHei', system-ui, sans-serif; font-family: 'Inter', 'HarmonyOS Sans SC', 'PingFang SC', 'Microsoft YaHei', system-ui, sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
/* 文字颜色跟随明暗主题,避免暗色下普通文本仍是浏览器默认黑色 */
color: var(--app-text-color);
/* 背景交由 <html>(或 macOS 下的 #app)绘制,body 保持透明避免覆盖画布背景 */ /* 背景交由 <html>(或 macOS 下的 #app)绘制,body 保持透明避免覆盖画布背景 */
background-color: transparent; background-color: transparent;
} }
@@ -112,7 +121,7 @@ body {
.glass-panel { .glass-panel {
border-radius: 16px; border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.55); border: 1px solid rgba(255, 255, 255, 0.55);
background: rgba(255, 255, 255, 0.45); background: rgba(255, 255, 255, var(--app-card-alpha));
backdrop-filter: blur(18px) saturate(160%); backdrop-filter: blur(18px) saturate(160%);
-webkit-backdrop-filter: blur(18px) saturate(160%); -webkit-backdrop-filter: blur(18px) saturate(160%);
box-shadow: 0 8px 32px rgba(15, 23, 42, 0.12); box-shadow: 0 8px 32px rgba(15, 23, 42, 0.12);
@@ -120,7 +129,7 @@ body {
.dark .glass-panel { .dark .glass-panel {
border-color: rgba(255, 255, 255, 0.08); border-color: rgba(255, 255, 255, 0.08);
background: rgba(15, 23, 42, 0.45); background: rgba(15, 23, 42, var(--app-card-alpha));
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
} }
@@ -131,7 +140,7 @@ body {
*/ */
.glass-card.ant-card { .glass-card.ant-card {
border: 1px solid rgba(255, 255, 255, 0.5) !important; border: 1px solid rgba(255, 255, 255, 0.5) !important;
background: rgba(255, 255, 255, 0.45) !important; background: rgba(255, 255, 255, var(--app-card-alpha)) !important;
backdrop-filter: blur(16px) saturate(160%); backdrop-filter: blur(16px) saturate(160%);
-webkit-backdrop-filter: blur(16px) saturate(160%); -webkit-backdrop-filter: blur(16px) saturate(160%);
box-shadow: 0 6px 22px rgba(15, 23, 42, 0.08) !important; box-shadow: 0 6px 22px rgba(15, 23, 42, 0.08) !important;
@@ -140,19 +149,19 @@ body {
.glass-card.ant-card:hover { .glass-card.ant-card:hover {
border-color: rgba(99, 102, 241, 0.45) !important; border-color: rgba(99, 102, 241, 0.45) !important;
background: rgba(255, 255, 255, 0.62) !important; background: rgba(255, 255, 255, var(--app-card-alpha-hover)) !important;
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.14) !important; box-shadow: 0 10px 28px rgba(15, 23, 42, 0.14) !important;
} }
.dark .glass-card.ant-card { .dark .glass-card.ant-card {
border-color: rgba(255, 255, 255, 0.1) !important; border-color: rgba(255, 255, 255, 0.1) !important;
background: rgba(30, 41, 59, 0.5) !important; background: rgba(30, 41, 59, var(--app-card-alpha)) !important;
box-shadow: 0 6px 22px rgba(0, 0, 0, 0.35) !important; box-shadow: 0 6px 22px rgba(0, 0, 0, 0.35) !important;
} }
.dark .glass-card.ant-card:hover { .dark .glass-card.ant-card:hover {
border-color: rgba(129, 140, 248, 0.5) !important; border-color: rgba(129, 140, 248, 0.5) !important;
background: rgba(30, 41, 59, 0.62) !important; background: rgba(30, 41, 59, var(--app-card-alpha-hover)) !important;
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45) !important; box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45) !important;
} }
@@ -172,7 +181,7 @@ body {
max-height: 80vh; max-height: 80vh;
overflow: hidden; overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.55); border: 1px solid rgba(255, 255, 255, 0.55);
background: rgba(255, 255, 255, 0.62) !important; background: rgba(255, 255, 255, var(--app-modal-alpha)) !important;
backdrop-filter: blur(24px) saturate(170%); backdrop-filter: blur(24px) saturate(170%);
-webkit-backdrop-filter: blur(24px) saturate(170%); -webkit-backdrop-filter: blur(24px) saturate(170%);
box-shadow: 0 16px 48px rgba(15, 23, 42, 0.18) !important; box-shadow: 0 16px 48px rgba(15, 23, 42, 0.18) !important;
@@ -187,7 +196,7 @@ body {
.dark .form-modal-wrap .ant-modal-content { .dark .form-modal-wrap .ant-modal-content {
border-color: rgba(255, 255, 255, 0.1); border-color: rgba(255, 255, 255, 0.1);
background: rgba(30, 41, 59, 0.62) !important; background: rgba(30, 41, 59, var(--app-modal-alpha)) !important;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5) !important; box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5) !important;
} }
@@ -217,7 +226,7 @@ body {
max-height: 60vh; max-height: 60vh;
overflow: hidden; overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.55); border: 1px solid rgba(255, 255, 255, 0.55);
background: rgba(255, 255, 255, 0.72) !important; background: rgba(255, 255, 255, var(--app-modal-alpha)) !important;
backdrop-filter: blur(24px) saturate(170%); backdrop-filter: blur(24px) saturate(170%);
-webkit-backdrop-filter: blur(24px) saturate(170%); -webkit-backdrop-filter: blur(24px) saturate(170%);
box-shadow: 0 16px 48px rgba(15, 23, 42, 0.18) !important; box-shadow: 0 16px 48px rgba(15, 23, 42, 0.18) !important;
@@ -232,7 +241,7 @@ body {
.dark .update-modal-wrap .ant-modal-content { .dark .update-modal-wrap .ant-modal-content {
border-color: rgba(255, 255, 255, 0.1); border-color: rgba(255, 255, 255, 0.1);
background: rgba(30, 41, 59, 0.72) !important; background: rgba(30, 41, 59, var(--app-modal-alpha)) !important;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5) !important; box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5) !important;
} }
+86
View File
@@ -0,0 +1,86 @@
<template>
<!-- 卡片式单选组:标题 + 描述纵向排列,选中时整卡高亮。关闭弹窗与「设置 - 常规」共用 -->
<a-radio-group v-model:value="model" class="card-radio-group" :disabled="disabled">
<a-radio
v-for="option in options"
:key="String(option.value)"
:value="option.value"
class="card-radio"
>
<span class="card-radio-title">{{ option.label }}</span>
<span v-if="option.description" class="card-radio-desc">{{ option.description }}</span>
</a-radio>
</a-radio-group>
</template>
<script setup lang="ts" generic="T extends string | number">
withDefaults(
defineProps<{
options: { value: T; label: string; description?: string }[]
disabled?: boolean
}>(),
{ disabled: false },
)
const model = defineModel<T>({ required: true })
</script>
<style scoped lang="scss">
.card-radio-group {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
}
.card-radio {
display: flex;
align-items: flex-start;
width: 100%;
margin-inline-end: 0;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid rgba(15, 23, 42, 0.1);
transition: border-color 0.2s ease, background-color 0.2s ease;
&:hover {
border-color: rgba(99, 102, 241, 0.5);
}
// 选中态高亮
&.ant-radio-wrapper-checked {
border-color: #6366f1;
background-color: rgba(99, 102, 241, 0.08);
}
:deep(.ant-radio) {
margin-top: 2px;
}
:deep(> span:last-child) {
display: flex;
flex-direction: column;
gap: 2px;
padding-inline-end: 0;
}
}
.dark .card-radio {
border-color: rgba(255, 255, 255, 0.12);
&.ant-radio-wrapper-checked {
border-color: #6366f1;
background-color: rgba(99, 102, 241, 0.16);
}
}
.card-radio-title {
font-weight: 500;
}
.card-radio-desc {
font-size: 12px;
line-height: 1.4;
opacity: 0.6;
}
</style>
+43 -73
View File
@@ -2,29 +2,27 @@
<a-modal <a-modal
:open="store.closeConfirmOpen" :open="store.closeConfirmOpen"
:title="t('closeDialog.title')" :title="t('closeDialog.title')"
:width="380" :width="400"
centered centered
:mask-closable="false" :mask-closable="false"
:closable="!store.quitting" :closable="!store.quitting"
:keyboard="!store.quitting" :keyboard="!store.quitting"
@cancel="store.cancelClose()" @cancel="store.cancelClose()"
> >
<a-radio-group v-model:value="store.closeChoice" class="close-choice"> <!-- 关闭方式:最小化到系统托盘 / 直接退出 -->
<a-radio value="tray" class="close-option"> <CardRadioGroup v-model="draftChoice" :options="choiceOptions" :disabled="store.quitting" />
<span class="close-option-title">{{ t("closeDialog.tray") }}</span>
<span class="close-option-desc">{{ t("closeDialog.trayDesc") }}</span> <a-divider class="!my-3" />
</a-radio>
<a-radio value="exit" class="close-option"> <!-- 关闭行为:每次询问 / 保持以上选择 -->
<span class="close-option-title">{{ t("closeDialog.exit") }}</span> <div class="mb-2 text-xs opacity-60">{{ t("closeDialog.behavior") }}</div>
<span class="close-option-desc">{{ t("closeDialog.exitDesc") }}</span> <CardRadioGroup v-model="draftBehavior" :options="behaviorOptions" :disabled="store.quitting" />
</a-radio>
</a-radio-group>
<template #footer> <template #footer>
<a-button :disabled="store.quitting" @click="store.cancelClose()"> <a-button :disabled="store.quitting" @click="store.cancelClose()">
{{ t("common.cancel") }} {{ t("common.cancel") }}
</a-button> </a-button>
<a-button type="primary" :loading="store.quitting" @click="store.confirmClose()"> <a-button type="primary" :loading="store.quitting" @click="onConfirm">
{{ t("common.confirm") }} {{ t("common.confirm") }}
</a-button> </a-button>
</template> </template>
@@ -33,68 +31,40 @@
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from "vue-i18n" import { useI18n } from "vue-i18n"
import { useWindowStore } from "@/stores/window" import { useWindowStore, type CloseBehavior, type CloseChoice } from "@/stores/window"
const { t } = useI18n() const { t } = useI18n()
const store = useWindowStore() const store = useWindowStore()
/** 弹窗内的临时选择,点击「确定」后才写回设置(避免误触单选框又取消时被改掉) */
const draftChoice = ref<CloseChoice>("tray")
const draftBehavior = ref<CloseBehavior>("ask")
const choiceOptions = computed(() => [
{ value: "tray" as const, label: t("closeDialog.tray"), description: t("closeDialog.trayDesc") },
{ value: "exit" as const, label: t("closeDialog.exit"), description: t("closeDialog.exitDesc") },
])
const behaviorOptions = computed(() => [
{ value: "ask" as const, label: t("closeDialog.ask"), description: t("closeDialog.askDesc") },
{
value: "remember" as const,
label: t("closeDialog.remember"),
description: t("closeDialog.rememberDesc"),
},
])
// 每次打开弹窗时,用当前设置初始化临时选择
watch(
() => store.closeConfirmOpen,
(open) => {
if (!open) return
draftChoice.value = store.closeChoice
draftBehavior.value = store.closeBehavior
},
)
function onConfirm() {
void store.confirmClose(draftChoice.value, draftBehavior.value)
}
</script> </script>
<style scoped lang="scss">
.close-choice {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
}
.close-option {
display: flex;
align-items: flex-start;
width: 100%;
margin-inline-end: 0;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid rgba(15, 23, 42, 0.1);
transition: border-color 0.2s ease, background-color 0.2s ease;
&:hover {
border-color: rgba(99, 102, 241, 0.5);
}
// 选中态高亮
&.ant-radio-wrapper-checked {
border-color: #6366f1;
background-color: rgba(99, 102, 241, 0.08);
}
:deep(.ant-radio) {
margin-top: 2px;
}
:deep(> span:last-child) {
display: flex;
flex-direction: column;
gap: 2px;
padding-inline-end: 0;
}
}
.dark .close-option {
border-color: rgba(255, 255, 255, 0.12);
&.ant-radio-wrapper-checked {
border-color: #6366f1;
background-color: rgba(99, 102, 241, 0.16);
}
}
.close-option-title {
font-weight: 500;
}
.close-option-desc {
font-size: 12px;
line-height: 1.4;
opacity: 0.6;
}
</style>
+45 -3
View File
@@ -5,13 +5,47 @@ import { useFrpcStore } from "@/stores/frpc"
import { useWindowStore } from "@/stores/window" import { useWindowStore } from "@/stores/window"
import { isTauri } from "@/utils/window" import { isTauri } from "@/utils/window"
/** 托盘图标固定 id(便于热更新 / 重载时清理残留的旧图标) */
const TRAY_ID = "frpilot-tray"
/** 是否已初始化,避免重复创建托盘 */ /** 是否已初始化,避免重复创建托盘 */
let initialized = false let initialized = false
/** 停止状态 / 语言监听,热更新时释放,避免旧实例订阅残留 */
let stopWatchers: (() => void) | null = null
function errMsg(e: unknown) { function errMsg(e: unknown) {
return e instanceof Error ? e.message : String(e) return e instanceof Error ? e.message : String(e)
} }
/**
* 移除所有同 id 的残留托盘图标。
*
* Tauri 在 Rust 侧用 `Vec` 保存托盘图标,且 JS 资源不会随页面卸载 / GC 自动释放
* (官方文档明确说明 JS 无法在对象被回收时执行清理),因此开发时每次热更新或
* 页面重载都会保留旧图标并叠加出一个新的。创建前先按 id 清空,保证始终只有一个。
*
* `removeById` 在找不到对应托盘时会抛错,据此作为循环终止条件(同时兜底清理
* 先前已经叠加的多个图标)。
*/
async function removeExistingTrays(TrayIcon: typeof import("@tauri-apps/api/tray").TrayIcon) {
for (let i = 0; i < 10; i += 1) {
try {
await TrayIcon.removeById(TRAY_ID)
} catch {
break
}
}
}
// 开发模式热更新:模块被替换时释放监听,避免旧实例的订阅残留
if (import.meta.hot) {
import.meta.hot.dispose(() => {
stopWatchers?.()
stopWatchers = null
})
}
/** /**
* 初始化系统托盘(Tauri 前端 API 方式,参见 Tauri 文档「系统托盘」)。 * 初始化系统托盘(Tauri 前端 API 方式,参见 Tauri 文档「系统托盘」)。
* *
@@ -43,6 +77,9 @@ async function createTray() {
import("@tauri-apps/api/app"), import("@tauri-apps/api/app"),
]) ])
// 先清理开发时热更新 / 页面重载残留的托盘图标,避免叠加出多个
await removeExistingTrays(TrayIcon)
// 显示窗口(始终可用) // 显示窗口(始终可用)
const showItem = await MenuItem.new({ const showItem = await MenuItem.new({
id: "tray-show", id: "tray-show",
@@ -86,7 +123,7 @@ async function createTray() {
const icon = await defaultWindowIcon().catch(() => null) const icon = await defaultWindowIcon().catch(() => null)
await TrayIcon.new({ await TrayIcon.new({
id: "frpilot-tray", id: TRAY_ID,
icon: icon ?? undefined, icon: icon ?? undefined,
tooltip: "Frpilot", tooltip: "Frpilot",
menu, menu,
@@ -101,7 +138,7 @@ async function createTray() {
}) })
// Frpc 运行状态变化时同步菜单可用状态 // Frpc 运行状态变化时同步菜单可用状态
watch( const stopStatusWatch = watch(
[() => frpcStore.canStart, () => frpcStore.canStop], [() => frpcStore.canStart, () => frpcStore.canStop],
([canStart, canStop]) => { ([canStart, canStop]) => {
void startItem.setEnabled(canStart) void startItem.setEnabled(canStart)
@@ -110,7 +147,7 @@ async function createTray() {
) )
// 语言切换时同步菜单文案 // 语言切换时同步菜单文案
watch( const stopTextWatch = watch(
() => appStore.language, () => appStore.language,
() => { () => {
void showItem.setText(translate("tray.show")) void showItem.setText(translate("tray.show"))
@@ -119,6 +156,11 @@ async function createTray() {
void quitItem.setText(translate("tray.quit")) void quitItem.setText(translate("tray.quit"))
}, },
) )
stopWatchers = () => {
stopStatusWatch()
stopTextWatch()
}
} }
/** 托盘「启动穿透」:复用仪表盘的一键启动逻辑 */ /** 托盘「启动穿透」:复用仪表盘的一键启动逻辑 */
+15 -3
View File
@@ -1,9 +1,15 @@
import bg1 from "@/assets/images/bg-1.webp" import bg1 from "@/assets/images/bg-1.webp"
import bg2 from "@/assets/images/bg-2.webp" import bg2 from "@/assets/images/bg-2.webp"
// import bg3 from "@/assets/images/bg-3.webp" // 暂时下架「背景三」,需要时与下方选项一并恢复 import bg3 from "@/assets/images/bg-3.webp"
import bg4 from "@/assets/images/bg-4.webp"
import bg5 from "@/assets/images/bg-5.webp"
import bg6 from "@/assets/images/bg-6.webp"
import bg7 from "@/assets/images/bg-7.webp"
import bg8 from "@/assets/images/bg-8.webp"
import bg9 from "@/assets/images/bg-9.webp"
/** 窗口背景图片标识 */ /** 窗口背景图片标识 */
export type BackgroundKey = "none" | "bg-1" | "bg-2" | "bg-3" export type BackgroundKey = "none" | "bg-1" | "bg-2" | "bg-3" | "bg-4" | "bg-5" | "bg-6" | "bg-7" | "bg-8" | "bg-9"
export interface BackgroundOption { export interface BackgroundOption {
/** 唯一标识 */ /** 唯一标识 */
@@ -19,7 +25,13 @@ export const backgroundOptions: BackgroundOption[] = [
{ key: "none", label: "settings.appearance.none", url: "" }, { key: "none", label: "settings.appearance.none", url: "" },
{ key: "bg-1", label: "settings.appearance.bg1", url: bg1 }, { key: "bg-1", label: "settings.appearance.bg1", url: bg1 },
{ key: "bg-2", label: "settings.appearance.bg2", url: bg2 }, { key: "bg-2", label: "settings.appearance.bg2", url: bg2 },
// { key: "bg-3", label: "settings.appearance.bg3", url: bg3 }, { key: "bg-3", label: "settings.appearance.bg3", url: bg3 },
{ key: "bg-4", label: "settings.appearance.bg4", url: bg4 },
{ key: "bg-5", label: "settings.appearance.bg5", url: bg5 },
{ key: "bg-6", label: "settings.appearance.bg6", url: bg6 },
{ key: "bg-7", label: "settings.appearance.bg7", url: bg7 },
{ key: "bg-8", label: "settings.appearance.bg8", url: bg8 },
{ key: "bg-9", label: "settings.appearance.bg9", url: bg9 },
] ]
export const DEFAULT_BACKGROUND: BackgroundKey = "none" export const DEFAULT_BACKGROUND: BackgroundKey = "none"
+29 -2
View File
@@ -39,7 +39,12 @@
"tray": "Minimize to system tray", "tray": "Minimize to system tray",
"trayDesc": "Keep Frpilot running in the background; reopen it from the system tray", "trayDesc": "Keep Frpilot running in the background; reopen it from the system tray",
"exit": "Quit directly", "exit": "Quit directly",
"exitDesc": "Stop the tunnel and exit Frpilot completely" "exitDesc": "Stop the tunnel and exit Frpilot completely",
"behavior": "Close behavior",
"ask": "Ask every time",
"askDesc": "Show this dialog on every close and let you choose",
"remember": "Keep the choice above",
"rememberDesc": "Remember the close method above and never ask again"
}, },
"system": { "system": {
"cpu": "CPU usage", "cpu": "CPU usage",
@@ -73,9 +78,20 @@
"title": "Settings", "title": "Settings",
"placeholder": "The settings page is under construction.", "placeholder": "The settings page is under construction.",
"menu": { "menu": {
"general": "General",
"appearance": "Appearance", "appearance": "Appearance",
"about": "About" "about": "About"
}, },
"general": {
"title": "General",
"subtitle": "Basic settings such as close behavior and app language",
"closeBehavior": "Close behavior",
"closeBehaviorTip": "Choose what happens when you click the close button. Kept in sync with the close dialog.",
"closeAction": "Close method",
"closeActionTip": "Takes effect when \"Keep the choice above\" is selected. The window closes this way directly, and it is also the default option in the close dialog.",
"language": "Language",
"languageTip": "Switch the interface language. In sync with the language switcher in the title bar."
},
"appearance": { "appearance": {
"title": "Appearance", "title": "Appearance",
"subtitle": "Customize the theme mode and window background", "subtitle": "Customize the theme mode and window background",
@@ -88,7 +104,18 @@
"none": "Default", "none": "Default",
"bg1": "Background 1", "bg1": "Background 1",
"bg2": "Background 2", "bg2": "Background 2",
"bg3": "Background 3" "bg3": "Background 3",
"bg4": "Background 4",
"bg5": "Background 5",
"bg6": "Background 6",
"bg7": "Background 7",
"bg8": "Background 8",
"bg9": "Background 9",
"glass": "Glass transparency",
"glassTip": "Drag the sliders to adjust how transparent the cards and dialogs are. Higher values are more transparent.",
"cardTransparency": "Card transparency",
"modalTransparency": "Dialog transparency",
"previewTip": "Top preview shows cards, bottom shows dialogs — drag a slider to preview live."
} }
}, },
"server": { "server": {
+29 -2
View File
@@ -39,7 +39,12 @@
"tray": "最小化到系统托盘", "tray": "最小化到系统托盘",
"trayDesc": "保持 Frpilot 在后台运行,可从系统托盘随时打开", "trayDesc": "保持 Frpilot 在后台运行,可从系统托盘随时打开",
"exit": "直接退出", "exit": "直接退出",
"exitDesc": "停止穿透并完全退出 Frpilot" "exitDesc": "停止穿透并完全退出 Frpilot",
"behavior": "关闭行为",
"ask": "每次询问",
"askDesc": "每次关闭都弹出此对话框,由你选择关闭方式",
"remember": "保持以上选择",
"rememberDesc": "记住上面的关闭方式,之后关闭不再询问"
}, },
"system": { "system": {
"cpu": "CPU 占用", "cpu": "CPU 占用",
@@ -73,9 +78,20 @@
"title": "设置", "title": "设置",
"placeholder": "设置页面开发中,敬请期待。", "placeholder": "设置页面开发中,敬请期待。",
"menu": { "menu": {
"general": "常规",
"appearance": "外观设置", "appearance": "外观设置",
"about": "关于" "about": "关于"
}, },
"general": {
"title": "常规",
"subtitle": "关闭行为与应用语言等基础设置",
"closeBehavior": "关闭行为",
"closeBehaviorTip": "设置点击关闭按钮时的处理方式,与关闭弹窗中的选项保持一致",
"closeAction": "关闭方式",
"closeActionTip": "选择「保持以上选择」后生效,关闭窗口时将直接按此方式处理,同时也作为关闭弹窗的默认选项",
"language": "语言",
"languageTip": "切换界面显示语言,与顶部栏的语言切换保持同步"
},
"appearance": { "appearance": {
"title": "外观设置", "title": "外观设置",
"subtitle": "自定义窗口的主题模式与背景图片", "subtitle": "自定义窗口的主题模式与背景图片",
@@ -88,7 +104,18 @@
"none": "默认", "none": "默认",
"bg1": "背景一", "bg1": "背景一",
"bg2": "背景二", "bg2": "背景二",
"bg3": "背景三" "bg3": "背景三",
"bg4": "背景四",
"bg5": "背景五",
"bg6": "背景六",
"bg7": "背景七",
"bg8": "背景八",
"bg9": "背景九",
"glass": "磨砂玻璃透明度",
"glassTip": "拖动滑块调整卡片与弹窗背景的透明程度,数值越大越透明",
"cardTransparency": "卡片透明度",
"modalTransparency": "弹窗透明度",
"previewTip": "上方为卡片效果,下方为弹窗效果,可拖动滑块实时预览"
} }
}, },
"server": { "server": {
+19 -1
View File
@@ -9,7 +9,12 @@
<Aside /> <Aside />
<main class="min-w-0 flex-1 overflow-hidden"> <main class="min-w-0 flex-1 overflow-hidden">
<router-view /> <!-- 按路由 meta.isCache 决定是否缓存页面组件(keep-alive 不持久化,仅在应用运行时生效) -->
<router-view v-slot="{ Component }">
<keep-alive :include="cachedViews">
<component :is="Component" />
</keep-alive>
</router-view>
</main> </main>
</div> </div>
@@ -31,6 +36,19 @@ import { useUpdateStore } from "@/stores/update"
const router = useRouter() const router = useRouter()
const updateStore = useUpdateStore() const updateStore = useUpdateStore()
/**
* 需要 keep-alive 缓存的页面组件名列表(由路由 meta.isCache 决定)。
*
* keep-alive 通过组件名匹配缓存,故约定:缓存页面的组件名需与路由 name 一致
* (页面通过 defineOptions({ name }) 显式声明)。
*/
const cachedViews = computed(() =>
router
.getRoutes()
.filter((record) => record.meta.isCache && record.name)
.map((record) => String(record.name)),
)
/** 点击标题栏“设置”按钮 → 跳转设置页 */ /** 点击标题栏“设置”按钮 → 跳转设置页 */
function goToSetting() { function goToSetting() {
router.push("/setting") router.push("/setting")
+19 -2
View File
@@ -1,6 +1,16 @@
import { createRouter, createWebHistory } from "vue-router"; import { createRouter, createWebHistory } from "vue-router";
import { RouteRecordRaw } from "vue-router"; import { RouteRecordRaw } from "vue-router";
/** 路由元信息类型扩展 */
declare module "vue-router" {
interface RouteMeta {
/** 页面标题(i18n key) */
title?: string;
/** 是否使用 keep-alive 缓存该页面组件(默认不缓存) */
isCache?: boolean;
}
}
const routes: RouteRecordRaw[] = [ const routes: RouteRecordRaw[] = [
{ {
path: "/", path: "/",
@@ -30,15 +40,22 @@ const routes: RouteRecordRaw[] = [
path: "download", path: "download",
name: "Download", name: "Download",
component: () => import("@/views/Download.vue"), component: () => import("@/views/Download.vue"),
meta: { title: "menu.download" }, // 缓存该页面:避免每次进入都请求 GitHub 触发限流,需刷新时由用户手动点击刷新按钮
meta: { title: "menu.download", isCache: true },
}, },
{ {
path: "setting", path: "setting",
name: "Setting", name: "Setting",
component: () => import("@/views/Setting.vue"), component: () => import("@/views/Setting.vue"),
redirect: "/setting/appearance", redirect: "/setting/general",
meta: { title: "settings.title" }, meta: { title: "settings.title" },
children: [ children: [
{
path: "general",
name: "General",
component: () => import("@/views/setting/General.vue"),
meta: { title: "settings.menu.general" },
},
{ {
path: "appearance", path: "appearance",
name: "Appearance", name: "Appearance",
+23
View File
@@ -45,6 +45,27 @@ export const useAppStore = defineStore("app", () => {
} }
}) })
// 磨砂玻璃透明度(0 - 100,数值越大越透明,持久化)
const cardTransparency = useStorage<number>("frpilot:card-transparency", 55)
const modalTransparency = useStorage<number>("frpilot:modal-transparency", 38)
/** 透明度百分比 → 背景 alpha(越大越透明,alpha 越小;并做范围兜底) */
function alphaFromTransparency(value: number) {
const percent = Math.min(100, Math.max(0, Number(value) || 0))
return (100 - percent) / 100
}
const cardAlpha = computed(() => alphaFromTransparency(cardTransparency.value))
const modalAlpha = computed(() => alphaFromTransparency(modalTransparency.value))
// 把透明度实时写入 <html> 的 CSS 变量,供磨砂玻璃样式(卡片 / 弹窗)即时生效
watchEffect(() => {
const root = document.documentElement
root.style.setProperty("--app-card-alpha", String(cardAlpha.value))
root.style.setProperty("--app-card-alpha-hover", String(Math.min(cardAlpha.value + 0.15, 0.95)))
root.style.setProperty("--app-modal-alpha", String(modalAlpha.value))
})
// Frpc 运行状态(后续由后端驱动) // Frpc 运行状态(后续由后端驱动)
const runStatus = ref<RunStatus>("stopped") const runStatus = ref<RunStatus>("stopped")
@@ -76,6 +97,8 @@ export const useAppStore = defineStore("app", () => {
toggleDark, toggleDark,
background, background,
backgroundUrl, backgroundUrl,
cardTransparency,
modalTransparency,
runStatus, runStatus,
antdLocale, antdLocale,
setLanguage, setLanguage,
+37 -11
View File
@@ -5,26 +5,34 @@ import { useFrpcStore } from "@/stores/frpc"
/** 关闭窗口时用户的选择:最小化到系统托盘 / 直接退出 */ /** 关闭窗口时用户的选择:最小化到系统托盘 / 直接退出 */
export type CloseChoice = "tray" | "exit" export type CloseChoice = "tray" | "exit"
/** 关闭行为:每次询问 / 保持以上选择 */
export type CloseBehavior = "ask" | "remember"
/** /**
* 窗口生命周期:关闭确认、最小化到系统托盘、退出软件。 * 窗口生命周期:关闭确认、最小化到系统托盘、退出软件。
* *
* - 顶部栏关闭按钮与系统原生关闭(如 Alt+F4)都会先弹出确认对话框; * - 顶部栏关闭按钮与系统原生关闭(如 Alt+F4)都会先经过 requestClose;
* - 选择「最小化到系统托盘」时隐藏窗口,程序保持后台运行; * - 关闭行为为「每次询问」时弹出确认对话框,选择「最小化到系统托盘」会隐藏窗口、
* - 选择「直接退出」时若穿透正在运行会先停止,再退出整个软件。 * 程序保持后台运行,选择「直接退出」会先停止穿透再退出整个软件;
* - 关闭行为为「保持以上选择」时不弹窗,直接按记住的关闭方式处理。
*
* 关闭行为与关闭方式均持久化,可在「设置 - 常规」中再次修改,与关闭弹窗内容保持一致。
*/ */
export const useWindowStore = defineStore("window", () => { export const useWindowStore = defineStore("window", () => {
const frpcStore = useFrpcStore() const frpcStore = useFrpcStore()
/** 关闭确认对话框是否打开 */ /** 关闭确认对话框是否打开 */
const closeConfirmOpen = ref(false) const closeConfirmOpen = ref(false)
/** 当前选中的关闭方式 */ /** 记住的关闭方式(弹窗默认选中项;「保持以上选择」时直接生效,持久化) */
const closeChoice = ref<CloseChoice>("tray") const closeChoice = useStorage<CloseChoice>("frpilot:close-choice", "tray")
/** 关闭行为:每次询问 / 保持以上选择(持久化,默认每次询问) */
const closeBehavior = useStorage<CloseBehavior>("frpilot:close-behavior", "ask")
/** 是否正在退出(停止穿透 + 退出软件) */ /** 是否正在退出(停止穿透 + 退出软件) */
const quitting = ref(false) const quitting = ref(false)
let initialized = false let initialized = false
/** 应用启动时调用一次:拦截系统原生关闭,改为弹出确认对话框 */ /** 应用启动时调用一次:拦截系统原生关闭,改为按关闭行为处理 */
async function init() { async function init() {
if (initialized || !isTauri()) return if (initialized || !isTauri()) return
initialized = true initialized = true
@@ -36,9 +44,16 @@ export const useWindowStore = defineStore("window", () => {
}) })
} }
/** 请求关闭窗口:弹出确认对话框(默认选中最小化到系统托盘) */ /**
* 请求关闭窗口:
* - 「每次询问」弹出确认对话框;
* - 「保持以上选择」不弹窗,直接按记住的方式处理。
*/
function requestClose() { function requestClose() {
closeChoice.value = "tray" if (closeBehavior.value === "remember") {
void applyClose(closeChoice.value)
return
}
closeConfirmOpen.value = true closeConfirmOpen.value = true
} }
@@ -47,10 +62,19 @@ export const useWindowStore = defineStore("window", () => {
closeConfirmOpen.value = false closeConfirmOpen.value = false
} }
/** 确认关闭:按所选方式处理 */ /** 确认关闭:持久化所选行为与方式,再按所选方式处理 */
async function confirmClose() { async function confirmClose(
const choice = closeChoice.value choice: CloseChoice = closeChoice.value,
behavior: CloseBehavior = closeBehavior.value,
) {
closeChoice.value = choice
closeBehavior.value = behavior
closeConfirmOpen.value = false closeConfirmOpen.value = false
await applyClose(choice)
}
/** 按所选关闭方式执行:最小化到托盘 / 退出软件 */
async function applyClose(choice: CloseChoice) {
if (choice === "tray") { if (choice === "tray") {
await minimizeToTray() await minimizeToTray()
} else { } else {
@@ -85,11 +109,13 @@ export const useWindowStore = defineStore("window", () => {
return { return {
closeConfirmOpen, closeConfirmOpen,
closeChoice, closeChoice,
closeBehavior,
quitting, quitting,
init, init,
requestClose, requestClose,
cancelClose, cancelClose,
confirmClose, confirmClose,
applyClose,
minimizeToTray, minimizeToTray,
showWindow, showWindow,
quitApp, quitApp,
+2
View File
@@ -36,6 +36,7 @@ declare module 'vue' {
ARow: typeof import('ant-design-vue/es')['Row'] ARow: typeof import('ant-design-vue/es')['Row']
ASegmented: typeof import('ant-design-vue/es')['Segmented'] ASegmented: typeof import('ant-design-vue/es')['Segmented']
ASelect: typeof import('ant-design-vue/es')['Select'] ASelect: typeof import('ant-design-vue/es')['Select']
ASlider: typeof import('ant-design-vue/es')['Slider']
ASpace: typeof import('ant-design-vue/es')['Space'] ASpace: typeof import('ant-design-vue/es')['Space']
ASpin: typeof import('ant-design-vue/es')['Spin'] ASpin: typeof import('ant-design-vue/es')['Spin']
AStatistic: typeof import('ant-design-vue/es')['Statistic'] AStatistic: typeof import('ant-design-vue/es')['Statistic']
@@ -44,6 +45,7 @@ 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']
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']
+3
View File
@@ -65,6 +65,9 @@ import { isTauri } from "@/utils/window"
const { t } = useI18n() const { t } = useI18n()
// 组件名需与路由 name 一致,以便被 Layout 的 <keep-alive :include> 缓存
defineOptions({ name: "Download" })
const loading = ref(false) const loading = ref(false)
const importing = ref(false) const importing = ref(false)
+6 -1
View File
@@ -20,7 +20,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { h } from "vue" import { h } from "vue"
import { BgColorsOutlined, InfoCircleOutlined } from "@ant-design/icons-vue" import { BgColorsOutlined, InfoCircleOutlined, SlidersOutlined } from "@ant-design/icons-vue"
import { useI18n } from "vue-i18n" import { useI18n } from "vue-i18n"
import type { MenuProps } from "ant-design-vue" import type { MenuProps } from "ant-design-vue"
@@ -31,6 +31,11 @@ const router = useRouter()
const selectedKeys = computed(() => [route.path]) const selectedKeys = computed(() => [route.path])
const menuItems = computed<MenuProps["items"]>(() => [ const menuItems = computed<MenuProps["items"]>(() => [
{
key: "/setting/general",
icon: () => h(SlidersOutlined),
label: t("settings.menu.general"),
},
{ {
key: "/setting/appearance", key: "/setting/appearance",
icon: () => h(BgColorsOutlined), icon: () => h(BgColorsOutlined),
+72
View File
@@ -33,6 +33,37 @@
</a-card> </a-card>
</div> </div>
</section> </section>
<a-divider />
<!-- 磨砂玻璃透明度(拖动即时生效) -->
<section>
<div class="mb-1 text-sm font-medium">{{ t("settings.appearance.glass") }}</div>
<p class="mb-3 text-xs opacity-60">{{ t("settings.appearance.glassTip") }}</p>
<div class="mb-4">
<div class="mb-1 flex items-center justify-between text-xs">
<span>{{ t("settings.appearance.cardTransparency") }}</span>
<span class="font-medium tabular-nums">{{ appStore.cardTransparency }}%</span>
</div>
<a-slider v-model:value="appStore.cardTransparency" :min="0" :max="100" />
</div>
<div class="mb-4">
<div class="mb-1 flex items-center justify-between text-xs">
<span>{{ t("settings.appearance.modalTransparency") }}</span>
<span class="font-medium tabular-nums">{{ appStore.modalTransparency }}%</span>
</div>
<a-slider v-model:value="appStore.modalTransparency" :min="0" :max="100" />
</div>
<!-- 实时预览:两种磨砂效果叠加在彩色底图上,拖动滑块即时可见 -->
<div class="glass-preview" aria-hidden="true">
<div class="glass-preview-card"></div>
<div class="glass-preview-modal"></div>
</div>
<p class="mt-2 text-xs opacity-60">{{ t("settings.appearance.previewTip") }}</p>
</section>
</div> </div>
</template> </template>
@@ -105,4 +136,45 @@ function selectBackground(key: BackgroundKey) {
rgba(148, 163, 184, 0.25) 0 8px, rgba(148, 163, 184, 0.25) 0 8px,
transparent 8px 16px); transparent 8px 16px);
} }
/* 透明度实时预览:纯色底图(暖调中性色,不用渐变)+ 卡片 / 弹窗两种磨砂效果 */
.glass-preview {
display: flex;
flex-direction: column;
gap: 12px;
padding: 14px;
border-radius: 12px;
background-color: #a09adf;
}
.dark .glass-preview {
background-color: #a09adf;
}
.glass-preview-card,
.glass-preview-modal {
border: 1px solid rgba(255, 255, 255, 0.5);
backdrop-filter: blur(10px) saturate(160%);
-webkit-backdrop-filter: blur(10px) saturate(160%);
}
.glass-preview-card {
height: 48px;
border-radius: 12px;
background: rgba(255, 255, 255, var(--app-card-alpha));
}
.glass-preview-modal {
height: 48px;
border-radius: 10px;
background: rgba(255, 255, 255, var(--app-modal-alpha));
}
.dark .glass-preview-card {
background: rgba(30, 41, 59, var(--app-card-alpha));
}
.dark .glass-preview-modal {
background: rgba(30, 41, 59, var(--app-modal-alpha));
}
</style> </style>
+60
View File
@@ -0,0 +1,60 @@
<template>
<div class="max-w-2xl">
<h2 class="mb-1 text-lg font-semibold">{{ t("settings.general.title") }}</h2>
<p class="mb-6 text-xs opacity-60">{{ t("settings.general.subtitle") }}</p>
<!-- 关闭行为:与关闭弹窗内容保持一致,可在此再次修改 -->
<section class="mb-6">
<div class="mb-1 text-sm font-medium">{{ t("settings.general.closeBehavior") }}</div>
<p class="mb-3 text-xs opacity-60">{{ t("settings.general.closeBehaviorTip") }}</p>
<CardRadioGroup v-model="windowStore.closeBehavior" :options="behaviorOptions" />
<!-- 关闭方式:与关闭弹窗一致,可随时修改;选择「保持以上选择」后关闭时直接生效 -->
<div class="mt-4 mb-1 text-sm font-medium">{{ t("settings.general.closeAction") }}</div>
<p class="mb-3 text-xs opacity-60">{{ t("settings.general.closeActionTip") }}</p>
<CardRadioGroup v-model="windowStore.closeChoice" :options="choiceOptions" />
</section>
<a-divider />
<!-- 语言:与顶部栏语言切换共用同一状态 -->
<section>
<div class="mb-1 text-sm font-medium">{{ t("settings.general.language") }}</div>
<p class="mb-3 text-xs opacity-60">{{ t("settings.general.languageTip") }}</p>
<a-radio-group
v-model:value="appStore.language"
:options="languageRadios"
option-type="button"
button-style="solid"
/>
</section>
</div>
</template>
<script setup lang="ts">
import { useI18n } from "vue-i18n"
import { languageOptions } from "@/i18n"
import { useAppStore } from "@/stores/app"
import { useWindowStore } from "@/stores/window"
const { t } = useI18n()
const appStore = useAppStore()
const windowStore = useWindowStore()
const behaviorOptions = computed(() => [
{ value: "ask" as const, label: t("closeDialog.ask"), description: t("closeDialog.askDesc") },
{
value: "remember" as const,
label: t("closeDialog.remember"),
description: t("closeDialog.rememberDesc"),
},
])
const choiceOptions = computed(() => [
{ value: "tray" as const, label: t("closeDialog.tray"), description: t("closeDialog.trayDesc") },
{ value: "exit" as const, label: t("closeDialog.exit"), description: t("closeDialog.exitDesc") },
])
/** 语言选项与顶部栏共用 languageOptions,保证两端一致 */
const languageRadios = languageOptions.map((item) => ({ label: item.label, value: item.key }))
</script>