Merge branch 'main' into feat/theme-toggle-transition

This commit is contained in:
immortal521
2025-08-15 13:20:37 +08:00
committed by GitHub
9 changed files with 3604 additions and 2018 deletions

View File

@@ -10,7 +10,7 @@
OpenIsle 是一个使用 Spring Boot 和 Vue 3 构建的全栈开源社区平台,提供用户注册、登录、贴文发布、评论交互等完整功能,可用于项目社区或直接打造自主社区站点。 OpenIsle 是一个使用 Spring Boot 和 Vue 3 构建的全栈开源社区平台,提供用户注册、登录、贴文发布、评论交互等完整功能,可用于项目社区或直接打造自主社区站点。
## 🚀 部署 ## 🚧 开发
### 后端 ### 后端
@@ -20,9 +20,26 @@ OpenIsle 是一个使用 Spring Boot 和 Vue 3 构建的全栈开源社区平台
### 前端 ### 前端
1. `cd open-isle-cli` 1. 进入前端目录
2. 执行 `npm install` ```bash
3. `npm run serve`可在本地启动开发服务,产品环境使用 `npm run build`生成 `dist/` 文件,配合线上网站方式部署 cd frontend_nuxt
```
2. 安装依赖
```bash
npm install
```
3. 启动开发服务
```bash
npm run dev
```
生产版本使用如下命令编译:
```bash
npm run build
```
会在 `.output` 目录生成文件,配合线上网站方式部署
## ✨ 项目特点 ## ✨ 项目特点

View File

