feat: support web

This commit is contained in:
fan-tastic-z
2025-08-25 19:39:51 +08:00
parent d5a44c2861
commit 0e943377cb
22 changed files with 2747 additions and 8 deletions

16
assets/index.html Normal file
View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>VulnFeed - Vulnerability Management</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

26
assets/package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "vulnfeed-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.8.1",
"axios": "^1.3.4",
"tailwindcss": "^3.2.7",
"autoprefixer": "^10.4.13",
"postcss": "^8.4.21",
"vite": "^4.1.4",
"@vitejs/plugin-react": "^3.1.0"
},
"devDependencies": {
"@types/react": "^18.0.28",
"@types/react-dom": "^18.0.11"
}
}

1589
assets/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

6
assets/postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

54
assets/src/App.jsx Normal file
View File

@@ -0,0 +1,54 @@
import { Routes, Route } from 'react-router-dom'
import LoginPage from './routes/auth/login'
import HomePage from './routes/home'
import VulnerabilityListPage from './routes/vuln/list'
import VulnerabilityDetailPage from './routes/vuln/detail'
import SyncDataTaskPage from './routes/sync/task'
import ProtectedRoute from './components/ProtectedRoute'
import Layout from './components/Layout'
function App() {
return (
<div>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/"
element={
<Layout>
<VulnerabilityListPage />
</Layout>
}
/>
<Route
path="/vulns"
element={
<Layout>
<VulnerabilityListPage />
</Layout>
}
/>
<Route
path="/vulns/:id"
element={
<Layout>
<VulnerabilityDetailPage />
</Layout>
}
/>
<Route
path="/sync/task"
element={
<ProtectedRoute>
<Layout>
<SyncDataTaskPage />
</Layout>
</ProtectedRoute>
}
/>
</Routes>
</div>
)
}
export default App

View File

@@ -0,0 +1,25 @@
import Navigation from './Navigation'
const Layout = ({ children }) => {
return (
<div className="min-h-screen flex flex-col bg-gray-50">
<Navigation />
<div className="flex-grow py-10">
<main>
<div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
{children}
</div>
</main>
</div>
<footer className="bg-white border-t border-gray-200">
<div className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
<p className="text-center text-sm text-gray-500">
© {new Date().getFullYear()} VulnFeed. All rights reserved.
</p>
</div>
</footer>
</div>
)
}
export default Layout

View File

