Compare commits

...

89 Commits

Author SHA1 Message Date
Tim
db9b48c3a4 feat: add reusable tabs component with swipe 2025-08-27 12:13:15 +08:00
tim
3eabafadf8 fix: UI修改,采用baseplaceholder #732 2025-08-26 23:18:07 +08:00
tim
62c1983fd5 fix: markdown 支持 video 2025-08-26 13:39:33 +08:00
tim
689b719e18 fix: use keys count 2025-08-26 13:18:20 +08:00
tim
c6eccb01b9 fix: 样式修改 2025-08-26 13:14:03 +08:00
tim
cdf7e61157 Revert "fix: use keys count"
This reverts commit d23511ecb9.
2025-08-26 13:09:20 +08:00
tim
d23511ecb9 fix: use keys count 2025-08-26 13:06:05 +08:00
Tim
c76708d2ff Merge pull request #725 from nagisa77/feature/daily_bugfix_0826
0826 daily bugfix
2025-08-26 11:22:10 +08:00
Tim
d978bd428e fix: 积分icon优化 2025-08-26 11:21:43 +08:00
Tim
e5954cfb62 Merge pull request #730 from nagisa77/codex/add-point-system-for-lottery-participation
feat: integrate points with lottery participation
2025-08-26 11:14:36 +08:00
Tim
cb614b9739 feat: integrate points with lottery participation 2025-08-26 11:14:20 +08:00
Tim
88ce6b682d fix: 抽奖ui 优化 2025-08-26 10:59:54 +08:00
Tim
e02db635c4 fix: 调整收藏样式 2025-08-26 10:52:53 +08:00
Tim
231379181a Merge pull request #729 from nagisa77/codex/add-tab-for-favorite-articles
feat: add favorites tab to user profile
2025-08-26 10:49:01 +08:00
Tim
bd9ce67d4b feat: add favorites tab to user profile 2025-08-26 10:48:38 +08:00
Tim
6527b3790e fix: add link logo, 以及跳转新窗口 2025-08-26 10:47:02 +08:00
Tim
f01e8c942a Merge remote-tracking branch 'origin/main' into feature/daily_bugfix_0826 2025-08-26 10:34:21 +08:00
Tim
1e1ae29d32 fix: reactions面板,超过三种reaction才采用省略样式 而不是三个 #724 2025-08-26 10:33:45 +08:00
Tim
d31a8bfee4 Merge pull request #726 from WoJiaoFuXiaoYun/main
fix: 修复小窗口点击站内链接,会从小窗直接跳,预期主窗口跳转 #723
2025-08-26 10:33:21 +08:00
WangHe
29a96595f7 fix: 修复小窗口点击站内链接,会从小窗直接跳,预期主窗口跳转 #723 2025-08-26 10:14:28 +08:00
Tim
2b242367d7 fix: 站内信:从红点点进去又退出来,没有消退红点,新信息也没适配 #712 2025-08-26 10:12:16 +08:00
Tim
3f0cd2bf0f Merge pull request #720 from nagisa77/feature/daily_bugfix_0825_b
Feature/daily bugfix 0825 b
2025-08-25 20:38:28 +08:00
Tim
a98a631378 Revert "feat: add message float components"
This reverts commit b0eef220a6.
2025-08-25 20:38:10 +08:00
Tim
7701d359dc fix: 允许窗口收起 2025-08-25 20:35:33 +08:00
Tim
ffd9ef8a32 fix: 新增交互 2025-08-25 19:25:06 +08:00
Tim
36cd5ab171 Merge pull request #722 from nagisa77/codex/add-floating-window-support-for-message-box-a7msu4
feat: add floating message box window
2025-08-25 17:20:30 +08:00
Tim
58d86fa065 Merge branch 'feature/daily_bugfix_0825_b' into codex/add-floating-window-support-for-message-box-a7msu4 2025-08-25 17:20:23 +08:00
Tim
df71cf901b feat: add floating message box window 2025-08-25 17:18:34 +08:00
Tim
ac3fc6702a Merge pull request #721 from nagisa77/codex/add-floating-window-support-for-message-box
feat: add floating message window
2025-08-25 17:12:37 +08:00
Tim
b0eef220a6 feat: add message float components 2025-08-25 17:12:21 +08:00
Tim
02d366e2c7 fix: 支持回复/reactions 2025-08-25 17:06:44 +08:00
Tim
6409531a64 Merge pull request #719 from nagisa77/codex/add-reply-and-reaction-support-to-messages
feat: support message replies and reactions
2025-08-25 16:45:49 +08:00
Tim
175ab79b27 feat: support message replies and reactions 2025-08-25 16:42:14 +08:00
Tim
b543953d22 Revert "feat: support floating message box"
This reverts commit cd73747164.

