数据同步

This commit is contained in:
乘风
2026-01-05 10:19:51 +08:00
parent ba0ebcf273
commit 408c6a2029
206 changed files with 52458 additions and 0 deletions

View File

@@ -0,0 +1,30 @@
<template>
<router-view />
</template>
<script setup lang="ts">
// App 根组件
import { onMounted } from 'vue'
import { useUserStore } from '@/store'
const userStore = useUserStore()
onMounted(() => {
// 初始化 token
userStore.initToken()
})
</script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
#app {
width: 100%;
min-height: 100vh;
}
</style>

View File

@@ -0,0 +1,115 @@
import { request } from '@/utils/request'
import type { ApiResponse } from '@/types/api'
import type { DataCollectionTask, DataSource } from '@/types'
// 获取数据采集任务列表
export const getDataCollectionTaskList = (params: {
name?: string
status?: string
page?: number
page_size?: number
}) => {
return request.get<{
tasks: DataCollectionTask[]
total: number
page: number
page_size: number
}>('/data-collection-tasks', params)
}
// 获取数据采集任务详情
export const getDataCollectionTaskDetail = (taskId: string) => {
return request.get<DataCollectionTask>(`/data-collection-tasks/${taskId}`)
}
// 创建数据采集任务
export const createDataCollectionTask = (data: Partial<DataCollectionTask>) => {
return request.post<DataCollectionTask>('/data-collection-tasks', data)
}
// 更新数据采集任务
export const updateDataCollectionTask = (taskId: string, data: Partial<DataCollectionTask>) => {
return request.put<DataCollectionTask>(`/data-collection-tasks/${taskId}`, data)
}
// 删除数据采集任务
export const deleteDataCollectionTask = (taskId: string) => {
return request.delete(`/data-collection-tasks/${taskId}`)
}
// 启动数据采集任务
export const startDataCollectionTask = (taskId: string) => {
return request.post(`/data-collection-tasks/${taskId}/start`)
}
// 暂停数据采集任务
export const pauseDataCollectionTask = (taskId: string) => {
return request.post(`/data-collection-tasks/${taskId}/pause`)
}
// 停止数据采集任务
export const stopDataCollectionTask = (taskId: string) => {
return request.post(`/data-collection-tasks/${taskId}/stop`)
}
// 获取任务进度
export const getDataCollectionTaskProgress = (taskId: string) => {
return request.get(`/data-collection-tasks/${taskId}/progress`)
}
// 获取数据源列表
export const getDataSources = () => {
return request.get<DataSource[]>('/data-collection-tasks/data-sources')
}
// 获取数据库列表
export const getDatabases = (dataSourceId: string) => {
return request.get<Array<{ name: string; id: string }>>(`/data-collection-tasks/data-sources/${dataSourceId}/databases`)
}
// 获取集合列表
export const getCollections = (dataSourceId: string, database: string | { name: string; id: string }) => {
// 如果传入的是对象包含id使用id否则使用字符串向后兼容
const dbIdentifier = typeof database === 'object' ? database.id : database
return request.get<Array<{ name: string; id: string }>>(
`/data-collection-tasks/data-sources/${dataSourceId}/databases/${dbIdentifier}/collections`
)
}
// 获取字段列表
export const getFields = (dataSourceId: string, database: string | { name: string; id: string }, collection: string | { name: string; id: string }) => {
// 如果传入的是对象包含id使用id否则使用字符串向后兼容
const dbIdentifier = typeof database === 'object' ? database.id : database
const collIdentifier = typeof collection === 'object' ? collection.id : collection
return request.get<Array<{ name: string; type: string }>>(
`/data-collection-tasks/data-sources/${dataSourceId}/databases/${dbIdentifier}/collections/${collIdentifier}/fields`
)
}
// 获取Handler的目标字段列表
export const getHandlerTargetFields = (handlerType: string) => {
return request.get<Array<{
name: string
label: string
type: string
required: boolean
description?: string
}>>(`/data-collection-tasks/handlers/${handlerType}/target-fields`)
}
// 预览查询结果
export const previewQuery = (data: {
data_source_id: string
database: string // 这里使用原始名称,因为后端会解码
collection: string // 这里使用原始名称,因为后端会解码
lookups?: any[]
filter_conditions?: any[]
limit?: number
}) => {
return request.post<{
fields: Array<{ name: string; type: string }>
data: Array<any>
count: number
}>('/data-collection-tasks/preview-query', data)
}

View File

@@ -0,0 +1,72 @@
import { request } from '@/utils/request'
import type { ApiResponse } from '@/types/api'
// 数据源类型
export interface DataSource {
data_source_id: string
name: string
type: 'mongodb' | 'mysql' | 'postgresql'
host: string
port: number
database: string
username?: string
password?: string
auth_source?: string
options?: Record<string, any>
description?: string
is_tag_engine?: boolean // 是否为标签引擎数据库ckb数据库
status: number // 1:启用, 0:禁用
created_at?: string
updated_at?: string
}
// 获取数据源列表
export const getDataSourceList = (params?: {
type?: string
status?: number
name?: string
page?: number
page_size?: number
}) => {
return request.get<{
data_sources: DataSource[]
total: number
page: number
page_size: number
}>('/data-sources', params)
}
// 获取数据源详情
export const getDataSourceDetail = (dataSourceId: string) => {
return request.get<DataSource>(`/data-sources/${dataSourceId}`)
}
// 创建数据源
export const createDataSource = (data: Partial<DataSource>) => {
return request.post<DataSource>('/data-sources', data)
}
// 更新数据源
export const updateDataSource = (dataSourceId: string, data: Partial<DataSource>) => {
return request.put<DataSource>(`/data-sources/${dataSourceId}`, data)
}
// 删除数据源
export const deleteDataSource = (dataSourceId: string) => {
return request.delete(`/data-sources/${dataSourceId}`)
}
// 测试数据源连接
export const testDataSourceConnection = (data: {
type: string
host: string
port: number
database: string
username?: string
password?: string
auth_source?: string
options?: Record<string, any>
}) => {
return request.post<{ connected: boolean }>('/data-sources/test-connection', data)
}

View File

@@ -0,0 +1,35 @@
import { request } from '@/utils/request'
import type { ApiResponse, PageParams, PageResponse } from '@/types/api'
// 示例:用户相关 API
export interface User {
id: number
name: string
email: string
avatar?: string
}
// 获取用户列表
export const getUserList = (params: PageParams) => {
return request.get<PageResponse<User>>('/users', params)
}
// 获取用户详情
export const getUserDetail = (id: number) => {
return request.get<User>(`/users/${id}`)
}
// 创建用户
export const createUser = (data: Omit<User, 'id'>) => {
return request.post<User>('/users', data)
}
// 更新用户
export const updateUser = (id: number, data: Partial<User>) => {
return request.put<User>(`/users/${id}`, data)
}
// 删除用户
export const deleteUser = (id: number) => {
return request.delete(`/users/${id}`)
}

View File

@@ -0,0 +1,50 @@
import { request } from '@/utils/request'
import type { ApiResponse } from '@/types/api'
import type { TagCohort, TagCohortListItem, TagCondition } from '@/types'
// 获取人群快照列表
export const getTagCohortList = (params?: {
page?: number
page_size?: number
}) => {
return request.get<{
cohorts: TagCohortListItem[]
total: number
page: number
page_size: number
}>('/tag-cohorts', params)
}
// 获取人群快照详情
export const getTagCohortDetail = (cohortId: string) => {
return request.get<TagCohort>(`/tag-cohorts/${cohortId}`)
}
// 创建人群快照
export const createTagCohort = (data: {
name: string
description?: string
conditions: TagCondition[]
logic?: 'AND' | 'OR'
user_ids?: string[]
created_by?: string
}) => {
return request.post<{
cohort_id: string
name: string
user_count: number
}>('/tag-cohorts', data)
}
// 删除人群快照
export const deleteTagCohort = (cohortId: string) => {
return request.delete(`/tag-cohorts/${cohortId}`)
}
// 导出人群快照
export const exportTagCohort = (cohortId: string) => {
return request.post(`/tag-cohorts/${cohortId}/export`, {}, {
responseType: 'blob'
})
}

View File

@@ -0,0 +1,47 @@
import { request } from '@/utils/request'
import type { ApiResponse } from '@/types/api'
import type { TagDefinition } from '@/types'
// 获取标签定义列表
export const getTagDefinitionList = (params?: {
name?: string
category?: string
status?: number
page?: number
page_size?: number
}) => {
return request.get<{
definitions: TagDefinition[]
total: number
page: number
page_size: number
}>('/tag-definitions', params)
}
// 获取标签定义详情
export const getTagDefinitionDetail = (tagId: string) => {
return request.get<TagDefinition>(`/tag-definitions/${tagId}`)
}
// 创建标签定义
export const createTagDefinition = (data: Partial<TagDefinition>) => {
return request.post<TagDefinition>('/tag-definitions', data)
}
// 更新标签定义
export const updateTagDefinition = (tagId: string, data: Partial<TagDefinition>) => {
return request.put<TagDefinition>(`/tag-definitions/${tagId}`, data)
}
// 删除标签定义
export const deleteTagDefinition = (tagId: string) => {
return request.delete(`/tag-definitions/${tagId}`)
}
// 批量初始化标签定义
export const batchInitTagDefinitions = (data: {
definitions: Partial<TagDefinition>[]
}) => {
return request.post('/tag-definitions/batch', data)
}

View File

@@ -0,0 +1,64 @@
import { request } from '@/utils/request'
import type { ApiResponse } from '@/types/api'
import type { UserTag, UserInfo, TagCondition, TagStatistics, TagHistoryResponse } from '@/types'
// 获取用户标签支持通过用户ID或手机号查询
export const getUserTags = (userIdOrPhone: string) => {
return request.get<{
user_id: string
tags: UserTag[]
count: number
}>(`/users/${userIdOrPhone}/tags`)
}
// 重新计算用户标签
export const recalculateUserTags = (userId: string) => {
return request.put<{
user_id: string
updated_tags: Array<{
tag_id: string
tag_code: string
tag_value: string
}>
count: number
}>(`/users/${userId}/tags`)
}
// 根据标签筛选用户
export const filterUsersByTags = (params: {
tag_conditions: TagCondition[]
logic: 'AND' | 'OR'
page?: number
page_size?: number
include_user_info?: boolean
}) => {
return request.post<{
users: UserInfo[]
total: number
page: number
page_size: number
total_pages?: number
}>('/tags/filter', params)
}
// 获取标签统计信息
export const getTagStatistics = (params?: {
tag_id?: string
start_date?: string
end_date?: string
}) => {
return request.get<TagStatistics>('/tags/statistics', params)
}
// 获取标签历史记录
export const getTagHistory = (params?: {
user_id?: string
tag_id?: string
start_date?: string
end_date?: string
page?: number
page_size?: number
}) => {
return request.get<TagHistoryResponse>('/tags/history', params)
}

View File

@@ -0,0 +1,66 @@
import { request } from '@/utils/request'
import type { ApiResponse } from '@/types/api'
import type { TagTask, TaskExecution } from '@/types'
// 获取标签任务列表
export const getTagTaskList = (params: {
name?: string
task_type?: string
status?: string
page?: number
page_size?: number
}) => {
return request.get<{
tasks: TagTask[]
total: number
page: number
page_size: number
}>('/tag-tasks', params)
}
// 获取标签任务详情
export const getTagTaskDetail = (taskId: string) => {
return request.get<TagTask>(`/tag-tasks/${taskId}`)
}
// 创建标签任务
export const createTagTask = (data: Partial<TagTask>) => {
return request.post<TagTask>('/tag-tasks', data)
}
// 更新标签任务
export const updateTagTask = (taskId: string, data: Partial<TagTask>) => {
return request.put<TagTask>(`/tag-tasks/${taskId}`, data)
}
// 删除标签任务
export const deleteTagTask = (taskId: string) => {
return request.delete(`/tag-tasks/${taskId}`)
}
// 启动标签任务
export const startTagTask = (taskId: string) => {
return request.post(`/tag-tasks/${taskId}/start`)
}
// 暂停标签任务
export const pauseTagTask = (taskId: string) => {
return request.post(`/tag-tasks/${taskId}/pause`)
}
// 停止标签任务
export const stopTagTask = (taskId: string) => {
return request.post(`/tag-tasks/${taskId}/stop`)
}
// 获取任务执行记录
export const getTagTaskExecutions = (taskId: string, params?: {
page?: number
page_size?: number
}) => {
return request.get<{
executions: TaskExecution[]
total: number
}>(`/tag-tasks/${taskId}/executions`, params)
}

View File

@@ -0,0 +1,33 @@
import { request } from '@/utils/request'
import type { ApiResponse } from '@/types/api'
import type { UserInfo } from '@/types'
// 搜索用户(复杂查询)
export const searchUsers = (params: {
id_card?: string
phone?: string
name?: string
page?: number
page_size?: number
}) => {
return request.post<{
users: UserInfo[]
total: number
page: number
page_size: number
}>('/users/search', params)
}
// 解密身份证
export const decryptIdCard = (userId: string) => {
return request.get<{
user_id: string
id_card: string
}>(`/users/${userId}/decrypt-id-card`)
}
// 删除用户标签
export const deleteUserTag = (userId: string, tagId: string) => {
return request.delete(`/users/${userId}/tags/${tagId}`)
}

View File

@@ -0,0 +1,8 @@
# Assets 目录
存放静态资源文件,如:
- 图片
- 样式文件
- 字体文件
- 其他静态资源

View File

@@ -0,0 +1,178 @@
<template>
<el-container class="layout-container">
<!-- 侧边栏 -->
<el-aside :width="isCollapse ? '64px' : '200px'" class="sidebar">
<div class="logo">
<h2 v-if="!isCollapse">TaskShow</h2>
<h2 v-else>T</h2>
</div>
<el-menu
:default-active="activeMenu"
:collapse="isCollapse"
:collapse-transition="false"
router
background-color="#304156"
text-color="#bfcbd9"
active-text-color="#409eff"
>
<el-menu-item index="/">
<el-icon><HomeFilled /></el-icon>
<template #title>首页</template>
</el-menu-item>
<el-sub-menu index="data-collection">
<template #title>
<el-icon><Document /></el-icon>
<span>数据采集</span>
</template>
<el-menu-item index="/data-collection/tasks">任务列表</el-menu-item>
<el-menu-item index="/data-sources">数据源配置</el-menu-item>
</el-sub-menu>
<el-sub-menu index="tag-tasks">
<template #title>
<el-icon><PriceTag /></el-icon>
<span>标签任务</span>
</template>
<el-menu-item index="/tag-tasks">任务列表</el-menu-item>
<el-menu-item index="/tag-definitions">标签定义</el-menu-item>
<el-menu-item index="/tag-data-lists">数据列表管理</el-menu-item>
</el-sub-menu>
<el-menu-item index="/tag-filter">
<el-icon><Filter /></el-icon>
<template #title>标签筛选</template>
</el-menu-item>
<el-sub-menu index="tag-query">
<template #title>
<el-icon><Search /></el-icon>
<span>标签查询</span>
</template>
<el-menu-item index="/tag-query/user">用户标签</el-menu-item>
<el-menu-item index="/tag-query/statistics">标签统计</el-menu-item>
<el-menu-item index="/tag-query/history">标签历史</el-menu-item>
</el-sub-menu>
</el-menu>
</el-aside>
<!-- 主内容区 -->
<el-container>
<!-- 顶部导航栏 -->
<el-header class="header">
<div class="header-left">
<el-button
:icon="isCollapse ? Expand : Fold"
circle
@click="toggleCollapse"
/>
</div>
<div class="header-right">
<el-dropdown>
<span class="user-info">
<el-icon><User /></el-icon>
<span>管理员</span>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item>个人设置</el-dropdown-item>
<el-dropdown-item divided>退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
<!-- 内容区域 -->
<el-main class="main-content">
<router-view />
</el-main>
</el-container>
</el-container>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRoute } from 'vue-router'
import {
HomeFilled,
Document,
PriceTag,
Filter,
Search,
User,
Fold,
Expand
} from '@element-plus/icons-vue'
const route = useRoute()
const isCollapse = ref(false)
const activeMenu = computed(() => {
return route.path
})
const toggleCollapse = () => {
isCollapse.value = !isCollapse.value
}
</script>
<style scoped lang="scss">
.layout-container {
height: 100vh;
}
.sidebar {
background-color: #304156;
transition: width 0.3s;
overflow: hidden;
.logo {
height: 60px;
line-height: 60px;
text-align: center;
background-color: #2b3a4a;
color: #fff;
font-size: 18px;
font-weight: bold;
}
.el-menu {
border-right: none;
height: calc(100vh - 60px);
overflow-y: auto;
}
}
.header {
background-color: #fff;
border-bottom: 1px solid #e4e7ed;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
.header-left {
display: flex;
align-items: center;
}
.header-right {
.user-info {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
color: #606266;
}
}
}
.main-content {
background-color: #f0f2f5;
padding: 20px;
overflow-y: auto;
}
</style>

View File

