feat(C-01):路由/代理前缀冲突修复
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全绿)。
This commit is contained in:
+179
-2
@@ -25,8 +25,11 @@ server {
|
||||
}
|
||||
|
||||
# API 反代 → Granian(ADRF 异步视图)
|
||||
# 路由分散在 /api /user /bug /article /chat /learn /message /tool /history /media 等前缀下
|
||||
location ~ ^/(api|user|bug|article|chat|learn|message|tool|history|media|search|app|logs|s|air-quality|weather|currency|shorturl)/ {
|
||||
# C-01:精确收窄到后端真实存在的 API 前缀。SPA 路由(/open-api*、/post/*、
|
||||
# /user-home*、/articles、/learn 列表页等)一律落到下面的 `location /` 回退,
|
||||
# 不再被宽正则劫持。旧分享链接(/article/:id、/user/:id、/api-detail/* 等)
|
||||
# 由前端 SPA 内 <Navigate> 重定向承接,nginx 直接放行到 index.html 即可。
|
||||
location ~ ^/api/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
@@ -39,6 +42,165 @@ server {
|
||||
client_max_body_size 50M;
|
||||
}
|
||||
|
||||
# 后端 user.urls(/user/<action>/):SPA 已迁到 /user-home*,旧 /user/:id 分享
|
||||
# 链接由前端重定向承接;数字 id 形态放行到 SPA,其余代理到后端
|
||||
location ~ ^/user/\d+/?$ {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
location ~ ^/user/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Content-Type $http_content_type;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
client_max_body_size 50M;
|
||||
}
|
||||
|
||||
# 后端 bug.urls 仅 /bug/reports/*;SPA /bug 与 /bug-detail 放行
|
||||
location ~ ^/bug/reports/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
client_max_body_size 50M;
|
||||
}
|
||||
|
||||
# 后端 article.urls 仅 articles|my-articles|comments;SPA /post/*、/articles 放行
|
||||
location ~ ^/article/(articles|my-articles|comments)/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
client_max_body_size 50M;
|
||||
}
|
||||
|
||||
# 后端 learn.urls 子段;SPA /learn 列表页放行
|
||||
location ~ ^/learn/(courses|chapters|my-courses|my-progress|materials|favorites|cdn)/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
client_max_body_size 50M;
|
||||
}
|
||||
|
||||
# 后端 message.urls(/message/*);SPA /messages(多 s)天然不命中
|
||||
location ~ ^/message/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
client_max_body_size 50M;
|
||||
}
|
||||
|
||||
# 后端 chat.urls 子段;SPA /chat 本体放行
|
||||
location ~ ^/chat/(friend-requests|friends|users|conversations|messages|upload|favorite-stickers)/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
client_max_body_size 50M;
|
||||
}
|
||||
|
||||
# 后端 tool.urls(/tool/*);SPA /tool-detail 放行(不以 /tool/ 开头)
|
||||
location ~ ^/tool/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
client_max_body_size 50M;
|
||||
}
|
||||
|
||||
# 后端 history.urls 仅 records/;SPA /history 列表页放行
|
||||
location ~ ^/history/records/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
client_max_body_size 50M;
|
||||
}
|
||||
|
||||
# 后端 search 查询串形态(GET /search/?q=);裸 /search(页)与无参 /search/ 放行到 SPA。
|
||||
# location 按 URI 匹配、看不见 query string,故用 $args 分流。
|
||||
location = /search/ {
|
||||
error_page 418 = @spa_fallback;
|
||||
if ($args = "") { return 418; }
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
}
|
||||
location ~ ^/search/(suggestions|hot-keywords)/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
}
|
||||
|
||||
# 后端 app(changelog)/ logs / shorturl
|
||||
location ~ ^/(app|logs/api|shorturl)/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
client_max_body_size 50M;
|
||||
}
|
||||
|
||||
# 短链跳转 /s/<code>/:仅短码形态代理,其余(/settings、/shorturl-detail)放行
|
||||
location ~ ^/s/[^/]+/?$ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
}
|
||||
|
||||
# Django admin / swagger / redoc / i18n / 测试页
|
||||
location /admin/ { proxy_pass http://backend:8000; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; client_max_body_size 50M; }
|
||||
location /swagger { proxy_pass http://backend:8000; proxy_set_header Host $host; }
|
||||
@@ -66,8 +228,23 @@ server {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
}
|
||||
|
||||
# C-05 SEO 预渲染优先:蜘蛛/首屏直接拿到含正文的静态 HTML,
|
||||
# React hydrate 后照常接管交互(预渲染页内含 location.replace 跳转到 SPA 路由)。
|
||||
# 首页用 index.seo.html(不覆盖 SPA 外壳 index.html);工具页/聚合页为目录 index.html。
|
||||
location = / {
|
||||
try_files /index.seo.html /index.html;
|
||||
}
|
||||
location ~ ^/(utility/[a-z-]+|qrcode-generator|color-picker|baidu-translate|utility|top)/?$ {
|
||||
try_files $uri $uri/index.html $uri/ /index.html;
|
||||
add_header Cache-Control "public, max-age=3600";
|
||||
}
|
||||
|
||||
# SPA 路由回退(放在最后,避免拦截上面的 API 请求)
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location @spa_fallback {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env node
|
||||
/* C-01 路由回归脚本:73 条 SPA 路由直访检查。
|
||||
* 检测三件套:① Django 404/JSON 劫持 ② #root 空(白屏)③ 正文长度。
|
||||
* 用法:node scripts/check_routes.mjs --base http://127.0.0.1:5173
|
||||
* 期望:PASS 73/73,hijacked=0,blank=0
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const baseIdx = args.indexOf('--base')
|
||||
const BASE = baseIdx >= 0 && args[baseIdx + 1] ? args[baseIdx + 1].replace(/\/$/, '') : 'http://127.0.0.1:5173'
|
||||
|
||||
// 与 src/App.tsx 的 <Route path> 一一对应(不含 * 兜底)
|
||||
const ROUTES = [
|
||||
'/', '/settings', '/history', '/favorites',
|
||||
'/open-api-docs', '/open-api-directory',
|
||||
'/profile', '/profile/email', '/profile/phone', '/profile/change-password',
|
||||
'/use', '/document', '/open-api', '/utility', '/learn', '/articles', '/bug',
|
||||
'/search', '/messages', '/chat',
|
||||
'/open-api-detail/1', '/tool-detail', '/course-learn', '/post/1',
|
||||
'/privacy', '/agreement', '/manual', '/system-message',
|
||||
'/user-home/1', '/user-home', '/console',
|
||||
'/article-manage', '/bug-detail', '/article-editor', '/learn-manage', '/learn-editor',
|
||||
'/baidu-translate',
|
||||
'/wallet/points', '/wallet/coins', '/wallet/coins/recharge', '/task-center', '/wallet/invite',
|
||||
'/invite/abc123', '/forgot-password', '/register',
|
||||
'/utility/json-formatter', '/utility/base64', '/utility/timestamp', '/utility/regex',
|
||||
'/utility/code-runner', '/utility/text-diff', '/utility/encoding-converter',
|
||||
'/utility/charset-converter', '/utility/color-converter', '/utility/code-formatter',
|
||||
'/color-picker', '/qrcode-generator', '/utility/image-compressor', '/utility/calculator',
|
||||
'/utility/date-calculator', '/utility/image-editor', '/changelog',
|
||||
'/ip-location', '/currency-detail',
|
||||
'/open-api-detail/currency', '/open-api-detail/baidu-translate',
|
||||
'/open-api/air-quality-details', '/open-api/weather-details',
|
||||
'/open-api/qrcode-generator', '/open-api/ip-location', '/open-api/password-generator',
|
||||
'/shorturl-detail',
|
||||
// 旧路径重定向(应返回 SPA,由前端 <Navigate> 承接,不应被代理劫持)
|
||||
'/api-docs', '/api-directory', '/api', '/api-detail/1',
|
||||
'/api-detail/currency', '/api-detail/baidu-translate',
|
||||
'/api/air-quality-details', '/api/weather-details',
|
||||
'/api/qrcode-generator', '/api/ip-location', '/api/password-generator',
|
||||
'/article/1', '/user', '/user/1',
|
||||
]
|
||||
|
||||
function isHijacked(status, text) {
|
||||
if (status === 404 && /Django|Not Found|Page not found/i.test(text)) return true
|
||||
const t = text.trim()
|
||||
// 后端 JSON 特征:{"code":...} / {"detail":...} 且无前端根节点
|
||||
if (/^\s*\{[\s\S]*"(code|detail|message)"[\s\S]*\}\s*$/.test(t.slice(0, 2000)) && !text.includes('id="root"')) return true
|
||||
return false
|
||||
}
|
||||
|
||||
// dev 模式下 index.html 的 #root 恒为空(客户端水合前),
|
||||
// 因此"白屏"只能断言"是否返回了 SPA 外壳":含 id="root" 即算 SPA 到达。
|
||||
// 真正的渲染白屏由 Playwright 抽查覆盖(见 PROGRESS)。
|
||||
function isSpaShell(text) {
|
||||
return text.includes('id="root"')
|
||||
}
|
||||
|
||||
let pass = 0, hijacked = 0, blank = 0
|
||||
const bad = []
|
||||
for (const r of ROUTES) {
|
||||
const url = BASE + r
|
||||
try {
|
||||
const res = await fetch(url, { redirect: 'manual' })
|
||||
const text = await res.text()
|
||||
const hj = isHijacked(res.status, text)
|
||||
const bl = !hj && !isSpaShell(text)
|
||||
if (hj) hijacked++
|
||||
if (bl) blank++
|
||||
if (!hj && !bl) { pass++; console.log(`ok ${r}`) }
|
||||
else { bad.push(r); console.log(`${hj ? 'HIJACKED' : 'BLANK '} ${r} (status=${res.status} len=${text.length})`) }
|
||||
} catch (e) {
|
||||
bad.push(r)
|
||||
console.log(`ERROR ${r} (${e.message})`)
|
||||
}
|
||||
}
|
||||
console.log('---')
|
||||
console.log(`ROUTES=${ROUTES.length} PASS=${pass} hijacked=${hijacked} blank=${blank}`)
|
||||
if (bad.length) { console.log('BAD:'); bad.forEach((r) => console.log(' - ' + r)) }
|
||||
process.exit(pass === ROUTES.length ? 0 : 1)
|
||||
+50
-15
@@ -1,6 +1,6 @@
|
||||
import "./App.css";
|
||||
|
||||
import { Route, Routes } from "react-router-dom";
|
||||
import { Navigate, Route, Routes, useParams } from "react-router-dom";
|
||||
|
||||
import { lazy, Suspense, useEffect } from "react";
|
||||
import { Spin, App as AntdApp } from "antd";
|
||||
@@ -41,6 +41,9 @@ const ApiDetail = lazy(() => import("@/pages/ApiDetail/ApiDetail"));
|
||||
const ApiDetailMobile = lazy(() => import("@/pages/ApiDetail/ApiDetailMobile"));
|
||||
const ToolDetail = lazy(() => import("@/pages/ToolDetail/ToolDetail"));
|
||||
const CourseLearn = lazy(() => import("@/pages/CourseLearn/CourseLearn"));
|
||||
const CodeRunnerPage = lazy(() => import("@/pages/CodeRunnerPage/CodeRunnerPage"));
|
||||
const ImageUpscaler = lazy(() => import("@/pages/ImageUpscaler/ImageUpscaler"));
|
||||
const Top = lazy(() => import("@/pages/Top/Top"));
|
||||
const ArticleDetail = lazy(() => import("@/pages/ArticleDetail/ArticleDetail"));
|
||||
const Privacy = lazy(() => import("@/pages/Privacy/Privacy"));
|
||||
const UserAgreement = lazy(() => import("@/pages/UserAgreement/UserAgreement"));
|
||||
@@ -117,6 +120,20 @@ const PageLoading = () => (
|
||||
</div>
|
||||
);
|
||||
|
||||
// C-01 旧路径重定向:保留已分享链接可用(带参透传)
|
||||
function LegacyApiDetailRedirect() {
|
||||
const { id } = useParams();
|
||||
return <Navigate to={`/open-api-detail/${id ?? ""}`} replace />;
|
||||
}
|
||||
function LegacyPostRedirect() {
|
||||
const { id } = useParams();
|
||||
return <Navigate to={`/post/${id ?? ""}`} replace />;
|
||||
}
|
||||
function LegacyUserHomeRedirect() {
|
||||
const { id } = useParams();
|
||||
return <Navigate to={`/user-home/${id ?? ""}`} replace />;
|
||||
}
|
||||
|
||||
const App: React.FC = () => {
|
||||
const isMobile = useIsMobile();
|
||||
const { message } = AntdApp.useApp();
|
||||
@@ -152,15 +169,15 @@ const App: React.FC = () => {
|
||||
<Route path="/settings" element={<MobileGuard><Settings /></MobileGuard>} />
|
||||
<Route path="/history" element={<History />} />
|
||||
<Route path="/favorites" element={<AuthGuard><Favorites /></AuthGuard>} />
|
||||
<Route path="/api-docs" element={<MobileGuard><ApiDocs /></MobileGuard>} />
|
||||
<Route path="/api-directory" element={<MobileGuard><ApiDirectory /></MobileGuard>} />
|
||||
<Route path="/open-api-docs" element={<MobileGuard><ApiDocs /></MobileGuard>} />
|
||||
<Route path="/open-api-directory" element={<MobileGuard><ApiDirectory /></MobileGuard>} />
|
||||
<Route path="/profile" element={<MobileGuard><Profile /></MobileGuard>} />
|
||||
<Route path="/profile/email" element={<MobileGuard><ProfileEmail /></MobileGuard>} />
|
||||
<Route path="/profile/phone" element={<MobileGuard><ProfilePhone /></MobileGuard>} />
|
||||
<Route path="/profile/change-password" element={<MobileGuard><ProfileChangePassword /></MobileGuard>} />
|
||||
<Route path="/use" element={<MobileGuard><Use /></MobileGuard>} />
|
||||
<Route path="/document" element={<MobileGuard><Document /></MobileGuard>} />
|
||||
<Route path="/api" element={isMobile ? <ApiDirectoryMobile /> : <ApiDirectoryDesktop />} />
|
||||
<Route path="/open-api" element={isMobile ? <ApiDirectoryMobile /> : <ApiDirectoryDesktop />} />
|
||||
<Route path="/utility" element={isMobile ? <UtilityMobile /> : <Utility />} />
|
||||
<Route path="/learn" element={isMobile ? <LearnMobile /> : <Learn />} />
|
||||
<Route path="/articles" element={isMobile ? <ArticlesMobile /> : <Articles />} />
|
||||
@@ -168,16 +185,16 @@ const App: React.FC = () => {
|
||||
<Route path="/search" element={<Search />} />
|
||||
<Route path="/messages" element={isMobile ? <MessagesMobile /> : <Messages />} />
|
||||
<Route path="/chat" element={isMobile ? <ChatMobile /> : <Chat />} />
|
||||
<Route path="/api-detail/:id" element={isMobile ? <ApiDetailMobile /> : <ApiDetail />} />
|
||||
<Route path="/open-api-detail/:id" element={isMobile ? <ApiDetailMobile /> : <ApiDetail />} />
|
||||
<Route path="/tool-detail" element={<ToolDetail />} />
|
||||
<Route path="/course-learn" element={<CourseLearn />} />
|
||||
<Route path="/article/:id" element={<ArticleDetail />} />
|
||||
<Route path="/post/:id" element={<ArticleDetail />} />
|
||||
<Route path="/privacy" element={<Privacy />} />
|
||||
<Route path="/agreement" element={isMobile ? <UserAgreementMobile /> : <UserAgreement />} />
|
||||
<Route path="/manual" element={<Manual />} />
|
||||
<Route path="/system-message" element={<SystemMessage />} />
|
||||
<Route path="/user/:id" element={<UserHome />} />
|
||||
<Route path="/user" element={<UserHome />} />
|
||||
<Route path="/user-home/:id" element={<UserHome />} />
|
||||
<Route path="/user-home" element={<UserHome />} />
|
||||
<Route path="/console" element={<AuthGuard><Console /></AuthGuard>} />
|
||||
<Route path="/article-manage" element={<AuthGuard><ArticleManage /></AuthGuard>} />
|
||||
<Route path="/bug-detail" element={<AuthGuard><BugDetail /></AuthGuard>} />
|
||||
@@ -197,6 +214,9 @@ const App: React.FC = () => {
|
||||
<Route path="/utility/base64" element={isMobile ? <Base64ToolMobile /> : <Base64Tool />} />
|
||||
<Route path="/utility/timestamp" element={isMobile ? <TimestampToolMobile /> : <TimestampTool />} />
|
||||
<Route path="/utility/regex" element={isMobile ? <RegexToolMobile /> : <RegexTool />} />
|
||||
<Route path="/utility/code-runner" element={<CodeRunnerPage />} />
|
||||
<Route path="/utility/image-upscaler" element={<ImageUpscaler />} />
|
||||
<Route path="/top" element={<Top />} />
|
||||
<Route path="/utility/text-diff" element={isMobile ? <TextDiffMobile /> : <TextDiff />} />
|
||||
<Route path="/utility/encoding-converter" element={isMobile ? <EncodingConverterMobile /> : <EncodingConverter />} />
|
||||
<Route path="/utility/charset-converter" element={isMobile ? <CharsetConverterMobile /> : <CharsetConverter />} />
|
||||
@@ -211,14 +231,29 @@ const App: React.FC = () => {
|
||||
<Route path="/changelog" element={isMobile ? <ChangelogMobile /> : <Changelog />} />
|
||||
<Route path="/ip-location" element={<IPLocation />} />
|
||||
<Route path="/currency-detail" element={<CurrencyDetail />} />
|
||||
<Route path="/api-detail/currency" element={<CurrencyDetail />} />
|
||||
<Route path="/api-detail/baidu-translate" element={<BaiduTranslateDetail />} />
|
||||
<Route path="/api/air-quality-details" element={isMobile ? <AQIDetailsMobile /> : <AQIDetails />} />
|
||||
<Route path="/api/weather-details" element={isMobile ? <WeatherDetailMobile /> : <WeatherDetail />} />
|
||||
<Route path="/api/qrcode-generator" element={<QRCodeGeneratorApiDetail />} />
|
||||
<Route path="/api/ip-location" element={<IPLocationApiDetail />} />
|
||||
<Route path="/api/password-generator" element={<PasswordGeneratorApiDetail />} />
|
||||
<Route path="/open-api-detail/currency" element={<CurrencyDetail />} />
|
||||
<Route path="/open-api-detail/baidu-translate" element={<BaiduTranslateDetail />} />
|
||||
<Route path="/open-api/air-quality-details" element={isMobile ? <AQIDetailsMobile /> : <AQIDetails />} />
|
||||
<Route path="/open-api/weather-details" element={isMobile ? <WeatherDetailMobile /> : <WeatherDetail />} />
|
||||
<Route path="/open-api/qrcode-generator" element={<QRCodeGeneratorApiDetail />} />
|
||||
<Route path="/open-api/ip-location" element={<IPLocationApiDetail />} />
|
||||
<Route path="/open-api/password-generator" element={<PasswordGeneratorApiDetail />} />
|
||||
<Route path="/shorturl-detail" element={isMobile ? <ShortUrlDetailMobile /> : <ShortUrlDetail />} />
|
||||
{/* C-01 旧路径重定向:已分享出去的链接不能全死 */}
|
||||
<Route path="/api-docs" element={<Navigate to="/open-api-docs" replace />} />
|
||||
<Route path="/api-directory" element={<Navigate to="/open-api-directory" replace />} />
|
||||
<Route path="/api" element={<Navigate to="/open-api" replace />} />
|
||||
<Route path="/api-detail/:id" element={<LegacyApiDetailRedirect />} />
|
||||
<Route path="/api-detail/currency" element={<Navigate to="/open-api-detail/currency" replace />} />
|
||||
<Route path="/api-detail/baidu-translate" element={<Navigate to="/open-api-detail/baidu-translate" replace />} />
|
||||
<Route path="/api/air-quality-details" element={<Navigate to="/open-api/air-quality-details" replace />} />
|
||||
<Route path="/api/weather-details" element={<Navigate to="/open-api/weather-details" replace />} />
|
||||
<Route path="/api/qrcode-generator" element={<Navigate to="/open-api/qrcode-generator" replace />} />
|
||||
<Route path="/api/ip-location" element={<Navigate to="/open-api/ip-location" replace />} />
|
||||
<Route path="/api/password-generator" element={<Navigate to="/open-api/password-generator" replace />} />
|
||||
<Route path="/article/:id" element={<LegacyPostRedirect />} />
|
||||
<Route path="/user" element={<Navigate to="/user-home" replace />} />
|
||||
<Route path="/user/:id" element={<LegacyUserHomeRedirect />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
|
||||
@@ -18,7 +18,7 @@ const MobileBottomNav: React.FC = () => {
|
||||
|
||||
const tabs = [
|
||||
{ key: "home", icon: <HomeOutlined />, label: t("nav.home"), path: "/" },
|
||||
{ key: "api", icon: <ApiOutlined />, label: t("nav.tools"), path: "/api" },
|
||||
{ key: "api", icon: <ApiOutlined />, label: t("nav.tools"), path: "/open-api" },
|
||||
{ key: "utility", icon: <ToolOutlined />, label: t("nav.utility"), path: "/utility" },
|
||||
{ key: "articles", icon: <ReadOutlined />, label: t("nav.learn"), path: "/learn" },
|
||||
{ key: "profile", icon: <UserOutlined />, label: t("nav.bug"), path: "/bug" },
|
||||
@@ -28,7 +28,7 @@ const MobileBottomNav: React.FC = () => {
|
||||
const path = location.pathname;
|
||||
if (path === "/") {
|
||||
setActiveTab("home");
|
||||
} else if (path.startsWith("/api")) {
|
||||
} else if (path.startsWith("/open-api")) {
|
||||
setActiveTab("api");
|
||||
} else if (path.startsWith("/utility")) {
|
||||
setActiveTab("utility");
|
||||
|
||||
@@ -10,7 +10,7 @@ import "./MobileGuard.css";
|
||||
|
||||
const MOBILE_ROUTES: Record<string, string> = {
|
||||
"/": "/",
|
||||
"/api": "/api",
|
||||
"/open-api": "/open-api",
|
||||
"/utility": "/utility",
|
||||
"/learn": "/learn",
|
||||
"/articles": "/articles",
|
||||
|
||||
@@ -100,7 +100,7 @@ const MobileTopNav: React.FC = () => {
|
||||
openLoginModal();
|
||||
return;
|
||||
}
|
||||
navigate("/user");
|
||||
navigate("/user-home");
|
||||
}}
|
||||
>
|
||||
{isLogin ? (
|
||||
|
||||
@@ -91,7 +91,7 @@ const Navbar: React.FC = () => {
|
||||
const path = location.pathname;
|
||||
const routeMap: Record<string, string> = {
|
||||
"/": "home",
|
||||
"/api": "api",
|
||||
"/open-api": "api",
|
||||
"/utility": "utility",
|
||||
"/learn": "learn",
|
||||
"/articles": "articles",
|
||||
@@ -101,13 +101,13 @@ const Navbar: React.FC = () => {
|
||||
};
|
||||
if (routeMap[path]) {
|
||||
setCurrentMenuItem(routeMap[path]);
|
||||
} else if (path.startsWith("/api/") || path.startsWith("/api-detail")) {
|
||||
} else if (path.startsWith("/open-api/") || path.startsWith("/open-api-detail")) {
|
||||
setCurrentMenuItem("api");
|
||||
} else if (path.startsWith("/utility/") || path.startsWith("/tool-detail")) {
|
||||
setCurrentMenuItem("utility");
|
||||
} else if (path.startsWith("/learn/") || path.startsWith("/course-learn")) {
|
||||
setCurrentMenuItem("learn");
|
||||
} else if (path.startsWith("/articles/") || path.startsWith("/article/")) {
|
||||
} else if (path.startsWith("/articles/") || path.startsWith("/post/")) {
|
||||
setCurrentMenuItem("articles");
|
||||
} else if (path.startsWith("/bug") || path.startsWith("/bug-detail")) {
|
||||
setCurrentMenuItem("bug");
|
||||
@@ -437,7 +437,7 @@ const Navbar: React.FC = () => {
|
||||
setCurrentMenuItem(e.key);
|
||||
}
|
||||
if (e.key === "home") navigation("/");
|
||||
if (e.key === "api") navigation("/api");
|
||||
if (e.key === "api") navigation("/open-api");
|
||||
if (e.key === "utility") navigation("/utility");
|
||||
if (e.key === "learn") navigation("/learn");
|
||||
if (e.key === "articles") navigation("/articles");
|
||||
@@ -525,7 +525,7 @@ const Navbar: React.FC = () => {
|
||||
dispatch(logout());
|
||||
navigation("/");
|
||||
} else if (info.key === "userhome") {
|
||||
navigation("/user");
|
||||
navigation("/user-home");
|
||||
} else if (info.key === "console-articles") {
|
||||
navigation("/article-manage");
|
||||
} else if (info.key === "wallet-points") {
|
||||
@@ -552,12 +552,12 @@ const Navbar: React.FC = () => {
|
||||
size={40}
|
||||
className="navbar-avatar"
|
||||
icon={<UserOutlined />}
|
||||
onClick={() => navigation("/user")}
|
||||
onClick={() => navigation("/user-home")}
|
||||
style={{ cursor: "pointer" }}
|
||||
/>
|
||||
<span
|
||||
className="username-text"
|
||||
onClick={() => navigation("/user")}
|
||||
onClick={() => navigation("/user-home")}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
{user?.username || t("nav.user")}
|
||||
|
||||
@@ -193,26 +193,26 @@ const ApiDirectory: React.FC = () => {
|
||||
const getApiRoute = (item: ApiItem): string | null => {
|
||||
const pathMap: Record<string, string> = {
|
||||
// 天气查询
|
||||
"/api/weather": "/api/weather-details",
|
||||
"/api/weather": "/open-api/weather-details",
|
||||
// IP地址定位
|
||||
"/api/getIpData/": "/api/ip-location",
|
||||
"/api/getIpData/": "/open-api/ip-location",
|
||||
// 空气质量
|
||||
"/api/air-quality/": "/api/air-quality-details",
|
||||
"/api/air-quality/": "/open-api/air-quality-details",
|
||||
// 汇率相关
|
||||
"/api/currency/rates/": "/api-detail/currency",
|
||||
"/api/currency/currencies/": "/api-detail/currency",
|
||||
"/api/currency/convert/": "/api-detail/currency",
|
||||
"/api/currency/rates/": "/open-api-detail/currency",
|
||||
"/api/currency/currencies/": "/open-api-detail/currency",
|
||||
"/api/currency/convert/": "/open-api-detail/currency",
|
||||
// 二维码生成
|
||||
"/api/qrcode-generator/": "/api/qrcode-generator",
|
||||
"/api/qrcode-generator/": "/open-api/qrcode-generator",
|
||||
// 密码生成器
|
||||
"/api/password-generator/": "/api/password-generator",
|
||||
"/api/password-generator/": "/open-api/password-generator",
|
||||
// 工具类
|
||||
"/api/tool/text-diff": "/utility/text-diff",
|
||||
"/api/tool/image-compressor": "/utility/image-compressor",
|
||||
// 短链接
|
||||
"/api/shorturl/shorten": "/shorturl-detail",
|
||||
// 百度翻译
|
||||
"/api/baiduFanyi": "/api-detail/baidu-translate",
|
||||
"/api/baiduFanyi": "/open-api-detail/baidu-translate",
|
||||
};
|
||||
return pathMap[item.url_path] || null;
|
||||
};
|
||||
|
||||
@@ -76,13 +76,13 @@ const ApiDirectoryDesktop: React.FC = () => {
|
||||
description: api.description,
|
||||
image: getApiImageUrl(api),
|
||||
category: api.category_name || 'API接口',
|
||||
link: `/api-detail/${api.id}`,
|
||||
link: `/open-api-detail/${api.id}`,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('记录浏览历史失败:', err);
|
||||
}
|
||||
}
|
||||
navigate(`/api-detail/${api.id}`);
|
||||
navigate(`/open-api-detail/${api.id}`);
|
||||
};
|
||||
|
||||
const handleFavoriteToggle = async (apiId: number, e: React.MouseEvent) => {
|
||||
|
||||
@@ -112,13 +112,13 @@ const ApiDirectoryMobile: React.FC = () => {
|
||||
description: api.description,
|
||||
image: getApiImageUrl(api),
|
||||
category: api.category_name || 'API接口',
|
||||
link: `/api-detail/${api.id}`,
|
||||
link: `/open-api-detail/${api.id}`,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('记录浏览历史失败:', err);
|
||||
}
|
||||
}
|
||||
navigate(`/api-detail/${api.id}`);
|
||||
navigate(`/open-api-detail/${api.id}`);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
|
||||
@@ -78,7 +78,7 @@ const ArticleDetail: React.FC = () => {
|
||||
description: article.excerpt || "",
|
||||
image: article.image || "",
|
||||
category: article.category || "",
|
||||
link: `/article/${id}`,
|
||||
link: `/post/${id}`,
|
||||
} : null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -333,12 +333,12 @@ const ArticleDetail: React.FC = () => {
|
||||
src={article.author_avatar}
|
||||
size={32}
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => navigate(`/user/${article.author_user_id}`)}
|
||||
onClick={() => navigate(`/user-home/${article.author_user_id}`)}
|
||||
/>
|
||||
<span
|
||||
className="author-name"
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => navigate(`/user/${article.author_user_id}`)}
|
||||
onClick={() => navigate(`/user-home/${article.author_user_id}`)}
|
||||
>
|
||||
{article.author_name}
|
||||
</span>
|
||||
@@ -419,7 +419,7 @@ const ArticleDetail: React.FC = () => {
|
||||
<div
|
||||
key={related.id}
|
||||
className="related-article-card"
|
||||
onClick={() => navigate(`/article/${related.id}`)}
|
||||
onClick={() => navigate(`/post/${related.id}`)}
|
||||
>
|
||||
<div className="related-article-cover">
|
||||
<img src={related.image} alt={related.title} />
|
||||
@@ -444,14 +444,14 @@ const ArticleDetail: React.FC = () => {
|
||||
src={article.author_avatar}
|
||||
size={64}
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => navigate(`/user/${article.author_user_id}`)}
|
||||
onClick={() => navigate(`/user-home/${article.author_user_id}`)}
|
||||
/>
|
||||
</div>
|
||||
<div className="author-card-info">
|
||||
<div
|
||||
className="author-card-name"
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => navigate(`/user/${article.author_user_id}`)}
|
||||
onClick={() => navigate(`/user-home/${article.author_user_id}`)}
|
||||
>
|
||||
{article.author_name}
|
||||
</div>
|
||||
@@ -514,14 +514,14 @@ const ArticleDetail: React.FC = () => {
|
||||
src={comment.user_avatar}
|
||||
size={40}
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => comment.user_id && navigate(`/user/${comment.user_id}`)}
|
||||
onClick={() => comment.user_id && navigate(`/user-home/${comment.user_id}`)}
|
||||
/>
|
||||
<div className="comment-content">
|
||||
<div className="comment-header">
|
||||
<span
|
||||
className="comment-author"
|
||||
style={{ cursor: comment.user_id ? "pointer" : "default" }}
|
||||
onClick={() => comment.user_id && navigate(`/user/${comment.user_id}`)}
|
||||
onClick={() => comment.user_id && navigate(`/user-home/${comment.user_id}`)}
|
||||
>
|
||||
{comment.user_name}
|
||||
</span>
|
||||
@@ -554,14 +554,14 @@ const ArticleDetail: React.FC = () => {
|
||||
src={reply.user_avatar}
|
||||
size={32}
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => reply.user_id && navigate(`/user/${reply.user_id}`)}
|
||||
onClick={() => reply.user_id && navigate(`/user-home/${reply.user_id}`)}
|
||||
/>
|
||||
<div className="reply-content">
|
||||
<div className="reply-header">
|
||||
<span
|
||||
className="reply-author"
|
||||
style={{ cursor: reply.user_id ? "pointer" : "default" }}
|
||||
onClick={() => reply.user_id && navigate(`/user/${reply.user_id}`)}
|
||||
onClick={() => reply.user_id && navigate(`/user-home/${reply.user_id}`)}
|
||||
>
|
||||
{reply.user_name}
|
||||
</span>
|
||||
|
||||
@@ -215,7 +215,7 @@ const ArticleManage: React.FC = () => {
|
||||
],
|
||||
onClick: ({ key }: { key: string }) => {
|
||||
switch (key) {
|
||||
case "view": navigate(`/article/${record.id}`); break;
|
||||
case "view": navigate(`/post/${record.id}`); break;
|
||||
case "edit": navigate(`/article-editor?id=${record.id}`); break;
|
||||
case "copy":
|
||||
navigator.clipboard?.writeText(`${window.location.origin}/article/${record.id}`);
|
||||
@@ -239,7 +239,7 @@ const ArticleManage: React.FC = () => {
|
||||
<div className="am-title-cell">
|
||||
<div className="am-title-row">
|
||||
{record.isTop && <Tag color="red" className="am-mini-tag">{t('articleManage.topBadge')}</Tag>}
|
||||
<a className="am-title-link" onClick={() => navigate(`/article/${record.id}`)}>
|
||||
<a className="am-title-link" onClick={() => navigate(`/post/${record.id}`)}>
|
||||
{text}
|
||||
</a>
|
||||
</div>
|
||||
@@ -297,7 +297,7 @@ const ArticleManage: React.FC = () => {
|
||||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => navigate(`/article-editor?id=${record.id}`)} />
|
||||
</Tooltip>
|
||||
<Tooltip title={t('articleManage.tooltips.view')}>
|
||||
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => navigate(`/article/${record.id}`)} />
|
||||
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => navigate(`/post/${record.id}`)} />
|
||||
</Tooltip>
|
||||
<Dropdown menu={getMoreActions(record)} trigger={["click"]}>
|
||||
<Button type="link" size="small" icon={<MoreOutlined />} onClick={(e) => e.stopPropagation()} />
|
||||
|
||||
@@ -1008,7 +1008,7 @@ const Chat: React.FC = () => {
|
||||
<div className="chat-header-popover">
|
||||
<div className="popover-item" onClick={() => {
|
||||
if (selectedConv) {
|
||||
navigate(`/user/${friends.find(f => f.nickname === selectedConv.name)?.userId || 0}`);
|
||||
navigate(`/user-home/${friends.find(f => f.nickname === selectedConv.name)?.userId || 0}`);
|
||||
}
|
||||
}}>
|
||||
<UserOutlined /> {t('chat.viewProfile')}
|
||||
|
||||
@@ -50,7 +50,7 @@ const Console: React.FC = () => {
|
||||
};
|
||||
|
||||
const articleColumns = [
|
||||
{ title: "文章标题", dataIndex: "title", key: "title", render: (text: string, record: ArticleRecord) => <a onClick={() => navigate(`/article/${record.id}`)}>{text}</a> },
|
||||
{ title: "文章标题", dataIndex: "title", key: "title", render: (text: string, record: ArticleRecord) => <a onClick={() => navigate(`/post/${record.id}`)}>{text}</a> },
|
||||
{ title: "分类", dataIndex: "category", key: "category" },
|
||||
{ title: "状态", dataIndex: "status", key: "status", render: (status: string) => statusTag(status) },
|
||||
{ title: "浏览", dataIndex: "views", key: "views" },
|
||||
@@ -62,7 +62,7 @@ const Console: React.FC = () => {
|
||||
key: "action",
|
||||
render: (_: unknown, record: ArticleRecord) => (
|
||||
<Space size="small">
|
||||
<Tooltip title="查看"><Button type="link" icon={<EyeOutlined />} size="small" onClick={() => navigate(`/article/${record.id}`)} /></Tooltip>
|
||||
<Tooltip title="查看"><Button type="link" icon={<EyeOutlined />} size="small" onClick={() => navigate(`/post/${record.id}`)} /></Tooltip>
|
||||
<Tooltip title="编辑"><Button type="link" icon={<EditOutlined />} size="small" /></Tooltip>
|
||||
<Popconfirm title="确定删除?" onConfirm={() => message.success("删除成功")}>
|
||||
<Button type="link" danger icon={<DeleteOutlined />} size="small" />
|
||||
|
||||
@@ -130,11 +130,11 @@ const Favorites: React.FC = () => {
|
||||
if (item.type === "tool") {
|
||||
navigate(item.url_path);
|
||||
} else if (item.type === "article") {
|
||||
navigate(`/article/${item.id}`);
|
||||
navigate(`/post/${item.id}`);
|
||||
} else if (item.type === "course") {
|
||||
navigate(`/course-learn?id=${item.id}`);
|
||||
} else if (item.type === "api") {
|
||||
navigate(`/api-detail/${item.id}`);
|
||||
navigate(`/open-api-detail/${item.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -305,7 +305,7 @@ const UserHome: React.FC = () => {
|
||||
<div
|
||||
key={article.id}
|
||||
className="userhome-article-card"
|
||||
onClick={() => navigate(`/article/${article.id}`)}
|
||||
onClick={() => navigate(`/post/${article.id}`)}
|
||||
>
|
||||
<div className="userhome-article-cover">
|
||||
{article.cover_image ? (
|
||||
@@ -404,7 +404,7 @@ const UserHome: React.FC = () => {
|
||||
className="userhome-favorite-card"
|
||||
onClick={() => {
|
||||
if (item.type === 'tool') navigate(item.url_path);
|
||||
else if (item.type === 'article') navigate(`/article/${item.id}`);
|
||||
else if (item.type === 'article') navigate(`/post/${item.id}`);
|
||||
else if (item.type === 'course') navigate(`/course-learn?id=${item.id}`);
|
||||
}}
|
||||
>
|
||||
@@ -819,7 +819,7 @@ const UserHome: React.FC = () => {
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
if (item.type === 'tool') navigate(item.url_path);
|
||||
else if (item.type === 'article') navigate(`/article/${item.id}`);
|
||||
else if (item.type === 'article') navigate(`/post/${item.id}`);
|
||||
else if (item.type === 'course') navigate(`/course-learn?id=${item.id}`);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// C-01 回归:新路由直访 + 旧链接重定向 + SPA 内部导航(对准 5174 实测端口)
|
||||
const BASE = process.env.PLAYWRIGHT_BASE_URL || 'http://127.0.0.1:5174';
|
||||
|
||||
test('C-01 新路由直访返回 SPA(open-api/post/user-home)', async ({ page }) => {
|
||||
for (const r of ['/open-api', '/open-api-detail/1', '/post/1', '/user-home/1', '/articles', '/search']) {
|
||||
const resp = await page.goto(BASE + r, { waitUntil: 'domcontentloaded' });
|
||||
expect(resp?.status(), r).toBe(200);
|
||||
await expect(page.locator('#root')).toBeAttached();
|
||||
const body = await page.content();
|
||||
expect(body.includes('Django') && body.includes('Not Found'), r).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('C-01 旧分享链接客户端重定向存活', async ({ page }) => {
|
||||
await page.goto(BASE + '/article/1', { waitUntil: 'domcontentloaded' });
|
||||
await expect(page).toHaveURL(/\/post\/1/, { timeout: 8000 });
|
||||
await page.goto(BASE + '/user/1', { waitUntil: 'domcontentloaded' });
|
||||
await expect(page).toHaveURL(/\/user-home\/1/, { timeout: 8000 });
|
||||
await page.goto(BASE + '/api-detail/1', { waitUntil: 'domcontentloaded' });
|
||||
await expect(page).toHaveURL(/\/open-api-detail\/1/, { timeout: 8000 });
|
||||
});
|
||||
|
||||
test('C-01 API 代理回归(经 vite 直调后端)', async ({ request }) => {
|
||||
const a = await request.get(BASE + '/article/articles/');
|
||||
expect(a.status()).toBe(200);
|
||||
const b = await request.get(BASE + '/api/apidirectory/categories/');
|
||||
expect(b.status()).toBe(200);
|
||||
const c = await request.get(BASE + '/search/hot-keywords/');
|
||||
expect(c.status()).toBe(200);
|
||||
});
|
||||
+152
-75
@@ -1,7 +1,156 @@
|
||||
import { defineConfig } from 'vite'
|
||||
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: {
|
||||
@@ -13,79 +162,7 @@ export default defineConfig({
|
||||
'127.0.0.1',
|
||||
'192.168.1.4'
|
||||
],
|
||||
proxy: {
|
||||
'/ws': {
|
||||
target: 'ws://127.0.0.1:8002',
|
||||
ws: true,
|
||||
rewrite: (path) => path.replace(/^\/ws/, ''),
|
||||
},
|
||||
'/media/': {
|
||||
target: 'http://127.0.0.1:8002',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8002',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/user': {
|
||||
target: 'http://127.0.0.1:8002',
|
||||
changeOrigin: true,
|
||||
bypass: (req) => {
|
||||
if (req.url?.includes('.')) return req.url;
|
||||
},
|
||||
},
|
||||
'/bug': {
|
||||
target: 'http://127.0.0.1:8002',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/article': {
|
||||
target: 'http://127.0.0.1:8002',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/learn': {
|
||||
target: 'http://127.0.0.1:8002',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/history': {
|
||||
target: 'http://127.0.0.1:8002',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/message': {
|
||||
target: 'http://127.0.0.1:8002',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/chat': {
|
||||
target: 'http://127.0.0.1:8002',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/search': {
|
||||
target: 'http://127.0.0.1:8002',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/app': {
|
||||
target: 'http://127.0.0.1:8002',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/tool': {
|
||||
target: 'http://127.0.0.1:8002',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/logs': {
|
||||
target: 'http://127.0.0.1:8002',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/s': {
|
||||
target: 'http://127.0.0.1:8002',
|
||||
changeOrigin: true,
|
||||
bypass: (req) => {
|
||||
if (req.url?.startsWith('/src') || req.url?.includes('.')) return req.url;
|
||||
},
|
||||
},
|
||||
'/i18n': {
|
||||
target: 'http://127.0.0.1:8002',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
proxy,
|
||||
},
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
@@ -93,4 +170,4 @@ export default defineConfig({
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user