swagger嵌入

This commit is contained in:
kazusa 2025-08-26 15:13:34 +08:00
parent a3c423d5d9
commit d59f99da21
9 changed files with 339 additions and 58 deletions

3
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"liveServer.settings.port": 5501
}

View File

@ -80,6 +80,12 @@ public class SecurityConfig {
// 其他请求需要认证 // 其他请求需要认证
.anyRequest().authenticated()) .anyRequest().authenticated())
// 允许iframe嵌入
.headers(headers -> headers
.frameOptions(frame -> frame.disable())
.contentTypeOptions(contentType -> contentType.disable())
.httpStrictTransportSecurity(hsts -> hsts.disable()))
// 认证提供者 // 认证提供者
.authenticationProvider(authenticationProvider()) .authenticationProvider(authenticationProvider())

View File

@ -0,0 +1,42 @@
package com.admin.config;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Component
@Order(Integer.MIN_VALUE) // 最高优先级
public class SwaggerFrameOptionsFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// 执行过滤器链
filterChain.doFilter(request, response);
// 如果是Swagger相关的请求强制覆盖响应头
String requestURI = request.getRequestURI();
if (requestURI != null && (
requestURI.contains("/swagger-ui") ||
requestURI.contains("/v3/api-docs") ||
requestURI.contains("/swagger-ui.html")
)) {
// 强制移除X-Frame-Options限制
response.setHeader("X-Frame-Options", "SAMEORIGIN");
response.setHeader("Content-Security-Policy", "frame-ancestors 'self' *");
response.setHeader("Access-Control-Allow-Origin", "*");
response.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
response.setHeader("Access-Control-Allow-Headers", "*");
// 确保这些头被正确设置
response.setHeader("X-Frame-Options", "SAMEORIGIN");
}
}
}

View File

@ -1,2 +1,2 @@
# 通用环境变量 # 通用环境变量
VITE_APP_TITLE=Admin Management System VITE_APP_TITLE=AI后台管理

View File

