mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-06-06 01:57:34 +08:00
Merge branch 'main' into feat/theme-toggle-transition
This commit is contained in:
25
README.md
25
README.md
@@ -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` 目录生成文件,配合线上网站方式部署
|
||||||
|
|
||||||
## ✨ 项目特点
|
## ✨ 项目特点
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
4950
frontend_nuxt/package-lock.json
generated
4950
frontend_nuxt/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user