feat: replace native select controls
This commit is contained in:
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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'">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user