Compare commits

...

8 Commits

Author SHA1 Message Date
tim
0a722c81c5 fix: 微信黑名单申诉 #676 2025-08-21 13:37:02 +08:00
Tim
15071471b2 Merge pull request #673 from nagisa77/feature/daily_bugfix_0821 2025-08-21 12:37:31 +08:00
Tim
98a9939738 Merge pull request #675 from nagisa77/codex-5yja7z 2025-08-21 12:36:37 +08:00
Tim
72e9a77373 fix: ui 调整 2025-08-21 12:27:50 +08:00
Tim
ed7dcd9414 Merge pull request #674 from nagisa77/codex/add-points-history-system-with-ui
feat: add point history
2025-08-21 11:05:07 +08:00
Tim
79fe8b5997 feat: add point history 2025-08-21 11:04:22 +08:00
Tim
cfce4d7d1d fix: 全局移除process.client、process.server #669 2025-08-21 10:22:33 +08:00
Tim
b7f5d8485c fix:「站点统计」新增loading #664 2025-08-21 10:15:20 +08:00
23 changed files with 504 additions and 97 deletions

View File

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

View File

@@ -47,7 +47,7 @@ public class CommentController {
Comment comment = commentService.addComment(auth.getName(), postId, req.getContent()); Comment comment = commentService.addComment(auth.getName(), postId, req.getContent());
CommentDto dto = commentMapper.toDto(comment); CommentDto dto = commentMapper.toDto(comment);
dto.setReward(levelService.awardForComment(auth.getName())); 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()); log.debug("createComment succeeded for comment {}", comment.getId());
return ResponseEntity.ok(dto); 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()); draftService.deleteDraft(auth.getName());
PostDetailDto dto = postMapper.toDetailDto(post, auth.getName()); PostDetailDto dto = postMapper.toDetailDto(post, auth.getName());
dto.setReward(levelService.awardForPost(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); return ResponseEntity.ok(dto);
} }

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

@@ -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,10 @@
package com.openisle.model;
public enum PointHistoryType {
POST,
COMMENT,
POST_LIKED,
COMMENT_LIKED,
INVITE,
SYSTEM_ONLINE
}

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

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

View File

@@ -1,7 +1,6 @@
package com.openisle.service; package com.openisle.service;
import com.openisle.model.PointLog; import com.openisle.model.*;
import com.openisle.model.User;
import com.openisle.repository.*; import com.openisle.repository.*;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -16,19 +15,22 @@ public class PointService {
private final PointLogRepository pointLogRepository; private final PointLogRepository pointLogRepository;
private final PostRepository postRepository; private final PostRepository postRepository;
private final CommentRepository commentRepository; 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(); User user = userRepository.findByUsername(userName).orElseThrow();
PointLog log = getTodayLog(user); PointLog log = getTodayLog(user);
if (log.getPostCount() > 1) return 0; if (log.getPostCount() > 1) return 0;
log.setPostCount(log.getPostCount() + 1); log.setPostCount(log.getPostCount() + 1);
pointLogRepository.save(log); 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(); 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);
} }
private PointLog getTodayLog(User user) { private PointLog getTodayLog(User user) {
@@ -45,20 +47,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); user.setPoint(user.getPoint() + amount);
userRepository.save(user); userRepository.save(user);
recordHistory(user, type, amount, post, comment, fromUser);
return amount; 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; boolean isTheRewardCapped = false;
// 根据帖子id找到发帖人 // 根据帖子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(); User commenter = userRepository.findByUsername(commenterName).orElseThrow();
@@ -74,15 +97,15 @@ public class PointService {
} else { } else {
log.setCommentCount(log.getCommentCount() + 1); log.setCommentCount(log.getCommentCount() + 1);
pointLogRepository.save(log); pointLogRepository.save(log);
return addPoint(commenter, 10); return addPoint(commenter, 10, PointHistoryType.COMMENT, post, comment, null);
} }
} else { } else {
addPoint(poster, 10); addPoint(poster, 10, PointHistoryType.COMMENT, post, comment, commenter);
// 如果发帖人与评论者不是同一个,则根据是否达到积分上限来判断评论者加分情况 // 如果发帖人与评论者不是同一个,则根据是否达到积分上限来判断评论者加分情况
if (isTheRewardCapped) { if (isTheRewardCapped) {
return 0; return 0;
} else { } else {
return addPoint(commenter, 10); return addPoint(commenter, 10, PointHistoryType.COMMENT, post, comment, null);
} }
} }
} }
@@ -101,7 +124,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 +142,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

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

View File

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

View File

@@ -63,7 +63,7 @@ const isImageIcon = (icon) => {
} }
const buildTagsUrl = (kw = '') => { 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) const url = new URL('/api/tags', base)
if (kw) url.searchParams.set('keyword', kw) if (kw) url.searchParams.set('keyword', kw)

View File

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

View File

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

View File

