SPA冲突路由改名+旧路径Navigate重定向(/api-docs→/open-api-docs、/api-detail→/open-api-detail、/article→/post、/user→/user-home);vite代理宽前缀改精确正则白名单;nginx.conf同步同口径;新增86路由回归脚本+Playwright 3项(86/86全绿)。
174 lines
6.9 KiB
TypeScript
174 lines
6.9 KiB
TypeScript
import { defineConfig } from 'vite'
|
||
import react from '@vitejs/plugin-react'
|
||
import path from 'path';
|
||
|
||
// C-01: vite 代理精确收窄 —— 只代理后端真实存在的 API 路径,SPA 路由一律放行。
|
||
// 后端真实挂载(chunyu_project/chunyu_project/urls.py + 各 app urls.py):
|
||
// /api/*(api.urls:getIpData/baiduFanyi/tool/weather/air-quality/currency/captcha/shorturl/...)
|
||
// /api/apidirectory/*、/user/*、/bug/reports/*、/article/articles|my-articles|comments/*
|
||
// /learn/*、/history/records/*、/message/*、/chat/*、/tool/*、/search/*、/app/*、
|
||
// /logs/api/*、/shorturl/*、/s/<code>/(短链跳转)、/admin/、/swagger*、/redoc/、/i18n/、/media/
|
||
// SPA 路由(/open-api*、/post/*、/user-home*、/api-docs 旧重定向等)不在此列,
|
||
// 由 bypass 精确放行,避免前缀误伤(如 /api 劫持 /open-api?不——新 SPA 已迁走,
|
||
// 但 /api-docs 旧重定向、/article 旧重定向等仍需放行到前端)。
|
||
const BACKEND = 'http://127.0.0.1:8002'
|
||
|
||
// 纯 SPA 前缀:命中即放行(返回 undefined → 走 vite dev 中间件/前端路由)
|
||
const SPA_PREFIXES = [
|
||
'/open-api',
|
||
'/post/',
|
||
'/user-home',
|
||
'/articles',
|
||
'/utility',
|
||
'/learn-manage',
|
||
'/learn-editor',
|
||
'/article-manage',
|
||
'/article-editor',
|
||
'/bug-detail',
|
||
'/tool-detail',
|
||
'/course-learn',
|
||
'/shorturl-detail',
|
||
'/currency-detail',
|
||
'/ip-location',
|
||
'/baidu-translate',
|
||
'/qrcode-generator',
|
||
'/color-picker',
|
||
'/changelog',
|
||
'/favorites',
|
||
'/console',
|
||
'/profile',
|
||
'/settings',
|
||
'/history',
|
||
'/favorites',
|
||
'/use',
|
||
'/document',
|
||
'/manual',
|
||
'/privacy',
|
||
'/agreement',
|
||
'/messages',
|
||
'/chat',
|
||
'/search',
|
||
'/bug',
|
||
'/learn',
|
||
'/wallet',
|
||
'/task-center',
|
||
'/invite',
|
||
'/forgot-password',
|
||
'/register',
|
||
]
|
||
|
||
// 精确 SPA 路由(无尾部、与后端前缀撞名的几个)
|
||
const SPA_EXACT = new Set([
|
||
'/api-docs', '/api-directory', '/api',
|
||
'/user', '/article-manage', '/article-editor', '/bug-detail',
|
||
])
|
||
|
||
// C-01 旧分享链接对应的 SPA 页面(已迁到 /open-api*,但旧 URL 仍由前端 <Navigate> 承接)
|
||
const LEGACY_API_PAGES = new Set([
|
||
'/api/air-quality-details', '/api/weather-details', '/api/qrcode-generator',
|
||
'/api/ip-location', '/api/password-generator',
|
||
])
|
||
|
||
function isSpaPath(url: string | undefined): boolean {
|
||
if (!url) return false
|
||
const p = url.split('?')[0]
|
||
if (SPA_EXACT.has(p)) return true
|
||
if (LEGACY_API_PAGES.has(p)) return true
|
||
// 旧重定向路由也属 SPA
|
||
if (p.startsWith('/api-detail') || p.startsWith('/api-docs') || p.startsWith('/api-directory')) return true
|
||
if (p.startsWith('/article/') || p === '/article') return true
|
||
if (p.startsWith('/user/') || p === '/user') return true
|
||
return SPA_PREFIXES.some((pre) => p === pre || p.startsWith(pre + '/') || (pre.endsWith('/') ? p.startsWith(pre) : p.startsWith(pre + '/') || p === pre))
|
||
}
|
||
|
||
function spaBypass(req: { url?: string }) {
|
||
if (isSpaPath(req.url)) return req.url // 放行到前端
|
||
}
|
||
|
||
// 后端真实 API 上下文 → 全部走精确前缀(每个都以 / 结尾或带参数段,
|
||
// 且各自的 bypass 先放行 SPA,避免 /message 误伤 /messages、/s 误伤 /settings)
|
||
const proxy: Record<string, object> = {
|
||
'/ws': {
|
||
target: 'ws://127.0.0.1:8002',
|
||
ws: true,
|
||
rewrite: (path: string) => path.replace(/^\/ws/, ''),
|
||
},
|
||
'/media/': { target: BACKEND, changeOrigin: true },
|
||
// /api/*:后端真实 API(含 /api/apidirectory/*、/api/shorturl/* 等)
|
||
'^/api/': { target: BACKEND, changeOrigin: true, bypass: spaBypass },
|
||
// /user/*:后端 user.urls 全是 /user/<action>/;SPA 已迁到 /user-home*,
|
||
// 仅放行旧分享链接 /user/<数字id>(LegacyUserHomeRedirect),其余一律代理
|
||
'^/user/': {
|
||
target: BACKEND,
|
||
changeOrigin: true,
|
||
bypass: (req: { url?: string }) => {
|
||
const p = (req.url || '').split('?')[0]
|
||
if (/^\/user\/\d+\/?$/.test(p)) return req.url // 旧用户主页分享链接 → SPA 重定向
|
||
},
|
||
},
|
||
// /bug/reports/*:后端仅 reports/ 三条;SPA 的 /bug 与 /bug-detail 放行
|
||
'^/bug/reports/': { target: BACKEND, changeOrigin: true },
|
||
// /article/articles|my-articles|comments/*:后端仅这几段;SPA /post/*、/articles、/article-manage 等放行
|
||
'^/article/(articles|my-articles|comments)/': { target: BACKEND, changeOrigin: true },
|
||
// /learn/*:后端 learn.urls;SPA 无 /learn/<id> 冲突(/learn 是列表页、/learn-manage 等已迁出判断)
|
||
// 但 /learn 本体是 SPA 列表页 → 仅代理带子段的后端路径,根 /learn 放行
|
||
'^/learn/(courses|chapters|my-courses|my-progress|materials|favorites|cdn)/': { target: BACKEND, changeOrigin: true },
|
||
// /history/records/*:后端仅 records/;SPA /history 是列表页
|
||
'^/history/records/': { target: BACKEND, changeOrigin: true },
|
||
// /message/*:后端 message.urls;SPA /messages(多 s)天然不命中
|
||
'^/message/': { target: BACKEND, changeOrigin: true },
|
||
// /chat/*:后端 chat.urls;SPA /chat 本体是页面 → 仅代理后端子段
|
||
'^/chat/(friend-requests|friends|users|conversations|messages|upload|favorite-stickers)/': { target: BACKEND, changeOrigin: true },
|
||
// /tool/*:后端 tool.urls;SPA /tool-detail 放行(不以 /tool/ 开头,天然安全)
|
||
'^/tool/': { target: BACKEND, changeOrigin: true },
|
||
// /search/*:后端 search.urls(GET /search/ 本体 + suggestions/hot-keywords)
|
||
// SPA /search 本体是搜索页 → 无查询串的裸 /search 放行到前端
|
||
'^/search/': {
|
||
target: BACKEND,
|
||
changeOrigin: true,
|
||
bypass: (req: { url?: string }) => {
|
||
const raw = req.url || ''
|
||
const p = raw.split('?')[0]
|
||
if (p === '/search/' && !raw.includes('?')) return req.url
|
||
},
|
||
},
|
||
// /app/*:后端 changelog;SPA 无 /app 冲突
|
||
'^/app/': { target: BACKEND, changeOrigin: true },
|
||
// /logs/api/*:后端日志;SPA 无冲突
|
||
'^/logs/api/': { target: BACKEND, changeOrigin: true },
|
||
// /shorturl/*:后端 shorturl.urls;SPA /shorturl-detail 放行
|
||
'^/shorturl/': { target: BACKEND, changeOrigin: true },
|
||
// /s/<code>/:短链跳转(后端 ShortUrlRedirectView);SPA /settings、/shorturl-detail 必须放行
|
||
'/s': {
|
||
target: BACKEND,
|
||
changeOrigin: true,
|
||
bypass: (req: { url?: string }) => {
|
||
const p = (req.url || '').split('?')[0]
|
||
if (p === '/s' || p.startsWith('/settings') || p.startsWith('/shorturl-detail') || p.startsWith('/src') || p.includes('.')) return req.url
|
||
if (!/^\/s\/[^/]+\/?$/.test(p)) return req.url // 非短码形态一律放行
|
||
},
|
||
},
|
||
'/i18n': { target: BACKEND, changeOrigin: true, bypass: spaBypass },
|
||
}
|
||
|
||
// https://vite.dev/config/
|
||
export default defineConfig({
|
||
server: {
|
||
hmr: true,
|
||
host: '::',
|
||
allowedHosts: [
|
||
'.mymoyu.top',
|
||
'localhost',
|
||
'127.0.0.1',
|
||
'192.168.1.4'
|
||
],
|
||
proxy,
|
||
},
|
||
plugins: [react()],
|
||
resolve: {
|
||
alias: {
|
||
'@': path.resolve(__dirname, './src'),
|
||
},
|
||
},
|
||
})
|