@@ -0,0 +1,139 @@
<template>
<div class="progress-display">
<div class="progress-header">
<span class="progress-title">{{ title }}</span>
<span class="progress-percentage">{{ percentage }}%</span>
</div>
<el-progress
:percentage="percentage"
:status="progressStatus"
:stroke-width="strokeWidth"
/>
<div class="progress-stats" v-if="showStats">
<el-row :gutter="20">
<el-col :span="6">
<div class="stat-item">
<span class="stat-label">总数</span>
<span class="stat-value">{{ total }}</span>
</div>
</el-col>
<el-col :span="6">
<div class="stat-item">
<span class="stat-label">已处理</span>
<span class="stat-value">{{ processed }}</span>
</div>
</el-col>
<el-col :span="6">
<div class="stat-item">
<span class="stat-label">成功</span>
<span class="stat-value success">{{ success }}</span>
</div>
</el-col>
<el-col :span="6">
<div class="stat-item">
<span class="stat-label">失败</span>
<span class="stat-value error">{{ error }}</span>
</div>
</el-col>
</el-row>
</div>
<div class="progress-time" v-if="showTime">
<span v-if="startTime">开始时间{{ formatTime(startTime) }}</span>
<span v-if="endTime" style="margin-left: 20px">结束时间{{ formatTime(endTime) }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
title?: string
percentage: number
total?: number
processed?: number
success?: number
error?: number
startTime?: string
endTime?: string
showStats?: boolean
showTime?: boolean
strokeWidth?: number
}
const props = withDefaults(defineProps<Props>(), {
title: '进度',
percentage: 0,
total: 0,
processed: 0,
success: 0,
error: 0,
showStats: true,
showTime: true,
strokeWidth: 8
})
const progressStatus = computed(() => {
if (props.percentage === 100) return 'success'
if (props.error > 0 && props.processed === props.total) return 'exception'
return undefined
})
const formatTime = (time: string) => {
if (!time) return ''
const date = new Date(time)
return date.toLocaleString('zh-CN')
}
</script>
<style scoped lang="scss">
.progress-display {
.progress-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
.progress-title {
font-weight: 500;
color: #303133;
}
.progress-percentage {
font-weight: 600;
color: #409eff;
}
}
.progress-stats {
margin-top: 15px;
.stat-item {
.stat-label {
color: #909399;
font-size: 14px;
}
.stat-value {
font-weight: 600;
color: #303133;
&.success {
color: #67c23a;
}
&.error {
color: #f56c6c;
}
}
}
}
.progress-time {
margin-top: 10px;
font-size: 12px;
color: #909399;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,294 @@
# QueryBuilder 组件说明
可视化查询构建器组件,支持 MongoDB 聚合查询配置。
## 功能特性
### ✅ 基础配置
- 数据源选择
- 数据库选择
- 单集合/多集合模式切换
### ✅ 多集合模式(新增)
- **集合列表展示**:复选框多选
- **文本筛选**:实时搜索过滤集合
- **批量操作**
- 全选:选择当前筛选结果的所有集合
- 清空:清空所有已选集合
- 反选:反选当前筛选结果
- **智能快捷筛选**(自动识别日期分片集合):
- 按年份2021年、2022年、2023年、2024年、2025年
- 按时间范围最近3个月、最近6个月、最近12个月
### ✅ 过滤条件WHERE
- 多条件支持
- AND/OR 逻辑关系
- 丰富的运算符:
- 等于、不等于
- 大于、大于等于、小于、小于等于
- 包含、不包含
- 模糊匹配、存在
### ✅ 联表查询JOIN/LOOKUP
- 多表关联
- 支持 LEFT JOIN 效果
- 数组解构选项
### ✅ 排序和限制
- 字段排序
- 升序/降序
- 结果数量限制
### ✅ 查询预览
- 实时生成 MongoDB 聚合管道代码
- 数据预览(最多显示配置的 limit 条)
## 使用示例
### 基础用法
```vue
<template>
<QueryBuilder v-model="queryConfig" />
</template>
<script setup>
import { reactive } from 'vue'
import QueryBuilder from '@/components/QueryBuilder/QueryBuilder.vue'
const queryConfig = reactive({
data_source_id: '',
database: '',
collection: '',
multi_collection: false,
collections: [],
filter: [],
lookups: [],
sort_field: '',
sort_order: '1',
limit: 1000
})
</script>
```
### 多集合模式示例
```javascript
// 配置数据结构
{
data_source_id: 'source_001',
database: 'ckb',
collection: 'consumption_records_202101', // 第一个集合(兼容性)
multi_collection: true, // 启用多集合模式
collections: [ // 选中的集合列表
'consumption_records_202101',
'consumption_records_202102',
'consumption_records_202103',
'consumption_records_202104',
'consumption_records_202105',
'consumption_records_202106'
],
filter: [
{ field: 'status', operator: 'eq', value: 'success' }
],
lookups: [],
sort_field: 'create_time',
sort_order: '-1',
limit: 1000
}
```
## 界面布局
```
┌─ 基础配置 ─────────────────────────────────────────┐
│ 数据源:[MongoDB数据源 ▼] │
│ 数据库:[ckb ▼] │
│ 多集合模式:[●启用] ○禁用 │
└────────────────────────────────────────────────────┘
┌─ 集合列表 ─────────────────────────────────────────┐
│ [🔍 筛选集合名称...] [全选] [清空] [反选] │
│ │
│ 快捷筛选:[2021年] [2022年] [2023年] [2024年] │
│ [最近3个月] [最近6个月] [最近12个月] │
├──────────────────────────────────────────────────────┤
│ ☑ consumption_records_202101 │
│ ☑ consumption_records_202102 │
│ ☑ consumption_records_202103 │
│ ☐ consumption_records_202104 │
│ ☐ consumption_records_202105 │
│ ... (滚动查看更多) │
│ │
│ 筛选结果28个集合 | 已选择 3个集合 │
└──────────────────────────────────────────────────────┘
┌─ 过滤条件WHERE ──────────────── [添加条件] ───┐
│ 逻辑 | 字段 | 运算符 | 值 | 操作 │
│ - | status | 等于 | success | [删除] │
└──────────────────────────────────────────────────────┘
┌─ 联表查询JOIN ─────────────── [添加关联] ────┐
│ 关联集合 | 主字段 | 关联字段 | 结果名 | 操作 │
│ user_profile | user_id | user_id | user_info | [删除] │
└──────────────────────────────────────────────────────┘
┌─ 排序和限制 ────────────────────────────────────────┐
│ 排序字段:[create_time ▼] 排序方式:[降序 ▼] │
│ 限制数量:[1000] │
└──────────────────────────────────────────────────────┘
┌─ 查询预览 ───────────────────────── [预览数据] ───┐
│ // 多集合模式:将查询以下 3 个集合并合并结果 │
│ // consumption_records_202101, ... │
│ │
│ db.consumption_records_202101.aggregate([ │
│ { $match: { status: { $eq: "success" } } }, │
│ { $sort: { create_time: -1 } }, │
│ { $limit: 1000 } │
│ ]) │
│ │
│ [预览数据表格] │
└──────────────────────────────────────────────────────┘
```
## Props
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| modelValue | Object | - | 查询配置对象v-model 绑定) |
## Events
| 事件名 | 参数 | 说明 |
|--------|------|------|
| update:modelValue | queryConfig | 配置变化时触发 |
## 多集合模式特性
### 智能识别
自动检测集合命名格式如果包含6位数字YYYYMM格式则显示快捷筛选按钮。
**支持的格式**
- `consumption_records_202101`
- `collection_2021_01`
- `data_202101_backup`
### 快捷筛选逻辑
#### 按年份筛选
匹配包含年份字符串的集合。
```javascript
// 点击 "2021年"
collections.filter(coll => coll.includes('2021'))
```
#### 按时间范围筛选
计算当前日期向前推算N个月匹配包含对应年月的集合。
```javascript
// 点击 "最近3个月"(假设当前 2025-01
匹配202501, 202412, 202411
```
### 筛选功能实现
```javascript
// 实时筛选
const filteredCollections = computed(() => {
if (!collectionFilter.value) return collections.value
const filter = collectionFilter.value.toLowerCase()
return collections.value.filter(coll =>
coll.toLowerCase().includes(filter)
)
})
// 全选
const handleSelectAllCollections = () => {
const newSelections = [
...new Set([
...queryConfig.collections,
...filteredCollections.value
])
]
queryConfig.collections = newSelections
}
// 反选
const handleInvertCollections = () => {
const currentSelections = new Set(queryConfig.collections)
const newSelections = filteredCollections.value.filter(
coll => !currentSelections.has(coll)
)
const keptSelections = queryConfig.collections.filter(
coll => !filteredCollections.value.includes(coll)
)
queryConfig.collections = [...keptSelections, ...newSelections]
}
```
## API 依赖
组件依赖以下 API
```typescript
import * as dataCollectionApi from '@/api/dataCollection'
// 获取数据源列表
dataCollectionApi.getDataSources()
// 获取数据库列表
dataCollectionApi.getDatabases(dataSourceId)
// 获取集合列表
dataCollectionApi.getCollections(dataSourceId, database)
// 获取字段列表
dataCollectionApi.getFields(dataSourceId, database, collection)
// 预览查询结果
dataCollectionApi.previewQuery({
data_source_id,
database,
collection, // 单集合模式
collections, // 多集合模式
lookups,
filter_conditions,
limit
})
```
## 注意事项
1. **字段一致性**
- 多集合模式下,各集合应有相同或相似的字段结构
- 字段列表从第一个选中的集合加载
2. **性能考虑**
- 选择的集合越多,查询性能越慢
- 建议根据实际需求选择合适的集合范围
- 合理设置 `limit` 限制返回数据量
3. **后端支持**
- 多集合模式需要后端 API 支持 `collections` 参数
- 后端需要遍历所有集合执行查询并合并结果
- 合并后需要重新应用排序和限制
4. **数据格式**
- API 返回的数据库和集合可能是对象或字符串
- 组件内部会自动处理格式转换
- 保存时同时存储对象用于后续 API 调用
## 相关文档
- [多集合模式使用说明](../../../提示词/多集合模式使用说明.md)
- [集合筛选功能使用技巧](../../../提示词/集合筛选功能使用技巧.md)
- [数据列表管理界面使用说明](../../../提示词/数据列表管理界面使用说明.md)
- [真实API接入说明](../../../提示词/数据列表管理_真实API接入说明.md)
---
**版本**v2.0
**更新时间**2025-01-XX
**作者**CKB Team

View File

@@ -0,0 +1,18 @@
# Components 目录
存放公共组件文件。
## 使用示例
```vue
<template>
<div>
<!-- 使用公共组件 -->
</div>
</template>
<script setup lang="ts">
// 组件逻辑
</script>
```

View File

@@ -0,0 +1,48 @@
<template>
<el-tag :type="tagType" :effect="effect">
{{ label }}
</el-tag>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
status: string
effect?: 'dark' | 'light' | 'plain'
}
const props = withDefaults(defineProps<Props>(), {
effect: 'light'
})
const statusMap: Record<string, { type: string; label: string }> = {
// 任务状态
pending: { type: 'info', label: '待启动' },
running: { type: 'success', label: '运行中' },
paused: { type: 'warning', label: '已暂停' },
stopped: { type: 'info', label: '已停止' },
completed: { type: 'success', label: '已完成' },
error: { type: 'danger', label: '错误' },
// 进度状态
idle: { type: 'info', label: '空闲' },
// 执行状态
failed: { type: 'danger', label: '失败' },
cancelled: { type: 'info', label: '已取消' },
// 标签状态
active: { type: 'success', label: '启用' },
inactive: { type: 'info', label: '禁用' },
}
const tagType = computed(() => {
return statusMap[props.status]?.type || 'info'
})
const label = computed(() => {
return statusMap[props.status]?.label || props.status
})
</script>

View File

@@ -0,0 +1,22 @@
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'
const app = createApp(App)
const pinia = createPinia()
// 注册所有 Element Plus 图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(pinia)
app.use(router)
app.use(ElementPlus)
app.mount('#app')

View File