@@ -338,7 +338,7 @@ watch([selectedCategory, selectedTags], () => {
watch(selectedTopic, (val) => { watch(selectedTopic, (val) => {
loadOptions() loadOptions()
selectedTopicCookie.value = val selectedTopicCookie.value = val
if (process.client) localStorage.setItem('homeTab', val) if (import.meta.client) localStorage.setItem('homeTab', val)
}) })
/** 选项首屏加载:服务端执行一次;客户端兜底 **/ /** 选项首屏加载:服务端执行一次;客户端兜底 **/

View File

@@ -1,62 +1,171 @@
<template> <template>
<div class="point-mall-page"> <div class="point-mall-page">
<section class="rules"> <div class="point-tabs">
<div class="section-title">🎉 积分规则</div> <div
<div class="section-content"> :class="['point-tab-item', { selected: selectedTab === 'mall' }]"
<div class="section-item" v-for="(rule, idx) in pointRules" :key="idx">{{ rule }}</div> @click="selectedTab = 'mall'"
>
积分兑换
</div>
<div
:class="['point-tab-item', { selected: selectedTab === 'history' }]"
@click="selectedTab = 'history'"
>
积分历史
</div> </div>
</section>
<div class="loading-points-container" v-if="isLoading">
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
</div> </div>
<div class="point-info"> <template v-if="selectedTab === 'mall'">
<p v-if="authState.loggedIn && point !== null"> <div class="point-mall-page-content">
<span><i class="fas fa-coins coin-icon"></i></span>我的积分<span class="point-value">{{ <section class="rules">
point <div class="section-title">🎉 积分规则</div>
}}</span> <div class="section-content">
</p> <div class="section-item" v-for="(rule, idx) in pointRules" :key="idx">{{ rule }}</div>
</div> </div>
</section>
<section class="goods"> <div class="loading-points-container" v-if="isLoading">
<div class="goods-item" v-for="(good, idx) in goods" :key="idx"> <l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
<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>
<div
class="goods-item-button" <div class="point-info">
:class="{ disabled: !authState.loggedIn || point === null || point < good.cost }" <p v-if="authState.loggedIn && point !== null">
@click="openRedeem(good)" <span><i class="fas fa-coins coin-icon"></i></span>我的积分<span
> class="point-value"
兑换 >{{ point }}</span
>
</p>
</div> </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> </div>
</section> </template>
<RedeemPopup
:visible="dialogVisible" <template v-else>
v-model="contact" <div class="loading-points-container" v-if="historyLoading">
:loading="loading" <l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
@close="closeRedeem" </div>
@submit="submitRedeem" <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 === '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> </div>
</template> </template>
<script setup> <script setup>
import { onMounted, ref } from 'vue' import { onMounted, ref, watch } from 'vue'
import { authState, fetchCurrentUser, getToken } from '~/utils/auth' import { authState, fetchCurrentUser, getToken } from '~/utils/auth'
import { toast } from '~/main' import { toast } from '~/main'
import RedeemPopup from '~/components/RedeemPopup.vue' 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 config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl const API_BASE_URL = config.public.apiBaseUrl
const selectedTab = ref('mall')
const point = ref(null) const point = ref(null)
const isLoading = ref(false) const isLoading = ref(false)
const histories = ref([])
const historyLoading = ref(false)
const historyLoaded = ref(false)
const pointRules = [ const pointRules = [
'发帖:每天前两次,每次 30 积分', '发帖:每天前两次,每次 30 积分',
@@ -72,6 +181,15 @@ const contact = ref('')
const loading = ref(false) const loading = ref(false)
const selectedGood = ref(null) 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',
}
onMounted(async () => { onMounted(async () => {
isLoading.value = true isLoading.value = true
if (authState.loggedIn) { if (authState.loggedIn) {
@@ -82,6 +200,12 @@ onMounted(async () => {
isLoading.value = false isLoading.value = false
}) })
watch(selectedTab, (val) => {
if (val === 'history' && !historyLoaded.value) {
loadHistory()
}
})
const loadGoods = async () => { const loadGoods = async () => {
const res = await fetch(`${API_BASE_URL}/api/point-goods`) const res = await fetch(`${API_BASE_URL}/api/point-goods`)
if (res.ok) { if (res.ok) {
@@ -89,6 +213,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) => { const openRedeem = (good) => {
if (!authState.loggedIn || point.value === null || point.value < good.cost) { if (!authState.loggedIn || point.value === null || point.value < good.cost) {
toast.error('积分不足') toast.error('积分不足')
@@ -129,12 +273,44 @@ const submitRedeem = async () => {
<style scoped> <style scoped>
.point-mall-page { .point-mall-page {
padding: 0 20px;
max-width: var(--page-max-width); max-width: var(--page-max-width);
background-color: var(--background-color); background-color: var(--background-color);
margin: 0 auto; 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 { .loading-points-container {
margin-top: 100px; margin-top: 100px;
display: flex; display: flex;
@@ -215,6 +391,17 @@ const submitRedeem = async () => {
cursor: not-allowed; 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 { .section-title {
font-size: 18px; font-size: 18px;
font-weight: bold; font-weight: bold;

View File

@@ -295,7 +295,7 @@ const commentSort = ref('NEWEST')
const isFetchingComments = ref(false) const isFetchingComments = ref(false)
const isMobile = useIsMobile() const isMobile = useIsMobile()
const headerHeight = process.client const headerHeight = import.meta.client
? parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--header-height')) || 0 ? parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--header-height')) || 0
: 0 : 0
@@ -309,7 +309,7 @@ useHead(() => ({
], ],
})) }))
if (process.client) { if (import.meta.client) {
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener('scroll', updateCurrentIndex) window.removeEventListener('scroll', updateCurrentIndex)
if (countdownTimer) clearInterval(countdownTimer) if (countdownTimer) clearInterval(countdownTimer)
@@ -355,7 +355,7 @@ const updateCountdown = () => {
countdown.value = `${h}:${m}:${s}` countdown.value = `${h}:${m}:${s}`
} }
const startCountdown = () => { const startCountdown = () => {
if (!process.client) return if (!import.meta.client) return
if (countdownTimer) clearInterval(countdownTimer) if (countdownTimer) clearInterval(countdownTimer)
updateCountdown() updateCountdown()
countdownTimer = setInterval(updateCountdown, 1000) countdownTimer = setInterval(updateCountdown, 1000)
@@ -515,7 +515,7 @@ watchEffect(() => {
}) })
// 404 客户端跳转 // 404 客户端跳转
// if (postError.value?.statusCode === 404 && process.client) { // if (postError.value?.statusCode === 404 && import.meta.client) {
// router.replace('/404') // router.replace('/404')
// } // }
@@ -877,6 +877,7 @@ const gotoProfile = () => {
} }
const initPage = async () => { const initPage = async () => {
scrollTo(0, 0)
await fetchComments() await fetchComments()
const hash = location.hash const hash = location.hash
const id = hash.startsWith('#comment-') ? hash.substring('#comment-'.length) : null const id = hash.startsWith('#comment-') ? hash.substring('#comment-'.length) : null

View File

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

View File

@@ -4,7 +4,7 @@ import '~/assets/toast.css'
export default defineNuxtPlugin(async (nuxtApp) => { export default defineNuxtPlugin(async (nuxtApp) => {
// 确保只在客户端环境中注册插件 // 确保只在客户端环境中注册插件
if (process.client) { if (import.meta.client) {
try { try {
// 使用动态导入来避免 CommonJS 模块问题 // 使用动态导入来避免 CommonJS 模块问题
const { default: Toast, POSITION } = await import('vue-toastification') 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, role: null,
}) })
if (process.client) { if (import.meta.client) {
authState.loggedIn = authState.loggedIn =
localStorage.getItem(TOKEN_KEY) !== null && localStorage.getItem(TOKEN_KEY) !== '' localStorage.getItem(TOKEN_KEY) !== null && localStorage.getItem(TOKEN_KEY) !== ''
authState.userId = localStorage.getItem(USER_ID_KEY) authState.userId = localStorage.getItem(USER_ID_KEY)
@@ -21,18 +21,18 @@ if (process.client) {
} }
export function getToken() { export function getToken() {
return process.client ? localStorage.getItem(TOKEN_KEY) : null return import.meta.client ? localStorage.getItem(TOKEN_KEY) : null
} }
export function setToken(token) { export function setToken(token) {
if (process.client) { if (import.meta.client) {
localStorage.setItem(TOKEN_KEY, token) localStorage.setItem(TOKEN_KEY, token)
authState.loggedIn = true authState.loggedIn = true
} }
} }
export function clearToken() { export function clearToken() {
if (process.client) { if (import.meta.client) {
localStorage.removeItem(TOKEN_KEY) localStorage.removeItem(TOKEN_KEY)
clearUserInfo() clearUserInfo()
authState.loggedIn = false authState.loggedIn = false
@@ -40,7 +40,7 @@ export function clearToken() {
} }
export function setUserInfo({ id, username }) { export function setUserInfo({ id, username }) {
if (process.client) { if (import.meta.client) {
authState.userId = id authState.userId = id
authState.username = username authState.username = username
if (arguments[0] && arguments[0].role) { if (arguments[0] && arguments[0].role) {
@@ -53,7 +53,7 @@ export function setUserInfo({ id, username }) {
} }
export function clearUserInfo() { export function clearUserInfo() {
if (process.client) { if (import.meta.client) {
localStorage.removeItem(USER_ID_KEY) localStorage.removeItem(USER_ID_KEY)
localStorage.removeItem(USERNAME_KEY) localStorage.removeItem(USERNAME_KEY)
localStorage.removeItem(ROLE_KEY) localStorage.removeItem(ROLE_KEY)