mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-03-03 18:40:46 +08:00
Compare commits
16 Commits
codex/add-
...
codex/add-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0edbeabac2 | ||
|
|
a3aec1133b | ||
|
|
8fa715477b | ||
|
|
dfef13e2be | ||
|
|
2f4d6e68da | ||
|
|
414872f61e | ||
|
|
82475f71db | ||
|
|
a6874e9be3 | ||
|
|
720031770d | ||
|
|
eb7a25434f | ||
|
|
bda4b24cf0 | ||
|
|
4dedb70d54 | ||
|
|
aea4f59af7 | ||
|
|
84ed778dc0 | ||
|
|
b3ea41ad1e | ||
|
|
80ecb1620d |
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ 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,
|
||||||
/** You were mentioned in a post or comment */
|
/** You were mentioned in a post or comment */
|
||||||
MENTION
|
MENTION
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import lombok.Setter;
|
|||||||
import org.hibernate.annotations.CreationTimestamp;
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.EnumSet;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
@@ -68,7 +69,10 @@ public class User {
|
|||||||
@CollectionTable(name = "user_disabled_notification_types", joinColumns = @JoinColumn(name = "user_id"))
|
@CollectionTable(name = "user_disabled_notification_types", joinColumns = @JoinColumn(name = "user_id"))
|
||||||
@Column(name = "notification_type")
|
@Column(name = "notification_type")
|
||||||
@Enumerated(EnumType.STRING)
|
@Enumerated(EnumType.STRING)
|
||||||
private Set<NotificationType> disabledNotificationTypes = new HashSet<>();
|
private Set<NotificationType> disabledNotificationTypes = EnumSet.of(
|
||||||
|
NotificationType.POST_VIEWED,
|
||||||
|
NotificationType.USER_ACTIVITY
|
||||||
|
);
|
||||||
|
|
||||||
@CreationTimestamp
|
@CreationTimestamp
|
||||||
@Column(nullable = false, updatable = false,
|
@Column(nullable = false, updatable = false,
|
||||||
|
|||||||
@@ -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());
|
pinned.sort(java.util.Comparator.comparing(Comment::getPinnedAt).reversed());
|
||||||
return list;
|
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();
|
||||||
|
|||||||
@@ -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,8 @@ 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()));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE comments ADD COLUMN pinned_at DATETIME(6) NULL;
|
||||||
@@ -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 // 如果是菜单按钮的点击,不处理关闭
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMobile.value) {
|
||||||
|
menuVisible.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { menuVisible, hideMenu, handleMenuOutside }
|
return { menuVisible, hideMenu, handleMenuOutside, header }
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -37,8 +37,8 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { API_BASE_URL, toast } from '../main'
|
import { API_BASE_URL, toast } from '~/main'
|
||||||
import { getToken } from '../utils/auth'
|
import { getToken } from '~/utils/auth'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
medals: {
|
medals: {
|
||||||
|
|||||||
@@ -14,15 +14,15 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { ref, onMounted, computed, watch, onUnmounted, useId } from 'vue'
|
import { computed, onMounted, onUnmounted, ref, useId, watch } from 'vue'
|
||||||
import { themeState } from '../utils/theme'
|
import { clearVditorStorage } from '~/utils/clearVditorStorage'
|
||||||
|
import { themeState } from '~/utils/theme'
|
||||||
import {
|
import {
|
||||||
createVditor,
|
createVditor,
|
||||||
getEditorTheme as getEditorThemeUtil,
|
getEditorTheme as getEditorThemeUtil,
|
||||||
getPreviewTheme as getPreviewThemeUtil,
|
getPreviewTheme as getPreviewThemeUtil,
|
||||||
} from '../utils/vditor'
|
} from '~/utils/vditor'
|
||||||
import LoginOverlay from './LoginOverlay.vue'
|
import LoginOverlay from '~/components/LoginOverlay.vue'
|
||||||
import { clearVditorStorage } from '../utils/clearVditorStorage'
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'CommentEditor',
|
name: 'CommentEditor',
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -89,19 +91,19 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { ref, watch, computed, nextTick } 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 { useRouter } from 'vue-router'
|
||||||
import CommentEditor from './CommentEditor.vue'
|
import { API_BASE_URL, toast } from '~/main'
|
||||||
import { renderMarkdown, handleMarkdownClick } from '../utils/markdown'
|
import { authState, getToken } from '~/utils/auth'
|
||||||
import { getMedalTitle } from '../utils/medal'
|
import { handleMarkdownClick, renderMarkdown } from '~/utils/markdown'
|
||||||
import TimeManager from '../utils/time'
|
import { getMedalTitle } from '~/utils/medal'
|
||||||
import BaseTimeline from './BaseTimeline.vue'
|
import TimeManager from '~/utils/time'
|
||||||
import { API_BASE_URL, toast } from '../main'
|
import BaseTimeline from '~/components/BaseTimeline.vue'
|
||||||
import { getToken, authState } from '../utils/auth'
|
import CommentEditor from '~/components/CommentEditor.vue'
|
||||||
import ReactionsGroup from './ReactionsGroup.vue'
|
import DropdownMenu from '~/components/DropdownMenu.vue'
|
||||||
import DropdownMenu from './DropdownMenu.vue'
|
import LoginOverlay from '~/components/LoginOverlay.vue'
|
||||||
import LoginOverlay from './LoginOverlay.vue'
|
import ReactionsGroup from '~/components/ReactionsGroup.vue'
|
||||||
|
|
||||||
const CommentItem = {
|
const CommentItem = {
|
||||||
name: 'CommentItem',
|
name: 'CommentItem',
|
||||||
@@ -119,6 +121,10 @@ const CommentItem = {
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
postAuthorId: {
|
||||||
|
type: [Number, String],
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
setup(props, { emit }) {
|
setup(props, { emit }) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -171,12 +177,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) {
|
||||||
@@ -196,6 +212,46 @@ const CommentItem = {
|
|||||||
toast.error('操作失败')
|
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) => {
|
const submitReply = async (parentUserName, text, clear) => {
|
||||||
if (!text.trim()) return
|
if (!text.trim()) return
|
||||||
isWaitingForReply.value = true
|
isWaitingForReply.value = true
|
||||||
@@ -284,6 +340,9 @@ const CommentItem = {
|
|||||||
isWaitingForReply,
|
isWaitingForReply,
|
||||||
commentMenuItems,
|
commentMenuItems,
|
||||||
deleteComment,
|
deleteComment,
|
||||||
|
pinComment,
|
||||||
|
unpinComment,
|
||||||
|
isPostAuthor,
|
||||||
lightboxVisible,
|
lightboxVisible,
|
||||||
lightboxIndex,
|
lightboxIndex,
|
||||||
lightboxImgs,
|
lightboxImgs,
|
||||||
@@ -370,6 +429,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;
|
||||||
|
|||||||
@@ -32,12 +32,12 @@ export default {
|
|||||||
},
|
},
|
||||||
async mounted() {
|
async mounted() {
|
||||||
await this.checkMilkTeaActivity()
|
await this.checkMilkTeaActivity()
|
||||||
if (!this.showMilkTeaPopup) {
|
if (this.showMilkTeaPopup) return
|
||||||
await this.checkNotificationSetting()
|
|
||||||
if (!this.showNotificationPopup) {
|
await this.checkNotificationSetting()
|
||||||
await this.checkNewMedals()
|
if (this.showNotificationPopup) return
|
||||||
}
|
|
||||||
}
|
await this.checkNewMedals()
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
async checkMilkTeaActivity() {
|
async checkMilkTeaActivity() {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<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>
|
||||||
@@ -49,14 +49,14 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
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 { useRouter } from 'vue-router'
|
||||||
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'
|
|
||||||
import { ClientOnly } from '#components'
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'HeaderComponent',
|
name: 'HeaderComponent',
|
||||||
@@ -67,7 +67,7 @@ export default {
|
|||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
setup() {
|
setup(props, { expose }) {
|
||||||
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)
|
||||||
@@ -76,6 +76,11 @@ export default {
|
|||||||
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)
|
||||||
|
|
||||||
|
expose({
|
||||||
|
menuBtn,
|
||||||
|
})
|
||||||
|
|
||||||
const goToHome = () => {
|
const goToHome = () => {
|
||||||
router.push('/').then(() => {
|
router.push('/').then(() => {
|
||||||
@@ -183,6 +188,7 @@ export default {
|
|||||||
searchDropdown,
|
searchDropdown,
|
||||||
userMenu,
|
userMenu,
|
||||||
avatar,
|
avatar,
|
||||||
|
menuBtn,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import ProgressBar from './ProgressBar.vue'
|
import { prevLevelExp } from '~/utils/level'
|
||||||
import { prevLevelExp } from '../utils/level'
|
import ProgressBar from '~/components/ProgressBar.vue'
|
||||||
export default {
|
export default {
|
||||||
name: 'LevelProgress',
|
name: 'LevelProgress',
|
||||||
components: { ProgressBar },
|
components: { ProgressBar },
|
||||||
|
|||||||
@@ -210,17 +210,13 @@ 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(() => {
|
router.push({ path: '/', query: { category: value } })
|
||||||
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(() => {
|
router.push({ path: '/', query: { tags: value } })
|
||||||
window.location.reload()
|
|
||||||
})
|
|
||||||
handleItemClick()
|
handleItemClick()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -58,12 +58,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import ProgressBar from './ProgressBar.vue'
|
import { API_BASE_URL, toast } from '~/main'
|
||||||
import LevelProgress from './LevelProgress.vue'
|
import { fetchCurrentUser, getToken } from '~/utils/auth'
|
||||||
import BaseInput from './BaseInput.vue'
|
import BaseInput from '~/components/BaseInput.vue'
|
||||||
import BasePopup from './BasePopup.vue'
|
import BasePopup from '~/components/BasePopup.vue'
|
||||||
import { API_BASE_URL, toast } from '../main'
|
import LevelProgress from '~/components/LevelProgress.vue'
|
||||||
import { getToken, fetchCurrentUser } from '../utils/auth'
|
import ProgressBar from '~/components/ProgressBar.vue'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'MilkTeaActivityComponent',
|
name: 'MilkTeaActivityComponent',
|
||||||
@@ -218,24 +218,30 @@ export default {
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.redeem-submit-button:disabled {
|
.redeem-submit-button:disabled {
|
||||||
background-color: var(--primary-color-disabled);
|
background-color: var(--primary-color-disabled);
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.redeem-submit-button:hover {
|
.redeem-submit-button:hover {
|
||||||
background-color: var(--primary-color-hover);
|
background-color: var(--primary-color-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.redeem-submit-button:disabled:hover {
|
.redeem-submit-button:disabled:hover {
|
||||||
background-color: var(--primary-color-disabled);
|
background-color: var(--primary-color-disabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
.redeem-cancel-button {
|
.redeem-cancel-button {
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.redeem-cancel-button:hover {
|
.redeem-cancel-button:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-level-text {
|
.user-level-text {
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { useIsMobile } from '../utils/screen'
|
import { useIsMobile } from '~/utils/screen'
|
||||||
export default {
|
export default {
|
||||||
name: 'NotificationContainer',
|
name: 'NotificationContainer',
|
||||||
props: {
|
props: {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<BasePopup :visible="visible" @close="close">
|
<BasePopup :visible="visible" @close="close">
|
||||||
<div class="notification-popup">
|
<div class="notification-popup">
|
||||||
<div class="notification-popup-title">通知设置上线啦</div>
|
<div class="notification-popup-title">🎉 通知设置上线啦</div>
|
||||||
<div class="notification-popup-text">现在可以调整通知类型</div>
|
<div class="notification-popup-text">现在可以在消息 -> 消息设置中调整通知类型</div>
|
||||||
<div class="notification-popup-actions">
|
<div class="notification-popup-actions">
|
||||||
<div class="notification-popup-close" @click="close">知道了</div>
|
<div class="notification-popup-close" @click="close">知道了</div>
|
||||||
<div class="notification-popup-button" @click="gotoSetting">去看看</div>
|
<div class="notification-popup-button" @click="gotoSetting">去看看</div>
|
||||||
@@ -47,6 +47,7 @@ export default {
|
|||||||
.notification-popup-title {
|
.notification-popup-title {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-popup-actions {
|
.notification-popup-actions {
|
||||||
|
|||||||
@@ -8,14 +8,14 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { ref, onMounted, watch, onUnmounted, useId } from 'vue'
|
import { onMounted, onUnmounted, ref, useId, watch } from 'vue'
|
||||||
import { themeState } from '../utils/theme'
|
import { clearVditorStorage } from '~/utils/clearVditorStorage'
|
||||||
|
import { themeState } from '~/utils/theme'
|
||||||
import {
|
import {
|
||||||
createVditor,
|
createVditor,
|
||||||
getEditorTheme as getEditorThemeUtil,
|
getEditorTheme as getEditorThemeUtil,
|
||||||
getPreviewTheme as getPreviewThemeUtil,
|
getPreviewTheme as getPreviewThemeUtil,
|
||||||
} from '../utils/vditor'
|
} from '~/utils/vditor'
|
||||||
import { clearVditorStorage } from '../utils/clearVditorStorage'
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'PostEditor',
|
name: 'PostEditor',
|
||||||
|
|||||||
@@ -47,10 +47,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { ref, computed, watch, onMounted } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { API_BASE_URL, toast } from '../main'
|
import { API_BASE_URL, toast } from '~/main'
|
||||||
import { getToken, authState } from '../utils/auth'
|
import { authState, getToken } from '~/utils/auth'
|
||||||
import { reactionEmojiMap } from '../utils/reactions'
|
import { reactionEmojiMap } from '~/utils/reactions'
|
||||||
|
|
||||||
let cachedTypes = null
|
let cachedTypes = null
|
||||||
const fetchTypes = async () => {
|
const fetchTypes = async () => {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import BasePlaceholder from './BasePlaceholder.vue'
|
import BasePlaceholder from '~/components/BasePlaceholder.vue'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'UserList',
|
name: 'UserList',
|
||||||
|
|||||||
13
frontend_nuxt/jsconfig.json
Normal file
13
frontend_nuxt/jsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"~/*": ["./*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["./**/*.js", "./**/*.vue"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -43,4 +43,9 @@ export default defineNuxtConfig({
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
vue: {
|
||||||
|
compilerOptions: {
|
||||||
|
isCustomElement: (tag) => ['l-hatch', 'l-hatch-spinner'].includes(tag),
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -23,8 +23,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { ref, onMounted } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
import { renderMarkdown, handleMarkdownClick } from '../utils/markdown'
|
import { handleMarkdownClick, renderMarkdown } from '~/utils/markdown'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'AboutPageView',
|
name: 'AboutPageView',
|
||||||
|
|||||||
@@ -30,19 +30,19 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
|
||||||
import VChart from 'vue-echarts'
|
|
||||||
import { use } from 'echarts/core'
|
|
||||||
import { LineChart } from 'echarts/charts'
|
import { LineChart } from 'echarts/charts'
|
||||||
import {
|
import {
|
||||||
|
DataZoomComponent,
|
||||||
|
GridComponent,
|
||||||
TitleComponent,
|
TitleComponent,
|
||||||
TooltipComponent,
|
TooltipComponent,
|
||||||
GridComponent,
|
|
||||||
DataZoomComponent,
|
|
||||||
} from 'echarts/components'
|
} from 'echarts/components'
|
||||||
|
import { use } from 'echarts/core'
|
||||||
import { CanvasRenderer } from 'echarts/renderers'
|
import { CanvasRenderer } from 'echarts/renderers'
|
||||||
import { API_BASE_URL } from '../main'
|
import { onMounted, ref } from 'vue'
|
||||||
import { getToken } from '../utils/auth'
|
import VChart from 'vue-echarts'
|
||||||
|
import { API_BASE_URL } from '~/main'
|
||||||
|
import { getToken } from '~/utils/auth'
|
||||||
|
|
||||||
use([LineChart, TitleComponent, TooltipComponent, GridComponent, DataZoomComponent, CanvasRenderer])
|
use([LineChart, TitleComponent, TooltipComponent, GridComponent, DataZoomComponent, CanvasRenderer])
|
||||||
|
|
||||||
|
|||||||
@@ -30,9 +30,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { API_BASE_URL } from '../main'
|
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'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'ActivityListPageView',
|
name: 'ActivityListPageView',
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import CallbackPage from '../components/CallbackPage.vue'
|
import CallbackPage from '~/components/CallbackPage.vue'
|
||||||
import { discordExchange } from '../utils/discord'
|
import { discordExchange } from '~/utils/discord'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'DiscordCallbackPageView',
|
name: 'DiscordCallbackPageView',
|
||||||
|
|||||||
@@ -24,8 +24,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { API_BASE_URL, toast } from '../main'
|
import { API_BASE_URL, toast } from '~/main'
|
||||||
import BaseInput from '../components/BaseInput.vue'
|
import BaseInput from '~/components/BaseInput.vue'
|
||||||
export default {
|
export default {
|
||||||
name: 'ForgotPasswordPageView',
|
name: 'ForgotPasswordPageView',
|
||||||
components: { BaseInput },
|
components: { BaseInput },
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import CallbackPage from '../components/CallbackPage.vue'
|
import CallbackPage from '~/components/CallbackPage.vue'
|
||||||
import { githubExchange } from '../utils/github'
|
import { githubExchange } from '~/utils/github'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'GithubCallbackPageView',
|
name: 'GithubCallbackPageView',
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import CallbackPage from '../components/CallbackPage.vue'
|
import CallbackPage from '~/components/CallbackPage.vue'
|
||||||
import { googleAuthWithToken } from '../utils/google'
|
import { googleAuthWithToken } from '~/utils/google'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'GoogleCallbackPageView',
|
name: 'GoogleCallbackPageView',
|
||||||
|
|||||||
@@ -113,18 +113,17 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import ArticleCategory from '~/components/ArticleCategory.vue'
|
||||||
import { useScrollLoadMore } from '~/utils/loadMore'
|
import ArticleTags from '~/components/ArticleTags.vue'
|
||||||
import { stripMarkdown } from '~/utils/markdown'
|
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 { API_BASE_URL } from '~/main'
|
||||||
import { getToken } from '~/utils/auth'
|
import { getToken } from '~/utils/auth'
|
||||||
import TimeManager from '~/utils/time'
|
import { useScrollLoadMore } from '~/utils/loadMore'
|
||||||
import CategorySelect from '~/components/CategorySelect.vue'
|
import { stripMarkdown } from '~/utils/markdown'
|
||||||
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 {
|
export default {
|
||||||
name: 'HomePageView',
|
name: 'HomePageView',
|
||||||
@@ -150,22 +149,9 @@ export default {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
const route = useRoute()
|
|
||||||
const selectedCategory = ref('')
|
const selectedCategory = ref('')
|
||||||
if (route.query.category) {
|
|
||||||
const c = decodeURIComponent(route.query.category)
|
|
||||||
selectedCategory.value = isNaN(c) ? c : Number(c)
|
|
||||||
}
|
|
||||||
const selectedTags = ref([])
|
const selectedTags = ref([])
|
||||||
if (route.query.tags) {
|
const route = useRoute()
|
||||||
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 tagOptions = ref([])
|
const tagOptions = ref([])
|
||||||
const categoryOptions = ref([])
|
const categoryOptions = ref([])
|
||||||
const isLoadingPosts = ref(false)
|
const isLoadingPosts = ref(false)
|
||||||
@@ -177,13 +163,50 @@ export default {
|
|||||||
? '最新'
|
? '最新'
|
||||||
: '最新回复',
|
: '最新回复',
|
||||||
)
|
)
|
||||||
|
|
||||||
const articles = ref([])
|
const articles = ref([])
|
||||||
const page = ref(0)
|
const page = ref(0)
|
||||||
const pageSize = 10
|
const pageSize = 10
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
const allLoaded = ref(false)
|
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 () => {
|
const loadOptions = async () => {
|
||||||
if (selectedCategory.value && !isNaN(selectedCategory.value)) {
|
if (selectedCategory.value && !isNaN(selectedCategory.value)) {
|
||||||
try {
|
try {
|
||||||
@@ -696,6 +719,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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,14 +52,14 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { API_BASE_URL, toast } from '../main'
|
import { API_BASE_URL, 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'
|
||||||
import { discordAuthorize } from '../utils/discord'
|
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 {
|
export default {
|
||||||
name: 'LoginPageView',
|
name: 'LoginPageView',
|
||||||
components: { BaseInput },
|
components: { BaseInput },
|
||||||
|
|||||||
@@ -185,6 +185,19 @@
|
|||||||
</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 === 'POST_UPDATED'">
|
<template v-else-if="item.type === 'POST_UPDATED'">
|
||||||
<NotificationContainer :item="item" :markRead="markRead">
|
<NotificationContainer :item="item" :markRead="markRead">
|
||||||
您关注的帖子
|
您关注的帖子
|
||||||
@@ -480,23 +493,17 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { ref, onMounted, computed } from 'vue'
|
import { ref, onMounted, computed } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { API_BASE_URL } from '../main'
|
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'
|
||||||
import { getToken, authState } from '../utils/auth'
|
import { getToken, authState } from '~/utils/auth'
|
||||||
import {
|
import { markNotificationsRead, fetchUnreadCount, notificationState } from '~/utils/notification'
|
||||||
markNotificationsRead,
|
import { toast } from '~/main'
|
||||||
fetchUnreadCount,
|
import { stripMarkdownLength } from '~/utils/markdown'
|
||||||
notificationState,
|
import TimeManager from '~/utils/time'
|
||||||
fetchNotificationPreferences,
|
import { reactionEmojiMap } from '~/utils/reactions'
|
||||||
updateNotificationPreference,
|
|
||||||
} from '../utils/notification'
|
|
||||||
import { toast } from '../main'
|
|
||||||
import { stripMarkdownLength } from '../utils/markdown'
|
|
||||||
import TimeManager from '../utils/time'
|
|
||||||
import { reactionEmojiMap } from '../utils/reactions'
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'MessagePageView',
|
name: 'MessagePageView',
|
||||||
@@ -571,6 +578,7 @@ 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',
|
||||||
MENTION: 'fas fa-at',
|
MENTION: 'fas fa-at',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -628,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') {
|
} else if (n.type === 'POST_UPDATED') {
|
||||||
notifications.value.push({
|
notifications.value.push({
|
||||||
...n,
|
...n,
|
||||||
@@ -797,6 +816,8 @@ export default {
|
|||||||
return '有人申请注册'
|
return '有人申请注册'
|
||||||
case 'ACTIVITY_REDEEM':
|
case 'ACTIVITY_REDEEM':
|
||||||
return '有人申请兑换奶茶'
|
return '有人申请兑换奶茶'
|
||||||
|
case 'LOTTERY_WIN':
|
||||||
|
return '抽奖中奖了'
|
||||||
default:
|
default:
|
||||||
return t
|
return t
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,18 +78,18 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<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 'flatpickr/dist/flatpickr.css'
|
||||||
import { API_BASE_URL, toast } from '../main'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { getToken, authState } from '../utils/auth'
|
import FlatPickr from 'vue-flatpickr-component'
|
||||||
import LoginOverlay from '../components/LoginOverlay.vue'
|
import AvatarCropper from '~/components/AvatarCropper.vue'
|
||||||
import BaseInput from '../components/BaseInput.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 {
|
export default {
|
||||||
name: 'NewPostPageView',
|
name: 'NewPostPageView',
|
||||||
|
|||||||
@@ -38,12 +38,12 @@
|
|||||||
<script>
|
<script>
|
||||||
import { ref, onMounted, computed } from 'vue'
|
import { ref, onMounted, computed } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } 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 { API_BASE_URL, 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'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'EditPostPageView',
|
name: 'EditPostPageView',
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -234,21 +235,22 @@
|
|||||||
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'
|
||||||
import CommentItem from '../../../components/CommentItem.vue'
|
import CommentItem from '~/components/CommentItem.vue'
|
||||||
import CommentEditor from '../../../components/CommentEditor.vue'
|
import CommentEditor from '~/components/CommentEditor.vue'
|
||||||
import BaseTimeline from '../../../components/BaseTimeline.vue'
|
import BaseTimeline from '~/components/BaseTimeline.vue'
|
||||||
import ArticleTags from '../../../components/ArticleTags.vue'
|
import ArticleTags from '~/components/ArticleTags.vue'
|
||||||
import ArticleCategory from '../../../components/ArticleCategory.vue'
|
import ArticleCategory from '~/components/ArticleCategory.vue'
|
||||||
import ReactionsGroup from '../../../components/ReactionsGroup.vue'
|
import ReactionsGroup from '~/components/ReactionsGroup.vue'
|
||||||
import DropdownMenu from '../../../components/DropdownMenu.vue'
|
import DropdownMenu from '~/components/DropdownMenu.vue'
|
||||||
import { 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 { API_BASE_URL, 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'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'PostPageView',
|
name: 'PostPageView',
|
||||||
@@ -262,6 +264,7 @@ export default {
|
|||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
VueEasyLightbox,
|
VueEasyLightbox,
|
||||||
Dropdown,
|
Dropdown,
|
||||||
|
ClientOnly,
|
||||||
},
|
},
|
||||||
async setup() {
|
async setup() {
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -403,6 +406,7 @@ 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,
|
||||||
|
|||||||
@@ -65,11 +65,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { API_BASE_URL, toast } from '../main'
|
import AvatarCropper from '~/components/AvatarCropper.vue'
|
||||||
import { getToken, fetchCurrentUser, setToken } from '../utils/auth'
|
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 AvatarCropper from '../components/AvatarCropper.vue'
|
import { fetchCurrentUser, getToken, setToken } from '~/utils/auth'
|
||||||
export default {
|
export default {
|
||||||
name: 'SettingsPageView',
|
name: 'SettingsPageView',
|
||||||
components: { BaseInput, Dropdown, AvatarCropper },
|
components: { BaseInput, Dropdown, AvatarCropper },
|
||||||
|
|||||||
@@ -19,8 +19,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import BaseInput from '../components/BaseInput.vue'
|
import BaseInput from '~/components/BaseInput.vue'
|
||||||
import { API_BASE_URL, toast } from '../main'
|
import { API_BASE_URL, toast } from '~/main'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'SignupReasonPageView',
|
name: 'SignupReasonPageView',
|
||||||
|
|||||||
@@ -70,19 +70,19 @@
|
|||||||
|
|
||||||
<div class="other-signup-page-content">
|
<div class="other-signup-page-content">
|
||||||
<div class="signup-page-button" @click="googleAuthorize">
|
<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 class="signup-page-button-text">Google 注册</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="signup-page-button" @click="signupWithGithub">
|
<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 class="signup-page-button-text">GitHub 注册</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="signup-page-button" @click="signupWithDiscord">
|
<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 class="signup-page-button-text">Discord 注册</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="signup-page-button" @click="signupWithTwitter">
|
<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 class="signup-page-button-text">Twitter 注册</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -90,12 +90,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { API_BASE_URL, toast } from '../main'
|
import BaseInput from '~/components/BaseInput.vue'
|
||||||
import { googleAuthorize } from '../utils/google'
|
import { API_BASE_URL, toast } from '~/main'
|
||||||
import { githubAuthorize } from '../utils/github'
|
import { discordAuthorize } from '~/utils/discord'
|
||||||
import { discordAuthorize } from '../utils/discord'
|
import { githubAuthorize } from '~/utils/github'
|
||||||
import { twitterAuthorize } from '../utils/twitter'
|
import { googleAuthorize } from '~/utils/google'
|
||||||
import BaseInput from '../components/BaseInput.vue'
|
import { twitterAuthorize } from '~/utils/twitter'
|
||||||
export default {
|
export default {
|
||||||
name: 'SignupPageView',
|
name: 'SignupPageView',
|
||||||
components: { BaseInput },
|
components: { BaseInput },
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import CallbackPage from '../components/CallbackPage.vue'
|
import CallbackPage from '~/components/CallbackPage.vue'
|
||||||
import { twitterExchange } from '../utils/twitter'
|
import { twitterExchange } from '~/utils/twitter'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'TwitterCallbackPageView',
|
name: 'TwitterCallbackPageView',
|
||||||
|
|||||||
@@ -297,27 +297,26 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
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 BasePlaceholder from '~/components/BasePlaceholder.vue'
|
||||||
import BaseTimeline from '../components/BaseTimeline.vue'
|
import BaseTimeline from '~/components/BaseTimeline.vue'
|
||||||
import UserList from '../components/UserList.vue'
|
import LevelProgress from '~/components/LevelProgress.vue'
|
||||||
import BasePlaceholder from '../components/BasePlaceholder.vue'
|
import UserList from '~/components/UserList.vue'
|
||||||
import LevelProgress from '../components/LevelProgress.vue'
|
import { API_BASE_URL, toast } from '~/main'
|
||||||
import { stripMarkdown, stripMarkdownLength } from '../utils/markdown'
|
import { authState, getToken } from '~/utils/auth'
|
||||||
import TimeManager from '../utils/time'
|
import { prevLevelExp } from '~/utils/level'
|
||||||
import { prevLevelExp } from '../utils/level'
|
import { stripMarkdown, stripMarkdownLength } from '~/utils/markdown'
|
||||||
import AchievementList from '../components/AchievementList.vue'
|
import TimeManager from '~/utils/time'
|
||||||
|
|
||||||
definePageMeta({
|
|
||||||
alias: ['/users/:id/'],
|
|
||||||
})
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'ProfileView',
|
name: 'ProfileView',
|
||||||
components: { BaseTimeline, UserList, BasePlaceholder, LevelProgress, AchievementList },
|
components: { BaseTimeline, UserList, BasePlaceholder, LevelProgress, AchievementList },
|
||||||
setup() {
|
setup() {
|
||||||
|
definePageMeta({
|
||||||
|
alias: ['/users/:id/'],
|
||||||
|
})
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const username = route.params.id
|
const username = route.params.id
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|
||||||
@@ -86,18 +86,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
|
||||||
.trim()
|
.replace(/\r\n/g, '\n') // Windows换行符转为Unix格式
|
||||||
} else {
|
.replace(/\r/g, '\n') // 旧Mac换行符转为Unix格式
|
||||||
const el = document.createElement('div')
|
.replace(/[ \t]+/g, ' ') // 合并空格和制表符为单个空格
|
||||||
el.innerHTML = html
|
.replace(/\n{3,}/g, '\n\n') // 最多保留两个连续换行(一个空行)
|
||||||
return el.textContent || el.innerText || ''
|
.trim()
|
||||||
}
|
|
||||||
|
return plainText
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stripMarkdownLength(text, length) {
|
export function stripMarkdownLength(text, length) {
|
||||||
|
|||||||
Reference in New Issue
Block a user