feat: 新增开机自启功能

This commit is contained in:
Yuhang Wu
2026-10-20 10:03:36 +08:00
parent 341161c20f
commit 7cbc80176d
13 changed files with 303 additions and 23 deletions
+23
View File
@@ -0,0 +1,23 @@
import { isTauri } from "@/utils/window"
/**
* 开机自启封装(Tauri 官方 autostart 插件)。
*
* 自启状态由系统启动项决定(Windows 注册表 Run 键 / macOS LaunchAgent /
* Linux XDG autostart),这里只做读写,不在前端另行持久化,避免两端不一致。
* 非 Tauri 环境(纯浏览器调试)下调用会被安全忽略。
*/
/** 读取系统启动项:本应用是否已设为开机自启 */
export async function isAutostartEnabled(): Promise<boolean> {
if (!isTauri()) return false
const { isEnabled } = await import("@tauri-apps/plugin-autostart")
return isEnabled()
}
/** 开启 / 关闭开机自启(直接写入系统启动项) */
export async function setAutostartEnabled(enabled: boolean): Promise<void> {
if (!isTauri()) return
const { enable, disable } = await import("@tauri-apps/plugin-autostart")
await (enabled ? enable() : disable())
}
+9 -7
View File
@@ -29,7 +29,7 @@ const model = defineModel<T>({ required: true })
.card-radio-group {
display: flex;
flex-direction: column;
gap: 10px;
gap: 6px;
width: 100%;
}
@@ -38,8 +38,8 @@ const model = defineModel<T>({ required: true })
align-items: flex-start;
width: 100%;
margin-inline-end: 0;
padding: 10px 12px;
border-radius: 10px;
padding: 7px 10px;
border-radius: 8px;
border: 1px solid rgba(15, 23, 42, 0.1);
transition: border-color 0.2s ease, background-color 0.2s ease;
@@ -54,13 +54,13 @@ const model = defineModel<T>({ required: true })
}
:deep(.ant-radio) {
margin-top: 2px;
margin-top: 1px;
}
:deep(> span:last-child) {
display: flex;
flex-direction: column;
gap: 2px;
gap: 0;
padding-inline-end: 0;
}
}
@@ -75,12 +75,14 @@ const model = defineModel<T>({ required: true })
}
.card-radio-title {
font-size: 13px;
font-weight: 500;
line-height: 1.5;
}
.card-radio-desc {
font-size: 12px;
line-height: 1.4;
font-size: 11.5px;
line-height: 1.35;
opacity: 0.6;
}
</style>
+10 -2
View File
@@ -7,15 +7,16 @@
:mask-closable="false"
:closable="!store.quitting"
:keyboard="!store.quitting"
:body-style="{ paddingTop: '10px', paddingBottom: '4px' }"
@cancel="store.cancelClose()"
>
<!-- 关闭方式:最小化到系统托盘 / 直接退出 -->
<CardRadioGroup v-model="draftChoice" :options="choiceOptions" :disabled="store.quitting" />
<a-divider class="!my-3" />
<a-divider class="close-divider" />
<!-- 关闭行为:每次询问 / 保持以上选择 -->
<div class="mb-2 text-xs opacity-60">{{ t("closeDialog.behavior") }}</div>
<div class="mb-1.5 text-xs opacity-60">{{ t("closeDialog.behavior") }}</div>
<CardRadioGroup v-model="draftBehavior" :options="behaviorOptions" :disabled="store.quitting" />
<template #footer>
@@ -68,3 +69,10 @@ function onConfirm() {
void store.confirmClose(draftChoice.value, draftBehavior.value)
}
</script>
<style scoped>
/* 弹窗内两组选项之间只留窄窄一段分隔,整体更紧凑 */
.close-divider {
margin: 8px 0;
}
</style>
+7 -1
View File
@@ -84,7 +84,13 @@
},
"general": {
"title": "General",
"subtitle": "Basic settings such as close behavior and app language",
"subtitle": "Basic settings such as launch at startup, close behavior and app language",
"autostart": "Launch at startup",
"autostartTip": "Start FrpcPilot automatically after you sign in. The switch writes straight to the system startup entry.",
"autostartUnsupported": "System startup entries are not accessible here, so this setting is unavailable.",
"autostartEnabled": "Launch at startup enabled",
"autostartDisabled": "Launch at startup disabled",
"autostartFailed": "Failed to update launch at startup: {message}",
"closeBehavior": "Close behavior",
"closeBehaviorTip": "Choose what happens when you click the close button. Kept in sync with the close dialog.",
"closeAction": "Close method",
+7 -1
View File
@@ -84,7 +84,13 @@
},
"general": {
"title": "常规",
"subtitle": "关闭行为与应用语言等基础设置",
"subtitle": "开机自启、关闭行为与应用语言等基础设置",
"autostart": "开机自启",
"autostartTip": "登录系统后自动启动 FrpcPilot,开关状态直接写入系统启动项",
"autostartUnsupported": "当前环境无法读写系统启动项,暂不支持该设置",
"autostartEnabled": "已开启开机自启",
"autostartDisabled": "已关闭开机自启",
"autostartFailed": "设置开机自启失败:{message}",
"closeBehavior": "关闭行为",
"closeBehaviorTip": "设置点击关闭按钮时的处理方式,与关闭弹窗中的选项保持一致",
"closeAction": "关闭方式",
+65
View File
@@ -0,0 +1,65 @@
import { defineStore } from "pinia"
import { message } from "ant-design-vue"
import { isTauri } from "@/utils/window"
import { isAutostartEnabled, setAutostartEnabled } from "@/api/autostart"
import { translate } from "@/i18n"
/**
* 开机自启:直接读写系统启动项,状态以系统为准,不做本地持久化。
*
* - 进入设置页时读一次系统启动项,保证开关展示的就是系统真实状态;
* - 切换后重新读取一次,写入失败则回滚开关并提示原因,避免界面与系统不一致;
* - 无法访问系统启动项(浏览器调试 / 插件不可用)时 supported 为 false,开关置灰。
*/
export const useAutostartStore = defineStore("autostart", () => {
/** 系统启动项是否已登记本应用 */
const enabled = ref(false)
/** 读取 / 写入系统启动项期间显示加载态,同时防止重复操作 */
const loading = ref(isTauri())
/** 当前环境能否读写系统启动项 */
const supported = ref(isTauri())
function errMsg(e: unknown) {
return e instanceof Error ? e.message : String(e)
}
/** 读取系统启动项的真实状态(进入设置页时调用) */
async function refresh() {
if (!isTauri()) return
loading.value = true
try {
enabled.value = await isAutostartEnabled()
supported.value = true
} catch {
// 读取失败(如插件未注册、权限缺失):禁用开关,不展示可能错误的状态
supported.value = false
} finally {
loading.value = false
}
}
/** 切换开机自启:写入系统启动项,成功 / 失败都以系统状态为准并给出提示 */
async function toggle(value: boolean) {
if (!supported.value || loading.value || value === enabled.value) return
const previous = enabled.value
loading.value = true
try {
await setAutostartEnabled(value)
// 以系统启动项为准;读回失败时先按目标值展示,下次进入设置页会再次校准
enabled.value = await isAutostartEnabled().catch(() => value)
message.success(
translate(
value ? "settings.general.autostartEnabled" : "settings.general.autostartDisabled",
),
)
} catch (e) {
enabled.value = previous
message.error(translate("settings.general.autostartFailed", { message: errMsg(e) }))
} finally {
loading.value = false
}
}
return { enabled, loading, supported, refresh, toggle }
})
+52 -7
View File
@@ -1,26 +1,45 @@
<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>
<p class="mb-5 text-xs opacity-60">{{ t("settings.general.subtitle") }}</p>
<!-- 开机自启:开关直接写入系统启动项,状态由系统决定 -->
<section>
<div class="flex items-center justify-between gap-4">
<div class="min-w-0">
<div class="text-sm font-medium">{{ t("settings.general.autostart") }}</div>
<p class="mt-1 text-xs opacity-60">{{ autostartTip }}</p>
</div>
<a-switch
class="shrink-0"
:checked="autostartStore.enabled"
:loading="autostartStore.loading"
:disabled="!autostartStore.supported"
@change="onAutostartChange"
/>
</div>
</section>
<a-divider class="general-divider" />
<!-- 关闭行为:与关闭弹窗内容保持一致,可在此再次修改 -->
<section class="mb-6">
<section>
<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>
<p class="mb-2 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>
<div class="mt-3 mb-1 text-sm font-medium">{{ t("settings.general.closeAction") }}</div>
<p class="mb-2 text-xs opacity-60">{{ t("settings.general.closeActionTip") }}</p>
<CardRadioGroup v-model="windowStore.closeChoice" :options="choiceOptions" />
</section>
<a-divider />
<a-divider class="general-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>
<p class="mb-2 text-xs opacity-60">{{ t("settings.general.languageTip") }}</p>
<a-radio-group
v-model:value="appStore.language"
:options="languageRadios"
@@ -35,10 +54,12 @@
import { useI18n } from "vue-i18n"
import { languageOptions } from "@/i18n"
import { useAppStore } from "@/stores/app"
import { useAutostartStore } from "@/stores/autostart"
import { useWindowStore } from "@/stores/window"
const { t } = useI18n()
const appStore = useAppStore()
const autostartStore = useAutostartStore()
const windowStore = useWindowStore()
const behaviorOptions = computed(() => [
@@ -57,4 +78,28 @@ const choiceOptions = computed(() => [
/** 语言选项与顶部栏共用 languageOptions,保证两端一致 */
const languageRadios = languageOptions.map((item) => ({ label: item.label, value: item.key }))
/** 无法访问系统启动项时(非桌面环境)给出说明,避免开关无故置灰 */
const autostartTip = computed(() =>
autostartStore.supported
? t("settings.general.autostartTip")
: t("settings.general.autostartUnsupported"),
)
/** 切换开机自启:失败时由 store 回滚开关状态并提示原因 */
async function onAutostartChange(checked: boolean | string | number) {
await autostartStore.toggle(Boolean(checked))
}
// 每次进入设置页都读一次系统启动项,用户可能在系统设置里改过
onMounted(() => {
void autostartStore.refresh()
})
</script>
<style scoped>
/* 各组设置之间留白收紧,避免分隔线两侧过于松散 */
.general-divider {
margin: 16px 0;
}
</style>