Compare commits

...

38 Commits

Author SHA1 Message Date
Tim
22693bfdd9 fix: 首屏ssr优化 2025-08-14 14:25:38 +08:00
Tim
304d941d68 Revert "fix: use home path"
This reverts commit 2efe4e733a.
2025-08-14 13:50:58 +08:00
Tim
2efe4e733a fix: use home path 2025-08-14 13:45:29 +08:00
Tim
cb49dc9b73 fix: 首屏ssr优化 2025-08-14 13:39:25 +08:00
Tim
1dc13698ad fix: 首屏ssr优化 2025-08-14 13:22:53 +08:00
Tim
d58432dcd9 Merge pull request #540 from nagisa77/codex/fix-logo-click-triggering-window.reload 2025-08-14 12:47:43 +08:00
Tim
e7ff73c7f9 fix: prevent header logo from triggering page reload 2025-08-14 12:47:26 +08:00
Tim
4ee9532d5f Merge pull request #539 from nagisa77/codex/fix-logo-click-reload-issue 2025-08-14 12:38:11 +08:00
Tim
80c3fd8ea2 fix: prevent homepage reload on logo click 2025-08-14 12:37:54 +08:00
Tim
7e277d06d5 Merge pull request #538 from nagisa77/feature/first_screen
fix: 首屏幕ssr优化
2025-08-14 12:29:58 +08:00
Tim
d2b68119bd fix: 首屏幕ssr优化 2025-08-14 12:29:08 +08:00
Tim
f7b0d7edd5 Merge pull request #537 from nagisa77/feature/first_screen
fix: 首屏幕ssr优化
2025-08-14 11:56:26 +08:00
Tim
cdea1ab911 fix: 首屏幕ssr优化 2025-08-14 11:55:39 +08:00
Tim
ada6bfb5cf Merge pull request #536 from nagisa77/codex/add-logo-click-to-refresh-homepage
feat: refresh home when clicking header logo
2025-08-14 11:00:37 +08:00
Tim
928dbd73b5 feat: allow logo to refresh home page 2025-08-14 11:00:17 +08:00
Tim
8c1a7afc6e Merge pull request #530 from nagisa77/feature/env
fix: 前后端代码域名hardcode调整(for预发环境做准备)
2025-08-14 10:38:49 +08:00
Tim
87453f7198 fix: add .env.example 2025-08-14 10:36:02 +08:00
Tim
48e3593ef9 Merge remote-tracking branch 'origin/main' into feature/env 2025-08-14 10:34:10 +08:00
Tim
655e8f2a65 fix: setup 迁移完成 v1 2025-08-14 10:27:01 +08:00
Tim
7a0afedc7c Merge pull request #533 from CH-122/feat/link 2025-08-13 18:12:34 +08:00
Tim
902fce5174 fix: setup 迁移完成 2025-08-13 17:59:38 +08:00
Tim
0034839e8d fix: 迁移部分页面为setup 2025-08-13 17:49:51 +08:00
CH-122
148fd36fd1 Merge branch 'main' into feat/link 2025-08-13 17:48:23 +08:00
Tim
06cd663eaf Merge pull request #532 from nagisa77/codex/add-comment-pinning-feature
feat: support comment pinning
2025-08-13 16:31:12 +08:00
Tim
0edbeabac2 feat: allow post authors to pin comments 2025-08-13 16:30:48 +08:00
Tim
65cc3ee58b Merge pull request #531 from nagisa77/codex/add-post-lottery-notification-to-author 2025-08-13 16:20:09 +08:00
Tim
6965fcfb7f feat: notify lottery author 2025-08-13 16:19:53 +08:00
Tim
40520c30ec Merge pull request #529 from nagisa77/codex/refactor-to-use-environment-variables
feat: move API and OAuth IDs to runtime config
2025-08-13 16:01:07 +08:00
Tim
5d7ca3d29a feat: use runtime config for API and OAuth client IDs 2025-08-13 16:00:26 +08:00
Tim
a3aec1133b Merge pull request #528 from nagisa77/codex/add-new-prize-notification-type
feat: add lottery win notification
2025-08-13 15:58:33 +08:00
Tim
8fa715477b feat: add lottery win notification 2025-08-13 15:57:59 +08:00
CH-122
9209ebea4c feat: 添加链接插件以支持外部链接在新窗口打开 2025-08-13 15:40:40 +08:00
Tim
47a9ce5843 fix: 后端取消网址hardcode 2025-08-13 14:02:32 +08:00
Tim
dfef13e2be Merge pull request #520 from AnNingUI/main
fix: 清理掉了大部分warn,优化了在移动端侧边栏的逻辑问题
2025-08-12 21:45:46 +08:00
AnNingUI
2f4d6e68da fix: 用传递menuBtn的ref代替手动查询dom的方式 2025-08-12 21:26:24 +08:00
AnNingUI
414872f61e fix: 解决tag与类别切换需要reload整个页面的bug 2025-08-12 20:42:31 +08:00
AnNingUI
82475f71db fix: 清理掉了所有warn,优化了在移动端侧边栏的逻辑问题 2025-08-12 20:36:00 +08:00
Tim
a6874e9be3 Merge pull request #512 from nagisa77/feature/message_control
feat: message control
2025-08-12 17:45:17 +08:00
72 changed files with 3496 additions and 3656 deletions

View File

@@ -41,7 +41,7 @@ public class SecurityConfig {
private final UserRepository userRepository; private final UserRepository userRepository;
private final AccessDeniedHandler customAccessDeniedHandler; private final AccessDeniedHandler customAccessDeniedHandler;
private final UserVisitService userVisitService; private final UserVisitService userVisitService;
@Value("${app.website-url:https://www.open-isle.com}") @Value("${app.website-url}")
private String websiteUrl; private String websiteUrl;
@Bean @Bean

View File

@@ -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));
}
}

View File

@@ -18,7 +18,7 @@ public class AdminUserController {
private final UserRepository userRepository; private final UserRepository userRepository;
private final NotificationRepository notificationRepository; private final NotificationRepository notificationRepository;
private final EmailSender emailSender; private final EmailSender emailSender;
@Value("${app.website-url:https://www.open-isle.com}") @Value("${app.website-url}")
private String websiteUrl; private String websiteUrl;
@PostMapping("/{id}/approve") @PostMapping("/{id}/approve")

View File

@@ -85,4 +85,16 @@ public class CommentController {
commentService.deleteComment(auth.getName(), id); commentService.deleteComment(auth.getName(), id);
log.debug("deleteComment completed for comment {}", 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));
}
} }

View File

@@ -22,7 +22,7 @@ import java.util.List;
public class SitemapController { public class SitemapController {
private final PostRepository postRepository; private final PostRepository postRepository;
@Value("${app.website-url:https://www.open-isle.com}") @Value("${app.website-url}")
private String websiteUrl; private String websiteUrl;
@GetMapping(value = "/sitemap.xml", produces = MediaType.APPLICATION_XML_VALUE) @GetMapping(value = "/sitemap.xml", produces = MediaType.APPLICATION_XML_VALUE)

View File

@@ -13,6 +13,7 @@ public class CommentDto {
private Long id; private Long id;
private String content; private String content;
private LocalDateTime createdAt; private LocalDateTime createdAt;
private LocalDateTime pinnedAt;
private AuthorDto author; private AuthorDto author;
private List<CommentDto> replies; private List<CommentDto> replies;
private List<ReactionDto> reactions; private List<ReactionDto> reactions;

View File

@@ -24,6 +24,7 @@ public class CommentMapper {
dto.setId(comment.getId()); dto.setId(comment.getId());
dto.setContent(comment.getContent()); dto.setContent(comment.getContent());
dto.setCreatedAt(comment.getCreatedAt()); dto.setCreatedAt(comment.getCreatedAt());
dto.setPinnedAt(comment.getPinnedAt());
dto.setAuthor(userMapper.toAuthorDto(comment.getAuthor())); dto.setAuthor(userMapper.toAuthorDto(comment.getAuthor()));
dto.setReward(0); dto.setReward(0);
return dto; return dto;

View File

@@ -38,4 +38,7 @@ public class Comment {
@JoinColumn(name = "parent_id") @JoinColumn(name = "parent_id")
private Comment parent; private Comment parent;
@Column
private LocalDateTime pinnedAt;
} }

View File

@@ -32,6 +32,10 @@ public enum NotificationType {
REGISTER_REQUEST, REGISTER_REQUEST,
/** A user redeemed an activity reward */ /** A user redeemed an activity reward */
ACTIVITY_REDEEM, ACTIVITY_REDEEM,
/** You won a lottery post */
LOTTERY_WIN,
/** Your lottery post was drawn */
LOTTERY_DRAW,
/** You were mentioned in a post or comment */ /** You were mentioned in a post or comment */
MENTION MENTION
} }

View File

@@ -23,6 +23,7 @@ import java.util.List;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -129,13 +130,26 @@ public class CommentService {
Post post = postRepository.findById(postId) Post post = postRepository.findById(postId)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
List<Comment> list = commentRepository.findByPostAndParentIsNullOrderByCreatedAtAsc(post); List<Comment> list = commentRepository.findByPostAndParentIsNullOrderByCreatedAtAsc(post);
if (sort == CommentSort.NEWEST) { java.util.List<Comment> pinned = new java.util.ArrayList<>();
list.sort(java.util.Comparator.comparing(Comment::getCreatedAt).reversed()); java.util.List<Comment> others = new java.util.ArrayList<>();
} else if (sort == CommentSort.MOST_INTERACTIONS) { for (Comment c : list) {
list.sort((a, b) -> Integer.compare(interactionCount(b), interactionCount(a))); 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) { public List<Comment> getReplies(Long parentId) {
@@ -223,6 +237,32 @@ public class CommentService {
log.debug("deleteCommentCascade removed comment {}", comment.getId()); 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) { private int interactionCount(Comment comment) {
int reactions = reactionRepository.findByComment(comment).size(); int reactions = reactionRepository.findByComment(comment).size();
int replies = commentRepository.findByParentOrderByCreatedAtAsc(comment).size(); int replies = commentRepository.findByParentOrderByCreatedAtAsc(comment).size();

View File

@@ -36,7 +36,7 @@ public class NotificationService {
private final ReactionRepository reactionRepository; private final ReactionRepository reactionRepository;
private final Executor notificationExecutor; private final Executor notificationExecutor;
@Value("${app.website-url:https://www.open-isle.com}") @Value("${app.website-url}")
private String websiteUrl; private String websiteUrl;
private static final Pattern MENTION_PATTERN = Pattern.compile("@\\[([^\\]]+)\\]"); private static final Pattern MENTION_PATTERN = Pattern.compile("@\\[([^\\]]+)\\]");

View File

@@ -69,6 +69,8 @@ public class PostService {
private final EmailSender emailSender; private final EmailSender emailSender;
private final ApplicationContext applicationContext; private final ApplicationContext applicationContext;
private final ConcurrentMap<Long, ScheduledFuture<?>> scheduledFinalizations = new ConcurrentHashMap<>(); 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 @org.springframework.beans.factory.annotation.Autowired
public PostService(PostRepository postRepository, public PostService(PostRepository postRepository,
@@ -249,6 +251,15 @@ public class PostService {
if (w.getEmail() != null) { if (w.getEmail() != null) {
emailSender.sendEmail(w.getEmail(), "你中奖了", "恭喜你在抽奖贴 \"" + lp.getTitle() + "\" 中获奖"); emailSender.sendEmail(w.getEmail(), "你中奖了", "恭喜你在抽奖贴 \"" + lp.getTitle() + "\" 中获奖");
} }
notificationService.createNotification(w, NotificationType.LOTTERY_WIN, lp, null, null, lp.getAuthor(), null, null);
notificationService.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()));
} }
}); });
} }

View File

@@ -27,7 +27,7 @@ public class ReactionService {
private final NotificationService notificationService; private final NotificationService notificationService;
private final EmailSender emailSender; private final EmailSender emailSender;
@Value("${app.website-url:https://www.open-isle.com}") @Value("${app.website-url}")
private String websiteUrl; private String websiteUrl;
@Transactional @Transactional

View File

@@ -0,0 +1 @@
ALTER TABLE comments ADD COLUMN pinned_at DATETIME(6) NULL;

View File

@@ -93,4 +93,50 @@ class PostServiceTest {
() -> service.createPost("alice", 1L, "t", "c", List.of(1L), () -> service.createPost("alice", 1L, "t", "c", List.of(1L),
null, null, null, null, null, null)); null, null, null, null, null, null));
} }
@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());
}
} }

View File

@@ -0,0 +1,6 @@
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
NUXT_PUBLIC_TWITTER_CLIENT_ID=ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ

View File

@@ -2,3 +2,4 @@ node_modules
.nuxt .nuxt
dist dist
.output .output
.env

View File

@@ -1,7 +1,11 @@
<template> <template>
<div id="app"> <div id="app">
<div class="header-container"> <div class="header-container">
<HeaderComponent @toggle-menu="menuVisible = !menuVisible" :show-menu-btn="!hideMenu" /> <HeaderComponent
ref="header"
@toggle-menu="menuVisible = !menuVisible"
:show-menu-btn="!hideMenu"
/>
</div> </div>
<div class="main-container"> <div class="main-container">
@@ -42,17 +46,26 @@ export default {
].includes(useRoute().path) ].includes(useRoute().path)
}) })
const header = useTemplateRef('header')
onMounted(() => { onMounted(() => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
menuVisible.value = window.innerWidth > 768 menuVisible.value = window.innerWidth > 768
} }
}) })
const handleMenuOutside = () => { const handleMenuOutside = (event) => {
if (isMobile.value) menuVisible.value = false const btn = header.value.$refs.menuBtn
if (btn && (btn === event.target || btn.contains(event.target))) {
return // 如果是菜单按钮的点击,不处理关闭
} }
return { menuVisible, hideMenu, handleMenuOutside } if (isMobile.value) {
menuVisible.value = false
}
}
return { menuVisible, hideMenu, handleMenuOutside, header }
}, },
} }
</script> </script>

View File

