Compare commits

..

2 Commits

Author SHA1 Message Date
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
6 changed files with 157 additions and 61 deletions

View File

@@ -8,18 +8,13 @@ import io.swagger.v3.oas.models.security.SecurityScheme;
import io.swagger.v3.oas.models.servers.Server; import io.swagger.v3.oas.models.servers.Server;
import java.util.List; import java.util.List;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
@Configuration @Configuration
@RequiredArgsConstructor
public class OpenApiConfig { public class OpenApiConfig {
private final SpringDocProperties springDocProperties;
@Value("${springdoc.info.title}") @Value("${springdoc.info.title}")
private String title; private String title;
@@ -35,21 +30,20 @@ public class OpenApiConfig {
@Value("${springdoc.info.header}") @Value("${springdoc.info.header}")
private String header; private String header;
@Value("${springdoc.api-docs.server-url}")
private String serverUrl;
@Bean @Bean
public OpenAPI openAPI() { public OpenAPI openAPI() {
SecurityScheme securityScheme = new SecurityScheme() SecurityScheme securityScheme = new SecurityScheme()
.type(SecurityScheme.Type.HTTP) .type(SecurityScheme.Type.HTTP)
.scheme(scheme.toLowerCase()) .scheme(scheme.toLowerCase())
.bearerFormat("JWT") .bearerFormat("JWT")
.in(SecurityScheme.In.HEADER) .in(SecurityScheme.In.HEADER)
.name(header); .name(header);
List<Server> servers = springDocProperties.getServers().stream()
.map(s -> new Server().url(s.getUrl()).description(s.getDescription()))
.collect(Collectors.toList());
return new OpenAPI() return new OpenAPI()
.servers(servers) .servers(List.of(new Server().url(serverUrl)))
.info(new Info() .info(new Info()
.title(title) .title(title)
.description(description) .description(description)

View File

@@ -1,20 +0,0 @@
package com.openisle.config;
import java.util.ArrayList;
import java.util.List;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Data
@Component
@ConfigurationProperties(prefix = "springdoc.api-docs")
public class SpringDocProperties {
private List<ServerConfig> servers = new ArrayList<>();
@Data
public static class ServerConfig {
private String url;
private String description;
}
}

View File

@@ -108,10 +108,7 @@ rabbitmq.sharding.enabled=true
# see https://springdoc.org/#springdoc-openapi-core-properties # see https://springdoc.org/#springdoc-openapi-core-properties
springdoc.api-docs.path=/api/v3/api-docs springdoc.api-docs.path=/api/v3/api-docs
springdoc.api-docs.enabled=true springdoc.api-docs.enabled=true
springdoc.api-docs.servers[0].url=${WEBSITE_URL:https://www.open-isle.com} springdoc.api-docs.server-url=${WEBSITE_URL:https://www.open-isle.com}
springdoc.api-docs.servers[0].description=正式环境
springdoc.api-docs.servers[1].url=https://www.staging.open-isle.com
springdoc.api-docs.servers[1].description=预发环境
springdoc.info.title=OpenIsle springdoc.info.title=OpenIsle
springdoc.info.description=OpenIsle Open API Documentation springdoc.info.description=OpenIsle Open API Documentation
springdoc.info.version=0.0.1 springdoc.info.version=0.0.1

View File

@@ -6,7 +6,6 @@ import com.openisle.exception.RateLimitException;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.scheduling.TaskScheduler; import org.springframework.scheduling.TaskScheduler;
import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContext;
import org.springframework.data.redis.core.RedisTemplate;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
@@ -39,12 +38,11 @@ class PostServiceTest {
ApplicationContext context = mock(ApplicationContext.class); ApplicationContext context = mock(ApplicationContext.class);
PointService pointService = mock(PointService.class); PointService pointService = mock(PointService.class);
PostChangeLogService postChangeLogService = mock(PostChangeLogService.class); PostChangeLogService postChangeLogService = mock(PostChangeLogService.class);
RedisTemplate redisTemplate = mock(RedisTemplate.class);
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo, PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo, pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo,
reactionRepo, subRepo, notificationRepo, postReadService, reactionRepo, subRepo, notificationRepo, postReadService,
imageUploader, taskScheduler, emailSender, context, pointService, postChangeLogService, PublishMode.DIRECT, redisTemplate); imageUploader, taskScheduler, emailSender, context, pointService, postChangeLogService, PublishMode.DIRECT);
when(context.getBean(PostService.class)).thenReturn(service); when(context.getBean(PostService.class)).thenReturn(service);
Post post = new Post(); Post post = new Post();
@@ -90,12 +88,11 @@ class PostServiceTest {
ApplicationContext context = mock(ApplicationContext.class); ApplicationContext context = mock(ApplicationContext.class);
PointService pointService = mock(PointService.class); PointService pointService = mock(PointService.class);
PostChangeLogService postChangeLogService = mock(PostChangeLogService.class); PostChangeLogService postChangeLogService = mock(PostChangeLogService.class);
RedisTemplate redisTemplate = mock(RedisTemplate.class);
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo, PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo, pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo,
reactionRepo, subRepo, notificationRepo, postReadService, reactionRepo, subRepo, notificationRepo, postReadService,
imageUploader, taskScheduler, emailSender, context, pointService, postChangeLogService, PublishMode.DIRECT, redisTemplate); imageUploader, taskScheduler, emailSender, context, pointService, postChangeLogService, PublishMode.DIRECT);
when(context.getBean(PostService.class)).thenReturn(service); when(context.getBean(PostService.class)).thenReturn(service);
Post post = new Post(); Post post = new Post();
@@ -147,12 +144,11 @@ class PostServiceTest {
ApplicationContext context = mock(ApplicationContext.class); ApplicationContext context = mock(ApplicationContext.class);
PointService pointService = mock(PointService.class); PointService pointService = mock(PointService.class);
PostChangeLogService postChangeLogService = mock(PostChangeLogService.class); PostChangeLogService postChangeLogService = mock(PostChangeLogService.class);
RedisTemplate redisTemplate = mock(RedisTemplate.class);
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo, PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo, pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo,
reactionRepo, subRepo, notificationRepo, postReadService, reactionRepo, subRepo, notificationRepo, postReadService,
imageUploader, taskScheduler, emailSender, context, pointService, postChangeLogService, PublishMode.DIRECT, redisTemplate); imageUploader, taskScheduler, emailSender, context, pointService, postChangeLogService, PublishMode.DIRECT);
when(context.getBean(PostService.class)).thenReturn(service); when(context.getBean(PostService.class)).thenReturn(service);
when(postRepo.countByAuthorAfter(eq("alice"), any())).thenReturn(1L); when(postRepo.countByAuthorAfter(eq("alice"), any())).thenReturn(1L);
@@ -185,12 +181,11 @@ class PostServiceTest {
ApplicationContext context = mock(ApplicationContext.class); ApplicationContext context = mock(ApplicationContext.class);
PointService pointService = mock(PointService.class); PointService pointService = mock(PointService.class);
PostChangeLogService postChangeLogService = mock(PostChangeLogService.class); PostChangeLogService postChangeLogService = mock(PostChangeLogService.class);
RedisTemplate redisTemplate = mock(RedisTemplate.class);
PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo, PostService service = new PostService(postRepo, userRepo, catRepo, tagRepo, lotteryRepo,
pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo, pollPostRepo, pollVoteRepo, notifService, subService, commentService, commentRepo,
reactionRepo, subRepo, notificationRepo, postReadService, reactionRepo, subRepo, notificationRepo, postReadService,
imageUploader, taskScheduler, emailSender, context, pointService, postChangeLogService, PublishMode.DIRECT, redisTemplate); imageUploader, taskScheduler, emailSender, context, pointService, postChangeLogService, PublishMode.DIRECT);
when(context.getBean(PostService.class)).thenReturn(service); when(context.getBean(PostService.class)).thenReturn(service);
User author = new User(); User author = new User();

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">登录</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,56 @@ 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;
}
.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

@@ -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)
}) })