Compare commits

...

16 Commits

Author SHA1 Message Date
Tim
0edbeabac2 feat: allow post authors to pin comments 2025-08-13 16:30:48 +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
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
Tim
720031770d Merge branch 'main' into feature/message_control 2025-08-12 17:43:36 +08:00
Tim
eb7a25434f fix: global popup 2025-08-12 17:34:13 +08:00
Tim
bda4b24cf0 Revert "Disable post viewed and user activity notifications by default"
This reverts commit aea4f59af7.
2025-08-12 17:31:01 +08:00
Tim
4dedb70d54 Merge pull request #519 from nagisa77/codex/enable-user-notification-filtering
Disable post-viewed and user-activity notifications by default
2025-08-12 17:18:15 +08:00
Tim
84ed778dc0 Merge pull request #518 from nagisa77/codex/add-notification-settings-pop-up
feat: add notification settings popup
2025-08-12 16:41:13 +08:00
Tim
6ca1862034 feat: allow navigating to notification settings 2025-08-12 16:29:04 +08:00
Tim
b3ea41ad1e Merge pull request #513 from AnNingUI/main
fix: 统一使用绝对路径别名“~”并加入jsconfig方便编辑器跳转
2025-08-12 15:19:34 +08:00
AnNingUI
80ecb1620d fix: 统一使用绝对路径别名“~”并加入jsconfig方便编辑器跳转
Fixes #510
2025-08-12 14:45:55 +08:00
51 changed files with 570 additions and 299 deletions

View File

@@ -1,39 +0,0 @@
package com.openisle.config;
import com.openisle.model.NotificationType;
import com.openisle.model.User;
import com.openisle.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Set;
/**
* Ensure default notification preferences are applied to existing users.
*/
@Component
@RequiredArgsConstructor
public class NotificationPreferenceInitializer implements CommandLineRunner {
private final UserRepository userRepository;
@Override
public void run(String... args) {
List<User> users = userRepository.findAll();
for (User user : users) {
Set<NotificationType> disabled = user.getDisabledNotificationTypes();
boolean changed = false;
if (disabled.add(NotificationType.POST_VIEWED)) {
changed = true;
}
if (disabled.add(NotificationType.USER_ACTIVITY)) {
changed = true;
}
if (changed) {
userRepository.save(user);
}
}
}
}

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

