Compare commits

...

7 Commits

Author SHA1 Message Date
Tim
856d3dd513 fix: 简化time规则 2025-08-27 20:57:20 +08:00
Tim
d96aae59d2 fix: 新增相对时间 2025-08-27 20:47:50 +08:00
Tim
ec0fd63e30 fix: 帖子可被刷积分,应新增取消赞消除积分😂 #685 2025-08-27 20:27:53 +08:00
Tim
dfd4c70b6e Merge pull request #760 from nagisa77/codex/fix-repeated-reaction-notifications-and-points
fix: resolve repeated reaction issues
2025-08-27 20:23:43 +08:00
Tim
d79dc8877d fix: handle reaction notification and point deduction 2025-08-27 20:23:28 +08:00
Tim
99bf80a47a Merge pull request #758 from nagisa77/codex-mv1xa5
fix: gray out unearned medals
2025-08-27 20:21:23 +08:00
Tim
02287c05be fix: 站内信 scroll问题 #749 2025-08-27 20:18:39 +08:00
9 changed files with 136 additions and 67 deletions

View File

@@ -36,6 +36,7 @@ public class ReactionController {
Authentication auth) {
Reaction reaction = reactionService.reactToPost(auth.getName(), postId, req.getType());
if (reaction == null) {
pointService.deductForReactionOfPost(auth.getName(), postId);
return ResponseEntity.noContent().build();
}
ReactionDto dto = reactionMapper.toDto(reaction);
@@ -50,6 +51,7 @@ public class ReactionController {
Authentication auth) {
Reaction reaction = reactionService.reactToComment(auth.getName(), commentId, req.getType());
if (reaction == null) {
pointService.deductForReactionOfComment(auth.getName(), commentId);
return ResponseEntity.noContent().build();
}
ReactionDto dto = reactionMapper.toDto(reaction);

View File

@@ -5,6 +5,8 @@ public enum PointHistoryType {
COMMENT,
POST_LIKED,
COMMENT_LIKED,
POST_LIKE_CANCELLED,
COMMENT_LIKE_CANCELLED,
INVITE,
FEATURE,
SYSTEM_ONLINE,

View File

@@ -5,6 +5,7 @@ import com.openisle.model.User;
import com.openisle.model.Post;
import com.openisle.model.Comment;
import com.openisle.model.NotificationType;
import com.openisle.model.ReactionType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
@@ -29,4 +30,8 @@ public interface NotificationRepository extends JpaRepository<Notification, Long
List<Notification> findByTypeAndFromUser(NotificationType type, User fromUser);
void deleteByTypeAndFromUserAndPost(NotificationType type, User fromUser, Post post);
void deleteByTypeAndFromUserAndPostAndReactionType(NotificationType type, User fromUser, Post post, ReactionType reactionType);
void deleteByTypeAndFromUserAndCommentAndReactionType(NotificationType type, User fromUser, Comment comment, ReactionType reactionType);
}

View File

@@ -114,6 +114,14 @@ public class NotificationService {
return n;
}
public void deleteReactionNotification(User fromUser, Post post, Comment comment, ReactionType reactionType) {
if (post != null) {
notificationRepository.deleteByTypeAndFromUserAndPostAndReactionType(NotificationType.REACTION, fromUser, post, reactionType);
} else if (comment != null) {
notificationRepository.deleteByTypeAndFromUserAndCommentAndReactionType(NotificationType.REACTION, fromUser, comment, reactionType);
}
}
/**
* Create notifications for all admins when a user submits a register request.
* Old register request notifications from the same applicant are removed first.

View File

@@ -150,6 +150,16 @@ public class PointService {
return addPoint(poster, 10, PointHistoryType.POST_LIKED, post, null, reactioner);
}
public int deductForReactionOfPost(String reactionerName, Long postId) {
User poster = postRepository.findById(postId).orElseThrow().getAuthor();
User reactioner = userRepository.findByUsername(reactionerName).orElseThrow();
if (poster.getId().equals(reactioner.getId())) {
return 0;
}
Post post = postRepository.findById(postId).orElseThrow();
return addPoint(poster, -10, PointHistoryType.POST_LIKE_CANCELLED, post, null, reactioner);
}
// 考虑点赞者和评论者是同一个的情况
public int awardForReactionOfComment(String reactionerName, Long commentId) {
// 根据帖子id找到评论者
@@ -169,6 +179,17 @@ public class PointService {
return addPoint(commenter, 10, PointHistoryType.COMMENT_LIKED, post, comment, reactioner);
}
public int deductForReactionOfComment(String reactionerName, Long commentId) {
User commenter = commentRepository.findById(commentId).orElseThrow().getAuthor();
User reactioner = userRepository.findByUsername(reactionerName).orElseThrow();
if (commenter.getId().equals(reactioner.getId())) {
return 0;
}
Comment comment = commentRepository.findById(commentId).orElseThrow();
Post post = comment.getPost();
return addPoint(commenter, -10, PointHistoryType.COMMENT_LIKE_CANCELLED, post, comment, reactioner);
}
public java.util.List<PointHistory> listHistory(String userName) {
User user = userRepository.findByUsername(userName).orElseThrow();
if (pointHistoryRepository.countByUser(user) == 0) {

View File

@@ -42,6 +42,7 @@ public class ReactionService {
java.util.Optional<Reaction> existing =
reactionRepository.findByUserAndPostAndType(user, post, type);
if (existing.isPresent()) {
notificationService.deleteReactionNotification(user, post, null, type);
reactionRepository.delete(existing.get());
return null;
}
@@ -65,6 +66,7 @@ public class ReactionService {
java.util.Optional<Reaction> existing =
reactionRepository.findByUserAndCommentAndType(user, comment, type);
if (existing.isPresent()) {
notificationService.deleteReactionNotification(user, null, comment, type);
reactionRepository.delete(existing.get());
return null;
}

View File

@@ -113,17 +113,23 @@ const error = ref(null)
const conversationId = route.params.id
const currentUser = ref(null)
const messagesListEl = ref(null)
let lastMessageEl = null
const currentPage = ref(0)
const totalPages = ref(0)
const loadingMore = ref(false)
let scrollInterval = null
const conversationName = ref('')
const isChannel = ref(false)
const isFloatMode = computed(() => route.query.float !== undefined)
const floatRoute = useState('messageFloatRoute')
const replyTo = ref(null)
const isUserNearBottom = ref(true)
function updateNearBottom() {
const el = messagesListEl.value
if (!el) return
const threshold = 40 // px
isUserNearBottom.value = el.scrollHeight - el.scrollTop - el.clientHeight <= threshold
}
const hasMoreMessages = computed(() => currentPage.value < totalPages.value - 1)
const otherParticipant = computed(() => {
@@ -133,20 +139,37 @@ const otherParticipant = computed(() => {
return participants.value.find((p) => p.id !== currentUser.value.id)
})
function isSentByCurrentUser(message) {
return message.sender.id === currentUser.value?.id
}
function handleAvatarError(event) {
event.target.src = '/default-avatar.svg'
}
function setReply(message) {
replyTo.value = message
}
// No changes needed here, as renderMarkdown is now imported.
// The old function is removed.
/** 改造:滚动函数 —— smooth & instant */
function scrollToBottomSmooth() {
const el = messagesListEl.value
if (!el) return
// 优先使用原生 smooth失败则降级
try {
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' })
} catch {
// 降级:简易动画
const start = el.scrollTop
const end = el.scrollHeight
const duration = 200
const startTs = performance.now()
function step(now) {
const p = Math.min(1, (now - startTs) / duration)
el.scrollTop = start + (end - start) * p
if (p < 1) requestAnimationFrame(step)
}
requestAnimationFrame(step)
}
}
function scrollToBottomInstant() {
const el = messagesListEl.value
if (!el) return
el.scrollTop = el.scrollHeight
}
async function fetchMessages(page = 0) {
if (page === 0) {
@@ -181,7 +204,6 @@ async function fetchMessages(page = 0) {
isChannel.value = conversationData.channel
}
// Since the backend sorts by descending, we need to reverse for correct chat order
const newMessages = pageData.content.reverse().map((item) => ({
...item,
src: item.sender.avatar,
@@ -202,12 +224,16 @@ async function fetchMessages(page = 0) {
currentPage.value = pageData.number
totalPages.value = pageData.totalPages
// Scrolling is now fully handled by the watcher
await nextTick()
if (page > 0 && list) {
// 加载更多:保持原视口位置
const newScrollHeight = list.scrollHeight
list.scrollTop = newScrollHeight - oldScrollHeight
} else if (page === 0) {
// 首次加载:定位到底部(不用动画,避免“闪动感”)
scrollToBottomInstant()
}
updateNearBottom()
} catch (e) {
error.value = e.message
toast.error(e.message)
@@ -272,9 +298,10 @@ async function sendMessage(content, clearInput) {
})
clearInput()
replyTo.value = null
setTimeout(() => {
scrollToBottom()
}, 100)
await nextTick()
// 仅“发送消息成功后”才平滑滚动到底部
scrollToBottomSmooth()
} catch (e) {
toast.error(e.message)
} finally {
@@ -290,7 +317,6 @@ async function markConversationAsRead() {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
})
// After marking as read, refresh the global unread count
refreshGlobalUnreadCount()
refreshChannelUnread()
} catch (e) {
@@ -298,37 +324,12 @@ async function markConversationAsRead() {
}
}
function scrollToBottom() {
if (messagesListEl.value) {
const element = messagesListEl.value
// 強制滾動到底部,使用 smooth 行為確保視覺效果
element.scrollTop = element.scrollHeight
// 再次確認滾動位置
setTimeout(() => {
if (element.scrollTop < element.scrollHeight - element.clientHeight) {
element.scrollTop = element.scrollHeight
}
}, 50)
}
}
watch(
messages,
async (newMessages) => {
if (newMessages.length === 0) return
await nextTick()
// Simple, reliable scroll to bottom
setTimeout(() => {
scrollToBottom()
}, 100)
},
{ deep: true },
)
onMounted(async () => {
// 监听列表滚动,实时感知是否接近底部
if (messagesListEl.value) {
messagesListEl.value.addEventListener('scroll', updateNearBottom, { passive: true })
}
currentUser.value = await fetchCurrentUser()
if (currentUser.value) {
await fetchMessages(0)
@@ -345,9 +346,8 @@ onMounted(async () => {
watch(isConnected, (newValue) => {
if (newValue) {
// 等待一小段时间确保连接稳定
setTimeout(() => {
subscription = subscribe(`/topic/conversation/${conversationId}`, (message) => {
subscription = subscribe(`/topic/conversation/${conversationId}`, async (message) => {
// 避免重复显示当前用户发送的消息
if (message.sender.id !== currentUser.value.id) {
messages.value.push({
@@ -357,11 +357,10 @@ watch(isConnected, (newValue) => {
openUser(message.sender.id)
},
})
// 实时收到消息时自动标记已读
// 收到消息后只标记已读,不强制滚动(符合“非发送不拉底”)
markConversationAsRead()
setTimeout(() => {
scrollToBottom()
}, 100)
await nextTick()
updateNearBottom()
}
})
}, 500)
@@ -369,23 +368,12 @@ watch(isConnected, (newValue) => {
})
onActivated(async () => {
// This will be called every time the component is activated (navigated to)
// 返回页面时:刷新数据与已读,不做强制滚动,保持用户当前位置
if (currentUser.value) {
await fetchMessages(0)
await markConversationAsRead()
// 確保滾動到底部 - 使用多重延遲策略
await nextTick()
setTimeout(() => {
scrollToBottom()
}, 100)
setTimeout(() => {
scrollToBottom()
}, 300)
setTimeout(() => {
scrollToBottom()
}, 500)
updateNearBottom()
if (!isConnected.value) {
const token = getToken()
if (token) connect(token)
@@ -406,6 +394,9 @@ onUnmounted(() => {
subscription.unsubscribe()
subscription = null
}
if (messagesListEl.value) {
messagesListEl.value.removeEventListener('scroll', updateNearBottom)
}
disconnect()
})

View File

@@ -104,6 +104,31 @@
获得{{ item.amount }}积分
</template>
</template>
<template v-else-if="item.type === 'POST_LIKE_CANCELLED' && item.fromUserId">
你的帖子
<NuxtLink :to="`/posts/${item.postId}`" class="timeline-link">
{{ item.postTitle }}
</NuxtLink>
<NuxtLink :to="`/users/${item.fromUserId}`" class="timeline-link">
{{ item.fromUserName }}
</NuxtLink>
取消点赞扣除{{ -item.amount }}积分
</template>
<template v-else-if="item.type === 'COMMENT_LIKE_CANCELLED' && item.fromUserId">
你的评论
<NuxtLink
:to="`/posts/${item.postId}#comment-${item.commentId}`"
class="timeline-link"
>
{{ stripMarkdownLength(item.commentContent, 100) }}
</NuxtLink>
<NuxtLink :to="`/users/${item.fromUserId}`" class="timeline-link">
{{ item.fromUserName }}
</NuxtLink>
取消点赞扣除{{ -item.amount }}积分
</template>
<template v-else-if="item.type === 'POST_LIKED' && item.fromUserId">
帖子
<NuxtLink :to="`/posts/${item.postId}`" class="timeline-link">{{
@@ -227,6 +252,8 @@ const iconMap = {
FEATURE: 'fas fa-star',
LOTTERY_JOIN: 'fas fa-ticket-alt',
LOTTERY_REWARD: 'fas fa-ticket-alt',
POST_LIKE_CANCELLED: 'fas fa-thumbs-down',
COMMENT_LIKE_CANCELLED: 'fas fa-thumbs-down',
}
const loadTrend = async () => {

View File

@@ -4,6 +4,17 @@ export default class TimeManager {
if (Number.isNaN(date.getTime())) return ''
const now = new Date()
const diffMs = now.getTime() - date.getTime()
if (diffMs >= 0 && diffMs < 60 * 1000) {
return '刚刚'
}
if (diffMs >= 0 && diffMs < 60 * 60 * 1000) {
const mins = Math.floor(diffMs / 60_000)
return `${mins || 1}分钟前`
}
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const startOfDate = new Date(date.getFullYear(), date.getMonth(), date.getDate())
const diffDays = Math.floor((startOfToday - startOfDate) / 86400000)