@@ -37,8 +37,10 @@
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import { API_BASE_URL, toast } from '~/main' import { toast } from '~/main'
import { getToken } from '~/utils/auth' import { getToken } from '~/utils/auth'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const props = defineProps({ const props = defineProps({
medals: { medals: {

View File

@@ -11,29 +11,20 @@
</BasePopup> </BasePopup>
</template> </template>
<script> <script setup>
import BasePopup from '~/components/BasePopup.vue' import BasePopup from '~/components/BasePopup.vue'
import { useRouter } from 'vue-router'
export default { const props = defineProps({
name: 'ActivityPopup',
components: { BasePopup },
props: {
visible: { type: Boolean, default: false }, visible: { type: Boolean, default: false },
icon: String, icon: String,
text: String, text: String,
}, })
emits: ['close'], const emit = defineEmits(['close'])
setup(props, { emit }) { const gotoActivity = async () => {
const router = useRouter()
const gotoActivity = () => {
emit('close') emit('close')
router.push('/activities') await navigateTo('/activities', { replace: true })
} }
const close = () => emit('close') const close = () => emit('close')
return { gotoActivity, close }
},
}
</script> </script>
<style scoped> <style scoped>

View File

@@ -12,25 +12,15 @@
</div> </div>
</template> </template>
<script> <script setup>
import { useRouter } from 'vue-router' const props = defineProps({
export default {
name: 'ArticleCategory',
props: {
category: { type: Object, default: null }, category: { type: Object, default: null },
}, })
setup(props) {
const router = useRouter() const gotoCategory = async () => {
const gotoCategory = () => {
if (!props.category) return if (!props.category) return
const value = encodeURIComponent(props.category.id ?? props.category.name) const value = encodeURIComponent(props.category.id ?? props.category.name)
router.push({ path: '/', query: { category: value } }).then(() => { await navigateTo({ path: '/', query: { category: value } }, { replace: true })
window.location.reload()
})
}
return { gotoCategory }
},
} }
</script> </script>

View File

@@ -17,24 +17,14 @@
</div> </div>
</template> </template>
<script> <script setup>
import { useRouter } from 'vue-router' defineProps({
export default {
name: 'ArticleTags',
props: {
tags: { type: Array, default: () => [] }, 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> </script>

View File

@@ -26,22 +26,20 @@
</Dropdown> </Dropdown>
</template> </template>
<script> <script setup>
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { API_BASE_URL } from '~/main'
import Dropdown from '~/components/Dropdown.vue' import Dropdown from '~/components/Dropdown.vue'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
export default { const props = defineProps({
name: 'CategorySelect',
components: { Dropdown },
props: {
modelValue: { type: [String, Number], default: '' }, modelValue: { type: [String, Number], default: '' },
options: { type: Array, 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( watch(
() => props.options, () => props.options,
(val) => { (val) => {
@@ -65,10 +63,6 @@ export default {
get: () => props.modelValue, get: () => props.modelValue,
set: (v) => emit('update:modelValue', v), set: (v) => emit('update:modelValue', v),
}) })
return { fetchCategories, selected, isImageIcon, providedOptions }
},
}
</script> </script>
<style scoped> <style scoped>

View File

@@ -22,6 +22,7 @@
:to="`/users/${comment.userId}?tab=achievements`" :to="`/users/${comment.userId}?tab=achievements`"
>{{ getMedalTitle(comment.medal) }}</router-link >{{ getMedalTitle(comment.medal) }}</router-link
> >
<i v-if="comment.pinned" class="fas fa-thumbtack pin-icon"></i>
<span v-if="level >= 2"> <span v-if="level >= 2">
<i class="fas fa-reply reply-icon"></i> <i class="fas fa-reply reply-icon"></i>
<span class="user-name reply-user-name">{{ comment.parentUserName }}</span> <span class="user-name reply-user-name">{{ comment.parentUserName }}</span>
@@ -74,6 +75,7 @@
:comment="item" :comment="item"
:level="level + 1" :level="level + 1"
:default-show-replies="item.openReplies" :default-show-replies="item.openReplies"
:post-author-id="postAuthorId"
/> />
</template> </template>
</BaseTimeline> </BaseTimeline>
@@ -88,11 +90,10 @@
</div> </div>
</template> </template>
<script> <script setup>
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import VueEasyLightbox from 'vue-easy-lightbox' import VueEasyLightbox from 'vue-easy-lightbox'
import { useRouter } from 'vue-router' import { toast } from '~/main'
import { API_BASE_URL, toast } from '~/main'
import { authState, getToken } from '~/utils/auth' import { authState, getToken } from '~/utils/auth'
import { handleMarkdownClick, renderMarkdown } from '~/utils/markdown' import { handleMarkdownClick, renderMarkdown } from '~/utils/markdown'
import { getMedalTitle } from '~/utils/medal' import { getMedalTitle } from '~/utils/medal'
@@ -100,13 +101,11 @@ import TimeManager from '~/utils/time'
import BaseTimeline from '~/components/BaseTimeline.vue' import BaseTimeline from '~/components/BaseTimeline.vue'
import CommentEditor from '~/components/CommentEditor.vue' import CommentEditor from '~/components/CommentEditor.vue'
import DropdownMenu from '~/components/DropdownMenu.vue' import DropdownMenu from '~/components/DropdownMenu.vue'
import LoginOverlay from '~/components/LoginOverlay.vue'
import ReactionsGroup from '~/components/ReactionsGroup.vue' import ReactionsGroup from '~/components/ReactionsGroup.vue'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const CommentItem = { const props = defineProps({
name: 'CommentItem',
emits: ['deleted'],
props: {
comment: { comment: {
type: Object, type: Object,
required: true, required: true,
@@ -119,9 +118,14 @@ const CommentItem = {
type: Boolean, type: Boolean,
default: false, 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) const showReplies = ref(props.level === 0 ? true : props.defaultShowReplies)
watch( watch(
() => props.defaultShowReplies, () => props.defaultShowReplies,
@@ -138,9 +142,11 @@ const CommentItem = {
const loggedIn = computed(() => authState.loggedIn) const loggedIn = computed(() => authState.loggedIn)
const countReplies = (list) => list.reduce((sum, r) => sum + 1 + countReplies(r.reply || []), 0) const countReplies = (list) => list.reduce((sum, r) => sum + 1 + countReplies(r.reply || []), 0)
const replyCount = computed(() => countReplies(props.comment.reply || [])) const replyCount = computed(() => countReplies(props.comment.reply || []))
const toggleReplies = () => { const toggleReplies = () => {
showReplies.value = !showReplies.value showReplies.value = !showReplies.value
} }
const toggleEditor = () => { const toggleEditor = () => {
showEditor.value = !showEditor.value showEditor.value = !showEditor.value
if (showEditor.value) { if (showEditor.value) {
@@ -150,7 +156,6 @@ const CommentItem = {
} }
} }
// 合并所有子回复为一个扁平数组
const flattenReplies = (list) => { const flattenReplies = (list) => {
let result = [] let result = []
for (const r of list) { for (const r of list) {
@@ -171,12 +176,22 @@ const CommentItem = {
}) })
const isAuthor = computed(() => authState.username === props.comment.userName) const isAuthor = computed(() => authState.username === props.comment.userName)
const isPostAuthor = computed(() => Number(authState.userId) === Number(props.postAuthorId))
const isAdmin = computed(() => authState.role === 'ADMIN') const isAdmin = computed(() => authState.role === 'ADMIN')
const commentMenuItems = computed(() => const commentMenuItems = computed(() => {
isAuthor.value || isAdmin.value const items = []
? [{ text: '删除评论', color: 'red', onClick: () => deleteComment() }] 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 deleteComment = async () => {
const token = getToken() const token = getToken()
if (!token) { if (!token) {
@@ -236,11 +251,11 @@ const CommentItem = {
reply: [], reply: [],
openReplies: false, openReplies: false,
src: r.author.avatar, src: r.author.avatar,
iconClick: () => router.push(`/users/${r.author.id}`), iconClick: () => navigateTo(`/users/${r.author.id}`),
})), })),
openReplies: false, openReplies: false,
src: data.author.avatar, src: data.author.avatar,
iconClick: () => router.push(`/users/${data.author.id}`), iconClick: () => navigateTo(`/users/${data.author.id}`),
}) })
clear() clear()
showEditor.value = false showEditor.value = false
@@ -257,12 +272,55 @@ const CommentItem = {
isWaitingForReply.value = false 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 copyCommentLink = () => {
const link = `${location.origin}${location.pathname}#comment-${props.comment.id}` const link = `${location.origin}${location.pathname}#comment-${props.comment.id}`
navigator.clipboard.writeText(link).then(() => { navigator.clipboard.writeText(link).then(() => {
toast.success('已复制') toast.success('已复制')
}) })
} }
const handleContentClick = (e) => { const handleContentClick = (e) => {
handleMarkdownClick(e) handleMarkdownClick(e)
if (e.target.tagName === 'IMG') { if (e.target.tagName === 'IMG') {
@@ -273,41 +331,6 @@ const CommentItem = {
lightboxVisible.value = true 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> </script>
<style scoped> <style scoped>
@@ -370,6 +393,12 @@ export default CommentItem
margin-left: 10px; margin-left: 10px;
} }
.pin-icon {
font-size: 12px;
margin-left: 10px;
opacity: 0.6;
}
@keyframes highlight { @keyframes highlight {
from { from {
background-color: yellow; background-color: yellow;

View File

@@ -11,36 +11,32 @@
</div> </div>
</template> </template>
<script> <script setup>
import ActivityPopup from '~/components/ActivityPopup.vue' import ActivityPopup from '~/components/ActivityPopup.vue'
import MedalPopup from '~/components/MedalPopup.vue' import MedalPopup from '~/components/MedalPopup.vue'
import NotificationSettingPopup from '~/components/NotificationSettingPopup.vue' import NotificationSettingPopup from '~/components/NotificationSettingPopup.vue'
import { API_BASE_URL } from '~/main'
import { authState } from '~/utils/auth' import { authState } from '~/utils/auth'
export default { const config = useRuntimeConfig()
name: 'GlobalPopups', const API_BASE_URL = config.public.apiBaseUrl
components: { ActivityPopup, MedalPopup, NotificationSettingPopup },
data() {
return {
showMilkTeaPopup: false,
milkTeaIcon: '',
showNotificationPopup: false,
showMedalPopup: false,
newMedals: [],
}
},
async mounted() {
await this.checkMilkTeaActivity()
if (this.showMilkTeaPopup) return
await this.checkNotificationSetting() const showMilkTeaPopup = ref(false)
if (this.showNotificationPopup) return const milkTeaIcon = ref('')
const showNotificationPopup = ref(false)
const showMedalPopup = ref(false)
const newMedals = ref([])
await this.checkNewMedals() onMounted(async () => {
}, await checkMilkTeaActivity()
methods: { if (showMilkTeaPopup.value) return
async checkMilkTeaActivity() {
await checkNotificationSetting()
if (showNotificationPopup.value) return
await checkNewMedals()
})
const checkMilkTeaActivity = async () => {
if (!process.client) return if (!process.client) return
if (localStorage.getItem('milkTeaActivityPopupShown')) return if (localStorage.getItem('milkTeaActivityPopupShown')) return
try { try {
@@ -49,33 +45,33 @@ export default {
const list = await res.json() const list = await res.json()
const a = list.find((i) => i.type === 'MILK_TEA' && !i.ended) const a = list.find((i) => i.type === 'MILK_TEA' && !i.ended)
if (a) { if (a) {
this.milkTeaIcon = a.icon milkTeaIcon.value = a.icon
this.showMilkTeaPopup = true showMilkTeaPopup.value = true
} }
} }
} catch (e) { } catch (e) {
// ignore network errors // ignore network errors
} }
}, }
closeMilkTeaPopup() { const closeMilkTeaPopup = () => {
if (!process.client) return if (!process.client) return
localStorage.setItem('milkTeaActivityPopupShown', 'true') localStorage.setItem('milkTeaActivityPopupShown', 'true')
this.showMilkTeaPopup = false showMilkTeaPopup.value = false
this.checkNotificationSetting() checkNotificationSetting()
}, }
async checkNotificationSetting() { const checkNotificationSetting = async () => {
if (!process.client) return if (!process.client) return
if (!authState.loggedIn) return if (!authState.loggedIn) return
if (localStorage.getItem('notificationSettingPopupShown')) return if (localStorage.getItem('notificationSettingPopupShown')) return
this.showNotificationPopup = true showNotificationPopup.value = true
}, }
closeNotificationPopup() { const closeNotificationPopup = () => {
if (!process.client) return if (!process.client) return
localStorage.setItem('notificationSettingPopupShown', 'true') localStorage.setItem('notificationSettingPopupShown', 'true')
this.showNotificationPopup = false showNotificationPopup.value = false
this.checkNewMedals() checkNewMedals()
}, }
async checkNewMedals() { const checkNewMedals = async () => {
if (!process.client) return if (!process.client) return
if (!authState.loggedIn || !authState.userId) return if (!authState.loggedIn || !authState.userId) return
try { try {
@@ -85,21 +81,19 @@ export default {
const seen = JSON.parse(localStorage.getItem('seenMedals') || '[]') const seen = JSON.parse(localStorage.getItem('seenMedals') || '[]')
const m = medals.filter((i) => i.completed && !seen.includes(i.type)) const m = medals.filter((i) => i.completed && !seen.includes(i.type))
if (m.length > 0) { if (m.length > 0) {
this.newMedals = m newMedals.value = m
this.showMedalPopup = true showMedalPopup.value = true
} }
} }
} catch (e) { } catch (e) {
// ignore errors // ignore errors
} }
}, }
closeMedalPopup() { const closeMedalPopup = () => {
if (!process.client) return if (!process.client) return
const seen = new Set(JSON.parse(localStorage.getItem('seenMedals') || '[]')) 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])) localStorage.setItem('seenMedals', JSON.stringify([...seen]))
this.showMedalPopup = false showMedalPopup.value = false
},
},
} }
</script> </script>

View File

@@ -3,12 +3,12 @@
<div class="header-content"> <div class="header-content">
<div class="header-content-left"> <div class="header-content-left">
<div v-if="showMenuBtn" class="menu-btn-wrapper"> <div v-if="showMenuBtn" class="menu-btn-wrapper">
<button class="menu-btn" @click="$emit('toggle-menu')"> <button class="menu-btn" ref="menuBtn" @click="$emit('toggle-menu')">
<i class="fas fa-bars"></i> <i class="fas fa-bars"></i>
</button> </button>
<span v-if="isMobile && unreadCount > 0" class="menu-unread-dot"></span> <span v-if="isMobile && unreadCount > 0" class="menu-unread-dot"></span>
</div> </div>
<div class="logo-container" @click="goToHome"> <NuxtLink class="logo-container" :to="`/`">
<img <img
alt="OpenIsle" alt="OpenIsle"
src="https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png" src="https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png"
@@ -16,7 +16,7 @@
height="60" height="60"
/> />
<div class="logo-text">OpenIsle</div> <div class="logo-text">OpenIsle</div>
</div> </NuxtLink>
</div> </div>
<ClientOnly> <ClientOnly>
@@ -48,40 +48,30 @@
</header> </header>
</template> </template>
<script> <script setup>
import { authState, clearToken, loadCurrentUser } from '~/utils/auth' import { ClientOnly } from '#components'
import { watch, nextTick, ref, computed } from 'vue' import { computed, nextTick, ref, watch } from 'vue'
import { fetchUnreadCount, notificationState } from '~/utils/notification'
import DropdownMenu from '~/components/DropdownMenu.vue' import DropdownMenu from '~/components/DropdownMenu.vue'
import SearchDropdown from '~/components/SearchDropdown.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' import { useIsMobile } from '~/utils/screen'
import { useRouter } from 'vue-router' const props = defineProps({
import { ClientOnly } from '#components'
export default {
name: 'HeaderComponent',
components: { DropdownMenu, SearchDropdown },
props: {
showMenuBtn: { showMenuBtn: {
type: Boolean, type: Boolean,
default: true, default: true,
}, },
}, })
setup() {
const isLogin = computed(() => authState.loggedIn) const isLogin = computed(() => authState.loggedIn)
const isMobile = useIsMobile() const isMobile = useIsMobile()
const unreadCount = computed(() => notificationState.unreadCount) const unreadCount = computed(() => notificationState.unreadCount)
const router = useRouter()
const avatar = ref('') const avatar = ref('')
const showSearch = ref(false) const showSearch = ref(false)
const searchDropdown = ref(null) const searchDropdown = ref(null)
const userMenu = ref(null) const userMenu = ref(null)
const menuBtn = ref(null)
const goToHome = () => {
router.push('/').then(() => {
window.location.reload()
})
}
const search = () => { const search = () => {
showSearch.value = true showSearch.value = true
nextTick(() => { nextTick(() => {
@@ -94,14 +84,14 @@ export default {
}) })
} }
const goToLogin = () => { const goToLogin = () => {
router.push('/login') navigateTo('/login', { replace: true })
} }
const goToSettings = () => { const goToSettings = () => {
router.push('/settings') navigateTo('/settings', { replace: true })
} }
const goToProfile = async () => { const goToProfile = async () => {
if (!authState.loggedIn) { if (!authState.loggedIn) {
router.push('/login') navigateTo('/login', { replace: true })
return return
} }
let id = authState.username || authState.userId let id = authState.username || authState.userId
@@ -112,15 +102,15 @@ export default {
} }
} }
if (id) { if (id) {
router.push(`/users/${id}`) navigateTo(`/users/${id}`, { replace: true })
} }
} }
const goToSignup = () => { const goToSignup = () => {
router.push('/signup') navigateTo('/signup', { replace: true })
} }
const goToLogout = () => { const goToLogout = () => {
clearToken() clearToken()
this.$router.push('/login') navigateTo('/login', { replace: true })
} }
const headerMenuItems = computed(() => [ const headerMenuItems = computed(() => [
@@ -165,27 +155,6 @@ export default {
}, },
) )
}) })
return {
isLogin,
isMobile,
headerMenuItems,
unreadCount,
goToHome,
search,
closeSearch,
goToLogin,
goToSettings,
goToProfile,
goToSignup,
goToLogout,
showSearch,
searchDropdown,
userMenu,
avatar,
}
},
}
</script> </script>
<style scoped> <style scoped>
@@ -206,6 +175,8 @@ export default {
font-size: 20px; font-size: 20px;
font-weight: bold; font-weight: bold;
cursor: pointer; cursor: pointer;
text-decoration: none;
color: inherit;
} }
.header-content { .header-content {

View File

@@ -9,18 +9,9 @@
</div> </div>
</template> </template>
<script> <script setup>
import { useRouter } from 'vue-router'
export default {
name: 'LoginOverlay',
setup() {
const router = useRouter()
const goLogin = () => { const goLogin = () => {
router.push('/login') navigateTo('/login', { replace: true })
}
return { goLogin }
},
} }
</script> </script>

View File

@@ -16,33 +16,25 @@
</BasePopup> </BasePopup>
</template> </template>
<script> <script setup>
import BasePopup from '~/components/BasePopup.vue' import BasePopup from '~/components/BasePopup.vue'
import { useRouter } from 'vue-router'
import { authState } from '~/utils/auth' import { authState } from '~/utils/auth'
export default { defineProps({
name: 'MedalPopup',
components: { BasePopup },
props: {
visible: { type: Boolean, default: false }, visible: { type: Boolean, default: false },
medals: { type: Array, default: () => [] }, medals: { type: Array, default: () => [] },
}, })
emits: ['close'], const emit = defineEmits(['close'])
setup(props, { emit }) {
const router = useRouter()
const gotoMedals = () => { const gotoMedals = () => {
emit('close') emit('close')
if (authState.username) { if (authState.username) {
router.push(`/users/${authState.username}?tab=achievements`) navigateTo(`/users/${authState.username}?tab=achievements`, { replace: true })
} else { } else {
router.push('/') navigateTo('/', { replace: true })
} }
} }
const close = () => emit('close') const close = () => emit('close')
return { gotoMedals, close }
},
}
</script> </script>
<style scoped> <style scoped>

View File

@@ -2,7 +2,7 @@
<transition name="slide"> <transition name="slide">
<nav v-if="visible" class="menu"> <nav v-if="visible" class="menu">
<div class="menu-item-container"> <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> <i class="menu-item-icon fas fa-hashtag"></i>
<span class="menu-item-text">话题</span> <span class="menu-item-text">话题</span>
</NuxtLink> </NuxtLink>
@@ -123,25 +123,23 @@
</transition> </transition>
</template> </template>
<script> <script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { themeState, cycleTheme, ThemeMode } from '~/utils/theme' import { themeState, cycleTheme, ThemeMode } from '~/utils/theme'
import { authState } from '~/utils/auth' import { authState } from '~/utils/auth'
import { fetchUnreadCount, notificationState } from '~/utils/notification' import { fetchUnreadCount, notificationState } from '~/utils/notification'
import { ref, computed, watch, onMounted } from 'vue' const config = useRuntimeConfig()
import { API_BASE_URL } from '~/main' const API_BASE_URL = config.public.apiBaseUrl
export default { const props = defineProps({
name: 'MenuComponent',
props: {
visible: { visible: {
type: Boolean, type: Boolean,
default: true, default: true,
}, },
}, })
async setup(props, { emit }) {
const router = useRouter() const emit = defineEmits(['item-click'])
const categories = ref([])
const tags = ref([])
const categoryOpen = ref(true) const categoryOpen = ref(true)
const tagOpen = ref(true) const tagOpen = ref(true)
const isLoadingCategory = ref(false) const isLoadingCategory = ref(false)
@@ -193,12 +191,6 @@ export default {
watch(() => authState.loggedIn, updateCount) watch(() => authState.loggedIn, updateCount)
}) })
const handleHomeClick = () => {
router.push('/').then(() => {
window.location.reload()
})
}
const handleItemClick = () => { const handleItemClick = () => {
if (window.innerWidth <= 768) emit('item-click') if (window.innerWidth <= 768) emit('item-click')
} }
@@ -210,42 +202,17 @@ export default {
const gotoCategory = (c) => { const gotoCategory = (c) => {
const value = encodeURIComponent(c.id ?? c.name) const value = encodeURIComponent(c.id ?? c.name)
router.push({ path: '/', query: { category: value } }).then(() => { navigateTo({ path: '/', query: { category: value } }, { replace: true })
window.location.reload()
})
handleItemClick() handleItemClick()
} }
const gotoTag = (t) => { const gotoTag = (t) => {
const value = encodeURIComponent(t.id ?? t.name) const value = encodeURIComponent(t.id ?? t.name)
router.push({ path: '/', query: { tags: value } }).then(() => { navigateTo({ path: '/', query: { tags: value } }, { replace: true })
window.location.reload()
})
handleItemClick() handleItemClick()
} }
await Promise.all([fetchCategoryData(), fetchTagData()]) await Promise.all([fetchCategoryData(), fetchTagData()])
return {
categoryData,
tagData,
categoryOpen,
tagOpen,
isLoadingCategory,
isLoadingTag,
iconClass,
unreadCount,
showUnreadCount,
shouldShowStats,
cycleTheme,
handleHomeClick,
handleItemClick,
isImageIcon,
gotoCategory,
gotoTag,
}
},
}
</script> </script>
<style scoped> <style scoped>

View File

@@ -57,49 +57,44 @@
</div> </div>
</template> </template>
<script> <script setup>
import { API_BASE_URL, toast } from '~/main' import { toast } from '~/main'
import { fetchCurrentUser, getToken } from '~/utils/auth' import { fetchCurrentUser, getToken } from '~/utils/auth'
import BaseInput from '~/components/BaseInput.vue' import BaseInput from '~/components/BaseInput.vue'
import BasePopup from '~/components/BasePopup.vue' import BasePopup from '~/components/BasePopup.vue'
import LevelProgress from '~/components/LevelProgress.vue' import LevelProgress from '~/components/LevelProgress.vue'
import ProgressBar from '~/components/ProgressBar.vue' import ProgressBar from '~/components/ProgressBar.vue'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
export default { const info = ref({ redeemCount: 0, ended: false })
name: 'MilkTeaActivityComponent', const user = ref(null)
components: { ProgressBar, LevelProgress, BaseInput, BasePopup }, const dialogVisible = ref(false)
data() { const contact = ref('')
return { const loading = ref(false)
info: { redeemCount: 0, ended: false }, const isLoadingUser = ref(true)
user: null,
dialogVisible: false, onMounted(async () => {
contact: '', await loadInfo()
loading: false, isLoadingUser.value = true
isLoadingUser: true, user.value = await fetchCurrentUser()
} isLoadingUser.value = false
}, })
async mounted() { const loadInfo = async () => {
await this.loadInfo()
this.isLoadingUser = true
this.user = await fetchCurrentUser()
this.isLoadingUser = false
},
methods: {
async loadInfo() {
const res = await fetch(`${API_BASE_URL}/api/activities/milk-tea`) const res = await fetch(`${API_BASE_URL}/api/activities/milk-tea`)
if (res.ok) { if (res.ok) {
this.info = await res.json() info.value = await res.json()
} }
}, }
openDialog() { const openDialog = () => {
this.dialogVisible = true dialogVisible.value = true
}, }
closeDialog() { const closeDialog = () => {
this.dialogVisible = false dialogVisible.value = false
}, }
async submitRedeem() { const submitRedeem = async () => {
if (!this.contact) return if (!contact.value) return
this.loading = true loading.value = true
const token = getToken() const token = getToken()
const res = await fetch(`${API_BASE_URL}/api/activities/milk-tea/redeem`, { const res = await fetch(`${API_BASE_URL}/api/activities/milk-tea/redeem`, {
method: 'POST', method: 'POST',
@@ -107,7 +102,7 @@ export default {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
}, },
body: JSON.stringify({ contact: this.contact }), body: JSON.stringify({ contact: contact.value }),
}) })
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json()
@@ -116,14 +111,12 @@ export default {
} else { } else {
toast.success('兑换成功!') toast.success('兑换成功!')
} }
this.dialogVisible = false dialogVisible.value = false
await this.loadInfo() await loadInfo()
} else { } else {
toast.error('兑换失败') toast.error('兑换失败')
} }
this.loading = false loading.value = false
},
},
} }
</script> </script>

View File

@@ -11,27 +11,19 @@
</BasePopup> </BasePopup>
</template> </template>
<script> <script setup>
import BasePopup from '~/components/BasePopup.vue' import BasePopup from '~/components/BasePopup.vue'
import { useRouter } from 'vue-router'
export default { defineProps({
name: 'NotificationSettingPopup',
components: { BasePopup },
props: {
visible: { type: Boolean, default: false }, visible: { type: Boolean, default: false },
}, })
emits: ['close'], const emit = defineEmits(['close'])
setup(props, { emit }) {
const router = useRouter()
const gotoSetting = () => { const gotoSetting = () => {
emit('close') emit('close')
router.push('/message?tab=control') navigateTo('/message?tab=control', { replace: true })
} }
const close = () => emit('close') const close = () => emit('close')
return { gotoSetting, close }
},
}
</script> </script>
<style scoped> <style scoped>

View File

@@ -46,11 +46,27 @@
</div> </div>
</template> </template>
<script> <script setup>
import { computed, onMounted, ref, watch } from 'vue' 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 { authState, getToken } from '~/utils/auth'
import { reactionEmojiMap } from '~/utils/reactions' 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 let cachedTypes = null
const fetchTypes = async () => { const fetchTypes = async () => {
@@ -71,22 +87,6 @@ const fetchTypes = async () => {
return cachedTypes 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 () => { onMounted(async () => {
reactionTypes.value = await fetchTypes() reactionTypes.value = await fetchTypes()
}) })
@@ -200,23 +200,6 @@ export default {
toast.error('操作失败') toast.error('操作失败')
} }
} }
return {
reactionEmojiMap,
counts,
totalCount,
likeCount,
displayedReactions,
panelTypes,
panelVisible,
openPanel,
scheduleHide,
cancelHide,
toggleReaction,
userReacted,
}
},
}
</script> </script>
<style> <style>

View File

@@ -36,20 +36,16 @@
</div> </div>
</template> </template>
<script> <script setup>
import { ref, watch } from 'vue'
import { useIsMobile } from '~/utils/screen' import { useIsMobile } from '~/utils/screen'
import { useRouter } from 'vue-router'
import Dropdown from '~/components/Dropdown.vue' import Dropdown from '~/components/Dropdown.vue'
import { API_BASE_URL } from '~/main'
import { stripMarkdown } from '~/utils/markdown' 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 keyword = ref('')
const selected = ref(null) const selected = ref(null)
const results = ref([]) const results = ref([])
@@ -99,35 +95,21 @@ export default {
const opt = results.value.find((r) => r.id === val) const opt = results.value.find((r) => r.id === val)
if (!opt) return if (!opt) return
if (opt.type === 'post' || opt.type === 'post_title') { if (opt.type === 'post' || opt.type === 'post_title') {
router.push(`/posts/${opt.id}`) navigateTo(`/posts/${opt.id}`, { replace: true })
} else if (opt.type === 'user') { } else if (opt.type === 'user') {
router.push(`/users/${opt.id}`) navigateTo(`/users/${opt.id}`, { replace: true })
} else if (opt.type === 'comment') { } else if (opt.type === 'comment') {
if (opt.postId) { 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') { } 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') { } else if (opt.type === 'tag') {
router.push({ path: '/', query: { tags: opt.id } }) navigateTo({ path: '/', query: { tags: opt.id } }, { replace: true })
} }
selected.value = null selected.value = null
keyword.value = '' keyword.value = ''
}) })
return {
keyword,
selected,
fetchResults,
highlight,
iconMap,
isMobile,
dropdown,
onClose,
toggle,
}
},
}
</script> </script>
<style scoped> <style scoped>

View File

@@ -28,21 +28,20 @@
</Dropdown> </Dropdown>
</template> </template>
<script> <script setup>
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { API_BASE_URL, toast } from '~/main' import { toast } from '~/main'
import Dropdown from '~/components/Dropdown.vue' import Dropdown from '~/components/Dropdown.vue'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
export default { const emit = defineEmits(['update:modelValue'])
name: 'TagSelect', const props = defineProps({
components: { Dropdown },
props: {
modelValue: { type: Array, default: () => [] }, modelValue: { type: Array, default: () => [] },
creatable: { type: Boolean, default: false }, creatable: { type: Boolean, default: false },
options: { type: Array, default: () => [] }, options: { type: Array, default: () => [] },
}, })
emits: ['update:modelValue'],
setup(props, { emit }) {
const localTags = ref([]) const localTags = ref([])
const providedTags = ref(Array.isArray(props.options) ? [...props.options] : []) const providedTags = ref(Array.isArray(props.options) ? [...props.options] : [])
@@ -91,11 +90,7 @@ export default {
// 3) 合并、去重、可创建 // 3) 合并、去重、可创建
let options = [...data, ...localTags.value] let options = [...data, ...localTags.value]
if ( if (props.creatable && kw && !options.some((t) => t.name.toLowerCase() === kw.toLowerCase())) {
props.creatable &&
kw &&
!options.some((t) => t.name.toLowerCase() === kw.toLowerCase())
) {
options.push({ id: `__create__:${kw}`, name: `创建"${kw}"` }) options.push({ id: `__create__:${kw}`, name: `创建"${kw}"` })
} }
@@ -132,10 +127,6 @@ export default {
emit('update:modelValue', v) emit('update:modelValue', v)
}, },
}) })
return { fetchTags, selected, isImageIcon, mergedOptions }
},
}
</script> </script>
<style scoped> <style scoped>

View File

@@ -11,20 +11,15 @@
</div> </div>
</template> </template>
<script> <script setup>
import BasePlaceholder from '~/components/BasePlaceholder.vue' import BasePlaceholder from '~/components/BasePlaceholder.vue'
export default { defineProps({
name: 'UserList',
components: { BasePlaceholder },
props: {
users: { type: Array, default: () => [] }, users: { type: Array, default: () => [] },
}, })
methods: {
handleUserClick(user) { const handleUserClick = (user) => {
this.$router.push(`/users/${user.id}`) navigateTo(`/users/${user.id}`, { replace: true })
},
},
} }
</script> </script>

View File

@@ -1 +0,0 @@
export const WEBSITE_BASE_URL = 'https://www.open-isle.com'

View File

@@ -1,11 +1 @@
export const API_BASE_URL = 'https://www.open-isle.com'
// export const API_BASE_URL = 'http://127.0.0.1:8081'
// export const API_BASE_URL = 'http://30.211.97.238:8081'
export const GOOGLE_CLIENT_ID =
'777830451304-nt8afkkap18gui4f9entcha99unal744.apps.googleusercontent.com'
export const GITHUB_CLIENT_ID = 'Ov23liVkO1NPAX5JyWxJ'
export const DISCORD_CLIENT_ID = '1394985417044000779'
export const TWITTER_CLIENT_ID = 'ZTRTU05KSk9KTTJrTTdrVC1tc1E6MTpjaQ'
// 重新导出 toast 功能,使用 composable 方式
export { toast } from './composables/useToast' export { toast } from './composables/useToast'

View File

@@ -2,6 +2,16 @@ import { defineNuxtConfig } from 'nuxt/config'
export default defineNuxtConfig({ export default defineNuxtConfig({
ssr: true, ssr: true,
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 // Ensure Vditor styles load before our overrides in global.css
css: ['vditor/dist/index.css', '~/assets/global.css'], css: ['vditor/dist/index.css', '~/assets/global.css'],
app: { app: {
@@ -42,5 +52,12 @@ export default defineNuxtConfig({
}, },
], ],
}, },
baseURL: '/',
buildAssetsDir: '/_nuxt/',
},
vue: {
compilerOptions: {
isCustomElement: (tag) => ['l-hatch', 'l-hatch-spinner'].includes(tag),
},
}, },
}) })

