Compare commits

..

1 Commits

Author SHA1 Message Date
Tim
da1ad73cf6 fix: fix reward db error 2025-08-14 15:19:21 +08:00
3 changed files with 210 additions and 148 deletions

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

@@ -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>
<NuxtLink class="logo-container" to="/" @click.prevent="goToHome"> <div class="logo-container" @click="goToHome">
<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>
</NuxtLink> </div>
</div> </div>
<ClientOnly> <ClientOnly>
@@ -184,8 +184,6 @@ 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

@@ -50,7 +50,7 @@
</div> </div>
</div> </div>
<div v-if="pendingFirst" class="loading-container"> <div v-if="isLoadingPosts && articles.length === 0" 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,12 +60,7 @@
</div> </div>
</div> </div>
<div <div class="article-item" v-for="article in articles" :key="article.id">
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>
@@ -109,14 +104,15 @@
热门帖子功能开发中,敬请期待。 热门帖子功能开发中,敬请期待。
</div> </div>
<div v-else class="placeholder-container">分类浏览功能开发中,敬请期待。</div> <div v-else class="placeholder-container">分类浏览功能开发中,敬请期待。</div>
<div v-if="isLoadingMore && articles.length > 0" class="loading-container bottom-loading"> <div v-if="isLoadingPosts && 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, watchEffect, computed, onMounted, onBeforeUnmount } from 'vue' import { ref, watch, 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'
@@ -146,9 +142,7 @@ 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' ? '最新' : '最新回复',
@@ -159,11 +153,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
@@ -173,17 +167,23 @@ const selectedTagsSet = (tags) => {
.map((v) => (isNaN(v) ? v : Number(v))) .map((v) => (isNaN(v) ? v : Number(v)))
} }
/** 初始化:仅在客户端首渲染时根据路由同步一次 **/
onMounted(() => { onMounted(() => {
const { category, tags } = route.query const query = route.query
if (category) selectedCategorySet(category) const category = query.category
if (tags) selectedTagsSet(tags) const tags = query.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,14 +191,18 @@ 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) categoryOptions.value = [await res.json()] if (res.ok) {
} catch {} categoryOptions.value = [await res.json()]
}
} 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) {
@@ -206,97 +210,74 @@ 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 {} } catch (e) {
/* ignore */
}
} }
} }
tagOptions.value = arr tagOptions.value = arr
} }
} }
/** 列表 API 路径与查询参数 **/ const buildUrl = () => {
const baseQuery = computed(() => ({ let url = `${API_BASE_URL}/api/posts?page=${page.value}&pageSize=${pageSize}`
categoryId: selectedCategory.value || undefined, if (selectedCategory.value) {
tagIds: selectedTags.value.length ? selectedTags.value : undefined, url += `&categoryId=${selectedCategory.value}`
})) }
const listApiPath = computed(() => { if (selectedTags.value.length) {
if (selectedTopic.value === '排行榜') return '/api/posts/ranking' selectedTags.value.forEach((t) => {
if (selectedTopic.value === '最新回复') return '/api/posts/latest-reply' url += `&tagIds=${t}`
return '/api/posts' })
}) }
const buildUrl = ({ pageNo }) => { return url
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}` } : {}
})
/** —— 首屏数据托管SSR —— **/ const buildRankUrl = () => {
const asyncKey = computed(() => [ let url = `${API_BASE_URL}/api/posts/ranking?page=${page.value}&pageSize=${pageSize}`
'home:firstpage', if (selectedCategory.value) {
selectedTopic.value, url += `&categoryId=${selectedCategory.value}`
String(baseQuery.value.categoryId ?? ''), }
JSON.stringify(baseQuery.value.tagIds ?? []), if (selectedTags.value.length) {
]) selectedTags.value.forEach((t) => {
const { url += `&tagIds=${t}`
data: firstPage, })
pending: pendingFirst, }
refresh: refreshFirst, return url
} = 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: !!p.pinnedAt,
type: p.type,
}))
},
{
server: true,
default: () => [],
watch: [selectedTopic, baseQuery],
},
)
/** 首屏/筛选变更:重置分页并灌入 firstPage **/ const buildReplyUrl = () => {
watch( let url = `${API_BASE_URL}/api/posts/latest-reply?page=${page.value}&pageSize=${pageSize}`
firstPage, if (selectedCategory.value) {
(data) => { 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 page.value = 0
articles.value = [...(data || [])] allLoaded.value = false
allLoaded.value = (data?.length || 0) < pageSize articles.value = []
}, }
{ immediate: true }, if (isLoadingPosts.value || allLoaded.value) return
) try {
isLoadingPosts.value = true
/** —— 滚动加载更多 —— **/ const token = getToken()
let inflight = null const res = await fetch(buildUrl(), {
const fetchNextPage = async () => { headers: {
if (allLoaded.value || pendingFirst.value || inflight) return Authorization: token ? `Bearer ${token}` : '',
const nextPage = page.value + 1 },
isLoadingMore.value = true })
inflight = $fetch(buildUrl({ pageNo: nextPage }), { headers: tokenHeader.value }) isLoadingPosts.value = false
.then((res) => { if (!res.ok) return
const data = Array.isArray(res) ? res : [] const data = await res.json()
const mapped = data.map((p) => ({ articles.value.push(
...data.map((p) => ({
id: p.id, id: p.id,
title: p.title, title: p.title,
description: p.content, description: p.content,
@@ -305,59 +286,142 @@ const fetchNextPage = async () => {
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( time: TimeManager.format(p.createdAt),
selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,
),
pinned: !!p.pinnedAt, pinned: !!p.pinnedAt,
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 = nextPage 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}` : '',
},
}) })
.finally(() => { isLoadingPosts.value = false
inflight = null if (!res.ok) return
isLoadingMore.value = false 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) => {
let initialReady = false if (selectedTopic.value === '排行榜') {
const loadMoreGuarded = async () => { await fetchRanking(reset)
if (!initialReady) return } else if (selectedTopic.value === '最新回复') {
await fetchNextPage() await fetchLatestReply(reset)
} else {
await fetchPosts(reset)
}
} }
useScrollLoadMore(loadMoreGuarded)
watch(
articles,
() => {
if (!initialReady && articles.value.length) initialReady = true
},
{ immediate: true },
)
/** 切换分类/标签/TabuseAsyncData 已 watch这里只需确保 options 加载 **/ const refreshHome = () => {
watch([selectedCategory, selectedTags], () => { fetchContent(true)
loadOptions()
})
watch(selectedTopic, () => {
// 仅当需要额外选项时加载
loadOptions()
})
/** 选项首屏加载:服务端执行一次;客户端兜底 **/
if (import.meta.server) {
await loadOptions()
} }
onMounted(() => { onMounted(() => {
if (categoryOptions.value.length === 0 && tagOptions.value.length === 0) loadOptions() window.addEventListener('refresh-home', refreshHome)
})
onBeforeUnmount(() => {
window.removeEventListener('refresh-home', refreshHome)
})
useScrollLoadMore(fetchContent)
watch([selectedCategory, selectedTags], () => {
fetchContent(true)
})
watch(selectedTopic, () => {
fetchContent(true)
}) })
/** 其他工具函数 **/
const sanitizeDescription = (text) => stripMarkdown(text) const sanitizeDescription = (text) => stripMarkdown(text)
await Promise.all([loadOptions(), fetchContent()])
</script> </script>
<style scoped> <style scoped>