Compare commits

..

64 Commits

Author SHA1 Message Date
Tim
2ccdc21568 fix: markdown渲染的分割线有点深 #767 2025-09-01 19:47:24 +08:00
tim
ff63d232a9 Merge branch 'main' of github.com:nagisa77/OpenIsle 2025-09-01 18:50:09 +08:00
tim
32a624e62d fix: 登录样式调整 2025-09-01 18:49:33 +08:00
Tim
5af0c9dee0 Merge pull request #822 from nagisa77/codex/fix-channel-ui-scroll-behavior
fix: scroll to bottom when entering channel
2025-09-01 18:19:17 +08:00
Tim
edaafdd000 fix: scroll channel to bottom on activation 2025-09-01 18:18:58 +08:00
Tim
24838ab714 Merge pull request #819 from sivdead/main
指定Node.js最低版本为20.0.0
2025-09-01 18:16:24 +08:00
Tim
56a80a184b Merge pull request #821 from smallclover/main
修改部署教程
2025-09-01 18:15:49 +08:00
sivdead
ed24ed174b fix: 还原package-lock.json 2025-09-01 17:56:27 +08:00
夢夢の幻想郷
3080acb6e4 Merge branch 'nagisa77:main' into main 2025-09-01 17:52:24 +08:00
wangshun
1856eb191b 修改部署教程
1.本地部署前后端时,如果是时https后端会无法解析请求
2.使用第三方登录时,callback路径需要和注册的路径一致
2025-09-01 17:50:19 +08:00
Tim
0c2a50d620 Merge pull request #820 from CH-122/feat/message-setting
feat: 增加通知设置的权限控制,只有管理员可以显示特定通知类型
2025-09-01 17:40:09 +08:00
CH-122
7562de11a5 feat: 增加通知设置的权限控制,只有管理员可以显示特定通知类型 2025-09-01 17:03:13 +08:00
sivdead
aaacf4efb1 chore(frontend): 指定Node.js最低版本为20.0.0 2025-09-01 15:53:05 +08:00
Tim
1f30cdfe85 Merge pull request #818 from nagisa77/codex/fix-backend-compilation-issue
Fix CommentServiceTest compilation by mocking PointService
2025-09-01 15:35:02 +08:00
Tim
8b37cf5abb test: mock PointService in CommentServiceTest 2025-09-01 15:34:52 +08:00
Tim
4af19a75c9 Merge pull request #815 from sivdead/main
fix: 解决删除评论后积分历史和当前积分不一致的问题
2025-09-01 14:32:01 +08:00
tim
37ea986389 fix: 域名修复 2025-09-01 14:31:05 +08:00
tim
fefd0b3b6c fix: compile problem 2025-09-01 13:18:01 +08:00
tim
a31ed29cfa Reapply "feat: unify third-party auth component"
This reverts commit 800970f078.
2025-09-01 13:16:04 +08:00
tim
2719819ad7 Revert "chore: remove obsolete login styles"
This reverts commit 18fde1052f.
2025-09-01 13:16:00 +08:00
Tim
27ff9a9c9b Merge pull request #814 from nagisa77/codex/create-unified-ui-for-third-party-login-uko0i1
feat: unify third-party auth buttons with customizable styles
2025-09-01 13:15:16 +08:00
Tim
18fde1052f chore: remove obsolete login styles 2025-09-01 13:14:55 +08:00
tim
800970f078 Revert "feat: unify third-party auth component"
This reverts commit 215616d771.
2025-09-01 13:14:13 +08:00
Tim
cbbd1440a1 Merge pull request #813 from nagisa77/codex/create-unified-ui-for-third-party-login
feat: unify third-party auth component
2025-09-01 13:13:36 +08:00
Tim
215616d771 feat: unify third-party auth component 2025-09-01 13:13:16 +08:00
tim
575e90e558 fix: telegram support 2025-09-01 13:02:13 +08:00
Tim
e63d66806d fix: tg 环境变量配置 2025-09-01 11:47:37 +08:00
Tim
1fc0118c5a Merge pull request #812 from nagisa77/codex/support-telegram-registration-and-login
feat: add Telegram authentication
2025-09-01 11:41:34 +08:00
Tim
f3512c1184 feat: add Telegram authentication 2025-09-01 11:39:10 +08:00
sivdead
28842c90b1 feat(service): 在 CommentService 中添加逻辑删除评论时重新计算用户积分的功能,并在 PointService 中实现用户积分的重新计算方法 2025-09-01 11:32:20 +08:00
Tim
d67cc326c4 Merge pull request #811 from nagisa77/codex/update-last-post-time-display
feat: show message when user has no posts
2025-09-01 11:31:09 +08:00
Tim
27c217a630 feat: show message when user has no posts 2025-09-01 11:30:56 +08:00
Tim
4e3e5f147c Merge pull request #810 from nagisa77/codex/fix-channel-ui-scroll-to-bottom
fix(frontend): scroll to bottom on channel entry
2025-09-01 11:30:40 +08:00
Tim
8767aa31d6 fix(frontend): scroll to bottom on channel entry 2025-09-01 11:30:16 +08:00
Tim
a428f472f2 Merge pull request #809 from nagisa77/codex/shorten-invitation-link
feat: shorten invite links
2025-09-01 11:26:25 +08:00
Tim
8544803e62 feat: shorten invite links 2025-09-01 11:25:32 +08:00
Tim
54874cea7a Merge pull request #808 from nagisa77/codex/add-email-notification-settings
feat: add email notification settings
2025-09-01 11:24:19 +08:00
Tim
098d82a6a0 feat: add email notification settings 2025-09-01 11:23:31 +08:00
Tim
90eee03198 Merge pull request #807 from nagisa77/codex/fix-backend-compilation-issues
test: fix PostServiceTest for new PostService deps
2025-09-01 10:54:07 +08:00
Tim
3f152906f2 test: fix PostServiceTest for new PostService deps 2025-09-01 10:53:50 +08:00
Tim
ef71d0b3d4 Merge pull request #798 from nagisa77/feature/vote
feature for vote
2025-09-01 10:28:44 +08:00
Tim
6f80d139ba fix: 投票UI优化 2025-09-01 10:27:02 +08:00
Tim
7454931fa5 Merge pull request #806 from nagisa77/codex/modify-postpoll.vue-for-single-choice-voting
feat: add join button for single polls
2025-09-01 09:54:37 +08:00
Tim
0852664a82 Merge pull request #802 from sivdead/main
feat(model): 为评论和积分历史实体添加逻辑删除功能
2025-09-01 09:54:07 +08:00
Tim
5814fb673a feat: add join button for single polls 2025-09-01 01:06:51 +08:00
Tim
4ee4266e3d Merge pull request #804 from nagisa77/codex/fix-jpasystemexception-for-pollpost
Fix poll multiple property null handling
2025-08-31 14:22:59 +08:00
Tim
6a27fbe1d7 Fix null multiple field for poll posts 2025-08-31 14:22:44 +08:00
Tim
38ff04c358 Merge pull request #803 from nagisa77/codex/add-baseswitch-component-to-voting-post
feat(poll): use BaseSwitch for multiple selection
2025-08-31 14:13:32 +08:00
Tim
fc27200ac1 feat(poll): use BaseSwitch for multiple selection 2025-08-31 14:13:18 +08:00
sivdead
b1998be425 Merge remote-tracking branch 'origin/main' 2025-08-31 14:06:18 +08:00
sivdead
72adc5b232 feat(model): 为 Comment 和 PointHistory 实体添加逻辑删除功能 2025-08-31 14:03:48 +08:00
sivdead
d24e67de5d feat(model): 为 Comment 和 PointHistory 实体添加逻辑删除功能 2025-08-31 14:03:10 +08:00
Tim
eefefac236 Merge pull request #801 from nagisa77/codex/add-multi-select-support-for-voting
feat: support multi-option polls
2025-08-31 12:13:54 +08:00
Tim
2f339fdbdb feat: enable multi-option polls 2025-08-31 12:13:41 +08:00
tim
3808becc8b fix: 多选ui 2025-08-31 11:25:34 +08:00
tim
18db4d7317 fix: toolbar 层级修改 2025-08-31 11:14:48 +08:00
Tim
52cbb71945 Merge pull request #800 from nagisa77/codex/refactor-voting-and-lottery-into-components-zk6hvx
refactor: extract poll and lottery components
2025-08-31 11:10:46 +08:00
Tim
39c34a9048 feat: add PostPoll and PostLottery components 2025-08-31 11:10:20 +08:00
tim
4baabf2224 Revert "refactor: extract poll and lottery sections"
This reverts commit 27efc493b2.
2025-08-31 11:09:22 +08:00
Tim
8023183bc6 Merge pull request #799 from nagisa77/codex/refactor-voting-and-lottery-into-components
refactor: extract poll and lottery sections
2025-08-31 11:08:05 +08:00
Tim
27efc493b2 refactor: extract poll and lottery sections 2025-08-31 11:07:49 +08:00
tim
ca6e45a711 fix: 适配夜间模式 2025-08-31 10:55:40 +08:00
tim
803ca9e103 新的通知类型适配 2025-08-31 02:06:32 +08:00
Tim
9d1e12773a Merge pull request #796 from nagisa77/codex/modify-voting-module-components
Refactor poll module and add poll notifications
2025-08-31 01:49:55 +08:00
51 changed files with 1608 additions and 757 deletions

View File

@@ -76,11 +76,13 @@ cp .env.staging.example .env
```yaml
; 本地部署后端
NUXT_PUBLIC_API_BASE_URL=https://127.0.0.1:8081
NUXT_PUBLIC_API_BASE_URL=http://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
; 开发环境
NUXT_PUBLIC_WEBSITE_BASE_URL=localhost:3000
```
2. 依赖预发环境后台环境

View File

@@ -28,6 +28,7 @@ TWITTER_CLIENT_ID=<你的twitter-client-id>
TWITTER_CLIENT_SECRET=<你的-twitter-client-secret>
DISCORD_CLIENT_ID=<你的discord-client-id>
DISCORD_CLIENT_SECRET=<你的discord-client-secret>
TELEGRAM_BOT_TOKEN=<你的telegram-bot-token>
# === OPENAI ===
OPENAI_API_KEY=<你的openai-api-key>

View File

@@ -26,6 +26,7 @@ public class AuthController {
private final GithubAuthService githubAuthService;
private final DiscordAuthService discordAuthService;
private final TwitterAuthService twitterAuthService;
private final TelegramAuthService telegramAuthService;
private final RegisterModeService registerModeService;
private final NotificationService notificationService;
private final UserRepository userRepository;
@@ -360,6 +361,51 @@ public class AuthController {
));
}
@PostMapping("/telegram")
public ResponseEntity<?> loginWithTelegram(@RequestBody TelegramLoginRequest req) {
boolean viaInvite = req.getInviteToken() != null && !req.getInviteToken().isEmpty();
InviteService.InviteValidateResult inviteValidateResult = inviteService.validate(req.getInviteToken());
if (viaInvite && !inviteValidateResult.isValidate()) {
return ResponseEntity.badRequest().body(Map.of("error", "Invalid invite token"));
}
Optional<AuthResult> resultOpt = telegramAuthService.authenticate(
req,
registerModeService.getRegisterMode(),
viaInvite);
if (resultOpt.isPresent()) {
AuthResult result = resultOpt.get();
if (viaInvite && result.isNewUser()) {
inviteService.consume(req.getInviteToken(), inviteValidateResult.getInviteToken().getInviter().getUsername());
return ResponseEntity.ok(Map.of(
"token", jwtService.generateToken(result.getUser().getUsername()),
"reason_code", "INVITE_APPROVED"
));
}
if (RegisterMode.DIRECT.equals(registerModeService.getRegisterMode())) {
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
}
if (!result.getUser().isApproved()) {
if (result.getUser().getRegisterReason() != null && !result.getUser().getRegisterReason().isEmpty()) {
return ResponseEntity.badRequest().body(Map.of(
"error", "Account awaiting approval",
"reason_code", "IS_APPROVING",
"token", jwtService.generateReasonToken(result.getUser().getUsername())
));
}
return ResponseEntity.badRequest().body(Map.of(
"error", "Account awaiting approval",
"reason_code", "NOT_APPROVED",
"token", jwtService.generateReasonToken(result.getUser().getUsername())
));
}
return ResponseEntity.ok(Map.of("token", jwtService.generateToken(result.getUser().getUsername())));
}
return ResponseEntity.badRequest().body(Map.of(
"error", "Invalid telegram data",
"reason_code", "INVALID_CREDENTIALS"
));
}
@GetMapping("/check")
public ResponseEntity<?> checkToken() {
return ResponseEntity.ok(Map.of("valid", true));

View File

@@ -62,4 +62,14 @@ public class NotificationController {
public void updatePref(@RequestBody NotificationPreferenceUpdateRequest req, Authentication auth) {
notificationService.updatePreference(auth.getName(), req.getType(), req.isEnabled());
}
@GetMapping("/email-prefs")
public List<NotificationPreferenceDto> emailPrefs(Authentication auth) {
return notificationService.listEmailPreferences(auth.getName());
}
@PostMapping("/email-prefs")
public void updateEmailPref(@RequestBody NotificationPreferenceUpdateRequest req, Authentication auth) {
notificationService.updateEmailPreference(auth.getName(), req.getType(), req.isEnabled());
}
}

View File

@@ -44,7 +44,7 @@ public class PostController {
req.getType(), req.getPrizeDescription(), req.getPrizeIcon(),
req.getPrizeCount(), req.getPointCost(),
req.getStartTime(), req.getEndTime(),
req.getOptions());
req.getOptions(), req.getMultiple());
draftService.deleteDraft(auth.getName());
PostDetailDto dto = postMapper.toDetailDto(post, auth.getName());
dto.setReward(levelService.awardForPost(auth.getName()));
@@ -94,7 +94,7 @@ public class PostController {
}
@PostMapping("/{id}/poll/vote")
public ResponseEntity<Void> vote(@PathVariable Long id, @RequestParam("option") int option, Authentication auth) {
public ResponseEntity<Void> vote(@PathVariable Long id, @RequestParam("option") List<Integer> option, Authentication auth) {
postService.votePoll(id, auth.getName(), option);
return ResponseEntity.ok().build();
}

View File

@@ -13,4 +13,5 @@ public class PollDto {
private LocalDateTime endTime;
private List<AuthorDto> participants;
private Map<Integer, List<AuthorDto>> optionParticipants;
private boolean multiple;
}

View File

@@ -28,5 +28,6 @@ public class PostRequest {
private LocalDateTime endTime;
// fields for poll posts
private List<String> options;
private Boolean multiple;
}

View File

@@ -0,0 +1,16 @@
package com.openisle.dto;
import lombok.Data;
/** Request for Telegram login. */
@Data
public class TelegramLoginRequest {
private String id;
private String firstName;
private String lastName;
private String username;
private String photoUrl;
private Long authDate;
private String hash;
private String inviteToken;
}

View File

@@ -111,6 +111,7 @@ public class PostMapper {
.collect(Collectors.groupingBy(PollVote::getOptionIndex,
Collectors.mapping(v -> userMapper.toAuthorDto(v.getUser()), Collectors.toList())));
p.setOptionParticipants(optionParticipants);
p.setMultiple(Boolean.TRUE.equals(pp.getMultiple()));
dto.setPoll(p);
}
}

