feat: replace native select controls
This commit is contained in:
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
@@ -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>
|
||||
|
||||
@@ -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