@@ -0,0 +1,199 @@
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'
import Layout from '@/components/Layout/index.vue'
const routes: Array<RouteRecordRaw> = [
{
path: '/',
component: Layout,
redirect: '/dashboard',
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('@/views/Dashboard/index.vue'),
meta: { title: '首页' }
}
]
},
{
path: '/data-collection',
component: Layout,
children: [
{
path: 'tasks',
name: 'DataCollectionTaskList',
component: () => import('@/views/DataCollection/TaskList.vue'),
meta: { title: '数据采集任务列表' }
},
{
path: 'tasks/create',
name: 'DataCollectionTaskCreate',
component: () => import('@/views/DataCollection/TaskForm.vue'),
meta: { title: '创建数据采集任务' }
},
{
path: 'tasks/:id',
name: 'DataCollectionTaskDetail',
component: () => import('@/views/DataCollection/TaskDetail.vue'),
meta: { title: '数据采集任务详情' }
},
{
path: 'tasks/:id/edit',
name: 'DataCollectionTaskEdit',
component: () => import('@/views/DataCollection/TaskForm.vue'),
meta: { title: '编辑数据采集任务' }
}
]
},
{
path: '/tag-tasks',
component: Layout,
children: [
{
path: '',
name: 'TagTaskList',
component: () => import('@/views/TagTask/TaskList.vue'),
meta: { title: '标签任务列表' }
},
{
path: 'create',
name: 'TagTaskCreate',
component: () => import('@/views/TagTask/TaskForm.vue'),
meta: { title: '创建标签任务' }
},
{
path: ':id',
name: 'TagTaskDetail',
component: () => import('@/views/TagTask/TaskDetail.vue'),
meta: { title: '标签任务详情' }
},
{
path: ':id/edit',
name: 'TagTaskEdit',
component: () => import('@/views/TagTask/TaskForm.vue'),
meta: { title: '编辑标签任务' }
}
]
},
{
path: '/tag-definitions',
component: Layout,
children: [
{
path: '',
name: 'TagDefinitionList',
component: () => import('@/views/TagDefinition/List.vue'),
meta: { title: '标签定义列表' }
},
{
path: 'create',
name: 'TagDefinitionCreate',
component: () => import('@/views/TagDefinition/Form.vue'),
meta: { title: '创建标签定义' }
},
{
path: ':id',
name: 'TagDefinitionDetail',
component: () => import('@/views/TagDefinition/Detail.vue'),
meta: { title: '标签定义详情' }
},
{
path: ':id/edit',
name: 'TagDefinitionEdit',
component: () => import('@/views/TagDefinition/Form.vue'),
meta: { title: '编辑标签定义' }
}
]
},
{
path: '/tag-filter',
component: Layout,
children: [
{
path: '',
name: 'TagFilter',
component: () => import('@/views/TagFilter/index.vue'),
meta: { title: '标签筛选' }
}
]
},
{
path: '/tag-query',
component: Layout,
children: [
{
path: 'user',
name: 'UserTagQuery',
component: () => import('@/views/TagQuery/User.vue'),
meta: { title: '用户标签查询' }
},
{
path: 'statistics',
name: 'TagStatistics',
component: () => import('@/views/TagQuery/Statistics.vue'),
meta: { title: '标签统计' }
},
{
path: 'history',
name: 'TagHistory',
component: () => import('@/views/TagQuery/History.vue'),
meta: { title: '标签历史' }
}
]
},
{
path: '/tag-data-lists',
component: Layout,
children: [
{
path: '',
name: 'TagDataListList',
component: () => import('@/views/TagDataList/List.vue'),
meta: { title: '数据列表管理' }
},
{
path: 'create',
name: 'TagDataListCreate',
component: () => import('@/views/TagDataList/Form.vue'),
meta: { title: '创建数据列表' }
},
{
path: ':id/edit',
name: 'TagDataListEdit',
component: () => import('@/views/TagDataList/Form.vue'),
meta: { title: '编辑数据列表' }
}
]
},
{
path: '/data-sources',
component: Layout,
children: [
{
path: '',
name: 'DataSourceList',
component: () => import('@/views/DataSource/List.vue'),
meta: { title: '数据源列表' }
},
{
path: 'create',
name: 'DataSourceCreate',
component: () => import('@/views/DataSource/Form.vue'),
meta: { title: '创建数据源' }
},
{
path: ':id/edit',
name: 'DataSourceEdit',
component: () => import('@/views/DataSource/Form.vue'),
meta: { title: '编辑数据源' }
}
]
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
export default router

6
Moncter/TaskShow/src/shims-vue.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
declare module '*.vue' {
import { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}

View File

@@ -0,0 +1,212 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { DataCollectionTask, DataSource } from '@/types'
import * as dataCollectionApi from '@/api/dataCollection'
export const useDataCollectionStore = defineStore('dataCollection', () => {
const tasks = ref<DataCollectionTask[]>([])
const currentTask = ref<DataCollectionTask | null>(null)
const dataSources = ref<DataSource[]>([])
const loading = ref(false)
// 获取任务列表
const fetchTasks = async (params?: {
name?: string
status?: string
page?: number
page_size?: number
}) => {
loading.value = true
try {
const response = await dataCollectionApi.getDataCollectionTaskList(params || {})
tasks.value = response.data.tasks
return response.data
} catch (error) {
console.error('获取任务列表失败:', error)
throw error
} finally {
loading.value = false
}
}
// 获取任务详情
const fetchTaskDetail = async (taskId: string) => {
loading.value = true
try {
const response = await dataCollectionApi.getDataCollectionTaskDetail(taskId)
currentTask.value = response.data
return response.data
} catch (error) {
console.error('获取任务详情失败:', error)
throw error
} finally {
loading.value = false
}
}
// 创建任务
const createTask = async (data: Partial<DataCollectionTask>) => {
try {
const response = await dataCollectionApi.createDataCollectionTask(data)
await fetchTasks()
return response.data
} catch (error) {
console.error('创建任务失败:', error)
throw error
}
}
// 更新任务
const updateTask = async (taskId: string, data: Partial<DataCollectionTask>) => {
try {
const response = await dataCollectionApi.updateDataCollectionTask(taskId, data)
await fetchTasks()
return response.data
} catch (error) {
console.error('更新任务失败:', error)
throw error
}
}
// 删除任务
const deleteTask = async (taskId: string, params?: {
name?: string
status?: string
page?: number
page_size?: number
}) => {
try {
await dataCollectionApi.deleteDataCollectionTask(taskId)
// 刷新列表时保持当前的筛选和分页状态,并返回结果以便更新分页信息
return await fetchTasks(params)
} catch (error) {
console.error('删除任务失败:', error)
throw error
}
}
// 启动任务
const startTask = async (taskId: string) => {
try {
await dataCollectionApi.startDataCollectionTask(taskId)
await fetchTaskDetail(taskId)
} catch (error) {
console.error('启动任务失败:', error)
throw error
}
}
// 暂停任务
const pauseTask = async (taskId: string) => {
try {
await dataCollectionApi.pauseDataCollectionTask(taskId)
await fetchTaskDetail(taskId)
} catch (error) {
console.error('暂停任务失败:', error)
throw error
}
}
// 停止任务
const stopTask = async (taskId: string) => {
try {
await dataCollectionApi.stopDataCollectionTask(taskId)
await fetchTaskDetail(taskId)
} catch (error) {
console.error('停止任务失败:', error)
throw error
}
}
// 获取数据源列表
const fetchDataSources = async () => {
try {
const response = await dataCollectionApi.getDataSources()
dataSources.value = response.data
return response.data
} catch (error) {
console.error('获取数据源列表失败:', error)
throw error
}
}
// 获取任务进度
const fetchTaskProgress = async (taskId: string) => {
try {
const response = await dataCollectionApi.getDataCollectionTaskProgress(taskId)
if (currentTask.value && currentTask.value.task_id === taskId) {
currentTask.value.progress = response.data
}
return response.data
} catch (error) {
console.error('获取任务进度失败:', error)
throw error
}
}
// 复制任务
const duplicateTask = async (taskId: string) => {
try {
// 获取原任务详情
const originalTask = await fetchTaskDetail(taskId)
if (!originalTask) {
throw new Error('任务不存在')
}
// 复制任务数据,但清除运行时数据
const taskData: Partial<DataCollectionTask> = {
name: `${originalTask.name}_副本`,
description: originalTask.description || '',
data_source_id: originalTask.data_source_id,
database: originalTask.database,
collection: originalTask.collection || null,
collections: originalTask.collections || null,
target_type: originalTask.target_type || 'generic',
target_data_source_id: originalTask.target_data_source_id || null,
target_database: originalTask.target_database || null,
target_collection: originalTask.target_collection || null,
mode: originalTask.mode || 'batch',
field_mappings: originalTask.field_mappings || [],
collection_field_mappings: originalTask.collection_field_mappings || {},
lookups: originalTask.lookups || [],
collection_lookups: originalTask.collection_lookups || {},
filter_conditions: originalTask.filter_conditions || [],
schedule: originalTask.schedule || { enabled: false, cron: '' },
status: 'pending' // 复制后的任务状态为待启动
}
// 创建新任务
const response = await dataCollectionApi.createDataCollectionTask(taskData)
await fetchTasks()
return response.data
} catch (error) {
console.error('复制任务失败:', error)
throw error
}
}
// 重置当前任务
const resetCurrentTask = () => {
currentTask.value = null
}
return {
tasks,
currentTask,
dataSources,
loading,
fetchTasks,
fetchTaskDetail,
createTask,
updateTask,
deleteTask,
startTask,
pauseTask,
stopTask,
fetchDataSources,
fetchTaskProgress,
duplicateTask,
resetCurrentTask
}
})

View File

@@ -0,0 +1,107 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import * as dataSourceApi from '@/api/dataSource'
import type { DataSource } from '@/api/dataSource'
import { ElMessage } from 'element-plus'
export const useDataSourceStore = defineStore('dataSource', () => {
const dataSources = ref<DataSource[]>([])
const currentDataSource = ref<DataSource | null>(null)
// 获取数据源列表
const fetchDataSources = async (params?: {
type?: string
status?: number
name?: string
page?: number
page_size?: number
}) => {
try {
const response = await dataSourceApi.getDataSourceList(params)
dataSources.value = response.data.data_sources
return response.data
} catch (error: any) {
ElMessage.error(error.message || '获取数据源列表失败')
throw error
}
}
// 获取数据源详情
const fetchDataSourceDetail = async (dataSourceId: string) => {
try {
const response = await dataSourceApi.getDataSourceDetail(dataSourceId)
currentDataSource.value = response.data
return response.data
} catch (error: any) {
ElMessage.error(error.message || '获取数据源详情失败')
throw error
}
}
// 创建数据源
const createDataSource = async (data: Partial<DataSource>) => {
try {
const response = await dataSourceApi.createDataSource(data)
ElMessage.success('数据源创建成功')
return response.data
} catch (error: any) {
ElMessage.error(error.message || '创建数据源失败')
throw error
}
}
// 更新数据源
const updateDataSource = async (dataSourceId: string, data: Partial<DataSource>) => {
try {
await dataSourceApi.updateDataSource(dataSourceId, data)
ElMessage.success('数据源更新成功')
} catch (error: any) {
ElMessage.error(error.message || '更新数据源失败')
throw error
}
}
// 删除数据源
const deleteDataSource = async (dataSourceId: string) => {
try {
await dataSourceApi.deleteDataSource(dataSourceId)
ElMessage.success('数据源删除成功')
} catch (error: any) {
ElMessage.error(error.message || '删除数据源失败')
throw error
}
}
// 测试连接
const testConnection = async (data: {
type: string
host: string
port: number
database: string
username?: string
password?: string
auth_source?: string
options?: Record<string, any>
}) => {
try {
const response = await dataSourceApi.testDataSourceConnection(data)
// 只返回结果,不显示消息,由调用方决定是否显示
return response.data.connected
} catch (error: any) {
ElMessage.error(error.message || '连接测试失败')
return false
}
}
return {
dataSources,
currentDataSource,
fetchDataSources,
fetchDataSourceDetail,
createDataSource,
updateDataSource,
deleteDataSource,
testConnection,
}
})

View File

@@ -0,0 +1,6 @@
// Store 统一导出
export { useUserStore } from './user'
export * from './dataCollection'
export * from './tagTask'
export * from './tagDefinition'
export { useDataSourceStore } from './dataSource'

View File

@@ -0,0 +1,105 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { TagDefinition } from '@/types'
import * as tagDefinitionApi from '@/api/tagDefinition'
export const useTagDefinitionStore = defineStore('tagDefinition', () => {
const definitions = ref<TagDefinition[]>([])
const currentDefinition = ref<TagDefinition | null>(null)
const loading = ref(false)
// 获取标签定义列表
const fetchDefinitions = async (params?: {
name?: string
category?: string
status?: number
page?: number
page_size?: number
}) => {
loading.value = true
try {
const response = await tagDefinitionApi.getTagDefinitionList(params)
definitions.value = response.data.definitions
return response.data
} catch (error) {
console.error('获取标签定义列表失败:', error)
throw error
} finally {
loading.value = false
}
}
// 获取标签定义详情
const fetchDefinitionDetail = async (tagId: string) => {
loading.value = true
try {
const response = await tagDefinitionApi.getTagDefinitionDetail(tagId)
currentDefinition.value = response.data
return response.data
} catch (error) {
console.error('获取标签定义详情失败:', error)
throw error
} finally {
loading.value = false
}
}
// 创建标签定义
const createDefinition = async (data: Partial<TagDefinition>) => {
try {
const response = await tagDefinitionApi.createTagDefinition(data)
await fetchDefinitions()
return response.data
} catch (error) {
console.error('创建标签定义失败:', error)
throw error
}
}
// 更新标签定义
const updateDefinition = async (tagId: string, data: Partial<TagDefinition>) => {
try {
const response = await tagDefinitionApi.updateTagDefinition(tagId, data)
await fetchDefinitions()
return response.data
} catch (error) {
console.error('更新标签定义失败:', error)
throw error
}
}
// 删除标签定义
const deleteDefinition = async (tagId: string) => {
try {
await tagDefinitionApi.deleteTagDefinition(tagId)
await fetchDefinitions()
} catch (error) {
console.error('删除标签定义失败:', error)
throw error
}
}
// 获取启用的标签定义列表(用于下拉选择)
const getActiveDefinitions = async () => {
return await fetchDefinitions({ status: 0 })
}
// 重置当前标签定义
const resetCurrentDefinition = () => {
currentDefinition.value = null
}
return {
definitions,
currentDefinition,
loading,
fetchDefinitions,
fetchDefinitionDetail,
createDefinition,
updateDefinition,
deleteDefinition,
getActiveDefinitions,
resetCurrentDefinition
}
})

View File

@@ -0,0 +1,154 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { TagTask, TaskExecution } from '@/types'
import * as tagTaskApi from '@/api/tagTask'
export const useTagTaskStore = defineStore('tagTask', () => {
const tasks = ref<TagTask[]>([])
const currentTask = ref<TagTask | null>(null)
const executions = ref<TaskExecution[]>([])
const loading = ref(false)
// 获取任务列表
const fetchTasks = async (params?: {
name?: string
task_type?: string
status?: string
page?: number
page_size?: number
}) => {
loading.value = true
try {
const response = await tagTaskApi.getTagTaskList(params || {})
tasks.value = response.data.tasks
return response.data
} catch (error) {
console.error('获取任务列表失败:', error)
throw error
} finally {
loading.value = false
}
}
// 获取任务详情
const fetchTaskDetail = async (taskId: string) => {
loading.value = true
try {
const response = await tagTaskApi.getTagTaskDetail(taskId)
currentTask.value = response.data
return response.data
} catch (error) {
console.error('获取任务详情失败:', error)
throw error
} finally {
loading.value = false
}
}
// 创建任务
const createTask = async (data: Partial<TagTask>) => {
try {
const response = await tagTaskApi.createTagTask(data)
await fetchTasks()
return response.data
} catch (error) {
console.error('创建任务失败:', error)
throw error
}
}
// 更新任务
const updateTask = async (taskId: string, data: Partial<TagTask>) => {
try {
const response = await tagTaskApi.updateTagTask(taskId, data)
await fetchTasks()
return response.data
} catch (error) {
console.error('更新任务失败:', error)
throw error
}
}
// 删除任务
const deleteTask = async (taskId: string) => {
try {
await tagTaskApi.deleteTagTask(taskId)
await fetchTasks()
} catch (error) {
console.error('删除任务失败:', error)
throw error
}
}
// 启动任务
const startTask = async (taskId: string) => {
try {
await tagTaskApi.startTagTask(taskId)
await fetchTaskDetail(taskId)
} catch (error) {
console.error('启动任务失败:', error)
throw error
}
}
// 暂停任务
const pauseTask = async (taskId: string) => {
try {
await tagTaskApi.pauseTagTask(taskId)
await fetchTaskDetail(taskId)
} catch (error) {
console.error('暂停任务失败:', error)
throw error
}
}
// 停止任务
const stopTask = async (taskId: string) => {
try {
await tagTaskApi.stopTagTask(taskId)
await fetchTaskDetail(taskId)
} catch (error) {
console.error('停止任务失败:', error)
throw error
}
}
// 获取执行记录
const fetchExecutions = async (taskId: string, params?: {
page?: number
page_size?: number
}) => {
try {
const response = await tagTaskApi.getTagTaskExecutions(taskId, params)
executions.value = response.data.executions
return response.data
} catch (error) {
console.error('获取执行记录失败:', error)
throw error
}
}
// 重置当前任务
const resetCurrentTask = () => {
currentTask.value = null
executions.value = []
}
return {
tasks,
currentTask,
executions,
loading,
fetchTasks,
fetchTaskDetail,
createTask,
updateTask,
deleteTask,
startTask,
pauseTask,
stopTask,
fetchExecutions,
resetCurrentTask
}
})

View File

@@ -0,0 +1,35 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useUserStore = defineStore('user', () => {
// 用户 token
const token = ref<string | null>(null)
// 初始化 token从 localStorage 读取)
const initToken = () => {
const storedToken = localStorage.getItem('token')
if (storedToken) {
token.value = storedToken
}
}
// 设置 token
const setToken = (newToken: string) => {
token.value = newToken
localStorage.setItem('token', newToken)
}
// 清除用户信息
const clearUser = () => {
token.value = null
localStorage.removeItem('token')
}
return {
token,
initToken,
setToken,
clearUser
}
})

View File

@@ -0,0 +1,33 @@
// API 响应类型定义
export interface ApiResponse<T = any> {
code: number
message: string
data: T
}
// 请求配置类型
export interface RequestConfig {
url: string
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
data?: any
params?: any
headers?: Record<string, string>
timeout?: number
showLoading?: boolean
showError?: boolean
}
// 分页请求参数
export interface PageParams {
page: number
pageSize: number
}
// 分页响应数据
export interface PageResponse<T> {
list: T[]
total: number
page: number
pageSize: number
}

View File

@@ -0,0 +1,299 @@
/**
* 通用类型定义
*/
// API 响应结构
export interface ApiResponse<T = any> {
code: number
message: string
data: T
}
// 分页响应
export interface PageResponse<T> {
items: T[]
total: number
page: number
page_size: number
total_pages: number
}
// 数据采集任务
export interface LookupConfig {
from: string
local_field: string
foreign_field: string
as: string
unwrap?: boolean
preserve_null?: boolean
}
export interface DataCollectionTask {
task_id: string
name: string
description?: string
data_source_id: string // 源数据源ID
database: string // 源数据库
collection?: string // 源集合(单集合模式)
collections?: string[] // 源集合列表(多集合模式)
multi_collection?: boolean
target_data_source_id?: string // 目标数据源ID
target_database?: string // 目标数据库
target_collection?: string // 目标集合
target_type?: string // 目标类型consumption_record=消费记录generic=通用集合
mode: 'batch' | 'realtime'
field_mappings: FieldMapping[]
collection_field_mappings?: Record<string, FieldMapping[]>
lookups?: LookupConfig[]
collection_lookups?: Record<string, LookupConfig[]>
filter_conditions?: FilterCondition[]
schedule: ScheduleConfig
status: 'pending' | 'running' | 'paused' | 'stopped' | 'error'
progress: TaskProgress
statistics?: TaskStatistics
created_by?: string
created_at: string
updated_at: string
}
// 字段映射
export interface FieldMapping {
source_field: string
target_field: string
transform?: string
value_mapping?: Array<{
source_value: string
target_value: number
}>
}
// 过滤条件
export interface FilterCondition {
field: string
operator: 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte' | 'in' | 'nin'
value: any
}
// 调度配置
export interface ScheduleConfig {
enabled: boolean
cron?: string
}
// 任务进度
export interface TaskProgress {
status: 'idle' | 'running' | 'paused' | 'completed' | 'error'
processed_count?: number
success_count?: number
error_count?: number
total_count?: number
percentage?: number
start_time?: string
end_time?: string
last_sync_time?: string
}
// 任务统计
export interface TaskStatistics {
total_processed?: number
total_success?: number
total_error?: number
last_run_time?: string
}
// 数据源
export interface DataSource {
id: string
name?: string
type: string
host: string
port: number
database: string
}
// 标签任务
export interface TagTask {
task_id: string
name: string
description?: string
task_type: 'full' | 'incremental' | 'specific'
target_tag_ids: string[]
user_scope: UserScope
schedule: ScheduleConfig
config: TagTaskConfig
status: 'pending' | 'running' | 'paused' | 'stopped' | 'completed' | 'error'
progress: TagTaskProgress
statistics?: TagTaskStatistics
created_by?: string
created_at: string
updated_at: string
}
// 用户范围
export interface UserScope {
type: 'all' | 'list' | 'filter'
user_ids?: string[]
filter_conditions?: FilterCondition[]
}
// 标签任务配置
export interface TagTaskConfig {
concurrency: number
batch_size: number
error_handling: 'skip' | 'stop' | 'retry'
}
// 标签任务进度
export interface TagTaskProgress {
total_users: number
processed_users: number
success_count: number
error_count: number
percentage: number
}
// 标签任务统计
export interface TagTaskStatistics {
total_executions: number
success_executions: number
failed_executions: number
last_run_time?: string
}
// 任务执行记录
export interface TaskExecution {
execution_id: string
task_id: string
started_at: string
finished_at?: string
status: 'running' | 'completed' | 'failed' | 'cancelled'
processed_users?: number
success_count?: number
error_count?: number
error_message?: string
}
// 标签定义
export interface TagDefinition {
tag_id: string
tag_code: string
tag_name: string
category: string
description?: string
rule_type: 'simple' | 'pipeline' | 'custom'
rule_config: RuleConfig
update_frequency: 'real_time' | 'daily' | 'weekly' | 'monthly'
status: number // 0-启用1-禁用
priority?: number
version?: number
created_at?: string
updated_at?: string
}
// 规则配置
export interface RuleConfig {
rule_type: 'simple' | 'pipeline' | 'custom'
conditions: RuleCondition[]
tag_value?: any
confidence?: number
}
// 规则条件
export interface RuleCondition {
field: string
operator: '>' | '>=' | '<' | '<=' | '=' | '!=' | 'in' | 'not_in'
value: any
}
// 标签条件(用于筛选)
export interface TagCondition {
tag_code: string
operator: 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte' | 'in' | 'nin' | 'contains' | 'not_contains'
value: any
}
// 用户标签
export interface UserTag {
tag_id: string
tag_code?: string
tag_name?: string
category?: string
tag_value: string
tag_value_type: string
confidence: number
effective_time?: string
expire_time?: string
update_time: string
}
// 用户信息
export interface UserInfo {
user_id: string
name?: string
phone?: string
total_amount?: number
total_count?: number
last_consume_time?: string
}
// 标签统计信息
export interface TagStatistics {
value_distribution: Array<{
value: string
count: number
}>
trend_data: Array<{
date: string
count: number
}>
coverage_stats: Array<{
tag_id: string
tag_name: string
total_users: number
tagged_users: number
coverage_rate: number
}>
}
// 标签历史记录
export interface TagHistoryItem {
user_id: string
tag_id: string
tag_name: string | null
old_value?: string | null
new_value: string
change_reason?: string | null
change_time: string | null
operator?: string | null
}
// 标签历史响应
export interface TagHistoryResponse {
items: TagHistoryItem[]
total: number
page: number
page_size: number
}
// 人群快照
export interface TagCohort {
cohort_id: string
name: string
description?: string
conditions: TagCondition[]
logic: 'AND' | 'OR'
user_ids: string[]
user_count: number
created_by?: string
created_at: string
updated_at?: string
}
// 人群快照列表项
export interface TagCohortListItem {
cohort_id: string
name: string
user_count: number
created_at: string
}

View File

@@ -0,0 +1,106 @@
/**
* 日期时间格式化工具
*/
/**
* 格式化日期时间
* @param date 日期对象或时间戳或日期字符串
* @param format 格式字符串,默认 'YYYY-MM-DD HH:mm:ss'
* @returns 格式化后的日期字符串
*/
export const formatDateTime = (
date: Date | number | string | null | undefined,
format: string = 'YYYY-MM-DD HH:mm:ss'
): string => {
if (!date) return '-'
try {
let d: Date
if (date instanceof Date) {
d = date
} else if (typeof date === 'string') {
// 处理 ISO 8601 格式字符串
d = new Date(date)
} else if (typeof date === 'number') {
d = new Date(date)
} else {
// 如果是对象,尝试转换为字符串再解析
d = new Date(String(date))
}
// 检查日期是否有效
if (!(d instanceof Date) || isNaN(d.getTime())) {
return '-'
}
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const hours = String(d.getHours()).padStart(2, '0')
const minutes = String(d.getMinutes()).padStart(2, '0')
const seconds = String(d.getSeconds()).padStart(2, '0')
return format
.replace('YYYY', String(year))
.replace('MM', month)
.replace('DD', day)
.replace('HH', hours)
.replace('mm', minutes)
.replace('ss', seconds)
} catch (error) {
console.error('formatDateTime error:', error, date)
return '-'
}
}
/**
* 格式化日期
* @param date 日期对象或时间戳或日期字符串
* @returns 格式化后的日期字符串 YYYY-MM-DD
*/
export const formatDate = (date: Date | number | string | null | undefined): string => {
return formatDateTime(date, 'YYYY-MM-DD')
}
/**
* 格式化时间
* @param date 日期对象或时间戳或日期字符串
* @returns 格式化后的时间字符串 HH:mm:ss
*/
export const formatTime = (date: Date | number | string | null | undefined): string => {
return formatDateTime(date, 'HH:mm:ss')
}
/**
* 相对时间格式化1分钟前、2小时前
* @param date 日期对象或时间戳或日期字符串
* @returns 相对时间字符串
*/
export const formatRelativeTime = (date: Date | number | string | null | undefined): string => {
if (!date) return '-'
const d = typeof date === 'string' || typeof date === 'number'
? new Date(date)
: date
if (isNaN(d.getTime())) return '-'
const now = new Date()
const diff = now.getTime() - d.getTime()
const seconds = Math.floor(diff / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
if (days > 0) {
return `${days}天前`
} else if (hours > 0) {
return `${hours}小时前`
} else if (minutes > 0) {
return `${minutes}分钟前`
} else {
return '刚刚'
}
}

View File

@@ -0,0 +1,8 @@
/**
* 工具函数统一导出
*/
export * from './format'
export * from './mask'
export * from './validator'

View File

@@ -0,0 +1,71 @@
/**
* 数据脱敏工具
*/
/**
* 脱敏手机号
* @param phone 手机号
* @returns 脱敏后的手机号138****8000
*/
export const maskPhone = (phone: string | null | undefined): string => {
if (!phone) return '-'
if (phone.length !== 11) return phone
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
}
/**
* 脱敏身份证号
* @param idCard 身份证号
* @returns 脱敏后的身份证号110101********1234
*/
export const maskIdCard = (idCard: string | null | undefined): string => {
if (!idCard) return '-'
if (idCard.length === 18) {
return idCard.replace(/(\d{6})\d{8}(\d{4})/, '$1********$2')
} else if (idCard.length === 15) {
return idCard.replace(/(\d{6})\d{6}(\d{3})/, '$1******$2')
}
return idCard
}
/**
* 脱敏银行卡号
* @param cardNo 银行卡号
* @returns 脱敏后的银行卡号6222 **** **** 1234
*/
export const maskBankCard = (cardNo: string | null | undefined): string => {
if (!cardNo) return '-'
if (cardNo.length < 8) return cardNo
const start = cardNo.substring(0, 4)
const end = cardNo.substring(cardNo.length - 4)
const middle = '*'.repeat(Math.max(0, cardNo.length - 8))
return `${start} ${middle} ${end}`
}
/**
* 脱敏姓名
* @param name 姓名
* @returns 脱敏后的姓名,如:张*、李**
*/
export const maskName = (name: string | null | undefined): string => {
if (!name) return '-'
if (name.length === 1) return name
if (name.length === 2) return `${name[0]}*`
return `${name[0]}${'*'.repeat(name.length - 2)}${name[name.length - 1]}`
}
/**
* 脱敏邮箱
* @param email 邮箱
* @returns 脱敏后的邮箱abc****@example.com
*/
export const maskEmail = (email: string | null | undefined): string => {
if (!email) return '-'
const [username, domain] = email.split('@')
if (!domain) return email
if (username.length <= 2) {
return `${username[0]}*@${domain}`
}
return `${username.substring(0, 2)}${'*'.repeat(Math.max(0, username.length - 2))}@${domain}`
}

View File

@@ -0,0 +1,191 @@
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios'
import { ElMessage, ElLoading } from 'element-plus'
import { useUserStore } from '@/store'
import type { ApiResponse, RequestConfig } from '@/types/api'
// 创建 axios 实例
const service: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
timeout: 30000,
headers: {
'Content-Type': 'application/json;charset=UTF-8'
}
})
// 请求拦截器
service.interceptors.request.use(
(config: any) => {
const userStore = useUserStore()
// 添加 token
if (userStore.token) {
config.headers.Authorization = `Bearer ${userStore.token}`
}
// 显示 loading
if (config.showLoading !== false) {
config.loadingInstance = ElLoading.service({
lock: true,
text: '加载中...',
background: 'rgba(0, 0, 0, 0.7)'
})
}
return config
},
(error: AxiosError) => {
console.error('请求错误:', error)
return Promise.reject(error)
}
)
// 响应拦截器
service.interceptors.response.use(
(response: AxiosResponse<ApiResponse>) => {
const config = response.config as any
// 关闭 loading
if (config.loadingInstance) {
config.loadingInstance.close()
}
const res = response.data
// 根据业务状态码处理
if (res.code === 200 || res.code === 0) {
return res
} else {
// 业务错误
if (config.showError !== false) {
ElMessage.error(res.message || '请求失败')
}
return Promise.reject(new Error(res.message || '请求失败'))
}
},
(error: AxiosError) => {
const config = error.config as any
// 关闭 loading
if (config?.loadingInstance) {
config.loadingInstance.close()
}
// HTTP 错误处理
if (error.response) {
const status = error.response.status
const userStore = useUserStore()
switch (status) {
case 401:
ElMessage.error('未授权,请重新登录')
userStore.clearUser()
// 可以在这里跳转到登录页
// router.push('/login')
break
case 403:
ElMessage.error('拒绝访问')
break
case 404:
ElMessage.error('请求错误,未找到该资源')
break
case 500:
ElMessage.error('服务器错误')
break
case 502:
ElMessage.error('网关错误')
break
case 503:
ElMessage.error('服务不可用')
break
case 504:
ElMessage.error('网关超时')
break
default:
ElMessage.error(`请求失败: ${error.response.statusText}`)
}
} else if (error.request) {
ElMessage.error('网络错误,请检查网络连接')
} else {
ElMessage.error('请求配置错误')
}
return Promise.reject(error)
}
)
// 封装请求方法
class Request {
/**
* GET 请求
*/
get<T = any>(url: string, params?: any, config?: Partial<RequestConfig>): Promise<ApiResponse<T>> {
return service.request({
url,
method: 'GET',
params,
...config
})
}
/**
* POST 请求
*/
post<T = any>(url: string, data?: any, config?: Partial<RequestConfig>): Promise<ApiResponse<T>> {
return service.request({
url,
method: 'POST',
data,
...config
})
}
/**
* PUT 请求
*/
put<T = any>(url: string, data?: any, config?: Partial<RequestConfig>): Promise<ApiResponse<T>> {
return service.request({
url,
method: 'PUT',
data,
...config
})
}
/**
* DELETE 请求
*/
delete<T = any>(url: string, params?: any, config?: Partial<RequestConfig>): Promise<ApiResponse<T>> {
return service.request({
url,
method: 'DELETE',
params,
...config
})
}
/**
* PATCH 请求
*/
patch<T = any>(url: string, data?: any, config?: Partial<RequestConfig>): Promise<ApiResponse<T>> {
return service.request({
url,
method: 'PATCH',
data,
...config
})
}
/**
* 通用请求方法
*/
request<T = any>(config: RequestConfig): Promise<ApiResponse<T>> {
return service.request(config)
}
}
// 导出请求实例
export const request = new Request()
// 导出 axios 实例(用于特殊需求)
export default service

View File

@@ -0,0 +1,78 @@
/**
* 表单验证工具
*/
/**
* 验证手机号
* @param phone 手机号
* @returns 是否有效
*/
export const validatePhone = (phone: string): boolean => {
const phoneReg = /^1[3-9]\d{9}$/
return phoneReg.test(phone)
}
/**
* 验证邮箱
* @param email 邮箱
* @returns 是否有效
*/
export const validateEmail = (email: string): boolean => {
const emailReg = /^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
return emailReg.test(email)
}
/**
* 验证身份证号
* @param idCard 身份证号
* @returns 是否有效
*/
export const validateIdCard = (idCard: string): boolean => {
const idCardReg = /(^\d{15}$)|(^\d{18}$)|(^\d{17}(\d|X|x)$)/
return idCardReg.test(idCard)
}
/**
* 验证URL
* @param url URL地址
* @returns 是否有效
*/
export const validateUrl = (url: string): boolean => {
try {
new URL(url)
return true
} catch {
return false
}
}
/**
* 验证Cron表达式
* @param cron Cron表达式
* @returns 是否有效
*/
export const validateCron = (cron: string): boolean => {
const cronReg = /^(\*|([0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9])|\*\/([0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9])) (\*|([0-9]|1[0-9]|2[0-3])|\*\/([0-9]|1[0-9]|2[0-3])) (\*|([1-9]|1[0-9]|2[0-9]|3[0-1])|\*\/([1-9]|1[0-9]|2[0-9]|3[0-1])) (\*|([1-9]|1[0-2])|\*\/([1-9]|1[0-2])) (\*|([0-6])|\*\/([0-6]))$/
return cronReg.test(cron)
}
/**
* 验证是否为正整数
* @param value 值
* @returns 是否有效
*/
export const validatePositiveInteger = (value: number | string): boolean => {
const num = typeof value === 'string' ? Number(value) : value
return Number.isInteger(num) && num > 0
}
/**
* 验证是否为正数
* @param value 值
* @returns 是否有效
*/
export const validatePositiveNumber = (value: number | string): boolean => {
const num = typeof value === 'string' ? Number(value) : value
return !isNaN(num) && num > 0
}

View File

@@ -0,0 +1,270 @@
<template>
<div class="dashboard">
<el-row :gutter="20">
<el-col :span="6">
<el-card class="stat-card">
<div class="stat-content">
<div class="stat-icon" style="background-color: #409eff;">
<el-icon><Document /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.dataCollectionTasks }}</div>
<div class="stat-label">数据采集任务</div>
</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stat-card">
<div class="stat-content">
<div class="stat-icon" style="background-color: #67c23a;">
<el-icon><PriceTag /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.tagTasks }}</div>
<div class="stat-label">标签任务</div>
</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stat-card">
<div class="stat-content">
<div class="stat-icon" style="background-color: #e6a23c;">
<el-icon><Filter /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.runningTasks }}</div>
<div class="stat-label">运行中任务</div>
</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stat-card">
<div class="stat-content">
<div class="stat-icon" style="background-color: #f56c6c;">
<el-icon><User /></el-icon>
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.totalUsers }}</div>
<div class="stat-label">用户总数</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="20" style="margin-top: 20px;">
<el-col :span="12">
<el-card>
<template #header>
<div class="card-header">
<span>最近任务</span>
<el-button type="primary" @click="goToTaskList">查看更多</el-button>
</div>
</template>
<el-table :data="recentTasks" style="width: 100%">
<el-table-column prop="name" label="任务名称" />
<el-table-column prop="type" label="类型" width="100">
<template #default="{ row }">
<el-tag v-if="row.type === 'data_collection'" type="primary">数据采集</el-tag>
<el-tag v-else type="success">标签任务</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<StatusBadge :status="row.status" />
</template>
</el-table-column>
<el-table-column prop="updated_at" label="更新时间" width="180" />
</el-table>
</el-card>
</el-col>
<el-col :span="12">
<el-card>
<template #header>
<div class="card-header">
<span>快速操作</span>
</div>
</template>
<div class="quick-actions">
<el-button type="primary" @click="goToCreateDataCollectionTask">
<el-icon><Plus /></el-icon>
创建数据采集任务
</el-button>
<el-button type="success" @click="goToCreateTagTask">
<el-icon><Plus /></el-icon>
创建标签任务
</el-button>
<el-button type="warning" @click="goToTagFilter">
<el-icon><Filter /></el-icon>
标签筛选
</el-button>
<el-button type="info" @click="goToTagQuery">
<el-icon><Search /></el-icon>
标签查询
</el-button>
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { Document, PriceTag, Filter, User, Plus, Search } from '@element-plus/icons-vue'
import StatusBadge from '@/components/StatusBadge/index.vue'
import { useDataCollectionStore } from '@/store'
import { useTagTaskStore } from '@/store'
import { formatDateTime } from '@/utils'
const router = useRouter()
const dataCollectionStore = useDataCollectionStore()
const tagTaskStore = useTagTaskStore()
const stats = ref({
dataCollectionTasks: 0,
tagTasks: 0,
runningTasks: 0,
totalUsers: 0
})
const recentTasks = ref<Array<{
name: string
type: string
status: string
updated_at: string
}>>([])
const loadStats = async () => {
try {
// 加载数据采集任务列表
const collectionResult = await dataCollectionStore.fetchTasks({ page: 1, page_size: 1 })
stats.value.dataCollectionTasks = collectionResult.total
// 统计运行中的任务
const runningCollectionTasks = dataCollectionStore.tasks.filter(
t => t.status === 'running'
).length
// 加载标签任务列表
const tagResult = await tagTaskStore.fetchTasks({ page: 1, page_size: 1 })
stats.value.tagTasks = tagResult.total
// 统计运行中的标签任务
const runningTagTasks = tagTaskStore.tasks.filter(
t => t.status === 'running'
).length
stats.value.runningTasks = runningCollectionTasks + runningTagTasks
// 合并最近任务
const allTasks = [
...dataCollectionStore.tasks.slice(0, 5).map(t => ({
name: t.name,
type: 'data_collection',
status: t.status,
updated_at: formatDateTime(t.updated_at)
})),
...tagTaskStore.tasks.slice(0, 5).map(t => ({
name: t.name,
type: 'tag_task',
status: t.status,
updated_at: formatDateTime(t.updated_at)
}))
]
recentTasks.value = allTasks
.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime())
.slice(0, 5)
// TODO: 加载用户总数(需要后端提供接口)
// stats.value.totalUsers = await getUserTotal()
} catch (error) {
console.error('加载统计数据失败:', error)
}
}
const goToTaskList = () => {
router.push('/data-collection/tasks')
}
const goToCreateDataCollectionTask = () => {
router.push('/data-collection/tasks/create')
}
const goToCreateTagTask = () => {
router.push('/tag-tasks/create')
}
const goToTagFilter = () => {
router.push('/tag-filter')
}
const goToTagQuery = () => {
router.push('/tag-query/user')
}
onMounted(() => {
loadStats()
})
</script>
<style scoped lang="scss">
.dashboard {
.stat-card {
.stat-content {
display: flex;
align-items: center;
gap: 15px;
.stat-icon {
width: 60px;
height: 60px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 24px;
}
.stat-info {
flex: 1;
.stat-value {
font-size: 28px;
font-weight: bold;
color: #303133;
line-height: 1;
margin-bottom: 8px;
}
.stat-label {
font-size: 14px;
color: #909399;
}
}
}
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.quick-actions {
display: flex;
flex-direction: column;
gap: 10px;
.el-button {
justify-content: flex-start;
}
}
}
</style>

View File

@@ -0,0 +1,205 @@
<template>
<div class="task-detail">
<el-card v-loading="loading">
<template #header>
<div class="card-header">
<span>任务详情</span>
<div>
<el-button
v-if="task?.status === 'pending' || task?.status === 'paused'"
type="success"
@click="handleStart"
>
启动
</el-button>
<el-button
v-if="task?.status === 'running'"
type="warning"
@click="handlePause"
>
暂停
</el-button>
<el-button
v-if="task?.status === 'running' || task?.status === 'paused'"
type="danger"
@click="handleStop"
>
停止
</el-button>
<el-button type="primary" @click="handleEdit">编辑</el-button>
</div>
</div>
</template>
<!-- 基本信息 -->
<el-descriptions title="基本信息" :column="2" border>
<el-descriptions-item label="任务名称">{{ task?.name }}</el-descriptions-item>
<el-descriptions-item label="任务状态">
<StatusBadge :status="task?.status || 'pending'" />
</el-descriptions-item>
<el-descriptions-item label="数据源">{{ task?.data_source_id }}</el-descriptions-item>
<el-descriptions-item label="数据库">{{ task?.database }}</el-descriptions-item>
<el-descriptions-item label="集合">{{ task?.collection }}</el-descriptions-item>
<el-descriptions-item label="采集模式">
<el-tag v-if="task?.mode === 'realtime'" type="success">实时</el-tag>
<el-tag v-else type="info">批量</el-tag>
</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatDateTime(task?.created_at) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatDateTime(task?.updated_at) }}</el-descriptions-item>
<el-descriptions-item label="任务描述" :span="2">{{ task?.description || '-' }}</el-descriptions-item>
</el-descriptions>
<!-- 进度信息 -->
<div class="progress-section" v-if="task?.progress">
<h3>进度信息</h3>
<ProgressDisplay
:title="'任务进度'"
:percentage="task.progress.percentage || 0"
:total="task.progress.total_count"
:processed="task.progress.processed_count"
:success="task.progress.success_count"
:error="task.progress.error_count"
:start-time="task.progress.start_time"
:end-time="task.progress.end_time"
/>
</div>
<!-- 字段映射 -->
<div class="field-mappings" v-if="task?.field_mappings?.length">
<h3>字段映射</h3>
<el-table :data="task.field_mappings" border>
<el-table-column prop="source_field" label="源字段" />
<el-table-column prop="target_field" label="目标字段" />
<el-table-column prop="transform" label="转换函数" />
</el-table>
</div>
<!-- 过滤条件 -->
<div class="filter-conditions" v-if="task?.filter_conditions?.length">
<h3>过滤条件</h3>
<el-table :data="task.filter_conditions" border>
<el-table-column prop="field" label="字段" />
<el-table-column prop="operator" label="运算符" />
<el-table-column prop="value" label="值" />
</el-table>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import StatusBadge from '@/components/StatusBadge/index.vue'
import ProgressDisplay from '@/components/ProgressDisplay/index.vue'
import { useDataCollectionStore } from '@/store'
import { formatDateTime } from '@/utils'
const route = useRoute()
const router = useRouter()
const store = useDataCollectionStore()
const loading = computed(() => store.loading)
const task = computed(() => store.currentTask)
let progressTimer: number | null = null
const loadTaskDetail = async () => {
try {
await store.fetchTaskDetail(route.params.id as string)
} catch (error: any) {
ElMessage.error(error.message || '加载任务详情失败')
}
}
const loadProgress = async () => {
if (!task.value) return
try {
await store.fetchTaskProgress(route.params.id as string)
} catch (error) {
// 静默失败
}
}
const handleStart = async () => {
try {
await store.startTask(route.params.id as string)
ElMessage.success('任务已启动')
await loadTaskDetail()
} catch (error: any) {
ElMessage.error(error.message || '启动任务失败')
}
}
const handlePause = async () => {
try {
await store.pauseTask(route.params.id as string)
ElMessage.success('任务已暂停')
await loadTaskDetail()
} catch (error: any) {
ElMessage.error(error.message || '暂停任务失败')
}
}
const handleStop = async () => {
try {
await ElMessageBox.confirm('确定要停止该任务吗?', '提示', {
type: 'warning'
})
await store.stopTask(route.params.id as string)
ElMessage.success('任务已停止')
await loadTaskDetail()
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error(error.message || '停止任务失败')
}
}
}
const handleEdit = () => {
router.push(`/data-collection/tasks/${route.params.id}/edit`)
}
onMounted(() => {
loadTaskDetail()
// 如果任务正在运行,定时刷新进度
progressTimer = window.setInterval(() => {
if (task.value?.status === 'running') {
loadProgress()
}
}, 5000)
})
onUnmounted(() => {
if (progressTimer) {
clearInterval(progressTimer)
}
store.resetCurrentTask()
})
</script>
<style scoped lang="scss">
.task-detail {
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.progress-section,
.field-mappings,
.filter-conditions {
margin-top: 30px;
h3 {
margin-bottom: 15px;
font-size: 16px;
font-weight: 500;
color: #303133;
}
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,382 @@
<template>
<div class="task-list">
<el-card>
<template #header>
<div class="card-header">
<span>数据采集任务列表</span>
<el-button type="primary" @click="handleCreate">
<el-icon><Plus /></el-icon>
创建任务
</el-button>
</div>
</template>
<!-- 搜索和筛选 -->
<div class="filter-bar">
<el-form :inline="true" :model="filters">
<el-form-item label="任务名称">
<el-input
v-model="filters.name"
placeholder="请输入任务名称"
clearable
@clear="handleSearch"
/>
</el-form-item>
<el-form-item label="状态">
<el-select
v-model="filters.status"
placeholder="请选择状态"
clearable
@change="handleSearch"
style="width: 140px"
>
<el-option label="待启动" value="pending" />
<el-option label="运行中" value="running" />
<el-option label="已暂停" value="paused" />
<el-option label="已停止" value="stopped" />
<el-option label="已完成" value="completed" />
<el-option label="错误" value="error" />
</el-select>
</el-form-item>
<el-form-item>
<div class="filter-buttons">
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</div>
</el-form-item>
</el-form>
</div>
<!-- 任务列表 -->
<el-table :data="taskList" v-loading="loading" >
<el-table-column prop="name" label="任务名称" />
<el-table-column prop="data_source_id" label="数据源" width="200">
<template #default="{ row }">
{{ getDataSourceName(row.data_source_id) }}
</template>
</el-table-column>
<el-table-column prop="database" label="数据库" width="200" />
<el-table-column prop="collection" label="集合" width="200" />
<el-table-column prop="mode" label="模式" width="100">
<template #default="{ row }">
<el-tag v-if="row.mode === 'realtime'" type="success">实时</el-tag>
<el-tag v-else type="info">批量</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<StatusBadge :status="row.status" />
</template>
</el-table-column>
<el-table-column prop="progress.percentage" label="进度" width="120">
<template #default="{ row }">
<el-progress
:percentage="row.progress?.percentage || 0"
:status="row.progress?.status === 'error' ? 'exception' : undefined"
/>
</template>
</el-table-column>
<el-table-column prop="updated_at" label="更新时间" width="180">
<template #default="{ row }">
{{ formatDateTime(row.updated_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="220" fixed="right">
<template #default="{ row }">
<div style="display: flex; justify-content:space-between;">
<!-- 状态控制按钮根据任务状态显示 -->
<!-- 启动按钮pending(待启动)paused(已暂停)stopped(已停止)completed(已完成)error(错误) -->
<el-button
v-if="['pending', 'paused', 'stopped', 'completed', 'error'].includes(row.status)"
type="success"
size="small"
@click="handleStart(row)"
>
{{ ['completed', 'error', 'stopped'].includes(row.status) ? '重新启动' : row.status === 'paused' ? '恢复' : '启动' }}
</el-button>
<!-- 暂停按钮仅在 running(运行中) 状态显示 -->
<el-button
v-if="row.status === 'running'"
type="warning"
size="small"
@click="handlePause(row)"
>
暂停
</el-button>
<!-- 停止按钮running(运行中) paused(已暂停) 状态显示 -->
<el-button
v-if="row.status === 'running' || row.status === 'paused'"
type="danger"
size="small"
@click="handleStop(row)"
>
停止
</el-button>
<!-- 其他操作下拉菜单 -->
<el-dropdown trigger="click" @command="(cmd) => handleDropdownCommand(cmd, row)">
<el-button size="small" type="primary" plain>
更多<el-icon class="el-icon--right"><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<!-- 查看所有状态可用 -->
<el-dropdown-item command="view">
<el-icon><View /></el-icon>
查看
</el-dropdown-item>
<!-- 编辑只有非运行中状态可用pending, paused, stopped, completed, error -->
<el-dropdown-item
command="edit"
:disabled="row.status === 'running'"
>
<el-icon><Edit /></el-icon>
编辑
</el-dropdown-item>
<!-- 复制所有状态可用 -->
<el-dropdown-item command="duplicate">
<el-icon><CopyDocument /></el-icon>
复制
</el-dropdown-item>
<!-- 删除所有状态可用但运行中/已暂停时会先停止任务 -->
<el-dropdown-item command="delete" divided>
<el-icon><Delete /></el-icon>
<span style="color: #f56c6c;">删除</span>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, ArrowDown, View, Edit, CopyDocument, Delete } from '@element-plus/icons-vue'
import StatusBadge from '@/components/StatusBadge/index.vue'
import { useDataCollectionStore } from '@/store'
import { formatDateTime } from '@/utils'
import type { DataCollectionTask } from '@/types'
const router = useRouter()
const store = useDataCollectionStore()
const filters = ref({
name: '',
status: ''
})
const pagination = ref({
page: 1,
pageSize: 20,
total: 0
})
const taskList = computed(() => store.tasks)
const loading = computed(() => store.loading)
const dataSources = computed(() => store.dataSources)
// 根据数据源ID获取数据源名称
const getDataSourceName = (dataSourceId: string): string => {
if (!dataSourceId) return '-'
const dataSource = dataSources.value.find(ds => ds.id === dataSourceId)
return dataSource?.name || dataSourceId
}
const loadTasks = async () => {
try {
const result = await store.fetchTasks({
...filters.value,
page: pagination.value.page,
page_size: pagination.value.pageSize
})
pagination.value.total = result.total
} catch (error: any) {
ElMessage.error(error.message || '加载任务列表失败')
}
}
const handleSearch = () => {
pagination.value.page = 1
loadTasks()
}
const handleReset = () => {
filters.value = {
name: '',
status: ''
}
handleSearch()
}
const handleCreate = () => {
router.push('/data-collection/tasks/create')
}
const handleView = (row: any) => {
router.push(`/data-collection/tasks/${row.task_id}`)
}
const handleEdit = (row: DataCollectionTask) => {
// 运行中的任务不允许编辑
if (row.status === 'running') {
ElMessage.warning('运行中的任务不允许编辑,请先停止任务')
return
}
router.push(`/data-collection/tasks/${row.task_id}/edit`)
}
const handleDuplicate = async (row: DataCollectionTask) => {
try {
await store.duplicateTask(row.task_id)
ElMessage.success('任务复制成功')
await loadTasks()
} catch (error: any) {
ElMessage.error(error.message || '复制任务失败')
}
}
const handleStart = async (row: DataCollectionTask) => {
try {
await store.startTask(row.task_id)
ElMessage.success('任务已启动')
await loadTasks()
} catch (error: any) {
ElMessage.error(error.message || '启动任务失败')
}
}
const handlePause = async (row: DataCollectionTask) => {
try {
await store.pauseTask(row.task_id)
ElMessage.success('任务已暂停')
await loadTasks()
} catch (error: any) {
ElMessage.error(error.message || '暂停任务失败')
}
}
const handleStop = async (row: DataCollectionTask) => {
try {
await ElMessageBox.confirm('确定要停止该任务吗?', '提示', {
type: 'warning'
})
await store.stopTask(row.task_id)
ElMessage.success('任务已停止')
await loadTasks()
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error(error.message || '停止任务失败')
}
}
}
const handleDelete = async (row: DataCollectionTask) => {
try {
// 运行中或已暂停的任务需要特别提示(后端会自动先停止)
const isRunningOrPaused = row.status === 'running' || row.status === 'paused'
const confirmMessage = isRunningOrPaused
? `任务当前状态为"${row.status === 'running' ? '运行中' : '已暂停'}",删除任务将先停止该任务。确定要删除吗?`
: '确定要删除该任务吗?'
await ElMessageBox.confirm(confirmMessage, '提示', {
type: 'warning'
})
// 传递当前筛选和分页参数,保持列表状态
// store.deleteTask 内部会自动刷新列表,不需要再次调用 loadTasks()
const result = await store.deleteTask(row.task_id, {
...filters.value,
page: pagination.value.page,
page_size: pagination.value.pageSize
})
pagination.value.total = result.total
ElMessage.success('任务已删除')
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error(error.message || '删除任务失败')
}
}
}
const handleSizeChange = () => {
loadTasks()
}
const handlePageChange = () => {
loadTasks()
}
// 处理下拉菜单命令
const handleDropdownCommand = (command: string, row: DataCollectionTask) => {
switch (command) {
case 'view':
handleView(row)
break
case 'edit':
// 编辑按钮可能被禁用,但点击时仍会触发,这里再次检查
if (row.status === 'running') {
ElMessage.warning('运行中的任务不允许编辑,请先停止任务')
return
}
handleEdit(row)
break
case 'duplicate':
handleDuplicate(row)
break
case 'delete':
handleDelete(row)
break
}
}
onMounted(async () => {
// 加载数据源列表,用于显示数据源名称
await store.fetchDataSources()
loadTasks()
})
</script>
<style scoped lang="scss">
.task-list {
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.filter-bar {
margin-bottom: 20px;
.filter-buttons{
.el-button{margin-left: 20px;}
}
}
.pagination {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
}
.el-button+.el-button {
margin-left: 0px;
}
</style>

View File

@@ -0,0 +1,268 @@
<template>
<div class="data-source-form">
<el-card>
<template #header>
<div class="card-header">
<span>{{ isEdit ? '编辑数据源' : '创建数据源' }}</span>
</div>
</template>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="120px"
>
<!-- 基本信息 -->
<el-divider>基本信息</el-divider>
<el-form-item label="数据源名称" prop="name">
<el-input v-model="form.name" placeholder="请输入数据源名称" />
</el-form-item>
<el-form-item label="数据源类型" prop="type">
<el-select v-model="form.type" placeholder="请选择数据源类型" @change="handleTypeChange">
<el-option label="MongoDB" value="mongodb" />
<el-option label="MySQL" value="mysql" />
<el-option label="PostgreSQL" value="postgresql" />
</el-select>
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input
v-model="form.description"
type="textarea"
:rows="3"
placeholder="请输入描述"
/>
</el-form-item>
<!-- 连接配置 -->
<el-divider>连接配置</el-divider>
<el-form-item label="主机地址" prop="host">
<el-input v-model="form.host" placeholder="例如: 192.168.1.106" />
</el-form-item>
<el-form-item label="端口" prop="port">
<el-input-number v-model="form.port" :min="1" :max="65535" placeholder="例如: 27017" />
</el-form-item>
<el-form-item label="数据库名" prop="database">
<el-input v-model="form.database" 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="isEdit ? '留空则不修改密码' : '请输入密码'"
show-password
/>
</el-form-item>
<el-form-item
v-if="form.type === 'mongodb'"
label="认证数据库"
prop="auth_source"
>
<el-input v-model="form.auth_source" placeholder="例如: admin" />
<div class="form-tip">MongoDB认证数据库默认为admin</div>
</el-form-item>
<!-- 标签引擎数据库标识 -->
<el-form-item label="标签引擎数据库" prop="is_tag_engine">
<el-switch
v-model="form.is_tag_engine"
active-text=""
inactive-text=""
/>
<div class="form-tip">
标识此数据源是否为标签引擎数据库ckb数据库设置为"是"系统会自动将其他数据源设置为"否"确保只有一个标签引擎数据库
</div>
</el-form-item>
<!-- 状态 -->
<el-form-item label="状态" prop="status">
<el-radio-group v-model="form.status">
<el-radio :label="1">启用</el-radio>
<el-radio :label="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
<!-- 操作按钮 -->
<el-form-item>
<el-button type="primary" @click="handleTestConnection" :loading="testing">
测试连接
</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitting">
保存
</el-button>
<el-button @click="handleCancel">取消</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElForm } from 'element-plus'
import { useDataSourceStore } from '@/store/dataSource'
import type { DataSource } from '@/api/dataSource'
const route = useRoute()
const router = useRouter()
const formRef = ref<InstanceType<typeof ElForm>>()
const store = useDataSourceStore()
const isEdit = computed(() => !!route.params.id)
const testing = ref(false)
const submitting = ref(false)
const form = reactive<Partial<DataSource>>({
name: '',
type: 'mongodb',
host: '',
port: 27017,
database: '',
username: '',
password: '',
auth_source: 'admin',
description: '',
is_tag_engine: false,
status: 1,
})
const rules = {
name: [{ required: true, message: '请输入数据源名称', trigger: 'blur' }],
type: [{ required: true, message: '请选择数据源类型', trigger: 'change' }],
host: [{ required: true, message: '请输入主机地址', trigger: 'blur' }],
port: [{ required: true, message: '请输入端口', trigger: 'blur' }],
database: [{ required: true, message: '请输入数据库名', trigger: 'blur' }],
}
const handleTypeChange = () => {
// 根据类型设置默认端口
if (form.type === 'mongodb') {
form.port = 27017
form.auth_source = 'admin'
} else if (form.type === 'mysql') {
form.port = 3306
form.auth_source = undefined
} else if (form.type === 'postgresql') {
form.port = 5432
form.auth_source = undefined
}
}
const handleTestConnection = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (!valid) return
testing.value = true
try {
const connected = await store.testConnection({
type: form.type!,
host: form.host!,
port: form.port!,
database: form.database!,
username: form.username,
password: form.password,
auth_source: form.auth_source,
})
// 统一在这里显示成功/失败消息
if (connected) {
ElMessage.success('连接测试成功')
} else {
ElMessage.error('连接测试失败')
}
} catch (error: any) {
// store 内部已经显示了错误消息,这里不需要重复显示
console.error('连接测试失败:', error)
} finally {
testing.value = false
}
})
}
const handleSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (!valid) return
submitting.value = true
try {
const formData = { ...form }
// 如果是编辑且密码为空,则不提交密码字段
if (isEdit.value && !formData.password) {
delete formData.password
}
if (isEdit.value) {
await store.updateDataSource(route.params.id as string, formData)
} else {
await store.createDataSource(formData)
}
router.push('/data-sources')
} catch (error: any) {
ElMessage.error(error.message || '保存失败')
} finally {
submitting.value = false
}
})
}
const handleCancel = () => {
router.back()
}
const loadDataSourceDetail = async () => {
if (!isEdit.value) return
try {
const dataSource = await store.fetchDataSourceDetail(route.params.id as string)
if (dataSource) {
Object.assign(form, {
name: dataSource.name,
type: dataSource.type,
host: dataSource.host,
port: dataSource.port,
database: dataSource.database,
username: dataSource.username || '',
password: '', // 不加载密码
auth_source: dataSource.auth_source || 'admin',
description: dataSource.description || '',
is_tag_engine: dataSource.is_tag_engine || false,
status: dataSource.status,
})
}
} catch (error: any) {
ElMessage.error(error.message || '加载数据源详情失败')
}
}
onMounted(() => {
if (isEdit.value) {
loadDataSourceDetail()
}
})
</script>
<style scoped lang="scss">
.data-source-form {
.card-header {
font-weight: 500;
font-size: 16px;
}
.form-tip {
font-size: 12px;
color: #909399;
margin-top: 5px;
}
}
</style>