@@ -1,6 +1,5 @@
<template> <template>
<div id="app"> <div id="app">
<div class="header-container"> <div class="header-container">
<HeaderComponent <HeaderComponent
ref="header" ref="header"
@@ -10,15 +9,14 @@
</div> </div>
<div class="main-container"> <div class="main-container">
<div class="menu-container" v-click-outside="handleMenuOutside"> <div class="menu-container" v-click-outside="handleMenuOutside">
<MenuComponent :visible="!hideMenu && menuVisible" @item-click="menuVisible = false" /> <MenuComponent :visible="!hideMenu && menuVisible" @item-click="menuVisible = false" />
</div> </div>
<div class="content" :class="{ 'menu-open': menuVisible && !hideMenu }"> <div class="content" :class="{ 'menu-open': menuVisible && !hideMenu }">
<NuxtPage keepalive /> <NuxtPage keepalive />
</div> </div>
<div v-if='!menuVisible && route.path !== "/new-post"' class="new-post-icon" @click="goToNewPost"> <div v-if="showNewPostIcon && isMobile" class="new-post-icon" @click="goToNewPost">
<i class="fas fa-edit"></i> <i class="fas fa-edit"></i>
</div> </div>
</div> </div>
@@ -26,61 +24,55 @@
</div> </div>
</template> </template>
<script> <script setup>
import HeaderComponent from '~/components/HeaderComponent.vue' import HeaderComponent from '~/components/HeaderComponent.vue'
import MenuComponent from '~/components/MenuComponent.vue' import MenuComponent from '~/components/MenuComponent.vue'
import GlobalPopups from '~/components/GlobalPopups.vue' import GlobalPopups from '~/components/GlobalPopups.vue'
import { useIsMobile } from '~/utils/screen' import { useIsMobile } from '~/utils/screen'
export default { const isMobile = useIsMobile()
name: 'App', const menuVisible = ref(!isMobile.value)
components: { HeaderComponent, MenuComponent, GlobalPopups },
setup() {
const isMobile = useIsMobile()
const menuVisible = ref(!isMobile.value)
const route = useRoute()
const hideMenu = computed(() => { const showNewPostIcon = computed(() => useRoute().path === '/')
return [
'/login',
'/signup',
'/404',
'/signup-reason',
'/github-callback',
'/twitter-callback',
'/discord-callback',
'/forgot-password',
'/google-callback',
].includes(useRoute().path)
})
const header = useTemplateRef('header') const hideMenu = computed(() => {
return [
'/login',
'/signup',
'/404',
'/signup-reason',
'/github-callback',
'/twitter-callback',
'/discord-callback',
'/forgot-password',
'/google-callback',
].includes(useRoute().path)
})
onMounted(() => { const header = useTemplateRef('header')
if (typeof window !== 'undefined') {
menuVisible.value = window.innerWidth > 768
}
})
const handleMenuOutside = (event) => { onMounted(() => {
const btn = header.value.$refs.menuBtn if (typeof window !== 'undefined') {
if (btn && (btn === event.target || btn.contains(event.target))) { menuVisible.value = window.innerWidth > 768
return // 如果是菜单按钮的点击,不处理关闭 }
} })
if (isMobile.value) { const handleMenuOutside = (event) => {
menuVisible.value = false const btn = header.value.$refs.menuBtn
} if (btn && (btn === event.target || btn.contains(event.target))) {
} return // 如果是菜单按钮的点击,不处理关闭
}
const goToNewPost = () => { if (isMobile.value) {
navigateTo('/new-post', { replace: false }) menuVisible.value = false
} }
}
return { menuVisible, hideMenu, handleMenuOutside, header, route, goToNewPost } const goToNewPost = () => {
}, navigateTo('/new-post', { replace: false })
} }
</script> </script>
<style src="~/assets/global.css"></style> <style src="~/assets/global.css"></style>
<style scoped> <style scoped>
.header-container { .header-container {
@@ -116,9 +108,10 @@ export default {
} }
.new-post-icon { .new-post-icon {
background-color: var(--primary-color); background-color: var(--new-post-icon-color);
width: 40px; color: white;
height: 40px; width: 60px;
height: 60px;
border-radius: 50%; border-radius: 50%;
position: fixed; position: fixed;
bottom: 40px; bottom: 40px;
@@ -127,6 +120,7 @@ export default {
cursor: pointer; cursor: pointer;
z-index: 1000; z-index: 1000;
display: flex; display: flex;
backdrop-filter: blur(5px);
justify-content: center; justify-content: center;
align-items: center; align-items: center;
} }

View File

@@ -2,6 +2,7 @@
--primary-color-hover: rgb(9, 95, 105); --primary-color-hover: rgb(9, 95, 105);
--primary-color: rgb(10, 110, 120); --primary-color: rgb(10, 110, 120);
--primary-color-disabled: rgba(93, 152, 156, 0.5); --primary-color-disabled: rgba(93, 152, 156, 0.5);
--new-post-icon-color: rgba(10, 111, 120, 0.598);
--header-height: 60px; --header-height: 60px;
--header-background-color: white; --header-background-color: white;
--header-border-color: lightgray; --header-border-color: lightgray;
@@ -35,6 +36,7 @@
--header-border-color: #555; --header-border-color: #555;
--primary-color: rgb(17, 182, 197); --primary-color: rgb(17, 182, 197);
--primary-color-hover: rgb(13, 137, 151); --primary-color-hover: rgb(13, 137, 151);
--new-post-icon-color: rgba(10, 111, 120, 0.598);
--header-text-color: white; --header-text-color: white;
--menu-background-color: #333; --menu-background-color: #333;
--background-color: #333; --background-color: #333;

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="`/`"> <NuxtLink class="logo-container" :to="`/`" @click="refrechData">
<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"
@@ -25,11 +25,7 @@
<i class="fas fa-search"></i> <i class="fas fa-search"></i>
</div> </div>
<ToolTip <ToolTip v-if="!isMobile" content="发帖" placement="bottom">
v-if="!isMobile"
content="发帖"
placement="bottom"
>
<div class="new-post-icon" @click="goToNewPost"> <div class="new-post-icon" @click="goToNewPost">
<i class="fas fa-edit"></i> <i class="fas fa-edit"></i>
</div> </div>
@@ -129,6 +125,10 @@ const goToNewPost = () => {
navigateTo('/new-post', { replace: false }) navigateTo('/new-post', { replace: false })
} }
const refrechData = async () => {
await fetchUnreadCount()
}
const headerMenuItems = computed(() => [ const headerMenuItems = computed(() => [
{ text: '设置', onClick: goToSettings }, { text: '设置', onClick: goToSettings },
{ text: '个人主页', onClick: goToProfile }, { text: '个人主页', onClick: goToProfile },
@@ -294,6 +294,7 @@ onMounted(async () => {
.new-post-icon { .new-post-icon {
font-size: 18px; font-size: 18px;
cursor: pointer; cursor: pointer;
margin-right: 10px;
} }
@media (max-width: 1200px) { @media (max-width: 1200px) {

View File

@@ -115,11 +115,15 @@
</div> </div>
</div> </div>
</div> </div>
<div class="menu-footer">
<div class="menu-footer-btn" @click="(e) => cycleTheme(e)"> <!-- 解决动态样式的水合错误 -->
<i :class="iconClass"></i> <ClientOnly>
<div class="menu-footer">
<div class="menu-footer-btn" @click="cycleTheme">
<i :class="iconClass"></i>
</div>
</div> </div>
</div> </ClientOnly>
</nav> </nav>
</transition> </transition>
</template> </template>

View File

@@ -60,4 +60,27 @@ export default defineNuxtConfig({
isCustomElement: (tag) => ['l-hatch', 'l-hatch-spinner'].includes(tag), isCustomElement: (tag) => ['l-hatch', 'l-hatch-spinner'].includes(tag),
}, },
}, },
vite: {
build: {
// increase warning limit and split large libraries into separate chunks
chunkSizeWarningLimit: 1024,
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules')) {
if (id.includes('vditor')) {
return 'vditor'
}
if (id.includes('echarts')) {
return 'echarts'
}
if (id.includes('highlight.js')) {
return 'highlight'
}
}
},
},
},
},
},
}) })

