Compare commits

...

8 Commits

Author SHA1 Message Date
Tim
6965fcfb7f feat: notify lottery author 2025-08-13 16:19:53 +08:00
Tim
a3aec1133b Merge pull request #528 from nagisa77/codex/add-new-prize-notification-type
feat: add lottery win notification
2025-08-13 15:58:33 +08:00
Tim
8fa715477b feat: add lottery win notification 2025-08-13 15:57:59 +08:00
Tim
dfef13e2be Merge pull request #520 from AnNingUI/main
fix: 清理掉了大部分warn,优化了在移动端侧边栏的逻辑问题
2025-08-12 21:45:46 +08:00
AnNingUI
2f4d6e68da fix: 用传递menuBtn的ref代替手动查询dom的方式 2025-08-12 21:26:24 +08:00
AnNingUI
414872f61e fix: 解决tag与类别切换需要reload整个页面的bug 2025-08-12 20:42:31 +08:00
AnNingUI
82475f71db fix: 清理掉了所有warn,优化了在移动端侧边栏的逻辑问题 2025-08-12 20:36:00 +08:00
Tim
a6874e9be3 Merge pull request #512 from nagisa77/feature/message_control
feat: message control
2025-08-12 17:45:17 +08:00
16 changed files with 231 additions and 69 deletions

View File

@@ -32,6 +32,10 @@ public enum NotificationType {
REGISTER_REQUEST,
/** A user redeemed an activity reward */
ACTIVITY_REDEEM,
/** You won a lottery post */
LOTTERY_WIN,
/** Your lottery post was drawn */
LOTTERY_DRAW,
/** You were mentioned in a post or comment */
MENTION
}

View File

@@ -69,6 +69,8 @@ public class PostService {
private final EmailSender emailSender;
private final ApplicationContext applicationContext;
private final ConcurrentMap<Long, ScheduledFuture<?>> scheduledFinalizations = new ConcurrentHashMap<>();
@Value("${app.website-url:https://www.open-isle.com}")
private String websiteUrl;
@org.springframework.beans.factory.annotation.Autowired
public PostService(PostRepository postRepository,
@@ -249,6 +251,15 @@ public class PostService {
if (w.getEmail() != null) {
emailSender.sendEmail(w.getEmail(), "你中奖了", "恭喜你在抽奖贴 \"" + lp.getTitle() + "\" 中获奖");
}
notificationService.createNotification(w, NotificationType.LOTTERY_WIN, lp, null, null, lp.getAuthor(), null, null);
notificationService.sendCustomPush(w, "你中奖了", String.format("%s/posts/%d", websiteUrl, lp.getId()));
}
if (lp.getAuthor() != null) {
if (lp.getAuthor().getEmail() != null) {
emailSender.sendEmail(lp.getAuthor().getEmail(), "抽奖已开奖", "您的抽奖贴 \"" + lp.getTitle() + "\" 已开奖");
}
notificationService.createNotification(lp.getAuthor(), NotificationType.LOTTERY_DRAW, lp, null, null, null, null, null);
notificationService.sendCustomPush(lp.getAuthor(), "抽奖已开奖", String.format("%s/posts/%d", websiteUrl, lp.getId()));
}
});
}

View File

@@ -93,4 +93,50 @@ class PostServiceTest {
() -> service.createPost("alice", 1L, "t", "c", List.of(1L),
null, null, null, null, null, null));
}
@Test
void finalizeLotteryNotifiesAuthor() {
PostRepository postRepo = mock(PostRepository.class);
UserRepository userRepo = mock(UserRepository.class);
CategoryRepository catRepo = mock(CategoryRepository.class);
TagRepository tagRepo = mock(TagRepository.class);
LotteryPostRepository lotteryRepo = mock(LotteryPostRepository.class);
NotificationService notifService = mock(NotificationService.class);
SubscriptionService subService = mock(SubscriptionService.class);
CommentService commentService = mock(CommentService.class);
CommentRepository commentRepo = mock(CommentRepository.class);
ReactionRepository reactionRepo = mock(ReactionRepository.class);
PostSubscriptionRepository subRepo = mock(PostSubscriptionRepository.class);
NotificationRepository notificationRepo = mock(NotificationRepository.class);
PostReadService postReadService = mock(PostReadService.class);
ImageUploader imageUploader = mock(ImageUploader.class);
TaskScheduler taskScheduler = mock(TaskScheduler.class);
EmailSender emailSender = mock(EmailSender.class);
ApplicationContext context = mock(ApplicationContext.class);
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
notifService, subService, commentService, commentRepo,
reactionRepo, subRepo, notificationRepo, postReadService,
imageUploader, taskScheduler, emailSender, context, PublishMode.DIRECT);
when(context.getBean(PostService.class)).thenReturn(service);
User author = new User();
author.setId(1L);
User winner = new User();
winner.setId(2L);
LotteryPost lp = new LotteryPost();
lp.setId(1L);
lp.setAuthor(author);
lp.setTitle("L");
lp.setPrizeCount(1);
lp.getParticipants().add(winner);
when(lotteryRepo.findById(1L)).thenReturn(Optional.of(lp));
service.finalizeLottery(1L);
verify(notifService).createNotification(eq(winner), eq(NotificationType.LOTTERY_WIN), eq(lp), isNull(), isNull(), eq(author), isNull(), isNull());
verify(notifService).createNotification(eq(author), eq(NotificationType.LOTTERY_DRAW), eq(lp), isNull(), isNull(), isNull(), isNull(), isNull());
}
}

