mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-15 19:41:02 +08:00
Compare commits
50 Commits
codex/modi
...
codex/fix-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b37cf5abb | ||
|
|
4af19a75c9 | ||
|
|
37ea986389 | ||
|
|
fefd0b3b6c | ||
|
|
a31ed29cfa | ||
|
|
2719819ad7 | ||
|
|
27ff9a9c9b | ||
|
|
18fde1052f | ||
|
|
800970f078 | ||
|
|
cbbd1440a1 | ||
|
|
215616d771 | ||
|
|
575e90e558 | ||
|
|
e63d66806d | ||
|
|
1fc0118c5a | ||
|
|
f3512c1184 | ||
|
|
28842c90b1 | ||
|
|
d67cc326c4 | ||
|
|
27c217a630 | ||
|
|
4e3e5f147c | ||
|
|
8767aa31d6 | ||
|
|
a428f472f2 | ||
|
|
8544803e62 | ||
|
|
54874cea7a | ||
|
|
098d82a6a0 | ||
|
|
90eee03198 | ||
|
|
3f152906f2 | ||
|
|
ef71d0b3d4 | ||
|
|
6f80d139ba | ||
|
|
7454931fa5 | ||
|
|
0852664a82 | ||
|
|
5814fb673a | ||
|
|
4ee4266e3d | ||
|
|
6a27fbe1d7 | ||
|
|
38ff04c358 | ||
|
|
fc27200ac1 | ||
|
|
b1998be425 | ||
|
|
72adc5b232 | ||
|
|
d24e67de5d | ||
|
|
eefefac236 | ||
|
|
2f339fdbdb | ||
|
|
3808becc8b | ||
|
|
18db4d7317 | ||
|
|
52cbb71945 | ||
|
|
39c34a9048 | ||
|
|
4baabf2224 | ||
|
|
8023183bc6 | ||
|
|
27efc493b2 | ||
|
|
ca6e45a711 | ||
|
|
803ca9e103 | ||
|
|
9d1e12773a |
@@ -28,6 +28,7 @@ TWITTER_CLIENT_ID=<你的twitter-client-id>
|
|||||||
TWITTER_CLIENT_SECRET=<你的-twitter-client-secret>
|
TWITTER_CLIENT_SECRET=<你的-twitter-client-secret>
|
||||||
DISCORD_CLIENT_ID=<你的discord-client-id>
|
DISCORD_CLIENT_ID=<你的discord-client-id>
|
||||||
DISCORD_CLIENT_SECRET=<你的discord-client-secret>
|
DISCORD_CLIENT_SECRET=<你的discord-client-secret>
|
||||||
|
TELEGRAM_BOT_TOKEN=<你的telegram-bot-token>
|
||||||
|
|
||||||
# === OPENAI ===
|
# === OPENAI ===
|
||||||
OPENAI_API_KEY=<你的openai-api-key>
|
OPENAI_API_KEY=<你的openai-api-key>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ public class AuthController {
|
|||||||
private final GithubAuthService githubAuthService;
|
private final GithubAuthService githubAuthService;
|
||||||
private final DiscordAuthService discordAuthService;
|
private final DiscordAuthService discordAuthService;
|
||||||
private final TwitterAuthService twitterAuthService;
|
private final TwitterAuthService twitterAuthService;
|
||||||
|
private final TelegramAuthService telegramAuthService;
|
||||||
private final RegisterModeService registerModeService;
|
private final RegisterModeService registerModeService;
|
||||||
private final NotificationService notificationService;
|
private final NotificationService notificationService;
|
||||||
private final UserRepository userRepository;
|
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")
|
@GetMapping("/check")
|
||||||
public ResponseEntity<?> checkToken() {
|
public ResponseEntity<?> checkToken() {
|
||||||
return ResponseEntity.ok(Map.of("valid", true));
|
return ResponseEntity.ok(Map.of("valid", true));
|
||||||
|
|||||||
@@ -62,4 +62,14 @@ public class NotificationController {
|
|||||||
public void updatePref(@RequestBody NotificationPreferenceUpdateRequest req, Authentication auth) {
|
public void updatePref(@RequestBody NotificationPreferenceUpdateRequest req, Authentication auth) {
|
||||||
notificationService.updatePreference(auth.getName(), req.getType(), req.isEnabled());
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ public class PostController {
|
|||||||
req.getType(), req.getPrizeDescription(), req.getPrizeIcon(),
|
req.getType(), req.getPrizeDescription(), req.getPrizeIcon(),
|
||||||
req.getPrizeCount(), req.getPointCost(),
|
req.getPrizeCount(), req.getPointCost(),
|
||||||
req.getStartTime(), req.getEndTime(),
|
req.getStartTime(), req.getEndTime(),
|
||||||
req.getOptions());
|
req.getOptions(), req.getMultiple());
|
||||||
draftService.deleteDraft(auth.getName());
|
draftService.deleteDraft(auth.getName());
|
||||||
PostDetailDto dto = postMapper.toDetailDto(post, auth.getName());
|
PostDetailDto dto = postMapper.toDetailDto(post, auth.getName());
|
||||||
dto.setReward(levelService.awardForPost(auth.getName()));
|
dto.setReward(levelService.awardForPost(auth.getName()));
|
||||||
@@ -94,7 +94,7 @@ public class PostController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/{id}/poll/vote")
|
@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);
|
postService.votePoll(id, auth.getName(), option);
|
||||||
return ResponseEntity.ok().build();
|
return ResponseEntity.ok().build();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,4 +13,5 @@ public class PollDto {
|
|||||||
private LocalDateTime endTime;
|
private LocalDateTime endTime;
|
||||||
private List<AuthorDto> participants;
|
private List<AuthorDto> participants;
|
||||||
private Map<Integer, List<AuthorDto>> optionParticipants;
|
private Map<Integer, List<AuthorDto>> optionParticipants;
|
||||||
|
private boolean multiple;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,5 +28,6 @@ public class PostRequest {
|
|||||||
private LocalDateTime endTime;
|
private LocalDateTime endTime;
|
||||||
// fields for poll posts
|
// fields for poll posts
|
||||||
private List<String> options;
|
private List<String> options;
|
||||||
|
private Boolean multiple;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -111,6 +111,7 @@ public class PostMapper {
|
|||||||
.collect(Collectors.groupingBy(PollVote::getOptionIndex,
|
.collect(Collectors.groupingBy(PollVote::getOptionIndex,
|
||||||
Collectors.mapping(v -> userMapper.toAuthorDto(v.getUser()), Collectors.toList())));
|
Collectors.mapping(v -> userMapper.toAuthorDto(v.getUser()), Collectors.toList())));
|
||||||
p.setOptionParticipants(optionParticipants);
|
p.setOptionParticipants(optionParticipants);
|
||||||
|
p.setMultiple(Boolean.TRUE.equals(pp.getMultiple()));
|
||||||
dto.setPoll(p);
|
dto.setPoll(p);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import lombok.Getter;
|
|||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
import lombok.Setter;
|
import lombok.Setter;
|
||||||
import org.hibernate.annotations.CreationTimestamp;
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
|
import org.hibernate.annotations.SQLDelete;
|
||||||
|
import org.hibernate.annotations.Where;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
@@ -13,6 +15,8 @@ import java.time.LocalDateTime;
|
|||||||
@Setter
|
@Setter
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@Table(name = "comments")
|
@Table(name = "comments")
|
||||||
|
@SQLDelete(sql = "UPDATE comments SET deleted_at = CURRENT_TIMESTAMP(6) WHERE id = ?")
|
||||||
|
@Where(clause = "deleted_at IS NULL")
|
||||||
public class Comment {
|
public class Comment {
|
||||||
@Id
|
@Id
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
@@ -41,4 +45,7 @@ public class Comment {
|
|||||||
@Column
|
@Column
|
||||||
private LocalDateTime pinnedAt;
|
private LocalDateTime pinnedAt;
|
||||||
|
|
||||||
|
@Column(name = "deleted_at")
|
||||||
|
private LocalDateTime deletedAt;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ public class InviteToken {
|
|||||||
@Id
|
@Id
|
||||||
private String token;
|
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
|
@ManyToOne
|
||||||
private User inviter;
|
private User inviter;
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import jakarta.persistence.*;
|
|||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
import lombok.Setter;
|
import lombok.Setter;
|
||||||
|
import org.hibernate.annotations.SQLDelete;
|
||||||
|
import org.hibernate.annotations.Where;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
@@ -13,6 +15,8 @@ import java.time.LocalDateTime;
|
|||||||
@Setter
|
@Setter
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@Table(name = "point_histories")
|
@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 {
|
public class PointHistory {
|
||||||
@Id
|
@Id
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
@@ -46,4 +50,7 @@ public class PointHistory {
|
|||||||
|
|
||||||
@Column(name = "created_at", nullable = false)
|
@Column(name = "created_at", nullable = false)
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Column(name = "deleted_at")
|
||||||
|
private LocalDateTime deletedAt;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ public class PollPost extends Post {
|
|||||||
inverseJoinColumns = @JoinColumn(name = "user_id"))
|
inverseJoinColumns = @JoinColumn(name = "user_id"))
|
||||||
private Set<User> participants = new HashSet<>();
|
private Set<User> participants = new HashSet<>();
|
||||||
|
|
||||||
|
@Column
|
||||||
|
private Boolean multiple = false;
|
||||||
|
|
||||||
@Column
|
@Column
|
||||||
private LocalDateTime endTime;
|
private LocalDateTime endTime;
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import lombok.NoArgsConstructor;
|
|||||||
import lombok.Setter;
|
import lombok.Setter;
|
||||||
|
|
||||||
@Entity
|
@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
|
@Getter
|
||||||
@Setter
|
@Setter
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
|
|||||||
@@ -74,6 +74,12 @@ public class User {
|
|||||||
NotificationType.USER_ACTIVITY
|
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
|
@CreationTimestamp
|
||||||
@Column(nullable = false, updatable = false,
|
@Column(nullable = false, updatable = false,
|
||||||
columnDefinition = "DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6)")
|
columnDefinition = "DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6)")
|
||||||
|
|||||||
@@ -9,4 +9,8 @@ import java.util.Optional;
|
|||||||
|
|
||||||
public interface InviteTokenRepository extends JpaRepository<InviteToken, String> {
|
public interface InviteTokenRepository extends JpaRepository<InviteToken, String> {
|
||||||
Optional<InviteToken> findByInviterAndCreatedDate(User inviter, LocalDate createdDate);
|
Optional<InviteToken> findByInviterAndCreatedDate(User inviter, LocalDate createdDate);
|
||||||
|
|
||||||
|
Optional<InviteToken> findByShortToken(String shortToken);
|
||||||
|
|
||||||
|
boolean existsByShortToken(String shortToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.openisle.repository;
|
|||||||
|
|
||||||
import com.openisle.model.PointHistory;
|
import com.openisle.model.PointHistory;
|
||||||
import com.openisle.model.User;
|
import com.openisle.model.User;
|
||||||
|
import com.openisle.model.Comment;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
@@ -12,4 +13,6 @@ public interface PointHistoryRepository extends JpaRepository<PointHistory, Long
|
|||||||
long countByUser(User user);
|
long countByUser(User user);
|
||||||
|
|
||||||
List<PointHistory> findByUserAndCreatedAtAfterOrderByCreatedAtDesc(User user, LocalDateTime createdAt);
|
List<PointHistory> findByUserAndCreatedAtAfterOrderByCreatedAtDesc(User user, LocalDateTime createdAt);
|
||||||
|
|
||||||
|
List<PointHistory> findByComment(Comment comment);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import com.openisle.model.Comment;
|
|||||||
import com.openisle.model.Post;
|
import com.openisle.model.Post;
|
||||||
import com.openisle.model.User;
|
import com.openisle.model.User;
|
||||||
import com.openisle.model.NotificationType;
|
import com.openisle.model.NotificationType;
|
||||||
|
import com.openisle.model.PointHistory;
|
||||||
import com.openisle.model.CommentSort;
|
import com.openisle.model.CommentSort;
|
||||||
import com.openisle.repository.CommentRepository;
|
import com.openisle.repository.CommentRepository;
|
||||||
import com.openisle.repository.PostRepository;
|
import com.openisle.repository.PostRepository;
|
||||||
@@ -11,8 +12,10 @@ import com.openisle.repository.UserRepository;
|
|||||||
import com.openisle.repository.ReactionRepository;
|
import com.openisle.repository.ReactionRepository;
|
||||||
import com.openisle.repository.CommentSubscriptionRepository;
|
import com.openisle.repository.CommentSubscriptionRepository;
|
||||||
import com.openisle.repository.NotificationRepository;
|
import com.openisle.repository.NotificationRepository;
|
||||||
|
import com.openisle.repository.PointHistoryRepository;
|
||||||
import com.openisle.service.NotificationService;
|
import com.openisle.service.NotificationService;
|
||||||
import com.openisle.service.SubscriptionService;
|
import com.openisle.service.SubscriptionService;
|
||||||
|
import com.openisle.service.PointService;
|
||||||
import com.openisle.model.Role;
|
import com.openisle.model.Role;
|
||||||
import com.openisle.exception.RateLimitException;
|
import com.openisle.exception.RateLimitException;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
@@ -20,6 +23,9 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.util.List;
|
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.PageRequest;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
@@ -37,6 +43,8 @@ public class CommentService {
|
|||||||
private final ReactionRepository reactionRepository;
|
private final ReactionRepository reactionRepository;
|
||||||
private final CommentSubscriptionRepository commentSubscriptionRepository;
|
private final CommentSubscriptionRepository commentSubscriptionRepository;
|
||||||
private final NotificationRepository notificationRepository;
|
private final NotificationRepository notificationRepository;
|
||||||
|
private final PointHistoryRepository pointHistoryRepository;
|
||||||
|
private final PointService pointService;
|
||||||
private final ImageUploader imageUploader;
|
private final ImageUploader imageUploader;
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -63,16 +71,19 @@ public class CommentService {
|
|||||||
log.debug("Comment {} saved for post {}", comment.getId(), postId);
|
log.debug("Comment {} saved for post {}", comment.getId(), postId);
|
||||||
imageUploader.addReferences(imageUploader.extractUrls(content));
|
imageUploader.addReferences(imageUploader.extractUrls(content));
|
||||||
if (!author.getId().equals(post.getAuthor().getId())) {
|
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)) {
|
for (User u : subscriptionService.getPostSubscribers(postId)) {
|
||||||
if (!u.getId().equals(author.getId())) {
|
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())) {
|
for (User u : subscriptionService.getSubscribers(author.getUsername())) {
|
||||||
if (!u.getId().equals(author.getId())) {
|
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);
|
notificationService.notifyMentions(content, author, post, comment);
|
||||||
@@ -109,21 +120,25 @@ public class CommentService {
|
|||||||
log.debug("Reply {} saved for parent {}", comment.getId(), parentId);
|
log.debug("Reply {} saved for parent {}", comment.getId(), parentId);
|
||||||
imageUploader.addReferences(imageUploader.extractUrls(content));
|
imageUploader.addReferences(imageUploader.extractUrls(content));
|
||||||
if (!author.getId().equals(parent.getAuthor().getId())) {
|
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)) {
|
for (User u : subscriptionService.getCommentSubscribers(parentId)) {
|
||||||
if (!u.getId().equals(author.getId())) {
|
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())) {
|
for (User u : subscriptionService.getPostSubscribers(parent.getPost().getId())) {
|
||||||
if (!u.getId().equals(author.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())) {
|
for (User u : subscriptionService.getSubscribers(author.getUsername())) {
|
||||||
if (!u.getId().equals(author.getId())) {
|
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);
|
notificationService.notifyMentions(content, author, parent.getPost(), comment);
|
||||||
@@ -235,11 +250,33 @@ public class CommentService {
|
|||||||
for (Comment c : replies) {
|
for (Comment c : replies) {
|
||||||
deleteCommentCascade(c);
|
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);
|
reactionRepository.findByComment(comment).forEach(reactionRepository::delete);
|
||||||
commentSubscriptionRepository.findByComment(comment).forEach(commentSubscriptionRepository::delete);
|
commentSubscriptionRepository.findByComment(comment).forEach(commentSubscriptionRepository::delete);
|
||||||
notificationRepository.deleteAll(notificationRepository.findByComment(comment));
|
notificationRepository.deleteAll(notificationRepository.findByComment(comment));
|
||||||
imageUploader.removeReferences(imageUploader.extractUrls(comment.getContent()));
|
imageUploader.removeReferences(imageUploader.extractUrls(comment.getContent()));
|
||||||
|
|
||||||
|
// 逻辑删除评论
|
||||||
commentRepository.delete(comment);
|
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());
|
log.debug("deleteCommentCascade removed comment {}", comment.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,33 +30,53 @@ public class InviteService {
|
|||||||
LocalDate today = LocalDate.now();
|
LocalDate today = LocalDate.now();
|
||||||
Optional<InviteToken> existing = inviteTokenRepository.findByInviterAndCreatedDate(inviter, today);
|
Optional<InviteToken> existing = inviteTokenRepository.findByInviterAndCreatedDate(inviter, today);
|
||||||
if (existing.isPresent()) {
|
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 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 inviteToken = new InviteToken();
|
||||||
inviteToken.setToken(token);
|
inviteToken.setToken(token);
|
||||||
|
inviteToken.setShortToken(shortToken);
|
||||||
inviteToken.setInviter(inviter);
|
inviteToken.setInviter(inviter);
|
||||||
inviteToken.setCreatedDate(today);
|
inviteToken.setCreatedDate(today);
|
||||||
inviteToken.setUsageCount(0);
|
inviteToken.setUsageCount(0);
|
||||||
inviteTokenRepository.save(inviteToken);
|
inviteTokenRepository.save(inviteToken);
|
||||||
return token;
|
return shortToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
public InviteValidateResult validate(String token) {
|
public InviteValidateResult validate(String token) {
|
||||||
if (token == null || token.isEmpty()) {
|
if (token == null || token.isEmpty()) {
|
||||||
return new InviteValidateResult(null, false);
|
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 {
|
try {
|
||||||
jwtService.validateAndGetSubjectForInvite(token);
|
jwtService.validateAndGetSubjectForInvite(realToken);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
return new InviteValidateResult(null, false);
|
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) {
|
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);
|
invite.setUsageCount(invite.getUsageCount() + 1);
|
||||||
inviteTokenRepository.save(invite);
|
inviteTokenRepository.save(invite);
|
||||||
pointService.awardForInvite(invite.getInviter().getUsername(), newUserName);
|
pointService.awardForInvite(invite.getInviter().getUsername(), newUserName);
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import java.util.regex.Pattern;
|
|||||||
import java.util.regex.Matcher;
|
import java.util.regex.Matcher;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
|
import java.util.EnumSet;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@@ -40,6 +41,12 @@ public class NotificationService {
|
|||||||
|
|
||||||
private static final Pattern MENTION_PATTERN = Pattern.compile("@\\[([^\\]]+)\\]");
|
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) {
|
private String buildPayload(String body, String url) {
|
||||||
// Ensure push notifications contain a link to the related resource so
|
// Ensure push notifications contain a link to the related resource so
|
||||||
// that verifications can assert its presence and users can navigate
|
// that verifications can assert its presence and users can navigate
|
||||||
@@ -75,7 +82,8 @@ public class NotificationService {
|
|||||||
n = notificationRepository.save(n);
|
n = notificationRepository.save(n);
|
||||||
|
|
||||||
// Runnable asyncTask = () -> {
|
// 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());
|
String url = String.format("%s/posts/%d#comment-%d", websiteUrl, post.getId(), comment.getId());
|
||||||
emailSender.sendEmail(user.getEmail(), "有人回复了你", url);
|
emailSender.sendEmail(user.getEmail(), "有人回复了你", url);
|
||||||
sendCustomPush(user, "有人回复了你", url);
|
sendCustomPush(user, "有人回复了你", url);
|
||||||
@@ -187,6 +195,35 @@ public class NotificationService {
|
|||||||
userRepository.save(user);
|
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) {
|
public List<Notification> listNotifications(String username, Boolean read, int page, int size) {
|
||||||
User user = userRepository.findByUsername(username)
|
User user = userRepository.findByUsername(username)
|
||||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||||
|
|||||||
@@ -219,4 +219,32 @@ public class PointService {
|
|||||||
return result;
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -186,7 +186,8 @@ public class PostService {
|
|||||||
Integer pointCost,
|
Integer pointCost,
|
||||||
LocalDateTime startTime,
|
LocalDateTime startTime,
|
||||||
LocalDateTime endTime,
|
LocalDateTime endTime,
|
||||||
java.util.List<String> options) {
|
java.util.List<String> options,
|
||||||
|
Boolean multiple) {
|
||||||
long recent = postRepository.countByAuthorAfter(username,
|
long recent = postRepository.countByAuthorAfter(username,
|
||||||
java.time.LocalDateTime.now().minusMinutes(5));
|
java.time.LocalDateTime.now().minusMinutes(5));
|
||||||
if (recent >= 1) {
|
if (recent >= 1) {
|
||||||
@@ -227,6 +228,7 @@ public class PostService {
|
|||||||
PollPost pp = new PollPost();
|
PollPost pp = new PollPost();
|
||||||
pp.setOptions(options);
|
pp.setOptions(options);
|
||||||
pp.setEndTime(endTime);
|
pp.setEndTime(endTime);
|
||||||
|
pp.setMultiple(multiple != null && multiple);
|
||||||
post = pp;
|
post = pp;
|
||||||
} else {
|
} else {
|
||||||
post = new Post();
|
post = new Post();
|
||||||
@@ -302,7 +304,7 @@ public class PostService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@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)
|
PollPost post = pollPostRepository.findById(postId)
|
||||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
|
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
|
||||||
if (post.getEndTime() != null && post.getEndTime().isBefore(LocalDateTime.now())) {
|
if (post.getEndTime() != null && post.getEndTime().isBefore(LocalDateTime.now())) {
|
||||||
@@ -313,16 +315,24 @@ public class PostService {
|
|||||||
if (post.getParticipants().contains(user)) {
|
if (post.getParticipants().contains(user)) {
|
||||||
throw new IllegalArgumentException("User already voted");
|
throw new IllegalArgumentException("User already voted");
|
||||||
}
|
}
|
||||||
if (optionIndex < 0 || optionIndex >= post.getOptions().size()) {
|
if (optionIndices == null || optionIndices.isEmpty()) {
|
||||||
throw new IllegalArgumentException("Invalid option");
|
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.getParticipants().add(user);
|
||||||
post.getVotes().merge(optionIndex, 1, Integer::sum);
|
for (int optionIndex : unique) {
|
||||||
PollVote vote = new PollVote();
|
post.getVotes().merge(optionIndex, 1, Integer::sum);
|
||||||
vote.setPost(post);
|
PollVote vote = new PollVote();
|
||||||
vote.setUser(user);
|
vote.setPost(post);
|
||||||
vote.setOptionIndex(optionIndex);
|
vote.setUser(user);
|
||||||
pollVoteRepository.save(vote);
|
vote.setOptionIndex(optionIndex);
|
||||||
|
pollVoteRepository.save(vote);
|
||||||
|
}
|
||||||
PollPost saved = pollPostRepository.save(post);
|
PollPost saved = pollPostRepository.save(post);
|
||||||
if (post.getAuthor() != null && !post.getAuthor().getId().equals(user.getId())) {
|
if (post.getAuthor() != null && !post.getAuthor().getId().equals(user.getId())) {
|
||||||
notificationService.createNotification(post.getAuthor(), NotificationType.POLL_VOTE, post, null, null, user, null, null);
|
notificationService.createNotification(post.getAuthor(), NotificationType.POLL_VOTE, post, null, null, user, null, null);
|
||||||
@@ -364,14 +374,16 @@ public class PostService {
|
|||||||
lp.setWinners(winners);
|
lp.setWinners(winners);
|
||||||
lotteryPostRepository.save(lp);
|
lotteryPostRepository.save(lp);
|
||||||
for (User w : winners) {
|
for (User w : winners) {
|
||||||
if (w.getEmail() != null) {
|
if (w.getEmail() != null &&
|
||||||
|
!w.getDisabledEmailNotificationTypes().contains(NotificationType.LOTTERY_WIN)) {
|
||||||
emailSender.sendEmail(w.getEmail(), "你中奖了", "恭喜你在抽奖贴 \"" + lp.getTitle() + "\" 中获奖");
|
emailSender.sendEmail(w.getEmail(), "你中奖了", "恭喜你在抽奖贴 \"" + lp.getTitle() + "\" 中获奖");
|
||||||
}
|
}
|
||||||
notificationService.createNotification(w, NotificationType.LOTTERY_WIN, lp, null, null, lp.getAuthor(), null, null);
|
notificationService.createNotification(w, NotificationType.LOTTERY_WIN, lp, null, null, lp.getAuthor(), null, null);
|
||||||
notificationService.sendCustomPush(w, "你中奖了", String.format("%s/posts/%d", websiteUrl, lp.getId()));
|
notificationService.sendCustomPush(w, "你中奖了", String.format("%s/posts/%d", websiteUrl, lp.getId()));
|
||||||
}
|
}
|
||||||
if (lp.getAuthor() != null) {
|
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() + "\" 已开奖");
|
emailSender.sendEmail(lp.getAuthor().getEmail(), "抽奖已开奖", "您的抽奖贴 \"" + lp.getTitle() + "\" 已开奖");
|
||||||
}
|
}
|
||||||
notificationService.createNotification(lp.getAuthor(), NotificationType.LOTTERY_DRAW, lp, null, null, null, null, null);
|
notificationService.createNotification(lp.getAuthor(), NotificationType.LOTTERY_DRAW, lp, null, null, null, null, null);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,6 +69,8 @@ discord.client-secret=${DISCORD_CLIENT_SECRET:}
|
|||||||
# Twitter OAuth configuration
|
# Twitter OAuth configuration
|
||||||
twitter.client-id=${TWITTER_CLIENT_ID:}
|
twitter.client-id=${TWITTER_CLIENT_ID:}
|
||||||
twitter.client-secret=${TWITTER_CLIENT_SECRET:}
|
twitter.client-secret=${TWITTER_CLIENT_SECRET:}
|
||||||
|
# Telegram login configuration
|
||||||
|
telegram.bot-token=${TELEGRAM_BOT_TOKEN:}
|
||||||
# OpenAI configuration
|
# OpenAI configuration
|
||||||
openai.api-key=${OPENAI_API_KEY:}
|
openai.api-key=${OPENAI_API_KEY:}
|
||||||
openai.model=${OPENAI_MODEL:gpt-4o}
|
openai.model=${OPENAI_MODEL:gpt-4o}
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -76,7 +76,7 @@ class PostControllerTest {
|
|||||||
post.setTags(Set.of(tag));
|
post.setTags(Set.of(tag));
|
||||||
|
|
||||||
when(postService.createPost(eq("alice"), eq(1L), eq("t"), eq("c"), eq(List.of(1L)),
|
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(postService.viewPost(eq(1L), any())).thenReturn(post);
|
||||||
when(commentService.getCommentsForPost(eq(1L), any())).thenReturn(List.of());
|
when(commentService.getCommentsForPost(eq(1L), any())).thenReturn(List.of());
|
||||||
when(commentService.getParticipants(anyLong(), anyInt())).thenReturn(List.of());
|
when(commentService.getParticipants(anyLong(), anyInt())).thenReturn(List.of());
|
||||||
@@ -187,7 +187,7 @@ class PostControllerTest {
|
|||||||
.andExpect(status().isBadRequest());
|
.andExpect(status().isBadRequest());
|
||||||
|
|
||||||
verify(postService, never()).createPost(any(), any(), any(), any(), any(),
|
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
|
@Test
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import com.openisle.repository.UserRepository;
|
|||||||
import com.openisle.repository.ReactionRepository;
|
import com.openisle.repository.ReactionRepository;
|
||||||
import com.openisle.repository.CommentSubscriptionRepository;
|
import com.openisle.repository.CommentSubscriptionRepository;
|
||||||
import com.openisle.repository.NotificationRepository;
|
import com.openisle.repository.NotificationRepository;
|
||||||
|
import com.openisle.repository.PointHistoryRepository;
|
||||||
|
import com.openisle.service.PointService;
|
||||||
import com.openisle.exception.RateLimitException;
|
import com.openisle.exception.RateLimitException;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
@@ -24,10 +26,12 @@ class CommentServiceTest {
|
|||||||
ReactionRepository reactionRepo = mock(ReactionRepository.class);
|
ReactionRepository reactionRepo = mock(ReactionRepository.class);
|
||||||
CommentSubscriptionRepository subRepo = mock(CommentSubscriptionRepository.class);
|
CommentSubscriptionRepository subRepo = mock(CommentSubscriptionRepository.class);
|
||||||
NotificationRepository nRepo = mock(NotificationRepository.class);
|
NotificationRepository nRepo = mock(NotificationRepository.class);
|
||||||
|
PointHistoryRepository pointHistoryRepo = mock(PointHistoryRepository.class);
|
||||||
|
PointService pointService = mock(PointService.class);
|
||||||
ImageUploader imageUploader = mock(ImageUploader.class);
|
ImageUploader imageUploader = mock(ImageUploader.class);
|
||||||
|
|
||||||
CommentService service = new CommentService(commentRepo, postRepo, userRepo,
|
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);
|
when(commentRepo.countByAuthorAfter(eq("alice"), any())).thenReturn(3L);
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ class PostServiceTest {
|
|||||||
CategoryRepository catRepo = mock(CategoryRepository.class);
|
CategoryRepository catRepo = mock(CategoryRepository.class);
|
||||||
TagRepository tagRepo = mock(TagRepository.class);
|
TagRepository tagRepo = mock(TagRepository.class);
|
||||||
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
||||||
|
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
|
||||||
|
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
|
||||||
NotificationService notifService = mock(NotificationService.class);
|
NotificationService notifService = mock(NotificationService.class);
|
||||||
SubscriptionService subService = mock(SubscriptionService.class);
|
SubscriptionService subService = mock(SubscriptionService.class);
|
||||||
CommentService commentService = mock(CommentService.class);
|
CommentService commentService = mock(CommentService.class);
|
||||||
@@ -37,7 +39,7 @@ class PostServiceTest {
|
|||||||
PointService pointService = mock(PointService.class);
|
PointService pointService = mock(PointService.class);
|
||||||
|
|
||||||
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
|
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
|
||||||
notifService, subService, commentService, commentRepo,
|
pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo,
|
||||||
reactionRepo, subRepo, notificationRepo, postReadService,
|
reactionRepo, subRepo, notificationRepo, postReadService,
|
||||||
imageUploader, taskScheduler, emailSender, context, pointService, PublishMode.DIRECT);
|
imageUploader, taskScheduler, emailSender, context, pointService, PublishMode.DIRECT);
|
||||||
when(context.getBean(PostService.class)).thenReturn(service);
|
when(context.getBean(PostService.class)).thenReturn(service);
|
||||||
@@ -69,6 +71,8 @@ class PostServiceTest {
|
|||||||
CategoryRepository catRepo = mock(CategoryRepository.class);
|
CategoryRepository catRepo = mock(CategoryRepository.class);
|
||||||
TagRepository tagRepo = mock(TagRepository.class);
|
TagRepository tagRepo = mock(TagRepository.class);
|
||||||
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
||||||
|
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
|
||||||
|
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
|
||||||
NotificationService notifService = mock(NotificationService.class);
|
NotificationService notifService = mock(NotificationService.class);
|
||||||
SubscriptionService subService = mock(SubscriptionService.class);
|
SubscriptionService subService = mock(SubscriptionService.class);
|
||||||
CommentService commentService = mock(CommentService.class);
|
CommentService commentService = mock(CommentService.class);
|
||||||
@@ -84,7 +88,7 @@ class PostServiceTest {
|
|||||||
PointService pointService = mock(PointService.class);
|
PointService pointService = mock(PointService.class);
|
||||||
|
|
||||||
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
|
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
|
||||||
notifService, subService, commentService, commentRepo,
|
pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo,
|
||||||
reactionRepo, subRepo, notificationRepo, postReadService,
|
reactionRepo, subRepo, notificationRepo, postReadService,
|
||||||
imageUploader, taskScheduler, emailSender, context, pointService, PublishMode.DIRECT);
|
imageUploader, taskScheduler, emailSender, context, pointService, PublishMode.DIRECT);
|
||||||
when(context.getBean(PostService.class)).thenReturn(service);
|
when(context.getBean(PostService.class)).thenReturn(service);
|
||||||
@@ -122,6 +126,8 @@ class PostServiceTest {
|
|||||||
CategoryRepository catRepo = mock(CategoryRepository.class);
|
CategoryRepository catRepo = mock(CategoryRepository.class);
|
||||||
TagRepository tagRepo = mock(TagRepository.class);
|
TagRepository tagRepo = mock(TagRepository.class);
|
||||||
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
||||||
|
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
|
||||||
|
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
|
||||||
NotificationService notifService = mock(NotificationService.class);
|
NotificationService notifService = mock(NotificationService.class);
|
||||||
SubscriptionService subService = mock(SubscriptionService.class);
|
SubscriptionService subService = mock(SubscriptionService.class);
|
||||||
CommentService commentService = mock(CommentService.class);
|
CommentService commentService = mock(CommentService.class);
|
||||||
@@ -137,7 +143,7 @@ class PostServiceTest {
|
|||||||
PointService pointService = mock(PointService.class);
|
PointService pointService = mock(PointService.class);
|
||||||
|
|
||||||
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
|
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
|
||||||
notifService, subService, commentService, commentRepo,
|
pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo,
|
||||||
reactionRepo, subRepo, notificationRepo, postReadService,
|
reactionRepo, subRepo, notificationRepo, postReadService,
|
||||||
imageUploader, taskScheduler, emailSender, context, pointService, PublishMode.DIRECT);
|
imageUploader, taskScheduler, emailSender, context, pointService, PublishMode.DIRECT);
|
||||||
when(context.getBean(PostService.class)).thenReturn(service);
|
when(context.getBean(PostService.class)).thenReturn(service);
|
||||||
@@ -146,7 +152,7 @@ class PostServiceTest {
|
|||||||
|
|
||||||
assertThrows(RateLimitException.class,
|
assertThrows(RateLimitException.class,
|
||||||
() -> service.createPost("alice", 1L, "t", "c", List.of(1L),
|
() -> 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
|
@Test
|
||||||
@@ -156,6 +162,8 @@ class PostServiceTest {
|
|||||||
CategoryRepository catRepo = mock(CategoryRepository.class);
|
CategoryRepository catRepo = mock(CategoryRepository.class);
|
||||||
TagRepository tagRepo = mock(TagRepository.class);
|
TagRepository tagRepo = mock(TagRepository.class);
|
||||||
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
||||||
|
PollPostRepository pollPostRepo = mock(PollPostRepository.class);
|
||||||
|
PollVoteRepository pollVoteRepo = mock(PollVoteRepository.class);
|
||||||
NotificationService notifService = mock(NotificationService.class);
|
NotificationService notifService = mock(NotificationService.class);
|
||||||
SubscriptionService subService = mock(SubscriptionService.class);
|
SubscriptionService subService = mock(SubscriptionService.class);
|
||||||
CommentService commentService = mock(CommentService.class);
|
CommentService commentService = mock(CommentService.class);
|
||||||
@@ -171,7 +179,7 @@ class PostServiceTest {
|
|||||||
PointService pointService = mock(PointService.class);
|
PointService pointService = mock(PointService.class);
|
||||||
|
|
||||||
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
|
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
|
||||||
notifService, subService, commentService, commentRepo,
|
pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo,
|
||||||
reactionRepo, subRepo, notificationRepo, postReadService,
|
reactionRepo, subRepo, notificationRepo, postReadService,
|
||||||
imageUploader, taskScheduler, emailSender, context, pointService, PublishMode.DIRECT);
|
imageUploader, taskScheduler, emailSender, context, pointService, PublishMode.DIRECT);
|
||||||
when(context.getBean(PostService.class)).thenReturn(service);
|
when(context.getBean(PostService.class)).thenReturn(service);
|
||||||
|
|||||||
@@ -3,13 +3,14 @@
|
|||||||
; 预发环境后端
|
; 预发环境后端
|
||||||
; NUXT_PUBLIC_API_BASE_URL=https://staging.open-isle.com
|
; 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://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_GOOGLE_CLIENT_ID=777830451304-nt8afkkap18gui4f9entcha99unal744.apps.googleusercontent.com
|
||||||
NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23liVkO1NPAX5JyWxJ
|
NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23liVkO1NPAX5JyWxJ
|
||||||
NUXT_PUBLIC_DISCORD_CLIENT_ID=1394985417044000779
|
NUXT_PUBLIC_DISCORD_CLIENT_ID=1394985417044000779
|
||||||
NUXT_PUBLIC_TWITTER_CLIENT_ID=ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ
|
NUXT_PUBLIC_TWITTER_CLIENT_ID=ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ
|
||||||
|
NUXT_PUBLIC_TELEGRAM_BOT_ID=8450237135
|
||||||
|
|||||||
@@ -3,14 +3,15 @@
|
|||||||
; 预发环境后端
|
; 预发环境后端
|
||||||
NUXT_PUBLIC_API_BASE_URL=https://staging.open-isle.com
|
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://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_GOOGLE_CLIENT_ID=777830451304-nt8afkkap18gui4f9entcha99unal744.apps.googleusercontent.com
|
||||||
NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23liVkO1NPAX5JyWxJ
|
NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23liVkO1NPAX5JyWxJ
|
||||||
NUXT_PUBLIC_DISCORD_CLIENT_ID=1394985417044000779
|
NUXT_PUBLIC_DISCORD_CLIENT_ID=1394985417044000779
|
||||||
NUXT_PUBLIC_TWITTER_CLIENT_ID=ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ
|
NUXT_PUBLIC_TWITTER_CLIENT_ID=ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ
|
||||||
|
NUXT_PUBLIC_TELEGRAM_BOT_ID=8450237135
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ const hideMenu = computed(() => {
|
|||||||
'/discord-callback',
|
'/discord-callback',
|
||||||
'/forgot-password',
|
'/forgot-password',
|
||||||
'/google-callback',
|
'/google-callback',
|
||||||
|
'/telegram-callback',
|
||||||
].includes(useRoute().path)
|
].includes(useRoute().path)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
--page-max-width-mobile: 900px;
|
--page-max-width-mobile: 900px;
|
||||||
--article-info-background-color: #f0f0f0;
|
--article-info-background-color: #f0f0f0;
|
||||||
--activity-card-background-color: #fafafa;
|
--activity-card-background-color: #fafafa;
|
||||||
|
--poll-option-button-background-color: rgb(218, 218, 218);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme='dark'] {
|
[data-theme='dark'] {
|
||||||
@@ -61,6 +62,7 @@
|
|||||||
--blockquote-text-color: #999;
|
--blockquote-text-color: #999;
|
||||||
--article-info-background-color: #747373;
|
--article-info-background-color: #747373;
|
||||||
--activity-card-background-color: #585858;
|
--activity-card-background-color: #585858;
|
||||||
|
--poll-option-button-background-color: #3a3a3a;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-frosted='off'] {
|
:root[data-frosted='off'] {
|
||||||
@@ -91,7 +93,7 @@ body {
|
|||||||
|
|
||||||
.vditor-toolbar--pin {
|
.vditor-toolbar--pin {
|
||||||
top: calc(var(--header-height) + 1px) !important;
|
top: calc(var(--header-height) + 1px) !important;
|
||||||
z-index: 2000;
|
z-index: 20;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vditor-panel {
|
.vditor-panel {
|
||||||
|
|||||||
4
frontend_nuxt/assets/icons/telegram.svg
Normal file
4
frontend_nuxt/assets/icons/telegram.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
|
<path fill="#2AABEE" d="M12 0C5.372 0 0 5.372 0 12s5.372 12 12 12 12-5.372 12-12S18.628 0 12 0z"/>
|
||||||
|
<path fill="#fff" d="M17.565 7.06L15.7 17.05c-.14.706-.51.88-1.033.548l-2.861-2.108-1.382 1.332c-.153.153-.282.282-.575.282l.205-2.912 5.303-4.788c.231-.205-.05-.32-.36-.116L8.9 11.27l-3.14-.98c-.682-.213-.696-.682.143-1.007l11.18-4.307c.511-.186.958.116.783.914z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 438 B |
@@ -18,6 +18,10 @@
|
|||||||
<flat-pickr v-model="data.endTime" :config="dateConfig" class="time-picker" />
|
<flat-pickr v-model="data.endTime" :config="dateConfig" class="time-picker" />
|
||||||
</client-only>
|
</client-only>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="poll-multiple-row">
|
||||||
|
<span class="poll-row-title">多选</span>
|
||||||
|
<BaseSwitch v-model="data.multiple" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -25,6 +29,7 @@
|
|||||||
import 'flatpickr/dist/flatpickr.css'
|
import 'flatpickr/dist/flatpickr.css'
|
||||||
import FlatPickr from 'vue-flatpickr-component'
|
import FlatPickr from 'vue-flatpickr-component'
|
||||||
import BaseInput from '~/components/BaseInput.vue'
|
import BaseInput from '~/components/BaseInput.vue'
|
||||||
|
import BaseSwitch from '~/components/BaseSwitch.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
data: {
|
data: {
|
||||||
@@ -80,6 +85,11 @@ const removeOption = (idx) => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
.poll-multiple-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
.time-picker {
|
.time-picker {
|
||||||
max-width: 200px;
|
max-width: 200px;
|
||||||
height: 30px;
|
height: 30px;
|
||||||
|
|||||||
310
frontend_nuxt/components/PostLottery.vue
Normal file
310
frontend_nuxt/components/PostLottery.vue
Normal 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>
|
||||||
459
frontend_nuxt/components/PostPoll.vue
Normal file
459
frontend_nuxt/components/PostPoll.vue
Normal 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>
|
||||||
174
frontend_nuxt/components/ThirdPartyAuth.vue
Normal file
174
frontend_nuxt/components/ThirdPartyAuth.vue
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
<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" :src="provider.icon" :alt="provider.alt" />
|
||||||
|
<div class="third-party-button-text">
|
||||||
|
{{ 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: `Twitter ${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: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.third-party-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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>
|
||||||
@@ -11,6 +11,7 @@ export default defineNuxtConfig({
|
|||||||
githubClientId: process.env.NUXT_PUBLIC_GITHUB_CLIENT_ID || '',
|
githubClientId: process.env.NUXT_PUBLIC_GITHUB_CLIENT_ID || '',
|
||||||
discordClientId: process.env.NUXT_PUBLIC_DISCORD_CLIENT_ID || '',
|
discordClientId: process.env.NUXT_PUBLIC_DISCORD_CLIENT_ID || '',
|
||||||
twitterClientId: process.env.NUXT_PUBLIC_TWITTER_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'],
|
css: ['vditor/dist/index.css', '~/assets/fonts.css', '~/assets/global.css'],
|
||||||
|
|||||||
@@ -34,35 +34,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="other-login-page-content">
|
<ThirdPartyAuth mode="login" />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { toast } from '~/main'
|
import { toast } from '~/main'
|
||||||
import { setToken, loadCurrentUser } from '~/utils/auth'
|
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 BaseInput from '~/components/BaseInput.vue'
|
||||||
|
import ThirdPartyAuth from '~/components/ThirdPartyAuth.vue'
|
||||||
import { registerPush } from '~/utils/push'
|
import { registerPush } from '~/utils/push'
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const API_BASE_URL = config.public.apiBaseUrl
|
const API_BASE_URL = config.public.apiBaseUrl
|
||||||
@@ -105,19 +85,6 @@ const submitLogin = async () => {
|
|||||||
isWaitingForLogin.value = false
|
isWaitingForLogin.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const loginWithGoogle = () => {
|
|
||||||
googleAuthorize()
|
|
||||||
}
|
|
||||||
const loginWithGithub = () => {
|
|
||||||
githubAuthorize()
|
|
||||||
}
|
|
||||||
const loginWithDiscord = () => {
|
|
||||||
discordAuthorize()
|
|
||||||
}
|
|
||||||
const loginWithTwitter = () => {
|
|
||||||
twitterAuthorize()
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -190,16 +157,6 @@ const loginWithTwitter = () => {
|
|||||||
font-size: 16px;
|
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 {
|
.login-page-button-primary {
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -229,29 +186,6 @@ const loginWithTwitter = () => {
|
|||||||
background-color: var(--primary-color-disabled);
|
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 {
|
.login-page-button-text {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
@@ -293,16 +227,5 @@ const loginWithTwitter = () => {
|
|||||||
margin-top: 0px;
|
margin-top: 0px;
|
||||||
font-size: 13px;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -334,6 +334,9 @@ onMounted(async () => {
|
|||||||
if (currentUser.value) {
|
if (currentUser.value) {
|
||||||
await fetchMessages(0)
|
await fetchMessages(0)
|
||||||
await markConversationAsRead()
|
await markConversationAsRead()
|
||||||
|
await nextTick()
|
||||||
|
// 初次进入频道时,平滑滚动到底部
|
||||||
|
scrollToBottomSmooth()
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
if (token && !isConnected.value) {
|
if (token && !isConnected.value) {
|
||||||
connect(token)
|
connect(token)
|
||||||
|
|||||||
@@ -23,6 +23,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</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) => toggleEmailPref(pref, val)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
@@ -579,6 +591,8 @@ import {
|
|||||||
hasMore,
|
hasMore,
|
||||||
fetchNotificationPreferences,
|
fetchNotificationPreferences,
|
||||||
updateNotificationPreference,
|
updateNotificationPreference,
|
||||||
|
fetchEmailNotificationPreferences,
|
||||||
|
updateEmailNotificationPreference,
|
||||||
} from '~/utils/notification'
|
} from '~/utils/notification'
|
||||||
import TimeManager from '~/utils/time'
|
import TimeManager from '~/utils/time'
|
||||||
import BaseSwitch from '~/components/BaseSwitch.vue'
|
import BaseSwitch from '~/components/BaseSwitch.vue'
|
||||||
@@ -595,6 +609,7 @@ const tabs = [
|
|||||||
{ key: 'control', label: '消息设置' },
|
{ key: 'control', label: '消息设置' },
|
||||||
]
|
]
|
||||||
const notificationPrefs = ref([])
|
const notificationPrefs = ref([])
|
||||||
|
const emailPrefs = ref([])
|
||||||
const page = ref(0)
|
const page = ref(0)
|
||||||
const pageSize = 30
|
const pageSize = 30
|
||||||
|
|
||||||
@@ -619,6 +634,10 @@ const fetchPrefs = async () => {
|
|||||||
notificationPrefs.value = await fetchNotificationPreferences()
|
notificationPrefs.value = await fetchNotificationPreferences()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fetchEmailPrefs = async () => {
|
||||||
|
emailPrefs.value = await fetchEmailNotificationPreferences()
|
||||||
|
}
|
||||||
|
|
||||||
const togglePref = async (pref, value) => {
|
const togglePref = async (pref, value) => {
|
||||||
const ok = await updateNotificationPreference(pref.type, value)
|
const ok = await updateNotificationPreference(pref.type, value)
|
||||||
if (ok) {
|
if (ok) {
|
||||||
@@ -634,6 +653,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) => {
|
const markRead = async (id) => {
|
||||||
markNotificationRead(id)
|
markNotificationRead(id)
|
||||||
if (selectedTab.value === 'unread') {
|
if (selectedTab.value === 'unread') {
|
||||||
@@ -714,6 +742,12 @@ const formatType = (t) => {
|
|||||||
return '帖子被删除'
|
return '帖子被删除'
|
||||||
case 'POST_FEATURED':
|
case 'POST_FEATURED':
|
||||||
return '文章被精选'
|
return '文章被精选'
|
||||||
|
case 'POLL_VOTE':
|
||||||
|
return '有人参与你的投票'
|
||||||
|
case 'POLL_RESULT_OWNER':
|
||||||
|
return '发布的投票结果已公布'
|
||||||
|
case 'POLL_RESULT_PARTICIPANT':
|
||||||
|
return '参与的投票结果已公布'
|
||||||
default:
|
default:
|
||||||
return t
|
return t
|
||||||
}
|
}
|
||||||
@@ -723,6 +757,7 @@ onActivated(async () => {
|
|||||||
page.value = 0
|
page.value = 0
|
||||||
await fetchNotifications({ page: 0, size: pageSize, unread: selectedTab.value === 'unread' })
|
await fetchNotifications({ page: 0, size: pageSize, unread: selectedTab.value === 'unread' })
|
||||||
fetchPrefs()
|
fetchPrefs()
|
||||||
|
fetchEmailPrefs()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ const lottery = reactive({
|
|||||||
const poll = reactive({
|
const poll = reactive({
|
||||||
options: ['', ''],
|
options: ['', ''],
|
||||||
endTime: null,
|
endTime: null,
|
||||||
|
multiple: false,
|
||||||
})
|
})
|
||||||
const startTime = ref(null)
|
const startTime = ref(null)
|
||||||
const isWaitingPosting = ref(false)
|
const isWaitingPosting = ref(false)
|
||||||
@@ -121,6 +122,7 @@ const clearPost = async () => {
|
|||||||
startTime.value = null
|
startTime.value = null
|
||||||
poll.options = ['', '']
|
poll.options = ['', '']
|
||||||
poll.endTime = null
|
poll.endTime = null
|
||||||
|
poll.multiple = false
|
||||||
|
|
||||||
// 删除草稿
|
// 删除草稿
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
@@ -318,6 +320,7 @@ const submitPost = async () => {
|
|||||||
prizeCount: postType.value === 'LOTTERY' ? lottery.prizeCount : undefined,
|
prizeCount: postType.value === 'LOTTERY' ? lottery.prizeCount : undefined,
|
||||||
prizeDescription: postType.value === 'LOTTERY' ? lottery.prizeDescription : undefined,
|
prizeDescription: postType.value === 'LOTTERY' ? lottery.prizeDescription : undefined,
|
||||||
options: postType.value === 'POLL' ? poll.options : undefined,
|
options: postType.value === 'POLL' ? poll.options : undefined,
|
||||||
|
multiple: postType.value === 'POLL' ? poll.multiple : undefined,
|
||||||
startTime:
|
startTime:
|
||||||
postType.value === 'LOTTERY' ? new Date(startTime.value).toISOString() : undefined,
|
postType.value === 'LOTTERY' ? new Date(startTime.value).toISOString() : undefined,
|
||||||
pointCost: postType.value === 'LOTTERY' ? lottery.pointCost : undefined,
|
pointCost: postType.value === 'LOTTERY' ? lottery.pointCost : undefined,
|
||||||
|
|||||||
@@ -94,157 +94,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="lottery" class="post-prize-container">
|
<PostLottery v-if="lottery" :lottery="lottery" :post-id="postId" @refresh="refreshPost" />
|
||||||
<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>
|
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<div v-if="poll" class="post-poll-container">
|
<PostPoll v-if="poll" :poll="poll" :post-id="postId" @refresh="refreshPost" />
|
||||||
<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>
|
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
<div v-if="closed" class="post-close-container">该帖子已关闭,内容仅供阅读,无法进行互动</div>
|
<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 ArticleCategory from '~/components/ArticleCategory.vue'
|
||||||
import ReactionsGroup from '~/components/ReactionsGroup.vue'
|
import ReactionsGroup from '~/components/ReactionsGroup.vue'
|
||||||
import DropdownMenu from '~/components/DropdownMenu.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 { renderMarkdown, handleMarkdownClick, stripMarkdownLength } from '~/utils/markdown'
|
||||||
import { getMedalTitle } from '~/utils/medal'
|
import { getMedalTitle } from '~/utils/medal'
|
||||||
import { toast } from '~/main'
|
import { toast } from '~/main'
|
||||||
@@ -388,7 +242,6 @@ useHead(() => ({
|
|||||||
if (import.meta.client) {
|
if (import.meta.client) {
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
window.removeEventListener('scroll', updateCurrentIndex)
|
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 isAuthor = computed(() => authState.username === author.value.username)
|
||||||
const lottery = ref(null)
|
const lottery = ref(null)
|
||||||
const poll = 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 articleMenuItems = computed(() => {
|
||||||
const items = []
|
const items = []
|
||||||
if (isAuthor.value || isAdmin.value) {
|
if (isAuthor.value || isAdmin.value) {
|
||||||
@@ -628,8 +414,6 @@ watchEffect(() => {
|
|||||||
postTime.value = TimeManager.format(data.createdAt)
|
postTime.value = TimeManager.format(data.createdAt)
|
||||||
lottery.value = data.lottery || null
|
lottery.value = data.lottery || null
|
||||||
poll.value = data.poll || null
|
poll.value = data.poll || null
|
||||||
if ((lottery.value && lottery.value.endTime) || (poll.value && poll.value.endTime))
|
|
||||||
startCountdown()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 404 客户端跳转
|
// 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 = () => {
|
const fetchCommentSorts = () => {
|
||||||
return Promise.resolve([
|
return Promise.resolve([
|
||||||
{ id: 'NEWEST', name: '最新', icon: 'fas fa-clock' },
|
{ id: 'NEWEST', name: '最新', icon: 'fas fa-clock' },
|
||||||
@@ -1287,95 +1032,6 @@ onMounted(async () => {
|
|||||||
cursor: pointer;
|
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 {
|
.action-menu-icon {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
@@ -1491,201 +1147,6 @@ onMounted(async () => {
|
|||||||
position: relative;
|
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) {
|
@media (max-width: 768px) {
|
||||||
.post-page-main-container {
|
.post-page-main-container {
|
||||||
width: calc(100% - 20px);
|
width: calc(100% - 20px);
|
||||||
@@ -1736,10 +1197,5 @@ onMounted(async () => {
|
|||||||
.loading-container {
|
.loading-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.join-prize-button,
|
|
||||||
.join-prize-button-disabled {
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -68,35 +68,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="other-signup-page-content">
|
<ThirdPartyAuth mode="signup" :invite-token="inviteToken" />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import BaseInput from '~/components/BaseInput.vue'
|
import BaseInput from '~/components/BaseInput.vue'
|
||||||
import { toast } from '~/main'
|
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 { loadCurrentUser, setToken } from '~/utils/auth'
|
||||||
|
import ThirdPartyAuth from '~/components/ThirdPartyAuth.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
@@ -216,18 +196,6 @@ const verifyCode = async () => {
|
|||||||
isWaitingForEmailVerified.value = false
|
isWaitingForEmailVerified.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const signupWithGoogle = () => {
|
|
||||||
googleAuthorize(inviteToken.value)
|
|
||||||
}
|
|
||||||
const signupWithGithub = () => {
|
|
||||||
githubAuthorize(inviteToken.value)
|
|
||||||
}
|
|
||||||
const signupWithDiscord = () => {
|
|
||||||
discordAuthorize(inviteToken.value)
|
|
||||||
}
|
|
||||||
const signupWithTwitter = () => {
|
|
||||||
twitterAuthorize(inviteToken.value)
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -300,16 +268,6 @@ const signupWithTwitter = () => {
|
|||||||
font-size: 16px;
|
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 {
|
.signup-page-button-primary {
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -339,29 +297,6 @@ const signupWithTwitter = () => {
|
|||||||
background-color: var(--primary-color-hover);
|
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 {
|
.signup-page-button-text {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
@@ -411,16 +346,5 @@ const signupWithTwitter = () => {
|
|||||||
margin-top: 0px;
|
margin-top: 0px;
|
||||||
font-size: 13px;
|
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>
|
</style>
|
||||||
|
|||||||
42
frontend_nuxt/pages/telegram-callback.vue
Normal file
42
frontend_nuxt/pages/telegram-callback.vue
Normal 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>
|
||||||
@@ -58,7 +58,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="profile-info-item">
|
<div class="profile-info-item">
|
||||||
<div class="profile-info-item-label">最后发帖时间:</div>
|
<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>
|
||||||
<div class="profile-info-item">
|
<div class="profile-info-item">
|
||||||
<div class="profile-info-item-label">最后评论时间:</div>
|
<div class="profile-info-item-label">最后评论时间:</div>
|
||||||
|
|||||||
@@ -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
|
* @returns
|
||||||
|
|||||||
56
frontend_nuxt/utils/telegram.js
Normal file
56
frontend_nuxt/utils/telegram.js
Normal 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: '登录失败' }
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user