refactor(module): 重构模块路径和数据库配置

- 将前端API路径从`system`和`monitor`重命名为`module_system`和`module_monitor`
- 移除MongoDB相关配置和依赖
- 统一数据库类型命名为`mysql`和`postgres`
- 修复代码生成模板中的字段命名问题
- 更新依赖项并移除不必要的包
- 优化数据库连接配置
- 添加新的文档和Redoc视图组件
- 修复SQL脚本中的字段注释
This commit is contained in:
zhangtao
2025-11-10 01:43:23 +08:00
parent bbde7b8c6e
commit 3df4f5a45a
89 changed files with 964 additions and 1531 deletions
@@ -0,0 +1,808 @@
<template>
<div class="chatgpt-container">
<!-- 主聊天区域 -->
<div class="main-chat">
<!-- 顶部导航栏 -->
<div class="chat-navbar">
<div class="navbar-left">
<h2>FA智能助手</h2>
</div>
<div class="navbar-right">
<div class="connection-status">
<el-icon :class="['status-icon', connectionStatus]">
<Connection v-if="connectionStatus === 'connected'" />
<Loading v-else-if="connectionStatus === 'connecting'" />
<Warning v-else />
</el-icon>
<span class="status-text">{{ connectionStatusText }}</span>
</div>
<el-button v-if="messages.length > 0" v-hasPerm="['ai:mcp:clear']" text :icon="Delete" @click="clearCurrentChat">清空对话</el-button>
<el-button v-hasPerm="['ai:mcp:connection']" text :icon="Setting" @click="toggleConnection">{{ isConnected ? '断开连接' : '重新连接' }}</el-button>
</div>
</div>
<!-- 聊天消息区域 -->
<div ref="messagesContainer" class="chat-messages">
<!-- 欢迎界面 -->
<div v-if="messages.length === 0" class="welcome-screen">
<div class="welcome-content">
<div class="ai-logo">
<el-icon size="64"><ChatDotRound /></el-icon>
</div>
<h1>FA智能助手</h1>
<p class="welcome-subtitle">我是您的专属AI助手,可以帮您回答问题、处理任务和进行智能对话</p>
<div class="example-prompts">
<div class="prompt-card" @click="setPrompt('请介绍一下FastApiAdmin系统')">
<h4>系统介绍</h4>
<p>请介绍一下FastApiAdmin系统</p>
</div>
<div class="prompt-card" @click="setPrompt('如何在系统中创建新的模块?')">
<h4>开发指导</h4>
<p>如何在系统中创建新的模块?</p>
</div>
<div class="prompt-card" @click="setPrompt('系统的权限管理是如何工作的?')">
<h4>权限管理</h4>
<p>FA系统的权限管理是如何工作的?</p>
</div>
<div class="prompt-card" @click="setPrompt('如何优化FA系统的性能?')">
<h4>性能优化</h4>
<p>如何优化系统的性能?</p>
</div>
</div>
</div>
</div>
<!-- 消息列表 -->
<div v-else class="messages-list">
<div
v-for="message in messages"
:key="message.id"
:class="['message-group', message.type]"
>
<div class="message-avatar">
<div v-if="message.type === 'user'" class="user-avatar">
<el-icon><User /></el-icon>
</div>
<div v-else class="ai-avatar">
<el-icon><ChatDotRound /></el-icon>
</div>
</div>
<div class="message-content">
<div class="message-header">
<strong class="sender-name">
{{ message.type === 'user' ? 'You' : 'FA助手' }}
</strong>
</div>
<div class="message-body">
<div v-if="message.type === 'assistant' && message.loading" class="typing-indicator">
<div class="typing-dots">
<span></span>
<span></span>
<span></span>
</div>
</div>
<div v-else class="message-text" v-html="formatMessage(message.content)"></div>
</div>
<div v-if="!message.loading" class="message-actions">
<el-button text size="small" :icon="CopyDocument" @click="copyMessage(message.content)"></el-button>
<el-button v-if="message.type === 'assistant'" text size="small" :icon="RefreshLeft"></el-button>
</div>
</div>
</div>
</div>
<!-- 错误提示 -->
<div v-if="error" class="error-banner">
<el-alert
:title="error"
type="error"
:closable="true"
show-icon
@close="error = ''"
/>
</div>
</div>
<!-- 输入区域 -->
<div class="chat-input">
<div class="input-wrapper">
<div class="input-container">
<el-input
v-model="inputMessage"
:placeholder="isConnected ? '向FA助手发送消息...' : '请先连接到服务器'"
:disabled="!isConnected || sending"
type="textarea"
:rows="1"
:autosize="{ minRows: 1, maxRows: 6 }"
resize="none"
class="message-input"
@keydown.enter.exact.prevent="sendMessage"
@keydown.shift.enter.exact="inputMessage += '\n'"
/>
<el-button
:disabled="!inputMessage.trim() || !isConnected || sending"
:loading="sending"
class="send-button"
type="primary"
circle
@click="sendMessage"
>
<el-icon><Promotion /></el-icon>
</el-button>
</div>
<div class="input-footer">
<span class="input-hint">
按 Enter 发送消息,Shift + Enter 换行
</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
ChatDotRound,
User,
Delete,
Promotion,
Connection,
Loading,
Warning,
Setting,
CopyDocument,
RefreshLeft
} from '@element-plus/icons-vue'
// 消息接口
interface ChatMessage {
id: string
type: 'user' | 'assistant'
content: string
timestamp: number
loading?: boolean
}
// 响应式数据
const messages = ref<ChatMessage[]>([])
const inputMessage = ref('')
const sending = ref(false)
const isConnected = ref(false)
const connectionStatus = ref<'connected' | 'connecting' | 'disconnected'>('disconnected')
const error = ref('')
const messagesContainer = ref<HTMLElement>()
// WebSocket 连接
let ws: WebSocket | null = null
const WS_URL = import.meta.env.VITE_APP_WS_ENDPOINT + '/api/v1/application/ai/ws/chat'
// 计算属性
const connectionStatusText = computed(() => {
switch (connectionStatus.value) {
case 'connected': return '已连接'
case 'connecting': return '连接中...'
case 'disconnected': return '未连接'
default: return '未知状态'
}
})
// WebSocket 连接管理
const connectWebSocket = () => {
if (ws?.readyState === WebSocket.OPEN) {
return
}
connectionStatus.value = 'connecting'
error.value = ''
try {
ws = new WebSocket(WS_URL)
ws.onopen = () => {
console.log('WebSocket 连接已建立')
isConnected.value = true
connectionStatus.value = 'connected'
ElMessage.success('连接成功')
}
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
handleWebSocketMessage(data)
} catch (err) {
console.error('解析消息失败:', err)
handleWebSocketMessage({ content: event.data })
}
}
ws.onclose = (event) => {
console.log('WebSocket 连接已关闭', event.code, event.reason)
isConnected.value = false
connectionStatus.value = 'disconnected'
}
ws.onerror = (error) => {
console.error('WebSocket 错误:', error)
isConnected.value = false
connectionStatus.value = 'disconnected'
ElMessage.error('连接失败,请检查服务器状态')
}
} catch (err) {
console.error('创建 WebSocket 连接失败:', err)
connectionStatus.value = 'disconnected'
error.value = '无法创建连接'
}
}
// 断开连接
const disconnectWebSocket = () => {
if (ws) {
ws.close(1000, '用户主动断开')
ws = null
}
isConnected.value = false
connectionStatus.value = 'disconnected'
}
// 切换连接状态
const toggleConnection = () => {
if (isConnected.value) {
disconnectWebSocket()
ElMessage.info('已断开连接')
} else {
connectWebSocket()
}
}
// 处理 WebSocket 消息
const handleWebSocketMessage = (data: any) => {
// 查找最后一个助手消息
const lastMessage = messages.value[messages.value.length - 1]
if (lastMessage && lastMessage.type === 'assistant' && lastMessage.loading) {
// 更新加载中的消息
lastMessage.content = data.content || data.message || '收到回复'
lastMessage.loading = false
} else {
// 添加新的助手消息
addMessage('assistant', data.content || data.message || '收到回复')
}
scrollToBottom()
}
// 发送消息
const sendMessage = async () => {
const message = inputMessage.value.trim()
if (!message || !isConnected.value || sending.value) {
return
}
// 添加用户消息
addMessage('user', message)
inputMessage.value = ''
// 添加加载中的助手消息
const loadingMessage: ChatMessage = {
id: generateId(),
type: 'assistant',
content: '',
timestamp: Date.now(),
loading: true
}
messages.value.push(loadingMessage)
sending.value = true
scrollToBottom()
try {
// 发送消息到 WebSocket
if (ws?.readyState === WebSocket.OPEN) {
const payload = {
message,
timestamp: Date.now()
}
ws.send(JSON.stringify(payload))
} else {
throw new Error('WebSocket 连接未建立')
}
} catch (err) {
console.error('发送消息失败:', err)
// 移除加载消息并显示错误
messages.value.pop()
error.value = '发送消息失败,请检查连接状态'
ElMessage.error('发送失败')
} finally {
sending.value = false
}
}
// 添加消息
const addMessage = (type: 'user' | 'assistant', content: string) => {
const message: ChatMessage = {
id: generateId(),
type,
content,
timestamp: Date.now()
}
messages.value.push(message)
nextTick(() => scrollToBottom())
}
const clearCurrentChat = async () => {
try {
await ElMessageBox.confirm(
'确定要清空当前对话吗?此操作不可恢复。',
'确认清空',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
messages.value = []
ElMessage.success('对话已清空')
} catch {
// 用户取消
}
}
// 设置提示词
const setPrompt = (prompt: string) => {
inputMessage.value = prompt
}
// 复制消息
const copyMessage = async (content: string) => {
try {
await navigator.clipboard.writeText(content)
ElMessage.success('已复制到剪贴板')
} catch {
// 降级方案
const textArea = document.createElement('textarea')
textArea.value = content
document.body.appendChild(textArea)
textArea.select()
document.execCommand('copy')
document.body.removeChild(textArea)
ElMessage.success('已复制到剪贴板')
}
}
// 滚动到底部
const scrollToBottom = () => {
nextTick(() => {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
})
}
// 格式化消息内容
const formatMessage = (content: string) => {
if (!content) return ''
// 简单的 Markdown 支持
return content
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.*?)\*/g, '<em>$1</em>')
.replace(/`(.*?)`/g, '<code>$1</code>')
.replace(/\n/g, '<br>')
}
// 生成唯一ID
const generateId = () => {
return Date.now().toString(36) + Math.random().toString(36).substr(2)
}
// 生命周期
onMounted(() => {
connectWebSocket()
})
onUnmounted(() => {
disconnectWebSocket()
})
</script>
<style lang="scss" scoped>
.chatgpt-container {
display: flex;
height: calc(100vh - 120px);
background: var(--el-bg-color);
overflow: hidden;
}
// 主聊天区域
.main-chat {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
.chat-navbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
border-bottom: 1px solid var(--el-border-color-light);
background: var(--el-bg-color);
.navbar-left {
h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--el-text-color-primary);
}
}
.navbar-right {
display: flex;
align-items: center;
gap: 16px;
.connection-status {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
.status-icon {
&.connected { color: var(--el-color-success); }
&.connecting { color: var(--el-color-warning); }
&.disconnected { color: var(--el-color-danger); }
}
.status-text {
color: var(--el-text-color-secondary);
}
}
}
}
.chat-messages {
flex: 1;
overflow-y: auto;
background: var(--el-bg-color);
padding-bottom: 120px; // 为底部输入框留出空间
// 欢迎界面
.welcome-screen {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: 32px;
text-align: center;
.welcome-content {
max-width: 800px;
.ai-logo {
margin-bottom: 24px;
color: var(--el-color-primary);
}
h1 {
font-size: 32px;
font-weight: 600;
margin: 0 0 16px;
color: var(--el-text-color-primary);
}
.welcome-subtitle {
font-size: 16px;
color: var(--el-text-color-secondary);
margin-bottom: 32px;
line-height: 1.5;
}
.example-prompts {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
max-width: 600px;
.prompt-card {
background: var(--el-bg-color-page);
border: 1px solid var(--el-border-color-light);
border-radius: 12px;
padding: 20px;
cursor: pointer;
transition: all 0.2s ease;
text-align: left;
&:hover {
border-color: var(--el-color-primary);
transform: translateY(-2px);
box-shadow: var(--el-box-shadow-light);
}
h4 {
margin: 0 0 8px;
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
}
p {
margin: 0;
font-size: 13px;
color: var(--el-text-color-secondary);
line-height: 1.4;
}
}
}
}
}
// 消息列表
.messages-list {
padding: 24px;
max-width: 800px;
margin: 0 auto;
.message-group {
margin-bottom: 32px;
display: flex;
gap: 16px;
.message-avatar {
flex-shrink: 0;
.user-avatar {
width: 32px;
height: 32px;
border-radius: 6px;
background: var(--el-color-primary);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 14px;
}
.ai-avatar {
width: 32px;
height: 32px;
border-radius: 6px;
background: var(--el-color-success);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 14px;
}
}
.message-content {
flex: 1;
min-width: 0;
.message-header {
margin-bottom: 8px;
.sender-name {
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
}
}
.message-body {
.message-text {
font-size: 15px;
line-height: 1.6;
color: var(--el-text-color-primary);
word-wrap: break-word;
:deep(p) {
margin: 0 0 12px;
&:last-child {
margin-bottom: 0;
}
}
:deep(code) {
background: var(--el-fill-color-light);
padding: 2px 6px;
border-radius: 4px;
font-family: 'JetBrains Mono', 'Courier New', monospace;
font-size: 14px;
}
:deep(pre) {
background: var(--el-fill-color-light);
padding: 16px;
border-radius: 8px;
overflow-x: auto;
margin: 12px 0;
code {
background: none;
padding: 0;
}
}
:deep(strong) {
font-weight: 600;
}
:deep(em) {
font-style: italic;
}
:deep(ul), :deep(ol) {
padding-left: 20px;
margin: 12px 0;
}
:deep(li) {
margin: 4px 0;
}
}
.typing-indicator {
display: flex;
align-items: center;
gap: 8px;
color: var(--el-text-color-secondary);
.typing-dots {
display: flex;
gap: 4px;
span {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--el-text-color-secondary);
animation: typing 1.4s infinite;
&:nth-child(2) { animation-delay: 0.2s; }
&:nth-child(3) { animation-delay: 0.4s; }
}
}
}
}
.message-actions {
display: flex;
gap: 4px;
margin-top: 8px;
opacity: 0;
transition: opacity 0.2s ease;
.el-button {
padding: 4px 8px;
min-height: auto;
}
}
&:hover .message-actions {
opacity: 1;
}
}
}
}
.error-banner {
padding: 16px 24px;
}
}
// 输入区域
.chat-input {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 16px 24px 24px;
background: var(--el-bg-color);
border-top: 1px solid var(--el-border-color-light);
z-index: 10;
.input-wrapper {
max-width: 800px;
margin: 0 auto;
.input-container {
position: relative;
background: var(--el-bg-color-page);
border: 1px solid var(--el-border-color);
border-radius: 12px;
transition: border-color 0.2s ease;
box-shadow: var(--el-box-shadow-light);
&:focus-within {
border-color: var(--el-color-primary);
box-shadow: var(--el-box-shadow);
}
.message-input {
:deep(.el-textarea__inner) {
border: none;
box-shadow: none;
background: transparent;
resize: none;
padding: 16px 60px 16px 16px;
font-size: 15px;
line-height: 1.5;
&:focus {
border: none;
box-shadow: none;
}
}
}
.send-button {
position: absolute;
right: 12px;
bottom: 12px;
width: 32px;
height: 32px;
min-height: 32px;
padding: 0;
}
}
.input-footer {
display: flex;
justify-content: center;
margin-top: 12px;
.input-hint {
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
}
}
}
@keyframes typing {
0%, 20% {
opacity: 0.4;
transform: scale(0.8);
}
50% {
opacity: 1;
transform: scale(1);
}
80%, 100% {
opacity: 0.4;
transform: scale(0.8);
}
}
// 滚动条样式
.chat-messages::-webkit-scrollbar {
width: 6px;
}
.chat-messages::-webkit-scrollbar-track {
background: transparent;
}
.chat-messages::-webkit-scrollbar-thumb {
background: var(--el-fill-color);
border-radius: 3px;
&:hover {
background: var(--el-fill-color-dark);
}
}
</style>
@@ -0,0 +1,414 @@
<!-- 任务日志抽屉 -->
<template>
<el-drawer v-model="drawerVisible" :title="'【' + props.jobName + '】任务日志'" :size="drawerSize">
<!-- 搜索区域 -->
<div class="search-container">
<el-form ref="queryFormRef" :model="queryFormData" :inline="true" label-suffix=":" @submit.prevent="handleQuery">
<el-form-item prop="status" label="执行状态">
<el-select v-model="queryFormData.status" placeholder="请选择执行状态" style="width: 167.5px" clearable>
<el-option :value="true" label="成功" />
<el-option :value="false" label="失败" />
</el-select>
</el-form-item>
<!-- 时间范围,收起状态下隐藏 -->
<el-form-item v-if="isExpand" prop="start_time" label="执行时间">
<DatePicker
v-model="dateRange"
@update:model-value="handleDateRangeChange"
/>
</el-form-item>
<!-- 查询、重置、展开/收起按钮 -->
<el-form-item class="search-buttons">
<el-button type="primary" icon="search" @click="handleQuery">查询</el-button>
<el-button icon="refresh" @click="handleResetQuery">重置</el-button>
<!-- 展开/收起 -->
<template v-if="isExpandable">
<el-link class="ml-3" type="primary" underline="never" @click="isExpand = !isExpand">
{{ isExpand ? "收起" : "展开" }}
<el-icon>
<template v-if="isExpand">
<ArrowUp />
</template>
<template v-else>
<ArrowDown />
</template>
</el-icon>
</el-link>
</template>
</el-form-item>
</el-form>
</div>
<!-- 内容区域 -->
<el-card class="data-table">
<template #header>
<div class="card-header">
<span>
<el-tooltip content="任务执行日志记录每次定时任务的执行情况,包括成功、失败状态及错误信息。">
<QuestionFilled class="w-4 h-4 mx-1" />
</el-tooltip>
任务执行日志列表
</span>
</div>
</template>
<!-- 功能区域 -->
<div class="data-table__toolbar">
<div class="data-table__toolbar--left">
<el-row :gutter="10">
<el-col :span="1.5">
<el-button type="danger" icon="delete" :disabled="selectIds.length === 0" @click="handleDelete(selectIds)">批量删除</el-button>
</el-col>
<el-col :span="1.5">
<el-button type="warning" icon="delete" @click="handleClearLog">清空日志</el-button>
</el-col>
</el-row>
</div>
<div class="data-table__toolbar--right">
<el-row :gutter="10">
<el-col :span="1.5">
<el-tooltip content="导出">
<!-- 将直接导出改为打开导出弹窗 -->
<el-button type="warning" icon="download" circle @click="handleOpenExportsModal" />
</el-tooltip>
</el-col>
<el-col :span="1.5">
<el-tooltip content="刷新">
<el-button type="default" icon="refresh" circle @click="handleRefresh" />
</el-tooltip>
</el-col>
</el-row>
</div>
</div>
<!-- 表格区域:任务日志列表 -->
<el-table ref="dataTableRef" v-loading="loading" :data="pageTableData" highlight-current-row class="data-table__content" height="460" border stripe @selection-change="handleSelectionChange">
<template #empty>
<el-empty :image-size="80" description="暂无数据" />
</template>
<el-table-column type="selection" min-width="55" align="center" />
<el-table-column type="index" fixed label="序号" min-width="60">
<template #default="scope">
{{ (queryFormData.page_no - 1) * queryFormData.page_size + scope.$index + 1 }}
</template>
</el-table-column>
<el-table-column label="任务名称" prop="job_name" min-width="150" show-overflow-tooltip />
<el-table-column label="任务组名" prop="job_group" min-width="120" show-overflow-tooltip />
<el-table-column label="执行状态" prop="status" min-width="100" show-overflow-tooltip>
<template #default="scope">
<el-tag :type="scope.row.status === true ? 'success' : 'danger'">
{{ scope.row.status ? "成功" : "失败" }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="执行信息" prop="job_message" min-width="200" show-overflow-tooltip />
<el-table-column label="异常信息" prop="exception_info" min-width="250" show-overflow-tooltip />
<el-table-column label="执行器" prop="job_executor" min-width="120" show-overflow-tooltip />
<el-table-column label="调用目标" prop="invoke_target" min-width="200" show-overflow-tooltip />
<el-table-column label="位置参数" prop="job_args" min-width="150" show-overflow-tooltip />
<el-table-column label="关键字参数" prop="job_kwargs" min-width="150" show-overflow-tooltip />
<el-table-column label="触发器" prop="job_trigger" min-width="120" show-overflow-tooltip />
<el-table-column label="创建时间" prop="create_time" min-width="180" sortable />
<el-table-column fixed="right" label="操作" align="center" min-width="150">
<template #default="scope">
<el-button type="info" size="small" link icon="document" @click="handleOpenDialog('detail', scope.row.id)">详情</el-button>
<el-button type="danger" size="small" link icon="delete" @click="handleDelete([scope.row.id])">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页区域 -->
<template #footer>
<pagination v-model:total="total" v-model:page="queryFormData.page_no" v-model:limit="queryFormData.page_size" @pagination="loadingData" />
</template>
</el-card>
<!-- 弹窗区域 -->
<el-dialog v-model="dialogVisible.visible" :title="dialogVisible.title" @close="handleCloseDialog">
<!-- 详情 -->
<template v-if="dialogVisible.type === 'detail'">
<el-descriptions :column="2" border label-width="120px">
<el-descriptions-item label="日志ID" :span="2">{{ detailFormData.id }}</el-descriptions-item>
<el-descriptions-item label="任务名称" :span="2">{{ detailFormData.job_name }}</el-descriptions-item>
<el-descriptions-item label="任务组名" :span="2">{{ detailFormData.job_group }}</el-descriptions-item>
<el-descriptions-item label="执行状态" :span="2">
<el-tag :type="detailFormData.status === true ? 'success' : 'danger'">
{{ detailFormData.status ? "成功" : "失败" }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="执行信息" :span="2">{{ detailFormData.job_message || '-' }}</el-descriptions-item>
<el-descriptions-item label="异常信息" :span="2">{{ detailFormData.exception_info || '-' }}</el-descriptions-item>
<el-descriptions-item label="执行器" :span="2">{{ detailFormData.job_executor || '-' }}</el-descriptions-item>
<el-descriptions-item label="调用目标" :span="2">{{ detailFormData.invoke_target || '-' }}</el-descriptions-item>
<el-descriptions-item label="位置参数" :span="2">{{ detailFormData.job_args || '-' }}</el-descriptions-item>
<el-descriptions-item label="关键字参数" :span="2">{{ detailFormData.job_kwargs || '-' }}</el-descriptions-item>
<el-descriptions-item label="触发器" :span="2">{{ detailFormData.job_trigger || '-' }}</el-descriptions-item>
<el-descriptions-item label="创建时间" :span="2">{{ detailFormData.create_time }}</el-descriptions-item>
</el-descriptions>
</template>
</el-dialog>
<!-- 导出弹窗 -->
<ExportModal
v-model="exportsDialogVisible"
:content-config="curdContentConfig"
:query-params="queryFormData"
:page-data="pageTableData"
:selection-data="selectionRows"
/>
</el-drawer>
</template>
<script setup lang="ts">
// 添加 props 来接收 jobId 和 jobName
const props = defineProps({
jobId: {
type: Number,
required: true
},
jobName: {
type: String,
required: true
}
})
import JobAPI, { JobLogPageQuery, JobLogTable } from "@/api/module_application/job";
import { useAppStore } from "@/store/modules/app.store";
import { DeviceEnum } from "@/enums/settings/device.enum";
import ExportModal from "@/components/CURD/ExportModal.vue";
import type { IContentConfig } from "@/components/CURD/types";
import { formatToDateTime } from "@/utils/dateUtil";
const appStore = useAppStore();
const drawerSize = computed(() => (appStore.device === DeviceEnum.DESKTOP ? "80%" : "60%"));
const queryFormRef = ref();
const dataTableRef = ref();
const total = ref(0);
const selectIds = ref<number[]>([]);
const loading = ref(false);
const isExpand = ref(false);
const isExpandable = ref(true);
const drawerVisible = ref<boolean>(false);
// 分页表单
const pageTableData = ref<JobLogTable[]>([]);
// 导出弹窗显示状态 & 选中行
const exportsDialogVisible = ref(false);
const selectionRows = ref<JobLogTable[]>([]);
// 详情表单
const detailFormData = ref<JobLogTable>({} as JobLogTable);
// 分页查询参数
const queryFormData = reactive<JobLogPageQuery>({
page_no: 1,
page_size: 10,
status: undefined,
start_time: undefined,
end_time: undefined,
job_id: props.jobId,
});
// 弹窗状态
const dialogVisible = reactive({
title: "",
visible: false,
type: 'detail',
});
// 日期范围临时变量
const dateRange = ref<[Date, Date] | []>([]);
// 处理日期范围变化
function handleDateRangeChange(range: [Date, Date]) {
dateRange.value = range;
if (range && range.length === 2) {
queryFormData.start_time = formatToDateTime(range[0]);
queryFormData.end_time = formatToDateTime(range[1]);
} else {
queryFormData.start_time = undefined;
queryFormData.end_time = undefined;
}
}
// 列表刷新
async function handleRefresh () {
await loadingData();
};
// 加载表格数据
async function loadingData() {
loading.value = true;
try {
// 调用任务日志列表接口
const response = await JobAPI.getJobLogList(queryFormData);
pageTableData.value = response.data.data.items;
total.value = response.data.data.total;
}
catch (error: any) {
console.error(error);
}
finally {
loading.value = false;
}
}
// 查询(重置页码后获取数据)
async function handleQuery() {
queryFormData.page_no = 1;
loadingData();
}
// 重置查询
async function handleResetQuery() {
queryFormRef.value.resetFields();
queryFormData.page_no = 1;
queryFormData.status = undefined;
queryFormData.start_time = undefined;
queryFormData.end_time = undefined;
dateRange.value = [];
loadingData();
}
// 行复选框选中项变化
async function handleSelectionChange(selection: any) {
// 提取有效的数字ID,过滤掉 null/undefined 并转为 number
selectIds.value = selection
.map((item: any) => item?.id)
.filter((id: any) => id !== null && id !== undefined)
.map((id: any) => Number(id));
// 记录选中行数据供导出弹窗使用
selectionRows.value = selection;
}
// 关闭弹窗
async function handleCloseDialog() {
dialogVisible.visible = false;
}
// 打开详情弹窗
async function handleOpenDialog(type: 'detail', id?: number) {
dialogVisible.type = type;
if (id) {
const response = await JobAPI.getJobLogDetail(id);
dialogVisible.title = "任务日志详情";
Object.assign(detailFormData.value, response.data.data);
}
dialogVisible.visible = true;
}
// 删除、批量删除
async function handleDelete(ids: number[]) {
// 如果没有有效ID,直接返回
const validIds = ids.filter((id) => id !== null && id !== undefined) as number[];
if (validIds.length === 0) return;
ElMessageBox.confirm("确认删除该任务日志?", "警告", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
}).then(async () => {
try {
loading.value = true;
await JobAPI.deleteJobLog(validIds);
// 删除后刷新并清空选择状态
handleResetQuery();
selectIds.value = [];
} catch (error: any) {
console.error(error);
} finally {
loading.value = false;
}
}).catch(() => {
ElMessageBox.close();
});
}
// 清空日志
async function handleClearLog() {
ElMessageBox.confirm("确认清空所有任务日志?此操作不可恢复!", "警告", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
}).then(async () => {
try {
loading.value = true;
await JobAPI.clearJobLog();
handleResetQuery();
} catch (error: any) {
console.error(error);
} finally {
loading.value = false;
}
}).catch(() => {
ElMessageBox.close();
});
}
// 打开导出弹窗
function handleOpenExportsModal() {
exportsDialogVisible.value = true;
}
// 导出字段
const exportColumns = [
{ prop: 'job_name', label: '任务名称' },
{ prop: 'job_group', label: '任务组名' },
{ prop: 'status', label: '执行状态' },
{ prop: 'job_message', label: '执行信息' },
{ prop: 'exception_info', label: '异常信息' },
{ prop: 'job_executor', label: '执行器' },
{ prop: 'invoke_target', label: '调用目标' },
{ prop: 'job_args', label: '位置参数' },
{ prop: 'job_kwargs', label: '关键字参数' },
{ prop: 'job_trigger', label: '触发器' },
{ prop: 'create_time', label: '创建时间' },
];
// 导出配置(用于导出弹窗)
const curdContentConfig = {
permPrefix: 'application:job_log',
cols: exportColumns as any,
exportsAction: async (params: any) => {
const query: any = { ...params };
query.page_no = 1;
query.page_size = 1000;
const all: any[] = [];
while (true) {
const res = await JobAPI.getJobLogList(query);
const items = res.data?.data?.items || [];
const total = res.data?.data?.total || 0;
all.push(...items);
if (all.length >= total || items.length === 0) break;
query.page_no += 1;
}
return all;
},
} as unknown as IContentConfig;
// 打开抽屉
function openDrawer() {
drawerVisible.value = true;
}
// 关闭抽屉
function closeDrawer() {
drawerVisible.value = false;
}
// 暴露方法给父组件
defineExpose({
openDrawer,
closeDrawer
});
onMounted(() => {
// 抽屉打开时会自动加载数据
loadingData();
});
</script>
<style lang="scss" scoped></style>
@@ -0,0 +1,974 @@
<!-- 定时任务 -->
<template>
<div class="app-container">
<!-- 搜索区域 -->
<div class="search-container">
<el-form
ref="queryFormRef"
:model="queryFormData"
:inline="true"
label-suffix=":"
@submit.prevent="handleQuery"
>
<el-form-item prop="name" label="任务名称">
<el-input
v-model="queryFormData.name"
placeholder="请输入任务名称"
clearable
/>
</el-form-item>
<el-form-item prop="status" label="状态">
<el-select
v-model="queryFormData.status"
placeholder="请选择状态"
clearable
style="width: 167.5px"
>
<el-option value="true" label="运行中" />
<el-option value="false" label="暂停" />
</el-select>
</el-form-item>
<!-- 时间范围,收起状态下隐藏 -->
<el-form-item v-if="isExpand" prop="start_time" label="创建时间">
<DatePicker
v-model="dateRange"
@update:model-value="handleDateRangeChange"
/>
</el-form-item>
<el-form-item v-if="isExpand" prop="creator" label="创建人">
<UserTableSelect
v-model="queryFormData.creator"
@confirm-click="handleConfirm"
@clear-click="handleQuery"
/>
</el-form-item>
<!-- 查询、重置、展开/收起按钮 -->
<el-form-item class="search-buttons">
<el-button v-hasPerm="['app:job:query']" type="primary" icon="search" native-type="submit">查询</el-button>
<el-button v-hasPerm="['app:job:query']" icon="refresh" @click="handleResetQuery">重置</el-button>
<!-- 展开/收起 -->
<template v-if="isExpandable">
<el-link
class="ml-3"
type="primary"
underline="never"
@click="isExpand = !isExpand"
>
{{ isExpand ? "收起" : "展开" }}
<el-icon>
<template v-if="isExpand">
<ArrowUp />
</template>
<template v-else>
<ArrowDown />
</template>
</el-icon>
</el-link>
</template>
</el-form-item>
</el-form>
</div>
<!-- 内容区域 -->
<el-card class="data-table">
<template #header>
<div class="card-header">
<span>
<el-tooltip content="定时任务列表">
<QuestionFilled class="w-4 h-4 mx-1" />
</el-tooltip>
定时任务列表
</span>
</div>
</template>
<!-- 功能区域 -->
<div class="data-table__toolbar">
<div class="data-table__toolbar--left">
<el-row :gutter="10">
<el-col :span="1.5">
<el-button
v-hasPerm="['app:job:create']"
type="success"
icon="plus"
@click="handleOpenDialog('create')"
>新增</el-button>
</el-col>
<el-col :span="1.5">
<el-button
v-hasPerm="['app:job:delete']"
type="danger"
icon="delete"
:disabled="selectIds.length === 0"
@click="handleDelete(selectIds)"
>批量删除</el-button>
</el-col>
</el-row>
</div>
<div class="data-table__toolbar--right">
<el-row :gutter="10">
<el-col :span="1.5">
<el-tooltip content="导出">
<el-button
v-hasPerm="['app:job:export']"
type="warning"
icon="download"
circle
@click="handleOpenExportsModal"
/>
</el-tooltip>
</el-col>
<el-col :span="1.5">
<el-tooltip content="清除">
<el-button v-hasPerm="['app:job:clear']" type="danger" icon="delete" circle @click="handleClear"/>
</el-tooltip>
</el-col>
<el-col :span="1.5">
<el-tooltip content="刷新">
<el-button
v-hasPerm="['app:job:refresh']"
type="primary"
icon="refresh"
circle
@click="handleRefresh"
/>
</el-tooltip>
</el-col>
</el-row>
</div>
</div>
<!-- 表格区域:系统配置列表 -->
<el-table
ref="dataTableRef"
v-loading="loading"
:data="pageTableData"
class="data-table__content"
highlight-current-row
height="450"
border
stripe
@selection-change="handleSelectionChange"
>
<template #empty>
<el-empty :image-size="80" description="暂无数据" />
</template>
<el-table-column type="selection" align="center" min-width="55" />
<el-table-column type="index" label="序号" fixed min-width="60">
<template #default="scope">
{{ (queryFormData.page_no - 1) * queryFormData.page_size + scope.$index + 1 }}
</template>
</el-table-column>
<el-table-column label="任务名称" prop="name" min-width="140" />
<el-table-column label="执行函数" prop="func" min-width="140" >
<template #default="scope">
{{ (dictStore.getDictLabel('sys_job_function',scope.row.func) as any)?.dict_label || scope.row.func }}
</template>
</el-table-column>
<el-table-column label="触发器" prop="trigger" min-width="100">
<template #default="scope">
{{ (dictStore.getDictLabel('sys_job_trigger',scope.row.trigger) as any)?.dict_label }}
</template>
</el-table-column>
<el-table-column label="存储器" prop="jobstore" min-width="120">
<template #default="scope">
{{ (dictStore.getDictLabel('sys_job_store',scope.row.jobstore) as any)?.dict_label }}
</template>
</el-table-column>
<el-table-column label="执行器" prop="executor" min-width="100">
<template #default="scope">
{{ (dictStore.getDictLabel('sys_job_executor',scope.row.executor) as any)?.dict_label }}
</template>
</el-table-column>
<el-table-column label="并发执行" prop="coalesce" min-width="100">
<template #default="scope">
<el-tag :type="scope.row.coalesce === true ? 'success' : 'danger'">
{{ scope.row.coalesce === true ? "是" : "否" }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" prop="status" min-width="100">
<template #default="scope">
<el-tag :type="scope.row.status === true ? 'success' : 'danger'">
{{ scope.row.status === true ? "运行中" : "暂停" }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="描述" prop="description" min-width="100" />
<el-table-column
label="创建时间"
prop="created_at"
min-width="200"
sortable
/>
<el-table-column
label="更新时间"
prop="updated_at"
min-width="200"
sortable
/>
<OperationColumn :list-data-length="pageTableData.length">
<template #default="scope">
<div class="flex">
<el-button
type="warning"
size="small"
link
icon="document"
@click="handleOpenLogDrawer(scope.row.id, scope.row.name)"
>
日志
</el-button>
<el-button
type="info"
size="small"
link
icon="document"
@click="handleOpenDialog('detail', scope.row.id)"
>
详情
</el-button>
<el-button
v-hasPerm="['app:job:update']"
type="primary"
size="small"
link
icon="edit"
@click="handleOpenDialog('update', scope.row.id)"
>
编辑
</el-button>
<el-button
v-hasPerm="['app:job:delete']"
type="danger"
size="small"
link
icon="delete"
@click="handleDelete([scope.row.id])"
>
删除
</el-button>
<el-dropdown v-hasPerm="['app:job:status']" trigger="click">
<el-button type="warning" size="small" link icon="ArrowDown">更多</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
:disabled="scope.row.status === false"
icon="Check"
@click="handleOption(scope.row.id, 1)"
>
暂停
</el-dropdown-item>
<el-dropdown-item
:disabled="scope.row.status === true"
icon="CircleClose"
@click="handleOption(scope.row.id, 2)"
>
恢复
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
</OperationColumn>
</el-table>
<!-- 分页区域 -->
<template #footer>
<pagination
v-model:total="total"
v-model:page="queryFormData.page_no"
v-model:limit="queryFormData.page_size"
@pagination="loadingData"
/>
</template>
</el-card>
<!-- 弹窗区域 -->
<el-dialog
v-model="dialogVisible.visible"
:title="dialogVisible.title"
@close="handleCloseDialog"
>
<!-- 详情 -->
<template v-if="dialogVisible.type === 'detail'">
<el-descriptions :column="4" border>
<el-descriptions-item label="序号" :span="2">{{
detailFormData.id
}}</el-descriptions-item>
<el-descriptions-item label="任务名称" :span="2">{{
detailFormData.name
}}</el-descriptions-item>
<el-descriptions-item label="任务函数" :span="2">{{
(detailFormData.func ? dictStore.getDictLabel('sys_job_function', detailFormData.func) as any : undefined)?.dict_label || detailFormData.func
}}</el-descriptions-item>
<el-descriptions-item label="存储器" :span="2">{{
(detailFormData.jobstore ? dictStore.getDictLabel('sys_job_store', detailFormData.jobstore) as any : undefined)?.dict_label || detailFormData.jobstore
}}</el-descriptions-item>
<el-descriptions-item label="执行器" :span="2">{{
(detailFormData.executor ? dictStore.getDictLabel('sys_job_executor', detailFormData.executor) as any : undefined)?.dict_label || detailFormData.executor
}}</el-descriptions-item>
<el-descriptions-item label="触发器" :span="2">{{
(detailFormData.trigger ? dictStore.getDictLabel('sys_job_trigger', detailFormData.trigger) as any : undefined)?.dict_label || detailFormData.trigger
}}</el-descriptions-item>
<el-descriptions-item label="位置参数" :span="2">{{
detailFormData.args
}}</el-descriptions-item>
<el-descriptions-item label="关键字参数" :span="2">{{
detailFormData.kwargs
}}</el-descriptions-item>
<el-descriptions-item label="并发执行" :span="2">
<el-tag :type="detailFormData.coalesce ? 'success' : 'danger'">
{{ detailFormData.coalesce ? "是" : "否" }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="状态" :span="2">
<el-tag :type="detailFormData.status ? 'success' : 'danger'">
{{ detailFormData.status ? "运行中" : "暂停" }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="最大实例数" :span="2">{{
detailFormData.max_instances
}}</el-descriptions-item>
<el-descriptions-item label="触发器参数" :span="2">{{
detailFormData.trigger_args
}}</el-descriptions-item>
<el-descriptions-item label="开始时间" :span="2">{{
detailFormData.start_date
}}</el-descriptions-item>
<el-descriptions-item label="结束时间" :span="2">{{
detailFormData.end_date
}}</el-descriptions-item>
<el-descriptions-item label="创建人" :span="2">{{
detailFormData.creator?.name
}}</el-descriptions-item>
<el-descriptions-item label="创建时间" :span="2">{{
detailFormData.created_at
}}</el-descriptions-item>
<el-descriptions-item label="更新时间" :span="2">{{
detailFormData.updated_at
}}</el-descriptions-item>
<el-descriptions-item label="描述" :span="4">{{
detailFormData.description
}}</el-descriptions-item>
</el-descriptions>
</template>
<!-- 新增、编辑表单 -->
<template v-else>
<el-form
ref="dataFormRef"
:model="formData"
:rules="rules"
label-suffix=":"
label-width="auto"
inline
>
<el-form-item label="任务名称" prop="name" style="width: 40%">
<el-input
v-model="formData.name"
placeholder="请输入任务名称"
:maxlength="50"
/>
</el-form-item>
<el-form-item label="任务函数" prop="func" style="width: 40%">
<el-select v-model="formData.func" placeholder="请选择任务函数">
<el-option
v-for="item in dictStore.getDictArray('sys_job_function')"
:key="item.dict_value"
:label="item.dict_label"
:value="item.dict_value"
/>
</el-select>
</el-form-item>
<el-form-item label="存储器" prop="jobstore" style="width: 40%">
<el-select v-model="formData.jobstore" placeholder="请选择存储器">
<el-option
v-for="item in dictStore.getDictArray('sys_job_store')"
:key="item.dict_value"
:label="item.dict_label"
:value="item.dict_value"
/>
</el-select>
</el-form-item>
<el-form-item label="执行器" prop="executor" style="width: 40%">
<el-select v-model="formData.executor" placeholder="请选择执行器">
<el-option
v-for="item in dictStore.getDictArray('sys_job_executor')"
:key="item.dict_value"
:label="item.dict_label"
:value="item.dict_value"
/>
</el-select>
</el-form-item>
<el-form-item label="位置参数" prop="args" style="width: 40%">
<el-input
v-model="formData.args"
placeholder="请输入位置参数"
:maxlength="50"
/>
</el-form-item>
<el-form-item label="关键字参数" prop="kwargs" style="width: 40%">
<el-input
v-model="formData.kwargs"
placeholder="请输入关键字参数"
:maxlength="50"
/>
</el-form-item>
<el-form-item label="并发执行" prop="coalesce" style="width: 40%">
<el-radio-group v-model="formData.coalesce">
<el-radio :value="true">是</el-radio>
<el-radio :value="false">否</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item
label="最大实例数"
prop="max_instances"
style="width: 40%"
>
<el-input-number
v-model="formData.max_instances"
controls-position="right"
:min="1"
:max="10"
/>
</el-form-item>
<el-form-item label="触发器" prop="trigger" style="width: 40%">
<el-select v-model="formData.trigger" placeholder="请选择触发器">
<el-option
v-for="item in dictStore.getDictArray('sys_job_trigger')"
:key="item.dict_value"
:label="item.dict_label"
:value="item.dict_value"
/>
</el-select>
</el-form-item>
<!-- 运行日期、间隔时间或 Cron 表达式 -->
<el-form-item
v-if="formData.trigger === 'date'"
label="运行日期"
prop="trigger_args"
:rules="[{ required: true, message: '请选择运行日期' }]"
style="width: 40%"
>
<el-date-picker
v-model="formData.trigger_args"
type="datetime"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="请选择运行日期"
/>
</el-form-item>
<el-form-item
v-else-if="formData.trigger === 'interval'"
label="间隔时间"
prop="trigger_args"
:rules="[
{ required: true, message: '请输入间隔时间', trigger: 'change' },
]"
style="width: 40%"
>
<el-popover
:visible="openIntervalTab"
width="600px"
trigger="click"
:persistent="false"
>
<template #reference>
<el-input
v-model="formData.trigger_args"
placeholder="请输入 秒-分-时-天-周"
readonly
@click="openIntervalTab = true"
/>
</template>
<IntervalTab
ref="intervalTabRef"
:cron-value="formData.trigger_args"
@confirm="handleIntervalConfirm"
@cancel="openIntervalTab = false"
/>
</el-popover>
</el-form-item>
<el-form-item
v-else-if="formData.trigger === 'cron'"
label="Cron表达式"
prop="trigger_args"
:rules="[
{
required: true,
message: '请输入Cron表达式',
trigger: 'change',
},
]"
style="width: 40%"
>
<el-popover
:visible="openCron"
width="600px"
trigger="click"
:persistent="false"
placement="left"
>
<template #reference>
<el-input
v-model="formData.trigger_args"
placeholder="请输入 * * * * * ? *"
readonly
@click="openCron = true"
/>
</template>
<vue3CronPlus
i18n="cn"
max-height="500px"
@change="handlechangeCron"
@close="openCron = false"
/>
</el-popover>
</el-form-item>
<!-- 开始日期和结束日期 -->
<el-form-item
v-if="formData.trigger && formData.trigger != 'date'"
label="开始日期"
prop="start_date"
:rules="[
{ required: false, message: '请选择开始日期', trigger: 'blur' },
]"
style="width: 40%"
>
<el-date-picker
v-model="formData.start_date"
type="datetime"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="请选择开始日期"
/>
</el-form-item>
<el-form-item
v-if="formData.trigger && formData.trigger != 'date'"
label="结束日期"
prop="end_date"
:rules="[
{ required: false, message: '请选择结束日期', trigger: 'blur' },
]"
style="width: 40%"
>
<el-date-picker
v-model="formData.end_date"
type="datetime"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="请选择结束日期"
/>
</el-form-item>
<el-form-item label="描述" prop="description" style="width: 85%">
<el-input
v-model="formData.description"
:rows="4"
:maxlength="100"
show-word-limit
type="textarea"
placeholder="请输入描述"
/>
</el-form-item>
</el-form>
</template>
<template #footer>
<div class="dialog-footer">
<!-- 详情弹窗不需要确定按钮的提交逻辑 -->
<el-button @click="handleCloseDialog">取消</el-button>
<el-button v-if="dialogVisible.type !== 'detail'" type="primary" @click="handleSubmit">确定</el-button>
<el-button v-else v-hasPerm="['app:job:detail']" type="primary" @click="handleCloseDialog" >确定</el-button>
</div>
</template>
</el-dialog>
<JobLogDrawer v-if="drawerVisible" v-model="drawerVisible" :job-id="currentJobId" :job-name="currentJobName" />
<ExportModal
v-model="exportsDialogVisible"
:content-config="curdContentConfig"
:query-params="queryFormData"
:page-data="pageTableData"
:selection-data="selectionRows"
/>
</div>
</template>
<script lang="ts" setup>
defineOptions({
name: "Job",
inheritAttrs: false,
});
import JobAPI, { JobTable, JobForm, JobPageQuery } from "@/api/module_application/job";
import IntervalTab from "@/components/IntervalTab/index.vue";
import { useDictStore } from "@/store/index";
import { vue3CronPlus } from "vue3-cron-plus";
import "vue3-cron-plus/dist/index.css"; // 引入样式
import JobLogDrawer from "@/views/module_application/job/components/JobLogDrawer.vue"
import OperationColumn from "@/components/OperationColumn/index.vue";
import ExportModal from "@/components/CURD/ExportModal.vue";
import type { IContentConfig } from "@/components/CURD/types";
import { formatToDateTime } from "@/utils/dateUtil";
const dictStore = useDictStore();
const queryFormRef = ref();
const dataFormRef = ref();
const total = ref(0);
const selectIds = ref<number[]>([]);
const loading = ref(false);
const isExpand = ref(false);
const isExpandable = ref(true);
const openCron = ref(false);
const openIntervalTab = ref(false);
const intervalTabRef = ref();
// 分页表单
const pageTableData = ref<JobTable[]>([]);
// 导出弹窗显示状态 & 选中行
const exportsDialogVisible = ref(false);
const selectionRows = ref<JobTable[]>([]);
// 详情表单
const detailFormData = ref<JobTable>({} as JobTable);
// 分页查询参数
const queryFormData = reactive<JobPageQuery>({
page_no: 1,
page_size: 10,
name: undefined,
status: undefined,
start_time: undefined,
end_time: undefined,
// 创建人
creator: undefined,
});
// 编辑表单
const formData = reactive<JobForm>({
id: undefined,
name: undefined,
func: undefined,
trigger: undefined,
args: undefined,
kwargs: undefined,
coalesce: false,
max_instances: 1,
jobstore: undefined,
executor: undefined,
trigger_args: undefined,
start_date: undefined,
end_date: undefined,
status: undefined,
description: undefined,
});
// 弹窗状态
const dialogVisible = reactive({
title: "",
visible: false,
type: "create" as "create" | "update" | "detail" | "log",
});
// 抽屉显隐
const drawerVisible = ref(false);
// 表单验证规则
const rules = reactive({
name: [{ required: true, message: "请输入任务名称", trigger: "blur" }],
func: [{ required: true, message: "请输入执行函数", trigger: "blur" }],
trigger: [{ required: true, message: "请选择触发器", trigger: "blur" }],
coalesce: [{ required: true, message: "请选择并发执行", trigger: "blur" }],
jobstore: [{ required: true, message: "请选择存储器", trigger: "blur" }],
executor: [{ required: true, message: "请选择执行器", trigger: "blur" }],
});
// 日期范围临时变量
const dateRange = ref<[Date, Date] | []>([]);
// 处理日期范围变化
function handleDateRangeChange(range: [Date, Date]) {
dateRange.value = range;
if (range && range.length === 2) {
queryFormData.start_time = formatToDateTime(range[0]);
queryFormData.end_time = formatToDateTime(range[1]);
} else {
queryFormData.start_time = undefined;
queryFormData.end_time = undefined;
}
}
// 列表刷新
async function handleRefresh() {
await loadingData();
}
// 加载表格数据
async function loadingData() {
loading.value = true;
try {
const response = await JobAPI.getJobList(queryFormData);
pageTableData.value = response.data.data.items;
total.value = response.data.data.total;
} catch (error: any) {
console.error(error);
} finally {
loading.value = false;
}
}
// 查询(重置页码后获取数据)
async function handleQuery() {
queryFormData.page_no = 1;
loadingData();
}
// 选择创建人后触发查询
function handleConfirm() {
handleQuery();
}
// 重置查询
async function handleResetQuery() {
queryFormRef.value.resetFields();
queryFormData.page_no = 1;
// 额外清空日期范围与时间查询参数
dateRange.value = [];
queryFormData.start_time = undefined;
queryFormData.end_time = undefined;
loadingData();
}
// 定义初始表单数据常量
const initialFormData: JobForm = {
id: undefined,
name: undefined,
func: undefined,
trigger: undefined,
args: undefined,
kwargs: undefined,
coalesce: false,
max_instances: 1,
jobstore: undefined,
executor: undefined,
trigger_args: undefined,
start_date: undefined,
end_date: undefined,
status: undefined,
description: undefined,
}
// 重置表单
async function resetForm() {
if (dataFormRef.value) {
dataFormRef.value.resetFields();
dataFormRef.value.clearValidate();
}
// 完全重置 formData 为初始状态
Object.assign(formData, initialFormData);
}
// 行复选框选中项变化
async function handleSelectionChange(selection: any) {
selectIds.value = selection.map((item: any) => item.id);
selectionRows.value = selection;
}
// 关闭弹窗
async function handleCloseDialog() {
dialogVisible.visible = false;
resetForm();
}
// 打开弹窗
async function handleOpenDialog(
type: "create" | "update" | "detail",
id?: number
) {
dialogVisible.type = type;
if (id) {
const response = await JobAPI.getJobDetail(id);
if (type === "detail") {
dialogVisible.title = "任务详情";
Object.assign(detailFormData.value, response.data.data);
} else if (type === "update") {
dialogVisible.title = "修改任务";
Object.assign(formData, response.data.data);
}
} else {
dialogVisible.title = "新增任务";
formData.id = undefined;
}
dialogVisible.visible = true;
}
// 提交表单(防抖)
async function handleSubmit() {
// 表单校验
dataFormRef.value.validate(async (valid: any) => {
if (valid) {
loading.value = true;
// 根据弹窗传入的参数(deatil\create\update)判断走什么逻辑
const id = formData.id;
if (id) {
try {
await JobAPI.updateJob(id, formData);
dialogVisible.visible = false;
resetForm();
handleCloseDialog();
handleResetQuery();
} catch (error: any) {
console.log(error);
} finally {
loading.value = false;
}
} else {
try {
await JobAPI.createJob(formData);
dialogVisible.visible = false;
resetForm();
handleCloseDialog();
handleResetQuery();
} catch (error: any) {
console.log(error);
} finally {
loading.value = false;
}
}
}
});
}
// 删除、批量删除
async function handleDelete(ids: number[]) {
ElMessageBox.confirm("确认删除该项数据?", "警告", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
})
.then(async () => {
try {
loading.value = true;
await JobAPI.deleteJob(ids);
handleResetQuery();
} catch (error: any) {
console.error(error);
} finally {
loading.value = false;
}
})
.catch(() => {
ElMessageBox.close();
});
}
// 打开导出弹窗
async function handleOpenExportsModal() {
exportsDialogVisible.value = true;
}
function handleIntervalConfirm(interval: string) {
formData.trigger_args = interval;
openIntervalTab.value = false;
}
const handlechangeCron = (cronStr: string) => {
// formData.trigger_args = cronStr;
if (typeof cronStr == "string") {
formData.trigger_args = cronStr;
}
};
// 清空按钮操作
const handleClear = () => {
ElMessageBox.confirm("是否确认清空所有定时任务数据?", "警告", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
})
.then(async () => {
try {
loading.value = true;
await JobAPI.clearJob();
handleResetQuery();
} catch (error: any) {
console.error(error);
} finally {
loading.value = false;
}
})
.catch(() => {
ElMessageBox.close();
});
};
// 操作按钮:操作类型 1: 暂停 2: 恢复 3: 重启(暂时移除重启)
const handleOption = (id: number, option: number) => {
JobAPI.OptionJob({ id, option }).then(() => {
loadingData();
});
};
const currentJobId = ref<number>(0);
const currentJobName = ref<string>("");
function handleOpenLogDrawer(jobId: number, jobName: string) {
currentJobId.value = jobId;
currentJobName.value = jobName;
drawerVisible.value = true;
}
// 导出字段
const exportColumns = [
{ prop: 'name', label: '任务名称' },
{ prop: 'func', label: '执行函数' },
{ prop: 'trigger', label: '触发器' },
{ prop: 'jobstore', label: '存储器' },
{ prop: 'executor', label: '执行器' },
{ prop: 'coalesce', label: '并发执行' },
{ prop: 'status', label: '状态' },
{ prop: 'description', label: '描述' },
{ prop: 'created_at', label: '创建时间' },
{ prop: 'updated_at', label: '更新时间' },
];
// 导出配置(用于导出弹窗)
const curdContentConfig = {
permPrefix: 'app:job',
cols: exportColumns as any,
exportsAction: async (params: any) => {
const query: any = { ...params };
if (typeof query.status === 'string') query.status = query.status === 'true';
query.page_no = 1;
query.page_size = 1000;
const all: any[] = [];
while (true) {
const res = await JobAPI.getJobList(query);
const items = res.data?.data?.items || [];
const total = res.data?.data?.total || 0;
all.push(...items);
if (all.length >= total || items.length === 0) break;
query.page_no += 1;
}
return all;
},
} as unknown as IContentConfig;
onMounted(async () => {
// 加载字典数据
await dictStore.getDict(['sys_job_function','sys_job_executor','sys_job_store', 'sys_job_trigger']);
// 加载表格数据
loadingData();
});
</script>
<style lang="scss" scoped>
</style>
@@ -0,0 +1,142 @@
<!-- 内部应用展示页面 -->
<template>
<div class="internal-app-container">
<div class="internal-app-content">
<iframe
ref="iframeRef"
:src="appUrl"
class="internal-app-iframe"
frameborder="0"
allowfullscreen
@load="handleIframeLoad"
></iframe>
<div v-if="loading" class="loading-overlay">
<el-icon class="loading-icon">
<Loading />
</el-icon>
<span>加载中...</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useRoute } from 'vue-router';
import { Loading } from '@element-plus/icons-vue';
import { useTagsViewStore } from '@/store';
defineOptions({
name: "InternalApp",
inheritAttrs: false,
});
const route = useRoute();
const tagsViewStore = useTagsViewStore();
const iframeRef = ref<HTMLIFrameElement>();
const loading = ref(true);
// 从路由参数获取应用信息
const appUrl = computed(() => route.query.url as string);
const appName = computed(() => route.query.appName as string);
// iframe加载完成
function handleIframeLoad() {
loading.value = false;
}
// 监听路由变化,更新iframe
watch(() => route.query.url, (newUrl) => {
if (newUrl && iframeRef.value) {
loading.value = true;
iframeRef.value.src = newUrl as string;
}
});
// 在组件挂载时设置标签标题
onMounted(() => {
if (appName.value) {
// 查找当前标签并更新标题
nextTick(() => {
const currentTag = tagsViewStore.visitedViews.find(tag => tag.path === route.path);
if (currentTag && currentTag.title !== appName.value) {
tagsViewStore.updateVisitedView({
...currentTag,
title: appName.value,
fullPath: route.fullPath,
query: route.query,
});
}
});
}
});
// 监听应用名称变化,更新标签标题
watch(() => appName.value, (newAppName) => {
if (newAppName) {
const currentTag = tagsViewStore.visitedViews.find(tag => tag.path === route.path);
if (currentTag) {
tagsViewStore.updateVisitedView({
...currentTag,
title: newAppName,
fullPath: route.fullPath,
query: route.query,
});
}
}
});
</script>
<style lang="scss" scoped>
.internal-app-container {
height: 100%;
display: flex;
flex-direction: column;
background: var(--el-bg-color);
}
.internal-app-content {
flex: 1;
position: relative;
overflow: hidden;
}
.internal-app-iframe {
width: 100%;
height: 100%;
border: none;
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background: var(--el-bg-color);
gap: 12px;
.loading-icon {
font-size: 24px;
color: var(--el-color-primary);
animation: rotate 2s linear infinite;
}
span {
color: var(--el-text-color-regular);
font-size: 14px;
}
}
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>
@@ -0,0 +1,624 @@
<!-- 我的应用管理 -->
<template>
<div class="app-container">
<!-- 顶部搜索和操作区域 -->
<div class="search-container">
<el-form ref="queryFormRef" :model="queryFormData" :inline="true" label-suffix=":" @submit.prevent="handleQuery">
<el-form-item prop="name" label="应用名称">
<el-input v-model="queryFormData.name" placeholder="请输入应用名称" clearable/>
</el-form-item>
<el-form-item prop="status" label="状态">
<el-select v-model="queryFormData.status" placeholder="请选择状态" clearable style="width: 170px;">
<el-option label="启用" :value="true" />
<el-option label="停用" :value="false" />
</el-select>
</el-form-item>
<el-form-item v-if="isExpand" prop="creator" label="创建人">
<UserTableSelect
v-model="queryFormData.creator"
@confirm-click="handleConfirm"
@clear-click="handleQuery"
/>
</el-form-item>
<el-form-item class="search-buttons">
<el-button v-hasPerm="['application:myapp:query']" type="primary" icon="search" native-type="submit">查询</el-button>
<el-button v-hasPerm="['application:myapp:query']" icon="refresh" @click="handleResetQuery">重置</el-button>
<!-- 展开/收起 -->
<template v-if="isExpandable">
<el-link class="ml-3" type="primary" underline="never" @click="isExpand = !isExpand">
{{ isExpand ? "收起" : "展开" }}
<el-icon>
<template v-if="isExpand">
<ArrowUp />
</template>
<template v-else>
<ArrowDown />
</template>
</el-icon>
</el-link>
</template>
</el-form-item>
</el-form>
</div>
<!-- 应用卡片展示区域 -->
<el-card shadow="hover" class="app-grid-card" >
<template #header>
<div class="card-header">
<span>应用市场</span>
<el-button v-hasPerm="['application:myapp:create']" type="primary" icon="plus" @click="handleCreateApp">
创建应用
</el-button>
</div>
</template>
<!-- 应用网格 -->
<div v-loading="loading" class="app-grid" >
<el-card
v-for="app in applicationList"
:key="app.id"
class="app-card-el"
:body-style="{ padding: '12px' }"
shadow="hover"
>
<template #header>
<div class="app-card-header-el">
<div class="app-info-header">
<el-avatar
:size="40"
:src="app.icon_url"
class="app-avatar-el"
>
<el-icon size="20"><Monitor /></el-icon>
</el-avatar>
<div class="app-title-section">
<h3 class="app-name-el">{{ app.name }}</h3>
<el-tag
:type="app.status ? 'success' : 'danger'"
size="small"
class="app-status-tag"
>
{{ app.status ? '启用' : '停用' }}
</el-tag>
</div>
</div>
<el-dropdown
v-hasPerm="['application:myapp:update']"
trigger="click"
@command="(command) => handleAppAction(command, app)"
>
<el-button
type="text"
icon="MoreFilled"
size="small"
class="app-menu-btn"
/>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="edit" icon="Edit">编辑</el-dropdown-item>
<el-dropdown-item command="delete" icon="Delete" divided>删除</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
<div class="app-card-content">
<p class="app-description">{{ app.description || '暂无描述' }}</p>
<div class="app-meta-info">
<div class="app-meta-row">
<div class="meta-item left">
<el-icon size="14" class="meta-icon"><User /></el-icon>
<span>{{ app.creator?.name || '未知' }}</span>
</div>
<div class="meta-item right">
<el-icon size="14" class="meta-icon"><Clock /></el-icon>
<span>{{ formatTime(app.created_at) }}</span>
</div>
</div>
</div>
</div>
<div class="app-card-actions">
<el-button
v-hasPerm="['application:myapp:open_external']"
type="primary"
icon="Link"
size="small"
:disabled="!app.status"
class="action-btn"
@click="openAppExternal(app.access_url)"
>
外部打开
</el-button>
<el-button
v-hasPerm="['application:myapp:open_internal']"
type="default"
icon="View"
size="small"
:disabled="!app.status"
class="action-btn"
@click="openAppInternal(app)"
>
内部打开
</el-button>
</div>
</el-card>
</div>
<!-- 空状态 -->
<div v-if="applicationList.length === 0 && !loading" >
<el-empty :image-size="80" description="暂无数据" />
</div>
<!-- 分页区域 -->
<template #footer>
<!-- 使用卡片 footer 样式右对齐,无需额外容器 -->
<pagination
v-model:total="total"
v-model:page="queryFormData.page_no"
v-model:limit="queryFormData.page_size"
:page-sizes="[12, 24, 48]"
@pagination="loadApplicationList"
/>
</template>
</el-card>
<!-- 应用创建/编辑弹窗 -->
<el-drawer
v-model="dialogVisible"
:title="dialogTitle"
:size="drawerSize"
direction="rtl"
@close="handleCloseDialog"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="100px"
label-position="right"
>
<el-form-item label="应用名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入应用名称" />
</el-form-item>
<el-form-item label="访问地址" prop="access_url">
<el-input v-model="formData.access_url" placeholder="请输入访问地址" />
</el-form-item>
<el-form-item label="图标地址" prop="icon_url">
<el-input v-model="formData.icon_url" placeholder="请输入图标地址" />
</el-form-item>
<el-form-item label="应用状态" prop="status">
<el-radio-group v-model="formData.status">
<el-radio :value="true">启用</el-radio>
<el-radio :value="false">停用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="应用描述" prop="description">
<el-input
v-model="formData.description"
type="textarea"
:rows="4"
placeholder="请输入应用描述"
maxlength="200"
show-word-limit
/>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleCloseDialog">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</div>
</template>
</el-drawer>
</div>
</template>
<script setup lang="ts">
defineOptions({
name: "MyApplication",
inheritAttrs: false,
});
import { useAppStore } from "@/store/modules/app.store";
import { useTagsViewStore } from "@/store";
import { useRouter } from "vue-router";
import { DeviceEnum } from "@/enums/settings/device.enum";
import { Monitor, User, Clock } from '@element-plus/icons-vue';
import ApplicationAPI, { type ApplicationForm, type ApplicationInfo, type ApplicationPageQuery } from "@/api/module_application/myapp";
import { formatToDateTime } from "@/utils/dateUtil";
const appStore = useAppStore();
const tagsViewStore = useTagsViewStore();
const router = useRouter();
// 响应式数据
const queryFormRef = ref();
const formRef = ref();
const loading = ref(false);
const total = ref(0);
const dialogVisible = ref(false);
const dialogType = ref<'create' | 'edit'>('create');
const currentApp = ref<ApplicationInfo | null>(null);
const isExpand = ref(false);
const isExpandable = ref(true);
// 分页查询参数
const queryFormData = reactive<ApplicationPageQuery>({
page_no: 1,
page_size: 12,
name: undefined,
status: undefined,
creator: undefined,
});
// 应用列表数据
const applicationList = ref<ApplicationInfo[]>([]);
// 表单数据
const formData = reactive<ApplicationForm>({
name: '',
access_url: '',
icon_url: '',
status: true,
description: '',
});
// 表单验证规则
const formRules = reactive({
name: [
{ required: true, message: "请输入应用名称", trigger: "blur" },
{ min: 2, max: 30, message: "长度在 2 到 30 个字符", trigger: "blur" }],
access_url: [
{ required: true, message: "请输入访问地址", trigger: "blur" },
{ type: 'url' as const, message: "请输入正确的URL格式", trigger: "blur" }
],
icon_url: [
{ required: true, message: "请输入图标地址", trigger: "blur" },
{ type: 'url' as const, message: "请输入正确的URL格式", trigger: "blur" }
],
status: [{ required: true, message: "请选择应用状态", trigger: "change" }],
});
// 计算属性
const drawerSize = computed(() => (appStore.device === DeviceEnum.DESKTOP ? "500px" : "90%"));
const dialogTitle = computed(() => dialogType.value === 'create' ? '创建应用' : '编辑应用');
// 格式化时间
const formatTime = (time: string | undefined) => {
if (!time) return '未知';
return formatToDateTime(time, 'YYYY-MM-DD HH:mm:ss');
};
// 加载应用列表
async function loadApplicationList() {
loading.value = true;
try {
const response = await ApplicationAPI.getApplicationList(queryFormData);
applicationList.value = response.data.data.items;
total.value = response.data.data.total;
} catch (error) {
console.error('加载应用列表失败:', error);
} finally {
loading.value = false;
}
}
// 查询
async function handleQuery() {
queryFormData.page_no = 1;
await loadApplicationList();
}
// 选择创建人后触发查询
function handleConfirm() {
handleQuery();
}
// 重置查询
async function handleResetQuery() {
queryFormRef.value?.resetFields();
queryFormData.page_no = 1;
await loadApplicationList();
}
// 创建应用
function handleCreateApp() {
console.log('handleCreateApp');
dialogType.value = 'create';
resetForm();
dialogVisible.value = true;
}
// 编辑应用
function handleEditApp(app: ApplicationInfo) {
dialogType.value = 'edit';
currentApp.value = app;
Object.assign(formData, app);
dialogVisible.value = true;
}
// 删除应用
async function handleDeleteApp(app: ApplicationInfo) {
try {
await ElMessageBox.confirm('确认删除该应用?', '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
});
await ApplicationAPI.deleteApplication([app.id!]);
await loadApplicationList();
} catch (error) {
if (error !== 'cancel') {
console.error('删除应用失败:', error);
}
}
}
// 应用操作
async function handleAppAction(command: string, app: ApplicationInfo) {
switch (command) {
case 'edit':
handleEditApp(app);
break;
case 'delete':
await handleDeleteApp(app);
break;
}
}
// 外部打开应用
function openAppExternal(url: string | undefined) {
if (url) {
window.open(url, '_blank');
}
}
// 内部打开应用
function openAppInternal(app: ApplicationInfo) {
if (!app.status) {
ElMessage.warning('应用已停用,无法打开');
return;
}
if (!app.access_url) {
ElMessage.warning('应用访问地址不存在');
return;
}
// 创建一个动态路由路径
const appPath = `/internal-app/${app.id}`;
const appName = `InternalApp${app.id}`;
const appTitle = app.name || '未命名应用';
// 先导航到路由,这样可以动态设置路由的meta信息
router.push({
path: appPath,
query: { url: app.access_url, appId: app.id?.toString() || '', appName: appTitle }
}).then(() => {
// 导航完成后,手动添加或更新标签视图
nextTick(() => {
// 查找是否已存在该标签
const existingTag = tagsViewStore.visitedViews.find(tag => tag.path === appPath);
if (existingTag) {
// 如果存在,更新标题
tagsViewStore.updateVisitedView({
...existingTag,
title: appTitle,
});
} else {
// 如果不存在,添加新标签
tagsViewStore.addView({
name: appName,
title: appTitle,
path: appPath,
fullPath: appPath + `?url=${encodeURIComponent(app.access_url || '')}&appId=${app.id || ''}&appName=${encodeURIComponent(appTitle)}`,
icon: 'Monitor',
affix: false,
keepAlive: false,
query: { url: app.access_url, appId: app.id?.toString() || '', appName: appTitle },
});
}
});
});
}
// 重置表单
function resetForm() {
Object.assign(formData, {
name: '',
access_url: '',
icon_url: '',
status: true,
description: '',
});
formRef.value?.resetFields();
}
// 关闭弹窗
function handleCloseDialog() {
dialogVisible.value = false;
resetForm();
}
// 提交表单
async function handleSubmit() {
try {
await formRef.value?.validate();
if (dialogType.value === 'create') {
await ApplicationAPI.createApplication(formData);
} else {
await ApplicationAPI.updateApplication(currentApp.value!.id!, formData);
}
dialogVisible.value = false;
resetForm();
await loadApplicationList();
} catch (error) {
console.error('提交失败:', error);
}
}
// 初始化
onMounted(() => {
loadApplicationList();
});
</script>
<style lang="scss" scoped>
.app-grid-card {
height: calc(100vh - 200px);
position: relative;
display: flex;
flex-direction: column;
:deep(.el-card__footer) {
margin-top: auto;
display: flex;
justify-content: flex-end;
}
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.app-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
flex: 1;
}
.app-card-el {
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
transition: all 0.3s ease;
}
.app-card-header-el {
display: flex;
align-items: center;
justify-content: space-between;
}
.app-info-header {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0;
}
.app-name-el {
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
margin: 0 0 4px 0;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.app-card-content {
margin: 12px 0;
}
.app-description {
font-size: 14px;
color: var(--el-text-color-regular);
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.app-meta-info {
display: flex;
flex-direction: column;
gap: 8px;
font-size: 13px;
color: var(--el-text-color-secondary);
}
.app-meta-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.meta-item {
display: flex;
align-items: center;
gap: 6px;
white-space: nowrap;
overflow: hidden;
}
.meta-item.left {
flex: 1;
justify-content: flex-start;
min-width: 0;
}
.meta-item.right {
flex: 1;
justify-content: flex-end;
text-align: right;
min-width: 0;
}
.meta-icon {
color: var(--el-text-color-placeholder);
flex-shrink: 0;
}
.meta-item span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-card-actions {
display: flex;
gap: 8px;
margin-top: 12px;
.action-btn {
flex: 1;
font-size: 12px;
padding: 4px 8px;
}
}
.pagination-container {
display: flex;
justify-content: flex-end;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
</style>
@@ -0,0 +1,455 @@
<!-- 演示示例 -->
<template>
<div class="app-container">
<!-- 搜索区域 -->
<div class="search-container">
<el-form ref="queryFormRef" :model="queryFormData" :inline="true" label-suffix=":" @submit.prevent="handleQuery">
<el-form-item prop="name" label="名称">
<el-input v-model="queryFormData.name" placeholder="请输入名称" clearable />
</el-form-item>
<el-form-item prop="status" label="状态">
<el-select v-model="queryFormData.status" placeholder="请选择状态" style="width: 167.5px" clearable>
<el-option value="true" label="启用" />
<el-option value="false" label="停用" />
</el-select>
</el-form-item>
<!-- 时间范围,收起状态下隐藏 -->
<el-form-item v-if="isExpand" prop="start_time" label="创建时间">
<DatePicker
v-model="dateRange"
@update:model-value="handleDateRangeChange"
/>
</el-form-item>
<!-- 查询、重置、展开/收起按钮 -->
<el-form-item class="search-buttons">
<el-button type="primary" icon="search" native-type="submit">
查询
</el-button>
<el-button icon="refresh" @click="handleResetQuery">
重置
</el-button>
<!-- 展开/收起 -->
<template v-if="isExpandable">
<el-link class="ml-3" type="primary" underline="never" @click="isExpand = !isExpand">
{{ isExpand ? "收起" : "展开" }}
<el-icon>
<template v-if="isExpand">
<ArrowUp />
</template>
<template v-else>
<ArrowDown />
</template>
</el-icon>
</el-link>
</template>
</el-form-item>
</el-form>
</div>
<!-- 内容区域 -->
<el-card class="data-table">
<template #header>
<div class="card-header">
<span>
<el-tooltip content="流程管理列表">
<QuestionFilled class="w-4 h-4 mx-1" />
</el-tooltip>
流程管理列表
</span>
</div>
</template>
<!-- 功能区域 -->
<div class="data-table__toolbar">
<div class="data-table__toolbar--left">
<el-button v-hasPerm="['workflow:operator:create']" type="success" icon="plus" @click="handleOpenDialog('create')">新增</el-button>
<el-button v-hasPerm="['workflow:operator:delete']" type="danger" icon="delete" :disabled="selectIds.length === 0" @click="handleDelete(selectIds)">批量删除</el-button>
<el-dropdown v-hasPerm="['application:myapp:update']" trigger="click">
<el-button type="default" :disabled="selectIds.length === 0" icon="ArrowDown">更多</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item icon="Check" @click="handleMoreClick(true)">批量启用</el-dropdown-item>
<el-dropdown-item icon="CircleClose" @click="handleMoreClick(false)">批量停用</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<div class="data-table__toolbar--right">
<el-tooltip content="刷新">
<el-button v-hasPerm="['workflow:operator:refresh']" type="warning" icon="refresh" circle @click="handleRefresh"/>
</el-tooltip>
<el-popover placement="bottom" trigger="click">
<template #reference>
<el-button type="danger" icon="operation" circle></el-button>
</template>
<el-scrollbar max-height="350px">
<template v-for="column in tableColumns" :key="column.prop">
<el-checkbox v-if="column.prop" v-model="column.show" :label="column.label" />
</template>
</el-scrollbar>
</el-popover>
</div>
</div>
<!-- 表格区域:工作流列表 -->
<el-table ref="dataTableRef" v-loading="loading" :data="pageTableData" highlight-current-row class="data-table__content" height="450" border stripe @selection-change="handleSelectionChange">
<template #empty>
<el-empty :image-size="80" description="暂无数据" />
</template>
<el-table-column v-if="tableColumns.find(col => col.prop === 'selection')?.show" type="selection" min-width="55" align="center" />
<el-table-column v-if="tableColumns.find(col => col.prop === 'index')?.show" fixed label="序号" min-width="60">
<template #default="scope">
{{ (queryFormData.page_no - 1) * queryFormData.page_size + scope.$index + 1 }}
</template>
</el-table-column>
<el-table-column v-if="tableColumns.find(col => col.prop === 'name')?.show" label="名称" prop="name" min-width="140" />
<el-table-column v-if="tableColumns.find(col => col.prop === 'status')?.show" label="状态" prop="status" min-width="80">
<template #default="scope">
<el-tag :type="scope.row.status === true ? 'success' : 'danger'">
{{ scope.row.status === true ? "启用" : "停用" }}
</el-tag>
</template>
</el-table-column>
<el-table-column v-if="tableColumns.find(col => col.prop === 'description')?.show" label="描述" prop="description" min-width="140" />
<el-table-column v-if="tableColumns.find(col => col.prop === 'created_at')?.show" label="创建时间" prop="created_at" min-width="180" sortable />
<el-table-column v-if="tableColumns.find(col => col.prop === 'updated_at')?.show" label="更新时间" prop="updated_at" min-width="180" sortable />
<el-table-column v-if="tableColumns.find(col => col.prop === 'creator')?.show" key="creator" label="创建人" min-width="100">
<template #default="scope">
{{ scope.row.creator?.name }}
</template>
</el-table-column>
<el-table-column v-if="tableColumns.find(col => col.prop === 'operation')?.show" fixed="right" label="操作" align="center" min-width="200">
<template #default="scope">
<el-button v-hasPerm="['workflow:operator:detail']" type="info" size="small" link icon="document" @click="handleOpenDialog('detail', scope.row.id)">详情</el-button>
<el-button v-hasPerm="['workflow:operator:update']" type="primary" size="small" link icon="edit" @click="handleOpenDialog('update', scope.row.id)">编辑</el-button>
<el-button v-hasPerm="['workflow:operator:delete']" type="danger" size="small" link icon="delete" @click="handleDelete([scope.row.id])">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页区域 -->
<template #footer>
<pagination v-model:total="total" v-model:page="queryFormData.page_no" v-model:limit="queryFormData.page_size" @pagination="loadingData" />
</template>
</el-card>
<!-- 弹窗区域 -->
<el-dialog v-model="dialogVisible.visible" :title="dialogVisible.title" @close="handleCloseDialog">
<!-- 详情 -->
<template v-if="dialogVisible.type === 'detail'">
<el-descriptions :column="4" border>
<el-descriptions-item label="名称" :span="2">
{{ detailFormData.name }}
</el-descriptions-item>
<el-descriptions-item label="状态" :span="2">
<el-tag :type="detailFormData.status ? 'success' : 'danger'">
{{ detailFormData.status ? '启用' : '停用' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="描述" :span="2">
{{ detailFormData.description }}
</el-descriptions-item>
<el-descriptions-item label="创建人" :span="2">
{{ detailFormData.creator?.name }}
</el-descriptions-item>
<el-descriptions-item label="创建时间" :span="2">
{{ detailFormData.created_at }}
</el-descriptions-item>
<el-descriptions-item label="更新时间" :span="2">
{{ detailFormData.updated_at }}
</el-descriptions-item>
</el-descriptions>
</template>
<!-- 新增、编辑表单 -->
<template v-else>
<el-form ref="dataFormRef" :model="formData" :rules="rules" label-suffix=":" label-width="auto" label-position="right">
<el-form-item label="名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入名称" :maxlength="50" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="formData.status">
<el-radio :value="true">
启用
</el-radio>
<el-radio :value="false">
停用
</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input v-model="formData.description" :rows="4" :maxlength="100" show-word-limit type="textarea" placeholder="请输入描述" />
</el-form-item>
</el-form>
</template>
<template #footer>
<div class="dialog-footer">
<!-- 详情弹窗不需要确定按钮的提交逻辑 -->
<el-button @click="handleCloseDialog">取消</el-button>
<el-button v-if="dialogVisible.type !== 'detail'" type="primary" @click="handleSubmit">确定</el-button>
<el-button v-else type="primary" @click="handleCloseDialog">确定</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
defineOptions({
name: "Workflow",
inheritAttrs: false,
});
import { ref, reactive, onMounted } from "vue";
import ExampleAPI, { ExampleTable, ExampleForm, ExamplePageQuery } from "@/api/module_generator/demo";
import DatePicker from "@/components/DatePicker/index.vue";
import { formatToDateTime } from "@/utils/dateUtil";
const queryFormRef = ref();
const dataFormRef = ref();
const total = ref(0);
const selectIds = ref<number[]>([]);
const loading = ref(false);
const isExpand = ref(false);
const isExpandable = ref(true);
// 分页表单
const pageTableData = ref<ExampleTable[]>([]);
// 表格列配置
const tableColumns = ref([
{ prop: 'selection', label: '选择框', show: true },
{ prop: 'index', label: '序号', show: true },
{ prop: 'name', label: '名称', show: true },
{ prop: 'status', label: '状态', show: true },
{ prop: 'description', label: '描述', show: true },
{ prop: 'created_at', label: '创建时间', show: true },
{ prop: 'updated_at', label: '更新时间', show: true },
{ prop: 'creator', label: '创建人', show: true },
{ prop: 'operation', label: '操作', show: true }
])
// 详情表单
const detailFormData = ref<ExampleTable>({});
// 日期范围临时变量
const dateRange = ref<[Date, Date] | []>([]);
// 处理日期范围变化
function handleDateRangeChange(range: [Date, Date]) {
dateRange.value = range;
if (range && range.length === 2) {
queryFormData.start_time = formatToDateTime(range[0]);
queryFormData.end_time = formatToDateTime(range[1]);
} else {
queryFormData.start_time = undefined;
queryFormData.end_time = undefined;
}
}
// 分页查询参数
const queryFormData = reactive<ExamplePageQuery>({
page_no: 1,
page_size: 10,
name: undefined,
status: undefined,
start_time: undefined,
end_time: undefined,
});
// 编辑表单
const formData = reactive<ExampleForm>({
id: undefined,
name: '',
status: true,
description: undefined,
})
// 弹窗状态
const dialogVisible = reactive({
title: "",
visible: false,
type: 'create' as 'create' | 'update' | 'detail',
});
// 表单验证规则
const rules = reactive({
name: [{ required: true, message: "请输入名称", trigger: "blur" }],
status: [{ required: true, message: "请选择状态", trigger: "blur" }],
});
// 列表刷新
async function handleRefresh() {
await loadingData();
};
// 加载表格数据
async function loadingData() {
loading.value = true;
try {
const response = await ExampleAPI.getExampleList(queryFormData);
pageTableData.value = response.data.data.items;
total.value = response.data.data.total;
}
catch (error: any) {
console.error(error);
}
finally {
loading.value = false;
}
}
// 查询(重置页码后获取数据)
async function handleQuery() {
queryFormData.page_no = 1;
loadingData();
}
// 重置查询
async function handleResetQuery() {
queryFormRef.value.resetFields();
queryFormData.page_no = 1;
// 重置日期范围选择器
dateRange.value = [];
queryFormData.start_time = undefined;
queryFormData.end_time = undefined;
loadingData();
}
// 定义初始表单数据常量
const initialFormData: ExampleForm = {
id: undefined,
name: '',
status: true,
description: '',
}
// 重置表单
async function resetForm() {
if (dataFormRef.value) {
dataFormRef.value.resetFields();
dataFormRef.value.clearValidate();
}
// 完全重置 formData 为初始状态
Object.assign(formData, initialFormData);
}
// 行复选框选中项变化
async function handleSelectionChange(selection: any) {
selectIds.value = selection.map((item: any) => item.id);
}
// 关闭弹窗
async function handleCloseDialog() {
dialogVisible.visible = false;
resetForm();
}
// 打开弹窗
async function handleOpenDialog(type: 'create' | 'update' | 'detail', id?: number) {
dialogVisible.type = type;
if (id) {
const response = await ExampleAPI.getExampleDetail(id);
if (type === 'detail') {
dialogVisible.title = "详情";
Object.assign(detailFormData.value, response.data.data);
} else if (type === 'update') {
dialogVisible.title = "修改";
Object.assign(formData, response.data.data);
}
} else {
dialogVisible.title = "新增工作流";
formData.id = undefined;
}
dialogVisible.visible = true;
}
// 提交表单(防抖)
async function handleSubmit() {
// 表单校验
dataFormRef.value.validate(async (valid: any) => {
if (valid) {
loading.value = true;
// 根据弹窗传入的参数(deatil\create\update)判断走什么逻辑
const id = formData.id;
if (id) {
try {
await ExampleAPI.updateExample(id, { id, ...formData })
dialogVisible.visible = false;
resetForm();
handleCloseDialog();
handleResetQuery();
} catch (error: any) {
console.error(error);
} finally {
loading.value = false;
}
} else {
try {
await ExampleAPI.createExample(formData)
dialogVisible.visible = false;
resetForm();
handleCloseDialog();
handleResetQuery();
} catch (error: any) {
console.error(error);
} finally {
loading.value = false;
}
}
}
});
}
// 删除、批量删除
async function handleDelete(ids: number[]) {
ElMessageBox.confirm("确认删除该项数据?", "警告", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
}).then(async () => {
try {
loading.value = true;
await ExampleAPI.deleteExample(ids);
handleResetQuery();
} catch (error: any) {
console.error(error);
} finally {
loading.value = false;
}
}).catch(() => {
ElMessageBox.close();
});
}
// 批量启用/停用
async function handleMoreClick(status: boolean) {
if (selectIds.value.length) {
ElMessageBox.confirm(`确认${status ? '启用' : '停用'}该项数据?`, "警告", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
}).then(async () => {
try {
loading.value = true;
await ExampleAPI.batchAvailableExample({ ids: selectIds.value, status });
handleResetQuery();
} catch (error: any) {
console.error(error);
} finally {
loading.value = false;
}
}).catch(() => {
ElMessageBox.close();
});
}
}
onMounted(() => {
loadingData();
});
</script>
<style lang="scss" scoped></style>