日志收集从请求页组件搬进模块级单例(web/log-store.ts,模式同 telemetry), 切走页面不再清空;离线时保留已收的日志,只提示不再更新。清空只清界面。 五处原生 <select> 在三个文件里各自重复同一套自绘箭头样式,而且原生控件 没有「展开中」状态可读,箭头做不了开合动画——提成 SelectBox 组件统一替换: 箭头随开合旋转 180 度,弹层带出入场动画,下方视口不够时向上弹。背景色 留 --select-bg 口子,对话框里沉一档、页面上浮一档,一个值满足不了两边。
273 lines
6.7 KiB
Vue
273 lines
6.7 KiB
Vue
<script setup lang="ts">
|
|
/**
|
|
* 单选下拉,替代原生 <select>。
|
|
*
|
|
* 换成自绘只为一件事:原生控件没有「展开中」这个可读状态,右侧箭头做不了
|
|
* 开合动画,弹层样式也管不到。交互仿 SourcePicker(同一族控件):点开列出
|
|
* 全部选项、勾标当前选中;上下键移动、Enter 选中、Esc 只收弹层不关对话框。
|
|
*
|
|
* 背景色用 --select-bg 变量留了口子:对话框里的控件要比对话框底色沉一档
|
|
* (--c-bg),页面上的控件要比页面底色浮一档(--c-surface),一个值满足不了。
|
|
*/
|
|
import { computed, ref } from 'vue'
|
|
import IconCheck from '~icons/ri/check-line'
|
|
import IconChevron from '~icons/ri/arrow-down-s-line'
|
|
|
|
export interface SelectItem {
|
|
value: string
|
|
/** 界面上显示的字,缺省用 value 原文 */
|
|
label?: string
|
|
/** 分组标题。相邻同组的选项归在一个标题下 */
|
|
group?: string
|
|
}
|
|
|
|
const props = defineProps<{
|
|
options: SelectItem[]
|
|
/** 当前值不在选项里(含空串)时显示的占位文案 */
|
|
placeholder?: string
|
|
ariaLabel?: string
|
|
}>()
|
|
|
|
const chosen = defineModel<string>({ default: '' })
|
|
|
|
const root = ref<HTMLElement | null>(null)
|
|
const open = ref(false)
|
|
/** 往上弹。下方视口不够放弹层、上方够时翻转,不把弹层怼出屏幕 */
|
|
const openUp = ref(false)
|
|
const active = ref(0)
|
|
|
|
const current = computed(() => props.options.find((o) => o.value === chosen.value) ?? null)
|
|
|
|
const MENU_ROOM = 220
|
|
|
|
function toggleOpen() {
|
|
if (open.value) {
|
|
open.value = false
|
|
return
|
|
}
|
|
active.value = Math.max(
|
|
0,
|
|
props.options.findIndex((o) => o.value === chosen.value),
|
|
)
|
|
const rect = root.value?.getBoundingClientRect()
|
|
openUp.value = !!rect && window.innerHeight - rect.bottom < MENU_ROOM && rect.top > MENU_ROOM
|
|
open.value = true
|
|
}
|
|
|
|
function pick(value: string) {
|
|
chosen.value = value
|
|
open.value = false
|
|
}
|
|
|
|
function onKeydown(event: KeyboardEvent) {
|
|
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
event.preventDefault()
|
|
if (!open.value) {
|
|
toggleOpen()
|
|
return
|
|
}
|
|
const count = props.options.length
|
|
if (count === 0) return
|
|
const delta = event.key === 'ArrowDown' ? 1 : -1
|
|
active.value = (active.value + delta + count) % count
|
|
} else if (event.key === 'Enter' || event.key === ' ') {
|
|
event.preventDefault()
|
|
if (!open.value) {
|
|
toggleOpen()
|
|
return
|
|
}
|
|
const item = props.options[active.value]
|
|
if (item) pick(item.value)
|
|
} else if (event.key === 'Escape' && open.value) {
|
|
// 对话框里按 Esc 只收弹层,不能顺手把整个对话框关了
|
|
event.stopPropagation()
|
|
event.preventDefault()
|
|
open.value = false
|
|
}
|
|
}
|
|
|
|
/** 焦点离开整块时收起。新焦点还在里面就不收,否则点选项会先把自己关掉。 */
|
|
function onFocusOut(event: FocusEvent) {
|
|
const next = event.relatedTarget
|
|
if (next instanceof Node && root.value?.contains(next)) return
|
|
open.value = false
|
|
}
|
|
|
|
/** 这个下标前面要不要画组标题:是组里第一项才画。 */
|
|
function groupAt(index: number): string | null {
|
|
const group = props.options[index]?.group
|
|
if (!group) return null
|
|
return props.options[index - 1]?.group === group ? null : group
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div ref="root" class="select" @focusout="onFocusOut">
|
|
<button
|
|
type="button"
|
|
class="face"
|
|
role="combobox"
|
|
:aria-expanded="open"
|
|
:aria-label="ariaLabel"
|
|
@click="toggleOpen"
|
|
@keydown="onKeydown"
|
|
>
|
|
<span :class="['face-text', !current && 'is-empty']">
|
|
{{ current ? (current.label ?? current.value) : (placeholder ?? '请选择') }}
|
|
</span>
|
|
<IconChevron :class="['chev', open && 'is-open']" aria-hidden="true" />
|
|
</button>
|
|
|
|
<Transition name="menu">
|
|
<ul v-if="open" :class="['menu', openUp && 'is-up']" role="listbox">
|
|
<li v-if="options.length === 0" class="none">没有可选项</li>
|
|
<template v-for="(item, i) in options" :key="item.value">
|
|
<li v-if="groupAt(i)" class="group" role="presentation">{{ groupAt(i) }}</li>
|
|
<li>
|
|
<button
|
|
type="button"
|
|
role="option"
|
|
:aria-selected="item.value === chosen"
|
|
:class="['opt', item.value === chosen && 'is-on', i === active && 'is-active']"
|
|
@mousemove="active = i"
|
|
@click="pick(item.value)"
|
|
>
|
|
<span class="mark"><IconCheck v-if="item.value === chosen" /></span>
|
|
{{ item.label ?? item.value }}
|
|
</button>
|
|
</li>
|
|
</template>
|
|
</ul>
|
|
</Transition>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.select {
|
|
position: relative;
|
|
width: 100%;
|
|
}
|
|
|
|
.face {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--s-2);
|
|
width: 100%;
|
|
padding: var(--s-2) var(--s-3);
|
|
border: 1px solid var(--c-border-strong);
|
|
border-radius: var(--r-sm);
|
|
background: var(--select-bg, var(--c-bg));
|
|
text-align: left;
|
|
cursor: pointer;
|
|
}
|
|
|
|
/* 和文本输入一致:焦点态是描边变色,不叠外圈 */
|
|
.face:focus-visible {
|
|
outline: none;
|
|
border-color: var(--c-accent);
|
|
}
|
|
|
|
.face-text {
|
|
flex: 1;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.face-text.is-empty {
|
|
color: var(--c-text-faint);
|
|
}
|
|
|
|
.chev {
|
|
flex: none;
|
|
width: 16px;
|
|
height: 16px;
|
|
color: var(--c-text-faint);
|
|
transition: transform 0.2s ease;
|
|
}
|
|
|
|
.chev.is-open {
|
|
transform: rotate(180deg);
|
|
}
|
|
|
|
.menu {
|
|
position: absolute;
|
|
z-index: 1;
|
|
top: calc(100% + var(--s-1));
|
|
left: 0;
|
|
min-width: 100%;
|
|
max-height: 200px;
|
|
overflow-y: auto;
|
|
list-style: none;
|
|
margin: 0;
|
|
padding: var(--s-1);
|
|
border: 1px solid var(--c-border-strong);
|
|
border-radius: var(--r-md);
|
|
background: var(--c-surface);
|
|
}
|
|
|
|
.menu.is-up {
|
|
top: auto;
|
|
bottom: calc(100% + var(--s-1));
|
|
}
|
|
|
|
.group {
|
|
padding: var(--s-2) var(--s-3) var(--s-1);
|
|
color: var(--c-text-faint);
|
|
font-size: var(--t-xs);
|
|
}
|
|
|
|
.opt {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--s-2);
|
|
width: 100%;
|
|
padding: var(--s-2) var(--s-3);
|
|
border: none;
|
|
border-radius: var(--r-sm);
|
|
background: none;
|
|
text-align: left;
|
|
font-size: var(--t-sm);
|
|
white-space: nowrap;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.opt.is-active {
|
|
background: var(--c-surface-sunken);
|
|
}
|
|
|
|
.opt.is-on {
|
|
color: var(--c-accent-text);
|
|
}
|
|
|
|
.mark {
|
|
display: flex;
|
|
flex: none;
|
|
width: 1em;
|
|
}
|
|
|
|
.none {
|
|
padding: var(--s-2) var(--s-3);
|
|
color: var(--c-text-faint);
|
|
font-size: var(--t-sm);
|
|
}
|
|
|
|
.menu-enter-active,
|
|
.menu-leave-active {
|
|
transition:
|
|
opacity 0.12s ease,
|
|
transform 0.12s ease;
|
|
}
|
|
|
|
.menu-enter-from,
|
|
.menu-leave-to {
|
|
opacity: 0;
|
|
transform: translateY(-4px);
|
|
}
|
|
|
|
.menu.is-up.menu-enter-from,
|
|
.menu.is-up.menu-leave-to {
|
|
transform: translateY(4px);
|
|
}
|
|
</style>
|