Compare commits

..

1 Commits

Author SHA1 Message Date
Tim
901b3f344a Add invite code points activity 2025-08-17 11:37:21 +08:00
98 changed files with 840 additions and 3237 deletions

View File

@@ -1,116 +0,0 @@
#### **⚠️注意:仅想修改前端的朋友可不用部署后端服务**
## 如何部署
> 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
```
![CleanShot 2025-08-04 at 11.35.49.png](https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/4cf210cfc6ea478a80dfc744c85ccdc4.png)
- 设置jdk版本为java 17
![CleanShot 2025-08-04 at 11.38.03@2x.png](https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/392eeec753ae436ca12a78f750dfea2d.png)
- 本机配置MySQL服务网上很多教程忽略
- 设置环境变量.env 文件 或.properties 文件(二选一)
1. 环境变量文件生成
```shell
cp open-isle.env.example open-isle.env
```
修改环境变量留下需要的比如你要开发Google登录业务就需要谷歌相关的变量数据库是一定要的
![CleanShot 2025-08-04 at 11.41.36@2x.png](https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/896c8363b6e64ea19d18c12ec4dae2b4.png)
应用环境文件, 选择刚刚的`open-isle.env`
![CleanShot 2025-08-04 at 11.44.41.png](https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/f588e37838014a6684c141605639b9fa.png)
2. 直接修改 .properities 文件
位置src/main/application.properties, 数据库需要修改标红处,其他按需修改
![CleanShot 2025-08-04 at 11.47.11@2x.png](https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/28c3104448a245419e0b06aee861abb4.png)
处理完环境问题直接跑起来就能通了
![CleanShot 2025-08-04 at 11.49.01@2x.png](https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/2c945eae44b1477db09e80fc96b5e02d.png)
> 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 即可访问前端页面

View File

@@ -1,18 +1,45 @@
<p align="center">
<img alt="OpenIsle" src="https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png" width="200">
<br>
<br><br>
高效的开源社区前后端端平台
<br><br><br>
<img alt="Image" src="https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/22752cfac5a04a9c90c41995b9f55fed.png" width="1200">
<br><br>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square"></a>
</p>
## 💡 简介
OpenIsle 是一个使用 Spring Boot 和 Vue 3 构建的全栈开源社区平台,提供用户注册、登录、贴文发布、评论交互等完整功能,可用于项目社区或直接打造自主社区站点。
## 🚧 开发 & 部署
## 🚧 开发
详细见 [Contributing](https://github.com/nagisa77/OpenIsle?tab=contributing-ov-file)
### 后端
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` 目录生成文件,配合线上网站方式部署
## ✨ 项目特点

View File

@@ -3,12 +3,6 @@ MYSQL_URL=jdbc:mysql://<数据库地址>:<端口>/<数据库名>?useUnicode=yes&
MYSQL_USER=<数据库用户名>
MYSQL_PASSWORD=<数据库密码>
# === JWT ===
JWT_SECRET=<jwt secret>
JWT_REASON_SECRET=<jwt reason secret>
JWT_RESET_SECRET=<jwt reset secret>
JWT_INVITE_SECRET=<jwt invite secret>
JWT_EXPIRATION=2592000000
# === Resend ===
RESEND_API_KEY=<你的resend-api-key>
@@ -36,4 +30,4 @@ OPENAI_API_KEY=<你的openai-api-key>
WEBPUSH_PUBLIC_KEY=<你的webpush-public-key>
WEBPUSH_PRIVATE_KEY=<你的webpush-private-key>
# LOG_LEVEL=DEBUG
# LOG_LEVEL=DEBUG

View File

@@ -38,16 +38,6 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>com.vladsch.flexmark</groupId>
<artifactId>flexmark-all</artifactId>
<version>0.64.8</version>
</dependency>
<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
<version>1.17.2</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>

View File

@@ -6,7 +6,7 @@ import com.openisle.repository.ActivityRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Component
@@ -29,10 +29,9 @@ public class ActivityInitializer implements CommandLineRunner {
Activity a = new Activity();
a.setTitle("🎁邀请码送积分活动");
a.setType(ActivityType.INVITE_POINTS);
a.setIcon("https://img.icons8.com/color/96/gift.png");
a.setContent("使用邀请码注册或邀请好友可获得积分奖励,快来参与吧!");
a.setStartTime(LocalDateTime.now());
a.setEndTime(LocalDate.of(LocalDate.now().getYear(), 10, 1).atStartOfDay());
a.setIcon("https://icons.veryicon.com/png/o/commerce-shopping/two-color-icon-library/gift-30.png");
a.setContent("活动期间,邀请好友注册可获得积分奖励,快来参与吧!");
a.setEndTime(LocalDateTime.of(2025, 10, 1, 0, 0));
activityRepository.save(a);
}
}

View File

@@ -119,7 +119,6 @@ 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/rss").permitAll()
.requestMatchers(HttpMethod.GET, "/api/point-goods").permitAll()
.requestMatchers(HttpMethod.POST, "/api/point-goods").permitAll()
.requestMatchers(HttpMethod.POST, "/api/categories/**").hasAuthority("ADMIN")
@@ -155,8 +154,7 @@ public class SecurityConfig {
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/sitemap.xml") || uri.startsWith("/api/medals") ||
uri.startsWith("/api/rss"));
uri.startsWith("/api/sitemap.xml") || uri.startsWith("/api/medals"));
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);

View File

@@ -45,14 +45,4 @@ public class AdminPostController {
public PostSummaryDto unpin(@PathVariable Long id) {
return postMapper.toSummaryDto(postService.unpinPost(id));
}
@PostMapping("/{id}/rss-exclude")
public PostSummaryDto excludeFromRss(@PathVariable Long id) {
return postMapper.toSummaryDto(postService.excludeFromRss(id));
}
@PostMapping("/{id}/rss-include")
public PostSummaryDto includeInRss(@PathVariable Long id) {
return postMapper.toSummaryDto(postService.includeInRss(id));
}
}

View File

