Compare commits

..

34 Commits

Author SHA1 Message Date
Tim
84b45f785d fix: 代码风格设置 2025-08-14 19:55:53 +08:00
tim
df56d7e885 Revert "optimize(backend): optimize /api/posts/latest-reply"
This reverts commit 1e87e9252d.
2025-08-14 18:54:12 +08:00
tim
76176e135c Merge branch 'main' of github.com:nagisa77/OpenIsle 2025-08-14 18:27:25 +08:00
tim
ab87e0e51c fix: fix missing setup 2025-08-14 18:27:12 +08:00
Tim
5346a063bf Merge pull request #555 from netcaty/main
优化主页列表接口/api/posts/latest-reply
2025-08-14 18:19:19 +08:00
netcaty
e53f2130b8 Merge branch 'nagisa77:main' into main 2025-08-14 17:54:08 +08:00
netcat
1e87e9252d optimize(backend): optimize /api/posts/latest-reply
resolves #554
2025-08-14 17:53:01 +08:00
tim
3fc4d29dce Merge branch 'main' of github.com:nagisa77/OpenIsle 2025-08-14 17:27:42 +08:00
tim
bcdac9d9b2 fix: delete hook update 2025-08-14 17:27:30 +08:00
Tim
ea9710d16f Merge pull request #553 from nagisa77/codex/fix-missing-comment-pinning-feature
fix: restore comment pin handling
2025-08-14 17:21:26 +08:00
Tim
47134cadc2 fix: handle pinned comments from backend 2025-08-14 17:21:08 +08:00
tim
1a1b20b9cf fix: update css import 2025-08-14 17:20:02 +08:00
Tim
b63ebb8fae Merge pull request #552 from immortal521/feat/code-block-line-number
feat: add code block line number display
2025-08-14 16:47:46 +08:00
immortal521
e0f7299a86 feat: add code block line number display
- Added Maple Mono font
- Changed code block font to Maple Mono
- Increased mobile line height from 1.1 to 1.5
2025-08-14 15:40:14 +08:00
Tim
1f9ae8d057 Merge pull request #550 from nagisa77/feature/fix_db_error
fix: fix reward db error
2025-08-14 15:21:31 +08:00
Tim
53c603f33a Merge pull request #546 from netcaty/main
optimize(backend): batch query for /api/categories && /api/tags
2025-08-14 14:30:14 +08:00
Tim
06f86f2b21 Merge pull request #545 from nagisa77/feature/first_screen
Feature/first screen
2025-08-14 14:26:17 +08:00
Tim
22693bfdd9 fix: 首屏ssr优化 2025-08-14 14:25:38 +08:00
netcat
0058f20b1e optimize(backend): batch query for /api/categories && /api/tags 2025-08-14 14:19:04 +08:00
Tim
304d941d68 Revert "fix: use home path"
This reverts commit 2efe4e733a.
2025-08-14 13:50:58 +08:00
Tim
3dbcd2ac4d Merge pull request #543 from nagisa77/feature/first_screen
fix: use home path
2025-08-14 13:46:48 +08:00
Tim
2efe4e733a fix: use home path 2025-08-14 13:45:29 +08:00
Tim
08239a16b8 Merge pull request #542 from nagisa77/feature/first_screen
fix: 首屏ssr优化
2025-08-14 13:40:07 +08:00
Tim
cb49dc9b73 fix: 首屏ssr优化 2025-08-14 13:39:25 +08:00
Tim
43d4c9be43 Merge pull request #541 from nagisa77/feature/first_screen
fix: 首屏ssr优化
2025-08-14 13:24:17 +08:00
Tim
1dc13698ad fix: 首屏ssr优化 2025-08-14 13:22:53 +08:00
Tim
d58432dcd9 Merge pull request #540 from nagisa77/codex/fix-logo-click-triggering-window.reload 2025-08-14 12:47:43 +08:00
Tim
e7ff73c7f9 fix: prevent header logo from triggering page reload 2025-08-14 12:47:26 +08:00
Tim
4ee9532d5f Merge pull request #539 from nagisa77/codex/fix-logo-click-reload-issue 2025-08-14 12:38:11 +08:00
Tim
80c3fd8ea2 fix: prevent homepage reload on logo click 2025-08-14 12:37:54 +08:00
Tim
7e277d06d5 Merge pull request #538 from nagisa77/feature/first_screen
fix: 首屏幕ssr优化
2025-08-14 12:29:58 +08:00
Tim
d2b68119bd fix: 首屏幕ssr优化 2025-08-14 12:29:08 +08:00
Tim
f7b0d7edd5 Merge pull request #537 from nagisa77/feature/first_screen
fix: 首屏幕ssr优化
2025-08-14 11:56:26 +08:00
Tim
cdea1ab911 fix: 首屏幕ssr优化 2025-08-14 11:55:39 +08:00
31 changed files with 401 additions and 277 deletions

