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:
hiderfong
2026-04-25 08:51:38 +08:00
parent 8b2bc84399
commit 6d70520e79
110 changed files with 6125 additions and 87 deletions
-1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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}
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -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>
+69
View File
@@ -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>
+24
View File
@@ -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 } })
}
+17
View File
@@ -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`)
}
+9
View File
@@ -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 })
}
+34
View File
@@ -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 } })
}
+6 -2
View File
@@ -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`)
}
+7 -3
View File
@@ -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`)
}
+13
View File
@@ -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}`)
}
+17
View File
@@ -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 })
}
+36
View File
@@ -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`)
}
+5
View File
@@ -0,0 +1,5 @@
import request from './request'
export function traceWatermark(text: string) {
return request.post('/watermark/trace', { text })
}
+81 -15
View File
@@ -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;
+1
View File
@@ -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'
+49 -1
View File
@@ -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',
+197
View File
@@ -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>
+179
View File
@@ -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>
+15 -4
View File
@@ -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>
+60 -1
View File
@@ -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
}
+87
View File
@@ -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>
+250
View File
@@ -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>
+91 -4
View File
@@ -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;
+18 -5
View File
@@ -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">支持 WordExcelPDFTXT 格式</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>