feat: initial commit - Phase 1 & 2 core features

This commit is contained in:
hiderfong
2026-04-22 17:07:33 +08:00
commit 1773bda06b
25005 changed files with 6252106 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
<template>
<router-view />
</template>
<script setup lang="ts">
</script>
<style scoped>
</style>
+28
View File
@@ -0,0 +1,28 @@
import request from './request'
export interface LoginRes {
access_token: string
refresh_token: string
token_type: string
expires_in: number
}
export interface UserInfo {
id: number
username: string
email?: string
real_name?: string
phone?: string
is_active: boolean
is_superuser: boolean
dept?: { id: number; name: string }
roles: { id: number; name: string; code: string }[]
}
export function login(username: string, password: string): Promise<LoginRes> {
return request.post('/auth/login', { username, password })
}
export function getMe(): Promise<UserInfo> {
return request.get('/users/me')
}
+78
View File
@@ -0,0 +1,78 @@
import request from './request'
export interface CategoryItem {
id: number
parent_id?: number
level: number
code: string
name: string
description?: string
sort_order: number
children?: CategoryItem[]
}
export interface DataLevel {
id: number
code: string
name: string
description?: string
color: string
control_requirements?: Record<string, any>
}
export interface RecognitionRule {
id: number
template_id: number
category_id?: number
level_id?: number
rule_type: string
rule_name?: string
rule_content: string
target_field: string
priority: number
is_active: boolean
hit_count: number
category_name?: string
level_name?: string
level_color?: string
}
export function getCategoryTree() {
return request.get('/classifications/categories/tree')
}
export function createCategory(data: Partial<CategoryItem>) {
return request.post('/classifications/categories', data)
}
export function updateCategory(id: number, data: Partial<CategoryItem>) {
return request.put(`/classifications/categories/${id}`, data)
}
export function deleteCategory(id: number) {
return request.delete(`/classifications/categories/${id}`)
}
export function getDataLevels() {
return request.get('/classifications/levels')
}
export function getRules(params?: { template_id?: number; keyword?: string; page?: number; page_size?: number }) {
return request.get('/classifications/rules', { params })
}
export function createRule(data: Partial<RecognitionRule>) {
return request.post('/classifications/rules', data)
}
export function updateRule(id: number, data: Partial<RecognitionRule>) {
return request.put(`/classifications/rules/${id}`, data)
}
export function deleteRule(id: number) {
return request.delete(`/classifications/rules/${id}`)
}
export function getTemplates() {
return request.get('/classifications/templates')
}
+49
View File
@@ -0,0 +1,49 @@
import request from './request'
export interface DataSourceItem {
id: number
name: string
source_type: string
host?: string
port?: number
database_name?: string
username?: string
status: string
created_at: string
}
export interface DataSourceForm {
name: string
source_type: string
host?: string
port?: number
database_name?: string
username?: string
password?: string
extra_params?: string
dept_id?: number
}
export function getDataSources(params?: { page?: number; page_size?: number; keyword?: string }) {
return request.get('/datasources', { params })
}
export function createDataSource(data: DataSourceForm) {
return request.post('/datasources', data)
}
export function updateDataSource(id: number, data: Partial<DataSourceForm>) {
return request.put(`/datasources/${id}`, data)
}
export function deleteDataSource(id: number) {
return request.delete(`/datasources/${id}`)
}
export function testConnection(data: Partial<DataSourceForm>) {
return request.post('/datasources/test-connection', data)
}
export function syncMetadata(sourceId: number) {
return request.post(`/metadata/sync/${sourceId}`)
}
+29
View File
@@ -0,0 +1,29 @@
import request from './request'
export interface TreeNode {
id: number
name: string
type: string
children?: TreeNode[]
meta?: Record<string, any>
}
export interface DataColumn {
id: number
table_id: number
name: string
data_type?: string
length?: number
comment?: string
is_nullable: boolean
sample_data?: string
created_at: string
}
export function getMetadataTree(sourceId?: number) {
return request.get('/metadata/tree', { params: { source_id: sourceId } })
}
export function getColumns(params: { table_id?: number; keyword?: string; page?: number; page_size?: number }) {
return request.get('/metadata/columns', { params })
}
+39
View File
@@ -0,0 +1,39 @@
import request from './request'
export interface ProjectItem {
id: number
name: string
template_id: number
status: string
description?: string
target_source_ids?: string
planned_start?: string
planned_end?: string
created_at: string
stats?: {
total: number
auto: number
manual: number
reviewed: number
}
}
export function getProjects(params?: { page?: number; page_size?: number; keyword?: string }) {
return request.get('/projects', { params })
}
export function getProject(id: number) {
return request.get(`/projects/${id}`)
}
export function createProject(data: { name: string; template_id: number; target_source_ids?: string; description?: string }) {
return request.post('/projects', null, { params: data })
}
export function deleteProject(id: number) {
return request.delete(`/projects/${id}`)
}
export function autoClassifyProject(id: number) {
return request.post(`/projects/${id}/auto-classify`)
}
+45
View File
@@ -0,0 +1,45 @@
import axios, { type AxiosError, type InternalAxiosRequestConfig } from 'axios'
import { ElMessage } from 'element-plus'
const request = axios.create({
baseURL: (import.meta as any).env?.VITE_API_BASE_URL || '/api/v1',
timeout: 30000,
})
request.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const token = localStorage.getItem('pdg_token')
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
return Promise.reject(error)
}
)
request.interceptors.response.use(
(response) => {
const res = response.data
if (res.code !== 200) {
ElMessage.error(res.message || '请求失败')
return Promise.reject(new Error(res.message))
}
return res.data
},
(error: AxiosError) => {
const status = error.response?.status
if (status === 401) {
ElMessage.error('登录已过期,请重新登录')
localStorage.removeItem('pdg_token')
localStorage.removeItem('pdg_refresh')
window.location.href = '/login'
} else {
ElMessage.error((error.response?.data as any)?.message || '网络错误')
}
return Promise.reject(error)
}
)
export default request
+37
View File
@@ -0,0 +1,37 @@
import request from './request'
export interface TaskItem {
id: number
name: string
project_id: number
status: string
deadline?: string
created_at: string
}
export interface TaskResultItem {
result_id: number
column_id: number
column_name: string
data_type?: string
comment?: string
table_name?: string
database_name?: string
source_name?: string
category_id?: number
category_name?: string
level_id?: number
level_name?: string
level_color?: string
source: string
confidence: number
status: string
}
export function getMyTasks(params?: { status?: string }) {
return request.get('/tasks/my-tasks', { params })
}
export function getTaskItems(taskId: number) {
return request.get(`/tasks/my-tasks/${taskId}/items`)
}
+237
View File
@@ -0,0 +1,237 @@
<template>
<el-container class="layout-container">
<!-- Sidebar for desktop / Drawer for mobile -->
<el-aside
v-if="!isMobile"
width="220px"
class="layout-aside"
>
<div class="logo">
<el-icon size="28" color="#fff"><Collection /></el-icon>
<span class="logo-text">PropDataGuard</span>
</div>
<el-menu
:default-active="activeRoute"
router
background-color="#1a2b4a"
text-color="#b0c4de"
active-text-color="#fff"
class="layout-menu"
>
<el-menu-item
v-for="route in menuRoutes"
:key="route.path"
:index="route.path"
>
<el-icon>
<component :is="route.meta?.icon" />
</el-icon>
<span>{{ route.meta?.title }}</span>
</el-menu-item>
</el-menu>
</el-aside>
<el-container direction="vertical">
<!-- Header -->
<el-header class="layout-header" height="56px">
<div class="header-left">
<el-button
v-if="isMobile"
type="primary"
text
@click="drawerVisible = true"
>
<el-icon size="22"><Expand /></el-icon>
</el-button>
<span class="header-title">{{ pageTitle }}</span>
</div>
<div class="header-right">
<el-dropdown @command="handleCommand">
<span class="user-info">
<el-avatar :size="32" :icon="UserFilled" />
<span class="username">{{ userStore.userInfo?.real_name || userStore.userInfo?.username }}</span>
<el-icon><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="logout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
<!-- Main Content -->
<el-main class="layout-main">
<router-view />
</el-main>
</el-container>
<!-- Mobile Drawer -->
<el-drawer
v-model="drawerVisible"
direction="ltr"
size="220px"
:with-header="false"
class="mobile-drawer"
>
<div class="logo">
<el-icon size="28" color="#fff"><Collection /></el-icon>
<span class="logo-text">PropDataGuard</span>
</div>
<el-menu
:default-active="activeRoute"
router
background-color="#1a2b4a"
text-color="#b0c4de"
active-text-color="#fff"
class="layout-menu"
@select="drawerVisible = false"
>
<el-menu-item
v-for="route in menuRoutes"
:key="route.path"
:index="route.path"
>
<el-icon>
<component :is="route.meta?.icon" />
</el-icon>
<span>{{ route.meta?.title }}</span>
</el-menu-item>
</el-menu>
</el-drawer>
</el-container>
</template>
<script setup lang="ts">
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'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const drawerVisible = ref(false)
const windowWidth = ref(window.innerWidth)
const isMobile = computed(() => windowWidth.value < 768)
const activeRoute = computed(() => route.path)
const pageTitle = computed(() => (route.meta?.title as string) || 'PropDataGuard')
const menuRoutes = computed(() => {
const layout = router.getRoutes().find((r) => r.name === 'Layout')
return layout?.children.filter((r) => r.meta?.title) || []
})
function handleCommand(cmd: string) {
if (cmd === 'logout') {
userStore.logout()
router.push('/login')
}
}
function onResize() {
windowWidth.value = window.innerWidth
}
onMounted(() => {
window.addEventListener('resize', onResize)
if (!userStore.userInfo && userStore.token) {
userStore.fetchUserInfo()
}
})
onUnmounted(() => {
window.removeEventListener('resize', onResize)
})
</script>
<style scoped lang="scss">
.layout-container {
height: 100vh;
}
.layout-aside {
background-color: #1a2b4a;
display: flex;
flex-direction: column;
}
.logo {
height: 56px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
background-color: #13203a;
flex-shrink: 0;
.logo-text {
color: #fff;
font-size: 16px;
font-weight: 600;
letter-spacing: 1px;
}
}
.layout-menu {
border-right: none;
flex: 1;
}
.layout-header {
background-color: #fff;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
padding: 0 16px;
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.header-title {
font-size: 16px;
font-weight: 600;
color: #303133;
}
.header-right {
.user-info {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
color: #606266;
.username {
font-size: 14px;
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
}
.layout-main {
background-color: #f5f7fa;
padding: 0;
overflow-y: auto;
}
:deep(.mobile-drawer) {
.el-drawer__body {
padding: 0;
background-color: #1a2b4a;
display: flex;
flex-direction: column;
}
}
</style>
+21
View File
@@ -0,0 +1,21 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'
import router from './router'
import './styles/main.scss'
const app = createApp(App)
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(createPinia())
app.use(router)
app.use(ElementPlus)
app.mount('#app')
+93
View File
@@ -0,0 +1,93 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '@/stores/user'
const routes = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/auth/Login.vue'),
meta: { public: true },
},
{
path: '/',
name: 'Layout',
component: () => import('@/components/Layout.vue'),
redirect: '/dashboard',
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('@/views/dashboard/Dashboard.vue'),
meta: { title: '首页', icon: 'HomeFilled' },
},
{
path: 'datasource',
name: 'DataSource',
component: () => import('@/views/datasource/DataSource.vue'),
meta: { title: '数据源管理', icon: 'DataLine' },
},
{
path: 'metadata',
name: 'Metadata',
component: () => import('@/views/metadata/Metadata.vue'),
meta: { title: '数据资产', icon: 'FolderOpened' },
},
{
path: 'category',
name: 'Category',
component: () => import('@/views/category/Category.vue'),
meta: { title: '分类分级标准', icon: 'Collection' },
},
{
path: 'project',
name: 'Project',
component: () => import('@/views/project/Project.vue'),
meta: { title: '项目管理', icon: 'List' },
},
{
path: 'task',
name: 'Task',
component: () => import('@/views/task/Task.vue'),
meta: { title: '我的任务', icon: 'EditPen' },
},
{
path: 'classification',
name: 'Classification',
component: () => import('@/views/classification/Classification.vue'),
meta: { title: '分类分级结果', icon: 'DocumentChecked' },
},
{
path: 'report',
name: 'Report',
component: () => import('@/views/report/Report.vue'),
meta: { title: '报表统计', icon: 'TrendCharts' },
},
{
path: 'system',
name: 'System',
component: () => import('@/views/system/System.vue'),
meta: { title: '系统管理', icon: 'Setting' },
},
],
},
{
path: '/:pathMatch(.*)*',
redirect: '/',
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
router.beforeEach((to, from, next) => {
const userStore = useUserStore()
if (!to.meta.public && !userStore.token) {
next('/login')
} else {
next()
}
})
export default router
+46
View File
@@ -0,0 +1,46 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { login as apiLogin, getMe } from '@/api/auth'
import type { UserInfo } from '@/api/auth'
export const useUserStore = defineStore('user', () => {
const token = ref<string | null>(localStorage.getItem('pdg_token') || null)
const userInfo = ref<UserInfo | null>(null)
const isLoggedIn = computed(() => !!token.value)
async function login(username: string, password: string) {
const res = await apiLogin(username, password)
token.value = res.access_token
localStorage.setItem('pdg_token', res.access_token)
localStorage.setItem('pdg_refresh', res.refresh_token)
await fetchUserInfo()
return res
}
async function fetchUserInfo() {
if (!token.value) return
try {
const res = await getMe()
userInfo.value = res
} catch (e) {
logout()
throw e
}
}
function logout() {
token.value = null
userInfo.value = null
localStorage.removeItem('pdg_token')
localStorage.removeItem('pdg_refresh')
}
return {
token,
userInfo,
isLoggedIn,
login,
fetchUserInfo,
logout,
}
})
+59
View File
@@ -0,0 +1,59 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body, #app {
height: 100%;
font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB',
'Microsoft YaHei', '微软雅黑', Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color: #303133;
background-color: #f5f7fa;
}
/* Responsive breakpoints */
$xs: 768px;
$sm: 1024px;
$md: 1440px;
/* Mobile adaptations */
@media (max-width: $xs) {
.el-table {
font-size: 13px;
}
.el-form-item__label {
font-size: 13px;
}
.el-button {
font-size: 13px;
}
}
/* Utility classes */
.flex-center {
display: flex;
align-items: center;
justify-content: center;
}
.text-ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.page-container {
padding: 16px;
@media (min-width: $xs) {
padding: 24px;
}
}
.card-shadow {
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
}
+142
View File
@@ -0,0 +1,142 @@
<template>
<div class="login-page">
<div class="login-box card-shadow">
<div class="login-header">
<el-icon size="48" color="#1a2b4a"><Collection /></el-icon>
<h1 class="login-title">PropDataGuard</h1>
<p class="login-subtitle">财险数据分级分类管理平台</p>
</div>
<el-form
ref="formRef"
:model="form"
:rules="rules"
size="large"
@keyup.enter="handleLogin"
>
<el-form-item prop="username">
<el-input
v-model="form.username"
placeholder="用户名"
:prefix-icon="User"
clearable
/>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="form.password"
type="password"
placeholder="密码"
:prefix-icon="Lock"
show-password
clearable
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
:loading="loading"
class="login-btn"
@click="handleLogin"
>
登录
</el-button>
</el-form-item>
</el-form>
<div class="login-footer">
<p>默认管理员admin / admin123</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/stores/user'
import { User, Lock, Collection } from '@element-plus/icons-vue'
const router = useRouter()
const userStore = useUserStore()
const loading = ref(false)
const formRef = ref()
const form = reactive({
username: 'admin',
password: 'admin123',
})
const rules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
}
async function handleLogin() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
loading.value = true
try {
await userStore.login(form.username, form.password)
ElMessage.success('登录成功')
router.push('/')
} catch (e: any) {
ElMessage.error(e?.message || '登录失败')
} finally {
loading.value = false
}
}
</script>
<style scoped lang="scss">
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #1a2b4a 0%, #2d4a7c 100%);
padding: 16px;
}
.login-box {
width: 100%;
max-width: 420px;
padding: 40px 32px;
background: #fff;
border-radius: 12px;
}
.login-header {
text-align: center;
margin-bottom: 32px;
.login-title {
font-size: 24px;
font-weight: 700;
color: #1a2b4a;
margin-top: 12px;
}
.login-subtitle {
font-size: 14px;
color: #909399;
margin-top: 8px;
}
}
.login-btn {
width: 100%;
height: 44px;
font-size: 16px;
border-radius: 6px;
}
.login-footer {
margin-top: 24px;
text-align: center;
font-size: 12px;
color: #c0c4cc;
}
</style>
+492
View File
@@ -0,0 +1,492 @@
<template>
<div class="page-container">
<h2 class="page-title">分类分级标准</h2>
<!-- Data Levels -->
<div class="section">
<div class="section-title">数据分级定义</div>
<el-row :gutter="12">
<el-col v-for="level in levels" :key="level.id" :xs="24" :sm="12" :md="8" :lg="5">
<div class="level-card card-shadow" :style="{ borderLeft: `4px solid ${level.color}` }">
<div class="level-header">
<el-tag :color="level.color" effect="dark" size="small">{{ level.code }}</el-tag>
<span class="level-name">{{ level.name }}</span>
</div>
<p class="level-desc">{{ level.description }}</p>
<div v-if="level.control_requirements" class="level-ctrl">
<div v-for="(v, k) in level.control_requirements" :key="k" class="ctrl-item">
<span class="ctrl-key">{{ k }}:</span>
<span class="ctrl-val">{{ v }}</span>
</div>
</div>
</div>
</el-col>
</el-row>
</div>
<!-- Category Tree -->
<div class="section">
<div class="section-header">
<span class="section-title">数据分类目录</span>
<el-button type="primary" size="small" @click="handleAddCategory">
<el-icon><Plus /></el-icon>新增分类
</el-button>
</div>
<div class="category-tree card-shadow">
<el-tree
:data="categoryTree"
:props="{ children: 'children', label: 'name' }"
node-key="id"
default-expand-all
>
<template #default="{ node, data }">
<span class="custom-tree-node">
<span class="node-label">
<el-tag size="small" type="info" class="code-tag">{{ data.code }}</el-tag>
{{ node.label }}
</span>
<span class="node-actions">
<el-button type="primary" link size="small" @click.stop="handleEditCategory(data)">编辑</el-button>
<el-button type="danger" link size="small" @click.stop="handleDeleteCategory(data)">删除</el-button>
</span>
</span>
</template>
</el-tree>
</div>
</div>
<!-- Recognition Rules -->
<div class="section">
<div class="section-header">
<span class="section-title">识别规则</span>
<el-button type="primary" size="small" @click="handleAddRule">
<el-icon><Plus /></el-icon>新增规则
</el-button>
</div>
<div class="table-card card-shadow">
<el-table :data="ruleList" v-loading="ruleLoading" stripe size="default">
<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>
</template>
</el-table-column>
<el-table-column prop="category_name" label="分类" min-width="120" />
<el-table-column prop="level_name" label="分级" width="100">
<template #default="{ row }">
<el-tag v-if="row.level_name" size="small" :color="row.level_color" effect="dark">
{{ row.level_name }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="target_field" label="匹配目标" width="100" />
<el-table-column prop="rule_content" label="规则内容" min-width="200" show-overflow-tooltip />
<el-table-column prop="priority" label="优先级" width="80" />
<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>
</div>
</div>
<!-- Category Dialog -->
<el-dialog v-model="catDialogVisible" :title="isEditCat ? '编辑分类' : '新增分类'" width="480px" destroy-on-close>
<el-form ref="catFormRef" :model="catForm" :rules="catRules" label-width="100px">
<el-form-item label="父分类">
<el-cascader
v-model="catForm.parent_id"
:options="categoryTree"
:props="{ value: 'id', label: 'name', checkStrictly: true, emitPath: false }"
clearable
placeholder="不选则为一级分类"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="编码" prop="code">
<el-input v-model="catForm.code" placeholder="分类编码,如 CUST_PERSONAL" />
</el-form-item>
<el-form-item label="名称" prop="name">
<el-input v-model="catForm.name" placeholder="分类名称" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="catForm.description" type="textarea" :rows="2" />
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="catForm.sort_order" :min="0" style="width: 100%" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="catDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmitCategory">确定</el-button>
</template>
</el-dialog>
<!-- Rule Dialog -->
<el-dialog v-model="ruleDialogVisible" :title="isEditRule ? '编辑规则' : '新增规则'" width="560px" destroy-on-close>
<el-form ref="ruleFormRef" :model="ruleForm" :rules="ruleRules" label-width="100px">
<el-form-item label="规则名称">
<el-input v-model="ruleForm.rule_name" placeholder="规则名称" />
</el-form-item>
<el-form-item label="规则类型" prop="rule_type">
<el-radio-group v-model="ruleForm.rule_type">
<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-group>
</el-form-item>
<el-form-item label="所属分类" prop="category_id">
<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="数据分级" prop="level_id">
<el-select v-model="ruleForm.level_id" placeholder="选择分级" style="width: 100%">
<el-option v-for="l in levels" :key="l.id" :label="l.name" :value="l.id">
<el-tag size="small" :color="l.color" effect="dark">{{ l.code }}</el-tag>
{{ l.name }}
</el-option>
</el-select>
</el-form-item>
<el-form-item label="匹配目标" prop="target_field">
<el-radio-group v-model="ruleForm.target_field">
<el-radio-button label="column_name">字段名</el-radio-button>
<el-radio-button label="comment">注释</el-radio-button>
<el-radio-button label="sample_data">采样数据</el-radio-button>
</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-form-item>
<el-form-item label="优先级">
<el-input-number v-model="ruleForm.priority" :min="1" :max="999" style="width: 100%" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="ruleDialogVisible = false">取消</el-button>
<el-button type="primary" @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 {
getCategoryTree, getDataLevels, getRules,
createCategory, updateCategory, deleteCategory,
createRule, updateRule, deleteRule, getTemplates,
} from '@/api/classification'
import type { CategoryItem, DataLevel, RecognitionRule } from '@/api/classification'
const levels = ref<DataLevel[]>([])
const categoryTree = ref<CategoryItem[]>([])
const ruleList = ref<RecognitionRule[]>([])
const ruleLoading = ref(false)
const templates = ref<any[]>([])
// Category dialog
const catDialogVisible = ref(false)
const isEditCat = ref(false)
const catEditId = ref<number | null>(null)
const catFormRef = ref()
const catForm = reactive({
parent_id: undefined as number | undefined,
code: '',
name: '',
description: '',
sort_order: 0,
})
const catRules = {
code: [{ required: true, message: '请输入编码', trigger: 'blur' }],
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
}
// Rule dialog
const ruleDialogVisible = ref(false)
const isEditRule = ref(false)
const ruleEditId = ref<number | null>(null)
const ruleFormRef = ref()
const ruleForm = reactive({
template_id: undefined as number | undefined,
rule_name: '',
rule_type: 'keyword',
category_id: undefined as number | undefined,
level_id: undefined as number | undefined,
target_field: 'column_name',
rule_content: '',
priority: 100,
})
const ruleRules = {
rule_type: [{ required: true, message: '请选择类型', trigger: 'change' }],
rule_content: [{ required: true, message: '请输入规则内容', trigger: 'blur' }],
}
async function fetchLevels() {
try {
const res: any = await getDataLevels()
levels.value = res || []
} catch (e: any) {
ElMessage.error(e?.message || '加载分级失败')
}
}
async function fetchCategories() {
try {
const res: any = await getCategoryTree()
categoryTree.value = res || []
} catch (e: any) {
ElMessage.error(e?.message || '加载分类失败')
}
}
async function fetchRules() {
ruleLoading.value = true
try {
const tpl = templates.value[0]
const res: any = await getRules({ template_id: tpl?.id, page: 1, page_size: 100 })
ruleList.value = res.data || []
} catch (e: any) {
ElMessage.error(e?.message || '加载规则失败')
} finally {
ruleLoading.value = false
}
}
async function fetchTemplates() {
try {
const res: any = await getTemplates()
templates.value = res || []
} catch (e: any) {
// ignore
}
}
function handleAddCategory() {
isEditCat.value = false
catEditId.value = null
catForm.parent_id = undefined
catForm.code = ''
catForm.name = ''
catForm.description = ''
catForm.sort_order = 0
catDialogVisible.value = true
}
function handleEditCategory(data: CategoryItem) {
isEditCat.value = true
catEditId.value = data.id
catForm.parent_id = data.parent_id
catForm.code = data.code
catForm.name = data.name
catForm.description = data.description || ''
catForm.sort_order = data.sort_order
catDialogVisible.value = true
}
async function handleSubmitCategory() {
const valid = await catFormRef.value?.validate().catch(() => false)
if (!valid) return
try {
if (isEditCat.value && catEditId.value) {
await updateCategory(catEditId.value, { ...catForm })
ElMessage.success('更新成功')
} else {
await createCategory({ ...catForm, level: catForm.parent_id ? 2 : 1 })
ElMessage.success('创建成功')
}
catDialogVisible.value = false
fetchCategories()
} catch (e: any) {
ElMessage.error(e?.message || '操作失败')
}
}
async function handleDeleteCategory(data: CategoryItem) {
try {
await ElMessageBox.confirm('确认删除该分类?', '提示', { type: 'warning' })
await deleteCategory(data.id)
ElMessage.success('删除成功')
fetchCategories()
} catch (e: any) {
if (e !== 'cancel') ElMessage.error(e?.message || '删除失败')
}
}
function handleAddRule() {
isEditRule.value = false
ruleEditId.value = null
ruleForm.template_id = templates.value[0]?.id
ruleForm.rule_name = ''
ruleForm.rule_type = 'keyword'
ruleForm.category_id = undefined
ruleForm.level_id = undefined
ruleForm.target_field = 'column_name'
ruleForm.rule_content = ''
ruleForm.priority = 100
ruleDialogVisible.value = true
}
function handleEditRule(row: RecognitionRule) {
isEditRule.value = true
ruleEditId.value = row.id
ruleForm.template_id = row.template_id
ruleForm.rule_name = row.rule_name || ''
ruleForm.rule_type = row.rule_type
ruleForm.category_id = row.category_id
ruleForm.level_id = row.level_id
ruleForm.target_field = row.target_field
ruleForm.rule_content = row.rule_content
ruleForm.priority = row.priority
ruleDialogVisible.value = true
}
async function handleSubmitRule() {
const valid = await ruleFormRef.value?.validate().catch(() => false)
if (!valid) return
try {
if (isEditRule.value && ruleEditId.value) {
await updateRule(ruleEditId.value, { ...ruleForm })
ElMessage.success('更新成功')
} else {
await createRule({ ...ruleForm })
ElMessage.success('创建成功')
}
ruleDialogVisible.value = false
fetchRules()
} catch (e: any) {
ElMessage.error(e?.message || '操作失败')
}
}
async function handleDeleteRule(row: RecognitionRule) {
try {
await ElMessageBox.confirm('确认删除该规则?', '提示', { type: 'warning' })
await deleteRule(row.id)
ElMessage.success('删除成功')
fetchRules()
} catch (e: any) {
if (e !== 'cancel') ElMessage.error(e?.message || '删除失败')
}
}
onMounted(async () => {
await fetchTemplates()
await fetchLevels()
await fetchCategories()
await fetchRules()
})
</script>
<style scoped lang="scss">
.page-title {
font-size: 20px;
font-weight: 600;
margin-bottom: 20px;
color: #303133;
}
.section {
margin-bottom: 24px;
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.section-title {
font-size: 16px;
font-weight: 600;
color: #303133;
}
}
.level-card {
padding: 16px;
margin-bottom: 12px;
background: #fff;
border-radius: 8px;
.level-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.level-name {
font-size: 15px;
font-weight: 600;
color: #303133;
}
.level-desc {
font-size: 13px;
color: #606266;
line-height: 1.5;
margin-bottom: 8px;
}
.level-ctrl {
.ctrl-item {
font-size: 12px;
color: #909399;
margin-bottom: 2px;
.ctrl-key {
font-weight: 500;
color: #606266;
}
}
}
}
.category-tree {
padding: 16px;
background: #fff;
border-radius: 8px;
.custom-tree-node {
display: flex;
align-items: center;
justify-content: space-between;
flex: 1;
overflow: hidden;
.node-label {
display: flex;
align-items: center;
gap: 8px;
overflow: hidden;
.code-tag {
font-size: 11px;
flex-shrink: 0;
}
}
.node-actions {
flex-shrink: 0;
}
}
}
.table-card {
padding: 16px;
background: #fff;
border-radius: 8px;
}
</style>
@@ -0,0 +1,185 @@
<template>
<div class="page-container">
<h2 class="page-title">分类分级结果</h2>
<div class="filter-bar card-shadow">
<el-select v-model="filterProjectId" placeholder="选择项目" clearable style="width: 180px">
<el-option v-for="p in projects" :key="p.id" :label="p.name" :value="p.id" />
</el-select>
<el-select v-model="filterLevelId" placeholder="选择分级" clearable style="width: 140px; margin-left: 12px">
<el-option v-for="l in levels" :key="l.id" :label="l.name" :value="l.id">
<el-tag size="small" :color="l.color" effect="dark">{{ l.code }}</el-tag>
{{ l.name }}
</el-option>
</el-select>
<el-input v-model="filterKeyword" placeholder="搜索字段名/注释" clearable style="width: 200px; margin-left: 12px" />
<el-button type="primary" style="margin-left: 12px" @click="handleSearch">
<el-icon><Search /></el-icon>查询
</el-button>
</div>
<div class="table-card card-shadow">
<el-table :data="resultList" v-loading="loading" stripe size="default" border>
<el-table-column prop="column_name" label="字段名" min-width="140" />
<el-table-column prop="data_type" label="类型" width="100" />
<el-table-column prop="comment" label="注释" min-width="150" show-overflow-tooltip />
<el-table-column prop="table_name" label="所属表" width="140" />
<el-table-column prop="database_name" label="所属库" width="120" />
<el-table-column prop="source_name" label="数据源" width="120" />
<el-table-column label="分类" width="140">
<template #default="{ row }">
<el-tag v-if="row.category_name" size="small" type="info">{{ row.category_name }}</el-tag>
<span v-else class="empty-text">--</span>
</template>
</el-table-column>
<el-table-column label="分级" width="100">
<template #default="{ row }">
<el-tag v-if="row.level_name" size="small" :color="row.level_color" effect="dark">
{{ row.level_name }}
</el-tag>
<span v-else class="empty-text">--</span>
</template>
</el-table-column>
<el-table-column label="来源" width="90">
<template #default="{ row }">
<el-tag size="small" :type="row.source === 'auto' ? 'warning' : 'success'">
{{ row.source === 'auto' ? '自动' : '人工' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="confidence" label="置信度" width="90">
<template #default="{ row }">
<span v-if="row.confidence > 0" :class="confidenceClass(row.confidence)">
{{ (row.confidence * 100).toFixed(0) }}%
</span>
<span v-else class="empty-text">--</span>
</template>
</el-table-column>
</el-table>
<div class="pagination-bar">
<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 { Search } from '@element-plus/icons-vue'
import { getProjects } from '@/api/project'
import { getDataLevels } from '@/api/classification'
const loading = ref(false)
const resultList = ref<any[]>([])
const page = ref(1)
const pageSize = ref(50)
const total = ref(0)
const filterProjectId = ref<number | undefined>(undefined)
const filterLevelId = ref<number | undefined>(undefined)
const filterKeyword = ref('')
const projects = ref<any[]>([])
const levels = ref<any[]>([])
function confidenceClass(v: number) {
if (v >= 0.8) return 'confidence-high'
if (v >= 0.5) return 'confidence-mid'
return 'confidence-low'
}
async function fetchData() {
loading.value = true
try {
// Use project API to get results (simplified for demo)
const params: any = { page: page.value, page_size: pageSize.value }
if (filterKeyword.value) params.keyword = filterKeyword.value
// In full implementation, call dedicated results API
const res: any = await getProjects(params)
// Mock data for demo
resultList.value = []
total.value = 0
} catch (e: any) {
ElMessage.error(e?.message || '加载失败')
} finally {
loading.value = false
}
}
function handleSearch() {
page.value = 1
fetchData()
}
async function fetchMeta() {
try {
const [pRes, lRes] = await Promise.all([getProjects({ page: 1, page_size: 100 }), getDataLevels()])
projects.value = (pRes as any)?.data || []
levels.value = (lRes as any) || []
} catch (e) {
// ignore
}
}
onMounted(() => {
fetchMeta()
fetchData()
})
</script>
<style scoped lang="scss">
.page-title {
font-size: 20px;
font-weight: 600;
margin-bottom: 16px;
color: #303133;
}
.filter-bar {
padding: 16px;
margin-bottom: 16px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.table-card {
padding: 16px;
background: #fff;
border-radius: 8px;
}
.pagination-bar {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
.empty-text {
color: #c0c4cc;
}
.confidence-high {
color: #67c23a;
font-weight: 600;
}
.confidence-mid {
color: #e6a23c;
font-weight: 600;
}
.confidence-low {
color: #f56c6c;
font-weight: 600;
}
</style>
+246
View File
@@ -0,0 +1,246 @@
<template>
<div class="page-container dashboard">
<h2 class="page-title">数据资产概览</h2>
<el-row :gutter="16" class="stat-row">
<el-col :xs="12" :sm="12" :md="6" :lg="6">
<div class="stat-card card-shadow">
<div class="stat-icon" style="background: #ecf5ff; color: #409eff;">
<el-icon size="28"><DataLine /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.dataSources }}</div>
<div class="stat-label">数据源</div>
</div>
</div>
</el-col>
<el-col :xs="12" :sm="12" :md="6" :lg="6">
<div class="stat-card card-shadow">
<div class="stat-icon" style="background: #f0f9eb; color: #67c23a;">
<el-icon size="28"><FolderOpened /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.tables }}</div>
<div class="stat-label">数据表</div>
</div>
</div>
</el-col>
<el-col :xs="12" :sm="12" :md="6" :lg="6">
<div class="stat-card card-shadow">
<div class="stat-icon" style="background: #fdf6ec; color: #e6a23c;">
<el-icon size="28"><DocumentChecked /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.labeled }}</div>
<div class="stat-label">已打标字段</div>
</div>
</div>
</el-col>
<el-col :xs="12" :sm="12" :md="6" :lg="6">
<div class="stat-card card-shadow">
<div class="stat-icon" style="background: #fef0f0; color: #f56c6c;">
<el-icon size="28"><Warning /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.sensitive }}</div>
<div class="stat-label">敏感数据(L4/L5)</div>
</div>
</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">数据分级分布</div>
<v-chart class="chart" :option="levelOption" autoresize />
</div>
</el-col>
<el-col :xs="24" :md="12">
<div class="chart-card card-shadow">
<div class="chart-title">数据分类TOP8</div>
<v-chart class="chart" :option="categoryOption" autoresize />
</div>
</el-col>
</el-row>
<el-row :gutter="16" class="chart-row">
<el-col :xs="24">
<div class="chart-card card-shadow">
<div class="chart-title">项目进度</div>
<el-table :data="projectList" stripe size="default">
<el-table-column prop="name" label="项目名称" min-width="180" />
<el-table-column prop="status" label="状态" width="120">
<template #default="{ row }">
<el-tag :type="statusType(row.status)">{{ statusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="progress" label="进度" width="200">
<template #default="{ row }">
<el-progress :percentage="row.progress" :status="row.progress === 100 ? 'success' : ''" />
</template>
</el-table-column>
<el-table-column prop="planned_end" label="计划完成时间" width="160" />
</el-table>
</div>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { PieChart, BarChart } from 'echarts/charts'
import { TooltipComponent, LegendComponent, GridComponent } from 'echarts/components'
import VChart from 'vue-echarts'
use([CanvasRenderer, PieChart, BarChart, TooltipComponent, LegendComponent, GridComponent])
const stats = reactive({
dataSources: 12,
tables: 3847,
labeled: 152340,
sensitive: 28931,
})
const levelOption = ref({
tooltip: { trigger: 'item' },
legend: { bottom: '0%', left: 'center' },
color: ['#67c23a', '#409eff', '#e6a23c', '#f56c6c', '#909399'],
series: [
{
type: 'pie',
radius: ['40%', '70%'],
avoidLabelOverlap: false,
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
label: { show: false },
data: [
{ value: 35000, name: 'L1 公开级' },
{ value: 62000, name: 'L2 内部级' },
{ value: 48000, name: 'L3 敏感级' },
{ value: 22000, name: 'L4 重要级' },
{ value: 6931, name: 'L5 核心级' },
],
},
],
})
const categoryOption = ref({
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
xAxis: { type: 'value' },
yAxis: {
type: 'category',
data: ['车辆信息', '理赔数据', '渠道数据', '财务数据', '监管报送', '内部管理', '保单数据', '客户数据'],
},
series: [
{
type: 'bar',
data: [8200, 15600, 4300, 12100, 2800, 5600, 22400, 36700],
itemStyle: { borderRadius: [0, 4, 4, 0], color: '#409eff' },
},
],
})
const projectList = ref([
{ name: '2024年数据分类分级专项', status: 'labeling', progress: 68, planned_end: '2024-08-30' },
{ name: '核心系统敏感数据梳理', status: 'reviewing', progress: 92, planned_end: '2024-07-15' },
{ name: '新核心上线数据定级', status: 'scanning', progress: 25, planned_end: '2024-09-20' },
])
function statusType(status: string) {
const map: Record<string, any> = {
created: 'info',
scanning: 'warning',
assigning: '',
labeling: 'primary',
reviewing: 'success',
accepting: 'success',
published: 'success',
}
return map[status] || 'info'
}
function statusText(status: string) {
const map: Record<string, string> = {
created: '已创建',
scanning: '扫描中',
assigning: '分配中',
labeling: '打标中',
reviewing: '审核中',
accepting: '验收中',
published: '已发布',
}
return map[status] || status
}
</script>
<style scoped lang="scss">
.dashboard {
.page-title {
font-size: 20px;
font-weight: 600;
margin-bottom: 20px;
color: #303133;
}
}
.stat-row {
margin-bottom: 16px;
}
.stat-card {
display: flex;
align-items: center;
padding: 20px;
margin-bottom: 16px;
.stat-icon {
width: 56px;
height: 56px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 16px;
flex-shrink: 0;
}
.stat-info {
.stat-value {
font-size: 24px;
font-weight: 700;
color: #303133;
line-height: 1.2;
}
.stat-label {
font-size: 13px;
color: #909399;
margin-top: 4px;
}
}
}
.chart-row {
margin-bottom: 16px;
}
.chart-card {
padding: 20px;
margin-bottom: 16px;
.chart-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 16px;
color: #303133;
}
.chart {
width: 100%;
height: 300px;
}
}
</style>
@@ -0,0 +1,298 @@
<template>
<div class="page-container">
<div class="page-header">
<h2 class="page-title">数据源管理</h2>
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>新增数据源
</el-button>
</div>
<div class="search-bar card-shadow">
<el-input
v-model="keyword"
placeholder="搜索数据源名称/主机"
clearable
style="width: 260px"
@keyup.enter="handleSearch"
>
<template #append>
<el-button @click="handleSearch">
<el-icon><Search /></el-icon>
</el-button>
</template>
</el-input>
</div>
<div class="table-card card-shadow">
<el-table :data="tableData" v-loading="loading" stripe size="default">
<el-table-column prop="name" label="名称" min-width="140" />
<el-table-column prop="source_type" label="类型" width="100">
<template #default="{ row }">
<el-tag size="small">{{ typeText(row.source_type) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="host" label="主机" min-width="140" />
<el-table-column prop="port" label="端口" width="80" />
<el-table-column prop="database_name" label="数据库" min-width="120" />
<el-table-column prop="username" label="用户名" min-width="100" />
<el-table-column prop="status" label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.status === 'active' ? 'success' : 'danger'" size="small">
{{ row.status === 'active' ? '正常' : '异常' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="260" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="handleEdit(row)">编辑</el-button>
<el-button type="primary" link size="small" @click="handleSync(row)">同步元数据</el-button>
<el-button type="danger" link size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-bar">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
layout="total, prev, pager, next"
@change="fetchData"
/>
</div>
</div>
<!-- Add/Edit Dialog -->
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑数据源' : '新增数据源'"
width="560px"
destroy-on-close
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="名称" prop="name">
<el-input v-model="form.name" placeholder="请输入数据源名称" />
</el-form-item>
<el-form-item label="类型" prop="source_type">
<el-select v-model="form.source_type" placeholder="选择数据库类型" style="width: 100%">
<el-option label="PostgreSQL" value="postgresql" />
<el-option label="MySQL" value="mysql" />
<el-option label="Oracle" value="oracle" />
<el-option label="SQL Server" value="sqlserver" />
<el-option label="达梦(DM)" value="dm" />
</el-select>
</el-form-item>
<el-form-item label="主机" prop="host">
<el-input v-model="form.host" placeholder="IP或域名" />
</el-form-item>
<el-form-item label="端口" prop="port">
<el-input-number v-model="form.port" :min="1" :max="65535" style="width: 100%" />
</el-form-item>
<el-form-item label="数据库" prop="database_name">
<el-input v-model="form.database_name" placeholder="数据库名称" />
</el-form-item>
<el-form-item label="用户名" prop="username">
<el-input v-model="form.username" placeholder="连接用户名" />
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input v-model="form.password" type="password" placeholder="连接密码" show-password />
</el-form-item>
<el-form-item label="扩展参数">
<el-input v-model="form.extra_params" type="textarea" :rows="2" placeholder="JSON格式额外连接参数" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button @click="handleTest">连接测试</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</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, Search } from '@element-plus/icons-vue'
import { getDataSources, createDataSource, updateDataSource, deleteDataSource, testConnection, syncMetadata } from '@/api/datasource'
import type { DataSourceItem, DataSourceForm } from '@/api/datasource'
const loading = ref(false)
const tableData = ref<DataSourceItem[]>([])
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const keyword = ref('')
const dialogVisible = ref(false)
const isEdit = ref(false)
const editId = ref<number | null>(null)
const submitLoading = ref(false)
const formRef = ref()
const form = reactive<DataSourceForm>({
name: '',
source_type: 'postgresql',
host: '',
port: 5432,
database_name: '',
username: '',
password: '',
extra_params: '',
})
const rules = {
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
source_type: [{ required: true, message: '请选择类型', trigger: 'change' }],
}
function typeText(type: string) {
const map: Record<string, string> = {
postgresql: 'PostgreSQL',
mysql: 'MySQL',
oracle: 'Oracle',
sqlserver: 'SQL Server',
dm: '达梦',
}
return map[type] || type
}
async function fetchData() {
loading.value = true
try {
const res: any = await getDataSources({
page: page.value,
page_size: pageSize.value,
keyword: keyword.value || undefined,
})
tableData.value = res.data || []
total.value = res.total || 0
} finally {
loading.value = false
}
}
function handleSearch() {
page.value = 1
fetchData()
}
function handleAdd() {
isEdit.value = false
editId.value = null
form.name = ''
form.source_type = 'postgresql'
form.host = ''
form.port = 5432
form.database_name = ''
form.username = ''
form.password = ''
form.extra_params = ''
dialogVisible.value = true
}
function handleEdit(row: DataSourceItem) {
isEdit.value = true
editId.value = row.id
form.name = row.name
form.source_type = row.source_type
form.host = row.host || ''
form.port = row.port || 5432
form.database_name = row.database_name || ''
form.username = row.username || ''
form.password = ''
dialogVisible.value = true
}
async function handleTest() {
try {
const res: any = await testConnection({ ...form })
if (res.success) {
ElMessage.success(res.message)
} else {
ElMessage.error(res.message)
}
} catch (e: any) {
ElMessage.error(e?.message || '测试失败')
}
}
async function handleSubmit() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
submitLoading.value = true
try {
if (isEdit.value && editId.value) {
await updateDataSource(editId.value, { ...form })
ElMessage.success('更新成功')
} else {
await createDataSource({ ...form })
ElMessage.success('创建成功')
}
dialogVisible.value = false
fetchData()
} catch (e: any) {
ElMessage.error(e?.message || '操作失败')
} finally {
submitLoading.value = false
}
}
async function handleDelete(row: DataSourceItem) {
try {
await ElMessageBox.confirm('确认删除该数据源?相关元数据也将被删除', '提示', { type: 'warning' })
await deleteDataSource(row.id)
ElMessage.success('删除成功')
fetchData()
} catch (e: any) {
if (e !== 'cancel') ElMessage.error(e?.message || '删除失败')
}
}
async function handleSync(row: DataSourceItem) {
try {
await ElMessageBox.confirm('确认同步该数据源的元数据?', '提示', { type: 'info' })
const res: any = await syncMetadata(row.id)
if (res.success) {
ElMessage.success(res.message)
} else {
ElMessage.error(res.message)
}
} catch (e: any) {
if (e !== 'cancel') ElMessage.error(e?.message || '同步失败')
}
}
onMounted(fetchData)
</script>
<style scoped lang="scss">
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
.page-title {
font-size: 20px;
font-weight: 600;
color: #303133;
}
}
.search-bar {
padding: 16px;
margin-bottom: 16px;
}
.table-card {
padding: 16px;
}
.pagination-bar {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
</style>
+248
View File
@@ -0,0 +1,248 @@
<template>
<div class="page-container metadata-page">
<h2 class="page-title">数据资产</h2>
<el-row :gutter="16" class="content-row">
<!-- Tree -->
<el-col :xs="24" :md="7" :lg="6">
<div class="tree-card card-shadow">
<div class="tree-header">资产目录</div>
<el-input
v-model="treeKeyword"
placeholder="搜索表名"
clearable
size="small"
class="tree-search"
/>
<el-tree
:data="treeData"
:props="{ children: 'children', label: 'name' }"
:filter-node-method="filterNode"
ref="treeRef"
highlight-current
default-expand-all
@node-click="handleNodeClick"
>
<template #default="{ node, data }">
<span class="custom-tree-node">
<el-icon v-if="data.type === 'source'" size="14"><DataLine /></el-icon>
<el-icon v-else-if="data.type === 'database'" size="14"><FolderOpened /></el-icon>
<el-icon v-else size="14"><Document /></el-icon>
<span class="node-label">{{ node.label }}</span>
<span v-if="data.type === 'table' && data.meta?.column_count" class="node-badge">
{{ data.meta.column_count }}
</span>
</span>
</template>
</el-tree>
</div>
</el-col>
<!-- Table Detail -->
<el-col :xs="24" :md="17" :lg="18">
<div class="detail-card card-shadow">
<div class="detail-header">
<div class="detail-title">
<span v-if="selectedTable">{{ selectedTable.name }}</span>
<span v-else class="placeholder">请从左侧选择数据表</span>
<el-tag v-if="selectedTable?.meta?.row_count" size="small" type="info">
{{ selectedTable.meta.row_count.toLocaleString() }}
</el-tag>
</div>
<el-input
v-if="selectedTable"
v-model="columnKeyword"
placeholder="搜索字段"
clearable
size="small"
style="width: 200px"
/>
</div>
<el-table
v-if="selectedTable"
:data="columnList"
v-loading="columnLoading"
stripe
size="default"
height="calc(100vh - 280px)"
>
<el-table-column prop="name" label="字段名" min-width="140" />
<el-table-column prop="data_type" label="类型" width="120" />
<el-table-column prop="comment" label="注释" min-width="180" show-overflow-tooltip />
<el-table-column prop="is_nullable" label="可空" width="80">
<template #default="{ row }">
<el-tag size="small" :type="row.is_nullable ? 'info' : 'success'">
{{ row.is_nullable ? '是' : '否' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="sample_data" label="采样数据" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<span class="sample-text">{{ row.sample_data }}</span>
</template>
</el-table-column>
</el-table>
<el-empty v-else description="请选择左侧数据表查看字段详情" />
</div>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { DataLine, FolderOpened, Document } from '@element-plus/icons-vue'
import { getMetadataTree, getColumns } from '@/api/metadata'
import type { TreeNode, DataColumn } from '@/api/metadata'
const treeData = ref<TreeNode[]>([])
const treeKeyword = ref('')
const treeRef = ref()
const selectedTable = ref<TreeNode | null>(null)
const columnList = ref<DataColumn[]>([])
const columnLoading = ref(false)
const columnKeyword = ref('')
watch(treeKeyword, (val) => {
treeRef.value?.filter(val)
})
function filterNode(value: string, data: TreeNode) {
if (!value) return true
return data.name.toLowerCase().includes(value.toLowerCase())
}
async function fetchTree() {
try {
const res: any = await getMetadataTree()
treeData.value = res || []
} catch (e: any) {
ElMessage.error(e?.message || '加载失败')
}
}
async function handleNodeClick(data: TreeNode) {
if (data.type === 'table') {
selectedTable.value = data
await fetchColumns(data.id)
}
}
async function fetchColumns(tableId: number) {
columnLoading.value = true
try {
const res: any = await getColumns({
table_id: tableId,
keyword: columnKeyword.value || undefined,
page: 1,
page_size: 500,
})
columnList.value = res.data || []
} finally {
columnLoading.value = false
}
}
watch(columnKeyword, () => {
if (selectedTable.value) {
fetchColumns(selectedTable.value.id)
}
})
onMounted(fetchTree)
</script>
<style scoped lang="scss">
.metadata-page {
.page-title {
font-size: 20px;
font-weight: 600;
margin-bottom: 16px;
color: #303133;
}
}
.content-row {
.el-col {
margin-bottom: 16px;
}
}
.tree-card {
padding: 16px;
height: calc(100vh - 140px);
overflow-y: auto;
.tree-header {
font-size: 16px;
font-weight: 600;
margin-bottom: 12px;
color: #303133;
}
.tree-search {
margin-bottom: 12px;
}
}
.custom-tree-node {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
overflow: hidden;
.node-label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.node-badge {
font-size: 11px;
color: #909399;
background: #f2f6fc;
padding: 0 6px;
border-radius: 10px;
}
}
.detail-card {
padding: 16px;
height: calc(100vh - 140px);
display: flex;
flex-direction: column;
.detail-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
flex-wrap: wrap;
gap: 12px;
.detail-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 16px;
font-weight: 600;
color: #303133;
.placeholder {
color: #909399;
font-weight: normal;
}
}
}
}
.sample-text {
color: #909399;
font-size: 12px;
}
</style>
+318
View File
@@ -0,0 +1,318 @@
<template>
<div class="page-container">
<div class="page-header">
<h2 class="page-title">项目管理</h2>
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>新建项目
</el-button>
</div>
<div class="search-bar card-shadow">
<el-input
v-model="keyword"
placeholder="搜索项目名称"
clearable
style="width: 260px"
@keyup.enter="handleSearch"
>
<template #append>
<el-button @click="handleSearch">
<el-icon><Search /></el-icon>
</el-button>
</template>
</el-input>
</div>
<div class="project-list">
<el-row :gutter="16">
<el-col v-for="p in projectList" :key="p.id" :xs="24" :sm="12" :lg="8">
<div class="project-card card-shadow">
<div class="project-header">
<div class="project-name">{{ p.name }}</div>
<el-tag :type="statusType(p.status)" size="small">{{ statusText(p.status) }}</el-tag>
</div>
<div class="project-desc">{{ p.description || '暂无描述' }}</div>
<div class="project-stats">
<div class="stat-item">
<div class="stat-num">{{ p.stats?.total || 0 }}</div>
<div class="stat-label">总字段</div>
</div>
<div class="stat-item">
<div class="stat-num">{{ p.stats?.auto || 0 }}</div>
<div class="stat-label">自动</div>
</div>
<div class="stat-item">
<div class="stat-num">{{ p.stats?.manual || 0 }}</div>
<div class="stat-label">人工</div>
</div>
<div class="stat-item">
<div class="stat-num">{{ p.stats?.reviewed || 0 }}</div>
<div class="stat-label">已审</div>
</div>
</div>
<div class="project-actions">
<el-button type="primary" size="small" @click="handleAutoClassify(p)">自动分类</el-button>
<el-button type="danger" link size="small" @click="handleDelete(p)">删除</el-button>
</div>
</div>
</el-col>
</el-row>
</div>
<!-- Add Dialog -->
<el-dialog v-model="dialogVisible" title="新建项目" width="520px" destroy-on-close>
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="项目名称" prop="name">
<el-input v-model="form.name" placeholder="请输入项目名称" />
</el-form-item>
<el-form-item label="模板" prop="template_id">
<el-select v-model="form.template_id" placeholder="选择分类分级模板" style="width: 100%">
<el-option v-for="t in templates" :key="t.id" :label="t.name" :value="t.id" />
</el-select>
</el-form-item>
<el-form-item label="数据源范围">
<el-select v-model="form.target_source_ids" multiple placeholder="选择数据源(不选则为全部)" style="width: 100%">
<el-option v-for="s in dataSources" :key="s.id" :label="s.name" :value="String(s.id)" />
</el-select>
</el-form-item>
<el-form-item label="描述">
<el-input v-model="form.description" type="textarea" :rows="2" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</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, Search } from '@element-plus/icons-vue'
import { getProjects, createProject, deleteProject, autoClassifyProject } from '@/api/project'
import { getTemplates } from '@/api/classification'
import { getDataSources } from '@/api/datasource'
import type { ProjectItem } from '@/api/project'
const loading = ref(false)
const projectList = ref<ProjectItem[]>([])
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const keyword = ref('')
const dialogVisible = ref(false)
const submitLoading = ref(false)
const formRef = ref()
const form = reactive({
name: '',
template_id: undefined as number | undefined,
target_source_ids: [] as string[],
description: '',
})
const rules = {
name: [{ required: true, message: '请输入项目名称', trigger: 'blur' }],
template_id: [{ required: true, message: '请选择模板', trigger: 'change' }],
}
const templates = ref<any[]>([])
const dataSources = ref<any[]>([])
function statusType(status: string) {
const map: Record<string, any> = {
created: 'info',
scanning: 'warning',
assigning: '',
labeling: 'primary',
reviewing: 'success',
accepting: 'success',
published: 'success',
}
return map[status] || 'info'
}
function statusText(status: string) {
const map: Record<string, string> = {
created: '已创建',
scanning: '扫描中',
assigning: '分配中',
labeling: '打标中',
reviewing: '审核中',
accepting: '验收中',
published: '已发布',
}
return map[status] || status
}
async function fetchData() {
loading.value = true
try {
const res: any = await getProjects({ page: page.value, page_size: pageSize.value, keyword: keyword.value || undefined })
projectList.value = res.data || []
total.value = res.total || 0
} finally {
loading.value = false
}
}
function handleSearch() {
page.value = 1
fetchData()
}
function handleAdd() {
form.name = ''
form.template_id = templates.value[0]?.id
form.target_source_ids = []
form.description = ''
dialogVisible.value = true
}
async function handleSubmit() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
submitLoading.value = true
try {
await createProject({
name: form.name,
template_id: form.template_id!,
target_source_ids: form.target_source_ids.join(','),
description: form.description,
})
ElMessage.success('创建成功')
dialogVisible.value = false
fetchData()
} catch (e: any) {
ElMessage.error(e?.message || '创建失败')
} finally {
submitLoading.value = false
}
}
async function handleAutoClassify(p: ProjectItem) {
try {
const res: any = await autoClassifyProject(p.id)
ElMessage.success(res.message || '自动分类完成')
fetchData()
} catch (e: any) {
ElMessage.error(e?.message || '自动分类失败')
}
}
async function handleDelete(p: ProjectItem) {
try {
await ElMessageBox.confirm('确认删除该项目?', '提示', { type: 'warning' })
await deleteProject(p.id)
ElMessage.success('删除成功')
fetchData()
} catch (e: any) {
if (e !== 'cancel') ElMessage.error(e?.message || '删除失败')
}
}
async function fetchMeta() {
try {
const [tRes, sRes] = await Promise.all([getTemplates(), getDataSources()])
templates.value = (tRes as any) || []
dataSources.value = (sRes as any)?.data || []
} catch (e) {
// ignore
}
}
onMounted(() => {
fetchData()
fetchMeta()
})
</script>
<style scoped lang="scss">
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
.page-title {
font-size: 20px;
font-weight: 600;
color: #303133;
}
}
.search-bar {
padding: 16px;
margin-bottom: 16px;
}
.project-list {
.el-col {
margin-bottom: 16px;
}
}
.project-card {
padding: 20px;
background: #fff;
border-radius: 8px;
transition: box-shadow 0.2s;
&:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
}
.project-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
.project-name {
font-size: 16px;
font-weight: 600;
color: #303133;
}
}
.project-desc {
font-size: 13px;
color: #909399;
margin-bottom: 16px;
min-height: 20px;
}
.project-stats {
display: flex;
gap: 16px;
margin-bottom: 16px;
padding: 12px 0;
border-top: 1px solid #f0f0f0;
border-bottom: 1px solid #f0f0f0;
.stat-item {
text-align: center;
flex: 1;
.stat-num {
font-size: 18px;
font-weight: 700;
color: #303133;
}
.stat-label {
font-size: 12px;
color: #909399;
margin-top: 2px;
}
}
}
.project-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
}
</style>
+149
View File
@@ -0,0 +1,149 @@
<template>
<div class="page-container">
<h2 class="page-title">报表统计</h2>
<el-row :gutter="16" class="chart-row">
<el-col :xs="24" :md="12">
<div class="chart-card card-shadow">
<div class="chart-title">数据分级分布</div>
<v-chart class="chart" :option="levelOption" autoresize />
</div>
</el-col>
<el-col :xs="24" :md="12">
<div class="chart-card card-shadow">
<div class="chart-title">项目进度</div>
<v-chart class="chart" :option="projectOption" autoresize />
</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">识别来源占比</div>
<v-chart class="chart" :option="sourceOption" autoresize />
</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="trendOption" autoresize />
</div>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { PieChart, BarChart, LineChart } from 'echarts/charts'
import { TooltipComponent, LegendComponent, GridComponent } from 'echarts/components'
import VChart from 'vue-echarts'
use([CanvasRenderer, PieChart, BarChart, LineChart, TooltipComponent, LegendComponent, GridComponent])
const levelOption = ref({
tooltip: { trigger: 'item' },
legend: { bottom: '0%', left: 'center' },
color: ['#67c23a', '#409eff', '#e6a23c', '#f56c6c', '#909399'],
series: [
{
type: 'pie',
radius: ['40%', '70%'],
avoidLabelOverlap: false,
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
label: { show: false },
data: [
{ value: 35000, name: 'L1 公开级' },
{ value: 62000, name: 'L2 内部级' },
{ value: 48000, name: 'L3 敏感级' },
{ value: 22000, name: 'L4 重要级' },
{ value: 6931, name: 'L5 核心级' },
],
},
],
})
const projectOption = ref({
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
xAxis: { type: 'category', data: ['项目A', '项目B', '项目C', '项目D'] },
yAxis: { type: 'value', max: 100 },
series: [
{
type: 'bar',
data: [68, 92, 25, 45],
itemStyle: { borderRadius: [4, 4, 0, 0], color: '#409eff' },
label: { show: true, position: 'top', formatter: '{c}%' },
},
],
})
const sourceOption = ref({
tooltip: { trigger: 'item' },
legend: { bottom: '0%', left: 'center' },
color: ['#e6a23c', '#67c23a'],
series: [
{
type: 'pie',
radius: ['40%', '70%'],
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
label: { show: false },
data: [
{ value: 124500, name: '自动识别' },
{ value: 27840, name: '人工打标' },
],
},
],
})
const trendOption = ref({
tooltip: { trigger: 'axis' },
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
xAxis: { type: 'category', data: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'] },
yAxis: { type: 'value' },
series: [
{
type: 'line',
data: [120, 132, 101, 134, 90, 230, 210],
smooth: true,
itemStyle: { color: '#f56c6c' },
areaStyle: { color: 'rgba(245,108,108,0.1)' },
},
],
})
</script>
<style scoped lang="scss">
.page-title {
font-size: 20px;
font-weight: 600;
margin-bottom: 20px;
color: #303133;
}
.chart-row {
margin-bottom: 16px;
}
.chart-card {
padding: 20px;
margin-bottom: 16px;
background: #fff;
border-radius: 8px;
.chart-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 16px;
color: #303133;
}
.chart {
width: 100%;
height: 300px;
}
}
</style>
+102
View File
@@ -0,0 +1,102 @@
<template>
<div class="page-container">
<h2 class="page-title">系统管理</h2>
<el-tabs v-model="activeTab" class="system-tabs">
<el-tab-pane label="用户管理" name="users">
<div class="table-card card-shadow">
<div class="table-header">
<el-input v-model="userKeyword" placeholder="搜索用户" clearable style="width: 220px" />
<el-button type="primary" size="small" @click="fetchUsers">
<el-icon><Search /></el-icon>查询
</el-button>
</div>
<el-table :data="userList" v-loading="userLoading" stripe size="default">
<el-table-column prop="username" label="用户名" min-width="120" />
<el-table-column prop="real_name" label="真实姓名" min-width="120" />
<el-table-column prop="email" label="邮箱" min-width="160" />
<el-table-column prop="dept.name" label="部门" min-width="120">
<template #default="{ row }">
{{ row.dept?.name || '--' }}
</template>
</el-table-column>
<el-table-column prop="roles" label="角色" min-width="180">
<template #default="{ row }">
<el-tag v-for="r in row.roles" :key="r.id" size="small" style="margin-right: 4px">
{{ r.name }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="is_active" label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.is_active ? 'success' : 'danger'" size="small">
{{ row.is_active ? '正常' : '禁用' }}
</el-tag>
</template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
<el-tab-pane label="操作日志" name="logs">
<div class="table-card card-shadow">
<el-empty description="日志功能开发中" />
</div>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Search } from '@element-plus/icons-vue'
const activeTab = ref('users')
const userList = ref<any[]>([])
const userLoading = ref(false)
const userKeyword = ref('')
async function fetchUsers() {
userLoading.value = true
try {
const res = await fetch(`/api/v1/users?keyword=${encodeURIComponent(userKeyword.value)}`, {
headers: { Authorization: `Bearer ${localStorage.getItem('pdg_token') || ''}` },
})
const data = await res.json()
userList.value = data.data || []
} catch (e: any) {
ElMessage.error(e?.message || '加载失败')
} finally {
userLoading.value = false
}
}
onMounted(fetchUsers)
</script>
<style scoped lang="scss">
.page-title {
font-size: 20px;
font-weight: 600;
margin-bottom: 16px;
color: #303133;
}
.system-tabs {
background: #fff;
padding: 16px;
border-radius: 8px;
}
.table-card {
padding: 16px;
.table-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
}
</style>
+239
View File
@@ -0,0 +1,239 @@
<template>
<div class="page-container">
<h2 class="page-title">我的任务</h2>
<el-tabs v-model="activeTab" class="task-tabs">
<el-tab-pane label="待处理" name="pending">
<TaskTable :tasks="pendingTasks" @refresh="fetchData" />
</el-tab-pane>
<el-tab-pane label="进行中" name="in_progress">
<TaskTable :tasks="inProgressTasks" @refresh="fetchData" />
</el-tab-pane>
<el-tab-pane label="已完成" name="completed">
<TaskTable :tasks="completedTasks" @refresh="fetchData" />
</el-tab-pane>
</el-tabs>
<!-- Label Dialog -->
<el-dialog
v-model="labelDialogVisible"
title="数据打标"
width="90%"
top="5vh"
destroy-on-close
class="label-dialog"
>
<div class="label-header">
<span> {{ labelItems.length }} 个字段</span>
<el-input v-model="labelKeyword" placeholder="搜索字段" clearable size="small" style="width: 200px" />
</div>
<el-table :data="filteredLabelItems" height="60vh" stripe size="default" border>
<el-table-column prop="column_name" label="字段名" width="150" />
<el-table-column prop="data_type" label="类型" width="100" />
<el-table-column prop="comment" label="注释" min-width="150" show-overflow-tooltip />
<el-table-column prop="table_name" label="所属表" width="140" />
<el-table-column prop="source_name" label="数据源" width="120" />
<el-table-column label="当前分类" width="140">
<template #default="{ row }">
<el-tag v-if="row.category_name" size="small">{{ row.category_name }}</el-tag>
<span v-else class="empty-text">--</span>
</template>
</el-table-column>
<el-table-column label="当前分级" width="100">
<template #default="{ row }">
<el-tag v-if="row.level_name" size="small" :color="row.level_color" effect="dark">{{ row.level_name }}</el-tag>
<span v-else class="empty-text">--</span>
</template>
</el-table-column>
<el-table-column label="来源" width="90">
<template #default="{ row }">
<el-tag size="small" :type="row.source === 'auto' ? 'warning' : 'success'">
{{ row.source === 'auto' ? '自动' : '人工' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-select
v-model="row._category_id"
placeholder="分类"
size="small"
style="width: 90px"
class="inline-select"
>
<el-option v-for="c in flatCategories" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
<el-select
v-model="row._level_id"
placeholder="分级"
size="small"
style="width: 70px; margin-left: 4px"
class="inline-select"
>
<el-option v-for="l in levels" :key="l.id" :label="l.code" :value="l.id" />
</el-select>
</template>
</el-table-column>
</el-table>
<template #footer>
<el-button @click="labelDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleBatchSave">批量保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, h } from 'vue'
import { ElMessage, ElButton } from 'element-plus'
import { getMyTasks, getTaskItems } from '@/api/task'
import { getCategoryTree, getDataLevels } from '@/api/classification'
import type { TaskItem, TaskResultItem } from '@/api/task'
const activeTab = ref('pending')
const tasks = ref<TaskItem[]>([])
const pendingTasks = computed(() => tasks.value.filter((t) => t.status === 'pending'))
const inProgressTasks = computed(() => tasks.value.filter((t) => t.status === 'in_progress'))
const completedTasks = computed(() => tasks.value.filter((t) => t.status === 'completed'))
const labelDialogVisible = ref(false)
const labelItems = ref<(TaskResultItem & { _category_id?: number; _level_id?: number })[]>([])
const labelKeyword = ref('')
const levels = ref<any[]>([])
const flatCategories = ref<any[]>([])
const filteredLabelItems = computed(() => {
if (!labelKeyword.value) return labelItems.value
const kw = labelKeyword.value.toLowerCase()
return labelItems.value.filter(
(i) =>
i.column_name?.toLowerCase().includes(kw) ||
i.comment?.toLowerCase().includes(kw) ||
i.table_name?.toLowerCase().includes(kw)
)
})
async function fetchData() {
try {
const res: any = await getMyTasks()
tasks.value = res || []
} catch (e: any) {
ElMessage.error(e?.message || '加载失败')
}
}
async function fetchMeta() {
try {
const [catRes, levelRes] = await Promise.all([getCategoryTree(), getDataLevels()])
levels.value = (levelRes as any) || []
flatCategories.value = flattenCategories((catRes as any) || [])
} catch (e) {
// ignore
}
}
function flattenCategories(tree: any[]): any[] {
const result: any[] = []
function walk(nodes: any[]) {
for (const n of nodes) {
result.push({ id: n.id, name: n.name })
if (n.children?.length) walk(n.children)
}
}
walk(tree)
return result
}
async function openLabel(task: TaskItem) {
try {
const res: any = await getTaskItems(task.id)
labelItems.value = (res || []).map((item: any) => ({
...item,
_category_id: item.category_id,
_level_id: item.level_id,
}))
labelDialogVisible.value = true
} catch (e: any) {
ElMessage.error(e?.message || '加载失败')
}
}
async function handleBatchSave() {
ElMessage.success('保存成功(演示模式)')
labelDialogVisible.value = false
fetchData()
}
// TaskTable sub-component
const TaskTable = {
props: ['tasks'],
emits: ['refresh'],
setup(props: any, { emit }: any) {
return () =>
h(
'div',
{ class: 'task-table-wrapper' },
props.tasks.length === 0
? h('el-empty', { description: '暂无任务' })
: h(
'el-table',
{ data: props.tasks, stripe: true, size: 'default' },
{
default: () => [
h('el-table-column', { prop: 'name', label: '任务名称', minWidth: '180' }),
h('el-table-column', { prop: 'status', label: '状态', width: '100' }, {
default: ({ row }: any) =>
h('el-tag', { size: 'small', type: row.status === 'pending' ? 'warning' : row.status === 'completed' ? 'success' : 'primary' },
row.status === 'pending' ? '待处理' : row.status === 'in_progress' ? '进行中' : '已完成'
),
}),
h('el-table-column', { prop: 'deadline', label: '截止时间', width: '160' }),
h('el-table-column', { label: '操作', width: '120', fixed: 'right' }, {
default: ({ row }: any) =>
h(ElButton, { type: 'primary', link: true, size: 'small', onClick: () => openLabel(row) }, () => '去打标'),
}),
],
}
)
)
},
}
onMounted(() => {
fetchData()
fetchMeta()
})
</script>
<style scoped lang="scss">
.page-title {
font-size: 20px;
font-weight: 600;
margin-bottom: 16px;
color: #303133;
}
.task-tabs {
background: #fff;
padding: 16px;
border-radius: 8px;
}
.label-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.empty-text {
color: #c0c4cc;
}
.inline-select {
:deep(.el-input__wrapper) {
padding: 0 4px;
}
}
</style>