@@ -29,7 +29,6 @@ public class AuthController {
private final RegisterModeService registerModeService;
private final NotificationService notificationService;
private final UserRepository userRepository;
private final InviteService inviteService;
@Value("${app.captcha.enabled:false}")
@@ -46,27 +45,6 @@ public class AuthController {
if (captchaEnabled && registerCaptchaEnabled && !captchaService.verify(req.getCaptcha())) {
return ResponseEntity.badRequest().body(Map.of("error", "Invalid captcha"));
}
if (req.getInviteToken() != null && !req.getInviteToken().isEmpty()) {
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(), user.getUsername());
emailService.sendEmail(user.getEmail(), "在网站填写验证码以验证", "您的验证码是 " + user.getVerificationCode());
return ResponseEntity.ok(Map.of(
"token", jwtService.generateToken(user.getUsername()),
"reason_code", "INVITE_APPROVED"
));
} catch (FieldException e) {
return ResponseEntity.badRequest().body(Map.of(
"field", e.getField(),
"error", e.getMessage()
));
}
}
User user = userService.register(
req.getUsername(), req.getEmail(), req.getPassword(), "", registerModeService.getRegisterMode());
emailService.sendEmail(user.getEmail(), "在网站填写验证码以验证", "您的验证码是 " + user.getVerificationCode());
@@ -80,26 +58,10 @@ public class AuthController {
public ResponseEntity<?> verify(@RequestBody VerifyRequest req) {
boolean ok = userService.verifyCode(req.getUsername(), req.getCode());
if (ok) {
Optional<User> userOpt = userService.findByUsername(req.getUsername());
if (userOpt.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("error", "Invalid credentials"));
}
User user = userOpt.get();
if (user.isApproved()) {
return ResponseEntity.ok(Map.of(
"message", "Verified and isApproved",
"reason_code", "VERIFIED_AND_APPROVED",
"token", jwtService.generateToken(req.getUsername())
));
} else {
return ResponseEntity.ok(Map.of(
"message", "Verified",
"reason_code", "VERIFIED",
"token", jwtService.generateReasonToken(req.getUsername())
));
}
return ResponseEntity.ok(Map.of(
"message", "Verified",
"token", jwtService.generateReasonToken(req.getUsername())
));
}
return ResponseEntity.badRequest().body(Map.of("error", "Invalid verification code"));
}
@@ -144,43 +106,27 @@ public class AuthController {
@PostMapping("/google")
public ResponseEntity<?> loginWithGoogle(@RequestBody GoogleLoginRequest req) {
boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty();
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(
req.getIdToken(),
registerModeService.getRegisterMode(),
viaInvite);
if (resultOpt.isPresent()) {
AuthResult result = resultOpt.get();
if (viaInvite && result.isNewUser()) {
inviteService.consume(req.getInviteToken(), inviteValidateResult.getInviteToken().getInviter().getUsername());
return ResponseEntity.ok(Map.of(
"token", jwtService.generateToken(result.getUser().getUsername()),
"reason_code", "INVITE_APPROVED"
));
}
Optional<User> user = googleAuthService.authenticate(req.getIdToken(), registerModeService.getRegisterMode());
if (user.isPresent()) {
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
}
if (!result.getUser().isApproved()) {
if (result.getUser().getRegisterReason() != null && !result.getUser().getRegisterReason().isEmpty()) {
if (!user.get().isApproved()) {
if (user.get().getRegisterReason() != null && !user.get().getRegisterReason().isEmpty()) {
return ResponseEntity.badRequest().body(Map.of(
"error", "Account awaiting approval",
"reason_code", "IS_APPROVING",
"token", jwtService.generateReasonToken(result.getUser().getUsername())
"token", jwtService.generateReasonToken(user.get().getUsername())
));
}
return ResponseEntity.badRequest().body(Map.of(
"error", "Account awaiting approval",
"reason_code", "NOT_APPROVED",
"token", jwtService.generateReasonToken(result.getUser().getUsername())
"token", jwtService.generateReasonToken(user.get().getUsername())
));
}
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
}
return ResponseEntity.badRequest().body(Map.of(
"error", "Invalid google token",
@@ -219,45 +165,28 @@ public class AuthController {
@PostMapping("/github")
public ResponseEntity<?> loginWithGithub(@RequestBody GithubLoginRequest req) {
boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty();
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(
req.getCode(),
registerModeService.getRegisterMode(),
req.getRedirectUri(),
viaInvite);
if (resultOpt.isPresent()) {
AuthResult result = resultOpt.get();
if (viaInvite && result.isNewUser()) {
inviteService.consume(req.getInviteToken(), inviteValidateResult.getInviteToken().getInviter().getUsername());
return ResponseEntity.ok(Map.of(
"token", jwtService.generateToken(result.getUser().getUsername()),
"reason_code", "INVITE_APPROVED"
));
}
Optional<User> user = githubAuthService.authenticate(req.getCode(), registerModeService.getRegisterMode(), req.getRedirectUri());
if (user.isPresent()) {
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
}
if (!result.getUser().isApproved()) {
if (result.getUser().getRegisterReason() != null && !result.getUser().getRegisterReason().isEmpty()) {
if (!user.get().isApproved()) {
if (user.get().getRegisterReason() != null && !user.get().getRegisterReason().isEmpty()) {
// 已填写注册理由
return ResponseEntity.badRequest().body(Map.of(
"error", "Account awaiting approval",
"reason_code", "IS_APPROVING",
"token", jwtService.generateReasonToken(result.getUser().getUsername())
"token", jwtService.generateReasonToken(user.get().getUsername())
));
}
return ResponseEntity.badRequest().body(Map.of(
"error", "Account awaiting approval",
"reason_code", "NOT_APPROVED",
"token", jwtService.generateReasonToken(result.getUser().getUsername())
"token", jwtService.generateReasonToken(user.get().getUsername())
));
}
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
}
return ResponseEntity.badRequest().body(Map.of(
"error", "Invalid github code",
@@ -267,44 +196,27 @@ public class AuthController {
@PostMapping("/discord")
public ResponseEntity<?> loginWithDiscord(@RequestBody DiscordLoginRequest req) {
boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty();
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(
req.getCode(),
registerModeService.getRegisterMode(),
req.getRedirectUri(),
viaInvite);
if (resultOpt.isPresent()) {
AuthResult result = resultOpt.get();
if (viaInvite && result.isNewUser()) {
inviteService.consume(req.getInviteToken(), inviteValidateResult.getInviteToken().getInviter().getUsername());
return ResponseEntity.ok(Map.of(
"token", jwtService.generateToken(result.getUser().getUsername()),
"reason_code", "INVITE_APPROVED"
));
}
Optional<User> user = discordAuthService.authenticate(req.getCode(), registerModeService.getRegisterMode(), req.getRedirectUri());
if (user.isPresent()) {
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
}
if (!result.getUser().isApproved()) {
if (result.getUser().getRegisterReason() != null && !result.getUser().getRegisterReason().isEmpty()) {
if (!user.get().isApproved()) {
if (user.get().getRegisterReason() != null && !user.get().getRegisterReason().isEmpty()) {
return ResponseEntity.badRequest().body(Map.of(
"error", "Account awaiting approval",
"reason_code", "IS_APPROVING",
"token", jwtService.generateReasonToken(result.getUser().getUsername())
"token", jwtService.generateReasonToken(user.get().getUsername())
));
}
return ResponseEntity.badRequest().body(Map.of(
"error", "Account awaiting approval",
"reason_code", "NOT_APPROVED",
"token", jwtService.generateReasonToken(result.getUser().getUsername())
"token", jwtService.generateReasonToken(user.get().getUsername())
));
}
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
}
return ResponseEntity.badRequest().body(Map.of(
"error", "Invalid discord code",
@@ -314,45 +226,31 @@ public class AuthController {
@PostMapping("/twitter")
public ResponseEntity<?> loginWithTwitter(@RequestBody TwitterLoginRequest req) {
boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty();
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(
Optional<User> user = twitterAuthService.authenticate(
req.getCode(),
req.getCodeVerifier(),
registerModeService.getRegisterMode(),
req.getRedirectUri(),
viaInvite);
if (resultOpt.isPresent()) {
AuthResult result = resultOpt.get();
if (viaInvite && result.isNewUser()) {
inviteService.consume(req.getInviteToken(), inviteValidateResult.getInviteToken().getInviter().getUsername());
return ResponseEntity.ok(Map.of(
"token", jwtService.generateToken(result.getUser().getUsername()),
"reason_code", "INVITE_APPROVED"
));
}
req.getRedirectUri());
if (user.isPresent()) {
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
}
if (!result.getUser().isApproved()) {
if (result.getUser().getRegisterReason() != null && !result.getUser().getRegisterReason().isEmpty()) {
if (!user.get().isApproved()) {
if (user.get().getRegisterReason() != null && !user.get().getRegisterReason().isEmpty()) {
return ResponseEntity.badRequest().body(Map.of(
"error", "Account awaiting approval",
"reason_code", "IS_APPROVING",
"token", jwtService.generateReasonToken(result.getUser().getUsername())
"token", jwtService.generateReasonToken(user.get().getUsername())
));
}
return ResponseEntity.badRequest().body(Map.of(
"error", "Account awaiting approval",
"reason_code", "NOT_APPROVED",
"token", jwtService.generateReasonToken(result.getUser().getUsername())
"token", jwtService.generateReasonToken(user.get().getUsername())
));
}
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername())));
}
return ResponseEntity.badRequest().body(Map.of(
"error", "Invalid twitter code",

View File

@@ -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, comment.getId()));
dto.setPointReward(pointService.awardForComment(auth.getName(),postId));
log.debug("createComment succeeded for comment {}", comment.getId());
return ResponseEntity.ok(dto);
}

View File

@@ -1,23 +0,0 @@
package com.openisle.controller;
import com.openisle.service.InviteService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping("/api/invite")
@RequiredArgsConstructor
public class InviteController {
private final InviteService inviteService;
@PostMapping("/generate")
public Map<String, String> generate(Authentication auth) {
String token = inviteService.generate(auth.getName());
return Map.of("token", token);
}
}

View File

@@ -23,19 +23,9 @@ public class NotificationController {
private final NotificationMapper notificationMapper;
@GetMapping
public List<NotificationDto> list(@RequestParam(value = "page", defaultValue = "0") int page,
@RequestParam(value = "size", defaultValue = "30") int size,
public List<NotificationDto> list(@RequestParam(value = "read", required = false) Boolean read,
Authentication auth) {
return notificationService.listNotifications(auth.getName(), null, page, size).stream()
.map(notificationMapper::toDto)
.collect(Collectors.toList());
}
@GetMapping("/unread")
public List<NotificationDto> listUnread(@RequestParam(value = "page", defaultValue = "0") int page,
@RequestParam(value = "size", defaultValue = "30") int size,
Authentication auth) {
return notificationService.listNotifications(auth.getName(), false, page, size).stream()
return notificationService.listNotifications(auth.getName(), read).stream()
.map(notificationMapper::toDto)
.collect(Collectors.toList());
}

View File

@@ -1,28 +0,0 @@
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());
}
}

View File

@@ -45,7 +45,7 @@ public class PostController {
draftService.deleteDraft(auth.getName());
PostDetailDto dto = postMapper.toDetailDto(post, auth.getName());
dto.setReward(levelService.awardForPost(auth.getName()));
dto.setPointReward(pointService.awardForPost(auth.getName(), post.getId()));
dto.setPointReward(pointService.awardForPost(auth.getName()));
return ResponseEntity.ok(dto);
}
@@ -62,16 +62,6 @@ 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;
@@ -171,27 +161,4 @@ 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());
}
}

View File

@@ -1,352 +0,0 @@
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;
import org.jsoup.nodes.Element;
import org.jsoup.safety.Safelist;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import com.vladsch.flexmark.ext.autolink.AutolinkExtension;
import com.vladsch.flexmark.ext.tables.TablesExtension;
import com.vladsch.flexmark.ext.gfm.strikethrough.StrikethroughExtension;
import com.vladsch.flexmark.ext.gfm.tasklist.TaskListExtension;
import com.vladsch.flexmark.html.HtmlRenderer;
import com.vladsch.flexmark.parser.Parser;
import com.vladsch.flexmark.util.data.MutableDataSet;
import java.net.URI;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@RestController
@RequiredArgsConstructor
public class RssController {
private final PostService postService;
private final CommentService commentService;
@Value("${app.website-url:https://www.open-isle.com}")
private String websiteUrl;
// 兼容 Markdown/HTML 两类图片写法(用于 enclosure
private static final Pattern MD_IMAGE = Pattern.compile("!\\[[^\\]]*\\]\\(([^)]+)\\)");
private static final Pattern HTML_IMAGE = Pattern.compile("<img[^>]+src=[\"']?([^\"'>]+)[\"']?[^>]*>");
private static final DateTimeFormatter RFC1123 = DateTimeFormatter.RFC_1123_DATE_TIME;
// flexmarkMarkdown -> HTML
private static final Parser MD_PARSER;
private static final HtmlRenderer MD_RENDERER;
static {
MutableDataSet opts = new MutableDataSet();
opts.set(Parser.EXTENSIONS, Arrays.asList(
TablesExtension.create(),
AutolinkExtension.create(),
StrikethroughExtension.create(),
TaskListExtension.create()
));
// 允许内联 HTML下游再做 sanitize
opts.set(Parser.HTML_BLOCK_PARSER, true);
MD_PARSER = Parser.builder(opts).build();
MD_RENDERER = HtmlRenderer.builder(opts).escapeHtml(false).build();
}
@GetMapping(value = "/api/rss", produces = "application/rss+xml;charset=UTF-8")
public String feed() {
// 建议 20你现在是 10这里保留你的 10
List<Post> posts = postService.listLatestRssPosts(10);
String base = trimTrailingSlash(websiteUrl);
StringBuilder sb = new StringBuilder(4096);
sb.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
sb.append("<rss version=\"2.0\" xmlns:content=\"http://purl.org/rss/1.0/modules/content/\">");
sb.append("<channel>");
elem(sb, "title", cdata("OpenIsle RSS"));
elem(sb, "link", base + "/");
elem(sb, "description", cdata("Latest posts"));
ZonedDateTime updated = posts.stream()
.map(p -> p.getCreatedAt().atZone(ZoneId.systemDefault()))
.max(Comparator.naturalOrder())
.orElse(ZonedDateTime.now());
// channel lastBuildDateGMT
elem(sb, "lastBuildDate", toRfc1123Gmt(updated));
for (Post p : posts) {
String link = base + "/posts/" + p.getId();
// 1) Markdown -> HTML
String html = renderMarkdown(p.getContent());
// 2) Sanitize白名单增强
String safeHtml = sanitizeHtml(html);
// 3) 绝对化 href/src + 强制 rel/target
String absHtml = absolutifyHtml(safeHtml, base);
// 4) 纯文本摘要(用于 <description>
String plain = textSummary(absHtml, 180);
// 5) enclosure首图已绝对化
String enclosure = firstImage(p.getContent());
if (enclosure == null) {
// 如果 Markdown 没有图,尝试从渲染后的 HTML 再抓一次
enclosure = firstImage(absHtml);
}
if (enclosure != null) {
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);
sb.append("<guid isPermaLink=\"true\">").append(link).append("</guid>");
elem(sb, "pubDate", toRfc1123Gmt(p.getCreatedAt().atZone(ZoneId.systemDefault())));
// 摘要
elem(sb, "description", cdata(plain));
// 全文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=\"")
.append(getMimeType(enclosure)).append("\" />");
}
sb.append("</item>");
}
sb.append("</channel></rss>");
return sb.toString();
}
/* ===================== Markdown → HTML ===================== */
private static String renderMarkdown(String md) {
if (md == null || md.isEmpty()) return "";
return MD_RENDERER.render(MD_PARSER.parse(md));
}
/* ===================== Sanitize & 绝对化 ===================== */
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",
"hr","blockquote"
)
.addAttributes("a", "href", "title", "target", "rel")
.addAttributes("img", "src", "alt", "title", "width", "height")
.addAttributes("source", "srcset", "type", "media")
.addAttributes("code", "class")
.addAttributes("pre", "class")
.addProtocols("a", "href", "http", "https", "mailto")
.addProtocols("img", "src", "http", "https", "data")
.addProtocols("source", "srcset", "http", "https");
// 清除所有 on* 事件、style避免阅读器环境差异
return Jsoup.clean(html, wl);
}
private static String absolutifyHtml(String html, String baseUrl) {
if (html == null || html.isEmpty()) return "";
Document doc = Jsoup.parseBodyFragment(html, baseUrl);
// a[href]
for (Element a : doc.select("a[href]")) {
String href = a.attr("href");
String abs = absolutifyUrl(href, baseUrl);
a.attr("href", abs);
// 强制外链安全属性
a.attr("rel", "noopener noreferrer nofollow");
a.attr("target", "_blank");
}
// img[src]
for (Element img : doc.select("img[src]")) {
String src = img.attr("src");
String abs = absolutifyUrl(src, baseUrl);
img.attr("src", abs);
}
// source[srcset] picture/webp
for (Element s : doc.select("source[srcset]")) {
String srcset = s.attr("srcset");
s.attr("srcset", absolutifySrcset(srcset, baseUrl));
}
return doc.body().html();
}
private static String absolutifyUrl(String url, String baseUrl) {
if (url == null || url.isEmpty()) return url;
String u = url.trim();
if (u.startsWith("//")) {
return "https:" + u;
}
if (u.startsWith("#")) {
// 保留页面内锚点:拼接到首页(也可拼接到当前帖子的 link但此处无上下文
return baseUrl + "/" + u;
}
try {
URI base = URI.create(ensureTrailingSlash(baseUrl));
URI abs = base.resolve(u);
return abs.toString();
} catch (Exception e) {
return url;
}
}
private static String absolutifySrcset(String srcset, String baseUrl) {
if (srcset == null || srcset.isEmpty()) return srcset;
String[] parts = srcset.split(",");
List<String> out = new ArrayList<>(parts.length);
for (String part : parts) {
String p = part.trim();
if (p.isEmpty()) continue;
String[] seg = p.split("\\s+");
String url = seg[0];
String size = seg.length > 1 ? seg[1] : "";
out.add(absolutifyUrl(url, baseUrl) + (size.isEmpty() ? "" : " " + size));
}
return String.join(", ", out);
}
/* ===================== 摘要 & enclosure ===================== */
private static String textSummary(String html, int maxLen) {
if (html == null) return "";
String text = Jsoup.parse(html).text().replaceAll("\\s+", " ").trim();
if (text.length() <= maxLen) return text;
return text.substring(0, maxLen) + "";
}
private String firstImage(String content) {
if (content == null) return null;
Matcher m = MD_IMAGE.matcher(content);
if (m.find()) return m.group(1);
m = HTML_IMAGE.matcher(content);
if (m.find()) return m.group(1);
// 再从纯 HTML 里解析一次(如果传入的是渲染后的)
try {
Document doc = Jsoup.parse(content);
Element img = doc.selectFirst("img[src]");
if (img != null) return img.attr("src");
} catch (Exception ignored) {}
return null;
}
private static String getMimeType(String url) {
String lower = url == null ? "" : url.toLowerCase(Locale.ROOT);
if (lower.endsWith(".png")) return "image/png";
if (lower.endsWith(".gif")) return "image/gif";
if (lower.endsWith(".webp")) return "image/webp";
if (lower.endsWith(".svg")) return "image/svg+xml";
if (lower.endsWith(".avif")) return "image/avif";
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
// 默认兜底
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) {
return zdt.withZoneSameInstant(ZoneId.of("GMT")).format(RFC1123);
}
private static String cdata(String s) {
if (s == null) return "<![CDATA[]]>";
// 防止出现 "]]>" 终止标记破坏 CDATA
return "<![CDATA[" + s.replace("]]>", "]]]]><![CDATA[>") + "]]>";
}
private static void elem(StringBuilder sb, String name, String value) {
sb.append('<').append(name).append('>').append(value).append("</").append(name).append('>');
}
private static String escapeXml(String s) {
if (s == null) return "";
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
.replace("\"", "&quot;").replace("'", "&apos;");
}
private static String trimTrailingSlash(String s) {
if (s == null) return "";
return s.endsWith("/") ? s.substring(0, s.length() - 1) : s;
}
private static String ensureTrailingSlash(String s) {
if (s == null || s.isEmpty()) return "/";
return s.endsWith("/") ? s : s + "/";
}
private static String nullSafe(String s) { return s == null ? "" : s; }
}

View File

@@ -7,5 +7,4 @@ import lombok.Data;
public class DiscordLoginRequest {
private String code;
private String redirectUri;
private String inviteToken;
}

View File

@@ -1,12 +0,0 @@
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;
}

View File

@@ -7,5 +7,4 @@ import lombok.Data;
public class GithubLoginRequest {
private String code;
private String redirectUri;
private String inviteToken;
}

View File

@@ -6,5 +6,4 @@ import lombok.Data;
@Data
public class GoogleLoginRequest {
private String idToken;
private String inviteToken;
}

View File

@@ -1,23 +0,0 @@
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;
}

View File

@@ -31,7 +31,5 @@ public class PostSummaryDto {
private int pointReward;
private PostType type;
private LotteryDto lottery;
private boolean rssExcluded;
private boolean closed;
}

View File

@@ -9,5 +9,4 @@ public class RegisterRequest {
private String email;
private String password;
private String captcha;
private String inviteToken;
}

View File

@@ -8,5 +8,4 @@ public class TwitterLoginRequest {
private String code;
private String redirectUri;
private String codeVerifier;
private String inviteToken;
}

View File

@@ -1,34 +0,0 @@
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;
}
}

View File

@@ -63,8 +63,6 @@ public class PostMapper {
dto.setCommentCount(commentService.countComments(post.getId()));
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()

View File

@@ -1,23 +0,0 @@
package com.openisle.model;
import jakarta.persistence.*;
import lombok.Data;
import java.time.LocalDate;
/**
* Invite token entity tracking usage counts.
*/
@Data
@Entity
public class InviteToken {
@Id
private String token;
@ManyToOne
private User inviter;
private LocalDate createdDate;
private int usageCount;
}

View File

@@ -3,7 +3,6 @@ package com.openisle.model;
public enum MedalType {
COMMENT,
POST,
FEATURED,
CONTRIBUTOR,
SEED,
PIONEER

View File

@@ -14,8 +14,6 @@ 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 */
@@ -40,8 +38,6 @@ 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
}

View File

@@ -1,49 +0,0 @@
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;
}

View File

@@ -1,12 +0,0 @@
package com.openisle.model;
public enum PointHistoryType {
POST,
COMMENT,
POST_LIKED,
COMMENT_LIKED,
INVITE,
FEATURE,
SYSTEM_ONLINE,
REDEEM
}

View File

@@ -64,12 +64,7 @@ public class Post {
@Column(nullable = false)
private PostType type = PostType.NORMAL;
@Column(nullable = false)
private boolean closed = false;
@Column
private LocalDateTime pinnedAt;
@Column(nullable = true)
private Boolean rssExcluded = true;
}

View File

@@ -1,12 +0,0 @@
package com.openisle.repository;
import com.openisle.model.InviteToken;
import com.openisle.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.time.LocalDate;
import java.util.Optional;
public interface InviteTokenRepository extends JpaRepository<InviteToken, String> {
Optional<InviteToken> findByInviterAndCreatedDate(User inviter, LocalDate createdDate);
}

View File

@@ -6,8 +6,6 @@ import com.openisle.model.Post;
import com.openisle.model.Comment;
import com.openisle.model.NotificationType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import java.util.List;
@@ -15,12 +13,7 @@ import java.util.List;
public interface NotificationRepository extends JpaRepository<Notification, Long> {
List<Notification> findByUserOrderByCreatedAtDesc(User user);
List<Notification> findByUserAndReadOrderByCreatedAtDesc(User user, boolean read);
Page<Notification> findByUserOrderByCreatedAtDesc(User user, Pageable pageable);
Page<Notification> findByUserAndReadOrderByCreatedAtDesc(User user, boolean read, Pageable pageable);
Page<Notification> findByUserAndTypeNotInOrderByCreatedAtDesc(User user, java.util.Collection<NotificationType> types, Pageable pageable);
Page<Notification> findByUserAndReadAndTypeNotInOrderByCreatedAtDesc(User user, boolean read, java.util.Collection<NotificationType> types, Pageable pageable);
long countByUserAndRead(User user, boolean read);
long countByUserAndReadAndTypeNotIn(User user, boolean read, java.util.Collection<NotificationType> types);
List<Notification> findByPost(Post post);
List<Notification> findByComment(Comment comment);

View File

@@ -1,12 +0,0 @@
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);
}

View File

@@ -97,8 +97,6 @@ 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);
@@ -108,6 +106,4 @@ public interface PostRepository extends JpaRepository<Post, Long> {
"WHERE p.createdAt >= :start AND p.createdAt < :end GROUP BY d ORDER BY d")
java.util.List<Object[]> countDailyRange(@Param("start") LocalDateTime start,
@Param("end") LocalDateTime end);
List<Post> findByStatusAndRssExcludedFalseOrderByCreatedAtDesc(PostStatus status, Pageable pageable);
}

View File

@@ -1,12 +0,0 @@
package com.openisle.service;
import com.openisle.model.User;
import lombok.Value;
/** Result for OAuth authentication indicating whether a new user was created. */
@Value
public class AuthResult {
User user;
boolean newUser;
}

View File

@@ -52,9 +52,6 @@ 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);
@@ -97,9 +94,6 @@ 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());

View File

@@ -26,7 +26,7 @@ public class DiscordAuthService {
@Value("${discord.client-secret:}")
private String clientSecret;
public Optional<AuthResult> authenticate(String code, com.openisle.model.RegisterMode mode, String redirectUri, boolean viaInvite) {
public Optional<User> authenticate(String code, com.openisle.model.RegisterMode mode, String redirectUri) {
try {
String tokenUrl = "https://discord.com/api/oauth2/token";
HttpHeaders headers = new HttpHeaders();
@@ -67,13 +67,13 @@ public class DiscordAuthService {
if (email == null) {
email = (username != null ? username : id) + "@users.noreply.discord.com";
}
return Optional.of(processUser(email, username, avatar, mode, viaInvite));
return Optional.of(processUser(email, username, avatar, mode));
} catch (Exception e) {
return Optional.empty();
}
}
private AuthResult processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode, boolean viaInvite) {
private User processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode) {
Optional<User> existing = userRepository.findByEmail(email);
if (existing.isPresent()) {
User user = existing.get();
@@ -82,7 +82,7 @@ public class DiscordAuthService {
user.setVerificationCode(null);
userRepository.save(user);
}
return new AuthResult(user, false);
return user;
}
String baseUsername = username != null ? username : email.split("@")[0];
String finalUsername = baseUsername;
@@ -96,12 +96,12 @@ public class DiscordAuthService {
user.setPassword("");
user.setRole(Role.USER);
user.setVerified(true);
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT || viaInvite);
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT);
if (avatar != null) {
user.setAvatar(avatar);
} else {
user.setAvatar("https://cdn.discordapp.com/embed/avatars/0.png");
}
return new AuthResult(userRepository.save(user), true);
return userRepository.save(user);
}
}

View File

@@ -30,7 +30,7 @@ public class GithubAuthService {
@Value("${github.client-secret:}")
private String clientSecret;
public Optional<AuthResult> authenticate(String code, com.openisle.model.RegisterMode mode, String redirectUri, boolean viaInvite) {
public Optional<User> authenticate(String code, com.openisle.model.RegisterMode mode, String redirectUri) {
try {
String tokenUrl = "https://github.com/login/oauth/access_token";
HttpHeaders headers = new HttpHeaders();
@@ -86,13 +86,13 @@ public class GithubAuthService {
if (email == null) {
email = username + "@users.noreply.github.com";
}
return Optional.of(processUser(email, username, avatarUrl, mode, viaInvite));
return Optional.of(processUser(email, username, avatarUrl, mode));
} catch (Exception e) {
return Optional.empty();
}
}
private AuthResult processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode, boolean viaInvite) {
private User processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode) {
Optional<User> existing = userRepository.findByEmail(email);
if (existing.isPresent()) {
User user = existing.get();
@@ -101,7 +101,7 @@ public class GithubAuthService {
user.setVerificationCode(null);
userRepository.save(user);
}
return new AuthResult(user, false);
return user;
}
String baseUsername = username != null ? username : email.split("@")[0];
String finalUsername = baseUsername;
@@ -115,12 +115,12 @@ public class GithubAuthService {
user.setPassword("");
user.setRole(Role.USER);
user.setVerified(true);
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT || viaInvite);
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT);
if (avatar != null) {
user.setAvatar(avatar);
} else {
user.setAvatar(avatarGenerator.generate(finalUsername));
}
return new AuthResult(userRepository.save(user), true);
return userRepository.save(user);
}
}

View File

@@ -25,7 +25,7 @@ public class GoogleAuthService {
@Value("${google.client-id:}")
private String clientId;
public Optional<AuthResult> authenticate(String idTokenString, com.openisle.model.RegisterMode mode, boolean viaInvite) {
public Optional<User> authenticate(String idTokenString, com.openisle.model.RegisterMode mode) {
GoogleIdTokenVerifier verifier = new GoogleIdTokenVerifier.Builder(new NetHttpTransport(), new JacksonFactory())
.setAudience(Collections.singletonList(clientId))
.build();
@@ -38,13 +38,13 @@ public class GoogleAuthService {
String email = payload.getEmail();
String name = (String) payload.get("name");
String picture = (String) payload.get("picture");
return Optional.of(processUser(email, name, picture, mode, viaInvite));
return Optional.of(processUser(email, name, picture, mode));
} catch (Exception e) {
return Optional.empty();
}
}
private AuthResult processUser(String email, String name, String avatar, com.openisle.model.RegisterMode mode, boolean viaInvite) {
private User processUser(String email, String name, String avatar, com.openisle.model.RegisterMode mode) {
Optional<User> existing = userRepository.findByEmail(email);
if (existing.isPresent()) {
User user = existing.get();
@@ -53,7 +53,8 @@ public class GoogleAuthService {
user.setVerificationCode(null);
userRepository.save(user);
}
return new AuthResult(user, false);
return user;
}
User user = new User();
String baseUsername = email.split("@")[0];
@@ -67,12 +68,12 @@ public class GoogleAuthService {
user.setPassword("");
user.setRole(Role.USER);
user.setVerified(true);
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT || viaInvite);
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT);
if (avatar != null) {
user.setAvatar(avatar);
} else {
user.setAvatar(avatarGenerator.generate(username));
}
return new AuthResult(userRepository.save(user), true);
return userRepository.save(user);
}
}

View File

@@ -1,64 +0,0 @@
package com.openisle.service;
import com.openisle.model.InviteToken;
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;
import java.util.Optional;
@Service
@RequiredArgsConstructor
public class InviteService {
private final InviteTokenRepository inviteTokenRepository;
private final UserRepository userRepository;
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();
Optional<InviteToken> existing = inviteTokenRepository.findByInviterAndCreatedDate(inviter, today);
if (existing.isPresent()) {
return existing.get().getToken();
}
String token = jwtService.generateInviteToken(username);
InviteToken inviteToken = new InviteToken();
inviteToken.setToken(token);
inviteToken.setInviter(inviter);
inviteToken.setCreatedDate(today);
inviteToken.setUsageCount(0);
inviteTokenRepository.save(inviteToken);
return token;
}
public InviteValidateResult validate(String token) {
if (token == null || token.isEmpty()) {
return new InviteValidateResult(null, false);
}
try {
jwtService.validateAndGetSubjectForInvite(token);
} catch (Exception e) {
return new InviteValidateResult(null, false);
}
InviteToken invite = inviteTokenRepository.findById(token).orElse(null);
return new InviteValidateResult(invite, invite != null && invite.getUsageCount() < 3);
}
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(), newUserName);
}
}

View File

@@ -24,9 +24,6 @@ public class JwtService {
@Value("${app.jwt.reset-secret}")
private String resetSecret;
@Value("${app.jwt.invite-secret}")
private String inviteSecret;
@Value("${app.jwt.expiration}")
private long expiration;
@@ -73,17 +70,6 @@ public class JwtService {
.compact();
}
public String generateInviteToken(String subject) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + expiration);
return Jwts.builder()
.setSubject(subject)
.setIssuedAt(now)
.setExpiration(expiryDate)
.signWith(getSigningKeyForSecret(inviteSecret))
.compact();
}
public String validateAndGetSubject(String token) {
Claims claims = Jwts.parserBuilder()
.setSigningKey(getSigningKeyForSecret(secret))
@@ -110,13 +96,4 @@ public class JwtService {
.getBody();
return claims.getSubject();
}
public String validateAndGetSubjectForInvite(String token) {
Claims claims = Jwts.parserBuilder()
.setSigningKey(getSigningKeyForSecret(inviteSecret))
.build()
.parseClaimsJws(token)
.getBody();
return claims.getSubject();
}
}

