mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-21 14:30:59 +08:00
Compare commits
5 Commits
codex/add-
...
codex/crea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7e58a5741 | ||
|
|
a68c925c68 | ||
|
|
4f248e8a71 | ||
|
|
277883f9d9 | ||
|
|
e9e996f291 |
@@ -73,12 +73,6 @@ cd OpenIsle
|
|||||||
docker compose -f docker/docker-compose.yaml --env-file .env --profile dev down
|
docker compose -f docker/docker-compose.yaml --env-file .env --profile dev down
|
||||||
```
|
```
|
||||||
|
|
||||||
5. 开发时若需要**重置所有容器及其挂载的数据卷**,可以执行:
|
|
||||||
```shell
|
|
||||||
docker compose -f docker/docker-compose.yaml --env-file .env --profile dev down -v
|
|
||||||
```
|
|
||||||
`-v` 参数会在关闭容器的同时移除通过 `volumes` 声明的挂载卷,适用于希望清理数据库、缓存等持久化数据,确保下一次启动时获得全新环境的场景。
|
|
||||||
|
|
||||||
如需自定义 Node 依赖缓存、数据库持久化等,可参考 `docker/docker-compose.yaml` 中各卷的定义进行调整。
|
如需自定义 Node 依赖缓存、数据库持久化等,可参考 `docker/docker-compose.yaml` 中各卷的定义进行调整。
|
||||||
|
|
||||||
## 启动后端服务
|
## 启动后端服务
|
||||||
|
|||||||
157
frontend_nuxt/components/BaseItemGroup.vue
Normal file
157
frontend_nuxt/components/BaseItemGroup.vue
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="base-item-group"
|
||||||
|
:style="{
|
||||||
|
width: `${containerWidth}px`,
|
||||||
|
height: `${itemSize}px`,
|
||||||
|
'--base-item-group-duration': `${animationDuration}ms`,
|
||||||
|
}"
|
||||||
|
@mouseenter="onMouseEnter"
|
||||||
|
@mouseleave="onMouseLeave"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in items"
|
||||||
|
:key="itemKey(item, index)"
|
||||||
|
class="base-item-group__item"
|
||||||
|
:style="{
|
||||||
|
width: `${itemSize}px`,
|
||||||
|
height: `${itemSize}px`,
|
||||||
|
transform: `translateX(${index * activeGap}px)`,
|
||||||
|
zIndex: items.length - index,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<slot :item="item" :index="index">
|
||||||
|
<BaseImage
|
||||||
|
v-if="item && (item.src || typeof item === 'string')"
|
||||||
|
class="base-item-group__image"
|
||||||
|
:src="typeof item === 'string' ? item : item.src"
|
||||||
|
:alt="itemAlt(item, index)"
|
||||||
|
/>
|
||||||
|
<div v-else class="base-item-group__placeholder">{{ placeholderText(item) }}</div>
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref, watchEffect } from 'vue'
|
||||||
|
import BaseImage from './BaseImage.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
items: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
itemSize: {
|
||||||
|
type: Number,
|
||||||
|
default: 40,
|
||||||
|
},
|
||||||
|
collapsedGap: {
|
||||||
|
type: Number,
|
||||||
|
default: 12,
|
||||||
|
},
|
||||||
|
expandedGap: {
|
||||||
|
type: Number,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
animationDuration: {
|
||||||
|
type: Number,
|
||||||
|
default: 200,
|
||||||
|
},
|
||||||
|
itemKeyField: {
|
||||||
|
type: String,
|
||||||
|
default: 'id',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const isHovered = ref(false)
|
||||||
|
|
||||||
|
const onMouseEnter = () => {
|
||||||
|
isHovered.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMouseLeave = () => {
|
||||||
|
isHovered.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectiveExpandedGap = computed(() =>
|
||||||
|
props.expandedGap == null ? props.itemSize : props.expandedGap,
|
||||||
|
)
|
||||||
|
|
||||||
|
const activeGap = computed(() =>
|
||||||
|
isHovered.value ? effectiveExpandedGap.value : props.collapsedGap,
|
||||||
|
)
|
||||||
|
|
||||||
|
const containerWidth = computed(() =>
|
||||||
|
props.items.length ? props.itemSize + (props.items.length - 1) * activeGap.value : props.itemSize,
|
||||||
|
)
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
if (effectiveExpandedGap.value < props.collapsedGap) {
|
||||||
|
console.warn('[BaseItemGroup] `expandedGap` should be greater than or equal to `collapsedGap`.')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const itemKey = (item, index) => {
|
||||||
|
if (item && typeof item === 'object' && props.itemKeyField in item) {
|
||||||
|
return item[props.itemKeyField]
|
||||||
|
}
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemAlt = (item, index) => {
|
||||||
|
if (item && typeof item === 'object') {
|
||||||
|
return item.alt || `item-${index}`
|
||||||
|
}
|
||||||
|
if (typeof item === 'string') {
|
||||||
|
return `item-${index}`
|
||||||
|
}
|
||||||
|
return 'item'
|
||||||
|
}
|
||||||
|
|
||||||
|
const placeholderText = (item) => {
|
||||||
|
if (item == null) return ''
|
||||||
|
if (typeof item === 'object' && 'text' in item) return item.text
|
||||||
|
return String(item)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.base-item-group {
|
||||||
|
display: flex;
|
||||||
|
position: relative;
|
||||||
|
transition: width var(--base-item-group-duration) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-item-group__item {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 9999px;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: var(--color-neutral-100, #f0f2f5);
|
||||||
|
transition: transform var(--base-item-group-duration) ease;
|
||||||
|
box-shadow: 0 0 0 2px var(--color-surface, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-item-group__image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base-item-group__placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-neutral-500, #666);
|
||||||
|
background-color: var(--color-neutral-200, #e5e7eb);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -53,14 +53,29 @@
|
|||||||
@click="handleContentClick"
|
@click="handleContentClick"
|
||||||
></div>
|
></div>
|
||||||
<div class="article-footer-container">
|
<div class="article-footer-container">
|
||||||
<ReactionsGroup v-model="comment.reactions" content-type="comment" :content-id="comment.id">
|
<ReactionsGroup
|
||||||
<div class="make-reaction-item comment-reaction" @click="toggleEditor">
|
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 />
|
<comment-icon />
|
||||||
</div>
|
</div>
|
||||||
<div class="make-reaction-item copy-link" @click="copyCommentLink">
|
<div class="reaction-action copy-link" @click="copyCommentLink">
|
||||||
<link-icon />
|
<link-icon />
|
||||||
</div>
|
</div>
|
||||||
</ReactionsGroup>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="comment-editor-wrapper" ref="editorWrapper">
|
<div class="comment-editor-wrapper" ref="editorWrapper">
|
||||||
<CommentEditor
|
<CommentEditor
|
||||||
@@ -156,6 +171,18 @@ const lightboxVisible = ref(false)
|
|||||||
const lightboxIndex = ref(0)
|
const lightboxIndex = ref(0)
|
||||||
const lightboxImgs = ref([])
|
const lightboxImgs = ref([])
|
||||||
const loggedIn = computed(() => authState.loggedIn)
|
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 countReplies = (list) => list.reduce((sum, r) => sum + 1 + countReplies(r.reply || []), 0)
|
||||||
const replyCount = computed(() => countReplies(props.comment.reply || []))
|
const replyCount = computed(() => countReplies(props.comment.reply || []))
|
||||||
const isCommentFromPostAuthor = computed(() => {
|
const isCommentFromPostAuthor = computed(() => {
|
||||||
@@ -365,6 +392,47 @@ const handleContentClick = (e) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<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 {
|
.reply-toggle {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
@@ -378,10 +446,6 @@ const handleContentClick = (e) => {
|
|||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.comment-reaction:hover {
|
|
||||||
background-color: lightgray;
|
|
||||||
}
|
|
||||||
|
|
||||||
.comment-highlight {
|
.comment-highlight {
|
||||||
animation: highlight 2s;
|
animation: highlight 2s;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,21 +35,11 @@
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</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
|
<div
|
||||||
v-if="panelVisible"
|
v-if="panelVisible"
|
||||||
class="reactions-panel"
|
class="reactions-panel"
|
||||||
|
ref="reactionsPanelRef"
|
||||||
|
:style="panelInlineStyle"
|
||||||
@mouseenter="cancelHide"
|
@mouseenter="cancelHide"
|
||||||
@mouseleave="scheduleHide"
|
@mouseleave="scheduleHide"
|
||||||
>
|
>
|
||||||
@@ -69,7 +59,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import { toast } from '~/main'
|
import { toast } from '~/main'
|
||||||
import { authState, getToken } from '~/utils/auth'
|
import { authState, getToken } from '~/utils/auth'
|
||||||
import { reactionEmojiMap } from '~/utils/reactions'
|
import { reactionEmojiMap } from '~/utils/reactions'
|
||||||
@@ -102,8 +92,6 @@ const counts = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const totalCount = computed(() => Object.values(counts.value).reduce((a, b) => a + b, 0))
|
const totalCount = computed(() => Object.values(counts.value).reduce((a, b) => a + b, 0))
|
||||||
const likeCount = computed(() => counts.value['LIKE'] || 0)
|
|
||||||
|
|
||||||
const userReacted = (type) =>
|
const userReacted = (type) =>
|
||||||
reactions.value.some((r) => r.type === type && r.user === authState.username)
|
reactions.value.some((r) => r.type === type && r.user === authState.username)
|
||||||
|
|
||||||
@@ -152,9 +140,11 @@ const displayedReactions = computed(() => {
|
|||||||
.map((type) => ({ type }))
|
.map((type) => ({ type }))
|
||||||
})
|
})
|
||||||
|
|
||||||
const panelTypes = computed(() => sortedReactionTypes.value.filter((t) => t !== 'LIKE'))
|
const panelTypes = computed(() => sortedReactionTypes.value)
|
||||||
|
|
||||||
const panelVisible = ref(false)
|
const panelVisible = ref(false)
|
||||||
|
const reactionsPanelRef = ref(null)
|
||||||
|
const panelInlineStyle = ref({})
|
||||||
let hideTimer = null
|
let hideTimer = null
|
||||||
const openPanel = () => {
|
const openPanel = () => {
|
||||||
clearTimeout(hideTimer)
|
clearTimeout(hideTimer)
|
||||||
@@ -170,6 +160,33 @@ const cancelHide = () => {
|
|||||||
clearTimeout(hideTimer)
|
clearTimeout(hideTimer)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updatePanelInlineStyle = () => {
|
||||||
|
if (!panelVisible.value) return
|
||||||
|
const panelEl = reactionsPanelRef.value
|
||||||
|
if (!panelEl) return
|
||||||
|
const parentEl = panelEl.closest('.reactions-container')?.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 toggleReaction = async (type) => {
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@@ -245,6 +262,15 @@ const toggleReaction = async (type) => {
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await initialize()
|
await initialize()
|
||||||
|
window.addEventListener('resize', updatePanelInlineStyle)
|
||||||
|
})
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
toggleReaction,
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('resize', updatePanelInlineStyle)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -253,11 +279,7 @@ onMounted(async () => {
|
|||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
gap: 10px;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
width: 100%;
|
|
||||||
justify-content: space-between;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.reactions-viewer {
|
.reactions-viewer {
|
||||||
@@ -295,32 +317,6 @@ onMounted(async () => {
|
|||||||
padding-left: 5px;
|
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 {
|
.reactions-count {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
@@ -328,7 +324,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
.reactions-panel {
|
.reactions-panel {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 50px;
|
bottom: 40px;
|
||||||
background-color: var(--background-color);
|
background-color: var(--background-color);
|
||||||
border: 1px solid var(--normal-border-color);
|
border: 1px solid var(--normal-border-color);
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
|
|||||||
@@ -61,14 +61,31 @@
|
|||||||
@click="handleContentClick"
|
@click="handleContentClick"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<ReactionsGroup
|
<div class="message-reaction-row">
|
||||||
:model-value="item.reactions"
|
<ReactionsGroup
|
||||||
content-type="message"
|
:ref="(el) => setMessageReactionRef(item.id, el)"
|
||||||
:content-id="item.id"
|
:model-value="item.reactions"
|
||||||
@update:modelValue="(v) => (item.reactions = v)"
|
content-type="message"
|
||||||
>
|
:content-id="item.id"
|
||||||
<div @click="setReply(item)" class="reply-btn"><next /> 写个回复...</div>
|
@update:modelValue="(v) => (item.reactions = v)"
|
||||||
</ReactionsGroup>
|
/>
|
||||||
|
<div class="message-reaction-actions">
|
||||||
|
<div
|
||||||
|
class="reaction-action like-action"
|
||||||
|
:class="{ selected: isMessageLiked(item) }"
|
||||||
|
@click="toggleMessageLike(item)"
|
||||||
|
>
|
||||||
|
<like v-if="!isMessageLiked(item)" />
|
||||||
|
<like v-else theme="filled" />
|
||||||
|
<span v-if="getMessageLikeCount(item)" class="reaction-count">{{
|
||||||
|
getMessageLikeCount(item)
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<div @click="setReply(item)" class="reaction-action reply-btn">
|
||||||
|
<next /> 写个回复...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</BaseTimeline>
|
</BaseTimeline>
|
||||||
<div class="empty-container">
|
<div class="empty-container">
|
||||||
@@ -180,6 +197,32 @@ function setReply(message) {
|
|||||||
replyTo.value = message
|
replyTo.value = message
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const messageReactionRefs = new Map()
|
||||||
|
function setMessageReactionRef(id, el) {
|
||||||
|
if (el) {
|
||||||
|
messageReactionRefs.set(id, el)
|
||||||
|
} else {
|
||||||
|
messageReactionRefs.delete(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMessageLikeCount(message) {
|
||||||
|
return (message.reactions || []).filter((reaction) => reaction.type === 'LIKE').length
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMessageLiked(message) {
|
||||||
|
const username = currentUser.value?.username
|
||||||
|
if (!username) return false
|
||||||
|
return (message.reactions || []).some(
|
||||||
|
(reaction) => reaction.type === 'LIKE' && reaction.user === username,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMessageLike(message) {
|
||||||
|
const group = messageReactionRefs.get(message.id)
|
||||||
|
group?.toggleReaction('LIKE')
|
||||||
|
}
|
||||||
|
|
||||||
/** 改造:滚动函数 —— smooth & instant */
|
/** 改造:滚动函数 —— smooth & instant */
|
||||||
function scrollToBottomSmooth() {
|
function scrollToBottomSmooth() {
|
||||||
const el = messagesListEl.value
|
const el = messagesListEl.value
|
||||||
@@ -710,6 +753,55 @@ function goBack() {
|
|||||||
background-color: var(--normal-light-background-color);
|
background-color: var(--normal-light-background-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message-reaction-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-reaction-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-action {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
opacity: 0.6;
|
||||||
|
font-size: 16px;
|
||||||
|
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-header {
|
.reply-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
@@ -723,14 +815,8 @@ function goBack() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.reply-btn {
|
.reply-btn {
|
||||||
cursor: pointer;
|
|
||||||
padding: 4px;
|
|
||||||
opacity: 0.6;
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
color: var(--primary-color);
|
||||||
|
|
||||||
.reply-btn:hover {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.active-reply {
|
.active-reply {
|
||||||
|
|||||||
@@ -92,11 +92,26 @@
|
|||||||
></div>
|
></div>
|
||||||
|
|
||||||
<div class="article-footer-container">
|
<div class="article-footer-container">
|
||||||
<ReactionsGroup v-model="postReactions" content-type="post" :content-id="postId">
|
<ReactionsGroup
|
||||||
<div class="make-reaction-item copy-link" @click="copyPostLink">
|
ref="postReactionsGroupRef"
|
||||||
|
v-model="postReactions"
|
||||||
|
content-type="post"
|
||||||
|
:content-id="postId"
|
||||||
|
/>
|
||||||
|
<div class="article-footer-actions">
|
||||||
|
<div
|
||||||
|
class="reaction-action like-action"
|
||||||
|
:class="{ selected: postLikedByMe }"
|
||||||
|
@click="togglePostLike"
|
||||||
|
>
|
||||||
|
<like v-if="!postLikedByMe" />
|
||||||
|
<like v-else theme="filled" />
|
||||||
|
<span v-if="postLikeCount" class="reaction-count">{{ postLikeCount }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="reaction-action copy-link" @click="copyPostLink">
|
||||||
<link-icon />
|
<link-icon />
|
||||||
</div>
|
</div>
|
||||||
</ReactionsGroup>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -223,6 +238,18 @@ const postContent = ref('')
|
|||||||
const category = ref('')
|
const category = ref('')
|
||||||
const tags = ref([])
|
const tags = ref([])
|
||||||
const postReactions = ref([])
|
const postReactions = ref([])
|
||||||
|
const postReactionsGroupRef = ref(null)
|
||||||
|
const postLikeCount = computed(
|
||||||
|
() => postReactions.value.filter((reaction) => reaction.type === 'LIKE').length,
|
||||||
|
)
|
||||||
|
const postLikedByMe = computed(() =>
|
||||||
|
postReactions.value.some(
|
||||||
|
(reaction) => reaction.type === 'LIKE' && reaction.user === authState.username,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const togglePostLike = () => {
|
||||||
|
postReactionsGroupRef.value?.toggleReaction('LIKE')
|
||||||
|
}
|
||||||
const comments = ref([])
|
const comments = ref([])
|
||||||
const changeLogs = ref([])
|
const changeLogs = ref([])
|
||||||
const status = ref('PUBLISHED')
|
const status = ref('PUBLISHED')
|
||||||
@@ -366,9 +393,9 @@ const changeLogIcon = (l) => {
|
|||||||
return 'unlock'
|
return 'unlock'
|
||||||
}
|
}
|
||||||
} else if (l.type === 'PINNED') {
|
} else if (l.type === 'PINNED') {
|
||||||
if(l.newPinnedAt){
|
if (l.newPinnedAt) {
|
||||||
return 'pin'
|
return 'pin'
|
||||||
}else{
|
} else {
|
||||||
return 'clear-icon'
|
return 'clear-icon'
|
||||||
}
|
}
|
||||||
} else if (l.type === 'FEATURED') {
|
} else if (l.type === 'FEATURED') {
|
||||||
@@ -1245,35 +1272,53 @@ onMounted(async () => {
|
|||||||
.article-footer-container {
|
.article-footer-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin-top: 0px;
|
margin-top: 0px;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reactions-viewer {
|
.article-footer-actions {
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
gap: 20px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reactions-viewer-item-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
gap: 2px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reactions-viewer-item {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.make-reaction-container {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.copy-link:hover {
|
.reaction-action {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 10px;
|
||||||
|
opacity: 0.6;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
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: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reaction-action.copy-link:hover {
|
||||||
background-color: #e2e2e2;
|
background-color: #e2e2e2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user