mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-07 15:41:02 +08:00
Compare commits
37 Commits
codex/add-
...
codex/crea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db9b48c3a4 | ||
|
|
3eabafadf8 | ||
|
|
62c1983fd5 | ||
|
|
689b719e18 | ||
|
|
c6eccb01b9 | ||
|
|
cdf7e61157 | ||
|
|
d23511ecb9 | ||
|
|
c76708d2ff | ||
|
|
d978bd428e | ||
|
|
e5954cfb62 | ||
|
|
cb614b9739 | ||
|
|
88ce6b682d | ||
|
|
e02db635c4 | ||
|
|
231379181a | ||
|
|
bd9ce67d4b | ||
|
|
6527b3790e | ||
|
|
f01e8c942a | ||
|
|
1e1ae29d32 | ||
|
|
d31a8bfee4 | ||
|
|
29a96595f7 | ||
|
|
2b242367d7 | ||
|
|
3f0cd2bf0f | ||
|
|
a98a631378 | ||
|
|
7701d359dc | ||
|
|
ffd9ef8a32 | ||
|
|
36cd5ab171 | ||
|
|
58d86fa065 | ||
|
|
df71cf901b | ||
|
|
ac3fc6702a | ||
|
|
b0eef220a6 | ||
|
|
02d366e2c7 | ||
|
|
6409531a64 | ||
|
|
175ab79b27 | ||
|
|
b543953d22 | ||
|
|
b4fef68af5 | ||
|
|
6c48a38212 | ||
|
|
8a3e4d8e98 |
@@ -5,7 +5,6 @@ import com.openisle.dto.ConversationDto;
|
||||
import com.openisle.dto.CreateConversationRequest;
|
||||
import com.openisle.dto.CreateConversationResponse;
|
||||
import com.openisle.dto.MessageDto;
|
||||
import com.openisle.dto.UserSummaryDto;
|
||||
import com.openisle.model.Message;
|
||||
import com.openisle.model.MessageConversation;
|
||||
import com.openisle.model.User;
|
||||
@@ -55,16 +54,16 @@ public class MessageController {
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<MessageDto> sendMessage(@RequestBody MessageRequest req, Authentication auth) {
|
||||
Message message = messageService.sendMessage(getCurrentUserId(auth), req.getRecipientId(), req.getContent());
|
||||
return ResponseEntity.ok(toDto(message));
|
||||
Message message = messageService.sendMessage(getCurrentUserId(auth), req.getRecipientId(), req.getContent(), req.getReplyToId());
|
||||
return ResponseEntity.ok(messageService.toDto(message));
|
||||
}
|
||||
|
||||
@PostMapping("/conversations/{conversationId}/messages")
|
||||
public ResponseEntity<MessageDto> sendMessageToConversation(@PathVariable Long conversationId,
|
||||
@RequestBody ChannelMessageRequest req,
|
||||
Authentication auth) {
|
||||
Message message = messageService.sendMessageToConversation(getCurrentUserId(auth), conversationId, req.getContent());
|
||||
return ResponseEntity.ok(toDto(message));
|
||||
Message message = messageService.sendMessageToConversation(getCurrentUserId(auth), conversationId, req.getContent(), req.getReplyToId());
|
||||
return ResponseEntity.ok(messageService.toDto(message));
|
||||
}
|
||||
|
||||
@PostMapping("/conversations/{conversationId}/read")
|
||||
@@ -79,23 +78,6 @@ public class MessageController {
|
||||
return ResponseEntity.ok(new CreateConversationResponse(conversation.getId()));
|
||||
}
|
||||
|
||||
private MessageDto toDto(Message message) {
|
||||
MessageDto dto = new MessageDto();
|
||||
dto.setId(message.getId());
|
||||
dto.setContent(message.getContent());
|
||||
dto.setCreatedAt(message.getCreatedAt());
|
||||
|
||||
dto.setConversationId(message.getConversation().getId());
|
||||
|
||||
UserSummaryDto senderDto = new UserSummaryDto();
|
||||
senderDto.setId(message.getSender().getId());
|
||||
senderDto.setUsername(message.getSender().getUsername());
|
||||
senderDto.setAvatar(message.getSender().getAvatar());
|
||||
dto.setSender(senderDto);
|
||||
|
||||
return dto;
|
||||
}
|
||||
|
||||
@GetMapping("/unread-count")
|
||||
public ResponseEntity<Long> getUnreadCount(Authentication auth) {
|
||||
return ResponseEntity.ok(messageService.getUnreadMessageCount(getCurrentUserId(auth)));
|
||||
@@ -105,6 +87,7 @@ public class MessageController {
|
||||
static class MessageRequest {
|
||||
private Long recipientId;
|
||||
private String content;
|
||||
private Long replyToId;
|
||||
|
||||
public Long getRecipientId() {
|
||||
return recipientId;
|
||||
@@ -121,10 +104,19 @@ public class MessageController {
|
||||
public void setContent(String content) {
|
||||
this.content = content;
|
||||
}
|
||||
|
||||
public Long getReplyToId() {
|
||||
return replyToId;
|
||||
}
|
||||
|
||||
public void setReplyToId(Long replyToId) {
|
||||
this.replyToId = replyToId;
|
||||
}
|
||||
}
|
||||
|
||||
static class ChannelMessageRequest {
|
||||
private String content;
|
||||
private Long replyToId;
|
||||
|
||||
public String getContent() {
|
||||
return content;
|
||||
@@ -133,5 +125,13 @@ public class MessageController {
|
||||
public void setContent(String content) {
|
||||
this.content = content;
|
||||
}
|
||||
|
||||
public Long getReplyToId() {
|
||||
return replyToId;
|
||||
}
|
||||
|
||||
public void setReplyToId(Long replyToId) {
|
||||
this.replyToId = replyToId;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,8 @@ public class PostController {
|
||||
Post post = postService.createPost(auth.getName(), req.getCategoryId(),
|
||||
req.getTitle(), req.getContent(), req.getTagIds(),
|
||||
req.getType(), req.getPrizeDescription(), req.getPrizeIcon(),
|
||||
req.getPrizeCount(), req.getStartTime(), req.getEndTime());
|
||||
req.getPrizeCount(), req.getPointCost(),
|
||||
req.getStartTime(), req.getEndTime());
|
||||
draftService.deleteDraft(auth.getName());
|
||||
PostDetailDto dto = postMapper.toDetailDto(post, auth.getName());
|
||||
dto.setReward(levelService.awardForPost(auth.getName()));
|
||||
|
||||
@@ -57,4 +57,17 @@ public class ReactionController {
|
||||
pointService.awardForReactionOfComment(auth.getName(), commentId);
|
||||
return ResponseEntity.ok(dto);
|
||||
}
|
||||
|
||||
@PostMapping("/messages/{messageId}/reactions")
|
||||
public ResponseEntity<ReactionDto> reactToMessage(@PathVariable Long messageId,
|
||||
@RequestBody ReactionRequest req,
|
||||
Authentication auth) {
|
||||
Reaction reaction = reactionService.reactToMessage(auth.getName(), messageId, req.getType());
|
||||
if (reaction == null) {
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
ReactionDto dto = reactionMapper.toDto(reaction);
|
||||
dto.setReward(levelService.awardForReaction(auth.getName()));
|
||||
return ResponseEntity.ok(dto);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +105,17 @@ public class UserController {
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
}
|
||||
|
||||
@GetMapping("/{identifier}/subscribed-posts")
|
||||
public java.util.List<PostMetaDto> subscribedPosts(@PathVariable("identifier") String identifier,
|
||||
@RequestParam(value = "limit", required = false) Integer limit) {
|
||||
int l = limit != null ? limit : defaultPostsLimit;
|
||||
User user = userService.findByIdentifier(identifier).orElseThrow();
|
||||
return subscriptionService.getSubscribedPosts(user.getUsername()).stream()
|
||||
.limit(l)
|
||||
.map(userMapper::toMetaDto)
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
}
|
||||
|
||||
@GetMapping("/{identifier}/replies")
|
||||
public java.util.List<CommentInfoDto> userReplies(@PathVariable("identifier") String identifier,
|
||||
@RequestParam(value = "limit", required = false) Integer limit) {
|
||||
|
||||
@@ -10,6 +10,7 @@ public class LotteryDto {
|
||||
private String prizeDescription;
|
||||
private String prizeIcon;
|
||||
private int prizeCount;
|
||||
private int pointCost;
|
||||
private LocalDateTime startTime;
|
||||
private LocalDateTime endTime;
|
||||
private List<AuthorDto> participants;
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.openisle.dto;
|
||||
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
public class MessageDto {
|
||||
@@ -10,4 +11,6 @@ public class MessageDto {
|
||||
private UserSummaryDto sender;
|
||||
private Long conversationId;
|
||||
private LocalDateTime createdAt;
|
||||
private MessageDto replyTo;
|
||||
private List<ReactionDto> reactions;
|
||||
}
|
||||
@@ -23,6 +23,7 @@ public class PostRequest {
|
||||
private String prizeDescription;
|
||||
private String prizeIcon;
|
||||
private Integer prizeCount;
|
||||
private Integer pointCost;
|
||||
private LocalDateTime startTime;
|
||||
private LocalDateTime endTime;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import com.openisle.model.ReactionType;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* DTO representing a reaction on a post or comment.
|
||||
* DTO representing a reaction on a post, comment or message.
|
||||
*/
|
||||
@Data
|
||||
public class ReactionDto {
|
||||
@@ -13,6 +13,7 @@ public class ReactionDto {
|
||||
private String user;
|
||||
private Long postId;
|
||||
private Long commentId;
|
||||
private Long messageId;
|
||||
private int reward;
|
||||
}
|
||||
|
||||
|
||||
@@ -86,6 +86,7 @@ public class PostMapper {
|
||||
l.setPrizeDescription(lp.getPrizeDescription());
|
||||
l.setPrizeIcon(lp.getPrizeIcon());
|
||||
l.setPrizeCount(lp.getPrizeCount());
|
||||
l.setPointCost(lp.getPointCost());
|
||||
l.setStartTime(lp.getStartTime());
|
||||
l.setEndTime(lp.getEndTime());
|
||||
l.setParticipants(lp.getParticipants().stream().map(userMapper::toAuthorDto).collect(Collectors.toList()));
|
||||
|
||||
@@ -19,6 +19,9 @@ public class ReactionMapper {
|
||||
if (reaction.getComment() != null) {
|
||||
dto.setCommentId(reaction.getComment().getId());
|
||||
}
|
||||
if (reaction.getMessage() != null) {
|
||||
dto.setMessageId(reaction.getMessage().getId());
|
||||
}
|
||||
dto.setReward(0);
|
||||
return dto;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,9 @@ public class LotteryPost extends Post {
|
||||
@Column(nullable = false)
|
||||
private int prizeCount;
|
||||
|
||||
@Column(nullable = false)
|
||||
private int pointCost;
|
||||
|
||||
@Column
|
||||
private LocalDateTime startTime;
|
||||
|
||||
|
||||
@@ -29,6 +29,10 @@ public class Message {
|
||||
@Column(nullable = false, columnDefinition = "TEXT")
|
||||
private String content;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "reply_to_id")
|
||||
private Message replyTo;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@@ -8,5 +8,7 @@ public enum PointHistoryType {
|
||||
INVITE,
|
||||
FEATURE,
|
||||
SYSTEM_ONLINE,
|
||||
REDEEM
|
||||
REDEEM,
|
||||
LOTTERY_JOIN,
|
||||
LOTTERY_REWARD
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import lombok.Setter;
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
|
||||
/**
|
||||
* Reaction entity representing a user's reaction to a post or comment.
|
||||
* Reaction entity representing a user's reaction to a post, comment or message.
|
||||
*/
|
||||
@Entity
|
||||
@Getter
|
||||
@@ -16,7 +16,8 @@ import org.hibernate.annotations.CreationTimestamp;
|
||||
@Table(name = "reactions",
|
||||
uniqueConstraints = {
|
||||
@UniqueConstraint(columnNames = {"user_id", "post_id", "type"}),
|
||||
@UniqueConstraint(columnNames = {"user_id", "comment_id", "type"})
|
||||
@UniqueConstraint(columnNames = {"user_id", "comment_id", "type"}),
|
||||
@UniqueConstraint(columnNames = {"user_id", "message_id", "type"})
|
||||
})
|
||||
public class Reaction {
|
||||
@Id
|
||||
@@ -39,6 +40,10 @@ public class Reaction {
|
||||
@JoinColumn(name = "comment_id")
|
||||
private Comment comment;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "message_id")
|
||||
private Message message;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(nullable = false, updatable = false,
|
||||
columnDefinition = "DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6)")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.openisle.repository;
|
||||
|
||||
import com.openisle.model.Comment;
|
||||
import com.openisle.model.Message;
|
||||
import com.openisle.model.Post;
|
||||
import com.openisle.model.Reaction;
|
||||
import com.openisle.model.User;
|
||||
@@ -15,8 +16,10 @@ import java.util.Optional;
|
||||
public interface ReactionRepository extends JpaRepository<Reaction, Long> {
|
||||
Optional<Reaction> findByUserAndPostAndType(User user, Post post, com.openisle.model.ReactionType type);
|
||||
Optional<Reaction> findByUserAndCommentAndType(User user, Comment comment, com.openisle.model.ReactionType type);
|
||||
Optional<Reaction> findByUserAndMessageAndType(User user, Message message, com.openisle.model.ReactionType type);
|
||||
List<Reaction> findByPost(Post post);
|
||||
List<Reaction> findByComment(Comment comment);
|
||||
List<Reaction> findByMessage(Message message);
|
||||
|
||||
@Query("SELECT r.post.id FROM Reaction r WHERE r.post IS NOT NULL AND r.post.author.username = :username AND r.type = com.openisle.model.ReactionType.LIKE GROUP BY r.post.id ORDER BY COUNT(r.id) DESC")
|
||||
List<Long> findTopPostIds(@Param("username") String username, Pageable pageable);
|
||||
|
||||
@@ -4,14 +4,18 @@ import com.openisle.model.Message;
|
||||
import com.openisle.model.MessageConversation;
|
||||
import com.openisle.model.MessageParticipant;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.model.Reaction;
|
||||
import com.openisle.repository.MessageConversationRepository;
|
||||
import com.openisle.repository.MessageParticipantRepository;
|
||||
import com.openisle.repository.MessageRepository;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import com.openisle.repository.ReactionRepository;
|
||||
import com.openisle.dto.ConversationDetailDto;
|
||||
import com.openisle.dto.ConversationDto;
|
||||
import com.openisle.dto.MessageDto;
|
||||
import com.openisle.dto.ReactionDto;
|
||||
import com.openisle.dto.UserSummaryDto;
|
||||
import com.openisle.mapper.ReactionMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.domain.Page;
|
||||
@@ -34,9 +38,11 @@ public class MessageService {
|
||||
private final MessageParticipantRepository participantRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final SimpMessagingTemplate messagingTemplate;
|
||||
private final ReactionRepository reactionRepository;
|
||||
private final ReactionMapper reactionMapper;
|
||||
|
||||
@Transactional
|
||||
public Message sendMessage(Long senderId, Long recipientId, String content) {
|
||||
public Message sendMessage(Long senderId, Long recipientId, String content, Long replyToId) {
|
||||
log.info("Attempting to send message from user {} to user {}", senderId, recipientId);
|
||||
User sender = userRepository.findById(senderId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Sender not found"));
|
||||
@@ -51,6 +57,11 @@ public class MessageService {
|
||||
message.setConversation(conversation);
|
||||
message.setSender(sender);
|
||||
message.setContent(content);
|
||||
if (replyToId != null) {
|
||||
Message replyTo = messageRepository.findById(replyToId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Message not found"));
|
||||
message.setReplyTo(replyTo);
|
||||
}
|
||||
message = messageRepository.save(message);
|
||||
log.info("Message saved with ID: {}", message.getId());
|
||||
|
||||
@@ -83,7 +94,7 @@ public class MessageService {
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Message sendMessageToConversation(Long senderId, Long conversationId, String content) {
|
||||
public Message sendMessageToConversation(Long senderId, Long conversationId, String content, Long replyToId) {
|
||||
User sender = userRepository.findById(senderId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Sender not found"));
|
||||
MessageConversation conversation = conversationRepository.findById(conversationId)
|
||||
@@ -102,6 +113,11 @@ public class MessageService {
|
||||
message.setConversation(conversation);
|
||||
message.setSender(sender);
|
||||
message.setContent(content);
|
||||
if (replyToId != null) {
|
||||
Message replyTo = messageRepository.findById(replyToId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Message not found"));
|
||||
message.setReplyTo(replyTo);
|
||||
}
|
||||
message = messageRepository.save(message);
|
||||
|
||||
conversation.setLastMessage(message);
|
||||
@@ -128,7 +144,7 @@ public class MessageService {
|
||||
return message;
|
||||
}
|
||||
|
||||
private MessageDto toDto(Message message) {
|
||||
public MessageDto toDto(Message message) {
|
||||
MessageDto dto = new MessageDto();
|
||||
dto.setId(message.getId());
|
||||
dto.setContent(message.getContent());
|
||||
@@ -141,6 +157,25 @@ public class MessageService {
|
||||
userSummaryDto.setAvatar(message.getSender().getAvatar());
|
||||
dto.setSender(userSummaryDto);
|
||||
|
||||
if (message.getReplyTo() != null) {
|
||||
Message reply = message.getReplyTo();
|
||||
MessageDto replyDto = new MessageDto();
|
||||
replyDto.setId(reply.getId());
|
||||
replyDto.setContent(reply.getContent());
|
||||
UserSummaryDto replySender = new UserSummaryDto();
|
||||
replySender.setId(reply.getSender().getId());
|
||||
replySender.setUsername(reply.getSender().getUsername());
|
||||
replySender.setAvatar(reply.getSender().getAvatar());
|
||||
replyDto.setSender(replySender);
|
||||
dto.setReplyTo(replyDto);
|
||||
}
|
||||
|
||||
java.util.List<Reaction> reactions = reactionRepository.findByMessage(message);
|
||||
java.util.List<ReactionDto> reactionDtos = reactions.stream()
|
||||
.map(reactionMapper::toDto)
|
||||
.collect(Collectors.toList());
|
||||
dto.setReactions(reactionDtos);
|
||||
|
||||
return dto;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.openisle.service;
|
||||
|
||||
import com.openisle.model.*;
|
||||
import com.openisle.repository.*;
|
||||
import com.openisle.exception.FieldException;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@@ -39,6 +40,17 @@ public class PointService {
|
||||
return addPoint(user, 500, PointHistoryType.FEATURE, post, null, null);
|
||||
}
|
||||
|
||||
public void processLotteryJoin(User participant, LotteryPost post) {
|
||||
int cost = post.getPointCost();
|
||||
if (cost > 0) {
|
||||
if (participant.getPoint() < cost) {
|
||||
throw new FieldException("point", "积分不足");
|
||||
}
|
||||
addPoint(participant, -cost, PointHistoryType.LOTTERY_JOIN, post, null, post.getAuthor());
|
||||
addPoint(post.getAuthor(), cost, PointHistoryType.LOTTERY_REWARD, post, null, participant);
|
||||
}
|
||||
}
|
||||
|
||||
private PointLog getTodayLog(User user) {
|
||||
LocalDate today = LocalDate.now();
|
||||
return pointLogRepository.findByUserAndLogDate(user, today)
|
||||
|
||||
@@ -164,6 +164,7 @@ public class PostService {
|
||||
String prizeDescription,
|
||||
String prizeIcon,
|
||||
Integer prizeCount,
|
||||
Integer pointCost,
|
||||
LocalDateTime startTime,
|
||||
LocalDateTime endTime) {
|
||||
long recent = postRepository.countByAuthorAfter(username,
|
||||
@@ -188,10 +189,14 @@ public class PostService {
|
||||
PostType actualType = type != null ? type : PostType.NORMAL;
|
||||
Post post;
|
||||
if (actualType == PostType.LOTTERY) {
|
||||
if (pointCost != null && (pointCost < 0 || pointCost > 100)) {
|
||||
throw new IllegalArgumentException("pointCost must be between 0 and 100");
|
||||
}
|
||||
LotteryPost lp = new LotteryPost();
|
||||
lp.setPrizeDescription(prizeDescription);
|
||||
lp.setPrizeIcon(prizeIcon);
|
||||
lp.setPrizeCount(prizeCount != null ? prizeCount : 0);
|
||||
lp.setPointCost(pointCost != null ? pointCost : 0);
|
||||
lp.setStartTime(startTime);
|
||||
lp.setEndTime(endTime);
|
||||
post = lp;
|
||||
@@ -250,8 +255,10 @@ public class PostService {
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||
post.getParticipants().add(user);
|
||||
lotteryPostRepository.save(post);
|
||||
if (post.getParticipants().add(user)) {
|
||||
pointService.processLotteryJoin(user, post);
|
||||
lotteryPostRepository.save(post);
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
|
||||
@@ -6,10 +6,12 @@ import com.openisle.model.Reaction;
|
||||
import com.openisle.model.ReactionType;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.model.NotificationType;
|
||||
import com.openisle.model.Message;
|
||||
import com.openisle.repository.CommentRepository;
|
||||
import com.openisle.repository.PostRepository;
|
||||
import com.openisle.repository.ReactionRepository;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import com.openisle.repository.MessageRepository;
|
||||
import com.openisle.service.NotificationService;
|
||||
import com.openisle.service.EmailSender;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -24,6 +26,7 @@ public class ReactionService {
|
||||
private final UserRepository userRepository;
|
||||
private final PostRepository postRepository;
|
||||
private final CommentRepository commentRepository;
|
||||
private final MessageRepository messageRepository;
|
||||
private final NotificationService notificationService;
|
||||
private final EmailSender emailSender;
|
||||
|
||||
@@ -77,6 +80,26 @@ public class ReactionService {
|
||||
return reaction;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Reaction reactToMessage(String username, Long messageId, ReactionType type) {
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||
Message message = messageRepository.findById(messageId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Message not found"));
|
||||
java.util.Optional<Reaction> existing =
|
||||
reactionRepository.findByUserAndMessageAndType(user, message, type);
|
||||
if (existing.isPresent()) {
|
||||
reactionRepository.delete(existing.get());
|
||||
return null;
|
||||
}
|
||||
Reaction reaction = new Reaction();
|
||||
reaction.setUser(user);
|
||||
reaction.setMessage(message);
|
||||
reaction.setType(type);
|
||||
reaction = reactionRepository.save(reaction);
|
||||
return reaction;
|
||||
}
|
||||
|
||||
public java.util.List<Reaction> getReactionsForPost(Long postId) {
|
||||
Post post = postRepository.findById(postId)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
|
||||
|
||||
@@ -107,6 +107,11 @@ public class SubscriptionService {
|
||||
return commentSubRepo.findByComment(c).stream().map(CommentSubscription::getUser).toList();
|
||||
}
|
||||
|
||||
public List<Post> getSubscribedPosts(String username) {
|
||||
User user = userRepo.findByUsername(username).orElseThrow();
|
||||
return postSubRepo.findByUser(user).stream().map(PostSubscription::getPost).toList();
|
||||
}
|
||||
|
||||
|
||||
public long countSubscribers(String username) {
|
||||
User user = userRepo.findByUsername(username).orElseThrow();
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE lottery_posts ADD COLUMN point_cost INT NOT NULL DEFAULT 0;
|
||||
@@ -76,7 +76,7 @@ class PostControllerTest {
|
||||
post.setTags(Set.of(tag));
|
||||
|
||||
when(postService.createPost(eq("alice"), eq(1L), eq("t"), eq("c"), eq(List.of(1L)),
|
||||
isNull(), isNull(), isNull(), isNull(), isNull(), isNull())).thenReturn(post);
|
||||
isNull(), isNull(), isNull(), isNull(), isNull(), isNull(), isNull())).thenReturn(post);
|
||||
when(postService.viewPost(eq(1L), any())).thenReturn(post);
|
||||
when(commentService.getCommentsForPost(eq(1L), any())).thenReturn(List.of());
|
||||
when(commentService.getParticipants(anyLong(), anyInt())).thenReturn(List.of());
|
||||
@@ -187,7 +187,7 @@ class PostControllerTest {
|
||||
.andExpect(status().isBadRequest());
|
||||
|
||||
verify(postService, never()).createPost(any(), any(), any(), any(), any(),
|
||||
any(), any(), any(), any(), any(), any());
|
||||
any(), any(), any(), any(), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.openisle.model.Post;
|
||||
import com.openisle.model.Reaction;
|
||||
import com.openisle.model.ReactionType;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.model.Message;
|
||||
import com.openisle.service.ReactionService;
|
||||
import com.openisle.service.LevelService;
|
||||
import com.openisle.mapper.ReactionMapper;
|
||||
@@ -78,6 +79,27 @@ class ReactionControllerTest {
|
||||
.andExpect(jsonPath("$.commentId").value(2));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reactToMessage() throws Exception {
|
||||
User user = new User();
|
||||
user.setUsername("u3");
|
||||
Message message = new Message();
|
||||
message.setId(3L);
|
||||
Reaction reaction = new Reaction();
|
||||
reaction.setId(3L);
|
||||
reaction.setUser(user);
|
||||
reaction.setMessage(message);
|
||||
reaction.setType(ReactionType.LIKE);
|
||||
Mockito.when(reactionService.reactToMessage(eq("u3"), eq(3L), eq(ReactionType.LIKE))).thenReturn(reaction);
|
||||
|
||||
mockMvc.perform(post("/api/messages/3/reactions")
|
||||
.contentType("application/json")
|
||||
.content("{\"type\":\"LIKE\"}")
|
||||
.principal(new UsernamePasswordAuthenticationToken("u3", "p")))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.messageId").value(3));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listReactionTypes() throws Exception {
|
||||
mockMvc.perform(get("/api/reaction-types"))
|
||||
|
||||
@@ -136,6 +136,30 @@ class UserControllerTest {
|
||||
.andExpect(jsonPath("$[0].title").value("hello"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listSubscribedPosts() throws Exception {
|
||||
User user = new User();
|
||||
user.setUsername("bob");
|
||||
com.openisle.model.Category cat = new com.openisle.model.Category();
|
||||
cat.setName("tech");
|
||||
com.openisle.model.Post post = new com.openisle.model.Post();
|
||||
post.setId(6L);
|
||||
post.setTitle("fav");
|
||||
post.setCreatedAt(java.time.LocalDateTime.now());
|
||||
post.setCategory(cat);
|
||||
post.setAuthor(user);
|
||||
Mockito.when(userService.findByIdentifier("bob")).thenReturn(Optional.of(user));
|
||||
Mockito.when(subscriptionService.getSubscribedPosts("bob")).thenReturn(java.util.List.of(post));
|
||||
PostMetaDto meta = new PostMetaDto();
|
||||
meta.setId(6L);
|
||||
meta.setTitle("fav");
|
||||
Mockito.when(userMapper.toMetaDto(post)).thenReturn(meta);
|
||||
|
||||
mockMvc.perform(get("/api/users/bob/subscribed-posts"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].title").value("fav"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listUserReplies() throws Exception {
|
||||
User user = new User();
|
||||
|
||||
@@ -146,7 +146,7 @@ class PostServiceTest {
|
||||
|
||||
assertThrows(RateLimitException.class,
|
||||
() -> service.createPost("alice", 1L, "t", "c", List.of(1L),
|
||||
null, null, null, null, null, null));
|
||||
null, null, null, null, null, null, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -15,9 +15,10 @@ class ReactionServiceTest {
|
||||
UserRepository userRepo = mock(UserRepository.class);
|
||||
PostRepository postRepo = mock(PostRepository.class);
|
||||
CommentRepository commentRepo = mock(CommentRepository.class);
|
||||
MessageRepository messageRepo = mock(MessageRepository.class);
|
||||
NotificationService notif = mock(NotificationService.class);
|
||||
EmailSender email = mock(EmailSender.class);
|
||||
ReactionService service = new ReactionService(reactionRepo, userRepo, postRepo, commentRepo, notif, email);
|
||||
ReactionService service = new ReactionService(reactionRepo, userRepo, postRepo, commentRepo, messageRepo, notif, email);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service, "websiteUrl", "https://ex.com");
|
||||
|
||||
User user = new User();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<div class="header-container">
|
||||
<div v-if="!isFloatMode" class="header-container">
|
||||
<HeaderComponent
|
||||
ref="header"
|
||||
@toggle-menu="menuVisible = !menuVisible"
|
||||
@@ -9,20 +9,28 @@
|
||||
</div>
|
||||
|
||||
<div class="main-container">
|
||||
<div class="menu-container" v-click-outside="handleMenuOutside">
|
||||
<div v-if="!isFloatMode" class="menu-container" v-click-outside="handleMenuOutside">
|
||||
<MenuComponent :visible="!hideMenu && menuVisible" @item-click="menuVisible = false" />
|
||||
</div>
|
||||
<div class="content" :class="{ 'menu-open': menuVisible && !hideMenu }">
|
||||
<div
|
||||
class="content"
|
||||
:class="{ 'menu-open': menuVisible && !hideMenu && !isFloatMode }"
|
||||
:style="isFloatMode ? { paddingTop: '0px', minHeight: '100vh' } : {}"
|
||||
>
|
||||
<NuxtPage keepalive />
|
||||
</div>
|
||||
|
||||
<div v-if="showNewPostIcon && isMobile" class="app-new-post-icon" @click="goToNewPost">
|
||||
<div
|
||||
v-if="showNewPostIcon && isMobile && !isFloatMode"
|
||||
class="app-new-post-icon"
|
||||
@click="goToNewPost"
|
||||
>
|
||||
<i class="fas fa-edit"></i>
|
||||
</div>
|
||||
</div>
|
||||
<GlobalPopups />
|
||||
<ConfirmDialog />
|
||||
<ChatFloating />
|
||||
<MessageFloatWindow v-if="!isFloatMode" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -31,7 +39,7 @@ import HeaderComponent from '~/components/HeaderComponent.vue'
|
||||
import MenuComponent from '~/components/MenuComponent.vue'
|
||||
import GlobalPopups from '~/components/GlobalPopups.vue'
|
||||
import ConfirmDialog from '~/components/ConfirmDialog.vue'
|
||||
import ChatFloating from '~/components/ChatFloating.vue'
|
||||
import MessageFloatWindow from '~/components/MessageFloatWindow.vue'
|
||||
import { useIsMobile } from '~/utils/screen'
|
||||
|
||||
const isMobile = useIsMobile()
|
||||
@@ -54,6 +62,7 @@ const hideMenu = computed(() => {
|
||||
})
|
||||
|
||||
const header = useTemplateRef('header')
|
||||
const isFloatMode = computed(() => useRoute().query.float !== undefined)
|
||||
|
||||
onMounted(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
|
||||
@@ -139,6 +139,9 @@ body {
|
||||
margin-bottom: 0.8em;
|
||||
}
|
||||
|
||||
.info-content-text video {
|
||||
max-width: 100%;
|
||||
}
|
||||
.info-content-text {
|
||||
word-break: break-word;
|
||||
max-width: 100%;
|
||||
|
||||
110
frontend_nuxt/components/BaseTabs.vue
Normal file
110
frontend_nuxt/components/BaseTabs.vue
Normal file
@@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<div class="base-tabs-wrapper" @touchstart="onTouchStart" @touchend="onTouchEnd">
|
||||
<div class="base-tabs-header">
|
||||
<div class="base-tabs-items">
|
||||
<div
|
||||
v-for="tab in tabs"
|
||||
:key="tab.name"
|
||||
:class="['base-tab-item', { selected: tab.name === current }]"
|
||||
@click="select(tab.name)"
|
||||
>
|
||||
<i v-if="tab.icon" :class="tab.icon"></i>
|
||||
<span>{{ tab.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="base-tabs-right">
|
||||
<slot name="right"></slot>
|
||||
</div>
|
||||
</div>
|
||||
<div class="base-tabs-content">
|
||||
<slot :current="current"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: undefined },
|
||||
tabs: { type: Array, required: true },
|
||||
swipe: { type: Boolean, default: true },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const current = ref(props.modelValue ?? (props.tabs[0] && props.tabs[0].name))
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
if (val !== undefined) current.value = val
|
||||
},
|
||||
)
|
||||
|
||||
function select(name) {
|
||||
emit('update:modelValue', name)
|
||||
}
|
||||
|
||||
let startX = 0
|
||||
|
||||
function onTouchStart(e) {
|
||||
if (!props.swipe) return
|
||||
startX = e.changedTouches[0].clientX
|
||||
}
|
||||
|
||||
function onTouchEnd(e) {
|
||||
if (!props.swipe) return
|
||||
const endX = e.changedTouches[0].clientX
|
||||
const diff = endX - startX
|
||||
if (Math.abs(diff) > 50) {
|
||||
const index = props.tabs.findIndex((t) => t.name === current.value)
|
||||
if (diff < 0 && index < props.tabs.length - 1) {
|
||||
emit('update:modelValue', props.tabs[index + 1].name)
|
||||
} else if (diff > 0 && index > 0) {
|
||||
emit('update:modelValue', props.tabs[index - 1].name)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.base-tabs-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.base-tabs-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid var(--normal-border-color);
|
||||
}
|
||||
|
||||
.base-tabs-items {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.base-tab-item {
|
||||
padding: 10px 20px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.base-tab-item.selected {
|
||||
color: var(--primary-color);
|
||||
border-bottom: 2px solid var(--primary-color);
|
||||
}
|
||||
|
||||
.base-tab-item i {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.base-tabs-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
</style>
|
||||
@@ -1,56 +0,0 @@
|
||||
<template>
|
||||
<div v-if="chatFloating" class="chat-floating">
|
||||
<iframe :src="iframeSrc" class="chat-frame"></iframe>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const chatFloating = useState('chatFloating', () => false)
|
||||
const chatPath = useState('chatPath', () => '/message-box')
|
||||
|
||||
const iframeSrc = computed(() =>
|
||||
chatPath.value.includes('?') ? `${chatPath.value}&float=1` : `${chatPath.value}?float=1`,
|
||||
)
|
||||
|
||||
if (process.client) {
|
||||
window.addEventListener('message', (event) => {
|
||||
if (event.data?.type === 'maximize-chat') {
|
||||
chatFloating.value = false
|
||||
navigateTo(event.data.path || chatPath.value)
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chat-floating {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
width: 400px;
|
||||
height: 70vh;
|
||||
max-height: 600px;
|
||||
background: var(--background-color);
|
||||
border: 1px solid var(--normal-border-color);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chat-frame {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
.chat-floating {
|
||||
right: 0;
|
||||
width: 100%;
|
||||
height: 60vh;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
110
frontend_nuxt/components/MessageFloatWindow.vue
Normal file
110
frontend_nuxt/components/MessageFloatWindow.vue
Normal file
@@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<div v-if="floatRoute" class="message-float-window" :style="{ height: floatHeight }">
|
||||
<iframe :src="iframeSrc" frameborder="0" ref="iframeRef" @load="injectBaseTag"></iframe>
|
||||
|
||||
<div class="float-actions">
|
||||
<i
|
||||
class="fas fa-chevron-down"
|
||||
v-if="floatHeight !== MINI_HEIGHT"
|
||||
title="收起至 100px"
|
||||
@click="collapseToMini"
|
||||
></i>
|
||||
<i
|
||||
class="fas fa-chevron-up"
|
||||
v-if="floatHeight !== DEFAULT_HEIGHT"
|
||||
title="回弹至 60vh"
|
||||
@click="reboundToDefault"
|
||||
></i>
|
||||
<i class="fas fa-expand" title="在页面中打开" @click="expand"></i>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const floatRoute = useState('messageFloatRoute')
|
||||
|
||||
const DEFAULT_HEIGHT = '60vh'
|
||||
const MINI_HEIGHT = '45px'
|
||||
const floatHeight = ref(DEFAULT_HEIGHT)
|
||||
|
||||
const iframeRef = ref(null)
|
||||
const iframeSrc = computed(() => {
|
||||
if (!floatRoute.value) return ''
|
||||
return floatRoute.value + (floatRoute.value.includes('?') ? '&' : '?') + 'float=1'
|
||||
})
|
||||
|
||||
function collapseToMini() {
|
||||
floatHeight.value = MINI_HEIGHT
|
||||
}
|
||||
|
||||
function reboundToDefault() {
|
||||
floatHeight.value = DEFAULT_HEIGHT
|
||||
}
|
||||
|
||||
function expand() {
|
||||
if (!floatRoute.value) return
|
||||
const target = floatRoute.value
|
||||
floatRoute.value = null
|
||||
navigateTo(target)
|
||||
}
|
||||
|
||||
function injectBaseTag() {
|
||||
if (!iframeRef.value) return
|
||||
|
||||
const iframeDoc = iframeRef.value.contentDocument || iframeRef.value.contentWindow.document
|
||||
if (iframeDoc && !iframeDoc.querySelector('base')) {
|
||||
const base = iframeDoc.createElement('base')
|
||||
base.target = '_top'
|
||||
iframeDoc.head.appendChild(base)
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => floatRoute.value,
|
||||
(v) => {
|
||||
if (v) floatHeight.value = DEFAULT_HEIGHT
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.message-float-window {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: 400px;
|
||||
max-height: 90vh;
|
||||
background-color: var(--background-color);
|
||||
border: 1px solid var(--normal-border-color);
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: height 0.25s ease;
|
||||
/* 平滑过渡 */
|
||||
}
|
||||
|
||||
.message-float-window iframe {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.float-actions {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 8px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.float-actions i {
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.float-actions i:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -6,7 +6,7 @@
|
||||
@mouseenter="cancelHide"
|
||||
@mouseleave="scheduleHide"
|
||||
>
|
||||
<template v-if="reactions.length < 4">
|
||||
<template v-if="Object.keys(counts).length < 4">
|
||||
<div
|
||||
v-for="r in displayedReactions"
|
||||
:key="r.type"
|
||||
@@ -19,7 +19,7 @@
|
||||
</div>
|
||||
|
||||
<div class="reactions-viewer-item placeholder" @click="openPanel">
|
||||
<i class="far fa-smile"></i>
|
||||
<i class="far fa-smile reactions-viewer-item-placeholder-icon"></i>
|
||||
<!-- <span class="reactions-viewer-item-placeholder-text">点击以表态</span> -->
|
||||
</div>
|
||||
</template>
|
||||
@@ -37,7 +37,11 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="make-reaction-container">
|
||||
<div class="make-reaction-item like-reaction" @click="toggleReaction('LIKE')">
|
||||
<div
|
||||
v-if="props.contentType !== 'message'"
|
||||
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>
|
||||
@@ -138,7 +142,9 @@ const toggleReaction = async (type) => {
|
||||
const url =
|
||||
props.contentType === 'post'
|
||||
? `${API_BASE_URL}/api/posts/${props.contentId}/reactions`
|
||||
: `${API_BASE_URL}/api/comments/${props.contentId}/reactions`
|
||||
: props.contentType === 'comment'
|
||||
? `${API_BASE_URL}/api/comments/${props.contentId}/reactions`
|
||||
: `${API_BASE_URL}/api/messages/${props.contentId}/reactions`
|
||||
|
||||
// optimistic update
|
||||
const existingIdx = reactions.value.findIndex(
|
||||
@@ -236,6 +242,10 @@ onMounted(async () => {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.reactions-viewer-item-placeholder-icon {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.reactions-viewer-item-placeholder-text {
|
||||
font-size: 14px;
|
||||
padding-left: 5px;
|
||||
|
||||
61
frontend_nuxt/package-lock.json
generated
61
frontend_nuxt/package-lock.json
generated
@@ -15,6 +15,7 @@
|
||||
"markdown-it": "^14.1.0",
|
||||
"nprogress": "^0.2.0",
|
||||
"nuxt": "latest",
|
||||
"sanitize-html": "^2.17.0",
|
||||
"sockjs-client": "^1.6.1",
|
||||
"vditor": "^3.11.1",
|
||||
"vue-easy-lightbox": "^1.19.0",
|
||||
@@ -6923,6 +6924,25 @@
|
||||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/htmlparser2": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
|
||||
"integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
|
||||
"funding": [
|
||||
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"domutils": "^3.0.1",
|
||||
"entities": "^4.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
|
||||
@@ -7260,6 +7280,15 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-plain-object": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
|
||||
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-reference": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz",
|
||||
@@ -8874,6 +8903,12 @@
|
||||
"protocols": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/parse-srcset": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
|
||||
"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/parse-url": {
|
||||
"version": "9.2.0",
|
||||
"resolved": "https://registry.npmjs.org/parse-url/-/parse-url-9.2.0.tgz",
|
||||
@@ -10018,6 +10053,32 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/sanitize-html": {
|
||||
"version": "2.17.0",
|
||||
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.0.tgz",
|
||||
"integrity": "sha512-dLAADUSS8rBwhaevT12yCezvioCA+bmUTPH/u57xKPT8d++voeYE6HeluA/bPbQ15TwDBG2ii+QZIEmYx8VdxA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"deepmerge": "^4.2.2",
|
||||
"escape-string-regexp": "^4.0.0",
|
||||
"htmlparser2": "^8.0.0",
|
||||
"is-plain-object": "^5.0.0",
|
||||
"parse-srcset": "^1.0.2",
|
||||
"postcss": "^8.3.11"
|
||||
}
|
||||
},
|
||||
"node_modules/sanitize-html/node_modules/escape-string-regexp": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/sax": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz",
|
||||
|
||||
@@ -9,20 +9,21 @@
|
||||
"generate": "nuxt generate"
|
||||
},
|
||||
"dependencies": {
|
||||
"@stomp/stompjs": "^7.0.0",
|
||||
"cropperjs": "^1.6.2",
|
||||
"echarts": "^5.6.0",
|
||||
"flatpickr": "^4.6.13",
|
||||
"highlight.js": "^11.11.1",
|
||||
"ldrs": "^1.0.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
"nuxt": "latest",
|
||||
"nprogress": "^0.2.0",
|
||||
"nuxt": "latest",
|
||||
"sanitize-html": "^2.17.0",
|
||||
"sockjs-client": "^1.6.1",
|
||||
"vditor": "^3.11.1",
|
||||
"vue-easy-lightbox": "^1.19.0",
|
||||
"vue-echarts": "^7.0.3",
|
||||
"vue-toastification": "^2.0.0-rc.5",
|
||||
"flatpickr": "^4.6.13",
|
||||
"vue-flatpickr-component": "^12.0.0",
|
||||
"@stomp/stompjs": "^7.0.0",
|
||||
"sockjs-client": "^1.6.1"
|
||||
"vue-toastification": "^2.0.0-rc.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +1,29 @@
|
||||
<template>
|
||||
<div class="about-page">
|
||||
<div class="about-tabs">
|
||||
<div
|
||||
v-for="tab in tabs"
|
||||
:key="tab.name"
|
||||
:class="['about-tabs-item', { selected: selectedTab === tab.name }]"
|
||||
@click="selectTab(tab.name)"
|
||||
>
|
||||
<div class="about-tabs-item-label">{{ tab.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="about-loading" v-if="isFetching">
|
||||
<l-hatch-spinner size="100" stroke="10" speed="1" color="var(--primary-color)" />
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="about-content"
|
||||
v-html="renderMarkdown(content)"
|
||||
@click="handleContentClick"
|
||||
></div>
|
||||
<BaseTabs v-model="selectedTab" :tabs="tabs" class="about-tabs">
|
||||
<template #default>
|
||||
<div class="about-loading" v-if="isFetching">
|
||||
<l-hatch-spinner size="100" stroke="10" speed="1" color="var(--primary-color)" />
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="about-content"
|
||||
v-html="renderMarkdown(content)"
|
||||
@click="handleContentClick"
|
||||
></div>
|
||||
</template>
|
||||
</BaseTabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
import { handleMarkdownClick, renderMarkdown } from '~/utils/markdown'
|
||||
import BaseTabs from '~/components/BaseTabs.vue'
|
||||
|
||||
export default {
|
||||
name: 'AboutPageView',
|
||||
components: { BaseTabs },
|
||||
setup() {
|
||||
const isFetching = ref(false)
|
||||
const tabs = [
|
||||
@@ -71,21 +67,20 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
const selectTab = (name) => {
|
||||
selectedTab.value = name
|
||||
const tab = tabs.find((t) => t.name === name)
|
||||
if (tab) loadContent(tab.file)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadContent(tabs[0].file)
|
||||
})
|
||||
watch(
|
||||
selectedTab,
|
||||
(name) => {
|
||||
const tab = tabs.find((t) => t.name === name)
|
||||
if (tab) loadContent(tab.file)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const handleContentClick = (e) => {
|
||||
handleMarkdownClick(e)
|
||||
}
|
||||
|
||||
return { tabs, selectedTab, content, renderMarkdown, selectTab, isFetching, handleContentClick }
|
||||
return { tabs, selectedTab, content, renderMarkdown, isFetching, handleContentClick }
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -100,25 +95,11 @@ export default {
|
||||
.about-tabs {
|
||||
top: calc(var(--header-height) + 1px);
|
||||
background-color: var(--background-color-blur);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
border-bottom: 1px solid var(--normal-border-color);
|
||||
margin-bottom: 20px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.about-tabs-item {
|
||||
padding: 10px 20px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.about-tabs-item.selected {
|
||||
color: var(--primary-color);
|
||||
border-bottom: 2px solid var(--primary-color);
|
||||
}
|
||||
|
||||
.about-content {
|
||||
line-height: 1.6;
|
||||
padding: 20px;
|
||||
|
||||
@@ -125,6 +125,7 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, onBeforeUnmount, nextTick, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import ArticleCategory from '~/components/ArticleCategory.vue'
|
||||
import ArticleTags from '~/components/ArticleTags.vue'
|
||||
import CategorySelect from '~/components/CategorySelect.vue'
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
<template>
|
||||
<div class="chat-container">
|
||||
<div class="chat-container" :class="{ float: isFloatMode }">
|
||||
<div v-if="!loading" class="chat-header">
|
||||
<NuxtLink to="/message-box" class="back-button">
|
||||
<i class="fas fa-arrow-left"></i>
|
||||
</NuxtLink>
|
||||
<h2 class="participant-name">
|
||||
{{ isChannel ? conversationName : otherParticipant?.username }}
|
||||
</h2>
|
||||
<div class="chat-controls">
|
||||
<i v-if="!isFloat" class="fas fa-window-minimize control-icon" @click="minimizeChat"></i>
|
||||
<i v-else class="fas fa-expand control-icon" @click="maximizeChat"></i>
|
||||
<div class="header-main">
|
||||
<div class="back-button" @click="goBack">
|
||||
<i class="fas fa-arrow-left"></i>
|
||||
</div>
|
||||
<h2 class="participant-name">
|
||||
{{ isChannel ? conversationName : otherParticipant?.username }}
|
||||
</h2>
|
||||
</div>
|
||||
<div v-if="!isFloatMode" class="float-control">
|
||||
<i class="fas fa-compress" @click="minimize" title="最小化"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -34,9 +35,21 @@
|
||||
{{ TimeManager.format(item.createdAt) }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="item.replyTo" class="reply-preview">
|
||||
<div class="reply-author">{{ item.replyTo.sender.username }}</div>
|
||||
<div class="reply-content" v-html="renderMarkdown(item.replyTo.content)"></div>
|
||||
</div>
|
||||
<div class="message-content">
|
||||
<div class="info-content-text" v-html="renderMarkdown(item.content)"></div>
|
||||
</div>
|
||||
<ReactionsGroup
|
||||
:model-value="item.reactions"
|
||||
content-type="message"
|
||||
:content-id="item.id"
|
||||
@update:modelValue="(v) => (item.reactions = v)"
|
||||
>
|
||||
<i class="fas fa-reply reply-btn" @click="setReply(item)"> 写个回复...</i>
|
||||
</ReactionsGroup>
|
||||
</template>
|
||||
</BaseTimeline>
|
||||
<div class="empty-container">
|
||||
@@ -50,6 +63,11 @@
|
||||
</div>
|
||||
|
||||
<div class="message-input-area">
|
||||
<div v-if="replyTo" class="active-reply">
|
||||
正在回复 {{ replyTo.sender.username }}:
|
||||
{{ stripMarkdownLength(replyTo.content, 50) }}
|
||||
<i class="fas fa-times close-reply" @click="replyTo = null"></i>
|
||||
</div>
|
||||
<MessageEditor :loading="sending" @submit="sendMessage" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -69,8 +87,9 @@ import {
|
||||
import { useRoute } from 'vue-router'
|
||||
import { getToken, fetchCurrentUser } from '~/utils/auth'
|
||||
import { toast } from '~/main'
|
||||
import { renderMarkdown } from '~/utils/markdown'
|
||||
import { renderMarkdown, stripMarkdownLength } from '~/utils/markdown'
|
||||
import MessageEditor from '~/components/MessageEditor.vue'
|
||||
import ReactionsGroup from '~/components/ReactionsGroup.vue'
|
||||
import { useWebSocket } from '~/composables/useWebSocket'
|
||||
import { useUnreadCount } from '~/composables/useUnreadCount'
|
||||
import { useChannelsUnreadCount } from '~/composables/useChannelsUnreadCount'
|
||||
@@ -86,10 +105,6 @@ const { fetchUnreadCount: refreshGlobalUnreadCount } = useUnreadCount()
|
||||
const { fetchChannelUnread: refreshChannelUnread } = useChannelsUnreadCount()
|
||||
let subscription = null
|
||||
|
||||
const chatFloating = useState('chatFloating', () => false)
|
||||
const chatPath = useState('chatPath', () => '/message-box')
|
||||
const isFloat = computed(() => route.query.float === '1')
|
||||
|
||||
const messages = ref([])
|
||||
const participants = ref([])
|
||||
const loading = ref(true)
|
||||
@@ -105,6 +120,9 @@ 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 hasMoreMessages = computed(() => currentPage.value < totalPages.value - 1)
|
||||
|
||||
@@ -123,22 +141,8 @@ function handleAvatarError(event) {
|
||||
event.target.src = '/default-avatar.svg'
|
||||
}
|
||||
|
||||
function minimizeChat() {
|
||||
chatPath.value = route.fullPath
|
||||
chatFloating.value = true
|
||||
navigateTo('/')
|
||||
}
|
||||
|
||||
function maximizeChat() {
|
||||
if (window.parent) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: 'maximize-chat',
|
||||
path: route.fullPath.replace('?float=1', '').replace('&float=1', ''),
|
||||
},
|
||||
'*',
|
||||
)
|
||||
}
|
||||
function setReply(message) {
|
||||
replyTo.value = message
|
||||
}
|
||||
|
||||
// No changes needed here, as renderMarkdown is now imported.
|
||||
@@ -182,7 +186,7 @@ async function fetchMessages(page = 0) {
|
||||
...item,
|
||||
src: item.sender.avatar,
|
||||
iconClick: () => {
|
||||
navigateTo(`/users/${item.sender.id}`, { replace: true })
|
||||
openUser(item.sender.id)
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -234,7 +238,7 @@ async function sendMessage(content, clearInput) {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ content }),
|
||||
body: JSON.stringify({ content, replyToId: replyTo.value?.id }),
|
||||
},
|
||||
)
|
||||
} else {
|
||||
@@ -252,6 +256,7 @@ async function sendMessage(content, clearInput) {
|
||||
body: JSON.stringify({
|
||||
recipientId: recipient.id,
|
||||
content: content,
|
||||
replyToId: replyTo.value?.id,
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -262,10 +267,11 @@ async function sendMessage(content, clearInput) {
|
||||
...newMessage,
|
||||
src: newMessage.sender.avatar,
|
||||
iconClick: () => {
|
||||
navigateTo(`/users/${newMessage.sender.id}`, { replace: true })
|
||||
openUser(newMessage.sender.id)
|
||||
},
|
||||
})
|
||||
clearInput()
|
||||
replyTo.value = null
|
||||
setTimeout(() => {
|
||||
scrollToBottom()
|
||||
}, 100)
|
||||
@@ -348,7 +354,7 @@ watch(isConnected, (newValue) => {
|
||||
...message,
|
||||
src: message.sender.avatar,
|
||||
iconClick: () => {
|
||||
navigateTo(`/users/${message.sender.id}`, { replace: true })
|
||||
openUser(message.sender.id)
|
||||
},
|
||||
})
|
||||
// 实时收到消息时自动标记为已读
|
||||
@@ -402,6 +408,28 @@ onUnmounted(() => {
|
||||
}
|
||||
disconnect()
|
||||
})
|
||||
|
||||
function minimize() {
|
||||
floatRoute.value = route.fullPath
|
||||
navigateTo('/')
|
||||
}
|
||||
|
||||
function openUser(id) {
|
||||
if (isFloatMode.value) {
|
||||
// 先不处理...
|
||||
// navigateTo(`/users/${id}?float=1`)
|
||||
} else {
|
||||
navigateTo(`/users/${id}`, { replace: true })
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (isFloatMode.value) {
|
||||
navigateTo('/message-box?float=1')
|
||||
} else {
|
||||
navigateTo('/message-box')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -414,8 +442,13 @@ onUnmounted(() => {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chat-container.float {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
@@ -426,6 +459,24 @@ onUnmounted(() => {
|
||||
backdrop-filter: var(--blur-10);
|
||||
}
|
||||
|
||||
.header-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.float-control {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
text-align: right;
|
||||
padding: 12px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.float-control i {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
font-size: 18px;
|
||||
color: var(--text-color-primary);
|
||||
@@ -437,7 +488,6 @@ onUnmounted(() => {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.messages-list {
|
||||
@@ -541,25 +591,57 @@ onUnmounted(() => {
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.messages-list {
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.message-input-area {
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.chat-controls {
|
||||
margin-left: auto;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
.reply-preview {
|
||||
padding: 5px 10px;
|
||||
border-left: 5px solid var(--primary-color);
|
||||
margin-bottom: 5px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.control-icon {
|
||||
font-size: 16px;
|
||||
.reply-author {
|
||||
font-weight: bold;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.reply-btn {
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
opacity: 0.6;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.reply-btn:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.active-reply {
|
||||
background-color: var(--bg-color-soft);
|
||||
padding: 5px 10px;
|
||||
border-left: 5px solid var(--primary-color);
|
||||
margin-bottom: 5px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.close-reply {
|
||||
margin-left: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (max-height: 200px) {
|
||||
.messages-list,
|
||||
.message-input-area {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.messages-list {
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,125 +1,131 @@
|
||||
<template>
|
||||
<div class="messages-container">
|
||||
<div class="chat-controls">
|
||||
<i v-if="!isFloat" class="fas fa-window-minimize control-icon" @click="minimizeChat"></i>
|
||||
<i v-else class="fas fa-expand control-icon" @click="maximizeChat"></i>
|
||||
<div class="page-title">
|
||||
<i class="fas fa-comments"></i>
|
||||
<span class="page-title-text">选择聊天</span>
|
||||
</div>
|
||||
<div class="tabs">
|
||||
<div :class="['tab', { active: activeTab === 'messages' }]" @click="activeTab = 'messages'">
|
||||
站内信
|
||||
</div>
|
||||
<div :class="['tab', { active: activeTab === 'channels' }]" @click="switchToChannels">
|
||||
频道
|
||||
</div>
|
||||
<div v-if="!isFloatMode" class="float-control">
|
||||
<i class="fas fa-compress" @click="minimize" title="最小化"></i>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'messages'">
|
||||
<div v-if="loading" class="loading-message">
|
||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="error-container">
|
||||
<div class="error-text">{{ error }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!loading" class="search-container">
|
||||
<SearchPersonDropdown />
|
||||
</div>
|
||||
|
||||
<div v-if="!loading && conversations.length === 0" class="empty-container">
|
||||
<BasePlaceholder v-if="conversations.length === 0" text="暂无会话" icon="fas fa-inbox" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!loading"
|
||||
v-for="convo in conversations"
|
||||
:key="convo.id"
|
||||
class="conversation-item"
|
||||
@click="goToConversation(convo.id)"
|
||||
>
|
||||
<div class="conversation-avatar">
|
||||
<img
|
||||
:src="getOtherParticipant(convo)?.avatar || '/default-avatar.svg'"
|
||||
:alt="getOtherParticipant(convo)?.username || '用户'"
|
||||
class="avatar-img"
|
||||
@error="handleAvatarError"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="conversation-content">
|
||||
<div class="conversation-header">
|
||||
<div class="participant-name">
|
||||
{{ getOtherParticipant(convo)?.username || '未知用户' }}
|
||||
</div>
|
||||
<div class="message-time">
|
||||
{{ formatTime(convo.lastMessage?.createdAt || convo.createdAt) }}
|
||||
</div>
|
||||
<BaseTabs v-model="activeTab" :tabs="tabs">
|
||||
<template #default>
|
||||
<div v-if="activeTab === 'messages'">
|
||||
<div v-if="loading" class="loading-message">
|
||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||
</div>
|
||||
|
||||
<div class="last-message-row">
|
||||
<div class="last-message">
|
||||
{{
|
||||
convo.lastMessage ? stripMarkdownLength(convo.lastMessage.content, 100) : '暂无消息'
|
||||
}}
|
||||
</div>
|
||||
<div v-if="convo.unreadCount > 0" class="unread-count-badge">
|
||||
{{ convo.unreadCount }}
|
||||
</div>
|
||||
<div v-else-if="error" class="error-container">
|
||||
<div class="error-text">{{ error }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div v-if="loadingChannels" class="loading-message">
|
||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-if="channels.length === 0" class="empty-container">
|
||||
<BasePlaceholder text="暂无频道" icon="fas fa-inbox" />
|
||||
</div>
|
||||
<div
|
||||
v-for="ch in channels"
|
||||
:key="ch.id"
|
||||
class="conversation-item"
|
||||
@click="goToChannel(ch.id)"
|
||||
>
|
||||
<div class="conversation-avatar">
|
||||
<img
|
||||
:src="ch.avatar || '/default-avatar.svg'"
|
||||
:alt="ch.name"
|
||||
class="avatar-img"
|
||||
@error="handleAvatarError"
|
||||
<div v-if="!loading && !isFloatMode" class="search-container">
|
||||
<SearchPersonDropdown />
|
||||
</div>
|
||||
|
||||
<div v-if="!loading && conversations.length === 0" class="empty-container">
|
||||
<BasePlaceholder
|
||||
v-if="conversations.length === 0"
|
||||
text="暂无会话"
|
||||
icon="fas fa-inbox"
|
||||
/>
|
||||
</div>
|
||||
<div class="conversation-content">
|
||||
<div class="conversation-header">
|
||||
<div class="participant-name">
|
||||
{{ ch.name }}
|
||||
<span v-if="ch.unreadCount > 0" class="unread-dot"></span>
|
||||
</div>
|
||||
<div class="message-time">
|
||||
{{ formatTime(ch.lastMessage?.createdAt || ch.createdAt) }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!loading"
|
||||
v-for="convo in conversations"
|
||||
:key="convo.id"
|
||||
class="conversation-item"
|
||||
@click="goToConversation(convo.id)"
|
||||
>
|
||||
<div class="conversation-avatar">
|
||||
<img
|
||||
:src="getOtherParticipant(convo)?.avatar || '/default-avatar.svg'"
|
||||
:alt="getOtherParticipant(convo)?.username || '用户'"
|
||||
class="avatar-img"
|
||||
@error="handleAvatarError"
|
||||
/>
|
||||
</div>
|
||||
<div class="last-message-row">
|
||||
<div class="last-message">
|
||||
{{
|
||||
ch.lastMessage ? stripMarkdownLength(ch.lastMessage.content, 100) : ch.description
|
||||
}}
|
||||
|
||||
<div class="conversation-content">
|
||||
<div class="conversation-header">
|
||||
<div class="participant-name">
|
||||
{{ getOtherParticipant(convo)?.username || '未知用户' }}
|
||||
</div>
|
||||
<div class="message-time">
|
||||
{{ formatTime(convo.lastMessage?.createdAt || convo.createdAt) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="last-message-row">
|
||||
<div class="last-message">
|
||||
{{
|
||||
convo.lastMessage
|
||||
? stripMarkdownLength(convo.lastMessage.content, 100)
|
||||
: '暂无消息'
|
||||
}}
|
||||
</div>
|
||||
<div v-if="convo.unreadCount > 0" class="unread-count-badge">
|
||||
{{ convo.unreadCount }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="member-count">成员 {{ ch.memberCount }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div v-if="loadingChannels" class="loading-message">
|
||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-if="channels.length === 0" class="empty-container">
|
||||
<BasePlaceholder text="暂无频道" icon="fas fa-inbox" />
|
||||
</div>
|
||||
<div
|
||||
v-for="ch in channels"
|
||||
:key="ch.id"
|
||||
class="conversation-item"
|
||||
@click="goToChannel(ch.id)"
|
||||
>
|
||||
<div class="conversation-avatar">
|
||||
<img
|
||||
:src="ch.avatar || '/default-avatar.svg'"
|
||||
:alt="ch.name"
|
||||
class="avatar-img"
|
||||
@error="handleAvatarError"
|
||||
/>
|
||||
</div>
|
||||
<div class="conversation-content">
|
||||
<div class="conversation-header">
|
||||
<div class="participant-name">
|
||||
{{ ch.name }}
|
||||
<span v-if="ch.unreadCount > 0" class="unread-dot"></span>
|
||||
</div>
|
||||
<div class="message-time">
|
||||
{{ formatTime(ch.lastMessage?.createdAt || ch.createdAt) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="last-message-row">
|
||||
<div class="last-message">
|
||||
{{
|
||||
ch.lastMessage
|
||||
? stripMarkdownLength(ch.lastMessage.content, 100)
|
||||
: ch.description
|
||||
}}
|
||||
</div>
|
||||
<div class="member-count">成员 {{ ch.memberCount }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</BaseTabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onUnmounted, watch, onActivated, computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { getToken, fetchCurrentUser } from '~/utils/auth'
|
||||
import { toast } from '~/main'
|
||||
import { useWebSocket } from '~/composables/useWebSocket'
|
||||
@@ -129,12 +135,13 @@ import TimeManager from '~/utils/time'
|
||||
import { stripMarkdownLength } from '~/utils/markdown'
|
||||
import SearchPersonDropdown from '~/components/SearchPersonDropdown.vue'
|
||||
import BasePlaceholder from '~/components/BasePlaceholder.vue'
|
||||
import BaseTabs from '~/components/BaseTabs.vue'
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const conversations = ref([])
|
||||
const loading = ref(true)
|
||||
const error = ref(null)
|
||||
const router = useRouter()
|
||||
|
||||
const route = useRoute()
|
||||
const currentUser = ref(null)
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
@@ -144,13 +151,15 @@ const { fetchChannelUnread: refreshChannelUnread, setFromList: setChannelUnreadF
|
||||
useChannelsUnreadCount()
|
||||
let subscription = null
|
||||
|
||||
const chatFloating = useState('chatFloating', () => false)
|
||||
const chatPath = useState('chatPath', () => '/message-box')
|
||||
const isFloat = computed(() => route.query.float === '1')
|
||||
|
||||
const activeTab = ref('messages')
|
||||
const activeTab = ref('channels')
|
||||
const channels = ref([])
|
||||
const loadingChannels = ref(false)
|
||||
const isFloatMode = computed(() => route.query.float === '1')
|
||||
const floatRoute = useState('messageFloatRoute')
|
||||
const tabs = [
|
||||
{ name: 'messages', label: '站内信' },
|
||||
{ name: 'channels', label: '频道' },
|
||||
]
|
||||
|
||||
async function fetchConversations() {
|
||||
const token = getToken()
|
||||
@@ -158,6 +167,7 @@ async function fetchConversations() {
|
||||
toast.error('请先登录')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/messages/conversations`, {
|
||||
method: 'GET',
|
||||
@@ -214,13 +224,6 @@ async function fetchChannels() {
|
||||
}
|
||||
}
|
||||
|
||||
function switchToChannels() {
|
||||
activeTab.value = 'channels'
|
||||
if (channels.value.length === 0) {
|
||||
fetchChannels()
|
||||
}
|
||||
}
|
||||
|
||||
async function goToChannel(id) {
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
@@ -232,37 +235,26 @@ async function goToChannel(id) {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
router.push(`/message-box/${id}`)
|
||||
if (isFloatMode.value) {
|
||||
navigateTo(`/message-box/${id}?float=1`)
|
||||
} else {
|
||||
navigateTo(`/message-box/${id}`)
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
function minimizeChat() {
|
||||
chatPath.value = route.fullPath
|
||||
chatFloating.value = true
|
||||
navigateTo('/')
|
||||
}
|
||||
|
||||
function maximizeChat() {
|
||||
if (window.parent) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: 'maximize-chat',
|
||||
path: route.fullPath.replace('?float=1', '').replace('&float=1', ''),
|
||||
},
|
||||
'*',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
onActivated(async () => {
|
||||
loading.value = true
|
||||
currentUser.value = await fetchCurrentUser()
|
||||
|
||||
if (currentUser.value) {
|
||||
await fetchConversations()
|
||||
refreshGlobalUnreadCount() // Refresh global count when entering the list
|
||||
if (activeTab.value === 'messages') {
|
||||
await fetchConversations()
|
||||
} else {
|
||||
await fetchChannels()
|
||||
}
|
||||
refreshGlobalUnreadCount()
|
||||
refreshChannelUnread()
|
||||
const token = getToken()
|
||||
if (token && !isConnected.value) {
|
||||
@@ -291,6 +283,14 @@ watch(isConnected, (newValue) => {
|
||||
}
|
||||
})
|
||||
|
||||
watch(activeTab, (val) => {
|
||||
if (val === 'messages') {
|
||||
fetchConversations()
|
||||
} else {
|
||||
fetchChannels()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (subscription) {
|
||||
subscription.unsubscribe()
|
||||
@@ -299,7 +299,16 @@ onUnmounted(() => {
|
||||
})
|
||||
|
||||
function goToConversation(id) {
|
||||
router.push(`/message-box/${id}`)
|
||||
if (isFloatMode.value) {
|
||||
navigateTo(`/message-box/${id}?float=1`)
|
||||
} else {
|
||||
navigateTo(`/message-box/${id}`)
|
||||
}
|
||||
}
|
||||
|
||||
function minimize() {
|
||||
floatRoute.value = route.fullPath
|
||||
navigateTo('/')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -308,20 +317,20 @@ function goToConversation(id) {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--normal-border-color);
|
||||
margin-bottom: 16px;
|
||||
.float-control {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
text-align: right;
|
||||
padding: 12px 12px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 10px 20px;
|
||||
.float-control i {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
border-bottom: 2px solid var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
.messages-container :deep(.base-tabs-header) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.loading-message {
|
||||
@@ -341,6 +350,21 @@ function goToConversation(id) {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
padding: 12px;
|
||||
display: none;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.page-title-text {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.page-title-text:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.messages-title {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
@@ -465,19 +489,21 @@ function goToConversation(id) {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.chat-controls {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
cursor: pointer;
|
||||
z-index: 10;
|
||||
@media (max-height: 200px) {
|
||||
.page-title {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.base-tabs-wrapper,
|
||||
.loading-message,
|
||||
.error-container,
|
||||
.search-container,
|
||||
.empty-container,
|
||||
.conversation-item {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.control-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.conversation-item {
|
||||
margin-left: 10px;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -66,6 +66,18 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="prize-point-row">
|
||||
<span class="prize-row-title">参与所需积分</span>
|
||||
<div class="prize-count-input">
|
||||
<input
|
||||
class="prize-count-input-field"
|
||||
type="number"
|
||||
v-model.number="pointCost"
|
||||
min="0"
|
||||
max="100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="prize-time-row">
|
||||
<span class="prize-row-title">抽奖结束时间</span>
|
||||
<client-only>
|
||||
@@ -105,6 +117,7 @@ const showPrizeCropper = ref(false)
|
||||
const prizeName = ref('')
|
||||
const prizeCount = ref(1)
|
||||
const prizeDescription = ref('')
|
||||
const pointCost = ref(0)
|
||||
const endTime = ref(null)
|
||||
const startTime = ref(null)
|
||||
const dateConfig = { enableTime: true, time_24hr: true, dateFormat: 'Y-m-d H:i' }
|
||||
@@ -133,6 +146,11 @@ watch(prizeCount, (val) => {
|
||||
if (!val || val < 1) prizeCount.value = 1
|
||||
})
|
||||
|
||||
watch(pointCost, (val) => {
|
||||
if (val === undefined || val === null || val < 0) pointCost.value = 0
|
||||
if (val > 100) pointCost.value = 100
|
||||
})
|
||||
|
||||
const loadDraft = async () => {
|
||||
const token = getToken()
|
||||
if (!token) return
|
||||
@@ -168,6 +186,7 @@ const clearPost = async () => {
|
||||
showPrizeCropper.value = false
|
||||
prizeDescription.value = ''
|
||||
prizeCount.value = 1
|
||||
pointCost.value = 0
|
||||
endTime.value = null
|
||||
startTime.value = null
|
||||
|
||||
@@ -315,6 +334,10 @@ const submitPost = async () => {
|
||||
toast.error('请选择抽奖结束时间')
|
||||
return
|
||||
}
|
||||
if (pointCost.value < 0 || pointCost.value > 100) {
|
||||
toast.error('参与积分需在0到100之间')
|
||||
return
|
||||
}
|
||||
}
|
||||
try {
|
||||
const token = getToken()
|
||||
@@ -354,6 +377,7 @@ const submitPost = async () => {
|
||||
prizeDescription: postType.value === 'LOTTERY' ? prizeDescription.value : undefined,
|
||||
startTime:
|
||||
postType.value === 'LOTTERY' ? new Date(startTime.value).toISOString() : undefined,
|
||||
pointCost: postType.value === 'LOTTERY' ? pointCost.value : undefined,
|
||||
// 将时间转换为 UTC+8.5 时区 todo: 需要优化
|
||||
endTime:
|
||||
postType.value === 'LOTTERY'
|
||||
@@ -498,6 +522,8 @@ const submitPost = async () => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
|
||||
margin-bottom: 200px;
|
||||
}
|
||||
|
||||
.prize-row-title {
|
||||
|
||||
@@ -1,159 +1,172 @@
|
||||
<template>
|
||||
<div class="point-mall-page">
|
||||
<div class="point-tabs">
|
||||
<div
|
||||
:class="['point-tab-item', { selected: selectedTab === 'mall' }]"
|
||||
@click="selectedTab = 'mall'"
|
||||
>
|
||||
积分兑换
|
||||
</div>
|
||||
<div
|
||||
:class="['point-tab-item', { selected: selectedTab === 'history' }]"
|
||||
@click="selectedTab = 'history'"
|
||||
>
|
||||
积分历史
|
||||
</div>
|
||||
</div>
|
||||
<BaseTabs v-model="selectedTab" :tabs="tabs">
|
||||
<template #default>
|
||||
<template v-if="selectedTab === 'mall'">
|
||||
<div class="point-mall-page-content">
|
||||
<section class="rules">
|
||||
<div class="section-title">🎉 积分规则</div>
|
||||
<div class="section-content">
|
||||
<div class="section-item" v-for="(rule, idx) in pointRules" :key="idx">
|
||||
{{ rule }}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<template v-if="selectedTab === 'mall'">
|
||||
<div class="point-mall-page-content">
|
||||
<section class="rules">
|
||||
<div class="section-title">🎉 积分规则</div>
|
||||
<div class="section-content">
|
||||
<div class="section-item" v-for="(rule, idx) in pointRules" :key="idx">{{ rule }}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="loading-points-container" v-if="isLoading">
|
||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||
</div>
|
||||
|
||||
<div class="point-info">
|
||||
<p v-if="authState.loggedIn && point !== null">
|
||||
<span><i class="fas fa-coins coin-icon"></i></span>我的积分:<span
|
||||
class="point-value"
|
||||
>{{ point }}</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section class="goods">
|
||||
<div class="goods-item" v-for="(good, idx) in goods" :key="idx">
|
||||
<img class="goods-item-image" :src="good.image" alt="good.name" />
|
||||
<div class="goods-item-name">{{ good.name }}</div>
|
||||
<div class="goods-item-cost">
|
||||
<i class="fas fa-coins"></i>
|
||||
{{ good.cost }} 积分
|
||||
<div class="loading-points-container" v-if="isLoading">
|
||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||
</div>
|
||||
<div
|
||||
class="goods-item-button"
|
||||
:class="{ disabled: !authState.loggedIn || point === null || point < good.cost }"
|
||||
@click="openRedeem(good)"
|
||||
>
|
||||
兑换
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<RedeemPopup
|
||||
:visible="dialogVisible"
|
||||
v-model="contact"
|
||||
:loading="loading"
|
||||
@close="closeRedeem"
|
||||
@submit="submitRedeem"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="loading-points-container" v-if="historyLoading">
|
||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||
</div>
|
||||
<BasePlaceholder v-else-if="histories.length === 0" text="暂无积分记录" icon="fas fa-inbox" />
|
||||
<div class="timeline-container" v-else>
|
||||
<BaseTimeline :items="histories">
|
||||
<template #item="{ item }">
|
||||
<div class="history-content">
|
||||
<template v-if="item.type === 'POST'">
|
||||
发送帖子
|
||||
<NuxtLink :to="`/posts/${item.postId}`" class="timeline-link">{{
|
||||
item.postTitle
|
||||
}}</NuxtLink>
|
||||
,获得{{ item.amount }}积分
|
||||
</template>
|
||||
<template v-else-if="item.type === 'COMMENT'">
|
||||
在文章
|
||||
<NuxtLink :to="`/posts/${item.postId}`" class="timeline-link">{{
|
||||
item.postTitle
|
||||
}}</NuxtLink>
|
||||
中
|
||||
<template v-if="!item.fromUserId">
|
||||
发送评论
|
||||
<NuxtLink
|
||||
:to="`/posts/${item.postId}#comment-${item.commentId}`"
|
||||
class="timeline-link"
|
||||
>{{ stripMarkdownLength(item.commentContent, 100) }}</NuxtLink
|
||||
>
|
||||
,获得{{ item.amount }}积分
|
||||
</template>
|
||||
<template v-else>
|
||||
被评论
|
||||
<NuxtLink
|
||||
:to="`/posts/${item.postId}#comment-${item.commentId}`"
|
||||
class="timeline-link"
|
||||
>{{ stripMarkdownLength(item.commentContent, 100) }}</NuxtLink
|
||||
>
|
||||
,获得{{ item.amount }}积分
|
||||
</template>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'POST_LIKED' && 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_LIKED' && item.fromUserId">
|
||||
评论
|
||||
<NuxtLink
|
||||
:to="`/posts/${item.postId}#comment-${item.commentId}`"
|
||||
class="timeline-link"
|
||||
>{{ stripMarkdownLength(item.commentContent, 100) }}</NuxtLink
|
||||
<div class="point-info">
|
||||
<p v-if="authState.loggedIn && point !== null">
|
||||
<span><i class="fas fa-coins coin-icon"></i></span>我的积分:<span
|
||||
class="point-value"
|
||||
>{{ point }}</span
|
||||
>
|
||||
被
|
||||
<NuxtLink :to="`/users/${item.fromUserId}`" class="timeline-link">{{
|
||||
item.fromUserName
|
||||
}}</NuxtLink>
|
||||
按赞,获得{{ item.amount }}积分
|
||||
</template>
|
||||
<template v-else-if="item.type === 'INVITE' && item.fromUserId">
|
||||
邀请了好友
|
||||
<NuxtLink :to="`/users/${item.fromUserId}`" class="timeline-link">{{
|
||||
item.fromUserName
|
||||
}}</NuxtLink>
|
||||
加入社区 🎉,获得 {{ item.amount }} 积分
|
||||
</template>
|
||||
<template v-else-if="item.type === 'FEATURE'">
|
||||
文章
|
||||
<NuxtLink :to="`/posts/${item.postId}`" class="timeline-link">{{
|
||||
item.postTitle
|
||||
}}</NuxtLink>
|
||||
被收录为精选,获得 {{ item.amount }} 积分
|
||||
</template>
|
||||
<template v-else-if="item.type === 'REDEEM'">
|
||||
兑换商品,消耗 {{ -item.amount }} 积分
|
||||
</template>
|
||||
<template v-else-if="item.type === 'SYSTEM_ONLINE'"> 积分历史系统上线 </template>
|
||||
<i class="fas fa-coins"></i> 你目前的积分是 {{ item.balance }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="history-time">{{ TimeManager.format(item.createdAt) }}</div>
|
||||
</template>
|
||||
</BaseTimeline>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<section class="goods">
|
||||
<div class="goods-item" v-for="(good, idx) in goods" :key="idx">
|
||||
<img class="goods-item-image" :src="good.image" alt="good.name" />
|
||||
<div class="goods-item-name">{{ good.name }}</div>
|
||||
<div class="goods-item-cost">
|
||||
<i class="fas fa-coins"></i>
|
||||
{{ good.cost }} 积分
|
||||
</div>
|
||||
<div
|
||||
class="goods-item-button"
|
||||
:class="{ disabled: !authState.loggedIn || point === null || point < good.cost }"
|
||||
@click="openRedeem(good)"
|
||||
>
|
||||
兑换
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<RedeemPopup
|
||||
:visible="dialogVisible"
|
||||
v-model="contact"
|
||||
:loading="loading"
|
||||
@close="closeRedeem"
|
||||
@submit="submitRedeem"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="loading-points-container" v-if="historyLoading">
|
||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||
</div>
|
||||
<BasePlaceholder
|
||||
v-else-if="histories.length === 0"
|
||||
text="暂无积分记录"
|
||||
icon="fas fa-inbox"
|
||||
/>
|
||||
<div class="timeline-container" v-else>
|
||||
<BaseTimeline :items="histories">
|
||||
<template #item="{ item }">
|
||||
<div class="history-content">
|
||||
<template v-if="item.type === 'POST'">
|
||||
发送帖子
|
||||
<NuxtLink :to="`/posts/${item.postId}`" class="timeline-link">{{
|
||||
item.postTitle
|
||||
}}</NuxtLink>
|
||||
,获得{{ item.amount }}积分
|
||||
</template>
|
||||
<template v-else-if="item.type === 'COMMENT'">
|
||||
在文章
|
||||
<NuxtLink :to="`/posts/${item.postId}`" class="timeline-link">{{
|
||||
item.postTitle
|
||||
}}</NuxtLink>
|
||||
中
|
||||
<template v-if="!item.fromUserId">
|
||||
发送评论
|
||||
<NuxtLink
|
||||
:to="`/posts/${item.postId}#comment-${item.commentId}`"
|
||||
class="timeline-link"
|
||||
>{{ stripMarkdownLength(item.commentContent, 100) }}</NuxtLink
|
||||
>
|
||||
,获得{{ item.amount }}积分
|
||||
</template>
|
||||
<template v-else>
|
||||
被评论
|
||||
<NuxtLink
|
||||
:to="`/posts/${item.postId}#comment-${item.commentId}`"
|
||||
class="timeline-link"
|
||||
>{{ stripMarkdownLength(item.commentContent, 100) }}</NuxtLink
|
||||
>
|
||||
,获得{{ item.amount }}积分
|
||||
</template>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'POST_LIKED' && 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_LIKED' && 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 === 'INVITE' && item.fromUserId">
|
||||
邀请了好友
|
||||
<NuxtLink :to="`/users/${item.fromUserId}`" class="timeline-link">{{
|
||||
item.fromUserName
|
||||
}}</NuxtLink>
|
||||
加入社区 🎉,获得 {{ item.amount }} 积分
|
||||
</template>
|
||||
<template v-else-if="item.type === 'FEATURE'">
|
||||
文章
|
||||
<NuxtLink :to="`/posts/${item.postId}`" class="timeline-link">{{
|
||||
item.postTitle
|
||||
}}</NuxtLink>
|
||||
被收录为精选,获得 {{ item.amount }} 积分
|
||||
</template>
|
||||
<template v-else-if="item.type === 'REDEEM'">
|
||||
兑换商品,消耗 {{ -item.amount }} 积分
|
||||
</template>
|
||||
<template v-else-if="item.type === 'LOTTERY_JOIN'">
|
||||
参与抽奖帖
|
||||
<NuxtLink :to="`/posts/${item.postId}`" class="timeline-link">{{
|
||||
item.postTitle
|
||||
}}</NuxtLink>
|
||||
,消耗 {{ -item.amount }} 积分
|
||||
</template>
|
||||
<template v-else-if="item.type === 'LOTTERY_REWARD'">
|
||||
你的抽奖帖
|
||||
<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 === 'SYSTEM_ONLINE'"> 积分历史系统上线 </template>
|
||||
<i class="fas fa-coins"></i> 你目前的积分是 {{ item.balance }}
|
||||
</div>
|
||||
<div class="history-time">{{ TimeManager.format(item.createdAt) }}</div>
|
||||
</template>
|
||||
</BaseTimeline>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</BaseTabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -164,6 +177,7 @@ import { toast } from '~/main'
|
||||
import RedeemPopup from '~/components/RedeemPopup.vue'
|
||||
import BaseTimeline from '~/components/BaseTimeline.vue'
|
||||
import BasePlaceholder from '~/components/BasePlaceholder.vue'
|
||||
import BaseTabs from '~/components/BaseTabs.vue'
|
||||
import { stripMarkdownLength } from '~/utils/markdown'
|
||||
import TimeManager from '~/utils/time'
|
||||
|
||||
@@ -171,6 +185,10 @@ const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
const selectedTab = ref('mall')
|
||||
const tabs = [
|
||||
{ name: 'mall', label: '积分兑换' },
|
||||
{ name: 'history', label: '积分历史' },
|
||||
]
|
||||
const point = ref(null)
|
||||
const isLoading = ref(false)
|
||||
const histories = ref([])
|
||||
@@ -201,6 +219,8 @@ const iconMap = {
|
||||
SYSTEM_ONLINE: 'fas fa-clock',
|
||||
REDEEM: 'fas fa-gift',
|
||||
FEATURE: 'fas fa-star',
|
||||
LOTTERY_JOIN: 'fas fa-ticket-alt',
|
||||
LOTTERY_REWARD: 'fas fa-ticket-alt',
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -295,21 +315,6 @@ const submitRedeem = async () => {
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.point-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--normal-border-color);
|
||||
}
|
||||
|
||||
.point-tab-item {
|
||||
padding: 10px 15px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.point-tab-item.selected {
|
||||
border-bottom: 2px solid var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.timeline-container {
|
||||
padding: 10px 20px;
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="lottery" class="prize-container">
|
||||
<div v-if="lottery" class="post-prize-container">
|
||||
<div class="prize-content">
|
||||
<div class="prize-info">
|
||||
<div class="prize-info-left">
|
||||
@@ -119,7 +119,9 @@
|
||||
class="join-prize-button"
|
||||
@click="joinLottery"
|
||||
>
|
||||
<div class="join-prize-button-text">参与抽奖</div>
|
||||
<div class="join-prize-button-text">
|
||||
参与抽奖 <i class="fas fa-coins"></i> {{ lottery.pointCost }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="hasJoined" class="join-prize-button-disabled">
|
||||
<div class="join-prize-button-text">已参与</div>
|
||||
@@ -134,7 +136,9 @@
|
||||
class="join-prize-button"
|
||||
@click="joinLottery"
|
||||
>
|
||||
<div class="join-prize-button-text">参与抽奖</div>
|
||||
<div class="join-prize-button-text">
|
||||
参与抽奖 <i class="fas fa-coins"></i> {{ lottery.pointCost }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="hasJoined" class="join-prize-button-disabled">
|
||||
<div class="join-prize-button-text">已参与</div>
|
||||
@@ -260,7 +264,6 @@ import { getMedalTitle } from '~/utils/medal'
|
||||
import { toast } from '~/main'
|
||||
import { getToken, authState } from '~/utils/auth'
|
||||
import TimeManager from '~/utils/time'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useIsMobile } from '~/utils/screen'
|
||||
import Dropdown from '~/components/Dropdown.vue'
|
||||
import { ClientOnly } from '#components'
|
||||
@@ -272,7 +275,6 @@ const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
const route = useRoute()
|
||||
const postId = route.params.id
|
||||
const router = useRouter()
|
||||
|
||||
const title = ref('')
|
||||
const author = ref('')
|
||||
@@ -812,11 +814,12 @@ const joinLottery = async () => {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
const data = await res.json().catch(() => ({}))
|
||||
if (res.ok) {
|
||||
toast.success('已参与抽奖')
|
||||
await refreshPost()
|
||||
} else {
|
||||
toast.error('操作失败')
|
||||
toast.error(data.error || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1264,7 +1267,7 @@ onMounted(async () => {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.prize-container {
|
||||
.post-prize-container {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -72,74 +72,172 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="profile-tabs">
|
||||
<div
|
||||
:class="['profile-tabs-item', { selected: selectedTab === 'summary' }]"
|
||||
@click="selectedTab = 'summary'"
|
||||
>
|
||||
<i class="fas fa-chart-line"></i>
|
||||
<div class="profile-tabs-item-label">总结</div>
|
||||
</div>
|
||||
<div
|
||||
:class="['profile-tabs-item', { selected: selectedTab === 'timeline' }]"
|
||||
@click="selectedTab = 'timeline'"
|
||||
>
|
||||
<i class="fas fa-clock"></i>
|
||||
<div class="profile-tabs-item-label">时间线</div>
|
||||
</div>
|
||||
<div
|
||||
:class="['profile-tabs-item', { selected: selectedTab === 'following' }]"
|
||||
@click="selectedTab = 'following'"
|
||||
>
|
||||
<i class="fas fa-user-plus"></i>
|
||||
<div class="profile-tabs-item-label">关注</div>
|
||||
</div>
|
||||
<div
|
||||
:class="['profile-tabs-item', { selected: selectedTab === 'achievements' }]"
|
||||
@click="selectedTab = 'achievements'"
|
||||
>
|
||||
<i class="fas fa-medal"></i>
|
||||
<div class="profile-tabs-item-label">勋章</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="tabLoading" class="tab-loading">
|
||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)" />
|
||||
</div>
|
||||
<template v-else>
|
||||
<div v-if="selectedTab === 'summary'" class="profile-summary">
|
||||
<div class="total-summary">
|
||||
<div class="summary-title">统计信息</div>
|
||||
<div class="total-summary-content">
|
||||
<div class="total-summary-item">
|
||||
<div class="total-summary-item-label">访问天数</div>
|
||||
<div class="total-summary-item-value">{{ user.visitedDays }}</div>
|
||||
<BaseTabs v-model="selectedTab" :tabs="tabs" class="profile-tabs">
|
||||
<template #default>
|
||||
<div v-if="tabLoading" class="tab-loading">
|
||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)" />
|
||||
</div>
|
||||
<template v-else>
|
||||
<div v-if="selectedTab === 'summary'" class="profile-summary">
|
||||
<div class="total-summary">
|
||||
<div class="summary-title">统计信息</div>
|
||||
<div class="total-summary-content">
|
||||
<div class="total-summary-item">
|
||||
<div class="total-summary-item-label">访问天数</div>
|
||||
<div class="total-summary-item-value">{{ user.visitedDays }}</div>
|
||||
</div>
|
||||
<div class="total-summary-item">
|
||||
<div class="total-summary-item-label">已读帖子</div>
|
||||
<div class="total-summary-item-value">{{ user.readPosts }}</div>
|
||||
</div>
|
||||
<div class="total-summary-item">
|
||||
<div class="total-summary-item-label">已送出的💗</div>
|
||||
<div class="total-summary-item-value">{{ user.likesSent }}</div>
|
||||
</div>
|
||||
<div class="total-summary-item">
|
||||
<div class="total-summary-item-label">已收到的💗</div>
|
||||
<div class="total-summary-item-value">{{ user.likesReceived }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="total-summary-item">
|
||||
<div class="total-summary-item-label">已读帖子</div>
|
||||
<div class="total-summary-item-value">{{ user.readPosts }}</div>
|
||||
</div>
|
||||
<div class="total-summary-item">
|
||||
<div class="total-summary-item-label">已送出的💗</div>
|
||||
<div class="total-summary-item-value">{{ user.likesSent }}</div>
|
||||
</div>
|
||||
<div class="total-summary-item">
|
||||
<div class="total-summary-item-label">已收到的💗</div>
|
||||
<div class="total-summary-item-value">{{ user.likesReceived }}</div>
|
||||
<div class="summary-divider">
|
||||
<div class="hot-reply">
|
||||
<div class="summary-title">热门回复</div>
|
||||
<div class="summary-content" v-if="hotReplies.length > 0">
|
||||
<BaseTimeline :items="hotReplies">
|
||||
<template #item="{ item }">
|
||||
在
|
||||
<NuxtLink :to="`/posts/${item.comment.post.id}`" class="timeline-link">
|
||||
{{ item.comment.post.title }}
|
||||
</NuxtLink>
|
||||
<template v-if="item.comment.parentComment">
|
||||
下对
|
||||
<NuxtLink
|
||||
:to="`/posts/${item.comment.post.id}#comment-${item.comment.parentComment.id}`"
|
||||
class="timeline-link"
|
||||
>
|
||||
{{ stripMarkdownLength(item.comment.parentComment.content, 200) }}
|
||||
</NuxtLink>
|
||||
回复了
|
||||
</template>
|
||||
<template v-else> 下评论了 </template>
|
||||
<NuxtLink
|
||||
:to="`/posts/${item.comment.post.id}#comment-${item.comment.id}`"
|
||||
class="timeline-link"
|
||||
>
|
||||
{{ stripMarkdownLength(item.comment.content, 200) }}
|
||||
</NuxtLink>
|
||||
<div class="timeline-date">
|
||||
{{ formatDate(item.comment.createdAt) }}
|
||||
</div>
|
||||
</template>
|
||||
</BaseTimeline>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="summary-empty">暂无热门回复</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hot-topic">
|
||||
<div class="summary-title">热门话题</div>
|
||||
<div class="summary-content" v-if="hotPosts.length > 0">
|
||||
<BaseTimeline :items="hotPosts">
|
||||
<template #item="{ item }">
|
||||
<NuxtLink :to="`/posts/${item.post.id}`" class="timeline-link">
|
||||
{{ item.post.title }}
|
||||
</NuxtLink>
|
||||
<div class="timeline-snippet">
|
||||
{{ stripMarkdown(item.post.snippet) }}
|
||||
</div>
|
||||
<div class="timeline-date">
|
||||
{{ formatDate(item.post.createdAt) }}
|
||||
</div>
|
||||
</template>
|
||||
</BaseTimeline>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="summary-empty">暂无热门话题</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hot-tag">
|
||||
<div class="summary-title">TA创建的tag</div>
|
||||
<div class="summary-content" v-if="hotTags.length > 0">
|
||||
<BaseTimeline :items="hotTags">
|
||||
<template #item="{ item }">
|
||||
<span class="timeline-link" @click="gotoTag(item.tag)">
|
||||
{{ item.tag.name
|
||||
}}<span v-if="item.tag.count"> x{{ item.tag.count }}</span>
|
||||
</span>
|
||||
<div class="timeline-snippet" v-if="item.tag.description">
|
||||
{{ item.tag.description }}
|
||||
</div>
|
||||
<div class="timeline-date">
|
||||
{{ formatDate(item.tag.createdAt) }}
|
||||
</div>
|
||||
</template>
|
||||
</BaseTimeline>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="summary-empty">暂无标签</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary-divider">
|
||||
<div class="hot-reply">
|
||||
<div class="summary-title">热门回复</div>
|
||||
<div class="summary-content" v-if="hotReplies.length > 0">
|
||||
<BaseTimeline :items="hotReplies">
|
||||
|
||||
<div v-else-if="selectedTab === 'timeline'" class="profile-timeline">
|
||||
<div class="timeline-tabs">
|
||||
<div
|
||||
:class="['timeline-tab-item', { selected: timelineFilter === 'all' }]"
|
||||
@click="timelineFilter = 'all'"
|
||||
>
|
||||
全部
|
||||
</div>
|
||||
<div
|
||||
:class="['timeline-tab-item', { selected: timelineFilter === 'articles' }]"
|
||||
@click="timelineFilter = 'articles'"
|
||||
>
|
||||
文章
|
||||
</div>
|
||||
<div
|
||||
:class="['timeline-tab-item', { selected: timelineFilter === 'comments' }]"
|
||||
@click="timelineFilter = 'comments'"
|
||||
>
|
||||
评论和回复
|
||||
</div>
|
||||
</div>
|
||||
<BasePlaceholder
|
||||
v-if="filteredTimelineItems.length === 0"
|
||||
text="暂无时间线"
|
||||
icon="fas fa-inbox"
|
||||
/>
|
||||
<div class="timeline-list">
|
||||
<BaseTimeline :items="filteredTimelineItems">
|
||||
<template #item="{ item }">
|
||||
在
|
||||
<NuxtLink :to="`/posts/${item.comment.post.id}`" class="timeline-link">
|
||||
{{ item.comment.post.title }}
|
||||
</NuxtLink>
|
||||
<template v-if="item.comment.parentComment">
|
||||
<template v-if="item.type === 'post'">
|
||||
发布了文章
|
||||
<NuxtLink :to="`/posts/${item.post.id}`" class="timeline-link">
|
||||
{{ item.post.title }}
|
||||
</NuxtLink>
|
||||
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'comment'">
|
||||
在
|
||||
<NuxtLink :to="`/posts/${item.comment.post.id}`" class="timeline-link">
|
||||
{{ item.comment.post.title }}
|
||||
</NuxtLink>
|
||||
下评论了
|
||||
<NuxtLink
|
||||
:to="`/posts/${item.comment.post.id}#comment-${item.comment.id}`"
|
||||
class="timeline-link"
|
||||
>
|
||||
{{ stripMarkdownLength(item.comment.content, 200) }}
|
||||
</NuxtLink>
|
||||
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'reply'">
|
||||
在
|
||||
<NuxtLink :to="`/posts/${item.comment.post.id}`" class="timeline-link">
|
||||
{{ item.comment.post.title }}
|
||||
</NuxtLink>
|
||||
下对
|
||||
<NuxtLink
|
||||
:to="`/posts/${item.comment.post.id}#comment-${item.comment.parentComment.id}`"
|
||||
@@ -148,28 +246,53 @@
|
||||
{{ stripMarkdownLength(item.comment.parentComment.content, 200) }}
|
||||
</NuxtLink>
|
||||
回复了
|
||||
<NuxtLink
|
||||
:to="`/posts/${item.comment.post.id}#comment-${item.comment.id}`"
|
||||
class="timeline-link"
|
||||
>
|
||||
{{ stripMarkdownLength(item.comment.content, 200) }}
|
||||
</NuxtLink>
|
||||
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'tag'">
|
||||
创建了标签
|
||||
<span class="timeline-link" @click="gotoTag(item.tag)">
|
||||
{{ item.tag.name }}<span v-if="item.tag.count"> x{{ item.tag.count }}</span>
|
||||
</span>
|
||||
<div class="timeline-snippet" v-if="item.tag.description">
|
||||
{{ item.tag.description }}
|
||||
</div>
|
||||
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
||||
</template>
|
||||
<template v-else> 下评论了 </template>
|
||||
<NuxtLink
|
||||
:to="`/posts/${item.comment.post.id}#comment-${item.comment.id}`"
|
||||
class="timeline-link"
|
||||
>
|
||||
{{ stripMarkdownLength(item.comment.content, 200) }}
|
||||
</NuxtLink>
|
||||
<div class="timeline-date">
|
||||
{{ formatDate(item.comment.createdAt) }}
|
||||
</div>
|
||||
</template>
|
||||
</BaseTimeline>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="summary-empty">暂无热门回复</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="selectedTab === 'following'" class="follow-container">
|
||||
<div class="follow-tabs">
|
||||
<div
|
||||
:class="['follow-tab-item', { selected: followTab === 'followers' }]"
|
||||
@click="followTab = 'followers'"
|
||||
>
|
||||
关注者
|
||||
</div>
|
||||
<div
|
||||
:class="['follow-tab-item', { selected: followTab === 'following' }]"
|
||||
@click="followTab = 'following'"
|
||||
>
|
||||
正在关注
|
||||
</div>
|
||||
</div>
|
||||
<div class="follow-list">
|
||||
<UserList v-if="followTab === 'followers'" :users="followers" />
|
||||
<UserList v-else :users="followings" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="hot-topic">
|
||||
<div class="summary-title">热门话题</div>
|
||||
<div class="summary-content" v-if="hotPosts.length > 0">
|
||||
<BaseTimeline :items="hotPosts">
|
||||
|
||||
<div v-else-if="selectedTab === 'favorites'" class="favorites-container">
|
||||
<div v-if="favoritePosts.length > 0">
|
||||
<BaseTimeline :items="favoritePosts">
|
||||
<template #item="{ item }">
|
||||
<NuxtLink :to="`/posts/${item.post.id}`" class="timeline-link">
|
||||
{{ item.post.title }}
|
||||
@@ -177,163 +300,34 @@
|
||||
<div class="timeline-snippet">
|
||||
{{ stripMarkdown(item.post.snippet) }}
|
||||
</div>
|
||||
<div class="timeline-date">
|
||||
{{ formatDate(item.post.createdAt) }}
|
||||
</div>
|
||||
<div class="timeline-date">{{ formatDate(item.post.createdAt) }}</div>
|
||||
</template>
|
||||
</BaseTimeline>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="summary-empty">暂无热门话题</div>
|
||||
<BasePlaceholder text="暂无收藏文章" icon="fas fa-inbox" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="hot-tag">
|
||||
<div class="summary-title">TA创建的tag</div>
|
||||
<div class="summary-content" v-if="hotTags.length > 0">
|
||||
<BaseTimeline :items="hotTags">
|
||||
<template #item="{ item }">
|
||||
<span class="timeline-link" @click="gotoTag(item.tag)">
|
||||
{{ item.tag.name }}<span v-if="item.tag.count"> x{{ item.tag.count }}</span>
|
||||
</span>
|
||||
<div class="timeline-snippet" v-if="item.tag.description">
|
||||
{{ item.tag.description }}
|
||||
</div>
|
||||
<div class="timeline-date">
|
||||
{{ formatDate(item.tag.createdAt) }}
|
||||
</div>
|
||||
</template>
|
||||
</BaseTimeline>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="summary-empty">暂无标签</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="selectedTab === 'timeline'" class="profile-timeline">
|
||||
<div class="timeline-tabs">
|
||||
<div
|
||||
:class="['timeline-tab-item', { selected: timelineFilter === 'all' }]"
|
||||
@click="timelineFilter = 'all'"
|
||||
>
|
||||
全部
|
||||
<div v-else-if="selectedTab === 'achievements'" class="achievements-container">
|
||||
<AchievementList :medals="medals" :can-select="isMine" />
|
||||
</div>
|
||||
<div
|
||||
:class="['timeline-tab-item', { selected: timelineFilter === 'articles' }]"
|
||||
@click="timelineFilter = 'articles'"
|
||||
>
|
||||
文章
|
||||
</div>
|
||||
<div
|
||||
:class="['timeline-tab-item', { selected: timelineFilter === 'comments' }]"
|
||||
@click="timelineFilter = 'comments'"
|
||||
>
|
||||
评论和回复
|
||||
</div>
|
||||
</div>
|
||||
<BasePlaceholder
|
||||
v-if="filteredTimelineItems.length === 0"
|
||||
text="暂无时间线"
|
||||
icon="fas fa-inbox"
|
||||
/>
|
||||
<div class="timeline-list">
|
||||
<BaseTimeline :items="filteredTimelineItems">
|
||||
<template #item="{ item }">
|
||||
<template v-if="item.type === 'post'">
|
||||
发布了文章
|
||||
<NuxtLink :to="`/posts/${item.post.id}`" class="timeline-link">
|
||||
{{ item.post.title }}
|
||||
</NuxtLink>
|
||||
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'comment'">
|
||||
在
|
||||
<NuxtLink :to="`/posts/${item.comment.post.id}`" class="timeline-link">
|
||||
{{ item.comment.post.title }}
|
||||
</NuxtLink>
|
||||
下评论了
|
||||
<NuxtLink
|
||||
:to="`/posts/${item.comment.post.id}#comment-${item.comment.id}`"
|
||||
class="timeline-link"
|
||||
>
|
||||
{{ stripMarkdownLength(item.comment.content, 200) }}
|
||||
</NuxtLink>
|
||||
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'reply'">
|
||||
在
|
||||
<NuxtLink :to="`/posts/${item.comment.post.id}`" class="timeline-link">
|
||||
{{ item.comment.post.title }}
|
||||
</NuxtLink>
|
||||
下对
|
||||
<NuxtLink
|
||||
:to="`/posts/${item.comment.post.id}#comment-${item.comment.parentComment.id}`"
|
||||
class="timeline-link"
|
||||
>
|
||||
{{ stripMarkdownLength(item.comment.parentComment.content, 200) }}
|
||||
</NuxtLink>
|
||||
回复了
|
||||
<NuxtLink
|
||||
:to="`/posts/${item.comment.post.id}#comment-${item.comment.id}`"
|
||||
class="timeline-link"
|
||||
>
|
||||
{{ stripMarkdownLength(item.comment.content, 200) }}
|
||||
</NuxtLink>
|
||||
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'tag'">
|
||||
创建了标签
|
||||
<span class="timeline-link" @click="gotoTag(item.tag)">
|
||||
{{ item.tag.name }}<span v-if="item.tag.count"> x{{ item.tag.count }}</span>
|
||||
</span>
|
||||
<div class="timeline-snippet" v-if="item.tag.description">
|
||||
{{ item.tag.description }}
|
||||
</div>
|
||||
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
|
||||
</template>
|
||||
</template>
|
||||
</BaseTimeline>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="selectedTab === 'following'" class="follow-container">
|
||||
<div class="follow-tabs">
|
||||
<div
|
||||
:class="['follow-tab-item', { selected: followTab === 'followers' }]"
|
||||
@click="followTab = 'followers'"
|
||||
>
|
||||
关注者
|
||||
</div>
|
||||
<div
|
||||
:class="['follow-tab-item', { selected: followTab === 'following' }]"
|
||||
@click="followTab = 'following'"
|
||||
>
|
||||
正在关注
|
||||
</div>
|
||||
</div>
|
||||
<div class="follow-list">
|
||||
<UserList v-if="followTab === 'followers'" :users="followers" />
|
||||
<UserList v-else :users="followings" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="selectedTab === 'achievements'" class="achievements-container">
|
||||
<AchievementList :medals="medals" :can-select="isMine" />
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
</BaseTabs>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useRoute } from 'vue-router'
|
||||
import AchievementList from '~/components/AchievementList.vue'
|
||||
import BasePlaceholder from '~/components/BasePlaceholder.vue'
|
||||
import BaseTimeline from '~/components/BaseTimeline.vue'
|
||||
import LevelProgress from '~/components/LevelProgress.vue'
|
||||
import UserList from '~/components/UserList.vue'
|
||||
import BaseTabs from '~/components/BaseTabs.vue'
|
||||
import { toast } from '~/main'
|
||||
import { authState, getToken } from '~/utils/auth'
|
||||
import { prevLevelExp } from '~/utils/level'
|
||||
@@ -346,13 +340,13 @@ definePageMeta({
|
||||
alias: ['/users/:id/'],
|
||||
})
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const username = route.params.id
|
||||
|
||||
const user = ref({})
|
||||
const hotPosts = ref([])
|
||||
const hotReplies = ref([])
|
||||
const hotTags = ref([])
|
||||
const favoritePosts = ref([])
|
||||
const timelineItems = ref([])
|
||||
const timelineFilter = ref('all')
|
||||
const filteredTimelineItems = computed(() => {
|
||||
@@ -370,10 +364,17 @@ const subscribed = ref(false)
|
||||
const isLoading = ref(true)
|
||||
const tabLoading = ref(false)
|
||||
const selectedTab = ref(
|
||||
['summary', 'timeline', 'following', 'achievements'].includes(route.query.tab)
|
||||
['summary', 'timeline', 'following', 'favorites', 'achievements'].includes(route.query.tab)
|
||||
? route.query.tab
|
||||
: 'summary',
|
||||
)
|
||||
const tabs = [
|
||||
{ name: 'summary', label: '总结', icon: 'fas fa-chart-line' },
|
||||
{ name: 'timeline', label: '时间线', icon: 'fas fa-clock' },
|
||||
{ name: 'following', label: '关注', icon: 'fas fa-user-plus' },
|
||||
{ name: 'favorites', label: '收藏', icon: 'fas fa-bookmark' },
|
||||
{ name: 'achievements', label: '勋章', icon: 'fas fa-medal' },
|
||||
]
|
||||
const followTab = ref('followers')
|
||||
|
||||
const levelInfo = computed(() => {
|
||||
@@ -407,7 +408,7 @@ const fetchUser = async () => {
|
||||
user.value = data
|
||||
subscribed.value = !!data.subscribed
|
||||
} else if (res.status === 404) {
|
||||
router.replace('/404')
|
||||
navigateTo('/404')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -473,6 +474,16 @@ const fetchFollowUsers = async () => {
|
||||
followings.value = followingRes.ok ? await followingRes.json() : []
|
||||
}
|
||||
|
||||
const fetchFavorites = async () => {
|
||||
const res = await fetch(`${API_BASE_URL}/api/users/${username}/subscribed-posts`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
favoritePosts.value = data.map((p) => ({ icon: 'fas fa-bookmark', post: p }))
|
||||
} else {
|
||||
favoritePosts.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const loadSummary = async () => {
|
||||
tabLoading.value = true
|
||||
await fetchSummary()
|
||||
@@ -491,6 +502,12 @@ const loadFollow = async () => {
|
||||
tabLoading.value = false
|
||||
}
|
||||
|
||||
const loadFavorites = async () => {
|
||||
tabLoading.value = true
|
||||
await fetchFavorites()
|
||||
tabLoading.value = false
|
||||
}
|
||||
|
||||
const fetchAchievements = async () => {
|
||||
const res = await fetch(`${API_BASE_URL}/api/medals?userId=${user.value.id}`)
|
||||
if (res.ok) {
|
||||
@@ -558,7 +575,7 @@ const sendMessage = async () => {
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
})
|
||||
const result = await response.json()
|
||||
router.push(`/message-box/${result.conversationId}`)
|
||||
navigateTo(`/message-box/${result.conversationId}`)
|
||||
} catch (e) {
|
||||
toast.error('无法发起私信')
|
||||
console.error(e)
|
||||
@@ -579,6 +596,8 @@ const init = async () => {
|
||||
await loadTimeline()
|
||||
} else if (selectedTab.value === 'following') {
|
||||
await loadFollow()
|
||||
} else if (selectedTab.value === 'favorites') {
|
||||
await loadFavorites()
|
||||
} else if (selectedTab.value === 'achievements') {
|
||||
await loadAchievements()
|
||||
}
|
||||
@@ -592,11 +611,13 @@ const init = async () => {
|
||||
onMounted(init)
|
||||
|
||||
watch(selectedTab, async (val) => {
|
||||
// router.replace({ query: { ...route.query, tab: val } })
|
||||
// navigateTo({ query: { ...route.query, tab: val } }, { replace: true })
|
||||
if (val === 'timeline' && timelineItems.value.length === 0) {
|
||||
await loadTimeline()
|
||||
} else if (val === 'following' && followers.value.length === 0 && followings.value.length === 0) {
|
||||
await loadFollow()
|
||||
} else if (val === 'favorites' && favoritePosts.value.length === 0) {
|
||||
await loadFavorites()
|
||||
} else if (val === 'achievements' && medals.value.length === 0) {
|
||||
await loadAchievements()
|
||||
}
|
||||
@@ -750,13 +771,11 @@ watch(selectedTab, async (val) => {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.profile-tabs {
|
||||
.profile-tabs :deep(.base-tabs-header) {
|
||||
position: sticky;
|
||||
top: calc(var(--header-height) + 1px);
|
||||
z-index: 200;
|
||||
background-color: var(--background-color-blur);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
padding: 0 20px;
|
||||
border-bottom: 1px solid var(--normal-border-color);
|
||||
scrollbar-width: none;
|
||||
@@ -764,7 +783,7 @@ watch(selectedTab, async (val) => {
|
||||
backdrop-filter: var(--blur-10);
|
||||
}
|
||||
|
||||
.profile-tabs-item {
|
||||
.profile-tabs :deep(.base-tab-item) {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
flex-direction: row;
|
||||
@@ -777,11 +796,6 @@ watch(selectedTab, async (val) => {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.profile-tabs-item.selected {
|
||||
color: var(--primary-color);
|
||||
border-bottom: 2px solid var(--primary-color);
|
||||
}
|
||||
|
||||
.profile-summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -901,6 +915,10 @@ watch(selectedTab, async (val) => {
|
||||
.follow-container {
|
||||
}
|
||||
|
||||
.favorites-container {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.follow-tabs {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@@ -932,7 +950,7 @@ watch(selectedTab, async (val) => {
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.profile-tabs-item {
|
||||
.profile-tabs :deep(.base-tab-item) {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
// markdown.js
|
||||
import hljs from 'highlight.js/lib/common'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import sanitizeHtml from 'sanitize-html'
|
||||
import { toast } from '../main'
|
||||
import { tiebaEmoji } from './tiebaEmoji'
|
||||
|
||||
// 动态切换 hljs 主题(保持你原有逻辑)
|
||||
if (typeof window !== 'undefined') {
|
||||
const theme =
|
||||
document.documentElement.dataset.theme ||
|
||||
@@ -10,10 +16,8 @@ if (typeof window !== 'undefined') {
|
||||
import('highlight.js/styles/atom-one-light.css')
|
||||
}
|
||||
}
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import { toast } from '../main'
|
||||
import { tiebaEmoji } from './tiebaEmoji'
|
||||
|
||||
/** @section 自定义插件:@mention */
|
||||
function mentionPlugin(md) {
|
||||
const mentionReg = /^@\[([^\]]+)\]/
|
||||
function mention(state, silent) {
|
||||
@@ -27,6 +31,7 @@ function mentionPlugin(md) {
|
||||
['href', `/users/${match[1]}`],
|
||||
['target', '_blank'],
|
||||
['class', 'mention-link'],
|
||||
['rel', 'noopener noreferrer'],
|
||||
]
|
||||
const text = state.push('text', '', 0)
|
||||
text.content = `@${match[1]}`
|
||||
@@ -38,6 +43,7 @@ function mentionPlugin(md) {
|
||||
md.inline.ruler.before('emphasis', 'mention', mention)
|
||||
}
|
||||
|
||||
/** @section 自定义插件:贴吧表情 :tieba123: */
|
||||
function tiebaEmojiPlugin(md) {
|
||||
md.renderer.rules['tieba-emoji'] = (tokens, idx) => {
|
||||
const name = tokens[idx].content
|
||||
@@ -60,7 +66,7 @@ function tiebaEmojiPlugin(md) {
|
||||
})
|
||||
}
|
||||
|
||||
// 链接在新窗口打开
|
||||
/** @section 链接外开 */
|
||||
function linkPlugin(md) {
|
||||
const defaultRender =
|
||||
md.renderer.rules.link_open ||
|
||||
@@ -74,7 +80,6 @@ function linkPlugin(md) {
|
||||
|
||||
if (hrefIndex >= 0) {
|
||||
const href = token.attrs[hrefIndex][1]
|
||||
// 如果是外部链接,添加 target="_blank" 和 rel="noopener noreferrer"
|
||||
if (href.startsWith('http://') || href.startsWith('https://')) {
|
||||
token.attrPush(['target', '_blank'])
|
||||
token.attrPush(['rel', 'noopener noreferrer'])
|
||||
@@ -85,8 +90,9 @@ function linkPlugin(md) {
|
||||
}
|
||||
}
|
||||
|
||||
/** @section MarkdownIt 实例:开启 HTML,但配合强净化 */
|
||||
const md = new MarkdownIt({
|
||||
html: false,
|
||||
html: true, // ⭐ 允许行内 HTML(为 <video> 服务)
|
||||
linkify: true,
|
||||
breaks: true,
|
||||
highlight: (str, lang) => {
|
||||
@@ -100,16 +106,105 @@ const md = new MarkdownIt({
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map(() => `<div class="line-number"></div>`)
|
||||
// 保留你原有的 CodeBlock + 复制按钮 + 行号结构
|
||||
return `<pre class="code-block"><button class="copy-code-btn">Copy</button><div class="line-numbers">${lineNumbers.join('')}</div><code class="hljs language-${lang || ''}">${code.trim()}</code></pre>`
|
||||
},
|
||||
})
|
||||
|
||||
md.use(mentionPlugin)
|
||||
md.use(tiebaEmojiPlugin)
|
||||
md.use(linkPlugin) // 添加链接插件
|
||||
md.use(linkPlugin)
|
||||
|
||||
/** @section sanitize-html 配置:只白名单需要的标签/属性/类名 */
|
||||
const SANITIZE_CFG = {
|
||||
// 允许的标签(包含你代码块里用到的 button/div)
|
||||
allowedTags: [
|
||||
'a',
|
||||
'p',
|
||||
'div',
|
||||
'span',
|
||||
'pre',
|
||||
'code',
|
||||
'button',
|
||||
'img',
|
||||
'br',
|
||||
'hr',
|
||||
'blockquote',
|
||||
'strong',
|
||||
'em',
|
||||
'ul',
|
||||
'ol',
|
||||
'li',
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'h5',
|
||||
'h6',
|
||||
'table',
|
||||
'thead',
|
||||
'tbody',
|
||||
'tr',
|
||||
'td',
|
||||
'th',
|
||||
'video',
|
||||
'source',
|
||||
],
|
||||
// 允许的属性
|
||||
allowedAttributes: {
|
||||
a: ['href', 'name', 'target', 'rel', 'class'],
|
||||
img: ['src', 'alt', 'title', 'width', 'height', 'class'],
|
||||
div: ['class'],
|
||||
span: ['class'],
|
||||
pre: ['class'],
|
||||
code: ['class'],
|
||||
button: ['class'],
|
||||
video: [
|
||||
'controls',
|
||||
'autoplay',
|
||||
'muted',
|
||||
'loop',
|
||||
'playsinline',
|
||||
'poster',
|
||||
'preload',
|
||||
'width',
|
||||
'height',
|
||||
'crossorigin',
|
||||
],
|
||||
source: ['src', 'type'],
|
||||
},
|
||||
// 允许的类名(保留你的样式钩子)
|
||||
allowedClasses: {
|
||||
a: ['mention-link'],
|
||||
img: ['emoji'],
|
||||
pre: ['code-block'],
|
||||
div: ['line-numbers', 'line-number'],
|
||||
code: ['hljs', /^language-/],
|
||||
button: ['copy-code-btn'],
|
||||
},
|
||||
// 允许的协议(视频可能是 blob: / data:)
|
||||
allowedSchemes: ['http', 'https', 'data', 'blob'],
|
||||
allowProtocolRelative: false,
|
||||
// 统一移除所有 on* 事件、style 等(默认就会清理)
|
||||
transformTags: {
|
||||
// 没写 controls 的 video,强制加上(避免静默自动播放)
|
||||
video: (tagName, attribs) => {
|
||||
const attrs = { ...attribs }
|
||||
if (!('controls' in attrs)) attrs.controls = 'controls'
|
||||
// 安全建议:若允许 autoplay,默认要求 muted
|
||||
if ('autoplay' in attrs && !('muted' in attrs)) {
|
||||
attrs.muted = 'muted'
|
||||
}
|
||||
return { tagName, attribs: attrs }
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
/** @section 渲染 & 事件 */
|
||||
export function renderMarkdown(text) {
|
||||
return md.render(text || '')
|
||||
const raw = md.render(text || '')
|
||||
// ⭐ 核心:对最终 HTML 进行一次净化
|
||||
return sanitizeHtml(raw, SANITIZE_CFG)
|
||||
}
|
||||
|
||||
export function handleMarkdownClick(e) {
|
||||
@@ -124,20 +219,16 @@ export function handleMarkdownClick(e) {
|
||||
}
|
||||
}
|
||||
|
||||
/** @section 纯文本提取(保持你原有“统一正则法”) */
|
||||
export function stripMarkdown(text) {
|
||||
const html = md.render(text || '')
|
||||
|
||||
// 统一使用正则表达式方法,确保服务端和客户端行为一致
|
||||
let plainText = html.replace(/<[^>]+>/g, '')
|
||||
|
||||
// 标准化空白字符处理
|
||||
plainText = plainText
|
||||
.replace(/\r\n/g, '\n') // Windows换行符转为Unix格式
|
||||
.replace(/\r/g, '\n') // 旧Mac换行符转为Unix格式
|
||||
.replace(/[ \t]+/g, ' ') // 合并空格和制表符为单个空格
|
||||
.replace(/\n{3,}/g, '\n\n') // 最多保留两个连续换行(一个空行)
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/\r/g, '\n')
|
||||
.replace(/[ \t]+/g, ' ')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
|
||||
return plainText
|
||||
}
|
||||
|
||||
@@ -146,6 +237,5 @@ export function stripMarkdownLength(text, length) {
|
||||
if (!length || plain.length <= length) {
|
||||
return plain
|
||||
}
|
||||
// 截断并加省略号
|
||||
return plain.slice(0, length) + '...'
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ export function createVditor(editorId, options = {}) {
|
||||
const { placeholder = '', preview = {}, input, after } = options
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
|
||||
|
||||
const fetchMentions = async (value) => {
|
||||
if (!value) {
|
||||
@@ -80,7 +81,7 @@ export function createVditor(editorId, options = {}) {
|
||||
}))
|
||||
},
|
||||
},
|
||||
vditorPostCitation(API_BASE_URL),
|
||||
vditorPostCitation(API_BASE_URL, WEBSITE_BASE_URL),
|
||||
],
|
||||
},
|
||||
cdn: 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/vditor',
|
||||
|
||||
@@ -10,7 +10,7 @@ async function searchPost(apiBaseUrl, keyword) {
|
||||
})
|
||||
}
|
||||
|
||||
export default (apiBaseUrl) => {
|
||||
export default (apiBaseUrl, websiteBaseUrl) => {
|
||||
return {
|
||||
key: '#',
|
||||
hint: async (keyword) => {
|
||||
@@ -22,8 +22,8 @@ export default (apiBaseUrl) => {
|
||||
let value = ''
|
||||
return (
|
||||
body.map((item) => ({
|
||||
value: `[${item.title}](/posts/${item.id})`,
|
||||
html: `<div>${item.title}</div>`,
|
||||
value: `[🔗${item.title}](${websiteBaseUrl}/posts/${item.id})`,
|
||||
html: `<div><i class="fas fa-link"></i> ${item.title}</div>`,
|
||||
})) ?? []
|
||||
)
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user