mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-06-18 11:44:22 +00:00
Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 84b45f785d | |||
| df56d7e885 | |||
| 76176e135c | |||
| ab87e0e51c | |||
| 5346a063bf | |||
| e53f2130b8 | |||
| 1e87e9252d | |||
| 3fc4d29dce | |||
| bcdac9d9b2 | |||
| ea9710d16f | |||
| 47134cadc2 | |||
| 1a1b20b9cf | |||
| b63ebb8fae | |||
| e0f7299a86 | |||
| 1f9ae8d057 | |||
| da1ad73cf6 | |||
| 53c603f33a | |||
| 06f86f2b21 | |||
| 22693bfdd9 | |||
| 0058f20b1e | |||
| 304d941d68 | |||
| 3dbcd2ac4d | |||
| 2efe4e733a | |||
| 08239a16b8 | |||
| cb49dc9b73 | |||
| 43d4c9be43 | |||
| 1dc13698ad | |||
| d58432dcd9 | |||
| e7ff73c7f9 | |||
| 4ee9532d5f | |||
| 80c3fd8ea2 | |||
| 7e277d06d5 | |||
| d2b68119bd | |||
| f7b0d7edd5 | |||
| cdea1ab911 | |||
| ada6bfb5cf | |||
| 928dbd73b5 | |||
| 8c1a7afc6e | |||
| 87453f7198 | |||
| 48e3593ef9 | |||
| 655e8f2a65 | |||
| 7a0afedc7c | |||
| 902fce5174 | |||
| 0034839e8d | |||
| 148fd36fd1 | |||
| 06cd663eaf | |||
| 0edbeabac2 | |||
| 65cc3ee58b | |||
| 6965fcfb7f | |||
| 40520c30ec | |||
| a3aec1133b | |||
| 8fa715477b | |||
| 9209ebea4c | |||
| 47a9ce5843 |
@@ -41,7 +41,7 @@ public class SecurityConfig {
|
||||
private final UserRepository userRepository;
|
||||
private final AccessDeniedHandler customAccessDeniedHandler;
|
||||
private final UserVisitService userVisitService;
|
||||
@Value("${app.website-url:https://www.open-isle.com}")
|
||||
@Value("${app.website-url}")
|
||||
private String websiteUrl;
|
||||
|
||||
@Bean
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.openisle.controller;
|
||||
|
||||
import com.openisle.dto.CommentDto;
|
||||
import com.openisle.mapper.CommentMapper;
|
||||
import com.openisle.service.CommentService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
/**
|
||||
* Endpoints for administrators to manage comments.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/comments")
|
||||
@RequiredArgsConstructor
|
||||
public class AdminCommentController {
|
||||
private final CommentService commentService;
|
||||
private final CommentMapper commentMapper;
|
||||
|
||||
@PostMapping("/{id}/pin")
|
||||
public CommentDto pin(@PathVariable Long id, Authentication auth) {
|
||||
return commentMapper.toDto(commentService.pinComment(auth.getName(), id));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/unpin")
|
||||
public CommentDto unpin(@PathVariable Long id, Authentication auth) {
|
||||
return commentMapper.toDto(commentService.unpinComment(auth.getName(), id));
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ public class AdminUserController {
|
||||
private final UserRepository userRepository;
|
||||
private final NotificationRepository notificationRepository;
|
||||
private final EmailSender emailSender;
|
||||
@Value("${app.website-url:https://www.open-isle.com}")
|
||||
@Value("${app.website-url}")
|
||||
private String websiteUrl;
|
||||
|
||||
@PostMapping("/{id}/approve")
|
||||
|
||||
@@ -12,6 +12,7 @@ import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@RestController
|
||||
@@ -44,8 +45,11 @@ public class CategoryController {
|
||||
|
||||
@GetMapping
|
||||
public List<CategoryDto> list() {
|
||||
return categoryService.listCategories().stream()
|
||||
.map(c -> categoryMapper.toDto(c, postService.countPostsByCategory(c.getId())))
|
||||
List<Category> all = categoryService.listCategories();
|
||||
List<Long> ids = all.stream().map(Category::getId).toList();
|
||||
Map<Long, Long> postsCntByCategoryIds = postService.countPostsByCategoryIds(ids);
|
||||
return all.stream()
|
||||
.map(c -> categoryMapper.toDto(c, postsCntByCategoryIds.getOrDefault(c.getId(), 0L)))
|
||||
.sorted((a, b) -> Long.compare(b.getCount(), a.getCount()))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@@ -85,4 +85,16 @@ public class CommentController {
|
||||
commentService.deleteComment(auth.getName(), id);
|
||||
log.debug("deleteComment completed for comment {}", id);
|
||||
}
|
||||
|
||||
@PostMapping("/comments/{id}/pin")
|
||||
public CommentDto pinComment(@PathVariable Long id, Authentication auth) {
|
||||
log.debug("pinComment called by user {} for comment {}", auth.getName(), id);
|
||||
return commentMapper.toDto(commentService.pinComment(auth.getName(), id));
|
||||
}
|
||||
|
||||
@PostMapping("/comments/{id}/unpin")
|
||||
public CommentDto unpinComment(@PathVariable Long id, Authentication auth) {
|
||||
log.debug("unpinComment called by user {} for comment {}", auth.getName(), id);
|
||||
return commentMapper.toDto(commentService.unpinComment(auth.getName(), id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ import java.util.List;
|
||||
public class SitemapController {
|
||||
private final PostRepository postRepository;
|
||||
|
||||
@Value("${app.website-url:https://www.open-isle.com}")
|
||||
@Value("${app.website-url}")
|
||||
private String websiteUrl;
|
||||
|
||||
@GetMapping(value = "/sitemap.xml", produces = MediaType.APPLICATION_XML_VALUE)
|
||||
|
||||
@@ -15,6 +15,7 @@ import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@RestController
|
||||
@@ -62,8 +63,11 @@ public class TagController {
|
||||
@GetMapping
|
||||
public List<TagDto> list(@RequestParam(value = "keyword", required = false) String keyword,
|
||||
@RequestParam(value = "limit", required = false) Integer limit) {
|
||||
List<TagDto> dtos = tagService.searchTags(keyword).stream()
|
||||
.map(t -> tagMapper.toDto(t, postService.countPostsByTag(t.getId())))
|
||||
List<Tag> tags = tagService.searchTags(keyword);
|
||||
List<Long> tagIds = tags.stream().map(Tag::getId).toList();
|
||||
Map<Long, Long> postCntByTagIds = postService.countPostsByTagIds(tagIds);
|
||||
List<TagDto> dtos = tags.stream()
|
||||
.map(t -> tagMapper.toDto(t, postCntByTagIds.getOrDefault(t.getId(), 0L)))
|
||||
.sorted((a, b) -> Long.compare(b.getCount(), a.getCount()))
|
||||
.collect(Collectors.toList());
|
||||
if (limit != null && limit > 0 && dtos.size() > limit) {
|
||||
|
||||
@@ -13,6 +13,7 @@ public class CommentDto {
|
||||
private Long id;
|
||||
private String content;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime pinnedAt;
|
||||
private AuthorDto author;
|
||||
private List<CommentDto> replies;
|
||||
private List<ReactionDto> reactions;
|
||||
|
||||
@@ -24,6 +24,7 @@ public class CommentMapper {
|
||||
dto.setId(comment.getId());
|
||||
dto.setContent(comment.getContent());
|
||||
dto.setCreatedAt(comment.getCreatedAt());
|
||||
dto.setPinnedAt(comment.getPinnedAt());
|
||||
dto.setAuthor(userMapper.toAuthorDto(comment.getAuthor()));
|
||||
dto.setReward(0);
|
||||
return dto;
|
||||
|
||||
@@ -38,4 +38,7 @@ public class Comment {
|
||||
@JoinColumn(name = "parent_id")
|
||||
private Comment parent;
|
||||
|
||||
@Column
|
||||
private LocalDateTime pinnedAt;
|
||||
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ public class Notification {
|
||||
private Long id;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false)
|
||||
@Column(nullable = false, length = 50)
|
||||
private NotificationType type;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
|
||||
@@ -32,6 +32,10 @@ public enum NotificationType {
|
||||
REGISTER_REQUEST,
|
||||
/** A user redeemed an activity reward */
|
||||
ACTIVITY_REDEEM,
|
||||
/** You won a lottery post */
|
||||
LOTTERY_WIN,
|
||||
/** Your lottery post was drawn */
|
||||
LOTTERY_DRAW,
|
||||
/** You were mentioned in a post or comment */
|
||||
MENTION
|
||||
}
|
||||
|
||||
@@ -92,8 +92,14 @@ public interface PostRepository extends JpaRepository<Post, Long> {
|
||||
|
||||
long countByCategory_Id(Long categoryId);
|
||||
|
||||
@Query("SELECT c.id, COUNT(p) FROM Post p JOIN p.category c WHERE c.id IN :categoryIds GROUP BY c.id")
|
||||
List<Object[]> countPostsByCategoryIds(@Param("categoryIds") List<Long> categoryIds);
|
||||
|
||||
long countDistinctByTags_Id(Long tagId);
|
||||
|
||||
@Query("SELECT t.id, COUNT(DISTINCT p) FROM Post p JOIN p.tags t WHERE t.id IN :tagIds GROUP BY t.id")
|
||||
List<Object[]> countPostsByTagIds(@Param("tagIds") List<Long> tagIds);
|
||||
|
||||
long countByAuthor_Id(Long userId);
|
||||
|
||||
@Query("SELECT FUNCTION('date', p.createdAt) AS d, COUNT(p) AS c FROM Post p " +
|
||||
|
||||
@@ -23,6 +23,7 @@ import java.util.List;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@@ -129,13 +130,26 @@ public class CommentService {
|
||||
Post post = postRepository.findById(postId)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
|
||||
List<Comment> list = commentRepository.findByPostAndParentIsNullOrderByCreatedAtAsc(post);
|
||||
if (sort == CommentSort.NEWEST) {
|
||||
list.sort(java.util.Comparator.comparing(Comment::getCreatedAt).reversed());
|
||||
} else if (sort == CommentSort.MOST_INTERACTIONS) {
|
||||
list.sort((a, b) -> Integer.compare(interactionCount(b), interactionCount(a)));
|
||||
java.util.List<Comment> pinned = new java.util.ArrayList<>();
|
||||
java.util.List<Comment> others = new java.util.ArrayList<>();
|
||||
for (Comment c : list) {
|
||||
if (c.getPinnedAt() != null) {
|
||||
pinned.add(c);
|
||||
} else {
|
||||
others.add(c);
|
||||
}
|
||||
log.debug("getCommentsForPost returning {} comments", list.size());
|
||||
return list;
|
||||
}
|
||||
pinned.sort(java.util.Comparator.comparing(Comment::getPinnedAt).reversed());
|
||||
if (sort == CommentSort.NEWEST) {
|
||||
others.sort(java.util.Comparator.comparing(Comment::getCreatedAt).reversed());
|
||||
} else if (sort == CommentSort.MOST_INTERACTIONS) {
|
||||
others.sort((a, b) -> Integer.compare(interactionCount(b), interactionCount(a)));
|
||||
}
|
||||
java.util.List<Comment> result = new java.util.ArrayList<>();
|
||||
result.addAll(pinned);
|
||||
result.addAll(others);
|
||||
log.debug("getCommentsForPost returning {} comments", result.size());
|
||||
return result;
|
||||
}
|
||||
|
||||
public List<Comment> getReplies(Long parentId) {
|
||||
@@ -223,6 +237,32 @@ public class CommentService {
|
||||
log.debug("deleteCommentCascade removed comment {}", comment.getId());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Comment pinComment(String username, Long id) {
|
||||
Comment c = commentRepository.findById(id)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Comment not found"));
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||
if (!user.getId().equals(c.getPost().getAuthor().getId()) && user.getRole() != Role.ADMIN) {
|
||||
throw new IllegalArgumentException("Unauthorized");
|
||||
}
|
||||
c.setPinnedAt(LocalDateTime.now());
|
||||
return commentRepository.save(c);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Comment unpinComment(String username, Long id) {
|
||||
Comment c = commentRepository.findById(id)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Comment not found"));
|
||||
User user = userRepository.findByUsername(username)
|
||||
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
|
||||
if (!user.getId().equals(c.getPost().getAuthor().getId()) && user.getRole() != Role.ADMIN) {
|
||||
throw new IllegalArgumentException("Unauthorized");
|
||||
}
|
||||
c.setPinnedAt(null);
|
||||
return commentRepository.save(c);
|
||||
}
|
||||
|
||||
private int interactionCount(Comment comment) {
|
||||
int reactions = reactionRepository.findByComment(comment).size();
|
||||
int replies = commentRepository.findByParentOrderByCreatedAtAsc(comment).size();
|
||||
|
||||
@@ -36,7 +36,7 @@ public class NotificationService {
|
||||
private final ReactionRepository reactionRepository;
|
||||
private final Executor notificationExecutor;
|
||||
|
||||
@Value("${app.website-url:https://www.open-isle.com}")
|
||||
@Value("${app.website-url}")
|
||||
private String websiteUrl;
|
||||
|
||||
private static final Pattern MENTION_PATTERN = Pattern.compile("@\\[([^\\]]+)\\]");
|
||||
|
||||
@@ -31,16 +31,15 @@ import com.openisle.service.EmailSender;
|
||||
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.*;
|
||||
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
@@ -69,6 +68,8 @@ public class PostService {
|
||||
private final EmailSender emailSender;
|
||||
private final ApplicationContext applicationContext;
|
||||
private final ConcurrentMap<Long, ScheduledFuture<?>> scheduledFinalizations = new ConcurrentHashMap<>();
|
||||
@Value("${app.website-url:https://www.open-isle.com}")
|
||||
private String websiteUrl;
|
||||
|
||||
@org.springframework.beans.factory.annotation.Autowired
|
||||
public PostService(PostRepository postRepository,
|
||||
@@ -249,6 +250,15 @@ public class PostService {
|
||||
if (w.getEmail() != null) {
|
||||
emailSender.sendEmail(w.getEmail(), "你中奖了", "恭喜你在抽奖贴 \"" + lp.getTitle() + "\" 中获奖");
|
||||
}
|
||||
notificationService.createNotification(w, NotificationType.LOTTERY_WIN, lp, null, null, lp.getAuthor(), null, null);
|
||||
notificationService.sendCustomPush(w, "你中奖了", String.format("%s/posts/%d", websiteUrl, lp.getId()));
|
||||
}
|
||||
if (lp.getAuthor() != null) {
|
||||
if (lp.getAuthor().getEmail() != null) {
|
||||
emailSender.sendEmail(lp.getAuthor().getEmail(), "抽奖已开奖", "您的抽奖贴 \"" + lp.getTitle() + "\" 已开奖");
|
||||
}
|
||||
notificationService.createNotification(lp.getAuthor(), NotificationType.LOTTERY_DRAW, lp, null, null, null, null, null);
|
||||
notificationService.sendCustomPush(lp.getAuthor(), "抽奖已开奖", String.format("%s/posts/%d", websiteUrl, lp.getId()));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -556,10 +566,31 @@ public class PostService {
|
||||
return postRepository.countByCategory_Id(categoryId);
|
||||
}
|
||||
|
||||
public Map<Long, Long> countPostsByCategoryIds(List<Long> categoryIds) {
|
||||
Map<Long, Long> result = new HashMap<>();
|
||||
var dbResult = postRepository.countPostsByCategoryIds(categoryIds);
|
||||
dbResult.forEach(r -> {
|
||||
result.put(((Long)r[0]), ((Long)r[1]));
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
public long countPostsByTag(Long tagId) {
|
||||
return postRepository.countDistinctByTags_Id(tagId);
|
||||
}
|
||||
|
||||
public Map<Long, Long> countPostsByTagIds(List<Long> tagIds) {
|
||||
Map<Long, Long> result = new HashMap<>();
|
||||
if (CollectionUtils.isEmpty(tagIds)) {
|
||||
return result;
|
||||
}
|
||||
var dbResult = postRepository.countPostsByTagIds(tagIds);
|
||||
dbResult.forEach(r -> {
|
||||
result.put(((Long)r[0]), ((Long)r[1]));
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
private java.util.List<Post> sortByPinnedAndCreated(java.util.List<Post> posts) {
|
||||
return posts.stream()
|
||||
.sorted(java.util.Comparator
|
||||
|
||||
@@ -27,7 +27,7 @@ public class ReactionService {
|
||||
private final NotificationService notificationService;
|
||||
private final EmailSender emailSender;
|
||||
|
||||
@Value("${app.website-url:https://www.open-isle.com}")
|
||||
@Value("${app.website-url}")
|
||||
private String websiteUrl;
|
||||
|
||||
@Transactional
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE comments ADD COLUMN pinned_at DATETIME(6) NULL;
|
||||
@@ -93,4 +93,50 @@ class PostServiceTest {
|
||||
() -> service.createPost("alice", 1L, "t", "c", List.of(1L),
|
||||
null, null, null, null, null, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void finalizeLotteryNotifiesAuthor() {
|
||||
PostRepository postRepo = mock(PostRepository.class);
|
||||
UserRepository userRepo = mock(UserRepository.class);
|
||||
CategoryRepository catRepo = mock(CategoryRepository.class);
|
||||
TagRepository tagRepo = mock(TagRepository.class);
|
||||
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
|
||||
NotificationService notifService = mock(NotificationService.class);
|
||||
SubscriptionService subService = mock(SubscriptionService.class);
|
||||
CommentService commentService = mock(CommentService.class);
|
||||
CommentRepository commentRepo = mock(CommentRepository.class);
|
||||
ReactionRepository reactionRepo = mock(ReactionRepository.class);
|
||||
PostSubscriptionRepository subRepo = mock(PostSubscriptionRepository.class);
|
||||
NotificationRepository notificationRepo = mock(NotificationRepository.class);
|
||||
PostReadService postReadService = mock(PostReadService.class);
|
||||
ImageUploader imageUploader = mock(ImageUploader.class);
|
||||
TaskScheduler taskScheduler = mock(TaskScheduler.class);
|
||||
EmailSender emailSender = mock(EmailSender.class);
|
||||
ApplicationContext context = mock(ApplicationContext.class);
|
||||
|
||||
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
|
||||
notifService, subService, commentService, commentRepo,
|
||||
reactionRepo, subRepo, notificationRepo, postReadService,
|
||||
imageUploader, taskScheduler, emailSender, context, PublishMode.DIRECT);
|
||||
when(context.getBean(PostService.class)).thenReturn(service);
|
||||
|
||||
User author = new User();
|
||||
author.setId(1L);
|
||||
User winner = new User();
|
||||
winner.setId(2L);
|
||||
|
||||
LotteryPost lp = new LotteryPost();
|
||||
lp.setId(1L);
|
||||
lp.setAuthor(author);
|
||||
lp.setTitle("L");
|
||||
lp.setPrizeCount(1);
|
||||
lp.getParticipants().add(winner);
|
||||
|
||||
when(lotteryRepo.findById(1L)).thenReturn(Optional.of(lp));
|
||||
|
||||
service.finalizeLottery(1L);
|
||||
|
||||
verify(notifService).createNotification(eq(winner), eq(NotificationType.LOTTERY_WIN), eq(lp), isNull(), isNull(), eq(author), isNull(), isNull());
|
||||
verify(notifService).createNotification(eq(author), eq(NotificationType.LOTTERY_DRAW), eq(lp), isNull(), isNull(), isNull(), isNull(), isNull());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
NUXT_PUBLIC_API_BASE_URL=https://www.open-isle.com
|
||||
NUXT_PUBLIC_WEBSITE_BASE_URL=https://www.open-isle.com
|
||||
NUXT_PUBLIC_GOOGLE_CLIENT_ID=777830451304-xxx.apps.googleusercontent.com
|
||||
NUXT_PUBLIC_GITHUB_CLIENT_ID=Ov23liVkO1NPAX5JyWxJ
|
||||
NUXT_PUBLIC_DISCORD_CLIENT_ID=1394985417044000779
|
||||
@@ -2,3 +2,4 @@ node_modules
|
||||
.nuxt
|
||||
dist
|
||||
.output
|
||||
.env
|
||||
@@ -0,0 +1,143 @@
|
||||
/* Maple Mono - Thin 100 */
|
||||
@font-face {
|
||||
font-family: "Maple Mono";
|
||||
src: url("/fonts/maple-mono-100-normal.woff2") format("woff2");
|
||||
font-weight: 100;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Maple Mono - Thin Italic 100 */
|
||||
@font-face {
|
||||
font-family: "Maple Mono";
|
||||
src: url("/fonts/maple-mono-100-italic.woff2") format("woff2");
|
||||
font-weight: 100;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Maple Mono - ExtraLight 200 */
|
||||
@font-face {
|
||||
font-family: "Maple Mono";
|
||||
src: url("/fonts/maple-mono-200-normal.woff2") format("woff2");
|
||||
font-weight: 200;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Maple Mono - ExtraLight Italic 200 */
|
||||
@font-face {
|
||||
font-family: "Maple Mono";
|
||||
src: url("/fonts/maple-mono-200-italic.woff2") format("woff2");
|
||||
font-weight: 200;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Maple Mono - Light 300 */
|
||||
@font-face {
|
||||
font-family: "Maple Mono";
|
||||
src: url("/fonts/maple-mono-300-normal.woff2") format("woff2");
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Maple Mono - Light Italic 300 */
|
||||
@font-face {
|
||||
font-family: "Maple Mono";
|
||||
src: url("/fonts/maple-mono-300-italic.woff2") format("woff2");
|
||||
font-weight: 300;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Maple Mono - Regular 400 */
|
||||
@font-face {
|
||||
font-family: "Maple Mono";
|
||||
src: url("/fonts/maple-mono-400-normal.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Maple Mono - Italic 400 */
|
||||
@font-face {
|
||||
font-family: "Maple Mono";
|
||||
src: url("/fonts/maple-mono-400-italic.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Maple Mono - Medium 500 */
|
||||
@font-face {
|
||||
font-family: "Maple Mono";
|
||||
src: url("/fonts/maple-mono-500-normal.woff2") format("woff2");
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Maple Mono - Medium Italic 500 */
|
||||
@font-face {
|
||||
font-family: "Maple Mono";
|
||||
src: url("/fonts/maple-mono-500-italic.woff2") format("woff2");
|
||||
font-weight: 500;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Maple Mono - SemiBold 600 */
|
||||
@font-face {
|
||||
font-family: "Maple Mono";
|
||||
src: url("/fonts/maple-mono-600-normal.woff2") format("woff2");
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Maple Mono - SemiBold Italic 600 */
|
||||
@font-face {
|
||||
font-family: "Maple Mono";
|
||||
src: url("/fonts/maple-mono-600-italic.woff2") format("woff2");
|
||||
font-weight: 600;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Maple Mono - Bold 700 */
|
||||
@font-face {
|
||||
font-family: "Maple Mono";
|
||||
src: url("/fonts/maple-mono-700-normal.woff2") format("woff2");
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Maple Mono - Bold Italic 700 */
|
||||
@font-face {
|
||||
font-family: "Maple Mono";
|
||||
src: url("/fonts/maple-mono-700-italic.woff2") format("woff2");
|
||||
font-weight: 700;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Maple Mono - ExtraBold 800 */
|
||||
@font-face {
|
||||
font-family: "Maple Mono";
|
||||
src: url("/fonts/maple-mono-800-normal.woff2") format("woff2");
|
||||
font-weight: 800;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Maple Mono - ExtraBold Italic 800 */
|
||||
@font-face {
|
||||
font-family: "Maple Mono";
|
||||
src: url("/fonts/maple-mono-800-italic.woff2") format("woff2");
|
||||
font-weight: 800;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
@@ -131,13 +131,43 @@ body {
|
||||
}
|
||||
|
||||
.info-content-text pre {
|
||||
background-color: var(--normal-background-color);
|
||||
display: flex;
|
||||
background-color: var(--lottery-background-color);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
line-height: 1.5;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.info-content-text pre .line-numbers {
|
||||
counter-reset: line-number 0;
|
||||
width: 2em;
|
||||
font-size: 13px;
|
||||
position: sticky;
|
||||
flex-shrink: 0;
|
||||
font-family: 'Maple Mono', monospace;
|
||||
margin: 1em 0;
|
||||
color: #888;
|
||||
border-right: 1px solid #888;
|
||||
box-sizing: border-box;
|
||||
padding-right: 0.5em;
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
.info-content-text pre .line-numbers .line-number::before {
|
||||
content: counter(line-number);
|
||||
counter-increment: line-number;
|
||||
}
|
||||
|
||||
.info-content-text code {
|
||||
font-family: 'Maple Mono', monospace;
|
||||
font-size: 13px;
|
||||
border-radius: 4px;
|
||||
white-space: no-wrap;
|
||||
background-color: var(--lottery-background-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.copy-code-btn {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
@@ -156,15 +186,6 @@ body {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.info-content-text code {
|
||||
font-family: 'Roboto Mono', monospace;
|
||||
font-size: 13px;
|
||||
border-radius: 4px;
|
||||
white-space: pre-wrap;
|
||||
background-color: var(--normal-background-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.info-content-text a {
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
@@ -267,7 +288,7 @@ body {
|
||||
}
|
||||
|
||||
.info-content-text pre {
|
||||
line-height: 1.1;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.vditor-panel {
|
||||
|
||||
@@ -37,8 +37,10 @@
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { API_BASE_URL, toast } from '~/main'
|
||||
import { toast } from '~/main'
|
||||
import { getToken } from '~/utils/auth'
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
const props = defineProps({
|
||||
medals: {
|
||||
|
||||
@@ -11,29 +11,20 @@
|
||||
</BasePopup>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import BasePopup from '~/components/BasePopup.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
export default {
|
||||
name: 'ActivityPopup',
|
||||
components: { BasePopup },
|
||||
props: {
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
icon: String,
|
||||
text: String,
|
||||
},
|
||||
emits: ['close'],
|
||||
setup(props, { emit }) {
|
||||
const router = useRouter()
|
||||
const gotoActivity = () => {
|
||||
})
|
||||
const emit = defineEmits(['close'])
|
||||
const gotoActivity = async () => {
|
||||
emit('close')
|
||||
router.push('/activities')
|
||||
await navigateTo('/activities', { replace: true })
|
||||
}
|
||||
const close = () => emit('close')
|
||||
return { gotoActivity, close }
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -12,25 +12,15 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
export default {
|
||||
name: 'ArticleCategory',
|
||||
props: {
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
category: { type: Object, default: null },
|
||||
},
|
||||
setup(props) {
|
||||
const router = useRouter()
|
||||
const gotoCategory = () => {
|
||||
})
|
||||
|
||||
const gotoCategory = async () => {
|
||||
if (!props.category) return
|
||||
const value = encodeURIComponent(props.category.id ?? props.category.name)
|
||||
router.push({ path: '/', query: { category: value } }).then(() => {
|
||||
window.location.reload()
|
||||
})
|
||||
}
|
||||
return { gotoCategory }
|
||||
},
|
||||
await navigateTo({ path: '/', query: { category: value } }, { replace: true })
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -17,24 +17,14 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
export default {
|
||||
name: 'ArticleTags',
|
||||
props: {
|
||||
<script setup>
|
||||
defineProps({
|
||||
tags: { type: Array, default: () => [] },
|
||||
},
|
||||
setup() {
|
||||
const router = useRouter()
|
||||
const gotoTag = (tag) => {
|
||||
const value = encodeURIComponent(tag.id ?? tag.name)
|
||||
router.push({ path: '/', query: { tags: value } }).then(() => {
|
||||
window.location.reload()
|
||||
})
|
||||
}
|
||||
return { gotoTag }
|
||||
},
|
||||
|
||||
const gotoTag = async (tag) => {
|
||||
const value = encodeURIComponent(tag.id ?? tag.name)
|
||||
await navigateTo({ path: '/', query: { tags: value } }, { replace: true })
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -26,22 +26,20 @@
|
||||
</Dropdown>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { API_BASE_URL } from '~/main'
|
||||
import Dropdown from '~/components/Dropdown.vue'
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
export default {
|
||||
name: 'CategorySelect',
|
||||
components: { Dropdown },
|
||||
props: {
|
||||
const props = defineProps({
|
||||
modelValue: { type: [String, Number], default: '' },
|
||||
options: { type: Array, default: () => [] },
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { emit }) {
|
||||
const providedOptions = ref(Array.isArray(props.options) ? [...props.options] : [])
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const providedOptions = ref(Array.isArray(props.options) ? [...props.options] : [])
|
||||
watch(
|
||||
() => props.options,
|
||||
(val) => {
|
||||
@@ -65,10 +63,6 @@ export default {
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
return { fetchCategories, selected, isImageIcon, providedOptions }
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
:to="`/users/${comment.userId}?tab=achievements`"
|
||||
>{{ getMedalTitle(comment.medal) }}</router-link
|
||||
>
|
||||
<i v-if="comment.pinned" class="fas fa-thumbtack pin-icon"></i>
|
||||
<span v-if="level >= 2">
|
||||
<i class="fas fa-reply reply-icon"></i>
|
||||
<span class="user-name reply-user-name">{{ comment.parentUserName }}</span>
|
||||
@@ -74,6 +75,7 @@
|
||||
:comment="item"
|
||||
:level="level + 1"
|
||||
:default-show-replies="item.openReplies"
|
||||
:post-author-id="postAuthorId"
|
||||
/>
|
||||
</template>
|
||||
</BaseTimeline>
|
||||
@@ -88,11 +90,10 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import VueEasyLightbox from 'vue-easy-lightbox'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { API_BASE_URL, toast } from '~/main'
|
||||
import { toast } from '~/main'
|
||||
import { authState, getToken } from '~/utils/auth'
|
||||
import { handleMarkdownClick, renderMarkdown } from '~/utils/markdown'
|
||||
import { getMedalTitle } from '~/utils/medal'
|
||||
@@ -100,13 +101,11 @@ import TimeManager from '~/utils/time'
|
||||
import BaseTimeline from '~/components/BaseTimeline.vue'
|
||||
import CommentEditor from '~/components/CommentEditor.vue'
|
||||
import DropdownMenu from '~/components/DropdownMenu.vue'
|
||||
import LoginOverlay from '~/components/LoginOverlay.vue'
|
||||
import ReactionsGroup from '~/components/ReactionsGroup.vue'
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
const CommentItem = {
|
||||
name: 'CommentItem',
|
||||
emits: ['deleted'],
|
||||
props: {
|
||||
const props = defineProps({
|
||||
comment: {
|
||||
type: Object,
|
||||
required: true,
|
||||
@@ -119,9 +118,14 @@ const CommentItem = {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
postAuthorId: {
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const router = useRouter()
|
||||
})
|
||||
|
||||
const emit = defineEmits(['deleted'])
|
||||
|
||||
const showReplies = ref(props.level === 0 ? true : props.defaultShowReplies)
|
||||
watch(
|
||||
() => props.defaultShowReplies,
|
||||
@@ -138,9 +142,11 @@ const CommentItem = {
|
||||
const loggedIn = computed(() => authState.loggedIn)
|
||||
const countReplies = (list) => list.reduce((sum, r) => sum + 1 + countReplies(r.reply || []), 0)
|
||||
const replyCount = computed(() => countReplies(props.comment.reply || []))
|
||||
|
||||
const toggleReplies = () => {
|
||||
showReplies.value = !showReplies.value
|
||||
}
|
||||
|
||||
const toggleEditor = () => {
|
||||
showEditor.value = !showEditor.value
|
||||
if (showEditor.value) {
|
||||
@@ -150,7 +156,6 @@ const CommentItem = {
|
||||
}
|
||||
}
|
||||
|
||||
// 合并所有子回复为一个扁平数组
|
||||
const flattenReplies = (list) => {
|
||||
let result = []
|
||||
for (const r of list) {
|
||||
@@ -171,12 +176,22 @@ const CommentItem = {
|
||||
})
|
||||
|
||||
const isAuthor = computed(() => authState.username === props.comment.userName)
|
||||
const isPostAuthor = computed(() => Number(authState.userId) === Number(props.postAuthorId))
|
||||
const isAdmin = computed(() => authState.role === 'ADMIN')
|
||||
const commentMenuItems = computed(() =>
|
||||
isAuthor.value || isAdmin.value
|
||||
? [{ text: '删除评论', color: 'red', onClick: () => deleteComment() }]
|
||||
: [],
|
||||
)
|
||||
const commentMenuItems = computed(() => {
|
||||
const items = []
|
||||
if (isAuthor.value || isAdmin.value) {
|
||||
items.push({ text: '删除评论', color: 'red', onClick: () => deleteComment() })
|
||||
}
|
||||
if (isAdmin.value || isPostAuthor.value) {
|
||||
if (props.comment.pinned) {
|
||||
items.push({ text: '取消置顶', onClick: () => unpinComment() })
|
||||
} else {
|
||||
items.push({ text: '置顶', onClick: () => pinComment() })
|
||||
}
|
||||
}
|
||||
return items
|
||||
})
|
||||
const deleteComment = async () => {
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
@@ -236,11 +251,11 @@ const CommentItem = {
|
||||
reply: [],
|
||||
openReplies: false,
|
||||
src: r.author.avatar,
|
||||
iconClick: () => router.push(`/users/${r.author.id}`),
|
||||
iconClick: () => navigateTo(`/users/${r.author.id}`),
|
||||
})),
|
||||
openReplies: false,
|
||||
src: data.author.avatar,
|
||||
iconClick: () => router.push(`/users/${data.author.id}`),
|
||||
iconClick: () => navigateTo(`/users/${data.author.id}`),
|
||||
})
|
||||
clear()
|
||||
showEditor.value = false
|
||||
@@ -257,12 +272,55 @@ const CommentItem = {
|
||||
isWaitingForReply.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const pinComment = async () => {
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
toast.error('请先登录')
|
||||
return
|
||||
}
|
||||
const url = isAdmin.value
|
||||
? `${API_BASE_URL}/api/admin/comments/${props.comment.id}/pin`
|
||||
: `${API_BASE_URL}/api/comments/${props.comment.id}/pin`
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (res.ok) {
|
||||
props.comment.pinned = true
|
||||
toast.success('已置顶')
|
||||
} else {
|
||||
toast.error('操作失败')
|
||||
}
|
||||
}
|
||||
const unpinComment = async () => {
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
toast.error('请先登录')
|
||||
return
|
||||
}
|
||||
const url = isAdmin.value
|
||||
? `${API_BASE_URL}/api/admin/comments/${props.comment.id}/unpin`
|
||||
: `${API_BASE_URL}/api/comments/${props.comment.id}/unpin`
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (res.ok) {
|
||||
props.comment.pinned = false
|
||||
toast.success('已取消置顶')
|
||||
} else {
|
||||
toast.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const copyCommentLink = () => {
|
||||
const link = `${location.origin}${location.pathname}#comment-${props.comment.id}`
|
||||
navigator.clipboard.writeText(link).then(() => {
|
||||
toast.success('已复制')
|
||||
})
|
||||
}
|
||||
|
||||
const handleContentClick = (e) => {
|
||||
handleMarkdownClick(e)
|
||||
if (e.target.tagName === 'IMG') {
|
||||
@@ -273,41 +331,6 @@ const CommentItem = {
|
||||
lightboxVisible.value = true
|
||||
}
|
||||
}
|
||||
return {
|
||||
showReplies,
|
||||
toggleReplies,
|
||||
showEditor,
|
||||
toggleEditor,
|
||||
submitReply,
|
||||
copyCommentLink,
|
||||
renderMarkdown,
|
||||
isWaitingForReply,
|
||||
commentMenuItems,
|
||||
deleteComment,
|
||||
lightboxVisible,
|
||||
lightboxIndex,
|
||||
lightboxImgs,
|
||||
handleContentClick,
|
||||
loggedIn,
|
||||
replyCount,
|
||||
replyList,
|
||||
getMedalTitle,
|
||||
editorWrapper,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
CommentItem.components = {
|
||||
CommentItem,
|
||||
CommentEditor,
|
||||
BaseTimeline,
|
||||
ReactionsGroup,
|
||||
DropdownMenu,
|
||||
VueEasyLightbox,
|
||||
LoginOverlay,
|
||||
}
|
||||
|
||||
export default CommentItem
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -370,6 +393,12 @@ export default CommentItem
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.pin-icon {
|
||||
font-size: 12px;
|
||||
margin-left: 10px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
@keyframes highlight {
|
||||
from {
|
||||
background-color: yellow;
|
||||
|
||||
@@ -11,36 +11,32 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import ActivityPopup from '~/components/ActivityPopup.vue'
|
||||
import MedalPopup from '~/components/MedalPopup.vue'
|
||||
import NotificationSettingPopup from '~/components/NotificationSettingPopup.vue'
|
||||
import { API_BASE_URL } from '~/main'
|
||||
import { authState } from '~/utils/auth'
|
||||
|
||||
export default {
|
||||
name: 'GlobalPopups',
|
||||
components: { ActivityPopup, MedalPopup, NotificationSettingPopup },
|
||||
data() {
|
||||
return {
|
||||
showMilkTeaPopup: false,
|
||||
milkTeaIcon: '',
|
||||
showNotificationPopup: false,
|
||||
showMedalPopup: false,
|
||||
newMedals: [],
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
await this.checkMilkTeaActivity()
|
||||
if (this.showMilkTeaPopup) return
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
await this.checkNotificationSetting()
|
||||
if (this.showNotificationPopup) return
|
||||
const showMilkTeaPopup = ref(false)
|
||||
const milkTeaIcon = ref('')
|
||||
const showNotificationPopup = ref(false)
|
||||
const showMedalPopup = ref(false)
|
||||
const newMedals = ref([])
|
||||
|
||||
await this.checkNewMedals()
|
||||
},
|
||||
methods: {
|
||||
async checkMilkTeaActivity() {
|
||||
onMounted(async () => {
|
||||
await checkMilkTeaActivity()
|
||||
if (showMilkTeaPopup.value) return
|
||||
|
||||
await checkNotificationSetting()
|
||||
if (showNotificationPopup.value) return
|
||||
|
||||
await checkNewMedals()
|
||||
})
|
||||
|
||||
const checkMilkTeaActivity = async () => {
|
||||
if (!process.client) return
|
||||
if (localStorage.getItem('milkTeaActivityPopupShown')) return
|
||||
try {
|
||||
@@ -49,33 +45,33 @@ export default {
|
||||
const list = await res.json()
|
||||
const a = list.find((i) => i.type === 'MILK_TEA' && !i.ended)
|
||||
if (a) {
|
||||
this.milkTeaIcon = a.icon
|
||||
this.showMilkTeaPopup = true
|
||||
milkTeaIcon.value = a.icon
|
||||
showMilkTeaPopup.value = true
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore network errors
|
||||
}
|
||||
},
|
||||
closeMilkTeaPopup() {
|
||||
}
|
||||
const closeMilkTeaPopup = () => {
|
||||
if (!process.client) return
|
||||
localStorage.setItem('milkTeaActivityPopupShown', 'true')
|
||||
this.showMilkTeaPopup = false
|
||||
this.checkNotificationSetting()
|
||||
},
|
||||
async checkNotificationSetting() {
|
||||
showMilkTeaPopup.value = false
|
||||
checkNotificationSetting()
|
||||
}
|
||||
const checkNotificationSetting = async () => {
|
||||
if (!process.client) return
|
||||
if (!authState.loggedIn) return
|
||||
if (localStorage.getItem('notificationSettingPopupShown')) return
|
||||
this.showNotificationPopup = true
|
||||
},
|
||||
closeNotificationPopup() {
|
||||
showNotificationPopup.value = true
|
||||
}
|
||||
const closeNotificationPopup = () => {
|
||||
if (!process.client) return
|
||||
localStorage.setItem('notificationSettingPopupShown', 'true')
|
||||
this.showNotificationPopup = false
|
||||
this.checkNewMedals()
|
||||
},
|
||||
async checkNewMedals() {
|
||||
showNotificationPopup.value = false
|
||||
checkNewMedals()
|
||||
}
|
||||
const checkNewMedals = async () => {
|
||||
if (!process.client) return
|
||||
if (!authState.loggedIn || !authState.userId) return
|
||||
try {
|
||||
@@ -85,21 +81,19 @@ export default {
|
||||
const seen = JSON.parse(localStorage.getItem('seenMedals') || '[]')
|
||||
const m = medals.filter((i) => i.completed && !seen.includes(i.type))
|
||||
if (m.length > 0) {
|
||||
this.newMedals = m
|
||||
this.showMedalPopup = true
|
||||
newMedals.value = m
|
||||
showMedalPopup.value = true
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore errors
|
||||
}
|
||||
},
|
||||
closeMedalPopup() {
|
||||
}
|
||||
const closeMedalPopup = () => {
|
||||
if (!process.client) return
|
||||
const seen = new Set(JSON.parse(localStorage.getItem('seenMedals') || '[]'))
|
||||
this.newMedals.forEach((m) => seen.add(m.type))
|
||||
newMedals.value.forEach((m) => seen.add(m.type))
|
||||
localStorage.setItem('seenMedals', JSON.stringify([...seen]))
|
||||
this.showMedalPopup = false
|
||||
},
|
||||
},
|
||||
showMedalPopup.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</button>
|
||||
<span v-if="isMobile && unreadCount > 0" class="menu-unread-dot"></span>
|
||||
</div>
|
||||
<div class="logo-container" @click="goToHome">
|
||||
<NuxtLink class="logo-container" :to="`/`">
|
||||
<img
|
||||
alt="OpenIsle"
|
||||
src="https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png"
|
||||
@@ -16,7 +16,7 @@
|
||||
height="60"
|
||||
/>
|
||||
<div class="logo-text">OpenIsle</div>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<ClientOnly>
|
||||
@@ -48,45 +48,30 @@
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import { ClientOnly } from '#components'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import DropdownMenu from '~/components/DropdownMenu.vue'
|
||||
import SearchDropdown from '~/components/SearchDropdown.vue'
|
||||
import { authState, clearToken, loadCurrentUser } from '~/utils/auth'
|
||||
import { fetchUnreadCount, notificationState } from '~/utils/notification'
|
||||
import { useIsMobile } from '~/utils/screen'
|
||||
|
||||
export default {
|
||||
name: 'HeaderComponent',
|
||||
components: { DropdownMenu, SearchDropdown },
|
||||
props: {
|
||||
const props = defineProps({
|
||||
showMenuBtn: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
setup(props, { expose }) {
|
||||
})
|
||||
|
||||
const isLogin = computed(() => authState.loggedIn)
|
||||
const isMobile = useIsMobile()
|
||||
const unreadCount = computed(() => notificationState.unreadCount)
|
||||
const router = useRouter()
|
||||
const avatar = ref('')
|
||||
const showSearch = ref(false)
|
||||
const searchDropdown = ref(null)
|
||||
const userMenu = ref(null)
|
||||
const menuBtn = ref(null)
|
||||
|
||||
expose({
|
||||
menuBtn,
|
||||
})
|
||||
|
||||
const goToHome = () => {
|
||||
router.push('/').then(() => {
|
||||
window.location.reload()
|
||||
})
|
||||
}
|
||||
const search = () => {
|
||||
showSearch.value = true
|
||||
nextTick(() => {
|
||||
@@ -99,14 +84,14 @@ export default {
|
||||
})
|
||||
}
|
||||
const goToLogin = () => {
|
||||
router.push('/login')
|
||||
navigateTo('/login', { replace: true })
|
||||
}
|
||||
const goToSettings = () => {
|
||||
router.push('/settings')
|
||||
navigateTo('/settings', { replace: true })
|
||||
}
|
||||
const goToProfile = async () => {
|
||||
if (!authState.loggedIn) {
|
||||
router.push('/login')
|
||||
navigateTo('/login', { replace: true })
|
||||
return
|
||||
}
|
||||
let id = authState.username || authState.userId
|
||||
@@ -117,15 +102,15 @@ export default {
|
||||
}
|
||||
}
|
||||
if (id) {
|
||||
router.push(`/users/${id}`)
|
||||
navigateTo(`/users/${id}`, { replace: true })
|
||||
}
|
||||
}
|
||||
const goToSignup = () => {
|
||||
router.push('/signup')
|
||||
navigateTo('/signup', { replace: true })
|
||||
}
|
||||
const goToLogout = () => {
|
||||
clearToken()
|
||||
this.$router.push('/login')
|
||||
navigateTo('/login', { replace: true })
|
||||
}
|
||||
|
||||
const headerMenuItems = computed(() => [
|
||||
@@ -161,37 +146,7 @@ export default {
|
||||
await updateUnread()
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => router.currentRoute.value.fullPath,
|
||||
() => {
|
||||
if (userMenu.value) userMenu.value.close()
|
||||
showSearch.value = false
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
return {
|
||||
isLogin,
|
||||
isMobile,
|
||||
headerMenuItems,
|
||||
unreadCount,
|
||||
goToHome,
|
||||
search,
|
||||
closeSearch,
|
||||
goToLogin,
|
||||
goToSettings,
|
||||
goToProfile,
|
||||
goToSignup,
|
||||
goToLogout,
|
||||
showSearch,
|
||||
searchDropdown,
|
||||
userMenu,
|
||||
avatar,
|
||||
menuBtn,
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -212,6 +167,8 @@ export default {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
|
||||
@@ -9,18 +9,9 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
export default {
|
||||
name: 'LoginOverlay',
|
||||
setup() {
|
||||
const router = useRouter()
|
||||
<script setup>
|
||||
const goLogin = () => {
|
||||
router.push('/login')
|
||||
}
|
||||
return { goLogin }
|
||||
},
|
||||
navigateTo('/login', { replace: true })
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -16,33 +16,25 @@
|
||||
</BasePopup>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import BasePopup from '~/components/BasePopup.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { authState } from '~/utils/auth'
|
||||
|
||||
export default {
|
||||
name: 'MedalPopup',
|
||||
components: { BasePopup },
|
||||
props: {
|
||||
defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
medals: { type: Array, default: () => [] },
|
||||
},
|
||||
emits: ['close'],
|
||||
setup(props, { emit }) {
|
||||
const router = useRouter()
|
||||
})
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const gotoMedals = () => {
|
||||
emit('close')
|
||||
if (authState.username) {
|
||||
router.push(`/users/${authState.username}?tab=achievements`)
|
||||
navigateTo(`/users/${authState.username}?tab=achievements`, { replace: true })
|
||||
} else {
|
||||
router.push('/')
|
||||
navigateTo('/', { replace: true })
|
||||
}
|
||||
}
|
||||
const close = () => emit('close')
|
||||
return { gotoMedals, close }
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<transition name="slide">
|
||||
<nav v-if="visible" class="menu">
|
||||
<div class="menu-item-container">
|
||||
<NuxtLink class="menu-item" exact-active-class="selected" to="/" @click="handleHomeClick">
|
||||
<NuxtLink class="menu-item" exact-active-class="selected" to="/" @click="handleItemClick">
|
||||
<i class="menu-item-icon fas fa-hashtag"></i>
|
||||
<span class="menu-item-text">话题</span>
|
||||
</NuxtLink>
|
||||
@@ -123,25 +123,23 @@
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { themeState, cycleTheme, ThemeMode } from '~/utils/theme'
|
||||
import { authState } from '~/utils/auth'
|
||||
import { fetchUnreadCount, notificationState } from '~/utils/notification'
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { API_BASE_URL } from '~/main'
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
export default {
|
||||
name: 'MenuComponent',
|
||||
props: {
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
async setup(props, { emit }) {
|
||||
const router = useRouter()
|
||||
const categories = ref([])
|
||||
const tags = ref([])
|
||||
})
|
||||
|
||||
const emit = defineEmits(['item-click'])
|
||||
|
||||
const categoryOpen = ref(true)
|
||||
const tagOpen = ref(true)
|
||||
const isLoadingCategory = ref(false)
|
||||
@@ -193,12 +191,6 @@ export default {
|
||||
watch(() => authState.loggedIn, updateCount)
|
||||
})
|
||||
|
||||
const handleHomeClick = () => {
|
||||
router.push('/').then(() => {
|
||||
window.location.reload()
|
||||
})
|
||||
}
|
||||
|
||||
const handleItemClick = () => {
|
||||
if (window.innerWidth <= 768) emit('item-click')
|
||||
}
|
||||
@@ -210,38 +202,17 @@ export default {
|
||||
|
||||
const gotoCategory = (c) => {
|
||||
const value = encodeURIComponent(c.id ?? c.name)
|
||||
router.push({ path: '/', query: { category: value } })
|
||||
navigateTo({ path: '/', query: { category: value } }, { replace: true })
|
||||
handleItemClick()
|
||||
}
|
||||
|
||||
const gotoTag = (t) => {
|
||||
const value = encodeURIComponent(t.id ?? t.name)
|
||||
router.push({ path: '/', query: { tags: value } })
|
||||
navigateTo({ path: '/', query: { tags: value } }, { replace: true })
|
||||
handleItemClick()
|
||||
}
|
||||
|
||||
await Promise.all([fetchCategoryData(), fetchTagData()])
|
||||
|
||||
return {
|
||||
categoryData,
|
||||
tagData,
|
||||
categoryOpen,
|
||||
tagOpen,
|
||||
isLoadingCategory,
|
||||
isLoadingTag,
|
||||
iconClass,
|
||||
unreadCount,
|
||||
showUnreadCount,
|
||||
shouldShowStats,
|
||||
cycleTheme,
|
||||
handleHomeClick,
|
||||
handleItemClick,
|
||||
isImageIcon,
|
||||
gotoCategory,
|
||||
gotoTag,
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -57,49 +57,44 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { API_BASE_URL, toast } from '~/main'
|
||||
<script setup>
|
||||
import { toast } from '~/main'
|
||||
import { fetchCurrentUser, getToken } from '~/utils/auth'
|
||||
import BaseInput from '~/components/BaseInput.vue'
|
||||
import BasePopup from '~/components/BasePopup.vue'
|
||||
import LevelProgress from '~/components/LevelProgress.vue'
|
||||
import ProgressBar from '~/components/ProgressBar.vue'
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
export default {
|
||||
name: 'MilkTeaActivityComponent',
|
||||
components: { ProgressBar, LevelProgress, BaseInput, BasePopup },
|
||||
data() {
|
||||
return {
|
||||
info: { redeemCount: 0, ended: false },
|
||||
user: null,
|
||||
dialogVisible: false,
|
||||
contact: '',
|
||||
loading: false,
|
||||
isLoadingUser: true,
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
await this.loadInfo()
|
||||
this.isLoadingUser = true
|
||||
this.user = await fetchCurrentUser()
|
||||
this.isLoadingUser = false
|
||||
},
|
||||
methods: {
|
||||
async loadInfo() {
|
||||
const info = ref({ redeemCount: 0, ended: false })
|
||||
const user = ref(null)
|
||||
const dialogVisible = ref(false)
|
||||
const contact = ref('')
|
||||
const loading = ref(false)
|
||||
const isLoadingUser = ref(true)
|
||||
|
||||
onMounted(async () => {
|
||||
await loadInfo()
|
||||
isLoadingUser.value = true
|
||||
user.value = await fetchCurrentUser()
|
||||
isLoadingUser.value = false
|
||||
})
|
||||
const loadInfo = async () => {
|
||||
const res = await fetch(`${API_BASE_URL}/api/activities/milk-tea`)
|
||||
if (res.ok) {
|
||||
this.info = await res.json()
|
||||
info.value = await res.json()
|
||||
}
|
||||
},
|
||||
openDialog() {
|
||||
this.dialogVisible = true
|
||||
},
|
||||
closeDialog() {
|
||||
this.dialogVisible = false
|
||||
},
|
||||
async submitRedeem() {
|
||||
if (!this.contact) return
|
||||
this.loading = true
|
||||
}
|
||||
const openDialog = () => {
|
||||
dialogVisible.value = true
|
||||
}
|
||||
const closeDialog = () => {
|
||||
dialogVisible.value = false
|
||||
}
|
||||
const submitRedeem = async () => {
|
||||
if (!contact.value) return
|
||||
loading.value = true
|
||||
const token = getToken()
|
||||
const res = await fetch(`${API_BASE_URL}/api/activities/milk-tea/redeem`, {
|
||||
method: 'POST',
|
||||
@@ -107,7 +102,7 @@ export default {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ contact: this.contact }),
|
||||
body: JSON.stringify({ contact: contact.value }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
@@ -116,14 +111,12 @@ export default {
|
||||
} else {
|
||||
toast.success('兑换成功!')
|
||||
}
|
||||
this.dialogVisible = false
|
||||
await this.loadInfo()
|
||||
dialogVisible.value = false
|
||||
await loadInfo()
|
||||
} else {
|
||||
toast.error('兑换失败')
|
||||
}
|
||||
this.loading = false
|
||||
},
|
||||
},
|
||||
loading.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -11,27 +11,19 @@
|
||||
</BasePopup>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import BasePopup from '~/components/BasePopup.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
export default {
|
||||
name: 'NotificationSettingPopup',
|
||||
components: { BasePopup },
|
||||
props: {
|
||||
defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
},
|
||||
emits: ['close'],
|
||||
setup(props, { emit }) {
|
||||
const router = useRouter()
|
||||
})
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const gotoSetting = () => {
|
||||
emit('close')
|
||||
router.push('/message?tab=control')
|
||||
navigateTo('/message?tab=control', { replace: true })
|
||||
}
|
||||
const close = () => emit('close')
|
||||
return { gotoSetting, close }
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -46,11 +46,27 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { API_BASE_URL, toast } from '~/main'
|
||||
import { toast } from '~/main'
|
||||
import { authState, getToken } from '~/utils/auth'
|
||||
import { reactionEmojiMap } from '~/utils/reactions'
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const props = defineProps({
|
||||
modelValue: { type: Array, default: () => [] },
|
||||
contentType: { type: String, required: true },
|
||||
contentId: { type: [Number, String], required: true },
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(v) => (reactions.value = v),
|
||||
)
|
||||
|
||||
const reactions = ref(props.modelValue)
|
||||
const reactionTypes = ref([])
|
||||
|
||||
let cachedTypes = null
|
||||
const fetchTypes = async () => {
|
||||
@@ -71,22 +87,6 @@ const fetchTypes = async () => {
|
||||
return cachedTypes
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'ReactionsGroup',
|
||||
props: {
|
||||
modelValue: { type: Array, default: () => [] },
|
||||
contentType: { type: String, required: true },
|
||||
contentId: { type: [Number, String], required: true },
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { emit }) {
|
||||
const reactions = ref(props.modelValue)
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(v) => (reactions.value = v),
|
||||
)
|
||||
|
||||
const reactionTypes = ref([])
|
||||
onMounted(async () => {
|
||||
reactionTypes.value = await fetchTypes()
|
||||
})
|
||||
@@ -200,23 +200,6 @@ export default {
|
||||
toast.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
reactionEmojiMap,
|
||||
counts,
|
||||
totalCount,
|
||||
likeCount,
|
||||
displayedReactions,
|
||||
panelTypes,
|
||||
panelVisible,
|
||||
openPanel,
|
||||
scheduleHide,
|
||||
cancelHide,
|
||||
toggleReaction,
|
||||
userReacted,
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -36,20 +36,16 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, watch } from 'vue'
|
||||
<script setup>
|
||||
import { useIsMobile } from '~/utils/screen'
|
||||
import { useRouter } from 'vue-router'
|
||||
import Dropdown from '~/components/Dropdown.vue'
|
||||
import { API_BASE_URL } from '~/main'
|
||||
import { stripMarkdown } from '~/utils/markdown'
|
||||
import { ref, watch } from 'vue'
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
export default {
|
||||
name: 'SearchDropdown',
|
||||
components: { Dropdown },
|
||||
emits: ['close'],
|
||||
setup(props, { emit }) {
|
||||
const router = useRouter()
|
||||
const keyword = ref('')
|
||||
const selected = ref(null)
|
||||
const results = ref([])
|
||||
@@ -99,35 +95,21 @@ export default {
|
||||
const opt = results.value.find((r) => r.id === val)
|
||||
if (!opt) return
|
||||
if (opt.type === 'post' || opt.type === 'post_title') {
|
||||
router.push(`/posts/${opt.id}`)
|
||||
navigateTo(`/posts/${opt.id}`, { replace: true })
|
||||
} else if (opt.type === 'user') {
|
||||
router.push(`/users/${opt.id}`)
|
||||
navigateTo(`/users/${opt.id}`, { replace: true })
|
||||
} else if (opt.type === 'comment') {
|
||||
if (opt.postId) {
|
||||
router.push(`/posts/${opt.postId}#comment-${opt.id}`)
|
||||
navigateTo(`/posts/${opt.postId}#comment-${opt.id}`, { replace: true })
|
||||
}
|
||||
} else if (opt.type === 'category') {
|
||||
router.push({ path: '/', query: { category: opt.id } })
|
||||
navigateTo({ path: '/', query: { category: opt.id } }, { replace: true })
|
||||
} else if (opt.type === 'tag') {
|
||||
router.push({ path: '/', query: { tags: opt.id } })
|
||||
navigateTo({ path: '/', query: { tags: opt.id } }, { replace: true })
|
||||
}
|
||||
selected.value = null
|
||||
keyword.value = ''
|
||||
})
|
||||
|
||||
return {
|
||||
keyword,
|
||||
selected,
|
||||
fetchResults,
|
||||
highlight,
|
||||
iconMap,
|
||||
isMobile,
|
||||
dropdown,
|
||||
onClose,
|
||||
toggle,
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -28,21 +28,20 @@
|
||||
</Dropdown>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { API_BASE_URL, toast } from '~/main'
|
||||
import { toast } from '~/main'
|
||||
import Dropdown from '~/components/Dropdown.vue'
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
export default {
|
||||
name: 'TagSelect',
|
||||
components: { Dropdown },
|
||||
props: {
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const props = defineProps({
|
||||
modelValue: { type: Array, default: () => [] },
|
||||
creatable: { type: Boolean, default: false },
|
||||
options: { type: Array, default: () => [] },
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { emit }) {
|
||||
})
|
||||
|
||||
const localTags = ref([])
|
||||
const providedTags = ref(Array.isArray(props.options) ? [...props.options] : [])
|
||||
|
||||
@@ -91,11 +90,7 @@ export default {
|
||||
// 3) 合并、去重、可创建
|
||||
let options = [...data, ...localTags.value]
|
||||
|
||||
if (
|
||||
props.creatable &&
|
||||
kw &&
|
||||
!options.some((t) => t.name.toLowerCase() === kw.toLowerCase())
|
||||
) {
|
||||
if (props.creatable && kw && !options.some((t) => t.name.toLowerCase() === kw.toLowerCase())) {
|
||||
options.push({ id: `__create__:${kw}`, name: `创建"${kw}"` })
|
||||
}
|
||||
|
||||
@@ -132,10 +127,6 @@ export default {
|
||||
emit('update:modelValue', v)
|
||||
},
|
||||
})
|
||||
|
||||
return { fetchTags, selected, isImageIcon, mergedOptions }
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -11,20 +11,15 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import BasePlaceholder from '~/components/BasePlaceholder.vue'
|
||||
|
||||
export default {
|
||||
name: 'UserList',
|
||||
components: { BasePlaceholder },
|
||||
props: {
|
||||
defineProps({
|
||||
users: { type: Array, default: () => [] },
|
||||
},
|
||||
methods: {
|
||||
handleUserClick(user) {
|
||||
this.$router.push(`/users/${user.id}`)
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const handleUserClick = (user) => {
|
||||
navigateTo(`/users/${user.id}`, { replace: true })
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export const WEBSITE_BASE_URL = 'https://www.open-isle.com'
|
||||
@@ -1,12 +1 @@
|
||||
import { useRuntimeConfig } from '#app'
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
export const API_BASE_URL = config.public.apiBaseUrl
|
||||
export const GOOGLE_CLIENT_ID = config.public.googleClientId
|
||||
export const GITHUB_CLIENT_ID = config.public.githubClientId
|
||||
export const DISCORD_CLIENT_ID = config.public.discordClientId
|
||||
export const TWITTER_CLIENT_ID = config.public.twitterClientId
|
||||
|
||||
// 重新导出 toast 功能,使用 composable 方式
|
||||
export { toast } from './composables/useToast'
|
||||
|
||||
@@ -5,14 +5,15 @@ export default defineNuxtConfig({
|
||||
runtimeConfig: {
|
||||
public: {
|
||||
apiBaseUrl: process.env.NUXT_PUBLIC_API_BASE_URL || '',
|
||||
websiteBaseUrl: process.env.NUXT_PUBLIC_WEBSITE_BASE_URL || '',
|
||||
googleClientId: process.env.NUXT_PUBLIC_GOOGLE_CLIENT_ID || '',
|
||||
githubClientId: process.env.NUXT_PUBLIC_GITHUB_CLIENT_ID || '',
|
||||
discordClientId: process.env.NUXT_PUBLIC_DISCORD_CLIENT_ID || '',
|
||||
twitterClientId: process.env.NUXT_PUBLIC_TWITTER_CLIENT_ID || '',
|
||||
},
|
||||
},
|
||||
// Ensure Vditor styles load before our overrides in global.css
|
||||
css: ['vditor/dist/index.css', '~/assets/global.css'],
|
||||
// 确保 Vditor 样式在 global.css 覆盖前加载
|
||||
css: ['vditor/dist/index.css', '~/assets/fonts.css', '~/assets/global.css'],
|
||||
app: {
|
||||
head: {
|
||||
script: [
|
||||
@@ -51,6 +52,8 @@ export default defineNuxtConfig({
|
||||
},
|
||||
],
|
||||
},
|
||||
baseURL: '/',
|
||||
buildAssetsDir: '/_nuxt/',
|
||||
},
|
||||
vue: {
|
||||
compilerOptions: {
|
||||
|
||||
@@ -41,8 +41,9 @@ import { use } from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import VChart from 'vue-echarts'
|
||||
import { API_BASE_URL } from '~/main'
|
||||
import { getToken } from '~/utils/auth'
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
use([LineChart, TitleComponent, TooltipComponent, GridComponent, DataZoomComponent, CanvasRenderer])
|
||||
|
||||
|
||||
@@ -29,35 +29,28 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { API_BASE_URL } from '~/main'
|
||||
<script setup>
|
||||
import TimeManager from '~/utils/time'
|
||||
import MilkTeaActivityComponent from '~/components/MilkTeaActivityComponent.vue'
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
export default {
|
||||
name: 'ActivityListPageView',
|
||||
components: { MilkTeaActivityComponent },
|
||||
data() {
|
||||
return {
|
||||
activities: [],
|
||||
TimeManager,
|
||||
isLoadingActivities: false,
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
this.isLoadingActivities = true
|
||||
const activities = ref([])
|
||||
const isLoadingActivities = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
isLoadingActivities.value = true
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/activities`)
|
||||
if (res.ok) {
|
||||
this.activities = await res.json()
|
||||
activities.value = await res.json()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
this.isLoadingActivities = false
|
||||
}
|
||||
},
|
||||
isLoadingActivities.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -2,24 +2,20 @@
|
||||
<CallbackPage />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import CallbackPage from '~/components/CallbackPage.vue'
|
||||
import { discordExchange } from '~/utils/discord'
|
||||
|
||||
export default {
|
||||
name: 'DiscordCallbackPageView',
|
||||
components: { CallbackPage },
|
||||
async mounted() {
|
||||
onMounted(async () => {
|
||||
const url = new URL(window.location.href)
|
||||
const code = url.searchParams.get('code')
|
||||
const state = url.searchParams.get('state')
|
||||
const result = await discordExchange(code, state, '')
|
||||
|
||||
if (result.needReason) {
|
||||
this.$router.push('/signup-reason?token=' + result.token)
|
||||
navigateTo(`/signup-reason?token=${result.token}`, { replace: true })
|
||||
} else {
|
||||
this.$router.push('/')
|
||||
}
|
||||
},
|
||||
navigateTo('/', { replace: true })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -23,105 +23,99 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { API_BASE_URL, toast } from '~/main'
|
||||
<script setup>
|
||||
import { toast } from '~/main'
|
||||
import BaseInput from '~/components/BaseInput.vue'
|
||||
export default {
|
||||
name: 'ForgotPasswordPageView',
|
||||
components: { BaseInput },
|
||||
data() {
|
||||
return {
|
||||
step: 0,
|
||||
email: '',
|
||||
code: '',
|
||||
password: '',
|
||||
token: '',
|
||||
emailError: '',
|
||||
passwordError: '',
|
||||
isSending: false,
|
||||
isVerifying: false,
|
||||
isResetting: false,
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
const step = ref(0)
|
||||
const email = ref('')
|
||||
const code = ref('')
|
||||
const password = ref('')
|
||||
const token = ref('')
|
||||
const emailError = ref('')
|
||||
const passwordError = ref('')
|
||||
const isSending = ref(false)
|
||||
const isVerifying = ref(false)
|
||||
const isResetting = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
if (route.query.email) {
|
||||
email.value = decodeURIComponent(route.query.email)
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (this.$route.query.email) {
|
||||
this.email = decodeURIComponent(this.$route.query.email)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async sendCode() {
|
||||
if (!this.email) {
|
||||
this.emailError = '邮箱不能为空'
|
||||
})
|
||||
const sendCode = async () => {
|
||||
if (!email.value) {
|
||||
emailError.value = '邮箱不能为空'
|
||||
return
|
||||
}
|
||||
try {
|
||||
this.isSending = true
|
||||
isSending.value = true
|
||||
const res = await fetch(`${API_BASE_URL}/api/auth/forgot/send`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: this.email }),
|
||||
body: JSON.stringify({ email: email.value }),
|
||||
})
|
||||
this.isSending = false
|
||||
isSending.value = false
|
||||
if (res.ok) {
|
||||
toast.success('验证码已发送')
|
||||
this.step = 1
|
||||
step.value = 1
|
||||
} else {
|
||||
toast.error('请填写已注册邮箱')
|
||||
}
|
||||
} catch (e) {
|
||||
this.isSending = false
|
||||
isSending.value = false
|
||||
toast.error('发送失败')
|
||||
}
|
||||
},
|
||||
async verifyCode() {
|
||||
}
|
||||
const verifyCode = async () => {
|
||||
try {
|
||||
this.isVerifying = true
|
||||
isVerifying.value = true
|
||||
const res = await fetch(`${API_BASE_URL}/api/auth/forgot/verify`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: this.email, code: this.code }),
|
||||
body: JSON.stringify({ email: email.value, code: code.value }),
|
||||
})
|
||||
this.isVerifying = false
|
||||
isVerifying.value = false
|
||||
const data = await res.json()
|
||||
if (res.ok) {
|
||||
this.token = data.token
|
||||
this.step = 2
|
||||
token.value = data.token
|
||||
step.value = 2
|
||||
} else {
|
||||
toast.error(data.error || '验证失败')
|
||||
}
|
||||
} catch (e) {
|
||||
this.isVerifying = false
|
||||
isVerifying.value = false
|
||||
toast.error('验证失败')
|
||||
}
|
||||
},
|
||||
async resetPassword() {
|
||||
if (!this.password) {
|
||||
this.passwordError = '密码不能为空'
|
||||
}
|
||||
const resetPassword = async () => {
|
||||
if (!password.value) {
|
||||
passwordError.value = '密码不能为空'
|
||||
return
|
||||
}
|
||||
try {
|
||||
this.isResetting = true
|
||||
isResetting.value = true
|
||||
const res = await fetch(`${API_BASE_URL}/api/auth/forgot/reset`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: this.token, password: this.password }),
|
||||
body: JSON.stringify({ token: token.value, password: password.value }),
|
||||
})
|
||||
this.isResetting = false
|
||||
isResetting.value = false
|
||||
const data = await res.json()
|
||||
if (res.ok) {
|
||||
toast.success('密码已重置')
|
||||
this.$router.push('/login')
|
||||
navigateTo('/login', { replace: true })
|
||||
} else if (data.field === 'password') {
|
||||
this.passwordError = data.error
|
||||
passwordError.value = data.error
|
||||
} else {
|
||||
toast.error(data.error || '重置失败')
|
||||
}
|
||||
} catch (e) {
|
||||
this.isResetting = false
|
||||
isResetting.value = false
|
||||
toast.error('重置失败')
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -2,24 +2,20 @@
|
||||
<CallbackPage />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import CallbackPage from '~/components/CallbackPage.vue'
|
||||
import { githubExchange } from '~/utils/github'
|
||||
|
||||
export default {
|
||||
name: 'GithubCallbackPageView',
|
||||
components: { CallbackPage },
|
||||
async mounted() {
|
||||
onMounted(async () => {
|
||||
const url = new URL(window.location.href)
|
||||
const code = url.searchParams.get('code')
|
||||
const state = url.searchParams.get('state')
|
||||
const result = await githubExchange(code, state, '')
|
||||
|
||||
if (result.needReason) {
|
||||
this.$router.push('/signup-reason?token=' + result.token)
|
||||
navigateTo(`/signup-reason?token=${result.token}`, { replace: true })
|
||||
} else {
|
||||
this.$router.push('/')
|
||||
}
|
||||
},
|
||||
navigateTo('/', { replace: true })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -2,29 +2,25 @@
|
||||
<CallbackPage />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import CallbackPage from '~/components/CallbackPage.vue'
|
||||
import { googleAuthWithToken } from '~/utils/google'
|
||||
|
||||
export default {
|
||||
name: 'GoogleCallbackPageView',
|
||||
components: { CallbackPage },
|
||||
async mounted() {
|
||||
onMounted(async () => {
|
||||
const hash = new URLSearchParams(window.location.hash.substring(1))
|
||||
const idToken = hash.get('id_token')
|
||||
if (idToken) {
|
||||
await googleAuthWithToken(
|
||||
idToken,
|
||||
() => {
|
||||
this.$router.push('/')
|
||||
navigateTo('/', { replace: true })
|
||||
},
|
||||
(token) => {
|
||||
this.$router.push('/signup-reason?token=' + token)
|
||||
navigateTo(`/signup-reason?token=${token}`, { replace: true })
|
||||
},
|
||||
)
|
||||
} else {
|
||||
this.$router.push('/login')
|
||||
}
|
||||
},
|
||||
navigateTo('/login', { replace: true })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
+140
-223
@@ -50,7 +50,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoadingPosts && articles.length === 0" class="loading-container">
|
||||
<div v-if="pendingFirst" class="loading-container">
|
||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||
</div>
|
||||
|
||||
@@ -60,7 +60,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="article-item" v-for="article in articles" :key="article.id">
|
||||
<div
|
||||
v-if="!pendingFirst"
|
||||
class="article-item"
|
||||
v-for="article in articles"
|
||||
:key="article.id"
|
||||
>
|
||||
<div class="article-main-container">
|
||||
<NuxtLink class="article-item-title main-item" :to="`/posts/${article.id}`">
|
||||
<i v-if="article.pinned" class="fas fa-thumbtack pinned-icon"></i>
|
||||
@@ -104,41 +109,25 @@
|
||||
热门帖子功能开发中,敬请期待。
|
||||
</div>
|
||||
<div v-else class="placeholder-container">分类浏览功能开发中,敬请期待。</div>
|
||||
<div v-if="isLoadingPosts && articles.length > 0" class="loading-container bottom-loading">
|
||||
<div v-if="isLoadingMore && articles.length > 0" class="loading-container bottom-loading">
|
||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, watch } from 'vue'
|
||||
<script setup>
|
||||
import { ref, watch, watchEffect, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import ArticleCategory from '~/components/ArticleCategory.vue'
|
||||
import ArticleTags from '~/components/ArticleTags.vue'
|
||||
import CategorySelect from '~/components/CategorySelect.vue'
|
||||
import SearchDropdown from '~/components/SearchDropdown.vue'
|
||||
import TagSelect from '~/components/TagSelect.vue'
|
||||
import { API_BASE_URL } from '~/main'
|
||||
import { getToken } from '~/utils/auth'
|
||||
import { useScrollLoadMore } from '~/utils/loadMore'
|
||||
import { stripMarkdown } from '~/utils/markdown'
|
||||
import { useIsMobile } from '~/utils/screen'
|
||||
import TimeManager from '~/utils/time'
|
||||
|
||||
export default {
|
||||
name: 'HomePageView',
|
||||
components: {
|
||||
CategorySelect,
|
||||
TagSelect,
|
||||
ArticleTags,
|
||||
ArticleCategory,
|
||||
SearchDropdown,
|
||||
ClientOnly: () =>
|
||||
import('vue').then((m) =>
|
||||
m.defineAsyncComponent(() => import('vue').then(() => ({ template: '<slot />' }))),
|
||||
),
|
||||
},
|
||||
async setup() {
|
||||
useHead({
|
||||
title: 'OpenIsle - 全面开源的自由社区',
|
||||
meta: [
|
||||
@@ -149,19 +138,20 @@ export default {
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
const selectedCategory = ref('')
|
||||
const selectedTags = ref([])
|
||||
const route = useRoute()
|
||||
const tagOptions = ref([])
|
||||
const categoryOptions = ref([])
|
||||
const isLoadingPosts = ref(false)
|
||||
|
||||
const isLoadingMore = ref(false)
|
||||
|
||||
const topics = ref(['最新回复', '最新', '排行榜' /*, '热门', '类别'*/])
|
||||
const selectedTopic = ref(
|
||||
route.query.view === 'ranking'
|
||||
? '排行榜'
|
||||
: route.query.view === 'latest'
|
||||
? '最新'
|
||||
: '最新回复',
|
||||
route.query.view === 'ranking' ? '排行榜' : route.query.view === 'latest' ? '最新' : '最新回复',
|
||||
)
|
||||
const articles = ref([])
|
||||
const page = ref(0)
|
||||
@@ -169,11 +159,11 @@ export default {
|
||||
const isMobile = useIsMobile()
|
||||
const allLoaded = ref(false)
|
||||
|
||||
/** URL 参数 -> 本地筛选值 **/
|
||||
const selectedCategorySet = (category) => {
|
||||
const c = decodeURIComponent(category)
|
||||
selectedCategory.value = isNaN(c) ? c : Number(c)
|
||||
}
|
||||
|
||||
const selectedTagsSet = (tags) => {
|
||||
const t = Array.isArray(tags) ? tags.join(',') : tags
|
||||
selectedTags.value = t
|
||||
@@ -183,23 +173,17 @@ export default {
|
||||
.map((v) => (isNaN(v) ? v : Number(v)))
|
||||
}
|
||||
|
||||
/** 初始化:仅在客户端首渲染时根据路由同步一次 **/
|
||||
onMounted(() => {
|
||||
const query = route.query
|
||||
const category = query.category
|
||||
const tags = query.tags
|
||||
|
||||
if (category) {
|
||||
selectedCategorySet(category)
|
||||
}
|
||||
if (tags) {
|
||||
selectedTagsSet(tags)
|
||||
}
|
||||
const { category, tags } = route.query
|
||||
if (category) selectedCategorySet(category)
|
||||
if (tags) selectedTagsSet(tags)
|
||||
})
|
||||
|
||||
/** 路由变更时同步筛选 **/
|
||||
watch(
|
||||
() => route.query,
|
||||
() => {
|
||||
const query = route.query
|
||||
(query) => {
|
||||
const category = query.category
|
||||
const tags = query.tags
|
||||
category && selectedCategorySet(category)
|
||||
@@ -207,18 +191,14 @@ export default {
|
||||
},
|
||||
)
|
||||
|
||||
/** 选项加载(分类/标签名称回填) **/
|
||||
const loadOptions = async () => {
|
||||
if (selectedCategory.value && !isNaN(selectedCategory.value)) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/categories/${selectedCategory.value}`)
|
||||
if (res.ok) {
|
||||
categoryOptions.value = [await res.json()]
|
||||
if (res.ok) categoryOptions.value = [await res.json()]
|
||||
} catch {}
|
||||
}
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedTags.value.length) {
|
||||
const arr = []
|
||||
for (const t of selectedTags.value) {
|
||||
@@ -226,74 +206,54 @@ export default {
|
||||
try {
|
||||
const r = await fetch(`${API_BASE_URL}/api/tags/${t}`)
|
||||
if (r.ok) arr.push(await r.json())
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
tagOptions.value = arr
|
||||
}
|
||||
}
|
||||
|
||||
const buildUrl = () => {
|
||||
let url = `${API_BASE_URL}/api/posts?page=${page.value}&pageSize=${pageSize}`
|
||||
if (selectedCategory.value) {
|
||||
url += `&categoryId=${selectedCategory.value}`
|
||||
}
|
||||
if (selectedTags.value.length) {
|
||||
selectedTags.value.forEach((t) => {
|
||||
url += `&tagIds=${t}`
|
||||
/** 列表 API 路径与查询参数 **/
|
||||
const baseQuery = computed(() => ({
|
||||
categoryId: selectedCategory.value || undefined,
|
||||
tagIds: selectedTags.value.length ? selectedTags.value : undefined,
|
||||
}))
|
||||
const listApiPath = computed(() => {
|
||||
if (selectedTopic.value === '排行榜') return '/api/posts/ranking'
|
||||
if (selectedTopic.value === '最新回复') return '/api/posts/latest-reply'
|
||||
return '/api/posts'
|
||||
})
|
||||
const buildUrl = ({ pageNo }) => {
|
||||
const url = new URL(`${API_BASE_URL}${listApiPath.value}`)
|
||||
url.searchParams.set('page', pageNo)
|
||||
url.searchParams.set('pageSize', pageSize)
|
||||
if (baseQuery.value.categoryId) url.searchParams.set('categoryId', baseQuery.value.categoryId)
|
||||
if (baseQuery.value.tagIds)
|
||||
for (const t of baseQuery.value.tagIds) url.searchParams.append('tagIds', t)
|
||||
return url.toString()
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
const buildRankUrl = () => {
|
||||
let url = `${API_BASE_URL}/api/posts/ranking?page=${page.value}&pageSize=${pageSize}`
|
||||
if (selectedCategory.value) {
|
||||
url += `&categoryId=${selectedCategory.value}`
|
||||
}
|
||||
if (selectedTags.value.length) {
|
||||
selectedTags.value.forEach((t) => {
|
||||
url += `&tagIds=${t}`
|
||||
})
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
const buildReplyUrl = () => {
|
||||
let url = `${API_BASE_URL}/api/posts/latest-reply?page=${page.value}&pageSize=${pageSize}`
|
||||
if (selectedCategory.value) {
|
||||
url += `&categoryId=${selectedCategory.value}`
|
||||
}
|
||||
if (selectedTags.value.length) {
|
||||
selectedTags.value.forEach((t) => {
|
||||
url += `&tagIds=${t}`
|
||||
})
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
const fetchPosts = async (reset = false) => {
|
||||
if (reset) {
|
||||
page.value = 0
|
||||
allLoaded.value = false
|
||||
articles.value = []
|
||||
}
|
||||
if (isLoadingPosts.value || allLoaded.value) return
|
||||
try {
|
||||
isLoadingPosts.value = true
|
||||
const tokenHeader = computed(() => {
|
||||
const token = getToken()
|
||||
const res = await fetch(buildUrl(), {
|
||||
headers: {
|
||||
Authorization: token ? `Bearer ${token}` : '',
|
||||
},
|
||||
return token ? { Authorization: `Bearer ${token}` } : {}
|
||||
})
|
||||
isLoadingPosts.value = false
|
||||
if (!res.ok) return
|
||||
const data = await res.json()
|
||||
articles.value.push(
|
||||
...data.map((p) => ({
|
||||
|
||||
/** —— 首屏数据托管(SSR) —— **/
|
||||
const asyncKey = computed(() => [
|
||||
'home:firstpage',
|
||||
selectedTopic.value,
|
||||
String(baseQuery.value.categoryId ?? ''),
|
||||
JSON.stringify(baseQuery.value.tagIds ?? []),
|
||||
])
|
||||
const {
|
||||
data: firstPage,
|
||||
pending: pendingFirst,
|
||||
refresh: refreshFirst,
|
||||
} = await useAsyncData(
|
||||
() => asyncKey.value.join('::'),
|
||||
async () => {
|
||||
const res = await $fetch(buildUrl({ pageNo: 0 }), { headers: tokenHeader.value })
|
||||
const data = Array.isArray(res) ? res : []
|
||||
return data.map((p) => ({
|
||||
id: p.id,
|
||||
title: p.title,
|
||||
description: p.content,
|
||||
@@ -302,41 +262,41 @@ export default {
|
||||
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
|
||||
comments: p.commentCount,
|
||||
views: p.views,
|
||||
time: TimeManager.format(p.createdAt),
|
||||
pinned: !!p.pinnedAt,
|
||||
time: TimeManager.format(
|
||||
selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,
|
||||
),
|
||||
pinned: Boolean(p.pinned ?? p.pinnedAt ?? p.pinned_at),
|
||||
type: p.type,
|
||||
})),
|
||||
)
|
||||
if (data.length < pageSize) {
|
||||
allLoaded.value = true
|
||||
} else {
|
||||
page.value += 1
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchRanking = async (reset = false) => {
|
||||
if (reset) {
|
||||
page.value = 0
|
||||
allLoaded.value = false
|
||||
articles.value = []
|
||||
}
|
||||
if (isLoadingPosts.value || allLoaded.value) return
|
||||
try {
|
||||
isLoadingPosts.value = true
|
||||
const token = getToken()
|
||||
const res = await fetch(buildRankUrl(), {
|
||||
headers: {
|
||||
Authorization: token ? `Bearer ${token}` : '',
|
||||
}))
|
||||
},
|
||||
})
|
||||
isLoadingPosts.value = false
|
||||
if (!res.ok) return
|
||||
const data = await res.json()
|
||||
articles.value.push(
|
||||
...data.map((p) => ({
|
||||
{
|
||||
server: true,
|
||||
default: () => [],
|
||||
watch: [selectedTopic, baseQuery],
|
||||
},
|
||||
)
|
||||
|
||||
/** 首屏/筛选变更:重置分页并灌入 firstPage **/
|
||||
watch(
|
||||
firstPage,
|
||||
(data) => {
|
||||
page.value = 0
|
||||
articles.value = [...(data || [])]
|
||||
allLoaded.value = (data?.length || 0) < pageSize
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
/** —— 滚动加载更多 —— **/
|
||||
let inflight = null
|
||||
const fetchNextPage = async () => {
|
||||
if (allLoaded.value || pendingFirst.value || inflight) return
|
||||
const nextPage = page.value + 1
|
||||
isLoadingMore.value = true
|
||||
inflight = $fetch(buildUrl({ pageNo: nextPage }), { headers: tokenHeader.value })
|
||||
.then((res) => {
|
||||
const data = Array.isArray(res) ? res : []
|
||||
const mapped = data.map((p) => ({
|
||||
id: p.id,
|
||||
title: p.title,
|
||||
description: p.content,
|
||||
@@ -345,102 +305,59 @@ export default {
|
||||
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
|
||||
comments: p.commentCount,
|
||||
views: p.views,
|
||||
time: TimeManager.format(p.createdAt),
|
||||
pinned: !!p.pinnedAt,
|
||||
time: TimeManager.format(
|
||||
selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,
|
||||
),
|
||||
pinned: Boolean(p.pinned ?? p.pinnedAt ?? p.pinned_at),
|
||||
type: p.type,
|
||||
})),
|
||||
)
|
||||
}))
|
||||
articles.value.push(...mapped)
|
||||
if (data.length < pageSize) {
|
||||
allLoaded.value = true
|
||||
} else {
|
||||
page.value += 1
|
||||
page.value = nextPage
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchLatestReply = async (reset = false) => {
|
||||
if (reset) {
|
||||
page.value = 0
|
||||
allLoaded.value = false
|
||||
articles.value = []
|
||||
}
|
||||
if (isLoadingPosts.value || allLoaded.value) return
|
||||
try {
|
||||
isLoadingPosts.value = true
|
||||
const token = getToken()
|
||||
const res = await fetch(buildReplyUrl(), {
|
||||
headers: {
|
||||
Authorization: token ? `Bearer ${token}` : '',
|
||||
},
|
||||
})
|
||||
isLoadingPosts.value = false
|
||||
if (!res.ok) return
|
||||
const data = await res.json()
|
||||
articles.value.push(
|
||||
...data.map((p) => ({
|
||||
id: p.id,
|
||||
title: p.title,
|
||||
description: p.content,
|
||||
category: p.category,
|
||||
tags: p.tags || [],
|
||||
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
|
||||
comments: p.commentCount,
|
||||
views: p.views,
|
||||
time: TimeManager.format(p.lastReplyAt || p.createdAt),
|
||||
pinned: !!p.pinnedAt,
|
||||
type: p.type,
|
||||
})),
|
||||
)
|
||||
if (data.length < pageSize) {
|
||||
allLoaded.value = true
|
||||
} else {
|
||||
page.value += 1
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchContent = async (reset = false) => {
|
||||
if (selectedTopic.value === '排行榜') {
|
||||
await fetchRanking(reset)
|
||||
} else if (selectedTopic.value === '最新回复') {
|
||||
await fetchLatestReply(reset)
|
||||
} else {
|
||||
await fetchPosts(reset)
|
||||
}
|
||||
}
|
||||
|
||||
useScrollLoadMore(fetchContent)
|
||||
|
||||
watch([selectedCategory, selectedTags], () => {
|
||||
fetchContent(true)
|
||||
.finally(() => {
|
||||
inflight = null
|
||||
isLoadingMore.value = false
|
||||
})
|
||||
}
|
||||
|
||||
watch(selectedTopic, () => {
|
||||
fetchContent(true)
|
||||
})
|
||||
|
||||
const sanitizeDescription = (text) => stripMarkdown(text)
|
||||
|
||||
await Promise.all([loadOptions(), fetchContent()])
|
||||
|
||||
return {
|
||||
topics,
|
||||
selectedTopic,
|
||||
/** 绑定滚动加载(避免挂载瞬间触发) **/
|
||||
let initialReady = false
|
||||
const loadMoreGuarded = async () => {
|
||||
if (!initialReady) return
|
||||
await fetchNextPage()
|
||||
}
|
||||
useScrollLoadMore(loadMoreGuarded)
|
||||
watch(
|
||||
articles,
|
||||
sanitizeDescription,
|
||||
isLoadingPosts,
|
||||
selectedCategory,
|
||||
selectedTags,
|
||||
tagOptions,
|
||||
categoryOptions,
|
||||
isMobile,
|
||||
}
|
||||
() => {
|
||||
if (!initialReady && articles.value.length) initialReady = true
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
/** 切换分类/标签/Tab:useAsyncData 已 watch,这里只需确保 options 加载 **/
|
||||
watch([selectedCategory, selectedTags], () => {
|
||||
loadOptions()
|
||||
})
|
||||
watch(selectedTopic, () => {
|
||||
// 仅当需要额外选项时加载
|
||||
loadOptions()
|
||||
})
|
||||
|
||||
/** 选项首屏加载:服务端执行一次;客户端兜底 **/
|
||||
if (import.meta.server) {
|
||||
await loadOptions()
|
||||
}
|
||||
onMounted(() => {
|
||||
if (categoryOptions.value.length === 0 && tagOptions.value.length === 0) loadOptions()
|
||||
})
|
||||
|
||||
/** 其他工具函数 **/
|
||||
const sanitizeDescription = (text) => stripMarkdown(text)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -51,8 +51,8 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { API_BASE_URL, toast } from '~/main'
|
||||
<script setup>
|
||||
import { toast } from '~/main'
|
||||
import { setToken, loadCurrentUser } from '~/utils/auth'
|
||||
import { googleAuthorize } from '~/utils/google'
|
||||
import { githubAuthorize } from '~/utils/github'
|
||||
@@ -60,27 +60,19 @@ import { discordAuthorize } from '~/utils/discord'
|
||||
import { twitterAuthorize } from '~/utils/twitter'
|
||||
import BaseInput from '~/components/BaseInput.vue'
|
||||
import { registerPush } from '~/utils/push'
|
||||
export default {
|
||||
name: 'LoginPageView',
|
||||
components: { BaseInput },
|
||||
setup() {
|
||||
return { googleAuthorize }
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
username: '',
|
||||
password: '',
|
||||
isWaitingForLogin: false,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async submitLogin() {
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const isWaitingForLogin = ref(false)
|
||||
|
||||
const submitLogin = async () => {
|
||||
try {
|
||||
this.isWaitingForLogin = true
|
||||
isWaitingForLogin.value = true
|
||||
const res = await fetch(`${API_BASE_URL}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: this.username, password: this.password }),
|
||||
body: JSON.stringify({ username: username.value, password: password.value }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (res.ok && data.token) {
|
||||
@@ -88,35 +80,36 @@ export default {
|
||||
await loadCurrentUser()
|
||||
toast.success('登录成功')
|
||||
registerPush()
|
||||
this.$router.push('/')
|
||||
await navigateTo('/', { replace: true })
|
||||
} else if (data.reason_code === 'NOT_VERIFIED') {
|
||||
toast.info('当前邮箱未验证,已经为您重新发送验证码')
|
||||
this.$router.push({ path: '/signup', query: { verify: 1, u: this.username } })
|
||||
await navigateTo(
|
||||
{ path: '/signup', query: { verify: '1', u: username.value } },
|
||||
{ replace: true },
|
||||
)
|
||||
} else if (data.reason_code === 'IS_APPROVING') {
|
||||
toast.info('您的注册正在审批中, 请留意邮件')
|
||||
this.$router.push('/')
|
||||
await navigateTo('/', { replace: true })
|
||||
} else if (data.reason_code === 'NOT_APPROVED') {
|
||||
this.$router.push('/signup-reason?token=' + data.token)
|
||||
await navigateTo({ path: '/signup-reason', query: { token: data.token } }, { replace: true })
|
||||
} else {
|
||||
toast.error(data.error || '登录失败')
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error('登录失败')
|
||||
} finally {
|
||||
this.isWaitingForLogin = false
|
||||
isWaitingForLogin.value = false
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
loginWithGithub() {
|
||||
const loginWithGithub = () => {
|
||||
githubAuthorize()
|
||||
},
|
||||
loginWithDiscord() {
|
||||
}
|
||||
const loginWithDiscord = () => {
|
||||
discordAuthorize()
|
||||
},
|
||||
loginWithTwitter() {
|
||||
}
|
||||
const loginWithTwitter = () => {
|
||||
twitterAuthorize()
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -185,6 +185,32 @@
|
||||
</router-link>
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'LOTTERY_WIN'">
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
恭喜你在抽奖贴
|
||||
<router-link
|
||||
class="notif-content-text"
|
||||
@click="markRead(item.id)"
|
||||
:to="`/posts/${item.post.id}`"
|
||||
>
|
||||
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||
</router-link>
|
||||
中获奖
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'LOTTERY_DRAW'">
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
您的抽奖贴
|
||||
<router-link
|
||||
class="notif-content-text"
|
||||
@click="markRead(item.id)"
|
||||
:to="`/posts/${item.post.id}`"
|
||||
>
|
||||
{{ stripMarkdownLength(item.post.title, 100) }}
|
||||
</router-link>
|
||||
已开奖
|
||||
</NotificationContainer>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'POST_UPDATED'">
|
||||
<NotificationContainer :item="item" :markRead="markRead">
|
||||
您关注的帖子
|
||||
@@ -478,10 +504,8 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { API_BASE_URL } from '~/main'
|
||||
import BaseTimeline from '~/components/BaseTimeline.vue'
|
||||
import BasePlaceholder from '~/components/BasePlaceholder.vue'
|
||||
import NotificationContainer from '~/components/NotificationContainer.vue'
|
||||
@@ -491,12 +515,8 @@ import { toast } from '~/main'
|
||||
import { stripMarkdownLength } from '~/utils/markdown'
|
||||
import TimeManager from '~/utils/time'
|
||||
import { reactionEmojiMap } from '~/utils/reactions'
|
||||
|
||||
export default {
|
||||
name: 'MessagePageView',
|
||||
components: { BaseTimeline, BasePlaceholder, NotificationContainer },
|
||||
setup() {
|
||||
const router = useRouter()
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
const route = useRoute()
|
||||
const notifications = ref([])
|
||||
const isLoadingMessage = ref(false)
|
||||
@@ -505,9 +525,7 @@ export default {
|
||||
)
|
||||
const notificationPrefs = ref([])
|
||||
const filteredNotifications = computed(() =>
|
||||
selectedTab.value === 'all'
|
||||
? notifications.value
|
||||
: notifications.value.filter((n) => !n.read),
|
||||
selectedTab.value === 'all' ? notifications.value : notifications.value.filter((n) => !n.read),
|
||||
)
|
||||
|
||||
const markRead = async (id) => {
|
||||
@@ -565,6 +583,8 @@ export default {
|
||||
POST_UNSUBSCRIBED: 'fas fa-bookmark',
|
||||
REGISTER_REQUEST: 'fas fa-user-clock',
|
||||
ACTIVITY_REDEEM: 'fas fa-coffee',
|
||||
LOTTERY_WIN: 'fas fa-trophy',
|
||||
LOTTERY_DRAW: 'fas fa-bullhorn',
|
||||
MENTION: 'fas fa-at',
|
||||
}
|
||||
|
||||
@@ -596,7 +616,7 @@ export default {
|
||||
src: n.comment.author.avatar,
|
||||
iconClick: () => {
|
||||
markRead(n.id)
|
||||
router.push(`/users/${n.comment.author.id}`)
|
||||
navigateTo(`/users/${n.comment.author.id}`, { replace: true })
|
||||
},
|
||||
})
|
||||
} else if (n.type === 'REACTION') {
|
||||
@@ -606,7 +626,7 @@ export default {
|
||||
iconClick: () => {
|
||||
if (n.fromUser) {
|
||||
markRead(n.id)
|
||||
router.push(`/users/${n.fromUser.id}`)
|
||||
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -618,7 +638,29 @@ export default {
|
||||
iconClick: () => {
|
||||
if (n.fromUser) {
|
||||
markRead(n.id)
|
||||
router.push(`/users/${n.fromUser.id}`)
|
||||
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
|
||||
}
|
||||
},
|
||||
})
|
||||
} else if (n.type === 'LOTTERY_WIN') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
icon: iconMap[n.type],
|
||||
iconClick: () => {
|
||||
if (n.post) {
|
||||
markRead(n.id)
|
||||
router.push(`/posts/${n.post.id}`)
|
||||
}
|
||||
},
|
||||
})
|
||||
} else if (n.type === 'LOTTERY_DRAW') {
|
||||
notifications.value.push({
|
||||
...n,
|
||||
icon: iconMap[n.type],
|
||||
iconClick: () => {
|
||||
if (n.post) {
|
||||
markRead(n.id)
|
||||
router.push(`/posts/${n.post.id}`)
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -628,7 +670,7 @@ export default {
|
||||
src: n.comment.author.avatar,
|
||||
iconClick: () => {
|
||||
markRead(n.id)
|
||||
router.push(`/users/${n.comment.author.id}`)
|
||||
navigateTo(`/users/${n.comment.author.id}`, { replace: true })
|
||||
},
|
||||
})
|
||||
} else if (n.type === 'USER_ACTIVITY') {
|
||||
@@ -637,7 +679,7 @@ export default {
|
||||
src: n.comment.author.avatar,
|
||||
iconClick: () => {
|
||||
markRead(n.id)
|
||||
router.push(`/users/${n.comment.author.id}`)
|
||||
navigateTo(`/users/${n.comment.author.id}`, { replace: true })
|
||||
},
|
||||
})
|
||||
} else if (n.type === 'MENTION') {
|
||||
@@ -647,7 +689,7 @@ export default {
|
||||
iconClick: () => {
|
||||
if (n.fromUser) {
|
||||
markRead(n.id)
|
||||
router.push(`/users/${n.fromUser.id}`)
|
||||
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -658,7 +700,7 @@ export default {
|
||||
iconClick: () => {
|
||||
if (n.fromUser) {
|
||||
markRead(n.id)
|
||||
router.push(`/users/${n.fromUser.id}`)
|
||||
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -669,7 +711,7 @@ export default {
|
||||
iconClick: () => {
|
||||
if (n.post) {
|
||||
markRead(n.id)
|
||||
router.push(`/posts/${n.post.id}`)
|
||||
navigateTo(`/posts/${n.post.id}`, { replace: true })
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -680,7 +722,7 @@ export default {
|
||||
iconClick: () => {
|
||||
if (n.post) {
|
||||
markRead(n.id)
|
||||
router.push(`/posts/${n.post.id}`)
|
||||
navigateTo(`/posts/${n.post.id}`, { replace: true })
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -692,7 +734,7 @@ export default {
|
||||
iconClick: () => {
|
||||
if (n.post) {
|
||||
markRead(n.id)
|
||||
router.push(`/posts/${n.post.id}`)
|
||||
navigateTo(`/posts/${n.post.id}`, { replace: true })
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -791,6 +833,10 @@ export default {
|
||||
return '有人申请注册'
|
||||
case 'ACTIVITY_REDEEM':
|
||||
return '有人申请兑换奶茶'
|
||||
case 'LOTTERY_WIN':
|
||||
return '抽奖中奖了'
|
||||
case 'LOTTERY_DRAW':
|
||||
return '抽奖已开奖'
|
||||
default:
|
||||
return t
|
||||
}
|
||||
@@ -800,25 +846,6 @@ export default {
|
||||
fetchNotifications()
|
||||
fetchPrefs()
|
||||
})
|
||||
|
||||
return {
|
||||
notifications,
|
||||
formatType,
|
||||
isLoadingMessage,
|
||||
stripMarkdownLength,
|
||||
markRead,
|
||||
approve,
|
||||
reject,
|
||||
TimeManager,
|
||||
selectedTab,
|
||||
filteredNotifications,
|
||||
markAllRead,
|
||||
authState,
|
||||
notificationPrefs,
|
||||
togglePref,
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import 'flatpickr/dist/flatpickr.css'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import FlatPickr from 'vue-flatpickr-component'
|
||||
@@ -88,21 +88,11 @@ import LoginOverlay from '~/components/LoginOverlay.vue'
|
||||
import PostEditor from '~/components/PostEditor.vue'
|
||||
import PostTypeSelect from '~/components/PostTypeSelect.vue'
|
||||
import TagSelect from '~/components/TagSelect.vue'
|
||||
import { API_BASE_URL, toast } from '~/main'
|
||||
import { toast } from '~/main'
|
||||
import { authState, getToken } from '~/utils/auth'
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
export default {
|
||||
name: 'NewPostPageView',
|
||||
components: {
|
||||
PostEditor,
|
||||
CategorySelect,
|
||||
TagSelect,
|
||||
LoginOverlay,
|
||||
PostTypeSelect,
|
||||
AvatarCropper,
|
||||
FlatPickr,
|
||||
},
|
||||
setup() {
|
||||
const title = ref('')
|
||||
const content = ref('')
|
||||
const selectedCategory = ref('')
|
||||
@@ -392,32 +382,6 @@ export default {
|
||||
isWaitingPosting.value = false
|
||||
}
|
||||
}
|
||||
return {
|
||||
title,
|
||||
content,
|
||||
selectedCategory,
|
||||
selectedTags,
|
||||
postType,
|
||||
prizeIcon,
|
||||
prizeCount,
|
||||
endTime,
|
||||
submitPost,
|
||||
saveDraft,
|
||||
clearPost,
|
||||
isWaitingPosting,
|
||||
aiGenerate,
|
||||
isAiLoading,
|
||||
isLogin,
|
||||
onPrizeIconChange,
|
||||
onPrizeCropped,
|
||||
showPrizeCropper,
|
||||
tempPrizeIcon,
|
||||
dateConfig,
|
||||
prizeName,
|
||||
prizeDescription,
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -35,20 +35,18 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useRoute } from 'vue-router'
|
||||
import PostEditor from '~/components/PostEditor.vue'
|
||||
import CategorySelect from '~/components/CategorySelect.vue'
|
||||
import TagSelect from '~/components/TagSelect.vue'
|
||||
import { API_BASE_URL, toast } from '~/main'
|
||||
import { toast } from '~/main'
|
||||
import { getToken, authState } from '~/utils/auth'
|
||||
import LoginOverlay from '~/components/LoginOverlay.vue'
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
export default {
|
||||
name: 'EditPostPageView',
|
||||
components: { PostEditor, CategorySelect, TagSelect, LoginOverlay },
|
||||
setup() {
|
||||
const title = ref('')
|
||||
const content = ref('')
|
||||
const selectedCategory = ref('')
|
||||
@@ -58,7 +56,6 @@ export default {
|
||||
const isLogin = computed(() => authState.loggedIn)
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const postId = route.params.id
|
||||
|
||||
const loadPost = async () => {
|
||||
@@ -199,22 +196,7 @@ export default {
|
||||
}
|
||||
}
|
||||
const cancelEdit = () => {
|
||||
router.push(`/posts/${postId}`)
|
||||
}
|
||||
return {
|
||||
title,
|
||||
content,
|
||||
selectedCategory,
|
||||
selectedTags,
|
||||
submitPost,
|
||||
clearPost,
|
||||
cancelEdit,
|
||||
isWaitingPosting,
|
||||
aiGenerate,
|
||||
isAiLoading,
|
||||
isLogin,
|
||||
}
|
||||
},
|
||||
navigateTo(`/posts/${postId}`, { replace: true })
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -195,6 +195,7 @@
|
||||
:comment="item"
|
||||
:level="0"
|
||||
:default-show-replies="item.openReplies"
|
||||
:post-author-id="author.id"
|
||||
@deleted="onCommentDeleted"
|
||||
/>
|
||||
</template>
|
||||
@@ -230,7 +231,7 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'
|
||||
import VueEasyLightbox from 'vue-easy-lightbox'
|
||||
import { useRoute } from 'vue-router'
|
||||
@@ -243,7 +244,7 @@ import ReactionsGroup from '~/components/ReactionsGroup.vue'
|
||||
import DropdownMenu from '~/components/DropdownMenu.vue'
|
||||
import { renderMarkdown, handleMarkdownClick, stripMarkdownLength } from '~/utils/markdown'
|
||||
import { getMedalTitle } from '~/utils/medal'
|
||||
import { API_BASE_URL, toast } from '~/main'
|
||||
import { toast } from '~/main'
|
||||
import { getToken, authState } from '~/utils/auth'
|
||||
import TimeManager from '~/utils/time'
|
||||
import { useRouter } from 'vue-router'
|
||||
@@ -251,21 +252,9 @@ import { useIsMobile } from '~/utils/screen'
|
||||
import Dropdown from '~/components/Dropdown.vue'
|
||||
import { ClientOnly } from '#components'
|
||||
|
||||
export default {
|
||||
name: 'PostPageView',
|
||||
components: {
|
||||
CommentItem,
|
||||
CommentEditor,
|
||||
BaseTimeline,
|
||||
ArticleTags,
|
||||
ArticleCategory,
|
||||
ReactionsGroup,
|
||||
DropdownMenu,
|
||||
VueEasyLightbox,
|
||||
Dropdown,
|
||||
ClientOnly,
|
||||
},
|
||||
async setup() {
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
const route = useRoute()
|
||||
const postId = route.params.id
|
||||
const router = useRouter()
|
||||
@@ -291,9 +280,7 @@ export default {
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const headerHeight = process.client
|
||||
? parseFloat(
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--header-height'),
|
||||
) || 0
|
||||
? parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--header-height')) || 0
|
||||
: 0
|
||||
|
||||
useHead(() => ({
|
||||
@@ -357,7 +344,7 @@ export default {
|
||||
updateCountdown()
|
||||
countdownTimer = setInterval(updateCountdown, 1000)
|
||||
}
|
||||
const gotoUser = (id) => router.push(`/users/${id}`)
|
||||
const gotoUser = (id) => navigateTo(`/users/${id}`, { replace: true })
|
||||
const articleMenuItems = computed(() => {
|
||||
const items = []
|
||||
if (isAuthor.value || isAdmin.value) {
|
||||
@@ -405,10 +392,11 @@ export default {
|
||||
avatar: c.author.avatar,
|
||||
text: c.content,
|
||||
reactions: c.reactions || [],
|
||||
pinned: Boolean(c.pinned ?? c.pinnedAt ?? c.pinned_at),
|
||||
reply: (c.replies || []).map((r) => mapComment(r, c.author.username, level + 1)),
|
||||
openReplies: level === 0,
|
||||
src: c.author.avatar,
|
||||
iconClick: () => router.push(`/users/${c.author.id}`),
|
||||
iconClick: () => navigateTo(`/users/${c.author.id}`, { replace: true }),
|
||||
parentUserName: parentUserName,
|
||||
})
|
||||
|
||||
@@ -655,7 +643,7 @@ export default {
|
||||
}
|
||||
|
||||
const editPost = () => {
|
||||
router.push(`/posts/${postId}/edit`)
|
||||
navigateTo(`/posts/${postId}/edit`, { replace: true })
|
||||
}
|
||||
|
||||
const deletePost = async () => {
|
||||
@@ -670,7 +658,7 @@ export default {
|
||||
})
|
||||
if (res.ok) {
|
||||
toast.success('已删除')
|
||||
router.push('/')
|
||||
navigateTo('/', { replace: true })
|
||||
} else {
|
||||
toast.error('操作失败')
|
||||
}
|
||||
@@ -780,7 +768,7 @@ export default {
|
||||
}
|
||||
|
||||
const gotoProfile = () => {
|
||||
router.push(`/users/${author.value.id}`)
|
||||
navigateTo(`/users/${author.value.id}`, { replace: true })
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -794,66 +782,6 @@ export default {
|
||||
})
|
||||
|
||||
await fetchPost()
|
||||
|
||||
return {
|
||||
postContent,
|
||||
author,
|
||||
title,
|
||||
category,
|
||||
tags,
|
||||
comments,
|
||||
postTime,
|
||||
scrollerTopTime,
|
||||
lastReplyTime,
|
||||
postItems,
|
||||
mainContainer,
|
||||
currentIndex,
|
||||
totalPosts,
|
||||
postReactions,
|
||||
articleMenuItems,
|
||||
postId,
|
||||
postComment,
|
||||
onSliderInput,
|
||||
copyPostLink,
|
||||
subscribePost,
|
||||
unsubscribePost,
|
||||
joinLottery,
|
||||
renderMarkdown,
|
||||
isWaitingFetchingPost,
|
||||
isWaitingPostingComment,
|
||||
gotoProfile,
|
||||
gotoUser,
|
||||
subscribed,
|
||||
loggedIn,
|
||||
isAuthor,
|
||||
status,
|
||||
isAdmin,
|
||||
approvePost,
|
||||
editPost,
|
||||
onCommentDeleted,
|
||||
deletePost,
|
||||
pinPost,
|
||||
unpinPost,
|
||||
rejectPost,
|
||||
lightboxVisible,
|
||||
lightboxIndex,
|
||||
lightboxImgs,
|
||||
handleContentClick,
|
||||
isMobile,
|
||||
pinnedAt,
|
||||
commentSort,
|
||||
fetchCommentSorts,
|
||||
isFetchingComments,
|
||||
getMedalTitle,
|
||||
lottery,
|
||||
countdown,
|
||||
lotteryParticipants,
|
||||
lotteryWinners,
|
||||
lotteryEnded,
|
||||
hasJoined,
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.post-page-container {
|
||||
|
||||
@@ -64,95 +64,92 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import AvatarCropper from '~/components/AvatarCropper.vue'
|
||||
import BaseInput from '~/components/BaseInput.vue'
|
||||
import Dropdown from '~/components/Dropdown.vue'
|
||||
import { API_BASE_URL, toast } from '~/main'
|
||||
import { toast } from '~/main'
|
||||
import { fetchCurrentUser, getToken, setToken } from '~/utils/auth'
|
||||
export default {
|
||||
name: 'SettingsPageView',
|
||||
components: { BaseInput, Dropdown, AvatarCropper },
|
||||
data() {
|
||||
return {
|
||||
username: '',
|
||||
introduction: '',
|
||||
usernameError: '',
|
||||
avatar: '',
|
||||
avatarFile: null,
|
||||
tempAvatar: '',
|
||||
showCropper: false,
|
||||
role: '',
|
||||
publishMode: 'DIRECT',
|
||||
passwordStrength: 'LOW',
|
||||
aiFormatLimit: 3,
|
||||
registerMode: 'DIRECT',
|
||||
isLoadingPage: false,
|
||||
isSaving: false,
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
this.isLoadingPage = true
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
const username = ref('')
|
||||
const introduction = ref('')
|
||||
const usernameError = ref('')
|
||||
const avatar = ref('')
|
||||
const avatarFile = ref(null)
|
||||
const tempAvatar = ref('')
|
||||
const showCropper = ref(false)
|
||||
const role = ref('')
|
||||
const publishMode = ref('DIRECT')
|
||||
const passwordStrength = ref('LOW')
|
||||
const aiFormatLimit = ref(3)
|
||||
const registerMode = ref('DIRECT')
|
||||
const isLoadingPage = ref(false)
|
||||
const isSaving = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
isLoadingPage.value = true
|
||||
const user = await fetchCurrentUser()
|
||||
|
||||
if (user) {
|
||||
this.username = user.username
|
||||
this.introduction = user.introduction || ''
|
||||
this.avatar = user.avatar
|
||||
this.role = user.role
|
||||
if (this.role === 'ADMIN') {
|
||||
this.loadAdminConfig()
|
||||
username.value = user.username
|
||||
introduction.value = user.introduction || ''
|
||||
avatar.value = user.avatar
|
||||
role.value = user.role
|
||||
if (role.value === 'ADMIN') {
|
||||
loadAdminConfig()
|
||||
}
|
||||
} else {
|
||||
toast.error('请先登录')
|
||||
this.$router.push('/login')
|
||||
navigateTo('/login', { replace: true })
|
||||
}
|
||||
this.isLoadingPage = false
|
||||
},
|
||||
methods: {
|
||||
onAvatarChange(e) {
|
||||
isLoadingPage.value = false
|
||||
})
|
||||
|
||||
const onAvatarChange = (e) => {
|
||||
const file = e.target.files[0]
|
||||
if (file) {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
this.tempAvatar = reader.result
|
||||
this.showCropper = true
|
||||
tempAvatar.value = reader.result
|
||||
showCropper.value = true
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
},
|
||||
onCropped({ file, url }) {
|
||||
this.avatarFile = file
|
||||
this.avatar = url
|
||||
},
|
||||
fetchPublishModes() {
|
||||
}
|
||||
const onCropped = ({ file, url }) => {
|
||||
avatarFile.value = file
|
||||
avatar.value = url
|
||||
}
|
||||
const fetchPublishModes = () => {
|
||||
return Promise.resolve([
|
||||
{ id: 'DIRECT', name: '直接发布', icon: 'fas fa-bolt' },
|
||||
{ id: 'REVIEW', name: '审核后发布', icon: 'fas fa-search' },
|
||||
])
|
||||
},
|
||||
fetchPasswordStrengths() {
|
||||
}
|
||||
const fetchPasswordStrengths = () => {
|
||||
return Promise.resolve([
|
||||
{ id: 'LOW', name: '低', icon: 'fas fa-lock-open' },
|
||||
{ id: 'MEDIUM', name: '中', icon: 'fas fa-lock' },
|
||||
{ id: 'HIGH', name: '高', icon: 'fas fa-user-shield' },
|
||||
])
|
||||
},
|
||||
fetchAiLimits() {
|
||||
}
|
||||
const fetchAiLimits = () => {
|
||||
return Promise.resolve([
|
||||
{ id: 3, name: '3次' },
|
||||
{ id: 5, name: '5次' },
|
||||
{ id: 10, name: '10次' },
|
||||
{ id: -1, name: '无限' },
|
||||
])
|
||||
},
|
||||
fetchRegisterModes() {
|
||||
}
|
||||
const fetchRegisterModes = () => {
|
||||
return Promise.resolve([
|
||||
{ id: 'DIRECT', name: '直接注册', icon: 'fas fa-user-check' },
|
||||
{ id: 'WHITELIST', name: '白名单邀请制', icon: 'fas fa-envelope' },
|
||||
])
|
||||
},
|
||||
async loadAdminConfig() {
|
||||
}
|
||||
const loadAdminConfig = async () => {
|
||||
try {
|
||||
const token = getToken()
|
||||
const res = await fetch(`${API_BASE_URL}/api/admin/config`, {
|
||||
@@ -160,31 +157,31 @@ export default {
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
this.publishMode = data.publishMode
|
||||
this.passwordStrength = data.passwordStrength
|
||||
this.aiFormatLimit = data.aiFormatLimit
|
||||
this.registerMode = data.registerMode
|
||||
publishMode.value = data.publishMode
|
||||
passwordStrength.value = data.passwordStrength
|
||||
aiFormatLimit.value = data.aiFormatLimit
|
||||
registerMode.value = data.registerMode
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
async save() {
|
||||
this.isSaving = true
|
||||
}
|
||||
const save = async () => {
|
||||
isSaving.value = true
|
||||
|
||||
do {
|
||||
let token = getToken()
|
||||
this.usernameError = ''
|
||||
if (!this.username) {
|
||||
this.usernameError = '用户名不能为空'
|
||||
usernameError.value = ''
|
||||
if (!username.value) {
|
||||
usernameError.value = '用户名不能为空'
|
||||
}
|
||||
if (this.usernameError) {
|
||||
toast.error(this.usernameError)
|
||||
if (usernameError.value) {
|
||||
toast.error(usernameError.value)
|
||||
break
|
||||
}
|
||||
if (this.avatarFile) {
|
||||
if (avatarFile.value) {
|
||||
const form = new FormData()
|
||||
form.append('file', this.avatarFile)
|
||||
form.append('file', avatarFile.value)
|
||||
const res = await fetch(`${API_BASE_URL}/api/users/me/avatar`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
@@ -192,7 +189,7 @@ export default {
|
||||
})
|
||||
const data = await res.json()
|
||||
if (res.ok) {
|
||||
this.avatar = data.url
|
||||
avatar.value = data.url
|
||||
} else {
|
||||
toast.error(data.error || '上传失败')
|
||||
break
|
||||
@@ -201,7 +198,7 @@ export default {
|
||||
const res = await fetch(`${API_BASE_URL}/api/users/me`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ username: this.username, introduction: this.introduction }),
|
||||
body: JSON.stringify({ username: username.value, introduction: introduction.value }),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
@@ -213,24 +210,22 @@ export default {
|
||||
setToken(data.token)
|
||||
token = data.token
|
||||
}
|
||||
if (this.role === 'ADMIN') {
|
||||
if (role.value === 'ADMIN') {
|
||||
await fetch(`${API_BASE_URL}/api/admin/config`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({
|
||||
publishMode: this.publishMode,
|
||||
passwordStrength: this.passwordStrength,
|
||||
aiFormatLimit: this.aiFormatLimit,
|
||||
registerMode: this.registerMode,
|
||||
publishMode: publishMode.value,
|
||||
passwordStrength: passwordStrength.value,
|
||||
aiFormatLimit: aiFormatLimit.value,
|
||||
registerMode: registerMode.value,
|
||||
}),
|
||||
})
|
||||
}
|
||||
toast.success('保存成功')
|
||||
} while (!this.isSaving)
|
||||
} while (!isSaving.value)
|
||||
|
||||
this.isSaving = false
|
||||
},
|
||||
},
|
||||
isSaving.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -18,36 +18,32 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import BaseInput from '~/components/BaseInput.vue'
|
||||
import { API_BASE_URL, toast } from '~/main'
|
||||
import { toast } from '~/main'
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
export default {
|
||||
name: 'SignupReasonPageView',
|
||||
components: { BaseInput },
|
||||
data() {
|
||||
return {
|
||||
reason: '',
|
||||
error: '',
|
||||
isWaitingForRegister: false,
|
||||
token: '',
|
||||
const reason = ref('')
|
||||
const error = ref('')
|
||||
const isWaitingForRegister = ref(false)
|
||||
const token = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
token.value = route.query.token || ''
|
||||
if (!token.value) {
|
||||
await navigateTo({ path: '/signup' }, { replace: true })
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.token = this.$route.query.token || ''
|
||||
if (!this.token) {
|
||||
this.$router.push('/signup')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async submit() {
|
||||
if (!this.reason || this.reason.trim().length < 20) {
|
||||
this.error = '请至少输入20个字'
|
||||
})
|
||||
|
||||
const submit = async () => {
|
||||
if (!reason.value || reason.value.trim().length < 20) {
|
||||
error.value = '请至少输入20个字'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
this.isWaitingForRegister = true
|
||||
isWaitingForRegister.value = true
|
||||
const res = await fetch(`${API_BASE_URL}/api/auth/reason`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -58,23 +54,21 @@ export default {
|
||||
reason: this.reason,
|
||||
}),
|
||||
})
|
||||
this.isWaitingForRegister = false
|
||||
isWaitingForRegister.value = false
|
||||
const data = await res.json()
|
||||
if (res.ok) {
|
||||
toast.success('注册理由已提交,请等待审核')
|
||||
this.$router.push('/')
|
||||
await navigateTo('/', { replace: true })
|
||||
} else if (data.reason_code === 'INVALID_CREDENTIALS') {
|
||||
toast.error('登录已过期,请重新登录')
|
||||
this.$router.push('/login')
|
||||
await navigateTo('/login', { replace: true })
|
||||
} else {
|
||||
toast.error(data.error || '提交失败')
|
||||
}
|
||||
} catch (e) {
|
||||
this.isWaitingForRegister = false
|
||||
isWaitingForRegister.value = false
|
||||
toast.error('提交失败')
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -89,115 +89,110 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import BaseInput from '~/components/BaseInput.vue'
|
||||
import { API_BASE_URL, 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'
|
||||
export default {
|
||||
name: 'SignupPageView',
|
||||
components: { BaseInput },
|
||||
setup() {
|
||||
return { googleAuthorize }
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
emailStep: 0,
|
||||
email: '',
|
||||
username: '',
|
||||
password: '',
|
||||
registerMode: 'DIRECT',
|
||||
emailError: '',
|
||||
usernameError: '',
|
||||
passwordError: '',
|
||||
code: '',
|
||||
isWaitingForEmailSent: false,
|
||||
isWaitingForEmailVerified: false,
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
this.username = this.$route.query.u || ''
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
const emailStep = ref(0)
|
||||
const email = ref('')
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const registerMode = ref('DIRECT')
|
||||
const emailError = ref('')
|
||||
const usernameError = ref('')
|
||||
const passwordError = ref('')
|
||||
const code = ref('')
|
||||
const isWaitingForEmailSent = ref(false)
|
||||
const isWaitingForEmailVerified = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
username.value = route.query.u || ''
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/config`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
this.registerMode = data.registerMode
|
||||
registerMode.value = data.registerMode
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (this.$route.query.verify) {
|
||||
this.emailStep = 1
|
||||
if (route.query.verify) {
|
||||
emailStep.value = 1
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clearErrors() {
|
||||
this.emailError = ''
|
||||
this.usernameError = ''
|
||||
this.passwordError = ''
|
||||
},
|
||||
async sendVerification() {
|
||||
this.clearErrors()
|
||||
})
|
||||
|
||||
const clearErrors = () => {
|
||||
emailError.value = ''
|
||||
usernameError.value = ''
|
||||
passwordError.value = ''
|
||||
}
|
||||
|
||||
const sendVerification = async () => {
|
||||
clearErrors()
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
if (!emailRegex.test(this.email)) {
|
||||
this.emailError = '邮箱格式不正确'
|
||||
if (!emailRegex.test(email.value)) {
|
||||
emailError.value = '邮箱格式不正确'
|
||||
}
|
||||
if (!this.password || this.password.length < 6) {
|
||||
this.passwordError = '密码至少6位'
|
||||
if (!password.value || password.value.length < 6) {
|
||||
passwordError.value = '密码至少6位'
|
||||
}
|
||||
if (!this.username) {
|
||||
this.usernameError = '用户名不能为空'
|
||||
if (!username.value) {
|
||||
usernameError.value = '用户名不能为空'
|
||||
}
|
||||
if (this.emailError || this.passwordError || this.usernameError) {
|
||||
if (emailError.value || passwordError.value || usernameError.value) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
this.isWaitingForEmailSent = true
|
||||
isWaitingForEmailSent.value = true
|
||||
const res = await fetch(`${API_BASE_URL}/api/auth/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username: this.username,
|
||||
email: this.email,
|
||||
password: this.password,
|
||||
username: username.value,
|
||||
email: email.value,
|
||||
password: password.value,
|
||||
}),
|
||||
})
|
||||
this.isWaitingForEmailSent = false
|
||||
isWaitingForEmailSent.value = false
|
||||
const data = await res.json()
|
||||
if (res.ok) {
|
||||
this.emailStep = 1
|
||||
emailStep.value = 1
|
||||
toast.success('验证码已发送,请查看邮箱')
|
||||
} else if (data.field) {
|
||||
if (data.field === 'username') this.usernameError = data.error
|
||||
if (data.field === 'email') this.emailError = data.error
|
||||
if (data.field === 'password') this.passwordError = data.error
|
||||
if (data.field === 'username') usernameError.value = data.error
|
||||
if (data.field === 'email') emailError.value = data.error
|
||||
if (data.field === 'password') passwordError.value = data.error
|
||||
} else {
|
||||
toast.error(data.error || '发送失败')
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error('发送失败')
|
||||
}
|
||||
},
|
||||
async verifyCode() {
|
||||
}
|
||||
|
||||
const verifyCode = async () => {
|
||||
try {
|
||||
this.isWaitingForEmailVerified = true
|
||||
isWaitingForEmailVerified.value = true
|
||||
const res = await fetch(`${API_BASE_URL}/api/auth/verify`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
code: this.code,
|
||||
username: this.username,
|
||||
code: code.value,
|
||||
username: username.value,
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (res.ok) {
|
||||
if (this.registerMode === 'WHITELIST') {
|
||||
this.$router.push('/signup-reason?token=' + data.token)
|
||||
if (registerMode.value === 'WHITELIST') {
|
||||
navigateTo(`/signup-reason?token=${data.token}`, { replace: true })
|
||||
} else {
|
||||
toast.success('注册成功,请登录')
|
||||
this.$router.push('/login')
|
||||
navigateTo('/login', { replace: true })
|
||||
}
|
||||
} else {
|
||||
toast.error(data.error || '注册失败')
|
||||
@@ -205,19 +200,17 @@ export default {
|
||||
} catch (e) {
|
||||
toast.error('注册失败')
|
||||
} finally {
|
||||
this.isWaitingForEmailVerified = false
|
||||
isWaitingForEmailVerified.value = false
|
||||
}
|
||||
},
|
||||
signupWithGithub() {
|
||||
}
|
||||
const signupWithGithub = () => {
|
||||
githubAuthorize()
|
||||
},
|
||||
signupWithDiscord() {
|
||||
}
|
||||
const signupWithDiscord = () => {
|
||||
discordAuthorize()
|
||||
},
|
||||
signupWithTwitter() {
|
||||
}
|
||||
const signupWithTwitter = () => {
|
||||
twitterAuthorize()
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -2,24 +2,20 @@
|
||||
<CallbackPage />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import CallbackPage from '~/components/CallbackPage.vue'
|
||||
import { twitterExchange } from '~/utils/twitter'
|
||||
|
||||
export default {
|
||||
name: 'TwitterCallbackPageView',
|
||||
components: { CallbackPage },
|
||||
async mounted() {
|
||||
onMounted(async () => {
|
||||
const url = new URL(window.location.href)
|
||||
const code = url.searchParams.get('code')
|
||||
const state = url.searchParams.get('state')
|
||||
const result = await twitterExchange(code, state, '')
|
||||
|
||||
if (result.needReason) {
|
||||
this.$router.push('/signup-reason?token=' + result.token)
|
||||
navigateTo(`/signup-reason?token=${result.token}`, { replace: true })
|
||||
} else {
|
||||
this.$router.push('/')
|
||||
}
|
||||
},
|
||||
navigateTo('/', { replace: true })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -296,7 +296,7 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import AchievementList from '~/components/AchievementList.vue'
|
||||
@@ -304,16 +304,14 @@ import BasePlaceholder from '~/components/BasePlaceholder.vue'
|
||||
import BaseTimeline from '~/components/BaseTimeline.vue'
|
||||
import LevelProgress from '~/components/LevelProgress.vue'
|
||||
import UserList from '~/components/UserList.vue'
|
||||
import { API_BASE_URL, toast } from '~/main'
|
||||
import { toast } from '~/main'
|
||||
import { authState, getToken } from '~/utils/auth'
|
||||
import { prevLevelExp } from '~/utils/level'
|
||||
import { stripMarkdown, stripMarkdownLength } from '~/utils/markdown'
|
||||
import TimeManager from '~/utils/time'
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
export default {
|
||||
name: 'ProfileView',
|
||||
components: { BaseTimeline, UserList, BasePlaceholder, LevelProgress, AchievementList },
|
||||
setup() {
|
||||
definePageMeta({
|
||||
alias: ['/users/:id/'],
|
||||
})
|
||||
@@ -508,7 +506,7 @@ export default {
|
||||
|
||||
const gotoTag = (tag) => {
|
||||
const value = encodeURIComponent(tag.id ?? tag.name)
|
||||
router.push({ path: '/', query: { tags: value } })
|
||||
navigateTo({ path: '/', query: { tags: value } }, { replace: true })
|
||||
}
|
||||
|
||||
const init = async () => {
|
||||
@@ -536,46 +534,12 @@ export default {
|
||||
// router.replace({ query: { ...route.query, tab: val } })
|
||||
if (val === 'timeline' && timelineItems.value.length === 0) {
|
||||
await loadTimeline()
|
||||
} else if (
|
||||
val === 'following' &&
|
||||
followers.value.length === 0 &&
|
||||
followings.value.length === 0
|
||||
) {
|
||||
} else if (val === 'following' && followers.value.length === 0 && followings.value.length === 0) {
|
||||
await loadFollow()
|
||||
} else if (val === 'achievements' && medals.value.length === 0) {
|
||||
await loadAchievements()
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
user,
|
||||
hotPosts,
|
||||
hotReplies,
|
||||
timelineItems,
|
||||
followers,
|
||||
followings,
|
||||
medals,
|
||||
subscribed,
|
||||
isMine,
|
||||
isLoading,
|
||||
tabLoading,
|
||||
selectedTab,
|
||||
followTab,
|
||||
formatDate,
|
||||
stripMarkdown,
|
||||
stripMarkdownLength,
|
||||
loadTimeline,
|
||||
loadFollow,
|
||||
loadAchievements,
|
||||
loadSummary,
|
||||
subscribeUser,
|
||||
unsubscribeUser,
|
||||
gotoTag,
|
||||
hotTags,
|
||||
levelInfo,
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { defineNuxtPlugin } from 'nuxt/app'
|
||||
import ClickOutside from '~/directives/clickOutside.js'
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,7 +0,0 @@
|
||||
export default {
|
||||
push(path) {
|
||||
if (process.client) {
|
||||
window.location.href = path
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { API_BASE_URL } from '~/main'
|
||||
import { reactive } from 'vue'
|
||||
|
||||
const TOKEN_KEY = 'token'
|
||||
@@ -65,6 +64,8 @@ export function clearUserInfo() {
|
||||
}
|
||||
|
||||
export async function fetchCurrentUser() {
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
const token = getToken()
|
||||
if (!token) return null
|
||||
try {
|
||||
@@ -91,6 +92,8 @@ export function isLogin() {
|
||||
}
|
||||
|
||||
export async function checkToken() {
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
const token = getToken()
|
||||
if (!token) return false
|
||||
try {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { API_BASE_URL, DISCORD_CLIENT_ID, toast } from '../main'
|
||||
import { WEBSITE_BASE_URL } from '../constants'
|
||||
import { toast } from '../main'
|
||||
import { setToken, loadCurrentUser } from './auth'
|
||||
import { registerPush } from './push'
|
||||
|
||||
export function discordAuthorize(state = '') {
|
||||
const config = useRuntimeConfig()
|
||||
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
|
||||
const DISCORD_CLIENT_ID = config.public.discordClientId
|
||||
if (!DISCORD_CLIENT_ID) {
|
||||
toast.error('Discord 登录不可用')
|
||||
return
|
||||
@@ -15,6 +17,8 @@ export function discordAuthorize(state = '') {
|
||||
|
||||
export async function discordExchange(code, state, reason) {
|
||||
try {
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
const res = await fetch(`${API_BASE_URL}/api/auth/discord`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { API_BASE_URL, GITHUB_CLIENT_ID, toast } from '../main'
|
||||
import { toast } from '../main'
|
||||
import { setToken, loadCurrentUser } from './auth'
|
||||
import { WEBSITE_BASE_URL } from '../constants'
|
||||
import { registerPush } from './push'
|
||||
|
||||
export function githubAuthorize(state = '') {
|
||||
const config = useRuntimeConfig()
|
||||
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
|
||||
const GITHUB_CLIENT_ID = config.public.githubClientId
|
||||
if (!GITHUB_CLIENT_ID) {
|
||||
toast.error('GitHub 登录不可用')
|
||||
return
|
||||
@@ -15,6 +17,8 @@ export function githubAuthorize(state = '') {
|
||||
|
||||
export async function githubExchange(code, state, reason) {
|
||||
try {
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
const res = await fetch(`${API_BASE_URL}/api/auth/github`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { API_BASE_URL, GOOGLE_CLIENT_ID, toast } from '../main'
|
||||
import { toast } from '../main'
|
||||
import { setToken, loadCurrentUser } from './auth'
|
||||
import { registerPush } from './push'
|
||||
import { WEBSITE_BASE_URL } from '../constants'
|
||||
|
||||
export async function googleGetIdToken() {
|
||||
const config = useRuntimeConfig()
|
||||
const GOOGLE_CLIENT_ID = config.public.googleClientId
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!window.google || !GOOGLE_CLIENT_ID) {
|
||||
toast.error('Google 登录不可用, 请检查网络设置与VPN')
|
||||
@@ -20,6 +22,8 @@ export async function googleGetIdToken() {
|
||||
}
|
||||
|
||||
export function googleAuthorize() {
|
||||
const config = useRuntimeConfig()
|
||||
const GOOGLE_CLIENT_ID = config.public.googleClientId
|
||||
if (!GOOGLE_CLIENT_ID) {
|
||||
toast.error('Google 登录不可用, 请检查网络设置与VPN')
|
||||
return
|
||||
@@ -32,6 +36,8 @@ export function googleAuthorize() {
|
||||
|
||||
export async function googleAuthWithToken(idToken, redirect_success, redirect_not_approved) {
|
||||
try {
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
const res = await fetch(`${API_BASE_URL}/api/auth/google`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -65,15 +71,13 @@ export async function googleSignIn(redirect_success, redirect_not_approved) {
|
||||
}
|
||||
}
|
||||
|
||||
import router from '../router'
|
||||
|
||||
export function loginWithGoogle() {
|
||||
googleSignIn(
|
||||
() => {
|
||||
router.push('/')
|
||||
navigateTo('/', { replace: true })
|
||||
},
|
||||
(token) => {
|
||||
router.push('/signup-reason?token=' + token)
|
||||
navigateTo(`/signup-reason?token=${token}`, { replace: true })
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import hljs from 'highlight.js'
|
||||
import 'highlight.js/styles/github.css'
|
||||
if (typeof window !== 'undefined') {
|
||||
const theme =
|
||||
document.documentElement.dataset.theme ||
|
||||
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
|
||||
if (theme === 'dark') {
|
||||
import('highlight.js/styles/atom-one-dark.css')
|
||||
} else {
|
||||
import('highlight.js/styles/atom-one-light.css')
|
||||
}
|
||||
}
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import { toast } from '../main'
|
||||
import { tiebaEmoji } from './tiebaEmoji'
|
||||
@@ -50,6 +59,31 @@ function tiebaEmojiPlugin(md) {
|
||||
})
|
||||
}
|
||||
|
||||
// 链接在新窗口打开
|
||||
function linkPlugin(md) {
|
||||
const defaultRender =
|
||||
md.renderer.rules.link_open ||
|
||||
function (tokens, idx, options, env, self) {
|
||||
return self.renderToken(tokens, idx, options)
|
||||
}
|
||||
|
||||
md.renderer.rules.link_open = function (tokens, idx, options, env, self) {
|
||||
const token = tokens[idx]
|
||||
const hrefIndex = token.attrIndex('href')
|
||||
|
||||
if (hrefIndex >= 0) {
|
||||
const href = token.attrs[hrefIndex][1]
|
||||
// 如果是外部链接,添加 target="_blank" 和 rel="noopener noreferrer"
|
||||
if (href.startsWith('http://') || href.startsWith('https://')) {
|
||||
token.attrPush(['target', '_blank'])
|
||||
token.attrPush(['rel', 'noopener noreferrer'])
|
||||
}
|
||||
}
|
||||
|
||||
return defaultRender(tokens, idx, options, env, self)
|
||||
}
|
||||
}
|
||||
|
||||
const md = new MarkdownIt({
|
||||
html: false,
|
||||
linkify: true,
|
||||
@@ -61,12 +95,17 @@ const md = new MarkdownIt({
|
||||
} else {
|
||||
code = hljs.highlightAuto(str).value
|
||||
}
|
||||
return `<pre class="code-block"><button class="copy-code-btn">Copy</button><code class="hljs language-${lang || ''}">${code}</code></pre>`
|
||||
const lineNumbers = code
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map(() => `<div class="line-number"></div>`)
|
||||
return `<pre class="code-block"><button class="copy-code-btn">Copy</button><div class="line-numbers">${lineNumbers.join('')}</div><code class="hljs language-${lang || ''}">${code.trim()}</code></pre>`
|
||||
},
|
||||
})
|
||||
|
||||
md.use(mentionPlugin)
|
||||
md.use(tiebaEmojiPlugin)
|
||||
md.use(linkPlugin) // 添加链接插件
|
||||
|
||||
export function renderMarkdown(text) {
|
||||
return md.render(text || '')
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { API_BASE_URL } from '~/main'
|
||||
import { getToken } from './auth'
|
||||
import { reactive } from 'vue'
|
||||
|
||||
@@ -7,6 +6,8 @@ export const notificationState = reactive({
|
||||
})
|
||||
|
||||
export async function fetchUnreadCount() {
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
try {
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
@@ -31,6 +32,9 @@ export async function fetchUnreadCount() {
|
||||
|
||||
export async function markNotificationsRead(ids) {
|
||||
try {
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
const token = getToken()
|
||||
if (!token || !ids || ids.length === 0) return false
|
||||
const res = await fetch(`${API_BASE_URL}/api/notifications/read`, {
|
||||
@@ -49,6 +53,9 @@ export async function markNotificationsRead(ids) {
|
||||
|
||||
export async function fetchNotificationPreferences() {
|
||||
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/prefs`, {
|
||||
@@ -63,6 +70,8 @@ export async function fetchNotificationPreferences() {
|
||||
|
||||
export async function updateNotificationPreference(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/prefs`, {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { API_BASE_URL } from '../main'
|
||||
import { getToken } from './auth'
|
||||
|
||||
function urlBase64ToUint8Array(base64String) {
|
||||
@@ -21,6 +20,8 @@ function arrayBufferToBase64(buffer) {
|
||||
|
||||
export async function registerPush() {
|
||||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.register('/notifications-sw.js')
|
||||
const res = await fetch(`${API_BASE_URL}/api/push/public-key`)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { API_BASE_URL, TWITTER_CLIENT_ID, toast } from '../main'
|
||||
import { WEBSITE_BASE_URL } from '../constants'
|
||||
import { toast } from '../main'
|
||||
import { setToken, loadCurrentUser } from './auth'
|
||||
import { registerPush } from './push'
|
||||
|
||||
@@ -22,6 +21,9 @@ async function generateCodeChallenge(codeVerifier) {
|
||||
}
|
||||
|
||||
export async function twitterAuthorize(state = '') {
|
||||
const config = useRuntimeConfig()
|
||||
const WEBSITE_BASE_URL = config.public.websiteBaseUrl
|
||||
const TWITTER_CLIENT_ID = config.public.twitterClientId
|
||||
if (!TWITTER_CLIENT_ID) {
|
||||
toast.error('Twitter 登录不可用')
|
||||
return
|
||||
@@ -42,6 +44,8 @@ export async function twitterAuthorize(state = '') {
|
||||
|
||||
export async function twitterExchange(code, state, reason) {
|
||||
try {
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
const codeVerifier = sessionStorage.getItem('twitter_code_verifier')
|
||||
sessionStorage.removeItem('twitter_code_verifier')
|
||||
const res = await fetch(`${API_BASE_URL}/api/auth/twitter`, {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { API_BASE_URL } from '../main'
|
||||
|
||||
export async function fetchFollowings(username) {
|
||||
if (!username) return []
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/users/${username}/following`)
|
||||
return res.ok ? await res.json() : []
|
||||
@@ -11,6 +11,8 @@ export async function fetchFollowings(username) {
|
||||
}
|
||||
|
||||
export async function fetchAdmins() {
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/users/admins`)
|
||||
return res.ok ? await res.json() : []
|
||||
@@ -21,6 +23,8 @@ export async function fetchAdmins() {
|
||||
|
||||
export async function searchUsers(keyword) {
|
||||
if (!keyword) return []
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/search/users?keyword=${encodeURIComponent(keyword)}`,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import Vditor from 'vditor'
|
||||
import { API_BASE_URL } from '../main'
|
||||
import { getToken, authState } from './auth'
|
||||
import { searchUsers, fetchFollowings, fetchAdmins } from './user'
|
||||
import { tiebaEmoji } from './tiebaEmoji'
|
||||
@@ -14,6 +13,8 @@ export function getPreviewTheme() {
|
||||
|
||||
export function createVditor(editorId, options = {}) {
|
||||
const { placeholder = '', preview = {}, input, after } = options
|
||||
const config = useRuntimeConfig()
|
||||
const API_BASE_URL = config.public.apiBaseUrl
|
||||
|
||||
const fetchMentions = async (value) => {
|
||||
if (!value) {
|
||||
|
||||
Reference in New Issue
Block a user