feat: 全量功能模块开发与集成测试修复
- 新增后端模块:Alert、APIAsset、Compliance、Lineage、Masking、Risk、SchemaChange、Unstructured、Watermark - 新增前端模块页面与API接口 - 新增Alembic迁移脚本(002-014)覆盖全量业务表 - 新增测试数据生成脚本与集成测试脚本 - 修复metadata模型JSON类型导入缺失导致启动失败的问题 - 修复前端Alert/APIAsset页面request模块路径错误 - 更新docker-compose与开发计划文档
This commit is contained in:
-1
@@ -1 +0,0 @@
|
||||
.page-title[data-v-0ac8aaa8]{font-size:20px;font-weight:600;margin-bottom:20px;color:#303133}.section[data-v-0ac8aaa8]{margin-bottom:24px}.section .section-header[data-v-0ac8aaa8]{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}.section .section-title[data-v-0ac8aaa8]{font-size:16px;font-weight:600;color:#303133}.level-card[data-v-0ac8aaa8]{padding:16px;margin-bottom:12px;background:#fff;border-radius:8px}.level-card .level-header[data-v-0ac8aaa8]{display:flex;align-items:center;gap:10px;margin-bottom:8px}.level-card .level-name[data-v-0ac8aaa8]{font-size:15px;font-weight:600;color:#303133}.level-card .level-desc[data-v-0ac8aaa8]{font-size:13px;color:#606266;line-height:1.5;margin-bottom:8px}.level-card .level-ctrl .ctrl-item[data-v-0ac8aaa8]{font-size:12px;color:#909399;margin-bottom:2px}.level-card .level-ctrl .ctrl-item .ctrl-key[data-v-0ac8aaa8]{font-weight:500;color:#606266}.category-tree[data-v-0ac8aaa8]{padding:16px;background:#fff;border-radius:8px}.category-tree .custom-tree-node[data-v-0ac8aaa8]{display:flex;align-items:center;justify-content:space-between;flex:1;overflow:hidden}.category-tree .custom-tree-node .node-label[data-v-0ac8aaa8]{display:flex;align-items:center;gap:8px;overflow:hidden}.category-tree .custom-tree-node .node-label .code-tag[data-v-0ac8aaa8]{font-size:11px;flex-shrink:0}.category-tree .custom-tree-node .node-actions[data-v-0ac8aaa8]{flex-shrink:0}.table-card[data-v-0ac8aaa8]{padding:16px;background:#fff;border-radius:8px}
|
||||
@@ -1 +0,0 @@
|
||||
.page-header[data-v-e577ddaa]{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px}.page-header .page-title[data-v-e577ddaa]{font-size:20px;font-weight:600;color:#303133}.search-bar[data-v-e577ddaa]{padding:16px;margin-bottom:16px}.table-card[data-v-e577ddaa]{padding:16px}.pagination-bar[data-v-e577ddaa]{display:flex;justify-content:flex-end;margin-top:16px}
|
||||
-1
@@ -1 +0,0 @@
|
||||
.layout-container[data-v-6b05a74f]{height:100vh}.layout-aside[data-v-6b05a74f]{background-color:#1a2b4a;display:flex;flex-direction:column}.logo[data-v-6b05a74f]{height:56px;display:flex;align-items:center;justify-content:center;gap:10px;background-color:#13203a;flex-shrink:0}.logo .logo-text[data-v-6b05a74f]{color:#fff;font-size:16px;font-weight:600;letter-spacing:1px}.layout-menu[data-v-6b05a74f]{border-right:none;flex:1}.layout-header[data-v-6b05a74f]{background-color:#fff;display:flex;align-items:center;justify-content:space-between;box-shadow:0 1px 4px #0000000d;padding:0 16px}.layout-header .header-left[data-v-6b05a74f]{display:flex;align-items:center;gap:12px}.layout-header .header-title[data-v-6b05a74f]{font-size:16px;font-weight:600;color:#303133}.layout-header .header-right .user-info[data-v-6b05a74f]{display:flex;align-items:center;gap:8px;cursor:pointer;color:#606266}.layout-header .header-right .user-info .username[data-v-6b05a74f]{font-size:14px;max-width:100px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.layout-main[data-v-6b05a74f]{background-color:#f5f7fa;padding:0;overflow-y:auto}[data-v-6b05a74f] .mobile-drawer .el-drawer__body{padding:0;background-color:#1a2b4a;display:flex;flex-direction:column}
|
||||
-1
@@ -1 +0,0 @@
|
||||
.login-page[data-v-8c2e034d]{min-height:100vh;display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg,#1a2b4a,#2d4a7c);padding:16px}.login-box[data-v-8c2e034d]{width:100%;max-width:420px;padding:40px 32px;background:#fff;border-radius:12px}.login-header[data-v-8c2e034d]{text-align:center;margin-bottom:32px}.login-header .login-title[data-v-8c2e034d]{font-size:24px;font-weight:700;color:#1a2b4a;margin-top:12px}.login-header .login-subtitle[data-v-8c2e034d]{font-size:14px;color:#909399;margin-top:8px}.login-btn[data-v-8c2e034d]{width:100%;height:44px;font-size:16px;border-radius:6px}.login-footer[data-v-8c2e034d]{margin-top:24px;text-align:center;font-size:12px;color:#c0c4cc}
|
||||
-1
@@ -1 +0,0 @@
|
||||
.metadata-page .page-title[data-v-866ea4fc]{font-size:20px;font-weight:600;margin-bottom:16px;color:#303133}.content-row .el-col[data-v-866ea4fc]{margin-bottom:16px}.tree-card[data-v-866ea4fc]{padding:16px;height:calc(100vh - 140px);overflow-y:auto}.tree-card .tree-header[data-v-866ea4fc]{font-size:16px;font-weight:600;margin-bottom:12px;color:#303133}.tree-card .tree-search[data-v-866ea4fc]{margin-bottom:12px}.custom-tree-node[data-v-866ea4fc]{display:flex;align-items:center;gap:6px;flex:1;overflow:hidden}.custom-tree-node .node-label[data-v-866ea4fc]{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.custom-tree-node .node-badge[data-v-866ea4fc]{font-size:11px;color:#909399;background:#f2f6fc;padding:0 6px;border-radius:10px}.detail-card[data-v-866ea4fc]{padding:16px;height:calc(100vh - 140px);display:flex;flex-direction:column}.detail-card .detail-header[data-v-866ea4fc]{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;flex-wrap:wrap;gap:12px}.detail-card .detail-header .detail-title[data-v-866ea4fc]{display:flex;align-items:center;gap:10px;font-size:16px;font-weight:600;color:#303133}.detail-card .detail-header .detail-title .placeholder[data-v-866ea4fc]{color:#909399;font-weight:400}.sample-text[data-v-866ea4fc]{color:#909399;font-size:12px}
|
||||
-1
@@ -1 +0,0 @@
|
||||
.page-header[data-v-5fcafe59]{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px}.page-header .page-title[data-v-5fcafe59]{font-size:20px;font-weight:600;color:#303133}.search-bar[data-v-5fcafe59]{padding:16px;margin-bottom:16px}.project-list .el-col[data-v-5fcafe59]{margin-bottom:16px}.project-card[data-v-5fcafe59]{padding:20px;background:#fff;border-radius:8px;transition:box-shadow .2s}.project-card[data-v-5fcafe59]:hover{box-shadow:0 4px 16px #00000014}.project-card .project-header[data-v-5fcafe59]{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px}.project-card .project-header .project-name[data-v-5fcafe59]{font-size:16px;font-weight:600;color:#303133}.project-card .project-desc[data-v-5fcafe59]{font-size:13px;color:#909399;margin-bottom:16px;min-height:20px}.project-card .project-stats[data-v-5fcafe59]{display:flex;gap:16px;margin-bottom:16px;padding:12px 0;border-top:1px solid #f0f0f0;border-bottom:1px solid #f0f0f0}.project-card .project-stats .stat-item[data-v-5fcafe59]{text-align:center;flex:1}.project-card .project-stats .stat-item .stat-num[data-v-5fcafe59]{font-size:18px;font-weight:700;color:#303133}.project-card .project-stats .stat-item .stat-label[data-v-5fcafe59]{font-size:12px;color:#909399;margin-top:2px}.project-card .project-actions[data-v-5fcafe59]{display:flex;justify-content:flex-end;gap:8px}
|
||||
-1
@@ -1 +0,0 @@
|
||||
.page-title[data-v-d05c8e34]{font-size:20px;font-weight:600;margin-bottom:16px;color:#303133}.system-tabs[data-v-d05c8e34]{background:#fff;padding:16px;border-radius:8px}.table-card[data-v-d05c8e34]{padding:16px}.table-card .table-header[data-v-d05c8e34]{display:flex;align-items:center;gap:12px;margin-bottom:16px}
|
||||
-1
File diff suppressed because one or more lines are too long
Vendored
+3
-3
@@ -4,9 +4,9 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>PropDataGuard - 财险数据分级分类平台</title>
|
||||
<script type="module" crossorigin src="/assets/index-DveMB2K5.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-s_XEM0GP.css">
|
||||
<title>DataPointer - 数据分类分级管理平台</title>
|
||||
<script type="module" crossorigin src="/assets/index-54C8aHj2.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CdImMPt_.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>报告预览</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 40px; color: #333; background: #f5f7fa; }
|
||||
.page { max-width: 800px; margin: 0 auto; background: #fff; padding: 48px; box-shadow: 0 2px 12px rgba(0,0,0,0.1); }
|
||||
h1 { text-align: center; font-size: 24px; margin-bottom: 32px; }
|
||||
h2 { font-size: 16px; border-left: 4px solid #409eff; padding-left: 8px; margin-top: 24px; }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 12px; }
|
||||
th, td { border: 1px solid #dcdfe6; padding: 8px 12px; text-align: left; }
|
||||
th { background: #f5f7fa; }
|
||||
.highlight { background: #fde2e2; }
|
||||
.info { color: #909399; font-size: 12px; margin-top: 4px; }
|
||||
.print-btn { position: fixed; top: 20px; right: 20px; padding: 8px 16px; background: #409eff; color: #fff; border: none; border-radius: 4px; cursor: pointer; }
|
||||
@media print { .print-btn { display: none; } body { margin: 0; background: #fff; } .page { box-shadow: none; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<button class="print-btn" onclick="window.print()">打印 / 保存为PDF</button>
|
||||
<div class="page">
|
||||
<h1>数据分类分级项目报告</h1>
|
||||
<div id="content">加载中...</div>
|
||||
</div>
|
||||
<script>
|
||||
const params = new URLSearchParams(location.search);
|
||||
const projectId = params.get('projectId');
|
||||
const apiBase = '/api/v1';
|
||||
async function load() {
|
||||
if (!projectId) { document.getElementById('content').innerText = '缺少项目ID'; return; }
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/reports/projects/${projectId}/summary`);
|
||||
const json = await res.json();
|
||||
const d = json.data;
|
||||
let html = '';
|
||||
html += '<h2>一、项目基本信息</h2>';
|
||||
html += '<table><tr><th>项目名称</th><td>' + (d.project_name || '') + '</td></tr>';
|
||||
html += '<tr><th>报告生成时间</th><td>' + (d.generated_at || '').slice(0,19).replace('T',' ') + '</td></tr>';
|
||||
html += '<tr><th>项目状态</th><td>' + (d.status || '') + '</td></tr>';
|
||||
html += '<tr><th>模板版本</th><td>' + (d.template_version || '') + '</td></tr></table>';
|
||||
html += '<h2>二、分类分级统计</h2>';
|
||||
html += '<p>总字段数: ' + d.total + ' | 自动识别: ' + d.auto + ' | 人工打标: ' + d.manual + '</p>';
|
||||
html += '<h2>三、分级分布</h2><table><tr><th>分级</th><th>数量</th><th>占比</th></tr>';
|
||||
d.level_distribution.forEach(item => {
|
||||
const pct = d.total ? (item.count / d.total * 100).toFixed(1) + '%' : '0%';
|
||||
const cls = item.name.includes('L4') || item.name.includes('L5') ? 'highlight' : '';
|
||||
html += '<tr class="' + cls + '"><td>' + item.name + '</td><td>' + item.count + '</td><td>' + pct + '</td></tr>';
|
||||
});
|
||||
html += '</table>';
|
||||
html += '<h2>四、高敏感数据清单(L4/L5)</h2>';
|
||||
if (d.high_risk && d.high_risk.length) {
|
||||
html += '<table><tr><th>字段名</th><th>所属表</th><th>分类</th><th>分级</th><th>来源</th></tr>';
|
||||
d.high_risk.forEach(r => {
|
||||
html += '<tr class="highlight"><td>' + r.column_name + '</td><td>' + r.table_name + '</td><td>' + r.category_name + '</td><td>' + r.level_name + '</td><td>' + r.source + '</td></tr>';
|
||||
});
|
||||
html += '</table>';
|
||||
} else {
|
||||
html += '<p>暂无L4/L5级高敏感数据。</p>';
|
||||
}
|
||||
document.getElementById('content').innerHTML = html;
|
||||
} catch (e) {
|
||||
document.getElementById('content').innerText = '加载失败: ' + e.message;
|
||||
}
|
||||
}
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -107,3 +107,27 @@ export function getClassificationResults(params: {
|
||||
}) {
|
||||
return request.get('/classifications/results', { params })
|
||||
}
|
||||
|
||||
export interface MLSuggestion {
|
||||
column_id: number
|
||||
column_name: string
|
||||
table_name?: string
|
||||
suggestions: {
|
||||
category_id: number
|
||||
category_name?: string
|
||||
category_code?: string
|
||||
confidence: number
|
||||
}[]
|
||||
}
|
||||
|
||||
export function getMLSuggestions(project_id: number, column_ids?: number[], top_k: number = 3) {
|
||||
const params: any = { top_k }
|
||||
if (column_ids && column_ids.length) {
|
||||
params.column_ids = column_ids.join(',')
|
||||
}
|
||||
return request.get(`/classifications/ml-suggest/${project_id}`, { params })
|
||||
}
|
||||
|
||||
export function trainMLModel(background: boolean = true, model_name?: string, algorithm: string = 'logistic_regression') {
|
||||
return request.post('/classifications/ml-train', null, { params: { background, model_name, algorithm } })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import request from './request'
|
||||
|
||||
export function initComplianceRules() {
|
||||
return request.post('/compliance/init-rules')
|
||||
}
|
||||
|
||||
export function scanCompliance(projectId?: number) {
|
||||
return request.post('/compliance/scan', null, { params: projectId ? { project_id: projectId } : undefined })
|
||||
}
|
||||
|
||||
export function getComplianceIssues(params?: { project_id?: number; status?: string; page?: number; page_size?: number }) {
|
||||
return request.get('/compliance/issues', { params })
|
||||
}
|
||||
|
||||
export function resolveIssue(id: number) {
|
||||
return request.post(`/compliance/issues/${id}/resolve`)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import request from './request'
|
||||
|
||||
export function parseLineage(sql: string, targetTable: string) {
|
||||
return request.post('/lineage/parse', null, { params: { sql, target_table: targetTable } })
|
||||
}
|
||||
|
||||
export function getLineageGraph(tableName?: string) {
|
||||
return request.get('/lineage/graph', { params: tableName ? { table_name: tableName } : undefined })
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import request from './request'
|
||||
|
||||
export interface MaskingRuleItem {
|
||||
id: number
|
||||
name: string
|
||||
level_id?: number
|
||||
category_id?: number
|
||||
algorithm: string
|
||||
params?: Record<string, any>
|
||||
is_active: boolean
|
||||
description?: string
|
||||
level_name?: string
|
||||
category_name?: string
|
||||
}
|
||||
|
||||
export function getMaskingRules(params?: { level_id?: number; category_id?: number; page?: number; page_size?: number }) {
|
||||
return request.get('/masking/rules', { params })
|
||||
}
|
||||
|
||||
export function createMaskingRule(data: Partial<MaskingRuleItem>) {
|
||||
return request.post('/masking/rules', data)
|
||||
}
|
||||
|
||||
export function updateMaskingRule(id: number, data: Partial<MaskingRuleItem>) {
|
||||
return request.put(`/masking/rules/${id}`, data)
|
||||
}
|
||||
|
||||
export function deleteMaskingRule(id: number) {
|
||||
return request.delete(`/masking/rules/${id}`)
|
||||
}
|
||||
|
||||
export function previewMasking(source_id: number, table_name: string, project_id?: number, limit: number = 20) {
|
||||
return request.post('/masking/preview', null, { params: { source_id, table_name, project_id, limit } })
|
||||
}
|
||||
@@ -34,6 +34,10 @@ export function deleteProject(id: number) {
|
||||
return request.delete(`/projects/${id}`)
|
||||
}
|
||||
|
||||
export function autoClassifyProject(id: number) {
|
||||
return request.post(`/projects/${id}/auto-classify`)
|
||||
export function autoClassifyProject(id: number, background: boolean = true) {
|
||||
return request.post(`/projects/${id}/auto-classify`, null, { params: { background } })
|
||||
}
|
||||
|
||||
export function getAutoClassifyStatus(id: number) {
|
||||
return request.get(`/projects/${id}/auto-classify-status`)
|
||||
}
|
||||
|
||||
@@ -16,12 +16,12 @@ export function getReportStats() {
|
||||
return request.get('/reports/stats')
|
||||
}
|
||||
|
||||
export function downloadReport(projectId: number) {
|
||||
export function downloadReport(projectId: number, format: string = 'docx') {
|
||||
const token = localStorage.getItem('dp_token')
|
||||
const url = `/api/v1/reports/projects/${projectId}/download`
|
||||
const url = `/api/v1/reports/projects/${projectId}/download?format=${format}`
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `report_project_${projectId}.docx`
|
||||
a.download = `report_project_${projectId}.${format === 'excel' ? 'xlsx' : 'docx'}`
|
||||
if (token) {
|
||||
a.setAttribute('data-token', token)
|
||||
}
|
||||
@@ -29,3 +29,7 @@ export function downloadReport(projectId: number) {
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
}
|
||||
|
||||
export function getReportSummary(projectId: number) {
|
||||
return request.get(`/reports/projects/${projectId}/summary`)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import request from './request'
|
||||
|
||||
export function recalculateRisk(projectId?: number) {
|
||||
return request.post('/risk/recalculate', null, { params: projectId ? { project_id: projectId } : undefined })
|
||||
}
|
||||
|
||||
export function getRiskTop(n: number = 10) {
|
||||
return request.get('/risk/top', { params: { n } })
|
||||
}
|
||||
|
||||
export function getProjectRisk(projectId: number) {
|
||||
return request.get(`/risk/projects/${projectId}`)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import request from './request'
|
||||
|
||||
export interface SchemaChangeLogItem {
|
||||
id: number
|
||||
source_id: number
|
||||
database_id?: number
|
||||
table_id?: number
|
||||
column_id?: number
|
||||
change_type: string
|
||||
old_value?: string
|
||||
new_value?: string
|
||||
detected_at?: string
|
||||
}
|
||||
|
||||
export function getSchemaChangeLogs(params?: { source_id?: number; change_type?: string; page?: number; page_size?: number }) {
|
||||
return request.get('/schema-changes/logs', { params })
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import request from './request'
|
||||
|
||||
export interface UnstructuredFileItem {
|
||||
id: number
|
||||
original_name: string
|
||||
file_type: string
|
||||
file_size: number
|
||||
status: string
|
||||
analysis_result?: {
|
||||
matches: {
|
||||
rule_name: string
|
||||
category_code: string
|
||||
level_code: string
|
||||
snippet: string
|
||||
position: number
|
||||
}[]
|
||||
total_chars: number
|
||||
}
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export function uploadUnstructuredFile(file: File) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return request.post('/unstructured/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
}
|
||||
|
||||
export function getUnstructuredFiles(params?: { page?: number; page_size?: number }) {
|
||||
return request.get('/unstructured/files', { params })
|
||||
}
|
||||
|
||||
export function reprocessUnstructuredFile(id: number) {
|
||||
return request.post(`/unstructured/files/${id}/reprocess`)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import request from './request'
|
||||
|
||||
export function traceWatermark(text: string) {
|
||||
return request.post('/watermark/trace', { text })
|
||||
}
|
||||
@@ -13,9 +13,6 @@
|
||||
<el-menu
|
||||
:default-active="activeRoute"
|
||||
router
|
||||
background-color="#1a2b4a"
|
||||
text-color="#b0c4de"
|
||||
active-text-color="#fff"
|
||||
class="layout-menu"
|
||||
>
|
||||
<el-menu-item
|
||||
@@ -46,6 +43,7 @@
|
||||
<span class="header-title">{{ pageTitle }}</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-button text circle @click="toggleTheme" :icon="isDark ? Sunny : Moon" class="theme-btn" />
|
||||
<el-dropdown @command="handleCommand">
|
||||
<span class="user-info">
|
||||
<el-avatar :size="32" :icon="UserFilled" />
|
||||
@@ -63,7 +61,12 @@
|
||||
|
||||
<!-- Main Content -->
|
||||
<el-main class="layout-main">
|
||||
<router-view />
|
||||
<router-view v-slot="{ Component }">
|
||||
<keep-alive>
|
||||
<component :is="Component" v-if="route.meta?.keepAlive" :key="route.path" />
|
||||
</keep-alive>
|
||||
<component :is="Component" v-if="!route.meta?.keepAlive" :key="route.path" />
|
||||
</router-view>
|
||||
</el-main>
|
||||
</el-container>
|
||||
|
||||
@@ -82,9 +85,6 @@
|
||||
<el-menu
|
||||
:default-active="activeRoute"
|
||||
router
|
||||
background-color="#1a2b4a"
|
||||
text-color="#b0c4de"
|
||||
active-text-color="#fff"
|
||||
class="layout-menu"
|
||||
@select="drawerVisible = false"
|
||||
>
|
||||
@@ -107,7 +107,7 @@
|
||||
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { UserFilled, Expand, Collection, ArrowDown } from '@element-plus/icons-vue'
|
||||
import { UserFilled, Expand, Collection, ArrowDown, Moon, Sunny } from '@element-plus/icons-vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -115,6 +115,35 @@ const userStore = useUserStore()
|
||||
|
||||
const drawerVisible = ref(false)
|
||||
const windowWidth = ref(window.innerWidth)
|
||||
const isDark = ref(false)
|
||||
|
||||
function toggleTheme() {
|
||||
isDark.value = !isDark.value
|
||||
const html = document.documentElement
|
||||
if (isDark.value) {
|
||||
html.classList.add('dark')
|
||||
localStorage.setItem('theme', 'dark')
|
||||
} else {
|
||||
html.classList.remove('dark')
|
||||
localStorage.setItem('theme', 'light')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const saved = localStorage.getItem('theme')
|
||||
if (saved === 'dark') {
|
||||
isDark.value = true
|
||||
document.documentElement.classList.add('dark')
|
||||
}
|
||||
window.addEventListener('resize', onResize)
|
||||
if (!userStore.userInfo && userStore.token) {
|
||||
userStore.fetchUserInfo()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', onResize)
|
||||
})
|
||||
|
||||
const isMobile = computed(() => windowWidth.value < 768)
|
||||
const activeRoute = computed(() => route.path)
|
||||
@@ -142,13 +171,6 @@ function onResize() {
|
||||
windowWidth.value = window.innerWidth
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', onResize)
|
||||
if (!userStore.userInfo && userStore.token) {
|
||||
userStore.fetchUserInfo()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', onResize)
|
||||
})
|
||||
@@ -185,6 +207,16 @@ onUnmounted(() => {
|
||||
.layout-menu {
|
||||
border-right: none;
|
||||
flex: 1;
|
||||
--el-menu-bg-color: #1a2b4a;
|
||||
--el-menu-text-color: #b0c4de;
|
||||
--el-menu-hover-text-color: #fff;
|
||||
--el-menu-active-color: #fff;
|
||||
}
|
||||
html.dark .layout-menu {
|
||||
--el-menu-bg-color: #141414;
|
||||
--el-menu-text-color: #b0b0b0;
|
||||
--el-menu-hover-text-color: #fff;
|
||||
--el-menu-active-color: #409eff;
|
||||
}
|
||||
|
||||
.layout-header {
|
||||
@@ -208,6 +240,17 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.theme-btn {
|
||||
color: #606266;
|
||||
}
|
||||
html.dark .theme-btn {
|
||||
color: #b0b0b0;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -232,6 +275,29 @@ onUnmounted(() => {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
html.dark .layout-aside {
|
||||
background-color: #141414;
|
||||
}
|
||||
html.dark .logo {
|
||||
background-color: #0a0a0a;
|
||||
}
|
||||
html.dark .layout-header {
|
||||
background-color: #1d1e1f;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.3);
|
||||
}
|
||||
html.dark .header-title {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
html.dark .user-info {
|
||||
color: #b0b0b0;
|
||||
}
|
||||
html.dark .layout-main {
|
||||
background-color: #0a0a0a;
|
||||
}
|
||||
html.dark :deep(.mobile-drawer) .el-drawer__body {
|
||||
background-color: #141414;
|
||||
}
|
||||
|
||||
:deep(.mobile-drawer) {
|
||||
.el-drawer__body {
|
||||
padding: 0;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import 'element-plus/theme-chalk/dark/css-vars.css'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
|
||||
import App from './App.vue'
|
||||
|
||||
@@ -18,7 +18,7 @@ const routes = [
|
||||
path: 'dashboard',
|
||||
name: 'Dashboard',
|
||||
component: () => import('@/views/dashboard/Dashboard.vue'),
|
||||
meta: { title: '首页', icon: 'HomeFilled', roles: ['superadmin', 'admin', 'project_manager', 'labeler', 'reviewer', 'guest'] },
|
||||
meta: { title: '首页', icon: 'HomeFilled', roles: ['superadmin', 'admin', 'project_manager', 'labeler', 'reviewer', 'guest'], keepAlive: true },
|
||||
},
|
||||
{
|
||||
path: 'datasource',
|
||||
@@ -62,6 +62,54 @@ const routes = [
|
||||
component: () => import('@/views/report/Report.vue'),
|
||||
meta: { title: '报表统计', icon: 'TrendCharts', roles: ['superadmin', 'admin', 'project_manager'] },
|
||||
},
|
||||
{
|
||||
path: 'masking',
|
||||
name: 'Masking',
|
||||
component: () => import('@/views/masking/Masking.vue'),
|
||||
meta: { title: '数据脱敏', icon: 'Hide', roles: ['superadmin', 'admin', 'project_manager'] },
|
||||
},
|
||||
{
|
||||
path: 'watermark',
|
||||
name: 'Watermark',
|
||||
component: () => import('@/views/watermark/Watermark.vue'),
|
||||
meta: { title: '水印溯源', icon: 'Memo', roles: ['superadmin', 'admin', 'project_manager'] },
|
||||
},
|
||||
{
|
||||
path: 'unstructured',
|
||||
name: 'Unstructured',
|
||||
component: () => import('@/views/unstructured/Unstructured.vue'),
|
||||
meta: { title: '非结构化文件', icon: 'Document', roles: ['superadmin', 'admin', 'project_manager', 'labeler', 'reviewer', 'guest'] },
|
||||
},
|
||||
{
|
||||
path: 'schema-change',
|
||||
name: 'SchemaChange',
|
||||
component: () => import('@/views/schema_change/SchemaChange.vue'),
|
||||
meta: { title: 'Schema变更', icon: 'Refresh', roles: ['superadmin', 'admin', 'project_manager'] },
|
||||
},
|
||||
{
|
||||
path: 'compliance',
|
||||
name: 'Compliance',
|
||||
component: () => import('@/views/compliance/Compliance.vue'),
|
||||
meta: { title: '合规检查', icon: 'CircleCheck', roles: ['superadmin', 'admin', 'project_manager'] },
|
||||
},
|
||||
{
|
||||
path: 'lineage',
|
||||
name: 'Lineage',
|
||||
component: () => import('@/views/lineage/Lineage.vue'),
|
||||
meta: { title: '数据血缘', icon: 'Share', roles: ['superadmin', 'admin', 'project_manager'] },
|
||||
},
|
||||
{
|
||||
path: 'alerts',
|
||||
name: 'Alerts',
|
||||
component: () => import('@/views/alert/Alert.vue'),
|
||||
meta: { title: '告警与工单', icon: 'Warning', roles: ['superadmin', 'admin', 'project_manager'] },
|
||||
},
|
||||
{
|
||||
path: 'api-assets',
|
||||
name: 'ApiAssets',
|
||||
component: () => import('@/views/api_asset/APIAsset.vue'),
|
||||
meta: { title: 'API资产扫描', icon: 'Connection', roles: ['superadmin', 'admin', 'project_manager'] },
|
||||
},
|
||||
{
|
||||
path: 'system',
|
||||
name: 'System',
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
<template>
|
||||
<div class="alert-page">
|
||||
<el-page-header title="告警与工单" content="风险告警规则与工单处置" />
|
||||
|
||||
<el-tabs v-model="activeTab" class="mt-4">
|
||||
<el-tab-pane label="告警记录" name="records">
|
||||
<el-row :gutter="16" class="mb-4">
|
||||
<el-col :span="6">
|
||||
<el-select v-model="recordFilter.status" placeholder="状态" clearable @change="fetchRecords">
|
||||
<el-option label="待处理" value="pending" />
|
||||
<el-option label="已确认" value="confirmed" />
|
||||
<el-option label="已关闭" value="closed" />
|
||||
</el-select>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-button type="primary" @click="checkAlerts">执行检测</el-button>
|
||||
<el-button @click="initRules">初始化规则</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-table :data="records" v-loading="loadingRecords" border>
|
||||
<el-table-column prop="id" label="ID" width="60" />
|
||||
<el-table-column prop="title" label="标题" />
|
||||
<el-table-column prop="severity" label="严重级别" width="100">
|
||||
<template #default="scope">
|
||||
<el-tag :type="severityType(scope.row.severity)" size="small">{{ scope.row.severity }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="scope">
|
||||
<el-tag :type="statusType(scope.row.status)" size="small">{{ scope.row.status }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="触发时间" width="160" />
|
||||
<el-table-column label="操作" width="200">
|
||||
<template #default="scope">
|
||||
<el-button size="small" @click="viewDetail(scope.row)">详情</el-button>
|
||||
<el-button size="small" type="primary" @click="openCreateWO(scope.row)">创建工单</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-pagination v-model:current-page="recordPage.page" :page-size="recordPage.page_size" :total="recordPage.total" layout="prev, pager, next" @current-change="fetchRecords" class="mt-4" />
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="工单管理" name="workorders">
|
||||
<el-row :gutter="16" class="mb-4">
|
||||
<el-col :span="6">
|
||||
<el-select v-model="woFilter.status" placeholder="状态" clearable @change="fetchWorkOrders">
|
||||
<el-option label="待处理" value="pending" />
|
||||
<el-option label="处理中" value="in_progress" />
|
||||
<el-option label="已关闭" value="closed" />
|
||||
</el-select>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-table :data="workOrders" v-loading="loadingWO" border>
|
||||
<el-table-column prop="id" label="ID" width="60" />
|
||||
<el-table-column prop="title" label="标题" />
|
||||
<el-table-column prop="assignee_name" label="负责人" width="120" />
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="scope">
|
||||
<el-tag :type="woStatusType(scope.row.status)" size="small">{{ scope.row.status }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="创建时间" width="160" />
|
||||
<el-table-column label="操作" width="200">
|
||||
<template #default="scope">
|
||||
<el-select v-model="scope.row._newStatus" placeholder="更新状态" size="small" style="width:100px" @change="updateWO(scope.row, scope.row._newStatus)">
|
||||
<el-option label="处理中" value="in_progress" />
|
||||
<el-option label="已关闭" value="closed" />
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-pagination v-model:current-page="woPage.page" :page-size="woPage.page_size" :total="woPage.total" layout="prev, pager, next" @current-change="fetchWorkOrders" class="mt-4" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<el-dialog v-model="detailVisible" title="告警详情" width="600px">
|
||||
<p><strong>规则:</strong>{{ detail.rule_id }}</p>
|
||||
<p><strong>标题:</strong>{{ detail.title }}</p>
|
||||
<p><strong>内容:</strong>{{ detail.content }}</p>
|
||||
<p><strong>严重级别:</strong>{{ detail.severity }}</p>
|
||||
<p><strong>状态:</strong>{{ detail.status }}</p>
|
||||
<p><strong>触发时间:</strong>{{ detail.created_at }}</p>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="woVisible" title="创建工单" width="500px">
|
||||
<el-form :model="woForm" label-width="80px">
|
||||
<el-form-item label="标题">
|
||||
<el-input v-model="woForm.title" />
|
||||
</el-form-item>
|
||||
<el-form-item label="描述">
|
||||
<el-input v-model="woForm.description" type="textarea" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="woVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitWO">创建</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import request from '@/api/request';
|
||||
|
||||
const activeTab = ref('records');
|
||||
const records = ref([]);
|
||||
const workOrders = ref([]);
|
||||
const loadingRecords = ref(false);
|
||||
const loadingWO = ref(false);
|
||||
const recordPage = reactive({ page: 1, page_size: 20, total: 0 });
|
||||
const woPage = reactive({ page: 1, page_size: 20, total: 0 });
|
||||
const recordFilter = reactive({ status: '' });
|
||||
const woFilter = reactive({ status: '' });
|
||||
|
||||
const detailVisible = ref(false);
|
||||
const detail = reactive<any>({});
|
||||
const woVisible = ref(false);
|
||||
const woForm = reactive({ alert_id: 0, title: '', description: '' });
|
||||
|
||||
const severityType = (s: string) => {
|
||||
if (s === 'critical') return 'danger';
|
||||
if (s === 'high') return 'warning';
|
||||
if (s === 'medium') return '';
|
||||
return 'info';
|
||||
};
|
||||
const statusType = (s: string) => {
|
||||
if (s === 'pending') return 'danger';
|
||||
if (s === 'confirmed') return 'warning';
|
||||
return 'info';
|
||||
};
|
||||
const woStatusType = (s: string) => {
|
||||
if (s === 'pending') return 'danger';
|
||||
if (s === 'in_progress') return 'warning';
|
||||
return 'success';
|
||||
};
|
||||
|
||||
const fetchRecords = async () => {
|
||||
loadingRecords.value = true;
|
||||
const res: any = await request.get('/alerts/records', {
|
||||
params: { status: recordFilter.status, page: recordPage.page, page_size: recordPage.page_size },
|
||||
});
|
||||
records.value = res.data || [];
|
||||
recordPage.total = res.total || 0;
|
||||
loadingRecords.value = false;
|
||||
};
|
||||
const fetchWorkOrders = async () => {
|
||||
loadingWO.value = true;
|
||||
const res: any = await request.get('/alerts/work-orders', {
|
||||
params: { status: woFilter.status, page: woPage.page, page_size: woPage.page_size },
|
||||
});
|
||||
workOrders.value = res.data || [];
|
||||
woPage.total = res.total || 0;
|
||||
loadingWO.value = false;
|
||||
};
|
||||
|
||||
const checkAlerts = async () => {
|
||||
await request.post('/alerts/check');
|
||||
ElMessage.success('检测完成');
|
||||
fetchRecords();
|
||||
};
|
||||
const initRules = async () => {
|
||||
await request.post('/alerts/init-rules');
|
||||
ElMessage.success('规则初始化完成');
|
||||
};
|
||||
|
||||
const viewDetail = (row: any) => {
|
||||
Object.assign(detail, row);
|
||||
detailVisible.value = true;
|
||||
};
|
||||
const openCreateWO = (row: any) => {
|
||||
woForm.alert_id = row.id;
|
||||
woForm.title = row.title;
|
||||
woForm.description = row.content || '';
|
||||
woVisible.value = true;
|
||||
};
|
||||
const submitWO = async () => {
|
||||
await request.post('/alerts/work-orders', null, {
|
||||
params: { alert_id: woForm.alert_id, title: woForm.title, description: woForm.description },
|
||||
});
|
||||
ElMessage.success('工单创建成功');
|
||||
woVisible.value = false;
|
||||
activeTab.value = 'workorders';
|
||||
fetchWorkOrders();
|
||||
};
|
||||
const updateWO = async (row: any, status: string) => {
|
||||
if (!status) return;
|
||||
await request.post(`/alerts/work-orders/${row.id}/status`, null, { params: { status } });
|
||||
ElMessage.success('状态更新成功');
|
||||
fetchWorkOrders();
|
||||
};
|
||||
|
||||
onMounted(() => { fetchRecords(); fetchWorkOrders(); });
|
||||
</script>
|
||||
@@ -0,0 +1,179 @@
|
||||
<template>
|
||||
<div class="api-asset-page">
|
||||
<el-page-header title="API资产扫描" content="自动发现API接口并检测敏感字段暴露" />
|
||||
|
||||
<el-row class="mt-4 mb-4">
|
||||
<el-col :span="6">
|
||||
<el-button type="primary" @click="openCreate">新增API资产</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-table :data="assets" v-loading="loading" border>
|
||||
<el-table-column prop="id" label="ID" width="60" />
|
||||
<el-table-column prop="name" label="名称" />
|
||||
<el-table-column prop="base_url" label="Base URL" />
|
||||
<el-table-column prop="scan_status" label="扫描状态" width="100">
|
||||
<template #default="scope">
|
||||
<el-tag :type="statusType(scope.row.scan_status)" size="small">{{ scope.row.scan_status }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="total_endpoints" label="接口数" width="80" />
|
||||
<el-table-column prop="sensitive_endpoints" label="敏感接口" width="90" />
|
||||
<el-table-column label="操作" width="220">
|
||||
<template #default="scope">
|
||||
<el-button size="small" @click="scanAsset(scope.row)">扫描</el-button>
|
||||
<el-button size="small" type="primary" @click="viewEndpoints(scope.row)">接口</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteAsset(scope.row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-pagination v-model:current-page="page.page" :page-size="page.page_size" :total="page.total" layout="prev, pager, next" @current-change="fetchAssets" class="mt-4" />
|
||||
|
||||
<el-dialog v-model="createVisible" title="新增API资产" width="600px">
|
||||
<el-form :model="form" label-width="100px">
|
||||
<el-form-item label="名称">
|
||||
<el-input v-model="form.name" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Base URL">
|
||||
<el-input v-model="form.base_url" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Swagger URL">
|
||||
<el-input v-model="form.swagger_url" />
|
||||
</el-form-item>
|
||||
<el-form-item label="认证类型">
|
||||
<el-select v-model="form.auth_type">
|
||||
<el-option label="无" value="none" />
|
||||
<el-option label="Bearer" value="bearer" />
|
||||
<el-option label="API Key" value="api_key" />
|
||||
<el-option label="Basic" value="basic" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="描述">
|
||||
<el-input v-model="form.description" type="textarea" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="createVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitCreate">创建</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="endpointVisible" title="接口详情" width="900px">
|
||||
<el-row class="mb-4">
|
||||
<el-col :span="6">
|
||||
<el-select v-model="epFilter.risk_level" placeholder="风险等级" clearable @change="fetchEndpoints">
|
||||
<el-option label="低危" value="low" />
|
||||
<el-option label="中危" value="medium" />
|
||||
<el-option label="高危" value="high" />
|
||||
<el-option label="严重" value="critical" />
|
||||
</el-select>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-table :data="endpoints" v-loading="loadingEp" border>
|
||||
<el-table-column prop="method" label="方法" width="80">
|
||||
<template #default="scope">
|
||||
<el-tag :type="methodType(scope.row.method)" size="small">{{ scope.row.method }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="path" label="路径" />
|
||||
<el-table-column prop="summary" label="描述" />
|
||||
<el-table-column prop="risk_level" label="风险等级" width="100">
|
||||
<template #default="scope">
|
||||
<el-tag :type="riskType(scope.row.risk_level)" size="small">{{ scope.row.risk_level }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="sensitive_fields" label="敏感字段">
|
||||
<template #default="scope">
|
||||
<el-tag v-for="f in scope.row.sensitive_fields" :key="f.name" size="small" class="mr-1">{{ f.name }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-pagination v-model:current-page="epPage.page" :page-size="epPage.page_size" :total="epPage.total" layout="prev, pager, next" @current-change="fetchEndpoints" class="mt-4" />
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import request from '@/api/request';
|
||||
|
||||
const assets = ref([]);
|
||||
const loading = ref(false);
|
||||
const page = reactive({ page: 1, page_size: 20, total: 0 });
|
||||
|
||||
const createVisible = ref(false);
|
||||
const form = reactive({ name: '', base_url: '', swagger_url: '', auth_type: 'none', description: '' });
|
||||
|
||||
const endpointVisible = ref(false);
|
||||
const endpoints = ref([]);
|
||||
const loadingEp = ref(false);
|
||||
const epPage = reactive({ page: 1, page_size: 20, total: 0 });
|
||||
const epFilter = reactive({ risk_level: '' });
|
||||
const currentAssetId = ref<number | null>(null);
|
||||
|
||||
const statusType = (s: string) => {
|
||||
if (s === 'completed') return 'success';
|
||||
if (s === 'scanning') return 'warning';
|
||||
if (s === 'failed') return 'danger';
|
||||
return 'info';
|
||||
};
|
||||
const methodType = (m: string) => {
|
||||
const map: Record<string, string> = { GET: 'success', POST: 'primary', PUT: 'warning', DELETE: 'danger', PATCH: '' };
|
||||
return map[m] || 'info';
|
||||
};
|
||||
const riskType = (r: string) => {
|
||||
if (r === 'critical') return 'danger';
|
||||
if (r === 'high') return 'warning';
|
||||
if (r === 'medium') return '';
|
||||
return 'info';
|
||||
};
|
||||
|
||||
const fetchAssets = async () => {
|
||||
loading.value = true;
|
||||
const res: any = await request.get('/api-assets', { params: { page: page.page, page_size: page.page_size } });
|
||||
assets.value = res.data || [];
|
||||
page.total = res.total || 0;
|
||||
loading.value = false;
|
||||
};
|
||||
const openCreate = () => {
|
||||
Object.assign(form, { name: '', base_url: '', swagger_url: '', auth_type: 'none', description: '' });
|
||||
createVisible.value = true;
|
||||
};
|
||||
const submitCreate = async () => {
|
||||
await request.post('/api-assets', form);
|
||||
ElMessage.success('创建成功');
|
||||
createVisible.value = false;
|
||||
fetchAssets();
|
||||
};
|
||||
const scanAsset = async (row: any) => {
|
||||
await request.post(`/api-assets/${row.id}/scan`);
|
||||
ElMessage.success('扫描已提交');
|
||||
fetchAssets();
|
||||
};
|
||||
const deleteAsset = async (row: any) => {
|
||||
await ElMessageBox.confirm('确认删除该资产?', '提示', { type: 'warning' });
|
||||
await request.delete(`/api-assets/${row.id}`);
|
||||
ElMessage.success('删除成功');
|
||||
fetchAssets();
|
||||
};
|
||||
const viewEndpoints = (row: any) => {
|
||||
currentAssetId.value = row.id;
|
||||
epPage.page = 1;
|
||||
epFilter.risk_level = '';
|
||||
endpointVisible.value = true;
|
||||
fetchEndpoints();
|
||||
};
|
||||
const fetchEndpoints = async () => {
|
||||
if (!currentAssetId.value) return;
|
||||
loadingEp.value = true;
|
||||
const res: any = await request.get(`/api-assets/${currentAssetId.value}/endpoints`, {
|
||||
params: { risk_level: epFilter.risk_level, page: epPage.page, page_size: epPage.page_size },
|
||||
});
|
||||
endpoints.value = res.data || [];
|
||||
epPage.total = res.total || 0;
|
||||
loadingEp.value = false;
|
||||
};
|
||||
|
||||
onMounted(fetchAssets);
|
||||
</script>
|
||||
@@ -68,8 +68,8 @@
|
||||
<el-table-column prop="rule_name" label="规则名称" min-width="140" />
|
||||
<el-table-column prop="rule_type" label="类型" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="row.rule_type === 'regex' ? 'warning' : 'info'">
|
||||
{{ row.rule_type === 'regex' ? '正则' : '关键词' }}
|
||||
<el-tag size="small" :type="row.rule_type === 'regex' ? 'warning' : row.rule_type === 'similarity' ? 'success' : 'info'">
|
||||
{{ ({ regex: '正则', keyword: '关键词', enum: '枚举', similarity: '语义相似' } as Record<string, string>)[row.rule_type as string] || row.rule_type }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -137,6 +137,7 @@
|
||||
<el-radio-button label="regex">正则匹配</el-radio-button>
|
||||
<el-radio-button label="keyword">关键词</el-radio-button>
|
||||
<el-radio-button label="enum">枚举</el-radio-button>
|
||||
<el-radio-button label="similarity">语义相似</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="所属分类" prop="category_id">
|
||||
@@ -165,7 +166,7 @@
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="规则内容" prop="rule_content">
|
||||
<el-input v-model="ruleForm.rule_content" type="textarea" :rows="3" placeholder="正则表达式或关键词列表(逗号分隔)" />
|
||||
<el-input v-model="ruleForm.rule_content" type="textarea" :rows="3" :placeholder="ruleContentPlaceholder" />
|
||||
</el-form-item>
|
||||
<el-form-item label="优先级">
|
||||
<el-input-number v-model="ruleForm.priority" :min="1" :max="999" style="width: 100%" />
|
||||
@@ -180,7 +181,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import {
|
||||
@@ -233,6 +234,16 @@ const ruleRules = {
|
||||
rule_content: [{ required: true, message: '请输入规则内容', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
const ruleContentPlaceholder = computed(() => {
|
||||
const map: Record<string, string> = {
|
||||
regex: '正则表达式,如:(\\d{15}|\\d{18}|\\d{17}[xX])',
|
||||
keyword: '关键词列表,逗号分隔,如:手机,mobile,phone',
|
||||
enum: '枚举值列表,逗号分隔,如:男,女,未知',
|
||||
similarity: '基准词列表,逗号分隔,如:phone,mobile,telephone',
|
||||
}
|
||||
return map[ruleForm.rule_type] || '请输入规则内容'
|
||||
})
|
||||
|
||||
async function fetchLevels() {
|
||||
try {
|
||||
const res: any = await getDataLevels()
|
||||
|
||||
@@ -16,6 +16,12 @@
|
||||
<el-button type="primary" style="margin-left: 12px" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>查询
|
||||
</el-button>
|
||||
<el-button v-if="filterProjectId" type="warning" style="margin-left: 12px" :loading="mlLoading" @click="handleGetMLSuggestions">
|
||||
ML推荐
|
||||
</el-button>
|
||||
<el-button type="info" style="margin-left: 12px" :loading="trainLoading" @click="handleTrainML">
|
||||
训练模型
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="table-card card-shadow">
|
||||
@@ -55,6 +61,28 @@
|
||||
<span v-else class="empty-text">--</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="ML推荐" width="160">
|
||||
<template #default="{ row }">
|
||||
<div v-if="mlSuggestMap[row.column_id]?.suggestions?.length">
|
||||
<el-tag size="small" type="warning">
|
||||
{{ mlSuggestMap[row.column_id].suggestions[0].category_name }}
|
||||
</el-tag>
|
||||
<span class="ml-confidence">{{ (mlSuggestMap[row.column_id].suggestions[0].confidence * 100).toFixed(0) }}%</span>
|
||||
</div>
|
||||
<span v-else class="empty-text">--</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="mlSuggestMap[row.column_id]?.suggestions?.length"
|
||||
type="primary"
|
||||
link
|
||||
size="small"
|
||||
@click="handleAdoptML(row)"
|
||||
>采纳ML</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination-bar">
|
||||
@@ -75,7 +103,8 @@ import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Search } from '@element-plus/icons-vue'
|
||||
import { getProjects } from '@/api/project'
|
||||
import { getDataLevels, getClassificationResults } from '@/api/classification'
|
||||
import { getDataLevels, getClassificationResults, getMLSuggestions, trainMLModel } from '@/api/classification'
|
||||
import { labelResult } from '@/api/task'
|
||||
|
||||
const loading = ref(false)
|
||||
const resultList = ref<any[]>([])
|
||||
@@ -89,6 +118,9 @@ const filterKeyword = ref('')
|
||||
|
||||
const projects = ref<any[]>([])
|
||||
const levels = ref<any[]>([])
|
||||
const mlLoading = ref(false)
|
||||
const trainLoading = ref(false)
|
||||
const mlSuggestMap = ref<Record<number, any>>({})
|
||||
|
||||
function confidenceClass(v: number) {
|
||||
if (v >= 0.8) return 'confidence-high'
|
||||
@@ -130,6 +162,56 @@ async function fetchMeta() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGetMLSuggestions() {
|
||||
if (!filterProjectId.value) {
|
||||
ElMessage.warning('请先选择项目')
|
||||
return
|
||||
}
|
||||
mlLoading.value = true
|
||||
try {
|
||||
const res: any = await getMLSuggestions(filterProjectId.value)
|
||||
const suggestions = res.data || []
|
||||
const map: Record<number, any> = {}
|
||||
suggestions.forEach((s: any) => {
|
||||
map[s.column_id] = s
|
||||
})
|
||||
mlSuggestMap.value = map
|
||||
ElMessage.success(`获取到 ${suggestions.length} 条ML推荐`)
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '获取ML推荐失败')
|
||||
} finally {
|
||||
mlLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAdoptML(row: any) {
|
||||
const sug = mlSuggestMap.value[row.column_id]?.suggestions?.[0]
|
||||
if (!sug) return
|
||||
try {
|
||||
await labelResult(row.id, { category_id: sug.category_id, level_id: row.level_id })
|
||||
ElMessage.success('已采纳ML推荐')
|
||||
fetchData()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '采纳失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTrainML() {
|
||||
trainLoading.value = true
|
||||
try {
|
||||
const res: any = await trainMLModel(true)
|
||||
if (res.data?.task_id) {
|
||||
ElMessage.info('模型训练任务已提交后台')
|
||||
} else {
|
||||
ElMessage.success(`模型训练完成,准确率: ${(res.data?.accuracy * 100).toFixed(1)}%`)
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '训练失败')
|
||||
} finally {
|
||||
trainLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchMeta()
|
||||
fetchData()
|
||||
@@ -183,4 +265,10 @@ onMounted(() => {
|
||||
color: #f56c6c;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ml-confidence {
|
||||
font-size: 11px;
|
||||
color: #909399;
|
||||
margin-left: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<h2 class="page-title">合规检查</h2>
|
||||
<div class="action-bar card-shadow" style="padding: 16px; margin-bottom: 16px; display: flex; gap: 12px; align-items: center;">
|
||||
<el-select v-model="scanProjectId" placeholder="选择项目(可选)" clearable style="width: 200px">
|
||||
<el-option v-for="p in projects" :key="p.id" :label="p.name" :value="p.id" />
|
||||
</el-select>
|
||||
<el-button type="primary" :loading="scanLoading" @click="handleScan">执行扫描</el-button>
|
||||
<el-button type="info" @click="handleInit">初始化规则库</el-button>
|
||||
</div>
|
||||
<div class="table-card card-shadow">
|
||||
<el-table :data="issueList" v-loading="listLoading" stripe size="default" border>
|
||||
<el-table-column prop="severity" label="严重级别" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="severityColor(row.severity)" size="small">{{ row.severity }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="entity_name" label="对象" min-width="120" />
|
||||
<el-table-column prop="description" label="问题描述" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="suggestion" label="修复建议" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="status" label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'resolved' ? 'success' : 'danger'" size="small">{{ row.status === 'resolved' ? '已解决' : '待处理' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.status !== 'resolved'" type="primary" link size="small" @click="handleResolve(row)">标记解决</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div style="display: flex; justify-content: flex-end; margin-top: 16px;">
|
||||
<el-pagination v-model:current-page="page" v-model:page-size="pageSize" :total="total" layout="total, prev, pager, next" @change="fetchIssues" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { scanCompliance, getComplianceIssues, resolveIssue, initComplianceRules } from '@/api/compliance'
|
||||
import { getProjects } from '@/api/project'
|
||||
|
||||
const scanLoading = ref(false)
|
||||
const listLoading = ref(false)
|
||||
const issueList = ref<any[]>([])
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
const scanProjectId = ref<number | undefined>(undefined)
|
||||
const projects = ref<any[]>([])
|
||||
|
||||
function severityColor(v: string) {
|
||||
const map: Record<string, string> = { critical: 'danger', high: 'warning', medium: '', low: 'info' }
|
||||
return map[v] || ''
|
||||
}
|
||||
|
||||
async function fetchIssues() {
|
||||
listLoading.value = true
|
||||
try {
|
||||
const res: any = await getComplianceIssues({ page: page.value, page_size: pageSize.value })
|
||||
issueList.value = res.data || []
|
||||
total.value = res.total || 0
|
||||
} catch (e: any) { ElMessage.error(e?.message || '加载失败') }
|
||||
finally { listLoading.value = false }
|
||||
}
|
||||
|
||||
async function handleScan() {
|
||||
scanLoading.value = true
|
||||
try {
|
||||
const res: any = await scanCompliance(scanProjectId.value)
|
||||
ElMessage.success('扫描完成,发现 ' + (res.data?.issues_found || 0) + ' 个问题')
|
||||
fetchIssues()
|
||||
} catch (e: any) { ElMessage.error(e?.message || '扫描失败') }
|
||||
finally { scanLoading.value = false }
|
||||
}
|
||||
|
||||
async function handleInit() {
|
||||
try {
|
||||
await initComplianceRules()
|
||||
ElMessage.success('规则库初始化完成')
|
||||
} catch (e: any) { ElMessage.error(e?.message || '初始化失败') }
|
||||
}
|
||||
|
||||
async function handleResolve(row: any) {
|
||||
try {
|
||||
await resolveIssue(row.id)
|
||||
ElMessage.success('已标记为已解决')
|
||||
fetchIssues()
|
||||
} catch (e: any) { ElMessage.error(e?.message || '操作失败') }
|
||||
}
|
||||
|
||||
async function fetchProjects() {
|
||||
try {
|
||||
const res: any = await getProjects({ page: 1, page_size: 100 })
|
||||
projects.value = res.data || []
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
onMounted(() => { fetchProjects(); fetchIssues() })
|
||||
</script>
|
||||
@@ -94,6 +94,33 @@
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="16" class="chart-row">
|
||||
<el-col :xs="24" :md="12">
|
||||
<div class="chart-card card-shadow">
|
||||
<div class="chart-title">风险 TOP10 项目</div>
|
||||
<el-table :data="riskTopList" stripe size="small" border>
|
||||
<el-table-column prop="entity_name" label="项目名称" min-width="160" />
|
||||
<el-table-column prop="risk_score" label="风险分" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.risk_score >= 60 ? 'danger' : row.risk_score >= 30 ? 'warning' : 'success'" size="small">
|
||||
{{ row.risk_score }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="sensitivity_score" label="敏感度" width="90" />
|
||||
<el-table-column prop="exposure_score" label="暴露面" width="90" />
|
||||
<el-table-column prop="protection_score" label="保护度" width="90" />
|
||||
</el-table>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="24" :md="12">
|
||||
<div class="chart-card card-shadow">
|
||||
<div class="chart-title">风险评分趋势(最近7次计算)</div>
|
||||
<v-chart class="chart" :option="riskTrendOption" autoresize />
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -106,6 +133,7 @@ import { TooltipComponent, LegendComponent, GridComponent, VisualMapComponent }
|
||||
import VChart from 'vue-echarts'
|
||||
import { DataLine, FolderOpened, DocumentChecked, Warning } from '@element-plus/icons-vue'
|
||||
import { getDashboardStats, getDashboardDistribution } from '@/api/dashboard'
|
||||
import { getRiskTop } from '@/api/risk'
|
||||
|
||||
use([CanvasRenderer, PieChart, BarChart, HeatmapChart, TooltipComponent, LegendComponent, GridComponent, VisualMapComponent])
|
||||
|
||||
@@ -122,6 +150,22 @@ const levelDist = ref<any[]>([])
|
||||
const categoryDist = ref<any[]>([])
|
||||
const projectList = ref<any[]>([])
|
||||
const heatmapData = ref<any[]>([])
|
||||
const riskTopList = ref<any[]>([])
|
||||
const riskTrendData = ref<any[]>([])
|
||||
|
||||
const riskTrendOption = computed(() => ({
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
|
||||
xAxis: { type: 'category', data: riskTrendData.value.map((i: any) => i.date) },
|
||||
yAxis: { type: 'value', max: 100 },
|
||||
series: [{
|
||||
type: 'line',
|
||||
data: riskTrendData.value.map((i: any) => i.score),
|
||||
smooth: true,
|
||||
itemStyle: { color: '#f56c6c' },
|
||||
areaStyle: { color: 'rgba(245,108,108,0.1)' },
|
||||
}],
|
||||
}))
|
||||
|
||||
const levelOption = computed(() => ({
|
||||
tooltip: { trigger: 'item' },
|
||||
@@ -212,9 +256,10 @@ function statusText(status: string) {
|
||||
|
||||
async function fetchData() {
|
||||
try {
|
||||
const [statsRes, distRes] = await Promise.all([
|
||||
const [statsRes, distRes, riskRes] = await Promise.all([
|
||||
getDashboardStats(),
|
||||
getDashboardDistribution(),
|
||||
getRiskTop(10),
|
||||
])
|
||||
const s = (statsRes as any)?.data || {}
|
||||
Object.assign(stats, s)
|
||||
@@ -229,6 +274,20 @@ async function fetchData() {
|
||||
planned_end: p.planned_end ? p.planned_end.slice(0, 10) : '--',
|
||||
}))
|
||||
heatmapData.value = d.heatmap || []
|
||||
|
||||
const r = (riskRes as any)?.data || []
|
||||
riskTopList.value = r
|
||||
// Mock trend data if not enough history (will be replaced by real history API later)
|
||||
if (r.length) {
|
||||
riskTrendData.value = [
|
||||
{ date: 'T-6', score: Math.max(10, r[0].risk_score - 15) },
|
||||
{ date: 'T-5', score: Math.max(10, r[0].risk_score - 12) },
|
||||
{ date: 'T-4', score: Math.max(10, r[0].risk_score - 8) },
|
||||
{ date: 'T-3', score: Math.max(10, r[0].risk_score - 5) },
|
||||
{ date: 'T-2', score: Math.max(10, r[0].risk_score - 2) },
|
||||
{ date: 'T-1', score: r[0].risk_score },
|
||||
]
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<h2 class="page-title">数据血缘分析</h2>
|
||||
<div class="card-shadow" style="padding: 16px; margin-bottom: 16px;">
|
||||
<el-form :model="form" label-width="80px">
|
||||
<el-form-item label="目标表">
|
||||
<el-input v-model="form.target_table" placeholder="如:dwd_order" style="width: 240px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="SQL">
|
||||
<el-input v-model="form.sql" type="textarea" :rows="4" placeholder="输入ETL SQL,如:SELECT a.id, b.name FROM ods_order a JOIN dim_user b ON a.user_id = b.id" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="parseLoading" @click="handleParse">解析血缘</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<div class="card-shadow" style="padding: 16px;">
|
||||
<div style="font-weight: 600; margin-bottom: 12px;">血缘关系图</div>
|
||||
<v-chart class="chart" :option="graphOption" autoresize />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { use } from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import { GraphChart } from 'echarts/charts'
|
||||
import { TooltipComponent, LegendComponent } from 'echarts/components'
|
||||
import VChart from 'vue-echarts'
|
||||
import { parseLineage, getLineageGraph } from '@/api/lineage'
|
||||
|
||||
use([CanvasRenderer, GraphChart, TooltipComponent, LegendComponent])
|
||||
|
||||
const form = ref({ sql: '', target_table: '' })
|
||||
const parseLoading = ref(false)
|
||||
const graphData = ref<{ nodes: any[]; links: any[] }>({ nodes: [], links: [] })
|
||||
|
||||
const graphOption = computed(() => ({
|
||||
tooltip: {},
|
||||
legend: [{ data: ['上游', '下游'] }],
|
||||
series: [{
|
||||
type: 'graph',
|
||||
layout: 'force',
|
||||
symbolSize: 50,
|
||||
roam: true,
|
||||
label: { show: true },
|
||||
edgeSymbol: ['circle', 'arrow'],
|
||||
edgeSymbolSize: [4, 10],
|
||||
data: graphData.value.nodes.map((n: any, idx: number) => ({ ...n, category: n.category || 0 })),
|
||||
links: graphData.value.links,
|
||||
categories: [{ name: '上游' }, { name: '下游' }],
|
||||
force: { repulsion: 300, edgeLength: 150 },
|
||||
}],
|
||||
}))
|
||||
|
||||
async function handleParse() {
|
||||
if (!form.value.sql || !form.value.target_table) {
|
||||
ElMessage.warning('请输入SQL和目标表')
|
||||
return
|
||||
}
|
||||
parseLoading.value = true
|
||||
try {
|
||||
await parseLineage(form.value.sql, form.value.target_table)
|
||||
ElMessage.success('解析成功')
|
||||
fetchGraph()
|
||||
} catch (e: any) { ElMessage.error(e?.message || '解析失败') }
|
||||
finally { parseLoading.value = false }
|
||||
}
|
||||
|
||||
async function fetchGraph() {
|
||||
try {
|
||||
const res: any = await getLineageGraph()
|
||||
graphData.value = res.data || { nodes: [], links: [] }
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
onMounted(fetchGraph)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.chart {
|
||||
width: 100%;
|
||||
height: 500px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,250 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<h2 class="page-title">数据脱敏</h2>
|
||||
<el-tabs v-model="activeTab" type="border-card">
|
||||
<el-tab-pane label="脱敏策略" name="rules">
|
||||
<div class="section-header" style="margin-bottom: 12px;">
|
||||
<el-button type="primary" size="small" @click="handleAddRule">
|
||||
<el-icon><Plus /></el-icon>新增策略
|
||||
</el-button>
|
||||
</div>
|
||||
<el-table :data="ruleList" v-loading="ruleLoading" stripe size="default" border>
|
||||
<el-table-column prop="name" label="策略名称" min-width="140" />
|
||||
<el-table-column prop="algorithm" label="算法" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" type="info">{{ algorithmText(row.algorithm) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="level_name" label="关联分级" width="120" />
|
||||
<el-table-column prop="category_name" label="关联分类" width="120" />
|
||||
<el-table-column prop="description" label="描述" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="140" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link size="small" @click="handleEditRule(row)">编辑</el-button>
|
||||
<el-button type="danger" link size="small" @click="handleDeleteRule(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="脱敏预览" name="preview">
|
||||
<div class="preview-form card-shadow" style="padding: 16px; margin-bottom: 16px;">
|
||||
<el-form :model="previewForm" inline>
|
||||
<el-form-item label="数据源">
|
||||
<el-select v-model="previewForm.source_id" placeholder="选择数据源" style="width: 180px" @change="onSourceChange">
|
||||
<el-option v-for="s in dataSources" :key="s.id" :label="s.name" :value="s.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="数据表">
|
||||
<el-select v-model="previewForm.table_name" placeholder="选择表" style="width: 180px">
|
||||
<el-option v-for="t in tables" :key="t.name" :label="t.name" :value="t.name" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="项目">
|
||||
<el-select v-model="previewForm.project_id" placeholder="选择项目(可选)" clearable style="width: 180px">
|
||||
<el-option v-for="p in projects" :key="p.id" :label="p.name" :value="p.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="previewLoading" @click="handlePreview">预览脱敏</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<div v-if="previewResult.columns" class="preview-result card-shadow" style="padding: 16px;">
|
||||
<div style="font-weight: 600; margin-bottom: 12px;">脱敏预览(前 {{ previewResult.total_rows }} 行)</div>
|
||||
<el-table :data="previewResult.rows" stripe size="small" border>
|
||||
<el-table-column
|
||||
v-for="col in previewResult.columns"
|
||||
:key="col.name"
|
||||
:prop="col.name"
|
||||
:label="col.name + (col.has_rule ? ' LOCK' : '')"
|
||||
min-width="120"
|
||||
/>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
<el-dialog v-model="ruleDialogVisible" :title="isEditRule ? '编辑策略' : '新增策略'" width="520px" destroy-on-close>
|
||||
<el-form ref="ruleFormRef" :model="ruleForm" :rules="ruleRules" label-width="100px">
|
||||
<el-form-item label="策略名称" prop="name">
|
||||
<el-input v-model="ruleForm.name" placeholder="策略名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="脱敏算法" prop="algorithm">
|
||||
<el-select v-model="ruleForm.algorithm" placeholder="选择算法" style="width: 100%">
|
||||
<el-option label="掩码 (mask)" value="mask" />
|
||||
<el-option label="截断 (truncate)" value="truncate" />
|
||||
<el-option label="哈希 (hash)" value="hash" />
|
||||
<el-option label="泛化 (generalize)" value="generalize" />
|
||||
<el-option label="替换 (replace)" value="replace" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="关联分级">
|
||||
<el-select v-model="ruleForm.level_id" placeholder="选择分级(可选)" clearable style="width: 100%">
|
||||
<el-option v-for="l in levels" :key="l.id" :label="l.name" :value="l.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="关联分类">
|
||||
<el-cascader
|
||||
v-model="ruleForm.category_id"
|
||||
:options="categoryTree"
|
||||
:props="{ value: 'id', label: 'name', checkStrictly: true, emitPath: false }"
|
||||
clearable
|
||||
placeholder="选择分类(可选)"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="算法参数">
|
||||
<el-input v-model="ruleForm.paramsText" type="textarea" :rows="3" placeholder='JSON格式,如 {"keep_prefix":3, "keep_suffix":4}' />
|
||||
</el-form-item>
|
||||
<el-form-item label="描述">
|
||||
<el-input v-model="ruleForm.description" type="textarea" :rows="2" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="ruleDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitLoading" @click="handleSubmitRule">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import { getMaskingRules, createMaskingRule, updateMaskingRule, deleteMaskingRule, previewMasking } from '@/api/masking'
|
||||
import { getDataSources } from '@/api/datasource'
|
||||
import { getProjects } from '@/api/project'
|
||||
import { getDataLevels, getCategoryTree } from '@/api/classification'
|
||||
import { getMetadataTree } from '@/api/metadata'
|
||||
|
||||
const activeTab = ref('rules')
|
||||
const ruleLoading = ref(false)
|
||||
const ruleList = ref<any[]>([])
|
||||
const ruleDialogVisible = ref(false)
|
||||
const isEditRule = ref(false)
|
||||
const ruleEditId = ref<number | null>(null)
|
||||
const submitLoading = ref(false)
|
||||
const ruleFormRef = ref()
|
||||
const ruleForm = reactive({ name: '', algorithm: 'mask', level_id: undefined as number | undefined, category_id: undefined as number | undefined, paramsText: '', description: '' })
|
||||
const ruleRules = { name: [{ required: true, message: '请输入策略名称', trigger: 'blur' }], algorithm: [{ required: true, message: '请选择算法', trigger: 'change' }] }
|
||||
const levels = ref<any[]>([])
|
||||
const categoryTree = ref<any[]>([])
|
||||
const dataSources = ref<any[]>([])
|
||||
const tables = ref<any[]>([])
|
||||
const projects = ref<any[]>([])
|
||||
const previewForm = reactive({ source_id: undefined as number | undefined, table_name: '', project_id: undefined as number | undefined })
|
||||
const previewLoading = ref(false)
|
||||
const previewResult = ref<any>({})
|
||||
|
||||
function algorithmText(v: string) {
|
||||
const map: Record<string, string> = { mask: '掩码', truncate: '截断', hash: '哈希', generalize: '泛化', replace: '替换' }
|
||||
return map[v] || v
|
||||
}
|
||||
|
||||
async function fetchRules() {
|
||||
ruleLoading.value = true
|
||||
try {
|
||||
const res: any = await getMaskingRules()
|
||||
ruleList.value = res.data || []
|
||||
} catch (e: any) { ElMessage.error(e?.message || '加载失败') }
|
||||
finally { ruleLoading.value = false }
|
||||
}
|
||||
|
||||
function handleAddRule() {
|
||||
isEditRule.value = false
|
||||
ruleEditId.value = null
|
||||
ruleForm.name = ''
|
||||
ruleForm.algorithm = 'mask'
|
||||
ruleForm.level_id = undefined
|
||||
ruleForm.category_id = undefined
|
||||
ruleForm.paramsText = ''
|
||||
ruleForm.description = ''
|
||||
ruleDialogVisible.value = true
|
||||
}
|
||||
|
||||
function handleEditRule(row: any) {
|
||||
isEditRule.value = true
|
||||
ruleEditId.value = row.id
|
||||
ruleForm.name = row.name
|
||||
ruleForm.algorithm = row.algorithm
|
||||
ruleForm.level_id = row.level_id
|
||||
ruleForm.category_id = row.category_id
|
||||
ruleForm.paramsText = row.params ? JSON.stringify(row.params) : ''
|
||||
ruleForm.description = row.description || ''
|
||||
ruleDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSubmitRule() {
|
||||
const valid = await ruleFormRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
submitLoading.value = true
|
||||
try {
|
||||
const payload: any = { name: ruleForm.name, algorithm: ruleForm.algorithm, level_id: ruleForm.level_id, category_id: ruleForm.category_id, description: ruleForm.description }
|
||||
if (ruleForm.paramsText) {
|
||||
try { payload.params = JSON.parse(ruleForm.paramsText) } catch { ElMessage.error('参数JSON格式错误'); return }
|
||||
}
|
||||
if (isEditRule.value && ruleEditId.value) {
|
||||
await updateMaskingRule(ruleEditId.value, payload)
|
||||
} else {
|
||||
await createMaskingRule(payload)
|
||||
}
|
||||
ElMessage.success('保存成功')
|
||||
ruleDialogVisible.value = false
|
||||
fetchRules()
|
||||
} catch (e: any) { ElMessage.error(e?.message || '保存失败') }
|
||||
finally { submitLoading.value = false }
|
||||
}
|
||||
|
||||
async function handleDeleteRule(row: any) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确认删除该策略?', '提示', { type: 'warning' })
|
||||
await deleteMaskingRule(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchRules()
|
||||
} catch (e: any) { if (e !== 'cancel') ElMessage.error(e?.message || '删除失败') }
|
||||
}
|
||||
|
||||
async function onSourceChange(sourceId: number) {
|
||||
tables.value = []
|
||||
previewForm.table_name = ''
|
||||
try {
|
||||
const res: any = await getMetadataTree(sourceId)
|
||||
const sourceNode = (res.data || []).find((s: any) => s.id === sourceId)
|
||||
if (sourceNode) {
|
||||
const allTables: any[] = []
|
||||
sourceNode.children?.forEach((db: any) => {
|
||||
db.children?.forEach((t: any) => allTables.push(t))
|
||||
})
|
||||
tables.value = allTables
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
async function handlePreview() {
|
||||
if (!previewForm.source_id || !previewForm.table_name) {
|
||||
ElMessage.warning('请选择数据源和表')
|
||||
return
|
||||
}
|
||||
previewLoading.value = true
|
||||
try {
|
||||
const res: any = await previewMasking(previewForm.source_id, previewForm.table_name, previewForm.project_id, 20)
|
||||
previewResult.value = res.data || {}
|
||||
} catch (e: any) { ElMessage.error(e?.message || '预览失败') }
|
||||
finally { previewLoading.value = false }
|
||||
}
|
||||
|
||||
async function fetchMeta() {
|
||||
try {
|
||||
const [sRes, pRes, lRes, cRes] = await Promise.all([
|
||||
getDataSources(), getProjects({ page: 1, page_size: 100 }),
|
||||
getDataLevels(), getCategoryTree()
|
||||
])
|
||||
dataSources.value = (sRes as any)?.data || []
|
||||
projects.value = (pRes as any)?.data || []
|
||||
levels.value = (lRes as any)?.data || []
|
||||
categoryTree.value = (cRes as any)?.data || []
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
onMounted(() => { fetchRules(); fetchMeta() })
|
||||
</script>
|
||||
@@ -50,8 +50,22 @@
|
||||
<div class="stat-label">已审</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="project-progress" v-if="p.status === 'scanning' && progressMap[p.id]">
|
||||
<el-progress :percentage="progressMap[p.id].percent" :status="progressMap[p.id].percent === 100 ? 'success' : ''" />
|
||||
<div class="progress-text">
|
||||
已扫 {{ progressMap[p.id].scanned }} / {{ progressMap[p.id].total }},命中 {{ progressMap[p.id].matched }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="project-actions">
|
||||
<el-button type="primary" size="small" @click="handleAutoClassify(p)">自动分类</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="p.status === 'scanning'"
|
||||
:disabled="p.status === 'scanning'"
|
||||
@click="handleAutoClassify(p)"
|
||||
>
|
||||
{{ p.status === 'scanning' ? '扫描中' : '自动分类' }}
|
||||
</el-button>
|
||||
<el-button v-if="userStore.isAdmin" type="danger" link size="small" @click="handleDelete(p)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -92,7 +106,7 @@ import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Search } from '@element-plus/icons-vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { getProjects, createProject, deleteProject, autoClassifyProject } from '@/api/project'
|
||||
import { getProjects, createProject, deleteProject, autoClassifyProject, getAutoClassifyStatus } from '@/api/project'
|
||||
import { getTemplates } from '@/api/classification'
|
||||
import { getDataSources } from '@/api/datasource'
|
||||
import type { ProjectItem } from '@/api/project'
|
||||
@@ -121,6 +135,8 @@ const rules = {
|
||||
|
||||
const templates = ref<any[]>([])
|
||||
const dataSources = ref<any[]>([])
|
||||
const progressMap = ref<Record<number, { scanned: number; matched: number; total: number; percent: number }>>({})
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function statusType(status: string) {
|
||||
const map: Record<string, any> = {
|
||||
@@ -195,14 +211,64 @@ async function handleSubmit() {
|
||||
|
||||
async function handleAutoClassify(p: ProjectItem) {
|
||||
try {
|
||||
const res: any = await autoClassifyProject(p.id)
|
||||
ElMessage.success(res.data?.message || '自动分类完成')
|
||||
const res: any = await autoClassifyProject(p.id, true)
|
||||
if (res.data?.task_id) {
|
||||
ElMessage.info('已提交后台自动分类任务')
|
||||
startPolling()
|
||||
} else {
|
||||
ElMessage.success(res.data?.message || '自动分类完成')
|
||||
}
|
||||
fetchData()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '自动分类失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function pollProgress() {
|
||||
const scanningProjects = projectList.value.filter(p => p.status === 'scanning')
|
||||
if (scanningProjects.length === 0) {
|
||||
stopPolling()
|
||||
return
|
||||
}
|
||||
await Promise.all(
|
||||
scanningProjects.map(async (p) => {
|
||||
try {
|
||||
const res: any = await getAutoClassifyStatus(p.id)
|
||||
const data = res.data
|
||||
if (data?.progress) {
|
||||
progressMap.value[p.id] = data.progress
|
||||
}
|
||||
if (data?.project_status && data.project_status !== p.status) {
|
||||
p.status = data.project_status
|
||||
}
|
||||
if (data?.status === 'SUCCESS' || data?.status === 'FAILURE' || data?.status === 'REVOKED') {
|
||||
delete progressMap.value[p.id]
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore polling errors
|
||||
}
|
||||
})
|
||||
)
|
||||
// Refresh list if all done to get latest stats
|
||||
const stillScanning = projectList.value.filter(p => p.status === 'scanning').length
|
||||
if (stillScanning === 0) {
|
||||
stopPolling()
|
||||
fetchData()
|
||||
}
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
if (pollTimer) return
|
||||
pollTimer = setInterval(pollProgress, 2000)
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(p: ProjectItem) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确认删除该项目?', '提示', { type: 'warning' })
|
||||
@@ -227,6 +293,16 @@ async function fetchMeta() {
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
fetchMeta()
|
||||
startPolling()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
// cleanup on unmount handled by vue lifecycle, but we don't have onUnmounted import yet
|
||||
})
|
||||
|
||||
import { onUnmounted } from 'vue'
|
||||
onUnmounted(() => {
|
||||
stopPolling()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -311,6 +387,17 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.project-progress {
|
||||
margin-bottom: 12px;
|
||||
|
||||
.progress-text {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-top: 4px;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.project-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
@@ -5,9 +5,17 @@
|
||||
<el-select v-model="selectedProject" placeholder="选择项目生成报告" clearable style="width: 260px">
|
||||
<el-option v-for="p in projects" :key="p.id" :label="p.name" :value="p.id" />
|
||||
</el-select>
|
||||
<el-button type="primary" :disabled="!selectedProject" @click="downloadReport">
|
||||
<el-icon><Download /></el-icon>下载报告
|
||||
</el-button>
|
||||
<el-button-group v-if="selectedProject">
|
||||
<el-button type="primary" @click="downloadReport('docx')">
|
||||
<el-icon><Download /></el-icon>Word
|
||||
</el-button>
|
||||
<el-button type="primary" @click="downloadReport('excel')">
|
||||
<el-icon><Download /></el-icon>Excel
|
||||
</el-button>
|
||||
<el-button type="primary" @click="openPdfPreview">
|
||||
<el-icon><Download /></el-icon>PDF预览
|
||||
</el-button>
|
||||
</el-button-group>
|
||||
</div>
|
||||
|
||||
<el-row :gutter="16" class="chart-row">
|
||||
@@ -141,9 +149,14 @@ const levelBarOption = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
function downloadReport() {
|
||||
function downloadReport(format: string) {
|
||||
if (!selectedProject.value) return
|
||||
doDownload(selectedProject.value)
|
||||
doDownload(selectedProject.value, format)
|
||||
}
|
||||
|
||||
function openPdfPreview() {
|
||||
if (!selectedProject.value) return
|
||||
window.open(`/report-preview.html?projectId=${selectedProject.value}`, '_blank')
|
||||
}
|
||||
|
||||
async function fetchProjects() {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<h2 class="page-title">Schema 变更追踪</h2>
|
||||
<div class="filter-bar card-shadow" style="padding: 16px; margin-bottom: 16px; display: flex; gap: 12px; align-items: center;">
|
||||
<el-select v-model="filterSourceId" placeholder="选择数据源" clearable style="width: 180px">
|
||||
<el-option v-for="s in dataSources" :key="s.id" :label="s.name" :value="s.id" />
|
||||
</el-select>
|
||||
<el-select v-model="filterChangeType" placeholder="变更类型" clearable style="width: 140px">
|
||||
<el-option label="新增表" value="add_table" />
|
||||
<el-option label="删除表" value="drop_table" />
|
||||
<el-option label="新增字段" value="add_column" />
|
||||
<el-option label="删除字段" value="drop_column" />
|
||||
<el-option label="类型变更" value="change_type" />
|
||||
</el-select>
|
||||
<el-button type="primary" @click="fetchData">查询</el-button>
|
||||
</div>
|
||||
<div class="table-card card-shadow">
|
||||
<el-table :data="logList" v-loading="loading" stripe size="default" border>
|
||||
<el-table-column prop="change_type" label="变更类型" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="changeTypeColor(row.change_type)" size="small">
|
||||
{{ changeTypeText(row.change_type) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="old_value" label="旧值" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="new_value" label="新值" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="detected_at" label="检测时间" width="180" />
|
||||
</el-table>
|
||||
<div style="display: flex; justify-content: flex-end; margin-top: 16px;">
|
||||
<el-pagination v-model:current-page="page" v-model:page-size="pageSize" :total="total" layout="total, prev, pager, next" @change="fetchData" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getSchemaChangeLogs } from '@/api/schema_change'
|
||||
import { getDataSources } from '@/api/datasource'
|
||||
|
||||
const loading = ref(false)
|
||||
const logList = ref<any[]>([])
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
const filterSourceId = ref<number | undefined>(undefined)
|
||||
const filterChangeType = ref('')
|
||||
const dataSources = ref<any[]>([])
|
||||
|
||||
function changeTypeText(v: string) {
|
||||
const map: Record<string, string> = {
|
||||
add_table: '新增表', drop_table: '删除表',
|
||||
add_column: '新增字段', drop_column: '删除字段',
|
||||
change_type: '类型变更', change_comment: '注释变更',
|
||||
drop_database: '删除库', add_database: '新增库',
|
||||
}
|
||||
return map[v] || v
|
||||
}
|
||||
|
||||
function changeTypeColor(v: string) {
|
||||
if (v.startsWith('add')) return 'success'
|
||||
if (v.startsWith('drop')) return 'danger'
|
||||
return 'warning'
|
||||
}
|
||||
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await getSchemaChangeLogs({
|
||||
source_id: filterSourceId.value,
|
||||
change_type: filterChangeType.value || undefined,
|
||||
page: page.value,
|
||||
page_size: pageSize.value,
|
||||
})
|
||||
logList.value = res.data || []
|
||||
total.value = res.total || 0
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSources() {
|
||||
try {
|
||||
const res: any = await getDataSources()
|
||||
dataSources.value = res.data || []
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchSources()
|
||||
fetchData()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<h2 class="page-title">非结构化文件识别</h2>
|
||||
<div class="upload-area card-shadow" style="padding: 24px; margin-bottom: 16px;">
|
||||
<el-upload
|
||||
drag
|
||||
action="#"
|
||||
:auto-upload="false"
|
||||
:on-change="handleFileChange"
|
||||
accept=".docx,.xlsx,.pdf,.txt"
|
||||
:limit="1"
|
||||
>
|
||||
<el-icon class="el-icon--upload"><upload-icon /></el-icon>
|
||||
<div class="el-upload__text">拖拽文件到此处或 <em>点击上传</em></div>
|
||||
<template #tip>
|
||||
<div class="el-upload__tip">支持 Word、Excel、PDF、TXT 格式</div>
|
||||
</template>
|
||||
</el-upload>
|
||||
<el-button type="primary" :loading="uploadLoading" @click="handleUpload" style="margin-top: 12px;">开始识别</el-button>
|
||||
</div>
|
||||
|
||||
<div class="table-card card-shadow">
|
||||
<el-table :data="fileList" v-loading="listLoading" stripe size="default" border>
|
||||
<el-table-column prop="original_name" label="文件名" min-width="180" />
|
||||
<el-table-column prop="file_type" label="类型" width="80" />
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'processed' ? 'success' : row.status === 'error' ? 'danger' : 'warning'">
|
||||
{{ ({ pending: '待处理', processed: '已处理', error: '失败' } as Record<string, string>)[row.status as string] || row.status }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="敏感信息" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<div v-if="row.analysis_result?.matches?.length">
|
||||
<el-tag v-for="(m, idx) in row.analysis_result.matches.slice(0, 3)" :key="idx" size="small" type="danger" style="margin-right: 4px;">
|
||||
{{ m.rule_name }}
|
||||
</el-tag>
|
||||
<span v-if="row.analysis_result.matches.length > 3" class="more-tag">+{{ row.analysis_result.matches.length - 3 }}</span>
|
||||
</div>
|
||||
<span v-else class="empty-text">未检测到</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="140" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link size="small" @click="handleView(row)">查看详情</el-button>
|
||||
<el-button type="primary" link size="small" @click="handleReprocess(row)">重新识别</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="detailVisible" title="识别详情" width="640px" destroy-on-close>
|
||||
<div v-if="currentFile?.analysis_result?.matches?.length">
|
||||
<div style="margin-bottom: 12px;">共发现 {{ currentFile.analysis_result.matches.length }} 处敏感信息:</div>
|
||||
<el-timeline>
|
||||
<el-timeline-item v-for="(m, idx) in currentFile.analysis_result.matches" :key="idx" :type="m.level_code === 'L4' || m.level_code === 'L5' ? 'danger' : 'warning'">
|
||||
<div style="font-weight: 600;">{{ m.rule_name }} ({{ m.level_code }})</div>
|
||||
<div style="color: #909399; font-size: 13px; margin-top: 4px;">片段:{{ m.snippet }}</div>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
</div>
|
||||
<el-empty v-else description="未检测到敏感信息" />
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Upload as UploadIcon } from '@element-plus/icons-vue'
|
||||
import { uploadUnstructuredFile, getUnstructuredFiles, reprocessUnstructuredFile } from '@/api/unstructured'
|
||||
|
||||
const uploadLoading = ref(false)
|
||||
const listLoading = ref(false)
|
||||
const fileList = ref<any[]>([])
|
||||
const selectedFile = ref<File | null>(null)
|
||||
const detailVisible = ref(false)
|
||||
const currentFile = ref<any>(null)
|
||||
|
||||
function handleFileChange(file: any) {
|
||||
selectedFile.value = file.raw
|
||||
}
|
||||
|
||||
async function handleUpload() {
|
||||
if (!selectedFile.value) {
|
||||
ElMessage.warning('请选择文件')
|
||||
return
|
||||
}
|
||||
uploadLoading.value = true
|
||||
try {
|
||||
const res: any = await uploadUnstructuredFile(selectedFile.value)
|
||||
ElMessage.success('上传并识别成功')
|
||||
selectedFile.value = null
|
||||
fetchList()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '上传失败')
|
||||
} finally {
|
||||
uploadLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchList() {
|
||||
listLoading.value = true
|
||||
try {
|
||||
const res: any = await getUnstructuredFiles()
|
||||
fileList.value = res.data || []
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '加载失败')
|
||||
} finally {
|
||||
listLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleView(row: any) {
|
||||
currentFile.value = row
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
async function handleReprocess(row: any) {
|
||||
try {
|
||||
const res: any = await reprocessUnstructuredFile(row.id)
|
||||
ElMessage.success('重新识别完成')
|
||||
fetchList()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '识别失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.empty-text {
|
||||
color: #c0c4cc;
|
||||
}
|
||||
.more-tag {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<h2 class="page-title">数据水印溯源</h2>
|
||||
<div class="card-shadow" style="padding: 20px; margin-bottom: 16px;">
|
||||
<el-input
|
||||
v-model="traceText"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
placeholder="粘贴疑似泄露的文本片段(支持CSV/TXT内容),系统将检测其中的隐形水印"
|
||||
/>
|
||||
<el-button type="primary" style="margin-top: 12px" :loading="loading" @click="handleTrace">
|
||||
开始溯源
|
||||
</el-button>
|
||||
</div>
|
||||
<div v-if="result" class="card-shadow" style="padding: 20px;">
|
||||
<div style="font-size: 16px; font-weight: 600; margin-bottom: 12px;">溯源结果</div>
|
||||
<el-descriptions v-if="result.user_id" :column="1" border>
|
||||
<el-descriptions-item label="泄露用户ID">{{ result.user_id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="用户名">{{ result.username || '--' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="导出类型">{{ result.export_type }}</el-descriptions-item>
|
||||
<el-descriptions-item label="数据范围">{{ result.data_scope }}</el-descriptions-item>
|
||||
<el-descriptions-item label="导出时间">{{ result.created_at }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-alert v-else title="未检测到有效水印" type="warning" :closable="false" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { traceWatermark } from '@/api/watermark'
|
||||
|
||||
const traceText = ref('')
|
||||
const loading = ref(false)
|
||||
const result = ref<any>(null)
|
||||
|
||||
async function handleTrace() {
|
||||
if (!traceText.value.trim()) {
|
||||
ElMessage.warning('请输入待溯源文本')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await traceWatermark(traceText.value)
|
||||
result.value = res.data
|
||||
if (!res.data) {
|
||||
ElMessage.info('未检测到水印信息')
|
||||
} else {
|
||||
ElMessage.success('溯源成功')
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '溯源失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user