mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-07 15:41:02 +08:00
Compare commits
173 Commits
feature/me
...
codex/crea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db9b48c3a4 | ||
|
|
3eabafadf8 | ||
|
|
62c1983fd5 | ||
|
|
689b719e18 | ||
|
|
c6eccb01b9 | ||
|
|
cdf7e61157 | ||
|
|
d23511ecb9 | ||
|
|
c76708d2ff | ||
|
|
d978bd428e | ||
|
|
e5954cfb62 | ||
|
|
cb614b9739 | ||
|
|
88ce6b682d | ||
|
|
e02db635c4 | ||
|
|
231379181a | ||
|
|
bd9ce67d4b | ||
|
|
6527b3790e | ||
|
|
f01e8c942a | ||
|
|
1e1ae29d32 | ||
|
|
d31a8bfee4 | ||
|
|
29a96595f7 | ||
|
|
2b242367d7 | ||
|
|
3f0cd2bf0f | ||
|
|
a98a631378 | ||
|
|
7701d359dc | ||
|
|
ffd9ef8a32 | ||
|
|
36cd5ab171 | ||
|
|
58d86fa065 | ||
|
|
df71cf901b | ||
|
|
ac3fc6702a | ||
|
|
b0eef220a6 | ||
|
|
02d366e2c7 | ||
|
|
6409531a64 | ||
|
|
175ab79b27 | ||
|
|
b543953d22 | ||
|
|
b4fef68af5 | ||
|
|
6c48a38212 | ||
|
|
8a3e4d8e98 | ||
|
|
cd73747164 | ||
|
|
0ee58df868 | ||
|
|
6fed8131f6 | ||
|
|
d75c08396a | ||
|
|
3a742fbb00 | ||
|
|
9c2b1f6e98 | ||
|
|
28b33d8c44 | ||
|
|
1f99a10322 | ||
|
|
743c3dbc72 | ||
|
|
d46a446f2b | ||
|
|
75a785f612 | ||
|
|
e79b75f340 | ||
|
|
1f6f470ab5 | ||
|
|
583d4042f5 | ||
|
|
8437c1c714 | ||
|
|
2613fe6cf1 | ||
|
|
a15d541b72 | ||
|
|
8657a06f52 | ||
|
|
09900b34aa | ||
|
|
4e1c3f5839 | ||
|
|
d97cc7df5e | ||
|
|
151242f3ba | ||
|
|
b2783a0168 | ||
|
|
c79bcac217 | ||
|
|
9a06da3bc1 | ||
|
|
98bbc36453 | ||
|
|
4a04f4ec17 | ||
|
|
77be2bfebb | ||
|
|
cf4ca89e19 | ||
|
|
094fc78d92 | ||
|
|
da3d2a6a71 | ||
|
|
15cba0c96e | ||
|
|
98a79acad9 | ||
|
|
4947978f81 | ||
|
|
24cc479a56 | ||
|
|
8ee1347b17 | ||
|
|
7e95120341 | ||
|
|
2f261983ac | ||
|
|
e8e7b9a245 | ||
|
|
d2bd949ac8 | ||
|
|
605654ec99 | ||
|
|
88127fcf34 | ||
|
|
0a82f0036b | ||
|
|
3a979277e4 | ||
|
|
1c582fbbf1 | ||
|
|
92452da19a | ||
|
|
a2ccaae7aa | ||
|
|
23371d4433 | ||
|
|
e05d65cf49 | ||
|
|
809a78fee3 | ||
|
|
aaf9b35a45 | ||
|
|
61c0336a78 | ||
|
|
69c913394f | ||
|
|
0ed9ad2f2a | ||
|
|
67e912381b | ||
|
|
a6a1c72a37 | ||
|
|
d77baa8a93 | ||
|
|
fce4832407 | ||
|
|
91c8cc9607 | ||
|
|
02273e018f | ||
|
|
4af10ecf79 | ||
|
|
d34ed3c058 | ||
|
|
8372e06949 | ||
|
|
a74cb0c272 | ||
|
|
5388767a2f | ||
|
|
97dda9601e | ||
|
|
cddbb602bf | ||
|
|
f21ed1f062 | ||
|
|
c009616f74 | ||
|
|
84ab87878a | ||
|
|
c53f91913c | ||
|
|
feed97154a | ||
|
|
f69562516d | ||
|
|
0b8e550097 | ||
|
|
cf722f5707 | ||
|
|
67e54c5106 | ||
|
|
d3dcc98122 | ||
|
|
c648d4cf39 | ||
|
|
41a5eda311 | ||
|
|
c6e0dc6a1d | ||
|
|
92e630df22 | ||
|
|
c6b0f32b09 | ||
|
|
5f5b6f84a8 | ||
|
|
cd57d478f2 | ||
|
|
da07313df8 | ||
|
|
c08ecb5e33 | ||
|
|
0a722c81c5 | ||
|
|
15071471b2 | ||
|
|
98a9939738 | ||
|
|
9554030054 | ||
|
|
72e9a77373 | ||
|
|
ed7dcd9414 | ||
|
|
79fe8b5997 | ||
|
|
cfce4d7d1d | ||
|
|
b7f5d8485c | ||
|
|
d4677a5799 | ||
|
|
99644046fc | ||
|
|
22c9bd7d39 | ||
|
|
3fc6929075 | ||
|
|
4eed6889d6 | ||
|
|
959b0f6a48 | ||
|
|
91ffacc335 | ||
|
|
4969a759aa | ||
|
|
81e3a80d35 | ||
|
|
d717ce03c1 | ||
|
|
66035447a8 | ||
|
|
fa1148bc4e | ||
|
|
f60f184c84 | ||
|
|
06ffb180fe | ||
|
|
1b892828f1 | ||
|
|
1aa88ab0fe | ||
|
|
86126699d3 | ||
|
|
a6a07b9bda | ||
|
|
d8b3c68150 | ||
|
|
318b481c4b | ||
|
|
7338b891db | ||
|
|
eb18dc8e94 | ||
|
|
aec5321f89 | ||
|
|
2e658f37a4 | ||
|
|
7ccb2a44e3 | ||
|
|
0fa08e2260 | ||
|
|
38a49f7414 | ||
|
|
fb89c9fb25 | ||
|
|
e9458f5419 | ||
|
|
2d87c8f23d | ||
|
|
cb281e4030 | ||
|
|
fe84e3f2fa | ||
|
|
c307732696 | ||
|
|
a29bf7d860 | ||
|
|
27393c15f2 | ||
|
|
c91a787f29 | ||
|
|
6096712291 | ||
|
|
6d20addcde | ||
|
|
d8f9fd670c | ||
|
|
5ebe739917 | ||
|
|
022edc866a |
@@ -1,4 +1 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
npx lint-staged
|
||||
|
||||
116
CONTRIBUTING.md
Normal file
116
CONTRIBUTING.md
Normal file
@@ -0,0 +1,116 @@
|
||||
#### **⚠️注意:仅想修改前端的朋友可不用部署后端服务**
|
||||
|
||||
## 如何部署
|
||||
|
||||
> Step1 先克隆仓库
|
||||
|
||||
```shell
|
||||
git clone https://github.com/nagisa77/OpenIsle.git
|
||||
cd OpenIsle
|
||||
```
|
||||
|
||||
> Step2 后端部署
|
||||
|
||||
```shell
|
||||
cd backend
|
||||
```
|
||||
|
||||
以IDEA编辑器为例,IDEA打开backend文件夹。
|
||||
|
||||
- 设置VM Option,最好运行在其他端口,非8080,这里设置8081
|
||||
|
||||
```shell
|
||||
-Dserver.port=8081
|
||||
```
|
||||
|
||||

|
||||
|
||||
- 设置jdk版本为java 17
|
||||
|
||||

|
||||
|
||||
- 本机配置MySQL服务(网上很多教程,忽略)
|
||||
- 设置环境变量.env 文件 或.properties 文件(二选一)
|
||||
|
||||
1. 环境变量文件生成
|
||||
|
||||
```shell
|
||||
cp open-isle.env.example open-isle.env
|
||||
```
|
||||
|
||||
修改环境变量,留下需要的,比如你要开发Google登录业务,就需要谷歌相关的变量,数据库是一定要的
|
||||
|
||||

|
||||
|
||||
应用环境文件, 选择刚刚的`open-isle.env`
|
||||
|
||||

|
||||
|
||||
2. 直接修改 .properities 文件
|
||||
|
||||
位置src/main/application.properties, 数据库需要修改标红处,其他按需修改
|
||||
|
||||

|
||||
|
||||
处理完环境问题直接跑起来就能通了
|
||||
|
||||