# Conflicts:
#	frontend_nuxt/pages/message-box/index.vue
2025-08-25 15:51:02 +08:00
Tim
b4fef68af5 Merge branch 'feature/daily_bugfix_0825_b' of github.com:nagisa77/OpenIsle into feature/daily_bugfix_0825_b 2025-08-25 15:45:19 +08:00
Tim
6c48a38212 feat: delete router 2025-08-25 15:44:14 +08:00
Tim
8a3e4d8e98 Merge pull request #718 from nagisa77/codex/add-floating-window-support
feat: add floating message window
2025-08-25 15:43:43 +08:00
Tim
cd73747164 feat: support floating message box 2025-08-25 15:42:09 +08:00
Tim
0ee58df868 Merge pull request #716 from 4twocc/fix/safari-copy
fix(frontend): 修复 Safari 浏览器下邀请链接复制问题
2025-08-25 14:01:39 +08:00
Tim
6fed8131f6 Merge pull request #717 from WoJiaoFuXiaoYun/main
feat: 编辑器支持引用站内帖子
2025-08-25 14:00:25 +08:00
Tim
d75c08396a Merge pull request #714 from nagisa77/feature/daily_bugfix_0825
Feature/daily bugfix 0825
2025-08-25 14:00:02 +08:00
Tim
3a742fbb00 fix: tabs ui格式统一 #710 2025-08-25 13:56:42 +08:00
浮小云
9c2b1f6e98 Merge branch 'nagisa77:main' into main 2025-08-25 11:40:01 +08:00
WangHe
28b33d8c44 opt: 优化仅支持文章标题搜索 2025-08-25 11:38:18 +08:00
jiahaosheng
1f99a10322 fix(frontend): 修复 Safari 浏览器下邀请链接复制问题
- 在 Safari 浏览器中,直接使用 navigator.clipboard.writeText 可能导致权限问题
- 通过在 setTimeout 中调用 clipboard API,规避了 Safari 的权限限制
2025-08-25 11:04:32 +08:00
Tim
743c3dbc72 Merge pull request #715 from nagisa77/codex/update-reactionemojimap-to-google-emoji-cdn
feat: use Google emoji CDN
2025-08-25 11:03:21 +08:00
Tim
d46a446f2b feat: use Google emoji CDN 2025-08-25 11:03:06 +08:00
Tim
75a785f612 Merge branch 'feature/daily_bugfix_0825' of github.com:nagisa77/OpenIsle into feature/daily_bugfix_0825 2025-08-25 11:02:22 +08:00
Tim
e79b75f340 fix: tabs ui格式统一 #710 2025-08-25 11:01:52 +08:00
Tim
1f6f470ab5 Merge pull request #713 from nagisa77/codex/limit-base-timeline-hover-to-messages
feat: limit BaseTimeline hover to private messages
2025-08-25 10:29:52 +08:00
Tim
583d4042f5 feat: add optional hover for BaseTimeline 2025-08-25 10:29:35 +08:00
Tim
8437c1c714 Merge pull request #709 from nagisa77/codex/add-globalpopup-for-internal-messages
feat: add message feature popup
2025-08-23 02:58:34 +08:00
Tim
2613fe6cf1 feat: introduce message popup component 2025-08-23 02:58:24 +08:00
Tim
a15d541b72 Merge pull request #699 from nagisa77/feature/daily_bugfix_0823
daily bugfix
2025-08-23 02:49:36 +08:00
Tim
8657a06f52 Merge pull request #708 from nagisa77/codex/restrict-conversation-search-to-direct-messages
Fix findOrCreateConversation to only retrieve private conversations
2025-08-23 02:46:53 +08:00
Tim
09900b34aa Restrict conversation lookup to private chats 2025-08-23 02:46:41 +08:00
Tim
4e1c3f5839 Merge pull request #707 from nagisa77/codex/fix-multiple-results-error-in-findconversationbyusers
Handle multiple conversations between users
2025-08-23 02:39:28 +08:00
Tim
d97cc7df5e Handle multiple conversations between users 2025-08-23 02:38:31 +08:00
Tim
151242f3ba Merge pull request #706 from nagisa77/codex/add-unread-message-indicators-for-channels
feat: separate channel unread notifications
2025-08-23 02:28:43 +08:00
Tim
b2783a0168 chore: remove obsolete channel unread hook 2025-08-23 02:28:06 +08:00
tim
c79bcac217 fix: member count word 2025-08-23 02:15:11 +08:00
Tim
9a06da3bc1 Merge pull request #705 from nagisa77/codex/add-notification-red-dot-for-channels-uk9sj8
feat: show channel message indicator
2025-08-23 02:11:45 +08:00
Tim
98bbc36453 feat: show channel message indicator 2025-08-23 02:11:25 +08:00
tim
4a04f4ec17 Revert "feat: show channel unread indicator"
This reverts commit cf4ca89e19.
2025-08-23 02:10:52 +08:00
Tim
77be2bfebb Merge pull request #704 from nagisa77/codex/add-notification-red-dot-for-channels
feat: show channel unread indicator
2025-08-23 02:06:01 +08:00
Tim
cf4ca89e19 feat: show channel unread indicator 2025-08-23 02:05:39 +08:00
Tim
094fc78d92 Merge pull request #703 from nagisa77/codex/add-lastmessage-support-for-channel
Add last message retrieval and display for channels
2025-08-23 02:03:16 +08:00
Tim
da3d2a6a71 Return and show last channel message 2025-08-23 02:03:00 +08:00
tim
15cba0c96e fix: 支持显示最后一条消息 2025-08-23 01:57:33 +08:00
Tim
98a79acad9 Merge pull request #702 from nagisa77/codex/add-multi-tab-support-to-message-box
feat: add channel support
2025-08-23 01:31:26 +08:00
Tim
4947978f81 feat: add channel support 2025-08-23 01:31:06 +08:00
Tim
24cc479a56 Merge pull request #700 from nagisa77/codex/create-searchpersondropdown-component
feat: add person search dropdown
2025-08-23 01:06:33 +08:00
Tim
8ee1347b17 Merge branch 'feature/daily_bugfix_0823' into codex/create-searchpersondropdown-component 2025-08-23 01:06:11 +08:00
Tim
7e95120341 feat: add person search dropdown 2025-08-23 01:05:28 +08:00
tim
2f261983ac fix: 暂无会话适配 2025-08-23 01:02:35 +08:00
tim
e8e7b9a245 feat: add search drop down 2025-08-23 00:53:32 +08:00
tim
d2bd949ac8 fix: 前端采用sockjs 2025-08-22 23:57:03 +08:00
tim
605654ec99 fix: 把原生 WS 与 SockJS 分路径,避免混淆 2025-08-22 23:52:53 +08:00
tim
88127fcf34 fix: 把原生 WS 与 SockJS 分路径,避免混淆 2025-08-22 23:51:42 +08:00
tim
0a82f0036b fix: 把原生 WS 与 SockJS 分路径,避免混淆 2025-08-22 23:46:58 +08:00
tim
3a979277e4 fix: registerStompEndpoints 里保留一次注册即可,一般写法是一次 addEndpoint("/api/ws") + .withSockJS(),并统一用 setAllowedOriginPatterns(...) 配置白名单,避免同一路径双注册引起歧义。 2025-08-22 23:35:15 +08:00
tim
1c582fbbf1 fix: WebSocketConfig:同时给 SockJS 注册设置允许的 Origin(endpoint 用 patterns,SockJS 用 exact) 2025-08-22 23:18:05 +08:00
tim
92452da19a fix: 改用 patterns,补齐 staging 域名(https) 2025-08-22 23:05:24 +08:00
tim
a2ccaae7aa fix: 改用 patterns,补齐 staging 域名(https) 2025-08-22 23:01:57 +08:00
tim
23371d4433 Revert "fix: 同源内嵌"
This reverts commit e05d65cf49.
2025-08-22 22:09:41 +08:00
tim
e05d65cf49 fix: 同源内嵌 2025-08-22 22:00:08 +08:00
WangHe
809a78fee3 feat: 编辑器支持引用站内帖子
Related #683
2025-08-22 19:27:33 +08:00
Tim
aaf9b35a45 Merge pull request #698 from nagisa77/feature/daily_bugfix_0822_b
fix: api fix
2025-08-22 17:11:55 +08:00
Tim
69c913394f Merge pull request #697 from nagisa77/feature/daily_bugfix_0822_b
fix: 修复nginx /ws拦截问题
2025-08-22 15:27:10 +08:00
64 changed files with 3092 additions and 1272 deletions

View File

@@ -0,0 +1,32 @@
package com.openisle.config;
import com.openisle.model.MessageConversation;
import com.openisle.repository.MessageConversationRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
public class ChannelInitializer implements CommandLineRunner {
private final MessageConversationRepository conversationRepository;
@Override
public void run(String... args) {
if (conversationRepository.countByChannelTrue() == 0) {
MessageConversation chat = new MessageConversation();
chat.setChannel(true);
chat.setName("吹水群");
chat.setDescription("吹水聊天");
chat.setAvatar("https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/32647273e2334d14adfd4a6ce9db0643.jpeg");
conversationRepository.save(chat);
MessageConversation tech = new MessageConversation();
tech.setChannel(true);
tech.setName("技术讨论群");
tech.setDescription("讨论技术相关话题");
tech.setAvatar("https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/5edde9a5864e471caa32491dbcdaa8b2.png");
conversationRepository.save(tech);
}
}
}

View File

@@ -99,12 +99,13 @@ public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf(csrf -> csrf.disable())
.cors(Customizer.withDefaults()) // 让 Spring 自带 CorsFilter 处理预检
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.cors(Customizer.withDefaults())
.headers(h -> h.frameOptions(f -> f.sameOrigin()))
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.exceptionHandling(eh -> eh.accessDeniedHandler(customAccessDeniedHandler))
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
.requestMatchers("/api/ws/**").permitAll()
.requestMatchers("/api/ws/**", "/api/sockjs/**").permitAll()
.requestMatchers(HttpMethod.POST, "/api/auth/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/posts/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/comments/**").permitAll()
@@ -120,6 +121,7 @@ public class SecurityConfig {
.requestMatchers(HttpMethod.GET, "/api/reaction-types").permitAll()
.requestMatchers(HttpMethod.GET, "/api/activities/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/sitemap.xml").permitAll()
.requestMatchers(HttpMethod.GET, "/api/channels").permitAll()
.requestMatchers(HttpMethod.GET, "/api/rss").permitAll()
.requestMatchers(HttpMethod.GET, "/api/point-goods").permitAll()
.requestMatchers(HttpMethod.POST, "/api/point-goods").permitAll()
@@ -155,7 +157,7 @@ public class SecurityConfig {
uri.startsWith("/api/search") || uri.startsWith("/api/users") ||
uri.startsWith("/api/reaction-types") || uri.startsWith("/api/config") ||
uri.startsWith("/api/activities") || uri.startsWith("/api/push/public-key") ||
uri.startsWith("/api/point-goods") ||
uri.startsWith("/api/point-goods") || uri.startsWith("/api/channels") ||
uri.startsWith("/api/sitemap.xml") || uri.startsWith("/api/medals") ||
uri.startsWith("/api/rss"));
@@ -173,7 +175,8 @@ public class SecurityConfig {
response.getWriter().write("{\"error\": \"Invalid or expired token\"}");
return;
}
} else if (!uri.startsWith("/api/auth") && !publicGet && !uri.startsWith("/api/ws")) {
} else if (!uri.startsWith("/api/auth") && !publicGet
&& !uri.startsWith("/api/ws") && !uri.startsWith("/api/sockjs")) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json");
response.getWriter().write("{\"error\": \"Missing token\"}");

View File

