feat: 新增SPA构建脚本和配置更新

- 新增 build-spa.bat 和 build-spa.sh 脚本,简化SPA应用构建流程
- 更新 next.config.js,调整输出配置为静态导出,并添加尾部斜杠和未优化图像设置
- 修改 package.json,添加构建和服务命令
- 更新 CORS_SOLUTION.md,简化代理规则
- 删除不再使用的健康检查和代理API
- 优化 ESLint 配置,添加警告规则
- 更新 Dockerfile,使用 Node 24 版本
- 新增公共 HTML 模板和404页面组件
- 清理无用的 package-lock.json 文件
This commit is contained in:
danial
2025-07-27 14:35:12 +08:00
parent 43ffa3b215
commit a4b8fd43f8
19 changed files with 204 additions and 8794 deletions

View File

@@ -101,14 +101,9 @@ pnpm dev
async rewrites() {
return [
{
source: '/api/proxy/:path*',
source: ':path*',
destination: `${process.env.NEXT_PUBLIC_TARGET_API_URL}/:path*`,
},
// 添加更多代理规则
{
source: '/api/v2/:path*',
destination: `${process.env.NEXT_PUBLIC_V2_API_URL}/:path*`,
},
];
},
```

View File

@@ -1,4 +1,4 @@
FROM node:18-alpine AS base
FROM node:24-alpine AS base
# 安装依赖阶段
FROM base AS deps

41
frontend/build-spa.bat Normal file
View File

@@ -0,0 +1,41 @@
@echo off
chcp 65001 >nul
echo 🚀 开始构建SPA应用...
REM 清理之前的构建
echo 📁 清理之前的构建文件...
if exist .next rmdir /s /q .next
if exist out rmdir /s /q out
REM 安装依赖
echo 📦 安装依赖...
call npm install
REM 构建应用
echo 🔨 构建SPA应用...
call npm run build
REM 检查构建结果
if exist out (
echo ✅ 构建成功!
echo 📊 构建统计:
for /f %%i in ('dir /s /b out ^| find /c /v ""') do echo - 总文件数: %%i
for /f %%i in ('dir out /-c ^| find "个文件"') do echo - 总大小: %%i
echo.
echo 📁 构建输出目录: out/
echo 🌐 可以通过以下方式测试:
echo - 使用 npx serve out
echo - 或使用任何静态文件服务器
) else (
echo ❌ 构建失败!
exit /b 1
)
echo.
echo 🎯 SPA配置已应用
echo - 静态导出 (output: 'export')
echo - 尾部斜杠 (trailingSlash: true)
echo - 未优化图像 (unoptimized: true)
echo - 路由回退处理
pause

39
frontend/build-spa.sh Normal file
View File

@@ -0,0 +1,39 @@
#!/bin/bash
echo "🚀 开始构建SPA应用..."
# 清理之前的构建
echo "📁 清理之前的构建文件..."
rm -rf .next
rm -rf out
# 安装依赖
echo "📦 安装依赖..."
npm install
# 构建应用
echo "🔨 构建SPA应用..."
npm run build
# 检查构建结果
if [ -d "out" ]; then
echo "✅ 构建成功!"
echo "📊 构建统计:"
echo " - 总文件数: $(find out -type f | wc -l)"
echo " - 总大小: $(du -sh out | cut -f1)"
echo ""
echo "📁 构建输出目录: out/"
echo "🌐 可以通过以下方式测试:"
echo " - 使用 npx serve out"
echo " - 或使用任何静态文件服务器"
else
echo "❌ 构建失败!"
exit 1
fi
echo ""
echo "🎯 SPA配置已应用"
echo " - 静态导出 (output: 'export')"
echo " - 尾部斜杠 (trailingSlash: true)"
echo " - 未优化图像 (unoptimized: true)"
echo " - 路由回退处理"

View File

@@ -25,6 +25,9 @@ export default [
},
rules: {
...typescriptPlugin.configs.recommended.rules,
"@typescript-eslint/no-unused-vars": "warn",
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-empty-object-type": "warn",
},
},
];

View File

@@ -1,15 +1,19 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
// 输出独立可部署的应用
output: 'standalone',
output: 'export',
// 启用严格模式
reactStrictMode: true,
// SPA配置 - 确保所有路由都指向index.html
trailingSlash: true,
// 图像优化配置
images: {
domains: ['localhost'],
formats: ['image/avif', 'image/webp'],
unoptimized: true, // 静态导出需要禁用图像优化
},
// 环境变量
@@ -22,52 +26,6 @@ const nextConfig = {
// 压缩配置
compress: true,
// 代理配置 - 绕过CORS限制
async rewrites() {
return [
{
source: '/api/proxy/:path*',
destination: `${process.env.NEXT_PUBLIC_TARGET_API_URL || 'http://localhost:3001'}/:path*`,
},
];
},
// 跨域资源共享配置
async headers() {
return [
{
source: '/(.*)',
headers: [
{
key: 'X-Content-Type-Options',
value: 'nosniff',
},
{
key: 'X-XSS-Protection',
value: '1; mode=block',
},
{
key: 'X-Frame-Options',
value: 'SAMEORIGIN',
},
// 添加CORS头
{
key: 'Access-Control-Allow-Origin',
value: '*',
},
{
key: 'Access-Control-Allow-Methods',
value: 'GET, POST, PUT, DELETE, OPTIONS',
},
{
key: 'Access-Control-Allow-Headers',
value: 'Content-Type, Authorization, X-Requested-With',
},
],
},
];
},
};
module.exports = nextConfig;

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,9 @@
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"build:spa": "next build && echo 'SPA构建完成输出目录: out/'",
"start": "next start",
"serve": "npx serve out -p 3000",
"lint": "next lint"
},
"dependencies": {
@@ -13,6 +15,8 @@
"@headlessui/react": "^2.2.6",
"@hookform/resolvers": "^3.3.4",
"@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-scroll-area": "^1.2.9",
@@ -39,6 +43,7 @@
"zod": "^3.22.4"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",

View File

@@ -20,6 +20,12 @@ importers:
'@radix-ui/react-alert-dialog':
specifier: ^1.1.14
version: 1.1.14(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-checkbox':
specifier: ^1.3.2
version: 1.3.2(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-dialog':
specifier: ^1.1.14
version: 1.1.14(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-label':
specifier: ^2.1.7
version: 2.1.7(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -93,6 +99,9 @@ importers:
specifier: ^3.22.4
version: 3.25.76
devDependencies:
'@eslint/eslintrc':
specifier: ^3.3.1
version: 3.3.1
'@types/node':
specifier: ^20
version: 20.19.9

View File

@@ -0,0 +1,71 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="爬虫监控前端应用" />
<title>爬虫监控系统</title>
<style>
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#root {
min-height: 100vh;
}
.loading {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
flex-direction: column;
}
.spinner {
border: 4px solid #f3f3f3;
border-top: 4px solid #3498db;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin-bottom: 20px;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>
</head>
<body>
<noscript>您需要启用JavaScript才能运行此应用程序。</noscript>
<div id="root">
<div class="loading">
<div class="spinner"></div>
<p>正在加载应用...</p>
</div>
</div>
<script>
// SPA路由回退处理
if (window.location.pathname !== '/' && !window.location.pathname.includes('.')) {
window.location.href = '/';
}
</script>
</body>
</html>

View File

@@ -1,17 +0,0 @@
import { NextResponse } from 'next/server';
/**
* 健康检查API
* 用于K8s和其他监控系统检查服务是否正常运行
*/
export async function GET() {
return NextResponse.json(
{
status: 'ok',
timestamp: new Date().toISOString(),
version: process.env.NEXT_PUBLIC_APP_VERSION || '1.0.0',
environment: process.env.NEXT_PUBLIC_ENV || 'development',
},
{ status: 200 }
);
}

View File

@@ -1,108 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
const TARGET_API_URL = process.env.NEXT_PUBLIC_TARGET_API_URL || 'http://localhost:3001';
export async function GET(
request: NextRequest,
{ params }: { params: { path: string[] } }
) {
return handleProxyRequest(request, params.path, 'GET');
}
export async function POST(
request: NextRequest,
{ params }: { params: { path: string[] } }
) {
return handleProxyRequest(request, params.path, 'POST');
}
export async function PUT(
request: NextRequest,
{ params }: { params: { path: string[] } }
) {
return handleProxyRequest(request, params.path, 'PUT');
}
export async function DELETE(
request: NextRequest,
{ params }: { params: { path: string[] } }
) {
return handleProxyRequest(request, params.path, 'DELETE');
}
export async function OPTIONS(
request: NextRequest,
{ params }: { params: { path: string[] } }
) {
return new NextResponse(null, {
status: 200,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Requested-With',
},
});
}
async function handleProxyRequest(
request: NextRequest,
pathSegments: string[],
method: string
) {
try {
const path = pathSegments.join('/');
const url = new URL(request.url);
const targetUrl = `${TARGET_API_URL}/${path}${url.search}`;
// 准备请求头
const headers = new Headers();
request.headers.forEach((value, key) => {
// 跳过一些可能导致问题的头
if (!['host', 'origin', 'referer'].includes(key.toLowerCase())) {
headers.set(key, value);
}
});
// 添加必要的头
headers.set('Content-Type', 'application/json');
// 准备请求体
let body: string | undefined;
if (method !== 'GET' && method !== 'DELETE') {
body = await request.text();
}
// 发送请求到目标API
const response = await fetch(targetUrl, {
method,
headers,
body,
});
// 获取响应数据
const responseData = await response.text();
let jsonData;
try {
jsonData = JSON.parse(responseData);
} catch {
jsonData = responseData;
}
// 返回响应
return NextResponse.json(jsonData, {
status: response.status,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Requested-With',
'Content-Type': 'application/json',
},
});
} catch (error) {
console.error('代理请求失败:', error);
return NextResponse.json(
{ error: '代理请求失败', message: error instanceof Error ? error.message : '未知错误' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,23 @@
'use client';
import { useEffect } from 'react';
export default function NotFound() {
useEffect(() => {
// 在SPA中所有未找到的路由都应该重定向到首页
// 使用window.location而不是useRouter避免SSR问题
if (typeof window !== 'undefined') {
window.location.href = '/';
}
}, []);
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<h1 className="text-2xl font-bold mb-4"></h1>
<p className="text-gray-600 mb-4">...</p>
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900 mx-auto"></div>
</div>
</div>
);
}

View File

@@ -15,7 +15,6 @@ import { Label } from "@/components/ui/label";
import { Settings } from "lucide-react";
import { toast } from "sonner";
import { RefreshIntervalSelector } from "@/components/dashboard/refresh-interval-selector";
import { ApiConfigDisplay } from "@/components/dashboard/api-config-display";
import { ThemeToggle } from "@/components/layout/theme-toggle";
import { ThemeTransition } from "@/components/layout/theme-transition";
import { NotificationBell } from "@/components/dashboard/notification-bell";
@@ -133,7 +132,7 @@ function HomeContentInner() {
{/* 右列 - 线程监控和表单 */}
<div className="space-y-6">
{/* API配置 */}
<ApiConfigDisplay refreshEnabled={isRefreshing} refreshInterval={refreshInterval} />
{/* <ApiConfigDisplay refreshEnabled={isRefreshing} refreshInterval={refreshInterval} /> */}
{/* 线程监控 */}
<ThreadMonitor refreshInterval={refreshInterval} refreshEnabled={isRefreshing} />

View File

@@ -2,14 +2,11 @@
import { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { Badge } from "@/components/ui/badge";
import { CrawlerThread } from "@/lib/api/crawler-service";
import { crawlerService, CrawlerThread } from "@/lib/api/crawler-service";
import { AlertCircle, Clock, CheckCircle, Pause, Play } from "lucide-react";
import { ScrollArea } from "@/components/ui/scroll-area";
// 导入 CrawlerService
import { CrawlerService as CrawlerServiceClass } from "@/lib/api/crawler-service";
import { Timer } from "@/components/animate-ui/icons/timer";
interface ThreadMonitorProps {
@@ -30,7 +27,6 @@ export function ThreadMonitor({ refreshInterval = 5000, refreshEnabled = true }:
},
});
const [loading, setLoading] = useState(true);
const crawlerService = new CrawlerServiceClass(); // 使用正确的类名
const fetchThreads = async () => {
try {

View File

@@ -1,15 +1,13 @@
"use client";
import { useState, useEffect } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { CardKeyRequest, crawlerService, CrawlerService } from "@/lib/api/crawler-service";
import { CardKeyRequest, crawlerService } from "@/lib/api/crawler-service";
import {
Clock,
User,

View File

@@ -144,7 +144,7 @@ export class CrawlerService {
try {
const response = await this.apiService.get('/api/thread_status');
return response.data;
} catch (error) {
} catch (_) {
// 返回模拟数据
return {
max_threads: 0,

View File

@@ -1,7 +1,6 @@
"use client";
import React, { createContext, useContext, useState, useEffect } from "react";
import { useTheme } from "next-themes";
import React, { createContext, useContext, useState } from "react";
interface ThemeContextType {
isTransitioning: boolean;

View File

@@ -1,6 +1,6 @@
{
"compilerOptions": {
"target": "ES2017",
"target": "ES2023",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,