Compare commits

...

15 Commits

Author SHA1 Message Date
Tim
7bd1225b27 feat: add point mall module 2025-08-17 01:23:47 +08:00
Tim
2dd56e27af Merge pull request #599 from nagisa77/feature/daily_bugfix_0816
Feature/daily bugfix 0816
2025-08-17 01:13:39 +08:00
tim
c3ecef3609 feat: tooltip修改 2025-08-17 01:06:21 +08:00
Tim
efc74d0f77 Merge pull request #601 from nagisa77/codex/save-user-tab-selection-in-localstorage-4dcpd4
feat: remember home tab selection
2025-08-16 18:13:49 +08:00
Tim
f27cb5c703 feat: remember home tab selection 2025-08-16 18:13:37 +08:00
tim
a756c2fab3 feat: add 毛玻璃效果 + 开关 2025-08-16 18:11:56 +08:00
Tim
4e2171a8a6 Merge pull request #600 from nagisa77/codex/add-switch-for-frosted-glass-effect
Add frosted glass effect toggle
2025-08-16 17:58:01 +08:00
Tim
bcbdff8768 feat: initialize frosted glass setting 2025-08-16 17:57:42 +08:00
Tim
b976a1f46f Merge pull request #598 from nagisa77/codex/add-sub-tabs-to-personal-homepage-timeline
feat: add timeline filters on profile page
2025-08-16 16:21:57 +08:00
Tim
b9fd9711de feat: add timeline filters on profile page 2025-08-16 16:21:45 +08:00
tim
642a527dcf Revert "feat: persist home tab selection"
This reverts commit 2c5462cd97.
2025-08-16 16:20:52 +08:00
Tim
88afcc5a8e Merge pull request #597 from nagisa77/codex/save-user-tab-selection-in-localstorage-9dskt8
feat: persist home tab selection
2025-08-16 16:19:58 +08:00
Tim
2c5462cd97 feat: persist home tab selection 2025-08-16 16:19:44 +08:00
tim
2f29946b11 Revert "feat: remember selected tab"
This reverts commit 2322b2da15.
2025-08-16 16:19:23 +08:00
Tim
e27aa34cfd Merge pull request #596 from nagisa77/codex/save-user-tab-selection-in-localstorage
feat: persist home tab selection
2025-08-16 16:10:49 +08:00
16 changed files with 710 additions and 416 deletions

View File

@@ -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;
} }

View File

@@ -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;

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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;
} }

View File

@@ -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;

View File

@@ -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>

View 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>

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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;
} }

View File

@@ -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 {

View File

@@ -0,0 +1,6 @@
import { defineNuxtPlugin } from 'nuxt/app'
import { initFrosted } from '~/utils/frosted'
export default defineNuxtPlugin(() => {
initFrosted()
})

View 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()
}

View File

@@ -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)