View File

@@ -5,6 +5,8 @@ import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.Where;
import java.time.LocalDateTime;
@@ -13,6 +15,8 @@ import java.time.LocalDateTime;
@Setter
@NoArgsConstructor
@Table(name = "comments")
@SQLDelete(sql = "UPDATE comments SET deleted_at = CURRENT_TIMESTAMP(6) WHERE id = ?")
@Where(clause = "deleted_at IS NULL")
public class Comment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@@ -41,4 +45,7 @@ public class Comment {
@Column
private LocalDateTime pinnedAt;
@Column(name = "deleted_at")
private LocalDateTime deletedAt;
}

View File

@@ -14,6 +14,13 @@ public class InviteToken {
@Id
private String token;
/**
* Short token used in invite links. Existing records may have this field null
* and fall back to {@link #token} for backward compatibility.
*/
@Column(unique = true)
private String shortToken;
@ManyToOne
private User inviter;

View File

@@ -4,6 +4,8 @@ import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.Where;
import java.time.LocalDateTime;
@@ -13,6 +15,8 @@ import java.time.LocalDateTime;
@Setter
@NoArgsConstructor
@Table(name = "point_histories")
@SQLDelete(sql = "UPDATE point_histories SET deleted_at = CURRENT_TIMESTAMP(6) WHERE id = ?")
@Where(clause = "deleted_at IS NULL")
public class PointHistory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@@ -46,4 +50,7 @@ public class PointHistory {
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
@Column(name = "deleted_at")
private LocalDateTime deletedAt;
}

View File

@@ -32,6 +32,9 @@ public class PollPost extends Post {
inverseJoinColumns = @JoinColumn(name = "user_id"))
private Set<User> participants = new HashSet<>();
@Column
private Boolean multiple = false;
@Column
private LocalDateTime endTime;

View File

@@ -6,7 +6,7 @@ import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name = "poll_votes", uniqueConstraints = @UniqueConstraint(columnNames = {"post_id", "user_id"}))
@Table(name = "poll_votes", uniqueConstraints = @UniqueConstraint(columnNames = {"post_id", "user_id", "option_index"}))
@Getter
@Setter
@NoArgsConstructor

View File

@@ -74,6 +74,12 @@ public class User {
NotificationType.USER_ACTIVITY
);
@ElementCollection(targetClass = NotificationType.class)
@CollectionTable(name = "user_disabled_email_notification_types", joinColumns = @JoinColumn(name = "user_id"))
@Column(name = "notification_type")
@Enumerated(EnumType.STRING)
private Set<NotificationType> disabledEmailNotificationTypes = EnumSet.noneOf(NotificationType.class);
@CreationTimestamp
@Column(nullable = false, updatable = false,
columnDefinition = "DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6)")

View File

@@ -9,4 +9,8 @@ import java.util.Optional;
public interface InviteTokenRepository extends JpaRepository<InviteToken, String> {
Optional<InviteToken> findByInviterAndCreatedDate(User inviter, LocalDate createdDate);
Optional<InviteToken> findByShortToken(String shortToken);
boolean existsByShortToken(String shortToken);
}

View File

@@ -2,6 +2,7 @@ package com.openisle.repository;
import com.openisle.model.PointHistory;
import com.openisle.model.User;
import com.openisle.model.Comment;
import org.springframework.data.jpa.repository.JpaRepository;
import java.time.LocalDateTime;
@@ -12,4 +13,6 @@ public interface PointHistoryRepository extends JpaRepository<PointHistory, Long
long countByUser(User user);
List<PointHistory> findByUserAndCreatedAtAfterOrderByCreatedAtDesc(User user, LocalDateTime createdAt);
List<PointHistory> findByComment(Comment comment);
}

View File

