Compare commits

...

38 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
da1ad73cf6 fix: fix reward db error 2025-08-14 15:19:21 +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
Tim
ada6bfb5cf Merge pull request #536 from nagisa77/codex/add-logo-click-to-refresh-homepage
feat: refresh home when clicking header logo
2025-08-14 11:00:37 +08:00
Tim
928dbd73b5 feat: allow logo to refresh home page 2025-08-14 11:00:17 +08:00
Tim
8c1a7afc6e Merge pull request #530 from nagisa77/feature/env
fix: 前后端代码域名hardcode调整(for预发环境做准备)
2025-08-14 10:38:49 +08:00
32 changed files with 388 additions and 248 deletions

View File

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

View File

@@ -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) {

View File

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

View File

@@ -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 " +

View File

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

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 { .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 {

View File

@@ -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,16 +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 () => {
await navigateTo('/', { replace: true })
}
const search = () => { const search = () => {
showSearch.value = true showSearch.value = true
nextTick(() => { nextTick(() => {
@@ -151,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>
@@ -180,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 {

View File

@@ -2,7 +2,7 @@
<transition name="slide"> <transition name="slide">
<nav v-if="visible" class="menu"> <nav v-if="visible" class="menu">
<div class="menu-item-container"> <div class="menu-item-container">
<NuxtLink class="menu-item" exact-active-class="selected" to="/" @click="handleHomeClick"> <NuxtLink class="menu-item" exact-active-class="selected" to="/" @click="handleItemClick">
<i class="menu-item-icon fas fa-hashtag"></i> <i class="menu-item-icon fas fa-hashtag"></i>
<span class="menu-item-text">话题</span> <span class="menu-item-text">话题</span>
</NuxtLink> </NuxtLink>
@@ -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')
} }

View File

@@ -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: {

View File

@@ -50,7 +50,7 @@
</div> </div>
</div> </div>
<div v-if="isLoadingPosts && articles.length === 0" class="loading-container"> <div v-if="pendingFirst" class="loading-container">
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch> <l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
</div> </div>
@@ -60,7 +60,12 @@
</div> </div>
</div> </div>
<div class="article-item" v-for="article in articles" :key="article.id"> <div
v-if="!pendingFirst"
class="article-item"
v-for="article in articles"
:key="article.id"
>
<div class="article-main-container"> <div class="article-main-container">
<NuxtLink class="article-item-title main-item" :to="`/posts/${article.id}`"> <NuxtLink class="article-item-title main-item" :to="`/posts/${article.id}`">
<i v-if="article.pinned" class="fas fa-thumbtack pinned-icon"></i> <i v-if="article.pinned" class="fas fa-thumbtack pinned-icon"></i>
@@ -104,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 } 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,74 +206,97 @@ 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 token = getToken()
return token ? { Authorization: `Bearer ${token}` } : {}
})
const buildRankUrl = () => { /** —— 首屏数据托管SSR —— **/
let url = `${API_BASE_URL}/api/posts/ranking?page=${page.value}&pageSize=${pageSize}` const asyncKey = computed(() => [
if (selectedCategory.value) { 'home:firstpage',
url += `&categoryId=${selectedCategory.value}` selectedTopic.value,
} String(baseQuery.value.categoryId ?? ''),
if (selectedTags.value.length) { JSON.stringify(baseQuery.value.tagIds ?? []),
selectedTags.value.forEach((t) => { ])
url += `&tagIds=${t}` const {
}) data: firstPage,
} pending: pendingFirst,
return url 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],
},
)
const buildReplyUrl = () => { /** 首屏/筛选变更:重置分页并灌入 firstPage **/
let url = `${API_BASE_URL}/api/posts/latest-reply?page=${page.value}&pageSize=${pageSize}` watch(
if (selectedCategory.value) { firstPage,
url += `&categoryId=${selectedCategory.value}` (data) => {
}
if (selectedTags.value.length) {
selectedTags.value.forEach((t) => {
url += `&tagIds=${t}`
})
}
return url
}
const fetchPosts = async (reset = false) => {
if (reset) {
page.value = 0 page.value = 0
allLoaded.value = false articles.value = [...(data || [])]
articles.value = [] allLoaded.value = (data?.length || 0) < pageSize
} },
if (isLoadingPosts.value || allLoaded.value) return { immediate: true },
try { )
isLoadingPosts.value = true
const token = getToken() /** —— 滚动加载更多 —— **/
const res = await fetch(buildUrl(), { let inflight = null
headers: { const fetchNextPage = async () => {
Authorization: token ? `Bearer ${token}` : '', if (allLoaded.value || pendingFirst.value || inflight) return
}, const nextPage = page.value + 1
}) isLoadingMore.value = true
isLoadingPosts.value = false inflight = $fetch(buildUrl({ pageNo: nextPage }), { headers: tokenHeader.value })
if (!res.ok) return .then((res) => {
const data = await res.json() const data = Array.isArray(res) ? res : []
articles.value.push( const mapped = data.map((p) => ({
...data.map((p) => ({
id: p.id, id: p.id,
title: p.title, title: p.title,
description: p.content, description: p.content,
@@ -286,130 +305,59 @@ const fetchPosts = async (reset = false) => {
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })), members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
comments: p.commentCount, comments: p.commentCount,
views: p.views, views: p.views,
time: TimeManager.format(p.createdAt), time: TimeManager.format(
pinned: !!p.pinnedAt, selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,
),
pinned: Boolean(p.pinned ?? p.pinnedAt ?? p.pinned_at),
type: p.type, type: p.type,
})), }))
) articles.value.push(...mapped)
if (data.length < pageSize) { if (data.length < pageSize) {
allLoaded.value = true allLoaded.value = true
} else { } else {
page.value += 1 page.value = nextPage
} }
} catch (e) {
console.error(e)
}
}
const 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 .finally(() => {
if (!res.ok) return inflight = null
const data = await res.json() isLoadingMore.value = false
articles.value.push(
...data.map((p) => ({
id: p.id,
title: p.title,
description: p.content,
category: p.category,
tags: p.tags || [],
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
comments: p.commentCount,
views: p.views,
time: TimeManager.format(p.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 === '排行榜') { let initialReady = false
await fetchRanking(reset) const loadMoreGuarded = async () => {
} else if (selectedTopic.value === '最新回复') { if (!initialReady) return
await fetchLatestReply(reset) await fetchNextPage()
} else {
await fetchPosts(reset)
}
} }
useScrollLoadMore(loadMoreGuarded)
watch(
articles,
() => {
if (!initialReady && articles.value.length) initialReady = true
},
{ immediate: true },
)
useScrollLoadMore(fetchContent) /** 切换分类/标签/TabuseAsyncData 已 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>

View File

@@ -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'

View File

@@ -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,

View File

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

View File

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 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>`
}, },
}) })