View File

@@ -6,7 +6,6 @@ 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;
@@ -75,23 +74,6 @@ 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("贡献者");
@@ -159,8 +141,6 @@ 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) {

View File

@@ -23,6 +23,7 @@ import java.util.HashSet;
import java.util.List;
import java.util.ArrayList;
import java.util.concurrent.Executor;
import java.util.stream.Collectors;
/** Service for creating and retrieving notifications. */
@Service
@@ -179,26 +180,17 @@ public class NotificationService {
userRepository.save(user);
}
public List<Notification> listNotifications(String username, Boolean read, int page, int size) {
public List<Notification> listNotifications(String username, Boolean read) {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
Set<NotificationType> disabled = user.getDisabledNotificationTypes();
org.springframework.data.domain.Pageable pageable = org.springframework.data.domain.PageRequest.of(page, size);
org.springframework.data.domain.Page<Notification> result;
List<Notification> list;
if (read == null) {
if (disabled.isEmpty()) {
result = notificationRepository.findByUserOrderByCreatedAtDesc(user, pageable);
} else {
result = notificationRepository.findByUserAndTypeNotInOrderByCreatedAtDesc(user, disabled, pageable);
}
list = notificationRepository.findByUserOrderByCreatedAtDesc(user);
} else {
if (disabled.isEmpty()) {
result = notificationRepository.findByUserAndReadOrderByCreatedAtDesc(user, read, pageable);
} else {
result = notificationRepository.findByUserAndReadAndTypeNotInOrderByCreatedAtDesc(user, read, disabled, pageable);
}
list = notificationRepository.findByUserAndReadOrderByCreatedAtDesc(user, read);
}
return result.getContent();
return list.stream().filter(n -> !disabled.contains(n.getType())).collect(Collectors.toList());
}
public void markRead(String username, List<Long> ids) {
@@ -217,10 +209,8 @@ public class NotificationService {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
Set<NotificationType> disabled = user.getDisabledNotificationTypes();
if (disabled.isEmpty()) {
return notificationRepository.countByUserAndRead(user, false);
}
return notificationRepository.countByUserAndReadAndTypeNotIn(user, false, disabled);
return notificationRepository.findByUserAndReadOrderByCreatedAtDesc(user, false).stream()
.filter(n -> !disabled.contains(n.getType())).count();
}
public void notifyMentions(String content, User fromUser, Post post, Comment comment) {

View File

@@ -3,11 +3,8 @@ 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;
@@ -21,7 +18,6 @@ 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();
@@ -36,13 +32,6 @@ 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();
}
}

View File

@@ -1,6 +1,7 @@
package com.openisle.service;
import com.openisle.model.*;
import com.openisle.model.PointLog;
import com.openisle.model.User;
import com.openisle.repository.*;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@@ -15,28 +16,14 @@ public class PointService {
private final PointLogRepository pointLogRepository;
private final PostRepository postRepository;
private final CommentRepository commentRepository;
private final PointHistoryRepository pointHistoryRepository;
public int awardForPost(String userName, Long postId) {
public int awardForPost(String userName) {
User user = userRepository.findByUsername(userName).orElseThrow();
PointLog log = getTodayLog(user);
if (log.getPostCount() > 1) return 0;
log.setPostCount(log.getPostCount() + 1);
pointLogRepository.save(log);
Post post = postRepository.findById(postId).orElseThrow();
return addPoint(user, 30, PointHistoryType.POST, post, null, null);
}
public int awardForInvite(String userName, String inviteeName) {
User user = userRepository.findByUsername(userName).orElseThrow();
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);
return addPoint(user, 30);
}
private PointLog getTodayLog(User user) {
@@ -53,41 +40,20 @@ public class PointService {
});
}
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);
}
private int addPoint(User user, int amount) {
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, Long commentId) {
public int awardForComment(String commenterName, Long postId) {
// 标记评论者是否已达到积分奖励上限
boolean isTheRewardCapped = false;
// 根据帖子id找到发帖人
Post post = postRepository.findById(postId).orElseThrow();
User poster = post.getAuthor();
Comment comment = commentRepository.findById(commentId).orElseThrow();
User poster = postRepository.findById(postId).orElseThrow().getAuthor();
// 获取评论者的加分日志
User commenter = userRepository.findByUsername(commenterName).orElseThrow();
@@ -103,15 +69,15 @@ public class PointService {
} else {
log.setCommentCount(log.getCommentCount() + 1);
pointLogRepository.save(log);
return addPoint(commenter, 10, PointHistoryType.COMMENT, post, comment, null);
return addPoint(commenter, 10);
}
} else {
addPoint(poster, 10, PointHistoryType.COMMENT, post, comment, commenter);
addPoint(poster, 10);
// 如果发帖人与评论者不是同一个,则根据是否达到积分上限来判断评论者加分情况
if (isTheRewardCapped) {
return 0;
} else {
return addPoint(commenter, 10, PointHistoryType.COMMENT, post, comment, null);
return addPoint(commenter, 10);
}
}
}
@@ -130,8 +96,7 @@ public class PointService {
}
// 如果不是同一个,则为发帖人加分
Post post = postRepository.findById(postId).orElseThrow();
return addPoint(poster, 10, PointHistoryType.POST_LIKED, post, null, reactioner);
return addPoint(poster, 10);
}
// 考虑点赞者和评论者是同一个的情况
@@ -148,17 +113,7 @@ public class PointService {
}
// 如果不是同一个,则为发帖人加分
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);
return addPoint(commenter, 10);
}
}

View File

@@ -67,7 +67,6 @@ 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;
@@ -90,7 +89,6 @@ 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;
@@ -109,7 +107,6 @@ public class PostService {
this.taskScheduler = taskScheduler;
this.emailSender = emailSender;
this.applicationContext = applicationContext;
this.pointService = pointService;
this.publishMode = publishMode;
}
@@ -135,26 +132,6 @@ public class PostService {
this.publishMode = publishMode;
}
public List<Post> listLatestRssPosts(int limit) {
Pageable pageable = PageRequest.of(0, limit, Sort.by(Sort.Direction.DESC, "createdAt"));
return postRepository.findByStatusAndRssExcludedFalseOrderByCreatedAtDesc(PostStatus.PUBLISHED, pageable);
}
public Post excludeFromRss(Long id) {
Post post = postRepository.findById(id).orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
post.setRssExcluded(true);
return postRepository.save(post);
}
public Post includeInRss(Long id) {
Post post = postRepository.findById(id).orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
post.setRssExcluded(false);
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,
Long categoryId,
String title,
@@ -464,26 +441,6 @@ public class PostService {
return paginate(sortByPinnedAndCreated(posts), page, pageSize);
}
public List<Post> listFeaturedPosts(java.util.List<Long> categoryIds,
java.util.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();
}
posts = posts.stream().filter(p -> !Boolean.TRUE.equals(p.getRssExcluded())).toList();
return paginate(sortByPinnedAndCreated(posts), page, pageSize);
}
public List<Post> listPendingPosts() {
return postRepository.findByStatus(PostStatus.PENDING);
}
@@ -538,30 +495,6 @@ 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,
@@ -605,9 +538,7 @@ 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"));
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) {
if (!user.getId().equals(post.getAuthor().getId()) && user.getRole() != Role.ADMIN) {
throw new IllegalArgumentException("Unauthorized");
}
for (Comment c : commentRepository.findByPostAndParentIsNullOrderByCreatedAtAsc(post)) {
@@ -624,12 +555,7 @@ 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) {

View File

@@ -33,12 +33,11 @@ public class TwitterAuthService {
@Value("${twitter.client-secret:}")
private String clientSecret;
public Optional<AuthResult> authenticate(
public Optional<User> authenticate(
String code,
String codeVerifier,
RegisterMode mode,
String redirectUri,
boolean viaInvite) {
String redirectUri) {
logger.debug("Starting authentication with code {} and verifier {}", code, codeVerifier);
@@ -107,10 +106,10 @@ public class TwitterAuthService {
// Twitter v2 默认拿不到 email如果你申请到 email.scope可改用 /2/users/:id?user.fields=email
String email = username + "@twitter.com";
logger.debug("Processing user {} with email {}", username, email);
return Optional.of(processUser(email, username, avatar, mode, viaInvite));
return Optional.of(processUser(email, username, avatar, mode));
}
private AuthResult processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode, boolean viaInvite) {
private User processUser(String email, String username, String avatar, com.openisle.model.RegisterMode mode) {
Optional<User> existing = userRepository.findByEmail(email);
if (existing.isPresent()) {
User user = existing.get();
@@ -120,7 +119,7 @@ public class TwitterAuthService {
userRepository.save(user);
}
logger.debug("Existing user {} authenticated", user.getUsername());
return new AuthResult(user, false);
return user;
}
String baseUsername = username != null ? username : email.split("@")[0];
String finalUsername = baseUsername;
@@ -134,13 +133,13 @@ public class TwitterAuthService {
user.setPassword("");
user.setRole(Role.USER);
user.setVerified(true);
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT || viaInvite);
user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT);
if (avatar != null) {
user.setAvatar(avatar);
} else {
user.setAvatar("https://twitter.com/" + finalUsername + "/profile_image");
}
logger.debug("Creating new user {}", finalUsername);
return new AuthResult(userRepository.save(user), true);
return userRepository.save(user);
}
}

View File

@@ -74,13 +74,6 @@ public class UserService {
return userRepository.save(user);
}
public User registerWithInvite(String username, String email, String password) {
User user = register(username, email, password, "", com.openisle.model.RegisterMode.DIRECT);
user.setVerified(true);
user.setVerificationCode(genCode());
return userRepository.save(user);
}
private String genCode() {
return String.format("%06d", new Random().nextInt(1000000));
}

View File

@@ -10,7 +10,6 @@ spring.jpa.hibernate.ddl-auto=update
app.jwt.secret=${JWT_SECRET:jwt_sec}
app.jwt.reason-secret=${JWT_REASON_SECRET:jwt_reason_sec}
app.jwt.reset-secret=${JWT_RESET_SECRET:jwt_reset_sec}
app.jwt.invite-secret=${JWT_INVITE_SECRET:jwt_invite_sec}
# 30 days
app.jwt.expiration=${JWT_EXPIRATION:2592000000}
# Password strength: LOW, MEDIUM or HIGH

View File

@@ -1 +0,0 @@
ALTER TABLE posts ADD COLUMN rss_excluded BOOLEAN NOT NULL DEFAULT 0;

View File

@@ -45,7 +45,7 @@ class NotificationControllerTest {
p.setId(2L);
n.setPost(p);
n.setCreatedAt(LocalDateTime.now());
when(notificationService.listNotifications("alice", null, 0, 30))
when(notificationService.listNotifications("alice", null))
.thenReturn(List.of(n));
NotificationDto dto = new NotificationDto();
@@ -62,24 +62,6 @@ class NotificationControllerTest {
.andExpect(jsonPath("$[0].post.id").value(2));
}
@Test
void listUnreadNotifications() throws Exception {
Notification n = new Notification();
n.setId(5L);
n.setType(NotificationType.POST_VIEWED);
when(notificationService.listNotifications("alice", false, 0, 30))
.thenReturn(List.of(n));
NotificationDto dto = new NotificationDto();
dto.setId(5L);
when(notificationMapper.toDto(n)).thenReturn(dto);
mockMvc.perform(get("/api/notifications/unread")
.principal(new UsernamePasswordAuthenticationToken("alice","p")))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].id").value(5));
}
@Test
void markReadEndpoint() throws Exception {
mockMvc.perform(post("/api/notifications/read")

View File

@@ -27,7 +27,7 @@ class MedalServiceTest {
List<MedalDto> medals = service.getMedals(null);
medals.forEach(m -> assertFalse(m.isCompleted()));
assertEquals(6, medals.size());
assertEquals(5, medals.size());
}
@Test

View File

@@ -11,9 +11,6 @@ import org.mockito.Mockito;
import java.util.List;
import java.util.Optional;
import java.util.HashSet;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
@@ -65,17 +62,15 @@ class NotificationServiceTest {
User user = new User();
user.setId(2L);
user.setUsername("bob");
user.setDisabledNotificationTypes(new HashSet<>());
when(uRepo.findByUsername("bob")).thenReturn(Optional.of(user));
Notification n = new Notification();
when(nRepo.findByUserOrderByCreatedAtDesc(eq(user), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of(n)));
when(nRepo.findByUserOrderByCreatedAtDesc(user)).thenReturn(List.of(n));
List<Notification> list = service.listNotifications("bob", null, 0, 10);
List<Notification> list = service.listNotifications("bob", null);
assertEquals(1, list.size());
verify(nRepo).findByUserOrderByCreatedAtDesc(eq(user), any(Pageable.class));
verify(nRepo).findByUserOrderByCreatedAtDesc(user);
}
@Test
@@ -92,7 +87,6 @@ class NotificationServiceTest {
User user = new User();
user.setId(3L);
user.setUsername("carl");
user.setDisabledNotificationTypes(new HashSet<>());
when(uRepo.findByUsername("carl")).thenReturn(Optional.of(user));
when(nRepo.countByUserAndRead(user, false)).thenReturn(5L);
@@ -102,56 +96,6 @@ class NotificationServiceTest {
verify(nRepo).countByUserAndRead(user, false);
}
@Test
void listNotificationsFiltersDisabledTypes() {
NotificationRepository nRepo = mock(NotificationRepository.class);
UserRepository uRepo = mock(UserRepository.class);
ReactionRepository rRepo = mock(ReactionRepository.class);
EmailSender email = mock(EmailSender.class);
PushNotificationService push = mock(PushNotificationService.class);
Executor executor = Runnable::run;
NotificationService service = new NotificationService(nRepo, uRepo, email, push, rRepo, executor);
org.springframework.test.util.ReflectionTestUtils.setField(service, "websiteUrl", "https://ex.com");
User user = new User();
user.setId(4L);
user.setUsername("dana");
when(uRepo.findByUsername("dana")).thenReturn(Optional.of(user));
Notification n = new Notification();
when(nRepo.findByUserAndTypeNotInOrderByCreatedAtDesc(eq(user), eq(user.getDisabledNotificationTypes()), any(Pageable.class)))
.thenReturn(new PageImpl<>(List.of(n)));
List<Notification> list = service.listNotifications("dana", null, 0, 10);
assertEquals(1, list.size());
verify(nRepo).findByUserAndTypeNotInOrderByCreatedAtDesc(eq(user), eq(user.getDisabledNotificationTypes()), any(Pageable.class));
}
@Test
void countUnreadFiltersDisabledTypes() {
NotificationRepository nRepo = mock(NotificationRepository.class);
UserRepository uRepo = mock(UserRepository.class);
ReactionRepository rRepo = mock(ReactionRepository.class);
EmailSender email = mock(EmailSender.class);
PushNotificationService push = mock(PushNotificationService.class);
Executor executor = Runnable::run;
NotificationService service = new NotificationService(nRepo, uRepo, email, push, rRepo, executor);
org.springframework.test.util.ReflectionTestUtils.setField(service, "websiteUrl", "https://ex.com");
User user = new User();
user.setId(5L);
user.setUsername("erin");
when(uRepo.findByUsername("erin")).thenReturn(Optional.of(user));
when(nRepo.countByUserAndReadAndTypeNotIn(eq(user), eq(false), eq(user.getDisabledNotificationTypes())))
.thenReturn(2L);
long count = service.countUnread("erin");
assertEquals(2L, count);
verify(nRepo).countByUserAndReadAndTypeNotIn(eq(user), eq(false), eq(user.getDisabledNotificationTypes()));
}
@Test
void createRegisterRequestNotificationsDeletesOldOnes() {
NotificationRepository nRepo = mock(NotificationRepository.class);

View File

@@ -34,12 +34,11 @@ 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, pointService, PublishMode.DIRECT);
imageUploader, taskScheduler, emailSender, context, PublishMode.DIRECT);
when(context.getBean(PostService.class)).thenReturn(service);
Post post = new Post();
@@ -62,59 +61,6 @@ 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);
@@ -134,12 +80,11 @@ 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, pointService, PublishMode.DIRECT);
imageUploader, taskScheduler, emailSender, context, PublishMode.DIRECT);
when(context.getBean(PostService.class)).thenReturn(service);
when(postRepo.countByAuthorAfter(eq("alice"), any())).thenReturn(1L);
@@ -168,12 +113,11 @@ 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, pointService, PublishMode.DIRECT);
imageUploader, taskScheduler, emailSender, context, PublishMode.DIRECT);
when(context.getBean(PostService.class)).thenReturn(service);
User author = new User();

View File

@@ -21,7 +21,6 @@
</div>
</div>
<GlobalPopups />
<ConfirmDialog />
</div>
</template>
@@ -29,7 +28,6 @@
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 { useIsMobile } from '~/utils/screen'
const isMobile = useIsMobile()

View File