|
||||
|
||||
> Step3 前端部署
|
||||
|
||||
前端可以依赖本机部署的后端,也可以直接调用线上的后端接口
|
||||
|
||||
```shell
|
||||
cd ../frontend_nuxt/
|
||||
```
|
||||
|
||||
copy环境.env文件
|
||||
|
||||
```shell
|
||||
cp .env.staging.example .env
|
||||
```
|
||||
|
||||
1. 依赖本机部署的后端:打开本文件夹,修改.env 修改为瞄准本机后端端口
|
||||
|
||||
```yaml
|
||||
; 本地部署后端
|
||||
NUXT_PUBLIC_API_BASE_URL=https://127.0.0.1:8081
|
||||
; 预发环境后端
|
||||
; NUXT_PUBLIC_API_BASE_URL=https://staging.open-isle.com
|
||||
; 生产环境后端
|
||||
; NUXT_PUBLIC_API_BASE_URL=https://www.open-isle.com
|
||||
```
|
||||
|
||||
2. 依赖预发环境后台环境
|
||||
|
||||
**(⚠️强烈推荐只部署前端的朋友使用该环境)**
|
||||
|
||||
```yaml
|
||||
; 本地部署后端
|
||||
; NUXT_PUBLIC_API_BASE_URL=https://127.0.0.1:8081
|
||||
; 预发环境后端
|
||||
NUXT_PUBLIC_API_BASE_URL=https://staging.open-isle.com
|
||||
; 生产环境后端
|
||||
; NUXT_PUBLIC_API_BASE_URL=https://www.open-isle.com
|
||||
```
|
||||
|
||||
4. 依赖线上后台环境
|
||||
|
||||
```yaml
|
||||
; 本地部署后端
|
||||
; NUXT_PUBLIC_API_BASE_URL=https://127.0.0.1:8081
|
||||
; 预发环境后端
|
||||
; NUXT_PUBLIC_API_BASE_URL=https://staging.open-isle.com
|
||||
; 生产环境后端
|
||||
NUXT_PUBLIC_API_BASE_URL=https://www.open-isle.com
|
||||
```
|
||||
|
||||
```shell
|
||||
# 安装依赖
|
||||
npm install --verbose
|
||||
|
||||
# 运行前端服务
|
||||
npm run dev
|
||||
```
|
||||
|
||||
如此一来,浏览器访问 http://127.0.0.1:3000 即可访问前端页面
|
||||
39
README.md
39
README.md
@@ -1,45 +1,18 @@
|
||||
<p align="center">
|
||||
<img alt="OpenIsle" src="https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png" width="200">
|
||||
<br><br>
|
||||
高效的开源社区前后端端平台
|
||||
<br><br>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square"></a>
|
||||
<br>
|
||||
高效的开源社区前后端平台
|
||||
<br><br><br>
|
||||
<img alt="Image" src="https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/22752cfac5a04a9c90c41995b9f55fed.png" width="1200">
|
||||
</p>
|
||||
|
||||
## 💡 简介
|
||||
|
||||
OpenIsle 是一个使用 Spring Boot 和 Vue 3 构建的全栈开源社区平台,提供用户注册、登录、贴文发布、评论交互等完整功能,可用于项目社区或直接打造自主社区站点。
|
||||
|
||||
## 🚧 开发
|
||||
## 🚧 开发 & 部署
|
||||
|
||||
### 后端
|
||||
|
||||
1. 确保安装 JDK 17 及 Maven
|
||||
2. 信息配置修改 `src/main/resources/application.properties`,或通过环境变量设置数据库等参数
|
||||
3. 执行 `mvn clean package` 生成包,之后使用 `java -jar target/openisle-0.0.1-SNAPSHOT.jar`启动,或在开发时直接使用 `mvn spring-boot:run`
|
||||
|
||||
### 前端
|
||||
|
||||
1. 进入前端目录
|
||||
```bash
|
||||
cd frontend_nuxt
|
||||
```
|
||||
2. 安装依赖
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
3. 启动开发服务
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
生产版本使用如下命令编译:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
会在 `.output` 目录生成文件,配合线上网站方式部署
|
||||
详细见 [Contributing](https://github.com/nagisa77/OpenIsle?tab=contributing-ov-file)
|
||||
|
||||
## ✨ 项目特点
|
||||
|
||||
|
||||
@@ -26,6 +26,10 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-websocket</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-api</artifactId>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,11 +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/**", "/api/sockjs/**").permitAll()
|
||||
.requestMatchers(HttpMethod.POST, "/api/auth/**").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/posts/**").permitAll()
|
||||
.requestMatchers(HttpMethod.GET, "/api/comments/**").permitAll()
|
||||
@@ -119,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()
|
||||
@@ -154,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"));
|
||||
|
||||
@@ -172,7 +175,8 @@ public class SecurityConfig {
|
||||
response.getWriter().write("{\"error\": \"Invalid or expired token\"}");
|
||||
return;
|
||||
}
|
||||
} else if (!uri.startsWith("/api/auth") && !publicGet) {
|
||||
} 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\"}");
|
||||
|
||||
110
backend/src/main/java/com/openisle/config/WebSocketConfig.java
Normal file
110
backend/src/main/java/com/openisle/config/WebSocketConfig.java
Normal file
@@ -0,0 +1,110 @@
|
||||
package com.openisle.config;
|
||||
|
||||
import com.openisle.service.JwtService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.messaging.Message;
|
||||
import org.springframework.messaging.MessageChannel;
|
||||
import org.springframework.messaging.simp.config.ChannelRegistration;
|
||||
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
|
||||
import org.springframework.messaging.simp.stomp.StompCommand;
|
||||
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
|
||||
import org.springframework.messaging.support.ChannelInterceptor;
|
||||
import org.springframework.messaging.support.MessageHeaderAccessor;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
|
||||
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
|
||||
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSocketMessageBroker
|
||||
@RequiredArgsConstructor
|
||||
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
|
||||
|
||||
private final JwtService jwtService;
|
||||
private final UserDetailsService userDetailsService;
|
||||
@Value("${app.website-url}")
|
||||
private String websiteUrl;
|
||||
|
||||
@Override
|
||||
public void configureMessageBroker(MessageBrokerRegistry config) {
|
||||
// Enable a simple memory-based message broker to carry the messages back to the client on destinations prefixed with "/topic" and "/queue"
|
||||
config.enableSimpleBroker("/topic", "/queue");
|
||||
// Set user destination prefix for personal messages
|
||||
config.setUserDestinationPrefix("/user");
|
||||
// Designates the "/app" prefix for messages that are bound for @MessageMapping-annotated methods.
|
||||
config.setApplicationDestinationPrefixes("/app");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerStompEndpoints(StompEndpointRegistry registry) {
|
||||
// 1) 原生 WebSocket(不带 SockJS)
|
||||
registry.addEndpoint("/api/ws")
|
||||
.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()
|
||||
.setWebSocketEnabled(true)
|
||||
.setSessionCookieNeeded(false);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
public void configureClientInboundChannel(ChannelRegistration registration) {
|
||||
registration.interceptors(new ChannelInterceptor() {
|
||||
@Override
|
||||
public Message<?> preSend(Message<?> message, MessageChannel channel) {
|
||||
StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
|
||||
|
||||
if (StompCommand.CONNECT.equals(accessor.getCommand())) {
|
||||
System.out.println("WebSocket CONNECT command received");
|
||||
String authHeader = accessor.getFirstNativeHeader("Authorization");
|
||||
System.out.println("Authorization header: " + (authHeader != null ? "present" : "missing"));
|
||||
|
||||
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
||||
String token = authHeader.substring(7);
|
||||
try {
|
||||
String username = jwtService.validateAndGetSubject(token);
|
||||
System.out.println("JWT validated for user: " + username);
|
||||
var userDetails = userDetailsService.loadUserByUsername(username);
|
||||
Authentication auth = new UsernamePasswordAuthenticationToken(
|
||||
userDetails, null, userDetails.getAuthorities());
|
||||
accessor.setUser(auth);
|
||||
System.out.println("WebSocket user set: " + username);
|
||||
} catch (Exception e) {
|
||||
System.err.println("JWT validation failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
} else if (StompCommand.SUBSCRIBE.equals(accessor.getCommand())) {
|
||||
System.out.println("WebSocket SUBSCRIBE to: " + accessor.getDestination());
|
||||
System.out.println("WebSocket user during subscribe: " + (accessor.getUser() != null ? accessor.getUser().getName() : "null"));
|
||||
}
|
||||
return message;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -47,13 +47,14 @@ public class AuthController {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "Invalid captcha"));
|
||||
}
|
||||
if (req.getInviteToken() != null && !req.getInviteToken().isEmpty()) {
|
||||
if (!inviteService.validate(req.getInviteToken())) {
|
||||
InviteService.InviteValidateResult result = inviteService.validate(req.getInviteToken());
|
||||
if (!result.isValidate()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "邀请码使用次数过多"));
|
||||
}
|
||||
try {
|
||||
User user = userService.registerWithInvite(
|
||||
req.getUsername(), req.getEmail(), req.getPassword());
|
||||
inviteService.consume(req.getInviteToken());
|
||||
inviteService.consume(req.getInviteToken(), user.getUsername());
|
||||
emailService.sendEmail(user.getEmail(), "在网站填写验证码以验证", "您的验证码是 " + user.getVerificationCode());
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"token", jwtService.generateToken(user.getUsername()),
|
||||
@@ -144,7 +145,8 @@ public class AuthController {
|
||||
@PostMapping("/google")
|
||||
public ResponseEntity<?> loginWithGoogle(@RequestBody GoogleLoginRequest req) {
|
||||
boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty();
|
||||
if (viaInvite && !inviteService.validate(req.getInviteToken())) {
|
||||
InviteService.InviteValidateResult inviteValidateResult = inviteService.validate(req.getInviteToken());
|
||||
if (viaInvite && !inviteValidateResult.isValidate()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token"));
|
||||
}
|
||||
Optional<AuthResult> resultOpt = googleAuthService.authenticate(
|
||||
@@ -154,7 +156,7 @@ public class AuthController {
|
||||
if (resultOpt.isPresent()) {
|
||||
AuthResult result = resultOpt.get();
|
||||
if (viaInvite && result.isNewUser()) {
|
||||
inviteService.consume(req.getInviteToken());
|
||||
inviteService.consume(req.getInviteToken(), inviteValidateResult.getInviteToken().getInviter().getUsername());
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"token", jwtService.generateToken(result.getUser().getUsername()),
|
||||
"reason_code", "INVITE_APPROVED"
|
||||
@@ -218,7 +220,8 @@ public class AuthController {
|
||||
@PostMapping("/github")
|
||||
public ResponseEntity<?> loginWithGithub(@RequestBody GithubLoginRequest req) {
|
||||
boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty();
|
||||
if (viaInvite && !inviteService.validate(req.getInviteToken())) {
|
||||
InviteService.InviteValidateResult inviteValidateResult = inviteService.validate(req.getInviteToken());
|
||||
if (viaInvite && !inviteValidateResult.isValidate()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token"));
|
||||
}
|
||||
Optional<AuthResult> resultOpt = githubAuthService.authenticate(
|
||||
@@ -229,7 +232,7 @@ public class AuthController {
|
||||
if (resultOpt.isPresent()) {
|
||||
AuthResult result = resultOpt.get();
|
||||
if (viaInvite && result.isNewUser()) {
|
||||
inviteService.consume(req.getInviteToken());
|
||||
inviteService.consume(req.getInviteToken(), inviteValidateResult.getInviteToken().getInviter().getUsername());
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"token", jwtService.generateToken(result.getUser().getUsername()),
|
||||
"reason_code", "INVITE_APPROVED"
|
||||
@@ -265,7 +268,8 @@ public class AuthController {
|
||||
@PostMapping("/discord")
|
||||
public ResponseEntity<?> loginWithDiscord(@RequestBody DiscordLoginRequest req) {
|
||||
boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty();
|
||||
if (viaInvite && !inviteService.validate(req.getInviteToken())) {
|
||||
InviteService.InviteValidateResult inviteValidateResult = inviteService.validate(req.getInviteToken());
|
||||
if (viaInvite && !inviteValidateResult.isValidate()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token"));
|
||||
}
|
||||
Optional<AuthResult> resultOpt = discordAuthService.authenticate(
|
||||
@@ -276,7 +280,7 @@ public class AuthController {
|
||||
if (resultOpt.isPresent()) {
|
||||
AuthResult result = resultOpt.get();
|
||||
if (viaInvite && result.isNewUser()) {
|
||||
inviteService.consume(req.getInviteToken());
|
||||
inviteService.consume(req.getInviteToken(), inviteValidateResult.getInviteToken().getInviter().getUsername());
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"token", jwtService.generateToken(result.getUser().getUsername()),
|
||||
"reason_code", "INVITE_APPROVED"
|
||||
@@ -311,7 +315,8 @@ public class AuthController {
|
||||
@PostMapping("/twitter")
|
||||
public ResponseEntity<?> loginWithTwitter(@RequestBody TwitterLoginRequest req) {
|
||||
boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty();
|
||||
if (viaInvite && !inviteService.validate(req.getInviteToken())) {
|
||||
InviteService.InviteValidateResult inviteValidateResult = inviteService.validate(req.getInviteToken());
|
||||
if (viaInvite && !inviteValidateResult.isValidate()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token"));
|
||||
}
|
||||
Optional<AuthResult> resultOpt = twitterAuthService.authenticate(
|
||||
@@ -323,7 +328,7 @@ public class AuthController {
|
||||
if (resultOpt.isPresent()) {
|
||||
AuthResult result = resultOpt.get();
|
||||
if (viaInvite && result.isNewUser()) {
|
||||
inviteService.consume(req.getInviteToken());
|
||||
inviteService.consume(req.getInviteToken(), inviteValidateResult.getInviteToken().getInviter().getUsername());
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"token", jwtService.generateToken(result.getUser().getUsername()),
|
||||
"reason_code", "INVITE_APPROVED"
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,7 @@ public class CommentController {
|
||||
Comment comment = commentService.addComment(auth.getName(), postId, req.getContent());
|
||||
CommentDto dto = commentMapper.toDto(comment);
|
||||
dto.setReward(levelService.awardForComment(auth.getName()));
|
||||
dto.setPointReward(pointService.awardForComment(auth.getName(),postId));
|
||||
dto.setPointReward(pointService.awardForComment(auth.getName(), postId, comment.getId()));
|
||||
log.debug("createComment succeeded for comment {}", comment.getId());
|
||||
return ResponseEntity.ok(dto);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import com.openisle.dto.ConversationDetailDto;
|
||||
import com.openisle.dto.ConversationDto;
|
||||
import com.openisle.dto.CreateConversationRequest;
|
||||
import com.openisle.dto.CreateConversationResponse;
|
||||
import com.openisle.dto.MessageDto;
|
||||
import com.openisle.model.Message;
|
||||
import com.openisle.model.MessageConversation;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import com.openisle.service.MessageService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/messages")
|
||||
@RequiredArgsConstructor
|
||||
public class MessageController {
|
||||
|
||||
private final MessageService messageService;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
// This is a placeholder for getting the current user's ID
|
||||
private Long getCurrentUserId(Authentication auth) {
|
||||
User user = userRepository.findByUsername(auth.getName()).orElseThrow(() -> new IllegalArgumentException("Sender not found"));
|
||||
// In a real application, you would get this from the Authentication object
|
||||
return user.getId();
|
||||
}
|
||||
|
||||
@GetMapping("/conversations")
|
||||
public ResponseEntity<List<ConversationDto>> getConversations(Authentication auth) {
|
||||
List<ConversationDto> conversations = messageService.getConversations(getCurrentUserId(auth));
|
||||
return ResponseEntity.ok(conversations);
|
||||
}
|
||||
|
||||
@GetMapping("/conversations/{conversationId}")
|
||||
public ResponseEntity<ConversationDetailDto> getMessages(@PathVariable Long conversationId,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size,
|
||||
Authentication auth) {
|
||||
Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
|
||||
ConversationDetailDto conversationDetails = messageService.getConversationDetails(conversationId, getCurrentUserId(auth), pageable);
|
||||
return ResponseEntity.ok(conversationDetails);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<MessageDto> sendMessage(@RequestBody MessageRequest req, Authentication auth) {
|
||||
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")
|
||||
public ResponseEntity<Void> markAsRead(@PathVariable Long conversationId, Authentication auth) {
|
||||
messageService.markConversationAsRead(conversationId, getCurrentUserId(auth));
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
@PostMapping("/conversations")
|
||||
public ResponseEntity<CreateConversationResponse> findOrCreateConversation(@RequestBody CreateConversationRequest req, Authentication auth) {
|
||||
MessageConversation conversation = messageService.findOrCreateConversation(getCurrentUserId(auth), req.getRecipientId());
|
||||
return ResponseEntity.ok(new CreateConversationResponse(conversation.getId()));
|
||||
}
|
||||
|
||||
@GetMapping("/unread-count")
|
||||
public ResponseEntity<Long> getUnreadCount(Authentication auth) {
|
||||
return ResponseEntity.ok(messageService.getUnreadMessageCount(getCurrentUserId(auth)));
|
||||
}
|
||||
|
||||
// A simple request DTO
|
||||
static class MessageRequest {
|
||||
private Long recipientId;
|
||||
private String content;
|
||||
private Long replyToId;
|
||||
|
||||
public Long getRecipientId() {
|
||||
return recipientId;
|
||||
}
|
||||
|
||||
public void setRecipientId(Long recipientId) {
|
||||
this.recipientId = recipientId;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import com.openisle.dto.PointHistoryDto;
|
||||
import com.openisle.mapper.PointHistoryMapper;
|
||||
import com.openisle.service.PointService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/point-histories")
|
||||
@RequiredArgsConstructor
|
||||
public class PointHistoryController {
|
||||
private final PointService pointService;
|
||||
private final PointHistoryMapper pointHistoryMapper;
|
||||
|
||||
@GetMapping
|
||||
public List<PointHistoryDto> list(Authentication auth) {
|
||||
return pointService.listHistory(auth.getName()).stream()
|
||||
.map(pointHistoryMapper::toDto)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
}
|
||||
@@ -41,11 +41,12 @@ 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()));
|
||||
dto.setPointReward(pointService.awardForPost(auth.getName()));
|
||||
dto.setPointReward(pointService.awardForPost(auth.getName(), post.getId()));
|
||||
return ResponseEntity.ok(dto);
|
||||
}
|
||||
|
||||
@@ -62,6 +63,16 @@ public class PostController {
|
||||
postService.deletePost(id, auth.getName());
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/close")
|
||||
public PostSummaryDto close(@PathVariable Long id, Authentication auth) {
|
||||
return postMapper.toSummaryDto(postService.closePost(id, auth.getName()));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/reopen")
|
||||
public PostSummaryDto reopen(@PathVariable Long id, Authentication auth) {
|
||||
return postMapper.toSummaryDto(postService.reopenPost(id, auth.getName()));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<PostDetailDto> getPost(@PathVariable Long id, Authentication auth) {
|
||||
String viewer = auth != null ? auth.getName() : null;
|
||||
@@ -161,4 +172,27 @@ public class PostController {
|
||||
return postService.listPostsByLatestReply(ids, tids, page, pageSize)
|
||||
.stream().map(postMapper::toSummaryDto).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@GetMapping("/featured")
|
||||
public List<PostSummaryDto> featuredPosts(@RequestParam(value = "categoryId", required = false) Long categoryId,
|
||||
@RequestParam(value = "categoryIds", required = false) List<Long> categoryIds,
|
||||
@RequestParam(value = "tagId", required = false) Long tagId,
|
||||
@RequestParam(value = "tagIds", required = false) List<Long> tagIds,
|
||||
@RequestParam(value = "page", required = false) Integer page,
|
||||
@RequestParam(value = "pageSize", required = false) Integer pageSize,
|
||||
Authentication auth) {
|
||||
List<Long> ids = categoryIds;
|
||||
if (categoryId != null) {
|
||||
ids = java.util.List.of(categoryId);
|
||||
}
|
||||
List<Long> tids = tagIds;
|
||||
if (tagId != null) {
|
||||
tids = java.util.List.of(tagId);
|
||||
}
|
||||
if (auth != null) {
|
||||
userVisitService.recordVisit(auth.getName());
|
||||
}
|
||||
return postService.listFeaturedPosts(ids, tids, page, pageSize)
|
||||
.stream().map(postMapper::toSummaryDto).collect(Collectors.toList());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import com.openisle.model.Post;
|
||||
import com.openisle.model.Comment;
|
||||
import com.openisle.model.CommentSort;
|
||||
import com.openisle.service.PostService;
|
||||
import com.openisle.service.CommentService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.jsoup.Jsoup;
|
||||
import org.jsoup.nodes.Document;
|
||||
@@ -31,6 +34,7 @@ import java.util.regex.Pattern;
|
||||
@RequiredArgsConstructor
|
||||
public class RssController {
|
||||
private final PostService postService;
|
||||
private final CommentService commentService;
|
||||
|
||||
@Value("${app.website-url:https://www.open-isle.com}")
|
||||
private String websiteUrl;
|
||||
@@ -103,6 +107,12 @@ public class RssController {
|
||||
enclosure = absolutifyUrl(enclosure, base);
|
||||
}
|
||||
|
||||
// 6) 构造优雅的附加区块(原文链接 + 精选评论),编入 <content:encoded>
|
||||
List<Comment> topComments = commentService
|
||||
.getCommentsForPost(p.getId(), CommentSort.MOST_INTERACTIONS);
|
||||
topComments = topComments.subList(0, Math.min(10, topComments.size()));
|
||||
String footerHtml = buildFooterHtml(base, link, topComments);
|
||||
|
||||
sb.append("<item>");
|
||||
elem(sb, "title", cdata(nullSafe(p.getTitle())));
|
||||
elem(sb, "link", link);
|
||||
@@ -110,8 +120,11 @@ public class RssController {
|
||||
elem(sb, "pubDate", toRfc1123Gmt(p.getCreatedAt().atZone(ZoneId.systemDefault())));
|
||||
// 摘要
|
||||
elem(sb, "description", cdata(plain));
|
||||
// 全文(HTML)
|
||||
sb.append("<content:encoded><![CDATA[").append(absHtml).append("]]></content:encoded>");
|
||||
// 全文(HTML):正文 + 优雅的 Markdown 区块(已转 HTML)
|
||||
sb.append("<content:encoded><![CDATA[")
|
||||
.append(absHtml)
|
||||
.append(footerHtml)
|
||||
.append("]]></content:encoded>");
|
||||
// 首图 enclosure(图片类型)
|
||||
if (enclosure != null) {
|
||||
sb.append("<enclosure url=\"").append(escapeXml(enclosure)).append("\" type=\"")
|
||||
@@ -136,8 +149,12 @@ public class RssController {
|
||||
private static String sanitizeHtml(String html) {
|
||||
if (html == null) return "";
|
||||
Safelist wl = Safelist.relaxed()
|
||||
.addTags("pre", "code", "figure", "figcaption", "picture", "source",
|
||||
"table","thead","tbody","tr","th","td","h1","h2","h3","h4","h5","h6")
|
||||
.addTags(
|
||||
"pre","code","figure","figcaption","picture","source",
|
||||
"table","thead","tbody","tr","th","td",
|
||||
"h1","h2","h3","h4","h5","h6",
|
||||
"hr","blockquote"
|
||||
)
|
||||
.addAttributes("a", "href", "title", "target", "rel")
|
||||
.addAttributes("img", "src", "alt", "title", "width", "height")
|
||||
.addAttributes("source", "srcset", "type", "media")
|
||||
@@ -246,6 +263,59 @@ public class RssController {
|
||||
return "image/jpeg";
|
||||
}
|
||||
|
||||
/* ===================== 附加区块(原文链接 + 精选评论) ===================== */
|
||||
|
||||
/**
|
||||
* 将“原文链接 + 精选评论(最多 10 条)”以优雅的 Markdown 形式渲染为 HTML,
|
||||
* 并做 sanitize + 绝对化,然后拼入 content:encoded 尾部。
|
||||
*/
|
||||
private static String buildFooterHtml(String baseUrl, String originalLink, List<Comment> topComments) {
|
||||
StringBuilder md = new StringBuilder(256);
|
||||
|
||||
// 分割线
|
||||
md.append("\n\n---\n\n");
|
||||
|
||||
// 原文链接(强调 + 可点击)
|
||||
md.append("**原文链接:** ")
|
||||
.append("[").append(originalLink).append("](").append(originalLink).append(")")
|
||||
.append("\n\n");
|
||||
|
||||
// 精选评论(仅当有评论时展示)
|
||||
if (topComments != null && !topComments.isEmpty()) {
|
||||
md.append("### 精选评论(Top ").append(Math.min(10, topComments.size())).append(")\n\n");
|
||||
for (Comment c : topComments) {
|
||||
String author = usernameOf(c);
|
||||
String content = nullSafe(c.getContent()).replace("\r", "");
|
||||
// 使用引用样式展示,提升可读性
|
||||
md.append("> @").append(author).append(": ").append(content).append("\n\n");
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染为 HTML,并保持和正文一致的处理流程
|
||||
String html = renderMarkdown(md.toString());
|
||||
String safe = sanitizeHtml(html);
|
||||
return absolutifyHtml(safe, baseUrl);
|
||||
}
|
||||
|
||||
private static String usernameOf(Comment c) {
|
||||
if (c == null) return "匿名";
|
||||
try {
|
||||
Object authorObj = c.getAuthor();
|
||||
if (authorObj == null) return "匿名";
|
||||
// 反射避免直接依赖实体字段名变化(也可直接强转到具体类型)
|
||||
String username;
|
||||
try {
|
||||
username = (String) authorObj.getClass().getMethod("getUsername").invoke(authorObj);
|
||||
} catch (Exception e) {
|
||||
username = null;
|
||||
}
|
||||
if (username == null || username.isEmpty()) return "匿名";
|
||||
return username;
|
||||
} catch (Exception ignored) {
|
||||
return "匿名";
|
||||
}
|
||||
}
|
||||
|
||||
/* ===================== 时间/字符串/XML ===================== */
|
||||
|
||||
private static String toRfc1123Gmt(ZonedDateTime zdt) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
17
backend/src/main/java/com/openisle/dto/ChannelDto.java
Normal file
17
backend/src/main/java/com/openisle/dto/ChannelDto.java
Normal 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;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.openisle.dto;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.data.domain.Page;
|
||||
|
||||
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;
|
||||
}
|
||||
20
backend/src/main/java/com/openisle/dto/ConversationDto.java
Normal file
20
backend/src/main/java/com/openisle/dto/ConversationDto.java
Normal file
@@ -0,0 +1,20 @@
|
||||
package com.openisle.dto;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Getter
|
||||
@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;
|
||||
private long unreadCount;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.openisle.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class CreateConversationRequest {
|
||||
private Long recipientId;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.openisle.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
public class CreateConversationResponse {
|
||||
private Long conversationId;
|
||||
}
|
||||
12
backend/src/main/java/com/openisle/dto/FeaturedMedalDto.java
Normal file
12
backend/src/main/java/com/openisle/dto/FeaturedMedalDto.java
Normal file
@@ -0,0 +1,12 @@
|
||||
package com.openisle.dto;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class FeaturedMedalDto extends MedalDto {
|
||||
private long currentFeaturedCount;
|
||||
private long targetFeaturedCount;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
16
backend/src/main/java/com/openisle/dto/MessageDto.java
Normal file
16
backend/src/main/java/com/openisle/dto/MessageDto.java
Normal file
@@ -0,0 +1,16 @@
|
||||
package com.openisle.dto;
|
||||
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
public class MessageDto {
|
||||
private Long id;
|
||||
private String content;
|
||||
private UserSummaryDto sender;
|
||||
private Long conversationId;
|
||||
private LocalDateTime createdAt;
|
||||
private MessageDto replyTo;
|
||||
private List<ReactionDto> reactions;
|
||||
}
|
||||
23
backend/src/main/java/com/openisle/dto/PointHistoryDto.java
Normal file
23
backend/src/main/java/com/openisle/dto/PointHistoryDto.java
Normal file
@@ -0,0 +1,23 @@
|
||||
package com.openisle.dto;
|
||||
|
||||
import com.openisle.model.PointHistoryType;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public class PointHistoryDto {
|
||||
private Long id;
|
||||
private PointHistoryType type;
|
||||
private int amount;
|
||||
private int balance;
|
||||
private Long postId;
|
||||
private String postTitle;
|
||||
private Long commentId;
|
||||
private String commentContent;
|
||||
private Long fromUserId;
|
||||
private String fromUserName;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -32,5 +32,6 @@ public class PostSummaryDto {
|
||||
private PostType type;
|
||||
private LotteryDto lottery;
|
||||
private boolean rssExcluded;
|
||||
private boolean closed;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
10
backend/src/main/java/com/openisle/dto/UserSummaryDto.java
Normal file
10
backend/src/main/java/com/openisle/dto/UserSummaryDto.java
Normal file
@@ -0,0 +1,10 @@
|
||||
package com.openisle.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class UserSummaryDto {
|
||||
private Long id;
|
||||
private String username;
|
||||
private String avatar;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.openisle.mapper;
|
||||
|
||||
import com.openisle.dto.PointHistoryDto;
|
||||
import com.openisle.model.PointHistory;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class PointHistoryMapper {
|
||||
public PointHistoryDto toDto(PointHistory history) {
|
||||
PointHistoryDto dto = new PointHistoryDto();
|
||||
dto.setId(history.getId());
|
||||
dto.setType(history.getType());
|
||||
dto.setAmount(history.getAmount());
|
||||
dto.setBalance(history.getBalance());
|
||||
dto.setCreatedAt(history.getCreatedAt());
|
||||
if (history.getPost() != null) {
|
||||
dto.setPostId(history.getPost().getId());
|
||||
dto.setPostTitle(history.getPost().getTitle());
|
||||
}
|
||||
if (history.getComment() != null) {
|
||||
dto.setCommentId(history.getComment().getId());
|
||||
dto.setCommentContent(history.getComment().getContent());
|
||||
if (history.getComment().getPost() != null && dto.getPostId() == null) {
|
||||
dto.setPostId(history.getComment().getPost().getId());
|
||||
dto.setPostTitle(history.getComment().getPost().getTitle());
|
||||
}
|
||||
}
|
||||
if (history.getFromUser() != null) {
|
||||
dto.setFromUserId(history.getFromUser().getId());
|
||||
dto.setFromUserName(history.getFromUser().getUsername());
|
||||
}
|
||||
return dto;
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,7 @@ public class PostMapper {
|
||||
dto.setStatus(post.getStatus());
|
||||
dto.setPinnedAt(post.getPinnedAt());
|
||||
dto.setRssExcluded(post.getRssExcluded() == null || post.getRssExcluded());
|
||||
dto.setClosed(post.isClosed());
|
||||
|
||||
List<ReactionDto> reactions = reactionService.getReactionsForPost(post.getId())
|
||||
.stream()
|
||||
@@ -85,6 +86,7 @@ public class PostMapper {
|
||||
l.setPrizeDescription(lp.getPrizeDescription());
|
||||
l.setPrizeIcon(lp.getPrizeIcon());
|
||||
l.setPrizeCount(lp.getPrizeCount());
|
||||
l.setPointCost(lp.getPointCost());
|
||||
l.setStartTime(lp.getStartTime());
|
||||
l.setEndTime(lp.getEndTime());
|
||||
l.setParticipants(lp.getParticipants().stream().map(userMapper::toAuthorDto).collect(Collectors.toList()));
|
||||
|
||||
@@ -19,6 +19,9 @@ public class ReactionMapper {
|
||||
if (reaction.getComment() != null) {
|
||||
dto.setCommentId(reaction.getComment().getId());
|
||||
}
|
||||
if (reaction.getMessage() != null) {
|
||||
dto.setMessageId(reaction.getMessage().getId());
|
||||
}
|
||||
dto.setReward(0);
|
||||
return dto;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,9 @@ public class LotteryPost extends Post {
|
||||
@Column(nullable = false)
|
||||
private int prizeCount;
|
||||
|
||||
@Column(nullable = false)
|
||||
private int pointCost;
|
||||
|
||||
@Column
|
||||
private LocalDateTime startTime;
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.openisle.model;
|
||||
public enum MedalType {
|
||||
COMMENT,
|
||||
POST,
|
||||
FEATURED,
|
||||
CONTRIBUTOR,
|
||||
SEED,
|
||||
PIONEER
|
||||
|
||||
39
backend/src/main/java/com/openisle/model/Message.java
Normal file
39
backend/src/main/java/com/openisle/model/Message.java
Normal file
@@ -0,0 +1,39 @@
|
||||
package com.openisle.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@Table(name = "messages")
|
||||
public class Message {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(optional = false, fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "conversation_id")
|
||||
private MessageConversation conversation;
|
||||
|
||||
@ManyToOne(optional = false, fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "sender_id")
|
||||
private User sender;
|
||||
|
||||
@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;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.openisle.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
@Entity
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@Table(name = "message_conversations")
|
||||
public class MessageConversation {
|
||||
@Id
|
||||
@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;
|
||||
|
||||
@OneToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "last_message_id")
|
||||
private Message lastMessage;
|
||||
|
||||
@OneToMany(mappedBy = "conversation", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||
private Set<MessageParticipant> participants = new HashSet<>();
|
||||
|
||||
@OneToMany(mappedBy = "conversation", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||
private Set<Message> messages = new HashSet<>();
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.openisle.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@Table(name = "message_participants")
|
||||
public class MessageParticipant {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(optional = false, fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "conversation_id")
|
||||
private MessageConversation conversation;
|
||||
|
||||
@ManyToOne(optional = false, fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "user_id")
|
||||
private User user;
|
||||
|
||||
@Column
|
||||
private LocalDateTime lastReadAt;
|
||||
}
|
||||
@@ -14,6 +14,8 @@ public enum NotificationType {
|
||||
POST_REVIEW_REQUEST,
|
||||
/** Your post under review was approved or rejected */
|
||||
POST_REVIEWED,
|
||||
/** An administrator deleted your post */
|
||||
POST_DELETED,
|
||||
/** A subscribed post received a new comment */
|
||||
POST_UPDATED,
|
||||
/** Someone subscribed to your post */
|
||||
@@ -38,6 +40,8 @@ public enum NotificationType {
|
||||
LOTTERY_WIN,
|
||||
/** Your lottery post was drawn */
|
||||
LOTTERY_DRAW,
|
||||
/** Your post was featured */
|
||||
POST_FEATURED,
|
||||
/** You were mentioned in a post or comment */
|
||||
MENTION
|
||||
}
|
||||
|
||||
49
backend/src/main/java/com/openisle/model/PointHistory.java
Normal file
49
backend/src/main/java/com/openisle/model/PointHistory.java
Normal file
@@ -0,0 +1,49 @@
|
||||
package com.openisle.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** Point change history for a user. */
|
||||
@Entity
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@Table(name = "point_histories")
|
||||
public class PointHistory {
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "user_id")
|
||||
private User user;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false)
|
||||
private PointHistoryType type;
|
||||
|
||||
@Column(nullable = false)
|
||||
private int amount;
|
||||
|
||||
@Column(nullable = false)
|
||||
private int balance;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "post_id")
|
||||
private Post post;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "comment_id")
|
||||
private Comment comment;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "from_user_id")
|
||||
private User fromUser;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.openisle.model;
|
||||
|
||||
public enum PointHistoryType {
|
||||
POST,
|
||||
COMMENT,
|
||||
POST_LIKED,
|
||||
COMMENT_LIKED,
|
||||
INVITE,
|
||||
FEATURE,
|
||||
SYSTEM_ONLINE,
|
||||
REDEEM,
|
||||
LOTTERY_JOIN,
|
||||
LOTTERY_REWARD
|
||||
}
|
||||
@@ -64,6 +64,9 @@ public class Post {
|
||||
@Column(nullable = false)
|
||||
private PostType type = PostType.NORMAL;
|
||||
|
||||
@Column(nullable = false)
|
||||
private boolean closed = false;
|
||||
|
||||
@Column
|
||||
private LocalDateTime pinnedAt;
|
||||
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.openisle.repository;
|
||||
|
||||
import com.openisle.model.MessageConversation;
|
||||
import com.openisle.model.User;
|
||||
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.List;
|
||||
|
||||
@Repository
|
||||
public interface MessageConversationRepository extends JpaRepository<MessageConversation, Long> {
|
||||
@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 " +
|
||||
"LEFT JOIN FETCH c.lastMessage lm " +
|
||||
"LEFT JOIN FETCH lm.sender " +
|
||||
"LEFT JOIN FETCH c.participants cp " +
|
||||
"LEFT JOIN FETCH cp.user " +
|
||||
"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();
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.openisle.repository;
|
||||
|
||||
import com.openisle.model.MessageParticipant;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public interface MessageParticipantRepository extends JpaRepository<MessageParticipant, Long> {
|
||||
Optional<MessageParticipant> findByConversationIdAndUserId(Long conversationId, Long userId);
|
||||
List<MessageParticipant> findByUserId(Long userId);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.openisle.repository;
|
||||
|
||||
import com.openisle.model.Message;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public interface MessageRepository extends JpaRepository<Message, Long> {
|
||||
List<Message> findByConversationIdOrderByCreatedAtAsc(Long conversationId);
|
||||
|
||||
Page<Message> findByConversationId(Long conversationId, Pageable pageable);
|
||||
|
||||
long countByConversationIdAndCreatedAtAfter(Long conversationId, java.time.LocalDateTime createdAt);
|
||||
|
||||
// 只计算不是指定用户发送的消息(即别人发给当前用户的消息)
|
||||
long countByConversationIdAndCreatedAtAfterAndSenderIdNot(Long conversationId, java.time.LocalDateTime createdAt, Long senderId);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.openisle.repository;
|
||||
|
||||
import com.openisle.model.PointHistory;
|
||||
import com.openisle.model.User;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface PointHistoryRepository extends JpaRepository<PointHistory, Long> {
|
||||
List<PointHistory> findByUserOrderByIdDesc(User user);
|
||||
long countByUser(User user);
|
||||
}
|
||||
@@ -97,6 +97,8 @@ public interface PostRepository extends JpaRepository<Post, Long> {
|
||||
|
||||
long countDistinctByTags_Id(Long tagId);
|
||||
|
||||
long countByAuthor_IdAndRssExcludedFalse(Long userId);
|
||||
|
||||
@Query("SELECT t.id, COUNT(DISTINCT p) FROM Post p JOIN p.tags t WHERE t.id IN :tagIds GROUP BY t.id")
|
||||
List<Object[]> countPostsByTagIds(@Param("tagIds") List<Long> tagIds);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,9 @@ public class CommentService {
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||
Post post = postRepository.findById(postId)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
|
||||
if (post.isClosed()) {
|
||||
throw new IllegalStateException("Post closed");
|
||||
}
|
||||
Comment comment = new Comment();
|
||||
comment.setAuthor(author);
|
||||
comment.setPost(post);
|
||||
@@ -94,6 +97,9 @@ public class CommentService {
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||
Comment parent = commentRepository.findById(parentId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Comment not found"));
|
||||
if (parent.getPost().isClosed()) {
|
||||
throw new IllegalStateException("Post closed");
|
||||
}
|
||||
Comment comment = new Comment();
|
||||
comment.setAuthor(author);
|
||||
comment.setPost(parent.getPost());
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.openisle.model.User;
|
||||
import com.openisle.repository.InviteTokenRepository;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
@@ -18,6 +19,12 @@ public class InviteService {
|
||||
private final JwtService jwtService;
|
||||
private final PointService pointService;
|
||||
|
||||
@Value
|
||||
public class InviteValidateResult {
|
||||
InviteToken inviteToken;
|
||||
boolean validate;
|
||||
}
|
||||
|
||||
public String generate(String username) {
|
||||
User inviter = userRepository.findByUsername(username).orElseThrow();
|
||||
LocalDate today = LocalDate.now();
|
||||
@@ -35,20 +42,23 @@ public class InviteService {
|
||||
return token;
|
||||
}
|
||||
|
||||
public boolean validate(String token) {
|
||||
public InviteValidateResult validate(String token) {
|
||||
if (token == null || token.isEmpty()) {
|
||||
return new InviteValidateResult(null, false);
|
||||
}
|
||||
try {
|
||||
jwtService.validateAndGetSubjectForInvite(token);
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
return new InviteValidateResult(null, false);
|
||||
}
|
||||
InviteToken invite = inviteTokenRepository.findById(token).orElse(null);
|
||||
return invite != null && invite.getUsageCount() < 3;
|
||||
return new InviteValidateResult(invite, invite != null && invite.getUsageCount() < 3);
|
||||
}
|
||||
|
||||
public void consume(String token) {
|
||||
public void consume(String token, String newUserName) {
|
||||
InviteToken invite = inviteTokenRepository.findById(token).orElseThrow();
|
||||
invite.setUsageCount(invite.getUsageCount() + 1);
|
||||
inviteTokenRepository.save(invite);
|
||||
pointService.awardForInvite(invite.getInviter().getUsername());
|
||||
pointService.awardForInvite(invite.getInviter().getUsername(), newUserName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.openisle.dto.MedalDto;
|
||||
import com.openisle.dto.PostMedalDto;
|
||||
import com.openisle.dto.SeedUserMedalDto;
|
||||
import com.openisle.dto.PioneerMedalDto;
|
||||
import com.openisle.dto.FeaturedMedalDto;
|
||||
import com.openisle.model.MedalType;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.repository.CommentRepository;
|
||||
@@ -74,6 +75,23 @@ public class MedalService {
|
||||
postMedal.setSelected(selected == MedalType.POST);
|
||||
medals.add(postMedal);
|
||||
|
||||
FeaturedMedalDto featuredMedal = new FeaturedMedalDto();
|
||||
featuredMedal.setIcon("https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/achi_rss.png");
|
||||
featuredMedal.setTitle("精选作者");
|
||||
featuredMedal.setDescription("至少有1篇文章被收录为精选");
|
||||
featuredMedal.setType(MedalType.FEATURED);
|
||||
featuredMedal.setTargetFeaturedCount(1);
|
||||
if (user != null) {
|
||||
long count = postRepository.countByAuthor_IdAndRssExcludedFalse(user.getId());
|
||||
featuredMedal.setCurrentFeaturedCount(count);
|
||||
featuredMedal.setCompleted(count >= 1);
|
||||
} else {
|
||||
featuredMedal.setCurrentFeaturedCount(0);
|
||||
featuredMedal.setCompleted(false);
|
||||
}
|
||||
featuredMedal.setSelected(selected == MedalType.FEATURED);
|
||||
medals.add(featuredMedal);
|
||||
|
||||
ContributorMedalDto contributorMedal = new ContributorMedalDto();
|
||||
contributorMedal.setIcon("https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/achi_coder.png");
|
||||
contributorMedal.setTitle("贡献者");
|
||||
@@ -141,6 +159,8 @@ public class MedalService {
|
||||
user.setDisplayMedal(MedalType.COMMENT);
|
||||
} else if (postRepository.countByAuthor_Id(user.getId()) >= POST_TARGET) {
|
||||
user.setDisplayMedal(MedalType.POST);
|
||||
} else if (postRepository.countByAuthor_IdAndRssExcludedFalse(user.getId()) >= 1) {
|
||||
user.setDisplayMedal(MedalType.FEATURED);
|
||||
} else if (contributorService.getContributionLines(user.getUsername()) >= CONTRIBUTION_TARGET) {
|
||||
user.setDisplayMedal(MedalType.CONTRIBUTOR);
|
||||
} else if (userRepository.countByCreatedAtBefore(user.getCreatedAt()) < PIONEER_LIMIT) {
|
||||
|
||||
324
backend/src/main/java/com/openisle/service/MessageService.java
Normal file
324
backend/src/main/java/com/openisle/service/MessageService.java
Normal file
@@ -0,0 +1,324 @@
|
||||
package com.openisle.service;
|
||||
|
||||
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;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||
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
|
||||
@Slf4j
|
||||
public class MessageService {
|
||||
|
||||
private final MessageRepository messageRepository;
|
||||
private final MessageConversationRepository conversationRepository;
|
||||
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, 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"));
|
||||
User recipient = userRepository.findById(recipientId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Recipient not found"));
|
||||
|
||||
log.info("Finding or creating conversation for users {} and {}", sender.getUsername(), recipient.getUsername());
|
||||
MessageConversation conversation = findOrCreateConversation(sender, recipient);
|
||||
log.info("Conversation found or created with ID: {}", conversation.getId());
|
||||
|
||||
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);
|
||||
log.info("Message saved with ID: {}", message.getId());
|
||||
|
||||
conversation.setLastMessage(message);
|
||||
conversationRepository.save(conversation);
|
||||
log.info("Conversation {} updated with last message ID {}", conversation.getId(), message.getId());
|
||||
|
||||
// Broadcast the new message to subscribed clients
|
||||
MessageDto messageDto = toDto(message);
|
||||
String conversationDestination = "/topic/conversation/" + conversation.getId();
|
||||
messagingTemplate.convertAndSend(conversationDestination, messageDto);
|
||||
log.info("Message {} broadcasted to destination: {}", message.getId(), conversationDestination);
|
||||
|
||||
// Also notify the recipient on their personal channel to update the conversation list
|
||||
String userDestination = "/topic/user/" + recipient.getId() + "/messages";
|
||||
messagingTemplate.convertAndSend(userDestination, messageDto);
|
||||
log.info("Message {} notification sent to destination: {}", message.getId(), userDestination);
|
||||
|
||||
// Notify recipient of new unread count
|
||||
long unreadCount = getUnreadMessageCount(recipientId);
|
||||
log.info("Calculating unread count for user {}: {}", recipientId, unreadCount);
|
||||
|
||||
// Send using username instead of user ID for WebSocket routing
|
||||
String recipientUsername = recipient.getUsername();
|
||||
messagingTemplate.convertAndSendToUser(recipientUsername, "/queue/unread-count", unreadCount);
|
||||
log.info("Sent unread count {} to user {} (username: {}) via WebSocket destination: /user/{}/queue/unread-count",
|
||||
unreadCount, recipientId, recipientUsername, recipientUsername);
|
||||
|
||||
return 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());
|
||||
dto.setConversationId(message.getConversation().getId());
|
||||
dto.setCreatedAt(message.getCreatedAt());
|
||||
|
||||
UserSummaryDto userSummaryDto = new UserSummaryDto();
|
||||
userSummaryDto.setId(message.getSender().getId());
|
||||
userSummaryDto.setUsername(message.getSender().getUsername());
|
||||
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;
|
||||
}
|
||||
|
||||
public MessageConversation findOrCreateConversation(Long user1Id, Long user2Id) {
|
||||
User user1 = userRepository.findById(user1Id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("User1 not found"));
|
||||
User user2 = userRepository.findById(user2Id)
|
||||
.orElseThrow(() -> new IllegalArgumentException("User2 not found"));
|
||||
return findOrCreateConversation(user1, user2);
|
||||
}
|
||||
|
||||
private MessageConversation findOrCreateConversation(User user1, User user2) {
|
||||
log.info("Searching for existing conversation between {} and {}", user1.getUsername(), user2.getUsername());
|
||||
return conversationRepository.findConversationsByUsers(user1, user2).stream()
|
||||
.findFirst()
|
||||
.orElseGet(() -> {
|
||||
log.info("No existing conversation found. Creating a new one.");
|
||||
MessageConversation conversation = new MessageConversation();
|
||||
conversation = conversationRepository.save(conversation);
|
||||
log.info("New conversation created with ID: {}", conversation.getId());
|
||||
|
||||
MessageParticipant participant1 = new MessageParticipant();
|
||||
participant1.setConversation(conversation);
|
||||
participant1.setUser(user1);
|
||||
participantRepository.save(participant1);
|
||||
log.info("Participant {} added to conversation {}", user1.getUsername(), conversation.getId());
|
||||
|
||||
MessageParticipant participant2 = new MessageParticipant();
|
||||
participant2.setConversation(conversation);
|
||||
participant2.setUser(user2);
|
||||
participantRepository.save(participant2);
|
||||
log.info("Participant {} added to conversation {}", user2.getUsername(), conversation.getId());
|
||||
|
||||
return conversation;
|
||||
});
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<ConversationDto> getConversations(Long userId) {
|
||||
List<MessageConversation> conversations = conversationRepository.findConversationsByUserIdOrderByLastMessageDesc(userId);
|
||||
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()));
|
||||
}
|
||||
dto.setParticipants(conversation.getParticipants().stream()
|
||||
.map(p -> {
|
||||
UserSummaryDto userDto = new UserSummaryDto();
|
||||
userDto.setId(p.getUser().getId());
|
||||
userDto.setUsername(p.getUser().getUsername());
|
||||
userDto.setAvatar(p.getUser().getAvatar());
|
||||
return userDto;
|
||||
})
|
||||
.collect(Collectors.toList()));
|
||||
|
||||
MessageParticipant self = conversation.getParticipants().stream()
|
||||
.filter(p -> p.getUser().getId().equals(userId))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new IllegalStateException("Participant not found in conversation"));
|
||||
|
||||
LocalDateTime lastRead = self.getLastReadAt() == null ? LocalDateTime.of(1970, 1, 1, 0, 0) : self.getLastReadAt();
|
||||
// 只计算别人发送给当前用户的未读消息
|
||||
long unreadCount = messageRepository.countByConversationIdAndCreatedAtAfterAndSenderIdNot(conversation.getId(), lastRead, userId);
|
||||
dto.setUnreadCount(unreadCount);
|
||||
|
||||
return dto;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ConversationDetailDto getConversationDetails(Long conversationId, Long userId, Pageable pageable) {
|
||||
markConversationAsRead(conversationId, userId);
|
||||
|
||||
MessageConversation conversation = conversationRepository.findById(conversationId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Conversation not found"));
|
||||
|
||||
Page<Message> messagesPage = messageRepository.findByConversationId(conversationId, pageable);
|
||||
Page<MessageDto> messageDtoPage = messagesPage.map(this::toDto);
|
||||
|
||||
List<UserSummaryDto> participants = conversation.getParticipants().stream()
|
||||
.map(p -> {
|
||||
UserSummaryDto userDto = new UserSummaryDto();
|
||||
userDto.setId(p.getUser().getId());
|
||||
userDto.setUsername(p.getUser().getUsername());
|
||||
userDto.setAvatar(p.getUser().getAvatar());
|
||||
return userDto;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
|
||||
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);
|
||||
|
||||
return detailDto;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void markConversationAsRead(Long conversationId, Long userId) {
|
||||
MessageParticipant participant = participantRepository.findByConversationIdAndUserId(conversationId, userId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Participant not found"));
|
||||
participant.setLastReadAt(LocalDateTime.now());
|
||||
participantRepository.save(participant);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public long getUnreadMessageCount(Long userId) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,11 @@ package com.openisle.service;
|
||||
import com.openisle.exception.FieldException;
|
||||
import com.openisle.exception.NotFoundException;
|
||||
import com.openisle.model.PointGood;
|
||||
import com.openisle.model.PointHistory;
|
||||
import com.openisle.model.PointHistoryType;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.repository.PointGoodRepository;
|
||||
import com.openisle.repository.PointHistoryRepository;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -18,6 +21,7 @@ public class PointMallService {
|
||||
private final PointGoodRepository pointGoodRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final NotificationService notificationService;
|
||||
private final PointHistoryRepository pointHistoryRepository;
|
||||
|
||||
public List<PointGood> listGoods() {
|
||||
return pointGoodRepository.findAll();
|
||||
@@ -32,6 +36,13 @@ public class PointMallService {
|
||||
user.setPoint(user.getPoint() - good.getCost());
|
||||
userRepository.save(user);
|
||||
notificationService.createPointRedeemNotifications(user, good.getName() + ": " + contact);
|
||||
PointHistory history = new PointHistory();
|
||||
history.setUser(user);
|
||||
history.setType(PointHistoryType.REDEEM);
|
||||
history.setAmount(-good.getCost());
|
||||
history.setBalance(user.getPoint());
|
||||
history.setCreatedAt(java.time.LocalDateTime.now());
|
||||
pointHistoryRepository.save(history);
|
||||
return user.getPoint();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package com.openisle.service;
|
||||
|
||||
import com.openisle.model.PointLog;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.model.*;
|
||||
import com.openisle.repository.*;
|
||||
import com.openisle.exception.FieldException;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@@ -16,19 +16,39 @@ public class PointService {
|
||||
private final PointLogRepository pointLogRepository;
|
||||
private final PostRepository postRepository;
|
||||
private final CommentRepository commentRepository;
|
||||
private final PointHistoryRepository pointHistoryRepository;
|
||||
|
||||
public int awardForPost(String userName) {
|
||||
public int awardForPost(String userName, Long postId) {
|
||||
User user = userRepository.findByUsername(userName).orElseThrow();
|
||||
PointLog log = getTodayLog(user);
|
||||
if (log.getPostCount() > 1) return 0;
|
||||
log.setPostCount(log.getPostCount() + 1);
|
||||
pointLogRepository.save(log);
|
||||
return addPoint(user, 30);
|
||||
Post post = postRepository.findById(postId).orElseThrow();
|
||||
return addPoint(user, 30, PointHistoryType.POST, post, null, null);
|
||||
}
|
||||
|
||||
public int awardForInvite(String userName) {
|
||||
public int awardForInvite(String userName, String inviteeName) {
|
||||
User user = userRepository.findByUsername(userName).orElseThrow();
|
||||
return addPoint(user, 500);
|
||||
User invitee = userRepository.findByUsername(inviteeName).orElseThrow();
|
||||
return addPoint(user, 500, PointHistoryType.INVITE, null, null, invitee);
|
||||
}
|
||||
|
||||
public int awardForFeatured(String userName, Long postId) {
|
||||
User user = userRepository.findByUsername(userName).orElseThrow();
|
||||
Post post = postRepository.findById(postId).orElseThrow();
|
||||
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) {
|
||||
@@ -45,20 +65,41 @@ public class PointService {
|
||||
});
|
||||
}
|
||||
|
||||
private int addPoint(User user, int amount) {
|
||||
private int addPoint(User user, int amount, PointHistoryType type,
|
||||
Post post, Comment comment, User fromUser) {
|
||||
if (pointHistoryRepository.countByUser(user) == 0) {
|
||||
recordHistory(user, PointHistoryType.SYSTEM_ONLINE, 0, null, null, null);
|
||||
}
|
||||
user.setPoint(user.getPoint() + amount);
|
||||
userRepository.save(user);
|
||||
recordHistory(user, type, amount, post, comment, fromUser);
|
||||
return amount;
|
||||
}
|
||||
|
||||
private void recordHistory(User user, PointHistoryType type, int amount,
|
||||
Post post, Comment comment, User fromUser) {
|
||||
PointHistory history = new PointHistory();
|
||||
history.setUser(user);
|
||||
history.setType(type);
|
||||
history.setAmount(amount);
|
||||
history.setBalance(user.getPoint());
|
||||
history.setPost(post);
|
||||
history.setComment(comment);
|
||||
history.setFromUser(fromUser);
|
||||
history.setCreatedAt(java.time.LocalDateTime.now());
|
||||
pointHistoryRepository.save(history);
|
||||
}
|
||||
|
||||
// 同时为评论者和发帖人增加积分,返回值为评论者增加的积分数
|
||||
// 注意需要考虑发帖和回复是同一人的场景
|
||||
public int awardForComment(String commenterName, Long postId) {
|
||||
public int awardForComment(String commenterName, Long postId, Long commentId) {
|
||||
// 标记评论者是否已达到积分奖励上限
|
||||
boolean isTheRewardCapped = false;
|
||||
|
||||
// 根据帖子id找到发帖人
|
||||
User poster = postRepository.findById(postId).orElseThrow().getAuthor();
|
||||
Post post = postRepository.findById(postId).orElseThrow();
|
||||
User poster = post.getAuthor();
|
||||
Comment comment = commentRepository.findById(commentId).orElseThrow();
|
||||
|
||||
// 获取评论者的加分日志
|
||||
User commenter = userRepository.findByUsername(commenterName).orElseThrow();
|
||||
@@ -74,15 +115,15 @@ public class PointService {
|
||||
} else {
|
||||
log.setCommentCount(log.getCommentCount() + 1);
|
||||
pointLogRepository.save(log);
|
||||
return addPoint(commenter, 10);
|
||||
return addPoint(commenter, 10, PointHistoryType.COMMENT, post, comment, null);
|
||||
}
|
||||
} else {
|
||||
addPoint(poster, 10);
|
||||
addPoint(poster, 10, PointHistoryType.COMMENT, post, comment, commenter);
|
||||
// 如果发帖人与评论者不是同一个,则根据是否达到积分上限来判断评论者加分情况
|
||||
if (isTheRewardCapped) {
|
||||
return 0;
|
||||
} else {
|
||||
return addPoint(commenter, 10);
|
||||
return addPoint(commenter, 10, PointHistoryType.COMMENT, post, comment, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,7 +142,8 @@ public class PointService {
|
||||
}
|
||||
|
||||
// 如果不是同一个,则为发帖人加分
|
||||
return addPoint(poster, 10);
|
||||
Post post = postRepository.findById(postId).orElseThrow();
|
||||
return addPoint(poster, 10, PointHistoryType.POST_LIKED, post, null, reactioner);
|
||||
}
|
||||
|
||||
// 考虑点赞者和评论者是同一个的情况
|
||||
@@ -118,7 +160,17 @@ public class PointService {
|
||||
}
|
||||
|
||||
// 如果不是同一个,则为发帖人加分
|
||||
return addPoint(commenter, 10);
|
||||
Comment comment = commentRepository.findById(commentId).orElseThrow();
|
||||
Post post = comment.getPost();
|
||||
return addPoint(commenter, 10, PointHistoryType.COMMENT_LIKED, post, comment, reactioner);
|
||||
}
|
||||
|
||||
public java.util.List<PointHistory> listHistory(String userName) {
|
||||
User user = userRepository.findByUsername(userName).orElseThrow();
|
||||
if (pointHistoryRepository.countByUser(user) == 0) {
|
||||
recordHistory(user, PointHistoryType.SYSTEM_ONLINE, 0, null, null, null);
|
||||
}
|
||||
return pointHistoryRepository.findByUserOrderByIdDesc(user);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ public class PostService {
|
||||
private final TaskScheduler taskScheduler;
|
||||
private final EmailSender emailSender;
|
||||
private final ApplicationContext applicationContext;
|
||||
private final PointService pointService;
|
||||
private final ConcurrentMap<Long, ScheduledFuture<?>> scheduledFinalizations = new ConcurrentHashMap<>();
|
||||
@Value("${app.website-url:https://www.open-isle.com}")
|
||||
private String websiteUrl;
|
||||
@@ -89,6 +90,7 @@ public class PostService {
|
||||
TaskScheduler taskScheduler,
|
||||
EmailSender emailSender,
|
||||
ApplicationContext applicationContext,
|
||||
PointService pointService,
|
||||
@Value("${app.post.publish-mode:DIRECT}") PublishMode publishMode) {
|
||||
this.postRepository = postRepository;
|
||||
this.userRepository = userRepository;
|
||||
@@ -107,6 +109,7 @@ public class PostService {
|
||||
this.taskScheduler = taskScheduler;
|
||||
this.emailSender = emailSender;
|
||||
this.applicationContext = applicationContext;
|
||||
this.pointService = pointService;
|
||||
this.publishMode = publishMode;
|
||||
}
|
||||
|
||||
@@ -146,7 +149,10 @@ public class PostService {
|
||||
public Post includeInRss(Long id) {
|
||||
Post post = postRepository.findById(id).orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
|
||||
post.setRssExcluded(false);
|
||||
return postRepository.save(post);
|
||||
post = postRepository.save(post);
|
||||
notificationService.createNotification(post.getAuthor(), NotificationType.POST_FEATURED, post, null, null, null, null, null);
|
||||
pointService.awardForFeatured(post.getAuthor().getUsername(), post.getId());
|
||||
return post;
|
||||
}
|
||||
|
||||
public Post createPost(String username,
|
||||
@@ -158,6 +164,7 @@ public class PostService {
|
||||
String prizeDescription,
|
||||
String prizeIcon,
|
||||
Integer prizeCount,
|
||||
Integer pointCost,
|
||||
LocalDateTime startTime,
|
||||
LocalDateTime endTime) {
|
||||
long recent = postRepository.countByAuthorAfter(username,
|
||||
@@ -182,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;
|
||||
@@ -244,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
|
||||
@@ -458,6 +471,34 @@ public class PostService {
|
||||
return paginate(sortByPinnedAndCreated(posts), page, pageSize);
|
||||
}
|
||||
|
||||
public List<Post> listFeaturedPosts(List<Long> categoryIds,
|
||||
List<Long> tagIds,
|
||||
Integer page,
|
||||
Integer pageSize) {
|
||||
List<Post> posts;
|
||||
boolean hasCategories = categoryIds != null && !categoryIds.isEmpty();
|
||||
boolean hasTags = tagIds != null && !tagIds.isEmpty();
|
||||
|
||||
if (hasCategories && hasTags) {
|
||||
posts = listPostsByCategoriesAndTags(categoryIds, tagIds, null, null);
|
||||
} else if (hasCategories) {
|
||||
posts = listPostsByCategories(categoryIds, null, null);
|
||||
} else if (hasTags) {
|
||||
posts = listPostsByTags(tagIds, null, null);
|
||||
} else {
|
||||
posts = listPosts();
|
||||
}
|
||||
|
||||
// 仅保留 getRssExcluded 为 0 且不为空
|
||||
// 若字段类型是 Boolean(包装类型),0 等价于 false:
|
||||
posts = posts.stream()
|
||||
.filter(p -> p.getRssExcluded() != null && !p.getRssExcluded())
|
||||
.toList();
|
||||
|
||||
return paginate(sortByPinnedAndCreated(posts), page, pageSize);
|
||||
}
|
||||
|
||||
|
||||
public List<Post> listPendingPosts() {
|
||||
return postRepository.findByStatus(PostStatus.PENDING);
|
||||
}
|
||||
@@ -512,6 +553,30 @@ public class PostService {
|
||||
return postRepository.save(post);
|
||||
}
|
||||
|
||||
public Post closePost(Long id, String username) {
|
||||
Post post = postRepository.findById(id)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||
if (!user.getId().equals(post.getAuthor().getId()) && user.getRole() != Role.ADMIN) {
|
||||
throw new IllegalArgumentException("Unauthorized");
|
||||
}
|
||||
post.setClosed(true);
|
||||
return postRepository.save(post);
|
||||
}
|
||||
|
||||
public Post reopenPost(Long id, String username) {
|
||||
Post post = postRepository.findById(id)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||
if (!user.getId().equals(post.getAuthor().getId()) && user.getRole() != Role.ADMIN) {
|
||||
throw new IllegalArgumentException("Unauthorized");
|
||||
}
|
||||
post.setClosed(false);
|
||||
return postRepository.save(post);
|
||||
}
|
||||
|
||||
@org.springframework.transaction.annotation.Transactional
|
||||
public Post updatePost(Long id,
|
||||
String username,
|
||||
@@ -555,7 +620,9 @@ 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"));
|
||||
if (!user.getId().equals(post.getAuthor().getId()) && user.getRole() != Role.ADMIN) {
|
||||
User author = post.getAuthor();
|
||||
boolean adminDeleting = !user.getId().equals(author.getId()) && user.getRole() == Role.ADMIN;
|
||||
if (!user.getId().equals(author.getId()) && user.getRole() != Role.ADMIN) {
|
||||
throw new IllegalArgumentException("Unauthorized");
|
||||
}
|
||||
for (Comment c : commentRepository.findByPostAndParentIsNullOrderByCreatedAtAsc(post)) {
|
||||
@@ -572,7 +639,12 @@ public class PostService {
|
||||
future.cancel(false);
|
||||
}
|
||||
}
|
||||
String title = post.getTitle();
|
||||
postRepository.delete(post);
|
||||
if (adminDeleting) {
|
||||
notificationService.createNotification(author, NotificationType.POST_DELETED,
|
||||
null, null, null, user, null, title);
|
||||
}
|
||||
}
|
||||
|
||||
public java.util.List<Post> getPostsByIds(java.util.List<Long> ids) {
|
||||
|
||||
@@ -6,10 +6,12 @@ import com.openisle.model.Reaction;
|
||||
import com.openisle.model.ReactionType;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.model.NotificationType;
|
||||
import com.openisle.model.Message;
|
||||
import com.openisle.repository.CommentRepository;
|
||||
import com.openisle.repository.PostRepository;
|
||||
import com.openisle.repository.ReactionRepository;
|
||||
import com.openisle.repository.UserRepository;
|
||||
import com.openisle.repository.MessageRepository;
|
||||
import com.openisle.service.NotificationService;
|
||||
import com.openisle.service.EmailSender;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -24,6 +26,7 @@ public class ReactionService {
|
||||
private final UserRepository userRepository;
|
||||
private final PostRepository postRepository;
|
||||
private final CommentRepository commentRepository;
|
||||
private final MessageRepository messageRepository;
|
||||
private final NotificationService notificationService;
|
||||
private final EmailSender emailSender;
|
||||
|
||||
@@ -77,6 +80,26 @@ public class ReactionService {
|
||||
return reaction;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Reaction reactToMessage(String username, Long messageId, ReactionType type) {
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||
Message message = messageRepository.findById(messageId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Message not found"));
|
||||
java.util.Optional<Reaction> existing =
|
||||
reactionRepository.findByUserAndMessageAndType(user, message, type);
|
||||
if (existing.isPresent()) {
|
||||
reactionRepository.delete(existing.get());
|
||||
return null;
|
||||
}
|
||||
Reaction reaction = new Reaction();
|
||||
reaction.setUser(user);
|
||||
reaction.setMessage(message);
|
||||
reaction.setType(type);
|
||||
reaction = reactionRepository.save(reaction);
|
||||
return reaction;
|
||||
}
|
||||
|
||||
public java.util.List<Reaction> getReactionsForPost(Long postId) {
|
||||
Post post = postRepository.findById(postId)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
|
||||
|
||||
@@ -107,6 +107,11 @@ public class SubscriptionService {
|
||||
return commentSubRepo.findByComment(c).stream().map(CommentSubscription::getUser).toList();
|
||||
}
|
||||
|
||||
public List<Post> getSubscribedPosts(String username) {
|
||||
User user = userRepo.findByUsername(username).orElseThrow();
|
||||
return postSubRepo.findByUser(user).stream().map(PostSubscription::getPost).toList();
|
||||
}
|
||||
|
||||
|
||||
public long countSubscribers(String username) {
|
||||
User user = userRepo.findByUsername(username).orElseThrow();
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE lottery_posts ADD COLUMN point_cost INT NOT NULL DEFAULT 0;
|
||||
@@ -76,7 +76,7 @@ class PostControllerTest {
|
||||
post.setTags(Set.of(tag));
|
||||
|
||||
when(postService.createPost(eq("alice"), eq(1L), eq("t"), eq("c"), eq(List.of(1L)),
|
||||
isNull(), isNull(), isNull(), isNull(), isNull(), isNull())).thenReturn(post);
|
||||
isNull(), isNull(), isNull(), isNull(), isNull(), isNull(), isNull())).thenReturn(post);
|
||||
when(postService.viewPost(eq(1L), any())).thenReturn(post);
|
||||
when(commentService.getCommentsForPost(eq(1L), any())).thenReturn(List.of());
|
||||
when(commentService.getParticipants(anyLong(), anyInt())).thenReturn(List.of());
|
||||
@@ -187,7 +187,7 @@ class PostControllerTest {
|
||||
.andExpect(status().isBadRequest());
|
||||
|
||||
verify(postService, never()).createPost(any(), any(), any(), any(), any(),
|
||||
any(), any(), any(), any(), any(), any());
|
||||
any(), any(), any(), any(), any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.openisle.model.Post;
|
||||
import com.openisle.model.Reaction;
|
||||
import com.openisle.model.ReactionType;
|
||||
import com.openisle.model.User;
|
||||
import com.openisle.model.Message;
|
||||
import com.openisle.service.ReactionService;
|
||||
import com.openisle.service.LevelService;
|
||||
import com.openisle.mapper.ReactionMapper;
|
||||
@@ -78,6 +79,27 @@ class ReactionControllerTest {
|
||||
.andExpect(jsonPath("$.commentId").value(2));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reactToMessage() throws Exception {
|
||||
User user = new User();
|
||||
user.setUsername("u3");
|
||||
Message message = new Message();
|
||||
message.setId(3L);
|
||||
Reaction reaction = new Reaction();
|
||||
reaction.setId(3L);
|
||||
reaction.setUser(user);
|
||||
reaction.setMessage(message);
|
||||
reaction.setType(ReactionType.LIKE);
|
||||
Mockito.when(reactionService.reactToMessage(eq("u3"), eq(3L), eq(ReactionType.LIKE))).thenReturn(reaction);
|
||||
|
||||
mockMvc.perform(post("/api/messages/3/reactions")
|
||||
.contentType("application/json")
|
||||
.content("{\"type\":\"LIKE\"}")
|
||||
.principal(new UsernamePasswordAuthenticationToken("u3", "p")))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.messageId").value(3));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listReactionTypes() throws Exception {
|
||||
mockMvc.perform(get("/api/reaction-types"))
|
||||
|
||||
@@ -136,6 +136,30 @@ class UserControllerTest {
|
||||
.andExpect(jsonPath("$[0].title").value("hello"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listSubscribedPosts() throws Exception {
|
||||
User user = new User();
|
||||
user.setUsername("bob");
|
||||
com.openisle.model.Category cat = new com.openisle.model.Category();
|
||||
cat.setName("tech");
|
||||
com.openisle.model.Post post = new com.openisle.model.Post();
|
||||
post.setId(6L);
|
||||
post.setTitle("fav");
|
||||
post.setCreatedAt(java.time.LocalDateTime.now());
|
||||
post.setCategory(cat);
|
||||
post.setAuthor(user);
|
||||
Mockito.when(userService.findByIdentifier("bob")).thenReturn(Optional.of(user));
|
||||
Mockito.when(subscriptionService.getSubscribedPosts("bob")).thenReturn(java.util.List.of(post));
|
||||
PostMetaDto meta = new PostMetaDto();
|
||||
meta.setId(6L);
|
||||
meta.setTitle("fav");
|
||||
Mockito.when(userMapper.toMetaDto(post)).thenReturn(meta);
|
||||
|
||||
mockMvc.perform(get("/api/users/bob/subscribed-posts"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].title").value("fav"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void listUserReplies() throws Exception {
|
||||
User user = new User();
|
||||
|
||||
@@ -27,7 +27,7 @@ class MedalServiceTest {
|
||||
|
||||
List<MedalDto> medals = service.getMedals(null);
|
||||
medals.forEach(m -> assertFalse(m.isCompleted()));
|
||||
assertEquals(5, medals.size());
|
||||
assertEquals(6, medals.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -34,11 +34,12 @@ class PostServiceTest {
|
||||
TaskScheduler taskScheduler = mock(TaskScheduler.class);
|
||||
EmailSender emailSender = mock(EmailSender.class);
|
||||
ApplicationContext context = mock(ApplicationContext.class);
|
||||
PointService pointService = mock(PointService.class);
|
||||
|
||||
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
|
||||
notifService, subService, commentService, commentRepo,
|
||||
reactionRepo, subRepo, notificationRepo, postReadService,
|
||||
imageUploader, taskScheduler, emailSender, context, PublishMode.DIRECT);
|
||||
imageUploader, taskScheduler, emailSender, context, pointService, PublishMode.DIRECT);
|
||||
when(context.getBean(PostService.class)).thenReturn(service);
|
||||
|
||||
Post post = new Post();
|
||||
@@ -61,6 +62,59 @@ class PostServiceTest {
|
||||
verify(postRepo).delete(post);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deletePostByAdminNotifiesAuthor() {
|
||||
PostRepository postRepo = mock(PostRepository.class);
|
||||
UserRepository userRepo = mock(UserRepository.class);
|
||||
CategoryRepository catRepo = mock(CategoryRepository.class);
|
||||
TagRepository tagRepo = mock(TagRepository.class);
|
||||
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
||||
NotificationService notifService = mock(NotificationService.class);
|
||||
SubscriptionService subService = mock(SubscriptionService.class);
|
||||
CommentService commentService = mock(CommentService.class);
|
||||
CommentRepository commentRepo = mock(CommentRepository.class);
|
||||
ReactionRepository reactionRepo = mock(ReactionRepository.class);
|
||||
PostSubscriptionRepository subRepo = mock(PostSubscriptionRepository.class);
|
||||
NotificationRepository notificationRepo = mock(NotificationRepository.class);
|
||||
PostReadService postReadService = mock(PostReadService.class);
|
||||
ImageUploader imageUploader = mock(ImageUploader.class);
|
||||
TaskScheduler taskScheduler = mock(TaskScheduler.class);
|
||||
EmailSender emailSender = mock(EmailSender.class);
|
||||
ApplicationContext context = mock(ApplicationContext.class);
|
||||
PointService pointService = mock(PointService.class);
|
||||
|
||||
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
|
||||
notifService, subService, commentService, commentRepo,
|
||||
reactionRepo, subRepo, notificationRepo, postReadService,
|
||||
imageUploader, taskScheduler, emailSender, context, pointService, PublishMode.DIRECT);
|
||||
when(context.getBean(PostService.class)).thenReturn(service);
|
||||
|
||||
Post post = new Post();
|
||||
post.setId(1L);
|
||||
post.setTitle("T");
|
||||
post.setContent("");
|
||||
User author = new User();
|
||||
author.setId(2L);
|
||||
author.setRole(Role.USER);
|
||||
post.setAuthor(author);
|
||||
|
||||
User admin = new User();
|
||||
admin.setId(1L);
|
||||
admin.setRole(Role.ADMIN);
|
||||
|
||||
when(postRepo.findById(1L)).thenReturn(Optional.of(post));
|
||||
when(userRepo.findByUsername("admin")).thenReturn(Optional.of(admin));
|
||||
when(commentRepo.findByPostAndParentIsNullOrderByCreatedAtAsc(post)).thenReturn(List.of());
|
||||
when(reactionRepo.findByPost(post)).thenReturn(List.of());
|
||||
when(subRepo.findByPost(post)).thenReturn(List.of());
|
||||
when(notificationRepo.findByPost(post)).thenReturn(List.of());
|
||||
|
||||
service.deletePost(1L, "admin");
|
||||
|
||||
verify(notifService).createNotification(eq(author), eq(NotificationType.POST_DELETED), isNull(),
|
||||
isNull(), isNull(), eq(admin), isNull(), eq("T"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void createPostRespectsRateLimit() {
|
||||
PostRepository postRepo = mock(PostRepository.class);
|
||||
@@ -80,18 +134,19 @@ class PostServiceTest {
|
||||
TaskScheduler taskScheduler = mock(TaskScheduler.class);
|
||||
EmailSender emailSender = mock(EmailSender.class);
|
||||
ApplicationContext context = mock(ApplicationContext.class);
|
||||
PointService pointService = mock(PointService.class);
|
||||
|
||||
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
|
||||
notifService, subService, commentService, commentRepo,
|
||||
reactionRepo, subRepo, notificationRepo, postReadService,
|
||||
imageUploader, taskScheduler, emailSender, context, PublishMode.DIRECT);
|
||||
imageUploader, taskScheduler, emailSender, context, pointService, PublishMode.DIRECT);
|
||||
when(context.getBean(PostService.class)).thenReturn(service);
|
||||
|
||||
when(postRepo.countByAuthorAfter(eq("alice"), any())).thenReturn(1L);
|
||||
|
||||
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
|
||||
@@ -113,11 +168,12 @@ class PostServiceTest {
|
||||
TaskScheduler taskScheduler = mock(TaskScheduler.class);
|
||||
EmailSender emailSender = mock(EmailSender.class);
|
||||
ApplicationContext context = mock(ApplicationContext.class);
|
||||
PointService pointService = mock(PointService.class);
|
||||
|
||||
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
|
||||
notifService, subService, commentService, commentRepo,
|
||||
reactionRepo, subRepo, notificationRepo, postReadService,
|
||||
imageUploader, taskScheduler, emailSender, context, PublishMode.DIRECT);
|
||||
imageUploader, taskScheduler, emailSender, context, pointService, PublishMode.DIRECT);
|
||||
when(context.getBean(PostService.class)).thenReturn(service);
|
||||
|
||||
User author = new User();
|
||||
|
||||
@@ -15,9 +15,10 @@ class ReactionServiceTest {
|
||||
UserRepository userRepo = mock(UserRepository.class);
|
||||
PostRepository postRepo = mock(PostRepository.class);
|
||||
CommentRepository commentRepo = mock(CommentRepository.class);
|
||||
MessageRepository messageRepo = mock(MessageRepository.class);
|
||||
NotificationService notif = mock(NotificationService.class);
|
||||
EmailSender email = mock(EmailSender.class);
|
||||
ReactionService service = new ReactionService(reactionRepo, userRepo, postRepo, commentRepo, notif, email);
|
||||
ReactionService service = new ReactionService(reactionRepo, userRepo, postRepo, commentRepo, messageRepo, notif, email);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(service, "websiteUrl", "https://ex.com");
|
||||
|
||||
User user = new User();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<div class="header-container">
|
||||
<div v-if="!isFloatMode" class="header-container">
|
||||
<HeaderComponent
|
||||
ref="header"
|
||||
@toggle-menu="menuVisible = !menuVisible"
|
||||
@@ -9,18 +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>
|
||||
|
||||
@@ -28,6 +38,8 @@
|
||||
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()
|
||||
@@ -50,6 +62,7 @@ const hideMenu = computed(() => {
|
||||
})
|
||||
|
||||
const header = useTemplateRef('header')
|
||||
const isFloatMode = computed(() => useRoute().query.float !== undefined)
|
||||
|
||||
onMounted(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
|
||||
@@ -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); */
|
||||
@@ -90,7 +90,8 @@ body {
|
||||
}
|
||||
|
||||
.vditor-toolbar--pin {
|
||||
top: var(--header-height) !important;
|
||||
top: calc(var(--header-height) + 1px) !important;
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.vditor-panel {
|
||||
@@ -138,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%;
|
||||
@@ -183,7 +187,7 @@ body {
|
||||
font-family: 'Maple Mono', monospace;
|
||||
font-size: 13px;
|
||||
border-radius: 4px;
|
||||
white-space: no-wrap;
|
||||
white-space: break-spaces;
|
||||
background-color: var(--code-highlight-background-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
<template v-else-if="medal.type === 'POST'">
|
||||
{{ medal.currentPostCount }}/{{ medal.targetPostCount }}
|
||||
</template>
|
||||
<template v-else-if="medal.type === 'FEATURED'">
|
||||
{{ medal.currentFeaturedCount }}/{{ medal.targetFeaturedCount }}
|
||||
</template>
|
||||
<template v-else-if="medal.type === 'CONTRIBUTOR'">
|
||||
{{ medal.currentContributionLines }}/{{ medal.targetContributionLines }}
|
||||
</template>
|
||||
|
||||
65
frontend_nuxt/components/BaseSwitch.vue
Normal file
65
frontend_nuxt/components/BaseSwitch.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<label class="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="modelValue"
|
||||
@change="$emit('update:modelValue', $event.target.checked)"
|
||||
/>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
defineEmits(['update:modelValue'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 40px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: #ccc;
|
||||
transition: 0.2s;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.slider:before {
|
||||
position: absolute;
|
||||
content: '';
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
left: 2px;
|
||||
bottom: 2px;
|
||||
background-color: white;
|
||||
transition: 0.2s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
input:checked + .slider {
|
||||
background-color: var(--primary-color);
|
||||
}
|
||||
|
||||
input:checked + .slider:before {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
</style>
|
||||
110
frontend_nuxt/components/BaseTabs.vue
Normal file
110
frontend_nuxt/components/BaseTabs.vue
Normal file
@@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<div class="base-tabs-wrapper" @touchstart="onTouchStart" @touchend="onTouchEnd">
|
||||
<div class="base-tabs-header">
|
||||
<div class="base-tabs-items">
|
||||
<div
|
||||
v-for="tab in tabs"
|
||||
:key="tab.name"
|
||||
:class="['base-tab-item', { selected: tab.name === current }]"
|
||||
@click="select(tab.name)"
|
||||
>
|
||||
<i v-if="tab.icon" :class="tab.icon"></i>
|
||||
<span>{{ tab.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="base-tabs-right">
|
||||
<slot name="right"></slot>
|
||||
</div>
|
||||
</div>
|
||||
<div class="base-tabs-content">
|
||||
<slot :current="current"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: undefined },
|
||||
tabs: { type: Array, required: true },
|
||||
swipe: { type: Boolean, default: true },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const current = ref(props.modelValue ?? (props.tabs[0] && props.tabs[0].name))
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
if (val !== undefined) current.value = val
|
||||
},
|
||||
)
|
||||
|
||||
function select(name) {
|
||||
emit('update:modelValue', name)
|
||||
}
|
||||
|
||||
let startX = 0
|
||||
|
||||
function onTouchStart(e) {
|
||||
if (!props.swipe) return
|
||||
startX = e.changedTouches[0].clientX
|
||||
}
|
||||
|
||||
function onTouchEnd(e) {
|
||||
if (!props.swipe) return
|
||||
const endX = e.changedTouches[0].clientX
|
||||
const diff = endX - startX
|
||||
if (Math.abs(diff) > 50) {
|
||||
const index = props.tabs.findIndex((t) => t.name === current.value)
|
||||
if (diff < 0 && index < props.tabs.length - 1) {
|
||||
emit('update:modelValue', props.tabs[index + 1].name)
|
||||
} else if (diff > 0 && index > 0) {
|
||||
emit('update:modelValue', props.tabs[index - 1].name)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.base-tabs-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.base-tabs-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid var(--normal-border-color);
|
||||
}
|
||||
|
||||
.base-tabs-items {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.base-tab-item {
|
||||
padding: 10px 20px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.base-tab-item.selected {
|
||||
color: var(--primary-color);
|
||||
border-bottom: 2px solid var(--primary-color);
|
||||
}
|
||||
|
||||
.base-tab-item i {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.base-tabs-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
</style>
|
||||
@@ -1,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 {
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
getEditorTheme as getEditorThemeUtil,
|
||||
getPreviewTheme as getPreviewThemeUtil,
|
||||
} from '~/utils/vditor'
|
||||
import '~/assets/global.css'
|
||||
import LoginOverlay from '~/components/LoginOverlay.vue'
|
||||
|
||||
export default {
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
v-if="showEditor"
|
||||
@submit="submitReply"
|
||||
:loading="isWaitingForReply"
|
||||
:disabled="!loggedIn"
|
||||
:disabled="!loggedIn || postClosed"
|
||||
:show-login-overlay="!loggedIn"
|
||||
:parent-user-name="comment.userName"
|
||||
/>
|
||||
@@ -76,6 +76,7 @@
|
||||
:level="level + 1"
|
||||
:default-show-replies="item.openReplies"
|
||||
:post-author-id="postAuthorId"
|
||||
:post-closed="postClosed"
|
||||
/>
|
||||
</template>
|
||||
</BaseTimeline>
|
||||
@@ -122,6 +123,10 @@ const props = defineProps({
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
postClosed: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['deleted'])
|
||||
@@ -148,6 +153,7 @@ const toggleReplies = () => {
|
||||
}
|
||||
|
||||
const toggleEditor = () => {
|
||||
if (props.postClosed) return
|
||||
showEditor.value = !showEditor.value
|
||||
if (showEditor.value) {
|
||||
setTimeout(() => {
|
||||
@@ -213,6 +219,10 @@ const deleteComment = async () => {
|
||||
}
|
||||
const submitReply = async (parentUserName, text, clear) => {
|
||||
if (!text.trim()) return
|
||||
if (props.postClosed) {
|
||||
toast.error('帖子已关闭')
|
||||
return
|
||||
}
|
||||
isWaitingForReply.value = true
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
|
||||
71
frontend_nuxt/components/ConfirmDialog.vue
Normal file
71
frontend_nuxt/components/ConfirmDialog.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<BasePopup :visible="visible" @close="onCancel">
|
||||
<div class="confirm-dialog" role="dialog" aria-modal="true">
|
||||
<h3 class="confirm-title">{{ title }}</h3>
|
||||
<p class="confirm-message">{{ message }}</p>
|
||||
<div class="confirm-actions">
|
||||
<div class="cancel-button" @click="onCancel">取消</div>
|
||||
<div class="confirm-button" @click="onConfirm">确认</div>
|
||||
</div>
|
||||
</div>
|
||||
</BasePopup>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import BasePopup from '~/components/BasePopup.vue'
|
||||
import { useConfirm } from '~/composables/useConfirm'
|
||||
|
||||
const { visible, title, message, onConfirm, onCancel } = useConfirm()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.confirm-dialog {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.confirm-title {
|
||||
margin-top: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.confirm-message {
|
||||
margin: 16px 0 20px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
.confirm-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.confirm-button,
|
||||
.cancel-button {
|
||||
min-width: 88px;
|
||||
height: 36px;
|
||||
padding: 0 14px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.confirm-button {
|
||||
background: var(--primary-color);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.confirm-button:hover {
|
||||
background: var(--primary-color-hover);
|
||||
}
|
||||
.cancel-button {
|
||||
background: transparent;
|
||||
color: var(--primary-color);
|
||||
border-color: currentColor;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.cancel-button:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
</style>
|
||||
@@ -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
|
||||
|
||||
@@ -50,7 +56,7 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
const checkMilkTeaActivity = async () => {
|
||||
if (!process.client) return
|
||||
if (!import.meta.client) return
|
||||
if (localStorage.getItem('milkTeaActivityPopupShown')) return
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/activities`)
|
||||
@@ -68,7 +74,7 @@ const checkMilkTeaActivity = async () => {
|
||||
}
|
||||
|
||||
const checkInviteCodeActivity = async () => {
|
||||
if (!process.client) return
|
||||
if (!import.meta.client) return
|
||||
if (localStorage.getItem('inviteCodeActivityPopupShown')) return
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/activities`)
|
||||
@@ -86,32 +92,42 @@ const checkInviteCodeActivity = async () => {
|
||||
}
|
||||
|
||||
const closeInviteCodePopup = () => {
|
||||
if (!process.client) return
|
||||
if (!import.meta.client) return
|
||||
localStorage.setItem('inviteCodeActivityPopupShown', 'true')
|
||||
showInviteCodePopup.value = false
|
||||
}
|
||||
|
||||
const closeMilkTeaPopup = () => {
|
||||
if (!process.client) return
|
||||
if (!import.meta.client) return
|
||||
localStorage.setItem('milkTeaActivityPopupShown', 'true')
|
||||
showMilkTeaPopup.value = false
|
||||
checkNotificationSetting()
|
||||
}
|
||||
|
||||
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 (!process.client) return
|
||||
if (!import.meta.client) return
|
||||
if (!authState.loggedIn) return
|
||||
if (localStorage.getItem('notificationSettingPopupShown')) return
|
||||
showNotificationPopup.value = true
|
||||
}
|
||||
const closeNotificationPopup = () => {
|
||||
if (!process.client) return
|
||||
if (!import.meta.client) return
|
||||
localStorage.setItem('notificationSettingPopupShown', 'true')
|
||||
showNotificationPopup.value = false
|
||||
checkNewMedals()
|
||||
}
|
||||
const checkNewMedals = async () => {
|
||||
if (!process.client) return
|
||||
if (!import.meta.client) return
|
||||
if (!authState.loggedIn || !authState.userId) return
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/medals?userId=${authState.userId}`)
|
||||
@@ -129,7 +145,7 @@ const checkNewMedals = async () => {
|
||||
}
|
||||
}
|
||||
const closeMedalPopup = () => {
|
||||
if (!process.client) return
|
||||
if (!import.meta.client) return
|
||||
const seen = new Set(JSON.parse(localStorage.getItem('seenMedals') || '[]'))
|
||||
newMedals.value.forEach((m) => seen.add(m.type))
|
||||
localStorage.setItem('seenMedals', JSON.stringify([...seen]))
|
||||
|
||||
@@ -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 && unreadCount > 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
|
||||
@@ -47,6 +50,16 @@
|
||||
</div>
|
||||
</ToolTip>
|
||||
|
||||
<ToolTip v-if="isLogin" content="站内信和频道" placement="bottom">
|
||||
<div class="messages-icon" @click="goToMessages">
|
||||
<i class="fas fa-comments"></i>
|
||||
<span v-if="unreadMessageCount > 0" class="unread-badge">{{
|
||||
unreadMessageCount
|
||||
}}</span>
|
||||
<span v-else-if="hasChannelUnread" class="unread-dot"></span>
|
||||
</div>
|
||||
</ToolTip>
|
||||
|
||||
<DropdownMenu v-if="isLogin" ref="userMenu" :items="headerMenuItems">
|
||||
<template #trigger>
|
||||
<div class="avatar-container">
|
||||
@@ -75,7 +88,8 @@ import DropdownMenu from '~/components/DropdownMenu.vue'
|
||||
import ToolTip from '~/components/ToolTip.vue'
|
||||
import SearchDropdown from '~/components/SearchDropdown.vue'
|
||||
import { authState, clearToken, loadCurrentUser } from '~/utils/auth'
|
||||
import { fetchUnreadCount, notificationState } from '~/utils/notification'
|
||||
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'
|
||||
@@ -93,7 +107,8 @@ const props = defineProps({
|
||||
|
||||
const isLogin = computed(() => authState.loggedIn)
|
||||
const isMobile = useIsMobile()
|
||||
const unreadCount = computed(() => notificationState.unreadCount)
|
||||
const { count: unreadMessageCount, fetchUnreadCount } = useUnreadCount()
|
||||
const { hasUnread: hasChannelUnread, fetchChannelUnread } = useChannelsUnreadCount()
|
||||
const avatar = ref('')
|
||||
const showSearch = ref(false)
|
||||
const searchDropdown = ref(null)
|
||||
@@ -134,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 || '生成邀请链接失败')
|
||||
@@ -182,10 +209,13 @@ const goToNewPost = () => {
|
||||
}
|
||||
|
||||
const refrechData = async () => {
|
||||
await fetchUnreadCount()
|
||||
window.dispatchEvent(new Event('refresh-home'))
|
||||
}
|
||||
|
||||
const goToMessages = () => {
|
||||
navigateTo('/message-box')
|
||||
}
|
||||
|
||||
const headerMenuItems = computed(() => [
|
||||
{ text: '设置', onClick: goToSettings },
|
||||
{ text: '个人主页', onClick: goToProfile },
|
||||
@@ -215,9 +245,10 @@ onMounted(async () => {
|
||||
}
|
||||
const updateUnread = async () => {
|
||||
if (authState.loggedIn) {
|
||||
await fetchUnreadCount()
|
||||
fetchUnreadCount()
|
||||
fetchChannelUnread()
|
||||
} else {
|
||||
notificationState.unreadCount = 0
|
||||
fetchChannelUnread()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,7 +257,7 @@ onMounted(async () => {
|
||||
|
||||
watch(
|
||||
() => authState.loggedIn,
|
||||
async () => {
|
||||
async (isLoggedIn) => {
|
||||
await updateAvatar()
|
||||
await updateUnread()
|
||||
},
|
||||
@@ -379,9 +410,37 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.rss-icon,
|
||||
.new-post-icon {
|
||||
.new-post-icon,
|
||||
.messages-icon {
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.unread-badge {
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
right: -10px;
|
||||
background-color: #ff4d4f;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
padding: 2px 5px;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
min-width: 16px;
|
||||
text-align: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.unread-dot {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
right: -4px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: #ff4d4f;
|
||||
}
|
||||
|
||||
.rss-icon {
|
||||
|
||||
@@ -40,7 +40,7 @@ const stopObserver = () => {
|
||||
}
|
||||
|
||||
const startObserver = () => {
|
||||
if (!process.client || props.pause || done.value) return
|
||||
if (!import.meta.client || props.pause || done.value) return
|
||||
stopObserver()
|
||||
io = new IntersectionObserver(
|
||||
async (entries) => {
|
||||
|
||||
@@ -106,7 +106,7 @@
|
||||
|
||||
<div class="menu-section">
|
||||
<div class="section-header" @click="tagOpen = !tagOpen">
|
||||
<span>tag</span>
|
||||
<span>标签</span>
|
||||
<i :class="tagOpen ? 'fas fa-chevron-up' : 'fas fa-chevron-down'"></i>
|
||||
</div>
|
||||
<div v-if="tagOpen" class="section-items">
|
||||
@@ -262,7 +262,7 @@ const gotoTag = (t) => {
|
||||
top: var(--header-height);
|
||||
width: 220px;
|
||||
background-color: var(--app-menu-background-color);
|
||||
height: calc(100vh - 20px - var(--header-height));
|
||||
height: calc(100vh - var(--header-height));
|
||||
border-right: 1px solid var(--menu-border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -348,6 +348,7 @@ const gotoTag = (t) => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.menu-section {
|
||||
|
||||
182
frontend_nuxt/components/MessageEditor.vue
Normal file
182
frontend_nuxt/components/MessageEditor.vue
Normal file
@@ -0,0 +1,182 @@
|
||||
<template>
|
||||
<div class="message-editor-container">
|
||||
<div class="message-editor-wrapper">
|
||||
<div :id="editorId" ref="vditorElement"></div>
|
||||
</div>
|
||||
<div class="message-bottom-container">
|
||||
<div class="message-submit" :class="{ disabled: isDisabled }" @click="submit">
|
||||
<template v-if="!loading"> 发送 </template>
|
||||
<template v-else> <i class="fa-solid fa-spinner fa-spin"></i> 发送中... </template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { computed, onMounted, onUnmounted, ref, useId, watch } from 'vue'
|
||||
import { clearVditorStorage } from '~/utils/clearVditorStorage'
|
||||
import { themeState } from '~/utils/theme'
|
||||
import {
|
||||
createVditor,
|
||||
getEditorTheme as getEditorThemeUtil,
|
||||
getPreviewTheme as getPreviewThemeUtil,
|
||||
} from '~/utils/vditor'
|
||||
import '~/assets/global.css'
|
||||
|
||||
export default {
|
||||
name: 'MessageEditor',
|
||||
emits: ['submit'],
|
||||
props: {
|
||||
editorId: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const vditorInstance = ref(null)
|
||||
const text = ref('')
|
||||
const editorId = ref(props.editorId)
|
||||
if (!editorId.value) {
|
||||
editorId.value = 'editor-' + useId()
|
||||
}
|
||||
const getEditorTheme = getEditorThemeUtil
|
||||
const getPreviewTheme = getPreviewThemeUtil
|
||||
const applyTheme = () => {
|
||||
if (vditorInstance.value) {
|
||||
vditorInstance.value.setTheme(getEditorTheme(), getPreviewTheme())
|
||||
}
|
||||
}
|
||||
|
||||
const isDisabled = computed(() => props.loading || props.disabled || !text.value.trim())
|
||||
|
||||
const submit = () => {
|
||||
if (!vditorInstance.value || isDisabled.value) return
|
||||
const value = vditorInstance.value.getValue()
|
||||
emit('submit', value, () => {
|
||||
if (!vditorInstance.value) return
|
||||
vditorInstance.value.setValue('')
|
||||
text.value = ''
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
vditorInstance.value = createVditor(editorId.value, {
|
||||
placeholder: '输入消息...',
|
||||
height: 150,
|
||||
toolbar: [
|
||||
'emoji',
|
||||
'bold',
|
||||
'italic',
|
||||
'strike',
|
||||
'link',
|
||||
'|',
|
||||
'list',
|
||||
'|',
|
||||
'line',
|
||||
'quote',
|
||||
'code',
|
||||
'inline-code',
|
||||
'|',
|
||||
'upload',
|
||||
],
|
||||
preview: {
|
||||
actions: [],
|
||||
markdown: { toc: false },
|
||||
},
|
||||
input(value) {
|
||||
text.value = value
|
||||
},
|
||||
after() {
|
||||
if (props.loading || props.disabled) {
|
||||
vditorInstance.value.disabled()
|
||||
}
|
||||
applyTheme()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clearVditorStorage()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.loading,
|
||||
(val) => {
|
||||
if (!vditorInstance.value) return
|
||||
if (val) {
|
||||
vditorInstance.value.disabled()
|
||||
} else if (!props.disabled) {
|
||||
vditorInstance.value.enable()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.disabled,
|
||||
(val) => {
|
||||
if (!vditorInstance.value) return
|
||||
if (val) {
|
||||
vditorInstance.value.disabled()
|
||||
} else if (!props.loading) {
|
||||
vditorInstance.value.enable()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => themeState.mode,
|
||||
() => {
|
||||
applyTheme()
|
||||
},
|
||||
)
|
||||
|
||||
return { submit, isDisabled, editorId }
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.message-editor-container {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.message-bottom-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
padding: 10px;
|
||||
background-color: var(--bg-color-soft);
|
||||
border-top: 1px solid var(--border-color);
|
||||
border-bottom-left-radius: 8px;
|
||||
border-bottom-right-radius: 8px;
|
||||
}
|
||||
|
||||
.message-submit {
|
||||
background-color: var(--primary-color);
|
||||
color: #fff;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.message-submit.disabled {
|
||||
background-color: var(--primary-color-disabled);
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.message-submit:not(.disabled):hover {
|
||||
background-color: var(--primary-color-hover);
|
||||
}
|
||||
</style>
|
||||
110
frontend_nuxt/components/MessageFloatWindow.vue
Normal file
110
frontend_nuxt/components/MessageFloatWindow.vue
Normal file
@@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<div v-if="floatRoute" class="message-float-window" :style="{ height: floatHeight }">
|
||||
<iframe :src="iframeSrc" frameborder="0" ref="iframeRef" @load="injectBaseTag"></iframe>
|
||||
|
||||
<div class="float-actions">
|
||||
<i
|
||||
class="fas fa-chevron-down"
|
||||
v-if="floatHeight !== MINI_HEIGHT"
|
||||
title="收起至 100px"
|
||||
@click="collapseToMini"
|
||||
></i>
|
||||
<i
|
||||
class="fas fa-chevron-up"
|
||||
v-if="floatHeight !== DEFAULT_HEIGHT"
|
||||
title="回弹至 60vh"
|
||||
@click="reboundToDefault"
|
||||
></i>
|
||||
<i class="fas fa-expand" title="在页面中打开" @click="expand"></i>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const floatRoute = useState('messageFloatRoute')
|
||||
|
||||
const DEFAULT_HEIGHT = '60vh'
|
||||
const MINI_HEIGHT = '45px'
|
||||
const floatHeight = ref(DEFAULT_HEIGHT)
|
||||
|
||||
const iframeRef = ref(null)
|
||||
const iframeSrc = computed(() => {
|
||||
if (!floatRoute.value) return ''
|
||||
return floatRoute.value + (floatRoute.value.includes('?') ? '&' : '?') + 'float=1'
|
||||
})
|
||||
|
||||
function collapseToMini() {
|
||||
floatHeight.value = MINI_HEIGHT
|
||||
}
|
||||
|
||||
function reboundToDefault() {
|
||||
floatHeight.value = DEFAULT_HEIGHT
|
||||
}
|
||||
|
||||
function expand() {
|
||||
if (!floatRoute.value) return
|
||||
const target = floatRoute.value
|
||||
floatRoute.value = null
|
||||
navigateTo(target)
|
||||
}
|
||||
|
||||
function injectBaseTag() {
|
||||
if (!iframeRef.value) return
|
||||
|
||||
const iframeDoc = iframeRef.value.contentDocument || iframeRef.value.contentWindow.document
|
||||
if (iframeDoc && !iframeDoc.querySelector('base')) {
|
||||
const base = iframeDoc.createElement('base')
|
||||
base.target = '_top'
|
||||
iframeDoc.head.appendChild(base)
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => floatRoute.value,
|
||||
(v) => {
|
||||
if (v) floatHeight.value = DEFAULT_HEIGHT
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.message-float-window {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: 400px;
|
||||
max-height: 90vh;
|
||||
background-color: var(--background-color);
|
||||
border: 1px solid var(--normal-border-color);
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: height 0.25s ease;
|
||||
/* 平滑过渡 */
|
||||
}
|
||||
|
||||
.message-float-window iframe {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.float-actions {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 8px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.float-actions i {
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.float-actions i:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
74
frontend_nuxt/components/MessagePopup.vue
Normal file
74
frontend_nuxt/components/MessagePopup.vue
Normal 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>
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
getEditorTheme as getEditorThemeUtil,
|
||||
getPreviewTheme as getPreviewThemeUtil,
|
||||
} from '~/utils/vditor'
|
||||
import '~/assets/global.css'
|
||||
|
||||
export default {
|
||||
name: 'PostEditor',
|
||||
|
||||
@@ -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>
|
||||
@@ -51,6 +74,10 @@ import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { toast } from '~/main'
|
||||
import { authState, getToken } from '~/utils/auth'
|
||||
import { reactionEmojiMap } from '~/utils/reactions'
|
||||
import { useReactionTypes } from '~/composables/useReactionTypes'
|
||||
|
||||
const { reactionTypes, initialize } = useReactionTypes()
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
@@ -66,30 +93,6 @@ watch(
|
||||
)
|
||||
|
||||
const reactions = ref(props.modelValue)
|
||||
const reactionTypes = ref([])
|
||||
|
||||
let cachedTypes = null
|
||||
const fetchTypes = async () => {
|
||||
if (cachedTypes) return cachedTypes
|
||||
try {
|
||||
const token = getToken()
|
||||
const res = await fetch(`${API_BASE_URL}/api/reaction-types`, {
|
||||
headers: { Authorization: token ? `Bearer ${token}` : '' },
|
||||
})
|
||||
if (res.ok) {
|
||||
cachedTypes = await res.json()
|
||||
} else {
|
||||
cachedTypes = []
|
||||
}
|
||||
} catch {
|
||||
cachedTypes = []
|
||||
}
|
||||
return cachedTypes
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
reactionTypes.value = await fetchTypes()
|
||||
})
|
||||
|
||||
const counts = computed(() => {
|
||||
const c = {}
|
||||
@@ -139,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(
|
||||
@@ -200,6 +205,10 @@ const toggleReaction = async (type) => {
|
||||
toast.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await initialize()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@@ -233,11 +242,8 @@ const toggleReaction = async (type) => {
|
||||
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 {
|
||||
@@ -253,7 +259,7 @@ const toggleReaction = async (type) => {
|
||||
|
||||
.make-reaction-item {
|
||||
cursor: pointer;
|
||||
padding: 10px;
|
||||
padding: 4px;
|
||||
opacity: 0.5;
|
||||
border-radius: 8px;
|
||||
font-size: 20px;
|
||||
@@ -278,18 +284,16 @@ const toggleReaction = async (type) => {
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -303,6 +307,27 @@ const toggleReaction = async (type) => {
|
||||
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);
|
||||
}
|
||||
|
||||
198
frontend_nuxt/components/SearchPersonDropdown.vue
Normal file
198
frontend_nuxt/components/SearchPersonDropdown.vue
Normal 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>
|
||||
@@ -63,7 +63,7 @@ const isImageIcon = (icon) => {
|
||||
}
|
||||
|
||||
const buildTagsUrl = (kw = '') => {
|
||||
const base = API_BASE_URL || (process.client ? window.location.origin : '')
|
||||
const base = API_BASE_URL || (import.meta.client ? window.location.origin : '')
|
||||
const url = new URL('/api/tags', base)
|
||||
|
||||
if (kw) url.searchParams.set('keyword', kw)
|
||||
|
||||
92
frontend_nuxt/composables/useChannelsUnreadCount.js
Normal file
92
frontend_nuxt/composables/useChannelsUnreadCount.js
Normal 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,
|
||||
}
|
||||
}
|
||||
52
frontend_nuxt/composables/useConfirm.ts
Normal file
52
frontend_nuxt/composables/useConfirm.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
// composables/useConfirm.ts
|
||||
import { ref } from 'vue'
|
||||
|
||||
// 全局单例(SPA 下即可;Nuxt/SSR 下见文末“SSR 提醒”)
|
||||
const visible = ref(false)
|
||||
const title = ref('')
|
||||
const message = ref('')
|
||||
|
||||
let resolver: ((ok: boolean) => void) | null = null
|
||||
|
||||
function reset() {
|
||||
visible.value = false
|
||||
title.value = ''
|
||||
message.value = ''
|
||||
resolver = null
|
||||
}
|
||||
|
||||
export function useConfirm() {
|
||||
/**
|
||||
* 打开确认框,返回 Promise<boolean>
|
||||
* - 确认 => resolve(true)
|
||||
* - 取消/关闭 => resolve(false)
|
||||
* 若并发调用,以最后一次为准(更简单直观)
|
||||
*/
|
||||
const confirm = (t: string, m: string) => {
|
||||
title.value = t
|
||||
message.value = m
|
||||
visible.value = true
|
||||
return new Promise<boolean>((resolve) => {
|
||||
resolver = resolve
|
||||
})
|
||||
}
|
||||
|
||||
const onConfirm = () => {
|
||||
resolver?.(true)
|
||||
reset()
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
resolver?.(false)
|
||||
reset()
|
||||
}
|
||||
|
||||
return {
|
||||
visible,
|
||||
title,
|
||||
message,
|
||||
confirm,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}
|
||||
}
|
||||
52
frontend_nuxt/composables/useReactionTypes.js
Normal file
52
frontend_nuxt/composables/useReactionTypes.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
const reactionTypes = ref([])
|
||||
let isLoading = false
|
||||
let isInitialized = false
|
||||
|
||||
export function useReactionTypes() {
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
const fetchReactionTypes = async () => {
|
||||
if (isInitialized || isLoading) {
|
||||
reactionTypes.value = [...(window.reactionTypes || [])]
|
||||
return reactionTypes.value
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
try {
|
||||
const token = getToken()
|
||||
const res = await fetch(`${API_BASE_URL}/api/reaction-types`, {
|
||||
headers: { Authorization: token ? `Bearer ${token}` : '' },
|
||||
})
|
||||
if (res.ok) {
|
||||
reactionTypes.value = await res.json()
|
||||
window.reactionTypes = [...reactionTypes.value]
|
||||
isInitialized = true
|
||||
} else {
|
||||
reactionTypes.value = []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch reaction types:', error)
|
||||
reactionTypes.value = []
|
||||
} finally {
|
||||
isLoading = false
|
||||
}
|
||||
return reactionTypes.value
|
||||
}
|
||||
|
||||
const initialize = async () => {
|
||||
if (!isInitialized) {
|
||||
await fetchReactionTypes()
|
||||
}
|
||||
return reactionTypes.value
|
||||
}
|
||||
|
||||
return {
|
||||
reactionTypes: readonly(reactionTypes),
|
||||
fetchReactionTypes,
|
||||
initialize,
|
||||
isInitialized: readonly(isInitialized)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// 导出一个便捷的 toast 对象
|
||||
export const toast = {
|
||||
success: async (message) => {
|
||||
if (process.client) {
|
||||
if (import.meta.client) {
|
||||
try {
|
||||
const { useToast } = await import('vue-toastification')
|
||||
const toastInstance = useToast()
|
||||
@@ -12,7 +12,7 @@ export const toast = {
|
||||
}
|
||||
},
|
||||
error: async (message) => {
|
||||
if (process.client) {
|
||||
if (import.meta.client) {
|
||||
try {
|
||||
const { useToast } = await import('vue-toastification')
|
||||
const toastInstance = useToast()
|
||||
@@ -23,7 +23,7 @@ export const toast = {
|
||||
}
|
||||
},
|
||||
warning: async (message) => {
|
||||
if (process.client) {
|
||||
if (import.meta.client) {
|
||||
try {
|
||||
const { useToast } = await import('vue-toastification')
|
||||
const toastInstance = useToast()
|
||||
@@ -34,7 +34,7 @@ export const toast = {
|
||||
}
|
||||
},
|
||||
info: async (message) => {
|
||||
if (process.client) {
|
||||
if (import.meta.client) {
|
||||
try {
|
||||
const { useToast } = await import('vue-toastification')
|
||||
const toastInstance = useToast()
|
||||
@@ -48,7 +48,7 @@ export const toast = {
|
||||
|
||||
// 导出 useToast composable
|
||||
export const useToast = () => {
|
||||
if (process.client) {
|
||||
if (import.meta.client) {
|
||||
return new Promise(async (resolve) => {
|
||||
try {
|
||||
const { useToast: useVueToast } = await import('vue-toastification')
|
||||
|
||||
93
frontend_nuxt/composables/useUnreadCount.js
Normal file
93
frontend_nuxt/composables/useUnreadCount.js
Normal file
@@ -0,0 +1,93 @@
|
||||
import { ref, watch, onMounted } from 'vue';
|
||||
import { useWebSocket } from './useWebSocket';
|
||||
import { getToken } from '~/utils/auth';
|
||||
|
||||
const count = ref(0);
|
||||
let isInitialized = false;
|
||||
let wsSubscription = null;
|
||||
|
||||
export function useUnreadCount() {
|
||||
const config = useRuntimeConfig();
|
||||
const API_BASE_URL = config.public.apiBaseUrl;
|
||||
const { subscribe, isConnected, connect } = useWebSocket();
|
||||
|
||||
const fetchUnreadCount = async () => {
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
count.value = 0;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/messages/unread-count`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
count.value = data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch unread count:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const initialize = async () => {
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
count.value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// 总是获取最新的未读数量
|
||||
fetchUnreadCount();
|
||||
|
||||
// 确保WebSocket连接
|
||||
if (!isConnected.value) {
|
||||
connect(token);
|
||||
}
|
||||
|
||||
// 设置WebSocket监听
|
||||
await setupWebSocketListener();
|
||||
};
|
||||
|
||||
const setupWebSocketListener = async () => {
|
||||
// 只有在还没有订阅的情况下才设置监听
|
||||
if (!wsSubscription) {
|
||||
|
||||
watch(isConnected, (newValue) => {
|
||||
if (newValue && !wsSubscription) {
|
||||
const destination = `/user/queue/unread-count`;
|
||||
wsSubscription = subscribe(destination, (message) => {
|
||||
const unreadCount = parseInt(message.body, 10);
|
||||
if (!isNaN(unreadCount)) {
|
||||
count.value = unreadCount;
|
||||
}
|
||||
});
|
||||
}
|
||||
}, { immediate: true });
|
||||
}
|
||||
};
|
||||
|
||||
// 自动初始化逻辑 - 确保每次调用都能获取到未读数量并设置监听
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
if (!isInitialized) {
|
||||
isInitialized = true;
|
||||
initialize(); // 完整初始化,包括WebSocket监听
|
||||
} else {
|
||||
// 即使已经初始化,也要确保获取最新的未读数量并确保WebSocket监听存在
|
||||
fetchUnreadCount();
|
||||
|
||||
// 确保WebSocket连接和监听都存在
|
||||
if (!isConnected.value) {
|
||||
connect(token);
|
||||
}
|
||||
setupWebSocketListener();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
count,
|
||||
fetchUnreadCount,
|
||||
initialize,
|
||||
};
|
||||
}
|
||||
86
frontend_nuxt/composables/useWebSocket.js
Normal file
86
frontend_nuxt/composables/useWebSocket.js
Normal file
@@ -0,0 +1,86 @@
|
||||
import { ref } from 'vue'
|
||||
import { Client } from '@stomp/stompjs'
|
||||
import SockJS from 'sockjs-client/dist/sockjs.min.js'
|
||||
import { useRuntimeConfig } from '#app'
|
||||
|
||||
const client = ref(null)
|
||||
const isConnected = ref(false)
|
||||
|
||||
const connect = (token) => {
|
||||
if (isConnected.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
const socketUrl = `${API_BASE_URL}/api/sockjs`
|
||||
|
||||
const socket = new SockJS(socketUrl)
|
||||
const stompClient = new Client({
|
||||
webSocketFactory: () => socket,
|
||||
connectHeaders: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
debug: function (str) {},
|
||||
reconnectDelay: 5000,
|
||||
heartbeatIncoming: 4000,
|
||||
heartbeatOutgoing: 4000,
|
||||
})
|
||||
|
||||
stompClient.onConnect = (frame) => {
|
||||
isConnected.value = true
|
||||
}
|
||||
|
||||
stompClient.onStompError = (frame) => {
|
||||
console.error('WebSocket STOMP error:', frame)
|
||||
}
|
||||
|
||||
stompClient.activate()
|
||||
client.value = stompClient
|
||||
}
|
||||
|
||||
const disconnect = () => {
|
||||
if (client.value) {
|
||||
isConnected.value = false
|
||||
client.value.deactivate()
|
||||
client.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const subscribe = (destination, callback) => {
|
||||
if (!isConnected.value || !client.value || !client.value.connected) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const subscription = client.value.subscribe(destination, (message) => {
|
||||
try {
|
||||
if (
|
||||
destination.includes('/queue/unread-count') ||
|
||||
destination.includes('/queue/channel-unread')
|
||||
) {
|
||||
callback(message)
|
||||
} else {
|
||||
const parsedMessage = JSON.parse(message.body)
|
||||
callback(parsedMessage)
|
||||
}
|
||||
} catch (error) {
|
||||
callback(message)
|
||||
}
|
||||
})
|
||||
|
||||
return subscription
|
||||
} catch (error) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function useWebSocket() {
|
||||
return {
|
||||
client,
|
||||
isConnected,
|
||||
connect,
|
||||
disconnect,
|
||||
subscribe,
|
||||
}
|
||||
}
|
||||
534
frontend_nuxt/package-lock.json
generated
534
frontend_nuxt/package-lock.json
generated
@@ -6,6 +6,7 @@
|
||||
"": {
|
||||
"name": "frontend_nuxt",
|
||||
"dependencies": {
|
||||
"@stomp/stompjs": "^7.0.0",
|
||||
"cropperjs": "^1.6.2",
|
||||
"echarts": "^5.6.0",
|
||||
"flatpickr": "^4.6.13",
|
||||
@@ -14,6 +15,8 @@
|
||||
"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",
|
||||
"vue-echarts": "^7.0.3",
|
||||
@@ -957,9 +960,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/busboy": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.1.1.tgz",
|
||||
"integrity": "sha512-5DGmA8FTdB2XbDeEwc/5ZXBl6UbBAyBOOLlPuBnZ/N1SwdH9Ii+cOX3tBROlDgcTXxjOYnLMVoKk9+FXAw0CJw==",
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz",
|
||||
"integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@ioredis/commands": {
|
||||
@@ -1007,6 +1010,16 @@
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/remapping": {
|
||||
"version": "2.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
||||
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/resolve-uri": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
@@ -1663,9 +1676,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@netlify/zip-it-and-ship-it/node_modules/@netlify/serverless-functions-api": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@netlify/serverless-functions-api/-/serverless-functions-api-2.1.3.tgz",
|
||||
"integrity": "sha512-bNlN/hpND8xFQzpjyKxm6vJayD+bPBlOvs4lWihE7WULrphuH1UuFsoVE5386bNNGH8Rs1IH01AFsl7ALQgOlQ==",
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@netlify/serverless-functions-api/-/serverless-functions-api-2.2.0.tgz",
|
||||
"integrity": "sha512-eQNnGUMyatgEeFJ8iKI2DT7wXDEjbWmZ+hJpCZtfg1bVsD4JdprIhLqdrUqmrDgPG2r45sQYigO9oq8BWXO37w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
@@ -3410,9 +3423,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.46.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.46.2.tgz",
|
||||
"integrity": "sha512-Zj3Hl6sN34xJtMv7Anwb5Gu01yujyE/cLBDB2gnHTAHaWS1Z38L7kuSG+oAh0giZMqG060f/YBStXtMH6FvPMA==",
|
||||
"version": "4.46.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.46.3.tgz",
|
||||
"integrity": "sha512-UmTdvXnLlqQNOCJnyksjPs1G4GqXNGW1LrzCe8+8QoaLhhDeTXYBgJ3k6x61WIhlHX2U+VzEJ55TtIjR/HTySA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -3423,9 +3436,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.46.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.46.2.tgz",
|
||||
"integrity": "sha512-nTeCWY83kN64oQ5MGz3CgtPx8NSOhC5lWtsjTs+8JAJNLcP3QbLCtDDgUKQc/Ro/frpMq4SHUaHN6AMltcEoLQ==",
|
||||
"version": "4.46.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.46.3.tgz",
|
||||
"integrity": "sha512-8NoxqLpXm7VyeI0ocidh335D6OKT0UJ6fHdnIxf3+6oOerZZc+O7r+UhvROji6OspyPm+rrIdb1gTXtVIqn+Sg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -3436,9 +3449,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.46.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.46.2.tgz",
|
||||
"integrity": "sha512-HV7bW2Fb/F5KPdM/9bApunQh68YVDU8sO8BvcW9OngQVN3HHHkw99wFupuUJfGR9pYLLAjcAOA6iO+evsbBaPQ==",
|
||||
"version": "4.46.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.46.3.tgz",
|
||||
"integrity": "sha512-csnNavqZVs1+7/hUKtgjMECsNG2cdB8F7XBHP6FfQjqhjF8rzMzb3SLyy/1BG7YSfQ+bG75Ph7DyedbUqwq1rA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -3449,9 +3462,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.46.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.46.2.tgz",
|
||||
"integrity": "sha512-SSj8TlYV5nJixSsm/y3QXfhspSiLYP11zpfwp6G/YDXctf3Xkdnk4woJIF5VQe0of2OjzTt8EsxnJDCdHd2xMA==",
|
||||
"version": "4.46.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.46.3.tgz",
|
||||
"integrity": "sha512-r2MXNjbuYabSIX5yQqnT8SGSQ26XQc8fmp6UhlYJd95PZJkQD1u82fWP7HqvGUf33IsOC6qsiV+vcuD4SDP6iw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3462,9 +3475,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.46.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.46.2.tgz",
|
||||
"integrity": "sha512-ZyrsG4TIT9xnOlLsSSi9w/X29tCbK1yegE49RYm3tu3wF1L/B6LVMqnEWyDB26d9Ecx9zrmXCiPmIabVuLmNSg==",
|
||||
"version": "4.46.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.46.3.tgz",
|
||||
"integrity": "sha512-uluObTmgPJDuJh9xqxyr7MV61Imq+0IvVsAlWyvxAaBSNzCcmZlhfYcRhCdMaCsy46ccZa7vtDDripgs9Jkqsw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -3475,9 +3488,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.46.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.46.2.tgz",
|
||||
"integrity": "sha512-pCgHFoOECwVCJ5GFq8+gR8SBKnMO+xe5UEqbemxBpCKYQddRQMgomv1104RnLSg7nNvgKy05sLsY51+OVRyiVw==",
|
||||
"version": "4.46.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.46.3.tgz",
|
||||
"integrity": "sha512-AVJXEq9RVHQnejdbFvh1eWEoobohUYN3nqJIPI4mNTMpsyYN01VvcAClxflyk2HIxvLpRcRggpX1m9hkXkpC/A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3488,9 +3501,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.46.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.46.2.tgz",
|
||||
"integrity": "sha512-EtP8aquZ0xQg0ETFcxUbU71MZlHaw9MChwrQzatiE8U/bvi5uv/oChExXC4mWhjiqK7azGJBqU0tt5H123SzVA==",
|
||||
"version": "4.46.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.46.3.tgz",
|
||||
"integrity": "sha512-byyflM+huiwHlKi7VHLAYTKr67X199+V+mt1iRgJenAI594vcmGGddWlu6eHujmcdl6TqSNnvqaXJqZdnEWRGA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -3501,9 +3514,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.46.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.46.2.tgz",
|
||||
"integrity": "sha512-qO7F7U3u1nfxYRPM8HqFtLd+raev2K137dsV08q/LRKRLEc7RsiDWihUnrINdsWQxPR9jqZ8DIIZ1zJJAm5PjQ==",
|
||||
"version": "4.46.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.46.3.tgz",
|
||||
"integrity": "sha512-aLm3NMIjr4Y9LklrH5cu7yybBqoVCdr4Nvnm8WB7PKCn34fMCGypVNpGK0JQWdPAzR/FnoEoFtlRqZbBBLhVoQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -3514,9 +3527,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.46.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.46.2.tgz",
|
||||
"integrity": "sha512-3dRaqLfcOXYsfvw5xMrxAk9Lb1f395gkoBYzSFcc/scgRFptRXL9DOaDpMiehf9CO8ZDRJW2z45b6fpU5nwjng==",
|
||||
"version": "4.46.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.46.3.tgz",
|
||||
"integrity": "sha512-VtilE6eznJRDIoFOzaagQodUksTEfLIsvXymS+UdJiSXrPW7Ai+WG4uapAc3F7Hgs791TwdGh4xyOzbuzIZrnw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -3527,9 +3540,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.46.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.46.2.tgz",
|
||||
"integrity": "sha512-fhHFTutA7SM+IrR6lIfiHskxmpmPTJUXpWIsBXpeEwNgZzZZSg/q4i6FU4J8qOGyJ0TR+wXBwx/L7Ho9z0+uDg==",
|
||||
"version": "4.46.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.46.3.tgz",
|
||||
"integrity": "sha512-dG3JuS6+cRAL0GQ925Vppafi0qwZnkHdPeuZIxIPXqkCLP02l7ka+OCyBoDEv8S+nKHxfjvjW4OZ7hTdHkx8/w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -3540,9 +3553,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loongarch64-gnu": {
|
||||
"version": "4.46.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.46.2.tgz",
|
||||
"integrity": "sha512-i7wfGFXu8x4+FRqPymzjD+Hyav8l95UIZ773j7J7zRYc3Xsxy2wIn4x+llpunexXe6laaO72iEjeeGyUFmjKeA==",
|
||||
"version": "4.46.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.46.3.tgz",
|
||||
"integrity": "sha512-iU8DxnxEKJptf8Vcx4XvAUdpkZfaz0KWfRrnIRrOndL0SvzEte+MTM7nDH4A2Now4FvTZ01yFAgj6TX/mZl8hQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -3553,9 +3566,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.46.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.46.2.tgz",
|
||||
"integrity": "sha512-B/l0dFcHVUnqcGZWKcWBSV2PF01YUt0Rvlurci5P+neqY/yMKchGU8ullZvIv5e8Y1C6wOn+U03mrDylP5q9Yw==",
|
||||
"version": "4.46.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.46.3.tgz",
|
||||
"integrity": "sha512-VrQZp9tkk0yozJoQvQcqlWiqaPnLM6uY1qPYXvukKePb0fqaiQtOdMJSxNFUZFsGw5oA5vvVokjHrx8a9Qsz2A==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -3566,9 +3579,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.46.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.46.2.tgz",
|
||||
"integrity": "sha512-32k4ENb5ygtkMwPMucAb8MtV8olkPT03oiTxJbgkJa7lJ7dZMr0GCFJlyvy+K8iq7F/iuOr41ZdUHaOiqyR3iQ==",
|
||||
"version": "4.46.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.46.3.tgz",
|
||||
"integrity": "sha512-uf2eucWSUb+M7b0poZ/08LsbcRgaDYL8NCGjUeFMwCWFwOuFcZ8D9ayPl25P3pl+D2FH45EbHdfyUesQ2Lt9wA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -3579,9 +3592,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.46.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.46.2.tgz",
|
||||
"integrity": "sha512-t5B2loThlFEauloaQkZg9gxV05BYeITLvLkWOkRXogP4qHXLkWSbSHKM9S6H1schf/0YGP/qNKtiISlxvfmmZw==",
|
||||
"version": "4.46.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.46.3.tgz",
|
||||
"integrity": "sha512-7tnUcDvN8DHm/9ra+/nF7lLzYHDeODKKKrh6JmZejbh1FnCNZS8zMkZY5J4sEipy2OW1d1Ncc4gNHUd0DLqkSg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -3592,9 +3605,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.46.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.46.2.tgz",
|
||||
"integrity": "sha512-YKjekwTEKgbB7n17gmODSmJVUIvj8CX7q5442/CK80L8nqOUbMtf8b01QkG3jOqyr1rotrAnW6B/qiHwfcuWQA==",
|
||||
"version": "4.46.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.46.3.tgz",
|
||||
"integrity": "sha512-MUpAOallJim8CsJK+4Lc9tQzlfPbHxWDrGXZm2z6biaadNpvh3a5ewcdat478W+tXDoUiHwErX/dOql7ETcLqg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -3605,9 +3618,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.46.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.46.2.tgz",
|
||||
"integrity": "sha512-Jj5a9RUoe5ra+MEyERkDKLwTXVu6s3aACP51nkfnK9wJTraCC8IMe3snOfALkrjTYd2G1ViE1hICj0fZ7ALBPA==",
|
||||
"version": "4.46.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.46.3.tgz",
|
||||
"integrity": "sha512-F42IgZI4JicE2vM2PWCe0N5mR5vR0gIdORPqhGQ32/u1S1v3kLtbZ0C/mi9FFk7C5T0PgdeyWEPajPjaUpyoKg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3618,9 +3631,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.46.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.46.2.tgz",
|
||||
"integrity": "sha512-7kX69DIrBeD7yNp4A5b81izs8BqoZkCIaxQaOpumcJ1S/kmqNFjPhDu1LHeVXv0SexfHQv5cqHsxLOjETuqDuA==",
|
||||
"version": "4.46.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.46.3.tgz",
|
||||
"integrity": "sha512-oLc+JrwwvbimJUInzx56Q3ujL3Kkhxehg7O1gWAYzm8hImCd5ld1F2Gry5YDjR21MNb5WCKhC9hXgU7rRlyegQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3631,9 +3644,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.46.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.46.2.tgz",
|
||||
"integrity": "sha512-wiJWMIpeaak/jsbaq2HMh/rzZxHVW1rU6coyeNNpMwk5isiPjSTx0a4YLSlYDwBH/WBvLz+EtsNqQScZTLJy3g==",
|
||||
"version": "4.46.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.46.3.tgz",
|
||||
"integrity": "sha512-lOrQ+BVRstruD1fkWg9yjmumhowR0oLAAzavB7yFSaGltY8klttmZtCLvOXCmGE9mLIn8IBV/IFrQOWz5xbFPg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -3644,9 +3657,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.46.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.46.2.tgz",
|
||||
"integrity": "sha512-gBgaUDESVzMgWZhcyjfs9QFK16D8K6QZpwAaVNJxYDLHWayOta4ZMjGm/vsAEy3hvlS2GosVFlBlP9/Wb85DqQ==",
|
||||
"version": "4.46.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.46.3.tgz",
|
||||
"integrity": "sha512-vvrVKPRS4GduGR7VMH8EylCBqsDcw6U+/0nPDuIjXQRbHJc6xOBj+frx8ksfZAh6+Fptw5wHrN7etlMmQnPQVg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -3657,9 +3670,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.46.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.46.2.tgz",
|
||||
"integrity": "sha512-CvUo2ixeIQGtF6WvuB87XWqPQkoFAFqW+HUo/WzHwuHDvIwZCtjdWXoYCcr06iKGydiqTclC4jU/TNObC/xKZg==",
|
||||
"version": "4.46.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.46.3.tgz",
|
||||
"integrity": "sha512-fi3cPxCnu3ZeM3EwKZPgXbWoGzm2XHgB/WShKI81uj8wG0+laobmqy5wbgEwzstlbLu4MyO8C19FyhhWseYKNQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3699,6 +3712,12 @@
|
||||
"integrity": "sha512-0dxmVj4gxg3Jg879kvFS/msl4s9F3T9UXC1InxgOf7t5NvcPD97u/WTA5vL/IxWHMn7qSxBozqrnnE2wvl1m8g==",
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/@stomp/stompjs": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@stomp/stompjs/-/stompjs-7.1.1.tgz",
|
||||
"integrity": "sha512-chcDs6YkAnKp1FqzwhGvh3i7v0+/ytzqWdKYw6XzINEKAzke/iD00dNgFPWSZEqktHOK+C1gSzXhLkLbARIaZw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.0.tgz",
|
||||
@@ -3723,9 +3742,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.2.1.tgz",
|
||||
"integrity": "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ==",
|
||||
"version": "24.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz",
|
||||
"integrity": "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -3767,13 +3786,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.39.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.39.1.tgz",
|
||||
"integrity": "sha512-8fZxek3ONTwBu9ptw5nCKqZOSkXshZB7uAxuFF0J/wTMkKydjXCzqqga7MlFMpHi9DoG4BadhmTkITBcg8Aybw==",
|
||||
"version": "8.40.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.40.0.tgz",
|
||||
"integrity": "sha512-/A89vz7Wf5DEXsGVvcGdYKbVM9F7DyFXj52lNYUDS1L9yJfqjW/fIp5PgMuEJL/KeqVTe2QSbXAGUZljDUpArw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.39.1",
|
||||
"@typescript-eslint/types": "^8.39.1",
|
||||
"@typescript-eslint/tsconfig-utils": "^8.40.0",
|
||||
"@typescript-eslint/types": "^8.40.0",
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"engines": {
|
||||
@@ -3788,9 +3807,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.39.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.39.1.tgz",
|
||||
"integrity": "sha512-ePUPGVtTMR8XMU2Hee8kD0Pu4NDE1CN9Q1sxGSGd/mbOtGZDM7pnhXNJnzW63zk/q+Z54zVzj44HtwXln5CvHA==",
|
||||
"version": "8.40.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.40.0.tgz",
|
||||
"integrity": "sha512-jtMytmUaG9d/9kqSl/W3E3xaWESo4hFDxAIHGVW/WKKtQhesnRIJSAJO6XckluuJ6KDB5woD1EiqknriCtAmcw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -3804,9 +3823,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/types": {
|
||||
"version": "8.39.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.39.1.tgz",
|
||||
"integrity": "sha512-7sPDKQQp+S11laqTrhHqeAbsCfMkwJMrV7oTDvtDds4mEofJYir414bYKUEb8YPUm9QL3U+8f6L6YExSoAGdQw==",
|
||||
"version": "8.40.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.40.0.tgz",
|
||||
"integrity": "sha512-ETdbFlgbAmXHyFPwqUIYrfc12ArvpBhEVgGAxVYSwli26dn8Ko+lIo4Su9vI9ykTZdJn+vJprs/0eZU0YMAEQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -3817,15 +3836,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.39.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.39.1.tgz",
|
||||
"integrity": "sha512-EKkpcPuIux48dddVDXyQBlKdeTPMmALqBUbEk38McWv0qVEZwOpVJBi7ugK5qVNgeuYjGNQxrrnoM/5+TI/BPw==",
|
||||
"version": "8.40.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.40.0.tgz",
|
||||
"integrity": "sha512-k1z9+GJReVVOkc1WfVKs1vBrR5MIKKbdAjDTPvIK3L8De6KbFfPFt6BKpdkdk7rZS2GtC/m6yI5MYX+UsuvVYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.39.1",
|
||||
"@typescript-eslint/tsconfig-utils": "8.39.1",
|
||||
"@typescript-eslint/types": "8.39.1",
|
||||
"@typescript-eslint/visitor-keys": "8.39.1",
|
||||
"@typescript-eslint/project-service": "8.40.0",
|
||||
"@typescript-eslint/tsconfig-utils": "8.40.0",
|
||||
"@typescript-eslint/types": "8.40.0",
|
||||
"@typescript-eslint/visitor-keys": "8.40.0",
|
||||
"debug": "^4.3.4",
|
||||
"fast-glob": "^3.3.2",
|
||||
"is-glob": "^4.0.3",
|
||||
@@ -3845,12 +3864,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.39.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.39.1.tgz",
|
||||
"integrity": "sha512-W8FQi6kEh2e8zVhQ0eeRnxdvIoOkAp/CPAahcNio6nO9dsIwb9b34z90KOlheoyuVf6LSOEdjlkxSkapNEc+4A==",
|
||||
"version": "8.40.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.40.0.tgz",
|
||||
"integrity": "sha512-8CZ47QwalyRjsypfwnbI3hKy5gJDPmrkLjkgMxhi0+DZZ2QNx2naS6/hWoVYUHU7LU2zleF68V9miaVZvhFfTA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.39.1",
|
||||
"@typescript-eslint/types": "8.40.0",
|
||||
"eslint-visitor-keys": "^4.2.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -4379,15 +4398,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/alien-signals": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-2.0.6.tgz",
|
||||
"integrity": "sha512-P3TxJSe31bUHBiblg59oU1PpaWPtmxF9GhJ/cB7OkgJ0qN/ifFSKUI25/v8ZhsT+lIG6ac8DpTOplXxORX6F3Q==",
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-2.0.7.tgz",
|
||||
"integrity": "sha512-wE7y3jmYeb0+h6mr5BOovuqhFv22O/MV9j5p0ndJsa7z1zJNPGQ4ph5pQk/kTTCWRC3xsA4SmtwmkzQO+7NCNg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
|
||||
"integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==",
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.0.tgz",
|
||||
"integrity": "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -4522,13 +4541,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ast-walker-scope": {
|
||||
"version": "0.8.1",
|
||||
"resolved": "https://registry.npmjs.org/ast-walker-scope/-/ast-walker-scope-0.8.1.tgz",
|
||||
"integrity": "sha512-72XOdbzQCMKERvFrxAykatn2pu7osPNq/sNUzwcHdWzwPvOsNpPqkawfDXVvQbA2RT+ivtsMNjYdojTUZitt1A==",
|
||||
"version": "0.8.2",
|
||||
"resolved": "https://registry.npmjs.org/ast-walker-scope/-/ast-walker-scope-0.8.2.tgz",
|
||||
"integrity": "sha512-3pYeLyDZ6nJew9QeBhS4Nly02269Dkdk32+zdbbKmL6n4ZuaGorwwA+xx12xgOciA8BF1w9x+dlH7oUkFTW91w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.27.2",
|
||||
"ast-kit": "^2.0.0"
|
||||
"@babel/parser": "^7.28.3",
|
||||
"ast-kit": "^2.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.18.0"
|
||||
@@ -4671,9 +4690,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.25.2",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.2.tgz",
|
||||
"integrity": "sha512-0si2SJK3ooGzIawRu61ZdPCO1IncZwS8IzuX73sPZsXW6EQ/w/DAfPyKI8l1ETTCr2MnvqWitmlCUxgdul45jA==",
|
||||
"version": "4.25.3",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.3.tgz",
|
||||
"integrity": "sha512-cDGv1kkDI4/0e5yON9yM5G/0A5u8sf5TnmdX5C9qHzI9PPu++sQ9zjm1k9NiOrf3riY4OkK0zSGqfvJyJsgCBQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -4690,8 +4709,8 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"caniuse-lite": "^1.0.30001733",
|
||||
"electron-to-chromium": "^1.5.199",
|
||||
"caniuse-lite": "^1.0.30001735",
|
||||
"electron-to-chromium": "^1.5.204",
|
||||
"node-releases": "^2.0.19",
|
||||
"update-browserslist-db": "^1.1.3"
|
||||
},
|
||||
@@ -5946,9 +5965,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.202",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.202.tgz",
|
||||
"integrity": "sha512-NxbYjRmiHcHXV1Ws3fWUW+SLb62isauajk45LUJ/HgIOkUA7jLZu/X2Iif+X9FBNK8QkF9Zb4Q2mcwXCcY30mg==",
|
||||
"version": "1.5.206",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.206.tgz",
|
||||
"integrity": "sha512-/eucXSTaI8L78l42xPurxdBzPTjAkMVCQO7unZCWk9LnZiwKcSvQUhF4c99NWQLwMQXxjlfoQy0+8m9U2yEDQQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
@@ -6234,6 +6253,15 @@
|
||||
"node": ">=0.8.x"
|
||||
}
|
||||
},
|
||||
"node_modules/eventsource": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz",
|
||||
"integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/execa": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz",
|
||||
@@ -6338,6 +6366,18 @@
|
||||
"reusify": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/faye-websocket": {
|
||||
"version": "0.11.4",
|
||||
"resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz",
|
||||
"integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"websocket-driver": ">=0.5.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fd-slicer": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
|
||||
@@ -6884,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",
|
||||
@@ -6909,6 +6968,12 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/http-parser-js": {
|
||||
"version": "0.5.10",
|
||||
"resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz",
|
||||
"integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/http-shutdown": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/http-shutdown/-/http-shutdown-1.2.2.tgz",
|
||||
@@ -7215,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",
|
||||
@@ -8829,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",
|
||||
@@ -9574,9 +9654,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/quansync": {
|
||||
"version": "0.2.10",
|
||||
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.10.tgz",
|
||||
"integrity": "sha512-t41VRkMYbkHyCYmOvx/6URnN80H7k4X0lLdBMGsz+maAwrJQYB1djpV6vHrQIBE0WBSGqhtEHrK9U3DWWH8v7A==",
|
||||
"version": "0.2.11",
|
||||
"resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz",
|
||||
"integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -9589,6 +9669,12 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/querystringify": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
|
||||
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
@@ -9786,6 +9872,12 @@
|
||||
"integrity": "sha512-uuoJ1hU/k6M0779t3VMVIYpb2VMJk05cehCaABFhXaibcbvfgR8wKiozLjVFSzJPmQMRqIcO0HMyTFqfV09V6Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/requires-port": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
||||
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "2.0.0-next.5",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz",
|
||||
@@ -9829,9 +9921,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.46.2",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.46.2.tgz",
|
||||
"integrity": "sha512-WMmLFI+Boh6xbop+OAGo9cQ3OgX9MIg7xOQjn+pTCwOkk+FNDAeAemXkJ3HzDJrVXleLOFVa1ipuc1AmEx1Dwg==",
|
||||
"version": "4.46.3",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.46.3.tgz",
|
||||
"integrity": "sha512-RZn2XTjXb8t5g13f5YclGoilU/kwT696DIkY3sywjdZidNSi3+vseaQov7D7BZXVJCPv3pDWUN69C78GGbXsKw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
@@ -9844,26 +9936,26 @@
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-android-arm-eabi": "4.46.2",
|
||||
"@rollup/rollup-android-arm64": "4.46.2",
|
||||
"@rollup/rollup-darwin-arm64": "4.46.2",
|
||||
"@rollup/rollup-darwin-x64": "4.46.2",
|
||||
"@rollup/rollup-freebsd-arm64": "4.46.2",
|
||||
"@rollup/rollup-freebsd-x64": "4.46.2",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.46.2",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.46.2",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.46.2",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.46.2",
|
||||
"@rollup/rollup-linux-loongarch64-gnu": "4.46.2",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.46.2",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.46.2",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.46.2",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.46.2",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.46.2",
|
||||
"@rollup/rollup-linux-x64-musl": "4.46.2",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.46.2",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.46.2",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.46.2",
|
||||
"@rollup/rollup-android-arm-eabi": "4.46.3",
|
||||
"@rollup/rollup-android-arm64": "4.46.3",
|
||||
"@rollup/rollup-darwin-arm64": "4.46.3",
|
||||
"@rollup/rollup-darwin-x64": "4.46.3",
|
||||
"@rollup/rollup-freebsd-arm64": "4.46.3",
|
||||
"@rollup/rollup-freebsd-x64": "4.46.3",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.46.3",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.46.3",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.46.3",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.46.3",
|
||||
"@rollup/rollup-linux-loongarch64-gnu": "4.46.3",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.46.3",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.46.3",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.46.3",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.46.3",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.46.3",
|
||||
"@rollup/rollup-linux-x64-musl": "4.46.3",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.46.3",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.46.3",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.46.3",
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
@@ -9961,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",
|
||||
@@ -10225,6 +10343,34 @@
|
||||
"integrity": "sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sockjs-client": {
|
||||
"version": "1.6.1",
|
||||
"resolved": "https://registry.npmjs.org/sockjs-client/-/sockjs-client-1.6.1.tgz",
|
||||
"integrity": "sha512-2g0tjOR+fRs0amxENLi/q5TiJTqY+WXFOzb5UwXndlK6TO3U/mirZznpx6w34HVMoc3g7cY24yC/ZMIYnDlfkw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^3.2.7",
|
||||
"eventsource": "^2.0.2",
|
||||
"faye-websocket": "^0.11.4",
|
||||
"inherits": "^2.0.4",
|
||||
"url-parse": "^1.5.10"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://tidelift.com/funding/github/npm/sockjs-client"
|
||||
}
|
||||
},
|
||||
"node_modules/sockjs-client/node_modules/debug": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
|
||||
"integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.7.6",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz",
|
||||
@@ -10516,9 +10662,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.1.0.tgz",
|
||||
"integrity": "sha512-GBuewsPrhJPftT+fqDa9oI/zc5HNsG9nREqwzoSFDOIqf0NggOZbHQj2TE1P1CDJK8ZogFnlZY9hWoUiur7I/A==",
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.0.tgz",
|
||||
"integrity": "sha512-5eG9FQjEjDbAlI5+kdpdyPIBMRH4GfTVDGREVupaZHmVoppknhM29b/S9BkQz7cathp85BVgRi/As3Siln7e0Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -10937,13 +11083,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/unplugin": {
|
||||
"version": "2.3.5",
|
||||
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.5.tgz",
|
||||
"integrity": "sha512-RyWSb5AHmGtjjNQ6gIlA67sHOsWpsbWpwDokLwTcejVdOjEkJZh7QKu14J00gDDVSh8kGH4KYC/TNBceXFZhtw==",
|
||||
"version": "2.3.6",
|
||||
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.6.tgz",
|
||||
"integrity": "sha512-+/MdXl8bLTXI2lJF22gUBeCFqZruEpL/oM9f8wxCuKh9+Mw9qeul3gTqgbKpMeOFlusCzc0s7x2Kax2xKW+FQg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"acorn": "^8.14.1",
|
||||
"picomatch": "^4.0.2",
|
||||
"@jridgewell/remapping": "^2.3.5",
|
||||
"acorn": "^8.15.0",
|
||||
"picomatch": "^4.0.3",
|
||||
"webpack-virtual-modules": "^0.6.2"
|
||||
},
|
||||
"engines": {
|
||||
@@ -11134,59 +11281,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/unwasm": {
|
||||
"version": "0.3.9",
|
||||
"resolved": "https://registry.npmjs.org/unwasm/-/unwasm-0.3.9.tgz",
|
||||
"integrity": "sha512-LDxTx/2DkFURUd+BU1vUsF/moj0JsoTvl+2tcg2AUOiEzVturhGGx17/IMgGvKUYdZwr33EJHtChCJuhu9Ouvg==",
|
||||
"version": "0.3.11",
|
||||
"resolved": "https://registry.npmjs.org/unwasm/-/unwasm-0.3.11.tgz",
|
||||
"integrity": "sha512-Vhp5gb1tusSQw5of/g3Q697srYgMXvwMgXMjcG4ZNga02fDX9coxJ9fAb0Ci38hM2Hv/U1FXRPGgjP2BYqhNoQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"knitwork": "^1.0.0",
|
||||
"magic-string": "^0.30.8",
|
||||
"mlly": "^1.6.1",
|
||||
"pathe": "^1.1.2",
|
||||
"pkg-types": "^1.0.3",
|
||||
"unplugin": "^1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unwasm/node_modules/confbox": {
|
||||
"version": "0.1.8",
|
||||
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz",
|
||||
"integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unwasm/node_modules/pathe": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
|
||||
"integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unwasm/node_modules/pkg-types": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz",
|
||||
"integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"confbox": "^0.1.8",
|
||||
"knitwork": "^1.2.0",
|
||||
"magic-string": "^0.30.17",
|
||||
"mlly": "^1.7.4",
|
||||
"pathe": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/unwasm/node_modules/pkg-types/node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unwasm/node_modules/unplugin": {
|
||||
"version": "1.16.1",
|
||||
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-1.16.1.tgz",
|
||||
"integrity": "sha512-4/u/j4FrCKdi17jaxuJA0jClGxB1AvU2hw/IuayPc4ay1XGaJs/rbb4v5WKwAjNifjmXK9PIFyuPiaK8azyR9w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"acorn": "^8.14.0",
|
||||
"webpack-virtual-modules": "^0.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
"pathe": "^2.0.3",
|
||||
"pkg-types": "^2.2.0",
|
||||
"unplugin": "^2.3.6"
|
||||
}
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
@@ -11225,6 +11330,16 @@
|
||||
"integrity": "sha512-MJu7ypHq6QasgF5YRTjqscSzQp/W11zoUk6kvmlH+fmWEs63Y0Eib13hYFwAzagRJcVY8WVnlV+eBDUGMJ5IbA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/url-parse": {
|
||||
"version": "1.5.10",
|
||||
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
|
||||
"integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"querystringify": "^2.1.1",
|
||||
"requires-port": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/urlpattern-polyfill": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-8.0.2.tgz",
|
||||
@@ -11273,13 +11388,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.2.tgz",
|
||||
"integrity": "sha512-J0SQBPlQiEXAF7tajiH+rUooJPo0l8KQgyg4/aMunNtrOa7bwuZJsJbDWzeljqQpgftxuq5yNJxQ91O9ts29UQ==",
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.3.tgz",
|
||||
"integrity": "sha512-OOUi5zjkDxYrKhTV3V7iKsoS37VUM7v40+HuwEmcrsf11Cdx9y3DIr2Px6liIcZFwt3XSRpQvFpL3WVy7ApkGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.6",
|
||||
"fdir": "^6.5.0",
|
||||
"picomatch": "^4.0.3",
|
||||
"postcss": "^8.5.6",
|
||||
"rollup": "^4.43.0",
|
||||
@@ -11737,6 +11852,29 @@
|
||||
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/websocket-driver": {
|
||||
"version": "0.7.4",
|
||||
"resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz",
|
||||
"integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"http-parser-js": ">=0.5.1",
|
||||
"safe-buffer": ">=5.1.0",
|
||||
"websocket-extensions": ">=0.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/websocket-extensions": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz",
|
||||
"integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
|
||||
@@ -9,18 +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"
|
||||
"vue-flatpickr-component": "^12.0.0",
|
||||
"vue-toastification": "^2.0.0-rc.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +1,29 @@
|
||||
<template>
|
||||
<div class="about-page">
|
||||
<div class="about-tabs">
|
||||
<div
|
||||
v-for="tab in tabs"
|
||||
:key="tab.name"
|
||||
:class="['about-tabs-item', { selected: selectedTab === tab.name }]"
|
||||
@click="selectTab(tab.name)"
|
||||
>
|
||||
<div class="about-tabs-item-label">{{ tab.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="about-loading" v-if="isFetching">
|
||||
<l-hatch-spinner size="100" stroke="10" speed="1" color="var(--primary-color)" />
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="about-content"
|
||||
v-html="renderMarkdown(content)"
|
||||
@click="handleContentClick"
|
||||
></div>
|
||||
<BaseTabs v-model="selectedTab" :tabs="tabs" class="about-tabs">
|
||||
<template #default>
|
||||
<div class="about-loading" v-if="isFetching">
|
||||
<l-hatch-spinner size="100" stroke="10" speed="1" color="var(--primary-color)" />
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="about-content"
|
||||
v-html="renderMarkdown(content)"
|
||||
@click="handleContentClick"
|
||||
></div>
|
||||
</template>
|
||||
</BaseTabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
import { handleMarkdownClick, renderMarkdown } from '~/utils/markdown'
|
||||
import BaseTabs from '~/components/BaseTabs.vue'
|
||||
|
||||
export default {
|
||||
name: 'AboutPageView',
|
||||
components: { BaseTabs },
|
||||
setup() {
|
||||
const isFetching = ref(false)
|
||||
const tabs = [
|
||||
@@ -71,21 +67,20 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
const selectTab = (name) => {
|
||||
selectedTab.value = name
|
||||
const tab = tabs.find((t) => t.name === name)
|
||||
if (tab) loadContent(tab.file)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadContent(tabs[0].file)
|
||||
})
|
||||
watch(
|
||||
selectedTab,
|
||||
(name) => {
|
||||
const tab = tabs.find((t) => t.name === name)
|
||||
if (tab) loadContent(tab.file)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const handleContentClick = (e) => {
|
||||
handleMarkdownClick(e)
|
||||
}
|
||||
|
||||
return { tabs, selectedTab, content, renderMarkdown, selectTab, isFetching, handleContentClick }
|
||||
return { tabs, selectedTab, content, renderMarkdown, isFetching, handleContentClick }
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -98,29 +93,13 @@ export default {
|
||||
}
|
||||
|
||||
.about-tabs {
|
||||
position: sticky;
|
||||
top: calc(var(--header-height) + 1px);
|
||||
z-index: 200;
|
||||
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;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<div class="site-stats-page">
|
||||
<div v-if="isLoading" class="loading-message">
|
||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||
</div>
|
||||
<ClientOnly>
|
||||
<VChart
|
||||
v-if="dauOption"
|
||||
@@ -51,8 +54,10 @@ const dauOption = ref(null)
|
||||
const newUserOption = ref(null)
|
||||
const postOption = ref(null)
|
||||
const commentOption = ref(null)
|
||||
const isLoading = ref(false)
|
||||
|
||||
async function loadData() {
|
||||
isLoading.value = true
|
||||
const token = getToken()
|
||||
const headers = { Authorization: `Bearer ${token}` }
|
||||
|
||||
@@ -93,6 +98,7 @@ async function loadData() {
|
||||
const data = await commentRes.json()
|
||||
commentOption.value = toOption('每日回贴量', data)
|
||||
}
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
@@ -105,4 +111,11 @@ onMounted(loadData)
|
||||
background-color: var(--background-color);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.loading-message {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 400px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -26,7 +26,10 @@
|
||||
<div class="article-container">
|
||||
<template
|
||||
v-if="
|
||||
selectedTopic === '最新' || selectedTopic === '排行榜' || selectedTopic === '最新回复'
|
||||
selectedTopic === '最新' ||
|
||||
selectedTopic === '排行榜' ||
|
||||
selectedTopic === '最新回复' ||
|
||||
selectedTopic === '精选'
|
||||
"
|
||||
>
|
||||
<div class="article-header-container">
|
||||
@@ -122,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'
|
||||
@@ -152,17 +156,22 @@ const route = useRoute()
|
||||
const tagOptions = ref([])
|
||||
const categoryOptions = ref([])
|
||||
|
||||
const topics = ref(['最新回复', '最新', '排行榜' /*, '热门', '类别'*/])
|
||||
const topics = ref(['最新回复', '最新', '精选', '排行榜' /*, '热门', '类别'*/])
|
||||
const selectedTopicCookie = useCookie('homeTab')
|
||||
const selectedTopic = ref(
|
||||
selectedTopicCookie.value
|
||||
? selectedTopicCookie.value
|
||||
: route.query.view === 'ranking'
|
||||
? '排行榜'
|
||||
: route.query.view === 'latest'
|
||||
? '最新'
|
||||
: '最新回复',
|
||||
)
|
||||
|
||||
let defaultTopic = '最新回复'
|
||||
|
||||
if (selectedTopicCookie.value) {
|
||||
defaultTopic = selectedTopicCookie.value
|
||||
} else if (route.query.view === 'ranking') {
|
||||
defaultTopic = '排行榜'
|
||||
} else if (route.query.view === 'latest') {
|
||||
defaultTopic = '最新'
|
||||
} else if (route.query.view === 'featured') {
|
||||
defaultTopic = '精选'
|
||||
}
|
||||
const selectedTopic = ref(defaultTopic)
|
||||
|
||||
if (!selectedTopicCookie.value) selectedTopicCookie.value = selectedTopic.value
|
||||
const articles = ref([])
|
||||
const page = ref(0)
|
||||
@@ -236,6 +245,7 @@ const baseQuery = computed(() => ({
|
||||
const listApiPath = computed(() => {
|
||||
if (selectedTopic.value === '排行榜') return '/api/posts/ranking'
|
||||
if (selectedTopic.value === '最新回复') return '/api/posts/latest-reply'
|
||||
if (selectedTopic.value === '精选') return '/api/posts/featured'
|
||||
return '/api/posts'
|
||||
})
|
||||
const buildUrl = ({ pageNo }) => {
|
||||
@@ -338,7 +348,7 @@ watch([selectedCategory, selectedTags], () => {
|
||||
watch(selectedTopic, (val) => {
|
||||
loadOptions()
|
||||
selectedTopicCookie.value = val
|
||||
if (process.client) localStorage.setItem('homeTab', val)
|
||||
if (import.meta.client) localStorage.setItem('homeTab', val)
|
||||
})
|
||||
|
||||
/** 选项首屏加载:服务端执行一次;客户端兜底 **/
|
||||
@@ -440,7 +450,7 @@ const sanitizeDescription = (text) => stripMarkdown(text)
|
||||
}
|
||||
|
||||
.topic-item {
|
||||
padding: 2px 10px;
|
||||
padding: 6px 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -535,6 +545,7 @@ const sanitizeDescription = (text) => stripMarkdown(text)
|
||||
}
|
||||
|
||||
.article-item-description {
|
||||
max-width: 100%;
|
||||
margin-top: 10px;
|
||||
font-size: 14px;
|
||||
color: gray;
|
||||
@@ -642,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);
|
||||
@@ -699,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>
|
||||
|
||||
647
frontend_nuxt/pages/message-box/[id].vue
Normal file
647
frontend_nuxt/pages/message-box/[id].vue
Normal file
@@ -0,0 +1,647 @@
|
||||
<template>
|
||||
<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">
|
||||
<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">
|
||||
<div @click="loadMoreMessages" :disabled="loadingMore" class="load-more-button">
|
||||
{{ loadingMore ? '加载中...' : '查看更多消息' }}
|
||||
</div>
|
||||
</div>
|
||||
<BaseTimeline :items="messages" hover>
|
||||
<template #item="{ item }">
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
ref,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
nextTick,
|
||||
computed,
|
||||
watch,
|
||||
onActivated,
|
||||
onDeactivated,
|
||||
} from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { getToken, fetchCurrentUser } from '~/utils/auth'
|
||||
import { toast } from '~/main'
|
||||
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([])
|
||||
const participants = ref([])
|
||||
const loading = ref(true)
|
||||
const sending = ref(false)
|
||||
const error = ref(null)
|
||||
const conversationId = route.params.id
|
||||
const currentUser = ref(null)
|
||||
const messagesListEl = ref(null)
|
||||
let lastMessageEl = null
|
||||
const currentPage = ref(0)
|
||||
const totalPages = ref(0)
|
||||
const loadingMore = ref(false)
|
||||
let scrollInterval = null
|
||||
const conversationName = ref('')
|
||||
const isChannel = ref(false)
|
||||
const isFloatMode = computed(() => route.query.float !== undefined)
|
||||
const floatRoute = useState('messageFloatRoute')
|
||||
const replyTo = ref(null)
|
||||
|
||||
const hasMoreMessages = computed(() => currentPage.value < totalPages.value - 1)
|
||||
|
||||
const otherParticipant = computed(() => {
|
||||
if (isChannel.value || !currentUser.value || participants.value.length === 0) {
|
||||
return null
|
||||
}
|
||||
return participants.value.find((p) => p.id !== currentUser.value.id)
|
||||
})
|
||||
|
||||
function isSentByCurrentUser(message) {
|
||||
return message.sender.id === currentUser.value?.id
|
||||
}
|
||||
|
||||
function handleAvatarError(event) {
|
||||
event.target.src = '/default-avatar.svg'
|
||||
}
|
||||
|
||||
function setReply(message) {
|
||||
replyTo.value = message
|
||||
}
|
||||
|
||||
// No changes needed here, as renderMarkdown is now imported.
|
||||
// The old function is removed.
|
||||
|
||||
async function fetchMessages(page = 0) {
|
||||
if (page === 0) {
|
||||
loading.value = true
|
||||
messages.value = []
|
||||
} else {
|
||||
loadingMore.value = true
|
||||
}
|
||||
error.value = null
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
toast.error('请先登录')
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/api/messages/conversations/${conversationId}?page=${page}&size=20`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
},
|
||||
)
|
||||
if (!response.ok) throw new Error('无法加载消息')
|
||||
|
||||
const conversationData = await response.json()
|
||||
const pageData = conversationData.messages
|
||||
|
||||
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
|
||||
const newMessages = pageData.content.reverse().map((item) => ({
|
||||
...item,
|
||||
src: item.sender.avatar,
|
||||
iconClick: () => {
|
||||
openUser(item.sender.id)
|
||||
},
|
||||
}))
|
||||
|
||||
const list = messagesListEl.value
|
||||
const oldScrollHeight = list ? list.scrollHeight : 0
|
||||
|
||||
if (page === 0) {
|
||||
messages.value = newMessages
|
||||
} else {
|
||||
messages.value = [...newMessages, ...messages.value]
|
||||
}
|
||||
|
||||
currentPage.value = pageData.number
|
||||
totalPages.value = pageData.totalPages
|
||||
|
||||
// Scrolling is now fully handled by the watcher
|
||||
await nextTick()
|
||||
if (page > 0 && list) {
|
||||
const newScrollHeight = list.scrollHeight
|
||||
list.scrollTop = newScrollHeight - oldScrollHeight
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
toast.error(e.message)
|
||||
} finally {
|
||||
loading.value = false
|
||||
loadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMoreMessages() {
|
||||
if (hasMoreMessages.value && !loadingMore.value) {
|
||||
await fetchMessages(currentPage.value + 1)
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMessage(content, clearInput) {
|
||||
if (!content.trim()) return
|
||||
sending.value = true
|
||||
const token = getToken()
|
||||
try {
|
||||
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()
|
||||
messages.value.push({
|
||||
...newMessage,
|
||||
src: newMessage.sender.avatar,
|
||||
iconClick: () => {
|
||||
openUser(newMessage.sender.id)
|
||||
},
|
||||
})
|
||||
clearInput()
|
||||
replyTo.value = null
|
||||
setTimeout(() => {
|
||||
scrollToBottom()
|
||||
}, 100)
|
||||
} catch (e) {
|
||||
toast.error(e.message)
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function markConversationAsRead() {
|
||||
const token = getToken()
|
||||
if (!token) return
|
||||
try {
|
||||
await fetch(`${API_BASE_URL}/api/messages/conversations/${conversationId}/read`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
// After marking as read, refresh the global unread count
|
||||
refreshGlobalUnreadCount()
|
||||
refreshChannelUnread()
|
||||
} catch (e) {
|
||||
console.error('Failed to mark conversation as read', e)
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
if (messagesListEl.value) {
|
||||
const element = messagesListEl.value
|
||||
// 強制滾動到底部,使用 smooth 行為確保視覺效果
|
||||
element.scrollTop = element.scrollHeight
|
||||
|
||||
// 再次確認滾動位置
|
||||
setTimeout(() => {
|
||||
if (element.scrollTop < element.scrollHeight - element.clientHeight) {
|
||||
element.scrollTop = element.scrollHeight
|
||||
}
|
||||
}, 50)
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
messages,
|
||||
async (newMessages) => {
|
||||
if (newMessages.length === 0) return
|
||||
|
||||
await nextTick()
|
||||
|
||||
// Simple, reliable scroll to bottom
|
||||
setTimeout(() => {
|
||||
scrollToBottom()
|
||||
}, 100)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
currentUser.value = await fetchCurrentUser()
|
||||
if (currentUser.value) {
|
||||
await fetchMessages(0)
|
||||
await markConversationAsRead()
|
||||
const token = getToken()
|
||||
if (token && !isConnected.value) {
|
||||
connect(token)
|
||||
}
|
||||
} else {
|
||||
toast.error('请先登录')
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
watch(isConnected, (newValue) => {
|
||||
if (newValue) {
|
||||
// 等待一小段时间确保连接稳定
|
||||
setTimeout(() => {
|
||||
subscription = subscribe(`/topic/conversation/${conversationId}`, (message) => {
|
||||
// 避免重复显示当前用户发送的消息
|
||||
if (message.sender.id !== currentUser.value.id) {
|
||||
messages.value.push({
|
||||
...message,
|
||||
src: message.sender.avatar,
|
||||
iconClick: () => {
|
||||
openUser(message.sender.id)
|
||||
},
|
||||
})
|
||||
// 实时收到消息时自动标记为已读
|
||||
markConversationAsRead()
|
||||
setTimeout(() => {
|
||||
scrollToBottom()
|
||||
}, 100)
|
||||
}
|
||||
})
|
||||
}, 500)
|
||||
}
|
||||
})
|
||||
|
||||
onActivated(async () => {
|
||||
// This will be called every time the component is activated (navigated to)
|
||||
if (currentUser.value) {
|
||||
await fetchMessages(0)
|
||||
await markConversationAsRead()
|
||||
|
||||
// 確保滾動到底部 - 使用多重延遲策略
|
||||
await nextTick()
|
||||
setTimeout(() => {
|
||||
scrollToBottom()
|
||||
}, 100)
|
||||
setTimeout(() => {
|
||||
scrollToBottom()
|
||||
}, 300)
|
||||
setTimeout(() => {
|
||||
scrollToBottom()
|
||||
}, 500)
|
||||
|
||||
if (!isConnected.value) {
|
||||
const token = getToken()
|
||||
if (token) connect(token)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onDeactivated(() => {
|
||||
if (subscription) {
|
||||
subscription.unsubscribe()
|
||||
subscription = null
|
||||
}
|
||||
disconnect()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (subscription) {
|
||||
subscription.unsubscribe()
|
||||
subscription = null
|
||||
}
|
||||
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>
|
||||
.chat-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0 auto;
|
||||
overflow: auto;
|
||||
height: calc(100vh - var(--header-height));
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chat-container.float {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
align-items: center;
|
||||
padding: 10px 20px;
|
||||
border-bottom: 1px solid var(--normal-border-color);
|
||||
background-color: var(--background-color-blur);
|
||||
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);
|
||||
margin-right: 15px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.participant-name {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.messages-list {
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
padding-bottom: 100px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.load-more-container {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.load-more-button {
|
||||
color: var(--primary-color);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.load-more-button:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.message-item {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.message-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.message-timestamp {
|
||||
font-size: 11px;
|
||||
color: var(--text-color-secondary);
|
||||
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;
|
||||
}
|
||||
|
||||
.message-item.sent .message-timestamp {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Received messages */
|
||||
.message-item.received {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.message-item.received .message-content {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.message-item.received .message-bubble {
|
||||
background-color: var(--bg-color-soft);
|
||||
border: 1px solid var(--border-color);
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
.message-input-area {
|
||||
margin-left: 20px;
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
542
frontend_nuxt/pages/message-box/index.vue
Normal file
542
frontend_nuxt/pages/message-box/index.vue
Normal file
@@ -0,0 +1,542 @@
|
||||
<template>
|
||||
<div class="messages-container">
|
||||
<div class="page-title">
|
||||
<i class="fas fa-comments"></i>
|
||||
<span class="page-title-text">选择聊天</span>
|
||||
</div>
|
||||
<div v-if="!isFloatMode" class="float-control">
|
||||
<i class="fas fa-compress" @click="minimize" title="最小化"></i>
|
||||
</div>
|
||||
<BaseTabs v-model="activeTab" :tabs="tabs">
|
||||
<template #default>
|
||||
<div v-if="activeTab === 'messages'">
|
||||
<div v-if="loading" class="loading-message">
|
||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||
</div>
|
||||
|
||||
<div 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 v-else>
|
||||
<div v-if="loadingChannels" class="loading-message">
|
||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-if="channels.length === 0" class="empty-container">
|
||||
<BasePlaceholder text="暂无频道" icon="fas fa-inbox" />
|
||||
</div>
|
||||
<div
|
||||
v-for="ch in channels"
|
||||
:key="ch.id"
|
||||
class="conversation-item"
|
||||
@click="goToChannel(ch.id)"
|
||||
>
|
||||
<div class="conversation-avatar">
|
||||
<img
|
||||
:src="ch.avatar || '/default-avatar.svg'"
|
||||
:alt="ch.name"
|
||||
class="avatar-img"
|
||||
@error="handleAvatarError"
|
||||
/>
|
||||
</div>
|
||||
<div class="conversation-content">
|
||||
<div class="conversation-header">
|
||||
<div class="participant-name">
|
||||
{{ ch.name }}
|
||||
<span v-if="ch.unreadCount > 0" class="unread-dot"></span>
|
||||
</div>
|
||||
<div class="message-time">
|
||||
{{ formatTime(ch.lastMessage?.createdAt || ch.createdAt) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="last-message-row">
|
||||
<div class="last-message">
|
||||
{{
|
||||
ch.lastMessage
|
||||
? stripMarkdownLength(ch.lastMessage.content, 100)
|
||||
: ch.description
|
||||
}}
|
||||
</div>
|
||||
<div class="member-count">成员 {{ ch.memberCount }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</BaseTabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onUnmounted, watch, onActivated, computed } from 'vue'
|
||||
import { 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 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',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
const data = await response.json()
|
||||
conversations.value = data
|
||||
} catch (e) {
|
||||
error.value = '无法加载会话列表。'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取对话中的另一个参与者(非当前用户)
|
||||
function getOtherParticipant(conversation) {
|
||||
if (!currentUser.value || !conversation.participants) return null
|
||||
return conversation.participants.find((p) => p.id !== currentUser.value.id)
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
function formatTime(timeString) {
|
||||
if (!timeString) return ''
|
||||
return TimeManager.format(timeString)
|
||||
}
|
||||
|
||||
// 头像加载失败处理
|
||||
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 () => {
|
||||
currentUser.value = await fetchCurrentUser()
|
||||
|
||||
if (currentUser.value) {
|
||||
if (activeTab.value === 'messages') {
|
||||
await fetchConversations()
|
||||
} else {
|
||||
await fetchChannels()
|
||||
}
|
||||
refreshGlobalUnreadCount()
|
||||
refreshChannelUnread()
|
||||
const token = getToken()
|
||||
if (token && !isConnected.value) {
|
||||
connect(token)
|
||||
}
|
||||
} else {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
watch(isConnected, (newValue) => {
|
||||
if (newValue && currentUser.value) {
|
||||
const destination = `/topic/user/${currentUser.value.id}/messages`
|
||||
|
||||
// 清理旧的订阅
|
||||
if (subscription) {
|
||||
subscription.unsubscribe()
|
||||
}
|
||||
|
||||
subscription = subscribe(destination, (message) => {
|
||||
fetchConversations()
|
||||
if (activeTab.value === 'channels') {
|
||||
fetchChannels()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
watch(activeTab, (val) => {
|
||||
if (val === 'messages') {
|
||||
fetchConversations()
|
||||
} else {
|
||||
fetchChannels()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (subscription) {
|
||||
subscription.unsubscribe()
|
||||
}
|
||||
disconnect()
|
||||
})
|
||||
|
||||
function goToConversation(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 {
|
||||
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 {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
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;
|
||||
color: #1a1a1a;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.loading-container,
|
||||
.error-container,
|
||||
.empty-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.loading-text,
|
||||
.error-text,
|
||||
.empty-text {
|
||||
font-size: 16px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: #e53e3e;
|
||||
}
|
||||
|
||||
.conversations-list {
|
||||
}
|
||||
|
||||
.conversation-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: 20px;
|
||||
margin-right: 20px;
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.conversation-item:hover {
|
||||
background-color: var(--menu-selected-background-color);
|
||||
}
|
||||
|
||||
.conversation-avatar {
|
||||
flex-shrink: 0;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.avatar-img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.conversation-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.conversation-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.participant-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.member-count {
|
||||
font-size: 12px;
|
||||
color: gray;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-size: 12px;
|
||||
color: gray;
|
||||
flex-shrink: 0;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.last-message-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.last-message {
|
||||
font-size: 14px;
|
||||
color: gray;
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex-grow: 1;
|
||||
padding-right: 10px; /* Add some space between message and badge */
|
||||
}
|
||||
|
||||
.unread-count-badge {
|
||||
background-color: #f56c6c;
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
line-height: 1.5;
|
||||
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) {
|
||||
.conversation-item {
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.messages-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.conversations-list {
|
||||
max-height: 500px;
|
||||
}
|
||||
|
||||
.conversation-item {
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.avatar-img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.participant-name {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.last-message {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user