Compare commits

..

12 Commits

Author SHA1 Message Date
tim
c296e25927 fix: 聊天UI优化 #957 2025-09-09 21:02:59 +08:00
Tim
61fc9d799d feat(chat): improve markdown and editor 2025-09-09 20:03:22 +08:00
Tim
20c6c73f8c Merge pull request #954 from smallclover/main
用户访问统计使用缓存+定时任务
2025-09-09 19:35:45 +08:00
Tim
81d1f79aae Merge pull request #958 from WoJiaoFuXiaoYun/main
fix: 修复发帖框/修改框边缘不对齐的case
2025-09-09 19:35:18 +08:00
WangHe
4ff76d2586 fix: 修复发帖框/修改框边缘不对齐的case 2025-09-09 17:16:18 +08:00
Tim
f24bc239cc Update CONTRIBUTING.md 2025-09-09 16:49:49 +08:00
Tim
143691206d Merge pull request #955 from nagisa77/codex/add-openapi-annotations-to-controller-methods
doc: add OpenAPI annotations to demo controllers
2025-09-09 16:37:47 +08:00
wangshun
843e53143d 用户访问统计使用缓存+定时任务
+ 重要:注释的地方如果没用到@nagisa77可以删除
2025-09-09 16:31:59 +08:00
Tim
16c94690bd fix: 未登录UI适配 2025-09-09 15:58:50 +08:00
Tim
5be00e7013 Merge pull request #952 from nagisa77/codex/modify-about-page-with-new-tab
feat: add API debug tab and query param navigation for about page
2025-09-09 15:49:57 +08:00
Tim
a3201f05fb fix: share icon 2025-09-09 15:39:08 +08:00
Tim
da311806c1 feat: add API tab to about page 2025-09-09 15:04:49 +08:00
13 changed files with 304 additions and 53 deletions

View File

@@ -249,6 +249,6 @@ https://resend.com/emails 创建账号并登录
## 开源共建和API文档 ## 开源共建和API文档
- API文档: https://openisle-docs.netlify.app/docs/openapi - API文档: https://docs.open-isle.com/openapi

View File