@@ -4,6 +4,7 @@ import com.openisle.model.Comment;
import com.openisle.model.Post;
import com.openisle.model.User;
import com.openisle.model.NotificationType;
import com.openisle.model.PointHistory;
import com.openisle.model.CommentSort;
import com.openisle.repository.CommentRepository;
import com.openisle.repository.PostRepository;
@@ -11,8 +12,10 @@ import com.openisle.repository.UserRepository;
import com.openisle.repository.ReactionRepository;
import com.openisle.repository.CommentSubscriptionRepository;
import com.openisle.repository.NotificationRepository;
import com.openisle.repository.PointHistoryRepository;
import com.openisle.service.NotificationService;
import com.openisle.service.SubscriptionService;
import com.openisle.service.PointService;
import com.openisle.model.Role;
import com.openisle.exception.RateLimitException;
import lombok.RequiredArgsConstructor;
@@ -20,6 +23,9 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Set;
import java.util.HashSet;
import java.util.stream.Collectors;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.transaction.annotation.Transactional;
@@ -37,6 +43,8 @@ public class CommentService {
private final ReactionRepository reactionRepository;
private final CommentSubscriptionRepository commentSubscriptionRepository;
private final NotificationRepository notificationRepository;
private final PointHistoryRepository pointHistoryRepository;
private final PointService pointService;
private final ImageUploader imageUploader;
@Transactional
@@ -63,16 +71,19 @@ public class CommentService {
log.debug("Comment {} saved for post {}", comment.getId(), postId);
imageUploader.addReferences(imageUploader.extractUrls(content));
if (!author.getId().equals(post.getAuthor().getId())) {
notificationService.createNotification(post.getAuthor(), NotificationType.COMMENT_REPLY, post, comment, null, null, null, null);
notificationService.createNotification(post.getAuthor(), NotificationType.COMMENT_REPLY, post, comment,
null, null, null, null);
}
for (User u : subscriptionService.getPostSubscribers(postId)) {
if (!u.getId().equals(author.getId())) {
notificationService.createNotification(u, NotificationType.POST_UPDATED, post, comment, null, null, null, null);
notificationService.createNotification(u, NotificationType.POST_UPDATED, post, comment, null, null,
null, null);
}
}
for (User u : subscriptionService.getSubscribers(author.getUsername())) {
if (!u.getId().equals(author.getId())) {
notificationService.createNotification(u, NotificationType.USER_ACTIVITY, post, comment, null, null, null, null);
notificationService.createNotification(u, NotificationType.USER_ACTIVITY, post, comment, null, null,
null, null);
}
}
notificationService.notifyMentions(content, author, post, comment);
@@ -109,21 +120,25 @@ public class CommentService {
log.debug("Reply {} saved for parent {}", comment.getId(), parentId);
imageUploader.addReferences(imageUploader.extractUrls(content));
if (!author.getId().equals(parent.getAuthor().getId())) {
notificationService.createNotification(parent.getAuthor(), NotificationType.COMMENT_REPLY, parent.getPost(), comment, null, null, null, null);
notificationService.createNotification(parent.getAuthor(), NotificationType.COMMENT_REPLY, parent.getPost(),
comment, null, null, null, null);
}
for (User u : subscriptionService.getCommentSubscribers(parentId)) {
if (!u.getId().equals(author.getId())) {
notificationService.createNotification(u, NotificationType.COMMENT_REPLY, parent.getPost(), comment, null, null, null, null);
notificationService.createNotification(u, NotificationType.COMMENT_REPLY, parent.getPost(), comment,
null, null, null, null);
}
}
for (User u : subscriptionService.getPostSubscribers(parent.getPost().getId())) {
if (!u.getId().equals(author.getId())) {
notificationService.createNotification(u, NotificationType.POST_UPDATED, parent.getPost(), comment, null, null, null, null);
notificationService.createNotification(u, NotificationType.POST_UPDATED, parent.getPost(), comment,
null, null, null, null);
}
}
for (User u : subscriptionService.getSubscribers(author.getUsername())) {
if (!u.getId().equals(author.getId())) {
notificationService.createNotification(u, NotificationType.USER_ACTIVITY, parent.getPost(), comment, null, null, null, null);
notificationService.createNotification(u, NotificationType.USER_ACTIVITY, parent.getPost(), comment,
null, null, null, null);
}
}
notificationService.notifyMentions(content, author, parent.getPost(), comment);
@@ -235,11 +250,33 @@ public class CommentService {
for (Comment c : replies) {
deleteCommentCascade(c);
}
// 逻辑删除相关的积分历史记录,并收集受影响的用户
List<PointHistory> pointHistories = pointHistoryRepository.findByComment(comment);
// 收集需要重新计算积分的用户
Set<User> usersToRecalculate = pointHistories.stream().map(PointHistory::getUser).collect(Collectors.toSet());
// 删除其他相关数据
reactionRepository.findByComment(comment).forEach(reactionRepository::delete);
commentSubscriptionRepository.findByComment(comment).forEach(commentSubscriptionRepository::delete);
notificationRepository.deleteAll(notificationRepository.findByComment(comment));
imageUploader.removeReferences(imageUploader.extractUrls(comment.getContent()));
// 逻辑删除评论
commentRepository.delete(comment);
// 删除积分历史
pointHistoryRepository.deleteAll(pointHistories);
// 重新计算受影响用户的积分
if (!usersToRecalculate.isEmpty()) {
for (User user : usersToRecalculate) {
int newPoints = pointService.recalculateUserPoints(user);
user.setPoint(newPoints);
log.debug("Recalculated points for user {}: {}", user.getUsername(), newPoints);
}
userRepository.saveAll(usersToRecalculate);
}
log.debug("deleteCommentCascade removed comment {}", comment.getId());
}

View File

@@ -30,33 +30,53 @@ public class InviteService {
LocalDate today = LocalDate.now();
Optional<InviteToken> existing = inviteTokenRepository.findByInviterAndCreatedDate(inviter, today);
if (existing.isPresent()) {
return existing.get().getToken();
InviteToken inviteToken = existing.get();
return inviteToken.getShortToken() != null ? inviteToken.getShortToken() : inviteToken.getToken();
}
String token = jwtService.generateInviteToken(username);
String shortToken;
do {
shortToken = java.util.UUID.randomUUID().toString().replace("-", "").substring(0, 8);
} while (inviteTokenRepository.existsByShortToken(shortToken));
InviteToken inviteToken = new InviteToken();
inviteToken.setToken(token);
inviteToken.setShortToken(shortToken);
inviteToken.setInviter(inviter);
inviteToken.setCreatedDate(today);
inviteToken.setUsageCount(0);
inviteTokenRepository.save(inviteToken);
return token;
return shortToken;
}
public InviteValidateResult validate(String token) {
if (token == null || token.isEmpty()) {
return new InviteValidateResult(null, false);
}
InviteToken invite = inviteTokenRepository.findById(token).orElse(null);
String realToken = token;
if (invite == null) {
invite = inviteTokenRepository.findByShortToken(token).orElse(null);
if (invite == null) {
return new InviteValidateResult(null, false);
}
realToken = invite.getToken();
}
try {
jwtService.validateAndGetSubjectForInvite(token);
jwtService.validateAndGetSubjectForInvite(realToken);
} catch (Exception e) {
return new InviteValidateResult(null, false);
}
InviteToken invite = inviteTokenRepository.findById(token).orElse(null);
return new InviteValidateResult(invite, invite != null && invite.getUsageCount() < 3);
return new InviteValidateResult(invite, invite.getUsageCount() < 3);
}
public void consume(String token, String newUserName) {
InviteToken invite = inviteTokenRepository.findById(token).orElseThrow();
InviteToken invite = inviteTokenRepository.findById(token)
.orElseGet(() -> inviteTokenRepository.findByShortToken(token).orElseThrow());
invite.setUsageCount(invite.getUsageCount() + 1);
inviteTokenRepository.save(invite);
pointService.awardForInvite(invite.getInviter().getUsername(), newUserName);

View File

@@ -19,6 +19,7 @@ import java.util.regex.Pattern;
import java.util.regex.Matcher;
import java.util.Set;
import java.util.HashSet;
import java.util.EnumSet;
import java.util.List;
import java.util.ArrayList;
@@ -40,6 +41,12 @@ public class NotificationService {
private static final Pattern MENTION_PATTERN = Pattern.compile("@\\[([^\\]]+)\\]");
private static final Set<NotificationType> EMAIL_TYPES = EnumSet.of(
NotificationType.COMMENT_REPLY,
NotificationType.LOTTERY_WIN,
NotificationType.LOTTERY_DRAW
);
private String buildPayload(String body, String url) {
// Ensure push notifications contain a link to the related resource so
// that verifications can assert its presence and users can navigate
@@ -75,7 +82,8 @@ public class NotificationService {
n = notificationRepository.save(n);
// Runnable asyncTask = () -> {
if (type == NotificationType.COMMENT_REPLY && user.getEmail() != null && post != null && comment != null) {
if (type == NotificationType.COMMENT_REPLY && user.getEmail() != null && post != null && comment != null
&& !user.getDisabledEmailNotificationTypes().contains(NotificationType.COMMENT_REPLY)) {
String url = String.format("%s/posts/%d#comment-%d", websiteUrl, post.getId(), comment.getId());
emailSender.sendEmail(user.getEmail(), "有人回复了你", url);
sendCustomPush(user, "有人回复了你", url);
@@ -187,6 +195,35 @@ public class NotificationService {
userRepository.save(user);
}
public List<NotificationPreferenceDto> listEmailPreferences(String username) {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
Set<NotificationType> disabled = user.getDisabledEmailNotificationTypes();
List<NotificationPreferenceDto> prefs = new ArrayList<>();
for (NotificationType nt : EMAIL_TYPES) {
NotificationPreferenceDto dto = new NotificationPreferenceDto();
dto.setType(nt);
dto.setEnabled(!disabled.contains(nt));
prefs.add(dto);
}
return prefs;
}
public void updateEmailPreference(String username, NotificationType type, boolean enabled) {
if (!EMAIL_TYPES.contains(type)) {
return;
}
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
Set<NotificationType> disabled = user.getDisabledEmailNotificationTypes();
if (enabled) {
disabled.remove(type);
} else {
disabled.add(type);
}
userRepository.save(user);
}
public List<Notification> listNotifications(String username, Boolean read, int page, int size) {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));

View File

@@ -219,4 +219,32 @@ public class PointService {
return result;
}
/**
* 重新计算用户的积分总数
* 通过累加所有积分历史记录来重新计算用户的当前积分
*/
public int recalculateUserPoints(User user) {
// 获取用户所有的积分历史记录(由于@Where注解已删除的记录会被自动过滤
List<PointHistory> histories = pointHistoryRepository.findByUserOrderByIdDesc(user);
int totalPoints = 0;
for (PointHistory history : histories) {
totalPoints += history.getAmount();
}
// 更新用户积分
user.setPoint(totalPoints);
userRepository.save(user);
return totalPoints;
}
/**
* 重新计算用户的积分总数(通过用户名)
*/
public int recalculateUserPoints(String userName) {
User user = userRepository.findByUsername(userName).orElseThrow();
return recalculateUserPoints(user);
}
}

View File

@@ -186,7 +186,8 @@ public class PostService {
Integer pointCost,
LocalDateTime startTime,
LocalDateTime endTime,
java.util.List<String> options) {
java.util.List<String> options,
Boolean multiple) {
long recent = postRepository.countByAuthorAfter(username,
java.time.LocalDateTime.now().minusMinutes(5));
if (recent >= 1) {
@@ -227,6 +228,7 @@ public class PostService {
PollPost pp = new PollPost();
pp.setOptions(options);
pp.setEndTime(endTime);
pp.setMultiple(multiple != null && multiple);
post = pp;
} else {
post = new Post();
@@ -302,7 +304,7 @@ public class PostService {
}
@Transactional
public PollPost votePoll(Long postId, String username, int optionIndex) {
public PollPost votePoll(Long postId, String username, java.util.List<Integer> optionIndices) {
PollPost post = pollPostRepository.findById(postId)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
if (post.getEndTime() != null && post.getEndTime().isBefore(LocalDateTime.now())) {
@@ -313,16 +315,24 @@ public class PostService {
if (post.getParticipants().contains(user)) {
throw new IllegalArgumentException("User already voted");
}
if (optionIndex < 0 || optionIndex >= post.getOptions().size()) {
throw new IllegalArgumentException("Invalid option");
if (optionIndices == null || optionIndices.isEmpty()) {
throw new IllegalArgumentException("No options selected");
}
java.util.Set<Integer> unique = new java.util.HashSet<>(optionIndices);
for (int optionIndex : unique) {
if (optionIndex < 0 || optionIndex >= post.getOptions().size()) {
throw new IllegalArgumentException("Invalid option");
}
}
post.getParticipants().add(user);
post.getVotes().merge(optionIndex, 1, Integer::sum);
PollVote vote = new PollVote();
vote.setPost(post);
vote.setUser(user);
vote.setOptionIndex(optionIndex);
pollVoteRepository.save(vote);
for (int optionIndex : unique) {
post.getVotes().merge(optionIndex, 1, Integer::sum);
PollVote vote = new PollVote();
vote.setPost(post);
vote.setUser(user);
vote.setOptionIndex(optionIndex);
pollVoteRepository.save(vote);
}
PollPost saved = pollPostRepository.save(post);
if (post.getAuthor() != null && !post.getAuthor().getId().equals(user.getId())) {
notificationService.createNotification(post.getAuthor(), NotificationType.POLL_VOTE, post, null, null, user, null, null);
@@ -364,14 +374,16 @@ public class PostService {
lp.setWinners(winners);
lotteryPostRepository.save(lp);
for (User w : winners) {
if (w.getEmail() != null) {
if (w.getEmail() != null &&
!w.getDisabledEmailNotificationTypes().contains(NotificationType.LOTTERY_WIN)) {
emailSender.sendEmail(w.getEmail(), "你中奖了", "恭喜你在抽奖贴 \"" + lp.getTitle() + "\" 中获奖");
}
notificationService.createNotification(w, NotificationType.LOTTERY_WIN, lp, null, null, lp.getAuthor(), null, null);
notificationService.sendCustomPush(w, "你中奖了", String.format("%s/posts/%d", websiteUrl, lp.getId()));
}
if (lp.getAuthor() != null) {
if (lp.getAuthor().getEmail() != null) {
if (lp.getAuthor().getEmail() != null &&
!lp.getAuthor().getDisabledEmailNotificationTypes().contains(NotificationType.LOTTERY_DRAW)) {
emailSender.sendEmail(lp.getAuthor().getEmail(), "抽奖已开奖", "您的抽奖贴 \"" + lp.getTitle() + "\" 已开奖");
}
notificationService.createNotification(lp.getAuthor(), NotificationType.LOTTERY_DRAW, lp, null, null, null, null, null);

View File

@@ -0,0 +1,102 @@
package com.openisle.service;
import com.openisle.dto.TelegramLoginRequest;
import com.openisle.model.RegisterMode;
import com.openisle.model.Role;
import com.openisle.model.User;
import com.openisle.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.*;
@Service
@RequiredArgsConstructor
public class TelegramAuthService {
private final UserRepository userRepository;
private final AvatarGenerator avatarGenerator;
@Value("${telegram.bot-token:}")
private String botToken;
public Optional<AuthResult> authenticate(TelegramLoginRequest req, RegisterMode mode, boolean viaInvite) {
try {
if (botToken == null || botToken.isEmpty()) {
return Optional.empty();
}
String dataCheckString = buildDataCheckString(req);
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] secretKey = md.digest(botToken.getBytes(StandardCharsets.UTF_8));
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secretKey, "HmacSHA256"));
byte[] hash = mac.doFinal(dataCheckString.getBytes(StandardCharsets.UTF_8));
String hex = bytesToHex(hash);
if (!hex.equalsIgnoreCase(req.getHash())) {
return Optional.empty();
}
String username = req.getUsername();
String email = (username != null ? username : req.getId()) + "@telegram.org";
String avatar = req.getPhotoUrl();
return Optional.of(processUser(email, username, avatar, mode, viaInvite));
} catch (Exception e) {
return Optional.empty();
}
}
private String buildDataCheckString(TelegramLoginRequest req) {
List<String> data = new ArrayList<>();
if (req.getAuthDate() != null) data.add("auth_date=" + req.getAuthDate());
if (req.getFirstName() != null) data.add("first_name=" + req.getFirstName());
if (req.getId() != null) data.add("id=" + req.getId());
if (req.getLastName() != null) data.add("last_name=" + req.getLastName());
if (req.getPhotoUrl() != null) data.add("photo_url=" + req.getPhotoUrl());
if (req.getUsername() != null) data.add("username=" + req.getUsername());
Collections.sort(data);
return String.join("\n", data);
}
private String bytesToHex(byte[] bytes) {
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
private AuthResult processUser(String email, String username, String avatar, RegisterMode mode, boolean viaInvite) {
Optional<User> existing = userRepository.findByEmail(email);
if (existing.isPresent()) {
User user = existing.get();
if (!user.isVerified()) {
user.setVerified(true);
user.setVerificationCode(null);
userRepository.save(user);
}
return new AuthResult(user, false);
}
String baseUsername = username != null ? username : email.split("@")[0];
String finalUsername = baseUsername;
int suffix = 1;
while (userRepository.findByUsername(finalUsername).isPresent()) {
finalUsername = baseUsername + suffix++;
}
User user = new User();
user.setUsername(finalUsername);
user.setEmail(email);
user.setPassword("");
user.setRole(Role.USER);
user.setVerified(true);
user.setApproved(mode == RegisterMode.DIRECT || viaInvite);
if (avatar != null) {
user.setAvatar(avatar);
} else {
user.setAvatar(avatarGenerator.generate(finalUsername));
}
return new AuthResult(userRepository.save(user), true);
}
}

View File

@@ -69,6 +69,8 @@ discord.client-secret=${DISCORD_CLIENT_SECRET:}
# Twitter OAuth configuration
twitter.client-id=${TWITTER_CLIENT_ID:}
twitter.client-secret=${TWITTER_CLIENT_SECRET:}
# Telegram login configuration
telegram.bot-token=${TELEGRAM_BOT_TOKEN:}
# OpenAI configuration
openai.api-key=${OPENAI_API_KEY:}
openai.model=${OPENAI_MODEL:gpt-4o}

View File

@@ -0,0 +1,11 @@
-- Add logical delete support for comments and point_histories tables
-- Add deleted_at column to comments table
ALTER TABLE comments ADD COLUMN deleted_at DATETIME(6) NULL;
-- Add deleted_at column to point_histories table
ALTER TABLE point_histories ADD COLUMN deleted_at DATETIME(6) NULL;
-- Add index for better performance on logical delete queries
CREATE INDEX idx_comments_deleted_at ON comments(deleted_at);
CREATE INDEX idx_point_histories_deleted_at ON point_histories(deleted_at);

View File

@@ -76,7 +76,7 @@ class PostControllerTest {
post.setTags(Set.of(tag));
when(postService.createPost(eq("alice"), eq(1L), eq("t"), eq("c"), eq(List.of(1L)),
isNull(), isNull(), isNull(), isNull(), isNull(), isNull(), isNull(), isNull())).thenReturn(post);
isNull(), isNull(), isNull(), isNull(), isNull(), isNull(), isNull(), isNull(), isNull())).thenReturn(post);
when(postService.viewPost(eq(1L), any())).thenReturn(post);
when(commentService.getCommentsForPost(eq(1L), any())).thenReturn(List.of());
when(commentService.getParticipants(anyLong(), anyInt())).thenReturn(List.of());
@@ -187,7 +187,7 @@ class PostControllerTest {
.andExpect(status().isBadRequest());
verify(postService, never()).createPost(any(), any(), any(), any(), any(),
any(), any(), any(), any(), any(), any(), any(), any());
any(), any(), any(), any(), any(), any(), any(), any(), any());
}
@Test

View File

@@ -6,6 +6,8 @@ import com.openisle.repository.UserRepository;
import com.openisle.repository.ReactionRepository;
import com.openisle.repository.CommentSubscriptionRepository;
import com.openisle.repository.NotificationRepository;
import com.openisle.repository.PointHistoryRepository;
import com.openisle.service.PointService;
import com.openisle.exception.RateLimitException;
import org.junit.jupiter.api.Test;
@@ -24,10 +26,12 @@ class CommentServiceTest {
ReactionRepository reactionRepo = mock(ReactionRepository.class);
CommentSubscriptionRepository subRepo = mock(CommentSubscriptionRepository.class);
NotificationRepository nRepo = mock(NotificationRepository.class);
PointHistoryRepository pointHistoryRepo = mock(PointHistoryRepository.class);
PointService pointService = mock(PointService.class);
ImageUploader imageUploader = mock(ImageUploader.class);
CommentService service = new CommentService(commentRepo, postRepo, userRepo,
notifService, subService, reactionRepo, subRepo, nRepo, imageUploader);
notifService, subService, reactionRepo, subRepo, nRepo, pointHistoryRepo, pointService, imageUploader);
when(commentRepo.countByAuthorAfter(eq("alice"), any())).thenReturn(3L);

View File

@@ -22,6 +22,8 @@ class PostServiceTest {
CategoryRepository catRepo = mock(CategoryRepository.class);
TagRepository tagRepo = mock(TagRepository.class);
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
NotificationService notifService = mock(NotificationService.class);
SubscriptionService subService = mock(SubscriptionService.class);
CommentService commentService = mock(CommentService.class);
@@ -37,7 +39,7 @@ class PostServiceTest {
PointService pointService = mock(PointService.class);
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
notifService, subService, commentService, commentRepo,
pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo,
reactionRepo, subRepo, notificationRepo, postReadService,
imageUploader, taskScheduler, emailSender, context, pointService, PublishMode.DIRECT);
when(context.getBean(PostService.class)).thenReturn(service);
@@ -69,6 +71,8 @@ class PostServiceTest {
CategoryRepository catRepo = mock(CategoryRepository.class);
TagRepository tagRepo = mock(TagRepository.class);
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
NotificationService notifService = mock(NotificationService.class);
SubscriptionService subService = mock(SubscriptionService.class);
CommentService commentService = mock(CommentService.class);
@@ -84,7 +88,7 @@ class PostServiceTest {
PointService pointService = mock(PointService.class);
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
notifService, subService, commentService, commentRepo,
pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo,
reactionRepo, subRepo, notificationRepo, postReadService,
imageUploader, taskScheduler, emailSender, context, pointService, PublishMode.DIRECT);
when(context.getBean(PostService.class)).thenReturn(service);
@@ -122,6 +126,8 @@ class PostServiceTest {
CategoryRepository catRepo = mock(CategoryRepository.class);
TagRepository tagRepo = mock(TagRepository.class);
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
NotificationService notifService = mock(NotificationService.class);
SubscriptionService subService = mock(SubscriptionService.class);
CommentService commentService = mock(CommentService.class);
@@ -137,7 +143,7 @@ class PostServiceTest {
PointService pointService = mock(PointService.class);
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
notifService, subService, commentService, commentRepo,
pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo,
reactionRepo, subRepo, notificationRepo, postReadService,
imageUploader, taskScheduler, emailSender, context, pointService, PublishMode.DIRECT);
when(context.getBean(PostService.class)).thenReturn(service);
@@ -146,7 +152,7 @@ class PostServiceTest {
assertThrows(RateLimitException.class,
() -> service.createPost("alice", 1L, "t", "c", List.of(1L),
null, null, null, null, null, null, null, null));
null, null, null, null, null, null, null, null, null));
}
@Test
@@ -156,6 +162,8 @@ class PostServiceTest {
CategoryRepository catRepo = mock(CategoryRepository.class);
TagRepository tagRepo = mock(TagRepository.class);
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
NotificationService notifService = mock(NotificationService.class);
SubscriptionService subService = mock(SubscriptionService.class);
CommentService commentService = mock(CommentService.class);
@@ -171,7 +179,7 @@ class PostServiceTest {
PointService pointService = mock(PointService.class);
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
notifService, subService, commentService, commentRepo,
pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo,
reactionRepo, subRepo, notificationRepo, postReadService,
imageUploader, taskScheduler, emailSender, context, pointService, PublishMode.DIRECT);
when(context.getBean(PostService.class)).thenReturn(service);

View File

@@ -3,13 +3,14 @@
; 预发环境后端
; NUXT_PUBLIC_API_BASE_URL=https://staging.open-isle.com
; 生产环境后端
NUXT_PUBLIC_API_BASE_URL=https://www.open-isle.com
NUXT_PUBLIC_API_BASE_URL=https://open-isle.com
; 预发环境
; NUXT_PUBLIC_WEBSITE_BASE_URL=https://staging.open-isle.com
; 正式环境/生产环境
NUXT_PUBLIC_WEBSITE_BASE_URL=https://www.open-isle.com
NUXT_PUBLIC_WEBSITE_BASE_URL=https://open-isle.com
NUXT_PUBLIC_GOOGLE_CLIENT_ID=777830451304-nt8afkkap18gui4f9entcha99unal744.apps.googleusercontent.com
NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23liVkO1NPAX5JyWxJ
NUXT_PUBLIC_DISCORD_CLIENT_ID=1394985417044000779
NUXT_PUBLIC_TWITTER_CLIENT_ID=ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ
NUXT_PUBLIC_TELEGRAM_BOT_ID=8450237135

View File

@@ -3,14 +3,15 @@
; 预发环境后端
NUXT_PUBLIC_API_BASE_URL=https://staging.open-isle.com
; 生产环境后端
; NUXT_PUBLIC_API_BASE_URL=https://www.open-isle.com
; NUXT_PUBLIC_API_BASE_URL=https://open-isle.com
; 预发环境
NUXT_PUBLIC_WEBSITE_BASE_URL=https://staging.open-isle.com
; 正式环境/生产环境
; NUXT_PUBLIC_WEBSITE_BASE_URL=https://www.open-isle.com
; NUXT_PUBLIC_WEBSITE_BASE_URL=https://open-isle.com
NUXT_PUBLIC_GOOGLE_CLIENT_ID=777830451304-nt8afkkap18gui4f9entcha99unal744.apps.googleusercontent.com
NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23liVkO1NPAX5JyWxJ
NUXT_PUBLIC_DISCORD_CLIENT_ID=1394985417044000779
NUXT_PUBLIC_TWITTER_CLIENT_ID=ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ
NUXT_PUBLIC_TELEGRAM_BOT_ID=8450237135

View File

@@ -58,6 +58,7 @@ const hideMenu = computed(() => {
'/discord-callback',
'/forgot-password',
'/google-callback',
'/telegram-callback',
].includes(useRoute().path)
})

View File

@@ -34,6 +34,13 @@
--page-max-width-mobile: 900px;
--article-info-background-color: #f0f0f0;
--activity-card-background-color: #fafafa;
--poll-option-button-background-color: rgb(218, 218, 218);
--telegram-bg: #caedff74;
--telegram-bg-hover: #67a2c088;
--twitter-bg: rgb(68, 68, 68);
--twitter-bg-hover: rgb(91, 91, 91);
--discord-bg: #5865f258;
--discord-bg-hover: #5865f2b1;
}
[data-theme='dark'] {
@@ -61,6 +68,7 @@
--blockquote-text-color: #999;
--article-info-background-color: #747373;
--activity-card-background-color: #585858;
--poll-option-button-background-color: #3a3a3a;
}
:root[data-frosted='off'] {
@@ -91,7 +99,7 @@ body {
.vditor-toolbar--pin {
top: calc(var(--header-height) + 1px) !important;
z-index: 2000;
z-index: 20;
}
.vditor-panel {
@@ -237,6 +245,14 @@ body {
overflow-x: auto; /* 小屏可横向滚动 */
}
.info-content-text hr {
border: none;
border-top: 1px solid var(--normal-border-color);
padding: 0;
height: 1px;
width: 100%;
}
.info-content-text thead th {
background-color: var(--primary-color);
color: #fff;

View File

@@ -1 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Discord</title><path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" shape-rendering="geometricPrecision" text-rendering="geometricPrecision" image-rendering="optimizeQuality" fill-rule="evenodd" clip-rule="evenodd" viewBox="0 0 512 512"><path fill="#5865F2" d="M105 0h302c57.928.155 104.845 47.072 105 104.996V407c-.155 57.926-47.072 104.844-104.996 104.998L105 512C47.074 511.844.156 464.926.002 407.003L0 105C.156 47.072 47.074.155 104.997 0H105z"/><g data-name="图层 2"><g data-name="Discord Logos"><path fill="#fff" fill-rule="nonzero" d="M368.896 153.381a269.506 269.506 0 00-67.118-20.637 186.88 186.88 0 00-8.57 17.475 250.337 250.337 0 00-37.247-2.8c-12.447 0-24.955.946-37.25 2.776-2.511-5.927-5.427-11.804-8.592-17.454a271.73 271.73 0 00-67.133 20.681c-42.479 62.841-53.991 124.112-48.235 184.513a270.622 270.622 0 0082.308 41.312c6.637-8.959 12.582-18.497 17.63-28.423a173.808 173.808 0 01-27.772-13.253c2.328-1.688 4.605-3.427 6.805-5.117 25.726 12.083 53.836 18.385 82.277 18.385 28.442 0 56.551-6.302 82.279-18.387 2.226 1.817 4.503 3.557 6.805 5.117a175.002 175.002 0 01-27.823 13.289 197.847 197.847 0 0017.631 28.4 269.513 269.513 0 0082.363-41.305l-.007.007c6.754-70.045-11.538-130.753-48.351-184.579zM201.968 300.789c-16.04 0-29.292-14.557-29.292-32.465s12.791-32.592 29.241-32.592 29.599 14.684 29.318 32.592c-.282 17.908-12.919 32.465-29.267 32.465zm108.062 0c-16.066 0-29.267-14.557-29.267-32.465s12.791-32.592 29.267-32.592c16.475 0 29.522 14.684 29.241 32.592-.281 17.908-12.894 32.465-29.241 32.465z" data-name="Discord Logo - Large - White"/></g></g></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><defs><clipPath id="A"><path d="M15.903 131.998c0-65.17 52.83-118 118-118s118 52.83 118 118-52.83 118-118 118-118-52.83-118-118"/></clipPath><linearGradient x1="133.903" y1="13.999" x2="133.903" y2="249.999" gradientUnits="userSpaceOnUse" spreadMethod="pad" id="B"><stop offset="0" stop-color="#1d93d2"/><stop offset="1" stop-color="#38b0e3"/></linearGradient><clipPath id="C"><path d="M0 265.9h266.987V0H0z"/></clipPath><clipPath id="D"><path d="M0 265.9h266.987V0H0z"/></clipPath><clipPath id="E"><path d="M0 265.9h266.987V0H0z"/></clipPath></defs><g transform="matrix(.271187 0 0 -.271187 -4.312678 67.796339)"><path d="M15.903 131.998c0-65.17 52.83-118 118-118s118 52.83 118 118-52.83 118-118 118-118-52.83-118-118" fill="url(#B)" clip-path="url(#A)"/><g clip-path="url(#C)"><path d="M95.778 123.374l14-38.75S111.528 81 113.403 81s29.75 29 29.75 29l31 59.875-77.875-36.5z" fill="#c8daea"/></g><g clip-path="url(#D)"><path d="M114.34 113.436l-2.688-28.562s-1.125-8.75 7.625 0 17.125 15.5 17.125 15.5" fill="#a9c6d8"/></g><g clip-path="url(#E)"><path d="M96.03 121.99l-28.795 9.383s-3.437 1.395-2.333 4.562c.228.653.687 1.208 2.062 2.167 6.382 4.447 118.104 44.604 118.104 44.604s3.155 1.062 5.02.356c.852-.323 1.396-.688 1.854-2.02.167-.485.263-1.516.25-2.542-.01-.74-.1-1.425-.166-2.5-.68-10.98-21.04-92.918-21.04-92.918s-1.218-4.795-5.583-4.958c-1.592-.06-3.524.263-5.834 2.25-8.565 7.368-38.172 27.265-44.713 31.64-.37.246-.474.567-.537.88-.092.46.4 1.034.4 1.034s51.552 45.825 52.924 50.633c.106.373-.293.557-.834.396-3.424-1.26-62.78-38.74-69.33-42.88-.383-.242-1.457-.086-1.457-.086" fill="#fff"/></g></g></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -1,4 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<title>Twitter icon</title>
<path d="M23.954 4.569c-.885.389-1.83.654-2.825.775 1.014-.611 1.794-1.574 2.163-2.723-.949.555-2.005.959-3.127 1.184-.897-.959-2.173-1.559-3.591-1.559-2.717 0-4.92 2.203-4.92 4.917 0 .39.045.765.127 1.124-4.083-.205-7.697-2.159-10.126-5.134-.422.722-.666 1.561-.666 2.475 0 1.709.87 3.214 2.188 4.096-.807-.026-1.566-.248-2.229-.616v.061c0 2.385 1.693 4.374 3.946 4.827-.413.111-.849.171-1.296.171-.314 0-.615-.03-.916-.086.631 1.953 2.445 3.376 4.6 3.416-1.68 1.318-3.808 2.105-6.102 2.105-.39 0-.779-.023-1.17-.069 2.189 1.394 4.768 2.209 7.548 2.209 9.051 0 14.001-7.496 14.001-13.986 0-.21 0-.42-.015-.63.961-.689 1.8-1.56 2.46-2.548l-.047-.02z"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-480 -466.815 2160 2160"><circle fill="#444" cx="600" cy="613.185" r="1080"/><path fill="#fff" d="M306.615 79.694H144.011L892.476 1150.3h162.604ZM0 0h357.328l309.814 450.883L1055.03 0h105.86L714.15 519.295 1200 1226.37H842.672L515.493 750.215 105.866 1226.37H0l468.485-544.568Z"/></svg>

Before

Width:  |  Height:  |  Size: 764 B

After

Width:  |  Height:  |  Size: 335 B

View File

@@ -18,6 +18,10 @@
<flat-pickr v-model="data.endTime" :config="dateConfig" class="time-picker" />
</client-only>
</div>
<div class="poll-multiple-row">
<span class="poll-row-title">多选</span>
<BaseSwitch v-model="data.multiple" />
</div>
</div>
</template>
@@ -25,6 +29,7 @@
import 'flatpickr/dist/flatpickr.css'
import FlatPickr from 'vue-flatpickr-component'
import BaseInput from '~/components/BaseInput.vue'
import BaseSwitch from '~/components/BaseSwitch.vue'
const props = defineProps({
data: {
@@ -80,6 +85,11 @@ const removeOption = (idx) => {
display: flex;
flex-direction: column;
}
.poll-multiple-row {
display: flex;
align-items: center;
gap: 10px;
}
.time-picker {
max-width: 200px;
height: 30px;

View File

@@ -0,0 +1,310 @@
<template>
<div class="post-prize-container" v-if="lottery">
<div class="prize-content">
<div class="prize-info">
<div class="prize-info-left">
<div class="prize-icon">
<BaseImage
class="prize-icon-img"
v-if="lottery.prizeIcon"
:src="lottery.prizeIcon"
alt="prize"
/>
<i v-else class="fa-solid fa-gift default-prize-icon"></i>
</div>
<div class="prize-name">{{ lottery.prizeDescription }}</div>
<div class="prize-count">x {{ lottery.prizeCount }}</div>
</div>
<div class="prize-end-time prize-info-right">
<div v-if="!isMobile" class="prize-end-time-title">离结束还有</div>
<div class="prize-end-time-value">{{ countdown }}</div>
<div v-if="!isMobile" class="join-prize-button-container-desktop">
<div
v-if="loggedIn && !hasJoined && !lotteryEnded"
class="join-prize-button"
@click="joinLottery"
>
<div class="join-prize-button-text">
参与抽奖 <i class="fas fa-coins"></i> {{ lottery.pointCost }}
</div>
</div>
<div v-else-if="hasJoined" class="join-prize-button-disabled">
<div class="join-prize-button-text">已参与</div>
</div>
</div>
</div>
</div>
<div v-if="isMobile" class="join-prize-button-container-mobile">
<div
v-if="loggedIn && !hasJoined && !lotteryEnded"
class="join-prize-button"
@click="joinLottery"
>
<div class="join-prize-button-text">
参与抽奖 <i class="fas fa-coins"></i> {{ lottery.pointCost }}
</div>
</div>
<div v-else-if="hasJoined" class="join-prize-button-disabled">
<div class="join-prize-button-text">已参与</div>
</div>
</div>
</div>
<div class="prize-member-container">
<BaseImage
v-for="p in lotteryParticipants"
:key="p.id"
class="prize-member-avatar"
:src="p.avatar"
alt="avatar"
@click="gotoUser(p.id)"
/>
<div v-if="lotteryEnded && lotteryWinners.length" class="prize-member-winner">
<i class="fas fa-medal medal-icon"></i>
<span class="prize-member-winner-name">获奖者: </span>
<BaseImage
v-for="w in lotteryWinners"
:key="w.id"
class="prize-member-avatar"
:src="w.avatar"
alt="avatar"
@click="gotoUser(w.id)"
/>
<div v-if="lotteryWinners.length === 1" class="prize-member-winner-name">
{{ lotteryWinners[0].username }}
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { getToken, authState } from '~/utils/auth'
import { toast } from '~/main'
import { useRuntimeConfig } from '#imports'
import { useIsMobile } from '~/utils/screen'
const props = defineProps({
lottery: { type: Object, required: true },
postId: { type: [String, Number], required: true },
})
const emit = defineEmits(['refresh'])
const isMobile = useIsMobile()
const loggedIn = computed(() => authState.loggedIn)
const lotteryParticipants = computed(() => props.lottery?.participants || [])
const lotteryWinners = computed(() => props.lottery?.winners || [])
const lotteryEnded = computed(() => {
if (!props.lottery || !props.lottery.endTime) return false
return new Date(props.lottery.endTime).getTime() <= Date.now()
})
const hasJoined = computed(() => {
if (!loggedIn.value) return false
return lotteryParticipants.value.some((p) => p.id === Number(authState.userId))
})
const countdown = ref('00:00:00')
let timer = null
const updateCountdown = () => {
if (!props.lottery || !props.lottery.endTime) {
countdown.value = '00:00:00'
return
}
const diff = new Date(props.lottery.endTime).getTime() - Date.now()
if (diff <= 0) {
countdown.value = '00:00:00'
if (timer) {
clearInterval(timer)
timer = null
}
return
}
const h = String(Math.floor(diff / 3600000)).padStart(2, '0')
const m = String(Math.floor((diff % 3600000) / 60000)).padStart(2, '0')
const s = String(Math.floor((diff % 60000) / 1000)).padStart(2, '0')
countdown.value = `${h}:${m}:${s}`
}
const startCountdown = () => {
updateCountdown()
if (timer) clearInterval(timer)
timer = setInterval(updateCountdown, 1000)
}
watch(
() => props.lottery?.endTime,
() => {
if (props.lottery && props.lottery.endTime) startCountdown()
},
)
onMounted(() => {
if (props.lottery && props.lottery.endTime) startCountdown()
})
onBeforeUnmount(() => {
if (timer) clearInterval(timer)
})
const gotoUser = (id) => navigateTo(`/users/${id}`, { replace: true })
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const joinLottery = async () => {
const token = getToken()
if (!token) {
toast.error('请先登录')
return
}
const res = await fetch(`${API_BASE_URL}/api/posts/${props.postId}/lottery/join`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
})
const data = await res.json().catch(() => ({}))
if (res.ok) {
toast.success('已参与抽奖')
emit('refresh')
} else {
toast.error(data.error || '操作失败')
}
}
</script>
<style scoped>
.post-prize-container {
margin-top: 20px;
display: flex;
flex-direction: column;
gap: 10px;
background-color: var(--lottery-background-color);
border-radius: 10px;
padding: 10px;
}
.prize-info {
display: flex;
flex-direction: row;
justify-content: space-between;
width: 100%;
align-items: center;
}
.join-prize-button-container-mobile {
margin-top: 15px;
margin-bottom: 10px;
}
.prize-icon {
width: 24px;
height: 24px;
}
.default-prize-icon {
font-size: 24px;
opacity: 0.5;
}
.prize-icon-img {
width: 100%;
height: 100%;
}
.prize-name {
font-size: 13px;
opacity: 0.7;
margin-left: 10px;
}
.prize-count {
font-size: 13px;
font-weight: bold;
opacity: 0.7;
margin-left: 10px;
color: var(--primary-color);
}
.prize-end-time {
display: flex;
flex-direction: row;
align-items: center;
font-size: 13px;
opacity: 0.7;
margin-left: 10px;
}
.prize-end-time-title {
font-size: 13px;
opacity: 0.7;
margin-right: 5px;
}
.prize-end-time-value {
font-size: 13px;
font-weight: bold;
color: var(--primary-color);
}
.prize-info-left,
.prize-info-right {
display: flex;
flex-direction: row;
align-items: center;
}
.join-prize-button {
margin-left: 10px;
background-color: var(--primary-color);
color: white;
padding: 5px 10px;
border-radius: 8px;
cursor: pointer;
text-align: center;
}
.join-prize-button:hover {
background-color: var(--primary-color-hover);
}
.join-prize-button-disabled {
text-align: center;
margin-left: 10px;
background-color: var(--primary-color);
color: white;
padding: 5px 10px;
border-radius: 8px;
background-color: var(--primary-color-disabled);
opacity: 0.5;
cursor: not-allowed;
}
.prize-member-avatar {
width: 30px;
height: 30px;
margin-left: 3px;
border-radius: 50%;
object-fit: cover;
cursor: pointer;
}
.prize-member-winner {
display: flex;
flex-direction: row;
align-items: center;
gap: 5px;
margin-top: 10px;
}
.medal-icon {
font-size: 16px;
color: var(--primary-color);
}
.prize-member-winner-name {
font-size: 13px;
opacity: 0.7;
}
@media (max-width: 768px) {
.join-prize-button,
.join-prize-button-disabled {
margin-left: 0;
}
}
</style>

View File

@@ -0,0 +1,459 @@
<template>
<div class="post-poll-container" v-if="poll">
<div class="poll-top-container">
<div class="poll-options-container">
<div v-if="showPollResult || pollEnded || hasVoted">
<div v-for="(opt, idx) in poll.options" :key="idx" class="poll-option-result">
<div class="poll-option-info-container">
<div class="poll-option-text">{{ opt }}</div>
<div class="poll-option-progress-info">
{{ pollPercentages[idx] }}% ({{ pollVotes[idx] || 0 }}人已投票)
</div>
</div>
<div class="poll-option-progress">
<div
class="poll-option-progress-bar"
:style="{ width: pollPercentages[idx] + '%' }"
></div>
</div>
<div class="poll-participants">
<BaseImage
v-for="p in pollOptionParticipants[idx] || []"
:key="p.id"
class="poll-participant-avatar"
:src="p.avatar"
alt="avatar"
@click="gotoUser(p.id)"
/>
</div>
</div>
</div>
<div v-else>
<div class="poll-title-section">
<div class="poll-option-title" v-if="poll.multiple">多选</div>
<div class="poll-option-title" v-else>单选</div>
<div class="poll-left-time">
<div class="poll-left-time-title">离结束还有</div>
<div class="poll-left-time-value">{{ countdown }}</div>
</div>
</div>
<template v-if="poll.multiple">
<div
v-for="(opt, idx) in poll.options"
:key="idx"
class="poll-option"
@click="toggleOption(idx)"
>
<input
type="checkbox"
:checked="selectedOptions.includes(idx)"
class="poll-option-input"
/>
<span class="poll-option-text">{{ opt }}</span>
</div>
<div class="multi-selection-container">
<div class="join-poll-button" @click="submitMultiPoll">
<i class="fas fa-check"></i> 确认投票
</div>
</div>
</template>
<template v-else>
<div
v-for="(opt, idx) in poll.options"
:key="idx"
class="poll-option"
@click="selectOption(idx)"
>
<input
type="radio"
:checked="selectedOption === idx"
name="poll-option"
class="poll-option-input"
/>
<span class="poll-option-text">{{ opt }}</span>
</div>
<div class="single-selection-container">
<div class="join-poll-button" @click="submitSinglePoll">
<i class="fas fa-check"></i> 确认投票
</div>
</div>
</template>
</div>
</div>
<div class="poll-info">
<div class="total-votes">{{ pollParticipants.length }}</div>
<div class="total-votes-title">投票人</div>
</div>
</div>
<div class="poll-bottom-container">
<div
v-if="showPollResult && !pollEnded && !hasVoted"
class="poll-option-button"
@click="showPollResult = false"
>
<i class="fas fa-chevron-left"></i> 投票
</div>
<div
v-else-if="!pollEnded && !hasVoted"
class="poll-option-button"
@click="showPollResult = true"
>
<i class="fas fa-chart-bar"></i> 结果
</div>
<div v-else-if="pollEnded" class="poll-option-hint">
<i class="fas fa-stopwatch"></i> 投票已结束
</div>
<div v-else class="poll-option-hint">
<i class="fas fa-stopwatch"></i> 您已投票等待结束查看结果
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { getToken, authState } from '~/utils/auth'
import { toast } from '~/main'
import { useRuntimeConfig } from '#imports'
const props = defineProps({
poll: { type: Object, required: true },
postId: { type: [String, Number], required: true },
})
const emit = defineEmits(['refresh'])
const loggedIn = computed(() => authState.loggedIn)
const showPollResult = ref(false)
const pollParticipants = computed(() => props.poll?.participants || [])
const pollOptionParticipants = computed(() => props.poll?.optionParticipants || {})
const pollVotes = computed(() => props.poll?.votes || {})
const totalPollVotes = computed(() => Object.values(pollVotes.value).reduce((a, b) => a + b, 0))
const pollPercentages = computed(() =>
props.poll
? props.poll.options.map((_, idx) => {
const c = pollVotes.value[idx] || 0
return totalPollVotes.value ? ((c / totalPollVotes.value) * 100).toFixed(1) : 0
})
: [],
)
const pollEnded = computed(() => {
if (!props.poll || !props.poll.endTime) return false
return new Date(props.poll.endTime).getTime() <= Date.now()
})
const hasVoted = computed(() => {
if (!loggedIn.value) return false
return pollParticipants.value.some((p) => p.id === Number(authState.userId))
})
watch([hasVoted, pollEnded], ([voted, ended]) => {
if (voted || ended) showPollResult.value = true
})
const countdown = ref('00:00:00')
let timer = null
const updateCountdown = () => {
if (!props.poll || !props.poll.endTime) {
countdown.value = '00:00:00'
return
}
const diff = new Date(props.poll.endTime).getTime() - Date.now()
if (diff <= 0) {
countdown.value = '00:00:00'
if (timer) {
clearInterval(timer)
timer = null
}
return
}
const h = String(Math.floor(diff / 3600000)).padStart(2, '0')
const m = String(Math.floor((diff % 3600000) / 60000)).padStart(2, '0')
const s = String(Math.floor((diff % 60000) / 1000)).padStart(2, '0')
countdown.value = `${h}:${m}:${s}`
}
const startCountdown = () => {
updateCountdown()
if (timer) clearInterval(timer)
timer = setInterval(updateCountdown, 1000)
}
watch(
() => props.poll?.endTime,
() => {
if (props.poll && props.poll.endTime) startCountdown()
},
)
onMounted(() => {
if (props.poll && props.poll.endTime) startCountdown()
})
onBeforeUnmount(() => {
if (timer) clearInterval(timer)
})
const gotoUser = (id) => navigateTo(`/users/${id}`, { replace: true })
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const voteOption = async (idx) => {
const token = getToken()
if (!token) {
toast.error('请先登录')
return
}
const res = await fetch(`${API_BASE_URL}/api/posts/${props.postId}/poll/vote?option=${idx}`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
})
const data = await res.json().catch(() => ({}))
if (res.ok) {
toast.success('投票成功')
emit('refresh')
showPollResult.value = true
} else {
toast.error(data.error || '操作失败')
}
}
const selectedOption = ref(null)
const selectOption = (idx) => {
selectedOption.value = idx
}
const submitSinglePoll = async () => {
if (selectedOption.value === null) {
toast.error('请选择一个选项')
return
}
await voteOption(selectedOption.value)
}
const selectedOptions = ref([])
const toggleOption = (idx) => {
const i = selectedOptions.value.indexOf(idx)
if (i >= 0) {
selectedOptions.value.splice(i, 1)
} else {
selectedOptions.value.push(idx)
}
}
const submitMultiPoll = async () => {
const token = getToken()
if (!token) {
toast.error('请先登录')
return
}
if (!selectedOptions.value.length) {
toast.error('请选择至少一个选项')
return
}
const params = selectedOptions.value.map((o) => `option=${o}`).join('&')
const res = await fetch(`${API_BASE_URL}/api/posts/${props.postId}/poll/vote?${params}`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
})
const data = await res.json().catch(() => ({}))
if (res.ok) {
toast.success('投票成功')
emit('refresh')
showPollResult.value = true
} else {
toast.error(data.error || '操作失败')
}
}
</script>
<style scoped>
.post-poll-container {
margin-top: 20px;
display: flex;
flex-direction: column;
gap: 10px;
background-color: var(--lottery-background-color);
border-radius: 10px;
padding: 10px;
}
.poll-option-button {
color: var(--text-color);
padding: 5px 10px;
border-radius: 8px;
background-color: var(--poll-option-button-background-color);
cursor: pointer;
width: fit-content;
}
.poll-top-container {
display: flex;
flex-direction: row;
align-items: center;
border-bottom: 1px solid var(--normal-border-color);
}
.poll-options-container {
display: flex;
flex-direction: column;
overflow-y: auto;
flex: 4;
border-right: 1px solid var(--normal-border-color);
}
.poll-info {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
min-height: 100px;
}
.total-votes {
font-size: 40px;
font-weight: bold;
opacity: 0.8;
}
.total-votes-title {
font-size: 18px;
opacity: 0.5;
}
.poll-option {
margin-bottom: 10px;
margin-right: 10px;
cursor: pointer;
display: flex;
align-items: center;
}
.poll-option-result {
margin-bottom: 10px;
margin-right: 10px;
gap: 5px;
display: flex;
flex-direction: column;
}
.poll-option-input {
margin-right: 10px;
width: 18px;
height: 18px;
accent-color: var(--primary-color);
border-radius: 50%;
border: 2px solid var(--primary-color);
}
.poll-option-text {
font-size: 18px;
}
.poll-bottom-container {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.poll-left-time {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 5px;
}
.poll-left-time-title {
font-size: 13px;
opacity: 0.7;
}
.poll-left-time-value {
font-size: 13px;
font-weight: bold;
color: var(--primary-color);
}
.poll-option-progress {
position: relative;
background-color: rgb(187, 187, 187);
height: 20px;
border-radius: 5px;
overflow: hidden;
}
.poll-option-progress-bar {
background-color: var(--primary-color);
height: 100%;
}
.poll-option-info-container {
display: flex;
flex-direction: row;
justify-content: space-between;
}
.poll-option-progress-info {
font-size: 12px;
line-height: 20px;
color: var(--text-color);
}
.multi-selection-container,
.single-selection-container {
margin-top: 30px;
margin-bottom: 10px;
display: flex;
flex-direction: row;
justify-content: space-between;
}
.multi-selection-title,
.single-selection-title {
font-size: 13px;
color: var(--text-color);
}
.poll-title-section {
display: flex;
gap: 30px;
flex-direction: row;
margin-bottom: 20px;
}
.poll-option-title {
font-size: 18px;
font-weight: bold;
}
.poll-left-time {
font-size: 18px;
}
.info-icon {
margin-right: 5px;
}
.join-poll-button {
padding: 5px 10px;
background-color: var(--primary-color);
color: white;
border-radius: 8px;
cursor: pointer;
}
.join-poll-button:hover {
background-color: var(--primary-color-hover);
}
.poll-participants {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.poll-participant-avatar {
width: 30px;
height: 30px;
border-radius: 50%;
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,183 @@
<template>
<div class="third-party-auth">
<div
v-for="provider in providers"
:key="provider.name"
class="third-party-button"
:class="provider.name"
@click="provider.action"
>
<img
class="third-party-button-icon"
:class="provider.name"
:src="provider.icon"
:alt="provider.alt"
/>
<div class="third-party-button-text" :class="provider.name">
{{ provider.label }}
</div>
</div>
</div>
</template>
<script setup>
import googleIcon from '~/assets/icons/google.svg'
import githubIcon from '~/assets/icons/github.svg'
import discordIcon from '~/assets/icons/discord.svg'
import twitterIcon from '~/assets/icons/twitter.svg'
import telegramIcon from '~/assets/icons/telegram.svg'
import { googleAuthorize } from '~/utils/google'
import { githubAuthorize } from '~/utils/github'
import { discordAuthorize } from '~/utils/discord'
import { twitterAuthorize } from '~/utils/twitter'
import { telegramAuthorize } from '~/utils/telegram'
const props = defineProps({
mode: {
type: String,
default: 'login',
},
inviteToken: {
type: String,
default: '',
},
})
const actionText = computed(() => (props.mode === 'signup' ? '注册' : '登录'))
const providers = computed(() => [
{
name: 'google',
icon: googleIcon,
action: () => googleAuthorize(props.inviteToken),
alt: 'Google Logo',
label: `Google ${actionText.value}`,
},
{
name: 'github',
icon: githubIcon,
action: () => githubAuthorize(props.inviteToken),
alt: 'GitHub Logo',
label: `GitHub ${actionText.value}`,
},
{
name: 'discord',
icon: discordIcon,
action: () => discordAuthorize(props.inviteToken),
alt: 'Discord Logo',
label: `Discord ${actionText.value}`,
},
{
name: 'twitter',
icon: twitterIcon,
action: () => twitterAuthorize(props.inviteToken),
alt: 'Twitter Logo',
label: `X ${actionText.value}`,
},
{
name: 'telegram',
icon: telegramIcon,
action: () => telegramAuthorize(props.inviteToken),
alt: 'Telegram Logo',
label: `Telegram ${actionText.value}`,
},
])
</script>
<style scoped>
.third-party-auth {
margin-left: 20px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 30%;
gap: 11px;
}
.third-party-button {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding: 7px 20px;
min-width: 150px;
background-color: var(--login-background-color);
border: 1px solid var(--normal-border-color);
border-radius: 20px;
cursor: pointer;
gap: 10px;
}
.third-party-button:hover {
background-color: var(--login-background-color-hover);
}
.third-party-button-icon {
width: 20px;
height: 20px;
}
.third-party-button-text {
font-size: 16px;
}
.third-party-button-text.twitter {
color: rgb(182, 182, 182);
}
/* Provider specific classes for customization */
.third-party-button.google {
background-color: var(--google-bg, var(--login-background-color));
color: var(--google-color, inherit);
}
.third-party-button.google:hover {
background-color: var(--google-bg-hover, var(--login-background-color-hover));
}
.third-party-button.github {
background-color: var(--github-bg, var(--login-background-color));
color: var(--github-color, inherit);
}
.third-party-button.github:hover {
background-color: var(--github-bg-hover, var(--login-background-color-hover));
}
.third-party-button.discord {
background-color: var(--discord-bg, var(--login-background-color));
color: var(--discord-color, inherit);
}
.third-party-button.discord:hover {
background-color: var(--discord-bg-hover, var(--login-background-color-hover));
}
.third-party-button.twitter {
background-color: var(--twitter-bg, var(--login-background-color));
color: var(--twitter-color, inherit);
}
.third-party-button.twitter:hover {
background-color: var(--twitter-bg-hover, var(--login-background-color-hover));
}
.third-party-button.telegram {
background-color: var(--telegram-bg, var(--login-background-color));
color: var(--telegram-color, inherit);
}
.third-party-button.telegram:hover {
background-color: var(--telegram-bg-hover, var(--login-background-color-hover));
}
@media (max-width: 768px) {
.third-party-auth {
margin-top: 20px;
margin-left: 0px;
width: calc(100% - 40px);
gap: 10px;
}
.third-party-button {
width: calc(100% - 40px);
}
}
</style>

View File

@@ -11,6 +11,7 @@ export default defineNuxtConfig({
githubClientId: process.env.NUXT_PUBLIC_GITHUB_CLIENT_ID || '',
discordClientId: process.env.NUXT_PUBLIC_DISCORD_CLIENT_ID || '',
twitterClientId: process.env.NUXT_PUBLIC_TWITTER_CLIENT_ID || '',
telegramBotId: process.env.NUXT_PUBLIC_TELEGRAM_BOT_ID || '',
},
},
css: ['vditor/dist/index.css', '~/assets/fonts.css', '~/assets/global.css'],

View File

@@ -2,6 +2,9 @@
"name": "frontend_nuxt",
"private": true,
"type": "module",
"engines": {
"node": ">=20.0.0"
},
"scripts": {
"dev": "nuxt dev",
"build": "nuxt build",

View File

@@ -34,35 +34,15 @@
</div>
</div>
<div class="other-login-page-content">
<div class="login-page-button" @click="loginWithGoogle">
<img class="login-page-button-icon" src="../assets/icons/google.svg" alt="Google Logo" />
<div class="login-page-button-text">Google 登录</div>
</div>
<div class="login-page-button" @click="loginWithGithub">
<img class="login-page-button-icon" src="../assets/icons/github.svg" alt="GitHub Logo" />
<div class="login-page-button-text">GitHub 登录</div>
</div>
<div class="login-page-button" @click="loginWithDiscord">
<img class="login-page-button-icon" src="../assets/icons/discord.svg" alt="Discord Logo" />
<div class="login-page-button-text">Discord 登录</div>
</div>
<div class="login-page-button" @click="loginWithTwitter">
<img class="login-page-button-icon" src="../assets/icons/twitter.svg" alt="Twitter Logo" />
<div class="login-page-button-text">Twitter 登录</div>
</div>
</div>
<ThirdPartyAuth mode="login" />
</div>
</template>
<script setup>
import { toast } from '~/main'
import { setToken, loadCurrentUser } from '~/utils/auth'
import { googleAuthorize } from '~/utils/google'
import { githubAuthorize } from '~/utils/github'
import { discordAuthorize } from '~/utils/discord'
import { twitterAuthorize } from '~/utils/twitter'
import BaseInput from '~/components/BaseInput.vue'
import ThirdPartyAuth from '~/components/ThirdPartyAuth.vue'
import { registerPush } from '~/utils/push'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
@@ -105,19 +85,6 @@ const submitLogin = async () => {
isWaitingForLogin.value = false
}
}
const loginWithGoogle = () => {
googleAuthorize()
}
const loginWithGithub = () => {
githubAuthorize()
}
const loginWithDiscord = () => {
discordAuthorize()
}
const loginWithTwitter = () => {
twitterAuthorize()
}
</script>
<style scoped>
@@ -190,16 +157,6 @@ const loginWithTwitter = () => {
font-size: 16px;
}
.other-login-page-content {
margin-left: 20px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 30%;
gap: 20px;
}
.login-page-button-primary {
margin-top: 20px;
display: flex;
@@ -229,29 +186,6 @@ const loginWithTwitter = () => {
background-color: var(--primary-color-disabled);
}
.login-page-button {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding: 10px 20px;
min-width: 150px;
background-color: var(--login-background-color);
border: 1px solid var(--normal-border-color);
border-radius: 10px;
cursor: pointer;
gap: 10px;
}
.login-page-button:hover {
background-color: var(--login-background-color-hover);
}
.login-page-button-icon {
width: 20px;
height: 20px;
}
.login-page-button-text {
font-size: 16px;
}
@@ -293,16 +227,5 @@ const loginWithTwitter = () => {
margin-top: 0px;
font-size: 13px;
}
.other-login-page-content {
margin-top: 20px;
margin-left: 0px;
width: calc(100% - 40px);
gap: 10px;
}
.login-page-button {
width: calc(100% - 40px);
}
}
</style>

View File

@@ -334,6 +334,9 @@ onMounted(async () => {
if (currentUser.value) {
await fetchMessages(0)
await markConversationAsRead()
await nextTick()
// 初次进入频道时,平滑滚动到底部
scrollToBottomSmooth()
const token = getToken()
if (token && !isConnected.value) {
connect(token)
@@ -368,11 +371,12 @@ watch(isConnected, (newValue) => {
})
onActivated(async () => {
// 返回页面时:刷新数据与已读,不做强制滚动,保持用户当前位置
// 返回页面时:刷新数据与已读,并滚动到底部
if (currentUser.value) {
await fetchMessages(0)
await markConversationAsRead()
await nextTick()
scrollToBottomSmooth()
updateNearBottom()
if (!isConnected.value) {
const token = getToken()

View File

@@ -14,11 +14,25 @@
<div class="message-control-container">
<div class="message-control-title">通知设置</div>
<div class="message-control-item-container">
<div v-for="pref in notificationPrefs" :key="pref.type" class="message-control-item">
<template v-for="pref in notificationPrefs">
<div v-if="canShowNotification(pref.type)" :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>
</template>
</div>
</div>
<div class="message-control-container">
<div class="message-control-title">邮件通知设置</div>
<div class="message-control-item-container">
<div v-for="pref in emailPrefs" :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)"
@update:modelValue="(val) => toggleEmailPref(pref, val)"
/>
</div>
</div>
@@ -579,6 +593,8 @@ import {
hasMore,
fetchNotificationPreferences,
updateNotificationPreference,
fetchEmailNotificationPreferences,
updateEmailNotificationPreference,
} from '~/utils/notification'
import TimeManager from '~/utils/time'
import BaseSwitch from '~/components/BaseSwitch.vue'
@@ -595,6 +611,7 @@ const tabs = [
{ key: 'control', label: '消息设置' },
]
const notificationPrefs = ref([])
const emailPrefs = ref([])
const page = ref(0)
const pageSize = 30
@@ -619,6 +636,10 @@ const fetchPrefs = async () => {
notificationPrefs.value = await fetchNotificationPreferences()
}
const fetchEmailPrefs = async () => {
emailPrefs.value = await fetchEmailNotificationPreferences()
}
const togglePref = async (pref, value) => {
const ok = await updateNotificationPreference(pref.type, value)
if (ok) {
@@ -634,6 +655,15 @@ const togglePref = async (pref, value) => {
}
}
const toggleEmailPref = async (pref, value) => {
const ok = await updateEmailNotificationPreference(pref.type, value)
if (ok) {
pref.enabled = value
} else {
toast.error('操作失败')
}
}
const markRead = async (id) => {
markNotificationRead(id)
if (selectedTab.value === 'unread') {
@@ -714,15 +744,30 @@ const formatType = (t) => {
return '帖子被删除'
case 'POST_FEATURED':
return '文章被精选'
case 'POLL_VOTE':
return '有人参与你的投票'
case 'POLL_RESULT_OWNER':
return '发布的投票结果已公布'
case 'POLL_RESULT_PARTICIPANT':
return '参与的投票结果已公布'
default:
return t
}
}
const isAdmin = computed(() => authState.role === 'ADMIN')
const needAdminSet = new Set(['POST_REVIEW_REQUEST','REGISTER_REQUEST', 'POINT_REDEEM', 'ACTIVITY_REDEEM'])
const canShowNotification = (type) => {
return !needAdminSet.has(type) || isAdmin.value
}
onActivated(async () => {
page.value = 0
await fetchNotifications({ page: 0, size: pageSize, unread: selectedTab.value === 'unread' })
fetchPrefs()
fetchEmailPrefs()
})
</script>

View File

@@ -74,6 +74,7 @@ const lottery = reactive({
const poll = reactive({
options: ['', ''],
endTime: null,
multiple: false,
})
const startTime = ref(null)
const isWaitingPosting = ref(false)
@@ -121,6 +122,7 @@ const clearPost = async () => {
startTime.value = null
poll.options = ['', '']
poll.endTime = null
poll.multiple = false
// 删除草稿
const token = getToken()
@@ -318,6 +320,7 @@ const submitPost = async () => {
prizeCount: postType.value === 'LOTTERY' ? lottery.prizeCount : undefined,
prizeDescription: postType.value === 'LOTTERY' ? lottery.prizeDescription : undefined,
options: postType.value === 'POLL' ? poll.options : undefined,
multiple: postType.value === 'POLL' ? poll.multiple : undefined,
startTime:
postType.value === 'LOTTERY' ? new Date(startTime.value).toISOString() : undefined,
pointCost: postType.value === 'LOTTERY' ? lottery.pointCost : undefined,

View File

@@ -94,157 +94,9 @@
</div>
</div>
<div v-if="lottery" class="post-prize-container">
<div class="prize-content">
<div class="prize-info">
<div class="prize-info-left">
<div class="prize-icon">
<BaseImage
class="prize-icon-img"
v-if="lottery.prizeIcon"
:src="lottery.prizeIcon"
alt="prize"
/>
<i v-else class="fa-solid fa-gift default-prize-icon"></i>
</div>
<div class="prize-name">{{ lottery.prizeDescription }}</div>
<div class="prize-count">x {{ lottery.prizeCount }}</div>
</div>
<div class="prize-end-time prize-info-right">
<div v-if="!isMobile" class="prize-end-time-title">离结束还有</div>
<div class="prize-end-time-value">{{ countdown }}</div>
<div v-if="!isMobile" class="join-prize-button-container-desktop">
<div
v-if="loggedIn && !hasJoined && !lotteryEnded"
class="join-prize-button"
@click="joinLottery"
>
<div class="join-prize-button-text">
参与抽奖 <i class="fas fa-coins"></i> {{ lottery.pointCost }}
</div>
</div>
<div v-else-if="hasJoined" class="join-prize-button-disabled">
<div class="join-prize-button-text">已参与</div>
</div>
</div>
</div>
</div>
<div v-if="isMobile" class="join-prize-button-container-mobile">
<div
v-if="loggedIn && !hasJoined && !lotteryEnded"
class="join-prize-button"
@click="joinLottery"
>
<div class="join-prize-button-text">
参与抽奖 <i class="fas fa-coins"></i> {{ lottery.pointCost }}
</div>
</div>
<div v-else-if="hasJoined" class="join-prize-button-disabled">
<div class="join-prize-button-text">已参与</div>
</div>
</div>
</div>
<div class="prize-member-container">
<BaseImage
v-for="p in lotteryParticipants"
:key="p.id"
class="prize-member-avatar"
:src="p.avatar"
alt="avatar"
@click="gotoUser(p.id)"
/>
<div v-if="lotteryEnded && lotteryWinners.length" class="prize-member-winner">
<i class="fas fa-medal medal-icon"></i>
<span class="prize-member-winner-name">获奖者: </span>
<BaseImage
v-for="w in lotteryWinners"
:key="w.id"
class="prize-member-avatar"
:src="w.avatar"
alt="avatar"
@click="gotoUser(w.id)"
/>
<div v-if="lotteryWinners.length === 1" class="prize-member-winner-name">
{{ lotteryWinners[0].username }}
</div>
</div>
</div>
</div>
<PostLottery v-if="lottery" :lottery="lottery" :post-id="postId" @refresh="refreshPost" />
<ClientOnly>
<div v-if="poll" class="post-poll-container">
<div class="poll-top-container">
<div class="poll-options-container">
<div v-if="showPollResult || pollEnded || hasVoted">
<div v-for="(opt, idx) in poll.options" :key="idx" class="poll-option-result">
<div class="poll-option-info-container">
<div class="poll-option-text">{{ opt }}</div>
<div class="poll-option-progress-info">
{{ pollPercentages[idx] }}% ({{ pollVotes[idx] || 0 }}人已投票)
</div>
</div>
<div class="poll-option-progress">
<div
class="poll-option-progress-bar"
:style="{ width: pollPercentages[idx] + '%' }"
></div>
</div>
<div class="poll-participants">
<BaseImage
v-for="p in pollOptionParticipants[idx] || []"
:key="p.id"
class="poll-participant-avatar"
:src="p.avatar"
alt="avatar"
@click="gotoUser(p.id)"
/>
</div>
</div>
</div>
<div v-else>
<div
v-for="(opt, idx) in poll.options"
:key="idx"
class="poll-option"
@click="voteOption(idx)"
>
<input
type="radio"
:checked="false"
name="poll-option"
class="poll-option-input"
/>
<span class="poll-option-text">{{ opt }}</span>
</div>
</div>
</div>
<div class="poll-info">
<div class="total-votes">{{ pollParticipants.length }}</div>
<div class="total-votes-title">投票人</div>
</div>
</div>
<div class="poll-bottom-container">
<div
v-if="showPollResult && !pollEnded && !hasVoted"
class="poll-option-button"
@click="showPollResult = false"
>
<i class="fas fa-chevron-left"></i> 投票
</div>
<div
v-else-if="!pollEnded && !hasVoted"
class="poll-option-button"
@click="showPollResult = true"
>
<i class="fas fa-chart-bar"></i> 结果
</div>
<div class="poll-left-time">
<div class="poll-left-time-title">离结束还有</div>
<div class="poll-left-time-value">{{ countdown }}</div>
</div>
</div>
</div>
<PostPoll v-if="poll" :poll="poll" :post-id="postId" @refresh="refreshPost" />
</ClientOnly>
<div v-if="closed" class="post-close-container">该帖子已关闭内容仅供阅读无法进行互动</div>
@@ -333,6 +185,8 @@ import ArticleTags from '~/components/ArticleTags.vue'
import ArticleCategory from '~/components/ArticleCategory.vue'
import ReactionsGroup from '~/components/ReactionsGroup.vue'
import DropdownMenu from '~/components/DropdownMenu.vue'
import PostLottery from '~/components/PostLottery.vue'
import PostPoll from '~/components/PostPoll.vue'
import { renderMarkdown, handleMarkdownClick, stripMarkdownLength } from '~/utils/markdown'
import { getMedalTitle } from '~/utils/medal'
import { toast } from '~/main'
@@ -388,7 +242,6 @@ useHead(() => ({
if (import.meta.client) {
onBeforeUnmount(() => {
window.removeEventListener('scroll', updateCurrentIndex)
if (countdownTimer) clearInterval(countdownTimer)
})
}
@@ -400,73 +253,6 @@ const isAdmin = computed(() => authState.role === 'ADMIN')
const isAuthor = computed(() => authState.username === author.value.username)
const lottery = ref(null)
const poll = ref(null)
const showPollResult = ref(false)
const countdown = ref('00:00:00')
let countdownTimer = null
const lotteryParticipants = computed(() => lottery.value?.participants || [])
const lotteryWinners = computed(() => lottery.value?.winners || [])
const lotteryEnded = computed(() => {
if (!lottery.value || !lottery.value.endTime) return false
return new Date(lottery.value.endTime).getTime() <= Date.now()
})
const hasJoined = computed(() => {
if (!loggedIn.value) return false
return lotteryParticipants.value.some((p) => p.id === Number(authState.userId))
})
const pollParticipants = computed(() => poll.value?.participants || [])
const pollOptionParticipants = computed(() => poll.value?.optionParticipants || {})
const pollVotes = computed(() => poll.value?.votes || {})
const totalPollVotes = computed(() => Object.values(pollVotes.value).reduce((a, b) => a + b, 0))
const pollPercentages = computed(() =>
poll.value
? poll.value.options.map((_, idx) => {
const c = pollVotes.value[idx] || 0
return totalPollVotes.value ? ((c / totalPollVotes.value) * 100).toFixed(1) : 0
})
: [],
)
const pollEnded = computed(() => {
if (!poll.value || !poll.value.endTime) return false
return new Date(poll.value.endTime).getTime() <= Date.now()
})
const hasVoted = computed(() => {
if (!loggedIn.value) return false
return pollParticipants.value.some((p) => p.id === Number(authState.userId))
})
watch([hasVoted, pollEnded], ([voted, ended]) => {
if (voted || ended) showPollResult.value = true
})
const currentEndTime = computed(() => {
if (lottery.value && lottery.value.endTime) return lottery.value.endTime
if (poll.value && poll.value.endTime) return poll.value.endTime
return null
})
const updateCountdown = () => {
if (!currentEndTime.value) {
countdown.value = '00:00:00'
return
}
const diff = new Date(currentEndTime.value).getTime() - Date.now()
if (diff <= 0) {
countdown.value = '00:00:00'
if (countdownTimer) {
clearInterval(countdownTimer)
countdownTimer = null
}
return
}
const h = String(Math.floor(diff / 3600000)).padStart(2, '0')
const m = String(Math.floor((diff % 3600000) / 60000)).padStart(2, '0')
const s = String(Math.floor((diff % 60000) / 1000)).padStart(2, '0')
countdown.value = `${h}:${m}:${s}`
}
const startCountdown = () => {
if (!import.meta.client) return
if (countdownTimer) clearInterval(countdownTimer)
updateCountdown()
countdownTimer = setInterval(updateCountdown, 1000)
}
const gotoUser = (id) => navigateTo(`/users/${id}`, { replace: true })
const articleMenuItems = computed(() => {
const items = []
if (isAuthor.value || isAdmin.value) {
@@ -628,8 +414,6 @@ watchEffect(() => {
postTime.value = TimeManager.format(data.createdAt)
lottery.value = data.lottery || null
poll.value = data.poll || null
if ((lottery.value && lottery.value.endTime) || (poll.value && poll.value.endTime))
startCountdown()
})
// 404 客户端跳转
@@ -920,45 +704,6 @@ const unsubscribePost = async () => {
}
}
const joinLottery = async () => {
const token = getToken()
if (!token) {
toast.error('请先登录')
return
}
const res = await fetch(`${API_BASE_URL}/api/posts/${postId}/lottery/join`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
})
const data = await res.json().catch(() => ({}))
if (res.ok) {
toast.success('已参与抽奖')
await refreshPost()
} else {
toast.error(data.error || '操作失败')
}
}
const voteOption = async (idx) => {
const token = getToken()
if (!token) {
toast.error('请先登录')
return
}
const res = await fetch(`${API_BASE_URL}/api/posts/${postId}/poll/vote?option=${idx}`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
})
const data = await res.json().catch(() => ({}))
if (res.ok) {
toast.success('投票成功')
await refreshPost()
showPollResult.value = true
} else {
toast.error(data.error || '操作失败')
}
}
const fetchCommentSorts = () => {
return Promise.resolve([
{ id: 'NEWEST', name: '最新', icon: 'fas fa-clock' },
@@ -1287,95 +1032,6 @@ onMounted(async () => {
cursor: pointer;
}
.poll-option-button {
color: var(--text-color);
padding: 5px 10px;
border-radius: 8px;
background-color: rgb(218, 218, 218);
cursor: pointer;
width: fit-content;
}
.poll-top-container {
display: flex;
flex-direction: row;
align-items: center;
border-bottom: 1px solid var(--normal-border-color);
}
.poll-options-container {
display: flex;
flex-direction: column;
overflow-y: auto;
flex: 4;
}
.poll-info {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
min-height: 100px;
}
.total-votes {
font-size: 40px;
font-weight: bold;
opacity: 0.8;
}
.total-votes-title {
font-size: 18px;
opacity: 0.5;
}
.poll-option {
margin-bottom: 10px;
margin-right: 10px;
cursor: pointer;
display: flex;
align-items: center;
}
.poll-option-result {
margin-bottom: 10px;
margin-right: 10px;
gap: 5px;
display: flex;
flex-direction: column;
}
.poll-option-input {
margin-right: 10px;
width: 18px;
height: 18px;
accent-color: var(--primary-color);
border-radius: 50%;
border: 2px solid var(--primary-color);
}
.poll-option-text {
font-size: 18px;
}
.poll-bottom-container {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.poll-left-time {
display: flex;
flex-direction: row;
}
.poll-left-time-title {
font-size: 13px;
opacity: 0.7;
}
.action-menu-icon {
cursor: pointer;
font-size: 18px;
@@ -1491,201 +1147,6 @@ onMounted(async () => {
position: relative;
}
.post-prize-container {
margin-top: 20px;
display: flex;
flex-direction: column;
gap: 10px;
background-color: var(--lottery-background-color);
border-radius: 10px;
padding: 10px;
}
.post-poll-container {
margin-top: 20px;
display: flex;
flex-direction: column;
gap: 10px;
background-color: var(--lottery-background-color);
border-radius: 10px;
padding: 10px;
}
.poll-question {
font-weight: bold;
margin-bottom: 10px;
}
.poll-option-progress {
position: relative;
background-color: rgb(187, 187, 187);
height: 20px;
border-radius: 5px;
overflow: hidden;
}
.poll-option-progress-bar {
background-color: var(--primary-color);
height: 100%;
}
.poll-option-info-container {
display: flex;
flex-direction: row;
justify-content: space-between;
}
.poll-option-progress-info {
font-size: 12px;
line-height: 20px;
color: var(--text-color);
}
.poll-vote-button {
margin-top: 5px;
color: var(--primary-color);
cursor: pointer;
width: fit-content;
}
.poll-participants {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.poll-participant-avatar {
width: 30px;
height: 30px;
border-radius: 50%;
cursor: pointer;
}
.prize-info {
display: flex;
flex-direction: row;
justify-content: space-between;
width: 100%;
align-items: center;
}
.join-prize-button-container-mobile {
margin-top: 15px;
margin-bottom: 10px;
}
.prize-icon {
width: 24px;
height: 24px;
}
.default-prize-icon {
font-size: 24px;
opacity: 0.5;
}
.prize-icon-img {
width: 100%;
height: 100%;
}
.prize-name {
font-size: 13px;
opacity: 0.7;
margin-left: 10px;
}
.prize-count {
font-size: 13px;
font-weight: bold;
opacity: 0.7;
margin-left: 10px;
color: var(--primary-color);
}
.prize-end-time {
display: flex;
flex-direction: row;
align-items: center;
font-size: 13px;
opacity: 0.7;
margin-left: 10px;
}
.poll-left-time-title,
.prize-end-time-title {
font-size: 13px;
opacity: 0.7;
margin-right: 5px;
}
.poll-left-time-value,
.prize-end-time-value {
font-size: 13px;
font-weight: bold;
color: var(--primary-color);
}
.prize-info-left,
.prize-info-right {
display: flex;
flex-direction: row;
align-items: center;
}
.join-prize-button {
margin-left: 10px;
background-color: var(--primary-color);
color: white;
padding: 5px 10px;
border-radius: 8px;
cursor: pointer;
text-align: center;
}
.join-prize-button:hover {
background-color: var(--primary-color-hover);
}
.join-prize-button-disabled {
text-align: center;
margin-left: 10px;
background-color: var(--primary-color);
color: white;
padding: 5px 10px;
border-radius: 8px;
background-color: var(--primary-color-disabled);
opacity: 0.5;
cursor: not-allowed;
}
.prize-member-avatar {
width: 30px;
height: 30px;
margin-left: 3px;
border-radius: 50%;
object-fit: cover;
cursor: pointer;
}
.prize-member-winner {
display: flex;
flex-direction: row;
align-items: center;
gap: 5px;
margin-top: 10px;
}
.medal-icon {
font-size: 16px;
color: var(--primary-color);
}
.prize-member-winner-name {
font-size: 13px;
opacity: 0.7;
}
@media (max-width: 768px) {
.post-page-main-container {
width: calc(100% - 20px);
@@ -1736,10 +1197,5 @@ onMounted(async () => {
.loading-container {
width: 100%;
}
.join-prize-button,
.join-prize-button-disabled {
margin-left: 0;
}
}
</style>

View File

@@ -68,35 +68,15 @@
</div>
</div>
<div class="other-signup-page-content">
<div class="signup-page-button" @click="signupWithGoogle">
<img class="signup-page-button-icon" src="~/assets/icons/google.svg" alt="Google Logo" />
<div class="signup-page-button-text">Google 注册</div>
</div>
<div class="signup-page-button" @click="signupWithGithub">
<img class="signup-page-button-icon" src="~/assets/icons/github.svg" alt="GitHub Logo" />
<div class="signup-page-button-text">GitHub 注册</div>
</div>
<div class="signup-page-button" @click="signupWithDiscord">
<img class="signup-page-button-icon" src="~/assets/icons/discord.svg" alt="Discord Logo" />
<div class="signup-page-button-text">Discord 注册</div>
</div>
<div class="signup-page-button" @click="signupWithTwitter">
<img class="signup-page-button-icon" src="~/assets/icons/twitter.svg" alt="Twitter Logo" />
<div class="signup-page-button-text">Twitter 注册</div>
</div>
</div>
<ThirdPartyAuth mode="signup" :invite-token="inviteToken" />
</div>
</template>
<script setup>
import BaseInput from '~/components/BaseInput.vue'
import { toast } from '~/main'
import { discordAuthorize } from '~/utils/discord'
import { githubAuthorize } from '~/utils/github'
import { googleAuthorize } from '~/utils/google'
import { twitterAuthorize } from '~/utils/twitter'
import { loadCurrentUser, setToken } from '~/utils/auth'
import ThirdPartyAuth from '~/components/ThirdPartyAuth.vue'
const route = useRoute()
const config = useRuntimeConfig()
@@ -216,18 +196,6 @@ const verifyCode = async () => {
isWaitingForEmailVerified.value = false
}
}
const signupWithGoogle = () => {
googleAuthorize(inviteToken.value)
}
const signupWithGithub = () => {
githubAuthorize(inviteToken.value)
}
const signupWithDiscord = () => {
discordAuthorize(inviteToken.value)
}
const signupWithTwitter = () => {
twitterAuthorize(inviteToken.value)
}
</script>
<style scoped>
@@ -300,16 +268,6 @@ const signupWithTwitter = () => {
font-size: 16px;
}
.other-signup-page-content {
margin-left: 20px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 30%;
gap: 20px;
}
.signup-page-button-primary {
margin-top: 20px;
display: flex;
@@ -339,29 +297,6 @@ const signupWithTwitter = () => {
background-color: var(--primary-color-hover);
}
.signup-page-button {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding: 10px 20px;
background-color: var(--login-background-color);
border: 1px solid var(--normal-border-color);
border-radius: 10px;
cursor: pointer;
min-width: 150px;
gap: 10px;
}
.signup-page-button:hover {
background-color: var(--login-background-color-hover);
}
.signup-page-button-icon {
width: 20px;
height: 20px;
}
.signup-page-button-text {
font-size: 16px;
}
@@ -411,16 +346,5 @@ const signupWithTwitter = () => {
margin-top: 0px;
font-size: 13px;
}
.other-signup-page-content {
margin-top: 20px;
margin-left: 0px;
width: calc(100% - 40px);
gap: 10px;
}
.signup-page-button {
width: calc(100% - 40px);
}
}
</style>

View File

@@ -0,0 +1,42 @@
<template>
<CallbackPage />
</template>
<script setup>
import CallbackPage from '~/components/CallbackPage.vue'
import { telegramExchange } from '~/utils/telegram'
onMounted(async () => {
const url = new URL(window.location.href)
const inviteToken =
url.searchParams.get('invite_token') || url.searchParams.get('invitetoken') || ''
const hash = url.hash.startsWith('#tgAuthResult=') ? url.hash.slice('#tgAuthResult='.length) : ''
if (!hash) {
navigateTo('/login', { replace: true })
return
}
let authData
try {
const decoded = atob(hash)
const parsed = JSON.parse(decoded)
authData = {
id: String(parsed.id),
firstName: parsed.first_name,
lastName: parsed.last_name,
username: parsed.username,
photoUrl: parsed.photo_url,
authDate: parsed.auth_date,
hash: parsed.hash,
}
} catch (e) {
navigateTo('/login', { replace: true })
return
}
const result = await telegramExchange(authData, inviteToken, '')
if (result.needReason) {
navigateTo(`/signup-reason?token=${result.token}`, { replace: true })
} else {
navigateTo('/', { replace: true })
}
})
</script>

View File

@@ -58,7 +58,9 @@
</div>
<div class="profile-info-item">
<div class="profile-info-item-label">最后发帖时间:</div>
<div class="profile-info-item-value">{{ formatDate(user.lastPostTime) }}</div>
<div class="profile-info-item-value">
{{ user.lastPostTime != null ? formatDate(user.lastPostTime) : '暂无帖子' }}
</div>
</div>
<div class="profile-info-item">
<div class="profile-info-item-label">最后评论时间:</div>

View File

@@ -116,6 +116,43 @@ export async function updateNotificationPreference(type, enabled) {
}
}
export async function fetchEmailNotificationPreferences() {
try {
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const token = getToken()
if (!token) return []
const res = await fetch(`${API_BASE_URL}/api/notifications/email-prefs`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) return []
return await res.json()
} catch (e) {
return []
}
}
export async function updateEmailNotificationPreference(type, enabled) {
try {
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const token = getToken()
if (!token) return false
const res = await fetch(`${API_BASE_URL}/api/notifications/email-prefs`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ type, enabled }),
})
return res.ok
} catch (e) {
return false
}
}
/**
* 处理信息的高阶函数
* @returns

View File

@@ -0,0 +1,56 @@
import { toast } from '../main'
import { setToken, loadCurrentUser } from './auth'
import { registerPush } from './push'
export function telegramAuthorize(inviteToken = '') {
const config = useRuntimeConfig()
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
const TELEGRAM_BOT_ID = config.public.telegramBotId
if (!TELEGRAM_BOT_ID) {
toast.error('Telegram 登录不可用')
return
}
const redirectUri = `${WEBSITE_BASE_URL}/telegram-callback${inviteToken ? `?invite_token=${encodeURIComponent(inviteToken)}` : ''}`
const url =
`https://oauth.telegram.org/auth` +
`?bot_id=${encodeURIComponent(TELEGRAM_BOT_ID)}` +
`&origin=${encodeURIComponent(redirectUri)}` +
`&request_access=write`
// `&redirect_uri=${encodeURIComponent(redirectUri)}`
window.location.href = url
}
export async function telegramExchange(authData, inviteToken = '', reason = '') {
try {
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const payload = { ...authData, reason }
if (inviteToken) payload.inviteToken = inviteToken
const res = await fetch(`${API_BASE_URL}/api/auth/telegram`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
const data = await res.json()
if (res.ok && data.token) {
setToken(data.token)
await loadCurrentUser()
toast.success('登录成功')
registerPush?.()
return { success: true, needReason: false }
} else if (data.reason_code === 'NOT_APPROVED') {
toast.info('当前为注册审核模式,请填写注册理由')
return { success: false, needReason: true, token: data.token }
} else if (data.reason_code === 'IS_APPROVING') {
toast.info('您的注册理由正在审批中')
return { success: true, needReason: false }
} else {
toast.error(data.error || '登录失败')
return { success: false, needReason: false, error: data.error || '登录失败' }
}
} catch (e) {
console.error(e)
toast.error('登录失败')
return { success: false, needReason: false, error: '登录失败' }
}
}