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:
chunyu
2026-09-15 15:25:59 +08:00
parent 26feb3d277
commit 24f200bfa6
18 changed files with 539 additions and 137 deletions
+179 -2
View File
@@ -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;
}
}
+81
View File
@@ -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
View File
@@ -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");
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -100,7 +100,7 @@ const MobileTopNav: React.FC = () => {
openLoginModal();
return;
}
navigate("/user");
navigate("/user-home");
}}
>
{isLogin ? (
+7 -7
View File
@@ -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")}
+9 -9
View File
@@ -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) {
+10 -10
View File
@@ -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>
+3 -3
View File
@@ -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()} />
+1 -1
View File
@@ -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')}
+2 -2
View File
@@ -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" />
+2 -2
View File
@@ -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}`);
}
};
+3 -3
View File
@@ -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}`);
}}
>
+32
View File
@@ -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
View File
@@ -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'),
},
},
})
})