@@ -41,27 +41,38 @@ public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
// 1) 原生 WebSocket不带 SockJS
registry.addEndpoint("/api/ws")
// 安全改进:使用具体的允许源,而不是通配符
.setAllowedOrigins(
"http://127.0.0.1:8080",
"http://127.0.0.1:3000",
"http://127.0.0.1:3001",
"http://127.0.0.1",
"http://localhost:8080",
"http://localhost:3000",
"http://localhost:3001",
"http://localhost",
"http://30.211.97.238:3000",
"http://30.211.97.238",
"http://192.168.7.98",
"http://192.168.7.98:3000",
websiteUrl,
websiteUrl.replace("://www.", "://")
.setAllowedOriginPatterns(
"https://staging.open-isle.com",
"https://www.staging.open-isle.com",
websiteUrl,
websiteUrl.replace("://www.", "://"),
"http://localhost:*",
"http://127.0.0.1:*",
"http://192.168.7.98:*",
"http://30.211.97.238:*"
);
// 2) SockJS 回退:单独路径
registry.addEndpoint("/api/sockjs")
.setAllowedOriginPatterns(
"https://staging.open-isle.com",
"https://www.staging.open-isle.com",
websiteUrl,
websiteUrl.replace("://www.", "://"),
"http://localhost:*",
"http://127.0.0.1:*",
"http://192.168.7.98:*",
"http://30.211.97.238:*"
)
.withSockJS();
.withSockJS()
.setWebSocketEnabled(true)
.setSessionCookieNeeded(false);
}
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(new ChannelInterceptor() {

View File

@@ -0,0 +1,42 @@
package com.openisle.controller;
import com.openisle.dto.ChannelDto;
import com.openisle.model.User;
import com.openisle.repository.UserRepository;
import com.openisle.service.ChannelService;
import com.openisle.service.MessageService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/channels")
@RequiredArgsConstructor
public class ChannelController {
private final ChannelService channelService;
private final MessageService messageService;
private final UserRepository userRepository;
private Long getCurrentUserId(Authentication auth) {
User user = userRepository.findByUsername(auth.getName())
.orElseThrow(() -> new IllegalArgumentException("User not found"));
return user.getId();
}
@GetMapping
public List<ChannelDto> listChannels(Authentication auth) {
return channelService.listChannels(getCurrentUserId(auth));
}
@PostMapping("/{channelId}/join")
public ChannelDto joinChannel(@PathVariable Long channelId, Authentication auth) {
return channelService.joinChannel(channelId, getCurrentUserId(auth));
}
@GetMapping("/unread-count")
public long unreadCount(Authentication auth) {
return messageService.getUnreadChannelCount(getCurrentUserId(auth));
}
}

View File

@@ -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,8 +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(), req.getReplyToId());
return ResponseEntity.ok(messageService.toDto(message));
}
@PostMapping("/conversations/{conversationId}/read")
@@ -71,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)));
@@ -97,6 +87,7 @@ public class MessageController {
static class MessageRequest {
private Long recipientId;
private String content;
private Long replyToId;
public Long getRecipientId() {
return recipientId;
@@ -113,5 +104,34 @@ 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;
}
public void setContent(String content) {
this.content = content;
}
public Long getReplyToId() {
return replyToId;
}
public void setReplyToId(Long replyToId) {
this.replyToId = replyToId;
}
}
}

View File

@@ -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()));

View File

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

View File

@@ -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) {

View File

@@ -0,0 +1,17 @@
package com.openisle.dto;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class ChannelDto {
private Long id;
private String name;
private String description;
private String avatar;
private MessageDto lastMessage;
private long memberCount;
private boolean joined;
private long unreadCount;
}

View File

@@ -8,6 +8,9 @@ import java.util.List;
@Data
public class ConversationDetailDto {
private Long id;
private String name;
private boolean channel;
private String avatar;
private List<UserSummaryDto> participants;
private Page<MessageDto> messages;
}

View File

@@ -10,6 +10,9 @@ import java.util.List;
@Setter
public class ConversationDto {
private Long id;
private String name;
private boolean channel;
private String avatar;
private MessageDto lastMessage;
private List<UserSummaryDto> participants;
private LocalDateTime createdAt;

View File

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

View File

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

View File

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

View File

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

View File

@@ -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()));

View File

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

View File

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

View File

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

View File

@@ -20,6 +20,18 @@ public class MessageConversation {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// Indicates whether this conversation represents a public channel
@Column(nullable = false)
private boolean channel = false;
// Channel metadata
private String name;
@Column(columnDefinition = "TEXT")
private String description;
private String avatar;
@CreationTimestamp
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;

View File

@@ -8,5 +8,7 @@ public enum PointHistoryType {
INVITE,
FEATURE,
SYSTEM_ONLINE,
REDEEM
REDEEM,
LOTTERY_JOIN,
LOTTERY_REWARD
}

View File

@@ -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)")

View File

@@ -6,7 +6,9 @@ package com.openisle.model;
public enum ReactionType {
LIKE,
DISLIKE,
SMILE,
RECOMMEND,
CONGRATULATIONS,
ANGRY,
FLUSHED,
STAR_STRUCK,
@@ -26,5 +28,5 @@ public enum ReactionType {
CHINA,
USA,
JAPAN,
KOREA
KOREA,
}

View File

@@ -1,23 +1,22 @@
package com.openisle.repository;
import com.openisle.model.MessageConversation;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import com.openisle.model.User;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.Optional;
import com.openisle.model.User;
import java.util.List;
@Repository
public interface MessageConversationRepository extends JpaRepository<MessageConversation, Long> {
@Query("SELECT c FROM MessageConversation c JOIN c.participants p1 JOIN c.participants p2 WHERE p1.user = :user1 AND p2.user = :user2")
Optional<MessageConversation> findConversationByUsers(@Param("user1") User user1, @Param("user2") User user2);
@Query("SELECT c FROM MessageConversation c " +
"WHERE c.channel = false AND size(c.participants) = 2 " +
"AND EXISTS (SELECT 1 FROM c.participants p1 WHERE p1.user = :user1) " +
"AND EXISTS (SELECT 1 FROM c.participants p2 WHERE p2.user = :user2) " +
"ORDER BY c.createdAt DESC")
List<MessageConversation> findConversationsByUsers(@Param("user1") User user1, @Param("user2") User user2);
@Query("SELECT DISTINCT c FROM MessageConversation c " +
"JOIN c.participants p " +
@@ -28,4 +27,8 @@ public interface MessageConversationRepository extends JpaRepository<MessageConv
"WHERE p.user.id = :userId " +
"ORDER BY COALESCE(lm.createdAt, c.createdAt) DESC")
List<MessageConversation> findConversationsByUserIdOrderByLastMessageDesc(@Param("userId") Long userId);
}
List<MessageConversation> findByChannelTrue();
long countByChannelTrue();
}

View File

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

View File

@@ -0,0 +1,98 @@
package com.openisle.service;
import com.openisle.dto.ChannelDto;
import com.openisle.dto.MessageDto;
import com.openisle.dto.UserSummaryDto;
import com.openisle.model.Message;
import com.openisle.model.MessageConversation;
import com.openisle.model.MessageParticipant;
import com.openisle.model.User;
import com.openisle.repository.MessageConversationRepository;
import com.openisle.repository.MessageParticipantRepository;
import com.openisle.repository.MessageRepository;
import com.openisle.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class ChannelService {
private final MessageConversationRepository conversationRepository;
private final MessageParticipantRepository participantRepository;
private final MessageRepository messageRepository;
private final UserRepository userRepository;
@Transactional(readOnly = true)
public List<ChannelDto> listChannels(Long userId) {
List<MessageConversation> channels = conversationRepository.findByChannelTrue();
return channels.stream().map(c -> toDto(c, userId)).collect(Collectors.toList());
}
@Transactional
public ChannelDto joinChannel(Long channelId, Long userId) {
MessageConversation channel = conversationRepository.findById(channelId)
.orElseThrow(() -> new IllegalArgumentException("Channel not found"));
User user = userRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("User not found"));
participantRepository.findByConversationIdAndUserId(channelId, userId)
.orElseGet(() -> {
MessageParticipant p = new MessageParticipant();
p.setConversation(channel);
p.setUser(user);
MessageParticipant saved = participantRepository.save(p);
channel.getParticipants().add(saved);
return saved;
});
return toDto(channel, userId);
}
private ChannelDto toDto(MessageConversation channel, Long userId) {
ChannelDto dto = new ChannelDto();
dto.setId(channel.getId());
dto.setName(channel.getName());
dto.setDescription(channel.getDescription());
dto.setAvatar(channel.getAvatar());
if (channel.getLastMessage() != null) {
dto.setLastMessage(toMessageDto(channel.getLastMessage()));
}
dto.setMemberCount(channel.getParticipants().size());
boolean joined = channel.getParticipants().stream()
.anyMatch(p -> p.getUser().getId().equals(userId));
dto.setJoined(joined);
if (joined) {
MessageParticipant participant = channel.getParticipants().stream()
.filter(p -> p.getUser().getId().equals(userId))
.findFirst().orElse(null);
LocalDateTime lastRead = participant.getLastReadAt() == null
? LocalDateTime.of(1970, 1, 1, 0, 0)
: participant.getLastReadAt();
long unread = messageRepository
.countByConversationIdAndCreatedAtAfterAndSenderIdNot(channel.getId(), lastRead, userId);
dto.setUnreadCount(unread);
} else {
dto.setUnreadCount(0);
}
return dto;
}
private MessageDto toMessageDto(Message message) {
MessageDto dto = new MessageDto();
dto.setId(message.getId());
dto.setContent(message.getContent());
dto.setConversationId(message.getConversation().getId());
dto.setCreatedAt(message.getCreatedAt());
UserSummaryDto userDto = new UserSummaryDto();
userDto.setId(message.getSender().getId());
userDto.setUsername(message.getSender().getUsername());
userDto.setAvatar(message.getSender().getAvatar());
dto.setSender(userDto);
return dto;
}
}