View File

File diff suppressed because it is too large Load Diff

View File

@@ -505,21 +505,26 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted, computed } from 'vue' import { computed, onMounted, ref } from 'vue'
import BaseTimeline from '~/components/BaseTimeline.vue'
import BasePlaceholder from '~/components/BasePlaceholder.vue' import BasePlaceholder from '~/components/BasePlaceholder.vue'
import BaseTimeline from '~/components/BaseTimeline.vue'
import NotificationContainer from '~/components/NotificationContainer.vue' import NotificationContainer from '~/components/NotificationContainer.vue'
import { getToken, authState } from '~/utils/auth'
import { markNotificationsRead, fetchUnreadCount, notificationState } from '~/utils/notification'
import { toast } from '~/main' import { toast } from '~/main'
import { authState, getToken } from '~/utils/auth'
import { stripMarkdownLength } from '~/utils/markdown' import { stripMarkdownLength } from '~/utils/markdown'
import {
fetchNotifications,
fetchUnreadCount,
isLoadingMessage,
markRead,
notifications,
markAllRead,
} from '~/utils/notification'
import TimeManager from '~/utils/time' import TimeManager from '~/utils/time'
import { reactionEmojiMap } from '~/utils/reactions'
const config = useRuntimeConfig() const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl const API_BASE_URL = config.public.apiBaseUrl
const route = useRoute() const route = useRoute()
const notifications = ref([])
const isLoadingMessage = ref(false)
const selectedTab = ref( const selectedTab = ref(
['all', 'unread', 'control'].includes(route.query.tab) ? route.query.tab : 'unread', ['all', 'unread', 'control'].includes(route.query.tab) ? route.query.tab : 'unread',
) )
@@ -528,234 +533,6 @@ const filteredNotifications = computed(() =>
selectedTab.value === 'all' ? notifications.value : notifications.value.filter((n) => !n.read), selectedTab.value === 'all' ? notifications.value : notifications.value.filter((n) => !n.read),
) )
const markRead = async (id) => {
if (!id) return
const n = notifications.value.find((n) => n.id === id)
if (!n || n.read) return
n.read = true
if (notificationState.unreadCount > 0) notificationState.unreadCount--
const ok = await markNotificationsRead([id])
if (!ok) {
n.read = false
notificationState.unreadCount++
} else {
fetchUnreadCount()
}
}
const markAllRead = async () => {
// 除了 REGISTER_REQUEST 类型消息
const idsToMark = notifications.value
.filter((n) => n.type !== 'REGISTER_REQUEST' && !n.read)
.map((n) => n.id)
if (idsToMark.length === 0) return
notifications.value.forEach((n) => {
if (n.type !== 'REGISTER_REQUEST') n.read = true
})
notificationState.unreadCount = notifications.value.filter((n) => !n.read).length
const ok = await markNotificationsRead(idsToMark)
if (!ok) {
notifications.value.forEach((n) => {
if (idsToMark.includes(n.id)) n.read = false
})
await fetchUnreadCount()
return
}
fetchUnreadCount()
if (authState.role === 'ADMIN') {
toast.success('已读所有消息(注册请求除外)')
} else {
toast.success('已读所有消息')
}
}
const iconMap = {
POST_VIEWED: 'fas fa-eye',
COMMENT_REPLY: 'fas fa-reply',
POST_REVIEWED: 'fas fa-shield-alt',
POST_REVIEW_REQUEST: 'fas fa-gavel',
POST_UPDATED: 'fas fa-comment-dots',
USER_ACTIVITY: 'fas fa-user',
FOLLOWED_POST: 'fas fa-feather-alt',
USER_FOLLOWED: 'fas fa-user-plus',
USER_UNFOLLOWED: 'fas fa-user-minus',
POST_SUBSCRIBED: 'fas fa-bookmark',
POST_UNSUBSCRIBED: 'fas fa-bookmark',
REGISTER_REQUEST: 'fas fa-user-clock',
ACTIVITY_REDEEM: 'fas fa-coffee',
LOTTERY_WIN: 'fas fa-trophy',
LOTTERY_DRAW: 'fas fa-bullhorn',
MENTION: 'fas fa-at',
}
const fetchNotifications = async () => {
try {
const token = getToken()
if (!token) {
toast.error('请先登录')
return
}
isLoadingMessage.value = true
notifications.value = []
const res = await fetch(`${API_BASE_URL}/api/notifications`, {
headers: {
Authorization: `Bearer ${token}`,
},
})
isLoadingMessage.value = false
if (!res.ok) {
toast.error('获取通知失败')
return
}
const data = await res.json()
for (const n of data) {
if (n.type === 'COMMENT_REPLY') {
notifications.value.push({
...n,
src: n.comment.author.avatar,
iconClick: () => {
markRead(n.id)
navigateTo(`/users/${n.comment.author.id}`, { replace: true })
},
})
} else if (n.type === 'REACTION') {
notifications.value.push({
...n,
emoji: reactionEmojiMap[n.reactionType],
iconClick: () => {
if (n.fromUser) {
markRead(n.id)
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
}
},
})
} else if (n.type === 'POST_VIEWED') {
notifications.value.push({
...n,
src: n.fromUser ? n.fromUser.avatar : null,
icon: n.fromUser ? undefined : iconMap[n.type],
iconClick: () => {
if (n.fromUser) {
markRead(n.id)
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
}
},
})
} else if (n.type === 'LOTTERY_WIN') {
notifications.value.push({
...n,
icon: iconMap[n.type],
iconClick: () => {
if (n.post) {
markRead(n.id)
router.push(`/posts/${n.post.id}`)
}
},
})
} else if (n.type === 'LOTTERY_DRAW') {
notifications.value.push({
...n,
icon: iconMap[n.type],
iconClick: () => {
if (n.post) {
markRead(n.id)
router.push(`/posts/${n.post.id}`)
}
},
})
} else if (n.type === 'POST_UPDATED') {
notifications.value.push({
...n,
src: n.comment.author.avatar,
iconClick: () => {
markRead(n.id)
navigateTo(`/users/${n.comment.author.id}`, { replace: true })
},
})
} else if (n.type === 'USER_ACTIVITY') {
notifications.value.push({
...n,
src: n.comment.author.avatar,
iconClick: () => {
markRead(n.id)
navigateTo(`/users/${n.comment.author.id}`, { replace: true })
},
})
} else if (n.type === 'MENTION') {
notifications.value.push({
...n,
icon: iconMap[n.type],
iconClick: () => {
if (n.fromUser) {
markRead(n.id)
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
}
},
})
} else if (n.type === 'USER_FOLLOWED' || n.type === 'USER_UNFOLLOWED') {
notifications.value.push({
...n,
icon: iconMap[n.type],
iconClick: () => {
if (n.fromUser) {
markRead(n.id)
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
}
},
})
} else if (n.type === 'FOLLOWED_POST') {
notifications.value.push({
...n,
icon: iconMap[n.type],
iconClick: () => {
if (n.post) {
markRead(n.id)
navigateTo(`/posts/${n.post.id}`, { replace: true })
}
},
})
} else if (n.type === 'POST_SUBSCRIBED' || n.type === 'POST_UNSUBSCRIBED') {
notifications.value.push({
...n,
icon: iconMap[n.type],
iconClick: () => {
if (n.post) {
markRead(n.id)
navigateTo(`/posts/${n.post.id}`, { replace: true })
}
},
})
} else if (n.type === 'POST_REVIEW_REQUEST') {
notifications.value.push({
...n,
src: n.fromUser ? n.fromUser.avatar : null,
icon: n.fromUser ? undefined : iconMap[n.type],
iconClick: () => {
if (n.post) {
markRead(n.id)
navigateTo(`/posts/${n.post.id}`, { replace: true })
}
},
})
} else if (n.type === 'REGISTER_REQUEST') {
notifications.value.push({
...n,
icon: iconMap[n.type],
iconClick: () => {},
})
} else {
notifications.value.push({
...n,
icon: iconMap[n.type],
})
}
}
} catch (e) {
console.error(e)
}
}
const fetchPrefs = async () => { const fetchPrefs = async () => {
notificationPrefs.value = await fetchNotificationPreferences() notificationPrefs.value = await fetchNotificationPreferences()
} }
@@ -842,7 +619,7 @@ const formatType = (t) => {
} }
} }
onMounted(() => { onActivated(() => {
fetchNotifications() fetchNotifications()
fetchPrefs() fetchPrefs()
}) })

