vite.config.ts 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  1. import { defineConfig, loadEnv, Plugin } from 'vite'
  2. import vue from '@vitejs/plugin-vue'
  3. import checker from 'vite-plugin-checker'
  4. import { resolve } from 'path'
  5. function escapeHtml(value: string): string {
  6. return value.replace(/[&<>"']/g, (character) => ({
  7. '&': '&amp;',
  8. '<': '&lt;',
  9. '>': '&gt;',
  10. '"': '&quot;',
  11. "'": '&#39;',
  12. })[character] || character)
  13. }
  14. function isSafeImageUrl(value: string): boolean {
  15. const trimmed = value.trim()
  16. if ((trimmed.startsWith('/') && !trimmed.startsWith('//')) || /^data:image\//i.test(trimmed)) {
  17. return true
  18. }
  19. try {
  20. const parsed = new URL(trimmed)
  21. return parsed.protocol === 'http:' || parsed.protocol === 'https:'
  22. } catch {
  23. return false
  24. }
  25. }
  26. function injectBranding(html: string, config: { site_name?: string; site_logo?: string }): string {
  27. let brandedHtml = html
  28. const siteName = config.site_name?.trim()
  29. if (siteName) {
  30. brandedHtml = brandedHtml.replace(
  31. /<title>[^<]*<\/title>/i,
  32. `<title>${escapeHtml(siteName)} - AI API Gateway</title>`,
  33. )
  34. }
  35. const siteLogo = config.site_logo?.trim()
  36. if (siteLogo && isSafeImageUrl(siteLogo)) {
  37. brandedHtml = brandedHtml.replace(
  38. /<link\s+rel=["']icon["'][^>]*>/i,
  39. `<link rel="icon" href="${escapeHtml(siteLogo)}" />`,
  40. )
  41. }
  42. return brandedHtml
  43. }
  44. /**
  45. * Vite 插件:开发模式下注入公开配置到 index.html
  46. * 与生产模式的后端注入行为保持一致,消除闪烁
  47. */
  48. function injectPublicSettings(backendUrl: string): Plugin {
  49. return {
  50. name: 'inject-public-settings',
  51. apply: 'serve',
  52. transformIndexHtml: {
  53. order: 'pre',
  54. async handler(html) {
  55. try {
  56. const response = await fetch(`${backendUrl}/api/v1/settings/public`, {
  57. signal: AbortSignal.timeout(2000)
  58. })
  59. if (response.ok) {
  60. const data = await response.json()
  61. if (data.code === 0 && data.data) {
  62. const script = `<script>window.__APP_CONFIG__=${JSON.stringify(data.data)};</script>`
  63. return injectBranding(html, data.data).replace('</head>', `${script}\n</head>`)
  64. }
  65. }
  66. } catch (e) {
  67. console.warn('[vite] 无法获取公开配置,将回退到 API 调用:', (e as Error).message)
  68. }
  69. return html
  70. }
  71. }
  72. }
  73. }
  74. export default defineConfig(({ mode }) => {
  75. // 加载环境变量
  76. const env = loadEnv(mode, process.cwd(), '')
  77. const backendUrl = env.VITE_DEV_PROXY_TARGET || 'http://localhost:8080'
  78. const devPort = Number(env.VITE_DEV_PORT || 3000)
  79. return {
  80. plugins: [
  81. vue(),
  82. checker({
  83. vueTsc: true
  84. }),
  85. injectPublicSettings(backendUrl)
  86. ],
  87. resolve: {
  88. alias: {
  89. '@': resolve(__dirname, 'src'),
  90. // 使用 vue-i18n 运行时版本,避免 CSP unsafe-eval 问题
  91. 'vue-i18n': 'vue-i18n/dist/vue-i18n.runtime.esm-bundler.js'
  92. }
  93. },
  94. define: {
  95. // 启用 vue-i18n JIT 编译,在 CSP 环境下处理消息插值
  96. // JIT 编译器生成 AST 对象而非 JS 代码,无需 unsafe-eval
  97. __INTLIFY_JIT_COMPILATION__: true
  98. },
  99. build: {
  100. outDir: '../backend/internal/web/dist',
  101. emptyOutDir: true,
  102. rollupOptions: {
  103. output: {
  104. /**
  105. * 手动分包配置
  106. * 分离第三方库并按功能合并应用代码,避免循环依赖
  107. */
  108. manualChunks(id: string) {
  109. if (id.includes('node_modules')) {
  110. // Vue 核心库
  111. if (
  112. id.includes('/vue/') ||
  113. id.includes('/vue-router/') ||
  114. id.includes('/pinia/') ||
  115. id.includes('/@vue/')
  116. ) {
  117. return 'vendor-vue'
  118. }
  119. // UI 工具库(较大,单独分离)
  120. if (id.includes('/@vueuse/') || id.includes('/xlsx/')) {
  121. return 'vendor-ui'
  122. }
  123. // 图表库
  124. if (id.includes('/chart.js/') || id.includes('/vue-chartjs/')) {
  125. return 'vendor-chart'
  126. }
  127. // 国际化
  128. if (id.includes('/vue-i18n/') || id.includes('/@intlify/')) {
  129. return 'vendor-i18n'
  130. }
  131. // Stripe 仅在支付流程中按需加载,避免进入首页公共依赖。
  132. if (id.includes('/@stripe/stripe-js/')) {
  133. return 'vendor-stripe'
  134. }
  135. // 其他小型第三方库合并
  136. return 'vendor-misc'
  137. }
  138. // 应用代码:按入口点自动分包,不手动干预
  139. // 这样可以避免循环依赖,同时保持合理的 chunk 数量
  140. }
  141. }
  142. }
  143. },
  144. server: {
  145. host: '0.0.0.0',
  146. port: devPort,
  147. proxy: {
  148. '/api': {
  149. target: backendUrl,
  150. changeOrigin: true
  151. },
  152. '/v1': {
  153. target: backendUrl,
  154. changeOrigin: true
  155. },
  156. '/setup': {
  157. target: backendUrl,
  158. changeOrigin: true
  159. }
  160. }
  161. }
  162. }
  163. })