feat: SVE 漏洞百科 Vue3 前端(替代 TempLate/Org),含一键部署脚本

This commit is contained in:
TermiNexus committed 2026-08-11 16:25:05 +08:00
commit d515dd51d4
31 files changed
+6155

No files matched your search

+24
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+2693
View File
File diff suppressed because it is too large. Load diff
+30
View File
@@ -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
View File
@@ -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>
+66
View File
@@ -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
+76
View File
@@ -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)
}
+23
View File
@@ -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>
+203
View File
@@ -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>
+52
View File
@@ -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>
+21
View File
@@ -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>
+86
View File
@@ -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>
+144
View File
@@ -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>
+35
View File
@@ -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 }
}
+8
View File
@@ -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
}
+58
View File
@@ -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,
}
}
+400
View File
@@ -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
View File
@@ -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')
+50
View File
@@ -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
+222
View File
@@ -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;
}
+136
View File
@@ -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: '其他关系' },
}
+290
View File
@@ -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>
+368
View File
@@ -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">&gt;</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>
+416
View File
@@ -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>
+43
View File
@@ -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>
+472
View File
@@ -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">&gt;</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>
+18
View File
@@ -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"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+23
View File
@@ -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"]
}
+42
View File
@@ -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,
},
})