View File

@@ -41,8 +41,9 @@ import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers' import { CanvasRenderer } from 'echarts/renderers'
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import VChart from 'vue-echarts' import VChart from 'vue-echarts'
import { API_BASE_URL } from '~/main'
import { getToken } from '~/utils/auth' import { getToken } from '~/utils/auth'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
use([LineChart, TitleComponent, TooltipComponent, GridComponent, DataZoomComponent, CanvasRenderer]) use([LineChart, TitleComponent, TooltipComponent, GridComponent, DataZoomComponent, CanvasRenderer])

View File

@@ -29,35 +29,28 @@
</div> </div>
</template> </template>
<script> <script setup>
import { API_BASE_URL } from '~/main'
import TimeManager from '~/utils/time' import TimeManager from '~/utils/time'
import MilkTeaActivityComponent from '~/components/MilkTeaActivityComponent.vue' import MilkTeaActivityComponent from '~/components/MilkTeaActivityComponent.vue'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
export default { const activities = ref([])
name: 'ActivityListPageView', const isLoadingActivities = ref(false)
components: { MilkTeaActivityComponent },
data() { onMounted(async () => {
return { isLoadingActivities.value = true
activities: [],
TimeManager,
isLoadingActivities: false,
}
},
async mounted() {
this.isLoadingActivities = true
try { try {
const res = await fetch(`${API_BASE_URL}/api/activities`) const res = await fetch(`${API_BASE_URL}/api/activities`)
if (res.ok) { if (res.ok) {
this.activities = await res.json() activities.value = await res.json()
} }
} catch (e) { } catch (e) {
console.error(e) console.error(e)
} finally { } finally {
this.isLoadingActivities = false isLoadingActivities.value = false
}
},
} }
})
</script> </script>
<style scoped> <style scoped>

