feat: SVE 漏洞百科 Vue3 前端(替代 TempLate/Org),含一键部署脚本
This commit is contained in:
commit
d515dd51d4
31 files changed
+6155
No files matched your search
+24
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
# SVE frontend deploy script: build and sync dist to production dir (TempLate/SVE)
|
||||
#
|
||||
# IMPORTANT: keep this file pure ASCII. PS 5.1 parses BOM-less UTF-8 files
|
||||
# using the ANSI codepage, so non-ASCII text would be garbled.
|
||||
#
|
||||
# Usage:
|
||||
# 1. Clean old artifacts (dist and TempLate/SVE/assets) so no stale hashes remain
|
||||
# 2. Full rebuild (vue-tsc type check + vite build)
|
||||
# 3. Sync dist to TempLate/SVE (index.html + assets)
|
||||
# 4. Verify TempLate matches dist exactly
|
||||
#
|
||||
# Run: .\deploy.ps1
|
||||
# (if execution policy blocks: Set-ExecutionPolicy -Scope Process Bypass)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$ProjectRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$DistDir = Join-Path $ProjectRoot 'dist'
|
||||
$ProdRoot = Join-Path (Split-Path -Parent $ProjectRoot) 'TempLate\SVE'
|
||||
$ProdAssetsDir = Join-Path $ProdRoot 'assets'
|
||||
$IndexHtml = Join-Path $ProdRoot 'index.html'
|
||||
|
||||
Write-Host "`n===== SVE DEPLOY START =====" -ForegroundColor Cyan
|
||||
Write-Host "Project dir : $ProjectRoot"
|
||||
Write-Host "Prod dir : $ProdRoot"
|
||||
Write-Host ""
|
||||
|
||||
# 1. Clean old artifacts
|
||||
Write-Host '[1/4] Cleaning old build artifacts...' -ForegroundColor Yellow
|
||||
if (Test-Path $DistDir) {
|
||||
Remove-Item -Recurse -Force $DistDir
|
||||
Write-Host " removed: $DistDir"
|
||||
}
|
||||
if (Test-Path $ProdAssetsDir) {
|
||||
Remove-Item -Recurse -Force $ProdAssetsDir
|
||||
Write-Host " removed: $ProdAssetsDir"
|
||||
}
|
||||
if (Test-Path $IndexHtml) {
|
||||
Remove-Item -Force $IndexHtml
|
||||
Write-Host " removed: $IndexHtml"
|
||||
}
|
||||
Write-Host ''
|
||||
|
||||
# 2. Full build (vue-tsc type check + vite build)
|
||||
Write-Host '[2/4] Running npm run build (type check + full build)...' -ForegroundColor Yellow
|
||||
Push-Location $ProjectRoot
|
||||
try {
|
||||
npm run build
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Build failed, exit code = $LASTEXITCODE"
|
||||
}
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
Write-Host ' Build OK'
|
||||
Write-Host ''
|
||||
|
||||
# 3. Sync artifacts to prod dir
|
||||
Write-Host '[3/4] Syncing artifacts to prod dir...' -ForegroundColor Yellow
|
||||
if (-not (Test-Path $ProdRoot)) {
|
||||
New-Item -ItemType Directory -Path $ProdRoot -Force | Out-Null
|
||||
}
|
||||
Copy-Item -Recurse -Force (Join-Path $DistDir '*') $ProdRoot
|
||||
Copy-Item -Force (Join-Path $DistDir 'index.html') $IndexHtml
|
||||
Write-Host ' Sync done'
|
||||
Write-Host ''
|
||||
|
||||
# 4. Verify prod dir matches dist (file name sets)
|
||||
Write-Host '[4/4] Verifying artifact consistency...' -ForegroundColor Yellow
|
||||
function Get-AssetNames([string]$dir) {
|
||||
if (-not (Test-Path $dir)) { return @() }
|
||||
return @(Get-ChildItem -Path $dir -Recurse -File | ForEach-Object { $_.FullName.Substring($dir.Length) })
|
||||
}
|
||||
$distNames = Get-AssetNames (Join-Path $DistDir 'assets')
|
||||
$prodNames = Get-AssetNames $ProdAssetsDir
|
||||
$distSet = @($distNames | Sort-Object -Unique)
|
||||
$prodSet = @($prodNames | Sort-Object -Unique)
|
||||
|
||||
$onlyDist = @($distSet | Where-Object { $_ -notin $prodSet })
|
||||
$onlyProd = @($prodSet | Where-Object { $_ -notin $distSet })
|
||||
|
||||
if ($onlyDist.Count -eq 0 -and $onlyProd.Count -eq 0) {
|
||||
Write-Host " OK: $($distSet.Count) assets, prod matches dist exactly" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host ' MISMATCH:' -ForegroundColor Red
|
||||
if ($onlyDist.Count) { Write-Host " only in dist: $onlyDist" -ForegroundColor Red }
|
||||
if ($onlyProd.Count) { Write-Host " only in prod: $onlyProd" -ForegroundColor Red }
|
||||
throw 'Artifact verification failed!'
|
||||
}
|
||||
|
||||
# Verify the entry JS referenced by index.html exists
|
||||
$indexContent = Get-Content $IndexHtml -Raw
|
||||
$entryMatch = [regex]::Match($indexContent, 'src="([^"]+\.js)"')
|
||||
if ($entryMatch.Success) {
|
||||
$entryPath = Join-Path $ProdRoot ($entryMatch.Groups[1].Value.TrimStart('/'))
|
||||
if (Test-Path $entryPath) {
|
||||
Write-Host " entry JS OK: $($entryMatch.Groups[1].Value)" -ForegroundColor Green
|
||||
} else {
|
||||
throw "index.html entry JS not found: $entryPath"
|
||||
}
|
||||
} else {
|
||||
throw 'No entry JS reference found in index.html'
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host "===== SVE DEPLOY DONE =====" -ForegroundColor Cyan
|
||||
Write-Host 'Please hard-refresh the browser (Ctrl+F5) to load the new build.' -ForegroundColor Cyan
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Cpath fill='%230F3460' d='M50 5 90 20v30c0 25-17 40-40 45C27 90 10 75 10 50V20z'/%3E%3Cpath fill='%231A73E8' d='M50 12 82 24v26c0 20-13 33-32 38-19-5-32-18-32-38V24z'/%3E%3Ctext x='50' y='62' font-size='38' text-anchor='middle' fill='%23fff' font-family='Arial' font-weight='bold'%3ES%3C/text%3E%3C/svg%3E" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>SVE Database</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2693
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "sve",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^7.3.1",
|
||||
"axios": "^1.19.0",
|
||||
"chart.js": "^4.4.9",
|
||||
"dompurify": "^3.2.6",
|
||||
"marked": "^18.0.9",
|
||||
"vue": "^3.5.40",
|
||||
"vue-router": "^5.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.0",
|
||||
"@types/node": "^24.13.3",
|
||||
"@vitejs/plugin-vue": "^6.0.8",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"tailwindcss": "^4.1.0",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.2.0",
|
||||
"vue-tsc": "^3.3.8"
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import SiteHeader from '@/components/SiteHeader.vue'
|
||||
import SiteFooter from '@/components/SiteFooter.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen flex flex-col bg-gray-50 text-dark">
|
||||
<SiteHeader />
|
||||
<main class="flex-1">
|
||||
<router-view />
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,66 @@
|
||||
/** 模块:SVE 前端 Axios 封装(后端统一 {code, msg, data, ...} 协议) */
|
||||
import axios, { type AxiosResponse, type InternalAxiosRequestConfig } from 'axios'
|
||||
|
||||
/** 后端统一响应包裹 */
|
||||
export interface ApiEnvelope<T = unknown> {
|
||||
code: number
|
||||
msg?: string
|
||||
data?: T
|
||||
total?: number
|
||||
Page?: number
|
||||
PageSize?: number
|
||||
}
|
||||
|
||||
/** 业务成功码 */
|
||||
const CODE_SUCCESS = 0
|
||||
|
||||
const instance = axios.create({
|
||||
baseURL: '/',
|
||||
timeout: 30000,
|
||||
// Cookie 认证:跨域请求需要携带凭证
|
||||
withCredentials: true,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
instance.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||
// 动态接口禁止 HTTP 缓存,保证数据实时性
|
||||
config.headers['Cache-Control'] = 'no-cache'
|
||||
config.headers['Pragma'] = 'no-cache'
|
||||
return config
|
||||
})
|
||||
|
||||
instance.interceptors.response.use(
|
||||
(response: AxiosResponse) => {
|
||||
const payload = response.data as ApiEnvelope | undefined
|
||||
if (payload && typeof payload === 'object' && 'code' in payload) {
|
||||
if (payload.code === CODE_SUCCESS) return response
|
||||
return Promise.reject(new Error(payload.msg || 'Business Error'))
|
||||
}
|
||||
// 非业务响应(文件流等)直接放行
|
||||
return response
|
||||
},
|
||||
(error) => {
|
||||
// 后端可读业务 JSON:提取 msg 附加到 error.message,保证 catch 拿到友好文案
|
||||
const biz = error?.response?.data as { code?: number; msg?: string } | undefined
|
||||
if (biz && typeof biz === 'object' && typeof biz.msg === 'string' && biz.msg) {
|
||||
error.message = biz.msg
|
||||
}
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
|
||||
/** GET 请求:返回完整包裹(含 data/total/Page/PageSize) */
|
||||
export async function get<T = unknown>(url: string, params?: Record<string, unknown>): Promise<ApiEnvelope<T>> {
|
||||
const res = await instance.get(url, { params })
|
||||
return res.data as ApiEnvelope<T>
|
||||
}
|
||||
|
||||
/** POST 请求:返回完整包裹(含 data/total/Page/PageSize) */
|
||||
export async function post<T = unknown>(url: string, data?: unknown): Promise<ApiEnvelope<T>> {
|
||||
const res = await instance.post(url, data)
|
||||
return res.data as ApiEnvelope<T>
|
||||
}
|
||||
|
||||
export default instance
|
||||
@@ -0,0 +1,76 @@
|
||||
/** 模块:SVE 后端 API 客户端(对应 gvsds.apps.sve 的 SVEViewSet) */
|
||||
import { get, post, type ApiEnvelope } from './request'
|
||||
|
||||
/** 列表项 */
|
||||
export interface SVEListItem {
|
||||
SveID: string
|
||||
SveName: string
|
||||
SveDescription: string
|
||||
SveVulnStatus: string
|
||||
CvssBaseScore?: number | null
|
||||
CvssSeverity?: string | null
|
||||
SveCreateTime?: string | null
|
||||
}
|
||||
|
||||
/** 详情(SveProducts/SveReferences 等为数据库原样 JSON,前端做防御性归一化) */
|
||||
export interface SVEDetail extends SVEListItem {
|
||||
SveProducts?: unknown
|
||||
SveCvss?: Record<string, unknown> | null
|
||||
CvssBaseSeverity?: string | null
|
||||
SveSolutions?: string | null
|
||||
SveReferences?: unknown
|
||||
SveRelatedSves?: unknown
|
||||
SveLastModified?: string | null
|
||||
}
|
||||
|
||||
/** 列表查询参数(对应 SVEListSerializer) */
|
||||
export interface SVEListParams {
|
||||
Keyword?: string
|
||||
CvssMin?: number
|
||||
CvssMax?: number
|
||||
Status?: string
|
||||
Page?: number
|
||||
PageSize?: number
|
||||
}
|
||||
|
||||
export interface SVEListResult {
|
||||
items: SVEListItem[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
/** 列表:POST /API/SVEList */
|
||||
export async function fetchSVEList(params: SVEListParams): Promise<SVEListResult> {
|
||||
const payload = await post<SVEListItem[]>('/API/SVEList', params)
|
||||
return {
|
||||
items: payload.data ?? [],
|
||||
total: payload.total ?? 0,
|
||||
page: payload.Page ?? 1,
|
||||
pageSize: payload.PageSize ?? params.PageSize ?? 20,
|
||||
}
|
||||
}
|
||||
|
||||
/** 详情:GET/POST /API/GetInformation?SveID=... */
|
||||
export async function fetchSVEInfo(sveId: string): Promise<SVEDetail | null> {
|
||||
const payload = await get<SVEDetail>('/API/GetInformation', { SveID: sveId })
|
||||
return payload.data ?? null
|
||||
}
|
||||
|
||||
/** 写入载荷(对应 SVEWriteSerializer) */
|
||||
export interface SVEWritePayload {
|
||||
SveID: string
|
||||
SveName: string
|
||||
SveDescription: string
|
||||
SveVulnStatus: string
|
||||
SveProducts: unknown[]
|
||||
SveCvss: Record<string, unknown>
|
||||
SveSolutions: string
|
||||
SveReferences: unknown[]
|
||||
SveRelatedSves: unknown[]
|
||||
}
|
||||
|
||||
/** 写入:POST /API/Write */
|
||||
export async function submitSVE(payload: SVEWritePayload): Promise<ApiEnvelope<unknown>> {
|
||||
return post<unknown>('/API/Write', payload)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
const props = defineProps<{
|
||||
severity?: string | null
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const LEVELS = ['critical', 'high', 'medium', 'low', 'none']
|
||||
|
||||
const normalized = computed<string>(() => {
|
||||
const raw = (props.severity || '').toLowerCase()
|
||||
return LEVELS.includes(raw) ? raw : 'none'
|
||||
})
|
||||
|
||||
const label = computed<string>(() => t(`severity_${normalized.value}`))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="severity-badge" :class="normalized">{{ label }}</span>
|
||||
</template>
|
||||
@@ -0,0 +1,203 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, computed, watch } from 'vue'
|
||||
import { useI18n } from '@/i18n'
|
||||
import CvssBadge from './CvssBadge.vue'
|
||||
|
||||
type MetricKey = 'AV' | 'AC' | 'PR' | 'UI' | 'S' | 'C' | 'I' | 'A'
|
||||
|
||||
interface MetricOption {
|
||||
value: string
|
||||
en: string
|
||||
zh: string
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: Record<string, unknown>): void
|
||||
}>()
|
||||
|
||||
const { locale, t } = useI18n()
|
||||
|
||||
const optionLabel = (opt: MetricOption): string => (locale.value === 'zh' ? opt.zh : opt.en)
|
||||
|
||||
const AV_OPTIONS: MetricOption[] = [
|
||||
{ value: 'N', en: 'Network (N) - Exploitable remotely', zh: '网络(N)- 可远程利用' },
|
||||
{ value: 'A', en: 'Adjacent (A) - Exploitable via adjacent network', zh: '相邻(A)- 可通过相邻网络利用' },
|
||||
{ value: 'L', en: 'Local (L) - Exploitable only locally', zh: '本地(L)- 仅可本地利用' },
|
||||
{ value: 'P', en: 'Physical (P) - Requires physical access', zh: '物理(P)- 需要物理访问' },
|
||||
]
|
||||
const AC_OPTIONS: MetricOption[] = [
|
||||
{ value: 'L', en: 'Low (L) - Minimal conditions', zh: '低(L)- 条件简单' },
|
||||
{ value: 'H', en: 'High (H) - Specialized conditions', zh: '高(H)- 条件特殊' },
|
||||
]
|
||||
const PR_OPTIONS: MetricOption[] = [
|
||||
{ value: 'N', en: 'None (N) - No privileges needed', zh: '无(N)- 无需权限' },
|
||||
{ value: 'L', en: 'Low (L) - Basic privileges needed', zh: '低(L)- 需要基本权限' },
|
||||
{ value: 'H', en: 'High (H) - Admin privileges needed', zh: '高(H)- 需要管理员权限' },
|
||||
]
|
||||
const UI_OPTIONS: MetricOption[] = [
|
||||
{ value: 'N', en: 'None (N) - No user interaction needed', zh: '无(N)- 无需用户交互' },
|
||||
{ value: 'R', en: 'Required (R) - User action needed', zh: '需要(R)- 需要用户操作' },
|
||||
]
|
||||
const S_OPTIONS: MetricOption[] = [
|
||||
{ value: 'U', en: 'Unchanged (U) - Impact limited to vulnerable component', zh: '未变更(U)- 影响仅限于漏洞组件' },
|
||||
{ value: 'C', en: 'Changed (C) - Impact extends beyond vulnerable component', zh: '已变更(C)- 影响超出漏洞组件' },
|
||||
]
|
||||
const C_OPTIONS: MetricOption[] = [
|
||||
{ value: 'N', en: 'None (N) - No confidentiality impact', zh: '无(N)- 无机密性影响' },
|
||||
{ value: 'L', en: 'Low (L) - Limited confidentiality loss', zh: '低(L)- 机密性有限损失' },
|
||||
{ value: 'H', en: 'High (H) - Complete confidentiality loss', zh: '高(H)- 机密性完全损失' },
|
||||
]
|
||||
const I_OPTIONS = C_OPTIONS
|
||||
const A_OPTIONS = C_OPTIONS
|
||||
|
||||
const METRIC_SECTIONS: { key: MetricKey; options: MetricOption[] }[] = [
|
||||
{ key: 'AV', options: AV_OPTIONS },
|
||||
{ key: 'AC', options: AC_OPTIONS },
|
||||
{ key: 'PR', options: PR_OPTIONS },
|
||||
{ key: 'UI', options: UI_OPTIONS },
|
||||
{ key: 'S', options: S_OPTIONS },
|
||||
{ key: 'C', options: C_OPTIONS },
|
||||
{ key: 'I', options: I_OPTIONS },
|
||||
{ key: 'A', options: A_OPTIONS },
|
||||
]
|
||||
|
||||
const METRIC_TITLE_KEYS: Record<MetricKey, string> = {
|
||||
AV: 'attack_vector',
|
||||
AC: 'attack_complexity',
|
||||
PR: 'privileges_required',
|
||||
UI: 'user_interaction',
|
||||
S: 'scope',
|
||||
C: 'confidentiality_impact',
|
||||
I: 'integrity_impact',
|
||||
A: 'availability_impact',
|
||||
}
|
||||
|
||||
/** CVSS v3.1 权重表(PR 权重随 Scope 变化,单独处理) */
|
||||
const WEIGHTS: Record<string, Record<string, number>> = {
|
||||
AV: { N: 0.85, A: 0.62, L: 0.55, P: 0.2 },
|
||||
AC: { L: 0.77, H: 0.44 },
|
||||
UI: { N: 0.85, R: 0.62 },
|
||||
C: { N: 0, L: 0.22, H: 0.56 },
|
||||
I: { N: 0, L: 0.22, H: 0.56 },
|
||||
A: { N: 0, L: 0.22, H: 0.56 },
|
||||
}
|
||||
|
||||
const metrics = reactive<Record<MetricKey, string>>({
|
||||
AV: 'N',
|
||||
AC: 'L',
|
||||
PR: 'N',
|
||||
UI: 'R',
|
||||
S: 'C',
|
||||
C: 'L',
|
||||
I: 'L',
|
||||
A: 'N',
|
||||
})
|
||||
|
||||
const roundUp = (value: number): number => Math.ceil(value * 10) / 10
|
||||
|
||||
const baseScore = computed<number>(() => {
|
||||
const av = WEIGHTS.AV[metrics.AV]
|
||||
const ac = WEIGHTS.AC[metrics.AC]
|
||||
const ui = WEIGHTS.UI[metrics.UI]
|
||||
const prTable = metrics.S === 'C'
|
||||
? { N: 0.85, L: 0.68, H: 0.5 }
|
||||
: { N: 0.85, L: 0.62, H: 0.27 }
|
||||
const pr = prTable[metrics.PR as keyof typeof prTable]
|
||||
const c = WEIGHTS.C[metrics.C]
|
||||
const i = WEIGHTS.I[metrics.I]
|
||||
const a = WEIGHTS.A[metrics.A]
|
||||
|
||||
const iss = 1 - (1 - c) * (1 - i) * (1 - a)
|
||||
const impact = metrics.S === 'C'
|
||||
? 7.52 * (iss - 0.029) - 3.25 * Math.pow(iss - 0.02, 15)
|
||||
: 6.42 * iss
|
||||
const exploitability = 8.22 * av * ac * pr * ui
|
||||
const raw = metrics.S === 'C' ? 1.08 * (impact + exploitability) : impact + exploitability
|
||||
return roundUp(Math.min(Math.max(raw, 0), 10))
|
||||
})
|
||||
|
||||
const baseSeverity = computed<string>(() => {
|
||||
const s = baseScore.value
|
||||
if (s === 0) return 'NONE'
|
||||
if (s < 4) return 'LOW'
|
||||
if (s < 7) return 'MEDIUM'
|
||||
if (s < 9) return 'HIGH'
|
||||
return 'CRITICAL'
|
||||
})
|
||||
|
||||
const vectorString = computed<string>(
|
||||
() => `CVSS:3.1/AV:${metrics.AV}/AC:${metrics.AC}/PR:${metrics.PR}/UI:${metrics.UI}/S:${metrics.S}/C:${metrics.C}/I:${metrics.I}/A:${metrics.A}`,
|
||||
)
|
||||
|
||||
const scoreColor = computed<string>(() => {
|
||||
const s = baseScore.value
|
||||
if (s >= 9) return 'text-danger'
|
||||
if (s >= 4) return 'text-warning'
|
||||
return 'text-success'
|
||||
})
|
||||
|
||||
watch([baseScore, baseSeverity, vectorString], () => {
|
||||
// 输出与存量数据一致的结构(小写指标键 + score/severity/vector),
|
||||
// 同时保留旧键 baseScore/baseSeverity/vectorString 以兼容两端读取
|
||||
emit('update:modelValue', {
|
||||
av: metrics.AV,
|
||||
ac: metrics.AC,
|
||||
pr: metrics.PR,
|
||||
ui: metrics.UI,
|
||||
s: metrics.S,
|
||||
c: metrics.C,
|
||||
i: metrics.I,
|
||||
a: metrics.A,
|
||||
score: baseScore.value,
|
||||
severity: baseSeverity.value,
|
||||
vector: vectorString.value,
|
||||
baseScore: baseScore.value,
|
||||
baseSeverity: baseSeverity.value,
|
||||
vectorString: vectorString.value,
|
||||
})
|
||||
}, { immediate: true })
|
||||
|
||||
function onChange(key: MetricKey, event: Event): void {
|
||||
metrics[key] = (event.target as HTMLSelectElement).value
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<div v-for="section in METRIC_SECTIONS" :key="section.key">
|
||||
<label class="block text-sm font-medium text-neutral mb-1">
|
||||
{{ t(METRIC_TITLE_KEYS[section.key]) }} *
|
||||
</label>
|
||||
<select
|
||||
:value="metrics[section.key]"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-secondary/50 focus:border-secondary"
|
||||
@change="onChange(section.key, $event)"
|
||||
>
|
||||
<option v-for="opt in section.options" :key="opt.value" :value="opt.value">
|
||||
{{ optionLabel(opt) }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 计算出的 CVSS 评分 -->
|
||||
<div class="bg-light p-4 rounded-lg border border-gray-200">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-sm text-neutral mb-1">{{ t('cvss_base_score') }}</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-2xl font-bold" :class="scoreColor">{{ baseScore.toFixed(1) }}</span>
|
||||
<CvssBadge :severity="baseSeverity" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-neutral mb-1">{{ t('cvss_vector_string') }}</div>
|
||||
<div class="text-sm font-mono bg-white px-2 py-1 rounded border border-gray-200 break-all">
|
||||
{{ vectorString }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from '@/i18n'
|
||||
import MarkdownView from './MarkdownView.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
placeholder?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const activeTab = ref<'edit' | 'preview'>('edit')
|
||||
|
||||
function onInput(event: Event): void {
|
||||
emit('update:modelValue', (event.target as HTMLTextAreaElement).value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="markdown-editor w-full">
|
||||
<div class="flex border-b border-gray-200">
|
||||
<div
|
||||
class="editor-tab flex-1 text-center"
|
||||
:class="activeTab === 'edit' ? 'active' : ''"
|
||||
@click="activeTab = 'edit'"
|
||||
>
|
||||
{{ t('editor_edit_tab') }}
|
||||
</div>
|
||||
<div
|
||||
class="editor-tab flex-1 text-center"
|
||||
:class="activeTab === 'preview' ? 'active' : ''"
|
||||
@click="activeTab = 'preview'"
|
||||
>
|
||||
{{ t('editor_preview_tab') }}
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
v-if="activeTab === 'edit'"
|
||||
:value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
class="editor-content"
|
||||
@input="onInput"
|
||||
></textarea>
|
||||
<div v-else class="preview-content">
|
||||
<MarkdownView :content="modelValue" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { marked } from 'marked'
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
const props = defineProps<{
|
||||
content?: string | null
|
||||
}>()
|
||||
|
||||
/** Markdown → 安全 HTML(marked 渲染 + DOMPurify 消毒,防 XSS) */
|
||||
const html = computed<string>(() => {
|
||||
const source = props.content || ''
|
||||
if (!source.trim()) return ''
|
||||
const raw = marked.parse(source) as string
|
||||
return DOMPurify.sanitize(raw)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="markdown-content" v-html="html"></div>
|
||||
</template>
|
||||
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer class="bg-white py-12 mt-12">
|
||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||
<div>
|
||||
<div class="flex items-center space-x-2 mb-4">
|
||||
<i class="fa fa-shield text-primary text-xl"></i>
|
||||
<span class="text-lg font-bold text-primary">{{ t('sve_database') }}</span>
|
||||
</div>
|
||||
<p class="text-sm text-neutral mb-4">{{ t('footer_desc') }}</p>
|
||||
<div class="flex space-x-4">
|
||||
<a href="#" class="text-neutral hover:text-primary transition-colors">
|
||||
<i class="fa-brands fa-twitter"></i>
|
||||
</a>
|
||||
<a href="https://www.gvsds.com/GVSDS/" target="_blank" rel="noopener noreferrer" class="text-neutral hover:text-primary transition-colors">
|
||||
<i class="fa-brands fa-github"></i>
|
||||
</a>
|
||||
<a href="#" class="text-neutral hover:text-primary transition-colors">
|
||||
<i class="fa-brands fa-linkedin"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-semibold text-dark mb-4">{{ t('footer_resources') }}</h4>
|
||||
<ul class="space-y-2 text-sm">
|
||||
<li><router-link to="/browse" class="text-neutral hover:text-secondary transition-colors">{{ t('documentation') }}</router-link></li>
|
||||
<li><router-link to="/browse" class="text-neutral hover:text-secondary transition-colors">{{ t('api') }}</router-link></li>
|
||||
<li><router-link to="/browse" class="text-neutral hover:text-secondary transition-colors">{{ t('bulk_data') }}</router-link></li>
|
||||
<li><router-link to="/browse" class="text-neutral hover:text-secondary transition-colors">{{ t('statistics') }}</router-link></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-semibold text-dark mb-4">{{ t('footer_information') }}</h4>
|
||||
<ul class="space-y-2 text-sm">
|
||||
<li><router-link to="/" class="text-neutral hover:text-secondary transition-colors">{{ t('about_sve') }}</router-link></li>
|
||||
<li><router-link to="/" class="text-neutral hover:text-secondary transition-colors">{{ t('cve_compatibility') }}</router-link></li>
|
||||
<li><router-link to="/browse" class="text-neutral hover:text-secondary transition-colors">{{ t('vuln_metrics') }}</router-link></li>
|
||||
<li><router-link to="/" class="text-neutral hover:text-secondary transition-colors">{{ t('faq') }}</router-link></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="font-semibold text-dark mb-4">{{ t('footer_contact') }}</h4>
|
||||
<ul class="space-y-2 text-sm">
|
||||
<li class="flex items-start">
|
||||
<i class="fa fa-envelope text-secondary mt-1 mr-2"></i>
|
||||
<a href="mailto:sve@gvsds.com" class="text-neutral hover:text-secondary transition-colors">sve@gvsds.com</a>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<i class="fa fa-file-text text-secondary mt-1 mr-2"></i>
|
||||
<a href="mailto:sve@gvsds.org?subject=[WARNING]%20Report%20A%20Vulnerability%20!" class="text-neutral hover:text-secondary transition-colors">
|
||||
{{ t('report_vuln') }}
|
||||
</a>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<i class="fa fa-info-circle text-secondary mt-1 mr-2"></i>
|
||||
<a href="https://doc.gvsds.com/6e845a45fc493c66a23a9361bce1464d" target="_blank" rel="noopener noreferrer" class="text-neutral hover:text-secondary transition-colors">
|
||||
{{ t('security_policy') }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-200 mt-8 pt-6 flex flex-col md:flex-row justify-between items-center">
|
||||
<p class="text-sm text-neutral">{{ t('copyright') }}</p>
|
||||
<div class="flex space-x-6 mt-4 md:mt-0 text-sm">
|
||||
<a href="https://doc.gvsds.com/fc084ab20a283208adb19033ca4de173" target="_blank" rel="noopener noreferrer" class="text-neutral hover:text-secondary transition-colors">
|
||||
{{ t('privacy_policy') }}
|
||||
</a>
|
||||
<a href="https://doc.gvsds.com/1f07d3b8cec43a2ab9892a15b150bdd6" target="_blank" rel="noopener noreferrer" class="text-neutral hover:text-secondary transition-colors">
|
||||
{{ t('terms_service') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
@@ -0,0 +1,144 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
const { locale, t, setLocale } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const keyword = ref('')
|
||||
const mobileMenuOpen = ref(false)
|
||||
|
||||
const isActive = (name: string): boolean => route.name === name
|
||||
|
||||
function goBrowse(): void {
|
||||
const kw = keyword.value.trim()
|
||||
if (kw) {
|
||||
router.push({ name: 'Browse', query: { Keyword: kw } })
|
||||
} else {
|
||||
router.push({ name: 'Browse' })
|
||||
}
|
||||
}
|
||||
|
||||
const navItems = computed(() => [
|
||||
{ name: 'Home', label: t('home'), active: isActive('Home') },
|
||||
{ name: 'Browse', label: t('browse'), active: isActive('Browse') || isActive('Detail') },
|
||||
{ name: 'Write', label: t('create_sve'), active: isActive('Write') },
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="bg-white shadow-sm sticky top-0 z-50">
|
||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center py-4">
|
||||
<!-- Logo -->
|
||||
<router-link to="/" class="flex items-center space-x-2">
|
||||
<i class="fa fa-shield text-primary text-2xl"></i>
|
||||
<span class="text-xl font-bold text-primary">{{ t('sve_database') }}</span>
|
||||
</router-link>
|
||||
|
||||
<!-- 桌面端语言切换 -->
|
||||
<div class="hidden md:flex items-center space-x-2 mr-4">
|
||||
<button
|
||||
class="lang-switch-btn"
|
||||
:class="locale === 'en' ? 'active' : 'inactive'"
|
||||
@click="setLocale('en')"
|
||||
>
|
||||
English
|
||||
</button>
|
||||
<button
|
||||
class="lang-switch-btn"
|
||||
:class="locale === 'zh' ? 'active' : 'inactive'"
|
||||
@click="setLocale('zh')"
|
||||
>
|
||||
中文
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 桌面端导航 -->
|
||||
<nav class="hidden md:flex items-center space-x-8">
|
||||
<router-link
|
||||
v-for="item in navItems"
|
||||
:key="item.name"
|
||||
:to="item.name === 'Write' ? { name: 'Write' } : { name: item.name }"
|
||||
class="transition-colors"
|
||||
:class="item.active ? 'text-primary font-medium' : 'text-neutral hover:text-secondary'"
|
||||
>
|
||||
{{ item.label }}
|
||||
</router-link>
|
||||
<a
|
||||
href="https://doc.gvsds.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-neutral hover:text-secondary transition-colors"
|
||||
>
|
||||
{{ t('documentation') }}
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- 桌面端搜索 -->
|
||||
<div class="relative hidden md:block">
|
||||
<input
|
||||
v-model="keyword"
|
||||
type="text"
|
||||
:placeholder="t('search_sve_placeholder')"
|
||||
class="pl-10 pr-4 py-2 rounded-lg border border-gray-300 input-focus w-64 transition-all"
|
||||
@keydown.enter="goBrowse"
|
||||
/>
|
||||
<i class="fa fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"></i>
|
||||
</div>
|
||||
<!-- 移动端搜索 -->
|
||||
<button class="md:hidden text-neutral hover:text-primary" @click="goBrowse">
|
||||
<i class="fa fa-search text-xl"></i>
|
||||
</button>
|
||||
<!-- 移动端菜单开关 -->
|
||||
<button class="md:hidden text-neutral hover:text-primary" @click="mobileMenuOpen = !mobileMenuOpen">
|
||||
<i class="fa fa-bars text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 移动端导航菜单 -->
|
||||
<div v-show="mobileMenuOpen" class="md:hidden bg-white border-t border-gray-100 py-3">
|
||||
<nav class="flex flex-col space-y-3 px-2">
|
||||
<router-link
|
||||
v-for="item in navItems"
|
||||
:key="item.name"
|
||||
:to="item.name === 'Write' ? { name: 'Write' } : { name: item.name }"
|
||||
class="py-2 px-3 rounded transition-colors"
|
||||
:class="item.active ? 'text-primary font-medium' : 'text-neutral hover:text-secondary'"
|
||||
@click="mobileMenuOpen = false"
|
||||
>
|
||||
{{ item.label }}
|
||||
</router-link>
|
||||
<a
|
||||
href="https://doc.gvsds.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-neutral hover:text-secondary transition-colors py-2 px-3 rounded"
|
||||
>
|
||||
{{ t('documentation') }}
|
||||
</a>
|
||||
<div class="flex items-center space-x-2 pt-1 pb-2">
|
||||
<button
|
||||
class="lang-switch-btn text-xs"
|
||||
:class="locale === 'en' ? 'active' : 'inactive'"
|
||||
@click="setLocale('en')"
|
||||
>
|
||||
EN
|
||||
</button>
|
||||
<button
|
||||
class="lang-switch-btn text-xs"
|
||||
:class="locale === 'zh' ? 'active' : 'inactive'"
|
||||
@click="setLocale('zh')"
|
||||
>
|
||||
中文
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
@@ -0,0 +1,35 @@
|
||||
/** 模块:Chart.js 图表生命周期封装(创建/销毁/按需重绘) */
|
||||
import { onMounted, onUnmounted, type Ref } from 'vue'
|
||||
import { Chart, type ChartConfiguration } from 'chart.js/auto'
|
||||
|
||||
export interface ChartHandle {
|
||||
render: () => void
|
||||
destroy: () => void
|
||||
}
|
||||
|
||||
/** 绑定 canvas 与配置构建函数;数据就绪后调用 render() 重绘 */
|
||||
export function useChart(
|
||||
canvasRef: Ref<HTMLCanvasElement | null>,
|
||||
buildConfig: () => ChartConfiguration,
|
||||
): ChartHandle {
|
||||
let chart: Chart | null = null
|
||||
|
||||
const destroy = (): void => {
|
||||
if (chart) {
|
||||
chart.destroy()
|
||||
chart = null
|
||||
}
|
||||
}
|
||||
|
||||
const render = (): void => {
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas) return
|
||||
destroy()
|
||||
chart = new Chart(canvas, buildConfig())
|
||||
}
|
||||
|
||||
onMounted(() => render())
|
||||
onUnmounted(() => destroy())
|
||||
|
||||
return { render, destroy }
|
||||
}
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>
|
||||
export default component
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/** 模块:SVE 前端轻量 i18n(en / zh) */
|
||||
import { ref, type ComputedRef } from 'vue'
|
||||
import { messages } from './resources'
|
||||
|
||||
export type Locale = 'en' | 'zh'
|
||||
|
||||
const STORAGE_KEY = 'sve-locale'
|
||||
|
||||
const locale = ref<Locale>('en')
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY)
|
||||
if (saved === 'en' || saved === 'zh') locale.value = saved
|
||||
} catch {
|
||||
// localStorage 不可用时保持默认 en
|
||||
}
|
||||
|
||||
/** 切换语言并持久化 */
|
||||
export function setLocale(l: Locale): void {
|
||||
locale.value = l
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, l)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
document.documentElement.lang = l
|
||||
}
|
||||
|
||||
/** 翻译函数:en/zh 字典查找,缺失回退 en,再缺失返回 key 本身 */
|
||||
export function translate(key: string): string {
|
||||
const table = messages[locale.value]
|
||||
return table[key] ?? messages.en[key] ?? key
|
||||
}
|
||||
|
||||
/** 带参数模板替换:tpl 中形如 {from} 的占位符会被替换 */
|
||||
export function translateTpl(key: string, params: Record<string, string | number>): string {
|
||||
let text = translate(key)
|
||||
for (const [name, value] of Object.entries(params)) {
|
||||
text = text.split(`{${name}}`).join(String(value))
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
export interface I18nInstance {
|
||||
locale: typeof locale
|
||||
t: (key: string) => string
|
||||
tpl: (key: string, params: Record<string, string | number>) => string
|
||||
setLocale: (l: Locale) => void
|
||||
}
|
||||
|
||||
/** 组件内使用:const { locale, t } = useI18n() */
|
||||
export function useI18n(): I18nInstance {
|
||||
return {
|
||||
locale: locale as ComputedRef<Locale>,
|
||||
t: translate,
|
||||
tpl: translateTpl,
|
||||
setLocale,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
/** 模块:SVE 前端多语言资源(en / zh) */
|
||||
import type { Locale } from './index'
|
||||
|
||||
export const messages: Record<Locale, Record<string, string>> = {
|
||||
en: {
|
||||
// === 基础导航与通用 ===
|
||||
sve_database: 'SVE Database',
|
||||
home: 'Home',
|
||||
browse: 'Browse',
|
||||
search: 'Search',
|
||||
about: 'About',
|
||||
create_sve: 'Create SVE',
|
||||
documentation: 'Documentation',
|
||||
search_sve_placeholder: 'Search SVE...',
|
||||
|
||||
// === 首页 Hero ===
|
||||
hero_title: 'GVSDS-Owned Projects Security / SMANAGE Vulnerability Exposure Database',
|
||||
hero_desc: 'A comprehensive repository of security vulnerabilities, providing detailed information for researchers, developers, and security professionals.',
|
||||
hero_search_placeholder: 'Search by SVE ID, CWE, keyword, or affected product...',
|
||||
search_btn: 'Search',
|
||||
hero_example_prefix: 'Example:',
|
||||
hero_example_or: 'or',
|
||||
|
||||
// === 统计数据 ===
|
||||
stat_total_vuln: 'Total Vulnerabilities',
|
||||
stat_critical_vuln: 'Critical Vulnerabilities',
|
||||
stat_this_month: 'This Month',
|
||||
stat_cwe_categories: 'CWE Categories',
|
||||
stat_public: 'Publicly Disclosed',
|
||||
|
||||
// === 图表 ===
|
||||
chart_vuln_trends: 'Vulnerability Trends',
|
||||
chart_severity_dist: 'Severity Distribution',
|
||||
chart_status_dist: 'Status Distribution',
|
||||
severity_critical: 'Critical',
|
||||
severity_high: 'High',
|
||||
severity_medium: 'Medium',
|
||||
severity_low: 'Low',
|
||||
severity_none: 'None',
|
||||
vuln_status_public: 'Publicly Disclosed',
|
||||
vuln_status_draft: 'Draft (Not Published)',
|
||||
vuln_status_reserved: 'Reserved (Pending Disclosure)',
|
||||
|
||||
// === 最新 SVE ===
|
||||
latest_sve: 'Latest SVE Entries',
|
||||
view_all: 'View All',
|
||||
table_sve_id: 'SVE ID',
|
||||
table_title: 'Title',
|
||||
table_severity: 'Severity',
|
||||
table_published: 'Published',
|
||||
empty_list: 'No entries yet',
|
||||
|
||||
// === 资源板块 ===
|
||||
resource_doc: 'Documentation',
|
||||
resource_doc_desc: 'Learn how to use the SVE database, understand vulnerability metrics, and interpret security ratings.',
|
||||
read_doc: 'Read Documentation',
|
||||
resource_api: 'API Access',
|
||||
resource_api_desc: 'Integrate SVE data into your applications with our comprehensive REST API and developer tools.',
|
||||
explore_api: 'Explore API',
|
||||
resource_report: 'Report Vulnerability',
|
||||
resource_report_desc: 'Submit a new security vulnerability for inclusion in the SVE database after verification.',
|
||||
submit_report: 'Submit Report',
|
||||
|
||||
// === 创建 SVE ===
|
||||
create_sve_title: 'Create New SVE Entry',
|
||||
create_sve_desc: 'Fill in the following fields to create a new SVE entry. All required fields are marked with an asterisk (*).',
|
||||
json_import_export_note: 'You can import SVE data from JSON or export the current form as JSON',
|
||||
import_json: 'Import JSON',
|
||||
export_json: 'Export JSON',
|
||||
sve_basic_info: '1. SVE Basic Information',
|
||||
sve_id: 'SVE ID *',
|
||||
sve_year_placeholder: 'YYYY',
|
||||
sve_number_placeholder: 'XXXXX',
|
||||
sve_id_format_note: 'Format: SVE-YYYY-NNNNN (e.g., SVE-2024-00012)',
|
||||
publish_date: 'Publish Date *',
|
||||
vuln_title: 'Vulnerability Title *',
|
||||
vuln_title_placeholder: 'e.g., Cross-Site Scripting Vulnerability in WebFramework v2.3.0',
|
||||
vuln_status: 'Vulnerability Status *',
|
||||
vuln_description: '2. Vulnerability Description *',
|
||||
vuln_description_note: 'Describe the vulnerability in detail, including technical details, attack scenarios, and potential impacts. Support Markdown formatting.',
|
||||
editor_edit_tab: 'Edit',
|
||||
editor_preview_tab: 'Preview',
|
||||
affected_products: '3. Affected Products *',
|
||||
add_product: 'Add Product',
|
||||
affected_products_note: 'List all products affected by this vulnerability. Include product name, version, and whether it is fixed in later versions.',
|
||||
product_name: 'Product Name *',
|
||||
product_name_placeholder: 'e.g., WebFramework',
|
||||
product_version: 'Affected Version(s) *',
|
||||
product_version_placeholder: 'e.g., v2.3.0, v2.2.x',
|
||||
fixed_version: 'Fixed Version (if any)',
|
||||
fixed_version_placeholder: 'e.g., v2.3.1, None',
|
||||
remove_product: 'Remove',
|
||||
cvss_rating: '4. CVSS v3.1 Rating *',
|
||||
cvss_rating_note: 'Select the CVSS v3.1 metrics to calculate the base score. Refer to the SVE documentation for guidance on selecting appropriate metrics.',
|
||||
attack_vector: 'Attack Vector (AV) *',
|
||||
attack_complexity: 'Attack Complexity (AC) *',
|
||||
privileges_required: 'Privileges Required (PR) *',
|
||||
user_interaction: 'User Interaction (UI) *',
|
||||
scope: 'Scope (S) *',
|
||||
confidentiality_impact: 'Confidentiality (C) *',
|
||||
integrity_impact: 'Integrity (I) *',
|
||||
availability_impact: 'Availability (A) *',
|
||||
cvss_base_score: 'CVSS v3.1 Base Score',
|
||||
cvss_vector_string: 'CVSS Vector String',
|
||||
solutions_mitigations: '5. Solutions and Mitigations',
|
||||
solutions_mitigations_note: 'Provide solutions, mitigations, and workarounds for the vulnerability. Support Markdown formatting (e.g., code blocks for workaround code).',
|
||||
references: '6. References',
|
||||
add_reference: 'Add Reference',
|
||||
references_note: 'Add references related to the vulnerability (e.g., vendor advisories, technical papers, SVE documentation links). Do not include CVE or CWE references.',
|
||||
reference_type: 'Reference Type *',
|
||||
ref_vendor: 'Vendor Advisory',
|
||||
ref_tech_doc: 'Technical Documentation',
|
||||
ref_sve_guide: 'SVE Guidance',
|
||||
ref_research: 'Research Paper',
|
||||
ref_other: 'Other',
|
||||
reference_title: 'Reference Title',
|
||||
reference_title_placeholder: 'e.g., WebFramework Security Advisory 2024-01',
|
||||
reference_url: 'Reference URL *',
|
||||
reference_url_placeholder: 'https://example.com/security/advisory',
|
||||
remove_reference: 'Remove',
|
||||
related_sve: '7. Related SVE Entries',
|
||||
add_related_sve: 'Add Related SVE',
|
||||
related_sve_note: 'Add other SVE entries that are related to this vulnerability (e.g., same product family, similar attack vector).',
|
||||
related_sve_id_placeholder: 'SVE-YYYY-NNNNN',
|
||||
related_same_product: 'Same Product',
|
||||
related_similar_vuln: 'Similar Vulnerability',
|
||||
related_same_vendor: 'Same Vendor',
|
||||
related_other: 'Other Relationship',
|
||||
remove_related_sve: 'Remove Related SVE',
|
||||
save_draft: 'Save as Draft',
|
||||
submit_sve: 'Submit SVE Entry',
|
||||
submit_success: 'SVE entry submitted successfully!',
|
||||
submit_failed: 'Submission failed',
|
||||
import_success: 'JSON imported successfully',
|
||||
import_failed: 'Invalid JSON file',
|
||||
validation_required: 'Please fill in all required fields',
|
||||
|
||||
// === SVE 详情 ===
|
||||
copy_sve_id: 'Copy SVE ID',
|
||||
copied: 'Copied!',
|
||||
description: 'Description',
|
||||
vector: 'Vector:',
|
||||
vulnerability_details: 'Vulnerability Details',
|
||||
status: 'Status',
|
||||
published: 'Published:',
|
||||
last_modified: 'Last Modified:',
|
||||
related_sve_entries: 'Related SVE Entries',
|
||||
no_related_sves: 'No related SVE entries found.',
|
||||
view_all_related: 'View All Related',
|
||||
no_fixed_version: 'No fixed version',
|
||||
back_to_home: 'Back to Home',
|
||||
sve_not_found: 'SVE entry not found',
|
||||
|
||||
// === 列表页 ===
|
||||
sve_list_title: 'SVE Vulnerability List',
|
||||
sve_list_desc: 'Display all publicly disclosed SVE vulnerabilities with multi-dimensional filtering and pagination',
|
||||
total_vulns: 'Total Vulnerabilities:',
|
||||
current_page: 'Current Page:',
|
||||
advanced_filter: 'Advanced Filter',
|
||||
filter_title: 'Vulnerability Title',
|
||||
filter_title_placeholder: 'Search by keyword',
|
||||
filter_cvss_severity: 'CVSS Severity',
|
||||
filter_publish_year: 'Publish Year',
|
||||
filter_page_size: 'Items per Page',
|
||||
filter_all: 'All',
|
||||
apply_filter: 'Apply Filters',
|
||||
reset_filter: 'Reset Filters',
|
||||
cvss_score: 'CVSS Score',
|
||||
actions: 'Actions',
|
||||
loading_data: 'Loading data...',
|
||||
no_results: 'No matching vulnerabilities found',
|
||||
reset_filters: 'Reset filters',
|
||||
load_error: 'Failed to load data',
|
||||
retry: 'Retry',
|
||||
view_details: 'View Details',
|
||||
showing_results: 'Showing {from} - {to} of {total} entries',
|
||||
|
||||
// === 页脚 ===
|
||||
footer_desc: 'A comprehensive database of security vulnerabilities and exposures, maintained by the Security Research Team.',
|
||||
footer_resources: 'Resources',
|
||||
api: 'API',
|
||||
bulk_data: 'Bulk Data',
|
||||
statistics: 'Statistics',
|
||||
footer_information: 'Information',
|
||||
about_sve: 'About SVE',
|
||||
cve_compatibility: 'CVE Compatibility',
|
||||
vuln_metrics: 'Vulnerability Metrics',
|
||||
faq: 'FAQ',
|
||||
footer_contact: 'Contact',
|
||||
report_vuln: 'Report a Vulnerability',
|
||||
security_policy: 'Security Policy',
|
||||
copyright: '© 2025 SVE Database. All rights reserved.',
|
||||
privacy_policy: 'Privacy Policy',
|
||||
terms_service: 'Terms of Service',
|
||||
|
||||
// === 错误页 ===
|
||||
error_404_title: 'Page Not Found',
|
||||
error_404_desc: "The page you're looking for doesn't exist or has been moved. Please check the URL or return to the homepage.",
|
||||
error_back_home: 'Back to Homepage',
|
||||
error_search_again: 'Refresh Page',
|
||||
},
|
||||
zh: {
|
||||
// === 基础导航与通用 ===
|
||||
sve_database: 'SVE 数据库',
|
||||
home: '首页',
|
||||
browse: '浏览',
|
||||
search: '搜索',
|
||||
about: '关于我们',
|
||||
create_sve: '创建 SVE',
|
||||
documentation: '文档',
|
||||
search_sve_placeholder: '搜索 SVE...',
|
||||
|
||||
// === 首页 Hero ===
|
||||
hero_title: 'GVSDS 旗下项目安全(SMANAGE)漏洞暴露数据库',
|
||||
hero_desc: '全面的安全漏洞知识库,为研究人员、开发人员和安全专业人员提供详细信息。',
|
||||
hero_search_placeholder: '按 SVE ID、CWE、关键词或受影响产品搜索...',
|
||||
search_btn: '搜索',
|
||||
hero_example_prefix: '示例:',
|
||||
hero_example_or: '或',
|
||||
|
||||
// === 统计数据 ===
|
||||
stat_total_vuln: '漏洞总数',
|
||||
stat_critical_vuln: '严重漏洞数',
|
||||
stat_this_month: '本月新增',
|
||||
stat_cwe_categories: 'CWE 类别数',
|
||||
stat_public: '已公开披露',
|
||||
|
||||
// === 图表 ===
|
||||
chart_vuln_trends: '漏洞趋势',
|
||||
chart_severity_dist: '漏洞严重程度分布',
|
||||
chart_status_dist: '漏洞状态分布',
|
||||
severity_critical: '严重',
|
||||
severity_high: '高危',
|
||||
severity_medium: '中等',
|
||||
severity_low: '低级',
|
||||
severity_none: '无',
|
||||
vuln_status_public: '已公开披露',
|
||||
vuln_status_draft: '草稿(未发布)',
|
||||
vuln_status_reserved: '保留(待披露)',
|
||||
|
||||
// === 最新 SVE ===
|
||||
latest_sve: '最新 SVE 条目',
|
||||
view_all: '查看全部',
|
||||
table_sve_id: 'SVE 编号',
|
||||
table_title: '标题',
|
||||
table_severity: '严重程度',
|
||||
table_published: '发布时间',
|
||||
empty_list: '暂无条目',
|
||||
|
||||
// === 资源板块 ===
|
||||
resource_doc: '文档中心',
|
||||
resource_doc_desc: '学习如何使用 SVE 数据库、理解漏洞指标以及解读安全评级。',
|
||||
read_doc: '阅读文档',
|
||||
resource_api: 'API 接口',
|
||||
resource_api_desc: '通过我们全面的 REST API 和开发工具,将 SVE 数据集成到您的应用中。',
|
||||
explore_api: '探索 API',
|
||||
resource_report: '漏洞上报',
|
||||
resource_report_desc: '提交新的安全漏洞,经审核后将纳入 SVE 数据库。',
|
||||
submit_report: '提交报告',
|
||||
|
||||
// === 创建 SVE ===
|
||||
create_sve_title: '创建新的 SVE 条目',
|
||||
create_sve_desc: '填写以下字段以创建新的 SVE 条目。所有必填字段均标有星号 (*)。',
|
||||
json_import_export_note: '您可以从 JSON 导入 SVE 数据,或将当前表单导出为 JSON',
|
||||
import_json: '导入 JSON',
|
||||
export_json: '导出 JSON',
|
||||
sve_basic_info: '1. SVE 基础信息',
|
||||
sve_id: 'SVE 编号 *',
|
||||
sve_year_placeholder: '年份',
|
||||
sve_number_placeholder: '编号(5位)',
|
||||
sve_id_format_note: '格式:SVE-年份-5位编号(例如:SVE-2024-00012)',
|
||||
publish_date: '发布日期 *',
|
||||
vuln_title: '漏洞标题 *',
|
||||
vuln_title_placeholder: '例如:WebFramework v2.3.0 中的跨站脚本漏洞',
|
||||
vuln_status: '漏洞状态 *',
|
||||
vuln_description: '2. 漏洞描述 *',
|
||||
vuln_description_note: '详细描述漏洞,包括技术细节、攻击场景和潜在影响。支持 Markdown 格式。',
|
||||
editor_edit_tab: '编辑',
|
||||
editor_preview_tab: '预览',
|
||||
affected_products: '3. 受影响产品 *',
|
||||
add_product: '添加产品',
|
||||
affected_products_note: '列出所有受此漏洞影响的产品,包括产品名称、版本以及后续版本是否已修复。',
|
||||
product_name: '产品名称 *',
|
||||
product_name_placeholder: '例如:WebFramework',
|
||||
product_version: '受影响版本 *',
|
||||
product_version_placeholder: '例如:v2.3.0、v2.2.x',
|
||||
fixed_version: '修复版本(如有)',
|
||||
fixed_version_placeholder: '例如:v2.3.1、无',
|
||||
remove_product: '删除',
|
||||
cvss_rating: '4. CVSS v3.1 评分 *',
|
||||
cvss_rating_note: '选择 CVSS v3.1 指标以计算基础评分。有关选择适当指标的指导,请参考 SVE 文档。',
|
||||
attack_vector: '攻击向量(AV)*',
|
||||
attack_complexity: '攻击复杂度(AC)*',
|
||||
privileges_required: '所需权限(PR)*',
|
||||
user_interaction: '用户交互(UI)*',
|
||||
scope: '影响范围(S)*',
|
||||
confidentiality_impact: '机密性(C)*',
|
||||
integrity_impact: '完整性(I)*',
|
||||
availability_impact: '可用性(A)*',
|
||||
cvss_base_score: 'CVSS v3.1 基础评分',
|
||||
cvss_vector_string: 'CVSS 向量字符串',
|
||||
solutions_mitigations: '5. 解决方案与缓解措施',
|
||||
solutions_mitigations_note: '提供漏洞的解决方案、缓解措施和临时规避方法。支持 Markdown 格式(例如,用于规避代码的代码块)。',
|
||||
references: '6. 参考资料',
|
||||
add_reference: '添加参考资料',
|
||||
references_note: '添加与漏洞相关的参考资料(例如,厂商公告、技术文档、SVE 文档链接)。请勿包含 CVE 或 CWE 参考资料。',
|
||||
reference_type: '参考资料类型 *',
|
||||
ref_vendor: '厂商公告',
|
||||
ref_tech_doc: '技术文档',
|
||||
ref_sve_guide: 'SVE 指导文档',
|
||||
ref_research: '研究论文',
|
||||
ref_other: '其他',
|
||||
reference_title: '参考资料标题',
|
||||
reference_title_placeholder: '例如:WebFramework 安全公告 2024-01',
|
||||
reference_url: '参考资料 URL *',
|
||||
reference_url_placeholder: 'https://example.com/security/advisory',
|
||||
remove_reference: '删除',
|
||||
related_sve: '7. 相关 SVE 条目',
|
||||
add_related_sve: '添加相关 SVE',
|
||||
related_sve_note: '添加与此漏洞相关的其他 SVE 条目(例如,同一产品系列、类似攻击向量)。',
|
||||
related_sve_id_placeholder: 'SVE-年份-5位编号',
|
||||
related_same_product: '同一产品',
|
||||
related_similar_vuln: '类似漏洞',
|
||||
related_same_vendor: '同一厂商',
|
||||
related_other: '其他关系',
|
||||
remove_related_sve: '删除相关 SVE',
|
||||
save_draft: '保存为草稿',
|
||||
submit_sve: '提交 SVE 条目',
|
||||
submit_success: 'SVE 条目提交成功!',
|
||||
submit_failed: '提交失败',
|
||||
import_success: 'JSON 导入成功',
|
||||
import_failed: '无效的 JSON 文件',
|
||||
validation_required: '请填写所有必填字段',
|
||||
|
||||
// === SVE 详情 ===
|
||||
copy_sve_id: '复制 SVE 编号',
|
||||
copied: '已复制!',
|
||||
sve_details: 'SVE 详情',
|
||||
description: '漏洞描述',
|
||||
vector: '向量:',
|
||||
vulnerability_details: '漏洞详情',
|
||||
status: '状态',
|
||||
published: '发布时间:',
|
||||
last_modified: '最后更新:',
|
||||
related_sve_entries: '相关 SVE 条目',
|
||||
no_related_sves: '未找到相关 SVE 条目。',
|
||||
view_all_related: '查看全部相关',
|
||||
no_fixed_version: '无修复版本',
|
||||
back_to_home: '返回首页',
|
||||
sve_not_found: '未找到该 SVE 条目',
|
||||
|
||||
// === 列表页 ===
|
||||
sve_list_title: 'SVE漏洞列表',
|
||||
sve_list_desc: '展示所有已公开的SVE漏洞信息,支持多维度筛选和分页查询',
|
||||
total_vulns: '总漏洞数:',
|
||||
current_page: '当前页:',
|
||||
advanced_filter: '高级筛选',
|
||||
filter_title: '漏洞标题',
|
||||
filter_title_placeholder: '输入关键词搜索',
|
||||
filter_cvss_severity: 'CVSS严重级别',
|
||||
filter_publish_year: '发布年份',
|
||||
filter_page_size: '每页条数',
|
||||
filter_all: '全部',
|
||||
apply_filter: '应用筛选',
|
||||
reset_filter: '重置筛选',
|
||||
cvss_score: 'CVSS评分',
|
||||
actions: '操作',
|
||||
loading_data: '加载数据中...',
|
||||
no_results: '未找到符合条件的漏洞',
|
||||
reset_filters: '重置筛选条件',
|
||||
load_error: '数据加载失败',
|
||||
retry: '重试',
|
||||
view_details: '查看详情',
|
||||
showing_results: '显示 {from} - {to} 条,共 {total} 条',
|
||||
|
||||
// === 页脚 ===
|
||||
footer_desc: '由安全研究团队维护的全面安全漏洞与暴露数据库。',
|
||||
footer_resources: '资源中心',
|
||||
api: 'API接口',
|
||||
bulk_data: '批量数据',
|
||||
statistics: '统计数据',
|
||||
footer_information: '相关信息',
|
||||
about_sve: '关于SVE',
|
||||
cve_compatibility: 'CVE 兼容性',
|
||||
vuln_metrics: '漏洞指标',
|
||||
faq: '常见问题',
|
||||
footer_contact: '联系我们',
|
||||
report_vuln: '上报漏洞',
|
||||
security_policy: '安全政策',
|
||||
copyright: '© 2025 SVE数据库 保留所有权利。',
|
||||
privacy_policy: '隐私政策',
|
||||
terms_service: '服务条款',
|
||||
|
||||
// === 错误页 ===
|
||||
error_404_title: '页面未找到',
|
||||
error_404_desc: '您访问的页面不存在或已被移动,请检查URL地址或返回首页。',
|
||||
error_back_home: '返回首页',
|
||||
error_search_again: '重新访问',
|
||||
},
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { createApp } from 'vue'
|
||||
import '@fortawesome/fontawesome-free/css/all.min.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './styles/global.css'
|
||||
|
||||
// hash 路由模式兜底:pathname 应恒为 /
|
||||
if (window.location.pathname !== '/' && window.location.hash) {
|
||||
window.history.replaceState({}, '', '/' + window.location.hash)
|
||||
}
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,50 @@
|
||||
/** 模块:SVE 前端路由(hash 模式,适配 frp 部署,无需后端 rewrite) */
|
||||
import { createRouter, createWebHashHistory, type RouteRecordRaw } from 'vue-router'
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Home',
|
||||
component: () => import('@/views/Home.vue'),
|
||||
meta: { title: 'SVE Database' },
|
||||
},
|
||||
{
|
||||
path: '/browse',
|
||||
name: 'Browse',
|
||||
component: () => import('@/views/Browse.vue'),
|
||||
meta: { title: 'SVE Vulnerability List' },
|
||||
},
|
||||
{
|
||||
path: '/sve/:id',
|
||||
name: 'Detail',
|
||||
component: () => import('@/views/Detail.vue'),
|
||||
meta: { title: 'SVE Details' },
|
||||
},
|
||||
{
|
||||
path: '/write',
|
||||
name: 'Write',
|
||||
component: () => import('@/views/Write.vue'),
|
||||
meta: { title: 'Create New SVE Entry' },
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'NotFound',
|
||||
component: () => import('@/views/NotFound.vue'),
|
||||
meta: { title: '404 Not Found' },
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes,
|
||||
scrollBehavior(_to, _from, savedPosition) {
|
||||
return savedPosition || { top: 0 }
|
||||
},
|
||||
})
|
||||
|
||||
router.afterEach((to) => {
|
||||
const title = to.meta.title as string | undefined
|
||||
document.title = title ? `${title} - SVE Database` : 'SVE Database'
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,222 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-primary: #0f3460;
|
||||
--color-secondary: #1a73e8;
|
||||
--color-danger: #d93025;
|
||||
--color-warning: #f4b400;
|
||||
--color-success: #1e8e3e;
|
||||
--color-neutral: #5f6368;
|
||||
--color-light: #f8f9fa;
|
||||
--color-dark: #202124;
|
||||
--font-inter: 'Inter', ui-sans-serif, system-ui, sans-serif;
|
||||
--animate-fade-in: fadeIn 0.3s ease-in-out;
|
||||
--animate-slide-up: slideUp 0.4s ease-out;
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes slideUp {
|
||||
from { transform: translateY(10px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
@apply font-inter bg-gray-50 text-dark antialiased;
|
||||
}
|
||||
|
||||
/* ===== 通用工具类 ===== */
|
||||
|
||||
.shadow-soft {
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.bg-gradient-primary {
|
||||
background: linear-gradient(135deg, #0f3460 0%, #1a73e8 100%);
|
||||
}
|
||||
|
||||
.lang-switch-btn {
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.lang-switch-btn.active {
|
||||
background-color: #1a73e8;
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
}
|
||||
.lang-switch-btn.inactive {
|
||||
background-color: #f8f9fa;
|
||||
color: #5f6368;
|
||||
}
|
||||
.lang-switch-btn.inactive:hover {
|
||||
background-color: #e8f0fe;
|
||||
color: #1a73e8;
|
||||
}
|
||||
|
||||
.pagination-btn {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid #d1d5db;
|
||||
color: #5f6368;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.pagination-btn:hover:not(:disabled) {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
.pagination-btn.active {
|
||||
background-color: #1a73e8;
|
||||
color: white;
|
||||
border-color: #1a73e8;
|
||||
}
|
||||
.pagination-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.severity-badge {
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.severity-badge.critical { background-color: rgba(217, 48, 37, 0.1); color: #d93025; }
|
||||
.severity-badge.high { background-color: rgba(244, 180, 0, 0.15); color: #b06000; }
|
||||
.severity-badge.medium { background-color: rgba(244, 180, 0, 0.1); color: #f4b400; }
|
||||
.severity-badge.low { background-color: rgba(30, 142, 62, 0.1); color: #1e8e3e; }
|
||||
.severity-badge.none { background-color: rgba(95, 99, 104, 0.1); color: #5f6368; }
|
||||
|
||||
.input-focus {
|
||||
outline: none;
|
||||
}
|
||||
.input-focus:focus {
|
||||
border-color: #1a73e8;
|
||||
box-shadow: 0 0 0 2px rgba(26, 115, 232, 0.5);
|
||||
}
|
||||
|
||||
.filter-card {
|
||||
background-color: white;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* ===== Markdown 编辑器 ===== */
|
||||
|
||||
.markdown-editor {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
background-color: #fff;
|
||||
}
|
||||
.editor-tab {
|
||||
padding: 0.5rem 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
color: #5f6368;
|
||||
}
|
||||
.editor-tab.active {
|
||||
border-bottom: 2px solid #1a73e8;
|
||||
color: #1a73e8;
|
||||
font-weight: 500;
|
||||
}
|
||||
.editor-content {
|
||||
min-height: 200px;
|
||||
resize: vertical;
|
||||
border: 0;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.6;
|
||||
background-color: #fff;
|
||||
}
|
||||
.editor-content:focus {
|
||||
outline: none;
|
||||
}
|
||||
.preview-content {
|
||||
min-height: 200px;
|
||||
padding: 1rem;
|
||||
background-color: #fafafa;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
/* ===== Markdown 渲染样式 ===== */
|
||||
|
||||
.markdown-content h1,
|
||||
.markdown-content h2,
|
||||
.markdown-content h3,
|
||||
.markdown-content h4,
|
||||
.markdown-content h5,
|
||||
.markdown-content h6 {
|
||||
color: #202124;
|
||||
font-weight: 600;
|
||||
}
|
||||
.markdown-content h1 { font-size: 1.5rem; margin: 1.25rem 0 0.75rem; }
|
||||
.markdown-content h2 { font-size: 1.35rem; margin: 1.5rem 0 0.75rem; padding-bottom: 0.5rem; border-bottom: 1px solid #e5e7eb; }
|
||||
.markdown-content h3 { font-size: 1.2rem; margin: 1.25rem 0 0.5rem; }
|
||||
.markdown-content h4, .markdown-content h5, .markdown-content h6 { font-size: 1.05rem; margin: 1rem 0 0.5rem; }
|
||||
.markdown-content p { color: #5f6368; margin: 0.5rem 0; line-height: 1.7; }
|
||||
.markdown-content a { color: #0f3460; text-decoration: underline; }
|
||||
.markdown-content ul { list-style: disc; margin: 0.5rem 0 0.5rem 1.5rem; }
|
||||
.markdown-content ol { list-style: decimal; margin: 0.5rem 0 0.5rem 1.5rem; }
|
||||
.markdown-content li { margin: 0.25rem 0; }
|
||||
.markdown-content blockquote {
|
||||
border-left: 4px solid #0f3460;
|
||||
padding-left: 1rem;
|
||||
font-style: italic;
|
||||
margin: 1rem 0;
|
||||
color: #5f6368;
|
||||
}
|
||||
.markdown-content code {
|
||||
padding: 0.2rem 0.4rem;
|
||||
background-color: #181a1f;
|
||||
color: #fff;
|
||||
border-radius: 0.25rem;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.markdown-content pre {
|
||||
padding: 1rem;
|
||||
background-color: #181a1f;
|
||||
color: #fff;
|
||||
border-radius: 0.5rem;
|
||||
overflow-x: auto;
|
||||
margin: 1rem 0;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
.markdown-content pre code {
|
||||
padding: 0;
|
||||
background-color: transparent;
|
||||
color: #fff;
|
||||
}
|
||||
.markdown-content table { border-collapse: collapse; width: 100%; margin: 1rem 0; }
|
||||
.markdown-content th,
|
||||
.markdown-content td {
|
||||
border: 1px solid #d1d5db;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
.markdown-content th { background-color: #f8f9fa; font-weight: 600; }
|
||||
.markdown-content img { max-width: 100%; }
|
||||
|
||||
/* ===== 404 渐变数字 ===== */
|
||||
|
||||
.error-number {
|
||||
font-size: clamp(6rem, 20vw, 12rem);
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, #0f3460 0%, #1a73e8 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
opacity: 0.9;
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
/** 模块:SVE 数据归一化与 CVSS 工具(后端 JSON 字段为原样存储,前端做防御性归一化) */
|
||||
|
||||
export interface ProductItem {
|
||||
name: string
|
||||
version: string
|
||||
fixedVersion?: string
|
||||
}
|
||||
|
||||
export interface ReferenceItem {
|
||||
type: string
|
||||
title?: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface RelatedItem {
|
||||
id: string
|
||||
relation?: string
|
||||
}
|
||||
|
||||
const asRecord = (raw: unknown): Record<string, unknown> => {
|
||||
return raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {}
|
||||
}
|
||||
|
||||
/** SveProducts:兼容 {name,version,fixedVersion} 与 {productName,productVersion,productFixedVersion} 两种形态 */
|
||||
export function normalizeProducts(raw: unknown): ProductItem[] {
|
||||
if (!Array.isArray(raw)) return []
|
||||
return raw
|
||||
.map((entry) => {
|
||||
const r = asRecord(entry)
|
||||
return {
|
||||
name: String(r.name ?? r.productName ?? r.Name ?? r.product_name ?? ''),
|
||||
version: String(r.version ?? r.productVersion ?? r.Version ?? r.product_version ?? ''),
|
||||
fixedVersion: r.fixedVersion != null ? String(r.fixedVersion) : undefined,
|
||||
}
|
||||
})
|
||||
.filter((p) => p.name || p.version)
|
||||
}
|
||||
|
||||
/** SveReferences:兼容 {type,title,url} 与 {referenceType,referenceTitle,referenceUrl} 两种形态 */
|
||||
export function normalizeReferences(raw: unknown): ReferenceItem[] {
|
||||
if (!Array.isArray(raw)) return []
|
||||
return raw
|
||||
.map((entry) => {
|
||||
const r = asRecord(entry)
|
||||
return {
|
||||
type: String(r.type ?? r.referenceType ?? r.Type ?? 'other'),
|
||||
title: r.title != null ? String(r.title) : undefined,
|
||||
url: String(r.url ?? r.referenceUrl ?? r.Url ?? r.link ?? ''),
|
||||
}
|
||||
})
|
||||
.filter((ref) => ref.url)
|
||||
}
|
||||
|
||||
/** SveRelatedSves:兼容 [{id,relation}] 与 [{SveID,relation}] 与 [字符串] 三种形态 */
|
||||
export function normalizeRelated(raw: unknown): RelatedItem[] {
|
||||
if (!Array.isArray(raw)) return []
|
||||
return raw
|
||||
.map((entry) => {
|
||||
if (typeof entry === 'string') return { id: entry }
|
||||
const r = asRecord(entry)
|
||||
return {
|
||||
id: String(r.id ?? r.SveID ?? r.sveId ?? ''),
|
||||
relation: r.relation != null ? String(r.relation) : undefined,
|
||||
}
|
||||
})
|
||||
.filter((rel) => rel.id)
|
||||
}
|
||||
|
||||
/** 解析 CVSS 向量字符串为指标字典,如 CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N */
|
||||
export function parseCvssVector(vector: string): Record<string, string> {
|
||||
const result: Record<string, string> = {}
|
||||
for (const part of vector.split('/')) {
|
||||
const m = /^([A-Z]{1,2}):(.+)$/.exec(part.trim())
|
||||
if (m) result[m[1]] = m[2]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** 指标缩写 → 完整名(用于详情页展示) */
|
||||
export const CVSS_METRIC_LABELS: Record<string, { en: string; zh: string }> = {
|
||||
AV: { en: 'Attack Vector', zh: '攻击向量' },
|
||||
AC: { en: 'Attack Complexity', zh: '攻击复杂度' },
|
||||
PR: { en: 'Privileges Required', zh: '所需权限' },
|
||||
UI: { en: 'User Interaction', zh: '用户交互' },
|
||||
S: { en: 'Scope', zh: '影响范围' },
|
||||
C: { en: 'Confidentiality', zh: '机密性' },
|
||||
I: { en: 'Integrity', zh: '完整性' },
|
||||
A: { en: 'Availability', zh: '可用性' },
|
||||
}
|
||||
|
||||
/** 指标值 → 显示文本 */
|
||||
export const CVSS_METRIC_VALUE_LABELS: Record<string, Record<string, { en: string; zh: string }>> = {
|
||||
AV: {
|
||||
N: { en: 'Network', zh: '网络' },
|
||||
A: { en: 'Adjacent', zh: '相邻网络' },
|
||||
L: { en: 'Local', zh: '本地' },
|
||||
P: { en: 'Physical', zh: '物理' },
|
||||
},
|
||||
AC: {
|
||||
L: { en: 'Low', zh: '低' },
|
||||
H: { en: 'High', zh: '高' },
|
||||
},
|
||||
PR: {
|
||||
N: { en: 'None', zh: '无' },
|
||||
L: { en: 'Low', zh: '低' },
|
||||
H: { en: 'High', zh: '高' },
|
||||
},
|
||||
UI: {
|
||||
N: { en: 'None', zh: '无' },
|
||||
R: { en: 'Required', zh: '需要' },
|
||||
},
|
||||
S: {
|
||||
U: { en: 'Unchanged', zh: '未变更' },
|
||||
C: { en: 'Changed', zh: '已变更' },
|
||||
},
|
||||
C: { N: { en: 'None', zh: '无' }, L: { en: 'Low', zh: '低' }, H: { en: 'High', zh: '高' } },
|
||||
I: { N: { en: 'None', zh: '无' }, L: { en: 'Low', zh: '低' }, H: { en: 'High', zh: '高' } },
|
||||
A: { N: { en: 'None', zh: '无' }, L: { en: 'Low', zh: '低' }, H: { en: 'High', zh: '高' } },
|
||||
}
|
||||
|
||||
/** 参考类型标签 */
|
||||
export const REFERENCE_TYPE_LABELS: Record<string, { en: string; zh: string }> = {
|
||||
vendor: { en: 'Vendor Advisory', zh: '厂商公告' },
|
||||
tech_doc: { en: 'Technical Documentation', zh: '技术文档' },
|
||||
sve_guide: { en: 'SVE Guidance', zh: 'SVE 指导文档' },
|
||||
research: { en: 'Research Paper', zh: '研究论文' },
|
||||
other: { en: 'Other', zh: '其他' },
|
||||
}
|
||||
|
||||
/** 相关关系标签 */
|
||||
export const RELATED_RELATION_LABELS: Record<string, { en: string; zh: string }> = {
|
||||
same_product: { en: 'Same Product', zh: '同一产品' },
|
||||
similar_vuln: { en: 'Similar Vulnerability', zh: '类似漏洞' },
|
||||
same_vendor: { en: 'Same Vendor', zh: '同一厂商' },
|
||||
other: { en: 'Other Relationship', zh: '其他关系' },
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { fetchSVEList, type SVEListItem } from '@/api/sve'
|
||||
import CvssBadge from '@/components/CvssBadge.vue'
|
||||
|
||||
const { t, tpl } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
/** CVSS 严重级别 → 评分区间(后端仅支持 CvssMin/CvssMax,故前端换算) */
|
||||
const SEVERITY_RANGES: Record<string, { min: number; max: number }> = {
|
||||
CRITICAL: { min: 9, max: 10 },
|
||||
HIGH: { min: 7, max: 8.9 },
|
||||
MEDIUM: { min: 4, max: 6.9 },
|
||||
LOW: { min: 0.1, max: 3.9 },
|
||||
}
|
||||
|
||||
/* ===== 筛选状态 ===== */
|
||||
const filterTitle = ref('')
|
||||
const filterSeverity = ref('')
|
||||
const pageSize = ref(10)
|
||||
const page = ref(1)
|
||||
const filterOpen = ref(true)
|
||||
|
||||
/* ===== 数据状态 ===== */
|
||||
const items = ref<SVEListItem[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const totalPages = computed<number>(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
||||
const showingFrom = computed<number>(() => (total.value === 0 ? 0 : (page.value - 1) * pageSize.value + 1))
|
||||
const showingTo = computed<number>(() => Math.min(page.value * pageSize.value, total.value))
|
||||
const showingText = computed<string>(() =>
|
||||
tpl('showing_results', { from: showingFrom.value, to: showingTo.value, total: total.value }),
|
||||
)
|
||||
const pageNumbers = computed<number[]>(() => {
|
||||
const pages: number[] = []
|
||||
const start = Math.max(1, page.value - 3)
|
||||
const end = Math.min(totalPages.value, page.value + 3)
|
||||
for (let i = start; i <= end; i++) pages.push(i)
|
||||
return pages
|
||||
})
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
const params: Record<string, unknown> = {
|
||||
Page: page.value,
|
||||
PageSize: pageSize.value,
|
||||
Keyword: filterTitle.value,
|
||||
}
|
||||
if (filterSeverity.value && SEVERITY_RANGES[filterSeverity.value]) {
|
||||
const range = SEVERITY_RANGES[filterSeverity.value]
|
||||
params.CvssMin = range.min
|
||||
params.CvssMax = range.max
|
||||
}
|
||||
try {
|
||||
const result = await fetchSVEList(params)
|
||||
items.value = result.items
|
||||
total.value = result.total
|
||||
} catch (e) {
|
||||
error.value = (e as Error).message || 'load_error'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function syncQuery(): void {
|
||||
router.replace({
|
||||
query: {
|
||||
...(filterTitle.value ? { Keyword: filterTitle.value } : {}),
|
||||
...(filterSeverity.value ? { Severity: filterSeverity.value } : {}),
|
||||
...(pageSize.value !== 10 ? { PageSize: String(pageSize.value) } : {}),
|
||||
...(page.value !== 1 ? { Page: String(page.value) } : {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function applyFilters(): void {
|
||||
page.value = 1
|
||||
syncQuery()
|
||||
void load()
|
||||
}
|
||||
|
||||
function resetFilters(): void {
|
||||
filterTitle.value = ''
|
||||
filterSeverity.value = ''
|
||||
pageSize.value = 10
|
||||
page.value = 1
|
||||
syncQuery()
|
||||
void load()
|
||||
}
|
||||
|
||||
function gotoPage(p: number): void {
|
||||
if (p < 1 || p > totalPages.value || p === page.value) return
|
||||
page.value = p
|
||||
syncQuery()
|
||||
void load()
|
||||
}
|
||||
|
||||
function formatDate(value?: string | null): string {
|
||||
return value ? new Date(value).toISOString().slice(0, 10) : '—'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const kw = typeof route.query.Keyword === 'string' ? route.query.Keyword : ''
|
||||
const sev = typeof route.query.Severity === 'string' ? route.query.Severity : ''
|
||||
const ps = typeof route.query.PageSize === 'string' ? Number(route.query.PageSize) : 10
|
||||
const pg = typeof route.query.Page === 'string' ? Number(route.query.Page) : 1
|
||||
if (kw) filterTitle.value = kw
|
||||
if (sev && SEVERITY_RANGES[sev]) filterSeverity.value = sev
|
||||
if ([10, 20, 50, 100].includes(ps)) pageSize.value = ps
|
||||
if (pg > 0) page.value = pg
|
||||
void load()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="container mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- 页面标题与统计 -->
|
||||
<div class="mb-8 animate-slide-up">
|
||||
<h1 class="text-2xl md:text-3xl font-bold text-primary mb-2">{{ t('sve_list_title') }}</h1>
|
||||
<p class="text-neutral">{{ t('sve_list_desc') }}</p>
|
||||
<div class="flex flex-wrap items-center gap-4 mt-4 text-sm">
|
||||
<span class="text-neutral">{{ t('total_vulns') }}</span>
|
||||
<span class="font-semibold text-primary">{{ total }}</span>
|
||||
<span class="text-neutral ml-4">{{ t('current_page') }}</span>
|
||||
<span class="font-semibold text-primary">{{ page }} / {{ totalPages }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 筛选区域 -->
|
||||
<div class="mb-8 animate-fade-in">
|
||||
<button class="flex items-center text-secondary hover:text-primary transition-colors mb-4" @click="filterOpen = !filterOpen">
|
||||
<i class="fa fa-filter mr-2"></i>
|
||||
<span>{{ t('advanced_filter') }}</span>
|
||||
<i class="fa fa-chevron-down ml-2 transition-transform duration-300" :class="filterOpen ? 'rotate-180' : ''"></i>
|
||||
</button>
|
||||
|
||||
<div v-show="filterOpen" class="bg-white rounded-xl shadow-soft p-6 mb-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<!-- 标题搜索 -->
|
||||
<div class="filter-card">
|
||||
<label class="block text-sm font-medium text-neutral mb-2">{{ t('filter_title') }}</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="filterTitle"
|
||||
type="text"
|
||||
:placeholder="t('filter_title_placeholder')"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 input-focus text-sm"
|
||||
@keydown.enter="applyFilters"
|
||||
/>
|
||||
<i class="fa fa-search absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CVSS 严重级别 -->
|
||||
<div class="filter-card">
|
||||
<label class="block text-sm font-medium text-neutral mb-2">{{ t('filter_cvss_severity') }}</label>
|
||||
<select v-model="filterSeverity" class="w-full px-4 py-2 rounded-lg border border-gray-300 input-focus text-sm">
|
||||
<option value="">{{ t('filter_all') }}</option>
|
||||
<option value="CRITICAL">{{ t('severity_critical') }}</option>
|
||||
<option value="HIGH">{{ t('severity_high') }}</option>
|
||||
<option value="MEDIUM">{{ t('severity_medium') }}</option>
|
||||
<option value="LOW">{{ t('severity_low') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 每页条数 -->
|
||||
<div class="filter-card">
|
||||
<label class="block text-sm font-medium text-neutral mb-2">{{ t('filter_page_size') }}</label>
|
||||
<select v-model="pageSize" class="w-full px-4 py-2 rounded-lg border border-gray-300 input-focus text-sm" @change="applyFilters">
|
||||
<option :value="10">10 {{ t('filter_page_size') }}</option>
|
||||
<option :value="20">20 {{ t('filter_page_size') }}</option>
|
||||
<option :value="50">50 {{ t('filter_page_size') }}</option>
|
||||
<option :value="100">100 {{ t('filter_page_size') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 筛选按钮 -->
|
||||
<div class="filter-card flex flex-col justify-end">
|
||||
<button class="w-full bg-secondary hover:bg-secondary/90 text-white font-medium py-2 px-4 rounded-lg transition-colors text-sm" @click="applyFilters">
|
||||
{{ t('apply_filter') }}
|
||||
</button>
|
||||
<button class="w-full mt-2 text-neutral hover:text-danger transition-colors text-sm" @click="resetFilters">
|
||||
{{ t('reset_filter') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 漏洞列表区域 -->
|
||||
<div class="bg-white rounded-xl shadow-soft overflow-hidden animate-fade-in">
|
||||
<!-- 列表表头 -->
|
||||
<div class="hidden md:grid grid-cols-12 bg-gray-50 py-3 px-6 border-b border-gray-200 font-medium text-sm">
|
||||
<div class="col-span-2">{{ t('sve_id') }}</div>
|
||||
<div class="col-span-4">{{ t('vuln_title') }}</div>
|
||||
<div class="col-span-2">{{ t('cvss_score') }}</div>
|
||||
<div class="col-span-2">{{ t('table_severity') }}</div>
|
||||
<div class="col-span-2">{{ t('publish_date') }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="py-12 text-center">
|
||||
<div class="inline-block animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-secondary mb-4"></div>
|
||||
<p class="text-neutral">{{ t('loading_data') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else-if="!error && items.length === 0" class="py-16 text-center">
|
||||
<i class="fa fa-search text-4xl text-gray-300 mb-4"></i>
|
||||
<p class="text-neutral">{{ t('no_results') }}</p>
|
||||
<button class="mt-4 text-secondary hover:text-primary transition-colors text-sm" @click="resetFilters">
|
||||
{{ t('reset_filters') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<div v-else-if="error" class="py-16 text-center">
|
||||
<i class="fa fa-exclamation-circle text-4xl text-danger mb-4"></i>
|
||||
<p class="text-neutral">{{ t('load_error') }}</p>
|
||||
<button class="mt-4 bg-secondary hover:bg-secondary/90 text-white font-medium py-2 px-4 rounded-lg transition-colors text-sm" @click="load">
|
||||
{{ t('retry') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 列表项 -->
|
||||
<div v-else class="divide-y divide-gray-200">
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.SveID"
|
||||
class="grid grid-cols-1 md:grid-cols-12 items-start md:items-center gap-2 md:gap-0 py-4 px-6 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div class="md:col-span-2">
|
||||
<router-link :to="`/sve/${item.SveID}`" class="text-secondary hover:underline font-medium">{{ item.SveID }}</router-link>
|
||||
</div>
|
||||
<div class="md:col-span-4">
|
||||
<router-link :to="`/sve/${item.SveID}`" class="text-dark hover:text-secondary transition-colors line-clamp-2">
|
||||
{{ item.SveName || '—' }}
|
||||
</router-link>
|
||||
<div v-if="item.SveDescription" class="text-xs text-neutral mt-1 line-clamp-1">{{ item.SveDescription }}</div>
|
||||
</div>
|
||||
<div class="md:col-span-2 font-semibold">
|
||||
{{ item.CvssBaseScore != null ? Number(item.CvssBaseScore).toFixed(1) : '—' }}
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<CvssBadge :severity="item.CvssSeverity" />
|
||||
</div>
|
||||
<div class="md:col-span-2 flex items-center justify-between">
|
||||
<span class="text-neutral text-sm">{{ formatDate(item.SveCreateTime) }}</span>
|
||||
<router-link
|
||||
:to="`/sve/${item.SveID}`"
|
||||
class="text-secondary hover:text-primary transition-colors text-sm font-medium md:hidden lg:inline-flex"
|
||||
>
|
||||
{{ t('view_details') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分页控件 -->
|
||||
<div class="mt-8 flex flex-col sm:flex-row justify-between items-center gap-4 animate-fade-in">
|
||||
<div class="text-sm text-neutral">{{ showingText }}</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="pagination-btn" :disabled="page <= 1" @click="gotoPage(page - 1)">
|
||||
<i class="fa fa-chevron-left"></i>
|
||||
</button>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
v-for="p in pageNumbers"
|
||||
:key="p"
|
||||
class="pagination-btn"
|
||||
:class="{ active: p === page }"
|
||||
@click="gotoPage(p)"
|
||||
>
|
||||
{{ p }}
|
||||
</button>
|
||||
</div>
|
||||
<button class="pagination-btn" :disabled="page >= totalPages" @click="gotoPage(page + 1)">
|
||||
<i class="fa fa-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,368 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import type { ChartConfiguration } from 'chart.js/auto'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { fetchSVEInfo, type SVEDetail } from '@/api/sve'
|
||||
import {
|
||||
normalizeProducts,
|
||||
normalizeReferences,
|
||||
normalizeRelated,
|
||||
parseCvssVector,
|
||||
CVSS_METRIC_LABELS,
|
||||
CVSS_METRIC_VALUE_LABELS,
|
||||
REFERENCE_TYPE_LABELS,
|
||||
RELATED_RELATION_LABELS,
|
||||
} from '@/utils/sve'
|
||||
import { useChart } from '@/composables/useChart'
|
||||
import MarkdownView from '@/components/MarkdownView.vue'
|
||||
import CvssBadge from '@/components/CvssBadge.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const { locale, t } = useI18n()
|
||||
|
||||
const detail = ref<SVEDetail | null>(null)
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const copied = ref(false)
|
||||
|
||||
const products = computed(() => normalizeProducts(detail.value?.SveProducts))
|
||||
const references = computed(() => normalizeReferences(detail.value?.SveReferences))
|
||||
const related = computed(() => normalizeRelated(detail.value?.SveRelatedSves))
|
||||
|
||||
/** 已知 CVSS 指标键(大写) */
|
||||
const METRIC_KEYS = ['AV', 'AC', 'PR', 'UI', 'S', 'C', 'I', 'A']
|
||||
|
||||
/** 任意形态指标字典 → 大写键标准字典(兼容小写 av/ac/... 与嵌套 metrics) */
|
||||
function normalizeMetrics(raw: Record<string, unknown> | null | undefined): Record<string, string> | null {
|
||||
if (!raw) return null
|
||||
const out: Record<string, string> = {}
|
||||
for (const [k, v] of Object.entries(raw)) {
|
||||
const upper = k.toUpperCase()
|
||||
if (METRIC_KEYS.includes(upper) && typeof v === 'string') out[upper] = v
|
||||
}
|
||||
return Object.keys(out).length ? out : null
|
||||
}
|
||||
|
||||
/** 优先取向量字符串解析,其次取 SveCvss.metrics / 直接小写指标字典 */
|
||||
const vectorMetrics = computed<Record<string, string> | null>(() => {
|
||||
const cvss = detail.value?.SveCvss
|
||||
if (!cvss) return null
|
||||
if (typeof cvss.vector === 'string') return parseCvssVector(cvss.vector)
|
||||
if (typeof cvss.vectorString === 'string') return parseCvssVector(cvss.vectorString)
|
||||
if (cvss.metrics && typeof cvss.metrics === 'object') {
|
||||
return normalizeMetrics(cvss.metrics as Record<string, unknown>)
|
||||
}
|
||||
return normalizeMetrics(cvss)
|
||||
})
|
||||
|
||||
const cvssScore = computed<number | null>(() => {
|
||||
if (!detail.value) return null
|
||||
if (detail.value.CvssBaseScore != null) return Number(detail.value.CvssBaseScore)
|
||||
const cvss = detail.value.SveCvss
|
||||
const score = cvss?.score ?? cvss?.baseScore
|
||||
return typeof score === 'number' ? score : null
|
||||
})
|
||||
|
||||
const cvssSeverity = computed<string | null>(() => {
|
||||
if (!detail.value) return null
|
||||
if (detail.value.CvssSeverity) return detail.value.CvssSeverity
|
||||
if (detail.value.CvssBaseSeverity) return detail.value.CvssBaseSeverity
|
||||
const sev = detail.value.SveCvss?.severity ?? detail.value.SveCvss?.baseSeverity
|
||||
return typeof sev === 'string' && sev ? sev : null
|
||||
})
|
||||
|
||||
const cvssVector = computed<string>(() => {
|
||||
const cvss = detail.value?.SveCvss
|
||||
const v = cvss?.vector ?? cvss?.vectorString
|
||||
return typeof v === 'string' ? v : ''
|
||||
})
|
||||
|
||||
function metricLabel(key: string, value: string): string {
|
||||
const entry = CVSS_METRIC_VALUE_LABELS[key]?.[value]
|
||||
if (entry) return locale.value === 'zh' ? entry.zh : entry.en
|
||||
return value
|
||||
}
|
||||
|
||||
function referenceTypeLabel(type: string): string {
|
||||
const entry = REFERENCE_TYPE_LABELS[type]
|
||||
if (entry) return locale.value === 'zh' ? entry.zh : entry.en
|
||||
return type
|
||||
}
|
||||
|
||||
function relationLabel(rel?: string): string {
|
||||
if (!rel) return ''
|
||||
const entry = RELATED_RELATION_LABELS[rel]
|
||||
if (entry) return locale.value === 'zh' ? entry.zh : entry.en
|
||||
return rel
|
||||
}
|
||||
|
||||
function formatDate(value?: string | null): string {
|
||||
return value ? new Date(value).toISOString().slice(0, 10) : '—'
|
||||
}
|
||||
|
||||
async function copySveId(): Promise<void> {
|
||||
if (!detail.value) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(detail.value.SveID)
|
||||
copied.value = true
|
||||
setTimeout(() => (copied.value = false), 1500)
|
||||
} catch {
|
||||
// 剪贴板不可用时静默失败
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== CVSS 雷达图(指标值 → 0..1 严重程度) ===== */
|
||||
const NORMALIZE: Record<string, Record<string, number>> = {
|
||||
AV: { N: 1, A: 0.75, L: 0.5, P: 0.25 },
|
||||
AC: { L: 1, H: 0.5 },
|
||||
PR: { N: 1, L: 0.6, H: 0.3 },
|
||||
UI: { N: 1, R: 0.5 },
|
||||
C: { H: 1, L: 0.5, N: 0 },
|
||||
I: { H: 1, L: 0.5, N: 0 },
|
||||
A: { H: 1, L: 0.5, N: 0 },
|
||||
}
|
||||
|
||||
const radarCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
|
||||
const radarChart = useChart(radarCanvas, () => {
|
||||
const metrics = vectorMetrics.value
|
||||
const axes = ['AV', 'AC', 'PR', 'UI', 'C', 'I', 'A'] as const
|
||||
const labels = axes.map((key) => {
|
||||
const entry = CVSS_METRIC_LABELS[key]
|
||||
return entry ? (locale.value === 'zh' ? entry.zh : entry.en) : key
|
||||
})
|
||||
const data = metrics
|
||||
? axes.map((key) => {
|
||||
const value = metrics[key]
|
||||
const table = NORMALIZE[key]
|
||||
return value && table && table[value] != null ? table[value] : 0
|
||||
})
|
||||
: []
|
||||
const config: ChartConfiguration = {
|
||||
type: 'radar',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
data,
|
||||
backgroundColor: 'rgba(26, 115, 232, 0.15)',
|
||||
borderColor: '#1A73E8',
|
||||
borderWidth: 2,
|
||||
pointBackgroundColor: '#1A73E8',
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: { r: { min: 0, max: 1, ticks: { display: false }, grid: { color: 'rgba(0,0,0,0.08)' } } },
|
||||
plugins: { legend: { display: false } },
|
||||
},
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
watch(locale, () => radarChart.render())
|
||||
|
||||
// detail 数据就绪后,canvas 才随 v-else-if 条件渲染挂载;
|
||||
// 必须等 DOM 更新(flush: 'post')再绘制雷达图,否则拿不到节点
|
||||
watch(detail, () => radarChart.render(), { flush: 'post' })
|
||||
|
||||
/* ===== 加载 ===== */
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
detail.value = null
|
||||
try {
|
||||
const sveId = typeof route.params.id === 'string' ? route.params.id : ''
|
||||
detail.value = await fetchSVEInfo(sveId)
|
||||
if (!detail.value) {
|
||||
error.value = 'not_found'
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = (e as Error).message || 'load_error'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => route.params.id, () => void load())
|
||||
|
||||
onMounted(() => void load())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="container mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- 面包屑导航 -->
|
||||
<div class="text-sm text-neutral mb-6">
|
||||
<router-link to="/" class="hover:text-secondary">{{ t('home') }}</router-link>
|
||||
<span class="mx-1">></span>
|
||||
<span class="text-primary font-medium">{{ detail?.SveID || t('sve_details') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="py-12 text-center">
|
||||
<div class="inline-block animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-secondary mb-4"></div>
|
||||
<p class="text-neutral">{{ t('loading_data') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<div v-else-if="error" class="py-16 text-center">
|
||||
<i class="fa fa-exclamation-circle text-4xl text-danger mb-4"></i>
|
||||
<p class="text-neutral">{{ t('sve_not_found') }}</p>
|
||||
<router-link to="/" class="mt-4 inline-block bg-secondary hover:bg-secondary/90 text-white font-medium py-2 px-4 rounded-lg transition-colors text-sm">
|
||||
{{ t('back_to_home') }}
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<template v-else-if="detail">
|
||||
<!-- 标题与元数据 -->
|
||||
<div class="bg-white rounded-xl shadow-soft p-6 mb-8">
|
||||
<div class="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-6">
|
||||
<div class="w-full lg:w-2/3 space-y-6">
|
||||
<div>
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<h1 class="text-2xl md:text-3xl font-bold text-primary">{{ detail.SveID }}</h1>
|
||||
<button class="text-neutral hover:text-secondary transition-colors" :title="t('copy_sve_id')" @click="copySveId">
|
||||
<i class="fa fa-copy"></i>
|
||||
</button>
|
||||
<span v-if="copied" class="text-success text-sm">{{ t('copied') }}</span>
|
||||
</div>
|
||||
<h2 class="text-xl md:text-2xl font-semibold text-dark mb-4 text-balance" style="word-wrap: break-word;">
|
||||
{{ detail.SveName || '—' }}
|
||||
</h2>
|
||||
<div class="flex flex-wrap gap-x-8 gap-y-2 text-sm text-neutral mb-2">
|
||||
<div class="flex items-center">
|
||||
<i class="fa fa-calendar mr-2 text-secondary"></i>
|
||||
<span>{{ t('published') }}</span>
|
||||
<span class="text-dark font-medium ml-1">{{ formatDate(detail.SveCreateTime) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<i class="fa fa-refresh mr-2 text-secondary"></i>
|
||||
<span>{{ t('last_modified') }}</span>
|
||||
<span class="text-dark font-medium ml-1">{{ formatDate(detail.SveLastModified) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="detail.SveDescription">
|
||||
<h3 class="text-xl font-semibold mb-4 flex items-center">
|
||||
<i class="fa fa-info-circle text-secondary mr-2"></i>
|
||||
{{ t('description') }}
|
||||
</h3>
|
||||
<MarkdownView :content="detail.SveDescription" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧 CVSS 评分卡片 -->
|
||||
<div class="bg-light rounded-lg p-5 border border-gray-200 w-full lg:w-auto flex-shrink-0">
|
||||
<div class="text-sm text-neutral mb-1">{{ t('cvss_base_score') }}</div>
|
||||
<div class="flex items-end gap-3 mb-2">
|
||||
<span class="text-3xl font-bold text-dark">{{ cvssScore != null ? cvssScore.toFixed(1) : '—' }}</span>
|
||||
<CvssBadge :severity="cvssSeverity" />
|
||||
</div>
|
||||
<div class="text-xs text-neutral">
|
||||
<span class="font-medium">{{ t('vector') }}</span>
|
||||
<span class="font-mono break-all">{{ cvssVector || '—' }}</span>
|
||||
</div>
|
||||
<div class="mt-4 h-64">
|
||||
<canvas ref="radarCanvas"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主要内容两栏布局 -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<!-- 左侧主内容 -->
|
||||
<div class="lg:col-span-2 space-y-8">
|
||||
<!-- 受影响的产品 -->
|
||||
<section class="bg-white rounded-xl shadow-soft p-6">
|
||||
<h3 class="text-xl font-semibold mb-4 flex items-center">
|
||||
<i class="fa fa-exclamation-triangle text-warning mr-2"></i>
|
||||
{{ t('affected_products') }}
|
||||
</h3>
|
||||
<ul v-if="products.length" class="space-y-3 text-neutral">
|
||||
<li v-for="(p, idx) in products" :key="idx" class="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-3">
|
||||
<span class="font-medium text-dark">{{ p.name || '—' }}</span>
|
||||
<span v-if="p.version" class="text-sm">v{{ p.version }}</span>
|
||||
<span v-if="p.fixedVersion" class="text-xs text-success">{{ t('fixed_version') }}: {{ p.fixedVersion }}</span>
|
||||
<span v-else-if="p.fixedVersion === ''" class="text-xs text-neutral">{{ t('no_fixed_version') }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="text-neutral text-sm">—</p>
|
||||
</section>
|
||||
|
||||
<!-- 解决方案 -->
|
||||
<section v-if="detail.SveSolutions" class="bg-white rounded-xl shadow-soft p-6">
|
||||
<h3 class="text-xl font-semibold mb-4 flex items-center">
|
||||
<i class="fa fa-wrench text-success mr-2"></i>
|
||||
{{ t('solutions_mitigations') }}
|
||||
</h3>
|
||||
<MarkdownView :content="detail.SveSolutions" />
|
||||
</section>
|
||||
|
||||
<!-- 参考资料 -->
|
||||
<section class="bg-white rounded-xl shadow-soft p-6">
|
||||
<h3 class="text-xl font-semibold mb-4 flex items-center">
|
||||
<i class="fa fa-external-link text-secondary mr-2"></i>
|
||||
{{ t('references') }}
|
||||
</h3>
|
||||
<ul v-if="references.length" class="space-y-3 text-neutral">
|
||||
<li v-for="(ref, idx) in references" :key="idx">
|
||||
<a :href="ref.url" target="_blank" rel="noopener noreferrer" class="text-secondary hover:underline break-all">
|
||||
{{ ref.title || ref.url }}
|
||||
</a>
|
||||
<span class="text-xs text-neutral ml-2">[{{ referenceTypeLabel(ref.type) }}]</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="text-neutral text-sm">—</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- 右侧边栏 -->
|
||||
<div class="space-y-8">
|
||||
<!-- 漏洞信息卡片 -->
|
||||
<div class="bg-white rounded-xl shadow-soft p-6">
|
||||
<h3 class="text-lg font-semibold mb-4 text-primary">{{ t('vulnerability_details') }}</h3>
|
||||
<div class="space-y-3 text-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-neutral">{{ t('status') }}</span>
|
||||
<span class="font-medium text-dark">{{ t('vuln_status_' + (detail.SveVulnStatus || 'draft')) }}</span>
|
||||
</div>
|
||||
<template v-if="vectorMetrics">
|
||||
<div
|
||||
v-for="(value, key) in vectorMetrics"
|
||||
:key="key"
|
||||
class="flex items-center justify-between"
|
||||
>
|
||||
<span class="text-neutral">
|
||||
{{ (CVSS_METRIC_LABELS[key] ? (locale === 'zh' ? CVSS_METRIC_LABELS[key].zh : CVSS_METRIC_LABELS[key].en) : key) }}
|
||||
</span>
|
||||
<span class="font-medium text-dark">{{ metricLabel(key, value) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 相关 SVE -->
|
||||
<div class="bg-white rounded-xl shadow-soft p-6">
|
||||
<h3 class="text-lg font-semibold mb-4 text-primary">{{ t('related_sve_entries') }}</h3>
|
||||
<div v-if="related.length" class="space-y-2">
|
||||
<router-link
|
||||
v-for="(rel, idx) in related"
|
||||
:key="idx"
|
||||
:to="`/sve/${rel.id}`"
|
||||
class="block text-secondary hover:underline text-sm"
|
||||
>
|
||||
{{ rel.id }}
|
||||
<span v-if="rel.relation" class="text-neutral text-xs ml-1">[{{ relationLabel(rel.relation) }}]</span>
|
||||
</router-link>
|
||||
</div>
|
||||
<p v-else class="text-neutral text-sm">{{ t('no_related_sves') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,416 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import type { ChartConfiguration } from 'chart.js/auto'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { fetchSVEList, type SVEListItem } from '@/api/sve'
|
||||
import { useChart } from '@/composables/useChart'
|
||||
import CvssBadge from '@/components/CvssBadge.vue'
|
||||
|
||||
const { locale, t } = useI18n()
|
||||
const router = useRouter()
|
||||
|
||||
/* ===== 数据 ===== */
|
||||
const items = ref<SVEListItem[]>([])
|
||||
const totalCount = ref(0)
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
|
||||
const heroKeyword = ref('')
|
||||
|
||||
/* ===== 统计卡片 ===== */
|
||||
const criticalCount = computed<number>(() => items.value.filter((i) => (i.CvssSeverity || '').toUpperCase() === 'CRITICAL').length)
|
||||
const thisMonthCount = computed<number>(() => {
|
||||
const now = new Date()
|
||||
return items.value.filter((i) => {
|
||||
if (!i.SveCreateTime) return false
|
||||
const d = new Date(i.SveCreateTime)
|
||||
return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth()
|
||||
}).length
|
||||
})
|
||||
const publicCount = computed<number>(() => items.value.filter((i) => i.SveVulnStatus === 'public').length)
|
||||
const latestItems = computed<SVEListItem[]>(() => items.value.slice(0, 5))
|
||||
|
||||
/* ===== 图表数据 ===== */
|
||||
function monthTrends(): { labels: string[]; data: number[] } {
|
||||
const counts = new Map<string, number>()
|
||||
for (const item of items.value) {
|
||||
if (!item.SveCreateTime) continue
|
||||
const d = new Date(item.SveCreateTime)
|
||||
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1)
|
||||
}
|
||||
const keys = [...counts.keys()].sort()
|
||||
return { labels: keys, data: keys.map((k) => counts.get(k) ?? 0) }
|
||||
}
|
||||
|
||||
function severityDist(): { labels: string[]; data: number[]; colors: string[] } {
|
||||
const order = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'NONE']
|
||||
const labels: Record<string, string> = {
|
||||
CRITICAL: t('severity_critical'),
|
||||
HIGH: t('severity_high'),
|
||||
MEDIUM: t('severity_medium'),
|
||||
LOW: t('severity_low'),
|
||||
NONE: t('severity_none'),
|
||||
}
|
||||
const colors: Record<string, string> = {
|
||||
CRITICAL: '#D93025',
|
||||
HIGH: '#F4511E',
|
||||
MEDIUM: '#F4B400',
|
||||
LOW: '#1E8E3E',
|
||||
NONE: '#9AA0A6',
|
||||
}
|
||||
const counts = new Map<string, number>()
|
||||
for (const item of items.value) {
|
||||
const sev = (item.CvssSeverity || 'NONE').toUpperCase()
|
||||
counts.set(sev, (counts.get(sev) ?? 0) + 1)
|
||||
}
|
||||
const present = order.filter((s) => counts.has(s) && (counts.get(s) ?? 0) > 0)
|
||||
return {
|
||||
labels: present.map((s) => labels[s] ?? s),
|
||||
data: present.map((s) => counts.get(s) ?? 0),
|
||||
colors: present.map((s) => colors[s] ?? '#9AA0A6'),
|
||||
}
|
||||
}
|
||||
|
||||
function statusDist(): { labels: string[]; data: number[]; colors: string[] } {
|
||||
const defs = [
|
||||
{ value: 'public', label: t('vuln_status_public'), color: '#1E8E3E' },
|
||||
{ value: 'draft', label: t('vuln_status_draft'), color: '#F4B400' },
|
||||
{ value: 'reserved', label: t('vuln_status_reserved'), color: '#1A73E8' },
|
||||
]
|
||||
const counts = new Map<string, number>()
|
||||
for (const item of items.value) {
|
||||
const status = item.SveVulnStatus || 'draft'
|
||||
counts.set(status, (counts.get(status) ?? 0) + 1)
|
||||
}
|
||||
const present = defs.filter((d) => counts.has(d.value) && (counts.get(d.value) ?? 0) > 0)
|
||||
return {
|
||||
labels: present.map((d) => d.label),
|
||||
data: present.map((d) => counts.get(d.value) ?? 0),
|
||||
colors: present.map((d) => d.color),
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 图表 ===== */
|
||||
const trendsCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
const severityCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
const statusCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
|
||||
const trendsChart = useChart(trendsCanvas, () => {
|
||||
const data = monthTrends()
|
||||
const config: ChartConfiguration = {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: data.labels,
|
||||
datasets: [
|
||||
{
|
||||
label: t('chart_vuln_trends'),
|
||||
data: data.data,
|
||||
borderColor: '#1A73E8',
|
||||
backgroundColor: 'rgba(26, 115, 232, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: { y: { beginAtZero: true, ticks: { precision: 0 } } },
|
||||
},
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
const severityChart = useChart(severityCanvas, () => {
|
||||
const data = severityDist()
|
||||
const config: ChartConfiguration = {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: data.labels,
|
||||
datasets: [{ data: data.data, backgroundColor: data.colors, borderWidth: 2 }],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { position: 'bottom' } },
|
||||
},
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
const statusChart = useChart(statusCanvas, () => {
|
||||
const data = statusDist()
|
||||
const config: ChartConfiguration = {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: data.labels,
|
||||
datasets: [{ data: data.data, backgroundColor: data.colors, borderRadius: 4 }],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: { y: { beginAtZero: true, ticks: { precision: 0 } } },
|
||||
},
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
/* ===== 加载 ===== */
|
||||
async function loadData(): Promise<void> {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const result = await fetchSVEList({ Page: 1, PageSize: 100 })
|
||||
items.value = result.items
|
||||
totalCount.value = result.total
|
||||
} catch (e) {
|
||||
error.value = (e as Error).message || 'load_error'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function search(): void {
|
||||
const kw = heroKeyword.value.trim()
|
||||
router.push({ name: 'Browse', query: kw ? { Keyword: kw } : {} })
|
||||
}
|
||||
|
||||
watch(locale, () => {
|
||||
trendsChart.render()
|
||||
severityChart.render()
|
||||
statusChart.render()
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await loadData()
|
||||
trendsChart.render()
|
||||
severityChart.render()
|
||||
statusChart.render()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- 英雄区域 -->
|
||||
<section class="bg-gradient-primary text-white py-16 md:py-24">
|
||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-3xl mx-auto text-center">
|
||||
<h1 class="text-3xl md:text-5xl font-bold mb-6 leading-tight">{{ t('hero_title') }}</h1>
|
||||
<p class="text-lg md:text-xl opacity-90 mb-8">{{ t('hero_desc') }}</p>
|
||||
|
||||
<div class="relative max-w-2xl mx-auto">
|
||||
<input
|
||||
v-model="heroKeyword"
|
||||
type="text"
|
||||
:placeholder="t('hero_search_placeholder')"
|
||||
class="w-full pl-5 pr-32 py-4 rounded-xl text-dark focus:outline-none focus:ring-4 focus:ring-white/20 transition-all"
|
||||
@keydown.enter="search"
|
||||
/>
|
||||
<button
|
||||
class="absolute right-3 top-1/2 transform -translate-y-1/2 bg-secondary hover:bg-secondary/90 text-white px-4 py-2 rounded-lg transition-colors"
|
||||
@click="search"
|
||||
>
|
||||
<i class="fa fa-search mr-2"></i>
|
||||
<span>{{ t('search_btn') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 text-sm opacity-80">
|
||||
{{ t('hero_example_prefix') }}
|
||||
<router-link to="/sve/SVE-2025-00012" class="underline hover:opacity-100">SVE-2025-00012</router-link>
|
||||
<span class="mx-1">{{ t('hero_example_or') }}</span>
|
||||
<router-link to="/browse?Keyword=xss" class="underline hover:opacity-100">Cross-Site Scripting</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 主要内容区 -->
|
||||
<main class="container mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<!-- 加载 / 错误状态 -->
|
||||
<div v-if="loading" class="py-12 text-center">
|
||||
<div class="inline-block animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-secondary mb-4"></div>
|
||||
<p class="text-neutral">{{ t('loading_data') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="py-16 text-center">
|
||||
<i class="fa fa-exclamation-circle text-4xl text-danger mb-4"></i>
|
||||
<p class="text-neutral">{{ t('load_error') }}</p>
|
||||
<button class="mt-4 bg-secondary hover:bg-secondary/90 text-white font-medium py-2 px-4 rounded-lg transition-colors text-sm" @click="loadData">
|
||||
{{ t('retry') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- 统计卡片 -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-12">
|
||||
<div class="bg-white rounded-xl shadow-soft p-6 transform hover:-translate-y-1 transition-transform">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<p class="text-neutral mb-1">{{ t('stat_total_vuln') }}</p>
|
||||
<h3 class="text-3xl font-bold text-primary">{{ totalCount.toLocaleString() }}</h3>
|
||||
</div>
|
||||
<div class="bg-primary/10 p-3 rounded-lg">
|
||||
<i class="fa fa-database text-primary text-xl"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-soft p-6 transform hover:-translate-y-1 transition-transform">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<p class="text-neutral mb-1">{{ t('stat_critical_vuln') }}</p>
|
||||
<h3 class="text-3xl font-bold text-danger">{{ criticalCount.toLocaleString() }}</h3>
|
||||
</div>
|
||||
<div class="bg-danger/10 p-3 rounded-lg">
|
||||
<i class="fa fa-exclamation-circle text-danger text-xl"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-soft p-6 transform hover:-translate-y-1 transition-transform">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<p class="text-neutral mb-1">{{ t('stat_this_month') }}</p>
|
||||
<h3 class="text-3xl font-bold text-warning">{{ thisMonthCount.toLocaleString() }}</h3>
|
||||
</div>
|
||||
<div class="bg-warning/10 p-3 rounded-lg">
|
||||
<i class="fa fa-calendar text-warning text-xl"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-soft p-6 transform hover:-translate-y-1 transition-transform">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<p class="text-neutral mb-1">{{ t('stat_public') }}</p>
|
||||
<h3 class="text-3xl font-bold text-secondary">{{ publicCount.toLocaleString() }}</h3>
|
||||
</div>
|
||||
<div class="bg-secondary/10 p-3 rounded-lg">
|
||||
<i class="fa fa-shield text-secondary text-xl"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图表区 -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8 mb-12">
|
||||
<div class="lg:col-span-1 bg-white rounded-xl shadow-soft p-6">
|
||||
<h3 class="text-lg font-semibold mb-6 text-primary">{{ t('chart_vuln_trends') }}</h3>
|
||||
<div class="h-64">
|
||||
<canvas ref="trendsCanvas"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lg:col-span-1 bg-white rounded-xl shadow-soft p-6">
|
||||
<h3 class="text-lg font-semibold mb-6 text-primary">{{ t('chart_severity_dist') }}</h3>
|
||||
<div class="h-64">
|
||||
<canvas ref="severityCanvas"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lg:col-span-1 bg-white rounded-xl shadow-soft p-6">
|
||||
<h3 class="text-lg font-semibold mb-6 text-primary">{{ t('chart_status_dist') }}</h3>
|
||||
<div class="h-64">
|
||||
<canvas ref="statusCanvas"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 最新 SVE 漏洞 -->
|
||||
<section class="mb-12">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-2xl font-bold text-primary">{{ t('latest_sve') }}</h2>
|
||||
<router-link to="/browse" class="text-secondary hover:text-primary transition-colors font-medium flex items-center">
|
||||
<span>{{ t('view_all') }}</span>
|
||||
<i class="fa fa-angle-right ml-2"></i>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div v-if="latestItems.length === 0" class="bg-white rounded-xl shadow-soft py-16 text-center">
|
||||
<i class="fa fa-database text-4xl text-gray-300 mb-4"></i>
|
||||
<p class="text-neutral">{{ t('empty_list') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="overflow-x-auto bg-white rounded-xl shadow-soft">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200">
|
||||
<th class="text-left py-4 px-6 text-neutral font-semibold">{{ t('table_sve_id') }}</th>
|
||||
<th class="text-left py-4 px-6 text-neutral font-semibold">{{ t('table_title') }}</th>
|
||||
<th class="text-left py-4 px-6 text-neutral font-semibold">{{ t('table_severity') }}</th>
|
||||
<th class="text-left py-4 px-6 text-neutral font-semibold">{{ t('table_published') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="item in latestItems"
|
||||
:key="item.SveID"
|
||||
class="border-b border-gray-100 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<td class="py-4 px-6">
|
||||
<router-link :to="`/sve/${item.SveID}`" class="text-secondary hover:underline font-medium">{{ item.SveID }}</router-link>
|
||||
</td>
|
||||
<td class="py-4 px-6 max-w-xs">
|
||||
<router-link :to="`/sve/${item.SveID}`" class="text-dark hover:text-secondary transition-colors line-clamp-1">
|
||||
{{ item.SveName || '—' }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td class="py-4 px-6">
|
||||
<CvssBadge :severity="item.CvssSeverity" />
|
||||
</td>
|
||||
<td class="py-4 px-6 text-neutral">
|
||||
{{ item.SveCreateTime ? new Date(item.SveCreateTime).toISOString().slice(0, 10) : '—' }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 资源和链接 -->
|
||||
<section class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div class="bg-white rounded-xl shadow-soft p-6 transform hover:-translate-y-1 transition-transform">
|
||||
<div class="bg-primary/10 w-12 h-12 rounded-lg flex items-center justify-center mb-4">
|
||||
<i class="fa fa-file text-primary text-xl"></i>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold mb-2 text-primary">{{ t('resource_doc') }}</h3>
|
||||
<p class="text-neutral mb-4">{{ t('resource_doc_desc') }}</p>
|
||||
<router-link to="/browse" class="text-secondary hover:text-primary transition-colors font-medium flex items-center">
|
||||
<span>{{ t('read_doc') }}</span>
|
||||
<i class="fa fa-arrow-right ml-2"></i>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-soft p-6 transform hover:-translate-y-1 transition-transform">
|
||||
<div class="bg-secondary/10 w-12 h-12 rounded-lg flex items-center justify-center mb-4">
|
||||
<i class="fa fa-code text-secondary text-xl"></i>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold mb-2 text-primary">{{ t('resource_api') }}</h3>
|
||||
<p class="text-neutral mb-4">{{ t('resource_api_desc') }}</p>
|
||||
<router-link to="/browse" class="text-secondary hover:text-primary transition-colors font-medium flex items-center">
|
||||
<span>{{ t('explore_api') }}</span>
|
||||
<i class="fa fa-arrow-right ml-2"></i>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-soft p-6 transform hover:-translate-y-1 transition-transform">
|
||||
<div class="bg-success/10 w-12 h-12 rounded-lg flex items-center justify-center mb-4">
|
||||
<i class="fa fa-flag text-success text-xl"></i>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold mb-2 text-primary">{{ t('resource_report') }}</h3>
|
||||
<p class="text-neutral mb-4">{{ t('resource_report_desc') }}</p>
|
||||
<router-link to="/write" class="text-secondary hover:text-primary transition-colors font-medium flex items-center">
|
||||
<span>{{ t('submit_report') }}</span>
|
||||
<i class="fa fa-arrow-right ml-2"></i>
|
||||
</router-link>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
function refresh(): void {
|
||||
window.location.reload()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="container mx-auto px-4 sm:px-6 lg:px-8 py-12 md:py-20">
|
||||
<div class="max-w-4xl mx-auto text-center space-y-8">
|
||||
<!-- 404 数字图标 -->
|
||||
<div class="error-number">404</div>
|
||||
|
||||
<!-- 标题与描述 -->
|
||||
<div class="space-y-4">
|
||||
<h1 class="text-3xl md:text-4xl font-bold text-primary">{{ t('error_404_title') }}</h1>
|
||||
<p class="text-lg text-neutral max-w-2xl mx-auto">{{ t('error_404_desc') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮组 -->
|
||||
<div class="flex flex-col sm:flex-row justify-center gap-4 mt-6">
|
||||
<router-link
|
||||
to="/"
|
||||
class="bg-secondary hover:bg-secondary/90 text-white font-medium py-3 px-8 rounded-lg transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<i class="fa fa-home"></i>
|
||||
{{ t('error_back_home') }}
|
||||
</router-link>
|
||||
<button
|
||||
type="button"
|
||||
class="bg-white border border-gray-300 hover:border-secondary text-dark font-medium py-3 px-8 rounded-lg transition-colors flex items-center justify-center gap-2"
|
||||
@click="refresh"
|
||||
>
|
||||
<i class="fa fa-search"></i>
|
||||
{{ t('error_search_again') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,472 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { submitSVE, type SVEWritePayload } from '@/api/sve'
|
||||
import MarkdownEditor from '@/components/MarkdownEditor.vue'
|
||||
import CvssCalculator from '@/components/CvssCalculator.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
|
||||
/* ===== 表单状态 ===== */
|
||||
const year = ref('')
|
||||
const number = ref('')
|
||||
const publishDate = ref('')
|
||||
const vulnTitle = ref('')
|
||||
const vulnStatus = ref<'draft' | 'public' | 'reserved'>('draft')
|
||||
const description = ref('')
|
||||
const solutions = ref('')
|
||||
|
||||
interface ProductRow { name: string; version: string; fixedVersion: string }
|
||||
interface ReferenceRow { type: string; title: string; url: string }
|
||||
interface RelatedRow { id: string; relation: string }
|
||||
|
||||
const products = ref<ProductRow[]>([{ name: '', version: '', fixedVersion: '' }])
|
||||
const references = ref<ReferenceRow[]>([{ type: 'vendor', title: '', url: '' }])
|
||||
const related = ref<RelatedRow[]>([{ id: '', relation: 'same_product' }])
|
||||
const cvss = ref<Record<string, unknown> | null>(null)
|
||||
|
||||
const submitting = ref(false)
|
||||
const banner = ref('')
|
||||
|
||||
const REFERENCE_TYPES = ['vendor', 'tech_doc', 'sve_guide', 'research', 'other']
|
||||
const RELATIONS = ['same_product', 'similar_vuln', 'same_vendor', 'other']
|
||||
|
||||
function referenceTypeLabel(type: string): string {
|
||||
return t(`ref_${type}`)
|
||||
}
|
||||
|
||||
function relationLabel(rel: string): string {
|
||||
return t(`related_${rel}`)
|
||||
}
|
||||
|
||||
/* ===== 动态行操作 ===== */
|
||||
function addProduct(): void {
|
||||
products.value.push({ name: '', version: '', fixedVersion: '' })
|
||||
}
|
||||
function removeProduct(idx: number): void {
|
||||
products.value.splice(idx, 1)
|
||||
}
|
||||
function addReference(): void {
|
||||
references.value.push({ type: 'vendor', title: '', url: '' })
|
||||
}
|
||||
function removeReference(idx: number): void {
|
||||
references.value.splice(idx, 1)
|
||||
}
|
||||
function addRelated(): void {
|
||||
related.value.push({ id: '', relation: 'same_product' })
|
||||
}
|
||||
function removeRelated(idx: number): void {
|
||||
related.value.splice(idx, 1)
|
||||
}
|
||||
|
||||
/* ===== 载荷构建 ===== */
|
||||
function buildPayload(): SVEWritePayload {
|
||||
return {
|
||||
SveID: `SVE-${year.value}-${number.value}`.toUpperCase(),
|
||||
SveName: vulnTitle.value,
|
||||
SveDescription: description.value,
|
||||
SveVulnStatus: vulnStatus.value,
|
||||
SveProducts: products.value
|
||||
.filter((p) => p.name || p.version)
|
||||
.map((p) => ({ name: p.name, version: p.version, fixedVersion: p.fixedVersion })),
|
||||
SveCvss: cvss.value ?? {},
|
||||
SveSolutions: solutions.value,
|
||||
SveReferences: references.value
|
||||
.filter((r) => r.url)
|
||||
.map((r) => ({ type: r.type, title: r.title, url: r.url })),
|
||||
SveRelatedSves: related.value
|
||||
.filter((r) => r.id)
|
||||
.map((r) => ({ id: r.id, relation: r.relation })),
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 提交 ===== */
|
||||
function validate(): boolean {
|
||||
if (!/^\d{4}$/.test(year.value)) return false
|
||||
if (!/^\d{1,5}$/.test(number.value)) return false
|
||||
if (!vulnTitle.value.trim()) return false
|
||||
if (!description.value.trim()) return false
|
||||
if (!products.value.some((p) => p.name && p.version)) return false
|
||||
if (!cvss.value) return false
|
||||
return true
|
||||
}
|
||||
|
||||
async function doSubmit(asDraft: boolean): Promise<void> {
|
||||
banner.value = ''
|
||||
if (asDraft) vulnStatus.value = 'draft'
|
||||
if (!validate()) {
|
||||
banner.value = t('validation_required')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await submitSVE(buildPayload())
|
||||
router.push({ name: 'Detail', params: { id: `SVE-${year.value}-${number.value}`.toUpperCase() } })
|
||||
} catch (e) {
|
||||
banner.value = (e as Error).message || t('submit_failed')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 导入 / 导出 JSON ===== */
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
function exportJson(): void {
|
||||
const blob = new Blob([JSON.stringify(buildPayload(), null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `SVE-${year.value}-${number.value || 'XXXXXX'}.json`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
function onImportFile(event: Event): void {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
try {
|
||||
const data = JSON.parse(String(reader.result)) as Record<string, unknown>
|
||||
applyImport(data)
|
||||
banner.value = t('import_success')
|
||||
} catch {
|
||||
banner.value = t('import_failed')
|
||||
} finally {
|
||||
if (fileInput.value) fileInput.value.value = ''
|
||||
}
|
||||
}
|
||||
reader.readAsText(file)
|
||||
}
|
||||
|
||||
function applyImport(data: Record<string, unknown>): void {
|
||||
const id = String(data.SveID || '')
|
||||
const m = /^SVE-(\d{4})-(\d+)$/i.exec(id.trim())
|
||||
if (m) {
|
||||
year.value = m[1]
|
||||
number.value = m[2]
|
||||
}
|
||||
if (typeof data.SveName === 'string') vulnTitle.value = data.SveName
|
||||
if (typeof data.SveDescription === 'string') description.value = data.SveDescription
|
||||
if (typeof data.SveSolutions === 'string') solutions.value = data.SveSolutions
|
||||
if (data.SveVulnStatus === 'public' || data.SveVulnStatus === 'reserved' || data.SveVulnStatus === 'draft') {
|
||||
vulnStatus.value = data.SveVulnStatus
|
||||
}
|
||||
if (Array.isArray(data.SveProducts)) {
|
||||
products.value = data.SveProducts.map((p) => {
|
||||
const r = (p && typeof p === 'object' ? p : {}) as Record<string, unknown>
|
||||
return {
|
||||
name: String(r.name ?? r.productName ?? ''),
|
||||
version: String(r.version ?? r.productVersion ?? ''),
|
||||
fixedVersion: r.fixedVersion != null ? String(r.fixedVersion) : '',
|
||||
}
|
||||
})
|
||||
if (products.value.length === 0) products.value = [{ name: '', version: '', fixedVersion: '' }]
|
||||
}
|
||||
if (data.SveCvss && typeof data.SveCvss === 'object') {
|
||||
cvss.value = data.SveCvss as Record<string, unknown>
|
||||
}
|
||||
if (Array.isArray(data.SveReferences)) {
|
||||
references.value = data.SveReferences.map((r) => {
|
||||
const o = (r && typeof r === 'object' ? r : {}) as Record<string, unknown>
|
||||
return {
|
||||
type: String(o.type ?? o.referenceType ?? 'other'),
|
||||
title: o.title != null ? String(o.title) : '',
|
||||
url: String(o.url ?? o.referenceUrl ?? ''),
|
||||
}
|
||||
})
|
||||
if (references.value.length === 0) references.value = [{ type: 'vendor', title: '', url: '' }]
|
||||
}
|
||||
if (Array.isArray(data.SveRelatedSves)) {
|
||||
related.value = data.SveRelatedSves.map((r) => {
|
||||
if (typeof r === 'string') return { id: r, relation: 'same_product' }
|
||||
const o = (r && typeof r === 'object' ? r : {}) as Record<string, unknown>
|
||||
return {
|
||||
id: String(o.id ?? o.SveID ?? ''),
|
||||
relation: String(o.relation ?? 'same_product'),
|
||||
}
|
||||
})
|
||||
if (related.value.length === 0) related.value = [{ id: '', relation: 'same_product' }]
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="container mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- 面包屑导航 -->
|
||||
<div class="text-sm text-neutral mb-6">
|
||||
<router-link to="/" class="hover:text-secondary">{{ t('home') }}</router-link>
|
||||
<span class="mx-1">></span>
|
||||
<span class="text-primary font-medium">{{ t('create_sve') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 页面标题 -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-2xl md:text-3xl font-bold text-primary mb-2">{{ t('create_sve_title') }}</h1>
|
||||
<p class="text-neutral">{{ t('create_sve_desc') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 提示横幅 -->
|
||||
<div v-if="banner" class="bg-danger/10 border border-danger/30 text-danger px-4 py-3 rounded-lg mb-6 text-sm">
|
||||
{{ banner }}
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮区(导入/导出) -->
|
||||
<div class="bg-white rounded-xl shadow-soft p-4 mb-8 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fa fa-info-circle text-secondary"></i>
|
||||
<span class="text-neutral text-sm">{{ t('json_import_export_note') }}</span>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<label class="relative group">
|
||||
<input ref="fileInput" type="file" accept=".json" class="absolute opacity-0 w-0 h-0" @change="onImportFile" />
|
||||
<span class="flex items-center gap-2 px-4 py-2 border border-secondary text-secondary hover:bg-secondary/5 rounded-lg transition-colors text-sm font-medium cursor-pointer">
|
||||
<i class="fa fa-upload"></i>
|
||||
{{ t('import_json') }}
|
||||
</span>
|
||||
</label>
|
||||
<button type="button" class="flex items-center gap-2 px-4 py-2 bg-secondary text-white hover:bg-secondary/90 rounded-lg transition-colors text-sm font-medium" @click="exportJson">
|
||||
<i class="fa fa-download"></i>
|
||||
{{ t('export_json') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SVE 表单 -->
|
||||
<form class="bg-white rounded-xl shadow-soft p-6 mb-8" @submit.prevent="doSubmit(false)">
|
||||
<!-- 第一部分:SVE 基础信息 -->
|
||||
<div class="mb-8 pb-6 border-b border-gray-200">
|
||||
<h2 class="text-xl font-semibold mb-4 text-primary">{{ t('sve_basic_info') }}</h2>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||
<!-- SVE ID -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-neutral mb-1">{{ t('sve_id') }}</label>
|
||||
<div class="flex items-center">
|
||||
<span class="text-neutral mr-2">SVE-</span>
|
||||
<input
|
||||
v-model="year"
|
||||
type="text"
|
||||
maxlength="4"
|
||||
:placeholder="t('sve_year_placeholder')"
|
||||
class="w-20 px-3 py-2 border border-gray-300 rounded-l-lg focus:outline-none focus:ring-2 focus:ring-secondary/50 focus:border-secondary"
|
||||
/>
|
||||
<span class="text-neutral mx-2">-</span>
|
||||
<input
|
||||
v-model="number"
|
||||
type="text"
|
||||
maxlength="5"
|
||||
:placeholder="t('sve_number_placeholder')"
|
||||
class="flex-1 px-3 py-2 border border-gray-300 rounded-r-lg focus:outline-none focus:ring-2 focus:ring-secondary/50 focus:border-secondary"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-xs text-neutral mt-1">{{ t('sve_id_format_note') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 发布日期 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-neutral mb-1">{{ t('publish_date') }}</label>
|
||||
<input
|
||||
v-model="publishDate"
|
||||
type="date"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-secondary/50 focus:border-secondary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- 漏洞标题 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-neutral mb-1">{{ t('vuln_title') }}</label>
|
||||
<input
|
||||
v-model="vulnTitle"
|
||||
type="text"
|
||||
:placeholder="t('vuln_title_placeholder')"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-secondary/50 focus:border-secondary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 漏洞状态 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-neutral mb-1">{{ t('vuln_status') }}</label>
|
||||
<select
|
||||
v-model="vulnStatus"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-secondary/50 focus:border-secondary"
|
||||
>
|
||||
<option value="draft">{{ t('vuln_status_draft') }}</option>
|
||||
<option value="public">{{ t('vuln_status_public') }}</option>
|
||||
<option value="reserved">{{ t('vuln_status_reserved') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第二部分:漏洞描述 -->
|
||||
<div class="mb-8 pb-6 border-b border-gray-200">
|
||||
<h2 class="text-xl font-semibold mb-4 text-primary">{{ t('vuln_description') }}</h2>
|
||||
<p class="text-sm text-neutral mb-3">{{ t('vuln_description_note') }}</p>
|
||||
<MarkdownEditor v-model="description" />
|
||||
</div>
|
||||
|
||||
<!-- 第三部分:受影响产品 -->
|
||||
<div class="mb-8 pb-6 border-b border-gray-200">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-semibold text-primary">{{ t('affected_products') }}</h2>
|
||||
<button type="button" class="flex items-center gap-1 px-3 py-1 text-secondary hover:text-primary text-sm font-medium" @click="addProduct">
|
||||
<i class="fa fa-plus-circle"></i>
|
||||
{{ t('add_product') }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-sm text-neutral mb-3">{{ t('affected_products_note') }}</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div v-for="(p, idx) in products" :key="idx" class="product-item p-4 border border-gray-200 rounded-lg">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-neutral mb-1">{{ t('product_name') }}</label>
|
||||
<input
|
||||
v-model="p.name"
|
||||
type="text"
|
||||
:placeholder="t('product_name_placeholder')"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-secondary/50 focus:border-secondary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-neutral mb-1">{{ t('product_version') }}</label>
|
||||
<input
|
||||
v-model="p.version"
|
||||
type="text"
|
||||
:placeholder="t('product_version_placeholder')"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-secondary/50 focus:border-secondary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-neutral mb-1">{{ t('fixed_version') }}</label>
|
||||
<input
|
||||
v-model="p.fixedVersion"
|
||||
type="text"
|
||||
:placeholder="t('fixed_version_placeholder')"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-secondary/50 focus:border-secondary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="text-danger hover:text-danger/80 text-sm flex items-center gap-1" @click="removeProduct(idx)">
|
||||
<i class="fa fa-trash-o"></i>
|
||||
{{ t('remove_product') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第四部分:CVSS 评分 -->
|
||||
<div class="mb-8 pb-6 border-b border-gray-200">
|
||||
<h2 class="text-xl font-semibold mb-4 text-primary">{{ t('cvss_rating') }}</h2>
|
||||
<p class="text-sm text-neutral mb-3">{{ t('cvss_rating_note') }}</p>
|
||||
<CvssCalculator v-model="cvss" />
|
||||
</div>
|
||||
|
||||
<!-- 第五部分:解决方案 -->
|
||||
<div class="mb-8 pb-6 border-b border-gray-200">
|
||||
<h2 class="text-xl font-semibold mb-4 text-primary">{{ t('solutions_mitigations') }}</h2>
|
||||
<p class="text-sm text-neutral mb-3">{{ t('solutions_mitigations_note') }}</p>
|
||||
<MarkdownEditor v-model="solutions" />
|
||||
</div>
|
||||
|
||||
<!-- 第六部分:参考资料 -->
|
||||
<div class="mb-8 pb-6 border-b border-gray-200">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-semibold text-primary">{{ t('references') }}</h2>
|
||||
<button type="button" class="flex items-center gap-1 px-3 py-1 text-secondary hover:text-primary text-sm font-medium" @click="addReference">
|
||||
<i class="fa fa-plus-circle"></i>
|
||||
{{ t('add_reference') }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-sm text-neutral mb-3">{{ t('references_note') }}</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div v-for="(ref, idx) in references" :key="idx" class="reference-item p-4 border border-gray-200 rounded-lg">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-neutral mb-1">{{ t('reference_type') }}</label>
|
||||
<select
|
||||
v-model="ref.type"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-secondary/50 focus:border-secondary"
|
||||
>
|
||||
<option v-for="type in REFERENCE_TYPES" :key="type" :value="type">{{ referenceTypeLabel(type) }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-neutral mb-1">{{ t('reference_title') }}</label>
|
||||
<input
|
||||
v-model="ref.title"
|
||||
type="text"
|
||||
:placeholder="t('reference_title_placeholder')"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-secondary/50 focus:border-secondary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-neutral mb-1">{{ t('reference_url') }}</label>
|
||||
<input
|
||||
v-model="ref.url"
|
||||
type="url"
|
||||
:placeholder="t('reference_url_placeholder')"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-secondary/50 focus:border-secondary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="text-danger hover:text-danger/80 text-sm flex items-center gap-1" @click="removeReference(idx)">
|
||||
<i class="fa fa-trash-o"></i>
|
||||
{{ t('remove_reference') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第七部分:相关 SVE -->
|
||||
<div class="mb-8">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-semibold text-primary">{{ t('related_sve') }}</h2>
|
||||
<button type="button" class="flex items-center gap-1 px-3 py-1 text-secondary hover:text-primary text-sm font-medium" @click="addRelated">
|
||||
<i class="fa fa-plus-circle"></i>
|
||||
{{ t('add_related_sve') }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-sm text-neutral mb-3">{{ t('related_sve_note') }}</p>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div v-for="(rel, idx) in related" :key="idx" class="related-sve-item flex flex-wrap items-center gap-3 p-3 border border-gray-200 rounded-lg">
|
||||
<input
|
||||
v-model="rel.id"
|
||||
type="text"
|
||||
:placeholder="t('related_sve_id_placeholder')"
|
||||
class="flex-1 min-w-[180px] px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-secondary/50 focus:border-secondary"
|
||||
/>
|
||||
<select
|
||||
v-model="rel.relation"
|
||||
class="w-40 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-secondary/50 focus:border-secondary"
|
||||
>
|
||||
<option v-for="relation in RELATIONS" :key="relation" :value="relation">{{ relationLabel(relation) }}</option>
|
||||
</select>
|
||||
<button type="button" class="text-danger hover:text-danger/80 p-2" :title="t('remove_related_sve')" @click="removeRelated(idx)">
|
||||
<i class="fa fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 表单提交按钮 -->
|
||||
<div class="flex flex-col sm:flex-row justify-end gap-4 pt-6 border-t border-gray-200">
|
||||
<button type="button" class="px-6 py-3 border border-gray-300 text-neutral hover:bg-gray-50 rounded-lg transition-colors font-medium" :disabled="submitting" @click="doSubmit(true)">
|
||||
{{ t('save_draft') }}
|
||||
</button>
|
||||
<button type="submit" class="px-6 py-3 bg-secondary text-white hover:bg-secondary/90 rounded-lg transition-colors font-medium" :disabled="submitting">
|
||||
{{ t('submit_sve') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"types": ["vite/client", "node"],
|
||||
"allowArbitraryExtensions": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "src/**/*.d.ts"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"module": "nodenext",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
tailwindcss(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5174,
|
||||
// 开发期把 SVE 相关 API 请求代理到 Django 后端,Cookie 认证跨域自动处理
|
||||
proxy: {
|
||||
'/API': {
|
||||
target: 'http://127.0.0.1:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/static': {
|
||||
target: 'http://127.0.0.1:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/media': {
|
||||
target: 'http://127.0.0.1:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: false,
|
||||
chunkSizeWarningLimit: 2000,
|
||||
cssCodeSplit: false,
|
||||
},
|
||||
})
|
||||
Reference in new issue
Block a user