mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-19 21:41:03 +08:00
Compare commits
8 Commits
feature/me
...
codex/add-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6965fcfb7f | ||
|
|
a3aec1133b | ||
|
|
8fa715477b | ||
|
|
dfef13e2be | ||
|
|
2f4d6e68da | ||
|
|
414872f61e | ||
|
|
82475f71db | ||
|
|
a6874e9be3 |
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -43,4 +43,9 @@ export default defineNuxtConfig({
|
||||
],
|
||||
},
|
||||
},
|
||||
vue: {
|
||||
compilerOptions: {
|
||||
isCustomElement: (tag) => ['l-hatch', 'l-hatch-spinner'].includes(tag),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// plugins/ldrs.client.ts
|
||||
import { defineNuxtPlugin } from '#app'
|
||||
import { defineNuxtPlugin } from 'nuxt/app'
|
||||
|
||||
export default defineNuxtPlugin(async () => {
|
||||
// 动态引入,防止打包时把 ldrs 拉进 SSR bundle
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { defineNuxtPlugin } from '#app'
|
||||
import { defineNuxtPlugin } from 'nuxt/app'
|
||||
import { initTheme } from '~/utils/theme'
|
||||
|
||||
export default defineNuxtPlugin(() => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { defineNuxtPlugin } from '#app'
|
||||
import { defineNuxtPlugin } from 'nuxt/app'
|
||||
import 'vue-toastification/dist/index.css'
|
||||
import '~/assets/toast.css'
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user