@ -19,11 +19,15 @@ declare module 'vue' {
ElCheckbox: typeof import('element-plus/es')['ElCheckbox'] ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup'] ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
ElCol: typeof import('element-plus/es')['ElCol'] ElCol: typeof import('element-plus/es')['ElCol']
ElCollapse: typeof import('element-plus/es')['ElCollapse']
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
ElContainer: typeof import('element-plus/es')['ElContainer'] ElContainer: typeof import('element-plus/es')['ElContainer']
ElDialog: typeof import('element-plus/es')['ElDialog'] ElDialog: typeof import('element-plus/es')['ElDialog']
ElDivider: typeof import('element-plus/es')['ElDivider']
ElDropdown: typeof import('element-plus/es')['ElDropdown'] ElDropdown: typeof import('element-plus/es')['ElDropdown']
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem'] ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu'] ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
ElEmpty: typeof import('element-plus/es')['ElEmpty']
ElForm: typeof import('element-plus/es')['ElForm'] ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem'] ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElHeader: typeof import('element-plus/es')['ElHeader'] ElHeader: typeof import('element-plus/es')['ElHeader']
@ -41,6 +45,7 @@ declare module 'vue' {
ElRow: typeof import('element-plus/es')['ElRow'] ElRow: typeof import('element-plus/es')['ElRow']
ElScrollbar: typeof import('element-plus/es')['ElScrollbar'] ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
ElSelect: typeof import('element-plus/es')['ElSelect'] ElSelect: typeof import('element-plus/es')['ElSelect']
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
ElSubMenu: typeof import('element-plus/es')['ElSubMenu'] ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
ElTable: typeof import('element-plus/es')['ElTable'] ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn'] ElTableColumn: typeof import('element-plus/es')['ElTableColumn']

View File

@ -115,27 +115,22 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import TabsView from '@/components/TabsView.vue'
import DynamicMenu from '@/components/DynamicMenu.vue' import DynamicMenu from '@/components/DynamicMenu.vue'
import TabsView from '@/components/TabsView.vue'
import { useMenuStore } from '@/stores/menu'
import { useTabsStore } from '@/stores/tabs' import { useTabsStore } from '@/stores/tabs'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { useMenuStore } from '@/stores/menu'
import { import {
ArrowDown, ArrowDown,
House, House,
Loading,
Lock, Lock,
Menu,
Setting,
SwitchButton, SwitchButton,
User, User
UserFilled,
Expand,
Fold,
Loading
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { computed, ref, watch, onMounted } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
const route = useRoute() const route = useRoute()

View File

@ -1,3 +1,4 @@
import { useTabsStore } from '@/stores/tabs'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { createRouter, createWebHashHistory, createWebHistory } from 'vue-router' import { createRouter, createWebHashHistory, createWebHistory } from 'vue-router'
@ -94,7 +95,6 @@ router.beforeEach(async (to, from, next) => {
// 路由后置守卫 - 添加标签页 // 路由后置守卫 - 添加标签页
router.afterEach((to) => { router.afterEach((to) => {
const { useTabsStore } = require('@/stores/tabs')
const tabsStore = useTabsStore() const tabsStore = useTabsStore()
// 只有需要认证且不是隐藏页面的路由才添加标签页 // 只有需要认证且不是隐藏页面的路由才添加标签页

View File

@ -5,48 +5,293 @@
<span>接口文档</span> <span>接口文档</span>
</template> </template>
<div class="content"> <div class="swagger-content">
<p>API接口文档页面</p> <div v-if="loading" class="loading-container">
<el-alert <el-skeleton :rows="10" animated />
title="API文档" </div>
description="您可以访问后端的Swagger文档查看完整的API接口信息"
type="success"
show-icon
:closable="false"
/>
<div class="swagger-link" style="margin-top: 20px;"> <div v-else-if="error" class="error-container">
<el-alert
title="加载失败"
:description="error"
type="error"
show-icon
:closable="false"
/>
<el-button <el-button
type="primary" type="primary"
size="large" style="margin-top: 20px;"
@click="openSwagger" @click="loadSwagger"
> >
打开Swagger文档 重新加载
</el-button> </el-button>
</div> </div>
<div v-else class="swagger-embedded-container">
<div class="swagger-header">
<h3>API接口文档</h3>
<div class="swagger-controls">
<el-button
type="primary"
size="small"
@click="refreshSwagger"
>
刷新
</el-button>
<el-button
type="success"
size="small"
@click="openInNewTab"
>
新窗口打开
</el-button>
</div>
</div>
<div class="swagger-body">
<div
ref="swaggerContainer"
class="swagger-content-container"
v-html="swaggerContent"
></div>
</div>
</div>
</div> </div>
</el-card> </el-card>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
// Swagger import { nextTick, onMounted, ref } from 'vue'
const openSwagger = () => {
window.open('http://localhost:8080/swagger-ui.html', '_blank') //
interface SwaggerUIBundle {
(config: SwaggerConfig): unknown
presets: {
apis: unknown
}
plugins: {
DownloadUrl: unknown
}
} }
interface SwaggerConfig {
url: string
dom_id: string
deepLinking: boolean
presets: unknown[]
plugins: unknown[]
layout: string
}
//
const loading = ref(true)
const error = ref('')
const swaggerContent = ref('')
const swaggerContainer = ref<HTMLElement>()
// Swagger UI
const loadSwagger = async () => {
loading.value = true
error.value = ''
try {
// Swagger UIHTML
const response = await fetch('http://localhost:8080/api/swagger-ui/index.html')
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
let html = await response.text()
// headHTML
html = html.replace(/<head>[\s\S]*?<\/head>/gi, '')
// body
html = html.replace(/<body[^>]*>/, '').replace(/<\/body>/, '')
// html
html = html.replace(/<html[^>]*>/, '').replace(/<\/html>/, '')
// CSSJS
html = html.replace(/href="\.\//g, 'href="http://localhost:8080/api/swagger-ui/')
html = html.replace(/src="\.\//g, 'src="http://localhost:8080/api/swagger-ui/')
swaggerContent.value = html
loading.value = false
// DOMSwagger UI
await nextTick()
initializeSwaggerUI()
} catch (err) {
console.error('加载Swagger UI失败:', err)
error.value = '无法连接到后端服务,请确保后端服务正在运行在 http://localhost:8080'
loading.value = false
}
}
// Swagger UI
const initializeSwaggerUI = () => {
if (!swaggerContainer.value) return
// Swagger UICSS
const link = document.createElement('link')
link.rel = 'stylesheet'
link.type = 'text/css'
link.href = 'http://localhost:8080/api/swagger-ui/swagger-ui.css'
document.head.appendChild(link)
// Swagger UIJS
const script = document.createElement('script')
script.src = 'http://localhost:8080/api/swagger-ui/swagger-ui-bundle.js'
script.onload = () => {
const standaloneScript = document.createElement('script')
standaloneScript.src = 'http://localhost:8080/api/swagger-ui/swagger-ui-standalone-preset.js'
standaloneScript.onload = () => {
// Swagger UI
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const SwaggerUIBundle = (window as any).SwaggerUIBundle as SwaggerUIBundle
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const SwaggerUIStandalonePreset = (window as any).SwaggerUIStandalonePreset
if (SwaggerUIBundle) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).ui = SwaggerUIBundle({
url: 'http://localhost:8080/api/v3/api-docs',
dom_id: '#swagger-ui',
deepLinking: true,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset
],
plugins: [
SwaggerUIBundle.plugins.DownloadUrl
],
layout: "StandaloneLayout"
})
}
}
document.head.appendChild(standaloneScript)
}
document.head.appendChild(script)
}
// Swagger UI
const refreshSwagger = () => {
loadSwagger()
}
// Swagger UI
const openInNewTab = () => {
window.open('http://localhost:8080/api/swagger-ui/index.html', '_blank')
}
//
onMounted(() => {
loadSwagger()
})
</script> </script>
<style scoped> <style scoped>
.swagger-container { .swagger-container {
height: 100%;
display: flex;
flex-direction: column;
}
.swagger-container :deep(.el-card) {
height: 100%;
display: flex;
flex-direction: column;
}
.swagger-container :deep(.el-card__body) {
flex: 1;
padding: 0;
overflow: hidden;
}
.swagger-content {
height: 100%;
display: flex;
flex-direction: column;
}
.loading-container {
padding: 40px;
flex: 1;
}
.error-container {
padding: 40px;
text-align: center;
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.swagger-embedded-container {
height: 100%;
display: flex;
flex-direction: column;
}
.swagger-header {
padding: 15px 20px;
border-bottom: 1px solid #e4e7ed;
display: flex;
justify-content: space-between;
align-items: center;
background-color: #fafafa;
flex-shrink: 0;
}
.swagger-header h3 {
margin: 0;
color: #303133;
font-size: 16px;
}
.swagger-controls {
display: flex;
gap: 10px;
}
.swagger-body {
flex: 1;
overflow: hidden;
position: relative;
}
.swagger-content-container {
height: 100%;
overflow: auto;
padding: 20px; padding: 20px;
} }
.content { .swagger-content-container :deep(#swagger-ui) {
text-align: center; height: 100%;
padding: 50px 0; min-height: 600px;
} }
.swagger-link { /* 自定义滚动条 */
margin-top: 30px; .swagger-content-container::-webkit-scrollbar {
width: 6px;
}
.swagger-content-container::-webkit-scrollbar-track {
background: #f1f1f1;
}
.swagger-content-container::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
.swagger-content-container::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
} }
</style> </style>

View File

@ -7,17 +7,18 @@ import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig(({ mode }) => ({
plugins: [ plugins: [
vue(), vue(),
vueDevTools(), // 仅在开发环境启用 DevTools避免对生产构建的注入影响打包产物
mode === 'development' && vueDevTools(),
AutoImport({ AutoImport({
resolvers: [ElementPlusResolver()], resolvers: [ElementPlusResolver()],
}), }),
Components({ Components({
resolvers: [ElementPlusResolver()], resolvers: [ElementPlusResolver()],
}), }),
], ].filter(Boolean),
server: { server: {
port: 3000, port: 3000,
proxy: { proxy: {
@ -28,28 +29,12 @@ export default defineConfig({
} }
}, },
build: { build: {
// 确保构建时使用相对路径 // 使用相对路径,便于在静态服务器(如 Live Server直接打开 dist
assetsDir: 'assets', assetsDir: 'assets',
rollupOptions: { // 交给 Vite/Rollup 自己做最佳分包,避免对 Vue 这类存在循环依赖的包强制分块
output: { // 若线上仍遇到压缩器相关问题,可将 minify 切为 'terser'
// 修复循环引用问题的分包策略 // minify: 'terser',
manualChunks(id) { sourcemap: false,
if (id.includes('node_modules')) {
if (id.includes('element-plus')) {
return 'element-plus'
}
if (id.includes('vue')) {
return 'vue'
}
if (id.includes('@element-plus/icons-vue')) {
return 'element-icons'
}
// 其他第三方库
return 'vendor'
}
}
}
}
}, },
// 设置基础路径为相对路径,适用于静态文件服务器 // 设置基础路径为相对路径,适用于静态文件服务器
base: './', base: './',
@ -58,4 +43,4 @@ export default defineConfig({
'@': fileURLToPath(new URL('./src', import.meta.url)) '@': fileURLToPath(new URL('./src', import.meta.url))
}, },
}, },
}) }))