mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-20 22:11:01 +08:00
Compare commits
21 Commits
feature/fi
...
codex/fix-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47134cadc2 | ||
|
|
1f9ae8d057 | ||
|
|
53c603f33a | ||
|
|
06f86f2b21 | ||
|
|
22693bfdd9 | ||
|
|
0058f20b1e | ||
|
|
304d941d68 | ||
|
|
3dbcd2ac4d | ||
|
|
2efe4e733a | ||
|
|
08239a16b8 | ||
|
|
cb49dc9b73 | ||
|
|
43d4c9be43 | ||
|
|
1dc13698ad | ||
|
|
d58432dcd9 | ||
|
|
e7ff73c7f9 | ||
|
|
4ee9532d5f | ||
|
|
80c3fd8ea2 | ||
|
|
7e277d06d5 | ||
|
|
d2b68119bd | ||
|
|
f7b0d7edd5 | ||
|
|
cdea1ab911 |
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
@@ -184,6 +175,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')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ export default defineNuxtConfig({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
baseURL: '/',
|
||||||
|
buildAssetsDir: '/_nuxt/',
|
||||||
},
|
},
|
||||||
vue: {
|
vue: {
|
||||||
compilerOptions: {
|
compilerOptions: {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
9
frontend_nuxt/plugins/soft-manifest.client.ts
Normal file
9
frontend_nuxt/plugins/soft-manifest.client.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineNuxtPlugin } from 'nuxt/app'
|
||||||
|
|
||||||
|
export default defineNuxtPlugin((nuxtApp) => {
|
||||||
|
// 覆盖默认行为:收到 manifest 更新时,不立刻在路由切换里刷新
|
||||||
|
nuxtApp.hooks.hook('app:manifest:update', () => {
|
||||||
|
// todo 选择:弹个提示,让用户点击刷新;或延迟到页面隐藏时再刷新
|
||||||
|
// 例如:document.addEventListener('visibilitychange', () => { if (document.hidden) location.reload() })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
export default {
|
|
||||||
push(path) {
|
|
||||||
if (process.client) {
|
|
||||||
window.location.href = path
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user