View File

@@ -0,0 +1,189 @@
<template>
<div class="data-source-list">
<el-card>
<template #header>
<div class="card-header">
<span>数据源列表</span>
<el-button type="primary" @click="handleCreate">
<el-icon><Plus /></el-icon>
添加数据源
</el-button>
</div>
</template>
<!-- 筛选条件 -->
<el-form :inline="true" :model="filters" class="filter-form">
<el-form-item label="类型">
<el-select v-model="filters.type" placeholder="请选择类型" clearable @change="loadDataSources">
<el-option label="MongoDB" value="mongodb" />
<el-option label="MySQL" value="mysql" />
<el-option label="PostgreSQL" value="postgresql" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" placeholder="请选择状态" clearable @change="loadDataSources">
<el-option label="启用" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
</el-form-item>
<el-form-item label="名称">
<el-input v-model="filters.name" placeholder="请输入名称" clearable @change="loadDataSources" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="loadDataSources">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
<!-- 数据源列表 -->
<el-table :data="dataSources" border v-loading="loading">
<el-table-column prop="name" label="名称" width="200" />
<el-table-column prop="type" label="类型" width="120">
<template #default="{ row }">
<el-tag v-if="row.type === 'mongodb'" type="success">MongoDB</el-tag>
<el-tag v-else-if="row.type === 'mysql'" type="primary">MySQL</el-tag>
<el-tag v-else-if="row.type === 'postgresql'" type="info">PostgreSQL</el-tag>
<el-tag v-else>{{ row.type }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="host" label="主机" width="150" />
<el-table-column prop="port" label="端口" width="100" />
<el-table-column prop="database" label="数据库" width="150" />
<el-table-column prop="username" label="用户名" width="150" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag v-if="row.status === 1" type="success">启用</el-tag>
<el-tag v-else type="danger">禁用</el-tag>
</template>
</el-table-column>
<el-table-column prop="is_tag_engine" label="标签引擎" width="120">
<template #default="{ row }">
<el-tag v-if="row.is_tag_engine" type="warning"></el-tag>
<el-tag v-else type="info"></el-tag>
</template>
</el-table-column>
<el-table-column prop="description" label="描述" />
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button type="primary" size="small" @click="handleEdit(row)">编辑</el-button>
<el-button type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="loadDataSources"
@current-change="loadDataSources"
/>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import { useDataSourceStore } from '@/store/dataSource'
import type { DataSource } from '@/api/dataSource'
const router = useRouter()
const store = useDataSourceStore()
const loading = ref(false)
const dataSources = ref<DataSource[]>([])
const filters = reactive({
type: '',
status: undefined as number | undefined,
name: '',
})
const pagination = reactive({
page: 1,
pageSize: 20,
total: 0,
})
const loadDataSources = async () => {
loading.value = true
try {
const result = await store.fetchDataSources({
type: filters.type || undefined,
status: filters.status,
name: filters.name || undefined,
page: pagination.page,
page_size: pagination.pageSize,
})
dataSources.value = result.data_sources
pagination.total = result.total
} catch (error) {
console.error('加载数据源列表失败:', error)
} finally {
loading.value = false
}
}
const handleCreate = () => {
router.push('/data-sources/create')
}
const handleEdit = (row: DataSource) => {
router.push(`/data-sources/${row.data_source_id}/edit`)
}
const handleDelete = async (row: DataSource) => {
try {
await ElMessageBox.confirm(`确定要删除数据源 "${row.name}" 吗?`, '提示', {
type: 'warning',
})
await store.deleteDataSource(row.data_source_id)
await loadDataSources()
} catch (error: any) {
if (error !== 'cancel') {
console.error('删除数据源失败:', error)
}
}
}
const handleReset = () => {
filters.type = ''
filters.status = undefined
filters.name = ''
pagination.page = 1
loadDataSources()
}
onMounted(() => {
loadDataSources()
})
</script>
<style scoped lang="scss">
.data-source-list {
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.filter-form {
margin-bottom: 20px;
}
.pagination {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
}
</style>

View File

@@ -0,0 +1,75 @@
<template>
<div class="home-container">
<el-container>
<el-header>
<h1>Task Show</h1>
</el-header>
<el-main>
<el-card>
<template #header>
<div class="card-header">
<span>欢迎使用</span>
</div>
</template>
<p>这是一个基于 Vue3 + Element Plus + Pinia + TypeScript + Axios 的基础工程</p>
<el-button type="primary" @click="handleTestRequest">测试请求</el-button>
</el-card>
</el-main>
</el-container>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { request } from '@/utils/request'
import { useUserStore } from '@/store'
const userStore = useUserStore()
onMounted(() => {
// 初始化 token
userStore.initToken()
})
const handleTestRequest = async () => {
try {
// 示例:测试 GET 请求
const response = await request.get('/test', { id: 1 })
ElMessage.success('请求成功: ' + JSON.stringify(response))
} catch (error) {
console.error('请求失败:', error)
}
}
</script>
<style scoped>
.home-container {
width: 100%;
min-height: 100vh;
}
.el-header {
background-color: #409eff;
color: white;
display: flex;
align-items: center;
padding: 0 20px;
}
.el-header h1 {
margin: 0;
font-size: 24px;
}
.el-main {
padding: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>

View File

@@ -0,0 +1,221 @@
<template>
<div class="tag-data-list-form">
<el-card>
<template #header>
<div class="card-header">
<span>{{ isEdit ? '编辑数据列表' : '创建数据列表' }}</span>
</div>
</template>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="120px"
>
<!-- 基本信息 -->
<el-divider>基本信息</el-divider>
<el-form-item label="列表名称" prop="list_name">
<el-input v-model="form.list_name" placeholder="例如: 消费记录表" />
<div class="form-tip">数据列表的显示名称</div>
</el-form-item>
<el-form-item label="列表编码" prop="list_code">
<el-input v-model="form.list_code" placeholder="例如: consumption_records" :disabled="isEdit" />
<div class="form-tip">唯一标识创建后不可修改</div>
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input
v-model="form.description"
type="textarea"
:rows="3"
placeholder="请输入数据列表描述(可选)"
/>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="form.status">
<el-radio :label="1">启用</el-radio>
<el-radio :label="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
<!-- 查询配置 -->
<el-divider>查询配置</el-divider>
<div class="form-tip" style="margin-bottom: 20px;">
通过可视化界面配置MongoDB查询支持过滤条件联表查询排序等功能
</div>
<QueryBuilder v-model="queryConfig" />
<!-- 操作按钮 -->
<el-form-item style="margin-top: 30px;">
<el-button type="primary" @click="handleSubmit" :loading="submitting">保存</el-button>
<el-button @click="handleCancel">取消</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElForm } from 'element-plus'
import QueryBuilder from '@/components/QueryBuilder/QueryBuilder.vue'
import request from '@/utils/request'
const route = useRoute()
const router = useRouter()
const formRef = ref<InstanceType<typeof ElForm>>()
const submitting = ref(false)
const isEdit = computed(() => !!route.params.id)
// 表单数据
const form = reactive({
list_name: '',
list_code: '',
description: '',
status: 1
})
// 查询配置
const queryConfig = reactive({
data_source_id: '',
database: '',
collection: '',
filter: [],
lookups: [],
sort_field: '',
sort_order: '1',
limit: 1000
})
// 验证规则
const rules = {
list_name: [{ required: true, message: '请输入列表名称', trigger: 'blur' }],
list_code: [{ required: true, message: '请输入列表编码', trigger: 'blur' }]
}
// 提交表单
const handleSubmit = async () => {
if (!formRef.value) return
// 验证查询配置
const hasCollection = queryConfig.multi_collection
? (queryConfig.collections && queryConfig.collections.length > 0)
: queryConfig.collection
if (!queryConfig.data_source_id || !queryConfig.database || !hasCollection) {
ElMessage.warning('请完成查询配置(数据源、数据库、集合)')
return
}
await formRef.value.validate(async (valid) => {
if (valid) {
submitting.value = true
try {
const submitData = {
list_name: form.list_name,
list_code: form.list_code,
description: form.description || undefined,
status: form.status,
data_source_id: queryConfig.data_source_id,
database: queryConfig.database,
collection: queryConfig.multi_collection ? (queryConfig.collections[0] || '') : queryConfig.collection,
multi_collection: queryConfig.multi_collection,
collections: queryConfig.multi_collection ? queryConfig.collections : undefined,
query_config: {
filter: queryConfig.filter,
lookups: queryConfig.lookups,
sort: queryConfig.sort_field ? {
[queryConfig.sort_field]: parseInt(queryConfig.sort_order)
} : undefined,
limit: queryConfig.limit
}
}
// TODO: 替换为真实API
// if (isEdit.value) {
// await request.put(`/tag-data-lists/${route.params.id}`, submitData)
// } else {
// await request.post('/tag-data-lists', submitData)
// }
// Mock保存
await new Promise(resolve => setTimeout(resolve, 500))
console.log('保存的数据:', submitData)
ElMessage.success(isEdit.value ? '数据列表更新成功Mock' : '数据列表创建成功Mock')
router.push('/tag-data-lists')
} catch (error: any) {
ElMessage.error(error.message || '保存失败')
} finally {
submitting.value = false
}
}
})
}
// 取消
const handleCancel = () => {
router.back()
}
// 加载详情
const loadDetail = async () => {
if (!isEdit.value) return
try {
const response = await request.get(`/tag-data-lists/${route.params.id}`)
if (response.code === 200) {
const data = response.data
Object.assign(form, {
list_name: data.list_name || '',
list_code: data.list_code || '',
description: data.description || '',
status: data.status ?? 1
})
// 加载查询配置
if (data.query_config) {
Object.assign(queryConfig, {
data_source_id: data.data_source_id || '',
database: data.database || '',
collection: data.collection || '',
multi_collection: data.multi_collection || false,
collections: data.collections || [],
filter: data.query_config.filter || [],
lookups: data.query_config.lookups || [],
sort_field: data.query_config.sort ? Object.keys(data.query_config.sort)[0] : '',
sort_order: data.query_config.sort ? String(Object.values(data.query_config.sort)[0]) : '1',
limit: data.query_config.limit || 1000
})
}
}
} catch (error: any) {
ElMessage.error(error.message || '加载详情失败')
}
}
onMounted(() => {
if (isEdit.value) {
loadDetail()
}
})
</script>
<style scoped lang="scss">
.tag-data-list-form {
.card-header {
font-weight: 500;
font-size: 16px;
}
.form-tip {
font-size: 12px;
color: #909399;
margin-top: 5px;
line-height: 1.5;
}
}
</style>

View File

@@ -0,0 +1,244 @@
<template>
<div class="tag-data-list-page">
<el-card>
<template #header>
<div class="card-header">
<span>数据列表管理</span>
<el-button type="primary" @click="handleCreate">
<el-icon><Plus /></el-icon>
创建数据列表
</el-button>
</div>
</template>
<!-- 搜索栏 -->
<el-form :inline="true" :model="searchForm" class="search-form">
<el-form-item label="列表名称">
<el-input
v-model="searchForm.list_name"
placeholder="请输入列表名称"
clearable
@clear="handleSearch"
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="searchForm.status" placeholder="请选择状态" clearable @change="handleSearch">
<el-option label="启用" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
<!-- 数据表格 -->
<el-table :data="dataList" v-loading="loading" border>
<el-table-column prop="list_name" label="列表名称" min-width="150" />
<el-table-column prop="list_code" label="列表编码" width="180" />
<el-table-column prop="data_source_id" label="数据源ID" width="150" />
<el-table-column prop="database" label="数据库" width="120" />
<el-table-column prop="collection" label="主集合" width="150" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag v-if="row.status === 1" type="success">启用</el-tag>
<el-tag v-else type="info">禁用</el-tag>
</template>
</el-table-column>
<el-table-column prop="description" label="描述" min-width="200" show-overflow-tooltip />
<el-table-column prop="create_time" label="创建时间" width="180">
<template #default="{ row }">
{{ formatDateTime(row.create_time) }}
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button type="primary" size="small" @click="handleEdit(row)">编辑</el-button>
<el-button type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.page_size"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import request from '@/utils/request'
import { formatDateTime } from '@/utils'
const router = useRouter()
const loading = ref(false)
const dataList = ref<any[]>([])
const searchForm = reactive({
list_name: '',
status: undefined as number | undefined
})
const pagination = reactive({
page: 1,
page_size: 20,
total: 0
})
// 加载数据
const loadData = async () => {
try {
loading.value = true
// TODO: 替换为真实API
// const response = await request.get('/tag-data-lists', params)
// Mock数据
await new Promise(resolve => setTimeout(resolve, 500))
const mockData = [
{
list_id: 'list_001',
list_code: 'consumption_records',
list_name: '消费记录表',
data_source_id: 'source_001',
database: 'tag_engine',
collection: 'consumption_records',
description: '用于标签定义的消费记录数据',
status: 1,
create_time: new Date().toISOString()
},
{
list_id: 'list_002',
list_code: 'user_profile',
list_name: '用户档案表',
data_source_id: 'source_001',
database: 'tag_engine',
collection: 'user_profile',
description: '用户基本信息和统计数据',
status: 1,
create_time: new Date().toISOString()
}
]
// 应用筛选
let filteredData = mockData
if (searchForm.list_name) {
filteredData = filteredData.filter(item =>
item.list_name.includes(searchForm.list_name)
)
}
if (searchForm.status !== undefined) {
filteredData = filteredData.filter(item => item.status === searchForm.status)
}
dataList.value = filteredData
pagination.total = filteredData.length
} catch (error: any) {
ElMessage.error(error.message || '加载数据失败')
} finally {
loading.value = false
}
}
// 搜索
const handleSearch = () => {
pagination.page = 1
loadData()
}
// 重置
const handleReset = () => {
searchForm.list_name = ''
searchForm.status = undefined
handleSearch()
}
// 创建
const handleCreate = () => {
router.push('/tag-data-lists/create')
}
// 编辑
const handleEdit = (row: any) => {
router.push(`/tag-data-lists/${row.list_id}/edit`)
}
// 删除
const handleDelete = async (row: any) => {
try {
await ElMessageBox.confirm(
`确定要删除数据列表"${row.list_name}"吗?`,
'提示',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
// TODO: 替换为真实API
// await request.delete(`/tag-data-lists/${row.list_id}`)
// Mock删除
await new Promise(resolve => setTimeout(resolve, 300))
ElMessage.success('删除成功Mock')
loadData()
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error(error.message || '删除失败')
}
}
}
// 分页变化
const handleSizeChange = () => {
loadData()
}
const handlePageChange = () => {
loadData()
}
onMounted(() => {
loadData()
})
</script>
<style scoped lang="scss">
.tag-data-list-page {
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 500;
font-size: 16px;
}
.search-form {
margin-bottom: 20px;
}
.pagination {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
}
</style>

View File

@@ -0,0 +1,191 @@
# 数据列表管理界面
## 文件说明
- `List.vue` - 数据列表管理列表页面
- `Form.vue` - 数据列表配置表单页面
## 功能说明
### List.vue列表页面
**功能**
- 显示所有数据列表配置
- 搜索和筛选数据列表
- 创建、编辑、删除数据列表
- 分页展示
**Mock数据**
- 默认显示2条示例数据
- 支持按名称和状态筛选
### Form.vue配置表单页面
**功能**
- 创建/编辑数据列表配置
- 基本信息配置(名称、编码、描述、状态)
- 使用QueryBuilder组件配置查询
**配置项**
1. **基本信息**
- 列表名称(必填)
- 列表编码(必填,创建后不可修改)
- 描述(可选)
- 状态(启用/禁用)
2. **查询配置**通过QueryBuilder组件
- 数据源选择
- 数据库选择
- 主集合选择
- 过滤条件配置
- 联表查询配置
- 排序和限制配置
- 查询预览
## 使用流程
1. 访问 `/tag-data-lists` 查看列表
2. 点击"创建数据列表"进入配置页面
3. 填写基本信息
4. 使用QueryBuilder配置查询
- 选择数据源、数据库、集合
- 添加过滤条件WHERE
- 添加联表查询JOIN
- 配置排序和限制
- 点击"预览数据"查看效果
5. 保存配置
## Mock数据说明
当前所有API都使用Mock数据标记为 `TODO: 替换为真实API`
### List.vue Mock数据
```javascript
// 默认2条数据列表
[
{
list_id: 'list_001',
list_name: '消费记录表',
collection: 'consumption_records'
},
{
list_id: 'list_002',
list_name: '用户档案表',
collection: 'user_profile'
}
]
```
### QueryBuilder Mock数据
```javascript
// 数据源
dataSources = [{ data_source_id: 'source_001', name: 'MongoDB标签引擎数据源' }]
// 数据库
databases = ['tag_engine', 'business_db', 'analytics_db']
// 集合(根据数据库动态变化)
collections = ['consumption_records', 'user_profile', 'user_phone_relations', ...]
// 字段(根据集合动态变化)
// consumption_records: 交易金额、店铺名称、交易状态、手机号、用户ID
// user_profile: 用户ID、总消费金额、总消费次数、最后消费时间
// 预览数据
previewData = [3条示例消费记录]
```
### TagDefinition/Form.vue Mock数据
```javascript
// 数据列表下拉
dataLists = [
{ list_id: 'list_001', list_name: '消费记录表' },
{ list_id: 'list_002', list_name: '用户档案表' }
]
// 字段列表(根据选择的数据列表动态变化)
```
## 界面展示效果
### 数据列表管理List.vue
```
┌─────────────────────────────────────────────────────┐
│ 数据列表管理 [创建数据列表] │
├─────────────────────────────────────────────────────┤
│ 搜索:[列表名称] [状态] [查询] [重置] │
├─────────────────────────────────────────────────────┤
│ 列表名称 | 列表编码 | 数据源 | 数据库 | 状态 | 操作 │
│ 消费记录表 | consumption_records | ... | 启用 | 编辑 删除│
│ 用户档案表 | user_profile | ... | 启用 | 编辑 删除│
└─────────────────────────────────────────────────────┘
```
### 数据列表配置Form.vue
```
┌─────────────────────────────────────────────────────┐
│ 创建数据列表 │
├─────────────────────────────────────────────────────┤
│ ══ 基本信息 ══ │
│ 列表名称:[消费记录表] │
│ 列表编码:[consumption_records] │
│ 描述:[用于标签定义的消费记录数据] │
│ 状态:⦿ 启用 ○ 禁用 │
├─────────────────────────────────────────────────────┤
│ ══ 查询配置 ══ │
│ ┌─ 基础配置 ─────────────────────────────────────┐ │
│ │ 数据源:[MongoDB数据源] │ │
│ │ 数据库:[tag_engine] │ │
│ │ 主集合:[consumption_records] │ │
│ └─────────────────────────────────────────────────┘ │
│ ┌─ 过滤条件WHERE ────────────── [添加条件] ──┐ │
│ │ 逻辑 | 字段 | 运算符 | 值 | 操作 │ │
│ │ - | 交易状态 | 等于 | success | 删除 │ │
│ └─────────────────────────────────────────────────┘ │
│ ┌─ 联表查询JOIN ───────────── [添加关联] ───┐ │
│ │ 关联集合 | 主字段 | 关联字段 | 结果名 | 操作 │ │
│ │ user_profile | user_id | user_id | user_info | 删除│ │
│ └─────────────────────────────────────────────────┘ │
│ ┌─ 排序和限制 ────────────────────────────────────┐│
│ │ 排序字段:[create_time] 排序方式:[降序] ││
│ │ 限制数量:[1000] ││
│ └─────────────────────────────────────────────────┘│
│ ┌─ 查询预览 ─────────────────── [预览数据] ─────┐│
│ │ db.consumption_records.aggregate([ ││
│ │ { $match: { status: { $eq: "success" } } }, ││
│ │ { $lookup: { ... } }, ││
│ │ { $sort: { create_time: -1 } }, ││
│ │ { $limit: 1000 } ││
│ │ ]) ││
│ │ ││
│ │ [预览数据表格] ││
│ └─────────────────────────────────────────────────┘│
├─────────────────────────────────────────────────────┤
│ [保存] [取消] │
└─────────────────────────────────────────────────────┘
```
## API接口待开发
```
GET /api/tag-data-lists - 获取数据列表列表
POST /api/tag-data-lists - 创建数据列表
GET /api/tag-data-lists/{id} - 获取数据列表详情
PUT /api/tag-data-lists/{id} - 更新数据列表
DELETE /api/tag-data-lists/{id} - 删除数据列表
GET /api/tag-data-lists/{id}/fields - 获取数据列表字段
GET /api/data-sources/{id}/databases - 获取数据库列表
GET /api/data-sources/{id}/collections - 获取集合列表
GET /api/data-sources/{id}/fields - 获取字段列表
POST /api/data-sources/preview-query - 预览查询结果
```
## 注意事项
1. 所有API调用都标记了 `TODO: 替换为真实API`
2. 保存时会在控制台打印提交的数据,方便调试
3. 界面完全可用等后端API开发完成后只需删除Mock代码即可

View File

@@ -0,0 +1,130 @@
<template>
<div class="tag-definition-detail">
<el-card v-loading="loading">
<template #header>
<div class="card-header">
<span>标签定义详情</span>
<el-button type="primary" @click="handleEdit">编辑</el-button>
</div>
</template>
<!-- 基本信息 -->
<el-descriptions title="基本信息" :column="2" border>
<el-descriptions-item label="标签编码">{{ tag?.tag_code }}</el-descriptions-item>
<el-descriptions-item label="标签名称">{{ tag?.tag_name }}</el-descriptions-item>
<el-descriptions-item label="分类">{{ tag?.category }}</el-descriptions-item>
<el-descriptions-item label="规则类型">
<el-tag type="primary">{{ tag?.rule_type }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="更新频率">
<el-tag v-if="tag?.update_frequency === 'real_time'" type="success">实时</el-tag>
<el-tag v-else>{{ tag?.update_frequency }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag v-if="tag?.status === 0" type="success">启用</el-tag>
<el-tag v-else type="info">禁用</el-tag>
</el-descriptions-item>
<el-descriptions-item label="描述" :span="2">{{ tag?.description || '-' }}</el-descriptions-item>
</el-descriptions>
<!-- 规则配置 -->
<div class="rule-config-section" v-if="tag?.rule_config">
<h3>规则配置</h3>
<el-descriptions :column="1" border>
<el-descriptions-item label="规则类型">
{{ tag.rule_config.rule_type }}
</el-descriptions-item>
<el-descriptions-item label="标签值">
{{ tag.rule_config.tag_value }}
</el-descriptions-item>
<el-descriptions-item label="置信度">
{{ tag.rule_config.confidence }}
</el-descriptions-item>
</el-descriptions>
<h4 style="margin-top: 20px; margin-bottom: 10px;">规则条件</h4>
<el-table :data="tag.rule_config.conditions" border>
<el-table-column prop="field" label="字段" />
<el-table-column prop="operator" label="运算符" />
<el-table-column prop="value" label="值" />
</el-table>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import type { TagDefinition } from '@/types'
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const tag = ref<TagDefinition | null>(null)
const loadTagDetail = async () => {
loading.value = true
try {
// TODO: 调用API加载标签详情
// const response = await request.get(`/tag-definitions/${route.params.id}`)
// tag.value = response.data
// 模拟数据
tag.value = {
tag_id: route.params.id as string,
tag_code: 'high_consumer',
tag_name: '高消费用户',
category: '消费能力',
description: '总消费金额大于等于5000的用户',
rule_type: 'simple',
rule_config: {
rule_type: 'simple',
conditions: [
{ field: 'total_amount', operator: '>=', value: 5000 }
],
tag_value: 'high',
confidence: 1.0
},
update_frequency: 'real_time',
status: 0
}
} catch (error) {
ElMessage.error('加载标签详情失败')
} finally {
loading.value = false
}
}
const handleEdit = () => {
router.push(`/tag-definitions/${route.params.id}/edit`)
}
onMounted(() => {
loadTagDetail()
})
</script>
<style scoped lang="scss">
.tag-definition-detail {
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.rule-config-section {
margin-top: 30px;
h3 {
margin-bottom: 15px;
font-size: 16px;
font-weight: 500;
color: #303133;
}
}
}
</style>

View File

@@ -0,0 +1,543 @@
<template>
<div class="tag-definition-form">
<el-card>
<template #header>
<div class="card-header">
<span>{{ isEdit ? '编辑标签定义' : '创建标签定义' }}</span>
</div>
</template>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="120px"
>
<!-- 基本信息 -->
<el-divider>基本信息</el-divider>
<el-form-item label="标签编码" prop="tag_code">
<el-input v-model="form.tag_code" placeholder="例如: consumer_level" :disabled="isEdit" />
<div class="form-tip">唯一标识创建后不可修改</div>
</el-form-item>
<el-form-item label="标签名称" prop="tag_name">
<el-input v-model="form.tag_name" placeholder="例如: 消费等级" />
</el-form-item>
<el-form-item label="分类" prop="category">
<el-select v-model="form.category" placeholder="请选择分类(可选)" clearable>
<el-option label="消费能力" value="消费能力" />
<el-option label="活跃度" value="活跃度" />
<el-option label="风险等级" value="风险等级" />
<el-option label="生命周期" value="生命周期" />
</el-select>
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input
v-model="form.description"
type="textarea"
:rows="3"
placeholder="请输入标签描述(可选)"
/>
</el-form-item>
<el-form-item label="更新频率" prop="update_frequency">
<el-select v-model="form.update_frequency" placeholder="请选择更新频率(可选)" clearable>
<el-option label="实时" value="real_time" />
<el-option label="每日" value="daily" />
<el-option label="每周" value="weekly" />
<el-option label="每月" value="monthly" />
</el-select>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="form.status">
<el-radio :label="0">启用</el-radio>
<el-radio :label="1">禁用</el-radio>
</el-radio-group>
</el-form-item>
<!-- 规则配置 -->
<el-divider>规则配置</el-divider>
<!-- 数据列表选择 -->
<el-form-item label="数据列表" prop="data_list_id">
<el-select
v-model="form.data_list_id"
placeholder="请选择数据列表"
filterable
@change="handleDataListChange"
:loading="dataListLoading"
>
<el-option
v-for="list in dataLists"
:key="list.list_id"
:label="list.list_name"
:value="list.list_id"
/>
</el-select>
<div class="form-tip">选择数据列表后将显示该列表的字段供选择</div>
</el-form-item>
<!-- 规则类型选择 -->
<el-form-item label="规则类型" prop="rule_type">
<el-radio-group v-model="form.rule_type" @change="handleRuleTypeChange">
<el-radio label="simple">运算规则</el-radio>
<el-radio label="regex">正则规则</el-radio>
</el-radio-group>
<div class="form-tip">
<span v-if="form.rule_type === 'simple'">运算规则使用运算符比较字段值</span>
<span v-else>正则规则使用正则表达式匹配字符串字段</span>
</div>
</el-form-item>
<!-- 规则条件 -->
<el-form-item label="规则条件" prop="conditions">
<el-button type="primary" @click="handleAddCondition" :disabled="!form.data_list_id">
<el-icon><Plus /></el-icon>
添加条件
</el-button>
<div v-if="!form.data_list_id" class="form-tip" style="color: #f56c6c;">
请先选择数据列表
</div>
<el-table
:data="form.rule_config.conditions"
border
style="margin-top: 10px"
v-if="form.rule_config.conditions && form.rule_config.conditions.length > 0"
>
<el-table-column label="字段" width="200">
<template #default="{ row }">
<el-select
v-model="row.field"
placeholder="请选择字段"
@change="handleFieldChange(row)"
:loading="fieldsLoading"
>
<el-option
v-for="field in fields"
:key="field.field_name"
:label="field.field"
:value="field.field"
:disabled="field.type === 'object' || field.type === 'array'"
>
<span>{{ field.field }}</span>
<span style="color: #909399; margin-left: 8px;">({{ field.type }})</span>
</el-option>
</el-select>
</template>
</el-table-column>
<el-table-column label="运算符" width="180">
<template #default="{ row }">
<!-- 运算规则运算符 -->
<el-select
v-if="form.rule_type === 'simple'"
v-model="row.operator"
placeholder="请选择运算符"
>
<el-option label="大于" value=">" />
<el-option label="大于等于" value=">=" />
<el-option label="小于" value="<" />
<el-option label="小于等于" value="<=" />
<el-option label="等于" value="=" />
<el-option label="不等于" value="!=" />
<el-option label="在列表中" value="in" />
<el-option label="不在列表中" value="not_in" />
</el-select>
<!-- 正则规则运算符 -->
<el-input
v-else
v-model="row.operator"
placeholder="例如: /淘宝/"
>
<template #prepend>正则</template>
</el-input>
</template>
</el-table-column>
<el-table-column label="值" width="200">
<template #default="{ row }">
<!-- 运算规则值 -->
<el-input-number
v-if="form.rule_type === 'simple' && row.operator !== 'in' && row.operator !== 'not_in'"
v-model="row.value"
style="width: 100%"
:precision="0"
/>
<el-input
v-else-if="form.rule_type === 'simple'"
v-model="row.value"
placeholder='例如: ["值1","值2"]'
type="textarea"
:rows="2"
/>
<!-- 正则规则值 -->
<el-switch
v-else
v-model="row.value"
active-text="匹配"
inactive-text="不匹配"
/>
</template>
</el-table-column>
<el-table-column label="标签值" width="200">
<template #default="{ row }">
<el-input
v-model="row.tag_value"
placeholder="满足条件时的标签值"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ $index }">
<el-button
type="danger"
size="small"
@click="handleRemoveCondition($index)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="form.rule_config.conditions && form.rule_config.conditions.length > 0" class="form-tip">
注意一个item代表一个条件满足任一条件即使用该条件的标签值
</div>
</el-form-item>
<!-- 操作按钮 -->
<el-form-item>
<el-button type="primary" @click="handleSubmit" :loading="submitting">保存</el-button>
<el-button @click="handleCancel">取消</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElForm } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import { useTagDefinitionStore } from '@/store'
import { getTagDefinitionDetail, createTagDefinition, updateTagDefinition } from '@/api/tagDefinition'
import request from '@/utils/request'
const route = useRoute()
const router = useRouter()
const formRef = ref<InstanceType<typeof ElForm>>()
const store = useTagDefinitionStore()
const isEdit = computed(() => !!route.params.id)
const submitting = ref(false)
const dataListLoading = ref(false)
const fieldsLoading = ref(false)
// 数据列表
const dataLists = ref<Array<{ list_id: string; list_name: string }>>([])
const fields = ref<Array<{ field: string; field_name: string; type: string; description?: string }>>([])
// 表单数据
const form = reactive({
tag_code: '',
tag_name: '',
category: '',
description: '',
rule_type: 'simple' as 'simple' | 'regex',
data_list_id: '',
data_list_name: '',
rule_config: {
rule_type: 'simple' as 'simple' | 'regex',
data_list_id: '',
data_list_name: '',
conditions: [] as Array<{
field: string
field_name: string
operator: string
value: any
tag_value: string
}>
},
update_frequency: 'real_time',
status: 0
})
// 验证规则
const rules = {
tag_code: [{ required: true, message: '请输入标签编码', trigger: 'blur' }],
tag_name: [{ required: true, message: '请输入标签名称', trigger: 'blur' }],
data_list_id: [{ required: true, message: '请选择数据列表', trigger: 'change' }],
conditions: [
{
validator: (rule: any, value: any[], callback: Function) => {
if (!value || value.length === 0) {
callback(new Error('至少添加一个规则条件'))
} else {
// 验证每个条件
for (let i = 0; i < value.length; i++) {
const condition = value[i]
if (!condition.field) {
callback(new Error(`${i + 1}个条件:请选择字段`))
return
}
if (!condition.operator) {
callback(new Error(`${i + 1}个条件:请选择运算符`))
return
}
if (condition.value === undefined || condition.value === null || condition.value === '') {
callback(new Error(`${i + 1}个条件:请输入值`))
return
}
if (!condition.tag_value) {
callback(new Error(`${i + 1}个条件:请输入标签值`))
return
}
}
callback()
}
},
trigger: 'change'
}
]
}
// 加载数据列表
const loadDataLists = async () => {
try {
dataListLoading.value = true
// TODO: 替换为真实API
// const response = await request.get('/tag-data-lists', { status: 1 })
// Mock数据
await new Promise(resolve => setTimeout(resolve, 300))
dataLists.value = [
{
list_id: 'list_001',
list_name: '消费记录表'
},
{
list_id: 'list_002',
list_name: '用户档案表'
}
]
} catch (error: any) {
ElMessage.error(error.message || '加载数据列表失败')
} finally {
dataListLoading.value = false
}
}
// 数据列表变化
const handleDataListChange = async (listId: string) => {
if (!listId) {
fields.value = []
form.data_list_name = ''
form.rule_config.data_list_id = ''
form.rule_config.data_list_name = ''
form.rule_config.conditions = []
return
}
const selectedList = dataLists.value.find(l => l.list_id === listId)
if (selectedList) {
form.data_list_name = selectedList.list_name
form.rule_config.data_list_id = listId
form.rule_config.data_list_name = selectedList.list_name
}
// 加载字段列表
await loadFields(listId)
}
// 加载字段列表
const loadFields = async (listId: string) => {
try {
fieldsLoading.value = true
// TODO: 替换为真实API
// const response = await request.get(`/tag-data-lists/${listId}/fields`)
// Mock数据
await new Promise(resolve => setTimeout(resolve, 300))
if (listId === 'list_001') {
// 消费记录表字段
fields.value = [
{ field: '交易金额', field_name: 'amount', type: 'number', description: '交易金额' },
{ field: '店铺名称', field_name: 'shop_name', type: 'string', description: '店铺名称' },
{ field: '交易状态', field_name: 'status', type: 'string', description: '交易状态' },
{ field: '手机号', field_name: 'phone', type: 'string', description: '手机号' },
{ field: '用户ID', field_name: 'user_id', type: 'string', description: '用户ID' }
]
} else if (listId === 'list_002') {
// 用户档案表字段
fields.value = [
{ field: '用户ID', field_name: 'user_id', type: 'string', description: '用户ID' },
{ field: '总消费金额', field_name: 'total_amount', type: 'number', description: '总消费金额' },
{ field: '总消费次数', field_name: 'total_count', type: 'number', description: '总消费次数' },
{ field: '最后消费时间', field_name: 'last_consume_time', type: 'datetime', description: '最后消费时间' }
]
} else {
fields.value = []
}
} catch (error: any) {
ElMessage.error(error.message || '加载字段列表失败')
fields.value = []
} finally {
fieldsLoading.value = false
}
}
// 字段变化
const handleFieldChange = (row: any) => {
const field = fields.value.find(f => f.field === row.field)
if (field) {
row.field_name = field.field_name
}
}
// 规则类型变化
const handleRuleTypeChange = (type: string) => {
form.rule_config.rule_type = type as 'simple' | 'regex'
// 重置所有条件
form.rule_config.conditions.forEach(condition => {
condition.operator = ''
condition.value = type === 'regex' ? true : ''
})
}
// 添加条件
const handleAddCondition = () => {
if (!form.data_list_id) {
ElMessage.warning('请先选择数据列表')
return
}
form.rule_config.conditions = form.rule_config.conditions || []
form.rule_config.conditions.push({
field: '',
field_name: '',
operator: form.rule_type === 'regex' ? '/淘宝/' : '>=',
value: form.rule_type === 'regex' ? true : '',
tag_value: ''
})
}
// 删除条件
const handleRemoveCondition = (index: number) => {
form.rule_config.conditions?.splice(index, 1)
}
// 提交表单
const handleSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (valid) {
submitting.value = true
try {
// 准备提交数据
const submitData = {
tag_code: form.tag_code,
tag_name: form.tag_name,
category: form.category || undefined,
description: form.description || undefined,
rule_type: form.rule_type,
rule_config: {
rule_type: form.rule_type,
data_list_id: form.data_list_id,
data_list_name: form.data_list_name,
conditions: form.rule_config.conditions.map(condition => ({
field: condition.field,
field_name: condition.field_name,
operator: condition.operator,
value: condition.value,
tag_value: condition.tag_value
}))
},
update_frequency: form.update_frequency || undefined,
status: form.status
}
if (isEdit.value) {
await updateTagDefinition(route.params.id as string, submitData)
ElMessage.success('标签定义更新成功')
} else {
await createTagDefinition(submitData)
ElMessage.success('标签定义创建成功')
}
router.push('/tag-definitions')
} catch (error: any) {
ElMessage.error(error.message || '保存失败')
} finally {
submitting.value = false
}
}
})
}
// 取消
const handleCancel = () => {
router.back()
}
// 加载标签详情
const loadTagDetail = async () => {
if (!isEdit.value) return
try {
const tag = await getTagDefinitionDetail(route.params.id as string)
if (tag) {
Object.assign(form, {
tag_code: tag.tag_code,
tag_name: tag.tag_name,
category: tag.category || '',
description: tag.description || '',
rule_type: tag.rule_type || 'simple',
update_frequency: tag.update_frequency || 'real_time',
status: tag.status
})
// 设置规则配置
if (tag.rule_config) {
form.rule_config = {
rule_type: tag.rule_config.rule_type || tag.rule_type || 'simple',
data_list_id: tag.rule_config.data_list_id || '',
data_list_name: tag.rule_config.data_list_name || '',
conditions: tag.rule_config.conditions || []
}
form.data_list_id = form.rule_config.data_list_id
form.data_list_name = form.rule_config.data_list_name
// 加载字段列表
if (form.data_list_id) {
await loadFields(form.data_list_id)
}
}
}
} catch (error: any) {
ElMessage.error(error.message || '加载标签详情失败')
}
}
onMounted(async () => {
await loadDataLists()
if (isEdit.value) {
await loadTagDetail()
}
})
</script>
<style scoped lang="scss">
.tag-definition-form {
.card-header {
font-weight: 500;
font-size: 16px;
}
.form-tip {
font-size: 12px;
color: #909399;
margin-top: 5px;
line-height: 1.5;
}
}
</style>

