mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-28 00:50:46 +08:00
Compare commits
21 Commits
codex-mv1x
...
codex/add-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac81bccd20 | ||
|
|
351447e3d1 | ||
|
|
453d8fa68b | ||
|
|
2c5b38ee9e | ||
|
|
b5fd5a3edc | ||
|
|
ee717aced2 | ||
|
|
9a9152593e | ||
|
|
856d3dd513 | ||
|
|
0e42a3335a | ||
|
|
d96aae59d2 | ||
|
|
122722d0e9 | ||
|
|
0c2264e509 | ||
|
|
1e503e26f2 | ||
|
|
ec0fd63e30 | ||
|
|
dfd4c70b6e | ||
|
|
d79dc8877d | ||
|
|
e979350d40 | ||
|
|
99bf80a47a | ||
|
|
906998a07f | ||
|
|
02287c05be | ||
|
|
56aed4603e |
@@ -1,9 +1,9 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<BaseImage alt="OpenIsle" src="https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png" width="200">
|
<img alt="OpenIsle" src="https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png" width="200">
|
||||||
<br>
|
<br>
|
||||||
高效的开源社区前后端平台
|
高效的开源社区前后端平台
|
||||||
<br><br><br>
|
<br><br><br>
|
||||||
<BaseImage alt="Image" src="https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/22752cfac5a04a9c90c41995b9f55fed.png" width="1200">
|
<img alt="Image" src="https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/22752cfac5a04a9c90c41995b9f55fed.png" width="1200">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## 💡 简介
|
## 💡 简介
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ public class ReactionController {
|
|||||||
Authentication auth) {
|
Authentication auth) {
|
||||||
Reaction reaction = reactionService.reactToPost(auth.getName(), postId, req.getType());
|
Reaction reaction = reactionService.reactToPost(auth.getName(), postId, req.getType());
|
||||||
if (reaction == null) {
|
if (reaction == null) {
|
||||||
|
pointService.deductForReactionOfPost(auth.getName(), postId);
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
ReactionDto dto = reactionMapper.toDto(reaction);
|
ReactionDto dto = reactionMapper.toDto(reaction);
|
||||||
@@ -50,6 +51,7 @@ public class ReactionController {
|
|||||||
Authentication auth) {
|
Authentication auth) {
|
||||||
Reaction reaction = reactionService.reactToComment(auth.getName(), commentId, req.getType());
|
Reaction reaction = reactionService.reactToComment(auth.getName(), commentId, req.getType());
|
||||||
if (reaction == null) {
|
if (reaction == null) {
|
||||||
|
pointService.deductForReactionOfComment(auth.getName(), commentId);
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
ReactionDto dto = reactionMapper.toDto(reaction);
|
ReactionDto dto = reactionMapper.toDto(reaction);
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ public class Draft {
|
|||||||
|
|
||||||
private String title;
|
private String title;
|
||||||
|
|
||||||
@Column(columnDefinition = "TEXT")
|
@Column(columnDefinition = "LONGTEXT")
|
||||||
private String content;
|
private String content;
|
||||||
|
|
||||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ public enum PointHistoryType {
|
|||||||
COMMENT,
|
COMMENT,
|
||||||
POST_LIKED,
|
POST_LIKED,
|
||||||
COMMENT_LIKED,
|
COMMENT_LIKED,
|
||||||
|
POST_LIKE_CANCELLED,
|
||||||
|
COMMENT_LIKE_CANCELLED,
|
||||||
INVITE,
|
INVITE,
|
||||||
FEATURE,
|
FEATURE,
|
||||||
SYSTEM_ONLINE,
|
SYSTEM_ONLINE,
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ public class Post {
|
|||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
private String title;
|
private String title;
|
||||||
|
|
||||||
@Column(nullable = false, columnDefinition = "TEXT")
|
@Column(nullable = false, columnDefinition = "LONGTEXT")
|
||||||
private String content;
|
private String content;
|
||||||
|
|
||||||
@CreationTimestamp
|
@CreationTimestamp
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import com.openisle.model.User;
|
|||||||
import com.openisle.model.Post;
|
import com.openisle.model.Post;
|
||||||
import com.openisle.model.Comment;
|
import com.openisle.model.Comment;
|
||||||
import com.openisle.model.NotificationType;
|
import com.openisle.model.NotificationType;
|
||||||
|
import com.openisle.model.ReactionType;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
@@ -29,4 +30,8 @@ public interface NotificationRepository extends JpaRepository<Notification, Long
|
|||||||
List<Notification> findByTypeAndFromUser(NotificationType type, User fromUser);
|
List<Notification> findByTypeAndFromUser(NotificationType type, User fromUser);
|
||||||
|
|
||||||
void deleteByTypeAndFromUserAndPost(NotificationType type, User fromUser, Post post);
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,6 +114,14 @@ public class NotificationService {
|
|||||||
return n;
|
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.
|
* Create notifications for all admins when a user submits a register request.
|
||||||
* Old register request notifications from the same applicant are removed first.
|
* Old register request notifications from the same applicant are removed first.
|
||||||
|
|||||||
@@ -150,6 +150,16 @@ public class PointService {
|
|||||||
return addPoint(poster, 10, PointHistoryType.POST_LIKED, post, null, reactioner);
|
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) {
|
public int awardForReactionOfComment(String reactionerName, Long commentId) {
|
||||||
// 根据帖子id找到评论者
|
// 根据帖子id找到评论者
|
||||||
@@ -169,6 +179,17 @@ public class PointService {
|
|||||||
return addPoint(commenter, 10, PointHistoryType.COMMENT_LIKED, post, comment, reactioner);
|
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) {
|
public java.util.List<PointHistory> listHistory(String userName) {
|
||||||
User user = userRepository.findByUsername(userName).orElseThrow();
|
User user = userRepository.findByUsername(userName).orElseThrow();
|
||||||
if (pointHistoryRepository.countByUser(user) == 0) {
|
if (pointHistoryRepository.countByUser(user) == 0) {
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ public class ReactionService {
|
|||||||
java.util.Optional<Reaction> existing =
|
java.util.Optional<Reaction> existing =
|
||||||
reactionRepository.findByUserAndPostAndType(user, post, type);
|
reactionRepository.findByUserAndPostAndType(user, post, type);
|
||||||
if (existing.isPresent()) {
|
if (existing.isPresent()) {
|
||||||
|
notificationService.deleteReactionNotification(user, post, null, type);
|
||||||
reactionRepository.delete(existing.get());
|
reactionRepository.delete(existing.get());
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -65,6 +66,7 @@ public class ReactionService {
|
|||||||
java.util.Optional<Reaction> existing =
|
java.util.Optional<Reaction> existing =
|
||||||
reactionRepository.findByUserAndCommentAndType(user, comment, type);
|
reactionRepository.findByUserAndCommentAndType(user, comment, type);
|
||||||
if (existing.isPresent()) {
|
if (existing.isPresent()) {
|
||||||
|
notificationService.deleteReactionNotification(user, null, comment, type);
|
||||||
reactionRepository.delete(existing.get());
|
reactionRepository.delete(existing.get());
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ const goToNewPost = () => {
|
|||||||
height: 60px;
|
height: 60px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 40px;
|
bottom: 70px;
|
||||||
right: 20px;
|
right: 20px;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ body {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
white-space: break-spaces;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-content-text pre .line-numbers {
|
.info-content-text pre .line-numbers {
|
||||||
@@ -188,7 +189,6 @@ body {
|
|||||||
font-family: 'Maple Mono', monospace;
|
font-family: 'Maple Mono', monospace;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
white-space: break-spaces;
|
|
||||||
background-color: var(--code-highlight-background-color);
|
background-color: var(--code-highlight-background-color);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<div v-if="show" class="cropper-modal">
|
<div v-if="show" class="cropper-modal">
|
||||||
<div class="cropper-body">
|
<div class="cropper-body">
|
||||||
<div class="cropper-wrapper">
|
<div class="cropper-wrapper">
|
||||||
<BaseImage ref="image" :src="src" alt="to crop" />
|
<img ref="image" :src="src" alt="to crop" />
|
||||||
</div>
|
</div>
|
||||||
<div class="cropper-actions">
|
<div class="cropper-actions">
|
||||||
<button class="cropper-btn" @click="$emit('close')">取消</button>
|
<button class="cropper-btn" @click="$emit('close')">取消</button>
|
||||||
|
|||||||
@@ -63,7 +63,7 @@
|
|||||||
<DropdownMenu v-if="isLogin" ref="userMenu" :items="headerMenuItems">
|
<DropdownMenu v-if="isLogin" ref="userMenu" :items="headerMenuItems">
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<div class="avatar-container">
|
<div class="avatar-container">
|
||||||
<BaseImage class="avatar-img" :src="avatar" alt="avatar" />
|
<img class="avatar-img" :src="avatar" alt="avatar" />
|
||||||
<i class="fas fa-caret-down dropdown-icon"></i>
|
<i class="fas fa-caret-down dropdown-icon"></i>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
@click="reboundToDefault"
|
@click="reboundToDefault"
|
||||||
></i>
|
></i>
|
||||||
<i class="fas fa-expand" title="在页面中打开" @click="expand"></i>
|
<i class="fas fa-expand" title="在页面中打开" @click="expand"></i>
|
||||||
|
<i class="fas fa-times" title="关闭" @click="close"></i>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -48,6 +49,10 @@ function expand() {
|
|||||||
navigateTo(target)
|
navigateTo(target)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
floatRoute.value = null
|
||||||
|
}
|
||||||
|
|
||||||
function injectBaseTag() {
|
function injectBaseTag() {
|
||||||
if (!iframeRef.value) return
|
if (!iframeRef.value) return
|
||||||
|
|
||||||
|
|||||||
1299
frontend_nuxt/package-lock.json
generated
1299
frontend_nuxt/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@
|
|||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"ldrs": "^1.0.0",
|
"ldrs": "^1.0.0",
|
||||||
"markdown-it": "^14.1.0",
|
"markdown-it": "^14.1.0",
|
||||||
|
"mermaid": "^10.9.4",
|
||||||
"nprogress": "^0.2.0",
|
"nprogress": "^0.2.0",
|
||||||
"nuxt": "latest",
|
"nuxt": "latest",
|
||||||
"sanitize-html": "^2.17.0",
|
"sanitize-html": "^2.17.0",
|
||||||
|
|||||||
@@ -113,17 +113,23 @@ const error = ref(null)
|
|||||||
const conversationId = route.params.id
|
const conversationId = route.params.id
|
||||||
const currentUser = ref(null)
|
const currentUser = ref(null)
|
||||||
const messagesListEl = ref(null)
|
const messagesListEl = ref(null)
|
||||||
let lastMessageEl = null
|
|
||||||
const currentPage = ref(0)
|
const currentPage = ref(0)
|
||||||
const totalPages = ref(0)
|
const totalPages = ref(0)
|
||||||
const loadingMore = ref(false)
|
const loadingMore = ref(false)
|
||||||
let scrollInterval = null
|
|
||||||
const conversationName = ref('')
|
const conversationName = ref('')
|
||||||
const isChannel = ref(false)
|
const isChannel = ref(false)
|
||||||
const isFloatMode = computed(() => route.query.float !== undefined)
|
const isFloatMode = computed(() => route.query.float !== undefined)
|
||||||
const floatRoute = useState('messageFloatRoute')
|
const floatRoute = useState('messageFloatRoute')
|
||||||
const replyTo = ref(null)
|
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 hasMoreMessages = computed(() => currentPage.value < totalPages.value - 1)
|
||||||
|
|
||||||
const otherParticipant = computed(() => {
|
const otherParticipant = computed(() => {
|
||||||
@@ -133,20 +139,37 @@ const otherParticipant = computed(() => {
|
|||||||
return participants.value.find((p) => p.id !== currentUser.value.id)
|
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) {
|
function setReply(message) {
|
||||||
replyTo.value = message
|
replyTo.value = message
|
||||||
}
|
}
|
||||||
|
|
||||||
// No changes needed here, as renderMarkdown is now imported.
|
/** 改造:滚动函数 —— smooth & instant */
|
||||||
// The old function is removed.
|
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) {
|
async function fetchMessages(page = 0) {
|
||||||
if (page === 0) {
|
if (page === 0) {
|
||||||
@@ -181,7 +204,6 @@ async function fetchMessages(page = 0) {
|
|||||||
isChannel.value = conversationData.channel
|
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) => ({
|
const newMessages = pageData.content.reverse().map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
src: item.sender.avatar,
|
src: item.sender.avatar,
|
||||||
@@ -202,12 +224,16 @@ async function fetchMessages(page = 0) {
|
|||||||
currentPage.value = pageData.number
|
currentPage.value = pageData.number
|
||||||
totalPages.value = pageData.totalPages
|
totalPages.value = pageData.totalPages
|
||||||
|
|
||||||
// Scrolling is now fully handled by the watcher
|
|
||||||
await nextTick()
|
await nextTick()
|
||||||
if (page > 0 && list) {
|
if (page > 0 && list) {
|
||||||
|
// 加载更多:保持原视口位置
|
||||||
const newScrollHeight = list.scrollHeight
|
const newScrollHeight = list.scrollHeight
|
||||||
list.scrollTop = newScrollHeight - oldScrollHeight
|
list.scrollTop = newScrollHeight - oldScrollHeight
|
||||||
|
} else if (page === 0) {
|
||||||
|
// 首次加载:定位到底部(不用动画,避免“闪动感”)
|
||||||
|
scrollToBottomInstant()
|
||||||
}
|
}
|
||||||
|
updateNearBottom()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e.message
|
error.value = e.message
|
||||||
toast.error(e.message)
|
toast.error(e.message)
|
||||||
@@ -272,9 +298,10 @@ async function sendMessage(content, clearInput) {
|
|||||||
})
|
})
|
||||||
clearInput()
|
clearInput()
|
||||||
replyTo.value = null
|
replyTo.value = null
|
||||||
setTimeout(() => {
|
|
||||||
scrollToBottom()
|
await nextTick()
|
||||||
}, 100)
|
// 仅“发送消息成功后”才平滑滚动到底部
|
||||||
|
scrollToBottomSmooth()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e.message)
|
toast.error(e.message)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -290,7 +317,6 @@ async function markConversationAsRead() {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
})
|
})
|
||||||
// After marking as read, refresh the global unread count
|
|
||||||
refreshGlobalUnreadCount()
|
refreshGlobalUnreadCount()
|
||||||
refreshChannelUnread()
|
refreshChannelUnread()
|
||||||
} catch (e) {
|
} 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 () => {
|
onMounted(async () => {
|
||||||
|
// 监听列表滚动,实时感知是否接近底部
|
||||||
|
if (messagesListEl.value) {
|
||||||
|
messagesListEl.value.addEventListener('scroll', updateNearBottom, { passive: true })
|
||||||
|
}
|
||||||
|
|
||||||
currentUser.value = await fetchCurrentUser()
|
currentUser.value = await fetchCurrentUser()
|
||||||
if (currentUser.value) {
|
if (currentUser.value) {
|
||||||
await fetchMessages(0)
|
await fetchMessages(0)
|
||||||
@@ -345,9 +346,8 @@ onMounted(async () => {
|
|||||||
|
|
||||||
watch(isConnected, (newValue) => {
|
watch(isConnected, (newValue) => {
|
||||||
if (newValue) {
|
if (newValue) {
|
||||||
// 等待一小段时间确保连接稳定
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
subscription = subscribe(`/topic/conversation/${conversationId}`, (message) => {
|
subscription = subscribe(`/topic/conversation/${conversationId}`, async (message) => {
|
||||||
// 避免重复显示当前用户发送的消息
|
// 避免重复显示当前用户发送的消息
|
||||||
if (message.sender.id !== currentUser.value.id) {
|
if (message.sender.id !== currentUser.value.id) {
|
||||||
messages.value.push({
|
messages.value.push({
|
||||||
@@ -357,11 +357,10 @@ watch(isConnected, (newValue) => {
|
|||||||
openUser(message.sender.id)
|
openUser(message.sender.id)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
// 实时收到消息时自动标记为已读
|
// 收到消息后只标记已读,不强制滚动(符合“非发送不拉底”)
|
||||||
markConversationAsRead()
|
markConversationAsRead()
|
||||||
setTimeout(() => {
|
await nextTick()
|
||||||
scrollToBottom()
|
updateNearBottom()
|
||||||
}, 100)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}, 500)
|
}, 500)
|
||||||
@@ -369,23 +368,12 @@ watch(isConnected, (newValue) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onActivated(async () => {
|
onActivated(async () => {
|
||||||
// This will be called every time the component is activated (navigated to)
|
// 返回页面时:刷新数据与已读,不做强制滚动,保持用户当前位置
|
||||||
if (currentUser.value) {
|
if (currentUser.value) {
|
||||||
await fetchMessages(0)
|
await fetchMessages(0)
|
||||||
await markConversationAsRead()
|
await markConversationAsRead()
|
||||||
|
|
||||||
// 確保滾動到底部 - 使用多重延遲策略
|
|
||||||
await nextTick()
|
await nextTick()
|
||||||
setTimeout(() => {
|
updateNearBottom()
|
||||||
scrollToBottom()
|
|
||||||
}, 100)
|
|
||||||
setTimeout(() => {
|
|
||||||
scrollToBottom()
|
|
||||||
}, 300)
|
|
||||||
setTimeout(() => {
|
|
||||||
scrollToBottom()
|
|
||||||
}, 500)
|
|
||||||
|
|
||||||
if (!isConnected.value) {
|
if (!isConnected.value) {
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
if (token) connect(token)
|
if (token) connect(token)
|
||||||
@@ -406,6 +394,9 @@ onUnmounted(() => {
|
|||||||
subscription.unsubscribe()
|
subscription.unsubscribe()
|
||||||
subscription = null
|
subscription = null
|
||||||
}
|
}
|
||||||
|
if (messagesListEl.value) {
|
||||||
|
messagesListEl.value.removeEventListener('scroll', updateNearBottom)
|
||||||
|
}
|
||||||
disconnect()
|
disconnect()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -104,6 +104,31 @@
|
|||||||
,获得{{ item.amount }}积分
|
,获得{{ item.amount }}积分
|
||||||
</template>
|
</template>
|
||||||
</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">
|
<template v-else-if="item.type === 'POST_LIKED' && item.fromUserId">
|
||||||
帖子
|
帖子
|
||||||
<NuxtLink :to="`/posts/${item.postId}`" class="timeline-link">{{
|
<NuxtLink :to="`/posts/${item.postId}`" class="timeline-link">{{
|
||||||
@@ -227,6 +252,8 @@ const iconMap = {
|
|||||||
FEATURE: 'fas fa-star',
|
FEATURE: 'fas fa-star',
|
||||||
LOTTERY_JOIN: 'fas fa-ticket-alt',
|
LOTTERY_JOIN: 'fas fa-ticket-alt',
|
||||||
LOTTERY_REWARD: '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 () => {
|
const loadTrend = async () => {
|
||||||
|
|||||||
@@ -96,6 +96,9 @@ const md = new MarkdownIt({
|
|||||||
linkify: true,
|
linkify: true,
|
||||||
breaks: true,
|
breaks: true,
|
||||||
highlight: (str, lang) => {
|
highlight: (str, lang) => {
|
||||||
|
if (lang === 'mermaid') {
|
||||||
|
return `<pre class="mermaid">${str}</pre>`
|
||||||
|
}
|
||||||
let code = ''
|
let code = ''
|
||||||
if (lang && hljs.getLanguage(lang)) {
|
if (lang && hljs.getLanguage(lang)) {
|
||||||
code = hljs.highlight(str, { language: lang, ignoreIllegals: true }).value
|
code = hljs.highlight(str, { language: lang, ignoreIllegals: true }).value
|
||||||
@@ -182,7 +185,7 @@ const SANITIZE_CFG = {
|
|||||||
allowedClasses: {
|
allowedClasses: {
|
||||||
a: ['mention-link'],
|
a: ['mention-link'],
|
||||||
img: ['emoji'],
|
img: ['emoji'],
|
||||||
pre: ['code-block'],
|
pre: ['code-block', 'mermaid'],
|
||||||
div: ['line-numbers', 'line-number'],
|
div: ['line-numbers', 'line-number'],
|
||||||
code: ['hljs', /^language-/],
|
code: ['hljs', /^language-/],
|
||||||
button: ['copy-code-btn'],
|
button: ['copy-code-btn'],
|
||||||
@@ -208,8 +211,15 @@ const SANITIZE_CFG = {
|
|||||||
/** @section 渲染 & 事件 */
|
/** @section 渲染 & 事件 */
|
||||||
export function renderMarkdown(text) {
|
export function renderMarkdown(text) {
|
||||||
const raw = md.render(text || '')
|
const raw = md.render(text || '')
|
||||||
// ⭐ 核心:对最终 HTML 进行一次净化
|
const html = sanitizeHtml(raw, SANITIZE_CFG)
|
||||||
return sanitizeHtml(raw, SANITIZE_CFG)
|
if (typeof window !== 'undefined') {
|
||||||
|
setTimeout(async () => {
|
||||||
|
const mermaid = await import('mermaid')
|
||||||
|
mermaid.default.initialize({ startOnLoad: false })
|
||||||
|
mermaid.default.run({ nodes: document.querySelectorAll('.mermaid') })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return html
|
||||||
}
|
}
|
||||||
|
|
||||||
export function handleMarkdownClick(e) {
|
export function handleMarkdownClick(e) {
|
||||||
|
|||||||
@@ -4,6 +4,17 @@ export default class TimeManager {
|
|||||||
if (Number.isNaN(date.getTime())) return ''
|
if (Number.isNaN(date.getTime())) return ''
|
||||||
|
|
||||||
const now = new Date()
|
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 startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||||
const startOfDate = new Date(date.getFullYear(), date.getMonth(), date.getDate())
|
const startOfDate = new Date(date.getFullYear(), date.getMonth(), date.getDate())
|
||||||
const diffDays = Math.floor((startOfToday - startOfDate) / 86400000)
|
const diffDays = Math.floor((startOfToday - startOfDate) / 86400000)
|
||||||
|
|||||||
Reference in New Issue
Block a user