mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-06-18 03:34:25 +00:00
Merge remote-tracking branch 'origin/main' into feat_category_proposal
This commit is contained in:
@@ -119,7 +119,7 @@ export default {
|
||||
|
||||
.cropper-btn {
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
border-radius: 10px;
|
||||
color: var(--primary-color);
|
||||
border: none;
|
||||
background: transparent;
|
||||
@@ -128,7 +128,7 @@ export default {
|
||||
|
||||
.cropper-btn.primary {
|
||||
background: var(--primary-color);
|
||||
color: var(--text-color);
|
||||
color: #ffff;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import { computed, ref } from 'vue'
|
||||
import { useAttrs } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
src: { type: String, required: true },
|
||||
src: { type: String, default: '' },
|
||||
alt: { type: String, default: '' },
|
||||
})
|
||||
|
||||
@@ -39,9 +39,6 @@ const placeholder = computed(() => {
|
||||
function onLoad() {
|
||||
loaded.value = true
|
||||
}
|
||||
function onError() {
|
||||
loaded.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
<template>
|
||||
<div
|
||||
ref="groupRef"
|
||||
class="base-item-group"
|
||||
:class="groupClass"
|
||||
:style="groupStyle"
|
||||
@mouseenter="onMouseEnter"
|
||||
@mouseleave="onMouseLeave"
|
||||
@focusin="onFocusIn"
|
||||
@focusout="onFocusOut"
|
||||
>
|
||||
<div
|
||||
v-for="(item, index) in normalizedItems"
|
||||
:key="resolveKey(item, index)"
|
||||
class="base-item-group-item"
|
||||
:style="{ zIndex: getZIndex(index) }"
|
||||
>
|
||||
<slot name="item" :item="item" :index="index"></slot>
|
||||
</div>
|
||||
<slot name="after"></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
itemKey: {
|
||||
type: [String, Function],
|
||||
default: null,
|
||||
},
|
||||
overlap: {
|
||||
type: [Number, String],
|
||||
default: 12,
|
||||
},
|
||||
expandedGap: {
|
||||
type: [Number, String],
|
||||
default: 8,
|
||||
},
|
||||
direction: {
|
||||
type: String,
|
||||
default: 'horizontal',
|
||||
validator: (value) => ['horizontal', 'vertical'].includes(value),
|
||||
},
|
||||
reverse: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
animationDuration: {
|
||||
type: [Number, String],
|
||||
default: 200,
|
||||
},
|
||||
})
|
||||
|
||||
const groupRef = ref(null)
|
||||
const state = reactive({
|
||||
hovering: false,
|
||||
focused: false,
|
||||
})
|
||||
|
||||
const normalizedItems = computed(() => props.items || [])
|
||||
|
||||
const sanitizedOverlap = computed(() => Math.max(0, Number(props.overlap) || 0))
|
||||
const sanitizedExpandedGap = computed(() => Math.max(0, Number(props.expandedGap) || 0))
|
||||
const sanitizedAnimationDuration = computed(() => Math.max(0, Number(props.animationDuration) || 0))
|
||||
|
||||
const groupClass = computed(() => [
|
||||
`base-item-group--${props.direction}`,
|
||||
{
|
||||
'is-expanded': isExpanded.value,
|
||||
'is-reversed': props.reverse,
|
||||
},
|
||||
])
|
||||
|
||||
const groupStyle = computed(() => ({
|
||||
'--base-item-group-overlap': `${sanitizedOverlap.value}px`,
|
||||
'--base-item-group-expanded-gap': `${sanitizedExpandedGap.value}px`,
|
||||
'--base-item-group-transition-duration': `${sanitizedAnimationDuration.value}ms`,
|
||||
}))
|
||||
|
||||
const isExpanded = computed(() => state.hovering || state.focused)
|
||||
|
||||
function onMouseEnter() {
|
||||
state.hovering = true
|
||||
}
|
||||
|
||||
function onMouseLeave() {
|
||||
state.hovering = false
|
||||
}
|
||||
|
||||
function onFocusIn() {
|
||||
state.focused = true
|
||||
}
|
||||
|
||||
function onFocusOut(event) {
|
||||
const nextTarget = event.relatedTarget
|
||||
if (!groupRef.value) {
|
||||
state.focused = false
|
||||
return
|
||||
}
|
||||
if (!nextTarget || !groupRef.value.contains(nextTarget)) {
|
||||
state.focused = false
|
||||
}
|
||||
}
|
||||
|
||||
function resolveKey(item, index) {
|
||||
if (typeof props.itemKey === 'function') {
|
||||
return props.itemKey(item, index)
|
||||
}
|
||||
if (props.itemKey && item && Object.prototype.hasOwnProperty.call(item, props.itemKey)) {
|
||||
return item[props.itemKey]
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
function getZIndex(index) {
|
||||
if (props.reverse) {
|
||||
return index + 1
|
||||
}
|
||||
return normalizedItems.value.length - index
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.base-item-group {
|
||||
--base-item-group-overlap: 12px;
|
||||
--base-item-group-expanded-gap: 8px;
|
||||
--base-item-group-transition-duration: 200ms;
|
||||
display: inline-flex;
|
||||
position: relative;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.base-item-group:focus-within {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.base-item-group--horizontal {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.base-item-group--horizontal.is-reversed {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.base-item-group--vertical {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.base-item-group--vertical.is-reversed {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.base-item-group-item {
|
||||
transition:
|
||||
margin var(--base-item-group-transition-duration) ease,
|
||||
transform var(--base-item-group-transition-duration) ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.base-item-group--horizontal:not(.is-expanded) .base-item-group-item:not(:first-child) {
|
||||
margin-left: calc(var(--base-item-group-overlap) * -1);
|
||||
}
|
||||
|
||||
.base-item-group--horizontal.is-expanded .base-item-group-item:not(:first-child) {
|
||||
margin-left: var(--base-item-group-expanded-gap);
|
||||
}
|
||||
|
||||
.base-item-group--vertical:not(.is-expanded) .base-item-group-item:not(:first-child) {
|
||||
margin-top: calc(var(--base-item-group-overlap) * -1);
|
||||
}
|
||||
|
||||
.base-item-group--vertical.is-expanded .base-item-group-item:not(:first-child) {
|
||||
margin-top: var(--base-item-group-expanded-gap);
|
||||
}
|
||||
|
||||
.base-item-group.is-expanded .base-item-group-item {
|
||||
transform: translateZ(0);
|
||||
}
|
||||
</style>
|
||||
@@ -1,22 +1,20 @@
|
||||
<template>
|
||||
<NuxtLink
|
||||
:to="resolvedLink"
|
||||
<div
|
||||
class="base-user-avatar"
|
||||
:class="wrapperClass"
|
||||
:style="wrapperStyle"
|
||||
v-bind="wrapperAttrs"
|
||||
@click="handleClick"
|
||||
>
|
||||
<BaseImage :src="currentSrc" :alt="altText" class="base-user-avatar-img" @error="onError" />
|
||||
</NuxtLink>
|
||||
<BaseImage :src="props.src" :alt="altText" class="base-user-avatar-img" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, watch } from 'vue'
|
||||
import { useAttrs } from 'vue'
|
||||
import BaseImage from './BaseImage.vue'
|
||||
|
||||
const DEFAULT_AVATAR = '/default-avatar.svg'
|
||||
|
||||
const props = defineProps({
|
||||
userId: {
|
||||
type: [String, Number],
|
||||
@@ -50,15 +48,6 @@ const props = defineProps({
|
||||
|
||||
const attrs = useAttrs()
|
||||
|
||||
const currentSrc = ref(props.src || DEFAULT_AVATAR)
|
||||
|
||||
watch(
|
||||
() => props.src,
|
||||
(value) => {
|
||||
currentSrc.value = value || DEFAULT_AVATAR
|
||||
},
|
||||
)
|
||||
|
||||
const resolvedLink = computed(() => {
|
||||
if (props.to) return props.to
|
||||
if (props.userId !== null && props.userId !== undefined && props.userId !== '') {
|
||||
@@ -70,10 +59,16 @@ const resolvedLink = computed(() => {
|
||||
const altText = computed(() => props.alt || '用户头像')
|
||||
|
||||
const sizeStyle = computed(() => {
|
||||
if (!props.width && props.width !== 0) return null
|
||||
const value = typeof props.width === 'number' ? `${props.width}px` : props.width
|
||||
if (!value) return null
|
||||
return { width: value, height: value }
|
||||
var style = {}
|
||||
|
||||
if (props.width > 0) {
|
||||
style.width = `${props.width}px`
|
||||
}
|
||||
if (props.height > 0) {
|
||||
style.height = `${props.height}px`
|
||||
}
|
||||
|
||||
return style
|
||||
})
|
||||
|
||||
const wrapperStyle = computed(() => {
|
||||
@@ -88,10 +83,9 @@ const wrapperAttrs = computed(() => {
|
||||
return rest
|
||||
})
|
||||
|
||||
function onError() {
|
||||
if (currentSrc.value !== DEFAULT_AVATAR) {
|
||||
currentSrc.value = DEFAULT_AVATAR
|
||||
}
|
||||
const handleClick = () => {
|
||||
if (props.disableLink) return
|
||||
navigateTo(resolvedLink.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -109,7 +103,7 @@ function onError() {
|
||||
}
|
||||
|
||||
.base-user-avatar:hover {
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);
|
||||
box-shadow: 0 4px 24px rgba(251, 138, 138, 0.1);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
|
||||
@@ -53,14 +53,29 @@
|
||||
@click="handleContentClick"
|
||||
></div>
|
||||
<div class="article-footer-container">
|
||||
<ReactionsGroup v-model="comment.reactions" content-type="comment" :content-id="comment.id">
|
||||
<div class="make-reaction-item comment-reaction" @click="toggleEditor">
|
||||
<ReactionsGroup
|
||||
ref="commentReactionsGroupRef"
|
||||
v-model="comment.reactions"
|
||||
content-type="comment"
|
||||
:content-id="comment.id"
|
||||
/>
|
||||
<div class="comment-reaction-actions">
|
||||
<div
|
||||
class="reaction-action like-action"
|
||||
:class="{ selected: commentLikedByMe }"
|
||||
@click="toggleCommentLike"
|
||||
>
|
||||
<like v-if="!commentLikedByMe" />
|
||||
<like v-else theme="filled" />
|
||||
<span v-if="commentLikeCount" class="reaction-count">{{ commentLikeCount }}</span>
|
||||
</div>
|
||||
<div class="reaction-action comment-reaction" @click="toggleEditor">
|
||||
<comment-icon />
|
||||
</div>
|
||||
<div class="make-reaction-item copy-link" @click="copyCommentLink">
|
||||
<div class="reaction-action copy-link" @click="copyCommentLink">
|
||||
<link-icon />
|
||||
</div>
|
||||
</ReactionsGroup>
|
||||
</div>
|
||||
</div>
|
||||
<div class="comment-editor-wrapper" ref="editorWrapper">
|
||||
<CommentEditor
|
||||
@@ -156,6 +171,18 @@ const lightboxVisible = ref(false)
|
||||
const lightboxIndex = ref(0)
|
||||
const lightboxImgs = ref([])
|
||||
const loggedIn = computed(() => authState.loggedIn)
|
||||
const commentReactionsGroupRef = ref(null)
|
||||
const commentLikeCount = computed(
|
||||
() => (props.comment.reactions || []).filter((reaction) => reaction.type === 'LIKE').length,
|
||||
)
|
||||
const commentLikedByMe = computed(() =>
|
||||
(props.comment.reactions || []).some(
|
||||
(reaction) => reaction.type === 'LIKE' && reaction.user === authState.username,
|
||||
),
|
||||
)
|
||||
const toggleCommentLike = () => {
|
||||
commentReactionsGroupRef.value?.toggleReaction('LIKE')
|
||||
}
|
||||
const countReplies = (list) => list.reduce((sum, r) => sum + 1 + countReplies(r.reply || []), 0)
|
||||
const replyCount = computed(() => countReplies(props.comment.reply || []))
|
||||
const isCommentFromPostAuthor = computed(() => {
|
||||
@@ -365,6 +392,47 @@ const handleContentClick = (e) => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.comment-reaction-actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.reaction-action {
|
||||
cursor: pointer;
|
||||
padding: 4px 10px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
opacity: 0.6;
|
||||
font-size: 18px;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.reaction-action:hover {
|
||||
opacity: 1;
|
||||
background-color: var(--normal-light-background-color);
|
||||
}
|
||||
|
||||
.reaction-action.like-action {
|
||||
color: #ff0000;
|
||||
}
|
||||
|
||||
.reaction-action.selected {
|
||||
opacity: 1;
|
||||
background-color: var(--normal-light-background-color);
|
||||
}
|
||||
|
||||
.reaction-count {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.reply-toggle {
|
||||
cursor: pointer;
|
||||
color: var(--primary-color);
|
||||
@@ -378,10 +446,6 @@ const handleContentClick = (e) => {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.comment-reaction:hover {
|
||||
background-color: lightgray;
|
||||
}
|
||||
|
||||
.comment-highlight {
|
||||
animation: highlight 2s;
|
||||
}
|
||||
@@ -424,6 +488,16 @@ const handleContentClick = (e) => {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.article-footer-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-top: 0px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.medal-name {
|
||||
font-size: 12px;
|
||||
margin-left: 1px;
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
<template>
|
||||
<div class="donate-container">
|
||||
<ToolTip content="打赏作者" placement="bottom" v-if="donationList.length > 0">
|
||||
<div class="donate-viewer" @click="openPanel">
|
||||
<div
|
||||
class="donate-viewer-item-container"
|
||||
@mouseenter="cancelHide"
|
||||
@mouseleave="scheduleHide"
|
||||
>
|
||||
<BaseItemGroup
|
||||
:items="donationList"
|
||||
:overlap="10"
|
||||
:expanded-gap="2"
|
||||
:direction="vertical"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<BaseUserAvatar
|
||||
:user-id="item.userId"
|
||||
:src="item.avatar"
|
||||
:alt="item.username"
|
||||
:width="20"
|
||||
:disable-link="true"
|
||||
/>
|
||||
</template>
|
||||
</BaseItemGroup>
|
||||
<div class="donate-counts-text">{{ totalAmount }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</ToolTip>
|
||||
<ToolTip content="赞赏作者" placement="bottom" v-else>
|
||||
<div class="donate-viewer-item placeholder" @click="openPanel">
|
||||
<financing class="donate-viewer-item-placeholder-icon" />
|
||||
</div>
|
||||
</ToolTip>
|
||||
<div
|
||||
v-if="panelVisible"
|
||||
class="donate-panel"
|
||||
ref="donatePanelRef"
|
||||
:style="panelInlineStyle"
|
||||
@mouseenter="cancelHide"
|
||||
@mouseleave="scheduleHide"
|
||||
>
|
||||
<div
|
||||
v-for="option in donateOptions"
|
||||
:key="option"
|
||||
class="donate-option"
|
||||
:class="{ disabled: donating || isAuthorUser || !authState.loggedIn }"
|
||||
@click="handleDonate(option)"
|
||||
>
|
||||
<financing class="donate-option-icon" />
|
||||
<div class="donate-counts-text">{{ option }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Finance } from '@icon-park/vue-next'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { toast } from '~/main'
|
||||
import { authState, getToken } from '~/utils/auth'
|
||||
|
||||
const financing = Finance
|
||||
|
||||
const props = defineProps({
|
||||
postId: {
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
authorId: {
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
isAuthor: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
const panelVisible = ref(false)
|
||||
const donatePanelRef = ref(null)
|
||||
const panelInlineStyle = ref({})
|
||||
const donationSummary = ref({ totalAmount: 0, donations: [] })
|
||||
const donating = ref(false)
|
||||
let hideTimer = null
|
||||
|
||||
const donateOptions = [10, 30, 100]
|
||||
const donationList = computed(() => donationSummary.value?.donations ?? [])
|
||||
const totalAmount = computed(() => donationSummary.value?.totalAmount ?? 0)
|
||||
const isAuthorUser = computed(() => {
|
||||
if (props.isAuthor) return true
|
||||
if (!authState.userId || !props.authorId) return false
|
||||
return Number(authState.userId) === Number(props.authorId)
|
||||
})
|
||||
|
||||
const openPanel = () => {
|
||||
clearTimeout(hideTimer)
|
||||
panelVisible.value = true
|
||||
}
|
||||
|
||||
const scheduleHide = () => {
|
||||
clearTimeout(hideTimer)
|
||||
hideTimer = setTimeout(() => {
|
||||
panelVisible.value = false
|
||||
}, 500)
|
||||
}
|
||||
const cancelHide = () => {
|
||||
clearTimeout(hideTimer)
|
||||
}
|
||||
|
||||
const updatePanelInlineStyle = () => {
|
||||
if (!panelVisible.value) return
|
||||
const panelEl = donatePanelRef.value
|
||||
if (!panelEl) return
|
||||
const parentEl = panelEl.closest('.donate-container')?.parentElement.parentElement
|
||||
if (!parentEl) return
|
||||
const parentWidth = parentEl.clientWidth - 20
|
||||
panelInlineStyle.value = {
|
||||
width: 'max-content',
|
||||
maxWidth: `${parentWidth}px`,
|
||||
}
|
||||
}
|
||||
|
||||
watch(panelVisible, async (visible) => {
|
||||
if (visible) {
|
||||
await nextTick()
|
||||
updatePanelInlineStyle()
|
||||
}
|
||||
})
|
||||
|
||||
const normalizeSummary = (data) => ({
|
||||
totalAmount: data?.totalAmount ?? 0,
|
||||
donations: Array.isArray(data?.donations) ? data.donations : [],
|
||||
})
|
||||
|
||||
const loadDonations = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/posts/${props.postId}/donations`)
|
||||
if (!res.ok) return
|
||||
const data = await res.json()
|
||||
donationSummary.value = normalizeSummary(data)
|
||||
} catch (e) {
|
||||
// ignore network errors for donation summary
|
||||
}
|
||||
}
|
||||
|
||||
const handleDonate = async (amount) => {
|
||||
if (!amount || donating.value) return
|
||||
if (!authState.loggedIn) {
|
||||
toast.error('请先登录后再打赏')
|
||||
panelVisible.value = false
|
||||
return
|
||||
}
|
||||
if (isAuthorUser.value) {
|
||||
toast.warning('不能给自己打赏')
|
||||
return
|
||||
}
|
||||
try {
|
||||
donating.value = true
|
||||
const token = getToken()
|
||||
const res = await fetch(`${API_BASE_URL}/api/posts/${props.postId}/donations`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: token ? `Bearer ${token}` : '',
|
||||
},
|
||||
body: JSON.stringify({ amount }),
|
||||
})
|
||||
const data = await res.json().catch(() => null)
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
toast.error('请先登录后再打赏')
|
||||
} else {
|
||||
toast.error(data?.error || '打赏失败')
|
||||
}
|
||||
return
|
||||
}
|
||||
donationSummary.value = normalizeSummary(data)
|
||||
toast.success('打赏成功,感谢你的支持!')
|
||||
panelVisible.value = false
|
||||
} catch (e) {
|
||||
toast.error('打赏失败,请稍后再试')
|
||||
} finally {
|
||||
donating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
window.addEventListener('resize', updatePanelInlineStyle)
|
||||
await loadDonations()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', updatePanelInlineStyle)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.postId,
|
||||
async () => {
|
||||
donationSummary.value = { totalAmount: 0, donations: [] }
|
||||
await loadDonations()
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.donate-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.donate-viewer-item-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.donate-viewer {
|
||||
border-radius: 13px;
|
||||
padding: 3px;
|
||||
padding-right: 6px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.5s ease;
|
||||
}
|
||||
|
||||
.donate-viewer:hover {
|
||||
background-color: var(--secondary-color-hover);
|
||||
}
|
||||
|
||||
.donate-counts-text {
|
||||
color: var(--primary-color);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.donate-panel {
|
||||
position: absolute;
|
||||
bottom: 35px;
|
||||
background-color: var(--background-color);
|
||||
border: 1px solid var(--normal-border-color);
|
||||
border-radius: 20px;
|
||||
padding: 5px 10px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
z-index: 10;
|
||||
gap: 5px;
|
||||
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.donate-viewer-item.placeholder {
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
flex-direction: row;
|
||||
padding: 2px 10px;
|
||||
gap: 5px;
|
||||
border: 1px solid var(--normal-border-color);
|
||||
border-radius: 10px;
|
||||
margin-right: 5px;
|
||||
font-size: 14px;
|
||||
color: var(--text-color);
|
||||
align-items: center;
|
||||
background-color: var(--normal-light-background-color);
|
||||
}
|
||||
|
||||
.donate-viewer-item {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.donate-viewer-item-placeholder-icon {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.donate-option {
|
||||
cursor: pointer;
|
||||
padding: 3px 6px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.donate-option:hover {
|
||||
background-color: var(--normal-light-background-color);
|
||||
}
|
||||
|
||||
.donate-option.disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.donate-option.disabled:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.donate-option-icon {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.donate-viewer-item.placeholder {
|
||||
padding: 4px 8px;
|
||||
gap: 3px;
|
||||
border: 1px solid var(--normal-border-color);
|
||||
border-radius: 10px;
|
||||
margin-right: 3px;
|
||||
margin-bottom: 3px;
|
||||
font-size: 12px;
|
||||
color: var(--text-color);
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -49,7 +49,11 @@
|
||||
</slot>
|
||||
</div>
|
||||
<div
|
||||
v-if="open && !isMobile && (loading || filteredOptions.length > 0 || showSearch)"
|
||||
v-if="
|
||||
open &&
|
||||
!isMobile &&
|
||||
(loading || filteredOptions.length > 0 || showSearch || (remote && search))
|
||||
"
|
||||
:class="['dropdown-menu', menuClass]"
|
||||
v-click-outside="close"
|
||||
ref="menuRef"
|
||||
@@ -62,26 +66,29 @@
|
||||
<l-hatch size="20" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="o in filteredOptions"
|
||||
:key="o.id"
|
||||
@click="select(o.id)"
|
||||
:class="['dropdown-option', optionClass, { selected: isSelected(o.id) }]"
|
||||
>
|
||||
<slot name="option" :option="o" :isSelected="isSelected(o.id)">
|
||||
<template v-if="o.icon">
|
||||
<BaseImage
|
||||
v-if="isImageIcon(o.icon)"
|
||||
:src="o.icon"
|
||||
class="option-icon"
|
||||
:alt="o.name"
|
||||
/>
|
||||
<component v-else :is="o.icon" class="option-icon" :size="16" />
|
||||
</template>
|
||||
<span>{{ o.name }}</span>
|
||||
</slot>
|
||||
</div>
|
||||
<slot name="footer" :close="close" :loading="loading" />
|
||||
<div v-if="filteredOptions.length === 0" class="dropdown-empty">没有搜索结果</div>
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="o in filteredOptions"
|
||||
:key="o.id"
|
||||
@click="select(o.id)"
|
||||
:class="['dropdown-option', optionClass, { selected: isSelected(o.id) }]"
|
||||
>
|
||||
<slot name="option" :option="o" :isSelected="isSelected(o.id)">
|
||||
<template v-if="o.icon">
|
||||
<BaseImage
|
||||
v-if="isImageIcon(o.icon)"
|
||||
:src="o.icon"
|
||||
class="option-icon"
|
||||
:alt="o.name"
|
||||
/>
|
||||
<component v-else :is="o.icon" class="option-icon" :size="16" />
|
||||
</template>
|
||||
<span>{{ o.name }}</span>
|
||||
</slot>
|
||||
</div>
|
||||
<slot name="footer" :close="close" :loading="loading" />
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
<Teleport to="body">
|
||||
@@ -99,26 +106,29 @@
|
||||
<l-hatch size="20" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="o in filteredOptions"
|
||||
:key="o.id"
|
||||
@click="select(o.id)"
|
||||
:class="['dropdown-option', optionClass, { selected: isSelected(o.id) }]"
|
||||
>
|
||||
<slot name="option" :option="o" :isSelected="isSelected(o.id)">
|
||||
<template v-if="o.icon">
|
||||
<BaseImage
|
||||
v-if="isImageIcon(o.icon)"
|
||||
:src="o.icon"
|
||||
class="option-icon"
|
||||
:alt="o.name"
|
||||
/>
|
||||
<component v-else :is="o.icon" class="option-icon" :size="16" />
|
||||
</template>
|
||||
<span>{{ o.name }}</span>
|
||||
</slot>
|
||||
</div>
|
||||
<slot name="footer" :close="close" :loading="loading" />
|
||||
<div v-if="filteredOptions.length === 0" class="dropdown-empty">没有搜索结果</div>
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="o in filteredOptions"
|
||||
:key="o.id"
|
||||
@click="select(o.id)"
|
||||
:class="['dropdown-option', optionClass, { selected: isSelected(o.id) }]"
|
||||
>
|
||||
<slot name="option" :option="o" :isSelected="isSelected(o.id)">
|
||||
<template v-if="o.icon">
|
||||
<BaseImage
|
||||
v-if="isImageIcon(o.icon)"
|
||||
:src="o.icon"
|
||||
class="option-icon"
|
||||
:alt="o.name"
|
||||
/>
|
||||
<component v-else :is="o.icon" class="option-icon" :size="16" />
|
||||
</template>
|
||||
<span>{{ o.name }}</span>
|
||||
</slot>
|
||||
</div>
|
||||
<slot name="footer" :close="close" :loading="loading" />
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -158,9 +168,19 @@ export default {
|
||||
const mobileMenuRef = ref(null)
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const openMenu = () => {
|
||||
if (!open.value) {
|
||||
open.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const toggle = () => {
|
||||
open.value = !open.value
|
||||
if (!open.value) emit('close')
|
||||
if (open.value) {
|
||||
open.value = false
|
||||
emit('close')
|
||||
} else {
|
||||
open.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
@@ -265,7 +285,7 @@ export default {
|
||||
return /^https?:\/\//.test(icon) || icon.startsWith('/')
|
||||
}
|
||||
|
||||
expose({ toggle, close, reload, scrollToBottom })
|
||||
expose({ toggle, close, reload, scrollToBottom, openMenu })
|
||||
|
||||
return {
|
||||
open,
|
||||
@@ -283,6 +303,7 @@ export default {
|
||||
isImageIcon,
|
||||
setSearch,
|
||||
isMobile,
|
||||
remote: props.remote,
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -297,7 +318,6 @@ export default {
|
||||
border: 1px solid var(--normal-border-color);
|
||||
border-radius: 5px;
|
||||
padding: 5px 10px;
|
||||
margin-bottom: 4px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -320,6 +340,7 @@ export default {
|
||||
z-index: 10000;
|
||||
max-height: 300px;
|
||||
min-width: 350px;
|
||||
margin-top: 4px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@@ -384,6 +405,13 @@ export default {
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.dropdown-empty {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: var(--muted-text-color, #8c8c8c);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.dropdown-mobile-page {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
|
||||
@@ -26,40 +26,63 @@
|
||||
|
||||
<ClientOnly>
|
||||
<div class="header-content-right">
|
||||
<div v-if="isMobile" class="search-icon" @click="search">
|
||||
<search-icon />
|
||||
</div>
|
||||
|
||||
<div v-if="isMobile" class="theme-icon" @click="cycleTheme">
|
||||
<component :is="iconClass" />
|
||||
</div>
|
||||
|
||||
<div v-if="!isMobile" class="invite_text" @click="copyInviteLink">
|
||||
<copy />
|
||||
邀请
|
||||
<loading v-if="isCopying" />
|
||||
</div>
|
||||
<SearchDropdown
|
||||
ref="searchDropdown"
|
||||
v-if="!isMobile || showSearch"
|
||||
@close="closeSearch"
|
||||
/>
|
||||
<!-- 搜索 -->
|
||||
<ToolTip v-if="isMobile" content="搜索" placement="bottom">
|
||||
<div class="header-icon-item" @click="search">
|
||||
<search-icon class="header-icon" />
|
||||
<span class="header-label">搜索</span>
|
||||
</div>
|
||||
</ToolTip>
|
||||
<!-- 主题切换 -->
|
||||
<ToolTip v-if="isMobile" content="切换主题" placement="bottom">
|
||||
<div class="header-icon-item" @click="cycleTheme">
|
||||
<component :is="iconClass" class="header-icon" />
|
||||
<span class="header-label">主题</span>
|
||||
</div>
|
||||
</ToolTip>
|
||||
<!-- 邀请 -->
|
||||
<ToolTip v-if="!isMobile" content="邀请好友" placement="bottom">
|
||||
<div class="header-icon-item" @click="copyInviteLink">
|
||||
<template v-if="!isCopying">
|
||||
<copy-link class="header-icon" />
|
||||
<span class="header-label">邀请</span>
|
||||
</template>
|
||||
<loading v-else />
|
||||
</div>
|
||||
</ToolTip>
|
||||
<!-- 在线人数 -->
|
||||
<ToolTip v-if="!isMobile" content="当前在线人数" placement="bottom">
|
||||
<div class="online-count">
|
||||
<peoples-two />
|
||||
<span>{{ onlineCount }}</span>
|
||||
<div class="header-icon-item">
|
||||
<peoples-two class="header-icon" />
|
||||
<span class="header-label">在线</span>
|
||||
<span class="header-badge">{{ onlineCount }}</span>
|
||||
</div>
|
||||
</ToolTip>
|
||||
<!-- RSS -->
|
||||
<ToolTip content="复制RSS链接" placement="bottom">
|
||||
<div class="rss-icon" @click="copyRssLink">
|
||||
<rss />
|
||||
<div class="header-icon-item" @click="copyRssLink">
|
||||
<rss class="header-icon" />
|
||||
<span class="header-label">RSS</span>
|
||||
</div>
|
||||
</ToolTip>
|
||||
|
||||
<!-- 发帖 -->
|
||||
<ToolTip v-if="!isMobile && isLogin" content="发帖" placement="bottom">
|
||||
<div class="new-post-icon" @click="goToNewPost">
|
||||
<edit />
|
||||
<div class="header-icon-item" @click="goToNewPost">
|
||||
<edit class="header-icon" />
|
||||
<span class="header-label">发帖</span>
|
||||
</div>
|
||||
</ToolTip>
|
||||
|
||||
<!-- 消息 -->
|
||||
<ToolTip v-if="isLogin" content="站内信和频道" placement="bottom">
|
||||
<div class="messages-icon" @click="goToMessages">
|
||||
<message-emoji />
|
||||
<div class="header-icon-item" @click="goToMessages">
|
||||
<message-emoji class="header-icon" />
|
||||
<span class="header-label">消息</span>
|
||||
<span v-if="unreadMessageCount > 0" class="unread-badge">{{
|
||||
unreadMessageCount
|
||||
}}</span>
|
||||
@@ -73,10 +96,9 @@
|
||||
<BaseUserAvatar
|
||||
class="avatar-img"
|
||||
:user-id="authState.userId"
|
||||
:src="avatar"
|
||||
alt="avatar"
|
||||
:width="32"
|
||||
:src="authState.avatar"
|
||||
:disable-link="true"
|
||||
:width="32"
|
||||
/>
|
||||
<down />
|
||||
</div>
|
||||
@@ -89,7 +111,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</ClientOnly>
|
||||
<SearchDropdown ref="searchDropdown" v-if="isMobile && showSearch" @close="closeSearch" />
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
@@ -101,7 +122,7 @@ import DropdownMenu from '~/components/DropdownMenu.vue'
|
||||
import ToolTip from '~/components/ToolTip.vue'
|
||||
import SearchDropdown from '~/components/SearchDropdown.vue'
|
||||
import BaseUserAvatar from '~/components/BaseUserAvatar.vue'
|
||||
import { authState, clearToken, loadCurrentUser } from '~/utils/auth'
|
||||
import { authState, clearToken } from '~/utils/auth'
|
||||
import { useUnreadCount } from '~/composables/useUnreadCount'
|
||||
import { useChannelsUnreadCount } from '~/composables/useChannelsUnreadCount'
|
||||
import { useIsMobile } from '~/utils/screen'
|
||||
@@ -123,13 +144,11 @@ const isLogin = computed(() => authState.loggedIn)
|
||||
const isMobile = useIsMobile()
|
||||
const { count: unreadMessageCount, fetchUnreadCount } = useUnreadCount()
|
||||
const { hasUnread: hasChannelUnread, fetchChannelUnread } = useChannelsUnreadCount()
|
||||
const avatar = ref('')
|
||||
const showSearch = ref(false)
|
||||
const searchDropdown = ref(null)
|
||||
const userMenu = ref(null)
|
||||
const menuBtn = ref(null)
|
||||
const isCopying = ref(false)
|
||||
|
||||
const onlineCount = ref(0)
|
||||
|
||||
// 心跳检测
|
||||
@@ -192,6 +211,7 @@ const copyInviteLink = async () => {
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
toast.error('请先登录')
|
||||
isCopying.value = false // 🔥 修复:未登录时立即复原状态
|
||||
return
|
||||
}
|
||||
try {
|
||||
@@ -235,17 +255,7 @@ const copyRssLink = async () => {
|
||||
}
|
||||
|
||||
const goToProfile = async () => {
|
||||
if (!authState.loggedIn) {
|
||||
navigateTo('/login', { replace: true })
|
||||
return
|
||||
}
|
||||
let id = authState.username || authState.userId
|
||||
if (!id) {
|
||||
const user = await loadCurrentUser()
|
||||
if (user) {
|
||||
id = user.username || user.id
|
||||
}
|
||||
}
|
||||
let id = authState.username || authState.id
|
||||
if (id) {
|
||||
navigateTo(`/users/${id}`, { replace: true })
|
||||
}
|
||||
@@ -289,14 +299,6 @@ const iconClass = computed(() => {
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const updateAvatar = async () => {
|
||||
if (authState.loggedIn) {
|
||||
const user = await loadCurrentUser()
|
||||
if (user && user.avatar) {
|
||||
avatar.value = user.avatar
|
||||
}
|
||||
}
|
||||
}
|
||||
const updateUnread = async () => {
|
||||
if (authState.loggedIn) {
|
||||
fetchUnreadCount()
|
||||
@@ -306,17 +308,8 @@ onMounted(async () => {
|
||||
}
|
||||
}
|
||||
|
||||
await updateAvatar()
|
||||
await updateUnread()
|
||||
|
||||
watch(
|
||||
() => authState.loggedIn,
|
||||
async (isLoggedIn) => {
|
||||
await updateAvatar()
|
||||
await updateUnread()
|
||||
},
|
||||
)
|
||||
|
||||
// 新增的在线人数逻辑
|
||||
sendPing()
|
||||
fetchCount()
|
||||
@@ -333,7 +326,7 @@ onMounted(async () => {
|
||||
height: var(--header-height);
|
||||
background-color: var(--background-color-blur);
|
||||
backdrop-filter: var(--blur-10);
|
||||
color: var(--header-text-color);
|
||||
color: var(--primary-color);
|
||||
border-bottom: 1px solid var(--header-border-color);
|
||||
}
|
||||
|
||||
@@ -376,6 +369,7 @@ onMounted(async () => {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding-right: 15px;
|
||||
}
|
||||
|
||||
.micon {
|
||||
@@ -464,16 +458,13 @@ onMounted(async () => {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.invite_text {
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.invite_text:hover {
|
||||
opacity: 0.8;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.invite_text,
|
||||
.online-count,
|
||||
.rss-icon,
|
||||
.new-post-icon,
|
||||
.messages-icon {
|
||||
@@ -484,8 +475,8 @@ onMounted(async () => {
|
||||
|
||||
.unread-badge {
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
right: -10px;
|
||||
top: -4px;
|
||||
right: -6px;
|
||||
background-color: #ff4d4f;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
@@ -500,8 +491,8 @@ onMounted(async () => {
|
||||
|
||||
.unread-dot {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
right: -4px;
|
||||
top: 0;
|
||||
right: -1px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
@@ -513,14 +504,60 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.online-count {
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: var(--primary-color);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* === 统一图标按钮风格 === */
|
||||
.header-icon-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
font-size: 14px;
|
||||
color: var(--primary-color);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition:
|
||||
color 0.25s ease,
|
||||
transform 0.15s ease,
|
||||
opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.header-icon-item:hover {
|
||||
opacity: 0.8;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* 点击时瞬间高亮 + 轻微缩放 */
|
||||
.header-icon-item:active {
|
||||
color: var(--primary-color-hover);
|
||||
transform: scale(0.92);
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.header-label {
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* 在线人数的数字文字样式(无背景) */
|
||||
.header-badge {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -6px;
|
||||
color: var(--primary-color); /* 🔹 使用主题主色 */
|
||||
background: none; /* 🔹 去掉背景 */
|
||||
font-size: 11px; /* 字体稍微大一点以便清晰 */
|
||||
font-weight: 600; /* 加一点权重让数字更醒目 */
|
||||
line-height: 1;
|
||||
padding: 0; /* 去掉内边距 */
|
||||
}
|
||||
|
||||
@keyframes rss-glow {
|
||||
0% {
|
||||
text-shadow: 0 0 0px var(--primary-color);
|
||||
@@ -556,5 +593,12 @@ onMounted(async () => {
|
||||
.header-content-right {
|
||||
gap: 15px;
|
||||
}
|
||||
/* 手机不显示文字 */
|
||||
.header-label {
|
||||
display: none;
|
||||
}
|
||||
.header-badge {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -45,6 +45,7 @@ export default {
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
margin-left: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mark-read-button:hover {
|
||||
@@ -53,6 +54,7 @@ export default {
|
||||
|
||||
.has-read-button {
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
|
||||
@@ -42,6 +42,9 @@
|
||||
<span v-else-if="log.type === 'LOTTERY_RESULT'" class="change-log-content"
|
||||
>系统已「精密计算」抽奖结果 (=゚ω゚)ノ</span
|
||||
>
|
||||
<span v-else-if="log.type === 'DONATE'" class="change-log-content"
|
||||
>为文章打赏了 {{ log.amount ?? 0 }} 积分</span
|
||||
>
|
||||
</div>
|
||||
<div class="change-log-time">{{ log.time }}</div>
|
||||
<div
|
||||
|
||||
@@ -18,9 +18,11 @@
|
||||
<div>{{ counts[r.type] }}</div>
|
||||
</div>
|
||||
|
||||
<div class="reactions-viewer-item placeholder" @click="openPanel">
|
||||
<sly-face-whit-smile class="reactions-viewer-item-placeholder-icon" />
|
||||
</div>
|
||||
<ToolTip content="发表心情" placement="bottom">
|
||||
<div class="reactions-viewer-item placeholder" @click="openPanel">
|
||||
<sly-face-whit-smile class="reactions-viewer-item-placeholder-icon" />
|
||||
</div>
|
||||
</ToolTip>
|
||||
</template>
|
||||
<template v-else-if="displayedReactions.length">
|
||||
<div
|
||||
@@ -35,21 +37,11 @@
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="make-reaction-container">
|
||||
<div
|
||||
v-if="props.contentType !== 'message'"
|
||||
class="make-reaction-item like-reaction"
|
||||
@click="toggleReaction('LIKE')"
|
||||
>
|
||||
<like v-if="!userReacted('LIKE')" />
|
||||
<like v-else theme="filled" />
|
||||
<span class="reactions-count" v-if="likeCount">{{ likeCount }}</span>
|
||||
</div>
|
||||
<slot></slot>
|
||||
</div>
|
||||
<div
|
||||
v-if="panelVisible"
|
||||
class="reactions-panel"
|
||||
ref="reactionsPanelRef"
|
||||
:style="panelInlineStyle"
|
||||
@mouseenter="cancelHide"
|
||||
@mouseleave="scheduleHide"
|
||||
>
|
||||
@@ -69,7 +61,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { toast } from '~/main'
|
||||
import { authState, getToken } from '~/utils/auth'
|
||||
import { reactionEmojiMap } from '~/utils/reactions'
|
||||
@@ -102,8 +94,6 @@ const counts = computed(() => {
|
||||
})
|
||||
|
||||
const totalCount = computed(() => Object.values(counts.value).reduce((a, b) => a + b, 0))
|
||||
const likeCount = computed(() => counts.value['LIKE'] || 0)
|
||||
|
||||
const userReacted = (type) =>
|
||||
reactions.value.some((r) => r.type === type && r.user === authState.username)
|
||||
|
||||
@@ -152,9 +142,11 @@ const displayedReactions = computed(() => {
|
||||
.map((type) => ({ type }))
|
||||
})
|
||||
|
||||
const panelTypes = computed(() => sortedReactionTypes.value.filter((t) => t !== 'LIKE'))
|
||||
const panelTypes = computed(() => sortedReactionTypes.value)
|
||||
|
||||
const panelVisible = ref(false)
|
||||
const reactionsPanelRef = ref(null)
|
||||
const panelInlineStyle = ref({})
|
||||
let hideTimer = null
|
||||
const openPanel = () => {
|
||||
clearTimeout(hideTimer)
|
||||
@@ -170,6 +162,33 @@ const cancelHide = () => {
|
||||
clearTimeout(hideTimer)
|
||||
}
|
||||
|
||||
const updatePanelInlineStyle = () => {
|
||||
if (!panelVisible.value) return
|
||||
const panelEl = reactionsPanelRef.value
|
||||
if (!panelEl) return
|
||||
const parentEl = panelEl.closest('.reactions-container')?.parentElement?.parentElement
|
||||
if (!parentEl) return
|
||||
const parentWidth = parentEl.clientWidth - 20
|
||||
panelInlineStyle.value = {
|
||||
width: 'max-content',
|
||||
maxWidth: `${parentWidth}px`,
|
||||
}
|
||||
}
|
||||
|
||||
watch(panelVisible, async (visible) => {
|
||||
if (visible) {
|
||||
await nextTick()
|
||||
updatePanelInlineStyle()
|
||||
}
|
||||
})
|
||||
|
||||
watch(panelTypes, async () => {
|
||||
if (panelVisible.value) {
|
||||
await nextTick()
|
||||
updatePanelInlineStyle()
|
||||
}
|
||||
})
|
||||
|
||||
const toggleReaction = async (type) => {
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
@@ -245,6 +264,15 @@ const toggleReaction = async (type) => {
|
||||
|
||||
onMounted(async () => {
|
||||
await initialize()
|
||||
window.addEventListener('resize', updatePanelInlineStyle)
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
toggleReaction,
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', updatePanelInlineStyle)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -253,11 +281,7 @@ onMounted(async () => {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.reactions-viewer {
|
||||
@@ -295,40 +319,15 @@ onMounted(async () => {
|
||||
padding-left: 5px;
|
||||
}
|
||||
|
||||
.make-reaction-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.make-reaction-item {
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
opacity: 0.5;
|
||||
border-radius: 8px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.like-reaction {
|
||||
color: #ff0000;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.make-reaction-item:hover {
|
||||
background-color: #ffe2e2;
|
||||
}
|
||||
|
||||
.reactions-count {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.reactions-panel {
|
||||
position: absolute;
|
||||
bottom: 50px;
|
||||
bottom: 35px;
|
||||
background-color: var(--background-color);
|
||||
border: 1px solid var(--normal-border-color);
|
||||
border-radius: 20px;
|
||||
@@ -361,7 +360,6 @@ onMounted(async () => {
|
||||
border: 1px solid var(--normal-border-color);
|
||||
border-radius: 10px;
|
||||
margin-right: 5px;
|
||||
margin-bottom: 5px;
|
||||
font-size: 14px;
|
||||
color: var(--text-color);
|
||||
align-items: center;
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
<input
|
||||
class="text-input"
|
||||
v-model="keyword"
|
||||
placeholder="Search"
|
||||
placeholder="键盘点击「/」以触发搜索"
|
||||
ref="searchInput"
|
||||
@input="setSearch(keyword)"
|
||||
/>
|
||||
</div>
|
||||
@@ -48,7 +49,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import Dropdown from '~/components/Dropdown.vue'
|
||||
import { stripMarkdown } from '~/utils/markdown'
|
||||
import { useIsMobile } from '~/utils/screen'
|
||||
@@ -61,8 +62,48 @@ const keyword = ref('')
|
||||
const selected = ref(null)
|
||||
const results = ref([])
|
||||
const dropdown = ref(null)
|
||||
const searchInput = ref(null)
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const isEditableElement = (el) => {
|
||||
if (!el) return false
|
||||
if (el.isContentEditable) return true
|
||||
const tagName = el.tagName ? el.tagName.toLowerCase() : ''
|
||||
if (tagName === 'input' || tagName === 'textarea' || tagName === 'select') {
|
||||
return true
|
||||
}
|
||||
const role = el.getAttribute ? el.getAttribute('role') : null
|
||||
return role === 'textbox'
|
||||
}
|
||||
|
||||
const focusSearchInput = () => {
|
||||
if (!searchInput.value) return
|
||||
dropdown.value?.openMenu?.()
|
||||
if (typeof searchInput.value.focus === 'function') {
|
||||
try {
|
||||
searchInput.value.focus({ preventScroll: true })
|
||||
} catch (e) {
|
||||
searchInput.value.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleGlobalSlash = (event) => {
|
||||
if (event.defaultPrevented) return
|
||||
if (event.key !== '/' || event.ctrlKey || event.metaKey || event.altKey) return
|
||||
if (isEditableElement(document.activeElement)) return
|
||||
event.preventDefault()
|
||||
focusSearchInput()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', handleGlobalSlash)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', handleGlobalSlash)
|
||||
})
|
||||
|
||||
const toggle = () => {
|
||||
dropdown.value.toggle()
|
||||
}
|
||||
@@ -144,8 +185,7 @@ defineExpose({
|
||||
|
||||
<style scoped>
|
||||
.search-dropdown {
|
||||
margin-top: 20px;
|
||||
width: 500px;
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
.search-mobile-trigger {
|
||||
@@ -154,7 +194,7 @@ defineExpose({
|
||||
}
|
||||
|
||||
.search-input {
|
||||
padding: 10px;
|
||||
padding: 2px 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
@@ -202,6 +242,7 @@ defineExpose({
|
||||
}
|
||||
|
||||
.result-body {
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -215,4 +256,14 @@ defineExpose({
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.search-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: 10000;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user