feat: initial TaskPulse release

AI Agent 定时任务跟踪、监控、状态看板系统。

- FastAPI + Vue3 单体应用
- AI Agent 一键接入(批量注册 API)
- 暗色主题 Dashboard
- 后台调度器 + 超时告警
- 飞书/邮件/Webhook 多渠道通知
- Base URL 可配置
- Gunicorn 生产部署
This commit is contained in:
2026-06-14 22:39:23 +08:00
commit 521cf0269c
54 changed files with 6328 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TaskPulse — AI Agent Task Monitor</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+1705
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "taskpulse-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.4.0",
"vue-router": "^4.3.0",
"element-plus": "^2.7.0",
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.7.0",
"dayjs": "^1.11.0",
"echarts": "^5.5.0",
"vue-echarts": "^6.7.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"vite": "^5.4.0"
}
}
+204
View File
@@ -0,0 +1,204 @@
<template>
<div id="app-container">
<!-- Sidebar -->
<aside class="sidebar">
<div class="logo">
<div class="logo-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
</div>
<span class="logo-text">TaskPulse</span>
</div>
<nav class="nav-menu">
<router-link to="/" class="nav-item" :class="{ active: currentRoute === '/' }">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
<span>仪表盘</span>
</router-link>
<router-link to="/tasks" class="nav-item" :class="{ active: currentRoute.startsWith('/tasks') }">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
<span>所有任务</span>
</router-link>
<router-link to="/agents" class="nav-item" :class="{ active: currentRoute.startsWith('/agents') }">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
<span>Agent 管理</span>
</router-link>
<router-link to="/alerts" class="nav-item" :class="{ active: currentRoute.startsWith('/alerts') }">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
<span>告警历史</span>
</router-link>
<router-link to="/settings" class="nav-item" :class="{ active: currentRoute.startsWith('/settings') }">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
<span>系统配置</span>
</router-link>
</nav>
<div class="sidebar-footer">
<div class="status-dot"></div>
<span>系统运行中</span>
</div>
</aside>
<!-- Main Content -->
<div class="main-area">
<header class="topbar">
<h2 class="page-title">{{ pageTitle }}</h2>
<div class="topbar-right">
<span class="version-badge">v1.0</span>
</div>
</header>
<main class="content">
<router-view />
</main>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const currentRoute = computed(() => route.path)
const routeTitles = {
'/': '仪表盘',
'/tasks': '所有任务',
'/agents': 'Agent 管理',
'/alerts': '告警历史',
'/settings': '系统配置',
}
const pageTitle = computed(() => routeTitles[route.path] || 'TaskPulse')
</script>
<style scoped>
#app-container {
display: flex;
height: 100vh;
background: #0a0a0f;
color: #e0e0e0;
}
/* ── Sidebar ───────────────────────────────────── */
.sidebar {
width: 220px;
min-width: 220px;
background: linear-gradient(180deg, #0f0f1a 0%, #1a1a2e 100%);
border-right: 1px solid rgba(255,255,255,0.05);
display: flex;
flex-direction: column;
padding: 0;
}
.logo {
display: flex;
align-items: center;
gap: 10px;
padding: 24px 20px 20px;
border-bottom: 1px solid rgba(255,255,255,0.06);
}
.logo-icon {
width: 36px;
height: 36px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
}
.logo-text {
font-size: 18px;
font-weight: 700;
background: linear-gradient(135deg, #a8b4ff 0%, #c084fc 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.nav-menu {
flex: 1;
padding: 12px 10px;
display: flex;
flex-direction: column;
gap: 2px;
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-radius: 10px;
color: rgba(255,255,255,0.55);
text-decoration: none;
font-size: 14px;
transition: all 0.2s;
}
.nav-item:hover {
color: #fff;
background: rgba(255,255,255,0.06);
}
.nav-item.active {
color: #fff;
background: linear-gradient(135deg, rgba(102,126,234,0.2) 0%, rgba(118,75,162,0.2) 100%);
border: 1px solid rgba(102,126,234,0.15);
}
.sidebar-footer {
padding: 16px 20px;
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: rgba(255,255,255,0.35);
border-top: 1px solid rgba(255,255,255,0.06);
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #22c55e;
box-shadow: 0 0 6px rgba(34,197,94,0.5);
}
/* ── Main ──────────────────────────────────────── */
.main-area {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
background: #0a0a0f;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 28px;
background: rgba(15,15,26,0.8);
backdrop-filter: blur(12px);
border-bottom: 1px solid rgba(255,255,255,0.05);
}
.page-title {
font-size: 18px;
font-weight: 600;
color: #e0e0e0;
margin: 0;
}
.version-badge {
font-size: 11px;
padding: 3px 10px;
border-radius: 20px;
background: rgba(255,255,255,0.06);
color: rgba(255,255,255,0.4);
border: 1px solid rgba(255,255,255,0.08);
}
.content {
flex: 1;
overflow-y: auto;
padding: 24px 28px;
}
</style>
+49
View File
@@ -0,0 +1,49 @@
import axios from 'axios'
const api = axios.create({
baseURL: '/api',
timeout: 10000,
})
// Dashboard
export const getDashboardSummary = () => api.get('/dashboard/summary')
export const getDashboardTasks = () => api.get('/dashboard/tasks')
// Agents
export const listAgents = () => api.get('/agents')
export const getAgent = (id) => api.get(`/agents/${id}`)
export const createAgent = (data) => api.post('/agents', data)
export const updateAgent = (id, data) => api.put(`/agents/${id}`, data)
// Tasks
export const listTasks = (agentId) => api.get('/tasks', { params: { agent_id: agentId } })
export const getTask = (id) => api.get(`/tasks/${id}`)
export const createTask = (agentId, data) => api.post('/tasks', data, { params: { agent_id: agentId } })
export const updateTask = (id, data) => api.put(`/tasks/${id}`, data)
export const deleteTask = (id) => api.delete(`/tasks/${id}`)
// Executions
export const listExecutions = (taskId, params) => api.get(`/tasks/${taskId}/executions`, { params })
export const getExecution = (id) => api.get(`/executions/${id}`)
export const getRecentExecutions = (params) => api.get('/executions/recent', { params })
// Alerts
export const listAlerts = (params) => api.get('/alerts', { params })
export const acknowledgeAlert = (id) => api.post(`/alerts/${id}/acknowledge`)
// Notification Channels
export const listChannels = () => api.get('/notification-channels')
export const createChannel = (data) => api.post('/notification-channels', data)
export const updateChannel = (id, data) => api.put(`/notification-channels/${id}`, data)
export const deleteChannel = (id) => api.delete(`/notification-channels/${id}`)
// Alert Rules
export const listAlertRules = (params) => api.get('/alert-rules', { params })
export const createAlertRule = (data) => api.post('/alert-rules', data)
export const deleteAlertRule = (id) => api.delete(`/alert-rules/${id}`)
// System Config
export const getSystemConfig = () => api.get('/system/config')
export const saveSystemConfig = (data) => api.put('/system/config', data)
export default api
+232
View File
@@ -0,0 +1,232 @@
/* ── TaskPulse Global Theme ─────────────────────── */
:root {
--bg-primary: #0a0a0f;
--bg-card: #13131f;
--bg-card-hover: #1a1a2e;
--bg-elevated: #1e1e32;
--border-color: rgba(255,255,255,0.06);
--border-hover: rgba(255,255,255,0.1);
--text-primary: #e8e8f0;
--text-secondary: rgba(255,255,255,0.5);
--text-muted: rgba(255,255,255,0.3);
--accent-1: #667eea;
--accent-2: #764ba2;
--accent-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
--success: #22c55e;
--warning: #f59e0b;
--danger: #ef4444;
--info: #6366f1;
--radius: 12px;
--radius-sm: 8px;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body, #app {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', 'Inter', Roboto, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
}
/* ── Scrollbar ─────────────────────────────────── */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(255,255,255,0.08);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255,255,255,0.15);
}
/* ── Element Plus Overrides ────────────────────── */
/* Cards */
.el-card {
background: var(--bg-card) !important;
border: 1px solid var(--border-color) !important;
border-radius: var(--radius) !important;
color: var(--text-primary) !important;
}
.el-card__header {
padding: 16px 20px !important;
border-bottom: 1px solid var(--border-color) !important;
}
.el-card__body {
padding: 20px !important;
}
/* Tables */
.el-table {
--el-table-bg-color: transparent !important;
--el-table-tr-bg-color: transparent !important;
--el-table-header-bg-color: rgba(255,255,255,0.02) !important;
--el-table-row-hover-bg-color: rgba(255,255,255,0.04) !important;
--el-table-border-color: var(--border-color) !important;
--el-table-text-color: var(--text-primary) !important;
--el-table-header-text-color: var(--text-secondary) !important;
background: transparent !important;
color: var(--text-primary) !important;
}
.el-table th.el-table__cell {
background: rgba(255,255,255,0.02) !important;
border-bottom: 1px solid var(--border-color) !important;
color: var(--text-secondary) !important;
font-weight: 500 !important;
font-size: 12px !important;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.el-table td.el-table__cell {
border-bottom: 1px solid var(--border-color) !important;
color: var(--text-primary) !important;
}
/* Tags */
.el-tag {
border: none !important;
font-weight: 500 !important;
}
.el-tag--success {
background: rgba(34,197,94,0.15) !important;
color: #4ade80 !important;
}
.el-tag--danger {
background: rgba(239,68,68,0.15) !important;
color: #f87171 !important;
}
.el-tag--warning {
background: rgba(245,158,11,0.15) !important;
color: #fbbf24 !important;
}
.el-tag--info {
background: rgba(255,255,255,0.06) !important;
color: var(--text-secondary) !important;
}
/* Buttons */
.el-button--primary {
--el-button-bg-color: var(--accent-1) !important;
--el-button-border-color: var(--accent-1) !important;
--el-button-hover-bg-color: #7c8ff0 !important;
--el-button-hover-border-color: #7c8ff0 !important;
border-radius: var(--radius-sm) !important;
}
.el-button--small {
border-radius: 6px !important;
}
.el-button.is-text {
color: var(--accent-1) !important;
}
.el-button.is-text.is-danger {
color: var(--danger) !important;
}
/* Dialog */
.el-dialog {
--el-dialog-bg-color: var(--bg-card) !important;
--el-dialog-border-color: var(--border-color) !important;
border-radius: var(--radius) !important;
border: 1px solid var(--border-color) !important;
}
.el-dialog__title {
color: var(--text-primary) !important;
}
.el-dialog__body {
color: var(--text-secondary) !important;
}
/* Form */
.el-form-item__label {
color: var(--text-secondary) !important;
}
.el-input {
--el-input-bg-color: rgba(255,255,255,0.04) !important;
--el-input-border-color: var(--border-color) !important;
--el-input-text-color: var(--text-primary) !important;
--el-input-hover-border-color: var(--accent-1) !important;
--el-input-focus-border-color: var(--accent-1) !important;
}
.el-input__inner {
background: rgba(255,255,255,0.04) !important;
color: var(--text-primary) !important;
border-color: var(--border-color) !important;
}
.el-select {
--el-select-input-focus-border-color: var(--accent-1) !important;
}
.el-select-dropdown {
background: var(--bg-elevated) !important;
border: 1px solid var(--border-color) !important;
}
.el-select-dropdown__item {
color: var(--text-primary) !important;
}
.el-select-dropdown__item.hover {
background: rgba(255,255,255,0.06) !important;
}
/* Switch */
.el-switch {
--el-switch-on-color: var(--accent-1) !important;
}
/* Radio */
.el-radio-group {
--el-radio-button-checked-bg-color: var(--accent-1) !important;
--el-radio-button-checked-border-color: var(--accent-1) !important;
}
.el-radio-button__inner {
background: rgba(255,255,255,0.04) !important;
color: var(--text-secondary) !important;
border-color: var(--border-color) !important;
}
.el-radio-button__original-radio:checked + .el-radio-button__inner {
background: var(--accent-1) !important;
color: #fff !important;
}
/* Descriptions */
.el-descriptions {
--el-descriptions-table-bg: transparent !important;
--el-descriptions-item-bg: transparent !important;
}
.el-descriptions__title {
color: var(--text-primary) !important;
}
.el-descriptions__label {
color: var(--text-secondary) !important;
background: rgba(255,255,255,0.02) !important;
}
.el-descriptions__content {
color: var(--text-primary) !important;
background: transparent !important;
}
.el-descriptions__cell {
border-color: var(--border-color) !important;
}
/* Input number */
.el-input-number {
--el-input-number-controls-height: 100% !important;
}
/* Message */
.el-message {
--el-message-bg-color: var(--bg-elevated) !important;
--el-message-border-color: var(--border-color) !important;
--el-message-text-color: var(--text-primary) !important;
border: 1px solid var(--border-color) !important;
backdrop-filter: blur(12px) !important;
}
+19
View File
@@ -0,0 +1,19 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import App from './App.vue'
import router from './router'
import './assets/style.css'
const app = createApp(App)
// Register all Element Plus icons
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(ElementPlus, { locale: zhCn })
app.use(router)
app.mount('#app')
+23
View File
@@ -0,0 +1,23 @@
import { createRouter, createWebHistory } from 'vue-router'
import Dashboard from '../views/Dashboard.vue'
import Tasks from '../views/Tasks.vue'
import TaskDetail from '../views/TaskDetail.vue'
import Agents from '../views/Agents.vue'
import Alerts from '../views/Alerts.vue'
import Settings from '../views/Settings.vue'
const routes = [
{ path: '/', component: Dashboard },
{ path: '/tasks', component: Tasks },
{ path: '/tasks/:id', component: TaskDetail, name: 'TaskDetail' },
{ path: '/agents', component: Agents },
{ path: '/alerts', component: Alerts },
{ path: '/settings', component: Settings },
]
const router = createRouter({
history: createWebHistory(),
routes,
})
export default router
+237
View File
@@ -0,0 +1,237 @@
<template>
<div class="agents-page">
<!-- Quick onboard -->
<div class="section mb-24">
<div class="section-header">
<h3>Agent 接入指南</h3>
</div>
<div class="onboard-body">
<p> AI Agent 通过以下方式自动注册无需手动填写</p>
<div class="code-block">
<div class="code-tabs">
<button :class="{ active: tab === 'curl' }" @click="tab = 'curl'">curl</button>
<button :class="{ active: tab === 'python' }" @click="tab = 'python'">Python</button>
<button :class="{ active: tab === 'batch' }" @click="tab = 'batch'">批量注册</button>
</div>
<div class="code-body">
<pre>{{ codes[tab] }}</pre>
</div>
<button class="btn-copy" @click="copyCode">复制</button>
</div>
</div>
</div>
<!-- Agent list -->
<div class="section">
<div class="section-header">
<h3>已注册 Agent</h3>
</div>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>ID</th>
<th>名称</th>
<th>描述</th>
<th>状态</th>
<th>任务数</th>
<th>最后心跳</th>
<th>API Key</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="a in agents" :key="a.id">
<td class="cell-mono">#{{ a.id }}</td>
<td class="cell-name">{{ a.name }}</td>
<td class="cell-muted">{{ a.description || '—' }}</td>
<td>
<span class="tag" :class="a.status === 'active' ? 'tag-green' : 'tag-gray'">
{{ a.status === 'active' ? '活跃' : '停用' }}
</span>
</td>
<td class="cell-mono">{{ a.task_count }}</td>
<td class="cell-mono">{{ a.last_heartbeat_at ? dayjs(a.last_heartbeat_at).format('MM-DD HH:mm') : '—' }}</td>
<td>
<code class="cell-key">{{ a.api_key.substring(0, 16) }}...</code>
<button class="btn-icon" @click="copyKey(a.api_key)" title="复制 API Key">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
</button>
</td>
<td>
<router-link to="/tasks" class="cell-link">任务</router-link>
</td>
</tr>
<tr v-if="agents.length === 0">
<td colspan="8" class="cell-empty">暂无 Agent AI Agent 调用上方 API 自动注册</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import dayjs from 'dayjs'
import { listAgents, getSystemConfig } from '../api/index.js'
const agents = ref([])
const tab = ref('curl')
const baseUrl = ref(window.location.origin)
const loadData = async () => {
const [agentRes, cfgRes] = await Promise.all([
listAgents(),
getSystemConfig(),
])
agents.value = agentRes.data
baseUrl.value = cfgRes.data.base_url
}
const copyKey = async (key) => {
try {
await navigator.clipboard.writeText(key)
const { ElMessage } = await import('element-plus')
ElMessage.success('已复制 API Key')
} catch {}
}
const codes = computed(() => {
const b = baseUrl.value
return {
curl: `# 注册单个 Agent
curl -X POST ${b}/api/agents \\
-H "Content-Type: application/json" \\
-d '{"name": "my-agent", "description": "你的 AI Agent"}'`,
python: `import requests\n\nresp = requests.post("${b}/api/agents", json={\n "name": "my-agent",\n "description": "你的 AI Agent"\n})\nprint(resp.json())`,
batch: `curl -X POST ${b}/api/agents/register-with-tasks \\\n -H "Content-Type: application/json" \\\n -d '{\n "name": "my-agent",\n "description": "全能数据Agent",\n "tasks": [\n {"name": "sync-data", "cron_expression": "*/5 * * * *"},\n {"name": "daily-report", "cron_expression": "0 9 * * *"}\n ]\n }'`,
}
})
const copyCode = async () => {
try {
await navigator.clipboard.writeText(codes.value[tab.value])
const { ElMessage } = await import('element-plus')
ElMessage.success('已复制')
} catch {}
}
onMounted(loadData)
</script>
<style scoped>
.agents-page { max-width: 1200px; }
.mb-24 { margin-bottom: 24px; }
.section {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 14px;
overflow: hidden;
}
.section-header {
padding: 16px 20px;
border-bottom: 1px solid var(--border-color);
}
.section-header h3 { font-size: 15px; font-weight: 600; color: #e0e0f0; margin: 0; }
.onboard-body {
padding: 20px;
}
.onboard-body > p {
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 16px;
}
.code-block {
position: relative;
background: rgba(0,0,0,0.3);
border-radius: 10px;
overflow: hidden;
}
.code-tabs {
display: flex;
border-bottom: 1px solid rgba(255,255,255,0.06);
}
.code-tabs button {
padding: 8px 16px;
background: none;
border: none;
color: rgba(255,255,255,0.4);
font-size: 12px;
cursor: pointer;
border-bottom: 2px solid transparent;
}
.code-tabs button.active {
color: #a8b4ff;
border-bottom-color: #667eea;
}
.code-body { padding: 16px; overflow-x: auto; }
.code-body pre {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 12px;
line-height: 1.7;
color: rgba(255,255,255,0.75);
white-space: pre;
margin: 0;
}
.btn-copy {
position: absolute;
top: 44px;
right: 12px;
padding: 4px 12px;
border-radius: 6px;
border: 1px solid rgba(255,255,255,0.1);
background: rgba(255,255,255,0.06);
color: rgba(255,255,255,0.5);
font-size: 11px;
cursor: pointer;
}
.btn-copy:hover { background: rgba(255,255,255,0.1); color: #fff; }
/* Table */
.table-wrap { overflow-x: auto; }
.data-table { width: 100%; border-collapse: collapse; }
.data-table th {
text-align: left;
padding: 12px 16px;
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-secondary);
background: rgba(255,255,255,0.02);
border-bottom: 1px solid var(--border-color);
}
.data-table td {
padding: 12px 16px;
font-size: 13px;
color: var(--text-primary);
border-bottom: 1px solid var(--border-color);
}
.data-table tbody tr:hover { background: rgba(255,255,255,0.02); }
.cell-name { font-weight: 500; }
.cell-mono { font-family: 'JetBrains Mono', monospace; font-size: 12px; color: var(--text-secondary); }
.cell-muted { color: var(--text-muted); }
.cell-key { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text-muted); }
.cell-empty { text-align: center; color: var(--text-muted); padding: 40px 16px !important; }
.cell-link { color: var(--accent-1); text-decoration: none; font-size: 13px; }
.cell-link:hover { text-decoration: underline; }
.tag { display: inline-block; padding: 2px 10px; border-radius: 20px; font-size: 12px; font-weight: 500; }
.tag-green { background: rgba(34,197,94,0.12); color: #4ade80; }
.tag-gray { background: rgba(255,255,255,0.05); color: var(--text-secondary); }
.btn-icon {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 4px;
vertical-align: middle;
}
.btn-icon:hover { color: var(--accent-1); }
</style>
+156
View File
@@ -0,0 +1,156 @@
<template>
<div class="alerts-page">
<div class="section">
<div class="section-header">
<h3>告警历史</h3>
<div class="filter-group">
<button :class="{ active: filterStatus === '' }" @click="filterStatus = ''; loadData()">全部</button>
<button :class="{ active: filterStatus === 'pending' }" @click="filterStatus = 'pending'; loadData()">待处理</button>
<button :class="{ active: filterStatus === 'acknowledged' }" @click="filterStatus = 'acknowledged'; loadData()">已确认</button>
</div>
</div>
<div class="alert-list">
<div v-for="a in alerts" :key="a.id" class="alert-item" :class="{ 'alert-pending': a.status === 'pending' }">
<div class="alert-left">
<div class="alert-dot" :class="a.status === 'pending' ? 'dot-red' : 'dot-gray'"></div>
</div>
<div class="alert-body">
<div class="alert-top">
<span class="tag" :class="a.alert_type === 'missed_run' ? 'tag-warn' : 'tag-red'">
{{ a.alert_type === 'missed_run' ? '未按时执行' : '执行失败' }}
</span>
<span class="alert-task">{{ a.task_name || '未知任务' }}</span>
<span class="alert-time">{{ dayjs(a.created_at).format('MM-DD HH:mm') }}</span>
</div>
<div class="alert-msg">{{ a.message }}</div>
<div class="alert-bottom">
<span class="alert-status" :class="a.status === 'pending' ? 'text-red' : 'text-muted'">
{{ a.status === 'pending' ? '待处理' : '已确认' }}
</span>
<button v-if="a.status === 'pending'" class="btn-ack" @click="handleAcknowledge(a)">确认</button>
</div>
</div>
</div>
<div v-if="alerts.length === 0" class="empty-state">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="color:var(--text-muted);margin-bottom:12px"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
<p>暂无告警</p>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import dayjs from 'dayjs'
import { listAlerts, acknowledgeAlert } from '../api/index.js'
const alerts = ref([])
const filterStatus = ref('')
const loadData = async () => {
const params = { limit: 200 }
if (filterStatus.value) params.status = filterStatus.value
const res = await listAlerts(params)
alerts.value = res.data
}
const handleAcknowledge = async (row) => {
try {
await acknowledgeAlert(row.id)
await loadData()
} catch (e) {
console.error(e)
}
}
onMounted(loadData)
</script>
<style scoped>
.alerts-page { max-width: 1200px; }
.section {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 14px;
overflow: hidden;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--border-color);
}
.section-header h3 { font-size: 15px; font-weight: 600; color: #e0e0f0; margin: 0; }
.filter-group { display: flex; gap: 4px; background: rgba(255,255,255,0.04); border-radius: 8px; padding: 3px; }
.filter-group button {
padding: 5px 14px; border-radius: 6px;
background: none; border: none;
color: var(--text-muted); font-size: 12px; cursor: pointer;
transition: all 0.15s;
}
.filter-group button.active {
background: var(--accent-1);
color: #fff;
}
.alert-list { padding: 0; }
.alert-item {
display: flex;
gap: 14px;
padding: 16px 20px;
border-bottom: 1px solid var(--border-color);
transition: background 0.15s;
}
.alert-item:last-child { border-bottom: none; }
.alert-item:hover { background: rgba(255,255,255,0.02); }
.alert-pending { background: rgba(239,68,68,0.03); }
.alert-left { padding-top: 2px; }
.alert-dot { width: 8px; height: 8px; border-radius: 50%; margin-top: 4px; }
.dot-red { background: #ef4444; box-shadow: 0 0 8px rgba(239,68,68,0.4); }
.dot-gray { background: rgba(255,255,255,0.15); }
.alert-body { flex: 1; min-width: 0; }
.alert-top { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.alert-task { font-size: 13px; font-weight: 500; color: var(--text-primary); }
.alert-time { font-size: 11px; color: var(--text-muted); margin-left: auto; }
.alert-msg {
font-size: 12px;
color: var(--text-secondary);
line-height: 1.5;
margin-bottom: 8px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.alert-bottom { display: flex; align-items: center; gap: 12px; }
.alert-status { font-size: 11px; }
.text-red { color: #f87171; }
.text-muted { color: var(--text-muted); }
.btn-ack {
padding: 3px 12px;
border-radius: 6px;
border: 1px solid rgba(102,126,234,0.3);
background: rgba(102,126,234,0.1);
color: #a8b4ff;
font-size: 11px;
cursor: pointer;
}
.btn-ack:hover { background: rgba(102,126,234,0.2); }
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--text-muted);
font-size: 14px;
}
.tag { display: inline-block; padding: 2px 10px; border-radius: 20px; font-size: 11px; font-weight: 500; }
.tag-warn { background: rgba(245,158,11,0.12); color: #fbbf24; }
.tag-red { background: rgba(239,68,68,0.12); color: #f87171; }
</style>
+541
View File
@@ -0,0 +1,541 @@
<template>
<div class="dashboard">
<!-- AI Quick-Start Panel -->
<div class="onboarding-panel" v-if="showOnboarding">
<div class="onboarding-glow"></div>
<div class="onboarding-content">
<div class="onboarding-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M12 2a4 4 0 0 1 4 4v2a4 4 0 0 1-8 0V6a4 4 0 0 1 4-4z"/>
<path d="M5 15h14l-1.5 6H6.5L5 15z"/>
<circle cx="12" cy="18" r="1" fill="currentColor"/>
</svg>
</div>
<div class="onboarding-text">
<h3> AI Agent 自动接入</h3>
<p>只需将下方指令发送给您的 AI Agent它将自动注册并开始汇报任务执行情况</p>
</div>
<button class="btn-primary" @click="showScript = !showScript">
{{ showScript ? '收起指令' : '生成接入指令' }}
</button>
</div>
<div v-if="showScript" class="onboarding-script">
<div class="script-tabs">
<button :class="{ active: scriptTab === 'curl' }" @click="scriptTab = 'curl'">curl</button>
<button :class="{ active: scriptTab === 'python' }" @click="scriptTab = 'python'">Python</button>
</div>
<div class="script-body">
<pre v-if="scriptTab === 'curl'">{{ curlScript }}</pre>
<pre v-else>{{ pythonScript }}</pre>
</div>
<button class="btn-copy" @click="copyScript">复制</button>
</div>
<button class="onboarding-close" @click="showOnboarding = false">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<!-- Summary Cards -->
<div class="stats-grid">
<div class="stat-card" style="--card-accent: #667eea">
<div class="stat-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
</div>
<div class="stat-body">
<div class="stat-label">Agent</div>
<div class="stat-value">{{ summary.total_agents }}</div>
<div class="stat-sub">
<span class="stat-dot active"></span>
活跃 {{ summary.active_agents }}
</div>
</div>
</div>
<div class="stat-card" style="--card-accent: #22c55e">
<div class="stat-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></svg>
</div>
<div class="stat-body">
<div class="stat-label">定时任务</div>
<div class="stat-value">{{ summary.total_tasks }}</div>
<div class="stat-sub">
<span class="stat-dot active"></span>
运行中 {{ summary.active_tasks }}
</div>
</div>
</div>
<div class="stat-card" style="--card-accent: #f59e0b">
<div class="stat-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
</div>
<div class="stat-body">
<div class="stat-label">最近24h执行</div>
<div class="stat-value">{{ summary.total_executions }}</div>
<div class="stat-sub">
<span style="color:#4ade80">成功 {{ summary.recent_executions_ok }}</span>
<span style="color:#f87171;margin-left:8px">失败 {{ summary.recent_executions_failed }}</span>
</div>
</div>
</div>
<div class="stat-card" :style="{ '--card-accent': summary.pending_alerts > 0 ? '#ef4444' : '#6366f1' }">
<div class="stat-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
</div>
<div class="stat-body">
<div class="stat-label">待处理告警</div>
<div class="stat-value" :style="{ color: summary.pending_alerts > 0 ? '#f87171' : 'var(--text-secondary)' }">
{{ summary.pending_alerts }}
</div>
<div class="stat-sub">{{ summary.pending_alerts > 0 ? '需要关注' : '一切正常' }}</div>
</div>
</div>
</div>
<!-- Task Overview -->
<div class="section">
<div class="section-header">
<h3>所有定时任务</h3>
<router-link to="/tasks" class="section-link">查看全部</router-link>
</div>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Agent</th>
<th>任务名称</th>
<th>Cron</th>
<th>状态</th>
<th>最近运行</th>
<th>结果</th>
<th>执行次数</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="t in tasks" :key="t.id">
<td><span class="cell-badge">{{ t.agent_name }}</span></td>
<td class="cell-name">{{ t.name }}</td>
<td><code class="cell-code">{{ t.cron_expression }}</code></td>
<td>
<span class="tag" :class="t.status === 'active' ? 'tag-green' : 'tag-gray'">
{{ t.status === 'active' ? '运行中' : t.status === 'paused' ? '已暂停' : '已停止' }}
</span>
</td>
<td class="cell-mono">{{ t.last_run_at ? dayjs(t.last_run_at).format('MM-DD HH:mm') : '—' }}</td>
<td>
<span v-if="t.last_run_result" class="tag" :class="t.last_run_result === 'success' ? 'tag-green' : 'tag-red'">
{{ t.last_run_result === 'success' ? '成功' : '失败' }}
</span>
<span v-else class="cell-muted"></span>
</td>
<td class="cell-mono">{{ t.total_run_count }}</td>
<td>
<router-link :to="`/tasks/${t.id}`" class="cell-link">详情</router-link>
</td>
</tr>
<tr v-if="tasks.length === 0">
<td colspan="8" class="cell-empty">暂无数据 AI Agent 接入后自动显示</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import dayjs from 'dayjs'
import { getDashboardSummary, getDashboardTasks, getSystemConfig } from '../api/index.js'
const summary = ref({
total_agents: 0, active_agents: 0,
total_tasks: 0, active_tasks: 0,
total_executions: 0,
recent_executions_ok: 0, recent_executions_failed: 0,
pending_alerts: 0,
})
const tasks = ref([])
const showOnboarding = ref(true)
const showScript = ref(false)
const scriptTab = ref('curl')
const baseUrl = ref(window.location.origin)
const curlScript = computed(() => {
const b = baseUrl.value
return `# 让 AI Agent 执行以下命令注册自己:
curl -X POST ${b}/api/agents \\
-H "Content-Type: application/json" \\
-d '{"name": "my-agent", "description": "我的 AI Agent"}'
# 保存返回的 api_key,后续汇报用
# 注册定时任务(替换 YOUR_API_KEY):
curl -X POST "${b}/api/tasks?agent_id=<ID>" \\
-H "Content-Type: application/json" \\
-H "Authorization: Bearer <YOUR_API_KEY>" \\
-d '{
"name": "data-sync",
"cron_expression": "*/5 * * * *",
"grace_period": 300
}'
# 执行后汇报:
curl -X POST "${b}/api/tasks/<TASK_ID>/executions" \\
-H "Content-Type: application/json" \\
-H "Authorization: Bearer <YOUR_API_KEY>" \\
-d '{
"status": "success",
"duration_ms": 1500,
"log": "任务执行完毕"
}'`
})
const pythonScript = computed(() => {
const b = baseUrl.value
return `import requests
BASE = "${b}"
# 1. AI Agent 注册自己
resp = requests.post(f"{BASE}/api/agents", json={
"name": "my-agent",
"description": "我的 AI Agent"
})
agent = resp.json()
API_KEY = agent["api_key"] # 保存此 Key
AGENT_ID = agent["id"]
# 2. 注册定时任务
resp = requests.post(f"{BASE}/api/tasks",
params={"agent_id": AGENT_ID},
headers={"Authorization": f"Bearer {API_KEY}"},
json={"name": "data-sync", "cron_expression": "*/5 * * * *"}
)
task = resp.json()
TASK_ID = task["id"]
# 3. 每次执行后汇报
requests.post(f"{BASE}/api/tasks/{TASK_ID}/executions",
headers={"Authorization": f"Bearer {API_KEY}"},
json={"status": "success", "duration_ms": 1500, "log": "done"})`
})
onMounted(async () => {
try {
const [sumRes, taskRes, cfgRes] = await Promise.all([
getDashboardSummary(),
getDashboardTasks(),
getSystemConfig(),
])
summary.value = sumRes.data
tasks.value = taskRes.data
baseUrl.value = cfgRes.data.base_url
} catch (e) {
console.error('Failed to load dashboard', e)
}
})
const copyScript = async () => {
const text = scriptTab.value === 'curl' ? curlScript.value : pythonScript.value
try {
await navigator.clipboard.writeText(text)
const { ElMessage } = await import('element-plus')
ElMessage.success('已复制')
} catch {
const { ElMessage } = await import('element-plus')
ElMessage.warning('复制失败,请手动复制')
}
}
</script>
<style scoped>
.dashboard {
max-width: 1200px;
}
/* ── Onboarding Panel ─────────────────────────── */
.onboarding-panel {
position: relative;
background: linear-gradient(135deg, rgba(102,126,234,0.08) 0%, rgba(118,75,162,0.08) 100%);
border: 1px solid rgba(102,126,234,0.15);
border-radius: 16px;
padding: 24px;
margin-bottom: 24px;
overflow: hidden;
}
.onboarding-glow {
position: absolute;
top: -50%;
right: -20%;
width: 300px;
height: 300px;
background: radial-gradient(circle, rgba(102,126,234,0.08) 0%, transparent 70%);
pointer-events: none;
}
.onboarding-content {
display: flex;
align-items: center;
gap: 16px;
position: relative;
}
.onboarding-icon {
width: 48px;
height: 48px;
background: linear-gradient(135deg, rgba(102,126,234,0.2), rgba(118,75,162,0.2));
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: #a8b4ff;
flex-shrink: 0;
}
.onboarding-text h3 {
font-size: 16px;
font-weight: 600;
color: #e0e0f0;
margin-bottom: 4px;
}
.onboarding-text p {
font-size: 13px;
color: rgba(255,255,255,0.45);
margin: 0;
}
.btn-primary {
margin-left: auto;
padding: 8px 18px;
border-radius: 8px;
border: none;
background: linear-gradient(135deg, #667eea, #764ba2);
color: #fff;
font-size: 13px;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: opacity 0.2s;
}
.btn-primary:hover { opacity: 0.9; }
.onboarding-close {
position: absolute;
top: 16px;
right: 16px;
background: none;
border: none;
color: rgba(255,255,255,0.3);
cursor: pointer;
padding: 4px;
}
.onboarding-close:hover { color: #fff; }
/* Script block */
.onboarding-script {
margin-top: 16px;
background: rgba(0,0,0,0.3);
border-radius: 10px;
overflow: hidden;
position: relative;
}
.script-tabs {
display: flex;
border-bottom: 1px solid rgba(255,255,255,0.06);
}
.script-tabs button {
padding: 8px 16px;
background: none;
border: none;
color: rgba(255,255,255,0.4);
font-size: 12px;
cursor: pointer;
border-bottom: 2px solid transparent;
}
.script-tabs button.active {
color: #a8b4ff;
border-bottom-color: #667eea;
}
.script-body {
padding: 16px;
overflow-x: auto;
}
.script-body pre {
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
font-size: 12px;
line-height: 1.7;
color: rgba(255,255,255,0.75);
white-space: pre;
margin: 0;
}
.btn-copy {
position: absolute;
top: 48px;
right: 12px;
padding: 4px 12px;
border-radius: 6px;
border: 1px solid rgba(255,255,255,0.1);
background: rgba(255,255,255,0.06);
color: rgba(255,255,255,0.5);
font-size: 11px;
cursor: pointer;
}
.btn-copy:hover {
background: rgba(255,255,255,0.1);
color: #fff;
}
/* ── Stats Grid ───────────────────────────────── */
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 14px;
padding: 20px;
display: flex;
gap: 16px;
transition: all 0.25s;
}
.stat-card:hover {
border-color: var(--card-accent, rgba(255,255,255,0.1));
box-shadow: 0 0 20px rgba(var(--card-accent, 255), 0.05);
transform: translateY(-2px);
}
.stat-icon {
width: 44px;
height: 44px;
border-radius: 12px;
background: linear-gradient(135deg, rgba(255,255,255,0.04), rgba(255,255,255,0.02));
border: 1px solid rgba(255,255,255,0.06);
display: flex;
align-items: center;
justify-content: center;
color: var(--card-accent, #667eea);
flex-shrink: 0;
}
.stat-body { flex: 1; }
.stat-label { font-size: 12px; color: var(--text-secondary); margin-bottom: 4px; }
.stat-value { font-size: 28px; font-weight: 700; color: #e8e8f0; line-height: 1.1; margin-bottom: 6px; }
.stat-sub { font-size: 12px; color: var(--text-muted); display: flex; align-items: center; gap: 6px; }
.stat-dot {
width: 5px; height: 5px; border-radius: 50%;
}
.stat-dot.active { background: #22c55e; box-shadow: 0 0 6px rgba(34,197,94,0.5); }
/* ── Task Table Section ───────────────────────── */
.section {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 14px;
overflow: hidden;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--border-color);
}
.section-header h3 {
font-size: 15px;
font-weight: 600;
color: #e0e0f0;
margin: 0;
}
.section-link {
font-size: 13px;
color: var(--accent-1);
text-decoration: none;
}
.section-link:hover { text-decoration: underline; }
.table-wrap {
overflow-x: auto;
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th {
text-align: left;
padding: 12px 16px;
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-secondary);
background: rgba(255,255,255,0.02);
border-bottom: 1px solid var(--border-color);
}
.data-table td {
padding: 12px 16px;
font-size: 13px;
color: var(--text-primary);
border-bottom: 1px solid var(--border-color);
}
.data-table tbody tr:hover {
background: rgba(255,255,255,0.02);
}
.cell-badge {
display: inline-block;
padding: 2px 10px;
border-radius: 20px;
background: rgba(255,255,255,0.04);
color: var(--text-secondary);
font-size: 12px;
}
.cell-name {
font-weight: 500;
}
.cell-code {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 12px;
color: #a8b4ff;
background: rgba(102,126,234,0.1);
padding: 2px 8px;
border-radius: 4px;
}
.cell-mono {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
color: var(--text-secondary);
}
.cell-muted { color: var(--text-muted); }
.cell-empty {
text-align: center;
color: var(--text-muted);
padding: 40px 16px !important;
}
.cell-link {
color: var(--accent-1);
text-decoration: none;
font-size: 13px;
}
.cell-link:hover { text-decoration: underline; }
/* Tags */
.tag {
display: inline-block;
padding: 2px 10px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
}
.tag-green {
background: rgba(34,197,94,0.12);
color: #4ade80;
}
.tag-red {
background: rgba(239,68,68,0.12);
color: #f87171;
}
.tag-gray {
background: rgba(255,255,255,0.05);
color: var(--text-secondary);
}
</style>
+431
View File
@@ -0,0 +1,431 @@
<template>
<div class="settings-page">
<!-- System Configuration -->
<div class="section mb-24">
<div class="section-header">
<h3>系统配置</h3>
</div>
<div class="config-body">
<div class="config-row">
<div class="config-info">
<div class="config-label">公网访问地址 (Base URL)</div>
<div class="config-desc">AI Agent 可通过此地址访问本系统 API修改后页面上的接入指令会自动更新</div>
</div>
<div class="config-input-group">
<input v-model="configForm.base_url" class="input config-input" placeholder="https://your-domain.com" />
<button class="btn-primary" @click="handleSaveConfig" :disabled="savingConfig">
{{ savingConfig ? '保存中...' : '保存' }}
</button>
</div>
</div>
<div v-if="configSaved" class="config-success">配置已保存</div>
</div>
</div>
<!-- Notification Channels -->
<div class="section mb-24">
<div class="section-header">
<h3>通知渠道</h3>
<button class="btn-primary" @click="showChannelDialog = true">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
添加渠道
</button>
</div>
<div class="card-grid">
<div v-for="c in channels" :key="c.id" class="channel-card">
<div class="channel-top">
<div class="channel-icon" :class="c.channel_type">
<svg v-if="c.channel_type === 'feishu_cli'" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 2a4 4 0 0 1 4 4v2a4 4 0 0 1-8 0V6a4 4 0 0 1 4-4z"/><path d="M5 15h14l-1.5 6H6.5L5 15z"/></svg>
<svg v-else-if="c.channel_type === 'email'" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
<svg v-else width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
</div>
<div>
<div class="channel-name">{{ c.name }}</div>
<div class="channel-type">{{ c.channel_type === 'feishu_cli' ? '飞书' : c.channel_type === 'email' ? '邮件' : 'Webhook' }}</div>
</div>
<div class="channel-spacer"></div>
<label class="toggle">
<input type="checkbox" :checked="c.enabled" @change="(e) => toggleChannel(c, e.target.checked)">
<span class="toggle-slider"></span>
</label>
</div>
<div class="channel-config">{{ c.config }}</div>
<button class="channel-delete" @click="handleDeleteChannel(c)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</button>
</div>
<div v-if="channels.length === 0" class="empty-state">
<p>暂无通知渠道 添加飞书或邮件通知</p>
</div>
</div>
</div>
<!-- Alert Rules -->
<div class="section mb-24">
<div class="section-header">
<h3>告警规则</h3>
<button class="btn-primary" @click="showRuleDialog = true">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
添加规则
</button>
</div>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>ID</th>
<th>任务</th>
<th>通知渠道</th>
<th>告警类型</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="r in rules" :key="r.id">
<td class="cell-mono">#{{ r.id }}</td>
<td class="cell-name">任务 #{{ r.task_id }}</td>
<td>{{ r.channel_name }}</td>
<td><span class="tag" :class="r.alert_type === 'missed_run' ? 'tag-warn' : 'tag-red'">{{ r.alert_type === 'missed_run' ? '未按时执行' : '执行失败' }}</span></td>
<td><button class="cell-link-btn" @click="handleDeleteRule(r)">删除</button></td>
</tr>
<tr v-if="rules.length === 0">
<td colspan="5" class="cell-empty">暂无规则</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Add Channel Modal -->
<div v-if="showChannelDialog" class="modal-overlay" @click.self="showChannelDialog = false">
<div class="modal">
<div class="modal-header">
<h4>添加通知渠道</h4>
<button class="modal-close" @click="showChannelDialog = false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
</div>
<div class="modal-body">
<div class="field">
<label>渠道名称</label>
<input v-model="channelForm.name" placeholder="例如:我的飞书" class="input" />
</div>
<div class="field">
<label>渠道类型</label>
<select v-model="channelForm.channel_type" class="input">
<option value="feishu_cli">飞书 Webhook</option>
<option value="email">邮件 (SMTP)</option>
<option value="webhook">通用 Webhook</option>
</select>
</div>
<div class="field">
<label>配置 (JSON)</label>
<textarea v-model="channelForm.config" rows="4" class="input" placeholder='飞书: {"webhook_url":"..."}'></textarea>
</div>
</div>
<div class="modal-footer">
<button class="btn-secondary" @click="showChannelDialog = false">取消</button>
<button class="btn-primary" @click="handleAddChannel">添加</button>
</div>
</div>
</div>
<!-- Add Rule Modal -->
<div v-if="showRuleDialog" class="modal-overlay" @click.self="showRuleDialog = false">
<div class="modal">
<div class="modal-header">
<h4>添加告警规则</h4>
<button class="modal-close" @click="showRuleDialog = false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
</div>
<div class="modal-body">
<div class="field">
<label>任务</label>
<select v-model="ruleForm.task_id" class="input">
<option v-for="t in allTasks" :key="t.id" :value="t.id">[{{ t.id }}] {{ t.name }}</option>
</select>
</div>
<div class="field">
<label>通知渠道</label>
<select v-model="ruleForm.channel_id" class="input">
<option v-for="c in channels" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
</div>
<div class="field">
<label>告警类型</label>
<select v-model="ruleForm.alert_type" class="input">
<option value="missed_run">未按时执行</option>
<option value="failure">执行失败</option>
</select>
</div>
</div>
<div class="modal-footer">
<button class="btn-secondary" @click="showRuleDialog = false">取消</button>
<button class="btn-primary" @click="handleAddRule">添加</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import {
listChannels, createChannel, updateChannel, deleteChannel,
listAlertRules, createAlertRule, deleteAlertRule,
listTasks, getSystemConfig, saveSystemConfig,
} from '../api/index.js'
const channels = ref([])
const rules = ref([])
const allTasks = ref([])
const showChannelDialog = ref(false)
const showRuleDialog = ref(false)
const channelForm = ref({ name: '', channel_type: 'feishu_cli', config: '' })
const ruleForm = ref({ task_id: null, channel_id: null, alert_type: 'missed_run' })
// System config
const configForm = ref({ base_url: '' })
const savingConfig = ref(false)
const configSaved = ref(false)
const loadData = async () => {
const [chRes, ruleRes, taskRes, cfgRes] = await Promise.all([
listChannels(), listAlertRules(), listTasks(), getSystemConfig(),
])
channels.value = chRes.data
rules.value = ruleRes.data
allTasks.value = taskRes.data
configForm.value.base_url = cfgRes.data.base_url
}
const handleSaveConfig = async () => {
savingConfig.value = true
configSaved.value = false
try {
const res = await saveSystemConfig({ base_url: configForm.value.base_url })
configForm.value.base_url = res.data.base_url
configSaved.value = true
setTimeout(() => { configSaved.value = false }, 3000)
} catch (e) {
console.error(e)
} finally {
savingConfig.value = false
}
}
const handleAddChannel = async () => {
try {
await createChannel(channelForm.value)
showChannelDialog.value = false
channelForm.value = { name: '', channel_type: 'feishu_cli', config: '' }
await loadData()
} catch (e) { console.error(e) }
}
const toggleChannel = async (row, enabled) => {
try { await updateChannel(row.id, { enabled }) } catch (e) { console.error(e) }
}
const handleDeleteChannel = async (row) => {
try { await deleteChannel(row.id); await loadData() } catch (e) { console.error(e) }
}
const handleAddRule = async () => {
try {
await createAlertRule(ruleForm.value)
showRuleDialog.value = false
ruleForm.value = { task_id: null, channel_id: null, alert_type: 'missed_run' }
await loadData()
} catch (e) { console.error(e) }
}
const handleDeleteRule = async (row) => {
try { await deleteAlertRule(row.id); await loadData() } catch (e) { console.error(e) }
}
onMounted(loadData)
</script>
<style scoped>
.settings-page { max-width: 1200px; }
.mb-24 { margin-bottom: 24px; }
.section {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 14px;
overflow: hidden;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--border-color);
}
.section-header h3 { font-size: 15px; font-weight: 600; color: #e0e0f0; margin: 0; }
/* System Config */
.config-body { padding: 20px; }
.config-row { display: flex; align-items: flex-start; gap: 20px; }
.config-info { flex: 1; }
.config-label { font-size: 14px; font-weight: 500; color: var(--text-primary); margin-bottom: 4px; }
.config-desc { font-size: 12px; color: var(--text-muted); line-height: 1.5; }
.config-input-group { display: flex; gap: 8px; align-items: center; flex-shrink: 0; }
.config-input { width: 320px; }
.config-success {
margin-top: 12px;
font-size: 13px;
color: #4ade80;
padding: 6px 12px;
background: rgba(34,197,94,0.1);
border-radius: 6px;
display: inline-block;
}
/* Cards */
.card-grid { padding: 16px; display: flex; flex-direction: column; gap: 12px; }
.channel-card {
position: relative;
background: rgba(255,255,255,0.02);
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 16px;
}
.channel-top { display: flex; align-items: center; gap: 12px; }
.channel-icon {
width: 40px; height: 40px; border-radius: 10px;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
}
.channel-icon.feishu_cli { background: rgba(102,126,234,0.15); color: #a8b4ff; }
.channel-icon.email { background: rgba(34,197,94,0.15); color: #4ade80; }
.channel-icon.webhook { background: rgba(245,158,11,0.15); color: #fbbf24; }
.channel-spacer { flex: 1; }
.channel-name { font-size: 14px; font-weight: 500; color: var(--text-primary); }
.channel-type { font-size: 11px; color: var(--text-muted); }
.channel-config {
margin-top: 8px;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.channel-delete {
position: absolute; top: 12px; right: 12px;
background: none; border: none;
color: var(--text-muted); cursor: pointer;
}
.channel-delete:hover { color: var(--danger); }
/* Toggle */
.toggle { position: relative; display: inline-block; width: 36px; height: 20px; }
.toggle input { opacity: 0; width: 0; height: 0; }
.toggle-slider {
position: absolute; inset: 0;
background: rgba(255,255,255,0.1);
border-radius: 10px;
cursor: pointer;
transition: 0.2s;
}
.toggle-slider::before {
content: '';
position: absolute;
width: 16px; height: 16px;
left: 2px; top: 2px;
background: #fff;
border-radius: 50%;
transition: 0.2s;
}
.toggle input:checked + .toggle-slider { background: var(--accent-1); }
.toggle input:checked + .toggle-slider::before { transform: translateX(16px); }
/* Table */
.table-wrap { overflow-x: auto; }
.data-table { width: 100%; border-collapse: collapse; }
.data-table th {
text-align: left; padding: 12px 16px; font-size: 11px; font-weight: 500;
text-transform: uppercase; letter-spacing: 0.5px;
color: var(--text-secondary); background: rgba(255,255,255,0.02);
border-bottom: 1px solid var(--border-color);
}
.data-table td {
padding: 12px 16px; font-size: 13px;
color: var(--text-primary); border-bottom: 1px solid var(--border-color);
}
.data-table tbody tr:hover { background: rgba(255,255,255,0.02); }
.cell-mono { font-family: 'JetBrains Mono', monospace; font-size: 12px; color: var(--text-secondary); }
.cell-name { font-weight: 500; }
.cell-empty { text-align: center; color: var(--text-muted); padding: 40px 16px !important; }
.cell-link-btn { background: none; border: none; color: var(--danger); font-size: 13px; cursor: pointer; }
.cell-link-btn:hover { text-decoration: underline; }
.tag { display: inline-block; padding: 2px 10px; border-radius: 20px; font-size: 11px; font-weight: 500; }
.tag-warn { background: rgba(245,158,11,0.12); color: #fbbf24; }
.tag-red { background: rgba(239,68,68,0.12); color: #f87171; }
.empty-state { text-align: center; padding: 40px 20px; color: var(--text-muted); }
/* Buttons */
.btn-primary {
display: inline-flex; align-items: center; gap: 6px;
padding: 7px 16px; border-radius: 8px;
border: none;
background: linear-gradient(135deg, #667eea, #764ba2);
color: #fff; font-size: 13px; font-weight: 500;
cursor: pointer;
}
.btn-primary:hover { opacity: 0.9; }
.btn-secondary {
padding: 7px 16px; border-radius: 8px;
border: 1px solid var(--border-color);
background: transparent;
color: var(--text-secondary); font-size: 13px;
cursor: pointer;
}
.btn-secondary:hover { color: #fff; border-color: rgba(255,255,255,0.15); }
/* Modal */
.modal-overlay {
position: fixed; inset: 0; z-index: 1000;
background: rgba(0,0,0,0.6);
backdrop-filter: blur(4px);
display: flex; align-items: center; justify-content: center;
}
.modal {
width: 500px; max-width: 90vw;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 14px;
overflow: hidden;
}
.modal-header {
display: flex; justify-content: space-between; align-items: center;
padding: 16px 20px; border-bottom: 1px solid var(--border-color);
}
.modal-header h4 { font-size: 15px; font-weight: 600; color: #e0e0f0; margin: 0; }
.modal-close { background: none; border: none; color: var(--text-muted); cursor: pointer; }
.modal-close:hover { color: #fff; }
.modal-body { padding: 20px; display: flex; flex-direction: column; gap: 16px; }
.modal-footer {
display: flex; justify-content: flex-end; gap: 8px;
padding: 16px 20px; border-top: 1px solid var(--border-color);
}
.field { display: flex; flex-direction: column; gap: 6px; }
.field label { font-size: 12px; color: var(--text-secondary); }
.input {
padding: 8px 12px;
border-radius: 8px;
border: 1px solid var(--border-color);
background: rgba(255,255,255,0.04);
color: var(--text-primary);
font-size: 13px;
outline: none;
transition: border-color 0.15s;
}
.input:focus { border-color: var(--accent-1); }
textarea.input { resize: vertical; font-family: 'JetBrains Mono', monospace; font-size: 12px; }
select.input { cursor: pointer; }
</style>
+242
View File
@@ -0,0 +1,242 @@
<template>
<div class="task-detail">
<button class="back-btn" @click="$router.back()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
返回
</button>
<div v-if="task" class="section mb-24">
<div class="section-header">
<h3>{{ task.name }}</h3>
<span class="tag" :class="task.status === 'active' ? 'tag-green' : 'tag-gray'">
{{ task.status === 'active' ? '运行中' : task.status === 'paused' ? '已暂停' : '已停止' }}
</span>
</div>
<div class="detail-grid">
<div class="detail-item"><span class="detail-label">Agent</span><span class="detail-value">{{ task.agent_name }}</span></div>
<div class="detail-item"><span class="detail-label">Cron</span><code class="detail-code">{{ task.cron_expression }}</code></div>
<div class="detail-item"><span class="detail-label">容忍窗口</span><span class="detail-value">{{ task.grace_period }}s</span></div>
<div class="detail-item"><span class="detail-label">最近运行</span><span class="detail-value mono">{{ task.last_run_at ? dayjs(task.last_run_at).format('YYYY-MM-DD HH:mm:ss') : '—' }}</span></div>
<div class="detail-item">
<span class="detail-label">最近结果</span>
<span v-if="task.last_run_result" class="tag" :class="task.last_run_result === 'success' ? 'tag-green' : 'tag-red'">
{{ task.last_run_result === 'success' ? '成功' : '失败' }}
</span>
<span v-else class="detail-value"></span>
</div>
<div class="detail-item"><span class="detail-label">执行次数</span><span class="detail-value mono">{{ task.total_run_count }}</span></div>
<div class="detail-item"><span class="detail-label">预计下次</span><span class="detail-value mono">{{ task.next_run_at ? dayjs(task.next_run_at).format('YYYY-MM-DD HH:mm:ss') : '待计算' }}</span></div>
<div class="detail-item"><span class="detail-label">描述</span><span class="detail-value">{{ task.description || '无' }}</span></div>
</div>
</div>
<!-- Execution History -->
<div class="section">
<div class="section-header">
<h3>执行历史</h3>
</div>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>ID</th>
<th>状态</th>
<th>开始时间</th>
<th>耗时(ms)</th>
<th>结果摘要</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="e in executions" :key="e.id">
<td class="cell-mono">#{{ e.id }}</td>
<td>
<span class="tag" :class="e.status === 'success' ? 'tag-green' : e.status === 'failed' ? 'tag-red' : 'tag-warn'">
{{ e.status === 'success' ? '成功' : e.status === 'failed' ? '失败' : '运行中' }}
</span>
</td>
<td class="cell-mono">{{ dayjs(e.started_at).format('MM-DD HH:mm:ss') }}</td>
<td class="cell-mono">{{ e.duration_ms ?? '—' }}</td>
<td class="cell-muted cell-ellipsis">{{ e.result || '—' }}</td>
<td><button class="cell-link-btn" @click="showLog(e)">日志</button></td>
</tr>
<tr v-if="executions.length === 0">
<td colspan="6" class="cell-empty">暂无执行记录</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Log Dialog -->
<div v-if="logVisible" class="modal-overlay" @click.self="logVisible = false">
<div class="modal">
<div class="modal-header">
<h4>执行日志 #{{ logExecution?.id }}</h4>
<button class="modal-close" @click="logVisible = false">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<pre class="log-viewer">{{ currentLog || '暂无日志' }}</pre>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import dayjs from 'dayjs'
import { getTask, listExecutions } from '../api/index.js'
const route = useRoute()
const taskId = Number(route.params.id)
const task = ref(null)
const executions = ref([])
const logVisible = ref(false)
const currentLog = ref('')
const logExecution = ref(null)
onMounted(async () => {
try {
const [taskRes, execRes] = await Promise.all([
getTask(taskId),
listExecutions(taskId, { limit: 100 }),
])
task.value = taskRes.data
executions.value = execRes.data
} catch (e) {
console.error(e)
}
})
const showLog = (row) => {
logExecution.value = row
currentLog.value = row.log || '暂无日志'
logVisible.value = true
}
</script>
<style scoped>
.task-detail { max-width: 1200px; }
.back-btn {
display: inline-flex;
align-items: center;
gap: 6px;
background: none;
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 6px 14px;
color: var(--text-secondary);
font-size: 13px;
cursor: pointer;
margin-bottom: 16px;
}
.back-btn:hover { color: #fff; border-color: rgba(255,255,255,0.15); }
.mb-24 { margin-bottom: 24px; }
.section {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 14px;
overflow: hidden;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--border-color);
}
.section-header h3 { font-size: 15px; font-weight: 600; color: #e0e0f0; margin: 0; }
.detail-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0;
}
.detail-item {
display: flex;
flex-direction: column;
gap: 4px;
padding: 14px 20px;
border-bottom: 1px solid var(--border-color);
}
.detail-item:nth-child(odd) { border-right: 1px solid var(--border-color); }
.detail-label { font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; }
.detail-value { font-size: 13px; color: var(--text-primary); }
.detail-value.mono, .cell-mono { font-family: 'JetBrains Mono', monospace; font-size: 12px; color: var(--text-secondary); }
.detail-code {
font-family: 'JetBrains Mono', monospace;
font-size: 12px; color: #a8b4ff;
background: rgba(102,126,234,0.1);
padding: 2px 8px; border-radius: 4px;
display: inline-block;
}
/* Table */
.table-wrap { overflow-x: auto; }
.data-table { width: 100%; border-collapse: collapse; }
.data-table th {
text-align: left; padding: 12px 16px; font-size: 11px; font-weight: 500;
text-transform: uppercase; letter-spacing: 0.5px;
color: var(--text-secondary); background: rgba(255,255,255,0.02);
border-bottom: 1px solid var(--border-color);
}
.data-table td {
padding: 12px 16px; font-size: 13px;
color: var(--text-primary); border-bottom: 1px solid var(--border-color);
}
.data-table tbody tr:hover { background: rgba(255,255,255,0.02); }
.cell-mono { font-family: 'JetBrains Mono', monospace; font-size: 12px; color: var(--text-secondary); }
.cell-muted { color: var(--text-muted); }
.cell-empty { text-align: center; color: var(--text-muted); padding: 40px 16px !important; }
.cell-ellipsis { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cell-link-btn {
background: none; border: none; color: var(--accent-1);
font-size: 13px; cursor: pointer;
}
.cell-link-btn:hover { text-decoration: underline; }
.tag { display: inline-block; padding: 2px 10px; border-radius: 20px; font-size: 12px; font-weight: 500; }
.tag-green { background: rgba(34,197,94,0.12); color: #4ade80; }
.tag-red { background: rgba(239,68,68,0.12); color: #f87171; }
.tag-warn { background: rgba(245,158,11,0.12); color: #fbbf24; }
.tag-gray { background: rgba(255,255,255,0.05); color: var(--text-secondary); }
/* Log Modal */
.modal-overlay {
position: fixed; inset: 0; z-index: 1000;
background: rgba(0,0,0,0.6);
backdrop-filter: blur(4px);
display: flex; align-items: center; justify-content: center;
}
.modal {
width: 700px; max-width: 90vw;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 14px;
overflow: hidden;
}
.modal-header {
display: flex; justify-content: space-between; align-items: center;
padding: 16px 20px; border-bottom: 1px solid var(--border-color);
}
.modal-header h4 { font-size: 15px; font-weight: 600; color: #e0e0f0; margin: 0; }
.modal-close { background: none; border: none; color: var(--text-muted); cursor: pointer; }
.modal-close:hover { color: #fff; }
.log-viewer {
background: #0d0d14;
color: #c0c4d0;
padding: 20px;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 13px;
line-height: 1.6;
max-height: 500px;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
margin: 0;
}
</style>
+220
View File
@@ -0,0 +1,220 @@
<template>
<div class="tasks-page">
<div class="section mb-24">
<div class="section-header">
<h3>定时任务 AI Agent 自动管理</h3>
</div>
<div class="onboard-body">
<p>Agent 注册后通过下方 API 汇报任务执行情况无需手动创建</p>
<div class="code-block">
<div class="code-tabs">
<button :class="{ active: tab === 'report' }" @click="tab = 'report'">汇报执行</button>
<button :class="{ active: tab === 'register' }" @click="tab = 'register'">注册任务</button>
<button :class="{ active: tab === 'batch' }" @click="tab = 'batch'">批量注册</button>
</div>
<div class="code-body">
<pre>{{ codes[tab] }}</pre>
</div>
<button class="btn-copy" @click="copyCode">复制</button>
</div>
</div>
</div>
<!-- Task list -->
<div class="section">
<div class="section-header">
<h3>所有任务</h3>
</div>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>ID</th>
<th>Agent</th>
<th>任务名称</th>
<th>Cron</th>
<th>状态</th>
<th>最近运行</th>
<th>结果</th>
<th>执行次数</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="t in tasks" :key="t.id">
<td class="cell-mono">#{{ t.id }}</td>
<td><span class="cell-badge">{{ t.agent_name }}</span></td>
<td class="cell-name">{{ t.name }}</td>
<td><code class="cell-code">{{ t.cron_expression }}</code></td>
<td>
<span class="tag" :class="t.status === 'active' ? 'tag-green' : 'tag-gray'">
{{ t.status === 'active' ? '运行中' : t.status === 'paused' ? '已暂停' : '已停止' }}
</span>
</td>
<td class="cell-mono">{{ t.last_run_at ? dayjs(t.last_run_at).format('MM-DD HH:mm') : '—' }}</td>
<td>
<span v-if="t.last_run_result" class="tag" :class="t.last_run_result === 'success' ? 'tag-green' : 'tag-red'">
{{ t.last_run_result === 'success' ? '成功' : '失败' }}
</span>
<span v-else class="cell-muted"></span>
</td>
<td class="cell-mono">{{ t.total_run_count }}</td>
<td>
<router-link :to="`/tasks/${t.id}`" class="cell-link">详情</router-link>
<button class="btn-icon" @click="handleDelete(t)" title="删除">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</button>
</td>
</tr>
<tr v-if="tasks.length === 0">
<td colspan="9" class="cell-empty">暂无任务 Agent 注册后会自动出现在这里</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import dayjs from 'dayjs'
import { listTasks, deleteTask, getSystemConfig } from '../api/index.js'
const tasks = ref([])
const tab = ref('report')
const baseUrl = ref(window.location.origin)
const loadData = async () => {
const [taskRes, cfgRes] = await Promise.all([
listTasks(),
getSystemConfig(),
])
tasks.value = taskRes.data
baseUrl.value = cfgRes.data.base_url
}
const handleDelete = async (row) => {
try {
await deleteTask(row.id)
await loadData()
} catch (e) {
console.error(e)
}
}
const codes = computed(() => {
const b = baseUrl.value
return {
report: `curl -X POST ${b}/api/tasks/<TASK_ID>/executions \\
-H "Content-Type: application/json" \\
-H "Authorization: Bearer <YOUR_API_KEY>" \\
-d '{"status":"success","duration_ms":2500,"log":"done"}'`,
register: `curl -X POST "${b}/api/tasks?agent_id=<AGENT_ID>" \\
-H "Content-Type: application/json" \\
-H "Authorization: Bearer <YOUR_API_KEY>" \\
-d '{"name":"data-sync","cron_expression":"*/5 * * * *","grace_period":300}'`,
batch: `curl -X POST ${b}/api/agents/register-with-tasks \\
-H "Content-Type: application/json" \\
-d '{"name":"agent","tasks":[{"name":"sync","cron_expression":"*/5 * * * *"}]}'`,
}
})
const copyCode = async () => {
try {
await navigator.clipboard.writeText(codes.value[tab.value])
const { ElMessage } = await import('element-plus')
ElMessage.success('已复制')
} catch {}
}
onMounted(loadData)
</script>
<style scoped>
.tasks-page { max-width: 1200px; }
.mb-24 { margin-bottom: 24px; }
.section {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 14px;
overflow: hidden;
}
.section-header {
padding: 16px 20px;
border-bottom: 1px solid var(--border-color);
}
.section-header h3 { font-size: 15px; font-weight: 600; color: #e0e0f0; margin: 0; }
.onboard-body { padding: 20px; }
.onboard-body > p { font-size: 13px; color: var(--text-secondary); margin-bottom: 16px; }
.code-block {
position: relative;
background: rgba(0,0,0,0.3);
border-radius: 10px;
overflow: hidden;
}
.code-tabs { display: flex; border-bottom: 1px solid rgba(255,255,255,0.06); }
.code-tabs button {
padding: 8px 16px; background: none; border: none;
color: rgba(255,255,255,0.4); font-size: 12px; cursor: pointer;
border-bottom: 2px solid transparent;
}
.code-tabs button.active { color: #a8b4ff; border-bottom-color: #667eea; }
.code-body { padding: 16px; overflow-x: auto; }
.code-body pre {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 12px; line-height: 1.7; color: rgba(255,255,255,0.75);
white-space: pre; margin: 0;
}
.btn-copy {
position: absolute; top: 44px; right: 12px;
padding: 4px 12px; border-radius: 6px;
border: 1px solid rgba(255,255,255,0.1);
background: rgba(255,255,255,0.06);
color: rgba(255,255,255,0.5); font-size: 11px; cursor: pointer;
}
.btn-copy:hover { background: rgba(255,255,255,0.1); color: #fff; }
.table-wrap { overflow-x: auto; }
.data-table { width: 100%; border-collapse: collapse; }
.data-table th {
text-align: left; padding: 12px 16px; font-size: 11px; font-weight: 500;
text-transform: uppercase; letter-spacing: 0.5px;
color: var(--text-secondary); background: rgba(255,255,255,0.02);
border-bottom: 1px solid var(--border-color);
}
.data-table td {
padding: 12px 16px; font-size: 13px;
color: var(--text-primary); border-bottom: 1px solid var(--border-color);
}
.data-table tbody tr:hover { background: rgba(255,255,255,0.02); }
.cell-badge {
display: inline-block; padding: 2px 10px; border-radius: 20px;
background: rgba(255,255,255,0.04); color: var(--text-secondary); font-size: 12px;
}
.cell-name { font-weight: 500; }
.cell-code {
font-family: 'JetBrains Mono', monospace; font-size: 12px;
color: #a8b4ff; background: rgba(102,126,234,0.1);
padding: 2px 8px; border-radius: 4px;
}
.cell-mono { font-family: 'JetBrains Mono', monospace; font-size: 12px; color: var(--text-secondary); }
.cell-muted { color: var(--text-muted); }
.cell-empty { text-align: center; color: var(--text-muted); padding: 40px 16px !important; }
.cell-link { color: var(--accent-1); text-decoration: none; font-size: 13px; margin-right: 8px; }
.cell-link:hover { text-decoration: underline; }
.tag { display: inline-block; padding: 2px 10px; border-radius: 20px; font-size: 12px; font-weight: 500; }
.tag-green { background: rgba(34,197,94,0.12); color: #4ade80; }
.tag-red { background: rgba(239,68,68,0.12); color: #f87171; }
.tag-gray { background: rgba(255,255,255,0.05); color: var(--text-secondary); }
.btn-icon {
background: none; border: none; color: var(--text-muted);
cursor: pointer; padding: 4px; vertical-align: middle;
}
.btn-icon:hover { color: var(--danger); }
</style>
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
assetsDir: 'assets',
},
})