@@ -85,4 +85,16 @@ public class CommentController {
commentService.deleteComment(auth.getName(), id);
log.debug("deleteComment completed for comment {}", id);
}
@PostMapping("/comments/{id}/pin")
public CommentDto pinComment(@PathVariable Long id, Authentication auth) {
log.debug("pinComment called by user {} for comment {}", auth.getName(), id);
return commentMapper.toDto(commentService.pinComment(auth.getName(), id));
}
@PostMapping("/comments/{id}/unpin")
public CommentDto unpinComment(@PathVariable Long id, Authentication auth) {
log.debug("unpinComment called by user {} for comment {}", auth.getName(), id);
return commentMapper.toDto(commentService.unpinComment(auth.getName(), id));
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ import org.hibernate.annotations.CreationTimestamp;
import java.time.LocalDateTime;
import java.util.EnumSet;
import java.util.HashSet;
import java.util.Set;
/**

View File

@@ -23,6 +23,7 @@ import java.util.List;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
@Service
@RequiredArgsConstructor
@@ -129,13 +130,26 @@ public class CommentService {
Post post = postRepository.findById(postId)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found"));
List<Comment> list = commentRepository.findByPostAndParentIsNullOrderByCreatedAtAsc(post);
if (sort == CommentSort.NEWEST) {
list.sort(java.util.Comparator.comparing(Comment::getCreatedAt).reversed());
} else if (sort == CommentSort.MOST_INTERACTIONS) {
list.sort((a, b) -> Integer.compare(interactionCount(b), interactionCount(a)));
java.util.List<Comment> pinned = new java.util.ArrayList<>();
java.util.List<Comment> others = new java.util.ArrayList<>();
for (Comment c : list) {
if (c.getPinnedAt() != null) {
pinned.add(c);
} else {
others.add(c);
}
}
log.debug("getCommentsForPost returning {} comments", list.size());
return list;
pinned.sort(java.util.Comparator.comparing(Comment::getPinnedAt).reversed());
if (sort == CommentSort.NEWEST) {
others.sort(java.util.Comparator.comparing(Comment::getCreatedAt).reversed());
} else if (sort == CommentSort.MOST_INTERACTIONS) {
others.sort((a, b) -> Integer.compare(interactionCount(b), interactionCount(a)));
}
java.util.List<Comment> result = new java.util.ArrayList<>();
result.addAll(pinned);
result.addAll(others);
log.debug("getCommentsForPost returning {} comments", result.size());
return result;
}
public List<Comment> getReplies(Long parentId) {
@@ -223,6 +237,32 @@ public class CommentService {
log.debug("deleteCommentCascade removed comment {}", comment.getId());
}
@Transactional
public Comment pinComment(String username, Long id) {
Comment c = commentRepository.findById(id)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Comment not found"));
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
if (!user.getId().equals(c.getPost().getAuthor().getId()) && user.getRole() != Role.ADMIN) {
throw new IllegalArgumentException("Unauthorized");
}
c.setPinnedAt(LocalDateTime.now());
return commentRepository.save(c);
}
@Transactional
public Comment unpinComment(String username, Long id) {
Comment c = commentRepository.findById(id)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("Comment not found"));
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
if (!user.getId().equals(c.getPost().getAuthor().getId()) && user.getRole() != Role.ADMIN) {
throw new IllegalArgumentException("Unauthorized");
}
c.setPinnedAt(null);
return commentRepository.save(c);
}
private int interactionCount(Comment comment) {
int reactions = reactionRepository.findByComment(comment).size();
int replies = commentRepository.findByParentOrderByCreatedAtAsc(comment).size();

View File

@@ -69,6 +69,8 @@ public class PostService {
private final EmailSender emailSender;
private final ApplicationContext applicationContext;
private final ConcurrentMap<Long, ScheduledFuture<?>> scheduledFinalizations = new ConcurrentHashMap<>();
@Value("${app.website-url:https://www.open-isle.com}")
private String websiteUrl;
@org.springframework.beans.factory.annotation.Autowired
public PostService(PostRepository postRepository,
@@ -249,6 +251,8 @@ public class PostService {
if (w.getEmail() != null) {
emailSender.sendEmail(w.getEmail(), "你中奖了", "恭喜你在抽奖贴 \"" + lp.getTitle() + "\" 中获奖");
}
notificationService.createNotification(w, NotificationType.LOTTERY_WIN, lp, null, null, lp.getAuthor(), null, null);
notificationService.sendCustomPush(w, "你中奖了", String.format("%s/posts/%d", websiteUrl, lp.getId()));
}
});
}

View File

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

View File

@@ -1,41 +0,0 @@
package com.openisle.config;
import com.openisle.model.NotificationType;
import com.openisle.model.User;
import com.openisle.repository.UserRepository;
import org.junit.jupiter.api.Test;
import java.util.EnumSet;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.*;
class NotificationPreferenceInitializerTest {
@Test
void addsDefaultsToUsers() throws Exception {
User u1 = new User();
u1.setId(1L);
u1.getDisabledNotificationTypes().clear();
User u2 = new User();
u2.setId(2L);
u2.getDisabledNotificationTypes().clear();
u2.getDisabledNotificationTypes().add(NotificationType.POST_VIEWED);
UserRepository repo = mock(UserRepository.class);
when(repo.findAll()).thenReturn(List.of(u1, u2));
NotificationPreferenceInitializer init = new NotificationPreferenceInitializer(repo);
init.run();
assertTrue(u1.getDisabledNotificationTypes().containsAll(
EnumSet.of(NotificationType.POST_VIEWED, NotificationType.USER_ACTIVITY)));
assertTrue(u2.getDisabledNotificationTypes().containsAll(
EnumSet.of(NotificationType.POST_VIEWED, NotificationType.USER_ACTIVITY)));
verify(repo).save(u1);
verify(repo).save(u2);
}
}

View File

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

View File

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

View File

@@ -14,15 +14,15 @@
</template>
<script>
import { ref, onMounted, computed, watch, onUnmounted, useId } from 'vue'
import { themeState } from '../utils/theme'
import { computed, onMounted, onUnmounted, ref, useId, watch } from 'vue'
import { clearVditorStorage } from '~/utils/clearVditorStorage'
import { themeState } from '~/utils/theme'
import {
createVditor,
getEditorTheme as getEditorThemeUtil,
getPreviewTheme as getPreviewThemeUtil,
} from '../utils/vditor'
import LoginOverlay from './LoginOverlay.vue'
import { clearVditorStorage } from '../utils/clearVditorStorage'
} from '~/utils/vditor'
import LoginOverlay from '~/components/LoginOverlay.vue'
export default {
name: 'CommentEditor',

View File

@@ -22,6 +22,7 @@
:to="`/users/${comment.userId}?tab=achievements`"
>{{ getMedalTitle(comment.medal) }}</router-link
>
<i v-if="comment.pinned" class="fas fa-thumbtack pin-icon"></i>
<span v-if="level >= 2">
<i class="fas fa-reply reply-icon"></i>
<span class="user-name reply-user-name">{{ comment.parentUserName }}</span>
@@ -74,6 +75,7 @@
:comment="item"
:level="level + 1"
:default-show-replies="item.openReplies"
:post-author-id="postAuthorId"
/>
</template>
</BaseTimeline>
@@ -89,19 +91,19 @@
</template>
<script>
import { ref, watch, computed, nextTick } from 'vue'
import { computed, ref, watch } from 'vue'
import VueEasyLightbox from 'vue-easy-lightbox'
import { useRouter } from 'vue-router'
import CommentEditor from './CommentEditor.vue'
import { renderMarkdown, handleMarkdownClick } from '../utils/markdown'
import { getMedalTitle } from '../utils/medal'
import TimeManager from '../utils/time'
import BaseTimeline from './BaseTimeline.vue'
import { API_BASE_URL, toast } from '../main'
import { getToken, authState } from '../utils/auth'
import ReactionsGroup from './ReactionsGroup.vue'
import DropdownMenu from './DropdownMenu.vue'
import LoginOverlay from './LoginOverlay.vue'
import { API_BASE_URL, toast } from '~/main'
import { authState, getToken } from '~/utils/auth'
import { handleMarkdownClick, renderMarkdown } from '~/utils/markdown'
import { getMedalTitle } from '~/utils/medal'
import TimeManager from '~/utils/time'
import BaseTimeline from '~/components/BaseTimeline.vue'
import CommentEditor from '~/components/CommentEditor.vue'
import DropdownMenu from '~/components/DropdownMenu.vue'
import LoginOverlay from '~/components/LoginOverlay.vue'
import ReactionsGroup from '~/components/ReactionsGroup.vue'
const CommentItem = {
name: 'CommentItem',
@@ -119,6 +121,10 @@ const CommentItem = {
type: Boolean,
default: false,
},
postAuthorId: {
type: [Number, String],
required: true,
},
},
setup(props, { emit }) {
const router = useRouter()
@@ -171,12 +177,22 @@ const CommentItem = {
})
const isAuthor = computed(() => authState.username === props.comment.userName)
const isPostAuthor = computed(() => Number(authState.userId) === Number(props.postAuthorId))
const isAdmin = computed(() => authState.role === 'ADMIN')
const commentMenuItems = computed(() =>
isAuthor.value || isAdmin.value
? [{ text: '删除评论', color: 'red', onClick: () => deleteComment() }]
: [],
)
const commentMenuItems = computed(() => {
const items = []
if (isAuthor.value || isAdmin.value) {
items.push({ text: '删除评论', color: 'red', onClick: () => deleteComment() })
}
if (isAdmin.value || isPostAuthor.value) {
if (props.comment.pinned) {
items.push({ text: '取消置顶', onClick: () => unpinComment() })
} else {
items.push({ text: '置顶', onClick: () => pinComment() })
}
}
return items
})
const deleteComment = async () => {
const token = getToken()
if (!token) {
@@ -196,6 +212,46 @@ const CommentItem = {
toast.error('操作失败')
}
}
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 submitReply = async (parentUserName, text, clear) => {
if (!text.trim()) return
isWaitingForReply.value = true
@@ -284,6 +340,9 @@ const CommentItem = {
isWaitingForReply,
commentMenuItems,
deleteComment,
pinComment,
unpinComment,
isPostAuthor,
lightboxVisible,
lightboxIndex,
lightboxImgs,
@@ -370,6 +429,12 @@ export default CommentItem
margin-left: 10px;
}
.pin-icon {
font-size: 12px;
margin-left: 10px;
opacity: 0.6;
}
@keyframes highlight {
from {
background-color: yellow;

View File

@@ -6,6 +6,7 @@
text="建站送奶茶活动火热进行中,快来参与吧!"
@close="closeMilkTeaPopup"
/>
<NotificationSettingPopup :visible="showNotificationPopup" @close="closeNotificationPopup" />
<MedalPopup :visible="showMedalPopup" :medals="newMedals" @close="closeMedalPopup" />
</div>
</template>
@@ -13,25 +14,30 @@
<script>
import ActivityPopup from '~/components/ActivityPopup.vue'
import MedalPopup from '~/components/MedalPopup.vue'
import NotificationSettingPopup from '~/components/NotificationSettingPopup.vue'
import { API_BASE_URL } from '~/main'
import { authState } from '~/utils/auth'
export default {
name: 'GlobalPopups',
components: { ActivityPopup, MedalPopup },
components: { ActivityPopup, MedalPopup, NotificationSettingPopup },
data() {
return {
showMilkTeaPopup: false,
milkTeaIcon: '',
showNotificationPopup: false,
showMedalPopup: false,
newMedals: [],
}
},
async mounted() {
await this.checkMilkTeaActivity()
if (!this.showMilkTeaPopup) {
await this.checkNewMedals()
}
if (this.showMilkTeaPopup) return
await this.checkNotificationSetting()
if (this.showNotificationPopup) return
await this.checkNewMedals()
},
methods: {
async checkMilkTeaActivity() {
@@ -55,6 +61,18 @@ export default {
if (!process.client) return
localStorage.setItem('milkTeaActivityPopupShown', 'true')
this.showMilkTeaPopup = false
this.checkNotificationSetting()
},
async checkNotificationSetting() {
if (!process.client) return
if (!authState.loggedIn) return
if (localStorage.getItem('notificationSettingPopupShown')) return
this.showNotificationPopup = true
},
closeNotificationPopup() {
if (!process.client) return
localStorage.setItem('notificationSettingPopupShown', 'true')
this.showNotificationPopup = false
this.checkNewMedals()
},
async checkNewMedals() {

View File

@@ -3,7 +3,7 @@
<div class="header-content">
<div class="header-content-left">
<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>
</button>
<span v-if="isMobile && unreadCount > 0" class="menu-unread-dot"></span>
@@ -49,14 +49,14 @@
</template>
<script>
import { authState, clearToken, loadCurrentUser } from '~/utils/auth'
import { watch, nextTick, ref, computed } from 'vue'
import { fetchUnreadCount, notificationState } from '~/utils/notification'
import { ClientOnly } from '#components'
import { computed, nextTick, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import DropdownMenu from '~/components/DropdownMenu.vue'
import SearchDropdown from '~/components/SearchDropdown.vue'
import { authState, clearToken, loadCurrentUser } from '~/utils/auth'
import { fetchUnreadCount, notificationState } from '~/utils/notification'
import { useIsMobile } from '~/utils/screen'
import { useRouter } from 'vue-router'
import { ClientOnly } from '#components'
export default {
name: 'HeaderComponent',
@@ -67,7 +67,7 @@ export default {
default: true,
},
},
setup() {
setup(props, { expose }) {
const isLogin = computed(() => authState.loggedIn)
const isMobile = useIsMobile()
const unreadCount = computed(() => notificationState.unreadCount)
@@ -76,6 +76,11 @@ export default {
const showSearch = ref(false)
const searchDropdown = ref(null)
const userMenu = ref(null)
const menuBtn = ref(null)
expose({
menuBtn,
})
const goToHome = () => {
router.push('/').then(() => {
@@ -183,6 +188,7 @@ export default {
searchDropdown,
userMenu,
avatar,
menuBtn,
}
},
}

View File

@@ -10,8 +10,8 @@
</template>
<script>
import ProgressBar from './ProgressBar.vue'
import { prevLevelExp } from '../utils/level'
import { prevLevelExp } from '~/utils/level'
import ProgressBar from '~/components/ProgressBar.vue'
export default {
name: 'LevelProgress',
components: { ProgressBar },

View File

@@ -210,17 +210,13 @@ export default {
const gotoCategory = (c) => {
const value = encodeURIComponent(c.id ?? c.name)
router.push({ path: '/', query: { category: value } }).then(() => {
window.location.reload()
})
router.push({ path: '/', query: { category: value } })
handleItemClick()
}
const gotoTag = (t) => {
const value = encodeURIComponent(t.id ?? t.name)
router.push({ path: '/', query: { tags: value } }).then(() => {
window.location.reload()
})
router.push({ path: '/', query: { tags: value } })
handleItemClick()
}

View File

@@ -58,12 +58,12 @@
</template>
<script>
import ProgressBar from './ProgressBar.vue'
import LevelProgress from './LevelProgress.vue'
import BaseInput from './BaseInput.vue'
import BasePopup from './BasePopup.vue'
import { API_BASE_URL, toast } from '../main'
import { getToken, fetchCurrentUser } from '../utils/auth'
import { API_BASE_URL, toast } from '~/main'
import { fetchCurrentUser, getToken } from '~/utils/auth'
import BaseInput from '~/components/BaseInput.vue'
import BasePopup from '~/components/BasePopup.vue'
import LevelProgress from '~/components/LevelProgress.vue'
import ProgressBar from '~/components/ProgressBar.vue'
export default {
name: 'MilkTeaActivityComponent',
@@ -218,24 +218,30 @@ export default {
border-radius: 10px;
cursor: pointer;
}
.redeem-submit-button:disabled {
background-color: var(--primary-color-disabled);
cursor: not-allowed;
}
.redeem-submit-button:hover {
background-color: var(--primary-color-hover);
}
.redeem-submit-button:disabled:hover {
background-color: var(--primary-color-disabled);
}
.redeem-cancel-button {
color: var(--primary-color);
border-radius: 10px;
cursor: pointer;
}
.redeem-cancel-button:hover {
text-decoration: underline;
}
.user-level-text {
opacity: 0.8;
font-size: 12px;

View File

@@ -13,7 +13,7 @@
</template>
<script>
import { useIsMobile } from '../utils/screen'
import { useIsMobile } from '~/utils/screen'
export default {
name: 'NotificationContainer',
props: {

View File

@@ -0,0 +1,82 @@
<template>
<BasePopup :visible="visible" @close="close">
<div class="notification-popup">
<div class="notification-popup-title">🎉 通知设置上线啦</div>
<div class="notification-popup-text">现在可以在消息 -> 消息设置中调整通知类型</div>
<div class="notification-popup-actions">
<div class="notification-popup-close" @click="close">知道了</div>
<div class="notification-popup-button" @click="gotoSetting">去看看</div>
</div>
</div>
</BasePopup>
</template>
<script>
import BasePopup from '~/components/BasePopup.vue'
import { useRouter } from 'vue-router'
export default {
name: 'NotificationSettingPopup',
components: { BasePopup },
props: {
visible: { type: Boolean, default: false },
},
emits: ['close'],
setup(props, { emit }) {
const router = useRouter()
const gotoSetting = () => {
emit('close')
router.push('/message?tab=control')
}
const close = () => emit('close')
return { gotoSetting, close }
},
}
</script>
<style scoped>
.notification-popup {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 10px;
min-width: 200px;
}
.notification-popup-title {
font-size: 18px;
font-weight: bold;
margin-bottom: 10px;
}
.notification-popup-actions {
margin-top: 10px;
display: flex;
flex-direction: row;
gap: 20px;
}
.notification-popup-button {
background-color: var(--primary-color);
color: #fff;
padding: 8px 16px;
border-radius: 8px;
cursor: pointer;
}
.notification-popup-button:hover {
background-color: var(--primary-color-hover);
}
.notification-popup-close {
cursor: pointer;
color: var(--primary-color);
display: flex;
align-items: center;
}
.notification-popup-close:hover {
text-decoration: underline;
}
</style>

View File

@@ -8,14 +8,14 @@
</template>
<script>
import { ref, onMounted, watch, onUnmounted, useId } from 'vue'
import { themeState } from '../utils/theme'
import { onMounted, onUnmounted, ref, useId, watch } from 'vue'
import { clearVditorStorage } from '~/utils/clearVditorStorage'
import { themeState } from '~/utils/theme'
import {
createVditor,
getEditorTheme as getEditorThemeUtil,
getPreviewTheme as getPreviewThemeUtil,
} from '../utils/vditor'
import { clearVditorStorage } from '../utils/clearVditorStorage'
} from '~/utils/vditor'
export default {
name: 'PostEditor',

View File

@@ -47,10 +47,10 @@
</template>
<script>
import { ref, computed, watch, onMounted } from 'vue'
import { API_BASE_URL, toast } from '../main'
import { getToken, authState } from '../utils/auth'
import { reactionEmojiMap } from '../utils/reactions'
import { computed, onMounted, ref, watch } from 'vue'
import { API_BASE_URL, toast } from '~/main'
import { authState, getToken } from '~/utils/auth'
import { reactionEmojiMap } from '~/utils/reactions'
let cachedTypes = null
const fetchTypes = async () => {

View File

@@ -12,7 +12,7 @@
</template>
<script>
import BasePlaceholder from './BasePlaceholder.vue'
import BasePlaceholder from '~/components/BasePlaceholder.vue'
export default {
name: 'UserList',

View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "node",
"baseUrl": ".",
"paths": {
"~/*": ["./*"]
}
},
"include": ["./**/*.js", "./**/*.vue"],
"exclude": ["node_modules"]
}

