mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-06-08 19:17:40 +08:00
feat: tooltip修改
This commit is contained in:
@@ -318,7 +318,6 @@ onMounted(async () => {
|
|||||||
.new-post-icon {
|
.new-post-icon {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
margin-right: 10px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1200px) {
|
@media (max-width: 1200px) {
|
||||||
|
|||||||
@@ -3,304 +3,379 @@
|
|||||||
<!-- 触发器 -->
|
<!-- 触发器 -->
|
||||||
<div
|
<div
|
||||||
class="tooltip-trigger"
|
class="tooltip-trigger"
|
||||||
@mouseenter="handleMouseEnter"
|
|
||||||
@mouseleave="handleMouseLeave"
|
|
||||||
@click="handleClick"
|
|
||||||
@focus="handleFocus"
|
|
||||||
@blur="handleBlur"
|
|
||||||
:tabindex="focusable ? 0 : -1"
|
:tabindex="focusable ? 0 : -1"
|
||||||
|
:aria-describedby="visible ? ariaId : undefined"
|
||||||
|
@mouseenter="onTriggerMouseEnter"
|
||||||
|
@mouseleave="onTriggerMouseLeave"
|
||||||
|
@click="onTriggerClick"
|
||||||
|
@focus="onTriggerFocus"
|
||||||
|
@blur="onTriggerBlur"
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 提示内容 -->
|
<!-- 提示内容(Teleport 到 body) -->
|
||||||
<Transition name="tooltip-fade">
|
<Teleport to="body" v-if="mounted">
|
||||||
<div
|
<Transition name="tooltip-fade">
|
||||||
v-if="visible"
|
<div
|
||||||
ref="tooltipRef"
|
v-show="visible"
|
||||||
class="tooltip-content"
|
:id="ariaId"
|
||||||
:class="[
|
ref="tooltipRef"
|
||||||
`tooltip-${placement}`,
|
class="tooltip-content"
|
||||||
{ 'tooltip-dark': dark },
|
:class="[
|
||||||
{ 'tooltip-light': !dark }
|
`tooltip-${currentPlacement}`,
|
||||||
]"
|
dark ? 'tooltip-dark' : 'tooltip-light',
|
||||||
:style="tooltipStyle"
|
props.trigger === 'hover' ? 'tooltip-noninteractive' : '',
|
||||||
role="tooltip"
|
]"
|
||||||
:aria-describedby="ariaId"
|
:style="tooltipInlineStyle"
|
||||||
>
|
role="tooltip"
|
||||||
<div class="tooltip-inner">
|
>
|
||||||
<slot name="content">
|
<div class="tooltip-inner">
|
||||||
{{ content }}
|
<slot name="content">
|
||||||
</slot>
|
{{ content }}
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 箭头 -->
|
||||||
|
<div
|
||||||
|
class="tooltip-arrow"
|
||||||
|
:class="`tooltip-arrow-${currentPlacement}`"
|
||||||
|
:style="arrowStyle"
|
||||||
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="tooltip-arrow" :class="`tooltip-arrow-${placement}`"></div>
|
</Transition>
|
||||||
</div>
|
</Teleport>
|
||||||
</Transition>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onBeforeUnmount, nextTick, useId, watch } from 'vue'
|
import {
|
||||||
|
ref,
|
||||||
|
computed,
|
||||||
|
onMounted,
|
||||||
|
onBeforeUnmount,
|
||||||
|
nextTick,
|
||||||
|
watch,
|
||||||
|
defineProps,
|
||||||
|
defineEmits,
|
||||||
|
defineOptions,
|
||||||
|
useId,
|
||||||
|
} from 'vue'
|
||||||
|
|
||||||
export default {
|
defineOptions({ name: 'Tooltip' })
|
||||||
name: 'ToolTip',
|
|
||||||
props: {
|
type Trigger = 'hover' | 'click' | 'focus' | 'manual'
|
||||||
// 提示内容
|
type Placement = 'top' | 'bottom' | 'left' | 'right'
|
||||||
content: {
|
|
||||||
type: String,
|
const props = defineProps({
|
||||||
default: ''
|
content: { type: String, default: '' },
|
||||||
},
|
trigger: {
|
||||||
// 触发方式:hover、click、focus
|
type: String as () => Trigger,
|
||||||
trigger: {
|
default: 'hover',
|
||||||
type: String,
|
validator: (v: string) => ['hover', 'click', 'focus', 'manual'].includes(v),
|
||||||
default: 'hover',
|
|
||||||
validator: (value) => ['hover', 'click', 'focus', 'manual'].includes(value)
|
|
||||||
},
|
|
||||||
// 位置:top、bottom、left、right
|
|
||||||
placement: {
|
|
||||||
type: String,
|
|
||||||
default: 'top',
|
|
||||||
validator: (value) => ['top', 'bottom', 'left', 'right'].includes(value)
|
|
||||||
},
|
|
||||||
// 是否启用暗色主题
|
|
||||||
dark: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false
|
|
||||||
},
|
|
||||||
// 延迟显示时间(毫秒)
|
|
||||||
delay: {
|
|
||||||
type: Number,
|
|
||||||
default: 100
|
|
||||||
},
|
|
||||||
// 是否禁用
|
|
||||||
disabled: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false
|
|
||||||
},
|
|
||||||
// 是否可通过Tab键聚焦
|
|
||||||
focusable: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true
|
|
||||||
},
|
|
||||||
// 偏移距离
|
|
||||||
offset: {
|
|
||||||
type: Number,
|
|
||||||
default: 8
|
|
||||||
},
|
|
||||||
// 最大宽度
|
|
||||||
maxWidth: {
|
|
||||||
type: [String, Number],
|
|
||||||
default: '200px'
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
emits: ['show', 'hide'],
|
placement: {
|
||||||
setup(props, { emit }) {
|
type: String as () => Placement,
|
||||||
const wrapperRef = ref(null)
|
default: 'top',
|
||||||
const tooltipRef = ref(null)
|
validator: (v: string) => ['top', 'bottom', 'left', 'right'].includes(v),
|
||||||
const visible = ref(false)
|
},
|
||||||
const ariaId = ref(`tooltip-${useId()}`)
|
dark: { type: Boolean, default: false },
|
||||||
|
delay: { type: Number, default: 100 },
|
||||||
let showTimer = null
|
disabled: { type: Boolean, default: false },
|
||||||
let hideTimer = null
|
focusable: { type: Boolean, default: true },
|
||||||
|
offset: { type: Number, default: 8 },
|
||||||
|
maxWidth: { type: [String, Number], default: '200px' },
|
||||||
|
/** 隐藏延时(毫秒),hover 离开后等待一点点以防抖 */
|
||||||
|
hideDelay: { type: Number, default: 80 },
|
||||||
|
})
|
||||||
|
|
||||||
// 计算tooltip样式
|
const emit = defineEmits<{
|
||||||
const tooltipStyle = computed(() => {
|
(e: 'show'): void
|
||||||
const maxWidth = typeof props.maxWidth === 'number'
|
(e: 'hide'): void
|
||||||
? `${props.maxWidth}px`
|
}>()
|
||||||
: props.maxWidth
|
|
||||||
|
|
||||||
return {
|
|
||||||
maxWidth,
|
|
||||||
zIndex: 2000
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 显示tooltip
|
const wrapperRef = ref<HTMLElement | null>(null)
|
||||||
const show = () => {
|
const tooltipRef = ref<HTMLElement | null>(null)
|
||||||
if (props.disabled) return
|
const visible = ref(false)
|
||||||
|
const currentPlacement = ref<Placement>(props.placement)
|
||||||
clearTimeout(hideTimer)
|
const ariaId = ref(`tooltip-${useId()}`)
|
||||||
showTimer = setTimeout(() => {
|
const mounted = ref(false)
|
||||||
visible.value = true
|
|
||||||
emit('show')
|
|
||||||
nextTick(() => {
|
|
||||||
updatePosition()
|
|
||||||
})
|
|
||||||
}, props.delay)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 隐藏tooltip
|
let showTimer: number | null = null
|
||||||
const hide = () => {
|
let hideTimer: number | null = null
|
||||||
clearTimeout(showTimer)
|
let ro: ResizeObserver | null = null
|
||||||
hideTimer = setTimeout(() => {
|
let rafId: number | null = null
|
||||||
visible.value = false
|
|
||||||
emit('hide')
|
|
||||||
}, 100)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 立即显示(用于manual模式)
|
const maxWidthValue = computed(() => {
|
||||||
const showImmediately = () => {
|
return typeof props.maxWidth === 'number' ? `${props.maxWidth}px` : props.maxWidth
|
||||||
if (props.disabled) return
|
})
|
||||||
clearTimeout(hideTimer)
|
|
||||||
clearTimeout(showTimer)
|
|
||||||
visible.value = true
|
|
||||||
emit('show')
|
|
||||||
nextTick(() => {
|
|
||||||
updatePosition()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 立即隐藏(用于manual模式)
|
const tooltipTransform = ref('translate3d(-9999px, -9999px, 0)')
|
||||||
const hideImmediately = () => {
|
|
||||||
clearTimeout(showTimer)
|
|
||||||
clearTimeout(hideTimer)
|
|
||||||
visible.value = false
|
|
||||||
emit('hide')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新位置
|
const tooltipInlineStyle = computed(() => ({
|
||||||
const updatePosition = () => {
|
position: 'fixed',
|
||||||
if (!wrapperRef.value || !tooltipRef.value) return
|
top: '0px',
|
||||||
|
left: '0px',
|
||||||
|
zIndex: 2000,
|
||||||
|
maxWidth: maxWidthValue.value,
|
||||||
|
transform: tooltipTransform.value,
|
||||||
|
}))
|
||||||
|
|
||||||
const trigger = wrapperRef.value.querySelector('.tooltip-trigger')
|
const arrowStyle = ref<Record<string, string>>({})
|
||||||
const tooltip = tooltipRef.value
|
|
||||||
|
|
||||||
if (!trigger) return
|
|
||||||
|
|
||||||
const triggerRect = trigger.getBoundingClientRect()
|
const clearTimers = () => {
|
||||||
const tooltipRect = tooltip.getBoundingClientRect()
|
if (showTimer) {
|
||||||
|
window.clearTimeout(showTimer)
|
||||||
let top = 0
|
showTimer = null
|
||||||
let left = 0
|
}
|
||||||
|
if (hideTimer) {
|
||||||
switch (props.placement) {
|
window.clearTimeout(hideTimer)
|
||||||
case 'top':
|
hideTimer = null
|
||||||
top = triggerRect.top - tooltipRect.height - props.offset
|
|
||||||
left = triggerRect.left + (triggerRect.width - tooltipRect.width) / 2
|
|
||||||
break
|
|
||||||
case 'bottom':
|
|
||||||
top = triggerRect.bottom + props.offset
|
|
||||||
left = triggerRect.left + (triggerRect.width - tooltipRect.width) / 2
|
|
||||||
break
|
|
||||||
case 'left':
|
|
||||||
top = triggerRect.top + (triggerRect.height - tooltipRect.height) / 2
|
|
||||||
left = triggerRect.left - tooltipRect.width - props.offset
|
|
||||||
break
|
|
||||||
case 'right':
|
|
||||||
top = triggerRect.top + (triggerRect.height - tooltipRect.height) / 2
|
|
||||||
left = triggerRect.right + props.offset
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// 边界检测
|
|
||||||
const padding = 8
|
|
||||||
const viewportWidth = window.innerWidth
|
|
||||||
const viewportHeight = window.innerHeight
|
|
||||||
|
|
||||||
if (left < padding) {
|
|
||||||
left = padding
|
|
||||||
} else if (left + tooltipRect.width > viewportWidth - padding) {
|
|
||||||
left = viewportWidth - tooltipRect.width - padding
|
|
||||||
}
|
|
||||||
|
|
||||||
if (top < padding) {
|
|
||||||
top = padding
|
|
||||||
} else if (top + tooltipRect.height > viewportHeight - padding) {
|
|
||||||
top = viewportHeight - tooltipRect.height - padding
|
|
||||||
}
|
|
||||||
|
|
||||||
tooltip.style.position = 'fixed'
|
|
||||||
tooltip.style.top = `${top}px`
|
|
||||||
tooltip.style.left = `${left}px`
|
|
||||||
}
|
|
||||||
|
|
||||||
// 事件处理
|
|
||||||
const handleMouseEnter = () => {
|
|
||||||
if (props.trigger === 'hover') {
|
|
||||||
show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleMouseLeave = () => {
|
|
||||||
if (props.trigger === 'hover') {
|
|
||||||
hide()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleClick = () => {
|
|
||||||
if (props.trigger === 'click') {
|
|
||||||
if (visible.value) {
|
|
||||||
hide()
|
|
||||||
} else {
|
|
||||||
show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFocus = () => {
|
|
||||||
if (props.trigger === 'focus') {
|
|
||||||
show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleBlur = () => {
|
|
||||||
if (props.trigger === 'focus') {
|
|
||||||
hide()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 点击外部隐藏
|
|
||||||
const handleClickOutside = (event) => {
|
|
||||||
if (props.trigger === 'click' && wrapperRef.value && !wrapperRef.value.contains(event.target)) {
|
|
||||||
hide()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 窗口大小改变时重新计算位置
|
|
||||||
const handleResize = () => {
|
|
||||||
if (visible.value) {
|
|
||||||
updatePosition()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 监听禁用状态变化
|
|
||||||
watch(() => props.disabled, (newVal) => {
|
|
||||||
if (newVal && visible.value) {
|
|
||||||
hideImmediately()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
document.addEventListener('click', handleClickOutside)
|
|
||||||
window.addEventListener('resize', handleResize)
|
|
||||||
window.addEventListener('scroll', handleResize)
|
|
||||||
})
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
clearTimeout(showTimer)
|
|
||||||
clearTimeout(hideTimer)
|
|
||||||
document.removeEventListener('click', handleClickOutside)
|
|
||||||
window.removeEventListener('resize', handleResize)
|
|
||||||
window.removeEventListener('scroll', handleResize)
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
wrapperRef,
|
|
||||||
tooltipRef,
|
|
||||||
visible,
|
|
||||||
ariaId,
|
|
||||||
tooltipStyle,
|
|
||||||
handleMouseEnter,
|
|
||||||
handleMouseLeave,
|
|
||||||
handleClick,
|
|
||||||
handleFocus,
|
|
||||||
handleBlur,
|
|
||||||
// 暴露给父组件的方法
|
|
||||||
show: showImmediately,
|
|
||||||
hide: hideImmediately
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const show = async () => {
|
||||||
|
if (props.disabled) return
|
||||||
|
clearTimers()
|
||||||
|
showTimer = window.setTimeout(async () => {
|
||||||
|
visible.value = true
|
||||||
|
emit('show')
|
||||||
|
await nextTick()
|
||||||
|
updatePosition()
|
||||||
|
}, props.delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hide = () => {
|
||||||
|
clearTimers()
|
||||||
|
hideTimer = window.setTimeout(() => {
|
||||||
|
visible.value = false
|
||||||
|
emit('hide')
|
||||||
|
}, props.hideDelay)
|
||||||
|
}
|
||||||
|
|
||||||
|
const showImmediately = async () => {
|
||||||
|
if (props.disabled) return
|
||||||
|
clearTimers()
|
||||||
|
visible.value = true
|
||||||
|
emit('show')
|
||||||
|
await nextTick()
|
||||||
|
updatePosition()
|
||||||
|
}
|
||||||
|
const hideImmediately = () => {
|
||||||
|
clearTimers()
|
||||||
|
visible.value = false
|
||||||
|
emit('hide')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 触发器事件
|
||||||
|
const onTriggerMouseEnter = () => {
|
||||||
|
if (props.trigger === 'hover') show()
|
||||||
|
}
|
||||||
|
const onTriggerMouseLeave = () => {
|
||||||
|
// 关键修改:hover 模式下,离开触发区即开始隐藏计时,不再保持可交互
|
||||||
|
if (props.trigger === 'hover') hide()
|
||||||
|
}
|
||||||
|
const onTriggerClick = () => {
|
||||||
|
if (props.trigger !== 'click') return
|
||||||
|
visible.value ? hideImmediately() : showImmediately()
|
||||||
|
}
|
||||||
|
const onTriggerFocus = () => {
|
||||||
|
if (props.trigger === 'focus') showImmediately()
|
||||||
|
}
|
||||||
|
const onTriggerBlur = () => {
|
||||||
|
if (props.trigger === 'focus') hideImmediately()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 点击外部关闭(只对 click 模式)
|
||||||
|
const onClickOutside = (e: MouseEvent) => {
|
||||||
|
if (props.trigger !== 'click') return
|
||||||
|
const w = wrapperRef.value
|
||||||
|
const t = tooltipRef.value
|
||||||
|
const target = e.target as Node
|
||||||
|
if (w && !w.contains(target) && t && !t.contains(target)) {
|
||||||
|
hideImmediately()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 定位算法
|
||||||
|
function clamp(n: number, min: number, max: number) {
|
||||||
|
return Math.max(min, Math.min(max, n))
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeBasePosition(
|
||||||
|
placement: Placement,
|
||||||
|
triggerRect: DOMRect,
|
||||||
|
tooltipRect: DOMRect,
|
||||||
|
offset: number,
|
||||||
|
) {
|
||||||
|
const centerX = triggerRect.left + triggerRect.width / 2
|
||||||
|
const centerY = triggerRect.top + triggerRect.height / 2
|
||||||
|
|
||||||
|
switch (placement) {
|
||||||
|
case 'top':
|
||||||
|
return {
|
||||||
|
top: triggerRect.top - tooltipRect.height - offset,
|
||||||
|
left: centerX - tooltipRect.width / 2,
|
||||||
|
}
|
||||||
|
case 'bottom':
|
||||||
|
return {
|
||||||
|
top: triggerRect.bottom + offset,
|
||||||
|
left: centerX - tooltipRect.width / 2,
|
||||||
|
}
|
||||||
|
case 'left':
|
||||||
|
return {
|
||||||
|
top: centerY - tooltipRect.height / 2,
|
||||||
|
left: triggerRect.left - tooltipRect.width - offset,
|
||||||
|
}
|
||||||
|
case 'right':
|
||||||
|
return {
|
||||||
|
top: centerY - tooltipRect.height / 2,
|
||||||
|
left: triggerRect.right + offset,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionWithSmartFlip(
|
||||||
|
preferred: Placement,
|
||||||
|
triggerRect: DOMRect,
|
||||||
|
tooltipRect: DOMRect,
|
||||||
|
offset: number,
|
||||||
|
) {
|
||||||
|
const padding = 8
|
||||||
|
const vw = window.innerWidth
|
||||||
|
const vh = window.innerHeight
|
||||||
|
|
||||||
|
let placement: Placement = preferred
|
||||||
|
let { top, left } = computeBasePosition(placement, triggerRect, tooltipRect, offset)!
|
||||||
|
|
||||||
|
const outTop = top < padding
|
||||||
|
const outBottom = top + tooltipRect.height > vh - padding
|
||||||
|
const outLeft = left < padding
|
||||||
|
const outRight = left + tooltipRect.width > vw - padding
|
||||||
|
|
||||||
|
if (
|
||||||
|
placement === 'top' &&
|
||||||
|
outTop &&
|
||||||
|
triggerRect.bottom + offset + tooltipRect.height <= vh - padding
|
||||||
|
) {
|
||||||
|
placement = 'bottom'
|
||||||
|
;({ top, left } = computeBasePosition(placement, triggerRect, tooltipRect, offset)!)
|
||||||
|
} else if (
|
||||||
|
placement === 'bottom' &&
|
||||||
|
outBottom &&
|
||||||
|
triggerRect.top - offset - tooltipRect.height >= padding
|
||||||
|
) {
|
||||||
|
placement = 'top'
|
||||||
|
;({ top, left } = computeBasePosition(placement, triggerRect, tooltipRect, offset)!)
|
||||||
|
} else if (
|
||||||
|
placement === 'left' &&
|
||||||
|
outLeft &&
|
||||||
|
triggerRect.right + offset + tooltipRect.width <= vw - padding
|
||||||
|
) {
|
||||||
|
placement = 'right'
|
||||||
|
;({ top, left } = computeBasePosition(placement, triggerRect, tooltipRect, offset)!)
|
||||||
|
} else if (
|
||||||
|
placement === 'right' &&
|
||||||
|
outRight &&
|
||||||
|
triggerRect.left - offset - tooltipRect.width >= padding
|
||||||
|
) {
|
||||||
|
placement = 'left'
|
||||||
|
;({ top, left } = computeBasePosition(placement, triggerRect, tooltipRect, offset)!)
|
||||||
|
}
|
||||||
|
|
||||||
|
top = clamp(top, padding, vh - tooltipRect.height - padding)
|
||||||
|
left = clamp(left, padding, vw - tooltipRect.width - padding)
|
||||||
|
|
||||||
|
const triggerCenterX = triggerRect.left + triggerRect.width / 2
|
||||||
|
const triggerCenterY = triggerRect.top + triggerRect.height / 2
|
||||||
|
const arrowLeft = clamp(triggerCenterX - left, 10, tooltipRect.width - 10)
|
||||||
|
const arrowTop = clamp(triggerCenterY - top, 10, tooltipRect.height - 10)
|
||||||
|
|
||||||
|
return { placement, top, left, arrowLeft, arrowTop }
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatePosition = () => {
|
||||||
|
if (!wrapperRef.value || !tooltipRef.value || !visible.value) return
|
||||||
|
if (rafId) cancelAnimationFrame(rafId)
|
||||||
|
rafId = requestAnimationFrame(() => {
|
||||||
|
const triggerEl = wrapperRef.value!.querySelector('.tooltip-trigger') as HTMLElement | null
|
||||||
|
const tooltipEl = tooltipRef.value!
|
||||||
|
if (!triggerEl) return
|
||||||
|
|
||||||
|
const triggerRect = triggerEl.getBoundingClientRect()
|
||||||
|
const tooltipRect = tooltipEl.getBoundingClientRect()
|
||||||
|
const { placement, top, left, arrowLeft, arrowTop } = positionWithSmartFlip(
|
||||||
|
props.placement,
|
||||||
|
triggerRect,
|
||||||
|
tooltipRect,
|
||||||
|
props.offset,
|
||||||
|
)
|
||||||
|
|
||||||
|
currentPlacement.value = placement
|
||||||
|
tooltipTransform.value = `translate3d(${Math.round(left)}px, ${Math.round(top)}px, 0)`
|
||||||
|
if (placement === 'top' || placement === 'bottom') {
|
||||||
|
arrowStyle.value = { '--arrow-left': `${Math.round(arrowLeft)}px` } as any
|
||||||
|
} else {
|
||||||
|
arrowStyle.value = { '--arrow-top': `${Math.round(arrowTop)}px` } as any
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onEnvChanged = () => {
|
||||||
|
if (visible.value) updatePosition()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.disabled,
|
||||||
|
(v) => {
|
||||||
|
if (v && visible.value) hideImmediately()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
watch(
|
||||||
|
() => props.placement,
|
||||||
|
() => {
|
||||||
|
if (visible.value) nextTick(updatePosition)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
watch(visible, (v) => {
|
||||||
|
if (!mounted.value) return
|
||||||
|
if (v) {
|
||||||
|
if ('ResizeObserver' in window && !ro) {
|
||||||
|
ro = new ResizeObserver(() => updatePosition())
|
||||||
|
if (tooltipRef.value) ro.observe(tooltipRef.value)
|
||||||
|
const triggerEl = wrapperRef.value?.querySelector('.tooltip-trigger') as HTMLElement | null
|
||||||
|
if (triggerEl) ro.observe(triggerEl)
|
||||||
|
}
|
||||||
|
updatePosition()
|
||||||
|
} else {
|
||||||
|
if (ro) {
|
||||||
|
ro.disconnect()
|
||||||
|
ro = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
mounted.value = true
|
||||||
|
window.addEventListener('resize', onEnvChanged, { passive: true })
|
||||||
|
window.addEventListener('scroll', onEnvChanged, { passive: true, capture: true })
|
||||||
|
document.addEventListener('click', onClickOutside, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
clearTimers()
|
||||||
|
if (rafId) cancelAnimationFrame(rafId)
|
||||||
|
if (ro) {
|
||||||
|
ro.disconnect()
|
||||||
|
ro = null
|
||||||
|
}
|
||||||
|
document.removeEventListener('click', onClickOutside, true)
|
||||||
|
window.removeEventListener('resize', onEnvChanged)
|
||||||
|
window.removeEventListener('scroll', onEnvChanged, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 暴露给父组件(manual 可用)
|
||||||
|
defineExpose({ show: showImmediately, hide: hideImmediately, updatePosition })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -308,16 +383,23 @@ export default {
|
|||||||
position: relative;
|
position: relative;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip-trigger {
|
.tooltip-trigger {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
.tooltip-trigger:focus-visible {
|
||||||
|
outline: 2px solid var(--primary-color);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.tooltip-content {
|
.tooltip-content {
|
||||||
position: fixed;
|
will-change: transform;
|
||||||
|
pointer-events: auto; /* 默认允许交互(click/focus 模式) */
|
||||||
|
}
|
||||||
|
.tooltip-noninteractive {
|
||||||
|
/* hover 模式下禁用指针事件,避免移入浮层导致保持显示 */
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 2000;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip-inner {
|
.tooltip-inner {
|
||||||
@@ -326,23 +408,22 @@ export default {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
|
border: 1px solid transparent;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 亮色主题 */
|
/* 主题 */
|
||||||
.tooltip-light .tooltip-inner {
|
.tooltip-light .tooltip-inner {
|
||||||
background-color: var(--background-color);
|
background-color: var(--background-color);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
border: 1px solid var(--normal-border-color);
|
border-color: var(--normal-border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 暗色主题 */
|
|
||||||
.tooltip-dark .tooltip-inner {
|
.tooltip-dark .tooltip-inner {
|
||||||
background-color: rgba(0, 0, 0, 0.9);
|
background-color: rgba(0, 0, 0, 0.9);
|
||||||
color: white;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 箭头基础样式 */
|
/* 箭头(用 CSS 变量控制偏移) */
|
||||||
.tooltip-arrow {
|
.tooltip-arrow {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 0;
|
width: 0;
|
||||||
@@ -350,18 +431,16 @@ export default {
|
|||||||
border-style: solid;
|
border-style: solid;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 顶部箭头 */
|
/* 顶部 */
|
||||||
.tooltip-top .tooltip-arrow-top {
|
.tooltip-top .tooltip-arrow-top {
|
||||||
bottom: -6px;
|
bottom: -6px;
|
||||||
left: 50%;
|
left: var(--arrow-left, 50%);
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
border-width: 6px 6px 0 6px;
|
border-width: 6px 6px 0 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip-light.tooltip-top .tooltip-arrow-top {
|
.tooltip-light.tooltip-top .tooltip-arrow-top {
|
||||||
border-color: var(--normal-border-color) transparent transparent transparent;
|
border-color: var(--normal-border-color) transparent transparent transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip-light.tooltip-top .tooltip-arrow-top::after {
|
.tooltip-light.tooltip-top .tooltip-arrow-top::after {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -371,23 +450,20 @@ export default {
|
|||||||
border-style: solid;
|
border-style: solid;
|
||||||
border-color: var(--background-color) transparent transparent transparent;
|
border-color: var(--background-color) transparent transparent transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip-dark.tooltip-top .tooltip-arrow-top {
|
.tooltip-dark.tooltip-top .tooltip-arrow-top {
|
||||||
border-color: rgba(0, 0, 0, 0.9) transparent transparent transparent;
|
border-color: rgba(0, 0, 0, 0.9) transparent transparent transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 底部箭头 */
|
/* 底部 */
|
||||||
.tooltip-bottom .tooltip-arrow-bottom {
|
.tooltip-bottom .tooltip-arrow-bottom {
|
||||||
top: -6px;
|
top: -6px;
|
||||||
left: 50%;
|
left: var(--arrow-left, 50%);
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
border-width: 0 6px 6px 6px;
|
border-width: 0 6px 6px 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip-light.tooltip-bottom .tooltip-arrow-bottom {
|
.tooltip-light.tooltip-bottom .tooltip-arrow-bottom {
|
||||||
border-color: transparent transparent var(--normal-border-color) transparent;
|
border-color: transparent transparent var(--normal-border-color) transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip-light.tooltip-bottom .tooltip-arrow-bottom::after {
|
.tooltip-light.tooltip-bottom .tooltip-arrow-bottom::after {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -397,23 +473,20 @@ export default {
|
|||||||
border-style: solid;
|
border-style: solid;
|
||||||
border-color: transparent transparent var(--background-color) transparent;
|
border-color: transparent transparent var(--background-color) transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip-dark.tooltip-bottom .tooltip-arrow-bottom {
|
.tooltip-dark.tooltip-bottom .tooltip-arrow-bottom {
|
||||||
border-color: transparent transparent rgba(0, 0, 0, 0.9) transparent;
|
border-color: transparent transparent rgba(0, 0, 0, 0.9) transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 左侧箭头 */
|
/* 左侧 */
|
||||||
.tooltip-left .tooltip-arrow-left {
|
.tooltip-left .tooltip-arrow-left {
|
||||||
right: -6px;
|
right: -6px;
|
||||||
top: 50%;
|
top: var(--arrow-top, 50%);
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
border-width: 6px 0 6px 6px;
|
border-width: 6px 0 6px 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip-light.tooltip-left .tooltip-arrow-left {
|
.tooltip-light.tooltip-left .tooltip-arrow-left {
|
||||||
border-color: transparent transparent transparent var(--normal-border-color);
|
border-color: transparent transparent transparent var(--normal-border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip-light.tooltip-left .tooltip-arrow-left::after {
|
.tooltip-light.tooltip-left .tooltip-arrow-left::after {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -423,23 +496,20 @@ export default {
|
|||||||
border-style: solid;
|
border-style: solid;
|
||||||
border-color: transparent transparent transparent var(--background-color);
|
border-color: transparent transparent transparent var(--background-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip-dark.tooltip-left .tooltip-arrow-left {
|
.tooltip-dark.tooltip-left .tooltip-arrow-left {
|
||||||
border-color: transparent transparent transparent rgba(0, 0, 0, 0.9);
|
border-color: transparent transparent transparent rgba(0, 0, 0, 0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 右侧箭头 */
|
/* 右侧 */
|
||||||
.tooltip-right .tooltip-arrow-right {
|
.tooltip-right .tooltip-arrow-right {
|
||||||
left: -6px;
|
left: -6px;
|
||||||
top: 50%;
|
top: var(--arrow-top, 50%);
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
border-width: 6px 6px 6px 0;
|
border-width: 6px 6px 6px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip-light.tooltip-right .tooltip-arrow-right {
|
.tooltip-light.tooltip-right .tooltip-arrow-right {
|
||||||
border-color: transparent var(--normal-border-color) transparent transparent;
|
border-color: transparent var(--normal-border-color) transparent transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip-light.tooltip-right .tooltip-arrow-right::after {
|
.tooltip-light.tooltip-right .tooltip-arrow-right::after {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -449,7 +519,6 @@ export default {
|
|||||||
border-style: solid;
|
border-style: solid;
|
||||||
border-color: transparent var(--background-color) transparent transparent;
|
border-color: transparent var(--background-color) transparent transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip-dark.tooltip-right .tooltip-arrow-right {
|
.tooltip-dark.tooltip-right .tooltip-arrow-right {
|
||||||
border-color: transparent rgba(0, 0, 0, 0.9) transparent transparent;
|
border-color: transparent rgba(0, 0, 0, 0.9) transparent transparent;
|
||||||
}
|
}
|
||||||
@@ -457,20 +526,17 @@ export default {
|
|||||||
/* 过渡动画 */
|
/* 过渡动画 */
|
||||||
.tooltip-fade-enter-active,
|
.tooltip-fade-enter-active,
|
||||||
.tooltip-fade-leave-active {
|
.tooltip-fade-leave-active {
|
||||||
transition: all 0.2s ease;
|
transition:
|
||||||
|
opacity 0.18s ease,
|
||||||
|
transform 0.18s ease;
|
||||||
}
|
}
|
||||||
|
.tooltip-fade-enter-from,
|
||||||
.tooltip-fade-enter-from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: scale(0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip-fade-leave-to {
|
.tooltip-fade-leave-to {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: scale(0.8);
|
transform: translate3d(0, 4px, 0) scale(0.98);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 响应式调整 */
|
/* 响应式微调 */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.tooltip-inner {
|
.tooltip-inner {
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
@@ -478,11 +544,4 @@ export default {
|
|||||||
max-width: 250px;
|
max-width: 250px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 键盘导航样式 */
|
|
||||||
.tooltip-trigger:focus-visible {
|
|
||||||
outline: 2px solid var(--primary-color);
|
|
||||||
outline-offset: 2px;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -27,6 +27,10 @@
|
|||||||
>找回密码</a
|
>找回密码</a
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="hint-message">
|
||||||
|
<i class="fas fa-info-circle"></i>
|
||||||
|
使用右侧第三方OAuth注册/登录的用户可使用对应的邮箱进行重设密码
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -259,6 +263,11 @@ const loginWithTwitter = () => {
|
|||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hint-message {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.login-page {
|
.login-page {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -35,10 +35,12 @@
|
|||||||
/>
|
/>
|
||||||
<div class="profile-level-target">
|
<div class="profile-level-target">
|
||||||
目标 Lv.{{ levelInfo.currentLevel + 1 }}
|
目标 Lv.{{ levelInfo.currentLevel + 1 }}
|
||||||
<i
|
<ToolTip
|
||||||
class="fas fa-info-circle profile-exp-info"
|
content="经验值可通过发帖、评论等操作获得,达到目标后即可提升等级,解锁更多功能。"
|
||||||
title="经验值可通过发帖、评论等操作获得,达到目标后即可提升等级,解锁更多功能。"
|
placement="bottom"
|
||||||
></i>
|
>
|
||||||
|
<i class="fas fa-info-circle profile-exp-info"></i>
|
||||||
|
</ToolTip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -685,12 +687,6 @@ watch(selectedTab, async (val) => {
|
|||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-exp-info {
|
|
||||||
margin-left: 4px;
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-info {
|
.profile-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
|||||||
Reference in New Issue
Block a user