View File

@@ -1,7 +1,11 @@
<template>
<div id="app">
<div class="header-container">
<HeaderComponent @toggle-menu="menuVisible = !menuVisible" :show-menu-btn="!hideMenu" />
<HeaderComponent
ref="header"
@toggle-menu="menuVisible = !menuVisible"
:show-menu-btn="!hideMenu"
/>
</div>
<div class="main-container">
@@ -42,17 +46,26 @@ export default {
].includes(useRoute().path)
})
const header = useTemplateRef('header')
onMounted(() => {
if (typeof window !== 'undefined') {
menuVisible.value = window.innerWidth > 768
}
})
const handleMenuOutside = () => {
if (isMobile.value) menuVisible.value = false
const handleMenuOutside = (event) => {
const btn = header.value.$refs.menuBtn
if (btn && (btn === event.target || btn.contains(event.target))) {
return // 如果是菜单按钮的点击,不处理关闭
}
if (isMobile.value) {
menuVisible.value = false
}
}
return { menuVisible, hideMenu, handleMenuOutside }
return { menuVisible, hideMenu, handleMenuOutside, header }
},
}
</script>

View File

@@ -3,7 +3,7 @@
<div class="header-content">
<div class="header-content-left">
<div v-if="showMenuBtn" class="menu-btn-wrapper">
<button class="menu-btn" @click="$emit('toggle-menu')">
<button class="menu-btn" ref="menuBtn" @click="$emit('toggle-menu')">
<i class="fas fa-bars"></i>
</button>
<span v-if="isMobile && unreadCount > 0" class="menu-unread-dot"></span>
@@ -49,14 +49,14 @@
</template>
<script>
import { authState, clearToken, loadCurrentUser } from '~/utils/auth'
import { watch, nextTick, ref, computed } from 'vue'
import { fetchUnreadCount, notificationState } from '~/utils/notification'
import { ClientOnly } from '#components'
import { computed, nextTick, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import DropdownMenu from '~/components/DropdownMenu.vue'
import SearchDropdown from '~/components/SearchDropdown.vue'
import { authState, clearToken, loadCurrentUser } from '~/utils/auth'
import { fetchUnreadCount, notificationState } from '~/utils/notification'
import { useIsMobile } from '~/utils/screen'
import { useRouter } from 'vue-router'
import { ClientOnly } from '#components'
export default {
name: 'HeaderComponent',
@@ -67,7 +67,7 @@ export default {
default: true,
},
},
setup() {
setup(props, { expose }) {
const isLogin = computed(() => authState.loggedIn)
const isMobile = useIsMobile()
const unreadCount = computed(() => notificationState.unreadCount)
@@ -76,6 +76,11 @@ export default {
const showSearch = ref(false)
const searchDropdown = ref(null)
const userMenu = ref(null)
const menuBtn = ref(null)
expose({
menuBtn,
})
const goToHome = () => {
router.push('/').then(() => {
@@ -183,6 +188,7 @@ export default {
searchDropdown,
userMenu,
avatar,
menuBtn,
}
},
}

View File

@@ -210,17 +210,13 @@ export default {
const gotoCategory = (c) => {
const value = encodeURIComponent(c.id ?? c.name)
router.push({ path: '/', query: { category: value } }).then(() => {
window.location.reload()
})
router.push({ path: '/', query: { category: value } })
handleItemClick()
}
const gotoTag = (t) => {
const value = encodeURIComponent(t.id ?? t.name)
router.push({ path: '/', query: { tags: value } }).then(() => {
window.location.reload()
})
router.push({ path: '/', query: { tags: value } })
handleItemClick()
}

View File

@@ -43,4 +43,9 @@ export default defineNuxtConfig({
],
},
},
vue: {
compilerOptions: {
isCustomElement: (tag) => ['l-hatch', 'l-hatch-spinner'].includes(tag),
},
},
})

View File

@@ -113,18 +113,17 @@
<script>
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useScrollLoadMore } from '~/utils/loadMore'
import { stripMarkdown } from '~/utils/markdown'
import ArticleCategory from '~/components/ArticleCategory.vue'
import ArticleTags from '~/components/ArticleTags.vue'
import CategorySelect from '~/components/CategorySelect.vue'
import SearchDropdown from '~/components/SearchDropdown.vue'
import TagSelect from '~/components/TagSelect.vue'
import { API_BASE_URL } from '~/main'
import { getToken } from '~/utils/auth'
import TimeManager from '~/utils/time'
import CategorySelect from '~/components/CategorySelect.vue'
import TagSelect from '~/components/TagSelect.vue'
import ArticleTags from '~/components/ArticleTags.vue'
import ArticleCategory from '~/components/ArticleCategory.vue'
import SearchDropdown from '~/components/SearchDropdown.vue'
import { useScrollLoadMore } from '~/utils/loadMore'
import { stripMarkdown } from '~/utils/markdown'
import { useIsMobile } from '~/utils/screen'
import TimeManager from '~/utils/time'
export default {
name: 'HomePageView',
@@ -150,22 +149,9 @@ export default {
},
],
})
const route = useRoute()
const selectedCategory = ref('')
if (route.query.category) {
const c = decodeURIComponent(route.query.category)
selectedCategory.value = isNaN(c) ? c : Number(c)
}
const selectedTags = ref([])
if (route.query.tags) {
const t = Array.isArray(route.query.tags) ? route.query.tags.join(',') : route.query.tags
selectedTags.value = t
.split(',')
.filter((v) => v)
.map((v) => decodeURIComponent(v))
.map((v) => (isNaN(v) ? v : Number(v)))
}
const route = useRoute()
const tagOptions = ref([])
const categoryOptions = ref([])
const isLoadingPosts = ref(false)
@@ -177,13 +163,50 @@ export default {
? '最新'
: '最新回复',
)
const articles = ref([])
const page = ref(0)
const pageSize = 10
const isMobile = useIsMobile()
const allLoaded = ref(false)
const selectedCategorySet = (category) => {
const c = decodeURIComponent(category)
selectedCategory.value = isNaN(c) ? c : Number(c)
}
const selectedTagsSet = (tags) => {
const t = Array.isArray(tags) ? tags.join(',') : tags
selectedTags.value = t
.split(',')
.filter((v) => v)
.map((v) => decodeURIComponent(v))
.map((v) => (isNaN(v) ? v : Number(v)))
}
onMounted(() => {
const query = route.query
const category = query.category
const tags = query.tags
if (category) {
selectedCategorySet(category)
}
if (tags) {
selectedTagsSet(tags)
}
})
watch(
() => route.query,
() => {
const query = route.query
const category = query.category
const tags = query.tags
category && selectedCategorySet(category)
tags && selectedTagsSet(tags)
},
)
const loadOptions = async () => {
if (selectedCategory.value && !isNaN(selectedCategory.value)) {
try {
@@ -696,6 +719,7 @@ export default {
.header-item.activity {
width: 10%;
}
.article-member-avatar-item:nth-child(n + 4) {
display: none;
}

View File

@@ -185,6 +185,32 @@
</router-link>
</NotificationContainer>
</template>
<template v-else-if="item.type === 'LOTTERY_WIN'">
<NotificationContainer :item="item" :markRead="markRead">
恭喜你在抽奖贴
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/posts/${item.post.id}`"
>
{{ stripMarkdownLength(item.post.title, 100) }}
</router-link>
中获奖
</NotificationContainer>
</template>
<template v-else-if="item.type === 'LOTTERY_DRAW'">
<NotificationContainer :item="item" :markRead="markRead">
您的抽奖贴
<router-link
class="notif-content-text"
@click="markRead(item.id)"
:to="`/posts/${item.post.id}`"
>
{{ stripMarkdownLength(item.post.title, 100) }}
</router-link>
已开奖
</NotificationContainer>
</template>
<template v-else-if="item.type === 'POST_UPDATED'">
<NotificationContainer :item="item" :markRead="markRead">
您关注的帖子
@@ -565,6 +591,8 @@ export default {
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',
}
@@ -622,6 +650,28 @@ export default {
}
},
})
} 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,
@@ -791,6 +841,10 @@ export default {
return '有人申请注册'
case 'ACTIVITY_REDEEM':
return '有人申请兑换奶茶'
case 'LOTTERY_WIN':
return '抽奖中奖了'
case 'LOTTERY_DRAW':
return '抽奖已开奖'
default:
return t
}

View File

@@ -249,6 +249,7 @@ import TimeManager from '~/utils/time'
import { useRouter } from 'vue-router'
import { useIsMobile } from '~/utils/screen'
import Dropdown from '~/components/Dropdown.vue'
import { ClientOnly } from '#components'
export default {
name: 'PostPageView',
@@ -262,6 +263,7 @@ export default {
DropdownMenu,
VueEasyLightbox,
Dropdown,
ClientOnly,
},
async setup() {
const route = useRoute()

View File

@@ -297,27 +297,26 @@
</template>
<script>
import { ref, computed, onMounted, watch } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { API_BASE_URL, toast } from '~/main'
import { getToken, authState } from '~/utils/auth'
import BaseTimeline from '~/components/BaseTimeline.vue'
import UserList from '~/components/UserList.vue'
import AchievementList from '~/components/AchievementList.vue'
import BasePlaceholder from '~/components/BasePlaceholder.vue'
import BaseTimeline from '~/components/BaseTimeline.vue'
import LevelProgress from '~/components/LevelProgress.vue'
import UserList from '~/components/UserList.vue'
import { API_BASE_URL, toast } from '~/main'
import { authState, getToken } from '~/utils/auth'
import { prevLevelExp } from '~/utils/level'
import { stripMarkdown, stripMarkdownLength } from '~/utils/markdown'
import TimeManager from '~/utils/time'
import { prevLevelExp } from '~/utils/level'
import AchievementList from '~/components/AchievementList.vue'
definePageMeta({
alias: ['/users/:id/'],
})
export default {
name: 'ProfileView',
components: { BaseTimeline, UserList, BasePlaceholder, LevelProgress, AchievementList },
setup() {
definePageMeta({
alias: ['/users/:id/'],
})
const route = useRoute()
const router = useRouter()
const username = route.params.id

View File

@@ -1,5 +1,5 @@
// plugins/ldrs.client.ts
import { defineNuxtPlugin } from '#app'
import { defineNuxtPlugin } from 'nuxt/app'
export default defineNuxtPlugin(async () => {
// 动态引入,防止打包时把 ldrs 拉进 SSR bundle

View File

@@ -1,5 +1,6 @@
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'
import { defineNuxtPlugin } from 'nuxt/app'
export default defineNuxtPlugin((nuxtApp) => {
NProgress.configure({ showSpinner: false })
@@ -12,7 +13,7 @@ export default defineNuxtPlugin((nuxtApp) => {
NProgress.done()
})
nuxtApp.hook('page:error', () => {
nuxtApp.hook('app:error', () => {
NProgress.done()
})
})

View File

@@ -1,4 +1,4 @@
import { defineNuxtPlugin } from '#app'
import { defineNuxtPlugin } from 'nuxt/app'
import { initTheme } from '~/utils/theme'
export default defineNuxtPlugin(() => {

View File

@@ -1,4 +1,4 @@
import { defineNuxtPlugin } from '#app'
import { defineNuxtPlugin } from 'nuxt/app'
import 'vue-toastification/dist/index.css'
import '~/assets/toast.css'

View File

@@ -1,6 +1,6 @@
import MarkdownIt from 'markdown-it'
import hljs from 'highlight.js'
import 'highlight.js/styles/github.css'
import MarkdownIt from 'markdown-it'
import { toast } from '../main'
import { tiebaEmoji } from './tiebaEmoji'
@@ -86,18 +86,19 @@ export function handleMarkdownClick(e) {
export function stripMarkdown(text) {
const html = md.render(text || '')
// SSR 环境下没有 document
if (typeof window === 'undefined') {
// 用正则去除 HTML 标签
return html
.replace(/<[^>]+>/g, '')
.replace(/\s+/g, ' ')
.trim()
} else {
const el = document.createElement('div')
el.innerHTML = html
return el.textContent || el.innerText || ''
}
// 统一使用正则表达式方法,确保服务端和客户端行为一致
let plainText = html.replace(/<[^>]+>/g, '')
// 标准化空白字符处理
plainText = plainText
.replace(/\r\n/g, '\n') // Windows换行符转为Unix格式
.replace(/\r/g, '\n') // 旧Mac换行符转为Unix格式
.replace(/[ \t]+/g, ' ') // 合并空格和制表符为单个空格
.replace(/\n{3,}/g, '\n\n') // 最多保留两个连续换行(一个空行)
.trim()
return plainText
}
export function stripMarkdownLength(text, length) {