mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-06-20 04:34:28 +00:00
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 84b45f785d | |||
| df56d7e885 | |||
| 76176e135c | |||
| ab87e0e51c | |||
| 5346a063bf | |||
| e53f2130b8 | |||
| 1e87e9252d | |||
| 3fc4d29dce | |||
| bcdac9d9b2 | |||
| ea9710d16f | |||
| 47134cadc2 | |||
| 1a1b20b9cf | |||
| b63ebb8fae | |||
| e0f7299a86 | |||
| 1f9ae8d057 | |||
| da1ad73cf6 | |||
| 53c603f33a | |||
| 06f86f2b21 | |||
| 22693bfdd9 | |||
| 0058f20b1e | |||
| 304d941d68 | |||
| 3dbcd2ac4d | |||
| 2efe4e733a | |||
| 08239a16b8 | |||
| cb49dc9b73 | |||
| 43d4c9be43 | |||
| 1dc13698ad | |||
| d58432dcd9 | |||
| e7ff73c7f9 | |||
| 4ee9532d5f | |||
| 80c3fd8ea2 | |||
| 7e277d06d5 | |||
| d2b68119bd | |||
| f7b0d7edd5 | |||
| cdea1ab911 | |||
| ada6bfb5cf |
@@ -12,6 +12,7 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@@ -44,8 +45,11 @@ public class CategoryController {
|
|||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public List<CategoryDto> list() {
|
public List<CategoryDto> list() {
|
||||||
return categoryService.listCategories().stream()
|
List<Category> all = categoryService.listCategories();
|
||||||
.map(c -> categoryMapper.toDto(c, postService.countPostsByCategory(c.getId())))
|
List<Long> ids = all.stream().map(Category::getId).toList();
|
||||||
|
Map<Long, Long> postsCntByCategoryIds = postService.countPostsByCategoryIds(ids);
|
||||||
|
return all.stream()
|
||||||
|
.map(c -> categoryMapper.toDto(c, postsCntByCategoryIds.getOrDefault(c.getId(), 0L)))
|
||||||
.sorted((a, b) -> Long.compare(b.getCount(), a.getCount()))
|
.sorted((a, b) -> Long.compare(b.getCount(), a.getCount()))
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@@ -62,8 +63,11 @@ public class TagController {
|
|||||||
@GetMapping
|
@GetMapping
|
||||||
public List<TagDto> list(@RequestParam(value = "keyword", required = false) String keyword,
|
public List<TagDto> list(@RequestParam(value = "keyword", required = false) String keyword,
|
||||||
@RequestParam(value = "limit", required = false) Integer limit) {
|
@RequestParam(value = "limit", required = false) Integer limit) {
|
||||||
List<TagDto> dtos = tagService.searchTags(keyword).stream()
|
List<Tag> tags = tagService.searchTags(keyword);
|
||||||
.map(t -> tagMapper.toDto(t, postService.countPostsByTag(t.getId())))
|
List<Long> tagIds = tags.stream().map(Tag::getId).toList();
|
||||||
|
Map<Long, Long> postCntByTagIds = postService.countPostsByTagIds(tagIds);
|
||||||
|
List<TagDto> dtos = tags.stream()
|
||||||
|
.map(t -> tagMapper.toDto(t, postCntByTagIds.getOrDefault(t.getId(), 0L)))
|
||||||
.sorted((a, b) -> Long.compare(b.getCount(), a.getCount()))
|
.sorted((a, b) -> Long.compare(b.getCount(), a.getCount()))
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
if (limit != null && limit > 0 && dtos.size() > limit) {
|
if (limit != null && limit > 0 && dtos.size() > limit) {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ public class Notification {
|
|||||||
private Long id;
|
private Long id;
|
||||||
|
|
||||||
@Enumerated(EnumType.STRING)
|
@Enumerated(EnumType.STRING)
|
||||||
@Column(nullable = false)
|
@Column(nullable = false, length = 50)
|
||||||
private NotificationType type;
|
private NotificationType type;
|
||||||
|
|
||||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||||
|
|||||||
@@ -92,8 +92,14 @@ public interface PostRepository extends JpaRepository<Post, Long> {
|
|||||||
|
|
||||||
long countByCategory_Id(Long categoryId);
|
long countByCategory_Id(Long categoryId);
|
||||||
|
|
||||||
|
@Query("SELECT c.id, COUNT(p) FROM Post p JOIN p.category c WHERE c.id IN :categoryIds GROUP BY c.id")
|
||||||
|
List<Object[]> countPostsByCategoryIds(@Param("categoryIds") List<Long> categoryIds);
|
||||||
|
|
||||||
long countDistinctByTags_Id(Long tagId);
|
long countDistinctByTags_Id(Long tagId);
|
||||||
|
|
||||||
|
@Query("SELECT t.id, COUNT(DISTINCT p) FROM Post p JOIN p.tags t WHERE t.id IN :tagIds GROUP BY t.id")
|
||||||
|
List<Object[]> countPostsByTagIds(@Param("tagIds") List<Long> tagIds);
|
||||||
|
|
||||||
long countByAuthor_Id(Long userId);
|
long countByAuthor_Id(Long userId);
|
||||||
|
|
||||||
@Query("SELECT FUNCTION('date', p.createdAt) AS d, COUNT(p) AS c FROM Post p " +
|
@Query("SELECT FUNCTION('date', p.createdAt) AS d, COUNT(p) AS c FROM Post p " +
|
||||||
|
|||||||
@@ -31,16 +31,15 @@ import com.openisle.service.EmailSender;
|
|||||||
|
|
||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
import java.time.ZoneOffset;
|
import java.time.ZoneOffset;
|
||||||
import java.util.List;
|
import java.util.*;
|
||||||
|
|
||||||
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.data.domain.Sort;
|
import org.springframework.data.domain.Sort;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.util.CollectionUtils;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.HashSet;
|
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
import java.util.concurrent.ConcurrentMap;
|
import java.util.concurrent.ConcurrentMap;
|
||||||
import java.util.concurrent.ScheduledFuture;
|
import java.util.concurrent.ScheduledFuture;
|
||||||
@@ -567,10 +566,31 @@ public class PostService {
|
|||||||
return postRepository.countByCategory_Id(categoryId);
|
return postRepository.countByCategory_Id(categoryId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Map<Long, Long> countPostsByCategoryIds(List<Long> categoryIds) {
|
||||||
|
Map<Long, Long> result = new HashMap<>();
|
||||||
|
var dbResult = postRepository.countPostsByCategoryIds(categoryIds);
|
||||||
|
dbResult.forEach(r -> {
|
||||||
|
result.put(((Long)r[0]), ((Long)r[1]));
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
public long countPostsByTag(Long tagId) {
|
public long countPostsByTag(Long tagId) {
|
||||||
return postRepository.countDistinctByTags_Id(tagId);
|
return postRepository.countDistinctByTags_Id(tagId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Map<Long, Long> countPostsByTagIds(List<Long> tagIds) {
|
||||||
|
Map<Long, Long> result = new HashMap<>();
|
||||||
|
if (CollectionUtils.isEmpty(tagIds)) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
var dbResult = postRepository.countPostsByTagIds(tagIds);
|
||||||
|
dbResult.forEach(r -> {
|
||||||
|
result.put(((Long)r[0]), ((Long)r[1]));
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
private java.util.List<Post> sortByPinnedAndCreated(java.util.List<Post> posts) {
|
private java.util.List<Post> sortByPinnedAndCreated(java.util.List<Post> posts) {
|
||||||
return posts.stream()
|
return posts.stream()
|
||||||
.sorted(java.util.Comparator
|
.sorted(java.util.Comparator
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
/* Maple Mono - Thin 100 */
|
||||||
|
@font-face {
|
||||||
|
font-family: "Maple Mono";
|
||||||
|
src: url("/fonts/maple-mono-100-normal.woff2") format("woff2");
|
||||||
|
font-weight: 100;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Maple Mono - Thin Italic 100 */
|
||||||
|
@font-face {
|
||||||
|
font-family: "Maple Mono";
|
||||||
|
src: url("/fonts/maple-mono-100-italic.woff2") format("woff2");
|
||||||
|
font-weight: 100;
|
||||||
|
font-style: italic;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Maple Mono - ExtraLight 200 */
|
||||||
|
@font-face {
|
||||||
|
font-family: "Maple Mono";
|
||||||
|
src: url("/fonts/maple-mono-200-normal.woff2") format("woff2");
|
||||||
|
font-weight: 200;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Maple Mono - ExtraLight Italic 200 */
|
||||||
|
@font-face {
|
||||||
|
font-family: "Maple Mono";
|
||||||
|
src: url("/fonts/maple-mono-200-italic.woff2") format("woff2");
|
||||||
|
font-weight: 200;
|
||||||
|
font-style: italic;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Maple Mono - Light 300 */
|
||||||
|
@font-face {
|
||||||
|
font-family: "Maple Mono";
|
||||||
|
src: url("/fonts/maple-mono-300-normal.woff2") format("woff2");
|
||||||
|
font-weight: 300;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Maple Mono - Light Italic 300 */
|
||||||
|
@font-face {
|
||||||
|
font-family: "Maple Mono";
|
||||||
|
src: url("/fonts/maple-mono-300-italic.woff2") format("woff2");
|
||||||
|
font-weight: 300;
|
||||||
|
font-style: italic;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Maple Mono - Regular 400 */
|
||||||
|
@font-face {
|
||||||
|
font-family: "Maple Mono";
|
||||||
|
src: url("/fonts/maple-mono-400-normal.woff2") format("woff2");
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Maple Mono - Italic 400 */
|
||||||
|
@font-face {
|
||||||
|
font-family: "Maple Mono";
|
||||||
|
src: url("/fonts/maple-mono-400-italic.woff2") format("woff2");
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: italic;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Maple Mono - Medium 500 */
|
||||||
|
@font-face {
|
||||||
|
font-family: "Maple Mono";
|
||||||
|
src: url("/fonts/maple-mono-500-normal.woff2") format("woff2");
|
||||||
|
font-weight: 500;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Maple Mono - Medium Italic 500 */
|
||||||
|
@font-face {
|
||||||
|
font-family: "Maple Mono";
|
||||||
|
src: url("/fonts/maple-mono-500-italic.woff2") format("woff2");
|
||||||
|
font-weight: 500;
|
||||||
|
font-style: italic;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Maple Mono - SemiBold 600 */
|
||||||
|
@font-face {
|
||||||
|
font-family: "Maple Mono";
|
||||||
|
src: url("/fonts/maple-mono-600-normal.woff2") format("woff2");
|
||||||
|
font-weight: 600;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Maple Mono - SemiBold Italic 600 */
|
||||||
|
@font-face {
|
||||||
|
font-family: "Maple Mono";
|
||||||
|
src: url("/fonts/maple-mono-600-italic.woff2") format("woff2");
|
||||||
|
font-weight: 600;
|
||||||
|
font-style: italic;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Maple Mono - Bold 700 */
|
||||||
|
@font-face {
|
||||||
|
font-family: "Maple Mono";
|
||||||
|
src: url("/fonts/maple-mono-700-normal.woff2") format("woff2");
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Maple Mono - Bold Italic 700 */
|
||||||
|
@font-face {
|
||||||
|
font-family: "Maple Mono";
|
||||||
|
src: url("/fonts/maple-mono-700-italic.woff2") format("woff2");
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: italic;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Maple Mono - ExtraBold 800 */
|
||||||
|
@font-face {
|
||||||
|
font-family: "Maple Mono";
|
||||||
|
src: url("/fonts/maple-mono-800-normal.woff2") format("woff2");
|
||||||
|
font-weight: 800;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Maple Mono - ExtraBold Italic 800 */
|
||||||
|
@font-face {
|
||||||
|
font-family: "Maple Mono";
|
||||||
|
src: url("/fonts/maple-mono-800-italic.woff2") format("woff2");
|
||||||
|
font-weight: 800;
|
||||||
|
font-style: italic;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
@@ -131,13 +131,43 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.info-content-text pre {
|
.info-content-text pre {
|
||||||
background-color: var(--normal-background-color);
|
display: flex;
|
||||||
|
background-color: var(--lottery-background-color);
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.info-content-text pre .line-numbers {
|
||||||
|
counter-reset: line-number 0;
|
||||||
|
width: 2em;
|
||||||
|
font-size: 13px;
|
||||||
|
position: sticky;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-family: 'Maple Mono', monospace;
|
||||||
|
margin: 1em 0;
|
||||||
|
color: #888;
|
||||||
|
border-right: 1px solid #888;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding-right: 0.5em;
|
||||||
|
text-align: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-content-text pre .line-numbers .line-number::before {
|
||||||
|
content: counter(line-number);
|
||||||
|
counter-increment: line-number;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-content-text code {
|
||||||
|
font-family: 'Maple Mono', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
border-radius: 4px;
|
||||||
|
white-space: no-wrap;
|
||||||
|
background-color: var(--lottery-background-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
.copy-code-btn {
|
.copy-code-btn {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 4px;
|
top: 4px;
|
||||||
@@ -156,15 +186,6 @@ body {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-content-text code {
|
|
||||||
font-family: 'Roboto Mono', monospace;
|
|
||||||
font-size: 13px;
|
|
||||||
border-radius: 4px;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
background-color: var(--normal-background-color);
|
|
||||||
color: var(--text-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.info-content-text a {
|
.info-content-text a {
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -267,7 +288,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.info-content-text pre {
|
.info-content-text pre {
|
||||||
line-height: 1.1;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vditor-panel {
|
.vditor-panel {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<span v-if="isMobile && unreadCount > 0" class="menu-unread-dot"></span>
|
<span v-if="isMobile && unreadCount > 0" class="menu-unread-dot"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="logo-container" @click="goToHome">
|
<NuxtLink class="logo-container" :to="`/`">
|
||||||
<img
|
<img
|
||||||
alt="OpenIsle"
|
alt="OpenIsle"
|
||||||
src="https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png"
|
src="https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png"
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
height="60"
|
height="60"
|
||||||
/>
|
/>
|
||||||
<div class="logo-text">OpenIsle</div>
|
<div class="logo-text">OpenIsle</div>
|
||||||
</div>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
@@ -51,7 +51,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ClientOnly } from '#components'
|
import { ClientOnly } from '#components'
|
||||||
import { computed, nextTick, ref, watch } from 'vue'
|
import { computed, nextTick, ref, watch } from 'vue'
|
||||||
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 { authState, clearToken, loadCurrentUser } from '~/utils/auth'
|
||||||
@@ -67,20 +66,12 @@ const props = defineProps({
|
|||||||
const isLogin = computed(() => authState.loggedIn)
|
const isLogin = computed(() => authState.loggedIn)
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
const unreadCount = computed(() => notificationState.unreadCount)
|
const unreadCount = computed(() => notificationState.unreadCount)
|
||||||
const router = useRouter()
|
|
||||||
const avatar = ref('')
|
const avatar = ref('')
|
||||||
const showSearch = ref(false)
|
const showSearch = ref(false)
|
||||||
const searchDropdown = ref(null)
|
const searchDropdown = ref(null)
|
||||||
const userMenu = ref(null)
|
const userMenu = ref(null)
|
||||||
const menuBtn = ref(null)
|
const menuBtn = ref(null)
|
||||||
|
|
||||||
const goToHome = async () => {
|
|
||||||
if (router.currentRoute.value.fullPath === '/') {
|
|
||||||
window.dispatchEvent(new Event('refresh-home'))
|
|
||||||
} else {
|
|
||||||
await navigateTo('/', { replace: true })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const search = () => {
|
const search = () => {
|
||||||
showSearch.value = true
|
showSearch.value = true
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
@@ -155,14 +146,6 @@ onMounted(async () => {
|
|||||||
await updateUnread()
|
await updateUnread()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
watch(
|
|
||||||
() => router.currentRoute.value.fullPath,
|
|
||||||
() => {
|
|
||||||
if (userMenu.value) userMenu.value.close()
|
|
||||||
showSearch.value = false
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -184,6 +167,8 @@ onMounted(async () => {
|
|||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-content {
|
.header-content {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<transition name="slide">
|
<transition name="slide">
|
||||||
<nav v-if="visible" class="menu">
|
<nav v-if="visible" class="menu">
|
||||||
<div class="menu-item-container">
|
<div class="menu-item-container">
|
||||||
<NuxtLink class="menu-item" exact-active-class="selected" to="/" @click="handleHomeClick">
|
<NuxtLink class="menu-item" exact-active-class="selected" to="/" @click="handleItemClick">
|
||||||
<i class="menu-item-icon fas fa-hashtag"></i>
|
<i class="menu-item-icon fas fa-hashtag"></i>
|
||||||
<span class="menu-item-text">话题</span>
|
<span class="menu-item-text">话题</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
@@ -191,10 +191,6 @@ onMounted(async () => {
|
|||||||
watch(() => authState.loggedIn, updateCount)
|
watch(() => authState.loggedIn, updateCount)
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleHomeClick = () => {
|
|
||||||
navigateTo('/', { replace: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleItemClick = () => {
|
const handleItemClick = () => {
|
||||||
if (window.innerWidth <= 768) emit('item-click')
|
if (window.innerWidth <= 768) emit('item-click')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ export default defineNuxtConfig({
|
|||||||
twitterClientId: process.env.NUXT_PUBLIC_TWITTER_CLIENT_ID || '',
|
twitterClientId: process.env.NUXT_PUBLIC_TWITTER_CLIENT_ID || '',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// Ensure Vditor styles load before our overrides in global.css
|
// 确保 Vditor 样式在 global.css 覆盖前加载
|
||||||
css: ['vditor/dist/index.css', '~/assets/global.css'],
|
css: ['vditor/dist/index.css', '~/assets/fonts.css', '~/assets/global.css'],
|
||||||
app: {
|
app: {
|
||||||
head: {
|
head: {
|
||||||
script: [
|
script: [
|
||||||
@@ -52,6 +52,8 @@ export default defineNuxtConfig({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
baseURL: '/',
|
||||||
|
buildAssetsDir: '/_nuxt/',
|
||||||
},
|
},
|
||||||
vue: {
|
vue: {
|
||||||
compilerOptions: {
|
compilerOptions: {
|
||||||
|
|||||||
+157
-221
@@ -50,7 +50,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="isLoadingPosts && articles.length === 0" class="loading-container">
|
<div v-if="pendingFirst" class="loading-container">
|
||||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -60,7 +60,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="article-item" v-for="article in articles" :key="article.id">
|
<div
|
||||||
|
v-if="!pendingFirst"
|
||||||
|
class="article-item"
|
||||||
|
v-for="article in articles"
|
||||||
|
:key="article.id"
|
||||||
|
>
|
||||||
<div class="article-main-container">
|
<div class="article-main-container">
|
||||||
<NuxtLink class="article-item-title main-item" :to="`/posts/${article.id}`">
|
<NuxtLink class="article-item-title main-item" :to="`/posts/${article.id}`">
|
||||||
<i v-if="article.pinned" class="fas fa-thumbtack pinned-icon"></i>
|
<i v-if="article.pinned" class="fas fa-thumbtack pinned-icon"></i>
|
||||||
@@ -104,15 +109,14 @@
|
|||||||
热门帖子功能开发中,敬请期待。
|
热门帖子功能开发中,敬请期待。
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="placeholder-container">分类浏览功能开发中,敬请期待。</div>
|
<div v-else class="placeholder-container">分类浏览功能开发中,敬请期待。</div>
|
||||||
<div v-if="isLoadingPosts && articles.length > 0" class="loading-container bottom-loading">
|
<div v-if="isLoadingMore && articles.length > 0" class="loading-container bottom-loading">
|
||||||
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
import { ref, watch, watchEffect, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import ArticleCategory from '~/components/ArticleCategory.vue'
|
import ArticleCategory from '~/components/ArticleCategory.vue'
|
||||||
import ArticleTags from '~/components/ArticleTags.vue'
|
import ArticleTags from '~/components/ArticleTags.vue'
|
||||||
import CategorySelect from '~/components/CategorySelect.vue'
|
import CategorySelect from '~/components/CategorySelect.vue'
|
||||||
@@ -142,7 +146,9 @@ const selectedTags = ref([])
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const tagOptions = ref([])
|
const tagOptions = ref([])
|
||||||
const categoryOptions = ref([])
|
const categoryOptions = ref([])
|
||||||
const isLoadingPosts = ref(false)
|
|
||||||
|
const isLoadingMore = ref(false)
|
||||||
|
|
||||||
const topics = ref(['最新回复', '最新', '排行榜' /*, '热门', '类别'*/])
|
const topics = ref(['最新回复', '最新', '排行榜' /*, '热门', '类别'*/])
|
||||||
const selectedTopic = ref(
|
const selectedTopic = ref(
|
||||||
route.query.view === 'ranking' ? '排行榜' : route.query.view === 'latest' ? '最新' : '最新回复',
|
route.query.view === 'ranking' ? '排行榜' : route.query.view === 'latest' ? '最新' : '最新回复',
|
||||||
@@ -153,11 +159,11 @@ const pageSize = 10
|
|||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
const allLoaded = ref(false)
|
const allLoaded = ref(false)
|
||||||
|
|
||||||
|
/** URL 参数 -> 本地筛选值 **/
|
||||||
const selectedCategorySet = (category) => {
|
const selectedCategorySet = (category) => {
|
||||||
const c = decodeURIComponent(category)
|
const c = decodeURIComponent(category)
|
||||||
selectedCategory.value = isNaN(c) ? c : Number(c)
|
selectedCategory.value = isNaN(c) ? c : Number(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedTagsSet = (tags) => {
|
const selectedTagsSet = (tags) => {
|
||||||
const t = Array.isArray(tags) ? tags.join(',') : tags
|
const t = Array.isArray(tags) ? tags.join(',') : tags
|
||||||
selectedTags.value = t
|
selectedTags.value = t
|
||||||
@@ -167,23 +173,17 @@ const selectedTagsSet = (tags) => {
|
|||||||
.map((v) => (isNaN(v) ? v : Number(v)))
|
.map((v) => (isNaN(v) ? v : Number(v)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 初始化:仅在客户端首渲染时根据路由同步一次 **/
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const query = route.query
|
const { category, tags } = route.query
|
||||||
const category = query.category
|
if (category) selectedCategorySet(category)
|
||||||
const tags = query.tags
|
if (tags) selectedTagsSet(tags)
|
||||||
|
|
||||||
if (category) {
|
|
||||||
selectedCategorySet(category)
|
|
||||||
}
|
|
||||||
if (tags) {
|
|
||||||
selectedTagsSet(tags)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** 路由变更时同步筛选 **/
|
||||||
watch(
|
watch(
|
||||||
() => route.query,
|
() => route.query,
|
||||||
() => {
|
(query) => {
|
||||||
const query = route.query
|
|
||||||
const category = query.category
|
const category = query.category
|
||||||
const tags = query.tags
|
const tags = query.tags
|
||||||
category && selectedCategorySet(category)
|
category && selectedCategorySet(category)
|
||||||
@@ -191,18 +191,14 @@ watch(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** 选项加载(分类/标签名称回填) **/
|
||||||
const loadOptions = async () => {
|
const loadOptions = async () => {
|
||||||
if (selectedCategory.value && !isNaN(selectedCategory.value)) {
|
if (selectedCategory.value && !isNaN(selectedCategory.value)) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE_URL}/api/categories/${selectedCategory.value}`)
|
const res = await fetch(`${API_BASE_URL}/api/categories/${selectedCategory.value}`)
|
||||||
if (res.ok) {
|
if (res.ok) categoryOptions.value = [await res.json()]
|
||||||
categoryOptions.value = [await res.json()]
|
} catch {}
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedTags.value.length) {
|
if (selectedTags.value.length) {
|
||||||
const arr = []
|
const arr = []
|
||||||
for (const t of selectedTags.value) {
|
for (const t of selectedTags.value) {
|
||||||
@@ -210,218 +206,158 @@ const loadOptions = async () => {
|
|||||||
try {
|
try {
|
||||||
const r = await fetch(`${API_BASE_URL}/api/tags/${t}`)
|
const r = await fetch(`${API_BASE_URL}/api/tags/${t}`)
|
||||||
if (r.ok) arr.push(await r.json())
|
if (r.ok) arr.push(await r.json())
|
||||||
} catch (e) {
|
} catch {}
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tagOptions.value = arr
|
tagOptions.value = arr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildUrl = () => {
|
/** 列表 API 路径与查询参数 **/
|
||||||
let url = `${API_BASE_URL}/api/posts?page=${page.value}&pageSize=${pageSize}`
|
const baseQuery = computed(() => ({
|
||||||
if (selectedCategory.value) {
|
categoryId: selectedCategory.value || undefined,
|
||||||
url += `&categoryId=${selectedCategory.value}`
|
tagIds: selectedTags.value.length ? selectedTags.value : undefined,
|
||||||
}
|
}))
|
||||||
if (selectedTags.value.length) {
|
const listApiPath = computed(() => {
|
||||||
selectedTags.value.forEach((t) => {
|
if (selectedTopic.value === '排行榜') return '/api/posts/ranking'
|
||||||
url += `&tagIds=${t}`
|
if (selectedTopic.value === '最新回复') return '/api/posts/latest-reply'
|
||||||
})
|
return '/api/posts'
|
||||||
}
|
})
|
||||||
return url
|
const buildUrl = ({ pageNo }) => {
|
||||||
|
const url = new URL(`${API_BASE_URL}${listApiPath.value}`)
|
||||||
|
url.searchParams.set('page', pageNo)
|
||||||
|
url.searchParams.set('pageSize', pageSize)
|
||||||
|
if (baseQuery.value.categoryId) url.searchParams.set('categoryId', baseQuery.value.categoryId)
|
||||||
|
if (baseQuery.value.tagIds)
|
||||||
|
for (const t of baseQuery.value.tagIds) url.searchParams.append('tagIds', t)
|
||||||
|
return url.toString()
|
||||||
}
|
}
|
||||||
|
const tokenHeader = computed(() => {
|
||||||
const buildRankUrl = () => {
|
const token = getToken()
|
||||||
let url = `${API_BASE_URL}/api/posts/ranking?page=${page.value}&pageSize=${pageSize}`
|
return token ? { Authorization: `Bearer ${token}` } : {}
|
||||||
if (selectedCategory.value) {
|
|
||||||
url += `&categoryId=${selectedCategory.value}`
|
|
||||||
}
|
|
||||||
if (selectedTags.value.length) {
|
|
||||||
selectedTags.value.forEach((t) => {
|
|
||||||
url += `&tagIds=${t}`
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildReplyUrl = () => {
|
|
||||||
let url = `${API_BASE_URL}/api/posts/latest-reply?page=${page.value}&pageSize=${pageSize}`
|
|
||||||
if (selectedCategory.value) {
|
|
||||||
url += `&categoryId=${selectedCategory.value}`
|
|
||||||
}
|
|
||||||
if (selectedTags.value.length) {
|
|
||||||
selectedTags.value.forEach((t) => {
|
|
||||||
url += `&tagIds=${t}`
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchPosts = async (reset = false) => {
|
|
||||||
if (reset) {
|
|
||||||
page.value = 0
|
|
||||||
allLoaded.value = false
|
|
||||||
articles.value = []
|
|
||||||
}
|
|
||||||
if (isLoadingPosts.value || allLoaded.value) return
|
|
||||||
try {
|
|
||||||
isLoadingPosts.value = true
|
|
||||||
const token = getToken()
|
|
||||||
const res = await fetch(buildUrl(), {
|
|
||||||
headers: {
|
|
||||||
Authorization: token ? `Bearer ${token}` : '',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
isLoadingPosts.value = false
|
|
||||||
if (!res.ok) return
|
|
||||||
const data = await res.json()
|
|
||||||
articles.value.push(
|
|
||||||
...data.map((p) => ({
|
|
||||||
id: p.id,
|
|
||||||
title: p.title,
|
|
||||||
description: p.content,
|
|
||||||
category: p.category,
|
|
||||||
tags: p.tags || [],
|
|
||||||
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
|
|
||||||
comments: p.commentCount,
|
|
||||||
views: p.views,
|
|
||||||
time: TimeManager.format(p.createdAt),
|
|
||||||
pinned: !!p.pinnedAt,
|
|
||||||
type: p.type,
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
if (data.length < pageSize) {
|
|
||||||
allLoaded.value = true
|
|
||||||
} else {
|
|
||||||
page.value += 1
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchRanking = async (reset = false) => {
|
|
||||||
if (reset) {
|
|
||||||
page.value = 0
|
|
||||||
allLoaded.value = false
|
|
||||||
articles.value = []
|
|
||||||
}
|
|
||||||
if (isLoadingPosts.value || allLoaded.value) return
|
|
||||||
try {
|
|
||||||
isLoadingPosts.value = true
|
|
||||||
const token = getToken()
|
|
||||||
const res = await fetch(buildRankUrl(), {
|
|
||||||
headers: {
|
|
||||||
Authorization: token ? `Bearer ${token}` : '',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
isLoadingPosts.value = false
|
|
||||||
if (!res.ok) return
|
|
||||||
const data = await res.json()
|
|
||||||
articles.value.push(
|
|
||||||
...data.map((p) => ({
|
|
||||||
id: p.id,
|
|
||||||
title: p.title,
|
|
||||||
description: p.content,
|
|
||||||
category: p.category,
|
|
||||||
tags: p.tags || [],
|
|
||||||
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
|
|
||||||
comments: p.commentCount,
|
|
||||||
views: p.views,
|
|
||||||
time: TimeManager.format(p.createdAt),
|
|
||||||
pinned: !!p.pinnedAt,
|
|
||||||
type: p.type,
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
if (data.length < pageSize) {
|
|
||||||
allLoaded.value = true
|
|
||||||
} else {
|
|
||||||
page.value += 1
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchLatestReply = async (reset = false) => {
|
|
||||||
if (reset) {
|
|
||||||
page.value = 0
|
|
||||||
allLoaded.value = false
|
|
||||||
articles.value = []
|
|
||||||
}
|
|
||||||
if (isLoadingPosts.value || allLoaded.value) return
|
|
||||||
try {
|
|
||||||
isLoadingPosts.value = true
|
|
||||||
const token = getToken()
|
|
||||||
const res = await fetch(buildReplyUrl(), {
|
|
||||||
headers: {
|
|
||||||
Authorization: token ? `Bearer ${token}` : '',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
isLoadingPosts.value = false
|
|
||||||
if (!res.ok) return
|
|
||||||
const data = await res.json()
|
|
||||||
articles.value.push(
|
|
||||||
...data.map((p) => ({
|
|
||||||
id: p.id,
|
|
||||||
title: p.title,
|
|
||||||
description: p.content,
|
|
||||||
category: p.category,
|
|
||||||
tags: p.tags || [],
|
|
||||||
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
|
|
||||||
comments: p.commentCount,
|
|
||||||
views: p.views,
|
|
||||||
time: TimeManager.format(p.lastReplyAt || p.createdAt),
|
|
||||||
pinned: !!p.pinnedAt,
|
|
||||||
type: p.type,
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
if (data.length < pageSize) {
|
|
||||||
allLoaded.value = true
|
|
||||||
} else {
|
|
||||||
page.value += 1
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchContent = async (reset = false) => {
|
|
||||||
if (selectedTopic.value === '排行榜') {
|
|
||||||
await fetchRanking(reset)
|
|
||||||
} else if (selectedTopic.value === '最新回复') {
|
|
||||||
await fetchLatestReply(reset)
|
|
||||||
} else {
|
|
||||||
await fetchPosts(reset)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const refreshHome = () => {
|
|
||||||
fetchContent(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
window.addEventListener('refresh-home', refreshHome)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
/** —— 首屏数据托管(SSR) —— **/
|
||||||
window.removeEventListener('refresh-home', refreshHome)
|
const asyncKey = computed(() => [
|
||||||
})
|
'home:firstpage',
|
||||||
|
selectedTopic.value,
|
||||||
|
String(baseQuery.value.categoryId ?? ''),
|
||||||
|
JSON.stringify(baseQuery.value.tagIds ?? []),
|
||||||
|
])
|
||||||
|
const {
|
||||||
|
data: firstPage,
|
||||||
|
pending: pendingFirst,
|
||||||
|
refresh: refreshFirst,
|
||||||
|
} = await useAsyncData(
|
||||||
|
() => asyncKey.value.join('::'),
|
||||||
|
async () => {
|
||||||
|
const res = await $fetch(buildUrl({ pageNo: 0 }), { headers: tokenHeader.value })
|
||||||
|
const data = Array.isArray(res) ? res : []
|
||||||
|
return data.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
title: p.title,
|
||||||
|
description: p.content,
|
||||||
|
category: p.category,
|
||||||
|
tags: p.tags || [],
|
||||||
|
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
|
||||||
|
comments: p.commentCount,
|
||||||
|
views: p.views,
|
||||||
|
time: TimeManager.format(
|
||||||
|
selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,
|
||||||
|
),
|
||||||
|
pinned: Boolean(p.pinned ?? p.pinnedAt ?? p.pinned_at),
|
||||||
|
type: p.type,
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
server: true,
|
||||||
|
default: () => [],
|
||||||
|
watch: [selectedTopic, baseQuery],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
useScrollLoadMore(fetchContent)
|
/** 首屏/筛选变更:重置分页并灌入 firstPage **/
|
||||||
|
watch(
|
||||||
|
firstPage,
|
||||||
|
(data) => {
|
||||||
|
page.value = 0
|
||||||
|
articles.value = [...(data || [])]
|
||||||
|
allLoaded.value = (data?.length || 0) < pageSize
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
/** —— 滚动加载更多 —— **/
|
||||||
|
let inflight = null
|
||||||
|
const fetchNextPage = async () => {
|
||||||
|
if (allLoaded.value || pendingFirst.value || inflight) return
|
||||||
|
const nextPage = page.value + 1
|
||||||
|
isLoadingMore.value = true
|
||||||
|
inflight = $fetch(buildUrl({ pageNo: nextPage }), { headers: tokenHeader.value })
|
||||||
|
.then((res) => {
|
||||||
|
const data = Array.isArray(res) ? res : []
|
||||||
|
const mapped = data.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
title: p.title,
|
||||||
|
description: p.content,
|
||||||
|
category: p.category,
|
||||||
|
tags: p.tags || [],
|
||||||
|
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
|
||||||
|
comments: p.commentCount,
|
||||||
|
views: p.views,
|
||||||
|
time: TimeManager.format(
|
||||||
|
selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,
|
||||||
|
),
|
||||||
|
pinned: Boolean(p.pinned ?? p.pinnedAt ?? p.pinned_at),
|
||||||
|
type: p.type,
|
||||||
|
}))
|
||||||
|
articles.value.push(...mapped)
|
||||||
|
if (data.length < pageSize) {
|
||||||
|
allLoaded.value = true
|
||||||
|
} else {
|
||||||
|
page.value = nextPage
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
inflight = null
|
||||||
|
isLoadingMore.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 绑定滚动加载(避免挂载瞬间触发) **/
|
||||||
|
let initialReady = false
|
||||||
|
const loadMoreGuarded = async () => {
|
||||||
|
if (!initialReady) return
|
||||||
|
await fetchNextPage()
|
||||||
|
}
|
||||||
|
useScrollLoadMore(loadMoreGuarded)
|
||||||
|
watch(
|
||||||
|
articles,
|
||||||
|
() => {
|
||||||
|
if (!initialReady && articles.value.length) initialReady = true
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 切换分类/标签/Tab:useAsyncData 已 watch,这里只需确保 options 加载 **/
|
||||||
watch([selectedCategory, selectedTags], () => {
|
watch([selectedCategory, selectedTags], () => {
|
||||||
fetchContent(true)
|
loadOptions()
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(selectedTopic, () => {
|
watch(selectedTopic, () => {
|
||||||
fetchContent(true)
|
// 仅当需要额外选项时加载
|
||||||
|
loadOptions()
|
||||||
})
|
})
|
||||||
|
|
||||||
const sanitizeDescription = (text) => stripMarkdown(text)
|
/** 选项首屏加载:服务端执行一次;客户端兜底 **/
|
||||||
|
if (import.meta.server) {
|
||||||
|
await loadOptions()
|
||||||
|
}
|
||||||
|
onMounted(() => {
|
||||||
|
if (categoryOptions.value.length === 0 && tagOptions.value.length === 0) loadOptions()
|
||||||
|
})
|
||||||
|
|
||||||
await Promise.all([loadOptions(), fetchContent()])
|
/** 其他工具函数 **/
|
||||||
|
const sanitizeDescription = (text) => stripMarkdown(text)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -77,7 +77,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup>
|
||||||
import 'flatpickr/dist/flatpickr.css'
|
import 'flatpickr/dist/flatpickr.css'
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import FlatPickr from 'vue-flatpickr-component'
|
import FlatPickr from 'vue-flatpickr-component'
|
||||||
|
|||||||
@@ -392,7 +392,7 @@ const mapComment = (c, parentUserName = '', level = 0) => ({
|
|||||||
avatar: c.author.avatar,
|
avatar: c.author.avatar,
|
||||||
text: c.content,
|
text: c.content,
|
||||||
reactions: c.reactions || [],
|
reactions: c.reactions || [],
|
||||||
pinned: !!c.pinnedAt,
|
pinned: Boolean(c.pinned ?? c.pinnedAt ?? c.pinned_at),
|
||||||
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,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { defineNuxtPlugin } from 'nuxt/app'
|
||||||
import ClickOutside from '~/directives/clickOutside.js'
|
import ClickOutside from '~/directives/clickOutside.js'
|
||||||
|
|
||||||
export default defineNuxtPlugin((nuxtApp) => {
|
export default defineNuxtPlugin((nuxtApp) => {
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,7 +0,0 @@
|
|||||||
export default {
|
|
||||||
push(path) {
|
|
||||||
if (process.client) {
|
|
||||||
window.location.href = path
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,14 @@
|
|||||||
import hljs from 'highlight.js'
|
import hljs from 'highlight.js'
|
||||||
import 'highlight.js/styles/github.css'
|
if (typeof window !== 'undefined') {
|
||||||
|
const theme =
|
||||||
|
document.documentElement.dataset.theme ||
|
||||||
|
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
|
||||||
|
if (theme === 'dark') {
|
||||||
|
import('highlight.js/styles/atom-one-dark.css')
|
||||||
|
} else {
|
||||||
|
import('highlight.js/styles/atom-one-light.css')
|
||||||
|
}
|
||||||
|
}
|
||||||
import MarkdownIt from 'markdown-it'
|
import MarkdownIt from 'markdown-it'
|
||||||
import { toast } from '../main'
|
import { toast } from '../main'
|
||||||
import { tiebaEmoji } from './tiebaEmoji'
|
import { tiebaEmoji } from './tiebaEmoji'
|
||||||
@@ -86,7 +95,11 @@ const md = new MarkdownIt({
|
|||||||
} else {
|
} else {
|
||||||
code = hljs.highlightAuto(str).value
|
code = hljs.highlightAuto(str).value
|
||||||
}
|
}
|
||||||
return `<pre class="code-block"><button class="copy-code-btn">Copy</button><code class="hljs language-${lang || ''}">${code}</code></pre>`
|
const lineNumbers = code
|
||||||
|
.trim()
|
||||||
|
.split('\n')
|
||||||
|
.map(() => `<div class="line-number"></div>`)
|
||||||
|
return `<pre class="code-block"><button class="copy-code-btn">Copy</button><div class="line-numbers">${lineNumbers.join('')}</div><code class="hljs language-${lang || ''}">${code.trim()}</code></pre>`
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user