Compare commits

..

5 Commits

Author SHA1 Message Date
Tim
d7e58a5741 feat: add base item group component 2025-10-15 21:04:29 +08:00
tim
a68c925c68 fix: 集成一下父亲容器 2025-10-15 19:52:30 +08:00
tim
4f248e8a71 fix: 布局微调 2025-10-15 18:08:16 +08:00
Tim
277883f9d9 Merge pull request #1064 from nagisa77/codex/refactor-reactionsgroup-to-remove-slot-mechanism
Refactor ReactionsGroup layout responsibilities
2025-10-15 17:55:55 +08:00
Tim
e9e996f291 refactor: simplify reactions group usage 2025-10-15 17:47:45 +08:00
6 changed files with 443 additions and 101 deletions

View File

@@ -73,12 +73,6 @@ cd OpenIsle
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` 中各卷的定义进行调整。
## 启动后端服务

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

View File

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

View File

@@ -35,21 +35,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 +59,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 +92,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 +140,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 +160,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
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 +262,15 @@ const toggleReaction = async (type) => {
onMounted(async () => {
await initialize()
window.addEventListener('resize', updatePanelInlineStyle)
})
defineExpose({
toggleReaction,
})
onBeforeUnmount(() => {
window.removeEventListener('resize', updatePanelInlineStyle)
})
</script>
@@ -253,11 +279,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,32 +317,6 @@ 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;
@@ -328,7 +324,7 @@ onMounted(async () => {
.reactions-panel {
position: absolute;
bottom: 50px;
bottom: 40px;
background-color: var(--background-color);
border: 1px solid var(--normal-border-color);
border-radius: 20px;

View File

@@ -61,14 +61,31 @@
@click="handleContentClick"
></div>
</div>
<ReactionsGroup
:model-value="item.reactions"
content-type="message"
:content-id="item.id"
@update:modelValue="(v) => (item.reactions = v)"
>
<div @click="setReply(item)" class="reply-btn"><next /> 写个回复...</div>
</ReactionsGroup>
<div class="message-reaction-row">
<ReactionsGroup
:ref="(el) => setMessageReactionRef(item.id, el)"
:model-value="item.reactions"
content-type="message"
:content-id="item.id"
@update:modelValue="(v) => (item.reactions = v)"
/>
<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>
</BaseTimeline>
<div class="empty-container">
@@ -180,6 +197,32 @@ function setReply(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 */
function scrollToBottomSmooth() {
const el = messagesListEl.value
@@ -710,6 +753,55 @@ function goBack() {
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 {
display: flex;
flex-direction: row;
@@ -723,14 +815,8 @@ function goBack() {
}
.reply-btn {
cursor: pointer;
padding: 4px;
opacity: 0.6;
font-size: 12px;
}
.reply-btn:hover {
opacity: 1;
color: var(--primary-color);
}
.active-reply {

View File

@@ -92,11 +92,26 @@
></div>
<div class="article-footer-container">
<ReactionsGroup v-model="postReactions" content-type="post" :content-id="postId">
<div class="make-reaction-item copy-link" @click="copyPostLink">
<ReactionsGroup
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 />
</div>
</ReactionsGroup>
</div>
</div>
</div>
</div>
@@ -223,6 +238,18 @@ const postContent = ref('')
const category = ref('')
const tags = 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 changeLogs = ref([])
const status = ref('PUBLISHED')
@@ -366,9 +393,9 @@ const changeLogIcon = (l) => {
return 'unlock'
}
} else if (l.type === 'PINNED') {
if(l.newPinnedAt){
if (l.newPinnedAt) {
return 'pin'
}else{
} else {
return 'clear-icon'
}
} else if (l.type === 'FEATURED') {
@@ -1245,35 +1272,53 @@ onMounted(async () => {
.article-footer-container {
display: flex;
flex-direction: row;
justify-content: space-between;
gap: 10px;
margin-top: 0px;
flex-wrap: wrap;
}
.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;
}
.reactions-viewer-item {
font-size: 16px;
}
.make-reaction-container {
.article-footer-actions {
display: flex;
flex-direction: row;
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;
}