feat: 数据分类分级 — 重要数据标记+资产清单+CSV导出(8/20评估办法政策驱动)
- subjects/kpi_definitions 新增 important_flag/data_level/data_category 列 - 新增 /api/cma/data-classification/* API: inventory/stats/标记/批量/export/industry-reference - 数据级别: core核心/important重要/general一般(GB/T 43697 简化) - 前端 DataClassification.vue: 统计概览+清单+打标+导出+行业参考目录 - pytest 17例覆盖(含多租户隔离验证)
This commit is contained in:
@@ -9,10 +9,10 @@ interface MenuItem {
|
||||
|
||||
// ── 角色路由映射 ──
|
||||
export const ROLE_ROUTES: Record<string, string[]> = {
|
||||
ceo: ['/my-dashboard', '/dashboard', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/notifications', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/receivables', '/growth-quality', '/product-matrix', '/tax-compliance'],
|
||||
finance: ['/my-dashboard', '/dashboard', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/data', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/receivables', '/growth-quality', '/product-matrix', '/tax-compliance'],
|
||||
business: ['/my-dashboard', '/dashboard', '/kpis', '/alerts', '/budget', '/deviations', '/action-plans', '/knowledge', '/guide', '/customer', '/expenses', '/cash-plan', '/growth-quality', '/product-matrix', '/tax-compliance'],
|
||||
it: ['/my-dashboard', '/dashboard', '/kpis', '/alerts', '/data', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/analysis-confidence', '/expenses', '/cash-plan', '/product-matrix', '/tax-compliance'],
|
||||
ceo: ['/my-dashboard', '/dashboard', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/notifications', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/receivables', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification'],
|
||||
finance: ['/my-dashboard', '/dashboard', '/kpis', '/maps', '/maps-review', '/maps/canvas', '/maps/review', '/alerts', '/data', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/reports', '/alignment', '/dupont-analysis', '/analysis-confidence', '/expenses', '/cash-plan', '/receivables', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification'],
|
||||
business: ['/my-dashboard', '/dashboard', '/kpis', '/alerts', '/budget', '/deviations', '/action-plans', '/knowledge', '/guide', '/customer', '/expenses', '/cash-plan', '/growth-quality', '/product-matrix', '/tax-compliance', '/data-classification'],
|
||||
it: ['/my-dashboard', '/dashboard', '/kpis', '/alerts', '/data', '/org', '/users', '/permissions', '/budget', '/deviations', '/cost', '/cost-intelligence', '/predict', '/action-plans', '/knowledge', '/guide', '/customer', '/learning-dashboard', '/analysis-confidence', '/expenses', '/cash-plan', '/product-matrix', '/tax-compliance', '/data-classification'],
|
||||
}
|
||||
|
||||
export const ROLE_ACTIONS: Record<string, string[]> = {
|
||||
@@ -61,6 +61,7 @@ export const MENU_ITEMS: MenuItem[] = [
|
||||
// ── GROUP 5: 基础数据与知识──
|
||||
{ path: '/kpis', label: 'KPI字典', icon: 'Document', roles: ['ceo', 'finance', 'business', 'it'], group: '基础数据与知识' },
|
||||
{ path: '/data', label: '数据管理', icon: 'Connection', roles: ['ceo', 'finance', 'it'], group: '基础数据与知识' },
|
||||
{ path: '/data-classification', label: '数据分类分级', icon: 'WarningFilled', roles: ['ceo', 'finance', 'business', 'it'], group: '基础数据与知识' },
|
||||
{ path: '/knowledge', label: 'CMA知识库', icon: 'Document', roles: ['ceo', 'finance', 'business', 'it'], group: '基础数据与知识' },
|
||||
{ path: '/okr-templates', label: 'OKR模板库', icon: 'Collection', roles: ['ceo', 'finance', 'it'], group: '基础数据与知识' },
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ const routes = [
|
||||
{ path: 'okr-templates', name: 'OKRTemplates', component: () => import('@/views/OKRTemplates.vue'), meta: { title: 'OKR模板库', roles: ['ceo', 'finance', 'it'], editable: true } },
|
||||
{ path: 'okr/:id', name: 'OkrDetail', component: () => import('@/views/OkrDetail.vue'), meta: { title: 'OKR详情', roles: ['ceo', 'finance', 'it'] } },
|
||||
{ path: 'subjects', name: 'SubjectManage', component: () => import('@/views/SubjectManage.vue'), meta: { title: '科目打标', roles: ['ceo', 'finance', 'business', 'it'] } },
|
||||
{ path: 'data-classification', name: 'DataClassification', component: () => import('@/views/DataClassification.vue'), meta: { title: '数据分类分级', roles: ['ceo', 'finance', 'business', 'it'], editable: true } },
|
||||
{ path: 'mpm-calculator', name: 'MpmCalculator', component: () => import('@/views/MpmCalculator.vue'), meta: { title: 'MPM计算器', roles: ['ceo', 'finance', 'business'] } },
|
||||
{ path: 'bot-kpis', name: 'BotKpis', component: () => import('@/views/BotKpiDashboard.vue'), meta: { title: 'Bot KPI看板', roles: ['ceo', 'finance', 'it'] } },
|
||||
{ path: 'analysis-confidence', name: 'AnalysisConfidence', component: () => import('@/views/AnalysisConfidence.vue'), meta: { title: '分析置信度', roles: ['ceo', 'finance', 'it'] } },
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
<template>
|
||||
<div class="dc-page">
|
||||
<!-- 政策提示 -->
|
||||
<div class="policy-banner">
|
||||
<span class="policy-icon">📜</span>
|
||||
<span>
|
||||
《网络数据安全风险评估办法》(2026-08-20 施行):重要数据处理者每年一次强制评估,评估前提=先完成数据分类分级。
|
||||
本页为「分类分级工具」第一版:标记 + 数据资产清单 + 导出(评估前准备材料)。
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 统计概览 -->
|
||||
<div class="stats-row">
|
||||
<div class="stat-card">
|
||||
<div class="stat-num">{{ stats.subjects?.total ?? 0 }}</div>
|
||||
<div class="stat-label">会计科目</div>
|
||||
<div class="stat-sub">已标记 {{ stats.subjects?.marked ?? 0 }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-num">{{ stats.kpis?.total ?? 0 }}</div>
|
||||
<div class="stat-label">KPI 指标</div>
|
||||
<div class="stat-sub">已标记 {{ stats.kpis?.marked ?? 0 }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-num">{{ stats.marked_total ?? 0 }}</div>
|
||||
<div class="stat-label">重要数据项</div>
|
||||
<div class="stat-sub">覆盖率 {{ stats.coverage_pct ?? 0 }}%</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-num">{{ stats.entity_name || ('账套 #' + (stats.entity_id ?? '-')) }}</div>
|
||||
<div class="stat-label">当前账套</div>
|
||||
<div class="stat-sub">多租户隔离</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作条 -->
|
||||
<div class="toolbar">
|
||||
<el-radio-group v-model="dataType" size="small" @change="loadInventory">
|
||||
<el-radio-button label="">全部</el-radio-button>
|
||||
<el-radio-button label="subject">科目</el-radio-button>
|
||||
<el-radio-button label="kpi">KPI</el-radio-button>
|
||||
</el-radio-group>
|
||||
|
||||
<el-select v-model="filterLevel" placeholder="数据级别" clearable size="small" style="width:130px;" @change="loadInventory">
|
||||
<el-option label="核心数据" value="core" />
|
||||
<el-option label="重要数据" value="important" />
|
||||
<el-option label="一般数据" value="general" />
|
||||
</el-select>
|
||||
|
||||
<el-checkbox v-model="onlyImportant" size="small" @change="loadInventory">仅看重要数据</el-checkbox>
|
||||
|
||||
<el-input v-model="keyword" placeholder="搜索编码/名称" size="small" style="width:180px;" clearable @change="loadInventory" />
|
||||
|
||||
<span style="margin-left:auto;">
|
||||
<el-button size="small" type="success" :disabled="selectedIds.length === 0 || !canWrite" @click="openBatchPanel">
|
||||
批量打标 ({{ selectedIds.length }})
|
||||
</el-button>
|
||||
<el-button size="small" type="primary" :loading="exporting" @click="doExport">导出清单 CSV</el-button>
|
||||
<el-button size="small" @click="loadInventory">刷新</el-button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 批量打标面板 -->
|
||||
<div v-if="batchVisible" class="batch-panel">
|
||||
<span>已选 <b>{{ selectedIds.length }}</b> 项,批量设置:</span>
|
||||
<el-select v-model="batchLevel" placeholder="数据级别" size="small" style="width:130px;">
|
||||
<el-option label="核心数据" value="core" />
|
||||
<el-option label="重要数据" value="important" />
|
||||
<el-option label="一般数据" value="general" />
|
||||
</el-select>
|
||||
<el-input v-model="batchCategory" placeholder="行业参考分类(可选,如 财务数据/客户数据)" size="small" style="width:220px;" />
|
||||
<el-button type="primary" size="small" :loading="batchLoading" @click="doBatch">确认打标</el-button>
|
||||
<el-button size="small" @click="batchVisible = false">取消</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 数据资产清单 -->
|
||||
<el-table
|
||||
:data="items"
|
||||
border stripe size="small"
|
||||
style="width:100%;margin-top:8px;"
|
||||
@selection-change="onSelectionChange"
|
||||
>
|
||||
<el-table-column type="selection" width="36" />
|
||||
<el-table-column label="类型" width="64">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.type === 'subject' ? '' : 'primary'" size="small">
|
||||
{{ row.type === 'subject' ? '科目' : 'KPI' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="code" label="编码" width="110" />
|
||||
<el-table-column prop="name" label="名称" min-width="170" />
|
||||
<el-table-column prop="dimension" label="维度" width="80">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.dimension">{{ dimLabel(row.dimension) }}</span>
|
||||
<span v-else style="color:#ccc;">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="重要标记" width="84">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.important_flag ? 'danger' : 'info'" size="small">
|
||||
{{ row.important_flag ? '重要' : '一般' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="数据级别" width="130">
|
||||
<template #default="{ row }">
|
||||
<el-select
|
||||
v-model="row.data_level"
|
||||
size="small"
|
||||
style="width:112px;"
|
||||
:disabled="!canWrite"
|
||||
@change="val => onLevelChange(row, val)"
|
||||
>
|
||||
<el-option label="核心数据" value="core" />
|
||||
<el-option label="重要数据" value="important" />
|
||||
<el-option label="一般数据" value="general" />
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="行业参考分类" width="170">
|
||||
<template #default="{ row }">
|
||||
<el-select
|
||||
v-model="row.data_category"
|
||||
size="small"
|
||||
style="width:150px;"
|
||||
:disabled="!canWrite"
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
clearable
|
||||
placeholder="选择或输入"
|
||||
@change="val => onCategoryChange(row, val)"
|
||||
>
|
||||
<el-option v-for="c in categoryOptions" :key="c" :label="c" :value="c" />
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="data_owner" label="责任人" width="90">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.data_owner">{{ row.data_owner }}</span>
|
||||
<span v-else style="color:#ccc;">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="storage" label="存储位置" width="130">
|
||||
<template #default="{ row }">
|
||||
<span style="font-size:12px;">{{ row.storage }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="table-footer">共 {{ totalItems }} 项 · 已标记 {{ markedCount }} 项</div>
|
||||
|
||||
<!-- 行业参考目录 -->
|
||||
<el-collapse style="margin-top:12px;">
|
||||
<el-collapse-item>
|
||||
<template #title>
|
||||
<span style="font-weight:600;">🏭 行业参考目录(按行业提示重点数据,可自定义)</span>
|
||||
</template>
|
||||
<el-table :data="industryRef" border stripe size="small" style="width:100%;">
|
||||
<el-table-column prop="industry" label="行业" width="130" />
|
||||
<el-table-column prop="category" label="数据分类" width="140" />
|
||||
<el-table-column label="建议级别" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.suggest_level === 'core' ? 'danger' : 'warning'" size="small">
|
||||
{{ row.suggest_level === 'core' ? '核心' : '重要' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="desc" label="说明" />
|
||||
</el-table>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import axios from 'axios'
|
||||
|
||||
const api = axios.create({ baseURL: '/api/cma', timeout: 30000 })
|
||||
api.interceptors.request.use((config: any) => {
|
||||
const token = localStorage.getItem('cma_token')
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||
return config
|
||||
})
|
||||
|
||||
const userRole = computed(() => (localStorage.getItem('cma_role') || 'finance'))
|
||||
const canWrite = computed(() => ['ceo', 'finance', 'it'].includes(userRole.value))
|
||||
|
||||
const loading = ref(false)
|
||||
const items = ref<any[]>([])
|
||||
const totalItems = ref(0)
|
||||
const markedCount = ref(0)
|
||||
const stats = ref<any>({})
|
||||
const industryRef = ref<any[]>([])
|
||||
|
||||
const dataType = ref('')
|
||||
const filterLevel = ref('')
|
||||
const onlyImportant = ref(false)
|
||||
const keyword = ref('')
|
||||
const selectedIds = ref<number[]>([])
|
||||
const selectedType = ref('subject')
|
||||
|
||||
const exporting = ref(false)
|
||||
const batchVisible = ref(false)
|
||||
const batchLevel = ref('important')
|
||||
const batchCategory = ref('')
|
||||
const batchLoading = ref(false)
|
||||
|
||||
const categoryOptions = ['财务数据', '客户数据', '员工数据', '生产数据', '供应链数据', '税务数据', '薪酬数据', '个人信息']
|
||||
|
||||
const dimMap: Record<string, string> = {
|
||||
finance: '财务', customer: '客户', process: '流程', learning: '学习成长',
|
||||
}
|
||||
|
||||
function dimLabel(d: string) {
|
||||
return dimMap[d] || d
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const r = await api.get('/data-classification/stats')
|
||||
stats.value = (r as any).data || {}
|
||||
} catch (e) {
|
||||
stats.value = {}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadInventory() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = {}
|
||||
if (dataType.value) params.data_type = dataType.value
|
||||
if (filterLevel.value) params.data_level = filterLevel.value
|
||||
if (onlyImportant.value) params.important = 1
|
||||
if (keyword.value) params.keyword = keyword.value
|
||||
const r = await api.get('/data-classification/inventory', { params })
|
||||
const d = (r as any).data || {}
|
||||
items.value = d.items || []
|
||||
totalItems.value = d.total || 0
|
||||
markedCount.value = (d.stats || {}).marked || 0
|
||||
} catch (e) {
|
||||
ElMessage.error('加载数据资产清单失败')
|
||||
items.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadIndustryRef() {
|
||||
try {
|
||||
const r = await api.get('/data-classification/industry-reference')
|
||||
industryRef.value = (r as any).data?.items || []
|
||||
} catch (e) {
|
||||
industryRef.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function onSelectionChange(rows: any[]) {
|
||||
selectedIds.value = rows.map((r: any) => r.id)
|
||||
selectedType.value = rows.length ? rows[0].type : 'subject'
|
||||
}
|
||||
|
||||
async function onLevelChange(row: any, val: string) {
|
||||
try {
|
||||
const url = row.type === 'subject' ? `/data-classification/subjects/${row.id}` : `/data-classification/kpis/${row.id}`
|
||||
await api.put(url, null, { params: { data_level: val } })
|
||||
row.important_flag = (val === 'core' || val === 'important') ? 1 : row.important_flag
|
||||
ElMessage.success(`${row.name} 分级更新成功`)
|
||||
await loadStats()
|
||||
} catch (e) {
|
||||
ElMessage.error('更新失败')
|
||||
await loadInventory()
|
||||
}
|
||||
}
|
||||
|
||||
async function onCategoryChange(row: any, val: string) {
|
||||
try {
|
||||
const url = row.type === 'subject' ? `/data-classification/subjects/${row.id}` : `/data-classification/kpis/${row.id}`
|
||||
await api.put(url, null, { params: { data_category: val || '' } })
|
||||
ElMessage.success(`${row.name} 分类更新成功`)
|
||||
} catch (e) {
|
||||
ElMessage.error('更新失败')
|
||||
await loadInventory()
|
||||
}
|
||||
}
|
||||
|
||||
function openBatchPanel() {
|
||||
batchLevel.value = 'important'
|
||||
batchCategory.value = ''
|
||||
batchVisible.value = true
|
||||
}
|
||||
|
||||
async function doBatch() {
|
||||
batchLoading.value = true
|
||||
try {
|
||||
await api.put('/data-classification/batch', null, {
|
||||
params: {
|
||||
data_type: selectedType.value,
|
||||
ids: selectedIds.value,
|
||||
data_level: batchLevel.value,
|
||||
...(batchCategory.value ? { data_category: batchCategory.value } : {}),
|
||||
}
|
||||
})
|
||||
ElMessage.success(`批量打标 ${selectedIds.value.length} 项成功`)
|
||||
batchVisible.value = false
|
||||
await loadInventory()
|
||||
await loadStats()
|
||||
} catch (e) {
|
||||
ElMessage.error('批量打标失败')
|
||||
} finally {
|
||||
batchLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function doExport() {
|
||||
exporting.value = true
|
||||
try {
|
||||
const params: any = {}
|
||||
if (dataType.value) params.data_type = dataType.value
|
||||
if (filterLevel.value) params.data_level = filterLevel.value
|
||||
if (onlyImportant.value) params.important = 1
|
||||
const r: any = await api.get('/data-classification/export', { params, responseType: 'blob' })
|
||||
const blob = r.data
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `数据分类分级清单_${new Date().toISOString().slice(0, 10)}.csv`
|
||||
a.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
ElMessage.success('清单已导出')
|
||||
} catch (e) {
|
||||
ElMessage.error('导出失败')
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadStats()
|
||||
loadInventory()
|
||||
loadIndustryRef()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dc-page { max-width: 1280px; margin: 0 auto; padding: 16px; }
|
||||
.policy-banner {
|
||||
background: linear-gradient(90deg, #fef3e2, #fff8ef);
|
||||
border: 1px solid #f5d9a8;
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
font-size: 13px;
|
||||
color: #7a5b1e;
|
||||
margin-bottom: 14px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.policy-icon { font-size: 16px; }
|
||||
.stats-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 14px; }
|
||||
.stat-card {
|
||||
background: #fff; border: 1px solid #e5e7ef; border-radius: 10px;
|
||||
padding: 14px 16px; box-shadow: 0 1px 3px rgba(0,0,0,.04);
|
||||
}
|
||||
.stat-num { font-size: 24px; font-weight: 700; color: #1a1a2e; }
|
||||
.stat-label { font-size: 13px; color: #666; margin-top: 2px; }
|
||||
.stat-sub { font-size: 12px; color: #999; margin-top: 2px; }
|
||||
.toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.batch-panel {
|
||||
background: #f0f7ff; border: 1px solid #c8ddf5; border-radius: 8px;
|
||||
padding: 10px 12px; margin-top: 10px; display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
||||
font-size: 13px;
|
||||
}
|
||||
.table-footer { font-size: 12px; color: #999; margin-top: 6px; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user