View File

@@ -2,24 +2,20 @@
<CallbackPage /> <CallbackPage />
</template> </template>
<script> <script setup>
import CallbackPage from '~/components/CallbackPage.vue' import CallbackPage from '~/components/CallbackPage.vue'
import { discordExchange } from '~/utils/discord' import { discordExchange } from '~/utils/discord'
export default { onMounted(async () => {
name: 'DiscordCallbackPageView',
components: { CallbackPage },
async mounted() {
const url = new URL(window.location.href) const url = new URL(window.location.href)
const code = url.searchParams.get('code') const code = url.searchParams.get('code')
const state = url.searchParams.get('state') const state = url.searchParams.get('state')
const result = await discordExchange(code, state, '') const result = await discordExchange(code, state, '')
if (result.needReason) { if (result.needReason) {
this.$router.push('/signup-reason?token=' + result.token) navigateTo(`/signup-reason?token=${result.token}`, { replace: true })
} else { } else {
this.$router.push('/') navigateTo('/', { replace: true })
}
},
} }
})
</script> </script>

View File

@@ -23,105 +23,99 @@
</div> </div>
</template> </template>
<script> <script setup>
import { API_BASE_URL, toast } from '~/main' import { toast } from '~/main'
import BaseInput from '~/components/BaseInput.vue' import BaseInput from '~/components/BaseInput.vue'
export default { const config = useRuntimeConfig()
name: 'ForgotPasswordPageView', const API_BASE_URL = config.public.apiBaseUrl
components: { BaseInput },
data() { const step = ref(0)
return { const email = ref('')
step: 0, const code = ref('')
email: '', const password = ref('')
code: '', const token = ref('')
password: '', const emailError = ref('')
token: '', const passwordError = ref('')
emailError: '', const isSending = ref(false)
passwordError: '', const isVerifying = ref(false)
isSending: false, const isResetting = ref(false)
isVerifying: false,
isResetting: false, onMounted(() => {
if (route.query.email) {
email.value = decodeURIComponent(route.query.email)
} }
}, })
mounted() { const sendCode = async () => {
if (this.$route.query.email) { if (!email.value) {
this.email = decodeURIComponent(this.$route.query.email) emailError.value = '邮箱不能为空'
}
},
methods: {
async sendCode() {
if (!this.email) {
this.emailError = '邮箱不能为空'
return return
} }
try { try {
this.isSending = true isSending.value = true
const res = await fetch(`${API_BASE_URL}/api/auth/forgot/send`, { const res = await fetch(`${API_BASE_URL}/api/auth/forgot/send`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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) { if (res.ok) {
toast.success('验证码已发送') toast.success('验证码已发送')
this.step = 1 step.value = 1
} else { } else {
toast.error('请填写已注册邮箱') toast.error('请填写已注册邮箱')
} }
} catch (e) { } catch (e) {
this.isSending = false isSending.value = false
toast.error('发送失败') toast.error('发送失败')
} }
}, }
async verifyCode() { const verifyCode = async () => {
try { try {
this.isVerifying = true isVerifying.value = true
const res = await fetch(`${API_BASE_URL}/api/auth/forgot/verify`, { const res = await fetch(`${API_BASE_URL}/api/auth/forgot/verify`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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() const data = await res.json()
if (res.ok) { if (res.ok) {
this.token = data.token token.value = data.token
this.step = 2 step.value = 2
} else { } else {
toast.error(data.error || '验证失败') toast.error(data.error || '验证失败')
} }
} catch (e) { } catch (e) {
this.isVerifying = false isVerifying.value = false
toast.error('验证失败') toast.error('验证失败')
} }
}, }
async resetPassword() { const resetPassword = async () => {
if (!this.password) { if (!password.value) {
this.passwordError = '密码不能为空' passwordError.value = '密码不能为空'
return return
} }
try { try {
this.isResetting = true isResetting.value = true
const res = await fetch(`${API_BASE_URL}/api/auth/forgot/reset`, { const res = await fetch(`${API_BASE_URL}/api/auth/forgot/reset`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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() const data = await res.json()
if (res.ok) { if (res.ok) {
toast.success('密码已重置') toast.success('密码已重置')
this.$router.push('/login') navigateTo('/login', { replace: true })
} else if (data.field === 'password') { } else if (data.field === 'password') {
this.passwordError = data.error passwordError.value = data.error
} else { } else {
toast.error(data.error || '重置失败') toast.error(data.error || '重置失败')
} }
} catch (e) { } catch (e) {
this.isResetting = false isResetting.value = false
toast.error('重置失败') toast.error('重置失败')
} }
},
},
} }
</script> </script>

View File

@@ -2,24 +2,20 @@
<CallbackPage /> <CallbackPage />
</template> </template>
<script> <script setup>
import CallbackPage from '~/components/CallbackPage.vue' import CallbackPage from '~/components/CallbackPage.vue'
import { githubExchange } from '~/utils/github' import { githubExchange } from '~/utils/github'
export default { onMounted(async () => {
name: 'GithubCallbackPageView',
components: { CallbackPage },
async mounted() {
const url = new URL(window.location.href) const url = new URL(window.location.href)
const code = url.searchParams.get('code') const code = url.searchParams.get('code')
const state = url.searchParams.get('state') const state = url.searchParams.get('state')
const result = await githubExchange(code, state, '') const result = await githubExchange(code, state, '')
if (result.needReason) { if (result.needReason) {
this.$router.push('/signup-reason?token=' + result.token) navigateTo(`/signup-reason?token=${result.token}`, { replace: true })
} else { } else {
this.$router.push('/') navigateTo('/', { replace: true })
}
},
} }
})
</script> </script>

View File

@@ -2,29 +2,25 @@
<CallbackPage /> <CallbackPage />
</template> </template>
<script> <script setup>
import CallbackPage from '~/components/CallbackPage.vue' import CallbackPage from '~/components/CallbackPage.vue'
import { googleAuthWithToken } from '~/utils/google' import { googleAuthWithToken } from '~/utils/google'
export default { onMounted(async () => {
name: 'GoogleCallbackPageView',
components: { CallbackPage },
async mounted() {
const hash = new URLSearchParams(window.location.hash.substring(1)) const hash = new URLSearchParams(window.location.hash.substring(1))
const idToken = hash.get('id_token') const idToken = hash.get('id_token')
if (idToken) { if (idToken) {
await googleAuthWithToken( await googleAuthWithToken(
idToken, idToken,
() => { () => {
this.$router.push('/') navigateTo('/', { replace: true })
}, },
(token) => { (token) => {
this.$router.push('/signup-reason?token=' + token) navigateTo(`/signup-reason?token=${token}`, { replace: true })
}, },
) )
} else { } else {
this.$router.push('/login') navigateTo('/login', { replace: true })
}
},
} }
})
</script> </script>

View File

@@ -50,7 +50,7 @@
</div> </div>
</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> <l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
</div> </div>
@@ -60,7 +60,12 @@
</div> </div>
</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"> <div class="article-main-container">
<NuxtLink class="article-item-title main-item" :to="`/posts/${article.id}`"> <NuxtLink class="article-item-title main-item" :to="`/posts/${article.id}`">
<i v-if="article.pinned" class="fas fa-thumbtack pinned-icon"></i> <i v-if="article.pinned" class="fas fa-thumbtack pinned-icon"></i>
@@ -104,42 +109,25 @@
热门帖子功能开发中,敬请期待。 热门帖子功能开发中,敬请期待。
</div> </div>
<div v-else class="placeholder-container">分类浏览功能开发中,敬请期待。</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> <l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup>
<script> import { ref, watch, watchEffect, computed, onMounted, onBeforeUnmount } from 'vue'
import { ref, watch } from 'vue' import ArticleCategory from '~/components/ArticleCategory.vue'
import { useRoute } from 'vue-router' 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 { getToken } from '~/utils/auth'
import { useScrollLoadMore } from '~/utils/loadMore' import { useScrollLoadMore } from '~/utils/loadMore'
import { stripMarkdown } from '~/utils/markdown' import { stripMarkdown } from '~/utils/markdown'
import { API_BASE_URL } from '~/main'
import { getToken } from '~/utils/auth'
import TimeManager from '~/utils/time'
import CategorySelect from '~/components/CategorySelect.vue'
import TagSelect from '~/components/TagSelect.vue'
import ArticleTags from '~/components/ArticleTags.vue'
import ArticleCategory from '~/components/ArticleCategory.vue'
import SearchDropdown from '~/components/SearchDropdown.vue'
import { useIsMobile } from '~/utils/screen' 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({ useHead({
title: 'OpenIsle - 全面开源的自由社区', title: 'OpenIsle - 全面开源的自由社区',
meta: [ meta: [
@@ -150,15 +138,34 @@ export default {
}, },
], ],
}) })
const route = useRoute()
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const selectedCategory = ref('') const selectedCategory = ref('')
if (route.query.category) { const selectedTags = ref([])
const c = decodeURIComponent(route.query.category) const route = useRoute()
const tagOptions = ref([])
const categoryOptions = ref([])
const isLoadingMore = ref(false)
const topics = ref(['最新回复', '最新', '排行榜' /*, '热门', '类别'*/])
const selectedTopic = ref(
route.query.view === 'ranking' ? '排行榜' : route.query.view === 'latest' ? '最新' : '最新回复',
)
const articles = ref([])
const page = ref(0)
const pageSize = 10
const isMobile = useIsMobile()
const allLoaded = ref(false)
/** URL 参数 -> 本地筛选值 **/
const selectedCategorySet = (category) => {
const c = decodeURIComponent(category)
selectedCategory.value = isNaN(c) ? c : Number(c) selectedCategory.value = isNaN(c) ? c : Number(c)
} }
const selectedTags = ref([]) const selectedTagsSet = (tags) => {
if (route.query.tags) { const t = Array.isArray(tags) ? tags.join(',') : tags
const t = Array.isArray(route.query.tags) ? route.query.tags.join(',') : route.query.tags
selectedTags.value = t selectedTags.value = t
.split(',') .split(',')
.filter((v) => v) .filter((v) => v)
@@ -166,36 +173,32 @@ export default {
.map((v) => (isNaN(v) ? v : Number(v))) .map((v) => (isNaN(v) ? v : Number(v)))
} }
const tagOptions = ref([]) /** 初始化:仅在客户端首渲染时根据路由同步一次 **/
const categoryOptions = ref([]) onMounted(() => {
const isLoadingPosts = ref(false) const { category, tags } = route.query
const topics = ref(['最新回复', '最新', '排行榜' /*, '热门', '类别'*/]) if (category) selectedCategorySet(category)
const selectedTopic = ref( if (tags) selectedTagsSet(tags)
route.query.view === 'ranking' })
? '排行榜'
: route.query.view === 'latest' /** 路由变更时同步筛选 **/
? '最新' watch(
: '最新回复', () => route.query,
(query) => {
const category = query.category
const tags = query.tags
category && selectedCategorySet(category)
tags && selectedTagsSet(tags)
},
) )
const articles = ref([]) /** 选项加载(分类/标签名称回填) **/
const page = ref(0)
const pageSize = 10
const isMobile = useIsMobile()
const allLoaded = ref(false)
const loadOptions = async () => { const loadOptions = async () => {
if (selectedCategory.value && !isNaN(selectedCategory.value)) { if (selectedCategory.value && !isNaN(selectedCategory.value)) {
try { try {
const res = await fetch(`${API_BASE_URL}/api/categories/${selectedCategory.value}`) const res = await fetch(`${API_BASE_URL}/api/categories/${selectedCategory.value}`)
if (res.ok) { if (res.ok) categoryOptions.value = [await res.json()]
categoryOptions.value = [await res.json()] } catch {}
} }
} catch (e) {
/* ignore */
}
}
if (selectedTags.value.length) { if (selectedTags.value.length) {
const arr = [] const arr = []
for (const t of selectedTags.value) { for (const t of selectedTags.value) {
@@ -203,74 +206,54 @@ export default {
try { try {
const r = await fetch(`${API_BASE_URL}/api/tags/${t}`) const r = await fetch(`${API_BASE_URL}/api/tags/${t}`)
if (r.ok) arr.push(await r.json()) if (r.ok) arr.push(await r.json())
} catch (e) { } catch {}
/* ignore */
}
} }
} }
tagOptions.value = arr tagOptions.value = arr
} }
} }
const buildUrl = () => { /** 列表 API 路径与查询参数 **/
let url = `${API_BASE_URL}/api/posts?page=${page.value}&pageSize=${pageSize}` const baseQuery = computed(() => ({
if (selectedCategory.value) { categoryId: selectedCategory.value || undefined,
url += `&categoryId=${selectedCategory.value}` tagIds: selectedTags.value.length ? selectedTags.value : undefined,
} }))
if (selectedTags.value.length) { const listApiPath = computed(() => {
selectedTags.value.forEach((t) => { if (selectedTopic.value === '排行榜') return '/api/posts/ranking'
url += `&tagIds=${t}` 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 tokenHeader = computed(() => {
}
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 token = getToken() const token = getToken()
const res = await fetch(buildUrl(), { return token ? { Authorization: `Bearer ${token}` } : {}
headers: {
Authorization: token ? `Bearer ${token}` : '',
},
}) })
isLoadingPosts.value = false
if (!res.ok) return /** —— 首屏数据托管SSR —— **/
const data = await res.json() const asyncKey = computed(() => [
articles.value.push( 'home:firstpage',
...data.map((p) => ({ 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, id: p.id,
title: p.title, title: p.title,
description: p.content, description: p.content,
@@ -279,41 +262,41 @@ export default {
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })), members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
comments: p.commentCount, comments: p.commentCount,
views: p.views, views: p.views,
time: TimeManager.format(p.createdAt), time: TimeManager.format(
selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,
),
pinned: !!p.pinnedAt, pinned: !!p.pinnedAt,
type: p.type, 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 server: true,
if (!res.ok) return default: () => [],
const data = await res.json() watch: [selectedTopic, baseQuery],
articles.value.push( },
...data.map((p) => ({ )
/** 首屏/筛选变更:重置分页并灌入 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, id: p.id,
title: p.title, title: p.title,
description: p.content, description: p.content,
@@ -322,102 +305,59 @@ export default {
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })), members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
comments: p.commentCount, comments: p.commentCount,
views: p.views, views: p.views,
time: TimeManager.format(p.createdAt), time: TimeManager.format(
selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,
),
pinned: !!p.pinnedAt, pinned: !!p.pinnedAt,
type: p.type, type: p.type,
})), }))
) articles.value.push(...mapped)
if (data.length < pageSize) { if (data.length < pageSize) {
allLoaded.value = true allLoaded.value = true
} else { } 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 .finally(() => {
if (!res.ok) return inflight = null
const data = await res.json() isLoadingMore.value = false
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)
}) })
}
watch(selectedTopic, () => { /** 绑定滚动加载(避免挂载瞬间触发) **/
fetchContent(true) let initialReady = false
}) const loadMoreGuarded = async () => {
if (!initialReady) return
const sanitizeDescription = (text) => stripMarkdown(text) await fetchNextPage()
}
await Promise.all([loadOptions(), fetchContent()]) useScrollLoadMore(loadMoreGuarded)
watch(
return {
topics,
selectedTopic,
articles, articles,
sanitizeDescription, () => {
isLoadingPosts, if (!initialReady && articles.value.length) initialReady = true
selectedCategory,
selectedTags,
tagOptions,
categoryOptions,
isMobile,
}
}, },
{ immediate: true },
)
/** 切换分类/标签/TabuseAsyncData 已 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> </script>
<style scoped> <style scoped>
@@ -696,6 +636,7 @@ export default {
.header-item.activity { .header-item.activity {
width: 10%; width: 10%;
} }
.article-member-avatar-item:nth-child(n + 4) { .article-member-avatar-item:nth-child(n + 4) {
display: none; display: none;
} }

View File

@@ -51,8 +51,8 @@
</div> </div>
</template> </template>
<script> <script setup>
import { API_BASE_URL, toast } from '~/main' import { toast } from '~/main'
import { setToken, loadCurrentUser } from '~/utils/auth' import { setToken, loadCurrentUser } from '~/utils/auth'
import { googleAuthorize } from '~/utils/google' import { googleAuthorize } from '~/utils/google'
import { githubAuthorize } from '~/utils/github' import { githubAuthorize } from '~/utils/github'
@@ -60,27 +60,19 @@ import { discordAuthorize } from '~/utils/discord'
import { twitterAuthorize } from '~/utils/twitter' import { twitterAuthorize } from '~/utils/twitter'
import BaseInput from '~/components/BaseInput.vue' import BaseInput from '~/components/BaseInput.vue'
import { registerPush } from '~/utils/push' import { registerPush } from '~/utils/push'
export default { const config = useRuntimeConfig()
name: 'LoginPageView', const API_BASE_URL = config.public.apiBaseUrl
components: { BaseInput }, const username = ref('')
setup() { const password = ref('')
return { googleAuthorize } const isWaitingForLogin = ref(false)
},
data() { const submitLogin = async () => {
return {
username: '',
password: '',
isWaitingForLogin: false,
}
},
methods: {
async submitLogin() {
try { try {
this.isWaitingForLogin = true isWaitingForLogin.value = true
const res = await fetch(`${API_BASE_URL}/api/auth/login`, { const res = await fetch(`${API_BASE_URL}/api/auth/login`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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() const data = await res.json()
if (res.ok && data.token) { if (res.ok && data.token) {
@@ -88,35 +80,36 @@ export default {
await loadCurrentUser() await loadCurrentUser()
toast.success('登录成功') toast.success('登录成功')
registerPush() registerPush()
this.$router.push('/') await navigateTo('/', { replace: true })
} else if (data.reason_code === 'NOT_VERIFIED') { } else if (data.reason_code === 'NOT_VERIFIED') {
toast.info('当前邮箱未验证,已经为您重新发送验证码') 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') { } else if (data.reason_code === 'IS_APPROVING') {
toast.info('您的注册正在审批中, 请留意邮件') toast.info('您的注册正在审批中, 请留意邮件')
this.$router.push('/') await navigateTo('/', { replace: true })
} else if (data.reason_code === 'NOT_APPROVED') { } 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 { } else {
toast.error(data.error || '登录失败') toast.error(data.error || '登录失败')
} }
} catch (e) { } catch (e) {
toast.error('登录失败') toast.error('登录失败')
} finally { } finally {
this.isWaitingForLogin = false isWaitingForLogin.value = false
}
} }
},
loginWithGithub() { const loginWithGithub = () => {
githubAuthorize() githubAuthorize()
}, }
loginWithDiscord() { const loginWithDiscord = () => {
discordAuthorize() discordAuthorize()
}, }
loginWithTwitter() { const loginWithTwitter = () => {
twitterAuthorize() twitterAuthorize()
},
},
} }
</script> </script>

View File

@@ -185,6 +185,32 @@
</router-link> </router-link>
</NotificationContainer> </NotificationContainer>
</template> </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'"> <template v-else-if="item.type === 'POST_UPDATED'">
<NotificationContainer :item="item" :markRead="markRead"> <NotificationContainer :item="item" :markRead="markRead">
您关注的帖子 您关注的帖子
@@ -478,10 +504,8 @@
</div> </div>
</template> </template>
<script> <script setup>
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { API_BASE_URL } from '~/main'
import BaseTimeline from '~/components/BaseTimeline.vue' import BaseTimeline from '~/components/BaseTimeline.vue'
import BasePlaceholder from '~/components/BasePlaceholder.vue' import BasePlaceholder from '~/components/BasePlaceholder.vue'
import NotificationContainer from '~/components/NotificationContainer.vue' import NotificationContainer from '~/components/NotificationContainer.vue'
@@ -491,12 +515,8 @@ import { toast } from '~/main'
import { stripMarkdownLength } from '~/utils/markdown' import { stripMarkdownLength } from '~/utils/markdown'
import TimeManager from '~/utils/time' import TimeManager from '~/utils/time'
import { reactionEmojiMap } from '~/utils/reactions' import { reactionEmojiMap } from '~/utils/reactions'
const config = useRuntimeConfig()
export default { const API_BASE_URL = config.public.apiBaseUrl
name: 'MessagePageView',
components: { BaseTimeline, BasePlaceholder, NotificationContainer },
setup() {
const router = useRouter()
const route = useRoute() const route = useRoute()
const notifications = ref([]) const notifications = ref([])
const isLoadingMessage = ref(false) const isLoadingMessage = ref(false)
@@ -505,9 +525,7 @@ export default {
) )
const notificationPrefs = ref([]) const notificationPrefs = ref([])
const filteredNotifications = computed(() => const filteredNotifications = computed(() =>
selectedTab.value === 'all' selectedTab.value === 'all' ? notifications.value : notifications.value.filter((n) => !n.read),
? notifications.value
: notifications.value.filter((n) => !n.read),
) )
const markRead = async (id) => { const markRead = async (id) => {
@@ -565,6 +583,8 @@ export default {
POST_UNSUBSCRIBED: 'fas fa-bookmark', POST_UNSUBSCRIBED: 'fas fa-bookmark',
REGISTER_REQUEST: 'fas fa-user-clock', REGISTER_REQUEST: 'fas fa-user-clock',
ACTIVITY_REDEEM: 'fas fa-coffee', ACTIVITY_REDEEM: 'fas fa-coffee',
LOTTERY_WIN: 'fas fa-trophy',
LOTTERY_DRAW: 'fas fa-bullhorn',
MENTION: 'fas fa-at', MENTION: 'fas fa-at',
} }
@@ -596,7 +616,7 @@ export default {
src: n.comment.author.avatar, src: n.comment.author.avatar,
iconClick: () => { iconClick: () => {
markRead(n.id) markRead(n.id)
router.push(`/users/${n.comment.author.id}`) navigateTo(`/users/${n.comment.author.id}`, { replace: true })
}, },
}) })
} else if (n.type === 'REACTION') { } else if (n.type === 'REACTION') {
@@ -606,7 +626,7 @@ export default {
iconClick: () => { iconClick: () => {
if (n.fromUser) { if (n.fromUser) {
markRead(n.id) markRead(n.id)
router.push(`/users/${n.fromUser.id}`) navigateTo(`/users/${n.fromUser.id}`, { replace: true })
} }
}, },
}) })
@@ -618,7 +638,29 @@ export default {
iconClick: () => { iconClick: () => {
if (n.fromUser) { if (n.fromUser) {
markRead(n.id) 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, src: n.comment.author.avatar,
iconClick: () => { iconClick: () => {
markRead(n.id) markRead(n.id)
router.push(`/users/${n.comment.author.id}`) navigateTo(`/users/${n.comment.author.id}`, { replace: true })
}, },
}) })
} else if (n.type === 'USER_ACTIVITY') { } else if (n.type === 'USER_ACTIVITY') {
@@ -637,7 +679,7 @@ export default {
src: n.comment.author.avatar, src: n.comment.author.avatar,
iconClick: () => { iconClick: () => {
markRead(n.id) markRead(n.id)
router.push(`/users/${n.comment.author.id}`) navigateTo(`/users/${n.comment.author.id}`, { replace: true })
}, },
}) })
} else if (n.type === 'MENTION') { } else if (n.type === 'MENTION') {
@@ -647,7 +689,7 @@ export default {
iconClick: () => { iconClick: () => {
if (n.fromUser) { if (n.fromUser) {
markRead(n.id) markRead(n.id)
router.push(`/users/${n.fromUser.id}`) navigateTo(`/users/${n.fromUser.id}`, { replace: true })
} }
}, },
}) })
@@ -658,7 +700,7 @@ export default {
iconClick: () => { iconClick: () => {
if (n.fromUser) { if (n.fromUser) {
markRead(n.id) markRead(n.id)
router.push(`/users/${n.fromUser.id}`) navigateTo(`/users/${n.fromUser.id}`, { replace: true })
} }
}, },
}) })
@@ -669,7 +711,7 @@ export default {
iconClick: () => { iconClick: () => {
if (n.post) { if (n.post) {
markRead(n.id) markRead(n.id)
router.push(`/posts/${n.post.id}`) navigateTo(`/posts/${n.post.id}`, { replace: true })
} }
}, },
}) })
@@ -680,7 +722,7 @@ export default {
iconClick: () => { iconClick: () => {
if (n.post) { if (n.post) {
markRead(n.id) markRead(n.id)
router.push(`/posts/${n.post.id}`) navigateTo(`/posts/${n.post.id}`, { replace: true })
} }
}, },
}) })
@@ -692,7 +734,7 @@ export default {
iconClick: () => { iconClick: () => {
if (n.post) { if (n.post) {
markRead(n.id) markRead(n.id)
router.push(`/posts/${n.post.id}`) navigateTo(`/posts/${n.post.id}`, { replace: true })
} }
}, },
}) })
@@ -791,6 +833,10 @@ export default {
return '有人申请注册' return '有人申请注册'
case 'ACTIVITY_REDEEM': case 'ACTIVITY_REDEEM':
return '有人申请兑换奶茶' return '有人申请兑换奶茶'
case 'LOTTERY_WIN':
return '抽奖中奖了'
case 'LOTTERY_DRAW':
return '抽奖已开奖'
default: default:
return t return t
} }
@@ -800,25 +846,6 @@ export default {
fetchNotifications() fetchNotifications()
fetchPrefs() fetchPrefs()
}) })
return {
notifications,
formatType,
isLoadingMessage,
stripMarkdownLength,
markRead,
approve,
reject,
TimeManager,
selectedTab,
filteredNotifications,
markAllRead,
authState,
notificationPrefs,
togglePref,
}
},
}
</script> </script>
<style scoped> <style scoped>

View File

@@ -88,21 +88,11 @@ import LoginOverlay from '~/components/LoginOverlay.vue'
import PostEditor from '~/components/PostEditor.vue' import PostEditor from '~/components/PostEditor.vue'
import PostTypeSelect from '~/components/PostTypeSelect.vue' import PostTypeSelect from '~/components/PostTypeSelect.vue'
import TagSelect from '~/components/TagSelect.vue' import TagSelect from '~/components/TagSelect.vue'
import { API_BASE_URL, toast } from '~/main' import { toast } from '~/main'
import { authState, getToken } from '~/utils/auth' 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 title = ref('')
const content = ref('') const content = ref('')
const selectedCategory = ref('') const selectedCategory = ref('')
@@ -392,32 +382,6 @@ export default {
isWaitingPosting.value = false 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> </script>
<style scoped> <style scoped>

View File

@@ -35,20 +35,18 @@
</div> </div>
</template> </template>
<script> <script setup>
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute } from 'vue-router'
import PostEditor from '~/components/PostEditor.vue' import PostEditor from '~/components/PostEditor.vue'
import CategorySelect from '~/components/CategorySelect.vue' import CategorySelect from '~/components/CategorySelect.vue'
import TagSelect from '~/components/TagSelect.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 { getToken, authState } from '~/utils/auth'
import LoginOverlay from '~/components/LoginOverlay.vue' 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 title = ref('')
const content = ref('') const content = ref('')
const selectedCategory = ref('') const selectedCategory = ref('')
@@ -58,7 +56,6 @@ export default {
const isLogin = computed(() => authState.loggedIn) const isLogin = computed(() => authState.loggedIn)
const route = useRoute() const route = useRoute()
const router = useRouter()
const postId = route.params.id const postId = route.params.id
const loadPost = async () => { const loadPost = async () => {
@@ -199,22 +196,7 @@ export default {
} }
} }
const cancelEdit = () => { const cancelEdit = () => {
router.push(`/posts/${postId}`) navigateTo(`/posts/${postId}`, { replace: true })
}
return {
title,
content,
selectedCategory,
selectedTags,
submitPost,
clearPost,
cancelEdit,
isWaitingPosting,
aiGenerate,
isAiLoading,
isLogin,
}
},
} }
</script> </script>

View File

@@ -195,6 +195,7 @@
:comment="item" :comment="item"
:level="0" :level="0"
:default-show-replies="item.openReplies" :default-show-replies="item.openReplies"
:post-author-id="author.id"
@deleted="onCommentDeleted" @deleted="onCommentDeleted"
/> />
</template> </template>
@@ -230,7 +231,7 @@
</div> </div>
</template> </template>
<script> <script setup>
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch } from 'vue' import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'
import VueEasyLightbox from 'vue-easy-lightbox' import VueEasyLightbox from 'vue-easy-lightbox'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
@@ -243,27 +244,17 @@ import ReactionsGroup from '~/components/ReactionsGroup.vue'
import DropdownMenu from '~/components/DropdownMenu.vue' import DropdownMenu from '~/components/DropdownMenu.vue'
import { renderMarkdown, handleMarkdownClick, stripMarkdownLength } from '~/utils/markdown' import { renderMarkdown, handleMarkdownClick, stripMarkdownLength } from '~/utils/markdown'
import { getMedalTitle } from '~/utils/medal' import { getMedalTitle } from '~/utils/medal'
import { API_BASE_URL, toast } from '~/main' import { toast } from '~/main'
import { getToken, authState } from '~/utils/auth' import { getToken, authState } from '~/utils/auth'
import TimeManager from '~/utils/time' import TimeManager from '~/utils/time'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useIsMobile } from '~/utils/screen' import { useIsMobile } from '~/utils/screen'
import Dropdown from '~/components/Dropdown.vue' import Dropdown from '~/components/Dropdown.vue'
import { ClientOnly } from '#components'
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
export default {
name: 'PostPageView',
components: {
CommentItem,
CommentEditor,
BaseTimeline,
ArticleTags,
ArticleCategory,
ReactionsGroup,
DropdownMenu,
VueEasyLightbox,
Dropdown,
},
async setup() {
const route = useRoute() const route = useRoute()
const postId = route.params.id const postId = route.params.id
const router = useRouter() const router = useRouter()
@@ -289,9 +280,7 @@ export default {
const isMobile = useIsMobile() const isMobile = useIsMobile()
const headerHeight = process.client const headerHeight = process.client
? parseFloat( ? parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--header-height')) || 0
getComputedStyle(document.documentElement).getPropertyValue('--header-height'),
) || 0
: 0 : 0
useHead(() => ({ useHead(() => ({
@@ -355,7 +344,7 @@ export default {
updateCountdown() updateCountdown()
countdownTimer = setInterval(updateCountdown, 1000) countdownTimer = setInterval(updateCountdown, 1000)
} }
const gotoUser = (id) => router.push(`/users/${id}`) const gotoUser = (id) => navigateTo(`/users/${id}`, { replace: true })
const articleMenuItems = computed(() => { const articleMenuItems = computed(() => {
const items = [] const items = []
if (isAuthor.value || isAdmin.value) { if (isAuthor.value || isAdmin.value) {
@@ -403,10 +392,11 @@ export default {
avatar: c.author.avatar, avatar: c.author.avatar,
text: c.content, text: c.content,
reactions: c.reactions || [], reactions: c.reactions || [],
pinned: !!c.pinnedAt,
reply: (c.replies || []).map((r) => mapComment(r, c.author.username, level + 1)), reply: (c.replies || []).map((r) => mapComment(r, c.author.username, level + 1)),
openReplies: level === 0, openReplies: level === 0,
src: c.author.avatar, src: c.author.avatar,
iconClick: () => router.push(`/users/${c.author.id}`), iconClick: () => navigateTo(`/users/${c.author.id}`, { replace: true }),
parentUserName: parentUserName, parentUserName: parentUserName,
}) })
@@ -653,7 +643,7 @@ export default {
} }
const editPost = () => { const editPost = () => {
router.push(`/posts/${postId}/edit`) navigateTo(`/posts/${postId}/edit`, { replace: true })
} }
const deletePost = async () => { const deletePost = async () => {
@@ -668,7 +658,7 @@ export default {
}) })
if (res.ok) { if (res.ok) {
toast.success('已删除') toast.success('已删除')
router.push('/') navigateTo('/', { replace: true })
} else { } else {
toast.error('操作失败') toast.error('操作失败')
} }
@@ -778,7 +768,7 @@ export default {
} }
const gotoProfile = () => { const gotoProfile = () => {
router.push(`/users/${author.value.id}`) navigateTo(`/users/${author.value.id}`, { replace: true })
} }
onMounted(async () => { onMounted(async () => {
@@ -792,66 +782,6 @@ export default {
}) })
await fetchPost() 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> </script>
<style> <style>
.post-page-container { .post-page-container {

View File

@@ -64,95 +64,92 @@
</div> </div>
</template> </template>
<script> <script setup>
import { ref, onMounted } from 'vue'
import AvatarCropper from '~/components/AvatarCropper.vue' import AvatarCropper from '~/components/AvatarCropper.vue'
import BaseInput from '~/components/BaseInput.vue' import BaseInput from '~/components/BaseInput.vue'
import Dropdown from '~/components/Dropdown.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' import { fetchCurrentUser, getToken, setToken } from '~/utils/auth'
export default { const config = useRuntimeConfig()
name: 'SettingsPageView', const API_BASE_URL = config.public.apiBaseUrl
components: { BaseInput, Dropdown, AvatarCropper }, const username = ref('')
data() { const introduction = ref('')
return { const usernameError = ref('')
username: '', const avatar = ref('')
introduction: '', const avatarFile = ref(null)
usernameError: '', const tempAvatar = ref('')
avatar: '', const showCropper = ref(false)
avatarFile: null, const role = ref('')
tempAvatar: '', const publishMode = ref('DIRECT')
showCropper: false, const passwordStrength = ref('LOW')
role: '', const aiFormatLimit = ref(3)
publishMode: 'DIRECT', const registerMode = ref('DIRECT')
passwordStrength: 'LOW', const isLoadingPage = ref(false)
aiFormatLimit: 3, const isSaving = ref(false)
registerMode: 'DIRECT',
isLoadingPage: false, onMounted(async () => {
isSaving: false, isLoadingPage.value = true
}
},
async mounted() {
this.isLoadingPage = true
const user = await fetchCurrentUser() const user = await fetchCurrentUser()
if (user) { if (user) {
this.username = user.username username.value = user.username
this.introduction = user.introduction || '' introduction.value = user.introduction || ''
this.avatar = user.avatar avatar.value = user.avatar
this.role = user.role role.value = user.role
if (this.role === 'ADMIN') { if (role.value === 'ADMIN') {
this.loadAdminConfig() loadAdminConfig()
} }
} else { } else {
toast.error('请先登录') toast.error('请先登录')
this.$router.push('/login') navigateTo('/login', { replace: true })
} }
this.isLoadingPage = false isLoadingPage.value = false
}, })
methods: {
onAvatarChange(e) { const onAvatarChange = (e) => {
const file = e.target.files[0] const file = e.target.files[0]
if (file) { if (file) {
const reader = new FileReader() const reader = new FileReader()
reader.onload = () => { reader.onload = () => {
this.tempAvatar = reader.result tempAvatar.value = reader.result
this.showCropper = true showCropper.value = true
} }
reader.readAsDataURL(file) reader.readAsDataURL(file)
} }
}, }
onCropped({ file, url }) { const onCropped = ({ file, url }) => {
this.avatarFile = file avatarFile.value = file
this.avatar = url avatar.value = url
}, }
fetchPublishModes() { const fetchPublishModes = () => {
return Promise.resolve([ return Promise.resolve([
{ id: 'DIRECT', name: '直接发布', icon: 'fas fa-bolt' }, { id: 'DIRECT', name: '直接发布', icon: 'fas fa-bolt' },
{ id: 'REVIEW', name: '审核后发布', icon: 'fas fa-search' }, { id: 'REVIEW', name: '审核后发布', icon: 'fas fa-search' },
]) ])
}, }
fetchPasswordStrengths() { const fetchPasswordStrengths = () => {
return Promise.resolve([ return Promise.resolve([
{ id: 'LOW', name: '低', icon: 'fas fa-lock-open' }, { id: 'LOW', name: '低', icon: 'fas fa-lock-open' },
{ id: 'MEDIUM', name: '中', icon: 'fas fa-lock' }, { id: 'MEDIUM', name: '中', icon: 'fas fa-lock' },
{ id: 'HIGH', name: '高', icon: 'fas fa-user-shield' }, { id: 'HIGH', name: '高', icon: 'fas fa-user-shield' },
]) ])
}, }
fetchAiLimits() { const fetchAiLimits = () => {
return Promise.resolve([ return Promise.resolve([
{ id: 3, name: '3次' }, { id: 3, name: '3次' },
{ id: 5, name: '5次' }, { id: 5, name: '5次' },
{ id: 10, name: '10次' }, { id: 10, name: '10次' },
{ id: -1, name: '无限' }, { id: -1, name: '无限' },
]) ])
}, }
fetchRegisterModes() { const fetchRegisterModes = () => {
return Promise.resolve([ return Promise.resolve([
{ id: 'DIRECT', name: '直接注册', icon: 'fas fa-user-check' }, { id: 'DIRECT', name: '直接注册', icon: 'fas fa-user-check' },
{ id: 'WHITELIST', name: '白名单邀请制', icon: 'fas fa-envelope' }, { id: 'WHITELIST', name: '白名单邀请制', icon: 'fas fa-envelope' },
]) ])
}, }
async loadAdminConfig() { const loadAdminConfig = async () => {
try { try {
const token = getToken() const token = getToken()
const res = await fetch(`${API_BASE_URL}/api/admin/config`, { const res = await fetch(`${API_BASE_URL}/api/admin/config`, {
@@ -160,31 +157,31 @@ export default {
}) })
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json()
this.publishMode = data.publishMode publishMode.value = data.publishMode
this.passwordStrength = data.passwordStrength passwordStrength.value = data.passwordStrength
this.aiFormatLimit = data.aiFormatLimit aiFormatLimit.value = data.aiFormatLimit
this.registerMode = data.registerMode registerMode.value = data.registerMode
} }
} catch (e) { } catch (e) {
// ignore // ignore
} }
}, }
async save() { const save = async () => {
this.isSaving = true isSaving.value = true
do { do {
let token = getToken() let token = getToken()
this.usernameError = '' usernameError.value = ''
if (!this.username) { if (!username.value) {
this.usernameError = '用户名不能为空' usernameError.value = '用户名不能为空'
} }
if (this.usernameError) { if (usernameError.value) {
toast.error(this.usernameError) toast.error(usernameError.value)
break break
} }
if (this.avatarFile) { if (avatarFile.value) {
const form = new FormData() 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`, { const res = await fetch(`${API_BASE_URL}/api/users/me/avatar`, {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
@@ -192,7 +189,7 @@ export default {
}) })
const data = await res.json() const data = await res.json()
if (res.ok) { if (res.ok) {
this.avatar = data.url avatar.value = data.url
} else { } else {
toast.error(data.error || '上传失败') toast.error(data.error || '上传失败')
break break
@@ -201,7 +198,7 @@ export default {
const res = await fetch(`${API_BASE_URL}/api/users/me`, { const res = await fetch(`${API_BASE_URL}/api/users/me`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, 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() const data = await res.json()
@@ -213,24 +210,22 @@ export default {
setToken(data.token) setToken(data.token)
token = data.token token = data.token
} }
if (this.role === 'ADMIN') { if (role.value === 'ADMIN') {
await fetch(`${API_BASE_URL}/api/admin/config`, { await fetch(`${API_BASE_URL}/api/admin/config`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ body: JSON.stringify({
publishMode: this.publishMode, publishMode: publishMode.value,
passwordStrength: this.passwordStrength, passwordStrength: passwordStrength.value,
aiFormatLimit: this.aiFormatLimit, aiFormatLimit: aiFormatLimit.value,
registerMode: this.registerMode, registerMode: registerMode.value,
}), }),
}) })
} }
toast.success('保存成功') toast.success('保存成功')
} while (!this.isSaving) } while (!isSaving.value)
this.isSaving = false isSaving.value = false
},
},
} }
</script> </script>

View File

@@ -18,36 +18,32 @@
</div> </div>
</template> </template>
<script> <script setup>
import BaseInput from '~/components/BaseInput.vue' 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 { const reason = ref('')
name: 'SignupReasonPageView', const error = ref('')
components: { BaseInput }, const isWaitingForRegister = ref(false)
data() { const token = ref('')
return {
reason: '', onMounted(async () => {
error: '', token.value = route.query.token || ''
isWaitingForRegister: false, if (!token.value) {
token: '', await navigateTo({ path: '/signup' }, { replace: true })
} }
}, })
mounted() {
this.token = this.$route.query.token || '' const submit = async () => {
if (!this.token) { if (!reason.value || reason.value.trim().length < 20) {
this.$router.push('/signup') error.value = '请至少输入20个字'
}
},
methods: {
async submit() {
if (!this.reason || this.reason.trim().length < 20) {
this.error = '请至少输入20个字'
return return
} }
try { try {
this.isWaitingForRegister = true isWaitingForRegister.value = true
const res = await fetch(`${API_BASE_URL}/api/auth/reason`, { const res = await fetch(`${API_BASE_URL}/api/auth/reason`, {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -58,23 +54,21 @@ export default {
reason: this.reason, reason: this.reason,
}), }),
}) })
this.isWaitingForRegister = false isWaitingForRegister.value = false
const data = await res.json() const data = await res.json()
if (res.ok) { if (res.ok) {
toast.success('注册理由已提交,请等待审核') toast.success('注册理由已提交,请等待审核')
this.$router.push('/') await navigateTo('/', { replace: true })
} else if (data.reason_code === 'INVALID_CREDENTIALS') { } else if (data.reason_code === 'INVALID_CREDENTIALS') {
toast.error('登录已过期,请重新登录') toast.error('登录已过期,请重新登录')
this.$router.push('/login') await navigateTo('/login', { replace: true })
} else { } else {
toast.error(data.error || '提交失败') toast.error(data.error || '提交失败')
} }
} catch (e) { } catch (e) {
this.isWaitingForRegister = false isWaitingForRegister.value = false
toast.error('提交失败') toast.error('提交失败')
} }
},
},
} }
</script> </script>

View File

@@ -89,115 +89,110 @@
</div> </div>
</template> </template>
<script> <script setup>
import BaseInput from '~/components/BaseInput.vue' import BaseInput from '~/components/BaseInput.vue'
import { API_BASE_URL, toast } from '~/main' import { toast } from '~/main'
import { discordAuthorize } from '~/utils/discord' import { discordAuthorize } from '~/utils/discord'
import { githubAuthorize } from '~/utils/github' import { githubAuthorize } from '~/utils/github'
import { googleAuthorize } from '~/utils/google' import { googleAuthorize } from '~/utils/google'
import { twitterAuthorize } from '~/utils/twitter' import { twitterAuthorize } from '~/utils/twitter'
export default { const config = useRuntimeConfig()
name: 'SignupPageView', const API_BASE_URL = config.public.apiBaseUrl
components: { BaseInput }, const emailStep = ref(0)
setup() { const email = ref('')
return { googleAuthorize } const username = ref('')
}, const password = ref('')
data() { const registerMode = ref('DIRECT')
return { const emailError = ref('')
emailStep: 0, const usernameError = ref('')
email: '', const passwordError = ref('')
username: '', const code = ref('')
password: '', const isWaitingForEmailSent = ref(false)
registerMode: 'DIRECT', const isWaitingForEmailVerified = ref(false)
emailError: '',
usernameError: '', onMounted(async () => {
passwordError: '', username.value = route.query.u || ''
code: '',
isWaitingForEmailSent: false,
isWaitingForEmailVerified: false,
}
},
async mounted() {
this.username = this.$route.query.u || ''
try { try {
const res = await fetch(`${API_BASE_URL}/api/config`) const res = await fetch(`${API_BASE_URL}/api/config`)
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json()
this.registerMode = data.registerMode registerMode.value = data.registerMode
} }
} catch { } catch {
/* ignore */ /* ignore */
} }
if (this.$route.query.verify) { if (route.query.verify) {
this.emailStep = 1 emailStep.value = 1
} }
}, })
methods: {
clearErrors() { const clearErrors = () => {
this.emailError = '' emailError.value = ''
this.usernameError = '' usernameError.value = ''
this.passwordError = '' passwordError.value = ''
}, }
async sendVerification() {
this.clearErrors() const sendVerification = async () => {
clearErrors()
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(this.email)) { if (!emailRegex.test(email.value)) {
this.emailError = '邮箱格式不正确' emailError.value = '邮箱格式不正确'
} }
if (!this.password || this.password.length < 6) { if (!password.value || password.value.length < 6) {
this.passwordError = '密码至少6位' passwordError.value = '密码至少6位'
} }
if (!this.username) { if (!username.value) {
this.usernameError = '用户名不能为空' usernameError.value = '用户名不能为空'
} }
if (this.emailError || this.passwordError || this.usernameError) { if (emailError.value || passwordError.value || usernameError.value) {
return return
} }
try { try {
this.isWaitingForEmailSent = true isWaitingForEmailSent.value = true
const res = await fetch(`${API_BASE_URL}/api/auth/register`, { const res = await fetch(`${API_BASE_URL}/api/auth/register`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
username: this.username, username: username.value,
email: this.email, email: email.value,
password: this.password, password: password.value,
}), }),
}) })
this.isWaitingForEmailSent = false isWaitingForEmailSent.value = false
const data = await res.json() const data = await res.json()
if (res.ok) { if (res.ok) {
this.emailStep = 1 emailStep.value = 1
toast.success('验证码已发送,请查看邮箱') toast.success('验证码已发送,请查看邮箱')
} else if (data.field) { } else if (data.field) {
if (data.field === 'username') this.usernameError = data.error if (data.field === 'username') usernameError.value = data.error
if (data.field === 'email') this.emailError = data.error if (data.field === 'email') emailError.value = data.error
if (data.field === 'password') this.passwordError = data.error if (data.field === 'password') passwordError.value = data.error
} else { } else {
toast.error(data.error || '发送失败') toast.error(data.error || '发送失败')
} }
} catch (e) { } catch (e) {
toast.error('发送失败') toast.error('发送失败')
} }
}, }
async verifyCode() {
const verifyCode = async () => {
try { try {
this.isWaitingForEmailVerified = true isWaitingForEmailVerified.value = true
const res = await fetch(`${API_BASE_URL}/api/auth/verify`, { const res = await fetch(`${API_BASE_URL}/api/auth/verify`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
code: this.code, code: code.value,
username: this.username, username: username.value,
}), }),
}) })
const data = await res.json() const data = await res.json()
if (res.ok) { if (res.ok) {
if (this.registerMode === 'WHITELIST') { if (registerMode.value === 'WHITELIST') {
this.$router.push('/signup-reason?token=' + data.token) navigateTo(`/signup-reason?token=${data.token}`, { replace: true })
} else { } else {
toast.success('注册成功,请登录') toast.success('注册成功,请登录')
this.$router.push('/login') navigateTo('/login', { replace: true })
} }
} else { } else {
toast.error(data.error || '注册失败') toast.error(data.error || '注册失败')
@@ -205,19 +200,17 @@ export default {
} catch (e) { } catch (e) {
toast.error('注册失败') toast.error('注册失败')
} finally { } finally {
this.isWaitingForEmailVerified = false isWaitingForEmailVerified.value = false
} }
}, }
signupWithGithub() { const signupWithGithub = () => {
githubAuthorize() githubAuthorize()
}, }
signupWithDiscord() { const signupWithDiscord = () => {
discordAuthorize() discordAuthorize()
}, }
signupWithTwitter() { const signupWithTwitter = () => {
twitterAuthorize() twitterAuthorize()
},
},
} }
</script> </script>

View File

@@ -2,24 +2,20 @@
<CallbackPage /> <CallbackPage />
</template> </template>
<script> <script setup>
import CallbackPage from '~/components/CallbackPage.vue' import CallbackPage from '~/components/CallbackPage.vue'
import { twitterExchange } from '~/utils/twitter' import { twitterExchange } from '~/utils/twitter'
export default { onMounted(async () => {
name: 'TwitterCallbackPageView',
components: { CallbackPage },
async mounted() {
const url = new URL(window.location.href) const url = new URL(window.location.href)
const code = url.searchParams.get('code') const code = url.searchParams.get('code')
const state = url.searchParams.get('state') const state = url.searchParams.get('state')
const result = await twitterExchange(code, state, '') const result = await twitterExchange(code, state, '')
if (result.needReason) { if (result.needReason) {
this.$router.push('/signup-reason?token=' + result.token) navigateTo(`/signup-reason?token=${result.token}`, { replace: true })
} else { } else {
this.$router.push('/') navigateTo('/', { replace: true })
}
},
} }
})
</script> </script>

View File

@@ -296,28 +296,25 @@
</div> </div>
</template> </template>
<script> <script setup>
import { ref, computed, onMounted, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { API_BASE_URL, toast } from '~/main' import AchievementList from '~/components/AchievementList.vue'
import { getToken, authState } from '~/utils/auth'
import BaseTimeline from '~/components/BaseTimeline.vue'
import UserList from '~/components/UserList.vue'
import BasePlaceholder from '~/components/BasePlaceholder.vue' import BasePlaceholder from '~/components/BasePlaceholder.vue'
import BaseTimeline from '~/components/BaseTimeline.vue'
import LevelProgress from '~/components/LevelProgress.vue' import LevelProgress from '~/components/LevelProgress.vue'
import UserList from '~/components/UserList.vue'
import { toast } from '~/main'
import { authState, getToken } from '~/utils/auth'
import { prevLevelExp } from '~/utils/level'
import { stripMarkdown, stripMarkdownLength } from '~/utils/markdown' import { stripMarkdown, stripMarkdownLength } from '~/utils/markdown'
import TimeManager from '~/utils/time' import TimeManager from '~/utils/time'
import { prevLevelExp } from '~/utils/level' const config = useRuntimeConfig()
import AchievementList from '~/components/AchievementList.vue' const API_BASE_URL = config.public.apiBaseUrl
definePageMeta({ definePageMeta({
alias: ['/users/:id/'], alias: ['/users/:id/'],
}) })
export default {
name: 'ProfileView',
components: { BaseTimeline, UserList, BasePlaceholder, LevelProgress, AchievementList },
setup() {
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const username = route.params.id const username = route.params.id
@@ -509,7 +506,7 @@ export default {
const gotoTag = (tag) => { const gotoTag = (tag) => {
const value = encodeURIComponent(tag.id ?? tag.name) const value = encodeURIComponent(tag.id ?? tag.name)
router.push({ path: '/', query: { tags: value } }) navigateTo({ path: '/', query: { tags: value } }, { replace: true })
} }
const init = async () => { const init = async () => {
@@ -537,46 +534,12 @@ export default {
// router.replace({ query: { ...route.query, tab: val } }) // router.replace({ query: { ...route.query, tab: val } })
if (val === 'timeline' && timelineItems.value.length === 0) { if (val === 'timeline' && timelineItems.value.length === 0) {
await loadTimeline() await loadTimeline()
} else if ( } else if (val === 'following' && followers.value.length === 0 && followings.value.length === 0) {
val === 'following' &&
followers.value.length === 0 &&
followings.value.length === 0
) {
await loadFollow() await loadFollow()
} else if (val === 'achievements' && medals.value.length === 0) { } else if (val === 'achievements' && medals.value.length === 0) {
await loadAchievements() 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> </script>
<style scoped> <style scoped>

View File

@@ -1,3 +1,4 @@
import { defineNuxtPlugin } from 'nuxt/app'
import ClickOutside from '~/directives/clickOutside.js' import ClickOutside from '~/directives/clickOutside.js'
export default defineNuxtPlugin((nuxtApp) => { export default defineNuxtPlugin((nuxtApp) => {

View File

@@ -1,5 +1,5 @@
// plugins/ldrs.client.ts // plugins/ldrs.client.ts
import { defineNuxtPlugin } from '#app' import { defineNuxtPlugin } from 'nuxt/app'
export default defineNuxtPlugin(async () => { export default defineNuxtPlugin(async () => {
// 动态引入,防止打包时把 ldrs 拉进 SSR bundle // 动态引入,防止打包时把 ldrs 拉进 SSR bundle

View File

@@ -1,5 +1,6 @@
import NProgress from 'nprogress' import NProgress from 'nprogress'
import 'nprogress/nprogress.css' import 'nprogress/nprogress.css'
import { defineNuxtPlugin } from 'nuxt/app'
export default defineNuxtPlugin((nuxtApp) => { export default defineNuxtPlugin((nuxtApp) => {
NProgress.configure({ showSpinner: false }) NProgress.configure({ showSpinner: false })
@@ -12,7 +13,7 @@ export default defineNuxtPlugin((nuxtApp) => {
NProgress.done() NProgress.done()
}) })
nuxtApp.hook('page:error', () => { nuxtApp.hook('app:error', () => {
NProgress.done() NProgress.done()
}) })
}) })

View File

@@ -0,0 +1,9 @@
import { defineNuxtPlugin } from 'nuxt/app'
export default defineNuxtPlugin((nuxtApp) => {
// 覆盖默认行为:收到 manifest 更新时,不立刻在路由切换里刷新
nuxtApp.hooks.hook('app:manifest:update', () => {
// todo 选择:弹个提示,让用户点击刷新;或延迟到页面隐藏时再刷新
// 例如document.addEventListener('visibilitychange', () => { if (document.hidden) location.reload() })
})
})

View File

@@ -1,4 +1,4 @@
import { defineNuxtPlugin } from '#app' import { defineNuxtPlugin } from 'nuxt/app'
import { initTheme } from '~/utils/theme' import { initTheme } from '~/utils/theme'
export default defineNuxtPlugin(() => { export default defineNuxtPlugin(() => {

View File

@@ -1,4 +1,4 @@
import { defineNuxtPlugin } from '#app' import { defineNuxtPlugin } from 'nuxt/app'
import 'vue-toastification/dist/index.css' import 'vue-toastification/dist/index.css'
import '~/assets/toast.css' import '~/assets/toast.css'

View File

@@ -1,7 +0,0 @@
export default {
push(path) {
if (process.client) {
window.location.href = path
}
},
}

View File

@@ -1,4 +1,3 @@
import { API_BASE_URL } from '~/main'
import { reactive } from 'vue' import { reactive } from 'vue'
const TOKEN_KEY = 'token' const TOKEN_KEY = 'token'
@@ -65,6 +64,8 @@ export function clearUserInfo() {
} }
export async function fetchCurrentUser() { export async function fetchCurrentUser() {
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const token = getToken() const token = getToken()
if (!token) return null if (!token) return null
try { try {
@@ -91,6 +92,8 @@ export function isLogin() {
} }
export async function checkToken() { export async function checkToken() {
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const token = getToken() const token = getToken()
if (!token) return false if (!token) return false
try { try {

View File

@@ -1,9 +1,11 @@
import { API_BASE_URL, DISCORD_CLIENT_ID, toast } from '../main' import { toast } from '../main'
import { WEBSITE_BASE_URL } from '../constants'
import { setToken, loadCurrentUser } from './auth' import { setToken, loadCurrentUser } from './auth'
import { registerPush } from './push' import { registerPush } from './push'
export function discordAuthorize(state = '') { 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) { if (!DISCORD_CLIENT_ID) {
toast.error('Discord 登录不可用') toast.error('Discord 登录不可用')
return return
@@ -15,6 +17,8 @@ export function discordAuthorize(state = '') {
export async function discordExchange(code, state, reason) { export async function discordExchange(code, state, reason) {
try { try {
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const res = await fetch(`${API_BASE_URL}/api/auth/discord`, { const res = await fetch(`${API_BASE_URL}/api/auth/discord`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },

View File

@@ -1,9 +1,11 @@
import { API_BASE_URL, GITHUB_CLIENT_ID, toast } from '../main' import { toast } from '../main'
import { setToken, loadCurrentUser } from './auth' import { setToken, loadCurrentUser } from './auth'
import { WEBSITE_BASE_URL } from '../constants'
import { registerPush } from './push' import { registerPush } from './push'
export function githubAuthorize(state = '') { 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) { if (!GITHUB_CLIENT_ID) {
toast.error('GitHub 登录不可用') toast.error('GitHub 登录不可用')
return return
@@ -15,6 +17,8 @@ export function githubAuthorize(state = '') {
export async function githubExchange(code, state, reason) { export async function githubExchange(code, state, reason) {
try { try {
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const res = await fetch(`${API_BASE_URL}/api/auth/github`, { const res = await fetch(`${API_BASE_URL}/api/auth/github`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },

View File

@@ -1,9 +1,11 @@
import { API_BASE_URL, GOOGLE_CLIENT_ID, toast } from '../main' import { toast } from '../main'
import { setToken, loadCurrentUser } from './auth' import { setToken, loadCurrentUser } from './auth'
import { registerPush } from './push' import { registerPush } from './push'
import { WEBSITE_BASE_URL } from '../constants'
export async function googleGetIdToken() { export async function googleGetIdToken() {
const config = useRuntimeConfig()
const GOOGLE_CLIENT_ID = config.public.googleClientId
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (!window.google || !GOOGLE_CLIENT_ID) { if (!window.google || !GOOGLE_CLIENT_ID) {
toast.error('Google 登录不可用, 请检查网络设置与VPN') toast.error('Google 登录不可用, 请检查网络设置与VPN')
@@ -20,6 +22,8 @@ export async function googleGetIdToken() {
} }
export function googleAuthorize() { export function googleAuthorize() {
const config = useRuntimeConfig()
const GOOGLE_CLIENT_ID = config.public.googleClientId
if (!GOOGLE_CLIENT_ID) { if (!GOOGLE_CLIENT_ID) {
toast.error('Google 登录不可用, 请检查网络设置与VPN') toast.error('Google 登录不可用, 请检查网络设置与VPN')
return return
@@ -32,6 +36,8 @@ export function googleAuthorize() {
export async function googleAuthWithToken(idToken, redirect_success, redirect_not_approved) { export async function googleAuthWithToken(idToken, redirect_success, redirect_not_approved) {
try { try {
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const res = await fetch(`${API_BASE_URL}/api/auth/google`, { const res = await fetch(`${API_BASE_URL}/api/auth/google`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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() { export function loginWithGoogle() {
googleSignIn( googleSignIn(
() => { () => {
router.push('/') navigateTo('/', { replace: true })
}, },
(token) => { (token) => {
router.push('/signup-reason?token=' + token) navigateTo(`/signup-reason?token=${token}`, { replace: true })
}, },
) )
} }

View File

@@ -1,6 +1,6 @@
import MarkdownIt from 'markdown-it'
import hljs from 'highlight.js' import hljs from 'highlight.js'
import 'highlight.js/styles/github.css' import 'highlight.js/styles/github.css'
import MarkdownIt from 'markdown-it'
import { toast } from '../main' import { toast } from '../main'
import { tiebaEmoji } from './tiebaEmoji' import { tiebaEmoji } from './tiebaEmoji'
@@ -50,6 +50,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({ const md = new MarkdownIt({
html: false, html: false,
linkify: true, linkify: true,
@@ -67,6 +92,7 @@ const md = new MarkdownIt({
md.use(mentionPlugin) md.use(mentionPlugin)
md.use(tiebaEmojiPlugin) md.use(tiebaEmojiPlugin)
md.use(linkPlugin) // 添加链接插件
export function renderMarkdown(text) { export function renderMarkdown(text) {
return md.render(text || '') return md.render(text || '')
@@ -86,18 +112,19 @@ export function handleMarkdownClick(e) {
export function stripMarkdown(text) { export function stripMarkdown(text) {
const html = md.render(text || '') const html = md.render(text || '')
// SSR 环境下没有 document
if (typeof window === 'undefined') { // 统一使用正则表达式方法,确保服务端和客户端行为一致
// 用正则去除 HTML 标签 let plainText = html.replace(/<[^>]+>/g, '')
return html
.replace(/<[^>]+>/g, '') // 标准化空白字符处理
.replace(/\s+/g, ' ') plainText = plainText
.replace(/\r\n/g, '\n') // Windows换行符转为Unix格式
.replace(/\r/g, '\n') // 旧Mac换行符转为Unix格式
.replace(/[ \t]+/g, ' ') // 合并空格和制表符为单个空格
.replace(/\n{3,}/g, '\n\n') // 最多保留两个连续换行(一个空行)
.trim() .trim()
} else {
const el = document.createElement('div') return plainText
el.innerHTML = html
return el.textContent || el.innerText || ''
}
} }
export function stripMarkdownLength(text, length) { export function stripMarkdownLength(text, length) {

View File

@@ -1,4 +1,3 @@
import { API_BASE_URL } from '~/main'
import { getToken } from './auth' import { getToken } from './auth'
import { reactive } from 'vue' import { reactive } from 'vue'
@@ -7,6 +6,8 @@ export const notificationState = reactive({
}) })
export async function fetchUnreadCount() { export async function fetchUnreadCount() {
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
try { try {
const token = getToken() const token = getToken()
if (!token) { if (!token) {
@@ -31,6 +32,9 @@ export async function fetchUnreadCount() {
export async function markNotificationsRead(ids) { export async function markNotificationsRead(ids) {
try { try {
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const token = getToken() const token = getToken()
if (!token || !ids || ids.length === 0) return false if (!token || !ids || ids.length === 0) return false
const res = await fetch(`${API_BASE_URL}/api/notifications/read`, { const res = await fetch(`${API_BASE_URL}/api/notifications/read`, {
@@ -49,6 +53,9 @@ export async function markNotificationsRead(ids) {
export async function fetchNotificationPreferences() { export async function fetchNotificationPreferences() {
try { try {
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const token = getToken() const token = getToken()
if (!token) return [] if (!token) return []
const res = await fetch(`${API_BASE_URL}/api/notifications/prefs`, { const res = await fetch(`${API_BASE_URL}/api/notifications/prefs`, {
@@ -63,6 +70,8 @@ export async function fetchNotificationPreferences() {
export async function updateNotificationPreference(type, enabled) { export async function updateNotificationPreference(type, enabled) {
try { try {
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const token = getToken() const token = getToken()
if (!token) return false if (!token) return false
const res = await fetch(`${API_BASE_URL}/api/notifications/prefs`, { const res = await fetch(`${API_BASE_URL}/api/notifications/prefs`, {

View File

@@ -1,4 +1,3 @@
import { API_BASE_URL } from '../main'
import { getToken } from './auth' import { getToken } from './auth'
function urlBase64ToUint8Array(base64String) { function urlBase64ToUint8Array(base64String) {
@@ -21,6 +20,8 @@ function arrayBufferToBase64(buffer) {
export async function registerPush() { export async function registerPush() {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return if (!('serviceWorker' in navigator) || !('PushManager' in window)) return
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
try { try {
const reg = await navigator.serviceWorker.register('/notifications-sw.js') const reg = await navigator.serviceWorker.register('/notifications-sw.js')
const res = await fetch(`${API_BASE_URL}/api/push/public-key`) const res = await fetch(`${API_BASE_URL}/api/push/public-key`)

View File

@@ -1,5 +1,4 @@
import { API_BASE_URL, TWITTER_CLIENT_ID, toast } from '../main' import { toast } from '../main'
import { WEBSITE_BASE_URL } from '../constants'
import { setToken, loadCurrentUser } from './auth' import { setToken, loadCurrentUser } from './auth'
import { registerPush } from './push' import { registerPush } from './push'
@@ -22,6 +21,9 @@ async function generateCodeChallenge(codeVerifier) {
} }
export async function twitterAuthorize(state = '') { 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) { if (!TWITTER_CLIENT_ID) {
toast.error('Twitter 登录不可用') toast.error('Twitter 登录不可用')
return return
@@ -42,6 +44,8 @@ export async function twitterAuthorize(state = '') {
export async function twitterExchange(code, state, reason) { export async function twitterExchange(code, state, reason) {
try { try {
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const codeVerifier = sessionStorage.getItem('twitter_code_verifier') const codeVerifier = sessionStorage.getItem('twitter_code_verifier')
sessionStorage.removeItem('twitter_code_verifier') sessionStorage.removeItem('twitter_code_verifier')
const res = await fetch(`${API_BASE_URL}/api/auth/twitter`, { const res = await fetch(`${API_BASE_URL}/api/auth/twitter`, {

View File

@@ -1,7 +1,7 @@
import { API_BASE_URL } from '../main'
export async function fetchFollowings(username) { export async function fetchFollowings(username) {
if (!username) return [] if (!username) return []
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
try { try {
const res = await fetch(`${API_BASE_URL}/api/users/${username}/following`) const res = await fetch(`${API_BASE_URL}/api/users/${username}/following`)
return res.ok ? await res.json() : [] return res.ok ? await res.json() : []
@@ -11,6 +11,8 @@ export async function fetchFollowings(username) {
} }
export async function fetchAdmins() { export async function fetchAdmins() {
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
try { try {
const res = await fetch(`${API_BASE_URL}/api/users/admins`) const res = await fetch(`${API_BASE_URL}/api/users/admins`)
return res.ok ? await res.json() : [] return res.ok ? await res.json() : []
@@ -21,6 +23,8 @@ export async function fetchAdmins() {
export async function searchUsers(keyword) { export async function searchUsers(keyword) {
if (!keyword) return [] if (!keyword) return []
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
try { try {
const res = await fetch( const res = await fetch(
`${API_BASE_URL}/api/search/users?keyword=${encodeURIComponent(keyword)}`, `${API_BASE_URL}/api/search/users?keyword=${encodeURIComponent(keyword)}`,

View File

@@ -1,5 +1,4 @@
import Vditor from 'vditor' import Vditor from 'vditor'
import { API_BASE_URL } from '../main'
import { getToken, authState } from './auth' import { getToken, authState } from './auth'
import { searchUsers, fetchFollowings, fetchAdmins } from './user' import { searchUsers, fetchFollowings, fetchAdmins } from './user'
import { tiebaEmoji } from './tiebaEmoji' import { tiebaEmoji } from './tiebaEmoji'
@@ -14,6 +13,8 @@ export function getPreviewTheme() {
export function createVditor(editorId, options = {}) { export function createVditor(editorId, options = {}) {
const { placeholder = '', preview = {}, input, after } = options const { placeholder = '', preview = {}, input, after } = options
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
const fetchMentions = async (value) => { const fetchMentions = async (value) => {
if (!value) { if (!value) {