feat: 新增常规设置页面,支持关闭行为与语言切换

新增常规设置路由和页面,实现关闭行为(每次询问/保持选择)与应用语言的持久化配置;封装卡片式单选组组件复用在关闭弹窗和设置页;修改关闭逻辑,根据关闭行为决定是否显示确认弹窗,同步更新多语言文案和类型定义。
This commit is contained in:
Yuhang Wu
2026-10-16 11:38:00 +08:00
parent dbde020d7e
commit 5c1ce2fa5c
10 changed files with 286 additions and 89 deletions
+12 -1
View File
@@ -159,4 +159,15 @@ Layout布局content区域的磨砂玻璃卡片圆角样式我要改动一下:
#### 3.8 系统托盘图标BUG修改
- 开发阶段每次修改代码热更新之后,系统托盘图标就会新增一个
- 需要修复确保只有一个系统托盘图标
- 需要修复确保只有一个系统托盘图标
#### 3.9 新增常规设置
- 设置页增加一个子页面-常规,第一项设置内容是关闭行为设置
- 目前的关闭行为弹窗分为最小化到系统托盘和直接退出两种,需要增加一个单选组,里面是每次询问和保持以上选择(默认每次询问)
- 每次询问即每次关闭都需要弹窗供用户选择,保持以上选择则是保持用户选择的关闭方式,持久生效
- 常规设置页面需要同步关闭弹窗里面的内容,使用户可以再次修改设置
- 常规设置页面再增加一个语言切换设置,保持跟Header栏同步
开发过程中使用到的组件尽可能全部使用Antdv库里面的,另外我引入的有tailwindcss原子化组件、auto-import和components依赖可以简化引入,开发之前尽可能先阅读一下package.json,看一下有哪些依赖可以简化代码开发
+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
:open="store.closeConfirmOpen"
:title="t('closeDialog.title')"
:width="380"
:width="400"
centered
:mask-closable="false"
:closable="!store.quitting"
:keyboard="!store.quitting"
@cancel="store.cancelClose()"
>
<a-radio-group v-model:value="store.closeChoice" class="close-choice">
<a-radio value="tray" class="close-option">
<span class="close-option-title">{{ t("closeDialog.tray") }}</span>
<span class="close-option-desc">{{ t("closeDialog.trayDesc") }}</span>
</a-radio>
<a-radio value="exit" class="close-option">
<span class="close-option-title">{{ t("closeDialog.exit") }}</span>
<span class="close-option-desc">{{ t("closeDialog.exitDesc") }}</span>
</a-radio>
</a-radio-group>
<!-- 关闭方式:最小化到系统托盘 / 直接退出 -->
<CardRadioGroup v-model="draftChoice" :options="choiceOptions" :disabled="store.quitting" />
<a-divider class="!my-3" />
<!-- 关闭行为:每次询问 / 保持以上选择 -->
<div class="mb-2 text-xs opacity-60">{{ t("closeDialog.behavior") }}</div>
<CardRadioGroup v-model="draftBehavior" :options="behaviorOptions" :disabled="store.quitting" />
<template #footer>
<a-button :disabled="store.quitting" @click="store.cancelClose()">
{{ t("common.cancel") }}
</a-button>
<a-button type="primary" :loading="store.quitting" @click="store.confirmClose()">
<a-button type="primary" :loading="store.quitting" @click="onConfirm">
{{ t("common.confirm") }}
</a-button>
</template>
@@ -33,68 +31,40 @@
<script setup lang="ts">
import { useI18n } from "vue-i18n"
import { useWindowStore } from "@/stores/window"
import { useWindowStore, type CloseBehavior, type CloseChoice } from "@/stores/window"
const { t } = useI18n()
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>
<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>
+17 -1
View File
@@ -39,7 +39,12 @@
"tray": "Minimize to system tray",
"trayDesc": "Keep Frpilot running in the background; reopen it from the system tray",
"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": {
"cpu": "CPU usage",
@@ -73,9 +78,20 @@
"title": "Settings",
"placeholder": "The settings page is under construction.",
"menu": {
"general": "General",
"appearance": "Appearance",
"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": {
"title": "Appearance",
"subtitle": "Customize the theme mode and window background",
+17 -1
View File
@@ -39,7 +39,12 @@
"tray": "最小化到系统托盘",
"trayDesc": "保持 Frpilot 在后台运行,可从系统托盘随时打开",
"exit": "直接退出",
"exitDesc": "停止穿透并完全退出 Frpilot"
"exitDesc": "停止穿透并完全退出 Frpilot",
"behavior": "关闭行为",
"ask": "每次询问",
"askDesc": "每次关闭都弹出此对话框,由你选择关闭方式",
"remember": "保持以上选择",
"rememberDesc": "记住上面的关闭方式,之后关闭不再询问"
},
"system": {
"cpu": "CPU 占用",
@@ -73,9 +78,20 @@
"title": "设置",
"placeholder": "设置页面开发中,敬请期待。",
"menu": {
"general": "常规",
"appearance": "外观设置",
"about": "关于"
},
"general": {
"title": "常规",
"subtitle": "关闭行为与应用语言等基础设置",
"closeBehavior": "关闭行为",
"closeBehaviorTip": "设置点击关闭按钮时的处理方式,与关闭弹窗中的选项保持一致",
"closeAction": "关闭方式",
"closeActionTip": "选择「保持以上选择」后生效,关闭窗口时将直接按此方式处理,同时也作为关闭弹窗的默认选项",
"language": "语言",
"languageTip": "切换界面显示语言,与顶部栏的语言切换保持同步"
},
"appearance": {
"title": "外观设置",
"subtitle": "自定义窗口的主题模式与背景图片",
+7 -1
View File
@@ -36,9 +36,15 @@ const routes: RouteRecordRaw[] = [
path: "setting",
name: "Setting",
component: () => import("@/views/Setting.vue"),
redirect: "/setting/appearance",
redirect: "/setting/general",
meta: { title: "settings.title" },
children: [
{
path: "general",
name: "General",
component: () => import("@/views/setting/General.vue"),
meta: { title: "settings.menu.general" },
},
{
path: "appearance",
name: "Appearance",
+37 -11
View File
@@ -5,26 +5,34 @@ import { useFrpcStore } from "@/stores/frpc"
/** 关闭窗口时用户的选择:最小化到系统托盘 / 直接退出 */
export type CloseChoice = "tray" | "exit"
/** 关闭行为:每次询问 / 保持以上选择 */
export type CloseBehavior = "ask" | "remember"
/**
* 窗口生命周期:关闭确认、最小化到系统托盘、退出软件。
*
* - 顶部栏关闭按钮与系统原生关闭(如 Alt+F4)都会先弹出确认对话框;
* - 选择「最小化到系统托盘」时隐藏窗口,程序保持后台运行;
* - 选择「直接退出」时若穿透正在运行会先停止,再退出整个软件。
* - 顶部栏关闭按钮与系统原生关闭(如 Alt+F4)都会先经过 requestClose;
* - 关闭行为为「每次询问」时弹出确认对话框,选择「最小化到系统托盘」会隐藏窗口、
* 程序保持后台运行,选择「直接退出」会先停止穿透再退出整个软件;
* - 关闭行为为「保持以上选择」时不弹窗,直接按记住的关闭方式处理。
*
* 关闭行为与关闭方式均持久化,可在「设置 - 常规」中再次修改,与关闭弹窗内容保持一致。
*/
export const useWindowStore = defineStore("window", () => {
const frpcStore = useFrpcStore()
/** 关闭确认对话框是否打开 */
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)
let initialized = false
/** 应用启动时调用一次:拦截系统原生关闭,改为弹出确认对话框 */
/** 应用启动时调用一次:拦截系统原生关闭,改为按关闭行为处理 */
async function init() {
if (initialized || !isTauri()) return
initialized = true
@@ -36,9 +44,16 @@ export const useWindowStore = defineStore("window", () => {
})
}
/** 请求关闭窗口:弹出确认对话框(默认选中最小化到系统托盘) */
/**
* 请求关闭窗口:
* - 「每次询问」弹出确认对话框;
* - 「保持以上选择」不弹窗,直接按记住的方式处理。
*/
function requestClose() {
closeChoice.value = "tray"
if (closeBehavior.value === "remember") {
void applyClose(closeChoice.value)
return
}
closeConfirmOpen.value = true
}
@@ -47,10 +62,19 @@ export const useWindowStore = defineStore("window", () => {
closeConfirmOpen.value = false
}
/** 确认关闭:按所选方式处理 */
async function confirmClose() {
const choice = closeChoice.value
/** 确认关闭:持久化所选行为与方式,再按所选方式处理 */
async function confirmClose(
choice: CloseChoice = closeChoice.value,
behavior: CloseBehavior = closeBehavior.value,
) {
closeChoice.value = choice
closeBehavior.value = behavior
closeConfirmOpen.value = false
await applyClose(choice)
}
/** 按所选关闭方式执行:最小化到托盘 / 退出软件 */
async function applyClose(choice: CloseChoice) {
if (choice === "tray") {
await minimizeToTray()
} else {
@@ -85,11 +109,13 @@ export const useWindowStore = defineStore("window", () => {
return {
closeConfirmOpen,
closeChoice,
closeBehavior,
quitting,
init,
requestClose,
cancelClose,
confirmClose,
applyClose,
minimizeToTray,
showWindow,
quitApp,
+1
View File
@@ -45,6 +45,7 @@ declare module 'vue' {
ATag: typeof import('ant-design-vue/es')['Tag']
ATextarea: typeof import('ant-design-vue/es')['Textarea']
ATooltip: typeof import('ant-design-vue/es')['Tooltip']
CardRadioGroup: typeof import('./../components/CardRadioGroup.vue')['default']
CloseConfirmModal: typeof import('./../components/CloseConfirmModal.vue')['default']
FrpCard: typeof import('./../components/frp/FrpCard.vue')['default']
MacTitleBar: typeof import('./../components/MacTitleBar.vue')['default']
+6 -1
View File
@@ -20,7 +20,7 @@
<script setup lang="ts">
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 type { MenuProps } from "ant-design-vue"
@@ -31,6 +31,11 @@ const router = useRouter()
const selectedKeys = computed(() => [route.path])
const menuItems = computed<MenuProps["items"]>(() => [
{
key: "/setting/general",
icon: () => h(SlidersOutlined),
label: t("settings.menu.general"),
},
{
key: "/setting/appearance",
icon: () => h(BgColorsOutlined),
+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>