Files
OpenIsle/frontend_nuxt/components/ReactionsGroup.vue
2025-08-19 21:58:02 +08:00

301 lines
7.3 KiB
Vue

<template>
<div class="reactions-container">
<div class="reactions-viewer">
<div
class="reactions-viewer-item-container"
@click="openPanel"
@mouseenter="cancelHide"
@mouseleave="scheduleHide"
>
<template v-if="displayedReactions.length">
<div v-for="r in displayedReactions" :key="r.type" class="reactions-viewer-item">
{{ reactionEmojiMap[r.type] }}
</div>
<div class="reactions-count">{{ totalCount }}</div>
</template>
<div v-else class="reactions-viewer-item placeholder">
<i class="far fa-smile"></i>
<span class="reactions-viewer-item-placeholder-text">点击以表态</span>
</div>
</div>
</div>
<div class="make-reaction-container">
<div class="make-reaction-item like-reaction" @click="toggleReaction('LIKE')">
<i v-if="!userReacted('LIKE')" class="far fa-heart"></i>
<i v-else class="fas fa-heart"></i>
<span class="reactions-count" v-if="likeCount">{{ likeCount }}</span>
</div>
<slot></slot>
</div>
<div
v-if="panelVisible"
class="reactions-panel"
@mouseenter="cancelHide"
@mouseleave="scheduleHide"
>
<div
v-for="t in panelTypes"
:key="t"
class="reaction-option"
@click="toggleReaction(t)"
:class="{ selected: userReacted(t) }"
>
{{ reactionEmojiMap[t] }}<span v-if="counts[t]">{{ counts[t] }}</span>
</div>
</div>
</div>
</template>
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { toast } from '~/main'
import { authState, getToken } from '~/utils/auth'
import { reactionEmojiMap } from '~/utils/reactions'
import { useReactionTypes } from '~/composables/useReactionTypes'
const { reactionTypes, initialize } = useReactionTypes()
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const emit = defineEmits(['update:modelValue'])
const props = defineProps({
modelValue: { type: Array, default: () => [] },
contentType: { type: String, required: true },
contentId: { type: [Number, String], required: true },
})
watch(
() => props.modelValue,
(v) => (reactions.value = v),
)
const reactions = ref(props.modelValue)
const counts = computed(() => {
const c = {}
for (const r of reactions.value) {
c[r.type] = (c[r.type] || 0) + 1
}
return c
})
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)
const displayedReactions = computed(() => {
return Object.entries(counts.value)
.sort((a, b) => b[1] - a[1])
.slice(0, 3)
.map(([type]) => ({ type }))
})
const panelTypes = computed(() => reactionTypes.value.filter((t) => t !== 'LIKE'))
const panelVisible = ref(false)
let hideTimer = null
const openPanel = () => {
clearTimeout(hideTimer)
panelVisible.value = true
}
const scheduleHide = () => {
clearTimeout(hideTimer)
hideTimer = setTimeout(() => {
panelVisible.value = false
}, 500)
}
const cancelHide = () => {
clearTimeout(hideTimer)
}
const toggleReaction = async (type) => {
const token = getToken()
if (!token) {
toast.error('请先登录')
return
}
const url =
props.contentType === 'post'
? `${API_BASE_URL}/api/posts/${props.contentId}/reactions`
: `${API_BASE_URL}/api/comments/${props.contentId}/reactions`
// optimistic update
const existingIdx = reactions.value.findIndex(
(r) => r.type === type && r.user === authState.username,
)
let tempReaction = null
let removedReaction = null
if (existingIdx > -1) {
removedReaction = reactions.value.splice(existingIdx, 1)[0]
} else {
tempReaction = { type, user: authState.username }
reactions.value.push(tempReaction)
}
emit('update:modelValue', reactions.value)
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ type }),
})
if (res.ok) {
if (res.status === 204) {
// removal already reflected
} else {
const data = await res.json()
const idx = tempReaction ? reactions.value.indexOf(tempReaction) : -1
if (idx > -1) {
reactions.value.splice(idx, 1, data)
} else if (removedReaction) {
// server added back reaction even though we removed? restore data
reactions.value.push(data)
}
if (data.reward && data.reward > 0) {
toast.success(`获得 ${data.reward} 经验值`)
}
}
emit('update:modelValue', reactions.value)
} else {
// revert optimistic update on failure
if (tempReaction) {
const idx = reactions.value.indexOf(tempReaction)
if (idx > -1) reactions.value.splice(idx, 1)
} else if (removedReaction) {
reactions.value.push(removedReaction)
}
emit('update:modelValue', reactions.value)
toast.error('操作失败')
}
} catch (e) {
if (tempReaction) {
const idx = reactions.value.indexOf(tempReaction)
if (idx > -1) reactions.value.splice(idx, 1)
} else if (removedReaction) {
reactions.value.push(removedReaction)
}
emit('update:modelValue', reactions.value)
toast.error('操作失败')
}
}
onMounted(async () => {
await initialize()
})
</script>
<style>
.reactions-container {
position: relative;
display: flex;
flex-direction: row;
gap: 10px;
align-items: center;
width: 100%;
justify-content: space-between;
}
.reactions-viewer {
display: flex;
flex-direction: row;
gap: 20px;
align-items: center;
}
.reactions-viewer-item-container {
display: flex;
flex-direction: row;
gap: 2px;
align-items: center;
cursor: pointer;
color: #a2a2a2;
}
.reactions-viewer-item {
font-size: 16px;
}
.reactions-viewer-item.placeholder {
opacity: 0.5;
display: flex;
flex-direction: row;
align-items: center;
}
.reactions-viewer-item-placeholder-text {
font-size: 14px;
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;
}
.reactions-panel {
position: absolute;
bottom: 40px;
left: -20px;
background-color: var(--background-color);
border: 1px solid var(--normal-border-color);
border-radius: 5px;
padding: 5px;
max-width: 240px;
display: flex;
flex-direction: row;
flex-wrap: wrap;
z-index: 10;
gap: 2px;
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);
}
.reaction-option {
cursor: pointer;
padding: 2px 4px;
border-radius: 4px;
display: flex;
flex-direction: row;
align-items: center;
gap: 2px;
}
.reaction-option.selected {
background-color: var(--menu-selected-background-color);
}
@media (max-width: 768px) {
.make-reaction-item {
font-size: 16px;
padding: 3px 5px;
}
}
</style>