mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-18 21:10:57 +08:00
Merge pull request #414 from WoJiaoFuXiaoYun/main
fix: Click outside the drop-down box to not hide
This commit is contained in:
@@ -1,13 +1,24 @@
|
||||
<template>
|
||||
<div class="dropdown" ref="wrapper">
|
||||
<div class="dropdown-display" @click="toggle">
|
||||
<slot name="display" :selected="selectedLabels" :toggle="toggle" :search="search" :setSearch="setSearch">
|
||||
<slot
|
||||
name="display"
|
||||
:selected="selectedLabels"
|
||||
:toggle="toggle"
|
||||
:search="search"
|
||||
:setSearch="setSearch"
|
||||
>
|
||||
<template v-if="multiple">
|
||||
<span v-if="selectedLabels.length">
|
||||
<template v-for="(label, idx) in selectedLabels" :key="label.id">
|
||||
<div class="selected-label">
|
||||
<template v-if="label.icon">
|
||||
<img v-if="isImageIcon(label.icon)" :src="label.icon" class="option-icon" :alt="label.name" />
|
||||
<img
|
||||
v-if="isImageIcon(label.icon)"
|
||||
:src="label.icon"
|
||||
class="option-icon"
|
||||
:alt="label.name"
|
||||
/>
|
||||
<i v-else :class="['option-icon', label.icon]"></i>
|
||||
</template>
|
||||
<span>{{ label.name }}</span>
|
||||
@@ -21,7 +32,12 @@
|
||||
<span v-if="selectedLabels.length">
|
||||
<div class="selected-label">
|
||||
<template v-if="selectedLabels[0].icon">
|
||||
<img v-if="isImageIcon(selectedLabels[0].icon)" :src="selectedLabels[0].icon" class="option-icon" :alt="selectedLabels[0].name" />
|
||||
<img
|
||||
v-if="isImageIcon(selectedLabels[0].icon)"
|
||||
:src="selectedLabels[0].icon"
|
||||
class="option-icon"
|
||||
:alt="selectedLabels[0].name"
|
||||
/>
|
||||
<i v-else :class="['option-icon', selectedLabels[0].icon]"></i>
|
||||
</template>
|
||||
<span>{{ selectedLabels[0].name }}</span>
|
||||
@@ -32,20 +48,46 @@
|
||||
<i class="fas fa-caret-down dropdown-caret"></i>
|
||||
</slot>
|
||||
</div>
|
||||
<div v-if="open && !isMobile && (loading || filteredOptions.length > 0 || showSearch)" :class="['dropdown-menu', menuClass]">
|
||||
<div
|
||||
v-if="
|
||||
open &&
|
||||
!isMobile &&
|
||||
(loading || filteredOptions.length > 0 || showSearch)
|
||||
"
|
||||
:class="['dropdown-menu', menuClass]"
|
||||
v-click-outside="close"
|
||||
>
|
||||
<div v-if="showSearch" class="dropdown-search">
|
||||
<i class="fas fa-search search-icon"></i>
|
||||
<input type="text" v-model="search" placeholder="搜索" />
|
||||
</div>
|
||||
<div v-if="loading" class="dropdown-loading">
|
||||
<l-hatch size="20" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||
<l-hatch
|
||||
size="20"
|
||||
stroke="4"
|
||||
speed="3.5"
|
||||
color="var(--primary-color)"
|
||||
></l-hatch>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div v-for="o in filteredOptions" :key="o.id" @click="select(o.id)"
|
||||
:class="['dropdown-option', optionClass, { 'selected': isSelected(o.id) }]">
|
||||
<div
|
||||
v-for="o in filteredOptions"
|
||||
:key="o.id"
|
||||
@click="select(o.id)"
|
||||
:class="[
|
||||
'dropdown-option',
|
||||
optionClass,
|
||||
{ selected: isSelected(o.id) },
|
||||
]"
|
||||
>
|
||||
<slot name="option" :option="o" :isSelected="isSelected(o.id)">
|
||||
<template v-if="o.icon">
|
||||
<img v-if="isImageIcon(o.icon)" :src="o.icon" class="option-icon" :alt="o.name" />
|
||||
<img
|
||||
v-if="isImageIcon(o.icon)"
|
||||
:src="o.icon"
|
||||
class="option-icon"
|
||||
:alt="o.name"
|
||||
/>
|
||||
<i v-else :class="['option-icon', o.icon]"></i>
|
||||
</template>
|
||||
<span>{{ o.name }}</span>
|
||||
@@ -65,14 +107,32 @@
|
||||
<input type="text" v-model="search" placeholder="搜索" />
|
||||
</div>
|
||||
<div v-if="loading" class="dropdown-loading">
|
||||
<l-hatch size="20" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||
<l-hatch
|
||||
size="20"
|
||||
stroke="4"
|
||||
speed="3.5"
|
||||
color="var(--primary-color)"
|
||||
></l-hatch>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div v-for="o in filteredOptions" :key="o.id" @click="select(o.id)"
|
||||
:class="['dropdown-option', optionClass, { 'selected': isSelected(o.id) }]">
|
||||
<div
|
||||
v-for="o in filteredOptions"
|
||||
:key="o.id"
|
||||
@click="select(o.id)"
|
||||
:class="[
|
||||
'dropdown-option',
|
||||
optionClass,
|
||||
{ selected: isSelected(o.id) },
|
||||
]"
|
||||
>
|
||||
<slot name="option" :option="o" :isSelected="isSelected(o.id)">
|
||||
<template v-if="o.icon">
|
||||
<img v-if="isImageIcon(o.icon)" :src="o.icon" class="option-icon" :alt="o.name" />
|
||||
<img
|
||||
v-if="isImageIcon(o.icon)"
|
||||
:src="o.icon"
|
||||
class="option-icon"
|
||||
:alt="o.name"
|
||||
/>
|
||||
<i v-else :class="['option-icon', o.icon]"></i>
|
||||
</template>
|
||||
<span>{{ o.name }}</span>
|
||||
@@ -86,47 +146,49 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { hatch } from 'ldrs'
|
||||
import { isMobile } from '../utils/screen'
|
||||
import { ref, computed, watch, onMounted } from "vue"
|
||||
import { hatch } from "ldrs"
|
||||
import { isMobile } from "../utils/screen"
|
||||
hatch.register()
|
||||
|
||||
export default {
|
||||
name: 'BaseDropdown',
|
||||
name: "BaseDropdown",
|
||||
props: {
|
||||
modelValue: { type: [Array, String, Number], default: () => [] },
|
||||
placeholder: { type: String, default: '返回' },
|
||||
placeholder: { type: String, default: "返回" },
|
||||
multiple: { type: Boolean, default: false },
|
||||
fetchOptions: { type: Function, required: true },
|
||||
remote: { type: Boolean, default: false },
|
||||
menuClass: { type: String, default: '' },
|
||||
optionClass: { type: String, default: '' },
|
||||
menuClass: { type: String, default: "" },
|
||||
optionClass: { type: String, default: "" },
|
||||
showSearch: { type: Boolean, default: true },
|
||||
initialOptions: { type: Array, default: () => [] }
|
||||
initialOptions: { type: Array, default: () => [] },
|
||||
},
|
||||
emits: ['update:modelValue', 'update:search', 'close'],
|
||||
emits: ["update:modelValue", "update:search", "close"],
|
||||
setup(props, { emit, expose }) {
|
||||
const open = ref(false)
|
||||
const search = ref('')
|
||||
const search = ref("")
|
||||
const setSearch = (val) => {
|
||||
search.value = val
|
||||
}
|
||||
const options = ref(Array.isArray(props.initialOptions) ? [...props.initialOptions] : [])
|
||||
const options = ref(
|
||||
Array.isArray(props.initialOptions) ? [...props.initialOptions] : []
|
||||
)
|
||||
const loaded = ref(false)
|
||||
const loading = ref(false)
|
||||
const wrapper = ref(null)
|
||||
|
||||
const toggle = () => {
|
||||
open.value = !open.value
|
||||
if (!open.value) emit('close')
|
||||
if (!open.value) emit("close")
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
open.value = false
|
||||
emit('close')
|
||||
emit("close")
|
||||
}
|
||||
|
||||
const select = id => {
|
||||
const select = (id) => {
|
||||
if (props.multiple) {
|
||||
const arr = Array.isArray(props.modelValue) ? [...props.modelValue] : []
|
||||
const idx = arr.indexOf(id)
|
||||
@@ -135,28 +197,23 @@ export default {
|
||||
} else {
|
||||
arr.push(id)
|
||||
}
|
||||
emit('update:modelValue', arr)
|
||||
emit("update:modelValue", arr)
|
||||
} else {
|
||||
emit('update:modelValue', id)
|
||||
emit("update:modelValue", id)
|
||||
close()
|
||||
}
|
||||
search.value = ''
|
||||
search.value = ""
|
||||
}
|
||||
|
||||
const filteredOptions = computed(() => {
|
||||
if (props.remote) return options.value
|
||||
if (!search.value) return options.value
|
||||
return options.value.filter(o => o.name.toLowerCase().includes(search.value.toLowerCase()))
|
||||
return options.value.filter((o) =>
|
||||
o.name.toLowerCase().includes(search.value.toLowerCase())
|
||||
)
|
||||
})
|
||||
|
||||
const clickOutside = e => {
|
||||
if (isMobile) return
|
||||
if (wrapper.value && !wrapper.value.contains(e.target)) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
const loadOptions = async (kw = '') => {
|
||||
const loadOptions = async (kw = "") => {
|
||||
if (!props.remote && loaded.value) return
|
||||
try {
|
||||
loading.value = true
|
||||
@@ -172,14 +229,14 @@ export default {
|
||||
|
||||
watch(
|
||||
() => props.initialOptions,
|
||||
val => {
|
||||
(val) => {
|
||||
if (Array.isArray(val)) {
|
||||
options.value = [...val]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(open, async val => {
|
||||
watch(open, async (val) => {
|
||||
if (val) {
|
||||
if (props.remote) {
|
||||
await loadOptions(search.value)
|
||||
@@ -189,39 +246,36 @@ export default {
|
||||
}
|
||||
})
|
||||
|
||||
watch(search, async val => {
|
||||
emit('update:search', val)
|
||||
watch(search, async (val) => {
|
||||
emit("update:search", val)
|
||||
if (props.remote && open.value) {
|
||||
await loadOptions(val)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', clickOutside)
|
||||
if (!props.remote) {
|
||||
loadOptions()
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', clickOutside)
|
||||
})
|
||||
|
||||
const selectedLabels = computed(() => {
|
||||
if (props.multiple) {
|
||||
return options.value.filter(o => (props.modelValue || []).includes(o.id))
|
||||
return options.value.filter((o) =>
|
||||
(props.modelValue || []).includes(o.id)
|
||||
)
|
||||
}
|
||||
const match = options.value.find(o => o.id === props.modelValue)
|
||||
const match = options.value.find((o) => o.id === props.modelValue)
|
||||
return match ? [match] : []
|
||||
})
|
||||
|
||||
const isSelected = (id) => {
|
||||
return selectedLabels.value.some(label => label.id === id)
|
||||
return selectedLabels.value.some((label) => label.id === id)
|
||||
}
|
||||
|
||||
const isImageIcon = icon => {
|
||||
const isImageIcon = (icon) => {
|
||||
if (!icon) return false
|
||||
return /^https?:\/\//.test(icon) || icon.startsWith('/')
|
||||
return /^https?:\/\//.test(icon) || icon.startsWith("/")
|
||||
}
|
||||
|
||||
expose({ toggle, close })
|
||||
@@ -239,9 +293,9 @@ export default {
|
||||
loading,
|
||||
isImageIcon,
|
||||
setSearch,
|
||||
isMobile
|
||||
isMobile,
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
159
frontend/src/directives/clickOutside.js
Normal file
159
frontend/src/directives/clickOutside.js
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* @file clickOutsideDirective.js
|
||||
* @description 一个用于检测元素外部点击的Vue 3自定义指令。
|
||||
*
|
||||
* @example
|
||||
* // 在 main.js 中全局注册
|
||||
* import { createApp } from 'vue'
|
||||
* import App from './App.vue'
|
||||
* import ClickOutside from './clickOutsideDirective.js'
|
||||
*
|
||||
* const app = createApp(App)
|
||||
* app.directive('click-outside', ClickOutside)
|
||||
* app.mount('#app')
|
||||
*
|
||||
* // 在组件中使用
|
||||
* <div v-click-outside="myMethod">...</div>
|
||||
*
|
||||
* // 排除特定元素
|
||||
* <div v-click-outside:[myExcludedElement]="myMethod">...</div>
|
||||
* <div v-click-outside:[[el1, el2]]="myMethod">...</div>
|
||||
*/
|
||||
|
||||
// 使用一个Map来存储所有指令绑定的元素及其对应的处理器
|
||||
// 键是HTMLElement,值是一个包含处理器和回调函数的对象数组
|
||||
const nodeList = new Map();
|
||||
|
||||
// 检查是否在客户端环境,以避免在SSR(服务器端渲染)时执行
|
||||
const isClient = typeof window !== 'undefined';
|
||||
|
||||
// 在客户端环境中,只设置一次全局的 mousedown 和 mouseup 监听器
|
||||
if (isClient) {
|
||||
let startClick;
|
||||
|
||||
document.addEventListener('mousedown', (e) => (startClick = e));
|
||||
|
||||
document.addEventListener('mouseup', (e) => {
|
||||
// 遍历所有注册的元素和它们的处理器
|
||||
for (const handlers of nodeList.values()) {
|
||||
for (const { documentHandler } of handlers) {
|
||||
// 调用每个处理器,传入 mouseup 和 mousedown 事件
|
||||
documentHandler(e, startClick);
|
||||
}
|
||||
}
|
||||
// 完成后重置 startClick
|
||||
startClick = undefined;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个文档事件处理器。
|
||||
* @param {HTMLElement} el - 指令绑定的元素。
|
||||
* @param {import('vue').DirectiveBinding} binding - 指令的绑定对象。
|
||||
* @returns {Function} 返回一个处理函数。
|
||||
*/
|
||||
function createDocumentHandler(el, binding) {
|
||||
let excludes = [];
|
||||
// binding.arg 可以是一个元素或一个元素数组,用于排除不需要触发回调的点击
|
||||
if (Array.isArray(binding.arg)) {
|
||||
excludes = binding.arg;
|
||||
} else if (binding.arg instanceof HTMLElement) {
|
||||
excludes.push(binding.arg);
|
||||
}
|
||||
|
||||
return function (mouseup, mousedown) {
|
||||
// 从组件实例中获取 popper 引用(如果存在),这对于处理下拉菜单、弹窗等很有用
|
||||
const popperRef = binding.instance?.popperRef;
|
||||
const mouseUpTarget = mouseup.target;
|
||||
const mouseDownTarget = mousedown?.target;
|
||||
|
||||
// 检查各种条件,如果满足任一条件,则不执行回调
|
||||
const isBound = !binding || !binding.instance;
|
||||
const isTargetExists = !mouseUpTarget || !mouseDownTarget;
|
||||
const isContainedByEl = el.contains(mouseUpTarget) || el.contains(mouseDownTarget);
|
||||
const isSelf = el === mouseUpTarget;
|
||||
|
||||
// 检查点击是否发生在任何被排除的元素内部
|
||||
const isTargetExcluded =
|
||||
(excludes.length && excludes.some((item) => item?.contains(mouseUpTarget))) ||
|
||||
(excludes.length && excludes.includes(mouseDownTarget));
|
||||
|
||||
// 检查点击是否发生在关联的 popper 元素内部
|
||||
const isContainedByPopper =
|
||||
popperRef &&
|
||||
(popperRef.contains(mouseUpTarget) || popperRef.contains(mouseDownTarget));
|
||||
|
||||
if (
|
||||
isBound ||
|
||||
isTargetExists ||
|
||||
isContainedByEl ||
|
||||
isSelf ||
|
||||
isTargetExcluded ||
|
||||
isContainedByPopper
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果所有检查都通过,说明点击发生在外部,执行指令传入的回调函数
|
||||
binding.value(mouseup, mousedown);
|
||||
};
|
||||
}
|
||||
|
||||
const ClickOutside = {
|
||||
/**
|
||||
* 在绑定元素的 attribute 或事件监听器被应用之前调用。
|
||||
* @param {HTMLElement} el
|
||||
* @param {import('vue').DirectiveBinding} binding
|
||||
*/
|
||||
beforeMount(el, binding) {
|
||||
if (!nodeList.has(el)) {
|
||||
nodeList.set(el, []);
|
||||
}
|
||||
|
||||
nodeList.get(el).push({
|
||||
documentHandler: createDocumentHandler(el, binding),
|
||||
bindingFn: binding.value,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 在包含组件的 VNode 及其子组件的 VNode 更新后调用。
|
||||
* @param {HTMLElement} el
|
||||
* @param {import('vue').DirectiveBinding} binding
|
||||
*/
|
||||
updated(el, binding) {
|
||||
if (!nodeList.has(el)) {
|
||||
nodeList.set(el, []);
|
||||
}
|
||||
|
||||
const handlers = nodeList.get(el);
|
||||
// 查找旧的回调函数对应的处理器
|
||||
const oldHandlerIndex = handlers.findIndex(
|
||||
(item) => item.bindingFn === binding.oldValue
|
||||
);
|
||||
|
||||
const newHandler = {
|
||||
documentHandler: createDocumentHandler(el, binding),
|
||||
bindingFn: binding.value,
|
||||
};
|
||||
|
||||
if (oldHandlerIndex >= 0) {
|
||||
// 如果找到了,就替换成新的处理器
|
||||
handlers.splice(oldHandlerIndex, 1, newHandler);
|
||||
} else {
|
||||
// 否则,直接添加新的处理器
|
||||
handlers.push(newHandler);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 在绑定元素的父组件卸载后调用。
|
||||
* @param {HTMLElement} el
|
||||
*/
|
||||
unmounted(el) {
|
||||
// 当元素卸载时,从Map中移除它,以进行垃圾回收并防止内存泄漏
|
||||
nodeList.delete(el);
|
||||
},
|
||||
};
|
||||
|
||||
export default ClickOutside;
|
||||
@@ -12,6 +12,8 @@ import { checkToken, clearToken, isLogin } from './utils/auth'
|
||||
import { loginWithGoogle } from './utils/google'
|
||||
import { initTheme } from './utils/theme'
|
||||
import { clearVditorStorage } from './utils/clearVditorStorage'
|
||||
import ClickOutside from './directives/clickOutside' // 确保路径正确
|
||||
|
||||
|
||||
// 采用本地开发环境
|
||||
// export const API_DOMAIN = 'http://127.0.0.1'
|
||||
@@ -38,16 +40,18 @@ clearVditorStorage()
|
||||
const app = createApp(App)
|
||||
app.use(router)
|
||||
app.use(
|
||||
Toast,
|
||||
{
|
||||
Toast,
|
||||
{
|
||||
position: POSITION.TOP_RIGHT,
|
||||
containerClassName: "open-isle-toast-style-v1",
|
||||
transition: "Vue-Toastification__fade",
|
||||
// closeButton: false,
|
||||
timeout: 2000,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
app.directive('click-outside', ClickOutside)
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
checkToken().then(valid => {
|
||||
|
||||
Reference in New Issue
Block a user