Merge branch 'nagisa77:main' into main

This commit is contained in:
zpaeng
2025-08-21 23:54:21 +08:00
committed by GitHub
42 changed files with 1102 additions and 250 deletions

116
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,116 @@
#### **⚠️注意:仅想修改前端的朋友可不用部署后端服务**
## 如何部署
> Step1 先克隆仓库
```shell
git clone https://github.com/nagisa77/OpenIsle.git
cd OpenIsle
```
> Step2 后端部署
```shell
cd backend
```
以IDEA编辑器为例IDEA打开backend文件夹。
- 设置VM Option最好运行在其他端口非8080这里设置8081
```shell
-Dserver.port=8081
```
![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,45 +1,18 @@
<p align="center">
<img alt="OpenIsle" src="https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png" width="200">
<br><br>
高效的开源社区前后端平台
<br><br>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square"></a>
<br>
高效的开源社区前后端平台
<br><br><br>
<img alt="Image" src="https://openisle-1307107697.cos.accelerate.myqcloud.com/dynamic_assert/22752cfac5a04a9c90c41995b9f55fed.png" width="1200">
</p>
## 💡 简介
OpenIsle 是一个使用 Spring Boot 和 Vue 3 构建的全栈开源社区平台,提供用户注册、登录、贴文发布、评论交互等完整功能,可用于项目社区或直接打造自主社区站点。
## 🚧 开发
## 🚧 开发 & 部署
### 后端
1. 确保安装 JDK 17 及 Maven
2. 信息配置修改 `src/main/resources/application.properties`,或通过环境变量设置数据库等参数
3. 执行 `mvn clean package` 生成包,之后使用 `java -jar target/openisle-0.0.1-SNAPSHOT.jar`启动,或在开发时直接使用 `mvn spring-boot:run`
### 前端
1. 进入前端目录
```bash
cd frontend_nuxt
```
2. 安装依赖
```bash
npm install
```
3. 启动开发服务
```bash
npm run dev
```
生产版本使用如下命令编译:
```bash
npm run build
```
会在 `.output` 目录生成文件,配合线上网站方式部署
详细见 [Contributing](https://github.com/nagisa77/OpenIsle?tab=contributing-ov-file)
## ✨ 项目特点

View File

@@ -47,13 +47,14 @@ public class AuthController {
return ResponseEntity.badRequest().body(Map.of("error", "Invalid captcha"));
}
if (req.getInviteToken() != null && !req.getInviteToken().isEmpty()) {
if (!inviteService.validate(req.getInviteToken())) {
InviteService.InviteValidateResult result = inviteService.validate(req.getInviteToken());
if (!result.isValidate()) {
return ResponseEntity.badRequest().body(Map.of("error", "邀请码使用次数过多"));
}
try {
User user = userService.registerWithInvite(
req.getUsername(), req.getEmail(), req.getPassword());
inviteService.consume(req.getInviteToken());
inviteService.consume(req.getInviteToken(), user.getUsername());
emailService.sendEmail(user.getEmail(), "在网站填写验证码以验证", "您的验证码是 " + user.getVerificationCode());
return ResponseEntity.ok(Map.of(
"token", jwtService.generateToken(user.getUsername()),
@@ -144,7 +145,8 @@ public class AuthController {
@PostMapping("/google")
public ResponseEntity<?> loginWithGoogle(@RequestBody GoogleLoginRequest req) {
boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty();
if (viaInvite && !inviteService.validate(req.getInviteToken())) {
InviteService.InviteValidateResult inviteValidateResult = inviteService.validate(req.getInviteToken());
if (viaInvite && !inviteValidateResult.isValidate()) {
return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token"));
}
Optional<AuthResult> resultOpt = googleAuthService.authenticate(
@@ -154,7 +156,7 @@ public class AuthController {
if (resultOpt.isPresent()) {
AuthResult result = resultOpt.get();
if (viaInvite && result.isNewUser()) {
inviteService.consume(req.getInviteToken());
inviteService.consume(req.getInviteToken(), inviteValidateResult.getInviteToken().getInviter().getUsername());
return ResponseEntity.ok(Map.of(
"token", jwtService.generateToken(result.getUser().getUsername()),
"reason_code", "INVITE_APPROVED"
@@ -218,7 +220,8 @@ public class AuthController {
@PostMapping("/github")
public ResponseEntity<?> loginWithGithub(@RequestBody GithubLoginRequest req) {
boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty();
if (viaInvite && !inviteService.validate(req.getInviteToken())) {
InviteService.InviteValidateResult inviteValidateResult = inviteService.validate(req.getInviteToken());
if (viaInvite && !inviteValidateResult.isValidate()) {
return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token"));
}
Optional<AuthResult> resultOpt = githubAuthService.authenticate(
@@ -229,7 +232,7 @@ public class AuthController {
if (resultOpt.isPresent()) {
AuthResult result = resultOpt.get();
if (viaInvite && result.isNewUser()) {
inviteService.consume(req.getInviteToken());
inviteService.consume(req.getInviteToken(), inviteValidateResult.getInviteToken().getInviter().getUsername());
return ResponseEntity.ok(Map.of(
"token", jwtService.generateToken(result.getUser().getUsername()),
"reason_code", "INVITE_APPROVED"
@@ -265,7 +268,8 @@ public class AuthController {
@PostMapping("/discord")
public ResponseEntity<?> loginWithDiscord(@RequestBody DiscordLoginRequest req) {
boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty();
if (viaInvite && !inviteService.validate(req.getInviteToken())) {
InviteService.InviteValidateResult inviteValidateResult = inviteService.validate(req.getInviteToken());
if (viaInvite && !inviteValidateResult.isValidate()) {
return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token"));
}
Optional<AuthResult> resultOpt = discordAuthService.authenticate(
@@ -276,7 +280,7 @@ public class AuthController {
if (resultOpt.isPresent()) {
AuthResult result = resultOpt.get();
if (viaInvite && result.isNewUser()) {
inviteService.consume(req.getInviteToken());
inviteService.consume(req.getInviteToken(), inviteValidateResult.getInviteToken().getInviter().getUsername());
return ResponseEntity.ok(Map.of(
"token", jwtService.generateToken(result.getUser().getUsername()),
"reason_code", "INVITE_APPROVED"
@@ -311,7 +315,8 @@ public class AuthController {
@PostMapping("/twitter")
public ResponseEntity<?> loginWithTwitter(@RequestBody TwitterLoginRequest req) {
boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty();
if (viaInvite && !inviteService.validate(req.getInviteToken())) {
InviteService.InviteValidateResult inviteValidateResult = inviteService.validate(req.getInviteToken());
if (viaInvite && !inviteValidateResult.isValidate()) {
return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token"));
}
Optional<AuthResult> resultOpt = twitterAuthService.authenticate(
@@ -323,7 +328,7 @@ public class AuthController {
if (resultOpt.isPresent()) {
AuthResult result = resultOpt.get();
if (viaInvite && result.isNewUser()) {
inviteService.consume(req.getInviteToken());
inviteService.consume(req.getInviteToken(), inviteValidateResult.getInviteToken().getInviter().getUsername());
return ResponseEntity.ok(Map.of(
"token", jwtService.generateToken(result.getUser().getUsername()),
"reason_code", "INVITE_APPROVED"

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

View File

@@ -0,0 +1,28 @@
package com.openisle.controller;
import com.openisle.dto.PointHistoryDto;
import com.openisle.mapper.PointHistoryMapper;
import com.openisle.service.PointService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/point-histories")
@RequiredArgsConstructor
public class PointHistoryController {
private final PointService pointService;
private final PointHistoryMapper pointHistoryMapper;
@GetMapping
public List<PointHistoryDto> list(Authentication auth) {
return pointService.listHistory(auth.getName()).stream()
.map(pointHistoryMapper::toDto)
.collect(Collectors.toList());
}
}

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()));
dto.setPointReward(pointService.awardForPost(auth.getName(), post.getId()));
return ResponseEntity.ok(dto);
}
@@ -171,4 +171,27 @@ public class PostController {
return postService.listPostsByLatestReply(ids, tids, page, pageSize)
.stream().map(postMapper::toSummaryDto).collect(Collectors.toList());
}
@GetMapping("/featured")
public List<PostSummaryDto> featuredPosts(@RequestParam(value = "categoryId", required = false) Long categoryId,
@RequestParam(value = "categoryIds", required = false) List<Long> categoryIds,
@RequestParam(value = "tagId", required = false) Long tagId,
@RequestParam(value = "tagIds", required = false) List<Long> tagIds,
@RequestParam(value = "page", required = false) Integer page,
@RequestParam(value = "pageSize", required = false) Integer pageSize,
Authentication auth) {
List<Long> ids = categoryIds;
if (categoryId != null) {
ids = java.util.List.of(categoryId);
}
List<Long> tids = tagIds;
if (tagId != null) {
tids = java.util.List.of(tagId);
}
if (auth != null) {
userVisitService.recordVisit(auth.getName());
}
return postService.listFeaturedPosts(ids, tids, page, pageSize)
.stream().map(postMapper::toSummaryDto).collect(Collectors.toList());
}
}

View File

@@ -1,7 +1,10 @@
package com.openisle.controller;
import com.openisle.model.Post;
import com.openisle.model.Comment;
import com.openisle.model.CommentSort;
import com.openisle.service.PostService;
import com.openisle.service.CommentService;
import lombok.RequiredArgsConstructor;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
@@ -31,6 +34,7 @@ import java.util.regex.Pattern;
@RequiredArgsConstructor
public class RssController {
private final PostService postService;
private final CommentService commentService;
@Value("${app.website-url:https://www.open-isle.com}")
private String websiteUrl;
@@ -103,6 +107,12 @@ public class RssController {
enclosure = absolutifyUrl(enclosure, base);
}
// 6) 构造优雅的附加区块(原文链接 + 精选评论),编入 <content:encoded>
List<Comment> topComments = commentService
.getCommentsForPost(p.getId(), CommentSort.MOST_INTERACTIONS);
topComments = topComments.subList(0, Math.min(10, topComments.size()));
String footerHtml = buildFooterHtml(base, link, topComments);
sb.append("<item>");
elem(sb, "title", cdata(nullSafe(p.getTitle())));
elem(sb, "link", link);
@@ -110,8 +120,11 @@ public class RssController {
elem(sb, "pubDate", toRfc1123Gmt(p.getCreatedAt().atZone(ZoneId.systemDefault())));
// 摘要
elem(sb, "description", cdata(plain));
// 全文HTML
sb.append("<content:encoded><![CDATA[").append(absHtml).append("]]></content:encoded>");
// 全文HTML:正文 + 优雅的 Markdown 区块(已转 HTML
sb.append("<content:encoded><![CDATA[")
.append(absHtml)
.append(footerHtml)
.append("]]></content:encoded>");
// 首图 enclosure图片类型
if (enclosure != null) {
sb.append("<enclosure url=\"").append(escapeXml(enclosure)).append("\" type=\"")
@@ -136,8 +149,12 @@ public class RssController {
private static String sanitizeHtml(String html) {
if (html == null) return "";
Safelist wl = Safelist.relaxed()
.addTags("pre", "code", "figure", "figcaption", "picture", "source",
"table","thead","tbody","tr","th","td","h1","h2","h3","h4","h5","h6")
.addTags(
"pre","code","figure","figcaption","picture","source",
"table","thead","tbody","tr","th","td",
"h1","h2","h3","h4","h5","h6",
"hr","blockquote"
)
.addAttributes("a", "href", "title", "target", "rel")
.addAttributes("img", "src", "alt", "title", "width", "height")
.addAttributes("source", "srcset", "type", "media")
@@ -246,6 +263,59 @@ public class RssController {
return "image/jpeg";
}
/* ===================== 附加区块(原文链接 + 精选评论) ===================== */
/**
* 将“原文链接 + 精选评论(最多 10 条)”以优雅的 Markdown 形式渲染为 HTML
* 并做 sanitize + 绝对化,然后拼入 content:encoded 尾部。
*/
private static String buildFooterHtml(String baseUrl, String originalLink, List<Comment> topComments) {
StringBuilder md = new StringBuilder(256);
// 分割线
md.append("\n\n---\n\n");
// 原文链接(强调 + 可点击)
md.append("**原文链接:** ")
.append("[").append(originalLink).append("](").append(originalLink).append(")")
.append("\n\n");
// 精选评论(仅当有评论时展示)
if (topComments != null && !topComments.isEmpty()) {
md.append("### 精选评论Top ").append(Math.min(10, topComments.size())).append("\n\n");
for (Comment c : topComments) {
String author = usernameOf(c);
String content = nullSafe(c.getContent()).replace("\r", "");
// 使用引用样式展示,提升可读性
md.append("> @").append(author).append(": ").append(content).append("\n\n");
}
}
// 渲染为 HTML并保持和正文一致的处理流程
String html = renderMarkdown(md.toString());
String safe = sanitizeHtml(html);
return absolutifyHtml(safe, baseUrl);
}
private static String usernameOf(Comment c) {
if (c == null) return "匿名";
try {
Object authorObj = c.getAuthor();
if (authorObj == null) return "匿名";
// 反射避免直接依赖实体字段名变化(也可直接强转到具体类型)
String username;
try {
username = (String) authorObj.getClass().getMethod("getUsername").invoke(authorObj);
} catch (Exception e) {
username = null;
}
if (username == null || username.isEmpty()) return "匿名";
return username;
} catch (Exception ignored) {
return "匿名";
}
}
/* ===================== 时间/字符串/XML ===================== */
private static String toRfc1123Gmt(ZonedDateTime zdt) {

View File

@@ -0,0 +1,12 @@
package com.openisle.dto;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
public class FeaturedMedalDto extends MedalDto {
private long currentFeaturedCount;
private long targetFeaturedCount;
}

View File

@@ -0,0 +1,23 @@
package com.openisle.dto;
import com.openisle.model.PointHistoryType;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
public class PointHistoryDto {
private Long id;
private PointHistoryType type;
private int amount;
private int balance;
private Long postId;
private String postTitle;
private Long commentId;
private String commentContent;
private Long fromUserId;
private String fromUserName;
private LocalDateTime createdAt;
}

View File

@@ -0,0 +1,34 @@
package com.openisle.mapper;
import com.openisle.dto.PointHistoryDto;
import com.openisle.model.PointHistory;
import org.springframework.stereotype.Component;
@Component
public class PointHistoryMapper {
public PointHistoryDto toDto(PointHistory history) {
PointHistoryDto dto = new PointHistoryDto();
dto.setId(history.getId());
dto.setType(history.getType());
dto.setAmount(history.getAmount());
dto.setBalance(history.getBalance());
dto.setCreatedAt(history.getCreatedAt());
if (history.getPost() != null) {
dto.setPostId(history.getPost().getId());
dto.setPostTitle(history.getPost().getTitle());
}
if (history.getComment() != null) {
dto.setCommentId(history.getComment().getId());
dto.setCommentContent(history.getComment().getContent());
if (history.getComment().getPost() != null && dto.getPostId() == null) {
dto.setPostId(history.getComment().getPost().getId());
dto.setPostTitle(history.getComment().getPost().getTitle());
}
}
if (history.getFromUser() != null) {
dto.setFromUserId(history.getFromUser().getId());
dto.setFromUserName(history.getFromUser().getUsername());
}
return dto;
}
}

View File

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

View File

@@ -14,6 +14,8 @@ public enum NotificationType {
POST_REVIEW_REQUEST,
/** Your post under review was approved or rejected */
POST_REVIEWED,
/** An administrator deleted your post */
POST_DELETED,
/** A subscribed post received a new comment */
POST_UPDATED,
/** Someone subscribed to your post */
@@ -38,6 +40,8 @@ public enum NotificationType {
LOTTERY_WIN,
/** Your lottery post was drawn */
LOTTERY_DRAW,
/** Your post was featured */
POST_FEATURED,
/** You were mentioned in a post or comment */
MENTION
}

View File

@@ -0,0 +1,49 @@
package com.openisle.model;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.LocalDateTime;
/** Point change history for a user. */
@Entity
@Getter
@Setter
@NoArgsConstructor
@Table(name = "point_histories")
public class PointHistory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "user_id")
private User user;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private PointHistoryType type;
@Column(nullable = false)
private int amount;
@Column(nullable = false)
private int balance;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "post_id")
private Post post;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "comment_id")
private Comment comment;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "from_user_id")
private User fromUser;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
}

View File

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

View File

@@ -0,0 +1,12 @@
package com.openisle.repository;
import com.openisle.model.PointHistory;
import com.openisle.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface PointHistoryRepository extends JpaRepository<PointHistory, Long> {
List<PointHistory> findByUserOrderByIdDesc(User user);
long countByUser(User user);
}

View File

@@ -97,6 +97,8 @@ public interface PostRepository extends JpaRepository<Post, Long> {
long countDistinctByTags_Id(Long tagId);
long countByAuthor_IdAndRssExcludedFalse(Long userId);
@Query("SELECT t.id, COUNT(DISTINCT p) FROM Post p JOIN p.tags t WHERE t.id IN :tagIds GROUP BY t.id")
List<Object[]> countPostsByTagIds(@Param("tagIds") List<Long> tagIds);

View File

@@ -5,6 +5,7 @@ import com.openisle.model.User;
import com.openisle.repository.InviteTokenRepository;
import com.openisle.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import lombok.Value;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
@@ -18,6 +19,12 @@ public class InviteService {
private final JwtService jwtService;
private final PointService pointService;
@Value
public class InviteValidateResult {
InviteToken inviteToken;
boolean validate;
}
public String generate(String username) {
User inviter = userRepository.findByUsername(username).orElseThrow();
LocalDate today = LocalDate.now();
@@ -35,20 +42,23 @@ public class InviteService {
return token;
}
public boolean validate(String token) {
public InviteValidateResult validate(String token) {
if (token == null || token.isEmpty()) {
return new InviteValidateResult(null, false);
}
try {
jwtService.validateAndGetSubjectForInvite(token);
} catch (Exception e) {
return false;
return new InviteValidateResult(null, false);
}
InviteToken invite = inviteTokenRepository.findById(token).orElse(null);
return invite != null && invite.getUsageCount() < 3;
return new InviteValidateResult(invite, invite != null && invite.getUsageCount() < 3);
}
public void consume(String token) {
public void consume(String token, String newUserName) {
InviteToken invite = inviteTokenRepository.findById(token).orElseThrow();
invite.setUsageCount(invite.getUsageCount() + 1);
inviteTokenRepository.save(invite);
pointService.awardForInvite(invite.getInviter().getUsername());
pointService.awardForInvite(invite.getInviter().getUsername(), newUserName);
}
}

View File

@@ -6,6 +6,7 @@ import com.openisle.dto.MedalDto;
import com.openisle.dto.PostMedalDto;
import com.openisle.dto.SeedUserMedalDto;
import com.openisle.dto.PioneerMedalDto;
import com.openisle.dto.FeaturedMedalDto;
import com.openisle.model.MedalType;
import com.openisle.model.User;
import com.openisle.repository.CommentRepository;
@@ -74,6 +75,23 @@ public class MedalService {
postMedal.setSelected(selected == MedalType.POST);
medals.add(postMedal);
FeaturedMedalDto featuredMedal = new FeaturedMedalDto();
featuredMedal.setIcon("https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/achi_rss.png");
featuredMedal.setTitle("精选作者");
featuredMedal.setDescription("至少有1篇文章被收录为精选");
featuredMedal.setType(MedalType.FEATURED);
featuredMedal.setTargetFeaturedCount(1);
if (user != null) {
long count = postRepository.countByAuthor_IdAndRssExcludedFalse(user.getId());
featuredMedal.setCurrentFeaturedCount(count);
featuredMedal.setCompleted(count >= 1);
} else {
featuredMedal.setCurrentFeaturedCount(0);
featuredMedal.setCompleted(false);
}
featuredMedal.setSelected(selected == MedalType.FEATURED);
medals.add(featuredMedal);
ContributorMedalDto contributorMedal = new ContributorMedalDto();
contributorMedal.setIcon("https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/icons/achi_coder.png");
contributorMedal.setTitle("贡献者");
@@ -141,6 +159,8 @@ public class MedalService {
user.setDisplayMedal(MedalType.COMMENT);
} else if (postRepository.countByAuthor_Id(user.getId()) >= POST_TARGET) {
user.setDisplayMedal(MedalType.POST);
} else if (postRepository.countByAuthor_IdAndRssExcludedFalse(user.getId()) >= 1) {
user.setDisplayMedal(MedalType.FEATURED);
} else if (contributorService.getContributionLines(user.getUsername()) >= CONTRIBUTION_TARGET) {
user.setDisplayMedal(MedalType.CONTRIBUTOR);
} else if (userRepository.countByCreatedAtBefore(user.getCreatedAt()) < PIONEER_LIMIT) {

View File

@@ -3,8 +3,11 @@ package com.openisle.service;
import com.openisle.exception.FieldException;
import com.openisle.exception.NotFoundException;
import com.openisle.model.PointGood;
import com.openisle.model.PointHistory;
import com.openisle.model.PointHistoryType;
import com.openisle.model.User;
import com.openisle.repository.PointGoodRepository;
import com.openisle.repository.PointHistoryRepository;
import com.openisle.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@@ -18,6 +21,7 @@ public class PointMallService {
private final PointGoodRepository pointGoodRepository;
private final UserRepository userRepository;
private final NotificationService notificationService;
private final PointHistoryRepository pointHistoryRepository;
public List<PointGood> listGoods() {
return pointGoodRepository.findAll();
@@ -32,6 +36,13 @@ public class PointMallService {
user.setPoint(user.getPoint() - good.getCost());
userRepository.save(user);
notificationService.createPointRedeemNotifications(user, good.getName() + ": " + contact);
PointHistory history = new PointHistory();
history.setUser(user);
history.setType(PointHistoryType.REDEEM);
history.setAmount(-good.getCost());
history.setBalance(user.getPoint());
history.setCreatedAt(java.time.LocalDateTime.now());
pointHistoryRepository.save(history);
return user.getPoint();
}
}

View File

@@ -1,7 +1,6 @@
package com.openisle.service;
import com.openisle.model.PointLog;
import com.openisle.model.User;
import com.openisle.model.*;
import com.openisle.repository.*;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@@ -16,19 +15,28 @@ public class PointService {
private final PointLogRepository pointLogRepository;
private final PostRepository postRepository;
private final CommentRepository commentRepository;
private final PointHistoryRepository pointHistoryRepository;
public int awardForPost(String userName) {
public int awardForPost(String userName, Long postId) {
User user = userRepository.findByUsername(userName).orElseThrow();
PointLog log = getTodayLog(user);
if (log.getPostCount() > 1) return 0;
log.setPostCount(log.getPostCount() + 1);
pointLogRepository.save(log);
return addPoint(user, 30);
Post post = postRepository.findById(postId).orElseThrow();
return addPoint(user, 30, PointHistoryType.POST, post, null, null);
}
public int awardForInvite(String userName) {
public int awardForInvite(String userName, String inviteeName) {
User user = userRepository.findByUsername(userName).orElseThrow();
return addPoint(user, 500);
User invitee = userRepository.findByUsername(inviteeName).orElseThrow();
return addPoint(user, 500, PointHistoryType.INVITE, null, null, invitee);
}
public int awardForFeatured(String userName, Long postId) {
User user = userRepository.findByUsername(userName).orElseThrow();
Post post = postRepository.findById(postId).orElseThrow();
return addPoint(user, 500, PointHistoryType.FEATURE, post, null, null);
}
private PointLog getTodayLog(User user) {
@@ -45,20 +53,41 @@ public class PointService {
});
}
private int addPoint(User user, int amount) {
private int addPoint(User user, int amount, PointHistoryType type,
Post post, Comment comment, User fromUser) {
if (pointHistoryRepository.countByUser(user) == 0) {
recordHistory(user, PointHistoryType.SYSTEM_ONLINE, 0, null, null, null);
}
user.setPoint(user.getPoint() + amount);
userRepository.save(user);
recordHistory(user, type, amount, post, comment, fromUser);
return amount;
}
private void recordHistory(User user, PointHistoryType type, int amount,
Post post, Comment comment, User fromUser) {
PointHistory history = new PointHistory();
history.setUser(user);
history.setType(type);
history.setAmount(amount);
history.setBalance(user.getPoint());
history.setPost(post);
history.setComment(comment);
history.setFromUser(fromUser);
history.setCreatedAt(java.time.LocalDateTime.now());
pointHistoryRepository.save(history);
}
// 同时为评论者和发帖人增加积分,返回值为评论者增加的积分数
// 注意需要考虑发帖和回复是同一人的场景
public int awardForComment(String commenterName, Long postId) {
public int awardForComment(String commenterName, Long postId, Long commentId) {
// 标记评论者是否已达到积分奖励上限
boolean isTheRewardCapped = false;
// 根据帖子id找到发帖人
User poster = postRepository.findById(postId).orElseThrow().getAuthor();
Post post = postRepository.findById(postId).orElseThrow();
User poster = post.getAuthor();
Comment comment = commentRepository.findById(commentId).orElseThrow();
// 获取评论者的加分日志
User commenter = userRepository.findByUsername(commenterName).orElseThrow();
@@ -74,15 +103,15 @@ public class PointService {
} else {
log.setCommentCount(log.getCommentCount() + 1);
pointLogRepository.save(log);
return addPoint(commenter, 10);
return addPoint(commenter, 10, PointHistoryType.COMMENT, post, comment, null);
}
} else {
addPoint(poster, 10);
addPoint(poster, 10, PointHistoryType.COMMENT, post, comment, commenter);
// 如果发帖人与评论者不是同一个,则根据是否达到积分上限来判断评论者加分情况
if (isTheRewardCapped) {
return 0;
} else {
return addPoint(commenter, 10);
return addPoint(commenter, 10, PointHistoryType.COMMENT, post, comment, null);
}
}
}
@@ -101,7 +130,8 @@ public class PointService {
}
// 如果不是同一个,则为发帖人加分
return addPoint(poster, 10);
Post post = postRepository.findById(postId).orElseThrow();
return addPoint(poster, 10, PointHistoryType.POST_LIKED, post, null, reactioner);
}
// 考虑点赞者和评论者是同一个的情况
@@ -118,7 +148,17 @@ public class PointService {
}
// 如果不是同一个,则为发帖人加分
return addPoint(commenter, 10);
Comment comment = commentRepository.findById(commentId).orElseThrow();
Post post = comment.getPost();
return addPoint(commenter, 10, PointHistoryType.COMMENT_LIKED, post, comment, reactioner);
}
public java.util.List<PointHistory> listHistory(String userName) {
User user = userRepository.findByUsername(userName).orElseThrow();
if (pointHistoryRepository.countByUser(user) == 0) {
recordHistory(user, PointHistoryType.SYSTEM_ONLINE, 0, null, null, null);
}
return pointHistoryRepository.findByUserOrderByIdDesc(user);
}
}

View File

@@ -67,6 +67,7 @@ public class PostService {
private final TaskScheduler taskScheduler;
private final EmailSender emailSender;
private final ApplicationContext applicationContext;
private final PointService pointService;
private final ConcurrentMap<Long, ScheduledFuture<?>> scheduledFinalizations = new ConcurrentHashMap<>();
@Value("${app.website-url:https://www.open-isle.com}")
private String websiteUrl;
@@ -89,6 +90,7 @@ public class PostService {
TaskScheduler taskScheduler,
EmailSender emailSender,
ApplicationContext applicationContext,
PointService pointService,
@Value("${app.post.publish-mode:DIRECT}") PublishMode publishMode) {
this.postRepository = postRepository;
this.userRepository = userRepository;
@@ -107,6 +109,7 @@ public class PostService {
this.taskScheduler = taskScheduler;
this.emailSender = emailSender;
this.applicationContext = applicationContext;
this.pointService = pointService;
this.publishMode = publishMode;
}
@@ -146,7 +149,10 @@ public class PostService {
public Post includeInRss(Long id) {
Post post = postRepository.findById(id).orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
post.setRssExcluded(false);
return postRepository.save(post);
post = postRepository.save(post);
notificationService.createNotification(post.getAuthor(), NotificationType.POST_FEATURED, post, null, null, null, null, null);
pointService.awardForFeatured(post.getAuthor().getUsername(), post.getId());
return post;
}
public Post createPost(String username,
@@ -458,6 +464,34 @@ public class PostService {
return paginate(sortByPinnedAndCreated(posts), page, pageSize);
}
public List<Post> listFeaturedPosts(List<Long> categoryIds,
List<Long> tagIds,
Integer page,
Integer pageSize) {
List<Post> posts;
boolean hasCategories = categoryIds != null && !categoryIds.isEmpty();
boolean hasTags = tagIds != null && !tagIds.isEmpty();
if (hasCategories && hasTags) {
posts = listPostsByCategoriesAndTags(categoryIds, tagIds, null, null);
} else if (hasCategories) {
posts = listPostsByCategories(categoryIds, null, null);
} else if (hasTags) {
posts = listPostsByTags(tagIds, null, null);
} else {
posts = listPosts();
}
// 仅保留 getRssExcluded 为 0 且不为空
// 若字段类型是 Boolean包装类型0 等价于 false
posts = posts.stream()
.filter(p -> p.getRssExcluded() != null && !p.getRssExcluded())
.toList();
return paginate(sortByPinnedAndCreated(posts), page, pageSize);
}
public List<Post> listPendingPosts() {
return postRepository.findByStatus(PostStatus.PENDING);
}
@@ -579,7 +613,9 @@ public class PostService {
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
if (!user.getId().equals(post.getAuthor().getId()) && user.getRole() != Role.ADMIN) {
User author = post.getAuthor();
boolean adminDeleting = !user.getId().equals(author.getId()) && user.getRole() == Role.ADMIN;
if (!user.getId().equals(author.getId()) && user.getRole() != Role.ADMIN) {
throw new IllegalArgumentException("Unauthorized");
}
for (Comment c : commentRepository.findByPostAndParentIsNullOrderByCreatedAtAsc(post)) {
@@ -596,7 +632,12 @@ public class PostService {
future.cancel(false);
}
}
String title = post.getTitle();
postRepository.delete(post);
if (adminDeleting) {
notificationService.createNotification(author, NotificationType.POST_DELETED,
null, null, null, user, null, title);
}
}
public java.util.List<Post> getPostsByIds(java.util.List<Long> ids) {

View File

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

View File

@@ -34,11 +34,12 @@ class PostServiceTest {
TaskScheduler taskScheduler = mock(TaskScheduler.class);
EmailSender emailSender = mock(EmailSender.class);
ApplicationContext context = mock(ApplicationContext.class);
PointService pointService = mock(PointService.class);
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
notifService, subService, commentService, commentRepo,
reactionRepo, subRepo, notificationRepo, postReadService,
imageUploader, taskScheduler, emailSender, context, PublishMode.DIRECT);
imageUploader, taskScheduler, emailSender, context, pointService, PublishMode.DIRECT);
when(context.getBean(PostService.class)).thenReturn(service);
Post post = new Post();
@@ -61,6 +62,59 @@ class PostServiceTest {
verify(postRepo).delete(post);
}
@Test
void deletePostByAdminNotifiesAuthor() {
PostRepository postRepo = mock(PostRepository.class);
UserRepository userRepo = mock(UserRepository.class);
CategoryRepository catRepo = mock(CategoryRepository.class);
TagRepository tagRepo = mock(TagRepository.class);
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
NotificationService notifService = mock(NotificationService.class);
SubscriptionService subService = mock(SubscriptionService.class);
CommentService commentService = mock(CommentService.class);
CommentRepository commentRepo = mock(CommentRepository.class);
ReactionRepository reactionRepo = mock(ReactionRepository.class);
PostSubscriptionRepository subRepo = mock(PostSubscriptionRepository.class);
NotificationRepository notificationRepo = mock(NotificationRepository.class);
PostReadService postReadService = mock(PostReadService.class);
ImageUploader imageUploader = mock(ImageUploader.class);
TaskScheduler taskScheduler = mock(TaskScheduler.class);
EmailSender emailSender = mock(EmailSender.class);
ApplicationContext context = mock(ApplicationContext.class);
PointService pointService = mock(PointService.class);
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
notifService, subService, commentService, commentRepo,
reactionRepo, subRepo, notificationRepo, postReadService,
imageUploader, taskScheduler, emailSender, context, pointService, PublishMode.DIRECT);
when(context.getBean(PostService.class)).thenReturn(service);
Post post = new Post();
post.setId(1L);
post.setTitle("T");
post.setContent("");
User author = new User();
author.setId(2L);
author.setRole(Role.USER);
post.setAuthor(author);
User admin = new User();
admin.setId(1L);
admin.setRole(Role.ADMIN);
when(postRepo.findById(1L)).thenReturn(Optional.of(post));
when(userRepo.findByUsername("admin")).thenReturn(Optional.of(admin));
when(commentRepo.findByPostAndParentIsNullOrderByCreatedAtAsc(post)).thenReturn(List.of());
when(reactionRepo.findByPost(post)).thenReturn(List.of());
when(subRepo.findByPost(post)).thenReturn(List.of());
when(notificationRepo.findByPost(post)).thenReturn(List.of());
service.deletePost(1L, "admin");
verify(notifService).createNotification(eq(author), eq(NotificationType.POST_DELETED), isNull(),
isNull(), isNull(), eq(admin), isNull(), eq("T"));
}
@Test
void createPostRespectsRateLimit() {
PostRepository postRepo = mock(PostRepository.class);
@@ -80,11 +134,12 @@ class PostServiceTest {
TaskScheduler taskScheduler = mock(TaskScheduler.class);
EmailSender emailSender = mock(EmailSender.class);
ApplicationContext context = mock(ApplicationContext.class);
PointService pointService = mock(PointService.class);
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
notifService, subService, commentService, commentRepo,
reactionRepo, subRepo, notificationRepo, postReadService,
imageUploader, taskScheduler, emailSender, context, PublishMode.DIRECT);
imageUploader, taskScheduler, emailSender, context, pointService, PublishMode.DIRECT);
when(context.getBean(PostService.class)).thenReturn(service);
when(postRepo.countByAuthorAfter(eq("alice"), any())).thenReturn(1L);
@@ -113,11 +168,12 @@ class PostServiceTest {
TaskScheduler taskScheduler = mock(TaskScheduler.class);
EmailSender emailSender = mock(EmailSender.class);
ApplicationContext context = mock(ApplicationContext.class);
PointService pointService = mock(PointService.class);
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
notifService, subService, commentService, commentRepo,
reactionRepo, subRepo, notificationRepo, postReadService,
imageUploader, taskScheduler, emailSender, context, PublishMode.DIRECT);
imageUploader, taskScheduler, emailSender, context, pointService, PublishMode.DIRECT);
when(context.getBean(PostService.class)).thenReturn(service);
User author = new User();

View File

@@ -184,7 +184,7 @@ body {
font-family: 'Maple Mono', monospace;
font-size: 13px;
border-radius: 4px;
white-space: no-wrap;
white-space: break-spaces;
background-color: var(--code-highlight-background-color);
color: var(--text-color);
}

View File

@@ -26,6 +26,9 @@
<template v-else-if="medal.type === 'POST'">
{{ medal.currentPostCount }}/{{ medal.targetPostCount }}
</template>
<template v-else-if="medal.type === 'FEATURED'">
{{ medal.currentFeaturedCount }}/{{ medal.targetFeaturedCount }}
</template>
<template v-else-if="medal.type === 'CONTRIBUTOR'">
{{ medal.currentContributionLines }}/{{ medal.targetContributionLines }}
</template>

View File

@@ -0,0 +1,65 @@
<template>
<label class="switch">
<input
type="checkbox"
:checked="modelValue"
@change="$emit('update:modelValue', $event.target.checked)"
/>
<span class="slider"></span>
</label>
</template>
<script setup>
defineProps({
modelValue: { type: Boolean, default: false },
})
defineEmits(['update:modelValue'])
</script>
<style scoped>
.switch {
position: relative;
display: inline-block;
width: 40px;
height: 20px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: 0.2s;
border-radius: 20px;
}
.slider:before {
position: absolute;
content: '';
height: 16px;
width: 16px;
left: 2px;
bottom: 2px;
background-color: white;
transition: 0.2s;
border-radius: 50%;
}
input:checked + .slider {
background-color: var(--primary-color);
}
input:checked + .slider:before {
transform: translateX(20px);
}
</style>

View File

@@ -50,7 +50,7 @@ onMounted(async () => {
})
const checkMilkTeaActivity = async () => {
if (!process.client) return
if (!import.meta.client) return
if (localStorage.getItem('milkTeaActivityPopupShown')) return
try {
const res = await fetch(`${API_BASE_URL}/api/activities`)
@@ -68,7 +68,7 @@ const checkMilkTeaActivity = async () => {
}
const checkInviteCodeActivity = async () => {
if (!process.client) return
if (!import.meta.client) return
if (localStorage.getItem('inviteCodeActivityPopupShown')) return
try {
const res = await fetch(`${API_BASE_URL}/api/activities`)
@@ -86,32 +86,30 @@ const checkInviteCodeActivity = async () => {
}
const closeInviteCodePopup = () => {
if (!process.client) return
if (!import.meta.client) return
localStorage.setItem('inviteCodeActivityPopupShown', 'true')
showInviteCodePopup.value = false
}
const closeMilkTeaPopup = () => {
if (!process.client) return
if (!import.meta.client) return
localStorage.setItem('milkTeaActivityPopupShown', 'true')
showMilkTeaPopup.value = false
checkNotificationSetting()
}
const checkNotificationSetting = async () => {
if (!process.client) return
if (!import.meta.client) return
if (!authState.loggedIn) return
if (localStorage.getItem('notificationSettingPopupShown')) return
showNotificationPopup.value = true
}
const closeNotificationPopup = () => {
if (!process.client) return
if (!import.meta.client) return
localStorage.setItem('notificationSettingPopupShown', 'true')
showNotificationPopup.value = false
checkNewMedals()
}
const checkNewMedals = async () => {
if (!process.client) return
if (!import.meta.client) return
if (!authState.loggedIn || !authState.userId) return
try {
const res = await fetch(`${API_BASE_URL}/api/medals?userId=${authState.userId}`)
@@ -129,7 +127,7 @@ const checkNewMedals = async () => {
}
}
const closeMedalPopup = () => {
if (!process.client) return
if (!import.meta.client) return
const seen = new Set(JSON.parse(localStorage.getItem('seenMedals') || '[]'))
newMedals.value.forEach((m) => seen.add(m.type))
localStorage.setItem('seenMedals', JSON.stringify([...seen]))

View File

@@ -40,7 +40,7 @@ const stopObserver = () => {
}
const startObserver = () => {
if (!process.client || props.pause || done.value) return
if (!import.meta.client || props.pause || done.value) return
stopObserver()
io = new IntersectionObserver(
async (entries) => {

View File

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

View File

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

View File

@@ -1,5 +1,8 @@
<template>
<div class="site-stats-page">
<div v-if="isLoading" class="loading-message">
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
</div>
<ClientOnly>
<VChart
v-if="dauOption"
@@ -51,8 +54,10 @@ const dauOption = ref(null)
const newUserOption = ref(null)
const postOption = ref(null)
const commentOption = ref(null)
const isLoading = ref(false)
async function loadData() {
isLoading.value = true
const token = getToken()
const headers = { Authorization: `Bearer ${token}` }
@@ -93,6 +98,7 @@ async function loadData() {
const data = await commentRes.json()
commentOption.value = toOption('每日回贴量', data)
}
isLoading.value = false
}
onMounted(loadData)
@@ -105,4 +111,11 @@ onMounted(loadData)
background-color: var(--background-color);
margin: 0 auto;
}
.loading-message {
display: flex;
justify-content: center;
align-items: center;
height: 400px;
}
</style>

View File

@@ -26,7 +26,10 @@
<div class="article-container">
<template
v-if="
selectedTopic === '最新' || selectedTopic === '排行榜' || selectedTopic === '最新回复'
selectedTopic === '最新' ||
selectedTopic === '排行榜' ||
selectedTopic === '最新回复' ||
selectedTopic === '精选'
"
>
<div class="article-header-container">
@@ -152,17 +155,22 @@ const route = useRoute()
const tagOptions = ref([])
const categoryOptions = ref([])
const topics = ref(['最新回复', '最新', '排行榜' /*, '热门', '类别'*/])
const topics = ref(['最新回复', '最新', '精选', '排行榜' /*, '热门', '类别'*/])
const selectedTopicCookie = useCookie('homeTab')
const selectedTopic = ref(
selectedTopicCookie.value
? selectedTopicCookie.value
: route.query.view === 'ranking'
? '排行榜'
: route.query.view === 'latest'
? '最新'
: '最新回复',
)
let defaultTopic = '最新回复'
if (selectedTopicCookie.value) {
defaultTopic = selectedTopicCookie.value
} else if (route.query.view === 'ranking') {
defaultTopic = '排行榜'
} else if (route.query.view === 'latest') {
defaultTopic = '最新'
} else if (route.query.view === 'featured') {
defaultTopic = '精选'
}
const selectedTopic = ref(defaultTopic)
if (!selectedTopicCookie.value) selectedTopicCookie.value = selectedTopic.value
const articles = ref([])
const page = ref(0)
@@ -236,6 +244,7 @@ const baseQuery = computed(() => ({
const listApiPath = computed(() => {
if (selectedTopic.value === '排行榜') return '/api/posts/ranking'
if (selectedTopic.value === '最新回复') return '/api/posts/latest-reply'
if (selectedTopic.value === '精选') return '/api/posts/featured'
return '/api/posts'
})
const buildUrl = ({ pageNo }) => {
@@ -338,7 +347,7 @@ watch([selectedCategory, selectedTags], () => {
watch(selectedTopic, (val) => {
loadOptions()
selectedTopicCookie.value = val
if (process.client) localStorage.setItem('homeTab', val)
if (import.meta.client) localStorage.setItem('homeTab', val)
})
/** 选项首屏加载:服务端执行一次;客户端兜底 **/

View File

@@ -33,15 +33,13 @@
<div v-if="selectedTab === 'control'">
<div class="message-control-container">
<div class="message-control-title">通知设置</div>
<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 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>
</div>
</div>
@@ -495,6 +493,37 @@
已被管理员拒绝
</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) }}
@@ -524,7 +553,7 @@ import {
fetchNotifications,
fetchUnreadCount,
isLoadingMessage,
markRead,
markNotificationRead,
notifications,
markAllRead,
hasMore,
@@ -532,6 +561,7 @@ import {
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
@@ -564,10 +594,10 @@ const fetchPrefs = async () => {
notificationPrefs.value = await fetchNotificationPreferences()
}
const togglePref = async (pref) => {
const ok = await updateNotificationPreference(pref.type, !pref.enabled)
const togglePref = async (pref, value) => {
const ok = await updateNotificationPreference(pref.type, value)
if (ok) {
pref.enabled = !pref.enabled
pref.enabled = value
await fetchNotifications({
page: page.value,
size: pageSize,
@@ -579,6 +609,14 @@ const togglePref = async (pref) => {
}
}
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
@@ -647,6 +685,10 @@ const formatType = (t) => {
return '抽奖中奖了'
case 'LOTTERY_DRAW':
return '抽奖已开奖'
case 'POST_DELETED':
return '帖子被删除'
case 'POST_FEATURED':
return '文章被精选'
default:
return t
}
@@ -818,26 +860,21 @@ onActivated(async () => {
padding: 20px;
}
.message-control-push-item-container {
.message-control-item-container {
display: flex;
flex-direction: row;
flex-wrap: wrap;
flex-direction: column;
gap: 10px;
}
.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 {
display: flex;
align-items: center;
justify-content: space-between;
max-width: 200px;
}
.message-control-push-item.select {
background-color: var(--primary-color);
color: white;
.message-control-item-label {
font-size: 14px;
}
@media (max-width: 768px) {

View File

@@ -1,62 +1,181 @@
<template>
<div class="point-mall-page">
<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 class="point-tabs">
<div
:class="['point-tab-item', { selected: selectedTab === 'mall' }]"
@click="selectedTab = 'mall'"
>
积分兑换
</div>
<div
:class="['point-tab-item', { selected: selectedTab === 'history' }]"
@click="selectedTab = 'history'"
>
积分历史
</div>
</section>
<div class="loading-points-container" v-if="isLoading">
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
</div>
<div class="point-info">
<p v-if="authState.loggedIn && point !== null">
<span><i class="fas fa-coins coin-icon"></i></span>我的积分<span class="point-value">{{
point
}}</span>
</p>
</div>
<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>
<section class="goods">
<div class="goods-item" v-for="(good, idx) in goods" :key="idx">
<img class="goods-item-image" :src="good.image" alt="good.name" />
<div class="goods-item-name">{{ good.name }}</div>
<div class="goods-item-cost">
<i class="fas fa-coins"></i>
{{ good.cost }} 积分
<div class="loading-points-container" v-if="isLoading">
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
</div>
<div
class="goods-item-button"
:class="{ disabled: !authState.loggedIn || point === null || point < good.cost }"
@click="openRedeem(good)"
>
兑换
<div class="point-info">
<p v-if="authState.loggedIn && point !== null">
<span><i class="fas fa-coins coin-icon"></i></span>我的积分<span
class="point-value"
>{{ point }}</span
>
</p>
</div>
<section class="goods">
<div class="goods-item" v-for="(good, idx) in goods" :key="idx">
<img class="goods-item-image" :src="good.image" alt="good.name" />
<div class="goods-item-name">{{ good.name }}</div>
<div class="goods-item-cost">
<i class="fas fa-coins"></i>
{{ good.cost }} 积分
</div>
<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>
</section>
<RedeemPopup
:visible="dialogVisible"
v-model="contact"
:loading="loading"
@close="closeRedeem"
@submit="submitRedeem"
/>
</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>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { onMounted, ref, watch } 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 积分',
@@ -64,6 +183,7 @@ const pointRules = [
'帖子被点赞:每次 10 积分',
'评论被点赞:每次 10 积分',
'邀请好友加入可获得 500 积分/次,注意需要使用邀请链接注册',
'文章被收录至精选:每次 500 积分',
]
const goods = ref([])
@@ -72,6 +192,17 @@ 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) {
@@ -82,6 +213,12 @@ 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) {
@@ -89,6 +226,26 @@ 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('积分不足')
@@ -129,12 +286,44 @@ const submitRedeem = async () => {
<style scoped>
.point-mall-page {
padding: 0 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;
@@ -215,6 +404,17 @@ 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

@@ -17,7 +17,7 @@
<div v-if="status === 'REJECTED'" class="article-block-button">已拒绝</div>
<div v-if="closed" class="article-closed-button">已关闭</div>
<div
v-if="loggedIn && !isAuthor && !subscribed"
v-if="!closed && loggedIn && !isAuthor && !subscribed"
class="article-subscribe-button"
@click="subscribePost"
>
@@ -27,7 +27,7 @@
</div>
</div>
<div
v-if="loggedIn && !isAuthor && subscribed"
v-if="!closed && loggedIn && !isAuthor && subscribed"
class="article-unsubscribe-button"
@click="unsubscribePost"
>
@@ -295,7 +295,7 @@ const commentSort = ref('NEWEST')
const isFetchingComments = ref(false)
const isMobile = useIsMobile()
const headerHeight = process.client
const headerHeight = import.meta.client
? parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--header-height')) || 0
: 0
@@ -309,7 +309,7 @@ useHead(() => ({
],
}))
if (process.client) {
if (import.meta.client) {
onBeforeUnmount(() => {
window.removeEventListener('scroll', updateCurrentIndex)
if (countdownTimer) clearInterval(countdownTimer)
@@ -355,7 +355,7 @@ const updateCountdown = () => {
countdown.value = `${h}:${m}:${s}`
}
const startCountdown = () => {
if (!process.client) return
if (!import.meta.client) return
if (countdownTimer) clearInterval(countdownTimer)
updateCountdown()
countdownTimer = setInterval(updateCountdown, 1000)
@@ -515,7 +515,7 @@ watchEffect(() => {
})
// 404 客户端跳转
// if (postError.value?.statusCode === 404 && process.client) {
// if (postError.value?.statusCode === 404 && import.meta.client) {
// router.replace('/404')
// }
@@ -876,12 +876,8 @@ const gotoProfile = () => {
navigateTo(`/users/${author.value.id}`, { replace: true })
}
onActivated(async () => {
await refreshPost()
await fetchComments()
})
onMounted(async () => {
const initPage = async () => {
scrollTo(0, 0)
await fetchComments()
const hash = location.hash
const id = hash.startsWith('#comment-') ? hash.substring('#comment-'.length) : null
@@ -889,6 +885,14 @@ onMounted(async () => {
updateCurrentIndex()
window.addEventListener('scroll', updateCurrentIndex)
jumpToHashComment()
}
onActivated(async () => {
await initPage()
})
onMounted(async () => {
await initPage()
})
</script>
@@ -1067,6 +1071,7 @@ onMounted(async () => {
white-space: nowrap;
}
.article-closed-button,
.article-subscribe-button-text,
.article-unsubscribe-button-text {
white-space: nowrap;

View File

@@ -38,10 +38,7 @@
</div>
<div class="form-row switch-row">
<div class="setting-title">毛玻璃效果</div>
<label class="switch">
<input type="checkbox" v-model="frosted" />
<span class="slider"></span>
</label>
<BaseSwitch v-model="frosted" />
</div>
</div>
<div v-if="role === 'ADMIN'" class="admin-section">
@@ -76,6 +73,7 @@ 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'
@@ -318,51 +316,6 @@ 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

@@ -1,7 +1,7 @@
import { clearToken } from '~/utils/auth'
export default defineNuxtPlugin(() => {
if (process.client) {
if (import.meta.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 (process.client) {
if (import.meta.client) {
try {
// 使用动态导入来避免 CommonJS 模块问题
const { default: Toast, POSITION } = await import('vue-toastification')

View File

@@ -0,0 +1 @@
1839503219847005265

View File

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

View File

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

View File

@@ -26,6 +26,8 @@ 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() {
@@ -158,7 +160,7 @@ function createFetchNotifications() {
...n,
src: n.comment.author.avatar,
iconClick: () => {
markRead(n.id)
markNotificationRead(n.id)
navigateTo(`/users/${n.comment.author.id}`, { replace: true })
},
})
@@ -168,7 +170,7 @@ function createFetchNotifications() {
emoji: reactionEmojiMap[n.reactionType],
iconClick: () => {
if (n.fromUser) {
markRead(n.id)
markNotificationRead(n.id)
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
}
},
@@ -180,7 +182,19 @@ function createFetchNotifications() {
icon: n.fromUser ? undefined : iconMap[n.type],
iconClick: () => {
if (n.fromUser) {
markRead(n.id)
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 })
}
},
@@ -191,7 +205,7 @@ function createFetchNotifications() {
icon: iconMap[n.type],
iconClick: () => {
if (n.post) {
markRead(n.id)
markNotificationRead(n.id)
navigateTo(`/posts/${n.post.id}`)
}
},
@@ -201,7 +215,7 @@ function createFetchNotifications() {
...n,
src: n.comment.author.avatar,
iconClick: () => {
markRead(n.id)
markNotificationRead(n.id)
navigateTo(`/users/${n.comment.author.id}`, { replace: true })
},
})
@@ -211,7 +225,7 @@ function createFetchNotifications() {
icon: iconMap[n.type],
iconClick: () => {
if (n.fromUser) {
markRead(n.id)
markNotificationRead(n.id)
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
}
},
@@ -222,7 +236,7 @@ function createFetchNotifications() {
icon: iconMap[n.type],
iconClick: () => {
if (n.fromUser) {
markRead(n.id)
markNotificationRead(n.id)
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
}
},
@@ -237,7 +251,7 @@ function createFetchNotifications() {
icon: iconMap[n.type],
iconClick: () => {
if (n.post) {
markRead(n.id)
markNotificationRead(n.id)
navigateTo(`/posts/${n.post.id}`, { replace: true })
}
},
@@ -249,7 +263,18 @@ function createFetchNotifications() {
icon: n.fromUser ? undefined : iconMap[n.type],
iconClick: () => {
if (n.post) {
markRead(n.id)
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 })
}
},
@@ -277,7 +302,7 @@ function createFetchNotifications() {
}
}
const markRead = async (id) => {
const markNotificationRead = async (id) => {
if (!id) return
const n = notifications.value.find((n) => n.id === id)
if (!n || n.read) return
@@ -319,7 +344,7 @@ function createFetchNotifications() {
}
return {
fetchNotifications,
markRead,
markNotificationRead,
notifications,
isLoadingMessage,
markAllRead,
@@ -329,7 +354,7 @@ function createFetchNotifications() {
export const {
fetchNotifications,
markRead,
markNotificationRead,
notifications,
isLoadingMessage,
markAllRead,