feat: replace native select controls

This commit is contained in:
lbl
2026-08-22 04:23:35 +08:00
parent cd5b4431dc
commit 55d47c5a1c
9 changed files with 386 additions and 186 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -19,8 +19,8 @@
if (dark) document.documentElement.classList.add("dark");
})();
</script>
<script type="module" crossorigin src="/assets/index-BxCqIugQ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BgNGjSZS.css">
<script type="module" crossorigin src="/assets/index-DIvs683K.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-F22jYCcu.css">
</head>
<body>
<div id="app"></div>
+183
View File
@@ -0,0 +1,183 @@
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useId, watch } from "vue";
defineOptions({ inheritAttrs: false });
const props = defineProps({
modelValue: { default: null },
options: { type: Array, default: () => [] },
placeholder: { type: String, default: "请选择" },
disabled: { type: Boolean, default: false },
});
const emit = defineEmits(["update:modelValue"]);
const root = ref(null);
const trigger = ref(null);
const open = ref(false);
const activeIndex = ref(-1);
const listboxId = `app-select-${useId().replaceAll(":", "")}`;
const selectedIndex = computed(() =>
props.options.findIndex((option) => Object.is(option.value, props.modelValue)),
);
const selectedOption = computed(() => props.options[selectedIndex.value] || null);
const firstEnabledIndex = (from, direction) => {
if (!props.options.length) return -1;
let index = from;
for (let count = 0; count < props.options.length; count += 1) {
index = (index + direction + props.options.length) % props.options.length;
if (!props.options[index]?.disabled) return index;
}
return -1;
};
const openMenu = async () => {
if (props.disabled) return;
open.value = true;
activeIndex.value = selectedIndex.value >= 0
? selectedIndex.value
: firstEnabledIndex(-1, 1);
await nextTick();
root.value?.querySelector(`[data-option-index="${activeIndex.value}"]`)?.scrollIntoView({ block: "nearest" });
};
const closeMenu = (restoreFocus = false) => {
open.value = false;
if (restoreFocus) trigger.value?.focus();
};
const choose = (option) => {
if (option.disabled) return;
emit("update:modelValue", option.value);
closeMenu(true);
};
const moveActive = (direction) => {
activeIndex.value = firstEnabledIndex(activeIndex.value, direction);
nextTick(() => {
root.value?.querySelector(`[data-option-index="${activeIndex.value}"]`)?.scrollIntoView({ block: "nearest" });
});
};
const onKeydown = (event) => {
if (props.disabled) return;
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
if (!open.value) openMenu();
else moveActive(event.key === "ArrowDown" ? 1 : -1);
return;
}
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
if (!open.value) openMenu();
else if (activeIndex.value >= 0) choose(props.options[activeIndex.value]);
return;
}
if (event.key === "Escape" && open.value) {
event.preventDefault();
closeMenu(true);
return;
}
if (event.key === "Home" && open.value) {
event.preventDefault();
activeIndex.value = firstEnabledIndex(-1, 1);
} else if (event.key === "End" && open.value) {
event.preventDefault();
activeIndex.value = firstEnabledIndex(0, -1);
} else if (event.key === "Tab") {
closeMenu();
}
};
const onDocumentPointerDown = (event) => {
if (open.value && !root.value?.contains(event.target)) closeMenu();
};
watch(() => props.disabled, (disabled) => {
if (disabled) closeMenu();
});
onMounted(() => document.addEventListener("pointerdown", onDocumentPointerDown));
onBeforeUnmount(() => document.removeEventListener("pointerdown", onDocumentPointerDown));
</script>
<template>
<div ref="root" class="relative w-full">
<button
ref="trigger"
v-bind="$attrs"
type="button"
role="combobox"
:aria-expanded="open"
:aria-controls="listboxId"
aria-haspopup="listbox"
:disabled="disabled"
class="input flex min-h-10 items-center justify-between gap-3 text-left"
:class="open ? 'border-indigo-500 ring-2 ring-indigo-500/25' : ''"
@click="open ? closeMenu() : openMenu()"
@keydown="onKeydown"
>
<span class="min-w-0 flex-1 truncate" :class="selectedOption ? '' : 'text-slate-400 dark:text-slate-500'">
{{ selectedOption?.label || placeholder }}
</span>
<svg
class="h-4 w-4 shrink-0 fill-none stroke-current text-slate-400 transition-transform duration-150"
:class="open ? 'rotate-180 text-indigo-500' : ''"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path d="m7 10 5 5 5-5" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" />
</svg>
</button>
<transition name="select-menu">
<div
v-if="open"
:id="listboxId"
role="listbox"
class="custom-scrollbar absolute z-40 mt-1.5 max-h-60 w-full overflow-y-auto rounded-xl border border-slate-200 bg-white p-1.5 shadow-xl shadow-slate-900/10 dark:border-slate-700 dark:bg-slate-800 dark:shadow-black/30"
>
<button
v-for="(option, index) in options"
:key="`${String(option.value)}-${index}`"
type="button"
role="option"
:aria-selected="Object.is(option.value, modelValue)"
:disabled="option.disabled"
:data-option-index="index"
class="flex w-full items-center gap-2 rounded-lg px-3 py-2.5 text-left text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-40"
:class="[
Object.is(option.value, modelValue)
? 'bg-indigo-50 font-medium text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300'
: 'text-slate-600 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-700/70',
activeIndex === index && !Object.is(option.value, modelValue)
? 'bg-slate-100 dark:bg-slate-700/70'
: '',
]"
@mouseenter="activeIndex = index"
@click="choose(option)"
>
<span class="min-w-0 flex-1 truncate">{{ option.label }}</span>
<svg v-if="Object.is(option.value, modelValue)" class="h-4 w-4 shrink-0 fill-none stroke-current" viewBox="0 0 24 24" aria-hidden="true">
<path d="m5 12 4 4L19 6" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" />
</svg>
</button>
<div v-if="options.length === 0" class="px-3 py-4 text-center text-xs text-slate-400">暂无可选项</div>
</div>
</transition>
</div>
</template>
<style scoped>
.select-menu-enter-active,
.select-menu-leave-active {
transform-origin: top;
transition: opacity 120ms ease, transform 120ms ease;
}
.select-menu-enter-from,
.select-menu-leave-to {
opacity: 0;
transform: translateY(-4px) scale(0.98);
}
</style>
+8 -6
View File
@@ -2,6 +2,7 @@
import { ref, computed, watch } from "vue";
import { useAppStore } from "../stores/app";
import { useUiStore } from "../stores/ui";
import AppSelect from "./AppSelect.vue";
// : + ,
const app = useAppStore();
@@ -15,6 +16,12 @@ const availableConfigs = computed(() =>
(cfg) => !app.instanceList.some((inst) => inst.file_name === cfg.file_name),
),
);
const configOptions = computed(() =>
availableConfigs.value.map((cfg) => ({
value: cfg.file_name,
label: cfg.config_name || cfg.file_name,
})),
);
// ;()
watch(
@@ -52,12 +59,7 @@ const handleStart = () => {
<div v-else class="flex flex-col gap-3 sm:flex-row sm:items-end">
<div class="flex-1">
<label class="mb-1.5 block text-xs font-medium muted">选择配置</label>
<select v-model="localSelectedConfig" class="input">
<option value="" disabled>请选择配置...</option>
<option v-for="cfg in availableConfigs" :key="cfg.file_name" :value="cfg.file_name">
{{ cfg.config_name || cfg.file_name }}
</option>
</select>
<AppSelect v-model="localSelectedConfig" :options="configOptions" placeholder="请选择配置…" aria-label="选择配置" />
</div>
<button
class="btn-primary px-8"
+7 -5
View File
@@ -1,6 +1,7 @@
<script setup>
import { ref, watch, nextTick } from "vue";
import AppModal from "../components/AppModal.vue";
import AppSelect from "../components/AppSelect.vue";
import { useUiStore } from "../stores/ui";
import { getConfig, saveConfig } from "../api";
import { emptyFormData, parseTomlToForm, formToToml, NEW_CONFIG_TEMPLATE } from "../utils/toml";
@@ -24,6 +25,11 @@ const hasTomlChanges = ref(false);
const hasFormChanges = ref(false);
const isParsingToml = ref(false);
const formData = ref(emptyFormData());
const certificateModeOptions = [
{ value: "skip", label: "跳过验证(默认)" },
{ value: "standard", label: "系统证书验证" },
{ value: "finger", label: "证书指纹验证" },
];
//
watch(
@@ -350,11 +356,7 @@ const sectionTitleClass = "text-md mb-4 flex items-center font-bold text-slate-9
</div>
<div>
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">服务端证书校验模式</label>
<select v-model="formData.cert_mode" class="input">
<option value="skip">跳过验证 (默认)</option>
<option value="standard">系统证书验证</option>
<option value="finger">证书指纹验证</option>
</select>
<AppSelect v-model="formData.cert_mode" :options="certificateModeOptions" aria-label="服务端证书校验模式" />
</div>
</div>
<div v-if="formData.cert_mode === 'finger'">
+17 -4
View File
@@ -1,5 +1,6 @@
<script setup>
import { onMounted, reactive, ref } from "vue";
import AppSelect from "../components/AppSelect.vue";
const bridge = globalThis.__VNT_WEB_ACCESS__;
const draft = reactive({ enabled: false, port: 19099, global: false, token: "" });
@@ -8,6 +9,10 @@ const loading = ref(true);
const saving = ref(false);
const notice = ref("");
const error = ref("");
const listenScopeOptions = [
{ value: false, label: "仅本机(推荐)" },
{ value: true, label: "局域网内所有设备" },
];
const sync = (value) => {
status.value = value;
@@ -77,6 +82,11 @@ const saveNetworkSettings = async () => {
await update({}, "监听设置已自动保存");
};
const updateListenScope = async (value) => {
draft.global = value;
await saveNetworkSettings();
};
const copyToken = async () => {
await navigator.clipboard.writeText(draft.token);
notice.value = "访问令牌已复制";
@@ -133,10 +143,13 @@ onMounted(load);
</label>
<label class="block">
<span class="mb-2 block text-sm font-medium text-slate-700 dark:text-slate-200">监听范围</span>
<select v-model="draft.global" class="input" :disabled="saving || draft.enabled" @change="saveNetworkSettings">
<option :value="false">仅本机推荐</option>
<option :value="true">局域网内所有设备</option>
</select>
<AppSelect
:model-value="draft.global"
:options="listenScopeOptions"
:disabled="saving || draft.enabled"
aria-label="监听范围"
@update:model-value="updateListenScope"
/>
</label>
</div>
<p class="-mt-3 text-xs text-slate-400">端口和监听范围会自动保存需要修改时请先关闭 Web 服务</p>