@@ -44,6 +44,8 @@ public class CachingConfig {
public static final String VERIFY_CACHE_NAME="openisle_verify"; public static final String VERIFY_CACHE_NAME="openisle_verify";
// 发帖频率限制 // 发帖频率限制
public static final String LIMIT_CACHE_NAME="openisle_limit"; public static final String LIMIT_CACHE_NAME="openisle_limit";
// 用户访问统计
public static final String VISIT_CACHE_NAME="openisle_visit";
/** /**
* 自定义Redis的序列化器 * 自定义Redis的序列化器

View File

@@ -6,6 +6,7 @@ import com.openisle.repository.UserRepository;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.HttpMethod; import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
@@ -26,6 +27,8 @@ import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import java.time.LocalDate;
import java.util.List; import java.util.List;
import jakarta.servlet.FilterChain; import jakarta.servlet.FilterChain;
@@ -44,6 +47,8 @@ public class SecurityConfig {
@Value("${app.website-url}") @Value("${app.website-url}")
private String websiteUrl; private String websiteUrl;
private final RedisTemplate redisTemplate;
@Bean @Bean
public PasswordEncoder passwordEncoder() { public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(); return new BCryptPasswordEncoder();
@@ -208,7 +213,8 @@ public class SecurityConfig {
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
var auth = org.springframework.security.core.context.SecurityContextHolder.getContext().getAuthentication(); var auth = org.springframework.security.core.context.SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.isAuthenticated() && !(auth instanceof org.springframework.security.authentication.AnonymousAuthenticationToken)) { if (auth != null && auth.isAuthenticated() && !(auth instanceof org.springframework.security.authentication.AnonymousAuthenticationToken)) {
userVisitService.recordVisit(auth.getName()); String key = CachingConfig.VISIT_CACHE_NAME+":"+ LocalDate.now();
redisTemplate.opsForSet().add(key, auth.getName());
} }
filterChain.doFilter(request, response); filterChain.doFilter(request, response);
} }

View File

@@ -155,10 +155,10 @@ public class PostController {
if (tagId != null) { if (tagId != null) {
tids = java.util.List.of(tagId); tids = java.util.List.of(tagId);
} }
// 只需要在请求的一开始统计一次
if (auth != null) { // if (auth != null) {
userVisitService.recordVisit(auth.getName()); // userVisitService.recordVisit(auth.getName());
} // }
boolean hasCategories = ids != null && !ids.isEmpty(); boolean hasCategories = ids != null && !ids.isEmpty();
boolean hasTags = tids != null && !tids.isEmpty(); boolean hasTags = tids != null && !tids.isEmpty();
@@ -195,10 +195,10 @@ public class PostController {
if (tagId != null) { if (tagId != null) {
tids = java.util.List.of(tagId); tids = java.util.List.of(tagId);
} }
// 只需要在请求的一开始统计一次
if (auth != null) { // if (auth != null) {
userVisitService.recordVisit(auth.getName()); // userVisitService.recordVisit(auth.getName());
} // }
return postService.listPostsByViews(ids, tids, page, pageSize) return postService.listPostsByViews(ids, tids, page, pageSize)
.stream().map(postMapper::toSummaryDto).collect(Collectors.toList()); .stream().map(postMapper::toSummaryDto).collect(Collectors.toList());
@@ -223,10 +223,10 @@ public class PostController {
if (tagId != null) { if (tagId != null) {
tids = java.util.List.of(tagId); tids = java.util.List.of(tagId);
} }
// 只需要在请求的一开始统计一次
if (auth != null) { // if (auth != null) {
userVisitService.recordVisit(auth.getName()); // userVisitService.recordVisit(auth.getName());
} // }
return postService.listPostsByLatestReply(ids, tids, page, pageSize) return postService.listPostsByLatestReply(ids, tids, page, pageSize)
.stream().map(postMapper::toSummaryDto).collect(Collectors.toList()); .stream().map(postMapper::toSummaryDto).collect(Collectors.toList());
@@ -251,9 +251,10 @@ public class PostController {
if (tagId != null) { if (tagId != null) {
tids = java.util.List.of(tagId); tids = java.util.List.of(tagId);
} }
if (auth != null) { // 只需要在请求的一开始统计一次
userVisitService.recordVisit(auth.getName()); // if (auth != null) {
} // userVisitService.recordVisit(auth.getName());
// }
return postService.listFeaturedPosts(ids, tids, page, pageSize) return postService.listFeaturedPosts(ids, tids, page, pageSize)
.stream().map(postMapper::toSummaryDto).collect(Collectors.toList()); .stream().map(postMapper::toSummaryDto).collect(Collectors.toList());
} }

View File

@@ -100,6 +100,7 @@ public class UserController {
)); ));
} }
// 这个方法似乎没有使用?
@PostMapping("/me/signin") @PostMapping("/me/signin")
@SecurityRequirement(name = "JWT") @SecurityRequirement(name = "JWT")
@Operation(summary = "Daily sign in", description = "Sign in to receive rewards") @Operation(summary = "Daily sign in", description = "Sign in to receive rewards")

View File

@@ -0,0 +1,48 @@
package com.openisle.schdule;
import com.openisle.config.CachingConfig;
import com.openisle.model.User;
import com.openisle.model.UserVisit;
import com.openisle.repository.UserRepository;
import com.openisle.repository.UserVisitRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import java.time.LocalDate;
import java.util.Set;
/**
* 执行计划
* 将每天用户访问落库
* @author smallclover
* @since 2025-09-09
*/
@Component
@RequiredArgsConstructor
public class UserVisitScheduler {
private final RedisTemplate redisTemplate;
private final UserRepository userRepository;
private final UserVisitRepository userVisitRepository;
@Scheduled(cron = "0 5 0 * * ?")// 每天 00:05 执行
public void persistDailyVisits(){
LocalDate yesterday = LocalDate.now().minusDays(1);
String key = CachingConfig.VISIT_CACHE_NAME+":"+ yesterday;
Set<String> usernames = redisTemplate.opsForSet().members(key);
if(!CollectionUtils.isEmpty(usernames)){
for(String username: usernames){
User user = userRepository.findByUsername(username).orElse(null);
if(user != null){
UserVisit userVisit = new UserVisit();
userVisit.setUser(user);
userVisit.setVisitDate(yesterday);
userVisitRepository.save(userVisit);
}
}
redisTemplate.delete(key);
}
}
}

View File

@@ -1,15 +1,22 @@
package com.openisle.service; package com.openisle.service;
import com.openisle.config.CachingConfig;
import com.openisle.model.User; import com.openisle.model.User;
import com.openisle.model.UserVisit; import com.openisle.model.UserVisit;
import com.openisle.repository.UserRepository; import com.openisle.repository.UserRepository;
import com.openisle.repository.UserVisitRepository; import com.openisle.repository.UserVisitRepository;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.CacheConfig;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
import java.time.Duration;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.Map; import java.util.Map;
import java.util.Set;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -17,6 +24,8 @@ public class UserVisitService {
private final UserVisitRepository userVisitRepository; private final UserVisitRepository userVisitRepository;
private final UserRepository userRepository; private final UserRepository userRepository;
private final RedisTemplate redisTemplate;
public boolean recordVisit(String username) { public boolean recordVisit(String username) {
User user = userRepository.findByUsername(username) User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
@@ -30,10 +39,36 @@ public class UserVisitService {
}); });
} }
/**
* 统计访问次数,改为从缓存获取/数据库获取
* @param username
* @return
*/
public long countVisits(String username) { public long countVisits(String username) {
User user = userRepository.findByUsername(username) User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
return userVisitRepository.countByUser(user);
// 如果缓存存在就返回
String key1 = CachingConfig.VISIT_CACHE_NAME + ":"+LocalDate.now()+":count:"+username;
Integer cached = (Integer) redisTemplate.opsForValue().get(key1);
if(cached != null){
return cached.longValue();
}
// Redis Set 检查今天是否访问
String todayKey = CachingConfig.VISIT_CACHE_NAME + ":" + LocalDate.now();
boolean todayVisited = redisTemplate.opsForSet().isMember(todayKey, username);
Long visitCount = userVisitRepository.countByUser(user);
if (todayVisited) visitCount += 1;
LocalDateTime now = LocalDateTime.now();
LocalDateTime endOfDay = now.toLocalDate().atTime(23, 59, 59);
long secondsUntilEndOfDay = Duration.between(now, endOfDay).getSeconds();
// 写入缓存,设置 TTL当天剩余时间
redisTemplate.opsForValue().set(key1, visitCount, Duration.ofSeconds(secondsUntilEndOfDay));
return visitCount;
} }
public long countDau(LocalDate date) { public long countDau(LocalDate date) {

View File

@@ -239,8 +239,16 @@ body {
} }
.info-content-text img { .info-content-text img {
max-width: 100%; max-width: 400px;
max-height: 600px;
height: auto; height: auto;
cursor: pointer;
box-shadow: 4px 12px 48px 0 rgba(0, 0, 0, 0.11);
transition: box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.info-content-text img:hover {
box-shadow: 4px 12px 48px 0 rgba(0, 0, 0, 0.35);
} }
.info-content-text table { .info-content-text table {

View File

@@ -100,7 +100,7 @@ export default {
.timeline-content { .timeline-content {
flex: 1; flex: 1;
width: calc(100% - 32px); width: calc(100% - 42px);
} }
@media (max-width: 768px) { @media (max-width: 768px) {

View File

@@ -70,23 +70,6 @@ export default {
onMounted(() => { onMounted(() => {
vditorInstance.value = createVditor(editorId.value, { vditorInstance.value = createVditor(editorId.value, {
placeholder: '输入消息...', placeholder: '输入消息...',
height: 150,
toolbar: [
'emoji',
'bold',
'italic',
'strike',
'link',
'|',
'list',
'|',
'line',
'quote',
'code',
'inline-code',
'|',
'upload',
],
preview: { preview: {
actions: [], actions: [],
markdown: { toc: false }, markdown: { toc: false },
@@ -149,11 +132,17 @@ export default {
border-radius: 8px; border-radius: 8px;
} }
.vditor {
min-height: 50px;
max-height: 150px;
}
.message-bottom-container { .message-bottom-container {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: flex-end; justify-content: flex-end;
padding: 10px; margin-top: 10px;
margin-bottom: 10px;
background-color: var(--bg-color-soft); background-color: var(--bg-color-soft);
border-top: 1px solid var(--border-color); border-top: 1px solid var(--border-color);
border-bottom-left-radius: 8px; border-bottom-left-radius: 8px;

View File

@@ -1,23 +1,48 @@
<template> <template>
<div class="about-page"> <div class="about-page">
<BaseTabs v-model="selectedTab" :tabs="tabs"> <BaseTabs v-model="selectedTab" :tabs="tabs">
<div class="about-loading" v-if="isFetching"> <template v-if="selectedTab === 'api'">
<l-hatch-spinner size="100" stroke="10" speed="1" color="var(--primary-color)" /> <div class="about-api">
</div> <div class="about-api-title">调试Token</div>
<div <div v-if="!authState.loggedIn" class="about-api-login">
v-else <NuxtLink to="/login" class="about-api-login-link">登录</NuxtLink>后查看 Token
class="about-content" </div>
v-html="renderMarkdown(content)" <div v-else class="about-api-token">
@click="handleContentClick" <div class="token-row">
></div> <span class="token-text">{{ shortToken }}</span>
<span @click="copyToken"><copy class="copy-icon" /></span>
</div>
<div class="warning-row">
<info-icon class="warning-icon" />
<div class="token-warning">请不要将 Token 泄露给他人</div>
</div>
</div>
<div class="about-api-title">API文档和调试入口</div>
<div class="about-api-link">API Playground <share /></div>
</div>
</template>
<template v-else>
<div class="about-loading" v-if="isFetching">
<l-hatch-spinner size="100" stroke="10" speed="1" color="var(--primary-color)" />
</div>
<div
v-else
class="about-content"
v-html="renderMarkdown(content)"
@click="handleContentClick"
></div>
</template>
</BaseTabs> </BaseTabs>
</div> </div>
</template> </template>
<script> <script>
import { onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from '#imports'
import { authState, getToken } from '~/utils/auth'
import { handleMarkdownClick, renderMarkdown } from '~/utils/markdown' import { handleMarkdownClick, renderMarkdown } from '~/utils/markdown'
import BaseTabs from '~/components/BaseTabs.vue' import BaseTabs from '~/components/BaseTabs.vue'
import { toast } from '~/composables/useToast'
export default { export default {
name: 'AboutPageView', name: 'AboutPageView',
@@ -44,11 +69,25 @@ export default {
label: '隐私政策', label: '隐私政策',
file: 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/about/privacy.md', file: 'https://openisle-1307107697.cos.ap-guangzhou.myqcloud.com/assert/about/privacy.md',
}, },
{
key: 'api',
label: 'API与调试',
},
] ]
const route = useRoute()
const router = useRouter()
const selectedTab = ref(tabs[0].key) const selectedTab = ref(tabs[0].key)
const content = ref('') const content = ref('')
const token = computed(() => (authState.loggedIn ? getToken() : ''))
const shortToken = computed(() => {
if (!token.value) return ''
if (token.value.length <= 20) return token.value
return `${token.value.slice(0, 20)}...${token.value.slice(-10)}`
})
const loadContent = async (file) => { const loadContent = async (file) => {
if (!file) return
try { try {
isFetching.value = true isFetching.value = true
const res = await fetch(file) const res = await fetch(file)
@@ -65,19 +104,58 @@ export default {
} }
onMounted(() => { onMounted(() => {
loadContent(tabs[0].file) const initTab = route.query.tab
if (initTab && tabs.find((t) => t.key === initTab)) {
selectedTab.value = initTab
const tab = tabs.find((t) => t.key === initTab)
if (tab && tab.file) loadContent(tab.file)
} else {
loadContent(tabs[0].file)
}
}) })
watch(selectedTab, (name) => { watch(selectedTab, (name) => {
const tab = tabs.find((t) => t.key === name) const tab = tabs.find((t) => t.key === name)
if (tab) loadContent(tab.file) if (tab && tab.file) loadContent(tab.file)
router.replace({ query: { ...route.query, tab: name } })
}) })
watch(
() => route.query.tab,
(name) => {
if (name && name !== selectedTab.value && tabs.find((t) => t.key === name)) {
selectedTab.value = name
}
},
)
const copyToken = async () => {
if (import.meta.client && token.value) {
try {
await navigator.clipboard.writeText(token.value)
toast.success('已复制 Token')
} catch (e) {
toast.error('复制失败')
}
}
}
const handleContentClick = (e) => { const handleContentClick = (e) => {
handleMarkdownClick(e) handleMarkdownClick(e)
} }
return { tabs, selectedTab, content, renderMarkdown, isFetching, handleContentClick } return {
tabs,
selectedTab,
content,
renderMarkdown,
isFetching,
handleContentClick,
authState,
token,
copyToken,
shortToken,
}
}, },
} }
</script> </script>
@@ -101,6 +179,66 @@ export default {
height: 200px; height: 200px;
} }
.about-api {
padding: 20px;
}
.about-api-title {
font-size: 20px;
font-weight: bold;
margin-bottom: 10px;
margin-top: 30px;
margin-bottom: 15px;
}
.about-api-login-link {
color: var(--primary-color);
cursor: pointer;
text-decoration: none;
}
.about-api-login-link:hover {
text-decoration: underline;
}
.warning-row {
display: flex;
align-items: center;
gap: 4px;
opacity: 0.7;
}
.warning-icon {
font-size: 13px;
}
.token-warning {
font-size: 13px;
}
.token-row {
display: flex;
align-items: center;
gap: 10px;
font: 14px;
margin-bottom: 10px;
word-break: break-all;
}
.copy-btn {
padding: 4px 8px;
cursor: pointer;
}
.about-api-link {
color: var(--primary-color);
cursor: pointer;
}
.about-api-link:hover {
text-decoration: underline;
}
@media (max-width: 768px) { @media (max-width: 768px) {
.about-tabs { .about-tabs {
width: 100vw; width: 100vw;

View File

@@ -1,5 +1,11 @@
<template> <template>
<div class="chat-container" :class="{ float: isFloatMode }"> <div class="chat-container" :class="{ float: isFloatMode }">
<vue-easy-lightbox
:visible="lightboxVisible"
:index="lightboxIndex"
:imgs="lightboxImgs"
@hide="lightboxVisible = false"
/>
<div v-if="!loading" class="chat-header"> <div v-if="!loading" class="chat-header">
<div class="header-main"> <div class="header-main">
<div class="back-button" @click="goBack"> <div class="back-button" @click="goBack">
@@ -14,7 +20,7 @@
</div> </div>
</div> </div>
<div class="messages-list" ref="messagesListEl"> <div class="messages-list" ref="messagesListEl" @click="handleContentClick">
<div v-if="loading" class="loading-container"> <div v-if="loading" 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>
@@ -101,7 +107,7 @@ import {
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { getToken, fetchCurrentUser } from '~/utils/auth' import { getToken, fetchCurrentUser } from '~/utils/auth'
import { toast } from '~/main' import { toast } from '~/main'
import { renderMarkdown, stripMarkdownLength } from '~/utils/markdown' import { renderMarkdown, stripMarkdownLength, handleMarkdownClick } from '~/utils/markdown'
import MessageEditor from '~/components/MessageEditor.vue' import MessageEditor from '~/components/MessageEditor.vue'
import ReactionsGroup from '~/components/ReactionsGroup.vue' import ReactionsGroup from '~/components/ReactionsGroup.vue'
import { useWebSocket } from '~/composables/useWebSocket' import { useWebSocket } from '~/composables/useWebSocket'
@@ -110,6 +116,7 @@ import { useChannelsUnreadCount } from '~/composables/useChannelsUnreadCount'
import TimeManager from '~/utils/time' import TimeManager from '~/utils/time'
import BaseTimeline from '~/components/BaseTimeline.vue' import BaseTimeline from '~/components/BaseTimeline.vue'
import BasePlaceholder from '~/components/BasePlaceholder.vue' import BasePlaceholder from '~/components/BasePlaceholder.vue'
import VueEasyLightbox from 'vue-easy-lightbox'
const config = useRuntimeConfig() const config = useRuntimeConfig()
const route = useRoute() const route = useRoute()
@@ -135,6 +142,9 @@ const isFloatMode = computed(() => route.query.float !== undefined)
const floatRoute = useState('messageFloatRoute') const floatRoute = useState('messageFloatRoute')
const replyTo = ref(null) const replyTo = ref(null)
const newMessagesCount = ref(0) const newMessagesCount = ref(0)
const lightboxVisible = ref(false)
const lightboxIndex = ref(0)
const lightboxImgs = ref([])
const isUserNearBottom = ref(true) const isUserNearBottom = ref(true)
function updateNearBottom() { function updateNearBottom() {
@@ -451,6 +461,17 @@ function minimize() {
navigateTo('/') navigateTo('/')
} }
function handleContentClick(e) {
handleMarkdownClick(e)
if (e.target.tagName === 'IMG') {
const container = e.target.parentNode
const imgs = [...container.querySelectorAll('img')].map((i) => i.src)
lightboxImgs.value = imgs
lightboxIndex.value = imgs.indexOf(e.target.src)
lightboxVisible.value = true
}
}
function openUser(id) { function openUser(id) {
if (isFloatMode.value) { if (isFloatMode.value) {
// 先不处理... // 先不处理...

View File

@@ -77,6 +77,7 @@ import {
Open, Open,
Dislike, Dislike,
CheckOne, CheckOne,
Share,
} from '@icon-park/vue-next' } from '@icon-park/vue-next'
export default defineNuxtPlugin((nuxtApp) => { export default defineNuxtPlugin((nuxtApp) => {
@@ -157,4 +158,5 @@ export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.component('OpenIcon', Open) nuxtApp.vueApp.component('OpenIcon', Open)
nuxtApp.vueApp.component('Dislike', Dislike) nuxtApp.vueApp.component('Dislike', Dislike)
nuxtApp.vueApp.component('CheckOne', CheckOne) nuxtApp.vueApp.component('CheckOne', CheckOne)
nuxtApp.vueApp.component('Share', Share)
}) })