@@ -0,0 +1,120 @@
import { Link, useLocation } from 'react-router-dom'
import { useState, useRef, useEffect } from 'react'
const Navigation = () => {
const location = useLocation()
const [userMenuOpen, setUserMenuOpen] = useState(false)
const userMenuRef = useRef(null)
// 检查用户是否已登录
const [isAuthenticated, setIsAuthenticated] = useState(false)
useEffect(() => {
const token = localStorage.getItem('token')
setIsAuthenticated(!!token)
}, [])
const isActive = (path) => {
return location.pathname === path
}
const handleLogout = () => {
// 清除token
localStorage.removeItem('token')
// 重定向到登录页面
window.location.href = '/login'
}
// 点击外部关闭用户菜单
useEffect(() => {
const handleClickOutside = (event) => {
if (userMenuRef.current && !userMenuRef.current.contains(event.target)) {
setUserMenuOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [])
return (
<nav className="bg-white shadow">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16">
<div className="flex">
<div className="flex-shrink-0 flex items-center">
<span className="text-xl font-bold text-indigo-600">VulnFeed</span>
</div>
<div className="hidden sm:ml-6 sm:flex sm:space-x-8">
<Link
to="/vulns"
className={`${
isActive('/vulns') || location.pathname === '/'
? 'border-indigo-500 text-gray-900'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'
} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium`}
>
漏洞列表
</Link>
{isAuthenticated && (
<Link
to="/sync/task"
className={`${
isActive('/sync/task')
? 'border-indigo-500 text-gray-900'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'
} inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium`}
>
同步任务
</Link>
)}
</div>
</div>
{isAuthenticated ? (
<div className="hidden sm:ml-6 sm:flex sm:items-center">
<div className="ml-3 relative">
<div>
<button
onClick={() => setUserMenuOpen(!userMenuOpen)}
className="flex text-sm rounded-full focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
<span className="sr-only">打开用户菜单</span>
<div className="h-8 w-8 rounded-full bg-indigo-100 flex items-center justify-center">
<span className="text-indigo-800 font-medium">U</span>
</div>
</button>
</div>
{userMenuOpen && (
<div
ref={userMenuRef}
className="origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg py-1 bg-white ring-1 ring-black ring-opacity-5 focus:outline-none"
>
<button
onClick={handleLogout}
className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
>
登出
</button>
</div>
)}
</div>
</div>
) : (
<div className="hidden sm:ml-6 sm:flex sm:items-center">
<Link
to="/login"
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
登录
</Link>
</div>
)}
</div>
</div>
</nav>
)
}
export default Navigation

View File

@@ -0,0 +1,36 @@
import { useEffect, useState } from 'react'
import { useNavigate, Navigate } from 'react-router-dom'
const ProtectedRoute = ({ children }) => {
const [isAuthenticated, setIsAuthenticated] = useState(null)
const navigate = useNavigate()
useEffect(() => {
// 检查用户是否已登录
const token = localStorage.getItem('token')
if (!token) {
// 如果没有token重定向到登录页面
setIsAuthenticated(false)
} else {
// 如果有token设置为已认证
setIsAuthenticated(true)
}
}, [navigate])
// 如果还在检查认证状态,显示加载状态
if (isAuthenticated === null) {
return <div className="flex justify-center items-center h-screen">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
</div>
}
// 如果未认证,重定向到登录页面
if (!isAuthenticated) {
return <Navigate to="/login" replace />
}
// 如果已认证,渲染子组件
return children
}
export default ProtectedRoute

3
assets/src/index.css Normal file
View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

76
assets/src/lib/api.js Normal file
View File

@@ -0,0 +1,76 @@
import axios from 'axios'
// 创建axios实例
const api = axios.create({
baseURL: '/api', // 使用相对路径,假设前端和后端在同一域名下
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
})
// 请求拦截器
api.interceptors.request.use(
(config) => {
// 在发送请求之前做些什么
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
// 对请求错误做些什么
return Promise.reject(error)
},
)
// 响应拦截器
api.interceptors.response.use(
(response) => {
// 对响应数据做些什么
return response
},
(error) => {
// 对响应错误做些什么
if (error.response?.status === 401) {
// 如果是未授权错误清除token并重定向到登录页面
localStorage.removeItem('token')
window.location.href = '/login'
}
return Promise.reject(error)
},
)
// 登录API
export const login = (username, password) => {
return api.post('/login', { username, password })
}
// 获取漏洞列表
export const getVulnerabilities = (pageNo, pageSize, searchTerm = '') => {
return api.get('/vulns', {
params: {
page_no: pageNo,
page_size: pageSize,
search: searchTerm,
},
})
}
// 获取漏洞详情
export const getVulnerabilityDetail = (id) => {
return api.get(`/vulns/${id}`)
}
// 获取同步任务
export const getSyncDataTask = () => {
return api.get('/sync_data_task')
}
// 创建或更新同步任务
export const createOrUpdateSyncDataTask = (data) => {
return api.post('/sync_data_task', data)
}
export default api

13
assets/src/main.jsx Normal file
View File

@@ -0,0 +1,13 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
)

View File

@@ -0,0 +1,108 @@
import { useState } from 'react'
import { useNavigate, Navigate } from 'react-router-dom'
import { login } from '../../lib/api'
const LoginPage = () => {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const navigate = useNavigate()
// 检查用户是否已经登录
const token = localStorage.getItem('token')
if (token) {
return <Navigate to="/vulns" replace />
}
const handleSubmit = async (e) => {
e.preventDefault()
setLoading(true)
setError('')
try {
const response = await login(username, password)
const { token } = response.data.data
// 保存token到localStorage
localStorage.setItem('token', token)
// 重定向到漏洞列表页面
navigate('/vulns')
} catch (err) {
setError('登录失败,请检查用户名和密码')
console.error('Login error:', err)
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
VulnFeed 管理平台
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
登录到您的账户
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<input type="hidden" name="remember" defaultValue="true" />
<div className="rounded-md shadow-sm -space-y-px">
<div>
<label htmlFor="username" className="sr-only">
用户名
</label>
<input
id="username"
name="username"
type="text"
required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
placeholder="用户名"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
<div>
<label htmlFor="password" className="sr-only">
密码
</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
placeholder="密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
</div>
{error && (
<div className="text-red-500 text-sm text-center">
{error}
</div>
)}
<div>
<button
type="submit"
disabled={loading}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
>
{loading ? '登录中...' : '登录'}
</button>
</div>
</form>
</div>
</div>
)
}
export default LoginPage

View File

@@ -0,0 +1,7 @@
import { Navigate } from 'react-router-dom'
const HomePage = () => {
return <Navigate to="/vulns" replace />
}
export default HomePage

View File

@@ -0,0 +1,189 @@
import { useState, useEffect } from 'react'
import { getSyncDataTask, createOrUpdateSyncDataTask } from '../../lib/api'
const SyncDataTaskPage = () => {
const [task, setTask] = useState({
name: '',
interval_minutes: 60,
status: true,
})
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
useEffect(() => {
fetchSyncDataTask()
}, [])
const fetchSyncDataTask = async () => {
setLoading(true)
setError('')
try {
const response = await getSyncDataTask()
const { data } = response.data
if (data) {
setTask({
name: data.name,
interval_minutes: data.interval_minutes,
status: data.status,
})
}
} catch (err) {
setError('获取同步任务信息失败')
console.error('Fetch sync data task error:', err)
} finally {
setLoading(false)
}
}
const handleChange = (e) => {
const { name, value, type, checked } = e.target
setTask(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : type === 'number' ? parseInt(value) : value,
}))
}
const handleSubmit = async (e) => {
e.preventDefault()
setSaving(true)
setError('')
setSuccess('')
try {
await createOrUpdateSyncDataTask(task)
setSuccess('同步任务配置已保存')
// 重新获取任务信息以确保数据是最新的
fetchSyncDataTask()
} catch (err) {
setError('保存同步任务配置失败')
console.error('Save sync data task error:', err)
} finally {
setSaving(false)
}
}
return (
<div className="max-w-3xl mx-auto">
<div className="text-center mb-8">
<h1 className="text-3xl font-extrabold text-gray-900 mb-2">
数据同步任务配置
</h1>
<p className="text-gray-600">
配置和管理漏洞数据同步任务
</p>
</div>
{error && (
<div className="bg-red-50 border border-red-200 rounded-md p-4 mb-6">
<div className="text-red-800 text-sm">
{error}
</div>
</div>
)}
{success && (
<div className="bg-green-50 border border-green-200 rounded-md p-4 mb-6">
<div className="text-green-800 text-sm">
{success}
</div>
</div>
)}
{loading ? (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
</div>
) : (
<div className="bg-white shadow overflow-hidden sm:rounded-lg">
<div className="px-4 py-5 sm:px-6 border-b border-gray-200">
<h2 className="text-lg leading-6 font-medium text-gray-900">
同步任务设置
</h2>
</div>
<form onSubmit={handleSubmit} className="px-4 py-5 sm:px-6">
<div className="grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-6">
<div className="sm:col-span-4">
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
任务名称
</label>
<div className="mt-1">
<input
type="text"
name="name"
id="name"
value={task.name}
onChange={handleChange}
required
className="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md"
/>
</div>
</div>
<div className="sm:col-span-4">
<label htmlFor="interval_minutes" className="block text-sm font-medium text-gray-700">
同步间隔分钟
</label>
<div className="mt-1">
<input
type="number"
name="interval_minutes"
id="interval_minutes"
min="1"
max="1440"
value={task.interval_minutes}
onChange={handleChange}
required
className="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md"
/>
</div>
<p className="mt-2 text-sm text-gray-500">
设置数据同步的时间间隔范围1-1440分钟1
</p>
</div>
<div className="sm:col-span-4">
<div className="flex items-start">
<div className="flex items-center h-5">
<input
id="status"
name="status"
type="checkbox"
checked={task.status}
onChange={handleChange}
className="focus:ring-indigo-500 h-4 w-4 text-indigo-600 border-gray-300 rounded"
/>
</div>
<div className="ml-3 text-sm">
<label htmlFor="status" className="font-medium text-gray-700">
启用任务
</label>
<p className="text-gray-500">
启用后将按照设定的时间间隔自动同步数据
</p>
</div>
</div>
</div>
</div>
<div className="mt-8 border-t border-gray-200 pt-5">
<div className="flex justify-end">
<button
type="submit"
disabled={saving}
className="ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
>
{saving ? '保存中...' : '保存配置'}
</button>
</div>
</div>
</form>
</div>
)}
</div>
)
}
export default SyncDataTaskPage

View File

@@ -0,0 +1,211 @@
import { useState, useEffect } from 'react'
import { useParams, Link } from 'react-router-dom'
import { getVulnerabilityDetail } from '../../lib/api'
const VulnerabilityDetailPage = () => {
const { id } = useParams()
const [vulnerability, setVulnerability] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
useEffect(() => {
fetchVulnerabilityDetail()
}, [id])
const fetchVulnerabilityDetail = async () => {
setLoading(true)
setError('')
try {
const response = await getVulnerabilityDetail(id)
const { data } = response.data
setVulnerability(data)
} catch (err) {
setError('获取漏洞详情失败')
console.error('Fetch vulnerability detail error:', err)
} finally {
setLoading(false)
}
}
const formatDate = (dateString) => {
const date = new Date(dateString)
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
return (
<div className="max-w-4xl mx-auto">
<div className="mb-6">
<Link
to="/vulns"
className="inline-flex items-center text-indigo-600 hover:text-indigo-800"
>
<svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z" clipRule="evenodd" />
</svg>
返回漏洞列表
</Link>
</div>
{error && (
<div className="bg-red-50 border border-red-200 rounded-md p-4 mb-6">
<div className="text-red-800 text-sm">
{error}
</div>
</div>
)}
{loading ? (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
</div>
) : vulnerability ? (
<div className="bg-white shadow overflow-hidden sm:rounded-lg">
<div className="px-4 py-5 sm:px-6 border-b border-gray-200">
<h1 className="text-2xl leading-6 font-medium text-gray-900">
{vulnerability.title}
</h1>
<div className="mt-2 flex items-center">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
vulnerability.severity === 'Critical' ? 'bg-red-100 text-red-800' :
vulnerability.severity === 'High' ? 'bg-orange-100 text-orange-800' :
vulnerability.severity === 'Medium' ? 'bg-yellow-100 text-yellow-800' :
'bg-green-100 text-green-800'
}`}>
{vulnerability.severity}
</span>
<span className="ml-2 text-sm text-gray-500">
CVE: {vulnerability.cve || 'N/A'}
</span>
</div>
</div>
<div className="px-4 py-5 sm:px-6">
<div className="grid grid-cols-1 gap-y-4 gap-x-8 sm:grid-cols-2">
<div>
<h3 className="text-lg font-medium text-gray-900 mb-2">基本信息</h3>
<dl className="grid grid-cols-1 gap-y-2">
<div>
<dt className="text-sm font-medium text-gray-500">漏洞编号</dt>
<dd className="mt-1 text-sm text-gray-900">{vulnerability.key}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">披露日期</dt>
<dd className="mt-1 text-sm text-gray-900">
{vulnerability.disclosure ? formatDate(vulnerability.disclosure) : 'N/A'}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">来源</dt>
<dd className="mt-1 text-sm text-gray-900">{vulnerability.source}</dd>
</div>
</dl>
</div>
<div>
<h3 className="text-lg font-medium text-gray-900 mb-2">标签</h3>
<div className="flex flex-wrap gap-2">
{vulnerability.tags.map((tag, index) => (
<span
key={index}
className="px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 rounded-full"
>
{tag}
</span>
))}
</div>
</div>
</div>
<div className="mt-6">
<h3 className="text-lg font-medium text-gray-900 mb-2">描述</h3>
<p className="text-sm text-gray-900 whitespace-pre-wrap">
{vulnerability.description}
</p>
</div>
<div className="mt-6">
<h3 className="text-lg font-medium text-gray-900 mb-2">解决方案</h3>
<p className="text-sm text-gray-900 whitespace-pre-wrap">
{vulnerability.solutions || '暂无解决方案信息'}
</p>
</div>
{vulnerability.reference_links && vulnerability.reference_links.length > 0 && (
<div className="mt-6">
<h3 className="text-lg font-medium text-gray-900 mb-2">参考链接</h3>
<ul className="list-disc list-inside text-sm text-gray-900">
{vulnerability.reference_links.map((link, index) => (
<li key={index} className="mb-1">
<a
href={link}
target="_blank"
rel="noopener noreferrer"
className="text-indigo-600 hover:text-indigo-800"
>
{link}
</a>
</li>
))}
</ul>
</div>
)}
{vulnerability.github_search && vulnerability.github_search.length > 0 && (
<div className="mt-6">
<h3 className="text-lg font-medium text-gray-900 mb-2">GitHub搜索</h3>
<ul className="list-disc list-inside text-sm text-gray-900">
{vulnerability.github_search.map((search, index) => (
<li key={index} className="mb-1">
{search}
</li>
))}
</ul>
</div>
)}
<div className="mt-6">
<h3 className="text-lg font-medium text-gray-900 mb-2">触发原因</h3>
<ul className="list-disc list-inside text-sm text-gray-900">
{vulnerability.reasons.map((reason, index) => (
<li key={index} className="mb-1">
{reason}
</li>
))}
</ul>
</div>
<div className="mt-6 grid grid-cols-1 gap-y-2 sm:grid-cols-2">
<div>
<dt className="text-sm font-medium text-gray-500">创建时间</dt>
<dd className="mt-1 text-sm text-gray-900">
{formatDate(vulnerability.created_at)}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">更新时间</dt>
<dd className="mt-1 text-sm text-gray-900">
{formatDate(vulnerability.updated_at)}
</dd>
</div>
</div>
</div>
</div>
) : (
<div className="bg-white shadow overflow-hidden sm:rounded-lg">
<div className="px-4 py-5 sm:px-6">
<div className="text-center text-gray-500">
未找到漏洞信息
</div>
</div>
</div>
)}
</div>
)
}
export default VulnerabilityDetailPage

View File

@@ -0,0 +1,196 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { getVulnerabilities } from '../../lib/api'
const VulnerabilityListPage = () => {
const [vulnerabilities, setVulnerabilities] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [pageNo, setPageNo] = useState(1)
const [pageSize] = useState(10)
const [totalCount, setTotalCount] = useState(0)
const [searchTerm, setSearchTerm] = useState('')
useEffect(() => {
fetchVulnerabilities()
}, [pageNo, searchTerm])
const fetchVulnerabilities = async () => {
setLoading(true)
setError('')
try {
const response = await getVulnerabilities(pageNo, pageSize, searchTerm)
const { data, total_count } = response.data.data
setVulnerabilities(data)
setTotalCount(total_count)
} catch (err) {
setError('获取漏洞列表失败')
console.error('Fetch vulnerabilities error:', err)
} finally {
setLoading(false)
}
}
const handlePageChange = (newPageNo) => {
setPageNo(newPageNo)
}
const handleSearch = (e) => {
e.preventDefault()
// 重置页码到第一页
setPageNo(1)
}
const totalPages = Math.ceil(totalCount / pageSize)
return (
<div className="max-w-7xl mx-auto">
<div className="text-center mb-8">
<h1 className="text-3xl font-extrabold text-gray-900 mb-2">
漏洞信息列表
</h1>
<p className="text-gray-600">
当前共有 {totalCount} 个漏洞信息
</p>
</div>
{/* 搜索框 */}
<div className="mb-6">
<form onSubmit={handleSearch} className="max-w-md mx-auto">
<div className="relative">
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="搜索漏洞标题或描述..."
className="block w-full pl-4 pr-12 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
<button
type="submit"
className="absolute inset-y-0 right-0 flex items-center pr-3"
>
<svg className="h-5 w-5 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clipRule="evenodd" />
</svg>
</button>
</div>
</form>
</div>
{error && (
<div className="bg-red-50 border border-red-200 rounded-md p-4 mb-6">
<div className="text-red-800 text-sm">
{error}
</div>
</div>
)}
{loading ? (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
</div>
) : (
<>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{vulnerabilities.map((vuln) => (
<div key={vuln.id} className="bg-white rounded-xl shadow-lg overflow-hidden hover:shadow-xl transition-shadow duration-300 ease-in-out">
<Link to={`/vulns/${vuln.id}`} className="block h-full">
<div className="p-6 h-full flex flex-col">
<div className="flex justify-between items-start">
<h3 className="text-lg font-bold text-gray-900 truncate">{vuln.title}</h3>
<span className={`ml-2 px-2 py-1 text-xs font-semibold rounded-full ${
vuln.severity === 'Critical' ? 'bg-red-100 text-red-800' :
vuln.severity === 'High' ? 'bg-orange-100 text-orange-800' :
vuln.severity === 'Medium' ? 'bg-yellow-100 text-yellow-800' :
'bg-green-100 text-green-800'
}`}>
{vuln.severity}
</span>
</div>
<p className="mt-3 text-gray-600 text-sm flex-grow">
{vuln.description.substring(0, 120)}...
</p>
<div className="mt-4 flex flex-wrap gap-2">
{vuln.tags && vuln.tags.slice(0, 3).map((tag, index) => (
<span key={index} className="px-2 py-1 bg-indigo-100 text-indigo-800 text-xs font-medium rounded-full">
{tag}
</span>
))}
{vuln.tags && vuln.tags.length > 3 && (
<span className="px-2 py-1 bg-gray-100 text-gray-800 text-xs font-medium rounded-full">
+{vuln.tags.length - 3}
</span>
)}
</div>
<div className="mt-4 flex items-center justify-between text-sm">
<div className="flex items-center">
<svg className="h-4 w-4 text-gray-500 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
<span className="text-gray-500">CVE: {vuln.cve || 'N/A'}</span>
</div>
<div className="flex items-center">
<svg className="h-4 w-4 text-gray-500 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="text-gray-500">
{vuln.pushed ? '已推送' : '未推送'}
</span>
</div>
</div>
<div className="mt-3 text-xs text-gray-400">
更新时间: {new Date(vuln.updated_at).toLocaleDateString('zh-CN')}
</div>
</div>
</Link>
</div>
))}
</div>
{/* 分页组件 */}
<div className="mt-6 flex items-center justify-between">
<div className="text-sm text-gray-700">
显示第 {(pageNo - 1) * pageSize + 1} {Math.min(pageNo * pageSize, totalCount)} 条记录
总共 {totalCount} 条记录
</div>
<div className="flex space-x-2">
<button
onClick={() => handlePageChange(pageNo - 1)}
disabled={pageNo === 1}
className={`px-4 py-2 text-sm font-medium rounded-md ${
pageNo === 1
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-white text-gray-700 hover:bg-gray-50 border border-gray-300'
}`}
>
上一页
</button>
<span className="px-4 py-2 text-sm text-gray-700">
{pageNo} {totalPages}
</span>
<button
onClick={() => handlePageChange(pageNo + 1)}
disabled={pageNo === totalPages}
className={`px-4 py-2 text-sm font-medium rounded-md ${
pageNo === totalPages
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: 'bg-white text-gray-700 hover:bg-gray-50 border border-gray-300'
}`}
>
下一页
</button>
</div>
</div>
</>
)}
</div>
)
}
export default VulnerabilityListPage

11
assets/tailwind.config.js Normal file
View File

@@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

21
assets/vite.config.js Normal file
View File

@@ -0,0 +1,21 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:9000',
changeOrigin: true,
secure: false,
},
},
},
build: {
outDir: '../public',
emptyOutDir: true,
},
})

View File

@@ -60,11 +60,15 @@ impl fmt::Display for Severity {
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ListVulnInformationRequest {
pub page_filter: PageFilter,
pub search: Option<String>,
}
impl ListVulnInformationRequest {
pub fn new(page_filter: PageFilter) -> Self {
ListVulnInformationRequest { page_filter }
pub fn new(page_filter: PageFilter, search: Option<String>) -> Self {
ListVulnInformationRequest {
page_filter,
search,
}
}
}

View File

@@ -26,6 +26,7 @@ use crate::{
pub struct ListVulnInformationRequestBody {
pub page_no: i32,
pub page_size: i32,
pub search: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
@@ -40,7 +41,7 @@ impl ListVulnInformationRequestBody {
let page_no = PageNo::try_new(self.page_no)?;
let page_size = PageSize::try_new(self.page_size)?;
let page_filter = PageFilter::new(page_no, page_size);
Ok(ListVulnInformationRequest::new(page_filter))
Ok(ListVulnInformationRequest::new(page_filter, self.search))
}
}

View File

@@ -71,9 +71,15 @@ impl VulnRepository for Pg {
self.pool.begin().await.change_context_lazy(|| {
Error::Message("failed to begin transaction".to_string())
})?;
let vuln_informations =
VulnInformationDao::filter_vulnfusion_information(&mut tx, &req.page_filter).await?;
let count = VulnInformationDao::filter_vulnfusion_information_count(&mut tx).await?;
let vuln_informations = VulnInformationDao::filter_vulnfusion_information(
&mut tx,
&req.page_filter,
req.search.as_deref(),
)
.await?;
let count =
VulnInformationDao::filter_vulnfusion_information_count(&mut tx, req.search.as_deref())
.await?;
tx.commit()
.await

View File

@@ -106,12 +106,22 @@ impl VulnInformationDao {
pub async fn filter_vulnfusion_information(
tx: &mut Transaction<'_, Postgres>,
page_filter: &PageFilter,
search: Option<&str>,
) -> Result<Vec<VulnInformation>, Error> {
let query_builder = DaoQueryBuilder::<Self>::new();
let mut query_builder = DaoQueryBuilder::<Self>::new();
let page_no = *page_filter.page_no().as_ref();
let page_size = *page_filter.page_size().as_ref();
let offset = (page_no - 1) * page_size;
// 添加搜索条件
if let Some(search_term) = search {
if !search_term.is_empty() {
query_builder = query_builder
.and_where_like("title", search_term)
.and_where_like("description", search_term);
}
}
query_builder
.order_by_desc("id")
.limit_offset(page_size as i64, offset as i64)
@@ -121,8 +131,19 @@ impl VulnInformationDao {
pub async fn filter_vulnfusion_information_count(
tx: &mut Transaction<'_, Postgres>,
search: Option<&str>,
) -> Result<i64, Error> {
let query_builder = DaoQueryBuilder::<Self>::new();
let mut query_builder = DaoQueryBuilder::<Self>::new();
// 添加搜索条件
if let Some(search_term) = search {
if !search_term.is_empty() {
query_builder = query_builder
.and_where_like("title", search_term)
.and_where_like("description", search_term);
}
}
query_builder.count(tx).await
}