View File

@@ -12,6 +12,7 @@ import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@RestController
@@ -44,8 +45,11 @@ public class CategoryController {
@GetMapping
public List<CategoryDto> list() {
return categoryService.listCategories().stream()
.map(c -> categoryMapper.toDto(c, postService.countPostsByCategory(c.getId())))
List<Category> all = categoryService.listCategories();
List<Long> ids = all.stream().map(Category::getId).toList();
Map<Long, Long> postsCntByCategoryIds = postService.countPostsByCategoryIds(ids);
return all.stream()
.map(c -> categoryMapper.toDto(c, postsCntByCategoryIds.getOrDefault(c.getId(), 0L)))
.sorted((a, b) -> Long.compare(b.getCount(), a.getCount()))
.collect(Collectors.toList());
}

View File

@@ -15,6 +15,7 @@ import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@RestController
@@ -62,8 +63,11 @@ public class TagController {
@GetMapping
public List<TagDto> list(@RequestParam(value = "keyword", required = false) String keyword,
@RequestParam(value = "limit", required = false) Integer limit) {
List<TagDto> dtos = tagService.searchTags(keyword).stream()
.map(t -> tagMapper.toDto(t, postService.countPostsByTag(t.getId())))
List<Tag> tags = tagService.searchTags(keyword);
List<Long> tagIds = tags.stream().map(Tag::getId).toList();
Map<Long, Long> postCntByTagIds = postService.countPostsByTagIds(tagIds);
List<TagDto> dtos = tags.stream()
.map(t -> tagMapper.toDto(t, postCntByTagIds.getOrDefault(t.getId(), 0L)))
.sorted((a, b) -> Long.compare(b.getCount(), a.getCount()))
.collect(Collectors.toList());
if (limit != null && limit > 0 && dtos.size() > limit) {

View File

@@ -92,8 +92,14 @@ public interface PostRepository extends JpaRepository<Post, Long> {
long countByCategory_Id(Long categoryId);
@Query("SELECT c.id, COUNT(p) FROM Post p JOIN p.category c WHERE c.id IN :categoryIds GROUP BY c.id")
List<Object[]> countPostsByCategoryIds(@Param("categoryIds") List<Long> categoryIds);
long countDistinctByTags_Id(Long tagId);
@Query("SELECT t.id, COUNT(DISTINCT p) FROM Post p JOIN p.tags t WHERE t.id IN :tagIds GROUP BY t.id")
List<Object[]> countPostsByTagIds(@Param("tagIds") List<Long> tagIds);
long countByAuthor_Id(Long userId);
@Query("SELECT FUNCTION('date', p.createdAt) AS d, COUNT(p) AS c FROM Post p " +

View File

@@ -31,16 +31,15 @@ import com.openisle.service.EmailSender;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.List;
import java.util.*;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.ScheduledFuture;
@@ -567,10 +566,31 @@ public class PostService {
return postRepository.countByCategory_Id(categoryId);
}
public Map<Long, Long> countPostsByCategoryIds(List<Long> categoryIds) {
Map<Long, Long> result = new HashMap<>();
var dbResult = postRepository.countPostsByCategoryIds(categoryIds);
dbResult.forEach(r -> {
result.put(((Long)r[0]), ((Long)r[1]));
});
return result;
}
public long countPostsByTag(Long tagId) {
return postRepository.countDistinctByTags_Id(tagId);
}
public Map<Long, Long> countPostsByTagIds(List<Long> tagIds) {
Map<Long, Long> result = new HashMap<>();
if (CollectionUtils.isEmpty(tagIds)) {
return result;
}
var dbResult = postRepository.countPostsByTagIds(tagIds);
dbResult.forEach(r -> {
result.put(((Long)r[0]), ((Long)r[1]));
});
return result;
}
private java.util.List<Post> sortByPinnedAndCreated(java.util.List<Post> posts) {
return posts.stream()
.sorted(java.util.Comparator

View File

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

View File

@@ -131,13 +131,43 @@ body {
}
.info-content-text pre {
background-color: var(--normal-background-color);
display: flex;
background-color: var(--lottery-background-color);
padding: 8px 12px;
border-radius: 4px;
line-height: 1.5;
position: relative;
}
.info-content-text pre .line-numbers {
counter-reset: line-number 0;
width: 2em;
font-size: 13px;
position: sticky;
flex-shrink: 0;
font-family: 'Maple Mono', monospace;
margin: 1em 0;
color: #888;
border-right: 1px solid #888;
box-sizing: border-box;
padding-right: 0.5em;
text-align: end;
}
.info-content-text pre .line-numbers .line-number::before {
content: counter(line-number);
counter-increment: line-number;
}
.info-content-text code {
font-family: 'Maple Mono', monospace;
font-size: 13px;
border-radius: 4px;
white-space: no-wrap;
background-color: var(--lottery-background-color);
color: var(--text-color);
}
.copy-code-btn {
position: absolute;
top: 4px;
@@ -156,15 +186,6 @@ body {
opacity: 1;
}
.info-content-text code {
font-family: 'Roboto Mono', monospace;
font-size: 13px;
border-radius: 4px;
white-space: pre-wrap;
background-color: var(--normal-background-color);
color: var(--text-color);
}
.info-content-text a {
color: var(--primary-color);
text-decoration: none;
@@ -267,7 +288,7 @@ body {
}
.info-content-text pre {
line-height: 1.1;
line-height: 1.5;
}
.vditor-panel {

View File

@@ -8,7 +8,7 @@
</button>
<span v-if="isMobile && unreadCount > 0" class="menu-unread-dot"></span>
</div>
<div class="logo-container" @click="goToHome">
<NuxtLink class="logo-container" :to="`/`">
<img
alt="OpenIsle"
src="https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/image.png"
@@ -16,7 +16,7 @@
height="60"
/>
<div class="logo-text">OpenIsle</div>
</div>
</NuxtLink>
</div>
<ClientOnly>
@@ -51,7 +51,6 @@
<script setup>
import { ClientOnly } from '#components'
import { computed, nextTick, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import DropdownMenu from '~/components/DropdownMenu.vue'
import SearchDropdown from '~/components/SearchDropdown.vue'
import { authState, clearToken, loadCurrentUser } from '~/utils/auth'
@@ -67,20 +66,12 @@ const props = defineProps({
const isLogin = computed(() => authState.loggedIn)
const isMobile = useIsMobile()
const unreadCount = computed(() => notificationState.unreadCount)
const router = useRouter()
const avatar = ref('')
const showSearch = ref(false)
const searchDropdown = ref(null)
const userMenu = ref(null)
const menuBtn = ref(null)
const goToHome = async () => {
if (router.currentRoute.value.fullPath === '/') {
window.dispatchEvent(new Event('refresh-home'))
} else {
await navigateTo('/', { replace: true })
}
}
const search = () => {
showSearch.value = true
nextTick(() => {
@@ -155,14 +146,6 @@ onMounted(async () => {
await updateUnread()
},
)
watch(
() => router.currentRoute.value.fullPath,
() => {
if (userMenu.value) userMenu.value.close()
showSearch.value = false
},
)
})
</script>
@@ -184,6 +167,8 @@ onMounted(async () => {
font-size: 20px;
font-weight: bold;
cursor: pointer;
text-decoration: none;
color: inherit;
}
.header-content {

View File

@@ -2,7 +2,7 @@
<transition name="slide">
<nav v-if="visible" class="menu">
<div class="menu-item-container">
<NuxtLink class="menu-item" exact-active-class="selected" to="/" @click="handleHomeClick">
<NuxtLink class="menu-item" exact-active-class="selected" to="/" @click="handleItemClick">
<i class="menu-item-icon fas fa-hashtag"></i>
<span class="menu-item-text">话题</span>
</NuxtLink>
@@ -191,10 +191,6 @@ onMounted(async () => {
watch(() => authState.loggedIn, updateCount)
})
const handleHomeClick = () => {
navigateTo('/', { replace: true })
}
const handleItemClick = () => {
if (window.innerWidth <= 768) emit('item-click')
}

View File

@@ -12,8 +12,8 @@ export default defineNuxtConfig({
twitterClientId: process.env.NUXT_PUBLIC_TWITTER_CLIENT_ID || '',
},
},
// Ensure Vditor styles load before our overrides in global.css
css: ['vditor/dist/index.css', '~/assets/global.css'],
// 确保 Vditor 样式在 global.css 覆盖前加载
css: ['vditor/dist/index.css', '~/assets/fonts.css', '~/assets/global.css'],
app: {
head: {
script: [
@@ -52,6 +52,8 @@ export default defineNuxtConfig({
},
],
},
baseURL: '/',
buildAssetsDir: '/_nuxt/',
},
vue: {
compilerOptions: {

View File

@@ -50,7 +50,7 @@
</div>
</div>
<div v-if="isLoadingPosts && articles.length === 0" class="loading-container">
<div v-if="pendingFirst" class="loading-container">
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
</div>
@@ -60,7 +60,12 @@
</div>
</div>
<div class="article-item" v-for="article in articles" :key="article.id">
<div
v-if="!pendingFirst"
class="article-item"
v-for="article in articles"
:key="article.id"
>
<div class="article-main-container">
<NuxtLink class="article-item-title main-item" :to="`/posts/${article.id}`">
<i v-if="article.pinned" class="fas fa-thumbtack pinned-icon"></i>
@@ -104,15 +109,14 @@
热门帖子功能开发中,敬请期待。
</div>
<div v-else class="placeholder-container">分类浏览功能开发中,敬请期待。</div>
<div v-if="isLoadingPosts && articles.length > 0" class="loading-container bottom-loading">
<div v-if="isLoadingMore && articles.length > 0" class="loading-container bottom-loading">
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
import { ref, watch, watchEffect, computed, onMounted, onBeforeUnmount } from 'vue'
import ArticleCategory from '~/components/ArticleCategory.vue'
import ArticleTags from '~/components/ArticleTags.vue'
import CategorySelect from '~/components/CategorySelect.vue'
@@ -142,7 +146,9 @@ const selectedTags = ref([])
const route = useRoute()
const tagOptions = ref([])
const categoryOptions = ref([])
const isLoadingPosts = ref(false)
const isLoadingMore = ref(false)
const topics = ref(['最新回复', '最新', '排行榜' /*, '热门', '类别'*/])
const selectedTopic = ref(
route.query.view === 'ranking' ? '排行榜' : route.query.view === 'latest' ? '最新' : '最新回复',
@@ -153,11 +159,11 @@ const pageSize = 10
const isMobile = useIsMobile()
const allLoaded = ref(false)
/** URL 参数 -> 本地筛选值 **/
const selectedCategorySet = (category) => {
const c = decodeURIComponent(category)
selectedCategory.value = isNaN(c) ? c : Number(c)
}
const selectedTagsSet = (tags) => {
const t = Array.isArray(tags) ? tags.join(',') : tags
selectedTags.value = t
@@ -167,23 +173,17 @@ const selectedTagsSet = (tags) => {
.map((v) => (isNaN(v) ? v : Number(v)))
}
/** 初始化:仅在客户端首渲染时根据路由同步一次 **/
onMounted(() => {
const query = route.query
const category = query.category
const tags = query.tags
if (category) {
selectedCategorySet(category)
}
if (tags) {
selectedTagsSet(tags)
}
const { category, tags } = route.query
if (category) selectedCategorySet(category)
if (tags) selectedTagsSet(tags)
})
/** 路由变更时同步筛选 **/
watch(
() => route.query,
() => {
const query = route.query
(query) => {
const category = query.category
const tags = query.tags
category && selectedCategorySet(category)
@@ -191,18 +191,14 @@ watch(
},
)
/** 选项加载(分类/标签名称回填) **/
const loadOptions = async () => {
if (selectedCategory.value && !isNaN(selectedCategory.value)) {
try {
const res = await fetch(`${API_BASE_URL}/api/categories/${selectedCategory.value}`)
if (res.ok) {
categoryOptions.value = [await res.json()]
}
} catch (e) {
/* ignore */
}
if (res.ok) categoryOptions.value = [await res.json()]
} catch {}
}
if (selectedTags.value.length) {
const arr = []
for (const t of selectedTags.value) {
@@ -210,218 +206,158 @@ const loadOptions = async () => {
try {
const r = await fetch(`${API_BASE_URL}/api/tags/${t}`)
if (r.ok) arr.push(await r.json())
} catch (e) {
/* ignore */
}
} catch {}
}
}
tagOptions.value = arr
}
}
const buildUrl = () => {
let url = `${API_BASE_URL}/api/posts?page=${page.value}&pageSize=${pageSize}`
if (selectedCategory.value) {
url += `&categoryId=${selectedCategory.value}`
}
if (selectedTags.value.length) {
selectedTags.value.forEach((t) => {
url += `&tagIds=${t}`
})
}
return url
/** 列表 API 路径与查询参数 **/
const baseQuery = computed(() => ({
categoryId: selectedCategory.value || undefined,
tagIds: selectedTags.value.length ? selectedTags.value : undefined,
}))
const listApiPath = computed(() => {
if (selectedTopic.value === '排行榜') return '/api/posts/ranking'
if (selectedTopic.value === '最新回复') return '/api/posts/latest-reply'
return '/api/posts'
})
const buildUrl = ({ pageNo }) => {
const url = new URL(`${API_BASE_URL}${listApiPath.value}`)
url.searchParams.set('page', pageNo)
url.searchParams.set('pageSize', pageSize)
if (baseQuery.value.categoryId) url.searchParams.set('categoryId', baseQuery.value.categoryId)
if (baseQuery.value.tagIds)
for (const t of baseQuery.value.tagIds) url.searchParams.append('tagIds', t)
return url.toString()
}
const buildRankUrl = () => {
let url = `${API_BASE_URL}/api/posts/ranking?page=${page.value}&pageSize=${pageSize}`
if (selectedCategory.value) {
url += `&categoryId=${selectedCategory.value}`
}
if (selectedTags.value.length) {
selectedTags.value.forEach((t) => {
url += `&tagIds=${t}`
})
}
return url
}
const buildReplyUrl = () => {
let url = `${API_BASE_URL}/api/posts/latest-reply?page=${page.value}&pageSize=${pageSize}`
if (selectedCategory.value) {
url += `&categoryId=${selectedCategory.value}`
}
if (selectedTags.value.length) {
selectedTags.value.forEach((t) => {
url += `&tagIds=${t}`
})
}
return url
}
const fetchPosts = async (reset = false) => {
if (reset) {
page.value = 0
allLoaded.value = false
articles.value = []
}
if (isLoadingPosts.value || allLoaded.value) return
try {
isLoadingPosts.value = true
const token = getToken()
const 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)
const tokenHeader = computed(() => {
const token = getToken()
return token ? { Authorization: `Bearer ${token}` } : {}
})
onBeforeUnmount(() => {
window.removeEventListener('refresh-home', refreshHome)
})
/** —— 首屏数据托管SSR —— **/
const asyncKey = computed(() => [
'home:firstpage',
selectedTopic.value,
String(baseQuery.value.categoryId ?? ''),
JSON.stringify(baseQuery.value.tagIds ?? []),
])
const {
data: firstPage,
pending: pendingFirst,
refresh: refreshFirst,
} = await useAsyncData(
() => asyncKey.value.join('::'),
async () => {
const res = await $fetch(buildUrl({ pageNo: 0 }), { headers: tokenHeader.value })
const data = Array.isArray(res) ? res : []
return data.map((p) => ({
id: p.id,
title: p.title,
description: p.content,
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 },
)
/** 切换分类/标签/TabuseAsyncData 已 watch这里只需确保 options 加载 **/
watch([selectedCategory, selectedTags], () => {
fetchContent(true)
loadOptions()
})
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>
<style scoped>

View File

@@ -77,7 +77,7 @@
</div>
</template>
<script>
<script setup>
import 'flatpickr/dist/flatpickr.css'
import { computed, onMounted, ref, watch } from 'vue'
import FlatPickr from 'vue-flatpickr-component'

View File

@@ -392,7 +392,7 @@ const mapComment = (c, parentUserName = '', level = 0) => ({
avatar: c.author.avatar,
text: c.content,
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)),
openReplies: level === 0,
src: c.author.avatar,

View File

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

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

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

View File

@@ -1,5 +1,14 @@
import hljs from 'highlight.js'
import 'highlight.js/styles/github.css'
if (typeof window !== 'undefined') {
const theme =
document.documentElement.dataset.theme ||
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
if (theme === 'dark') {
import('highlight.js/styles/atom-one-dark.css')
} else {
import('highlight.js/styles/atom-one-light.css')
}
}
import MarkdownIt from 'markdown-it'
import { toast } from '../main'
import { tiebaEmoji } from './tiebaEmoji'
@@ -86,7 +95,11 @@ const md = new MarkdownIt({
} else {
code = hljs.highlightAuto(str).value
}
return `<pre class="code-block"><button class="copy-code-btn">Copy</button><code class="hljs language-${lang || ''}">${code}</code></pre>`
const lineNumbers = code
.trim()
.split('\n')
.map(() => `<div class="line-number"></div>`)
return `<pre class="code-block"><button class="copy-code-btn">Copy</button><div class="line-numbers">${lineNumbers.join('')}</div><code class="hljs language-${lang || ''}">${code.trim()}</code></pre>`
},
})