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
+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>