feat: 新增常规设置页面,支持关闭行为与语言切换
新增常规设置路由和页面,实现关闭行为(每次询问/保持选择)与应用语言的持久化配置;封装卡片式单选组组件复用在关闭弹窗和设置页;修改关闭逻辑,根据关闭行为决定是否显示确认弹窗,同步更新多语言文案和类型定义。
This commit is contained in:
+12
-1
@@ -159,4 +159,15 @@ Layout布局content区域的磨砂玻璃卡片圆角样式我要改动一下:
|
||||
#### 3.8 系统托盘图标BUG修改
|
||||
|
||||
- 开发阶段每次修改代码热更新之后,系统托盘图标就会新增一个
|
||||
- 需要修复确保只有一个系统托盘图标
|
||||
- 需要修复确保只有一个系统托盘图标
|
||||
|
||||
#### 3.9 新增常规设置
|
||||
|
||||
- 设置页增加一个子页面-常规,第一项设置内容是关闭行为设置
|
||||
- 目前的关闭行为弹窗分为最小化到系统托盘和直接退出两种,需要增加一个单选组,里面是每次询问和保持以上选择(默认每次询问)
|
||||
- 每次询问即每次关闭都需要弹窗供用户选择,保持以上选择则是保持用户选择的关闭方式,持久生效
|
||||
- 常规设置页面需要同步关闭弹窗里面的内容,使用户可以再次修改设置
|
||||
|
||||
- 常规设置页面再增加一个语言切换设置,保持跟Header栏同步
|
||||
|
||||
开发过程中使用到的组件尽可能全部使用Antdv库里面的,另外我引入的有tailwindcss原子化组件、auto-import和components依赖可以简化引入,开发之前尽可能先阅读一下package.json,看一下有哪些依赖可以简化代码开发
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "自定义窗口的主题模式与背景图片",
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Vendored
+1
@@ -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']
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user