View File

@@ -0,0 +1,239 @@
<template>
<div class="tag-definition-list">
<el-card>
<template #header>
<div class="card-header">
<span>标签定义列表</span>
<el-button type="primary" @click="handleCreate">
<el-icon><Plus /></el-icon>
创建标签
</el-button>
</div>
</template>
<!-- 搜索和筛选 -->
<div class="filter-bar">
<el-form :inline="true" :model="filters">
<el-form-item label="标签名称">
<el-input
v-model="filters.name"
placeholder="请输入标签名称"
clearable
@clear="handleSearch"
/>
</el-form-item>
<el-form-item label="分类">
<el-select
v-model="filters.category"
placeholder="请选择分类"
clearable
@change="handleSearch"
>
<el-option label="消费能力" value="消费能力" />
<el-option label="活跃度" value="活跃度" />
<el-option label="风险等级" value="风险等级" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select
v-model="filters.status"
placeholder="请选择状态"
clearable
@change="handleSearch"
>
<el-option label="启用" :value="0" />
<el-option label="禁用" :value="1" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</div>
<!-- 标签列表 -->
<el-table :data="tagList" v-loading="loading" style="width: 100%">
<el-table-column prop="tag_code" label="标签编码" width="180" />
<el-table-column prop="tag_name" label="标签名称" min-width="150" />
<el-table-column prop="category" label="分类" width="120" />
<el-table-column prop="rule_type" label="规则类型" width="120">
<template #default="{ row }">
<el-tag v-if="row.rule_type === 'simple'" type="primary">简单规则</el-tag>
<el-tag v-else type="info">{{ row.rule_type }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="update_frequency" label="更新频率" width="120">
<template #default="{ row }">
<el-tag v-if="row.update_frequency === 'real_time'" type="success">实时</el-tag>
<el-tag v-else-if="row.update_frequency === 'daily'" type="warning">每日</el-tag>
<el-tag v-else>{{ row.update_frequency }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag v-if="row.status === 0" type="success">启用</el-tag>
<el-tag v-else type="info">禁用</el-tag>
</template>
</el-table-column>
<el-table-column prop="updated_at" label="更新时间" width="180">
<template #default="{ row }">
{{ formatDateTime(row.updated_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button
type="primary"
size="small"
@click="handleView(row)"
>
查看
</el-button>
<el-button
type="info"
size="small"
@click="handleEdit(row)"
>
编辑
</el-button>
<el-button
type="danger"
size="small"
@click="handleDelete(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import { useTagDefinitionStore } from '@/store'
import { formatDateTime } from '@/utils'
import type { TagDefinition } from '@/types'
const router = useRouter()
const store = useTagDefinitionStore()
const filters = ref({
name: '',
category: '',
status: undefined as number | undefined
})
const pagination = ref({
page: 1,
pageSize: 20,
total: 0
})
const tagList = computed(() => store.definitions)
const loading = computed(() => store.loading)
const loadTags = async () => {
try {
const result = await store.fetchDefinitions({
...filters.value,
page: pagination.value.page,
page_size: pagination.value.pageSize
})
pagination.value.total = result.total
} catch (error: any) {
ElMessage.error(error.message || '加载标签列表失败')
}
}
const handleSearch = () => {
pagination.value.page = 1
loadTags()
}
const handleReset = () => {
filters.value = {
name: '',
category: '',
status: undefined
}
handleSearch()
}
const handleCreate = () => {
router.push('/tag-definitions/create')
}
const handleView = (row: any) => {
router.push(`/tag-definitions/${row.tag_id}`)
}
const handleEdit = (row: any) => {
router.push(`/tag-definitions/${row.tag_id}/edit`)
}
const handleDelete = async (row: any) => {
try {
await ElMessageBox.confirm('确定要删除该标签定义吗?', '提示', {
type: 'warning'
})
await store.deleteDefinition(row.tag_id)
ElMessage.success('标签定义已删除')
await loadTags()
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error(error.message || '删除标签定义失败')
}
}
}
const handleSizeChange = () => {
loadTags()
}
const handlePageChange = () => {
loadTags()
}
onMounted(() => {
loadTags()
})
</script>
<style scoped lang="scss">
.tag-definition-list {
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.filter-bar {
margin-bottom: 20px;
}
.pagination {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
}
</style>

View File

@@ -0,0 +1,312 @@
<template>
<div class="tag-filter">
<el-card>
<template #header>
<div class="card-header">
<span>标签筛选</span>
</div>
</template>
<!-- 条件配置 -->
<div class="conditions-section">
<h3>筛选条件</h3>
<el-form :inline="true">
<el-form-item label="逻辑关系">
<el-radio-group v-model="logic">
<el-radio label="AND">AND所有条件都满足</el-radio>
<el-radio label="OR">OR任一条件满足</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<el-button type="primary" @click="handleAddCondition" style="margin-bottom: 15px;">
<el-icon><Plus /></el-icon>
添加条件
</el-button>
<el-table :data="conditions" border>
<el-table-column label="标签" width="200">
<template #default="{ row }">
<el-select
v-model="row.tag_code"
placeholder="请选择标签"
filterable
style="width: 100%"
>
<el-option
v-for="tag in tagDefinitions"
:key="tag.tag_code"
:label="tag.tag_name"
:value="tag.tag_code"
/>
</el-select>
</template>
</el-table-column>
<el-table-column label="运算符" width="150">
<template #default="{ row }">
<el-select v-model="row.operator">
<el-option label="等于" value="eq" />
<el-option label="不等于" value="ne" />
<el-option label="大于" value="gt" />
<el-option label="大于等于" value="gte" />
<el-option label="小于" value="lt" />
<el-option label="小于等于" value="lte" />
<el-option label="在列表中" value="in" />
<el-option label="不在列表中" value="nin" />
<el-option label="包含" value="contains" />
<el-option label="不包含" value="not_contains" />
</el-select>
</template>
</el-table-column>
<el-table-column label="值">
<template #default="{ row }">
<el-input v-model="row.value" placeholder="值" />
</template>
</el-table-column>
<el-table-column label="操作" width="100">
<template #default="{ $index }">
<el-button
type="danger"
size="small"
@click="handleRemoveCondition($index)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<!-- 操作按钮 -->
<div class="action-buttons">
<el-button type="primary" @click="handleSearch" :loading="searching">
查询
</el-button>
<el-button @click="handleReset">重置</el-button>
<el-button type="success" @click="handleSaveCohort" :disabled="!hasResults">
保存为人群快照
</el-button>
</div>
<!-- 结果展示 -->
<div class="results-section" v-if="hasResults">
<h3>查询结果</h3>
<div class="result-stats">
<el-alert
:title="`共找到 ${pagination.total} 个用户`"
type="info"
:closable="false"
/>
</div>
<el-table :data="results" border style="margin-top: 15px">
<el-table-column prop="user_id" label="用户ID" width="200" />
<el-table-column prop="name" label="姓名" width="120" />
<el-table-column prop="phone" label="手机号" width="150">
<template #default="{ row }">
{{ maskPhone(row.phone) }}
</template>
</el-table-column>
<el-table-column prop="total_amount" label="总消费金额" width="120">
<template #default="{ row }">
¥{{ row.total_amount?.toFixed(2) || '0.00' }}
</template>
</el-table-column>
<el-table-column prop="total_count" label="消费次数" width="100" />
<el-table-column label="操作" width="100">
<template #default="{ row }">
<el-button type="primary" @click="handleViewUser(row.user_id)">
查看标签
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</div>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import { useTagDefinitionStore } from '@/store'
import * as tagQueryApi from '@/api/tagQuery'
import * as tagCohortApi from '@/api/tagCohort'
import { maskPhone } from '@/utils'
import type { TagCondition, UserInfo } from '@/types'
const router = useRouter()
const tagDefinitionStore = useTagDefinitionStore()
const logic = ref<'AND' | 'OR'>('AND')
const conditions = ref<TagCondition[]>([])
const searching = ref(false)
const results = ref<UserInfo[]>([])
const pagination = ref({
page: 1,
pageSize: 20,
total: 0
})
const tagDefinitions = computed(() => tagDefinitionStore.definitions)
const hasResults = computed(() => results.value.length > 0 || pagination.value.total > 0)
const loadTagDefinitions = async () => {
try {
await tagDefinitionStore.getActiveDefinitions()
} catch (error: any) {
ElMessage.error(error.message || '加载标签定义失败')
}
}
const handleAddCondition = () => {
conditions.value.push({
tag_code: '',
operator: 'eq',
value: ''
})
}
const handleRemoveCondition = (index: number) => {
conditions.value.splice(index, 1)
}
const handleSearch = async () => {
if (conditions.value.length === 0) {
ElMessage.warning('请至少添加一个筛选条件')
return
}
searching.value = true
try {
const response = await tagQueryApi.filterUsersByTags({
tag_conditions: conditions.value,
logic: logic.value,
page: pagination.value.page,
page_size: pagination.value.pageSize,
include_user_info: true
})
results.value = response.data.users
pagination.value.total = response.data.total
} catch (error: any) {
ElMessage.error(error.message || '查询失败')
} finally {
searching.value = false
}
}
const handleReset = () => {
conditions.value = []
results.value = []
pagination.value = {
page: 1,
pageSize: 20,
total: 0
}
}
const handleSaveCohort = async () => {
try {
const { value: name } = await ElMessageBox.prompt('请输入人群快照名称', '保存人群快照', {
confirmButtonText: '保存',
cancelButtonText: '取消',
inputPattern: /.+/,
inputErrorMessage: '快照名称不能为空'
})
await tagCohortApi.createTagCohort({
name: name,
conditions: conditions.value,
logic: logic.value,
user_ids: results.value.map(u => u.user_id!)
})
ElMessage.success('人群快照保存成功')
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error(error.message || '保存人群快照失败')
}
}
}
const handleViewUser = (userId: string) => {
router.push(`/tag-query/user?user_id=${userId}`)
}
// maskPhone 已从 utils 导入
const handleSizeChange = () => {
handleSearch()
}
const handlePageChange = () => {
handleSearch()
}
onMounted(() => {
loadTagDefinitions()
})
</script>
<style scoped lang="scss">
.tag-filter {
.card-header {
font-weight: 500;
font-size: 16px;
}
.conditions-section {
margin-bottom: 30px;
h3 {
margin-bottom: 15px;
font-size: 16px;
font-weight: 500;
color: #303133;
}
}
.action-buttons {
margin-bottom: 20px;
display: flex;
gap: 10px;
}
.results-section {
margin-top: 30px;
h3 {
margin-bottom: 15px;
font-size: 16px;
font-weight: 500;
color: #303133;
}
.result-stats {
margin-bottom: 15px;
}
.pagination {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
}
}
</style>

View File

@@ -0,0 +1,193 @@
<template>
<div class="tag-history">
<el-card>
<template #header>
<div class="card-header">
<span>标签历史</span>
</div>
</template>
<!-- 查询表单 -->
<el-form :inline="true" :model="queryForm" @submit.prevent="handleQuery">
<el-form-item label="用户ID">
<el-input
v-model="queryForm.user_id"
placeholder="请输入用户ID"
clearable
/>
</el-form-item>
<el-form-item label="标签">
<el-select
v-model="queryForm.tag_id"
placeholder="请选择标签"
filterable
clearable
>
<el-option
v-for="tag in tagDefinitions"
:key="tag.tag_id"
:label="tag.tag_name"
:value="tag.tag_id"
/>
</el-select>
</el-form-item>
<el-form-item label="时间范围">
<el-date-picker
v-model="queryForm.dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleQuery" :loading="loading">
查询
</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
<!-- 历史记录表格 -->
<el-table :data="historyList" v-loading="loading" border style="margin-top: 20px">
<el-table-column prop="user_id" label="用户ID" width="200" />
<el-table-column prop="tag_name" label="标签名称" width="150" />
<el-table-column prop="old_value" label="旧值" width="150">
<template #default="{ row }">
<span v-if="row.old_value">{{ row.old_value }}</span>
<span v-else style="color: #909399;">-</span>
</template>
</el-table-column>
<el-table-column prop="new_value" label="新值" width="150" />
<el-table-column prop="change_reason" label="变更原因" width="150" />
<el-table-column prop="change_time" label="变更时间" width="180" />
<el-table-column prop="operator" label="操作人" width="120" />
</el-table>
<!-- 分页 -->
<div class="pagination">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import type { TagDefinition } from '@/types'
const route = useRoute()
const loading = ref(false)
const historyList = ref([])
const tagDefinitions = ref<TagDefinition[]>([])
const queryForm = reactive({
user_id: '',
tag_id: '',
dateRange: null as [Date, Date] | null
})
const pagination = ref({
page: 1,
pageSize: 20,
total: 0
})
const loadTagDefinitions = async () => {
try {
// TODO: 调用API加载标签定义列表
// const response = await request.get('/tag-definitions', { status: 0 })
// tagDefinitions.value = response.data.definitions
// 模拟数据
tagDefinitions.value = []
} catch (error) {
ElMessage.error('加载标签定义失败')
}
}
const handleQuery = async () => {
loading.value = true
try {
// TODO: 调用历史查询API
// const response = await request.get('/tags/history', {
// user_id: queryForm.user_id,
// tag_id: queryForm.tag_id,
// start_date: queryForm.dateRange?.[0],
// end_date: queryForm.dateRange?.[1],
// page: pagination.value.page,
// page_size: pagination.value.pageSize
// })
// historyList.value = response.data.items
// pagination.value.total = response.data.total
// 模拟数据
historyList.value = []
pagination.value.total = 0
} catch (error) {
ElMessage.error('查询历史记录失败')
} finally {
loading.value = false
}
}
const handleReset = () => {
queryForm.user_id = ''
queryForm.tag_id = ''
queryForm.dateRange = null
pagination.value.page = 1
handleQuery()
}
const handleSizeChange = () => {
handleQuery()
}
const handlePageChange = () => {
handleQuery()
}
onMounted(() => {
loadTagDefinitions()
// 如果URL中有参数自动查询
const userId = route.query.user_id as string
const tagId = route.query.tag_id as string
if (userId) {
queryForm.user_id = userId
}
if (tagId) {
queryForm.tag_id = tagId
}
if (userId || tagId) {
handleQuery()
}
})
</script>
<style scoped lang="scss">
.tag-history {
.card-header {
font-weight: 500;
font-size: 16px;
}
.pagination {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
}
</style>

View File

@@ -0,0 +1,165 @@
<template>
<div class="tag-statistics">
<el-card>
<template #header>
<div class="card-header">
<span>标签统计</span>
</div>
</template>
<!-- 筛选条件 -->
<el-form :inline="true" :model="filters" style="margin-bottom: 20px;">
<el-form-item label="标签">
<el-select
v-model="filters.tag_id"
placeholder="请选择标签"
filterable
clearable
>
<el-option
v-for="tag in tagDefinitions"
:key="tag.tag_id"
:label="tag.tag_name"
:value="tag.tag_id"
/>
</el-select>
</el-form-item>
<el-form-item label="时间范围">
<el-date-picker
v-model="filters.dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleQuery">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
<!-- 统计图表 -->
<div class="charts-section" v-if="hasData">
<el-row :gutter="20">
<el-col :span="12">
<el-card>
<template #header>
<span>标签值分布</span>
</template>
<div id="valueDistributionChart" style="height: 400px;"></div>
</el-card>
</el-col>
<el-col :span="12">
<el-card>
<template #header>
<span>标签趋势</span>
</template>
<div id="trendChart" style="height: 400px;"></div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="20" style="margin-top: 20px;">
<el-col :span="24">
<el-card>
<template #header>
<span>标签覆盖度统计</span>
</template>
<el-table :data="coverageStats" border>
<el-table-column prop="tag_name" label="标签名称" />
<el-table-column prop="total_users" label="总用户数" />
<el-table-column prop="tagged_users" label="已打标签用户数" />
<el-table-column prop="coverage_rate" label="覆盖率">
<template #default="{ row }">
<el-progress :percentage="row.coverage_rate" />
</template>
</el-table-column>
</el-table>
</el-card>
</el-col>
</el-row>
</div>
<!-- 空状态 -->
<el-empty v-if="!hasData" description="请选择标签并查询统计数据" />
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import type { TagDefinition } from '@/types'
// import * as echarts from 'echarts' // 需要安装 echarts
const tagDefinitions = ref<TagDefinition[]>([])
const hasData = ref(false)
const coverageStats = ref([])
const filters = reactive({
tag_id: '',
dateRange: null as [Date, Date] | null
})
const loadTagDefinitions = async () => {
try {
// TODO: 调用API加载标签定义列表
// const response = await request.get('/tag-definitions', { status: 0 })
// tagDefinitions.value = response.data.definitions
// 模拟数据
tagDefinitions.value = []
} catch (error) {
ElMessage.error('加载标签定义失败')
}
}
const handleQuery = async () => {
if (!filters.tag_id) {
ElMessage.warning('请选择标签')
return
}
try {
// TODO: 调用统计API
// const response = await request.get('/tags/statistics', {
// tag_id: filters.tag_id,
// start_date: filters.dateRange?.[0],
// end_date: filters.dateRange?.[1]
// })
// 处理统计数据并渲染图表
hasData.value = true
// TODO: 初始化图表
// initCharts(response.data)
} catch (error) {
ElMessage.error('查询统计数据失败')
}
}
const handleReset = () => {
filters.tag_id = ''
filters.dateRange = null
hasData.value = false
}
onMounted(() => {
loadTagDefinitions()
})
</script>
<style scoped lang="scss">
.tag-statistics {
.card-header {
font-weight: 500;
font-size: 16px;
}
.charts-section {
margin-top: 20px;
}
}
</style>

View File

@@ -0,0 +1,204 @@
<template>
<div class="user-tag-query">
<el-card>
<template #header>
<div class="card-header">
<span>用户标签查询</span>
</div>
</template>
<!-- 查询表单 -->
<el-form :inline="true" :model="queryForm" @submit.prevent="handleQuery">
<el-form-item label="用户ID">
<el-input
v-model="queryForm.user_id"
placeholder="请输入用户ID"
clearable
/>
</el-form-item>
<el-form-item label="手机号">
<el-input
v-model="queryForm.phone"
placeholder="请输入手机号"
clearable
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleQuery" :loading="loading">
查询
</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
<!-- 用户信息 -->
<div class="user-info" v-if="userInfo">
<el-descriptions title="用户基本信息" :column="2" border>
<el-descriptions-item label="用户ID">{{ userInfo.user_id }}</el-descriptions-item>
<el-descriptions-item label="姓名">{{ userInfo.name || '-' }}</el-descriptions-item>
<el-descriptions-item label="手机号">{{ maskPhone(userInfo.phone) }}</el-descriptions-item>
<el-descriptions-item label="总消费金额">
¥{{ userInfo.total_amount?.toFixed(2) || '0.00' }}
</el-descriptions-item>
<el-descriptions-item label="消费次数">{{ userInfo.total_count || 0 }}</el-descriptions-item>
<el-descriptions-item label="最后消费时间">
{{ userInfo.last_consume_time || '-' }}
</el-descriptions-item>
</el-descriptions>
</div>
<!-- 标签列表 -->
<div class="tags-section" v-if="tags.length > 0">
<div class="section-header">
<h3>用户标签</h3>
<el-button type="primary" @click="handleRecalculate">
重新计算标签
</el-button>
</div>
<el-table :data="tags" border>
<el-table-column prop="tag_name" label="标签名称" width="150" />
<el-table-column prop="tag_code" label="标签编码" width="180" />
<el-table-column prop="category" label="分类" width="120" />
<el-table-column prop="tag_value" label="标签值" width="150" />
<el-table-column prop="confidence" label="置信度" width="100">
<template #default="{ row }">
<el-progress :percentage="row.confidence" :show-text="false" />
<span style="margin-left: 10px;">{{ row.confidence }}%</span>
</template>
</el-table-column>
<el-table-column prop="update_time" label="更新时间" width="180" />
<el-table-column label="操作" width="120">
<template #default="{ row }">
<el-button type="primary" @click="handleViewHistory(row.tag_id)">
查看历史
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<!-- 空状态 -->
<el-empty v-if="!loading && !userInfo && !queryForm.user_id && !queryForm.phone" description="请输入用户ID或手机号进行查询" />
<el-empty v-if="!loading && userInfo && tags.length === 0" description="该用户暂无标签" />
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import type { UserInfo, UserTag } from '@/types'
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const userInfo = ref<UserInfo | null>(null)
const tags = ref<UserTag[]>([])
const queryForm = reactive({
user_id: '',
phone: ''
})
const handleQuery = async () => {
if (!queryForm.user_id && !queryForm.phone) {
ElMessage.warning('请输入用户ID或手机号')
return
}
loading.value = true
try {
// TODO: 根据用户ID或手机号查询用户信息
// 然后调用标签查询API
// const response = await request.get(`/users/${userId}/tags`)
// tags.value = response.data.tags
// 模拟数据
userInfo.value = {
user_id: queryForm.user_id || 'user-1',
name: '张三',
phone: '13800138000',
total_amount: 5000,
total_count: 10
}
tags.value = []
} catch (error) {
ElMessage.error('查询失败')
} finally {
loading.value = false
}
}
const handleReset = () => {
queryForm.user_id = ''
queryForm.phone = ''
userInfo.value = null
tags.value = []
}
const handleRecalculate = async () => {
if (!userInfo.value) return
try {
// TODO: 调用重新计算API
// await request.put(`/users/${userInfo.value.user_id}/tags`)
ElMessage.success('标签重新计算成功')
handleQuery()
} catch (error) {
ElMessage.error('重新计算失败')
}
}
const handleViewHistory = (tagId: string) => {
router.push(`/tag-query/history?user_id=${userInfo.value?.user_id}&tag_id=${tagId}`)
}
const maskPhone = (phone?: string) => {
if (!phone) return '-'
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
}
onMounted(() => {
// 如果URL中有user_id参数自动查询
const userId = route.query.user_id as string
if (userId) {
queryForm.user_id = userId
handleQuery()
}
})
</script>
<style scoped lang="scss">
.user-tag-query {
.card-header {
font-weight: 500;
font-size: 16px;
}
.user-info {
margin-top: 20px;
margin-bottom: 30px;
}
.tags-section {
margin-top: 30px;
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
h3 {
margin: 0;
font-size: 16px;
font-weight: 500;
color: #303133;
}
}
}
}
</style>

View File

@@ -0,0 +1,198 @@
<template>
<div class="tag-task-detail">
<el-card v-loading="loading">
<template #header>
<div class="card-header">
<span>标签任务详情</span>
<div>
<el-button
v-if="task?.status === 'pending' || task?.status === 'paused'"
type="success"
@click="handleStart"
>
启动
</el-button>
<el-button
v-if="task?.status === 'running'"
type="warning"
@click="handlePause"
>
暂停
</el-button>
<el-button
v-if="task?.status === 'running' || task?.status === 'paused'"
type="danger"
@click="handleStop"
>
停止
</el-button>
<el-button type="primary" @click="handleEdit">编辑</el-button>
</div>
</div>
</template>
<!-- 基本信息 -->
<el-descriptions title="基本信息" :column="2" border>
<el-descriptions-item label="任务名称">{{ task?.name }}</el-descriptions-item>
<el-descriptions-item label="任务状态">
<StatusBadge :status="task?.status || 'pending'" />
</el-descriptions-item>
<el-descriptions-item label="任务类型">
<el-tag v-if="task?.task_type === 'full'" type="primary">全量计算</el-tag>
<el-tag v-else-if="task?.task_type === 'incremental'" type="success">增量计算</el-tag>
<el-tag v-else type="info">指定用户</el-tag>
</el-descriptions-item>
<el-descriptions-item label="目标标签数量">
{{ task?.target_tag_ids?.length || 0 }}
</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatDateTime(task?.created_at) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatDateTime(task?.updated_at) }}</el-descriptions-item>
<el-descriptions-item label="任务描述" :span="2">{{ task?.description || '-' }}</el-descriptions-item>
</el-descriptions>
<!-- 进度信息 -->
<div class="progress-section" v-if="task?.progress">
<h3>进度信息</h3>
<ProgressDisplay
:title="'任务进度'"
:percentage="task.progress.percentage || 0"
:total="task.progress.total_users"
:processed="task.progress.processed_users"
:success="task.progress.success_count"
:error="task.progress.error_count"
/>
</div>
<!-- 执行记录 -->
<div class="executions-section">
<h3>执行记录</h3>
<el-table :data="executions" border>
<el-table-column prop="started_at" label="开始时间" width="180">
<template #default="{ row }">
{{ formatDateTime(row.started_at) }}
</template>
</el-table-column>
<el-table-column prop="finished_at" label="结束时间" width="180">
<template #default="{ row }">
{{ formatDateTime(row.finished_at) }}
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<StatusBadge :status="row.status" />
</template>
</el-table-column>
<el-table-column prop="processed_users" label="处理用户数" width="120" />
<el-table-column prop="success_count" label="成功数" width="100" />
<el-table-column prop="error_count" label="失败数" width="100" />
<el-table-column prop="error_message" label="错误信息" />
</el-table>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import StatusBadge from '@/components/StatusBadge/index.vue'
import ProgressDisplay from '@/components/ProgressDisplay/index.vue'
import { useTagTaskStore } from '@/store'
import { formatDateTime } from '@/utils'
const route = useRoute()
const router = useRouter()
const store = useTagTaskStore()
const loading = computed(() => store.loading)
const task = computed(() => store.currentTask)
const executions = computed(() => store.executions)
const loadTaskDetail = async () => {
try {
await store.fetchTaskDetail(route.params.id as string)
} catch (error: any) {
ElMessage.error(error.message || '加载任务详情失败')
}
}
const loadExecutions = async () => {
try {
await store.fetchExecutions(route.params.id as string)
} catch (error) {
// 静默失败
}
}
const handleStart = async () => {
try {
await store.startTask(route.params.id as string)
ElMessage.success('任务已启动')
await loadTaskDetail()
} catch (error: any) {
ElMessage.error(error.message || '启动任务失败')
}
}
const handlePause = async () => {
try {
await store.pauseTask(route.params.id as string)
ElMessage.success('任务已暂停')
await loadTaskDetail()
} catch (error: any) {
ElMessage.error(error.message || '暂停任务失败')
}
}
const handleStop = async () => {
try {
await ElMessageBox.confirm('确定要停止该任务吗?', '提示', {
type: 'warning'
})
await store.stopTask(route.params.id as string)
ElMessage.success('任务已停止')
await loadTaskDetail()
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error(error.message || '停止任务失败')
}
}
}
const handleEdit = () => {
router.push(`/tag-tasks/${route.params.id}/edit`)
}
onMounted(() => {
loadTaskDetail()
loadExecutions()
})
onUnmounted(() => {
store.resetCurrentTask()
})
</script>
<style scoped lang="scss">
.tag-task-detail {
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.progress-section,
.executions-section {
margin-top: 30px;
h3 {
margin-bottom: 15px;
font-size: 16px;
font-weight: 500;
color: #303133;
}
}
}
</style>

View File

@@ -0,0 +1,449 @@
<template>
<div class="tag-task-form">
<el-card>
<template #header>
<div class="card-header">
<span>{{ isEdit ? '编辑标签任务' : '创建标签任务' }}</span>
</div>
</template>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="120px"
>
<!-- 基本信息 -->
<el-divider>基本信息</el-divider>
<el-form-item label="任务名称" prop="name">
<el-input v-model="form.name" placeholder="请输入任务名称" />
</el-form-item>
<el-form-item label="任务描述" prop="description">
<el-input
v-model="form.description"
type="textarea"
:rows="3"
placeholder="请输入任务描述"
/>
</el-form-item>
<el-form-item label="任务类型" prop="task_type">
<el-radio-group v-model="form.task_type" @change="handleTaskTypeChange">
<el-radio label="full">全量计算</el-radio>
<el-radio label="incremental">增量计算</el-radio>
<el-radio label="specific">指定用户</el-radio>
</el-radio-group>
<div class="form-tip">
<span v-if="form.task_type === 'full'">全量计算计算所有用户的标签</span>
<span v-else-if="form.task_type === 'incremental'">增量计算只计算有数据变更的用户</span>
<span v-else-if="form.task_type === 'specific'">指定用户只计算指定范围的用户</span>
</div>
</el-form-item>
<el-form-item label="目标标签" prop="target_tag_ids">
<el-select
v-model="form.target_tag_ids"
multiple
placeholder="请选择要计算的标签"
style="width: 100%"
>
<el-option
v-for="tag in tagDefinitions"
:key="tag.tag_id"
:label="tag.tag_name"
:value="tag.tag_id"
/>
</el-select>
</el-form-item>
<!-- 用户范围配置 -->
<el-divider>用户范围配置</el-divider>
<el-form-item label="用户范围" prop="user_scope.type">
<el-radio-group v-model="form.user_scope.type" @change="handleUserScopeTypeChange">
<el-radio label="all">全部用户</el-radio>
<el-radio label="list">指定用户列表</el-radio>
<el-radio label="filter">按条件筛选</el-radio>
</el-radio-group>
</el-form-item>
<!-- 指定用户列表 -->
<el-form-item
v-if="form.user_scope.type === 'list'"
label="用户ID列表"
prop="user_scope.user_ids"
>
<el-input
v-model="userIdsText"
type="textarea"
:rows="5"
placeholder="请输入用户ID每行一个"
@blur="handleUserIdsChange"
/>
</el-form-item>
<!-- 按条件筛选 -->
<el-form-item
v-if="form.user_scope.type === 'filter'"
label="筛选条件"
>
<el-button type="primary" @click="handleAddFilterCondition">
<el-icon><Plus /></el-icon>
添加条件
</el-button>
<el-table
:data="form.user_scope.filter_conditions"
border
style="margin-top: 10px"
>
<el-table-column label="字段" width="200">
<template #default="{ row }">
<el-input v-model="row.field" placeholder="字段名" />
</template>
</el-table-column>
<el-table-column label="运算符" width="150">
<template #default="{ row }">
<el-select v-model="row.operator">
<el-option label="大于" value="gt" />
<el-option label="大于等于" value="gte" />
<el-option label="小于" value="lt" />
<el-option label="小于等于" value="lte" />
<el-option label="等于" value="eq" />
<el-option label="不等于" value="ne" />
</el-select>
</template>
</el-table-column>
<el-table-column label="值">
<template #default="{ row }">
<el-input v-model="row.value" placeholder="值" />
</template>
</el-table-column>
<el-table-column label="操作" width="100">
<template #default="{ $index }">
<el-button
type="danger"
size="small"
@click="handleRemoveFilterCondition($index)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</el-form-item>
<!-- 调度配置 -->
<el-divider>调度配置</el-divider>
<el-form-item label="启用调度">
<el-switch v-model="form.schedule.enabled" />
</el-form-item>
<el-form-item
v-if="form.schedule.enabled"
label="Cron表达式"
prop="schedule.cron"
>
<el-input
v-model="form.schedule.cron"
placeholder="例如: 0 2 * * * (每天凌晨2点)"
/>
</el-form-item>
<!-- 高级配置 -->
<el-divider>高级配置</el-divider>
<el-form-item label="并发数">
<el-input-number
v-model="form.config.concurrency"
:min="1"
:max="100"
/>
</el-form-item>
<el-form-item label="批量大小">
<el-input-number
v-model="form.config.batch_size"
:min="1"
:max="10000"
/>
</el-form-item>
<el-form-item label="错误处理">
<el-select v-model="form.config.error_handling">
<el-option label="跳过错误继续" value="skip" />
<el-option label="遇到错误停止" value="stop" />
<el-option label="重试" value="retry" />
</el-select>
</el-form-item>
<!-- 操作按钮 -->
<el-form-item>
<el-button type="primary" @click="handleSubmit">保存</el-button>
<el-button @click="handleCancel">取消</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElForm } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import { useTagTaskStore } from '@/store'
import { useTagDefinitionStore } from '@/store'
import type { TagTask, TagDefinition } from '@/types'
const route = useRoute()
const router = useRouter()
const formRef = ref<InstanceType<typeof ElForm>>()
const tagTaskStore = useTagTaskStore()
const tagDefinitionStore = useTagDefinitionStore()
const isEdit = computed(() => !!route.params.id)
const tagDefinitions = ref<TagDefinition[]>([])
const userIdsText = ref('')
const form = reactive<Partial<TagTask>>({
name: '',
description: '',
task_type: 'full',
target_tag_ids: [],
user_scope: {
type: 'all',
user_ids: [],
filter_conditions: []
},
schedule: {
enabled: false,
cron: ''
},
config: {
concurrency: 10,
batch_size: 100,
error_handling: 'skip'
}
})
const rules = {
name: [{ required: true, message: '请输入任务名称', trigger: 'blur' }],
task_type: [{ required: true, message: '请选择任务类型', trigger: 'change' }],
'target_tag_ids': [{ required: true, message: '请选择目标标签', trigger: 'change' }],
'user_scope.user_ids': [
{
validator: (rule: any, value: any, callback: any) => {
if (form.user_scope?.type === 'list') {
if (!value || value.length === 0) {
callback(new Error('请至少输入一个用户ID'))
} else {
callback()
}
} else {
callback()
}
},
trigger: 'blur'
}
],
'user_scope.filter_conditions': [
{
validator: (rule: any, value: any, callback: any) => {
if (form.user_scope?.type === 'filter') {
if (!value || value.length === 0) {
callback(new Error('请至少添加一个筛选条件'))
} else {
// 验证每个条件是否完整
const incomplete = value.some((cond: any) => !cond.field || !cond.operator || cond.value === '')
if (incomplete) {
callback(new Error('请完善所有筛选条件'))
} else {
callback()
}
}
} else {
callback()
}
},
trigger: 'change'
}
],
'schedule.cron': [
{
validator: (rule: any, value: any, callback: any) => {
if (form.schedule?.enabled) {
if (!value || value.trim() === '') {
callback(new Error('启用调度时必须填写Cron表达式'))
} else {
callback()
}
} else {
callback()
}
},
trigger: 'blur'
}
]
}
const loadTagDefinitions = async () => {
try {
await tagDefinitionStore.getActiveDefinitions()
tagDefinitions.value = tagDefinitionStore.definitions
} catch (error: any) {
ElMessage.error(error.message || '加载标签定义失败')
}
}
const handleUserIdsChange = () => {
if (userIdsText.value) {
form.user_scope!.user_ids = userIdsText.value
.split('\n')
.map(id => id.trim())
.filter(id => id)
} else {
form.user_scope!.user_ids = []
}
}
// 在提交前同步用户ID列表防止用户没有触发blur事件
const syncUserIdsBeforeSubmit = () => {
if (form.user_scope?.type === 'list') {
handleUserIdsChange()
}
}
const handleAddFilterCondition = () => {
form.user_scope!.filter_conditions = form.user_scope!.filter_conditions || []
form.user_scope!.filter_conditions.push({
field: '',
operator: 'eq',
value: ''
})
}
const handleRemoveFilterCondition = (index: number) => {
form.user_scope!.filter_conditions?.splice(index, 1)
}
// 处理任务类型变化
const handleTaskTypeChange = () => {
// 当任务类型为 specific指定用户如果用户范围是 all自动切换为 list
if (form.task_type === 'specific' && form.user_scope?.type === 'all') {
form.user_scope.type = 'list'
}
// 当任务类型为 full全量计算自动切换用户范围为 all
else if (form.task_type === 'full' && form.user_scope?.type !== 'all') {
form.user_scope!.type = 'all'
// 清理不需要的数据
form.user_scope!.user_ids = []
form.user_scope!.filter_conditions = []
}
}
// 处理用户范围类型变化
const handleUserScopeTypeChange = () => {
if (!form.user_scope) return
// 清理不需要的数据
if (form.user_scope.type === 'all') {
form.user_scope.user_ids = []
form.user_scope.filter_conditions = []
userIdsText.value = ''
} else if (form.user_scope.type === 'list') {
form.user_scope.filter_conditions = []
} else if (form.user_scope.type === 'filter') {
form.user_scope.user_ids = []
userIdsText.value = ''
}
}
const handleSubmit = async () => {
if (!formRef.value) return
// 提交前同步用户ID列表
syncUserIdsBeforeSubmit()
// 清理不需要的数据
if (form.user_scope) {
if (form.user_scope.type === 'all') {
// 全量计算时清除用户ID列表和筛选条件
form.user_scope.user_ids = []
form.user_scope.filter_conditions = []
} else if (form.user_scope.type === 'list') {
// 指定用户列表时,清除筛选条件
form.user_scope.filter_conditions = []
} else if (form.user_scope.type === 'filter') {
// 按条件筛选时清除用户ID列表
form.user_scope.user_ids = []
}
}
await formRef.value.validate(async (valid) => {
if (valid) {
try {
if (isEdit.value) {
await tagTaskStore.updateTask(route.params.id as string, form)
ElMessage.success('任务更新成功')
} else {
await tagTaskStore.createTask(form)
ElMessage.success('任务创建成功')
}
router.push('/tag-tasks')
} catch (error: any) {
ElMessage.error(error.message || '保存失败')
}
}
})
}
const handleCancel = () => {
router.back()
}
const loadTaskDetail = async () => {
if (!isEdit.value) return
try {
const task = await tagTaskStore.fetchTaskDetail(route.params.id as string)
if (task) {
Object.assign(form, {
name: task.name,
description: task.description,
task_type: task.task_type,
target_tag_ids: task.target_tag_ids || [],
user_scope: task.user_scope || { type: 'all' },
schedule: task.schedule || { enabled: false, cron: '' },
config: task.config || {
concurrency: 10,
batch_size: 100,
error_handling: 'skip'
}
})
if (task.user_scope?.user_ids && task.user_scope.user_ids.length > 0) {
userIdsText.value = task.user_scope.user_ids.join('\n')
}
}
} catch (error: any) {
ElMessage.error(error.message || '加载任务详情失败')
}
}
onMounted(() => {
loadTagDefinitions()
if (isEdit.value) {
loadTaskDetail()
}
})
</script>
<style scoped lang="scss">
.tag-task-form {
.card-header {
font-weight: 500;
font-size: 16px;
}
.form-tip {
font-size: 12px;
color: #909399;
margin-top: 5px;
}
}
</style>

View File

@@ -0,0 +1,310 @@
<template>
<div class="tag-task-list">
<el-card>
<template #header>
<div class="card-header">
<span>标签任务列表</span>
<el-button type="primary" @click="handleCreate">
<el-icon><Plus /></el-icon>
创建任务
</el-button>
</div>
</template>
<!-- 搜索和筛选 -->
<div class="filter-bar">
<el-form :inline="true" :model="filters">
<el-form-item label="任务名称">
<el-input
v-model="filters.name"
placeholder="请输入任务名称"
clearable
@clear="handleSearch"
/>
</el-form-item>
<el-form-item label="任务类型">
<el-select
v-model="filters.task_type"
placeholder="请选择类型"
clearable
@change="handleSearch"
>
<el-option label="全量计算" value="full" />
<el-option label="增量计算" value="incremental" />
<el-option label="指定用户" value="specific" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select
v-model="filters.status"
placeholder="请选择状态"
clearable
@change="handleSearch"
>
<el-option label="待启动" value="pending" />
<el-option label="运行中" value="running" />
<el-option label="已暂停" value="paused" />
<el-option label="已停止" value="stopped" />
<el-option label="已完成" value="completed" />
<el-option label="错误" value="error" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</div>
<!-- 任务列表 -->
<el-table :data="taskList" v-loading="loading" style="width: 100%">
<el-table-column prop="name" label="任务名称" min-width="200" />
<el-table-column prop="task_type" label="任务类型" width="120">
<template #default="{ row }">
<el-tag v-if="row.task_type === 'full'" type="primary">全量</el-tag>
<el-tag v-else-if="row.task_type === 'incremental'" type="success">增量</el-tag>
<el-tag v-else type="info">指定</el-tag>
</template>
</el-table-column>
<el-table-column prop="target_tag_ids" label="目标标签" width="150">
<template #default="{ row }">
<el-tag v-for="tagId in row.target_tag_ids?.slice(0, 2)" :key="tagId" size="small" style="margin-right: 5px;">
{{ tagId }}
</el-tag>
<span v-if="row.target_tag_ids?.length > 2">...</span>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<StatusBadge :status="row.status" />
</template>
</el-table-column>
<el-table-column prop="progress.percentage" label="进度" width="120">
<template #default="{ row }">
<el-progress
:percentage="row.progress?.percentage || 0"
:status="row.progress?.status === 'error' ? 'exception' : undefined"
/>
</template>
</el-table-column>
<el-table-column prop="updated_at" label="更新时间" width="180">
<template #default="{ row }">
{{ formatDateTime(row.updated_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="280" fixed="right">
<template #default="{ row }">
<el-button
v-if="row.status === 'pending' || row.status === 'paused'"
type="success"
size="small"
@click="handleStart(row)"
>
启动
</el-button>
<el-button
v-if="row.status === 'running'"
type="warning"
size="small"
@click="handlePause(row)"
>
暂停
</el-button>
<el-button
v-if="row.status === 'running' || row.status === 'paused'"
type="danger"
size="small"
@click="handleStop(row)"
>
停止
</el-button>
<el-button
type="primary"
size="small"
@click="handleView(row)"
>
查看
</el-button>
<el-button
type="info"
size="small"
@click="handleEdit(row)"
>
编辑
</el-button>
<el-button
type="danger"
size="small"
@click="handleDelete(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import StatusBadge from '@/components/StatusBadge/index.vue'
import { useTagTaskStore } from '@/store'
import { formatDateTime } from '@/utils'
import type { TagTask } from '@/types'
const router = useRouter()
const store = useTagTaskStore()
const filters = ref({
name: '',
task_type: '',
status: ''
})
const pagination = ref({
page: 1,
pageSize: 20,
total: 0
})
const taskList = computed(() => store.tasks)
const loading = computed(() => store.loading)
const loadTasks = async () => {
try {
const result = await store.fetchTasks({
...filters.value,
page: pagination.value.page,
page_size: pagination.value.pageSize
})
pagination.value.total = result.total
} catch (error: any) {
ElMessage.error(error.message || '加载任务列表失败')
}
}
const handleSearch = () => {
pagination.value.page = 1
loadTasks()
}
const handleReset = () => {
filters.value = {
name: '',
task_type: '',
status: ''
}
handleSearch()
}
const handleCreate = () => {
router.push('/tag-tasks/create')
}
const handleView = (row: any) => {
router.push(`/tag-tasks/${row.task_id}`)
}
const handleEdit = (row: any) => {
router.push(`/tag-tasks/${row.task_id}/edit`)
}
const handleStart = async (row: TagTask) => {
try {
await store.startTask(row.task_id)
ElMessage.success('任务已启动')
await loadTasks()
} catch (error: any) {
ElMessage.error(error.message || '启动任务失败')
}
}
const handlePause = async (row: TagTask) => {
try {
await store.pauseTask(row.task_id)
ElMessage.success('任务已暂停')
await loadTasks()
} catch (error: any) {
ElMessage.error(error.message || '暂停任务失败')
}
}
const handleStop = async (row: TagTask) => {
try {
await ElMessageBox.confirm('确定要停止该任务吗?', '提示', {
type: 'warning'
})
await store.stopTask(row.task_id)
ElMessage.success('任务已停止')
await loadTasks()
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error(error.message || '停止任务失败')
}
}
}
const handleDelete = async (row: TagTask) => {
try {
await ElMessageBox.confirm('确定要删除该任务吗?', '提示', {
type: 'warning'
})
await store.deleteTask(row.task_id)
ElMessage.success('任务已删除')
await loadTasks()
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error(error.message || '删除任务失败')
}
}
}
const handleSizeChange = () => {
loadTasks()
}
const handlePageChange = () => {
loadTasks()
}
onMounted(() => {
loadTasks()
})
</script>
<style scoped lang="scss">
.tag-task-list {
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.filter-bar {
margin-bottom: 20px;
}
.pagination {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
}
</style>

16
Moncter/TaskShow/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,16 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string
// 更多环境变量...
}
interface ImportMeta {
readonly env: ImportMetaEnv
}