@@ -90,8 +90,7 @@ body {
}
.vditor-toolbar--pin {
top: calc(var(--header-height) + 1px) !important;
z-index: 2000;
top: var(--header-height) !important;
}
.vditor-panel {
@@ -184,7 +183,7 @@ body {
font-family: 'Maple Mono', monospace;
font-size: 13px;
border-radius: 4px;
white-space: break-spaces;
white-space: no-wrap;
background-color: var(--code-highlight-background-color);
color: var(--text-color);
}

View File

@@ -26,9 +26,6 @@
<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>

View File

@@ -1,65 +0,0 @@
<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>

View File

@@ -22,7 +22,6 @@ import {
getEditorTheme as getEditorThemeUtil,
getPreviewTheme as getPreviewThemeUtil,
} from '~/utils/vditor'
import '~/assets/global.css'
import LoginOverlay from '~/components/LoginOverlay.vue'
export default {

View File

@@ -16,11 +16,11 @@
<div class="info-content-header-left">
<span class="user-name">{{ comment.userName }}</span>
<i class="fas fa-medal medal-icon"></i>
<NuxtLink
<router-link
v-if="comment.medal"
class="medal-name"
:to="`/users/${comment.userId}?tab=achievements`"
>{{ getMedalTitle(comment.medal) }}</NuxtLink
>{{ getMedalTitle(comment.medal) }}</router-link
>
<i v-if="comment.pinned" class="fas fa-thumbtack pin-icon"></i>
<span v-if="level >= 2">
@@ -57,7 +57,7 @@
v-if="showEditor"
@submit="submitReply"
:loading="isWaitingForReply"
:disabled="!loggedIn || postClosed"
:disabled="!loggedIn"
:show-login-overlay="!loggedIn"
:parent-user-name="comment.userName"
/>
@@ -76,7 +76,6 @@
:level="level + 1"
:default-show-replies="item.openReplies"
:post-author-id="postAuthorId"
:post-closed="postClosed"
/>
</template>
</BaseTimeline>
@@ -123,10 +122,6 @@ const props = defineProps({
type: [Number, String],
required: true,
},
postClosed: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['deleted'])
@@ -153,7 +148,6 @@ const toggleReplies = () => {
}
const toggleEditor = () => {
if (props.postClosed) return
showEditor.value = !showEditor.value
if (showEditor.value) {
setTimeout(() => {
@@ -219,10 +213,6 @@ 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) {

View File

@@ -1,71 +0,0 @@
<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>

View File

@@ -1,5 +1,11 @@
<template>
<div>
<ActivityPopup
:visible="showInvitePointsPopup"
:icon="invitePointsIcon"
text="邀请码送积分活动火热进行中,快来邀请好友吧!"
@close="closeInvitePointsPopup"
/>
<ActivityPopup
:visible="showMilkTeaPopup"
:icon="milkTeaIcon"
@@ -8,13 +14,6 @@
/>
<NotificationSettingPopup :visible="showNotificationPopup" @close="closeNotificationPopup" />
<MedalPopup :visible="showMedalPopup" :medals="newMedals" @close="closeMedalPopup" />
<ActivityPopup
:visible="showInviteCodePopup"
:icon="inviteCodeIcon"
text="邀请码活动开始了,速来参与大伙们🔥🔥🔥"
@close="closeInviteCodePopup"
/>
</div>
</template>
@@ -27,22 +26,21 @@ import { authState } from '~/utils/auth'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const showInvitePointsPopup = ref(false)
const invitePointsIcon = ref('')
const showMilkTeaPopup = ref(false)
const showInviteCodePopup = ref(false)
const milkTeaIcon = ref('')
const inviteCodeIcon = ref('')
const showNotificationPopup = ref(false)
const showMedalPopup = ref(false)
const newMedals = ref([])
onMounted(async () => {
await checkInvitePointsActivity()
if (showInvitePointsPopup.value) return
await checkMilkTeaActivity()
if (showMilkTeaPopup.value) return
await checkInviteCodeActivity()
if (showInviteCodePopup.value) return
await checkNotificationSetting()
if (showNotificationPopup.value) return
@@ -50,7 +48,7 @@ onMounted(async () => {
})
const checkMilkTeaActivity = async () => {
if (!import.meta.client) return
if (!process.client) return
if (localStorage.getItem('milkTeaActivityPopupShown')) return
try {
const res = await fetch(`${API_BASE_URL}/api/activities`)
@@ -66,50 +64,49 @@ const checkMilkTeaActivity = async () => {
// ignore network errors
}
}
const checkInviteCodeActivity = async () => {
if (!import.meta.client) return
if (localStorage.getItem('inviteCodeActivityPopupShown')) return
const closeMilkTeaPopup = () => {
if (!process.client) return
localStorage.setItem('milkTeaActivityPopupShown', 'true')
showMilkTeaPopup.value = false
checkNotificationSetting()
}
const checkInvitePointsActivity = async () => {
if (!process.client) return
if (localStorage.getItem('invitePointsActivityPopupShown')) return
try {
const res = await fetch(`${API_BASE_URL}/api/activities`)
if (res.ok) {
const list = await res.json()
const a = list.find((i) => i.type === 'INVITE_POINTS' && !i.ended)
if (a) {
inviteCodeIcon.value = a.icon
showInviteCodePopup.value = true
invitePointsIcon.value = a.icon
showInvitePointsPopup.value = true
}
}
} catch (e) {
// ignore network errors
}
}
const closeInviteCodePopup = () => {
if (!import.meta.client) return
localStorage.setItem('inviteCodeActivityPopupShown', 'true')
showInviteCodePopup.value = false
const closeInvitePointsPopup = () => {
if (!process.client) return
localStorage.setItem('invitePointsActivityPopupShown', 'true')
showInvitePointsPopup.value = false
checkMilkTeaActivity()
}
const closeMilkTeaPopup = () => {
if (!import.meta.client) return
localStorage.setItem('milkTeaActivityPopupShown', 'true')
showMilkTeaPopup.value = false
}
const checkNotificationSetting = async () => {
if (!import.meta.client) return
if (!process.client) return
if (!authState.loggedIn) return
if (localStorage.getItem('notificationSettingPopupShown')) return
showNotificationPopup.value = true
}
const closeNotificationPopup = () => {
if (!import.meta.client) return
if (!process.client) return
localStorage.setItem('notificationSettingPopupShown', 'true')
showNotificationPopup.value = false
checkNewMedals()
}
const checkNewMedals = async () => {
if (!import.meta.client) return
if (!process.client) return
if (!authState.loggedIn || !authState.userId) return
try {
const res = await fetch(`${API_BASE_URL}/api/medals?userId=${authState.userId}`)
@@ -127,7 +124,7 @@ const checkNewMedals = async () => {
}
}
const closeMedalPopup = () => {
if (!import.meta.client) return
if (!process.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]))

View File

@@ -29,18 +29,6 @@
<i :class="iconClass"></i>
</div>
<div v-if="!isMobile" class="invite_text" @click="copyInviteLink">
<i class="fas fa-copy"></i>
邀请
<i v-if="isCopying" class="fas fa-spinner fa-spin"></i>
</div>
<ToolTip content="复制RSS链接" placement="bottom">
<div class="rss-icon" @click="copyRssLink">
<i class="fas fa-rss"></i>
</div>
</ToolTip>
<ToolTip v-if="!isMobile && isLogin" content="发帖" placement="bottom">
<div class="new-post-icon" @click="goToNewPost">
<i class="fas fa-edit"></i>
@@ -78,11 +66,6 @@ import { authState, clearToken, loadCurrentUser } from '~/utils/auth'
import { fetchUnreadCount, notificationState } from '~/utils/notification'
import { useIsMobile } from '~/utils/screen'
import { themeState, cycleTheme, ThemeMode } from '~/utils/theme'
import { toast } from '~/main'
import { getToken } from '~/utils/auth'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
const props = defineProps({
showMenuBtn: {
@@ -99,7 +82,6 @@ const showSearch = ref(false)
const searchDropdown = ref(null)
const userMenu = ref(null)
const menuBtn = ref(null)
const isCopying = ref(false)
const search = () => {
showSearch.value = true
@@ -118,41 +100,6 @@ const goToLogin = () => {
const goToSettings = () => {
navigateTo('/settings', { replace: true })
}
const copyInviteLink = async () => {
isCopying.value = true
const token = getToken()
if (!token) {
toast.error('请先登录')
return
}
try {
const res = await fetch(`${API_BASE_URL}/api/invite/generate`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
})
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('邀请链接已复制')
} else {
const data = await res.json().catch(() => ({}))
toast.error(data.error || '生成邀请链接失败')
}
} catch (e) {
toast.error('生成邀请链接失败')
} finally {
isCopying.value = false
}
}
const copyRssLink = async () => {
const rssLink = `${API_BASE_URL}/api/rss`
await navigator.clipboard.writeText(rssLink)
toast.success('RSS链接已复制')
}
const goToProfile = async () => {
if (!authState.loggedIn) {
navigateTo('/login', { replace: true })
@@ -277,7 +224,7 @@ onMounted(async () => {
margin-left: auto;
flex-direction: row;
align-items: center;
gap: 30px;
gap: 20px;
}
.auth-btns {
@@ -368,41 +315,11 @@ onMounted(async () => {
cursor: pointer;
}
.invite_text {
font-size: 12px;
cursor: pointer;
color: var(--primary-color);
}
.invite_text:hover {
text-decoration: underline;
}
.rss-icon,
.new-post-icon {
font-size: 18px;
cursor: pointer;
}
.rss-icon {
animation: rss-glow 2s 3;
}
@keyframes rss-glow {
0% {
text-shadow: 0 0 0px var(--primary-color);
opacity: 1;
}
50% {
text-shadow: 0 0 12px var(--primary-color);
opacity: 0.8;
}
100% {
text-shadow: 0 0 0px var(--primary-color);
opacity: 1;
}
}
@media (max-width: 1200px) {
.header-content {
padding-left: 15px;
@@ -419,9 +336,5 @@ onMounted(async () => {
.logo-text {
display: none;
}
.header-content-right {
gap: 15px;
}
}
</style>

View File

@@ -1,102 +0,0 @@
<template>
<!-- done 后整个容器自动隐藏不再占位 -->
<div v-show="!done" class="infinite-loadmore">
<div v-show="isLoading" class="loading-container bottom-loading" aria-live="polite">
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
</div>
<!-- 永久存在的底部触发器由组件内部持有与观察 -->
<div ref="sentinel" class="load-more-trigger" aria-hidden="true"></div>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
const props = defineProps({
/** 父组件提供:执行“加载下一页”的函数
* 返回:
* - booleantrue 表示“已经没有更多数据done
* - { done: boolean }:同上
*/
onLoad: { type: Function, required: true },
/** pause=true 时暂停观察(例如首屏/筛选加载过程) */
pause: { type: Boolean, default: false },
/** 预取范围,默认 200px */
rootMargin: { type: String, default: '200px 0px' },
/** 触发阈值 */
threshold: { type: Number, default: 0 },
})
const isLoading = ref(false)
const done = ref(false)
const sentinel = ref(null)
let io = null
const stopObserver = () => {
if (io) {
io.disconnect()
io = null
}
}
const startObserver = () => {
if (!import.meta.client || props.pause || done.value) return
stopObserver()
io = new IntersectionObserver(
async (entries) => {
const e = entries[0]
if (!e?.isIntersecting || isLoading.value || done.value) return
isLoading.value = true
try {
const res = await props.onLoad()
const finished = typeof res === 'boolean' ? res : !!(res && res.done)
if (finished) {
done.value = true
stopObserver()
}
} finally {
isLoading.value = false
}
},
{ root: null, rootMargin: props.rootMargin, threshold: props.threshold },
)
if (sentinel.value) io.observe(sentinel.value)
}
onMounted(() => {
nextTick(startObserver)
})
onBeforeUnmount(stopObserver)
watch(
() => props.pause,
(p) => {
if (p) stopObserver()
else nextTick(startObserver)
},
)
/** 父组件可选择性调用,用于外部强制重置(一般直接用 :key 重建更简单) */
const reset = () => {
done.value = false
nextTick(startObserver)
}
defineExpose({ reset })
</script>
<style scoped>
.infinite-loadmore {
}
.loading-container {
display: flex;
justify-content: center;
align-items: center;
height: 100px; /* 与原样式匹配 */
}
.load-more-trigger {
width: 100%;
height: 1px;
}
</style>

View File

@@ -1,190 +0,0 @@
<template>
<div class="invite-code-activity">
<div class="invite-code-description">
<div class="invite-code-description-title">
<i class="fas fa-info-circle"></i>
<span class="invite-code-description-title-text">邀请规则说明</span>
</div>
<div class="invite-code-description-content">
<p>邀请好友注册并登录每次可以获得500积分🎉🎉🎉</p>
<p>邀请链接的有效期为1个月</p>
<p>每一个邀请链接的邀请人数上限为3人</p>
<p>通过邀请链接注册无需注册审核</p>
<p>每人每天仅能生产1个邀请链接</p>
</div>
</div>
<div v-if="inviteLink" class="invite-code-link-content">
<p class="invite-code-link-content-text">
邀请链接{{ inviteLink }}
<span @click="copyLink"><i class="fas fa-copy copy-icon"></i></span>
</p>
</div>
<div :class="['generate-button', { disabled: !user || loadingInvite }]" @click="generateInvite">
生成邀请链接
</div>
</div>
</template>
<script setup>
import { toast } from '~/main'
import { fetchCurrentUser, getToken } from '~/utils/auth'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
const user = ref(null)
const isLoadingUser = ref(true)
const inviteCode = ref('')
const loadingInvite = ref(false)
const inviteLink = computed(() =>
inviteCode.value ? `${WEBSITE_BASE_URL}/signup?invite_token=${inviteCode.value}` : '',
)
onMounted(async () => {
isLoadingUser.value = true
user.value = await fetchCurrentUser()
isLoadingUser.value = false
// if (user.value) {
// await fetchInvite(false)
// }
})
const fetchInvite = async (showToast = true) => {
loadingInvite.value = true
const token = getToken()
if (!token) {
toast.error('请先登录')
loadingInvite.value = false
return
}
try {
const res = await fetch(`${API_BASE_URL}/api/invite/generate`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
})
if (res.ok) {
const data = await res.json()
inviteCode.value = data.token
if (showToast) toast.success('邀请链接已生成')
} else {
const data = await res.json().catch(() => ({}))
toast.error(data.error || '生成邀请链接失败')
}
} catch (e) {
toast.error('生成邀请链接失败')
} finally {
loadingInvite.value = false
}
}
const generateInvite = () => fetchInvite(true)
const copyLink = async () => {
if (!inviteLink.value) return
try {
await navigator.clipboard.writeText(inviteLink.value)
toast.success('已复制')
} catch (e) {
toast.error('复制失败')
}
}
</script>
<style scoped>
.invite-code-description-title-text {
font-size: 14px;
font-weight: bold;
margin-left: 5px;
}
.invite-code-description-content {
font-size: 12px;
opacity: 0.8;
}
.status-title {
font-weight: bold;
}
.status-text {
font-size: 12px;
opacity: 0.8;
}
.invite-code-activity {
margin-top: 20px;
padding: 20px;
}
.generate-button {
margin-top: 20px;
background-color: var(--primary-color);
color: #fff;
padding: 8px 16px;
border-radius: 10px;
width: fit-content;
cursor: pointer;
}
.generate-button:hover {
background-color: var(--primary-color-hover);
}
.generate-button.disabled {
background-color: var(--primary-color-disabled);
cursor: not-allowed;
}
.generate-button.disabled:hover {
background-color: var(--primary-color-disabled);
}
.invite-code-status-container {
display: flex;
flex-direction: row;
align-items: center;
gap: 30px;
margin-top: 20px;
}
.invite-code-status {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 10px;
font-size: 14px;
}
.user-level-text {
opacity: 0.8;
font-size: 12px;
color: var(--primary-color);
}
.invite-code-link-content {
margin-top: 20px;
font-size: 12px;
opacity: 0.8;
}
.invite-code-link-content-text {
word-break: break-all;
}
.copy-icon {
cursor: pointer;
margin-left: 5px;
}
@media screen and (max-width: 768px) {
.invite-code-status-container {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
}
</style>

View File

@@ -16,7 +16,6 @@ import {
getEditorTheme as getEditorThemeUtil,
getPreviewTheme as getPreviewThemeUtil,
} from '~/utils/vditor'
import '~/assets/global.css'
export default {
name: 'PostEditor',

View File

@@ -51,10 +51,6 @@ 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'])
@@ -70,6 +66,30 @@ 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 = {}
@@ -180,10 +200,6 @@ const toggleReaction = async (type) => {
toast.error('操作失败')
}
}
onMounted(async () => {
await initialize()
})
</script>
<style>
@@ -237,7 +253,7 @@ onMounted(async () => {
.make-reaction-item {
cursor: pointer;
padding: 4px;
padding: 10px;
opacity: 0.5;
border-radius: 8px;
font-size: 20px;

View File

@@ -63,7 +63,7 @@ const isImageIcon = (icon) => {
}
const buildTagsUrl = (kw = '') => {
const base = API_BASE_URL || (import.meta.client ? window.location.origin : '')
const base = API_BASE_URL || (process.client ? window.location.origin : '')
const url = new URL('/api/tags', base)
if (kw) url.searchParams.set('keyword', kw)

View File

@@ -1,52 +0,0 @@
// 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,
}
}

View File

@@ -1,52 +0,0 @@
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)
}
}

View File

@@ -1,7 +1,7 @@
// 导出一个便捷的 toast 对象
export const toast = {
success: async (message) => {
if (import.meta.client) {
if (process.client) {
try {
const { useToast } = await import('vue-toastification')
const toastInstance = useToast()
@@ -12,7 +12,7 @@ export const toast = {
}
},
error: async (message) => {
if (import.meta.client) {
if (process.client) {
try {
const { useToast } = await import('vue-toastification')
const toastInstance = useToast()
@@ -23,7 +23,7 @@ export const toast = {
}
},
warning: async (message) => {
if (import.meta.client) {
if (process.client) {
try {
const { useToast } = await import('vue-toastification')
const toastInstance = useToast()
@@ -34,7 +34,7 @@ export const toast = {
}
},
info: async (message) => {
if (import.meta.client) {
if (process.client) {
try {
const { useToast } = await import('vue-toastification')
const toastInstance = useToast()
@@ -48,7 +48,7 @@ export const toast = {
// 导出 useToast composable
export const useToast = () => {
if (import.meta.client) {
if (process.client) {
return new Promise(async (resolve) => {
try {
const { useToast: useVueToast } = await import('vue-toastification')

View File

@@ -12,6 +12,7 @@ export default defineNuxtConfig({
twitterClientId: process.env.NUXT_PUBLIC_TWITTER_CLIENT_ID || '',
},
},
// 确保 Vditor 样式在 global.css 覆盖前加载
css: ['vditor/dist/index.css', '~/assets/fonts.css', '~/assets/global.css'],
app: {
pageTransition: { name: 'page', mode: 'out-in' },

View File

@@ -2,7 +2,7 @@
<div class="not-found-page">
<h1>404 - 页面不存在</h1>
<p>你访问的页面不存在或已被删除</p>
<NuxtLink to="/">返回首页</NuxtLink>
<router-link to="/">返回首页</router-link>
</div>
</template>

View File

@@ -1,8 +1,5 @@
<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"
@@ -54,10 +51,8 @@ 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}` }
@@ -98,7 +93,6 @@ async function loadData() {
const data = await commentRes.json()
commentOption.value = toOption('每日回贴量', data)
}
isLoading.value = false
}
onMounted(loadData)
@@ -111,11 +105,4 @@ onMounted(loadData)
background-color: var(--background-color);
margin: 0 auto;
}
.loading-message {
display: flex;
justify-content: center;
align-items: center;
height: 400px;
}
</style>

View File

@@ -25,7 +25,6 @@
</div>
</div>
<MilkTeaActivityComponent v-if="a.type === 'MILK_TEA'" />
<InviteCodeActivityComponent v-if="a.type === 'INVITE_POINTS'" />
</div>
</div>
</template>
@@ -33,7 +32,6 @@
<script setup>
import TimeManager from '~/utils/time'
import MilkTeaActivityComponent from '~/components/MilkTeaActivityComponent.vue'
import InviteCodeActivityComponent from '~/components/InviteCodeActivityComponent.vue'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
@@ -77,7 +75,6 @@ onMounted(async () => {
background-color: var(--activity-card-background-color);
border-radius: 20px;
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);
margin-bottom: 10px;
}
.activity-card-left-avatar-img {
@@ -144,10 +141,6 @@ onMounted(async () => {
color: inherit;
}
.activity-card-normal-right {
width: 100%;
}
@media screen and (max-width: 768px) {
.activity-card-left-avatar-img {
width: 80px;

View File

@@ -1,4 +1,3 @@
<!-- pages/discord-callback.vue -->
<template>
<CallbackPage />
</template>
@@ -9,30 +8,9 @@ import { discordExchange } from '~/utils/discord'
onMounted(async () => {
const url = new URL(window.location.href)
const code = url.searchParams.get('code') || ''
const stateStr = url.searchParams.get('state') || ''
// 从 state 解析 invite_token兜底支持 query ?invite_token=
let inviteToken = ''
if (stateStr) {
try {
const s = new URLSearchParams(stateStr)
inviteToken = s.get('invite_token') || s.get('invitetoken') || ''
} catch {}
}
// if (!inviteToken) {
// inviteToken =
// url.searchParams.get('invite_token') ||
// url.searchParams.get('invitetoken') ||
// ''
// }
if (!code) {
navigateTo('/login', { replace: true })
return
}
const result = await discordExchange(code, inviteToken, '')
const code = url.searchParams.get('code')
const state = url.searchParams.get('state')
const result = await discordExchange(code, state, '')
if (result.needReason) {
navigateTo(`/signup-reason?token=${result.token}`, { replace: true })

View File

@@ -1,4 +1,3 @@
<!-- pages/github-callback.vue -->
<template>
<CallbackPage />
</template>
@@ -9,31 +8,9 @@ import { githubExchange } from '~/utils/github'
onMounted(async () => {
const url = new URL(window.location.href)
const code = url.searchParams.get('code') || ''
const state = url.searchParams.get('state') || ''
// 从 state 中解析 invite_tokengithubAuthorize 已把它放进 state
let inviteToken = ''
if (state) {
try {
const s = new URLSearchParams(state)
inviteToken = s.get('invite_token') || s.get('invitetoken') || ''
} catch {}
}
// 兜底也支持直接跟在回调URL的查询参数上
// if (!inviteToken) {
// inviteToken =
// url.searchParams.get('invite_token') ||
// url.searchParams.get('invitetoken') ||
// ''
// }
if (!code) {
navigateTo('/login', { replace: true })
return
}
const result = await githubExchange(code, inviteToken, '')
const code = url.searchParams.get('code')
const state = url.searchParams.get('state')
const result = await githubExchange(code, state, '')
if (result.needReason) {
navigateTo(`/signup-reason?token=${result.token}`, { replace: true })

View File

@@ -9,21 +9,6 @@ import { googleAuthWithToken } from '~/utils/google'
onMounted(async () => {
const hash = new URLSearchParams(window.location.hash.substring(1))
const idToken = hash.get('id_token')
// 优先从 state 中解析
let inviteToken = ''
const stateStr = hash.get('state') || ''
if (stateStr) {
const state = new URLSearchParams(stateStr)
inviteToken = state.get('invite_token') || ''
}
// 兜底:如果之前把 invite_token 放在回调 URL 的查询参数中
// if (!inviteToken) {
// const query = new URLSearchParams(window.location.search)
// inviteToken = query.get('invite_token') || ''
// }
if (idToken) {
await googleAuthWithToken(
idToken,
@@ -33,7 +18,6 @@ onMounted(async () => {
(token) => {
navigateTo(`/signup-reason?token=${token}`, { replace: true })
},
{ inviteToken },
)
} else {
navigateTo('/login', { replace: true })

View File

@@ -26,10 +26,7 @@
<div class="article-container">
<template
v-if="
selectedTopic === '最新' ||
selectedTopic === '排行榜' ||
selectedTopic === '最新回复' ||
selectedTopic === '精选'
selectedTopic === '最新' || selectedTopic === '排行榜' || selectedTopic === '最新回复'
"
>
<div class="article-header-container">
@@ -105,33 +102,25 @@
</div>
</div>
</template>
<div v-else-if="selectedTopic === '热门'" class="placeholder-container">
热门帖子功能开发中,敬请期待。
</div>
<div v-else class="placeholder-container">分类浏览功能开发中,敬请期待。</div>
<!-- ✅ 通用“底部加载更多”组件(自管 loading/observer/并发) -->
<InfiniteLoadMore
v-if="articles.length > 0"
:key="ioKey"
:on-load="fetchNextPage"
:pause="pendingFirst"
root-margin="200px 0px"
/>
<div v-if="isLoadingMore && articles.length > 0" class="loading-container bottom-loading">
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
</div>
</div>
</div>
</template>
<script setup>
import { computed, onMounted, onBeforeUnmount, nextTick, ref, watch } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import ArticleCategory from '~/components/ArticleCategory.vue'
import ArticleTags from '~/components/ArticleTags.vue'
import CategorySelect from '~/components/CategorySelect.vue'
import SearchDropdown from '~/components/SearchDropdown.vue'
import TagSelect from '~/components/TagSelect.vue'
import InfiniteLoadMore from '~/components/InfiniteLoadMore.vue'
import { getToken } from '~/utils/auth'
import { useScrollLoadMore } from '~/utils/loadMore'
import { stripMarkdown } from '~/utils/markdown'
import { useIsMobile } from '~/utils/screen'
import TimeManager from '~/utils/time'
@@ -155,7 +144,9 @@ const route = useRoute()
const tagOptions = ref([])
const categoryOptions = ref([])
const topics = ref(['精选', '最新回复', '最新', '排行榜' /*, '热门', '类别'*/])
const isLoadingMore = ref(false)
const topics = ref(['最新回复', '最新', '排行榜' /*, '热门', '类别'*/])
const selectedTopicCookie = useCookie('homeTab')
const selectedTopic = ref(
selectedTopicCookie.value
@@ -171,6 +162,7 @@ const articles = ref([])
const page = ref(0)
const pageSize = 10
const isMobile = useIsMobile()
const allLoaded = ref(false)
/** URL 参数 -> 本地筛选值 **/
const selectedCategorySet = (category) => {
@@ -239,7 +231,6 @@ 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 }) => {
@@ -295,54 +286,80 @@ const {
},
)
/** 首屏/筛选变更:重置分页并灌入 firstPageInfiniteLoadMore 会凭 key 重建状态) **/
/** 首屏/筛选变更:重置分页并灌入 firstPage **/
watch(
firstPage,
(data) => {
page.value = 0
articles.value = [...(data || [])]
allLoaded.value = (data?.length || 0) < pageSize
},
{ immediate: true },
)
/** —— 提供给 InfiniteLoadMore 的加载函数 —— **/
/** —— 滚动加载更多 —— **/
let inflight = null
const fetchNextPage = async () => {
// 若首屏仍在 pending由组件 pause 控制,这里兜底返回“未完成”
if (pendingFirst.value) return false
if (allLoaded.value || pendingFirst.value || inflight) return
const nextPage = page.value + 1
const res = await $fetch(buildUrl({ pageNo: nextPage }), { headers: tokenHeader.value })
const data = Array.isArray(res) ? res : []
const mapped = data.map((p) => ({
id: p.id,
title: p.title,
description: p.content,
category: p.category,
tags: p.tags || [],
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
comments: p.commentCount,
views: p.views,
time: TimeManager.format(
selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,
),
pinned: Boolean(p.pinned ?? p.pinnedAt ?? p.pinned_at),
type: p.type,
}))
articles.value.push(...mapped)
const done = data.length < pageSize
if (!done) page.value = nextPage
return done // ✅ 返回给组件,决定是否停止观察
isLoadingMore.value = true
inflight = $fetch(buildUrl({ pageNo: nextPage }), { headers: tokenHeader.value })
.then((res) => {
const data = Array.isArray(res) ? res : []
const mapped = data.map((p) => ({
id: p.id,
title: p.title,
description: p.content,
category: p.category,
tags: p.tags || [],
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
comments: p.commentCount,
views: p.views,
time: TimeManager.format(
selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,
),
pinned: Boolean(p.pinned ?? p.pinnedAt ?? p.pinned_at),
type: p.type,
}))
articles.value.push(...mapped)
if (data.length < pageSize) {
allLoaded.value = true
} else {
page.value = nextPage
}
})
.finally(() => {
inflight = null
isLoadingMore.value = false
})
}
/** 选项首屏加载与状态持久 **/
/** 绑定滚动加载(避免挂载瞬间触发) **/
let initialReady = false
const loadMoreGuarded = async () => {
if (!initialReady) return
await fetchNextPage()
}
useScrollLoadMore(loadMoreGuarded)
watch(
articles,
() => {
if (!initialReady && articles.value.length) initialReady = true
},
{ immediate: true },
)
/** 切换分类/标签/TabuseAsyncData 已 watch这里只需确保 options 加载 **/
watch([selectedCategory, selectedTags], () => {
loadOptions()
})
watch(selectedTopic, (val) => {
// 仅当需要额外选项时加载
loadOptions()
selectedTopicCookie.value = val
if (import.meta.client) localStorage.setItem('homeTab', val)
if (process.client) {
localStorage.setItem('homeTab', val)
}
})
/** 选项首屏加载:服务端执行一次;客户端兜底 **/
@@ -351,14 +368,9 @@ if (import.meta.server) {
}
onMounted(() => {
if (categoryOptions.value.length === 0 && tagOptions.value.length === 0) loadOptions()
window.addEventListener('refresh-home', refreshFirst)
})
onBeforeUnmount(() => {
window.removeEventListener('refresh-home', refreshFirst)
})
/** 供 InfiniteLoadMore 重建用的 key筛选/Tab 改变即重建内部状态 */
const ioKey = computed(() => asyncKey.value.join('::'))
/** 其他工具函数 **/
const sanitizeDescription = (text) => stripMarkdown(text)
@@ -395,7 +407,6 @@ const sanitizeDescription = (text) => stripMarkdown(text)
height: 200px;
}
/* 这里的 bottom-loading 可保留给首屏 loading 使用InfiniteLoadMore 自带同名样式也兼容 */
.bottom-loading {
height: 100px;
}
@@ -539,7 +550,6 @@ const sanitizeDescription = (text) => stripMarkdown(text)
}
.article-item-description {
max-width: 100%;
margin-top: 10px;
font-size: 14px;
color: gray;

View File

@@ -35,7 +35,7 @@
</div>
<div class="other-login-page-content">
<div class="login-page-button" @click="loginWithGoogle">
<div class="login-page-button" @click="googleAuthorize">
<img class="login-page-button-icon" src="../assets/icons/google.svg" alt="Google Logo" />
<div class="login-page-button-text">Google 登录</div>
</div>
@@ -106,9 +106,6 @@ const submitLogin = async () => {
}
}
const loginWithGoogle = () => {
googleAuthorize()
}
const loginWithGithub = () => {
githubAuthorize()
}

View File

@@ -33,13 +33,15 @@
<div v-if="selectedTab === 'control'">
<div class="message-control-container">
<div class="message-control-title">通知设置</div>
<div class="message-control-item-container">
<div v-for="pref in notificationPrefs" :key="pref.type" class="message-control-item">
<div class="message-control-item-label">{{ formatType(pref.type) }}</div>
<BaseSwitch
:model-value="pref.enabled"
@update:modelValue="(val) => togglePref(pref, val)"
/>
<div class="message-control-push-item-container">
<div
v-for="pref in notificationPrefs"
:key="pref.type"
class="message-control-push-item"
:class="{ select: pref.enabled }"
@click="togglePref(pref)"
>
{{ formatType(pref.type) }}
</div>
</div>
</div>
@@ -51,74 +53,74 @@
</div>
<BasePlaceholder
v-else-if="notifications.length === 0"
v-else-if="filteredNotifications.length === 0"
text="暂时没有消息 :)"
icon="fas fa-inbox"
/>
<div class="timeline-container" v-if="notifications.length > 0">
<BaseTimeline :items="notifications">
<div class="timeline-container" v-if="filteredNotifications.length > 0">
<BaseTimeline :items="filteredNotifications">
<template #item="{ item }">
<div class="notif-content" :class="{ read: item.read }">
<span v-if="!item.read" class="unread-dot"></span>
<span class="notif-type">
<template v-if="item.type === 'COMMENT_REPLY' && item.parentComment">
<NotificationContainer :item="item" :markRead="markRead">
<NuxtLink
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/users/${item.comment.author.id}`"
>{{ item.comment.author.username }}
</NuxtLink>
</router-link>
对我的评论
<span>
<NuxtLink
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/posts/${item.post.id}#comment-${item.parentComment.id}`"
>
{{ stripMarkdownLength(item.parentComment.content, 100) }}
</NuxtLink>
</router-link>
</span>
回复了
<span>
<NuxtLink
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
>
{{ stripMarkdownLength(item.comment.content, 100) }}
</NuxtLink>
</router-link>
</span>
</NotificationContainer>
</template>
<template v-else-if="item.type === 'COMMENT_REPLY' && !item.parentComment">
<NotificationContainer :item="item" :markRead="markRead">
<NuxtLink
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/users/${item.comment.author.id}`"
>{{ item.comment.author.username }}
</NuxtLink>
</router-link>
对我的文章
<span>
<NuxtLink
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/posts/${item.post.id}`"
>
{{ stripMarkdownLength(item.post.title, 100) }}
</NuxtLink>
</router-link>
</span>
回复了
<span>
<NuxtLink
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
>
{{ stripMarkdownLength(item.comment.content, 100) }}
</NuxtLink>
</router-link>
</span>
</NotificationContainer>
</template>
@@ -138,310 +140,310 @@
<NotificationContainer :item="item" :markRead="markRead">
<span class="notif-user">{{ item.fromUser.username }} </span> 对我的文章
<span>
<NuxtLink
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/posts/${item.post.id}`"
>
{{ stripMarkdownLength(item.post.title, 100) }}
</NuxtLink>
</router-link>
</span>
进行了表态
</NotificationContainer>
</template>
<template v-else-if="item.type === 'REACTION' && item.comment">
<NotificationContainer :item="item" :markRead="markRead">
<NuxtLink
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/users/${item.fromUser.id}`"
>{{ item.fromUser.username }}
</NuxtLink>
</router-link>
对我的评论
<span>
<NuxtLink
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
>
{{ stripMarkdownLength(item.comment.content, 100) }}
</NuxtLink>
</router-link>
</span>
进行了表态
</NotificationContainer>
</template>
<template v-else-if="item.type === 'POST_VIEWED'">
<NotificationContainer :item="item" :markRead="markRead">
<NuxtLink
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/users/${item.fromUser.id}`"
>
{{ item.fromUser.username }}
</NuxtLink>
</router-link>
查看了您的帖子
<NuxtLink
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/posts/${item.post.id}`"
>
{{ stripMarkdownLength(item.post.title, 100) }}
</NuxtLink>
</router-link>
</NotificationContainer>
</template>
<template v-else-if="item.type === 'LOTTERY_WIN'">
<NotificationContainer :item="item" :markRead="markRead">
恭喜你在抽奖贴
<NuxtLink
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/posts/${item.post.id}`"
>
{{ stripMarkdownLength(item.post.title, 100) }}
</NuxtLink>
</router-link>
中获奖
</NotificationContainer>
</template>
<template v-else-if="item.type === 'LOTTERY_DRAW'">
<NotificationContainer :item="item" :markRead="markRead">
您的抽奖贴
<NuxtLink
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/posts/${item.post.id}`"
>
{{ stripMarkdownLength(item.post.title, 100) }}
</NuxtLink>
</router-link>
已开奖
</NotificationContainer>
</template>
<template v-else-if="item.type === 'POST_UPDATED'">
<NotificationContainer :item="item" :markRead="markRead">
您关注的帖子
<NuxtLink
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/posts/${item.post.id}`"
>
{{ stripMarkdownLength(item.post.title, 100) }}
</NuxtLink>
</router-link>
下面有新评论
<NuxtLink
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
>
{{ stripMarkdownLength(item.comment.content, 100) }}
</NuxtLink>
</router-link>
</NotificationContainer>
</template>
<template v-else-if="item.type === 'USER_ACTIVITY' && item.parentComment">
<NotificationContainer :item="item" :markRead="markRead">
你关注的
<NuxtLink
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/users/${item.comment.author.id}`"
>
{{ item.comment.author.username }}
</NuxtLink>
</router-link>
对评论
<NuxtLink
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/posts/${item.post.id}#comment-${item.parentComment.id}`"
>
{{ stripMarkdownLength(item.parentComment.content, 100) }}
</NuxtLink>
</router-link>
回复了
<NuxtLink
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
>
{{ stripMarkdownLength(item.comment.content, 100) }}
</NuxtLink>
</router-link>
</NotificationContainer>
</template>
<template v-else-if="item.type === 'USER_ACTIVITY'">
<NotificationContainer :item="item" :markRead="markRead">
你关注的
<NuxtLink
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/users/${item.comment.author.id}`"
>
{{ item.comment.author.username }}
</NuxtLink>
</router-link>
在文章
<NuxtLink
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/posts/${item.post.id}`"
>
{{ stripMarkdownLength(item.post.title, 100) }}
</NuxtLink>
</router-link>
下面评论了
<NuxtLink
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
>
{{ stripMarkdownLength(item.comment.content, 100) }}
</NuxtLink>
</router-link>
</NotificationContainer>
</template>
<template v-else-if="item.type === 'MENTION' && item.comment">
<NotificationContainer :item="item" :markRead="markRead">
<NuxtLink
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/users/${item.fromUser.id}`"
>
{{ item.fromUser.username }}
</NuxtLink>
</router-link>
在评论中提到了你
<NuxtLink
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/posts/${item.post.id}#comment-${item.comment.id}`"
>
{{ stripMarkdownLength(item.comment.content, 100) }}
</NuxtLink>
</router-link>
</NotificationContainer>
</template>
<template v-else-if="item.type === 'MENTION'">
<NotificationContainer :item="item" :markRead="markRead">
<NuxtLink
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/users/${item.fromUser.id}`"
>
{{ item.fromUser.username }}
</NuxtLink>
</router-link>
在帖子
<NuxtLink
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/posts/${item.post.id}`"
>
{{ stripMarkdownLength(item.post.title, 100) }}
</NuxtLink>
</router-link>
中提到了你
</NotificationContainer>
</template>
<template v-else-if="item.type === 'USER_FOLLOWED'">
<NotificationContainer :item="item" :markRead="markRead">
<NuxtLink
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/users/${item.fromUser.id}`"
>
{{ item.fromUser.username }}
</NuxtLink>
</router-link>
开始关注你了
</NotificationContainer>
</template>
<template v-else-if="item.type === 'USER_UNFOLLOWED'">
<NotificationContainer :item="item" :markRead="markRead">
<NuxtLink
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/users/${item.fromUser.id}`"
>
{{ item.fromUser.username }}
</NuxtLink>
</router-link>
取消关注你了
</NotificationContainer>
</template>
<template v-else-if="item.type === 'FOLLOWED_POST'">
<NotificationContainer :item="item" :markRead="markRead">
你关注的
<NuxtLink
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/users/${item.fromUser.id}`"
>
{{ item.fromUser.username }}
</NuxtLink>
</router-link>
发布了文章
<NuxtLink
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/posts/${item.post.id}`"
>
{{ stripMarkdownLength(item.post.title, 100) }}
</NuxtLink>
</router-link>
</NotificationContainer>
</template>
<template v-else-if="item.type === 'POST_SUBSCRIBED'">
<NotificationContainer :item="item" :markRead="markRead">
<NuxtLink
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/users/${item.fromUser.id}`"
>
{{ item.fromUser.username }}
</NuxtLink>
</router-link>
订阅了你的文章
<NuxtLink
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/posts/${item.post.id}`"
>
{{ stripMarkdownLength(item.post.title, 100) }}
</NuxtLink>
</router-link>
</NotificationContainer>
</template>
<template v-else-if="item.type === 'POST_UNSUBSCRIBED'">
<NotificationContainer :item="item" :markRead="markRead">
<NuxtLink
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/users/${item.fromUser.id}`"
>
{{ item.fromUser.username }}
</NuxtLink>
</router-link>
取消订阅了你的文章
<NuxtLink
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/posts/${item.post.id}`"
>
{{ stripMarkdownLength(item.post.title, 100) }}
</NuxtLink>
</router-link>
</NotificationContainer>
</template>
<template v-else-if="item.type === 'POST_REVIEW_REQUEST' && item.fromUser">
<NotificationContainer :item="item" :markRead="markRead">
<NuxtLink
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/users/${item.fromUser.id}`"
>
{{ item.fromUser.username }}
</NuxtLink>
</router-link>
发布了帖子
<NuxtLink
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/posts/${item.post.id}`"
>
{{ stripMarkdownLength(item.post.title, 100) }}
</NuxtLink>
</router-link>
请审核
</NotificationContainer>
</template>
<template v-else-if="item.type === 'POST_REVIEW_REQUEST'">
<NotificationContainer :item="item" :markRead="markRead">
您发布的帖子
<NuxtLink
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/posts/${item.post.id}`"
>
{{ stripMarkdownLength(item.post.title, 100) }}
</NuxtLink>
</router-link>
已提交审核
</NotificationContainer>
</template>
@@ -470,60 +472,29 @@
<template v-else-if="item.type === 'POST_REVIEWED' && item.approved">
<NotificationContainer :item="item" :markRead="markRead">
您发布的帖子
<NuxtLink
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/posts/${item.post.id}`"
>
{{ stripMarkdownLength(item.post.title, 100) }}
</NuxtLink>
</router-link>
已审核通过
</NotificationContainer>
</template>
<template v-else-if="item.type === 'POST_REVIEWED' && item.approved === false">
<NotificationContainer :item="item" :markRead="markRead">
您发布的帖子
<NuxtLink
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/posts/${item.post.id}`"
>
{{ stripMarkdownLength(item.post.title, 100) }}
</NuxtLink>
</router-link>
已被管理员拒绝
</NotificationContainer>
</template>
<template v-else-if="item.type === 'POST_FEATURED'">
<NotificationContainer :item="item" :markRead="markRead">
您的文章
<NuxtLink
class="notif-content-text"
@click="markRead(item.id)"
:to="`/posts/${item.post.id}`"
>
{{ stripMarkdownLength(item.post.title, 100) }}
</NuxtLink>
被收录为精选
</NotificationContainer>
</template>
<template v-else-if="item.type === 'POST_DELETED'">
<NotificationContainer :item="item" :markRead="markRead">
管理员
<template v-if="item.fromUser">
<NuxtLink
class="notif-content-text"
@click="markRead(item.id)"
:to="`/users/${item.fromUser.id}`"
>
{{ item.fromUser.username }}
</NuxtLink>
</template>
删除了您的帖子
<span class="notif-content-text">
{{ stripMarkdownLength(item.content, 100) }}
</span>
</NotificationContainer>
</template>
<template v-else>
<NotificationContainer :item="item" :markRead="markRead">
{{ formatType(item.type) }}
@@ -534,18 +505,16 @@
</div>
</template>
</BaseTimeline>
<InfiniteLoadMore :key="selectedTab" :on-load="loadMore" :pause="isLoadingMessage" />
</div>
</template>
</div>
</template>
<script setup>
import { ref, watch, onActivated } from 'vue'
import { computed, onMounted, ref } from 'vue'
import BasePlaceholder from '~/components/BasePlaceholder.vue'
import BaseTimeline from '~/components/BaseTimeline.vue'
import NotificationContainer from '~/components/NotificationContainer.vue'
import InfiniteLoadMore from '~/components/InfiniteLoadMore.vue'
import { toast } from '~/main'
import { authState, getToken } from '~/utils/auth'
import { stripMarkdownLength } from '~/utils/markdown'
@@ -553,15 +522,11 @@ import {
fetchNotifications,
fetchUnreadCount,
isLoadingMessage,
markNotificationRead,
markRead,
notifications,
markAllRead,
hasMore,
fetchNotificationPreferences,
updateNotificationPreference,
} from '~/utils/notification'
import TimeManager from '~/utils/time'
import BaseSwitch from '~/components/BaseSwitch.vue'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
@@ -570,53 +535,25 @@ const selectedTab = ref(
['all', 'unread', 'control'].includes(route.query.tab) ? route.query.tab : 'unread',
)
const notificationPrefs = ref([])
const page = ref(0)
const pageSize = 30
const loadMore = async () => {
if (!hasMore.value) return true
page.value++
await fetchNotifications({
page: page.value,
size: pageSize,
unread: selectedTab.value === 'unread',
append: true,
})
return !hasMore.value
}
watch(selectedTab, async (tab) => {
page.value = 0
await fetchNotifications({ page: 0, size: pageSize, unread: tab === 'unread' })
})
const filteredNotifications = computed(() =>
selectedTab.value === 'all' ? notifications.value : notifications.value.filter((n) => !n.read),
)
const fetchPrefs = async () => {
notificationPrefs.value = await fetchNotificationPreferences()
}
const togglePref = async (pref, value) => {
const ok = await updateNotificationPreference(pref.type, value)
const togglePref = async (pref) => {
const ok = await updateNotificationPreference(pref.type, !pref.enabled)
if (ok) {
pref.enabled = value
await fetchNotifications({
page: page.value,
size: pageSize,
unread: selectedTab.value === 'unread',
})
pref.enabled = !pref.enabled
await fetchNotifications()
await fetchUnreadCount()
} else {
toast.error('操作失败')
}
}
const markRead = async (id) => {
markNotificationRead(id)
if (selectedTab.value === 'unread') {
const index = notifications.value.findIndex((n) => n.id === id)
if (index !== -1) notifications.value.splice(index, 1)
}
}
const approve = async (id, nid) => {
const token = getToken()
if (!token) return
@@ -685,18 +622,13 @@ const formatType = (t) => {
return '抽奖中奖了'
case 'LOTTERY_DRAW':
return '抽奖已开奖'
case 'POST_DELETED':
return '帖子被删除'
case 'POST_FEATURED':
return '文章被精选'
default:
return t
}
}
onActivated(async () => {
page.value = 0
await fetchNotifications({ page: 0, size: pageSize, unread: selectedTab.value === 'unread' })
onActivated(() => {
fetchNotifications()
fetchPrefs()
})
</script>
@@ -712,6 +644,8 @@ onActivated(async () => {
.message-page {
background-color: var(--background-color);
overflow-x: hidden;
height: calc(100vh - var(--header-height));
overflow-y: auto;
}
.message-page-header {
@@ -860,21 +794,26 @@ onActivated(async () => {
padding: 20px;
}
.message-control-item-container {
.message-control-push-item-container {
display: flex;
flex-direction: column;
flex-direction: row;
flex-wrap: wrap;
gap: 10px;
}
.message-control-item {
display: flex;
align-items: center;
justify-content: space-between;
max-width: 200px;
.message-control-push-item {
font-size: 14px;
margin-bottom: 5px;
padding: 8px 16px;
border: 1px solid var(--normal-border-color);
border-radius: 10px;
cursor: pointer;
transition: all 0.3s ease;
}
.message-control-item-label {
font-size: 14px;
.message-control-push-item.select {
background-color: var(--primary-color);
color: white;
}
@media (max-width: 768px) {

View File

@@ -1,189 +1,68 @@
<template>
<div class="point-mall-page">
<div class="point-tabs">
<div
:class="['point-tab-item', { selected: selectedTab === 'mall' }]"
@click="selectedTab = 'mall'"
>
积分兑换
</div>
<div
:class="['point-tab-item', { selected: selectedTab === 'history' }]"
@click="selectedTab = 'history'"
>
积分历史
<section class="rules">
<div class="section-title">🎉 积分规则</div>
<div class="section-content">
<div class="section-item" v-for="(rule, idx) in pointRules" :key="idx">{{ rule }}</div>
</div>
</section>
<div class="loading-points-container" v-if="isLoading">
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
</div>
<template v-if="selectedTab === 'mall'">
<div class="point-mall-page-content">
<section class="rules">
<div class="section-title">🎉 积分规则</div>
<div class="section-content">
<div class="section-item" v-for="(rule, idx) in pointRules" :key="idx">{{ rule }}</div>
</div>
</section>
<div class="point-info">
<p v-if="authState.loggedIn && point !== null">
<span><i class="fas fa-coins coin-icon"></i></span>我的积分<span class="point-value">{{
point
}}</span>
</p>
</div>
<div class="loading-points-container" v-if="isLoading">
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
<section class="goods">
<div class="goods-item" v-for="(good, idx) in goods" :key="idx">
<img class="goods-item-image" :src="good.image" alt="good.name" />
<div class="goods-item-name">{{ good.name }}</div>
<div class="goods-item-cost">
<i class="fas fa-coins"></i>
{{ good.cost }} 积分
</div>
<div class="point-info">
<p v-if="authState.loggedIn && point !== null">
<span><i class="fas fa-coins coin-icon"></i></span>我的积分<span
class="point-value"
>{{ point }}</span
>
</p>
<div
class="goods-item-button"
:class="{ disabled: !authState.loggedIn || point === null || point < good.cost }"
@click="openRedeem(good)"
>
兑换
</div>
<section class="goods">
<div class="goods-item" v-for="(good, idx) in goods" :key="idx">
<img class="goods-item-image" :src="good.image" alt="good.name" />
<div class="goods-item-name">{{ good.name }}</div>
<div class="goods-item-cost">
<i class="fas fa-coins"></i>
{{ good.cost }} 积分
</div>
<div
class="goods-item-button"
:class="{ disabled: !authState.loggedIn || point === null || point < good.cost }"
@click="openRedeem(good)"
>
兑换
</div>
</div>
</section>
<RedeemPopup
:visible="dialogVisible"
v-model="contact"
:loading="loading"
@close="closeRedeem"
@submit="submitRedeem"
/>
</div>
</template>
<template v-else>
<div class="loading-points-container" v-if="historyLoading">
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
</div>
<BasePlaceholder v-else-if="histories.length === 0" text="暂无积分记录" icon="fas fa-inbox" />
<div class="timeline-container" v-else>
<BaseTimeline :items="histories">
<template #item="{ item }">
<div class="history-content">
<template v-if="item.type === 'POST'">
发送帖子
<NuxtLink :to="`/posts/${item.postId}`" class="timeline-link">{{
item.postTitle
}}</NuxtLink>
获得{{ item.amount }}积分
</template>
<template v-else-if="item.type === 'COMMENT'">
在文章
<NuxtLink :to="`/posts/${item.postId}`" class="timeline-link">{{
item.postTitle
}}</NuxtLink>
<template v-if="!item.fromUserId">
发送评论
<NuxtLink
:to="`/posts/${item.postId}#comment-${item.commentId}`"
class="timeline-link"
>{{ stripMarkdownLength(item.commentContent, 100) }}</NuxtLink
>
获得{{ item.amount }}积分
</template>
<template v-else>
被评论
<NuxtLink
:to="`/posts/${item.postId}#comment-${item.commentId}`"
class="timeline-link"
>{{ stripMarkdownLength(item.commentContent, 100) }}</NuxtLink
>
获得{{ item.amount }}积分
</template>
</template>
<template v-else-if="item.type === 'POST_LIKED' && item.fromUserId">
帖子
<NuxtLink :to="`/posts/${item.postId}`" class="timeline-link">{{
item.postTitle
}}</NuxtLink>
<NuxtLink :to="`/users/${item.fromUserId}`" class="timeline-link">{{
item.fromUserName
}}</NuxtLink>
按赞获得{{ item.amount }}积分
</template>
<template v-else-if="item.type === 'COMMENT_LIKED' && item.fromUserId">
评论
<NuxtLink
:to="`/posts/${item.postId}#comment-${item.commentId}`"
class="timeline-link"
>{{ stripMarkdownLength(item.commentContent, 100) }}</NuxtLink
>
<NuxtLink :to="`/users/${item.fromUserId}`" class="timeline-link">{{
item.fromUserName
}}</NuxtLink>
按赞获得{{ item.amount }}积分
</template>
<template v-else-if="item.type === 'INVITE' && item.fromUserId">
邀请了好友
<NuxtLink :to="`/users/${item.fromUserId}`" class="timeline-link">{{
item.fromUserName
}}</NuxtLink>
加入社区 🎉获得 {{ item.amount }} 积分
</template>
<template v-else-if="item.type === 'FEATURE'">
文章
<NuxtLink :to="`/posts/${item.postId}`" class="timeline-link">{{
item.postTitle
}}</NuxtLink>
被收录为精选获得 {{ item.amount }} 积分
</template>
<template v-else-if="item.type === 'REDEEM'">
兑换商品消耗 {{ -item.amount }} 积分
</template>
<template v-else-if="item.type === 'SYSTEM_ONLINE'"> 积分历史系统上线 </template>
<i class="fas fa-coins"></i> 你目前的积分是 {{ item.balance }}
</div>
<div class="history-time">{{ TimeManager.format(item.createdAt) }}</div>
</template>
</BaseTimeline>
</div>
</template>
</section>
<RedeemPopup
:visible="dialogVisible"
v-model="contact"
:loading="loading"
@close="closeRedeem"
@submit="submitRedeem"
/>
</div>
</template>
<script setup>
import { onMounted, ref, watch } from 'vue'
import { onMounted, ref } from 'vue'
import { authState, fetchCurrentUser, getToken } from '~/utils/auth'
import { toast } from '~/main'
import RedeemPopup from '~/components/RedeemPopup.vue'
import BaseTimeline from '~/components/BaseTimeline.vue'
import BasePlaceholder from '~/components/BasePlaceholder.vue'
import { stripMarkdownLength } from '~/utils/markdown'
import TimeManager from '~/utils/time'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const selectedTab = ref('mall')
const point = ref(null)
const isLoading = ref(false)
const histories = ref([])
const historyLoading = ref(false)
const historyLoaded = ref(false)
const pointRules = [
'发帖:每天前两次,每次 30 积分',
'评论:每天前四条评论可获 10 积分,你的帖子被评论也可获 10 积分',
'帖子被点赞:每次 10 积分',
'评论被点赞:每次 10 积分',
'邀请好友加入可获得 500 积分/次,注意需要使用邀请链接注册',
'文章被收录至精选:每次 500 积分',
]
const goods = ref([])
@@ -192,17 +71,6 @@ const contact = ref('')
const loading = ref(false)
const selectedGood = ref(null)
const iconMap = {
POST: 'fas fa-file-alt',
COMMENT: 'fas fa-comment',
POST_LIKED: 'fas fa-thumbs-up',
COMMENT_LIKED: 'fas fa-thumbs-up',
INVITE: 'fas fa-user-plus',
SYSTEM_ONLINE: 'fas fa-clock',
REDEEM: 'fas fa-gift',
FEATURE: 'fas fa-star',
}
onMounted(async () => {
isLoading.value = true
if (authState.loggedIn) {
@@ -213,12 +81,6 @@ onMounted(async () => {
isLoading.value = false
})
watch(selectedTab, (val) => {
if (val === 'history' && !historyLoaded.value) {
loadHistory()
}
})
const loadGoods = async () => {
const res = await fetch(`${API_BASE_URL}/api/point-goods`)
if (res.ok) {
@@ -226,26 +88,6 @@ const loadGoods = async () => {
}
}
const loadHistory = async () => {
if (!authState.loggedIn) {
historyLoaded.value = true
return
}
historyLoading.value = true
const token = getToken()
const res = await fetch(`${API_BASE_URL}/api/point-histories`, {
headers: { Authorization: `Bearer ${token}` },
})
if (res.ok) {
histories.value = (await res.json()).map((item) => ({
...item,
icon: iconMap[item.type],
}))
}
historyLoading.value = false
historyLoaded.value = true
}
const openRedeem = (good) => {
if (!authState.loggedIn || point.value === null || point.value < good.cost) {
toast.error('积分不足')
@@ -286,44 +128,12 @@ const submitRedeem = async () => {
<style scoped>
.point-mall-page {
padding-left: 20px;
max-width: var(--page-max-width);
background-color: var(--background-color);
margin: 0 auto;
}
.point-mall-page-content {
padding: 0 20px;
}
.point-tabs {
display: flex;
border-bottom: 1px solid var(--normal-border-color);
}
.point-tab-item {
padding: 10px 15px;
cursor: pointer;
}
.point-tab-item.selected {
border-bottom: 2px solid var(--primary-color);
color: var(--primary-color);
}
.timeline-container {
padding: 10px 20px;
}
.timeline-link {
color: var(--primary-color);
text-decoration: none;
font-weight: bold;
}
.timeline-link:hover {
text-decoration: underline;
}
.loading-points-container {
margin-top: 100px;
display: flex;
@@ -404,17 +214,6 @@ const submitRedeem = async () => {
cursor: not-allowed;
}
.history-content {
font-size: 14px;
opacity: 0.8;
}
.history-time {
font-size: 12px;
color: var(--text-color);
opacity: 0.7;
}
.section-title {
font-size: 18px;
font-weight: bold;

View File

@@ -15,9 +15,8 @@
<div class="article-title-container-right">
<div v-if="status === 'PENDING'" class="article-pending-button">审核中</div>
<div v-if="status === 'REJECTED'" class="article-block-button">已拒绝</div>
<div v-if="closed" class="article-closed-button">已关闭</div>
<div
v-if="!closed && loggedIn && !isAuthor && !subscribed"
v-if="loggedIn && !isAuthor && !subscribed"
class="article-subscribe-button"
@click="subscribePost"
>
@@ -27,7 +26,7 @@
</div>
</div>
<div
v-if="!closed && loggedIn && !isAuthor && subscribed"
v-if="loggedIn && !isAuthor && subscribed"
class="article-unsubscribe-button"
@click="unsubscribePost"
>
@@ -53,11 +52,11 @@
<div class="user-name">
{{ author.username }}
<i class="fas fa-medal medal-icon"></i>
<NuxtLink
<router-link
v-if="author.displayMedal"
class="user-medal"
:to="`/users/${author.id}?tab=achievements`"
>{{ getMedalTitle(author.displayMedal) }}</NuxtLink
>{{ getMedalTitle(author.displayMedal) }}</router-link
>
</div>
<div class="post-time">{{ postTime }}</div>
@@ -69,11 +68,11 @@
<div class="user-name">
{{ author.username }}
<i class="fas fa-medal medal-icon"></i>
<NuxtLink
<router-link
v-if="author.displayMedal"
class="user-medal"
:to="`/users/${author.id}?tab=achievements`"
>{{ getMedalTitle(author.displayMedal) }}</NuxtLink
>{{ getMedalTitle(author.displayMedal) }}</router-link
>
</div>
<div class="post-time">{{ postTime }}</div>
@@ -168,13 +167,11 @@
</div>
</div>
<div v-if="closed" class="post-close-container">该帖子已关闭内容仅供阅读无法进行互动</div>
<ClientOnly>
<CommentEditor
@submit="postComment"
:loading="isWaitingPostingComment"
:disabled="!loggedIn || closed"
:disabled="!loggedIn"
:show-login-overlay="!loggedIn"
:parent-user-name="author.username"
/>
@@ -199,7 +196,6 @@
:level="0"
:default-show-replies="item.openReplies"
:post-author-id="author.id"
:post-closed="closed"
@deleted="onCommentDeleted"
/>
</template>
@@ -236,16 +232,7 @@
</template>
<script setup>
import {
ref,
computed,
onMounted,
onBeforeUnmount,
nextTick,
watch,
watchEffect,
onActivated,
} from 'vue'
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch, watchEffect } from 'vue'
import VueEasyLightbox from 'vue-easy-lightbox'
import { useRoute } from 'vue-router'
import CommentItem from '~/components/CommentItem.vue'
@@ -264,8 +251,6 @@ import { useRouter } from 'vue-router'
import { useIsMobile } from '~/utils/screen'
import Dropdown from '~/components/Dropdown.vue'
import { ClientOnly } from '#components'
import { useConfirm } from '~/composables/useConfirm'
const { confirm } = useConfirm()
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
@@ -282,9 +267,7 @@ const tags = ref([])
const postReactions = ref([])
const comments = ref([])
const status = ref('PUBLISHED')
const closed = ref(false)
const pinnedAt = ref(null)
const rssExcluded = ref(false)
const isWaitingPostingComment = ref(false)
const postTime = ref('')
const postItems = ref([])
@@ -295,7 +278,7 @@ const commentSort = ref('NEWEST')
const isFetchingComments = ref(false)
const isMobile = useIsMobile()
const headerHeight = import.meta.client
const headerHeight = process.client
? parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--header-height')) || 0
: 0
@@ -309,7 +292,7 @@ useHead(() => ({
],
}))
if (import.meta.client) {
if (process.client) {
onBeforeUnmount(() => {
window.removeEventListener('scroll', updateCurrentIndex)
if (countdownTimer) clearInterval(countdownTimer)
@@ -355,7 +338,7 @@ const updateCountdown = () => {
countdown.value = `${h}:${m}:${s}`
}
const startCountdown = () => {
if (!import.meta.client) return
if (!process.client) return
if (countdownTimer) clearInterval(countdownTimer)
updateCountdown()
countdownTimer = setInterval(updateCountdown, 1000)
@@ -365,12 +348,7 @@ const articleMenuItems = computed(() => {
const items = []
if (isAuthor.value || isAdmin.value) {
items.push({ text: '编辑文章', onClick: () => editPost() })
items.push({ text: '删除文章', color: 'red', onClick: deletePost })
if (closed.value) {
items.push({ text: '重新打开帖子', onClick: () => reopenPost() })
} else {
items.push({ text: '关闭帖子', onClick: () => closePost() })
}
items.push({ text: '删除文章', color: 'red', onClick: () => deletePost() })
}
if (isAdmin.value) {
if (pinnedAt.value) {
@@ -378,11 +356,6 @@ const articleMenuItems = computed(() => {
} else {
items.push({ text: '置顶', onClick: () => pinPost() })
}
if (rssExcluded.value) {
items.push({ text: 'rss推荐', onClick: () => includeRss() })
} else {
items.push({ text: '取消rss推荐', onClick: () => excludeRss() })
}
}
if (isAdmin.value && status.value === 'PENDING') {
items.push({ text: '通过审核', onClick: () => approvePost() })
@@ -506,16 +479,14 @@ watchEffect(() => {
postReactions.value = data.reactions || []
subscribed.value = !!data.subscribed
status.value = data.status
closed.value = data.closed
pinnedAt.value = data.pinnedAt
rssExcluded.value = data.rssExcluded
postTime.value = TimeManager.format(data.createdAt)
lottery.value = data.lottery || null
if (lottery.value && lottery.value.endTime) startCountdown()
})
// 404 客户端跳转
// if (postError.value?.statusCode === 404 && import.meta.client) {
// if (postError.value?.statusCode === 404 && process.client) {
// router.replace('/404')
// }
@@ -566,10 +537,6 @@ const onSliderInput = (e) => {
const postComment = async (parentUserName, text, clear) => {
if (!text.trim()) return
if (closed.value) {
toast.error('帖子已关闭')
return
}
console.debug('Posting comment', { postId, text })
isWaitingPostingComment.value = true
const token = getToken()
@@ -678,92 +645,24 @@ const unpinPost = async () => {
}
}
const excludeRss = async () => {
const token = getToken()
if (!token) return
const res = await fetch(`${API_BASE_URL}/api/admin/posts/${postId}/rss-exclude`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
})
if (res.ok) {
rssExcluded.value = true
toast.success('已标记为rss不推荐')
} else {
toast.error('操作失败')
}
}
const includeRss = async () => {
const token = getToken()
if (!token) return
const res = await fetch(`${API_BASE_URL}/api/admin/posts/${postId}/rss-include`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
})
if (res.ok) {
rssExcluded.value = false
toast.success('已标记为rss推荐')
} else {
toast.error('操作失败')
}
}
const closePost = async () => {
const token = getToken()
if (!token) return
const res = await fetch(`${API_BASE_URL}/api/posts/${postId}/close`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
})
if (res.ok) {
closed.value = true
toast.success('已关闭')
await refreshPost()
} else {
toast.error('操作失败')
}
}
const reopenPost = async () => {
const token = getToken()
if (!token) return
const res = await fetch(`${API_BASE_URL}/api/posts/${postId}/reopen`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
})
if (res.ok) {
closed.value = false
toast.success('已重新打开')
await refreshPost()
} else {
toast.error('操作失败')
}
}
const editPost = () => {
navigateTo(`/posts/${postId}/edit`, { replace: true })
}
const deletePost = async () => {
try {
const ok = await confirm('删除帖子', '此操作不可恢复,确认要删除吗?')
if (!ok) return
const token = getToken()
if (!token) {
toast.error('请先登录')
return
}
const res = await fetch(`${API_BASE_URL}/api/posts/${postId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
})
if (res.ok) {
toast.success('已删除')
navigateTo('/', { replace: true })
} else {
toast.error('操作失败')
}
} catch (e) {
const token = getToken()
if (!token) {
toast.error('请先登录')
return
}
const res = await fetch(`${API_BASE_URL}/api/posts/${postId}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
})
if (res.ok) {
toast.success('已删除')
navigateTo('/', { replace: true })
} else {
toast.error('操作失败')
}
}
@@ -876,8 +775,7 @@ const gotoProfile = () => {
navigateTo(`/users/${author.value.id}`, { replace: true })
}
const initPage = async () => {
scrollTo(0, 0)
onMounted(async () => {
await fetchComments()
const hash = location.hash
const id = hash.startsWith('#comment-') ? hash.substring('#comment-'.length) : null
@@ -885,14 +783,6 @@ const initPage = async () => {
updateCurrentIndex()
window.addEventListener('scroll', updateCurrentIndex)
jumpToHashComment()
}
onActivated(async () => {
await initPage()
})
onMounted(async () => {
await initPage()
})
</script>
@@ -918,10 +808,6 @@ onMounted(async () => {
width: calc(85% - 40px);
}
.info-content-text p code {
padding: 2px 4px;
}
.post-page-scroller-container {
display: flex;
flex-direction: column;
@@ -945,18 +831,6 @@ onMounted(async () => {
gap: 10px;
}
.post-close-container {
padding: 40px;
margin-top: 15px;
text-align: center;
font-size: 12px;
color: var(--text-color);
background-color: var(--background-color);
border: 1px dashed var(--normal-border-color);
border-radius: 10px;
opacity: 0.5;
}
.scroller {
margin-top: 20px;
margin-left: 20px;
@@ -1071,7 +945,6 @@ onMounted(async () => {
white-space: nowrap;
}
.article-closed-button,
.article-subscribe-button-text,
.article-unsubscribe-button-text {
white-space: nowrap;
@@ -1114,15 +987,6 @@ onMounted(async () => {
font-size: 14px;
}
.article-closed-button {
background-color: var(--background-color);
color: gray;
border: 1px solid gray;
padding: 5px 10px;
border-radius: 8px;
font-size: 14px;
}
.article-title {
font-size: 30px;
font-weight: bold;

View File

@@ -38,7 +38,10 @@
</div>
<div class="form-row switch-row">
<div class="setting-title">毛玻璃效果</div>
<BaseSwitch v-model="frosted" />
<label class="switch">
<input type="checkbox" v-model="frosted" />
<span class="slider"></span>
</label>
</div>
</div>
<div v-if="role === 'ADMIN'" class="admin-section">
@@ -73,7 +76,6 @@ import { ref, onMounted, watch } from 'vue'
import AvatarCropper from '~/components/AvatarCropper.vue'
import BaseInput from '~/components/BaseInput.vue'
import Dropdown from '~/components/Dropdown.vue'
import BaseSwitch from '~/components/BaseSwitch.vue'
import { toast } from '~/main'
import { fetchCurrentUser, getToken, setToken } from '~/utils/auth'
import { frostedState, setFrosted } from '~/utils/frosted'
@@ -316,6 +318,51 @@ const save = async () => {
max-width: 200px;
}
.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);
}
.profile-section {
margin-bottom: 30px;
}

View File

@@ -36,7 +36,7 @@
class="signup-page-button-primary"
@click="sendVerification"
>
<div class="signup-page-button-text">验证并注册</div>
<div class="signup-page-button-text">验证邮箱</div>
</div>
<div v-else class="signup-page-button-primary disabled">
<div class="signup-page-button-text">
@@ -69,7 +69,7 @@
</div>
<div class="other-signup-page-content">
<div class="signup-page-button" @click="signupWithGoogle">
<div class="signup-page-button" @click="googleAuthorize">
<img class="signup-page-button-icon" src="~/assets/icons/google.svg" alt="Google Logo" />
<div class="signup-page-button-text">Google 注册</div>
</div>
@@ -96,9 +96,6 @@ import { discordAuthorize } from '~/utils/discord'
import { githubAuthorize } from '~/utils/github'
import { googleAuthorize } from '~/utils/google'
import { twitterAuthorize } from '~/utils/twitter'
import { loadCurrentUser, setToken } from '~/utils/auth'
const route = useRoute()
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const emailStep = ref(0)
@@ -112,11 +109,9 @@ const passwordError = ref('')
const code = ref('')
const isWaitingForEmailSent = ref(false)
const isWaitingForEmailVerified = ref(false)
const inviteToken = ref('')
onMounted(async () => {
username.value = route.query.u || ''
inviteToken.value = route.query.invite_token || ''
try {
const res = await fetch(`${API_BASE_URL}/api/config`)
if (res.ok) {
@@ -161,7 +156,6 @@ const sendVerification = async () => {
username: username.value,
email: email.value,
password: password.value,
inviteToken: inviteToken.value,
}),
})
isWaitingForEmailSent.value = false
@@ -194,18 +188,11 @@ const verifyCode = async () => {
})
const data = await res.json()
if (res.ok) {
if (data.reason_code === 'VERIFIED_AND_APPROVED') {
toast.success('注册成功')
setToken(data.token)
loadCurrentUser()
navigateTo('/', { replace: true })
} else if (data.reason_code === 'VERIFIED') {
if (registerMode.value === 'WHITELIST') {
navigateTo(`/signup-reason?token=${data.token}`, { replace: true })
} else {
toast.success('注册成功,请登录')
navigateTo('/login', { replace: true })
}
if (registerMode.value === 'WHITELIST') {
navigateTo(`/signup-reason?token=${data.token}`, { replace: true })
} else {
toast.success('注册成功,请登录')
navigateTo('/login', { replace: true })
}
} else {
toast.error(data.error || '注册失败')
@@ -216,17 +203,14 @@ const verifyCode = async () => {
isWaitingForEmailVerified.value = false
}
}
const signupWithGoogle = () => {
googleAuthorize(inviteToken.value)
}
const signupWithGithub = () => {
githubAuthorize(inviteToken.value)
githubAuthorize()
}
const signupWithDiscord = () => {
discordAuthorize(inviteToken.value)
discordAuthorize()
}
const signupWithTwitter = () => {
twitterAuthorize(inviteToken.value)
twitterAuthorize()
}
</script>

View File

@@ -130,26 +130,26 @@
<BaseTimeline :items="hotReplies">
<template #item="{ item }">
<NuxtLink :to="`/posts/${item.comment.post.id}`" class="timeline-link">
<router-link :to="`/posts/${item.comment.post.id}`" class="timeline-link">
{{ item.comment.post.title }}
</NuxtLink>
</router-link>
<template v-if="item.comment.parentComment">
下对
<NuxtLink
<router-link
:to="`/posts/${item.comment.post.id}#comment-${item.comment.parentComment.id}`"
class="timeline-link"
>
{{ stripMarkdownLength(item.comment.parentComment.content, 200) }}
</NuxtLink>
</router-link>
回复了
</template>
<template v-else> 下评论了 </template>
<NuxtLink
<router-link
:to="`/posts/${item.comment.post.id}#comment-${item.comment.id}`"
class="timeline-link"
>
{{ stripMarkdownLength(item.comment.content, 200) }}
</NuxtLink>
</router-link>
<div class="timeline-date">
{{ formatDate(item.comment.createdAt) }}
</div>
@@ -165,9 +165,9 @@
<div class="summary-content" v-if="hotPosts.length > 0">
<BaseTimeline :items="hotPosts">
<template #item="{ item }">
<NuxtLink :to="`/posts/${item.post.id}`" class="timeline-link">
<router-link :to="`/posts/${item.post.id}`" class="timeline-link">
{{ item.post.title }}
</NuxtLink>
</router-link>
<div class="timeline-snippet">
{{ stripMarkdown(item.post.snippet) }}
</div>
@@ -236,44 +236,44 @@
<template #item="{ item }">
<template v-if="item.type === 'post'">
发布了文章
<NuxtLink :to="`/posts/${item.post.id}`" class="timeline-link">
<router-link :to="`/posts/${item.post.id}`" class="timeline-link">
{{ item.post.title }}
</NuxtLink>
</router-link>
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
</template>
<template v-else-if="item.type === 'comment'">
<NuxtLink :to="`/posts/${item.comment.post.id}`" class="timeline-link">
<router-link :to="`/posts/${item.comment.post.id}`" class="timeline-link">
{{ item.comment.post.title }}
</NuxtLink>
</router-link>
下评论了
<NuxtLink
<router-link
:to="`/posts/${item.comment.post.id}#comment-${item.comment.id}`"
class="timeline-link"
>
{{ stripMarkdownLength(item.comment.content, 200) }}
</NuxtLink>
</router-link>
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
</template>
<template v-else-if="item.type === 'reply'">
<NuxtLink :to="`/posts/${item.comment.post.id}`" class="timeline-link">
<router-link :to="`/posts/${item.comment.post.id}`" class="timeline-link">
{{ item.comment.post.title }}
</NuxtLink>
</router-link>
下对
<NuxtLink
<router-link
:to="`/posts/${item.comment.post.id}#comment-${item.comment.parentComment.id}`"
class="timeline-link"
>
{{ stripMarkdownLength(item.comment.parentComment.content, 200) }}
</NuxtLink>
</router-link>
回复了
<NuxtLink
<router-link
:to="`/posts/${item.comment.post.id}#comment-${item.comment.id}`"
class="timeline-link"
>
{{ stripMarkdownLength(item.comment.content, 200) }}
</NuxtLink>
</router-link>
<div class="timeline-date">{{ formatDate(item.createdAt) }}</div>
</template>
<template v-else-if="item.type === 'tag'">

View File

@@ -1,7 +1,7 @@
import { clearToken } from '~/utils/auth'
export default defineNuxtPlugin(() => {
if (import.meta.client) {
if (process.client) {
const originalFetch = window.fetch
window.fetch = async (input, init) => {
const response = await originalFetch(input, init)

View File

@@ -4,7 +4,7 @@ import '~/assets/toast.css'
export default defineNuxtPlugin(async (nuxtApp) => {
// 确保只在客户端环境中注册插件
if (import.meta.client) {
if (process.client) {
try {
// 使用动态导入来避免 CommonJS 模块问题
const { default: Toast, POSITION } = await import('vue-toastification')

View File

@@ -1 +0,0 @@
1839503219847005265

View File

@@ -12,7 +12,7 @@ export const authState = reactive({
role: null,
})
if (import.meta.client) {
if (process.client) {
authState.loggedIn =
localStorage.getItem(TOKEN_KEY) !== null && localStorage.getItem(TOKEN_KEY) !== ''
authState.userId = localStorage.getItem(USER_ID_KEY)
@@ -21,18 +21,18 @@ if (import.meta.client) {
}
export function getToken() {
return import.meta.client ? localStorage.getItem(TOKEN_KEY) : null
return process.client ? localStorage.getItem(TOKEN_KEY) : null
}
export function setToken(token) {
if (import.meta.client) {
if (process.client) {
localStorage.setItem(TOKEN_KEY, token)
authState.loggedIn = true
}
}
export function clearToken() {
if (import.meta.client) {
if (process.client) {
localStorage.removeItem(TOKEN_KEY)
clearUserInfo()
authState.loggedIn = false
@@ -40,7 +40,7 @@ export function clearToken() {
}
export function setUserInfo({ id, username }) {
if (import.meta.client) {
if (process.client) {
authState.userId = id
authState.username = username
if (arguments[0] && arguments[0].role) {
@@ -53,7 +53,7 @@ export function setUserInfo({ id, username }) {
}
export function clearUserInfo() {
if (import.meta.client) {
if (process.client) {
localStorage.removeItem(USER_ID_KEY)
localStorage.removeItem(USERNAME_KEY)
localStorage.removeItem(ROLE_KEY)

View File

@@ -2,7 +2,7 @@ import { toast } from '../main'
import { setToken, loadCurrentUser } from './auth'
import { registerPush } from './push'
export function discordAuthorize(inviteToken = '') {
export function discordAuthorize(state = '') {
const config = useRuntimeConfig()
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
const DISCORD_CLIENT_ID = config.public.discordClientId
@@ -10,60 +10,62 @@ export function discordAuthorize(inviteToken = '') {
toast.error('Discord 登录不可用')
return
}
const redirectUri = `${WEBSITE_BASE_URL}/discord-callback`
// 用 state 明文携带 invite_token仅用于回传不再透传给后端
const state = new URLSearchParams({ invite_token: inviteToken }).toString()
const url =
`https://discord.com/api/oauth2/authorize` +
`?client_id=${encodeURIComponent(DISCORD_CLIENT_ID)}` +
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
`&response_type=code` +
`&scope=${encodeURIComponent('identify email')}` +
`&state=${encodeURIComponent(state)}`
const url = `https://discord.com/api/oauth2/authorize?client_id=${DISCORD_CLIENT_ID}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=identify%20email&state=${state}`
window.location.href = url
}
export async function discordExchange(code, inviteToken = '', reason = '') {
export async function discordExchange(code, state, reason) {
try {
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const payload = {
code,
redirectUri: `${window.location.origin}/discord-callback`,
reason,
}
if (inviteToken) payload.inviteToken = inviteToken // 明文传给后端
const res = await fetch(`${API_BASE_URL}/api/auth/discord`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(payload),
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
code,
redirectUri: `${window.location.origin}/discord-callback`,
reason,
state,
}),
})
const data = await res.json()
if (res.ok && data.token) {
setToken(data.token)
await loadCurrentUser()
toast.success('登录成功')
registerPush?.()
return { success: true, needReason: false }
registerPush()
return {
success: true,
needReason: false,
}
} else if (data.reason_code === 'NOT_APPROVED') {
toast.info('当前为注册审核模式,请填写注册理由')
return { success: false, needReason: true, token: data.token }
return {
success: false,
needReason: true,
token: data.token,
}
} else if (data.reason_code === 'IS_APPROVING') {
toast.info('您的注册理由正在审批中')
return { success: true, needReason: false }
return {
success: true,
needReason: false,
}
} else {
toast.error(data.error || '登录失败')
return { success: false, needReason: false, error: data.error || '登录失败' }
return {
success: false,
needReason: false,
error: data.error || '登录失败',
}
}
} catch (e) {
console.error(e)
toast.error('登录失败')
return { success: false, needReason: false, error: '登录失败' }
return {
success: false,
needReason: false,
error: '登录失败',
}
}
}

View File

@@ -2,7 +2,7 @@ import { toast } from '../main'
import { setToken, loadCurrentUser } from './auth'
import { registerPush } from './push'
export function githubAuthorize(inviteToken = '') {
export function githubAuthorize(state = '') {
const config = useRuntimeConfig()
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
const GITHUB_CLIENT_ID = config.public.githubClientId
@@ -10,58 +10,62 @@ export function githubAuthorize(inviteToken = '') {
toast.error('GitHub 登录不可用')
return
}
const redirectUri = `${WEBSITE_BASE_URL}/github-callback`
const state = new URLSearchParams({ invite_token: inviteToken }).toString()
const url =
`https://github.com/login/oauth/authorize` +
`?client_id=${encodeURIComponent(GITHUB_CLIENT_ID)}` +
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
`&scope=${encodeURIComponent('user:email')}` +
`&state=${encodeURIComponent(state)}`
const url = `https://github.com/login/oauth/authorize?client_id=${GITHUB_CLIENT_ID}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=user:email&state=${state}`
window.location.href = url
}
export async function githubExchange(code, inviteToken = '', reason = '') {
export async function githubExchange(code, state, reason) {
try {
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const payload = {
code,
redirectUri: `${window.location.origin}/github-callback`,
reason,
}
if (inviteToken) payload.inviteToken = inviteToken
const res = await fetch(`${API_BASE_URL}/api/auth/github`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
body: JSON.stringify({
code,
redirectUri: `${window.location.origin}/github-callback`,
reason,
state,
}),
})
const data = await res.json()
if (res.ok && data.token) {
setToken(data.token)
await loadCurrentUser()
toast.success('登录成功')
registerPush?.()
return { success: true, needReason: false }
registerPush()
return {
success: true,
needReason: false,
}
} else if (data.reason_code === 'NOT_APPROVED') {
toast.info('当前为注册审核模式,请填写注册理由')
return { success: false, needReason: true, token: data.token }
return {
success: false,
needReason: true,
token: data.token,
}
} else if (data.reason_code === 'IS_APPROVING') {
toast.info('您的注册理由正在审批中')
return { success: true, needReason: false }
return {
success: true,
needReason: false,
}
} else {
toast.error(data.error || '登录失败')
return { success: false, needReason: false, error: data.error || '登录失败' }
return {
success: false,
needReason: false,
error: data.error || '登录失败',
}
}
} catch (e) {
console.error(e)
toast.error('登录失败')
return { success: false, needReason: false, error: '登录失败' }
return {
success: false,
needReason: false,
error: '登录失败',
}
}
}

View File

@@ -21,85 +21,44 @@ export async function googleGetIdToken() {
})
}
export function googleAuthorize(inviteToken = '') {
export function googleAuthorize() {
const config = useRuntimeConfig()
const GOOGLE_CLIENT_ID = config.public.googleClientId
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
if (!GOOGLE_CLIENT_ID) {
toast.error('Google 登录不可用, 请检查网络设置与VPN')
return
}
const redirectUri = `${WEBSITE_BASE_URL}/google-callback`
const nonce = Math.random().toString(36).slice(2)
// 明文放在 state推荐Google 会原样回传)
const state = new URLSearchParams({ invite_token: inviteToken }).toString()
const url =
`https://accounts.google.com/o/oauth2/v2/auth` +
`?client_id=${encodeURIComponent(GOOGLE_CLIENT_ID)}` +
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
`&response_type=id_token` +
`&scope=${encodeURIComponent('openid email profile')}` +
`&nonce=${encodeURIComponent(nonce)}` +
`&state=${encodeURIComponent(state)}`
const nonce = Math.random().toString(36).substring(2)
const url = `https://accounts.google.com/o/oauth2/v2/auth?client_id=${GOOGLE_CLIENT_ID}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=id_token&scope=openid%20email%20profile&nonce=${nonce}`
window.location.href = url
}
export async function googleAuthWithToken(
idToken,
redirect_success,
redirect_not_approved,
options = {}, // { inviteToken?: string }
) {
export async function googleAuthWithToken(idToken, redirect_success, redirect_not_approved) {
try {
if (!idToken) {
toast.error('缺少 id_token')
return
}
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const payload = { idToken }
if (options && options.inviteToken) {
payload.inviteToken = options.inviteToken
}
const res = await fetch(`${API_BASE_URL}/api/auth/google`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(payload),
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ idToken }),
})
const data = await res.json().catch(() => ({}))
if (res.ok && data && data.token) {
const data = await res.json()
if (res.ok && data.token) {
setToken(data.token)
await loadCurrentUser()
toast.success('登录成功')
registerPush?.()
if (typeof redirect_success === 'function') redirect_success()
return
}
if (data && data.reason_code === 'NOT_APPROVED') {
registerPush()
if (redirect_success) redirect_success()
} else if (data.reason_code === 'NOT_APPROVED') {
toast.info('当前为注册审核模式,请填写注册理由')
if (typeof redirect_not_approved === 'function') redirect_not_approved(data.token)
return
}
if (data && data.reason_code === 'IS_APPROVING') {
if (redirect_not_approved) redirect_not_approved(data.token)
} else if (data.reason_code === 'IS_APPROVING') {
toast.info('您的注册理由正在审批中')
if (typeof redirect_success === 'function') redirect_success()
return
if (redirect_success) redirect_success()
}
toast.error(data?.message || '登录失败')
} catch (e) {
console.error(e)
toast.error('登录失败')
}
}

View File

@@ -0,0 +1,38 @@
import { ref, onMounted, onUnmounted, onActivated, nextTick } from 'vue'
export function useScrollLoadMore(loadMore, offset = 50) {
const savedScrollTop = ref(0)
const handleScroll = () => {
if (!process.client) return
const scrollTop = window.scrollY || document.documentElement.scrollTop
const scrollHeight = document.documentElement.scrollHeight
const windowHeight = window.innerHeight
savedScrollTop.value = scrollTop
if (scrollHeight - (scrollTop + windowHeight) <= offset) {
loadMore()
}
}
onMounted(() => {
if (process.client) {
window.addEventListener('scroll', handleScroll, { passive: true })
}
})
onUnmounted(() => {
if (process.client) {
window.removeEventListener('scroll', handleScroll)
}
})
onActivated(() => {
if (process.client) {
nextTick(() => {
window.scrollTo({ top: savedScrollTop.value })
})
}
})
return { savedScrollTop }
}

View File

@@ -1,5 +1,4 @@
import hljs from 'highlight.js/lib/common'
import hljs from 'highlight.js'
if (typeof window !== 'undefined') {
const theme =
document.documentElement.dataset.theme ||

View File

@@ -1,7 +1,6 @@
export const medalTitles = {
COMMENT: '评论达人',
POST: '发帖达人',
FEATURED: '精选作者',
SEED: '种子用户',
CONTRIBUTOR: '贡献者',
PIONEER: '开山鼻祖',

View File

@@ -26,8 +26,6 @@ const iconMap = {
LOTTERY_WIN: 'fas fa-trophy',
LOTTERY_DRAW: 'fas fa-bullhorn',
MENTION: 'fas fa-at',
POST_DELETED: 'fas fa-trash',
POST_FEATURED: 'fas fa-star',
}
export async function fetchUnreadCount() {
@@ -120,189 +118,179 @@ export async function updateNotificationPreference(type, enabled) {
function createFetchNotifications() {
const notifications = ref([])
const isLoadingMessage = ref(false)
const hasMore = ref(true)
const fetchNotifications = async ({
page = 0,
size = 30,
unread = false,
append = false,
} = {}) => {
const fetchNotifications = async () => {
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
try {
const token = getToken()
if (!token) {
toast.error('请先登录')
return
}
if (!append) notifications.value = []
isLoadingMessage.value = true
const res = await fetch(
`${API_BASE_URL}/api/notifications${unread ? '/unread' : ''}?page=${page}&size=${size}`,
{
if (isLoadingMessage && notifications && markRead) {
try {
const token = getToken()
if (!token) {
toast.error('请先登录')
return
}
isLoadingMessage.value = true
notifications.value = []
const res = await fetch(`${API_BASE_URL}/api/notifications`, {
headers: {
Authorization: `Bearer ${token}`,
},
},
)
isLoadingMessage.value = false
if (!res.ok) {
toast.error('获取通知失败')
return
}
const data = await res.json()
const arr = []
for (const n of data) {
if (n.type === 'COMMENT_REPLY') {
arr.push({
...n,
src: n.comment.author.avatar,
iconClick: () => {
markNotificationRead(n.id)
navigateTo(`/users/${n.comment.author.id}`, { replace: true })
},
})
} else if (n.type === 'REACTION') {
arr.push({
...n,
emoji: reactionEmojiMap[n.reactionType],
iconClick: () => {
if (n.fromUser) {
markNotificationRead(n.id)
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
}
},
})
} else if (n.type === 'POST_VIEWED') {
arr.push({
...n,
src: n.fromUser ? n.fromUser.avatar : null,
icon: n.fromUser ? undefined : iconMap[n.type],
iconClick: () => {
if (n.fromUser) {
markNotificationRead(n.id)
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
}
},
})
} else if (n.type === 'POST_DELETED') {
arr.push({
...n,
src: n.fromUser ? n.fromUser.avatar : null,
icon: n.fromUser ? undefined : iconMap[n.type],
iconClick: () => {
if (n.fromUser) {
markNotificationRead(n.id)
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
}
},
})
} else if (n.type === 'LOTTERY_WIN' || n.type === 'LOTTERY_DRAW') {
arr.push({
...n,
icon: iconMap[n.type],
iconClick: () => {
if (n.post) {
markNotificationRead(n.id)
navigateTo(`/posts/${n.post.id}`)
}
},
})
} else if (n.type === 'POST_UPDATED' || n.type === 'USER_ACTIVITY') {
arr.push({
...n,
src: n.comment.author.avatar,
iconClick: () => {
markNotificationRead(n.id)
navigateTo(`/users/${n.comment.author.id}`, { replace: true })
},
})
} else if (n.type === 'MENTION') {
arr.push({
...n,
icon: iconMap[n.type],
iconClick: () => {
if (n.fromUser) {
markNotificationRead(n.id)
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
}
},
})
} else if (n.type === 'USER_FOLLOWED' || n.type === 'USER_UNFOLLOWED') {
arr.push({
...n,
icon: iconMap[n.type],
iconClick: () => {
if (n.fromUser) {
markNotificationRead(n.id)
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
}
},
})
} else if (
n.type === 'FOLLOWED_POST' ||
n.type === 'POST_SUBSCRIBED' ||
n.type === 'POST_UNSUBSCRIBED'
) {
arr.push({
...n,
icon: iconMap[n.type],
iconClick: () => {
if (n.post) {
markNotificationRead(n.id)
navigateTo(`/posts/${n.post.id}`, { replace: true })
}
},
})
} else if (n.type === 'POST_REVIEW_REQUEST') {
arr.push({
...n,
src: n.fromUser ? n.fromUser.avatar : null,
icon: n.fromUser ? undefined : iconMap[n.type],
iconClick: () => {
if (n.post) {
markNotificationRead(n.id)
navigateTo(`/posts/${n.post.id}`, { replace: true })
}
},
})
} else if (n.type === 'POST_FEATURED') {
arr.push({
...n,
icon: iconMap[n.type],
iconClick: () => {
if (n.post) {
markNotificationRead(n.id)
navigateTo(`/posts/${n.post.id}`, { replace: true })
}
},
})
} else if (n.type === 'REGISTER_REQUEST') {
arr.push({
...n,
icon: iconMap[n.type],
iconClick: () => {},
})
} else {
arr.push({
...n,
icon: iconMap[n.type],
})
})
isLoadingMessage.value = false
if (!res.ok) {
toast.error('获取通知失败')
return
}
}
const data = await res.json()
if (append) notifications.value.push(...arr)
else notifications.value = arr
hasMore.value = data.length === size
} catch (e) {
console.error(e)
isLoadingMessage.value = false
for (const n of data) {
if (n.type === 'COMMENT_REPLY') {
notifications.value.push({
...n,
src: n.comment.author.avatar,
iconClick: () => {
markRead(n.id)
navigateTo(`/users/${n.comment.author.id}`, { replace: true })
},
})
} else if (n.type === 'REACTION') {
notifications.value.push({
...n,
emoji: reactionEmojiMap[n.reactionType],
iconClick: () => {
if (n.fromUser) {
markRead(n.id)
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
}
},
})
} else if (n.type === 'POST_VIEWED') {
notifications.value.push({
...n,
src: n.fromUser ? n.fromUser.avatar : null,
icon: n.fromUser ? undefined : iconMap[n.type],
iconClick: () => {
if (n.fromUser) {
markRead(n.id)
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
}
},
})
} else if (n.type === 'LOTTERY_WIN') {
notifications.value.push({
...n,
icon: iconMap[n.type],
iconClick: () => {
if (n.post) {
markRead(n.id)
router.push(`/posts/${n.post.id}`)
}
},
})
} else if (n.type === 'LOTTERY_DRAW') {
notifications.value.push({
...n,
icon: iconMap[n.type],
iconClick: () => {
if (n.post) {
markRead(n.id)
router.push(`/posts/${n.post.id}`)
}
},
})
} else if (n.type === 'POST_UPDATED') {
notifications.value.push({
...n,
src: n.comment.author.avatar,
iconClick: () => {
markRead(n.id)
navigateTo(`/users/${n.comment.author.id}`, { replace: true })
},
})
} else if (n.type === 'USER_ACTIVITY') {
notifications.value.push({
...n,
src: n.comment.author.avatar,
iconClick: () => {
markRead(n.id)
navigateTo(`/users/${n.comment.author.id}`, { replace: true })
},
})
} else if (n.type === 'MENTION') {
notifications.value.push({
...n,
icon: iconMap[n.type],
iconClick: () => {
if (n.fromUser) {
markRead(n.id)
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
}
},
})
} else if (n.type === 'USER_FOLLOWED' || n.type === 'USER_UNFOLLOWED') {
notifications.value.push({
...n,
icon: iconMap[n.type],
iconClick: () => {
if (n.fromUser) {
markRead(n.id)
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
}
},
})
} else if (n.type === 'FOLLOWED_POST') {
notifications.value.push({
...n,
icon: iconMap[n.type],
iconClick: () => {
if (n.post) {
markRead(n.id)
navigateTo(`/posts/${n.post.id}`, { replace: true })
}
},
})
} else if (n.type === 'POST_SUBSCRIBED' || n.type === 'POST_UNSUBSCRIBED') {
notifications.value.push({
...n,
icon: iconMap[n.type],
iconClick: () => {
if (n.post) {
markRead(n.id)
navigateTo(`/posts/${n.post.id}`, { replace: true })
}
},
})
} else if (n.type === 'POST_REVIEW_REQUEST') {
notifications.value.push({
...n,
src: n.fromUser ? n.fromUser.avatar : null,
icon: n.fromUser ? undefined : iconMap[n.type],
iconClick: () => {
if (n.post) {
markRead(n.id)
navigateTo(`/posts/${n.post.id}`, { replace: true })
}
},
})
} else if (n.type === 'REGISTER_REQUEST') {
notifications.value.push({
...n,
icon: iconMap[n.type],
iconClick: () => {},
})
} else {
notifications.value.push({
...n,
icon: iconMap[n.type],
})
}
}
} catch (e) {
console.error(e)
}
}
}
const markNotificationRead = async (id) => {
const markRead = async (id) => {
if (!id) return
const n = notifications.value.find((n) => n.id === id)
if (!n || n.read) return
@@ -344,19 +332,13 @@ function createFetchNotifications() {
}
return {
fetchNotifications,
markNotificationRead,
markRead,
notifications,
isLoadingMessage,
markRead,
markAllRead,
hasMore,
}
}
export const {
fetchNotifications,
markNotificationRead,
notifications,
isLoadingMessage,
markAllRead,
hasMore,
} = createFetchNotifications()
export const { fetchNotifications, markRead, notifications, isLoadingMessage, markAllRead } =
createFetchNotifications()

View File

@@ -20,8 +20,7 @@ async function generateCodeChallenge(codeVerifier) {
.replace(/=+$/, '')
}
// 邀请码明文放入 state同时生成 csrf 放入 state 并在回调校验
export async function twitterAuthorize(inviteToken = '') {
export async function twitterAuthorize(state = '') {
const config = useRuntimeConfig()
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
const TWITTER_CLIENT_ID = config.public.twitterClientId
@@ -29,30 +28,17 @@ export async function twitterAuthorize(inviteToken = '') {
toast.error('Twitter 登录不可用')
return
}
if (state === '') {
state = Math.random().toString(36).substring(2, 15)
}
const redirectUri = `${WEBSITE_BASE_URL}/twitter-callback`
// PKCE
const codeVerifier = generateCodeVerifier()
sessionStorage.setItem('twitter_code_verifier', codeVerifier)
const codeChallenge = await generateCodeChallenge(codeVerifier)
// CSRF + 邀请码一起放入 state
const csrf = Math.random().toString(36).slice(2)
sessionStorage.setItem('twitter_csrf_state', csrf)
const state = new URLSearchParams({
csrf,
invite_token: inviteToken || '',
}).toString()
const url =
`https://x.com/i/oauth2/authorize?response_type=code&client_id=${encodeURIComponent(TWITTER_CLIENT_ID)}` +
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
`&scope=${encodeURIComponent('tweet.read users.read')}` +
`&state=${encodeURIComponent(state)}` +
`&code_challenge=${encodeURIComponent(codeChallenge)}` +
`&code_challenge_method=S256`
`https://x.com/i/oauth2/authorize?response_type=code&client_id=${TWITTER_CLIENT_ID}` +
`&redirect_uri=${encodeURIComponent(redirectUri)}&scope=tweet.read%20users.read` +
`&state=${state}&code_challenge=${codeChallenge}&code_challenge_method=S256`
window.location.href = url
}
@@ -60,29 +46,8 @@ export async function twitterExchange(code, state, reason) {
try {
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
// 取出并清理 PKCE/CSRF
const codeVerifier = sessionStorage.getItem('twitter_code_verifier')
sessionStorage.removeItem('twitter_code_verifier')
const savedCsrf = sessionStorage.getItem('twitter_csrf_state')
sessionStorage.removeItem('twitter_csrf_state')
// 从 state 解析 csrf 与 invite_token
let parsedCsrf = ''
let inviteToken = ''
try {
const sp = new URLSearchParams(state || '')
parsedCsrf = sp.get('csrf') || ''
inviteToken = sp.get('invite_token') || sp.get('invitetoken') || ''
} catch {}
// 简单 CSRF 校验(存在才校验,避免误杀老会话)
if (savedCsrf && parsedCsrf && savedCsrf !== parsedCsrf) {
toast.error('登录状态校验失败,请重试')
return { success: false, needReason: false, error: 'state mismatch' }
}
const res = await fetch(`${API_BASE_URL}/api/auth/twitter`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -92,10 +57,8 @@ export async function twitterExchange(code, state, reason) {
reason,
state,
codeVerifier,
inviteToken,
}),
})
const data = await res.json()
if (res.ok && data.token) {
setToken(data.token)
@@ -114,7 +77,6 @@ export async function twitterExchange(code, state, reason) {
return { success: false, needReason: false, error: data.error || '登录失败' }
}
} catch (e) {
console.error(e)
toast.error('登录失败')
return { success: false, needReason: false, error: '登录失败' }
}