@@ -206,16 +206,16 @@
< div class = "form-item" v-if = "selectedTemplate" >
< span class = "form-label required" > 爬取方法 < / span >
< el-select
v-model = "selectedMethod"
v-model = "selectedMethodId "
placeholder = "请选择爬取方法"
style = "width: 100%"
>
< el-option
v-for = "method in selectedTemplate.methods"
:key = "method.na me"
:key = "method.metaId "
:label = "method.name"
:value = "method"
:value = "method.metaId "
/ >
< / el-select >
< span class = "form-tip" >
@@ -282,15 +282,42 @@
placeholder = "请输入爬虫描述"
/ >
< / div >
<!-- 邮件接收人配置 -- >
< div class = "form-item" >
< span class = "form-label" > 是否允许并发 < / span >
< el-radio-group v-model = "formData.concurr ent" >
< el -radio :label = "1" > 允许 < / el-radio >
< el-radio :label = "0" > 禁止 < / el-radio >
< / el-radio-group >
< span class = "form-tip" >
建议禁止并发 , 避免重复抓取
< span class = "form-label" > 邮件通知 < / span >
< el-checkbox v-model = "useDefaultRecipi ents " >
使用默认接收人
< / el -checkbox >
< span class = "form-tip" v-if = "useDefaultRecipients && defaultRecipients.length > 0" >
默认接收人 : {{ defaultRecipients.map ( r = > r . username ) . join ( '、' ) } }
< / span >
< span class = "form-tip" v-else-if = "useDefaultRecipients && defaultRecipients.length === 0" >
该任务模板暂无默认接收人
< / span >
< / div >
< div class = "form-item" >
< span class = "form-label" > 额外接收人 < / span >
< div class = "recipient-list" >
< el-tag
v-for = "recipient in additionalRecipients"
:key = "recipient.userId"
closable
@close ="removeRecipient(recipient)"
style = "margin-right: 8px; margin-bottom: 8px;"
>
{ { recipient . username } }
< / el-tag >
< / div >
< el-button
@click ="showRecipientSelector"
size = "small"
style = "margin-top: 8px;"
>
选择接收人
< / el-button >
<!-- TODO : 在这里添加自定义的用户选择组件 -- >
< / div >
< / div >
@@ -305,17 +332,38 @@
< / el-button >
< / template >
< / el-dialog >
< GenericSelector
v -model :visible = "showUserSelector"
title = "选择邮件接收人"
left -title = " 可选人员 "
right -title = " 已选人员 "
:fetch-available-api = "fetchAllUsers"
:initialTargetItems = "selectedRecipients"
:filter-selected = "filterUsers"
: item -config = " { id : ' userId ' , label : ' username ' , sublabel : ' userEmail ' } "
:use-tree = "true"
:tree-transform = "transformUserToTree"
: tree -props = " { children : ' children ' , label : ' username ' , id : ' userId ' } "
:only-leaf-selectable = "true"
unit -name = " 人 "
search -placeholder = " 搜索用户姓名或邮箱... "
@confirm ="handleUserConfirm"
@cancel ="resetUserSelector"
/ >
< / div >
< / AdminLayout >
< / template >
< script setup lang = "ts" >
import { ref , reactive , onMounted , watch } from 'vue' ;
import { ref , reactive , onMounted , watch , computed } from 'vue' ;
import { ElMessage , ElMessageBox } from 'element-plus' ;
import { Plus , Search , Refresh , DocumentCopy , VideoPlay , VideoPause , Promotion , Edit , Delete } from '@element-plus/icons-vue' ;
import { crontabApi } from '@/apis/crontab' ;
import type { CrontabTask , CrontabItem , CrontabMethod , PageParam } from '@/types ' ;
import { userApi } from '@/apis/system/user ' ;
import type { CrontabTask , TaskMeta , CrontabItem , CrontabMethod , CrontabParam , PageParam , CreateTaskRequest , RecipientUserInfo , UserVO , ResultDomain , EmailDefault } from '@/types' ;
import { AdminLayout } from '@/views/admin' ;
import { GenericSelector } from '@/components' ;
defineOptions ( {
name : 'NewsCrawlerView'
} ) ;
@@ -325,12 +373,20 @@ const submitting = ref(false);
const crawlerList = ref < CrontabTask [ ] > ( [ ] ) ;
const total = ref ( 0 ) ;
// 爬虫模板 数据
const crawlerTemplates = ref < CrontabItem [ ] > ( [ ] ) ;
// 爬虫元 数据
const taskMetaList = ref < TaskMeta [ ] > ( [ ] ) ;
const crawlerTemplates = ref < CrontabItem [ ] > ( [ ] ) ; // 转换后的模板结构
const selectedTemplate = ref < CrontabItem | null > ( null ) ;
const selectedMethod = ref < CrontabMethod | null > ( null ) ;
const selectedMethodId = ref < string > ( '' ) ; // 选中的方法ID(metaId)
const selectedMetaId = ref < string > ( '' ) ; // 选中的元数据ID
const dynamicParams = ref < Record < string , any > > ( { } ) ;
// 邮件接收人相关
const useDefaultRecipients = ref < boolean > ( false ) ;
const defaultRecipients = ref < RecipientUserInfo [ ] > ( [ ] ) ;
const additionalRecipients = ref < RecipientUserInfo [ ] > ( [ ] ) ;
const showUserSelector = ref < boolean > ( false ) ;
// 搜索表单
const searchForm = reactive ( {
taskName : '' ,
@@ -361,40 +417,251 @@ const formData = reactive<Partial<CrontabTask>>({
description : ''
} ) ;
// 根据selectedMethodId获取完整的method对象
const selectedMethod = computed ( ( ) => {
if ( ! selectedTemplate . value || ! selectedMethodId . value ) {
return null ;
}
return selectedTemplate . value . methods . find ( m => m . metaId === selectedMethodId . value ) || null ;
} ) ;
// 计算已选接收人(包括默认接收人+额外添加的接收人)
const selectedRecipients = computed ( ( ) => {
if ( useDefaultRecipients . value ) {
// 合并默认接收人和额外接收人,去重
const all = [ ... defaultRecipients . value , ... additionalRecipients . value ] ;
const uniqueMap = new Map < string , RecipientUserInfo > ( ) ;
all . forEach ( r => uniqueMap . set ( r . userId , r ) ) ;
return Array . from ( uniqueMap . values ( ) ) ;
} else {
return additionalRecipients . value ;
}
} ) ;
// 监听模板选择变化
watch ( selectedTemplate , ( newTemplate , oldTemplate ) => {
// 只在用户手动切换模板时重置( oldTemplate存在且不为null时才重置)
// 编辑回填时oldTemplate为null, 不会触发重置
if ( newTemplate && oldTemplate ) {
selectedMethod . value = null ;
selectedMethodId . value = '' ;
dynamicParams . value = { } ;
}
} ) ;
// 监听方法选择变化
watch ( selectedMethod , ( newMethod ) => {
if ( newMethod ) {
watch ( selectedMethodId , ( newMethodId ) => {
if ( newMethodId && selectedMethod . value ) {
// 保存metaId
selectedMetaId . value = newMethodId ;
dynamicParams . value = { } ;
// 遍历params数组提取默认值
if ( newMethod . params && Array . isArray ( newMethod . params ) ) {
new Method. params . forEach ( param => {
if ( selectedMethod . value . params && Array . isArray ( selectedMethod . value . params ) ) {
selected Method. value . params. forEach ( ( param : CrontabParam ) => {
dynamicParams . value [ param . name ] = param . value ;
} ) ;
}
// 加载该任务模板的默认接收人
if ( selectedMetaId . value ) {
loadDefaultRecipients ( selectedMetaId . value ) ;
}
}
} ) ;
// 加载爬虫模板
// ==================== 人员选择器相关 ====================
/**
* 1. 获取所有人员列表的接口方法
*/
async function fetchAllUsers ( ) : Promise < ResultDomain < any > > {
try {
const result = await userApi . getUserList ( { } ) ;
if ( result . success && result . dataList ) {
// 转换为 GenericSelector 需要的格式
const users = result . dataList . map ( ( user : UserVO ) => ( {
userId : user . id || '' ,
username : user . username || user . email || 'Unknown' ,
userEmail : user . email || '' ,
deptID : user . deptID ,
deptName : user . deptName ,
parentID : user . parentID
} ) ) ;
return {
... result ,
dataList : users
} ;
}
return result ;
} catch ( error ) {
ElMessage . error ( '获取用户列表失败' ) ;
return {
code : 500 ,
success : false ,
login : true ,
auth : true ,
message : '获取用户列表失败' ,
dataList : [ ]
} as ResultDomain < any > ;
}
}
/**
* 2. 过滤方法:从可选项中移除已选项
*/
function filterUsers ( available : any [ ] , selected : any [ ] ) : any [ ] {
const selectedIds = new Set ( selected . map ( item => item . userId ) ) ;
return available . filter ( item => ! selectedIds . has ( item . userId ) ) ;
}
/**
* 3. 构建多级部门树的方法
*/
function transformUserToTree ( flatData : any [ ] ) : any [ ] {
if ( ! flatData || flatData . length === 0 ) {
return [ ] ;
}
// 第一步:按部门分组,收集每个部门下的用户
const deptMap = new Map < string , any > ( ) ;
const tree : any [ ] = [ ] ;
flatData . forEach ( item => {
if ( ! item . deptID ) return ;
if ( ! deptMap . has ( item . deptID ) ) {
// 创建部门节点
deptMap . set ( item . deptID , {
userId : ` dept_ ${ item . deptID } ` ,
username : item . deptName || '未分配部门' ,
userEmail : '' ,
deptID : item . deptID ,
deptName : item . deptName ,
parentID : item . parentID ,
children : [ ] ,
isDept : true // 标记这是部门节点
} ) ;
}
// 添加用户到部门的children中
const deptNode = deptMap . get ( item . deptID ) ;
if ( deptNode ) {
deptNode . children . push ( {
... item ,
isDept : false // 标记这是用户节点
} ) ;
}
} ) ;
// 第二步:构建部门层级关系
const allDepts = Array . from ( deptMap . values ( ) ) ;
const deptTreeMap = new Map < string , any > ( ) ;
// 初始化所有部门节点(创建副本)
allDepts . forEach ( dept => {
deptTreeMap . set ( dept . deptID , { ... dept } ) ;
} ) ;
// 第三步:建立部门的父子关系
allDepts . forEach ( dept => {
const node = deptTreeMap . get ( dept . deptID ) ;
if ( ! node ) return ;
if ( ! dept . parentID || dept . parentID === '0' || dept . parentID === '' ) {
// 根部门
tree . push ( node ) ;
} else {
// 子部门
const parent = deptTreeMap . get ( dept . parentID ) ;
if ( parent ) {
if ( ! parent . children ) {
parent . children = [ ] ;
}
// 保存当前节点的用户列表
const users = node . children || [ ] ;
node . children = [ ] ;
// 将部门节点添加到父部门
parent . children . push ( node ) ;
// 恢复用户列表
node . children = users ;
} else {
// 找不到父节点,作为根节点
tree . push ( node ) ;
}
}
} ) ;
return tree ;
}
/**
* 4. 显示用户选择器
*/
function showRecipientSelector ( ) {
showUserSelector . value = true ;
}
/**
* 5. 确认选择用户
*/
function handleUserConfirm ( selected : any [ ] ) {
// 过滤掉部门节点,只保留用户节点
const userItems = selected . filter ( item => item . isDept !== true && ! defaultRecipients . value . find ( r => r . userId === item . userId ) ) ;
additionalRecipients . value = userItems . map ( item => ( {
userId : item . userId ,
username : item . username ,
userEmail : item . userEmail || ''
} ) ) ;
}
/**
* 6. 取消/重置选择器
*/
function resetUserSelector ( ) {
console . log ( '❌ 取消选择' ) ;
// 不做任何操作,保持原有选择
}
// 加载爬虫模板( 从数据库加载TaskMeta, 转换为CrontabItem结构)
async function loadCrawlerTemplates ( ) {
try {
const result = await crontabApi . getEnabledCrontabList ( ) ;
if ( result . success && result . dataList ) {
crawlerTemplates . value = result . dataList ;
taskMetaList . value = result . dataList ;
// 将TaskMeta[]按category分组转换为CrontabItem[]
const grouped = new Map < string , TaskMeta [ ] > ( ) ;
result . dataList . forEach ( ( meta : TaskMeta ) => {
const category = meta . category || '未分类' ;
if ( ! grouped . has ( category ) ) {
grouped . set ( category , [ ] ) ;
}
grouped . get ( category ) ! . push ( meta ) ;
} ) ;
// 转换为CrontabItem结构
crawlerTemplates . value = Array . from ( grouped . entries ( ) ) . map ( ( [ category , metas ] ) => ( {
name : category ,
methods : metas . map ( meta => ( {
name : meta . name || '' ,
clazz : meta . beanName || '' ,
excuete _method : meta . methodName || '' ,
path : meta . scriptPath || '' ,
metaId : meta . metaId || '' , // 保存metaId
params : meta . paramSchema ? JSON . parse ( meta . paramSchema ) : [ ]
} ) )
} ) ) ;
} else {
ElMessage . error ( result . message || '加载爬虫模板失败' ) ;
}
} catch ( error ) {
console . error ( '加载爬虫模板失败:' , error ) ;
ElMessage . error ( '加载爬虫模板失败' ) ;
}
}
@@ -428,7 +695,6 @@ async function loadCrawlerList() {
total . value = 0 ;
}
} catch ( error ) {
console . error ( '加载爬虫列表失败:' , error ) ;
ElMessage . error ( '加载爬虫列表失败' ) ;
crawlerList . value = [ ] ;
total . value = 0 ;
@@ -468,50 +734,68 @@ function handleAdd() {
isEdit . value = false ;
resetFormData ( ) ;
selectedTemplate . value = null ;
selectedMethod . value = null ;
selectedMethodId . value = '' ;
dynamicParams . value = { } ;
dialogVisible . value = true ;
}
// 编辑爬虫
function handleEdit ( row : CrontabTask ) {
async function handleEdit ( row : CrontabTask ) {
isEdit . value = true ;
Object . assign ( formData , row ) ;
// 重置选择
selectedTemplate . value = null ;
selectedMethod . value = null ;
selectedMethodId . value = '' ;
dynamicParams . value = { } ;
// 尝试解析methodParams来回填表单
if ( row . methodParams ) {
// 回填邮件接收人配置
useDefaultRecipients . value = row . defaultRecipient || false ;
additionalRecipients . value = [ ] ;
// 加载该任务的额外接收人
if ( row . taskId ) {
try {
const params = JSON . parse ( row . methodParams ) ;
// 如果有scriptPath,尝试匹配模板和方法
if ( params . scriptPath ) {
const template = crawlerTemplates . value . find ( t =>
t . methods . some ( m => m . path === params . scriptPath )
) ;
if ( template ) {
const method = template . methods . find ( m => m . path === params . scriptPath ) ;
if ( method ) {
// 先设置template和method, 触发watch填充默认值
selectedTemplate . value = template ;
selectedMethod . value = method ;
// 然后使用nextTick确保watch执行完后再覆盖为实际值
// 回填动态参数( 排除scriptPath)
const { scriptPath , ... restParams } = params ;
const recipientsResult = await crontabApi . getRecipientsByTaskId ( row . taskId ) ;
if ( recipientsResult . success && recipientsResult . dataList ) {
additionalRecipients . value = recipientsResult . dataList . map ( item => ( {
userId : item . userId || '' ,
username : item . name || '' ,
userEmail : item . email || ''
} ) ) ;
}
} catch ( error ) {
console . error ( '加载额外接收人失败:' , error ) ;
}
}
// 通过metaId直接匹配
if ( row . metaId ) {
// 遍历所有模板和方法, 找到匹配的metaId
for ( const template of crawlerTemplates . value ) {
const method = template . methods . find ( m => m . metaId === row . metaId ) ;
if ( method ) {
// 找到匹配的方法, 设置template和method
selectedTemplate . value = template ;
selectedMethodId . value = method . metaId || '' ;
selectedMetaId . value = method . metaId || '' ;
// 回填动态参数
if ( row . methodParams ) {
try {
const params = JSON . parse ( row . methodParams ) ;
// 排除系统参数
const { scriptPath , taskId , logId , ... restParams } = params ;
// 延迟设置, 确保watch先执行完
setTimeout ( ( ) => {
dynamicParams . value = restParams ;
console . log ( '📝 编辑回填 - template:' , template . name , 'method:' , method . name , 'params:' , restParams ) ;
} , 0 ) ;
} catch ( error ) {
console . error ( '解析methodParams失败:' , error ) ;
}
}
break ;
}
} catch ( error ) {
console . warn ( '解析methodParams失败:' , error ) ;
}
}
@@ -529,7 +813,6 @@ async function handleStart(row: CrontabTask) {
ElMessage . error ( result . message || '启动失败' ) ;
}
} catch ( error ) {
console . error ( '启动爬虫失败:' , error ) ;
ElMessage . error ( '启动爬虫失败' ) ;
}
}
@@ -545,7 +828,6 @@ async function handlePause(row: CrontabTask) {
ElMessage . error ( result . message || '暂停失败' ) ;
}
} catch ( error ) {
console . error ( '暂停爬虫失败:' , error ) ;
ElMessage . error ( '暂停爬虫失败' ) ;
}
}
@@ -563,15 +845,21 @@ async function handleExecute(row: CrontabTask) {
}
) ;
const result = await crontabApi . executeTaskOnce ( row . taskId ! ) ;
if ( result . success ) {
ElMessage . success ( '爬虫执行成功,请稍后查看执行日志' ) ;
} else {
ElMessage . error ( result . message || '执行失败' ) ;
}
// 异步执行,不等待任务完成
crontabApi . executeTaskOnce ( row . taskId ! ) . then ( result => {
if ( result . success ) {
ElMessage . success ( '任务已提交执行,请稍后查看执行日志' ) ;
} else {
ElMessage . error ( result . message || '提交执行失败' ) ;
}
} ) . catch ( ( ) => {
ElMessage . error ( '提交执行失败' ) ;
} ) ;
// 立即提示用户任务已触发
ElMessage . info ( '任务执行已触发,正在后台运行...' ) ;
} catch ( error : any ) {
if ( error !== 'cancel' ) {
console . error ( '执行爬虫失败:' , error ) ;
ElMessage . error ( '执行爬虫失败' ) ;
}
}
@@ -599,7 +887,6 @@ async function handleDelete(row: CrontabTask) {
}
} catch ( error : any ) {
if ( error !== 'cancel' ) {
console . error ( '删除爬虫失败:' , error ) ;
ElMessage . error ( '删除爬虫失败' ) ;
}
}
@@ -620,7 +907,6 @@ async function validateCron() {
ElMessage . error ( result . message || 'Cron表达式格式错误' ) ;
}
} catch ( error ) {
console . error ( '验证Cron表达式失败:' , error ) ;
ElMessage . error ( '验证失败' ) ;
}
}
@@ -640,6 +926,12 @@ async function handleSubmit() {
ElMessage . warning ( '请输入Cron表达式' ) ;
return ;
}
// 校验additionRecipients的email存在
const recipientWithoutEmail = additionalRecipients . value . find ( recipient => ! recipient . userEmail ) ;
if ( recipientWithoutEmail ) {
ElMessage . warning ( ` ${ recipientWithoutEmail . username } 邮箱不能为空 ` ) ;
return ;
}
// 验证必填参数
if ( selectedMethod . value . params && Array . isArray ( selectedMethod . value . params ) ) {
@@ -659,26 +951,24 @@ async function handleSubmit() {
submitting . value = true ;
try {
// 传递taskGroup和methodName( 中文名) , 后端根据这两个name查找配置并填充beanName、methodName和scriptPath
const data = {
... formData ,
taskGroup : selectedTemplate . value . name , // 模板名称(中文)
methodName : selectedMethod . value . name , // 方法名称(中文)
methodParams : JSON . stringify ( {
... dynamicParams . value // 只传用户输入的参数, scriptPath由后端填充
} )
// 构建CreateTaskRequest
const requestData : CreateTaskRequest = {
metaId : selectedMetaId . value ,
task: {
... formData ,
defaultRecipient : useDefaultRecipients . value ,
methodParams : JSON . stringify ( {
... dynamicParams . value
} )
} as CrontabTask ,
additionalRecipients : additionalRecipients . value
} ;
console . log ( '📤 准备提交的数据:' , data ) ;
console . log ( '📤 taskGroup:' , selectedTemplate . value . name ) ;
console . log ( '📤 methodName:' , selectedMethod . value . name ) ;
console . log ( '📤 动态参数:' , dynamicParams . value ) ;
let result ;
if ( isEdit . value ) {
result = await crontabApi . updateTask ( data as CrontabTask ) ;
result = await crontabApi . updateTask ( requestData ) ;
} else {
result = await crontabApi . createTask ( data as CrontabTask ) ;
result = await crontabApi . createTask ( requestData ) ;
}
if ( result . success ) {
@@ -689,7 +979,6 @@ async function handleSubmit() {
ElMessage . error ( result . message || ( isEdit . value ? '更新失败' : '创建失败' ) ) ;
}
} catch ( error ) {
console . error ( '提交失败:' , error ) ;
ElMessage . error ( '提交失败' ) ;
} finally {
submitting . value = false ;
@@ -713,8 +1002,41 @@ function resetFormData() {
status : 0 ,
concurrent : 0 ,
misfirePolicy : 3 ,
description : ''
description : '' ,
defaultRecipient : false
} ) ;
useDefaultRecipients . value = false ;
defaultRecipients . value = [ ] ;
additionalRecipients . value = [ ] ;
}
// 邮件接收人相关方法
async function loadDefaultRecipients ( metaId : string ) {
try {
const result = await crontabApi . getEmailDefaultByMetaId ( metaId ) ;
if ( result . success && result . dataList && result . dataList . length > 0 ) {
defaultRecipients . value = result . dataList
. map ( item => ( {
userId : item . userId ! ,
username : item . username ! ,
userEmail : item . userEmail !
} ) ) ;
} else {
defaultRecipients . value = [ ] ;
}
} catch ( error ) {
defaultRecipients . value = [ ] ;
}
}
// TODO: 用户可以在这里添加自定义的邮件接收人选择逻辑
function removeRecipient ( recipient : RecipientUserInfo ) {
const index = additionalRecipients . value . findIndex ( r => r . userId === recipient . userId ) ;
if ( index > - 1 ) {
additionalRecipients . value . splice ( index , 1 ) ;
}
}
// 初始化