View File

@@ -43,4 +43,9 @@ export default defineNuxtConfig({
],
},
},
vue: {
compilerOptions: {
isCustomElement: (tag) => ['l-hatch', 'l-hatch-spinner'].includes(tag),
},
},
})

View File

@@ -23,8 +23,8 @@
</template>
<script>
import { ref, onMounted } from 'vue'
import { renderMarkdown, handleMarkdownClick } from '../utils/markdown'
import { onMounted, ref } from 'vue'
import { handleMarkdownClick, renderMarkdown } from '~/utils/markdown'
export default {
name: 'AboutPageView',

View File

@@ -30,19 +30,19 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import VChart from 'vue-echarts'
import { use } from 'echarts/core'
import { LineChart } from 'echarts/charts'
import {
DataZoomComponent,
GridComponent,
TitleComponent,
TooltipComponent,
GridComponent,
DataZoomComponent,
} from 'echarts/components'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { API_BASE_URL } from '../main'
import { getToken } from '../utils/auth'
import { onMounted, ref } from 'vue'
import VChart from 'vue-echarts'
import { API_BASE_URL } from '~/main'
import { getToken } from '~/utils/auth'
use([LineChart, TitleComponent, TooltipComponent, GridComponent, DataZoomComponent, CanvasRenderer])

View File

@@ -30,9 +30,9 @@
</template>
<script>
import { API_BASE_URL } from '../main'
import TimeManager from '../utils/time'
import MilkTeaActivityComponent from '../components/MilkTeaActivityComponent.vue'
import { API_BASE_URL } from '~/main'
import TimeManager from '~/utils/time'
import MilkTeaActivityComponent from '~/components/MilkTeaActivityComponent.vue'
export default {
name: 'ActivityListPageView',

View File

@@ -3,8 +3,8 @@
</template>
<script>
import CallbackPage from '../components/CallbackPage.vue'
import { discordExchange } from '../utils/discord'
import CallbackPage from '~/components/CallbackPage.vue'
import { discordExchange } from '~/utils/discord'
export default {
name: 'DiscordCallbackPageView',

View File

@@ -24,8 +24,8 @@
</template>
<script>
import { API_BASE_URL, toast } from '../main'
import BaseInput from '../components/BaseInput.vue'
import { API_BASE_URL, toast } from '~/main'
import BaseInput from '~/components/BaseInput.vue'
export default {
name: 'ForgotPasswordPageView',
components: { BaseInput },

View File

@@ -3,8 +3,8 @@
</template>
<script>
import CallbackPage from '../components/CallbackPage.vue'
import { githubExchange } from '../utils/github'
import CallbackPage from '~/components/CallbackPage.vue'
import { githubExchange } from '~/utils/github'
export default {
name: 'GithubCallbackPageView',

View File

@@ -3,8 +3,8 @@
</template>
<script>
import CallbackPage from '../components/CallbackPage.vue'
import { googleAuthWithToken } from '../utils/google'
import CallbackPage from '~/components/CallbackPage.vue'
import { googleAuthWithToken } from '~/utils/google'
export default {
name: 'GoogleCallbackPageView',

View File

@@ -113,18 +113,17 @@
<script>
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useScrollLoadMore } from '~/utils/loadMore'
import { stripMarkdown } from '~/utils/markdown'
import ArticleCategory from '~/components/ArticleCategory.vue'
import ArticleTags from '~/components/ArticleTags.vue'
import CategorySelect from '~/components/CategorySelect.vue'
import SearchDropdown from '~/components/SearchDropdown.vue'
import TagSelect from '~/components/TagSelect.vue'
import { API_BASE_URL } from '~/main'
import { getToken } from '~/utils/auth'
import 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 { useScrollLoadMore } from '~/utils/loadMore'
import { stripMarkdown } from '~/utils/markdown'
import { useIsMobile } from '~/utils/screen'
import TimeManager from '~/utils/time'
export default {
name: 'HomePageView',
@@ -150,22 +149,9 @@ export default {
},
],
})
const route = useRoute()
const selectedCategory = ref('')
if (route.query.category) {
const c = decodeURIComponent(route.query.category)
selectedCategory.value = isNaN(c) ? c : Number(c)
}
const selectedTags = ref([])
if (route.query.tags) {
const t = Array.isArray(route.query.tags) ? route.query.tags.join(',') : route.query.tags
selectedTags.value = t
.split(',')
.filter((v) => v)
.map((v) => decodeURIComponent(v))
.map((v) => (isNaN(v) ? v : Number(v)))
}
const route = useRoute()
const tagOptions = ref([])
const categoryOptions = ref([])
const isLoadingPosts = ref(false)
@@ -177,13 +163,50 @@ export default {
? '最新'
: '最新回复',
)
const articles = ref([])
const page = ref(0)
const pageSize = 10
const isMobile = useIsMobile()
const allLoaded = ref(false)
const selectedCategorySet = (category) => {
const c = decodeURIComponent(category)
selectedCategory.value = isNaN(c) ? c : Number(c)
}
const selectedTagsSet = (tags) => {
const t = Array.isArray(tags) ? tags.join(',') : tags
selectedTags.value = t
.split(',')
.filter((v) => v)
.map((v) => decodeURIComponent(v))
.map((v) => (isNaN(v) ? v : Number(v)))
}
onMounted(() => {
const query = route.query
const category = query.category
const tags = query.tags
if (category) {
selectedCategorySet(category)
}
if (tags) {
selectedTagsSet(tags)
}
})
watch(
() => route.query,
() => {
const query = route.query
const category = query.category
const tags = query.tags
category && selectedCategorySet(category)
tags && selectedTagsSet(tags)
},
)
const loadOptions = async () => {
if (selectedCategory.value && !isNaN(selectedCategory.value)) {
try {
@@ -696,6 +719,7 @@ export default {
.header-item.activity {
width: 10%;
}
.article-member-avatar-item:nth-child(n + 4) {
display: none;
}

View File

@@ -52,14 +52,14 @@
</template>
<script>
import { API_BASE_URL, toast } from '../main'
import { setToken, loadCurrentUser } from '../utils/auth'
import { googleAuthorize } from '../utils/google'
import { githubAuthorize } from '../utils/github'
import { discordAuthorize } from '../utils/discord'
import { twitterAuthorize } from '../utils/twitter'
import BaseInput from '../components/BaseInput.vue'
import { registerPush } from '../utils/push'
import { API_BASE_URL, toast } from '~/main'
import { setToken, loadCurrentUser } from '~/utils/auth'
import { googleAuthorize } from '~/utils/google'
import { githubAuthorize } from '~/utils/github'
import { discordAuthorize } from '~/utils/discord'
import { twitterAuthorize } from '~/utils/twitter'
import BaseInput from '~/components/BaseInput.vue'
import { registerPush } from '~/utils/push'
export default {
name: 'LoginPageView',
components: { BaseInput },

View File

@@ -185,6 +185,19 @@
</router-link>
</NotificationContainer>
</template>
<template v-else-if="item.type === 'LOTTERY_WIN'">
<NotificationContainer :item="item" :markRead="markRead">
恭喜你在抽奖贴
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/posts/${item.post.id}`"
>
{{ stripMarkdownLength(item.post.title, 100) }}
</router-link>
中获奖
</NotificationContainer>
</template>
<template v-else-if="item.type === 'POST_UPDATED'">
<NotificationContainer :item="item" :markRead="markRead">
您关注的帖子
@@ -481,31 +494,28 @@
<script>
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { API_BASE_URL } from '../main'
import BaseTimeline from '../components/BaseTimeline.vue'
import BasePlaceholder from '../components/BasePlaceholder.vue'
import NotificationContainer from '../components/NotificationContainer.vue'
import { getToken, authState } from '../utils/auth'
import {
markNotificationsRead,
fetchUnreadCount,
notificationState,
fetchNotificationPreferences,
updateNotificationPreference,
} from '../utils/notification'
import { toast } from '../main'
import { stripMarkdownLength } from '../utils/markdown'
import TimeManager from '../utils/time'
import { reactionEmojiMap } from '../utils/reactions'
import { API_BASE_URL } from '~/main'
import BaseTimeline from '~/components/BaseTimeline.vue'
import BasePlaceholder from '~/components/BasePlaceholder.vue'
import NotificationContainer from '~/components/NotificationContainer.vue'
import { getToken, authState } from '~/utils/auth'
import { markNotificationsRead, fetchUnreadCount, notificationState } from '~/utils/notification'
import { toast } from '~/main'
import { stripMarkdownLength } from '~/utils/markdown'
import TimeManager from '~/utils/time'
import { reactionEmojiMap } from '~/utils/reactions'
export default {
name: 'MessagePageView',
components: { BaseTimeline, BasePlaceholder, NotificationContainer },
setup() {
const router = useRouter()
const route = useRoute()
const notifications = ref([])
const isLoadingMessage = ref(false)
const selectedTab = ref('unread')
const selectedTab = ref(
['all', 'unread', 'control'].includes(route.query.tab) ? route.query.tab : 'unread',
)
const notificationPrefs = ref([])
const filteredNotifications = computed(() =>
selectedTab.value === 'all'
@@ -568,6 +578,7 @@ export default {
POST_UNSUBSCRIBED: 'fas fa-bookmark',
REGISTER_REQUEST: 'fas fa-user-clock',
ACTIVITY_REDEEM: 'fas fa-coffee',
LOTTERY_WIN: 'fas fa-trophy',
MENTION: 'fas fa-at',
}
@@ -625,6 +636,17 @@ export default {
}
},
})
} 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 === 'POST_UPDATED') {
notifications.value.push({
...n,
@@ -794,6 +816,8 @@ export default {
return '有人申请注册'
case 'ACTIVITY_REDEEM':
return '有人申请兑换奶茶'
case 'LOTTERY_WIN':
return '抽奖中奖了'
default:
return t
}

View File

@@ -78,18 +78,18 @@
</template>
<script>
import { ref, onMounted, computed, watch } from 'vue'
import PostEditor from '../components/PostEditor.vue'
import CategorySelect from '../components/CategorySelect.vue'
import TagSelect from '../components/TagSelect.vue'
import PostTypeSelect from '../components/PostTypeSelect.vue'
import AvatarCropper from '../components/AvatarCropper.vue'
import FlatPickr from 'vue-flatpickr-component'
import 'flatpickr/dist/flatpickr.css'
import { API_BASE_URL, toast } from '../main'
import { getToken, authState } from '../utils/auth'
import LoginOverlay from '../components/LoginOverlay.vue'
import BaseInput from '../components/BaseInput.vue'
import { computed, onMounted, ref, watch } from 'vue'
import FlatPickr from 'vue-flatpickr-component'
import AvatarCropper from '~/components/AvatarCropper.vue'
import BaseInput from '~/components/BaseInput.vue'
import CategorySelect from '~/components/CategorySelect.vue'
import LoginOverlay from '~/components/LoginOverlay.vue'
import PostEditor from '~/components/PostEditor.vue'
import PostTypeSelect from '~/components/PostTypeSelect.vue'
import TagSelect from '~/components/TagSelect.vue'
import { API_BASE_URL, toast } from '~/main'
import { authState, getToken } from '~/utils/auth'
export default {
name: 'NewPostPageView',

View File

@@ -38,12 +38,12 @@
<script>
import { ref, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import PostEditor from '../../../components/PostEditor.vue'
import CategorySelect from '../../../components/CategorySelect.vue'
import TagSelect from '../../../components/TagSelect.vue'
import { API_BASE_URL, toast } from '../../../main'
import { getToken, authState } from '../../../utils/auth'
import LoginOverlay from '../../../components/LoginOverlay.vue'
import PostEditor from '~/components/PostEditor.vue'
import CategorySelect from '~/components/CategorySelect.vue'
import TagSelect from '~/components/TagSelect.vue'
import { API_BASE_URL, toast } from '~/main'
import { getToken, authState } from '~/utils/auth'
import LoginOverlay from '~/components/LoginOverlay.vue'
export default {
name: 'EditPostPageView',

View File

@@ -195,6 +195,7 @@
:comment="item"
:level="0"
:default-show-replies="item.openReplies"
:post-author-id="author.id"
@deleted="onCommentDeleted"
/>
</template>
@@ -234,21 +235,22 @@
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'
import VueEasyLightbox from 'vue-easy-lightbox'
import { useRoute } from 'vue-router'
import CommentItem from '../../../components/CommentItem.vue'
import CommentEditor from '../../../components/CommentEditor.vue'
import BaseTimeline from '../../../components/BaseTimeline.vue'
import ArticleTags from '../../../components/ArticleTags.vue'
import ArticleCategory from '../../../components/ArticleCategory.vue'
import ReactionsGroup from '../../../components/ReactionsGroup.vue'
import DropdownMenu from '../../../components/DropdownMenu.vue'
import { renderMarkdown, handleMarkdownClick, stripMarkdownLength } from '../../../utils/markdown'
import { getMedalTitle } from '../../../utils/medal'
import { API_BASE_URL, toast } from '../../../main'
import { getToken, authState } from '../../../utils/auth'
import TimeManager from '../../../utils/time'
import CommentItem from '~/components/CommentItem.vue'
import CommentEditor from '~/components/CommentEditor.vue'
import BaseTimeline from '~/components/BaseTimeline.vue'
import ArticleTags from '~/components/ArticleTags.vue'
import ArticleCategory from '~/components/ArticleCategory.vue'
import ReactionsGroup from '~/components/ReactionsGroup.vue'
import DropdownMenu from '~/components/DropdownMenu.vue'
import { renderMarkdown, handleMarkdownClick, stripMarkdownLength } from '~/utils/markdown'
import { getMedalTitle } from '~/utils/medal'
import { API_BASE_URL, toast } from '~/main'
import { getToken, authState } from '~/utils/auth'
import TimeManager from '~/utils/time'
import { useRouter } from 'vue-router'
import { useIsMobile } from '../../../utils/screen'
import Dropdown from '../../../components/Dropdown.vue'
import { useIsMobile } from '~/utils/screen'
import Dropdown from '~/components/Dropdown.vue'
import { ClientOnly } from '#components'
export default {
name: 'PostPageView',
@@ -262,6 +264,7 @@ export default {
DropdownMenu,
VueEasyLightbox,
Dropdown,
ClientOnly,
},
async setup() {
const route = useRoute()
@@ -403,6 +406,7 @@ export default {
avatar: c.author.avatar,
text: c.content,
reactions: c.reactions || [],
pinned: !!c.pinnedAt,
reply: (c.replies || []).map((r) => mapComment(r, c.author.username, level + 1)),
openReplies: level === 0,
src: c.author.avatar,

View File

@@ -65,11 +65,11 @@
</template>
<script>
import { API_BASE_URL, toast } from '../main'
import { getToken, fetchCurrentUser, setToken } from '../utils/auth'
import BaseInput from '../components/BaseInput.vue'
import Dropdown from '../components/Dropdown.vue'
import AvatarCropper from '../components/AvatarCropper.vue'
import AvatarCropper from '~/components/AvatarCropper.vue'
import BaseInput from '~/components/BaseInput.vue'
import Dropdown from '~/components/Dropdown.vue'
import { API_BASE_URL, toast } from '~/main'
import { fetchCurrentUser, getToken, setToken } from '~/utils/auth'
export default {
name: 'SettingsPageView',
components: { BaseInput, Dropdown, AvatarCropper },

View File

@@ -19,8 +19,8 @@
</template>
<script>
import BaseInput from '../components/BaseInput.vue'
import { API_BASE_URL, toast } from '../main'
import BaseInput from '~/components/BaseInput.vue'
import { API_BASE_URL, toast } from '~/main'
export default {
name: 'SignupReasonPageView',

View File

@@ -70,19 +70,19 @@
<div class="other-signup-page-content">
<div class="signup-page-button" @click="googleAuthorize">
<img class="signup-page-button-icon" src="../assets/icons/google.svg" alt="Google Logo" />
<img class="signup-page-button-icon" src="~/assets/icons/google.svg" alt="Google Logo" />
<div class="signup-page-button-text">Google 注册</div>
</div>
<div class="signup-page-button" @click="signupWithGithub">
<img class="signup-page-button-icon" src="../assets/icons/github.svg" alt="GitHub Logo" />
<img class="signup-page-button-icon" src="~/assets/icons/github.svg" alt="GitHub Logo" />
<div class="signup-page-button-text">GitHub 注册</div>
</div>
<div class="signup-page-button" @click="signupWithDiscord">
<img class="signup-page-button-icon" src="../assets/icons/discord.svg" alt="Discord Logo" />
<img class="signup-page-button-icon" src="~/assets/icons/discord.svg" alt="Discord Logo" />
<div class="signup-page-button-text">Discord 注册</div>
</div>
<div class="signup-page-button" @click="signupWithTwitter">
<img class="signup-page-button-icon" src="../assets/icons/twitter.svg" alt="Twitter Logo" />
<img class="signup-page-button-icon" src="~/assets/icons/twitter.svg" alt="Twitter Logo" />
<div class="signup-page-button-text">Twitter 注册</div>
</div>
</div>
@@ -90,12 +90,12 @@
</template>
<script>
import { API_BASE_URL, toast } from '../main'
import { googleAuthorize } from '../utils/google'
import { githubAuthorize } from '../utils/github'
import { discordAuthorize } from '../utils/discord'
import { twitterAuthorize } from '../utils/twitter'
import BaseInput from '../components/BaseInput.vue'
import BaseInput from '~/components/BaseInput.vue'
import { API_BASE_URL, toast } from '~/main'
import { discordAuthorize } from '~/utils/discord'
import { githubAuthorize } from '~/utils/github'
import { googleAuthorize } from '~/utils/google'
import { twitterAuthorize } from '~/utils/twitter'
export default {
name: 'SignupPageView',
components: { BaseInput },

View File

@@ -3,8 +3,8 @@
</template>
<script>
import CallbackPage from '../components/CallbackPage.vue'
import { twitterExchange } from '../utils/twitter'
import CallbackPage from '~/components/CallbackPage.vue'
import { twitterExchange } from '~/utils/twitter'
export default {
name: 'TwitterCallbackPageView',

View File

@@ -297,27 +297,26 @@
</template>
<script>
import { ref, computed, onMounted, watch } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { API_BASE_URL, toast } from '../main'
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 LevelProgress from '../components/LevelProgress.vue'
import { stripMarkdown, stripMarkdownLength } from '../utils/markdown'
import TimeManager from '../utils/time'
import { prevLevelExp } from '../utils/level'
import AchievementList from '../components/AchievementList.vue'
definePageMeta({
alias: ['/users/:id/'],
})
import AchievementList from '~/components/AchievementList.vue'
import BasePlaceholder from '~/components/BasePlaceholder.vue'
import BaseTimeline from '~/components/BaseTimeline.vue'
import LevelProgress from '~/components/LevelProgress.vue'
import UserList from '~/components/UserList.vue'
import { API_BASE_URL, toast } from '~/main'
import { authState, getToken } from '~/utils/auth'
import { prevLevelExp } from '~/utils/level'
import { stripMarkdown, stripMarkdownLength } from '~/utils/markdown'
import TimeManager from '~/utils/time'
export default {
name: 'ProfileView',
components: { BaseTimeline, UserList, BasePlaceholder, LevelProgress, AchievementList },
setup() {
definePageMeta({
alias: ['/users/:id/'],
})
const route = useRoute()
const router = useRouter()
const username = route.params.id

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { defineNuxtPlugin } from '#app'
import { defineNuxtPlugin } from 'nuxt/app'
import { initTheme } from '~/utils/theme'
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 '~/assets/toast.css'

View File

@@ -1,6 +1,6 @@
import MarkdownIt from 'markdown-it'
import hljs from 'highlight.js'
import 'highlight.js/styles/github.css'
import MarkdownIt from 'markdown-it'
import { toast } from '../main'
import { tiebaEmoji } from './tiebaEmoji'
@@ -86,18 +86,19 @@ export function handleMarkdownClick(e) {
export function stripMarkdown(text) {
const html = md.render(text || '')
// SSR 环境下没有 document
if (typeof window === 'undefined') {
// 用正则去除 HTML 标签
return html
.replace(/<[^>]+>/g, '')
.replace(/\s+/g, ' ')
.trim()
} else {
const el = document.createElement('div')
el.innerHTML = html
return el.textContent || el.innerText || ''
}
// 统一使用正则表达式方法,确保服务端和客户端行为一致
let plainText = html.replace(/<[^>]+>/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()
return plainText
}
export function stripMarkdownLength(text, length) {