View File

@@ -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());
@@ -82,7 +93,58 @@ public class MessageService {
return message;
}
private MessageDto toDto(Message message) {
@Transactional
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)
.orElseThrow(() -> new IllegalArgumentException("Conversation not found"));
// Join the conversation if not already a participant (useful for channels)
participantRepository.findByConversationIdAndUserId(conversationId, senderId)
.orElseGet(() -> {
MessageParticipant p = new MessageParticipant();
p.setConversation(conversation);
p.setUser(sender);
return participantRepository.save(p);
});
Message message = new Message();
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);
conversationRepository.save(conversation);
MessageDto messageDto = toDto(message);
String conversationDestination = "/topic/conversation/" + conversation.getId();
messagingTemplate.convertAndSend(conversationDestination, messageDto);
// Notify all participants except sender for updates
for (MessageParticipant participant : conversation.getParticipants()) {
if (participant.getUser().getId().equals(senderId)) continue;
String userDestination = "/topic/user/" + participant.getUser().getId() + "/messages";
messagingTemplate.convertAndSend(userDestination, messageDto);
long unreadCount = getUnreadMessageCount(participant.getUser().getId());
String username = participant.getUser().getUsername();
messagingTemplate.convertAndSendToUser(username, "/queue/unread-count", unreadCount);
long channelUnread = getUnreadChannelCount(participant.getUser().getId());
messagingTemplate.convertAndSendToUser(username, "/queue/channel-unread", channelUnread);
}
return message;
}
public MessageDto toDto(Message message) {
MessageDto dto = new MessageDto();
dto.setId(message.getId());
dto.setContent(message.getContent());
@@ -95,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;
}
@@ -108,7 +189,8 @@ public class MessageService {
private MessageConversation findOrCreateConversation(User user1, User user2) {
log.info("Searching for existing conversation between {} and {}", user1.getUsername(), user2.getUsername());
return conversationRepository.findConversationByUsers(user1, user2)
return conversationRepository.findConversationsByUsers(user1, user2).stream()
.findFirst()
.orElseGet(() -> {
log.info("No existing conversation found. Creating a new one.");
MessageConversation conversation = new MessageConversation();
@@ -134,12 +216,18 @@ public class MessageService {
@Transactional(readOnly = true)
public List<ConversationDto> getConversations(Long userId) {
List<MessageConversation> conversations = conversationRepository.findConversationsByUserIdOrderByLastMessageDesc(userId);
return conversations.stream().map(c -> toDto(c, userId)).collect(Collectors.toList());
return conversations.stream()
.filter(c -> !c.isChannel())
.map(c -> toDto(c, userId))
.collect(Collectors.toList());
}
private ConversationDto toDto(MessageConversation conversation, Long userId) {
ConversationDto dto = new ConversationDto();
dto.setId(conversation.getId());
dto.setChannel(conversation.isChannel());
dto.setName(conversation.getName());
dto.setAvatar(conversation.getAvatar());
dto.setCreatedAt(conversation.getCreatedAt());
if (conversation.getLastMessage() != null) {
dto.setLastMessage(toDto(conversation.getLastMessage()));
@@ -189,6 +277,9 @@ public class MessageService {
ConversationDetailDto detailDto = new ConversationDetailDto();
detailDto.setId(conversation.getId());
detailDto.setName(conversation.getName());
detailDto.setChannel(conversation.isChannel());
detailDto.setAvatar(conversation.getAvatar());
detailDto.setParticipants(participants);
detailDto.setMessages(messageDtoPage);
@@ -208,10 +299,26 @@ public class MessageService {
List<MessageParticipant> participations = participantRepository.findByUserId(userId);
long totalUnreadCount = 0;
for (MessageParticipant p : participations) {
if (p.getConversation().isChannel()) continue;
LocalDateTime lastRead = p.getLastReadAt() == null ? LocalDateTime.of(1970, 1, 1, 0, 0) : p.getLastReadAt();
// 只计算别人发送给当前用户的未读消息
totalUnreadCount += messageRepository.countByConversationIdAndCreatedAtAfterAndSenderIdNot(p.getConversation().getId(), lastRead, userId);
}
return totalUnreadCount;
}
@Transactional(readOnly = true)
public long getUnreadChannelCount(Long userId) {
List<MessageParticipant> participations = participantRepository.findByUserId(userId);
long unreadChannelCount = 0;
for (MessageParticipant p : participations) {
if (!p.getConversation().isChannel()) continue;
LocalDateTime lastRead = p.getLastReadAt() == null ? LocalDateTime.of(1970, 1, 1, 0, 0) : p.getLastReadAt();
long unread = messageRepository.countByConversationIdAndCreatedAtAfterAndSenderIdNot(p.getConversation().getId(), lastRead, userId);
if (unread > 0) {
unreadChannelCount++;
}
}
return unreadChannelCount;
}
}

View File

@@ -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)

View File

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

View File

@@ -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"));

View File

@@ -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();

View File

@@ -0,0 +1 @@
ALTER TABLE lottery_posts ADD COLUMN point_cost INT NOT NULL DEFAULT 0;

View File

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

View File

@@ -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"))

View File

@@ -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();

View File

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

View File

@@ -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();

View File

@@ -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,19 +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 />
<MessageFloatWindow v-if="!isFloatMode" />
</div>
</template>
@@ -30,6 +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 MessageFloatWindow from '~/components/MessageFloatWindow.vue'
import { useIsMobile } from '~/utils/screen'
const isMobile = useIsMobile()
@@ -52,6 +62,7 @@ const hideMenu = computed(() => {
})
const header = useTemplateRef('header')
const isFloatMode = computed(() => useRoute().query.float !== undefined)
onMounted(() => {
if (typeof window !== 'undefined') {

View File

@@ -18,7 +18,7 @@
--background-color-blur: rgba(255, 255, 255, 0.57);
--menu-border-color: lightgray;
--normal-border-color: lightgray;
--menu-selected-background-color: rgba(208, 250, 255, 0.659);
--menu-selected-background-color: rgba(228, 228, 228, 0.884);
--menu-text-color: black;
--scroller-background-color: rgba(130, 175, 180, 0.5);
/* --normal-background-color: rgb(241, 241, 241); */
@@ -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%;

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

View File

@@ -1,5 +1,5 @@
<template>
<div class="timeline">
<div class="timeline" :class="{ 'hover-enabled': hover }">
<div class="timeline-item" v-for="(item, idx) in items" :key="idx">
<div
class="timeline-icon"
@@ -8,7 +8,7 @@
>
<img v-if="item.src" :src="item.src" class="timeline-img" alt="timeline item" />
<i v-else-if="item.icon" :class="item.icon"></i>
<span v-else-if="item.emoji" class="timeline-emoji">{{ item.emoji }}</span>
<img v-else-if="item.emoji" :src="item.emoji" class="timeline-emoji" alt="emoji" />
</div>
<div class="timeline-content">
<slot name="item" :item="item">{{ item.content }}</slot>
@@ -22,6 +22,7 @@ export default {
name: 'BaseTimeline',
props: {
items: { type: Array, default: () => [] },
hover: { type: Boolean, default: false },
},
}
</script>
@@ -41,6 +42,12 @@ export default {
margin-top: 10px;
}
.hover-enabled .timeline-item:hover {
background-color: var(--menu-selected-background-color);
transition: background-color 0.2s;
border-radius: 10px;
}
.timeline-icon {
position: sticky;
top: 0;
@@ -67,8 +74,9 @@ export default {
}
.timeline-emoji {
font-size: 20px;
line-height: 1;
width: 20px;
height: 20px;
object-fit: contain;
}
.timeline-item::before {

View File

@@ -7,6 +7,7 @@
@close="closeMilkTeaPopup"
/>
<NotificationSettingPopup :visible="showNotificationPopup" @close="closeNotificationPopup" />
<MessagePopup :visible="showMessagePopup" @close="closeMessagePopup" />
<MedalPopup :visible="showMedalPopup" :medals="newMedals" @close="closeMedalPopup" />
<ActivityPopup
@@ -22,6 +23,7 @@
import ActivityPopup from '~/components/ActivityPopup.vue'
import MedalPopup from '~/components/MedalPopup.vue'
import NotificationSettingPopup from '~/components/NotificationSettingPopup.vue'
import MessagePopup from '~/components/MessagePopup.vue'
import { authState } from '~/utils/auth'
const config = useRuntimeConfig()
@@ -33,6 +35,7 @@ const milkTeaIcon = ref('')
const inviteCodeIcon = ref('')
const showNotificationPopup = ref(false)
const showMessagePopup = ref(false)
const showMedalPopup = ref(false)
const newMedals = ref([])
@@ -43,6 +46,9 @@ onMounted(async () => {
await checkInviteCodeActivity()
if (showInviteCodePopup.value) return
await checkMessageFeature()
if (showMessagePopup.value) return
await checkNotificationSetting()
if (showNotificationPopup.value) return
@@ -97,6 +103,18 @@ const closeMilkTeaPopup = () => {
showMilkTeaPopup.value = false
}
const checkMessageFeature = async () => {
if (!import.meta.client) return
if (!authState.loggedIn) return
if (localStorage.getItem('messageFeaturePopupShown')) return
showMessagePopup.value = true
}
const closeMessagePopup = () => {
if (!import.meta.client) return
localStorage.setItem('messageFeaturePopupShown', 'true')
showMessagePopup.value = false
}
const checkNotificationSetting = async () => {
if (!import.meta.client) return
if (!authState.loggedIn) return

View File

@@ -6,7 +6,10 @@
<button class="menu-btn" ref="menuBtn" @click="$emit('toggle-menu')">
<i class="fas fa-bars"></i>
</button>
<span v-if="isMobile && unreadMessageCount > 0" class="menu-unread-dot"></span>
<span
v-if="isMobile && (unreadMessageCount > 0 || hasChannelUnread)"
class="menu-unread-dot"
></span>
</div>
<NuxtLink class="logo-container" :to="`/`" @click="refrechData">
<img
@@ -53,6 +56,7 @@
<span v-if="unreadMessageCount > 0" class="unread-badge">{{
unreadMessageCount
}}</span>
<span v-else-if="hasChannelUnread" class="unread-dot"></span>
</div>
</ToolTip>
@@ -85,6 +89,7 @@ import ToolTip from '~/components/ToolTip.vue'
import SearchDropdown from '~/components/SearchDropdown.vue'
import { authState, clearToken, loadCurrentUser } from '~/utils/auth'
import { useUnreadCount } from '~/composables/useUnreadCount'
import { useChannelsUnreadCount } from '~/composables/useChannelsUnreadCount'
import { useIsMobile } from '~/utils/screen'
import { themeState, cycleTheme, ThemeMode } from '~/utils/theme'
import { toast } from '~/main'
@@ -103,6 +108,7 @@ const props = defineProps({
const isLogin = computed(() => authState.loggedIn)
const isMobile = useIsMobile()
const { count: unreadMessageCount, fetchUnreadCount } = useUnreadCount()
const { hasUnread: hasChannelUnread, fetchChannelUnread } = useChannelsUnreadCount()
const avatar = ref('')
const showSearch = ref(false)
const searchDropdown = ref(null)
@@ -143,8 +149,20 @@ const copyInviteLink = async () => {
if (res.ok) {
const data = await res.json()
const inviteLink = data.token ? `${WEBSITE_BASE_URL}/signup?invite_token=${data.token}` : ''
await navigator.clipboard.writeText(inviteLink)
toast.success('邀请链接已复制')
/**
* navigator.clipboard在webkit中有点奇怪的行为
* https://stackoverflow.com/questions/62327358/javascript-clipboard-api-safari-ios-notallowederror-message
* https://webkit.org/blog/10247/new-webkit-features-in-safari-13-1/
*/
setTimeout(() => {
navigator.clipboard.writeText(inviteLink)
.then(() => {
toast.success('邀请链接已复制')
})
.catch(() => {
toast.error('邀请链接复制失败')
})
}, 0)
} else {
const data = await res.json().catch(() => ({}))
toast.error(data.error || '生成邀请链接失败')
@@ -227,8 +245,10 @@ onMounted(async () => {
}
const updateUnread = async () => {
if (authState.loggedIn) {
// Initialize the unread count composable
fetchUnreadCount()
fetchChannelUnread()
} else {
fetchChannelUnread()
}
}
@@ -413,6 +433,16 @@ onMounted(async () => {
box-sizing: border-box;
}
.unread-dot {
position: absolute;
top: -2px;
right: -4px;
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #ff4d4f;
}
.rss-icon {
animation: rss-glow 2s 3;
}

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

View File

@@ -0,0 +1,74 @@
<template>
<BasePopup :visible="visible" @close="close">
<div class="message-popup">
<div class="message-popup-title">📨 站内信上线啦</div>
<div class="message-popup-text">现在可以在右上角使用站内信功能</div>
<div class="message-popup-actions">
<div class="message-popup-close" @click="close">知道了</div>
<div class="message-popup-button" @click="gotoMessage">去看看</div>
</div>
</div>
</BasePopup>
</template>
<script setup>
import BasePopup from '~/components/BasePopup.vue'
defineProps({
visible: { type: Boolean, default: false },
})
const emit = defineEmits(['close'])
const gotoMessage = () => {
emit('close')
navigateTo('/message-box', { replace: true })
}
const close = () => emit('close')
</script>
<style scoped>
.message-popup {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 10px;
min-width: 200px;
}
.message-popup-title {
font-size: 18px;
font-weight: bold;
margin-bottom: 10px;
}
.message-popup-actions {
margin-top: 10px;
display: flex;
flex-direction: row;
gap: 20px;
}
.message-popup-button {
background-color: var(--primary-color);
color: #fff;
padding: 8px 16px;
border-radius: 8px;
cursor: pointer;
}
.message-popup-button:hover {
background-color: var(--primary-color-hover);
}
.message-popup-close {
cursor: pointer;
color: var(--primary-color);
display: flex;
align-items: center;
}
.message-popup-close:hover {
text-decoration: underline;
}
</style>

View File

@@ -3,24 +3,45 @@
<div class="reactions-viewer">
<div
class="reactions-viewer-item-container"
@click="openPanel"
@mouseenter="cancelHide"
@mouseleave="scheduleHide"
>
<template v-if="displayedReactions.length">
<div v-for="r in displayedReactions" :key="r.type" class="reactions-viewer-item">
{{ reactionEmojiMap[r.type] }}
<template v-if="Object.keys(counts).length < 4">
<div
v-for="r in displayedReactions"
:key="r.type"
class="reactions-viewer-single-item"
:class="{ selected: userReacted(r.type) }"
@click="toggleReaction(r.type)"
>
<img :src="reactionEmojiMap[r.type]" class="emoji" alt="emoji" />
<div>{{ counts[r.type] }}</div>
</div>
<div class="reactions-viewer-item placeholder" @click="openPanel">
<i class="far fa-smile reactions-viewer-item-placeholder-icon"></i>
<!-- <span class="reactions-viewer-item-placeholder-text">点击以表态</span> -->
</div>
</template>
<template v-else-if="displayedReactions.length">
<div
v-for="r in displayedReactions"
:key="r.type"
class="reactions-viewer-item"
@click="openPanel"
>
<img :src="reactionEmojiMap[r.type]" class="emoji" alt="emoji" />
</div>
<div class="reactions-count">{{ totalCount }}</div>
</template>
<div v-else class="reactions-viewer-item placeholder">
<i class="far fa-smile"></i>
<span class="reactions-viewer-item-placeholder-text">点击以表态</span>
</div>
</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>
@@ -40,7 +61,9 @@
@click="toggleReaction(t)"
:class="{ selected: userReacted(t) }"
>
{{ reactionEmojiMap[t] }}<span v-if="counts[t]">{{ counts[t] }}</span>
<img :src="reactionEmojiMap[t]" class="emoji" alt="emoji" /><span v-if="counts[t]">{{
counts[t]
}}</span>
</div>
</div>
</div>
@@ -119,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(
@@ -217,11 +242,8 @@ onMounted(async () => {
font-size: 16px;
}
.reactions-viewer-item.placeholder {
.reactions-viewer-item-placeholder-icon {
opacity: 0.5;
display: flex;
flex-direction: row;
align-items: center;
}
.reactions-viewer-item-placeholder-text {
@@ -262,18 +284,16 @@ onMounted(async () => {
.reactions-panel {
position: absolute;
bottom: 40px;
left: -20px;
bottom: 50px;
background-color: var(--background-color);
border: 1px solid var(--normal-border-color);
border-radius: 5px;
padding: 5px;
max-width: 240px;
border-radius: 20px;
padding: 5px 10px;
display: flex;
flex-direction: row;
flex-wrap: wrap;
z-index: 10;
gap: 2px;
gap: 5px;
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);
}
@@ -287,6 +307,27 @@ onMounted(async () => {
gap: 2px;
}
.reactions-viewer-item.placeholder,
.reactions-viewer-single-item {
display: flex;
cursor: pointer;
flex-direction: row;
padding: 2px 10px;
gap: 5px;
border: 1px solid var(--normal-border-color);
border-radius: 10px;
margin-right: 5px;
margin-bottom: 5px;
font-size: 14px;
color: var(--text-color);
align-items: center;
}
.reactions-viewer-item.placeholder,
.reactions-viewer-single-item.selected {
background-color: var(--menu-selected-background-color);
}
.reaction-option.selected {
background-color: var(--menu-selected-background-color);
}

View File

@@ -0,0 +1,198 @@
<template>
<div class="search-dropdown">
<Dropdown
ref="dropdown"
v-model="selected"
:fetch-options="fetchResults"
remote
menu-class="search-menu"
option-class="search-option"
:show-search="isMobile"
@update:search="keyword = $event"
@close="onClose"
>
<template #display="{ setSearch }">
<div class="search-input">
<i class="search-input-icon fas fa-search"></i>
<input
class="text-input"
v-model="keyword"
placeholder="Search users"
@input="setSearch(keyword)"
/>
</div>
</template>
<template #option="{ option }">
<div class="search-option-item">
<img
:src="option.avatar || '/default-avatar.svg'"
class="avatar"
@error="handleAvatarError"
/>
<div class="result-body">
<div class="result-main" v-html="highlight(option.username)"></div>
<div
v-if="option.introduction"
class="result-sub"
v-html="highlight(option.introduction)"
></div>
</div>
</div>
</template>
</Dropdown>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import Dropdown from '~/components/Dropdown.vue'
import { stripMarkdown } from '~/utils/markdown'
import { useIsMobile } from '~/utils/screen'
import { getToken } from '~/utils/auth'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const emit = defineEmits(['close'])
const keyword = ref('')
const selected = ref(null)
const results = ref([])
const dropdown = ref(null)
const isMobile = useIsMobile()
const toggle = () => {
dropdown.value.toggle()
}
const onClose = () => emit('close')
const fetchResults = async (kw) => {
if (!kw) return []
const res = await fetch(`${API_BASE_URL}/api/search/users?keyword=${encodeURIComponent(kw)}`)
if (!res.ok) return []
const data = await res.json()
results.value = data.map((u) => ({
id: u.id,
username: u.username,
avatar: u.avatar,
introduction: u.introduction,
}))
return results.value
}
const highlight = (text) => {
text = stripMarkdown(text || '')
if (!keyword.value) return text
const reg = new RegExp(keyword.value, 'gi')
return text.replace(reg, (m) => `<span class="highlight">${m}</span>`)
}
const handleAvatarError = (e) => {
e.target.src = '/default-avatar.svg'
}
watch(selected, async (val) => {
if (!val) return
const user = results.value.find((u) => u.id === val)
if (!user) return
const token = getToken()
if (!token) {
navigateTo('/login', { replace: true })
} else {
try {
const res = await fetch(`${API_BASE_URL}/api/messages/conversations`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ recipientId: user.id }),
})
if (res.ok) {
const data = await res.json()
navigateTo(`/message-box/${data.conversationId}`, { replace: true })
}
} catch (e) {
// ignore
}
}
selected.value = null
keyword.value = ''
})
defineExpose({
toggle,
})
</script>
<style scoped>
.search-dropdown {
margin-top: 20px;
width: 500px;
}
.search-input {
padding: 10px;
display: flex;
align-items: center;
width: 100%;
}
.text-input {
background-color: var(--app-menu-background-color);
color: var(--text-color);
border: none;
outline: none;
width: 100%;
margin-left: 10px;
font-size: 16px;
}
.search-menu {
width: 100%;
max-width: 600px;
}
@media (max-width: 768px) {
.search-dropdown {
width: 100%;
}
}
.search-option-item {
display: flex;
gap: 10px;
}
.search-option {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 20px;
}
:deep(.highlight) {
color: var(--primary-color);
}
.avatar {
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
}
.result-body {
display: flex;
flex-direction: column;
}
.result-main {
font-weight: bold;
}
.result-sub {
font-size: 12px;
color: #666;
}
</style>

View File

@@ -0,0 +1,92 @@
import { ref, computed, watch } from 'vue'
import { useWebSocket } from './useWebSocket'
import { getToken } from '~/utils/auth'
const count = ref(0)
let isInitialized = false
let wsSubscription = null
export function useChannelsUnreadCount() {
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const { subscribe, isConnected, connect } = useWebSocket()
const fetchChannelUnread = async () => {
const token = getToken()
if (!token) {
count.value = 0
return
}
try {
const response = await fetch(`${API_BASE_URL}/api/channels/unread-count`, {
headers: { Authorization: `Bearer ${token}` },
})
if (response.ok) {
const data = await response.json()
count.value = data
}
} catch (e) {
console.error('Failed to fetch channel unread count:', e)
}
}
const initialize = () => {
const token = getToken()
if (!token) {
count.value = 0
return
}
fetchChannelUnread()
if (!isConnected.value) {
connect(token)
}
setupWebSocketListener()
}
const setupWebSocketListener = () => {
if (!wsSubscription) {
watch(
isConnected,
(newValue) => {
if (newValue && !wsSubscription) {
wsSubscription = subscribe('/user/queue/channel-unread', (message) => {
const unread = parseInt(message.body, 10)
if (!isNaN(unread)) {
count.value = unread
}
})
}
},
{ immediate: true },
)
}
}
const setFromList = (channels) => {
count.value = Array.isArray(channels) ? channels.filter((c) => c.unreadCount > 0).length : 0
}
const hasUnread = computed(() => count.value > 0)
const token = getToken()
if (token) {
if (!isInitialized) {
isInitialized = true
initialize()
} else {
fetchChannelUnread()
if (!isConnected.value) {
connect(token)
}
setupWebSocketListener()
}
}
return {
count,
hasUnread,
fetchChannelUnread,
initialize,
setFromList,
}
}

View File

@@ -13,7 +13,7 @@ const connect = (token) => {
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const socketUrl = `${API_BASE_URL}/api/ws`
const socketUrl = `${API_BASE_URL}/api/sockjs`
const socket = new SockJS(socketUrl)
const stompClient = new Client({
@@ -55,7 +55,10 @@ const subscribe = (destination, callback) => {
try {
const subscription = client.value.subscribe(destination, (message) => {
try {
if (destination.includes('/queue/unread-count')) {
if (
destination.includes('/queue/unread-count') ||
destination.includes('/queue/channel-unread')
) {
callback(message)
} else {
const parsedMessage = JSON.parse(message.body)

View File

@@ -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",

View File

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

View File

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

View File

@@ -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'
@@ -652,6 +653,10 @@ const sanitizeDescription = (text) => stripMarkdown(text)
}
@container home-page (max-width: 768px) {
.topic-item-container {
margin-left: 0px;
gap: 0px;
}
.article-main-container,
.header-item.main-item {
width: calc(70% - 20px);
@@ -709,6 +714,16 @@ const sanitizeDescription = (text) => stripMarkdown(text)
.topic-container {
position: initial;
padding: 0;
}
.topic-item {
padding: 10px 20px;
}
.topic-select-container {
margin-left: 10px;
margin-top: 10px;
}
}
</style>

View File

@@ -1,35 +1,73 @@
<template>
<div class="chat-container">
<div v-if="!loading && otherParticipant" class="chat-header">
<NuxtLink to="/message-box" class="back-button">
<i class="fas fa-arrow-left"></i>
</NuxtLink>
<h2 class="participant-name">{{ otherParticipant.username }}</h2>
<div class="chat-container" :class="{ float: isFloatMode }">
<div v-if="!loading" class="chat-header">
<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>
<div class="messages-list" ref="messagesListEl">
<div v-if="loading" class="loading-container">加载中...</div>
<div v-if="loading" class="loading-container">
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
</div>
<div v-else-if="error" class="error-container">{{ error }}</div>
<template v-else>
<div class="load-more-container" v-if="hasMoreMessages">
<button @click="loadMoreMessages" :disabled="loadingMore" class="load-more-button">
<div @click="loadMoreMessages" :disabled="loadingMore" class="load-more-button">
{{ loadingMore ? '加载中...' : '查看更多消息' }}
</button>
</div>
</div>
<BaseTimeline :items="messages">
<BaseTimeline :items="messages" hover>
<template #item="{ item }">
<div class="message-timestamp">
{{ TimeManager.format(item.createdAt) }}
<div class="message-header">
<div class="user-name">
{{ item.sender.username }}
</div>
<div class="message-timestamp">
{{ 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">
<BasePlaceholder
v-if="messages.length === 0"
text="暂无会话,发送消息试试 🎉"
icon="fas fa-inbox"
/>
</div>
</template>
</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>
@@ -49,18 +87,22 @@ 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'
import TimeManager from '~/utils/time'
import BaseTimeline from '~/components/BaseTimeline.vue'
import BasePlaceholder from '~/components/BasePlaceholder.vue'
const config = useRuntimeConfig()
const route = useRoute()
const API_BASE_URL = config.public.apiBaseUrl
const { connect, disconnect, subscribe, isConnected } = useWebSocket()
const { fetchUnreadCount: refreshGlobalUnreadCount } = useUnreadCount()
const { fetchChannelUnread: refreshChannelUnread } = useChannelsUnreadCount()
let subscription = null
const messages = ref([])
@@ -76,11 +118,16 @@ const currentPage = ref(0)
const totalPages = ref(0)
const loadingMore = ref(false)
let scrollInterval = null
const conversationName = ref('')
const isChannel = ref(false)
const isFloatMode = computed(() => route.query.float !== undefined)
const floatRoute = useState('messageFloatRoute')
const replyTo = ref(null)
const hasMoreMessages = computed(() => currentPage.value < totalPages.value - 1)
const otherParticipant = computed(() => {
if (!currentUser.value || participants.value.length === 0) {
if (isChannel.value || !currentUser.value || participants.value.length === 0) {
return null
}
return participants.value.find((p) => p.id !== currentUser.value.id)
@@ -94,6 +141,10 @@ function handleAvatarError(event) {
event.target.src = '/default-avatar.svg'
}
function setReply(message) {
replyTo.value = message
}
// No changes needed here, as renderMarkdown is now imported.
// The old function is removed.
@@ -126,6 +177,8 @@ async function fetchMessages(page = 0) {
if (page === 0) {
participants.value = conversationData.participants
conversationName.value = conversationData.name
isChannel.value = conversationData.channel
}
// Since the backend sorts by descending, we need to reverse for correct chat order
@@ -133,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)
},
}))
@@ -172,27 +225,41 @@ async function loadMoreMessages() {
async function sendMessage(content, clearInput) {
if (!content.trim()) return
const recipient = otherParticipant.value
if (!recipient) {
toast.error('无法确定收信人')
return
}
sending.value = true
const token = getToken()
try {
const response = await fetch(`${API_BASE_URL}/api/messages`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
recipientId: recipient.id,
content: content,
}),
})
let response
if (isChannel.value) {
response = await fetch(
`${API_BASE_URL}/api/messages/conversations/${conversationId}/messages`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ content, replyToId: replyTo.value?.id }),
},
)
} else {
const recipient = otherParticipant.value
if (!recipient) {
toast.error('无法确定收信人')
return
}
response = await fetch(`${API_BASE_URL}/api/messages`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
recipientId: recipient.id,
content: content,
replyToId: replyTo.value?.id,
}),
})
}
if (!response.ok) throw new Error('发送失败')
const newMessage = await response.json()
@@ -200,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)
@@ -224,6 +292,7 @@ async function markConversationAsRead() {
})
// After marking as read, refresh the global unread count
refreshGlobalUnreadCount()
refreshChannelUnread()
} catch (e) {
console.error('Failed to mark conversation as read', e)
}
@@ -285,7 +354,7 @@ watch(isConnected, (newValue) => {
...message,
src: message.sender.avatar,
iconClick: () => {
navigateTo(`/users/${message.sender.id}`, { replace: true })
openUser(message.sender.id)
},
})
// 实时收到消息时自动标记为已读
@@ -339,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>
@@ -351,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;
@@ -363,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);
@@ -382,27 +496,21 @@ onUnmounted(() => {
padding-bottom: 100px;
display: flex;
flex-direction: column;
gap: 20px;
margin-bottom: 10px;
}
.load-more-container {
text-align: center;
margin-bottom: 20px;
}
.load-more-button {
background-color: var(--bg-color-soft);
border: 1px solid var(--border-color);
color: var(--text-color-primary);
padding: 8px 16px;
border-radius: 20px;
color: var(--primary-color);
font-size: 12px;
cursor: pointer;
transition: background-color 0.2s;
}
.load-more-button:hover {
background-color: var(--border-color);
text-decoration: underline;
}
.message-item {
@@ -425,10 +533,22 @@ onUnmounted(() => {
.message-timestamp {
font-size: 11px;
color: var(--text-color-secondary);
margin-top: 5px;
opacity: 0.6;
}
.message-header {
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
}
.user-name {
font-size: 14px;
font-weight: 600;
color: var(--text-color);
}
.message-item.sent {
align-self: flex-end;
flex-direction: row-reverse;
@@ -458,21 +578,70 @@ onUnmounted(() => {
margin-right: 20px;
}
.loading-container,
.loading-container {
display: flex;
justify-content: center;
align-items: center;
height: 300px;
}
.error-container {
text-align: center;
padding: 50px;
color: var(--text-color-secondary);
}
.message-input-area {
margin-left: 10px;
margin-right: 10px;
}
.reply-preview {
padding: 5px 10px;
border-left: 5px solid var(--primary-color);
margin-bottom: 5px;
font-size: 13px;
}
.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;
}
}
.message-input-area {
margin-left: 10px;
margin-right: 10px;
}
</style>

View File

@@ -1,84 +1,173 @@
<template>
<div class="messages-container">
<div v-if="loading" class="loading-message">
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
<div class="page-title">
<i class="fas fa-comments"></i>
<span class="page-title-text">选择聊天</span>
</div>
<div v-else-if="error" class="error-container">
<div class="error-text">{{ error }}</div>
<div v-if="!isFloatMode" class="float-control">
<i class="fas fa-compress" @click="minimize" title="最小化"></i>
</div>
<div v-else-if="conversations.length === 0" class="empty-container">
<div class="empty-text">暂无会话</div>
</div>
<div
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 || '未知用户' }}
<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="message-time">
{{ formatTime(convo.lastMessage?.createdAt || convo.createdAt) }}
<div v-else-if="error" class="error-container">
<div class="error-text">{{ error }}</div>
</div>
<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
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>
</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>
</div>
</div>
<div class="last-message-row">
<div class="last-message">
{{
convo.lastMessage ? stripMarkdownLength(convo.lastMessage.content, 100) : '暂无消息'
}}
<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-if="convo.unreadCount > 0" class="unread-count-badge">
{{ convo.unreadCount }}
<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>
</div>
</div>
</template>
</BaseTabs>
</div>
</template>
<script setup>
import { ref, onUnmounted, watch, onActivated } from 'vue'
import { useRouter } from 'vue-router'
import { ref, onUnmounted, watch, onActivated, computed } from 'vue'
import { useRoute } from 'vue-router'
import { getToken, fetchCurrentUser } from '~/utils/auth'
import { toast } from '~/main'
import { useWebSocket } from '~/composables/useWebSocket'
import { useUnreadCount } from '~/composables/useUnreadCount'
import { useChannelsUnreadCount } from '~/composables/useChannelsUnreadCount'
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
const { connect, disconnect, subscribe, isConnected } = useWebSocket()
const { fetchUnreadCount: refreshGlobalUnreadCount } = useUnreadCount()
const { fetchChannelUnread: refreshChannelUnread, setFromList: setChannelUnreadFromList } =
useChannelsUnreadCount()
let subscription = null
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()
if (!token) {
toast.error('请先登录')
return
}
loading.value = true
try {
const response = await fetch(`${API_BASE_URL}/api/messages/conversations`, {
method: 'GET',
@@ -113,13 +202,60 @@ function handleAvatarError(event) {
event.target.src = '/default-avatar.svg'
}
async function fetchChannels() {
const token = getToken()
if (!token) {
toast.error('请先登录')
return
}
loadingChannels.value = true
try {
const response = await fetch(`${API_BASE_URL}/api/channels`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!response.ok) throw new Error('无法加载频道')
const data = await response.json()
channels.value = data
setChannelUnreadFromList(data)
} catch (e) {
toast.error(e.message)
} finally {
loadingChannels.value = false
}
}
async function goToChannel(id) {
const token = getToken()
if (!token) {
toast.error('请先登录')
return
}
try {
await fetch(`${API_BASE_URL}/api/channels/${id}/join`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
})
if (isFloatMode.value) {
navigateTo(`/message-box/${id}?float=1`)
} else {
navigateTo(`/message-box/${id}`)
}
} catch (e) {
toast.error(e.message)
}
}
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) {
connect(token)
@@ -140,10 +276,21 @@ watch(isConnected, (newValue) => {
subscription = subscribe(destination, (message) => {
fetchConversations()
if (activeTab.value === 'channels') {
fetchChannels()
}
})
}
})
watch(activeTab, (val) => {
if (val === 'messages') {
fetchConversations()
} else {
fetchChannels()
}
})
onUnmounted(() => {
if (subscription) {
subscription.unsubscribe()
@@ -152,14 +299,38 @@ 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>
<style scoped>
.messages-container {
margin: 0 auto;
padding: 20px;
position: relative;
}
.float-control {
position: absolute;
top: 0;
right: 0;
text-align: right;
padding: 12px 12px;
}
.float-control i {
cursor: pointer;
}
.messages-container :deep(.base-tabs-header) {
margin-bottom: 16px;
}
.loading-message {
@@ -169,10 +340,31 @@ function goToConversation(id) {
height: 300px;
}
.search-container {
margin-bottom: 24px;
margin-left: 20px;
margin-right: 20px;
}
.messages-header {
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;
@@ -206,6 +398,8 @@ function goToConversation(id) {
.conversation-item {
display: flex;
align-items: center;
margin-left: 20px;
margin-right: 20px;
padding: 8px 10px;
cursor: pointer;
transition: background-color 0.2s ease;
@@ -245,6 +439,12 @@ function goToConversation(id) {
color: var(--text-color);
}
.member-count {
font-size: 12px;
color: gray;
flex-shrink: 0;
}
.message-time {
font-size: 12px;
color: gray;
@@ -280,10 +480,34 @@ function goToConversation(id) {
flex-shrink: 0;
}
/* 响应式设计 */
.unread-dot {
display: inline-block;
width: 8px;
height: 8px;
background-color: #f56c6c;
border-radius: 50%;
margin-left: 4px;
}
@media (max-height: 200px) {
.page-title {
display: block;
}
.base-tabs-wrapper,
.loading-message,
.error-container,
.search-container,
.empty-container,
.conversation-item {
display: none;
}
}
@media (max-width: 768px) {
.messages-container {
padding: 10px 10px;
.conversation-item {
margin-left: 10px;
margin-right: 10px;
}
.messages-title {

View File

File diff suppressed because it is too large Load Diff

View File

@@ -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 {

View File

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

View File

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

View File

@@ -12,25 +12,27 @@
<div class="profile-page-header-user-info">
<div class="profile-page-header-user-info-name">{{ user.username }}</div>
<div class="profile-page-header-user-info-description">{{ user.introduction }}</div>
<div
v-if="!isMine && !subscribed"
class="profile-page-header-subscribe-button"
@click="subscribeUser"
>
<i class="fas fa-user-plus"></i>
关注
</div>
<div
v-if="!isMine && subscribed"
class="profile-page-header-unsubscribe-button"
@click="unsubscribeUser"
>
<i class="fas fa-user-minus"></i>
取消关注
</div>
<div v-if="!isMine" class="profile-page-header-subscribe-button" @click="sendMessage">
<i class="fas fa-paper-plane"></i>
发私信
<div class="profile-page-header-user-info-buttons">
<div
v-if="!isMine && !subscribed"
class="profile-page-header-subscribe-button"
@click="subscribeUser"
>
<i class="fas fa-user-plus"></i>
关注
</div>
<div
v-if="!isMine && subscribed"
class="profile-page-header-unsubscribe-button"
@click="unsubscribeUser"
>
<i class="fas fa-user-minus"></i>
取消关注
</div>
<div v-if="!isMine" class="profile-page-header-subscribe-button" @click="sendMessage">
<i class="fas fa-paper-plane"></i>
发私信
</div>
</div>
<LevelProgress
:exp="levelInfo.exp"
@@ -70,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}`"
@@ -146,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 }}
@@ -175,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'
@@ -344,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(() => {
@@ -368,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(() => {
@@ -405,7 +408,7 @@ const fetchUser = async () => {
user.value = data
subscribed.value = !!data.subscribed
} else if (res.status === 404) {
router.replace('/404')
navigateTo('/404')
}
}
@@ -471,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()
@@ -489,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) {
@@ -556,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)
@@ -577,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()
}
@@ -590,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()
}
@@ -640,6 +663,12 @@ watch(selectedTab, async (val) => {
color: #666;
}
.profile-page-header-user-info-buttons {
display: flex;
flex-direction: row;
gap: 10px;
}
.profile-page-header-subscribe-button {
display: flex;
flex-direction: row;
@@ -742,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;
@@ -756,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;
@@ -769,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;
@@ -893,6 +915,10 @@ watch(selectedTab, async (val) => {
.follow-container {
}
.favorites-container {
padding: 10px;
}
.follow-tabs {
display: flex;
flex-direction: row;
@@ -924,7 +950,7 @@ watch(selectedTab, async (val) => {
height: 100px;
}
.profile-tabs-item {
.profile-tabs :deep(.base-tab-item) {
width: 100px;
}

View File

@@ -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) + '...'
}

View File

@@ -1,25 +1,34 @@
export const reactionEmojiMap = {
LIKE: '❤️',
DISLIKE: '👎',
RECOMMEND: '👏',
ANGRY: '😡',
FLUSHED: '😳',
STAR_STRUCK: '🤩',
ROFL: '🤣',
HOLDING_BACK_TEARS: '🥹',
MIND_BLOWN: '🤯',
POOP: '💩',
CLOWN: '🤡',
SKULL: '☠️',
FIRE: '🔥',
EYES: '👀',
FROWN: '☹️',
HOT: '🥵',
EAGLE: '🦅',
SPIDER: '🕷️',
BAT: '🦇',
CHINA: '🇨🇳',
USA: '🇺🇸',
JAPAN: '🇯🇵',
KOREA: '🇰🇷',
const toCdnUrl = (emoji) => {
const codepoints = Array.from(emoji)
.map((c) => c.codePointAt(0).toString(16))
.join('_')
return `https://fonts.gstatic.com/s/e/notoemoji/latest/${codepoints}/emoji.svg`
}
export const reactionEmojiMap = {
LIKE: toCdnUrl('❤️'),
SMILE: toCdnUrl('😁'),
DISLIKE: toCdnUrl('👎'),
RECOMMEND: toCdnUrl('👏'),
CONGRATULATIONS: toCdnUrl('🎉'),
ANGRY: toCdnUrl('😡'),
FLUSHED: toCdnUrl('😳'),
STAR_STRUCK: toCdnUrl('🤩'),
ROFL: toCdnUrl('🤣'),
HOLDING_BACK_TEARS: toCdnUrl('🥹'),
MIND_BLOWN: toCdnUrl('🤯'),
POOP: toCdnUrl('💩'),
CLOWN: toCdnUrl('🤡'),
SKULL: toCdnUrl('☠️'),
FIRE: toCdnUrl('🔥'),
EYES: toCdnUrl('👀'),
FROWN: toCdnUrl('☹️'),
HOT: toCdnUrl('🥵'),
EAGLE: toCdnUrl('🦅'),
SPIDER: toCdnUrl('🕷️'),
BAT: toCdnUrl('🦇'),
CHINA: toCdnUrl('🇨🇳'),
USA: toCdnUrl('🇺🇸'),
JAPAN: toCdnUrl('🇯🇵'),
KOREA: toCdnUrl('🇰🇷'),
}

View File

@@ -2,6 +2,7 @@ import Vditor from 'vditor'
import { getToken, authState } from './auth'
import { searchUsers, fetchFollowings, fetchAdmins } from './user'
import { tiebaEmoji } from './tiebaEmoji'
import vditorPostCitation from './vditorPostCitation.js'
export function getEditorTheme() {
return document.documentElement.dataset.theme === 'dark' ? 'dark' : 'classic'
@@ -15,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) {
@@ -79,6 +81,7 @@ export function createVditor(editorId, options = {}) {
}))
},
},
vditorPostCitation(API_BASE_URL, WEBSITE_BASE_URL),
],
},
cdn: 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/vditor',

View File

@@ -0,0 +1,37 @@
import { authState, getToken } from '~/utils/auth'
async function searchPost(apiBaseUrl, keyword) {
return await fetch(`${apiBaseUrl}/api/search/posts/title?keyword=${keyword}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${getToken()}`,
},
})
}
export default (apiBaseUrl, websiteBaseUrl) => {
return {
key: '#',
hint: async (keyword) => {
if (!keyword.trim()) return []
try {
const response = await searchPost(apiBaseUrl, keyword)
if (response.ok) {
const body = await response.json()
let value = ''
return (
body.map((item) => ({
value: `[🔗${item.title}](${websiteBaseUrl}/posts/${item.id})`,
html: `<div><i class="fas fa-link"></i> ${item.title}</div>`,
})) ?? []
)
} else {
return []
}
} catch {
return []
}
},
}
}