mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-21 22:41:05 +08:00
Compare commits
15 Commits
codex/save
...
codex/add
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bd1225b27 | ||
|
|
2dd56e27af | ||
|
|
c3ecef3609 | ||
|
|
efc74d0f77 | ||
|
|
f27cb5c703 | ||
|
|
a756c2fab3 | ||
|
|
4e2171a8a6 | ||
|
|
bcbdff8768 | ||
|
|
b976a1f46f | ||
|
|
b9fd9711de | ||
|
|
642a527dcf | ||
|
|
88afcc5a8e | ||
|
|
2c5462cd97 | ||
|
|
2f29946b11 | ||
|
|
e27aa34cfd |
@@ -131,7 +131,7 @@ const goToNewPost = () => {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
display: flex;
|
display: flex;
|
||||||
backdrop-filter: blur(5px);
|
backdrop-filter: var(--blur-5);
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,15 @@
|
|||||||
--header-background-color: white;
|
--header-background-color: white;
|
||||||
--header-border-color: lightgray;
|
--header-border-color: lightgray;
|
||||||
--header-text-color: black;
|
--header-text-color: black;
|
||||||
|
--blur-1: blur(1px);
|
||||||
|
--blur-2: blur(2px);
|
||||||
|
--blur-4: blur(4px);
|
||||||
|
--blur-5: blur(5px);
|
||||||
|
--blur-10: blur(10px);
|
||||||
/* 加一个app前缀防止与firefox的userChrome.css中的--menu-background-color冲突 */
|
/* 加一个app前缀防止与firefox的userChrome.css中的--menu-background-color冲突 */
|
||||||
--app-menu-background-color: white;
|
--app-menu-background-color: white;
|
||||||
--background-color: white;
|
--background-color: white;
|
||||||
/* --background-color-blur: rgba(255, 255, 255, 0.57); */
|
--background-color-blur: rgba(255, 255, 255, 0.57);
|
||||||
--background-color-blur: var(--background-color);
|
|
||||||
--menu-border-color: lightgray;
|
--menu-border-color: lightgray;
|
||||||
--normal-border-color: lightgray;
|
--normal-border-color: lightgray;
|
||||||
--menu-selected-background-color: rgba(208, 250, 255, 0.659);
|
--menu-selected-background-color: rgba(208, 250, 255, 0.659);
|
||||||
@@ -59,6 +63,15 @@
|
|||||||
--activity-card-background-color: #585858;
|
--activity-card-background-color: #585858;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root[data-frosted='off'] {
|
||||||
|
--blur-1: none;
|
||||||
|
--blur-2: none;
|
||||||
|
--blur-4: none;
|
||||||
|
--blur-5: none;
|
||||||
|
--blur-10: none;
|
||||||
|
--background-color-blur: var(--background-color);
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ export default {
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
backdrop-filter: blur(2px);
|
backdrop-filter: var(--blur-2);
|
||||||
-webkit-backdrop-filter: blur(2px);
|
-webkit-backdrop-filter: var(--blur-2);
|
||||||
}
|
}
|
||||||
.popup-content {
|
.popup-content {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ onMounted(async () => {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
height: var(--header-height);
|
height: var(--header-height);
|
||||||
background-color: var(--background-color-blur);
|
background-color: var(--background-color-blur);
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: var(--blur-10);
|
||||||
color: var(--header-text-color);
|
color: var(--header-text-color);
|
||||||
border-bottom: 1px solid var(--header-border-color);
|
border-bottom: 1px solid var(--header-border-color);
|
||||||
}
|
}
|
||||||
@@ -210,6 +210,7 @@ onMounted(async () => {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
max-width: var(--page-max-width);
|
max-width: var(--page-max-width);
|
||||||
|
backdrop-filter: var(--blur-10);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-content-left {
|
.header-content-left {
|
||||||
@@ -317,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) {
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ const goLogin = () => {
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
backdrop-filter: blur(4px);
|
backdrop-filter: var(--blur-4);
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,19 @@
|
|||||||
<i class="menu-item-icon fas fa-chart-line"></i>
|
<i class="menu-item-icon fas fa-chart-line"></i>
|
||||||
<span class="menu-item-text">站点统计</span>
|
<span class="menu-item-text">站点统计</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
<NuxtLink
|
||||||
|
v-if="authState.loggedIn"
|
||||||
|
class="menu-item"
|
||||||
|
exact-active-class="selected"
|
||||||
|
to="/about/points"
|
||||||
|
@click="handleItemClick"
|
||||||
|
>
|
||||||
|
<i class="menu-item-icon fas fa-coins"></i>
|
||||||
|
<span class="menu-item-text">
|
||||||
|
积分商城
|
||||||
|
<span v-if="myPoint !== null" class="point-count">{{ myPoint }}</span>
|
||||||
|
</span>
|
||||||
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="menu-section">
|
<div class="menu-section">
|
||||||
@@ -130,7 +143,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { authState } from '~/utils/auth'
|
import { authState, fetchCurrentUser } from '~/utils/auth'
|
||||||
import { fetchUnreadCount, notificationState } from '~/utils/notification'
|
import { fetchUnreadCount, notificationState } from '~/utils/notification'
|
||||||
import { useIsMobile } from '~/utils/screen'
|
import { useIsMobile } from '~/utils/screen'
|
||||||
import { cycleTheme, ThemeMode, themeState } from '~/utils/theme'
|
import { cycleTheme, ThemeMode, themeState } from '~/utils/theme'
|
||||||
@@ -147,6 +160,7 @@ const emit = defineEmits(['item-click'])
|
|||||||
|
|
||||||
const categoryOpen = ref(true)
|
const categoryOpen = ref(true)
|
||||||
const tagOpen = ref(true)
|
const tagOpen = ref(true)
|
||||||
|
const myPoint = ref(null)
|
||||||
|
|
||||||
/** ✅ 用 useAsyncData 替换原生 fetch,避免 SSR+CSR 二次请求 */
|
/** ✅ 用 useAsyncData 替换原生 fetch,避免 SSR+CSR 二次请求 */
|
||||||
const {
|
const {
|
||||||
@@ -191,6 +205,15 @@ const unreadCount = computed(() => notificationState.unreadCount)
|
|||||||
const showUnreadCount = computed(() => (unreadCount.value > 99 ? '99+' : unreadCount.value))
|
const showUnreadCount = computed(() => (unreadCount.value > 99 ? '99+' : unreadCount.value))
|
||||||
const shouldShowStats = computed(() => authState.role === 'ADMIN')
|
const shouldShowStats = computed(() => authState.role === 'ADMIN')
|
||||||
|
|
||||||
|
const loadPoint = async () => {
|
||||||
|
if (authState.loggedIn) {
|
||||||
|
const user = await fetchCurrentUser()
|
||||||
|
myPoint.value = user ? user.point : null
|
||||||
|
} else {
|
||||||
|
myPoint.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const updateCount = async () => {
|
const updateCount = async () => {
|
||||||
if (authState.loggedIn) {
|
if (authState.loggedIn) {
|
||||||
await fetchUnreadCount()
|
await fetchUnreadCount()
|
||||||
@@ -200,9 +223,15 @@ const updateCount = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await updateCount()
|
await Promise.all([updateCount(), loadPoint()])
|
||||||
// 登录态变化时再拉一次未读数;与 useAsyncData 无关
|
// 登录态变化时再拉一次未读数和积分;与 useAsyncData 无关
|
||||||
watch(() => authState.loggedIn, updateCount)
|
watch(
|
||||||
|
() => authState.loggedIn,
|
||||||
|
() => {
|
||||||
|
updateCount()
|
||||||
|
loadPoint()
|
||||||
|
},
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleItemClick = () => {
|
const handleItemClick = () => {
|
||||||
@@ -239,6 +268,7 @@ const gotoTag = (t) => {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
|
backdrop-filter: var(--blur-10);
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-content {
|
.menu-content {
|
||||||
@@ -291,6 +321,12 @@ const gotoTag = (t) => {
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.point-count {
|
||||||
|
margin-left: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
.menu-item-icon {
|
.menu-item-icon {
|
||||||
margin-right: 10px;
|
margin-right: 10px;
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
29
frontend_nuxt/pages/about/points.vue
Normal file
29
frontend_nuxt/pages/about/points.vue
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<template>
|
||||||
|
<div class="point-mall-page">
|
||||||
|
<p v-if="authState.loggedIn && point !== null">我的积分:{{ point }}</p>
|
||||||
|
<p v-else>请先登录以查看积分</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { authState, fetchCurrentUser } from '~/utils/auth'
|
||||||
|
|
||||||
|
const point = ref(null)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (authState.loggedIn) {
|
||||||
|
const user = await fetchCurrentUser()
|
||||||
|
point.value = user ? user.point : null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.point-mall-page {
|
||||||
|
padding: 20px;
|
||||||
|
max-width: var(--page-max-width);
|
||||||
|
background-color: var(--background-color);
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -147,16 +147,17 @@ const categoryOptions = ref([])
|
|||||||
const isLoadingMore = ref(false)
|
const isLoadingMore = ref(false)
|
||||||
|
|
||||||
const topics = ref(['最新回复', '最新', '排行榜' /*, '热门', '类别'*/])
|
const topics = ref(['最新回复', '最新', '排行榜' /*, '热门', '类别'*/])
|
||||||
|
const selectedTopicCookie = useCookie('homeTab')
|
||||||
const selectedTopic = ref(
|
const selectedTopic = ref(
|
||||||
route.query.view === 'ranking' ? '排行榜' : route.query.view === 'latest' ? '最新' : '最新回复',
|
selectedTopicCookie.value
|
||||||
|
? selectedTopicCookie.value
|
||||||
|
: route.query.view === 'ranking'
|
||||||
|
? '排行榜'
|
||||||
|
: route.query.view === 'latest'
|
||||||
|
? '最新'
|
||||||
|
: '最新回复',
|
||||||
)
|
)
|
||||||
|
if (!selectedTopicCookie.value) selectedTopicCookie.value = selectedTopic.value
|
||||||
if (import.meta.client) {
|
|
||||||
const storedTopic = localStorage.getItem('home-selected-topic')
|
|
||||||
if (storedTopic && topics.value.includes(storedTopic)) {
|
|
||||||
selectedTopic.value = storedTopic
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const articles = ref([])
|
const articles = ref([])
|
||||||
const page = ref(0)
|
const page = ref(0)
|
||||||
const pageSize = 10
|
const pageSize = 10
|
||||||
@@ -182,6 +183,11 @@ onMounted(() => {
|
|||||||
const { category, tags } = route.query
|
const { category, tags } = route.query
|
||||||
if (category) selectedCategorySet(category)
|
if (category) selectedCategorySet(category)
|
||||||
if (tags) selectedTagsSet(tags)
|
if (tags) selectedTagsSet(tags)
|
||||||
|
|
||||||
|
const saved = localStorage.getItem('homeTab')
|
||||||
|
if (saved) {
|
||||||
|
selectedTopic.value = saved
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 路由变更时同步筛选 **/
|
/** 路由变更时同步筛选 **/
|
||||||
@@ -347,12 +353,13 @@ watch(
|
|||||||
watch([selectedCategory, selectedTags], () => {
|
watch([selectedCategory, selectedTags], () => {
|
||||||
loadOptions()
|
loadOptions()
|
||||||
})
|
})
|
||||||
watch(selectedTopic, (topic) => {
|
watch(selectedTopic, (val) => {
|
||||||
if (import.meta.client) {
|
|
||||||
localStorage.setItem('home-selected-topic', topic)
|
|
||||||
}
|
|
||||||
// 仅当需要额外选项时加载
|
// 仅当需要额外选项时加载
|
||||||
loadOptions()
|
loadOptions()
|
||||||
|
selectedTopicCookie.value = val
|
||||||
|
if (process.client) {
|
||||||
|
localStorage.setItem('homeTab', val)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 选项首屏加载:服务端执行一次;客户端兜底 **/
|
/** 选项首屏加载:服务端执行一次;客户端兜底 **/
|
||||||
@@ -427,6 +434,7 @@ const sanitizeDescription = (text) => stripMarkdown(text)
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 10px 0;
|
padding: 10px 0;
|
||||||
|
backdrop-filter: var(--blur-10);
|
||||||
}
|
}
|
||||||
|
|
||||||
.topic-item-container {
|
.topic-item-container {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -649,6 +649,7 @@ onActivated(() => {
|
|||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
backdrop-filter: var(--blur-10);
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-page-header-right {
|
.message-page-header-right {
|
||||||
|
|||||||
@@ -36,6 +36,13 @@
|
|||||||
<BaseInput v-model="introduction" textarea rows="3" placeholder="说些什么..." />
|
<BaseInput v-model="introduction" textarea rows="3" placeholder="说些什么..." />
|
||||||
<div class="setting-description">自我介绍会出现在你的个人主页,可以简要介绍自己</div>
|
<div class="setting-description">自我介绍会出现在你的个人主页,可以简要介绍自己</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-row switch-row">
|
||||||
|
<div class="setting-title">毛玻璃效果</div>
|
||||||
|
<label class="switch">
|
||||||
|
<input type="checkbox" v-model="frosted" />
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="role === 'ADMIN'" class="admin-section">
|
<div v-if="role === 'ADMIN'" class="admin-section">
|
||||||
<h3>管理员设置</h3>
|
<h3>管理员设置</h3>
|
||||||
@@ -65,12 +72,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted, watch } from 'vue'
|
||||||
import AvatarCropper from '~/components/AvatarCropper.vue'
|
import AvatarCropper from '~/components/AvatarCropper.vue'
|
||||||
import BaseInput from '~/components/BaseInput.vue'
|
import BaseInput from '~/components/BaseInput.vue'
|
||||||
import Dropdown from '~/components/Dropdown.vue'
|
import Dropdown from '~/components/Dropdown.vue'
|
||||||
import { toast } from '~/main'
|
import { toast } from '~/main'
|
||||||
import { fetchCurrentUser, getToken, setToken } from '~/utils/auth'
|
import { fetchCurrentUser, getToken, setToken } from '~/utils/auth'
|
||||||
|
import { frostedState, setFrosted } from '~/utils/frosted'
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const API_BASE_URL = config.public.apiBaseUrl
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
const username = ref('')
|
const username = ref('')
|
||||||
@@ -87,6 +95,7 @@ const aiFormatLimit = ref(3)
|
|||||||
const registerMode = ref('DIRECT')
|
const registerMode = ref('DIRECT')
|
||||||
const isLoadingPage = ref(false)
|
const isLoadingPage = ref(false)
|
||||||
const isSaving = ref(false)
|
const isSaving = ref(false)
|
||||||
|
const frosted = ref(true)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
isLoadingPage.value = true
|
isLoadingPage.value = true
|
||||||
@@ -105,6 +114,7 @@ onMounted(async () => {
|
|||||||
navigateTo('/login', { replace: true })
|
navigateTo('/login', { replace: true })
|
||||||
}
|
}
|
||||||
isLoadingPage.value = false
|
isLoadingPage.value = false
|
||||||
|
frosted.value = frostedState.enabled
|
||||||
})
|
})
|
||||||
|
|
||||||
const onAvatarChange = (e) => {
|
const onAvatarChange = (e) => {
|
||||||
@@ -118,6 +128,7 @@ const onAvatarChange = (e) => {
|
|||||||
reader.readAsDataURL(file)
|
reader.readAsDataURL(file)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
watch(frosted, (val) => setFrosted(val))
|
||||||
const onCropped = ({ file, url }) => {
|
const onCropped = ({ file, url }) => {
|
||||||
avatarFile.value = file
|
avatarFile.value = file
|
||||||
avatar.value = url
|
avatar.value = url
|
||||||
@@ -300,6 +311,58 @@ const save = async () => {
|
|||||||
max-width: 200px;
|
max-width: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.switch-row {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 40px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch input {
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider {
|
||||||
|
position: absolute;
|
||||||
|
cursor: pointer;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: #ccc;
|
||||||
|
transition: 0.2s;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider:before {
|
||||||
|
position: absolute;
|
||||||
|
content: '';
|
||||||
|
height: 16px;
|
||||||
|
width: 16px;
|
||||||
|
left: 2px;
|
||||||
|
bottom: 2px;
|
||||||
|
background-color: white;
|
||||||
|
transition: 0.2s;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:checked + .slider {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
input:checked + .slider:before {
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
|
||||||
.profile-section {
|
.profile-section {
|
||||||
margin-bottom: 30px;
|
margin-bottom: 30px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -204,67 +206,89 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="selectedTab === 'timeline'" class="profile-timeline">
|
<div v-else-if="selectedTab === 'timeline'" class="profile-timeline">
|
||||||
|
<div class="timeline-tabs">
|
||||||
|
<div
|
||||||
|
:class="['timeline-tab-item', { selected: timelineFilter === 'all' }]"
|
||||||
|
@click="timelineFilter = 'all'"
|
||||||
|
>
|
||||||
|
全部
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
:class="['timeline-tab-item', { selected: timelineFilter === 'articles' }]"
|
||||||
|
@click="timelineFilter = 'articles'"
|
||||||
|
>
|
||||||
|
文章
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
:class="['timeline-tab-item', { selected: timelineFilter === 'comments' }]"
|
||||||
|
@click="timelineFilter = 'comments'"
|
||||||
|
>
|
||||||
|
评论和回复
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<BasePlaceholder
|
<BasePlaceholder
|
||||||
v-if="timelineItems.length === 0"
|
v-if="filteredTimelineItems.length === 0"
|
||||||
text="暂无时间线"
|
text="暂无时间线"
|
||||||
icon="fas fa-inbox"
|
icon="fas fa-inbox"
|
||||||
/>
|
/>
|
||||||
<BaseTimeline :items="timelineItems">
|
<div class="timeline-list">
|
||||||
<template #item="{ item }">
|
<BaseTimeline :items="filteredTimelineItems">
|
||||||
<template v-if="item.type === 'post'">
|
<template #item="{ item }">
|
||||||
发布了文章
|
<template v-if="item.type === 'post'">
|
||||||
<router-link :to="`/posts/${item.post.id}`" class="timeline-link">
|
发布了文章
|
||||||
{{ item.post.title }}
|
<router-link :to="`/posts/${item.post.id}`" class="timeline-link">
|
||||||
</router-link>
|
{{ item.post.title }}
|
||||||
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
</router-link>
|
||||||
|
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.type === 'comment'">
|
||||||
|
在
|
||||||
|
<router-link :to="`/posts/${item.comment.post.id}`" class="timeline-link">
|
||||||
|
{{ item.comment.post.title }}
|
||||||
|
</router-link>
|
||||||
|
下评论了
|
||||||
|
<router-link
|
||||||
|
:to="`/posts/${item.comment.post.id}#comment-${item.comment.id}`"
|
||||||
|
class="timeline-link"
|
||||||
|
>
|
||||||
|
{{ stripMarkdownLength(item.comment.content, 200) }}
|
||||||
|
</router-link>
|
||||||
|
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.type === 'reply'">
|
||||||
|
在
|
||||||
|
<router-link :to="`/posts/${item.comment.post.id}`" class="timeline-link">
|
||||||
|
{{ item.comment.post.title }}
|
||||||
|
</router-link>
|
||||||
|
下对
|
||||||
|
<router-link
|
||||||
|
:to="`/posts/${item.comment.post.id}#comment-${item.comment.parentComment.id}`"
|
||||||
|
class="timeline-link"
|
||||||
|
>
|
||||||
|
{{ stripMarkdownLength(item.comment.parentComment.content, 200) }}
|
||||||
|
</router-link>
|
||||||
|
回复了
|
||||||
|
<router-link
|
||||||
|
:to="`/posts/${item.comment.post.id}#comment-${item.comment.id}`"
|
||||||
|
class="timeline-link"
|
||||||
|
>
|
||||||
|
{{ stripMarkdownLength(item.comment.content, 200) }}
|
||||||
|
</router-link>
|
||||||
|
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.type === 'tag'">
|
||||||
|
创建了标签
|
||||||
|
<span class="timeline-link" @click="gotoTag(item.tag)">
|
||||||
|
{{ item.tag.name }}<span v-if="item.tag.count"> x{{ item.tag.count }}</span>
|
||||||
|
</span>
|
||||||
|
<div class="timeline-snippet" v-if="item.tag.description">
|
||||||
|
{{ item.tag.description }}
|
||||||
|
</div>
|
||||||
|
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="item.type === 'comment'">
|
</BaseTimeline>
|
||||||
在
|
</div>
|
||||||
<router-link :to="`/posts/${item.comment.post.id}`" class="timeline-link">
|
|
||||||
{{ item.comment.post.title }}
|
|
||||||
</router-link>
|
|
||||||
下评论了
|
|
||||||
<router-link
|
|
||||||
:to="`/posts/${item.comment.post.id}#comment-${item.comment.id}`"
|
|
||||||
class="timeline-link"
|
|
||||||
>
|
|
||||||
{{ stripMarkdownLength(item.comment.content, 200) }}
|
|
||||||
</router-link>
|
|
||||||
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
|
||||||
</template>
|
|
||||||
<template v-else-if="item.type === 'reply'">
|
|
||||||
在
|
|
||||||
<router-link :to="`/posts/${item.comment.post.id}`" class="timeline-link">
|
|
||||||
{{ item.comment.post.title }}
|
|
||||||
</router-link>
|
|
||||||
下对
|
|
||||||
<router-link
|
|
||||||
:to="`/posts/${item.comment.post.id}#comment-${item.comment.parentComment.id}`"
|
|
||||||
class="timeline-link"
|
|
||||||
>
|
|
||||||
{{ stripMarkdownLength(item.comment.parentComment.content, 200) }}
|
|
||||||
</router-link>
|
|
||||||
回复了
|
|
||||||
<router-link
|
|
||||||
:to="`/posts/${item.comment.post.id}#comment-${item.comment.id}`"
|
|
||||||
class="timeline-link"
|
|
||||||
>
|
|
||||||
{{ stripMarkdownLength(item.comment.content, 200) }}
|
|
||||||
</router-link>
|
|
||||||
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
|
||||||
</template>
|
|
||||||
<template v-else-if="item.type === 'tag'">
|
|
||||||
创建了标签
|
|
||||||
<span class="timeline-link" @click="gotoTag(item.tag)">
|
|
||||||
{{ item.tag.name }}<span v-if="item.tag.count"> x{{ item.tag.count }}</span>
|
|
||||||
</span>
|
|
||||||
<div class="timeline-snippet" v-if="item.tag.description">
|
|
||||||
{{ item.tag.description }}
|
|
||||||
</div>
|
|
||||||
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
</BaseTimeline>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="selectedTab === 'following'" class="follow-container">
|
<div v-else-if="selectedTab === 'following'" class="follow-container">
|
||||||
@@ -324,6 +348,15 @@ const hotPosts = ref([])
|
|||||||
const hotReplies = ref([])
|
const hotReplies = ref([])
|
||||||
const hotTags = ref([])
|
const hotTags = ref([])
|
||||||
const timelineItems = ref([])
|
const timelineItems = ref([])
|
||||||
|
const timelineFilter = ref('all')
|
||||||
|
const filteredTimelineItems = computed(() => {
|
||||||
|
if (timelineFilter.value === 'articles') {
|
||||||
|
return timelineItems.value.filter((item) => item.type === 'post')
|
||||||
|
} else if (timelineFilter.value === 'comments') {
|
||||||
|
return timelineItems.value.filter((item) => item.type === 'comment' || item.type === 'reply')
|
||||||
|
}
|
||||||
|
return timelineItems.value
|
||||||
|
})
|
||||||
const followers = ref([])
|
const followers = ref([])
|
||||||
const followings = ref([])
|
const followings = ref([])
|
||||||
const medals = ref([])
|
const medals = ref([])
|
||||||
@@ -654,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;
|
||||||
@@ -700,6 +727,7 @@ watch(selectedTab, async (val) => {
|
|||||||
border-bottom: 1px solid var(--normal-border-color);
|
border-bottom: 1px solid var(--normal-border-color);
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
backdrop-filter: var(--blur-10);
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-tabs-item {
|
.profile-tabs-item {
|
||||||
@@ -777,8 +805,24 @@ watch(selectedTab, async (val) => {
|
|||||||
width: 40%;
|
width: 40%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-timeline {
|
.timeline-tabs {
|
||||||
padding: 20px;
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
border-bottom: 1px solid var(--normal-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-list {
|
||||||
|
padding: 10px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-tab-item {
|
||||||
|
padding: 10px 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-tab-item.selected {
|
||||||
|
color: var(--primary-color);
|
||||||
|
border-bottom: 2px solid var(--primary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-date {
|
.timeline-date {
|
||||||
|
|||||||
6
frontend_nuxt/plugins/frosted.client.ts
Normal file
6
frontend_nuxt/plugins/frosted.client.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { defineNuxtPlugin } from 'nuxt/app'
|
||||||
|
import { initFrosted } from '~/utils/frosted'
|
||||||
|
|
||||||
|
export default defineNuxtPlugin(() => {
|
||||||
|
initFrosted()
|
||||||
|
})
|
||||||
26
frontend_nuxt/utils/frosted.js
Normal file
26
frontend_nuxt/utils/frosted.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { reactive } from 'vue'
|
||||||
|
|
||||||
|
const FROSTED_KEY = 'frosted-glass'
|
||||||
|
|
||||||
|
export const frostedState = reactive({
|
||||||
|
enabled: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
function apply() {
|
||||||
|
if (!import.meta.client) return
|
||||||
|
document.documentElement.dataset.frosted = frostedState.enabled ? 'on' : 'off'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initFrosted() {
|
||||||
|
if (!import.meta.client) return
|
||||||
|
const saved = localStorage.getItem(FROSTED_KEY)
|
||||||
|
frostedState.enabled = saved !== 'false'
|
||||||
|
apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setFrosted(enabled) {
|
||||||
|
if (!import.meta.client) return
|
||||||
|
frostedState.enabled = enabled
|
||||||
|
localStorage.setItem(FROSTED_KEY, enabled ? 'true' : 'false')
|
||||||
|
apply()
|
||||||
|
}
|
||||||
@@ -143,7 +143,7 @@ function fallbackThemeTransition(applyFn) {
|
|||||||
background-color: ${currentBg};
|
background-color: ${currentBg};
|
||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
backdrop-filter: blur(1px);
|
backdrop-filter: var(--blur-1);
|
||||||
`
|
`
|
||||||
document.body.appendChild(transitionElement)
|
document.body.appendChild(transitionElement)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user