View File

@@ -1,10 +1,32 @@
import { getToken } from './auth' import { navigateTo, useRuntimeConfig } from 'nuxt/app'
import { reactive } from 'vue' import { reactive, ref } from 'vue'
import { toast } from '~/composables/useToast'
import { authState, getToken } from '~/utils/auth'
import { reactionEmojiMap } from '~/utils/reactions'
export const notificationState = reactive({ export const notificationState = reactive({
unreadCount: 0, unreadCount: 0,
}) })
const iconMap = {
POST_VIEWED: 'fas fa-eye',
COMMENT_REPLY: 'fas fa-reply',
POST_REVIEWED: 'fas fa-shield-alt',
POST_REVIEW_REQUEST: 'fas fa-gavel',
POST_UPDATED: 'fas fa-comment-dots',
USER_ACTIVITY: 'fas fa-user',
FOLLOWED_POST: 'fas fa-feather-alt',
USER_FOLLOWED: 'fas fa-user-plus',
USER_UNFOLLOWED: 'fas fa-user-minus',
POST_SUBSCRIBED: 'fas fa-bookmark',
POST_UNSUBSCRIBED: 'fas fa-bookmark',
REGISTER_REQUEST: 'fas fa-user-clock',
ACTIVITY_REDEEM: 'fas fa-coffee',
LOTTERY_WIN: 'fas fa-trophy',
LOTTERY_DRAW: 'fas fa-bullhorn',
MENTION: 'fas fa-at',
}
export async function fetchUnreadCount() { export async function fetchUnreadCount() {
const config = useRuntimeConfig() const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl const API_BASE_URL = config.public.apiBaseUrl
@@ -87,3 +109,235 @@ export async function updateNotificationPreference(type, enabled) {
return false return false
} }
} }
/**
* 处理信息的高阶函数
* @returns
*/
function createFetchNotifications() {
const notifications = ref([])
const isLoadingMessage = ref(false)
const fetchNotifications = async () => {
const config = useRuntimeConfig()
const API_BASE_URL = config.public.apiBaseUrl
if (isLoadingMessage && notifications && markRead) {
try {
const token = getToken()
if (!token) {
toast.error('请先登录')
return
}
isLoadingMessage.value = true
notifications.value = []
const res = await fetch(`${API_BASE_URL}/api/notifications`, {
headers: {
Authorization: `Bearer ${token}`,
},
})
isLoadingMessage.value = false
if (!res.ok) {
toast.error('获取通知失败')
return
}
const data = await res.json()
for (const n of data) {
if (n.type === 'COMMENT_REPLY') {
notifications.value.push({
...n,
src: n.comment.author.avatar,
iconClick: () => {
markRead(n.id)
navigateTo(`/users/${n.comment.author.id}`, { replace: true })
},
})
} else if (n.type === 'REACTION') {
notifications.value.push({
...n,
emoji: reactionEmojiMap[n.reactionType],
iconClick: () => {
if (n.fromUser) {
markRead(n.id)
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
}
},
})
} else if (n.type === 'POST_VIEWED') {
notifications.value.push({
...n,
src: n.fromUser ? n.fromUser.avatar : null,
icon: n.fromUser ? undefined : iconMap[n.type],
iconClick: () => {
if (n.fromUser) {
markRead(n.id)
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
}
},
})
} else if (n.type === 'LOTTERY_WIN') {
notifications.value.push({
...n,
icon: iconMap[n.type],
iconClick: () => {
if (n.post) {
markRead(n.id)
router.push(`/posts/${n.post.id}`)
}
},
})
} else if (n.type === 'LOTTERY_DRAW') {
notifications.value.push({
...n,
icon: iconMap[n.type],
iconClick: () => {
if (n.post) {
markRead(n.id)
router.push(`/posts/${n.post.id}`)
}
},
})
} else if (n.type === 'POST_UPDATED') {
notifications.value.push({
...n,
src: n.comment.author.avatar,
iconClick: () => {
markRead(n.id)
navigateTo(`/users/${n.comment.author.id}`, { replace: true })
},
})
} else if (n.type === 'USER_ACTIVITY') {
notifications.value.push({
...n,
src: n.comment.author.avatar,
iconClick: () => {
markRead(n.id)
navigateTo(`/users/${n.comment.author.id}`, { replace: true })
},
})
} else if (n.type === 'MENTION') {
notifications.value.push({
...n,
icon: iconMap[n.type],
iconClick: () => {
if (n.fromUser) {
markRead(n.id)
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
}
},
})
} else if (n.type === 'USER_FOLLOWED' || n.type === 'USER_UNFOLLOWED') {
notifications.value.push({
...n,
icon: iconMap[n.type],
iconClick: () => {
if (n.fromUser) {
markRead(n.id)
navigateTo(`/users/${n.fromUser.id}`, { replace: true })
}
},
})
} else if (n.type === 'FOLLOWED_POST') {
notifications.value.push({
...n,
icon: iconMap[n.type],
iconClick: () => {
if (n.post) {
markRead(n.id)
navigateTo(`/posts/${n.post.id}`, { replace: true })
}
},
})
} else if (n.type === 'POST_SUBSCRIBED' || n.type === 'POST_UNSUBSCRIBED') {
notifications.value.push({
...n,
icon: iconMap[n.type],
iconClick: () => {
if (n.post) {
markRead(n.id)
navigateTo(`/posts/${n.post.id}`, { replace: true })
}
},
})
} else if (n.type === 'POST_REVIEW_REQUEST') {
notifications.value.push({
...n,
src: n.fromUser ? n.fromUser.avatar : null,
icon: n.fromUser ? undefined : iconMap[n.type],
iconClick: () => {
if (n.post) {
markRead(n.id)
navigateTo(`/posts/${n.post.id}`, { replace: true })
}
},
})
} else if (n.type === 'REGISTER_REQUEST') {
notifications.value.push({
...n,
icon: iconMap[n.type],
iconClick: () => {},
})
} else {
notifications.value.push({
...n,
icon: iconMap[n.type],
})
}
}
} catch (e) {
console.error(e)
}
}
}
const markRead = async (id) => {
if (!id) return
const n = notifications.value.find((n) => n.id === id)
if (!n || n.read) return
n.read = true
if (notificationState.unreadCount > 0) notificationState.unreadCount--
const ok = await markNotificationsRead([id])
if (!ok) {
n.read = false
notificationState.unreadCount++
} else {
fetchUnreadCount()
}
}
const markAllRead = async () => {
// 除了 REGISTER_REQUEST 类型消息
const idsToMark = notifications.value
.filter((n) => n.type !== 'REGISTER_REQUEST' && !n.read)
.map((n) => n.id)
if (idsToMark.length === 0) return
notifications.value.forEach((n) => {
if (n.type !== 'REGISTER_REQUEST') n.read = true
})
notificationState.unreadCount = notifications.value.filter((n) => !n.read).length
const ok = await markNotificationsRead(idsToMark)
if (!ok) {
notifications.value.forEach((n) => {
if (idsToMark.includes(n.id)) n.read = false
})
await fetchUnreadCount()
return
}
fetchUnreadCount()
if (authState.role === 'ADMIN') {
toast.success('已读所有消息(注册请求除外)')
} else {
toast.success('已读所有消息')
}
}
return {
fetchNotifications,
markRead,
notifications,
isLoadingMessage,
markRead,
markAllRead,
}
}
export const { fetchNotifications, markRead, notifications, isLoadingMessage, markAllRead } =
createFetchNotifications()