TaskPulse/frontend/src/views/Tasks.vue
Steven b04c0ce321 feat: agent avatars, task tags, search, edit/delete agents, cron readability, timezone fix
- Agent avatars: first-letter + deterministic color avatar in all views
- Agent management: edit (name/description) and delete with confirmation
- Execution API: validates agent existence, returns 410 if deleted
- Task tags: JSON array field, editable with tag pills UI
- Task search: by name (?q=) and by tags (?tags=) and by status
- Task edit: simplified to name/tags/description only (not agent params)
- Cron display: human-readable Chinese (e.g. '每5分钟', '每天 09:00')
- Timezone fix: UTC→local via dayjs.utc().local() across all pages
- Content area: full width (removed max-width:1200px constraints)
- Login page: standalone layout without sidebar
2026-06-15 23:33:28 +08:00

342 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="tasks-page">
<!-- Search & Filter Bar -->
<div class="section mb-24">
<div class="section-header">
<h3>所有任务</h3>
</div>
<div class="search-bar">
<div class="search-input-wrap">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input v-model="searchQ" class="search-input" placeholder="搜索任务名称..." @input="loadData" />
</div>
<input v-model="searchTags" class="search-input" style="width:200px" placeholder="标签筛选(逗号分隔)" @input="loadData" />
<select v-model="searchStatus" class="search-input" style="width:130px" @change="loadData">
<option value="">全部状态</option>
<option value="active">运行中</option>
<option value="paused">已暂停</option>
<option value="stopped">已停止</option>
</select>
</div>
</div>
<!-- Task list -->
<div class="section">
<div class="section-header">
<h3>任务列表({{ tasks.length }}</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>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="t in tasks" :key="t.id">
<td class="cell-mono">#{{ t.id }}</td>
<td>
<span class="agent-avatar" :style="{background: $avatar(t.agent_name).bg}">{{ $avatar(t.agent_name).letter }}</span>
<span class="cell-badge">{{ t.agent_name }}</span>
</td>
<td class="cell-name">{{ t.name }}</td>
<td><code class="cell-code" :title="t.cron_expression">{{ $cron(t.cron_expression) }}</code></td>
<td>
<span v-for="tag in (t.tags || [])" :key="tag" class="tag-pill">{{ tag }}</span>
<span v-if="!t.tags || t.tags.length === 0" class="cell-muted">—</span>
</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.utc(t.last_run_at).local().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="cell-link-btn" style="color:var(--accent-1);margin-left:6px" @click="showEdit(t)">编辑</button>
<button class="cell-link-btn" style="color:var(--danger);margin-left:4px" @click="handleDelete(t)">删除</button>
</td>
</tr>
<tr v-if="tasks.length === 0">
<td colspan="10" class="cell-empty">暂无任务 — Agent 注册后会自动出现在这里</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Edit Task Modal -->
<div v-if="editTask" class="modal-overlay" @click.self="editTask = null">
<div class="modal modal-wide">
<div class="modal-header">
<h4>编辑任务</h4>
<button class="modal-close" @click="editTask = null">
<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="form-fields">
<div class="field">
<label>任务名称</label>
<input v-model="editForm.name" class="input" />
</div>
<div class="field">
<label>标签(回车添加)</label>
<div class="tags-edit">
<span v-for="(tag, i) in editForm.tags" :key="i" class="tag-pill">
{{ tag }}
<button class="tag-remove" @click="editForm.tags.splice(i, 1)">
<svg width="12" height="12" 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>
</span>
<input v-model="tagInput" class="tag-input" placeholder="输入标签后回车" @keydown.enter.prevent="addTag" @keydown.,.prevent="addTag" />
</div>
</div>
<div class="field">
<label>描述</label>
<input v-model="editForm.description" class="input" placeholder="任务描述(选填)" />
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn-secondary" @click="editTask = null">取消</button>
<button class="btn-primary" @click="handleEdit">保存</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import dayjs from 'dayjs'
import { listTasks, deleteTask, updateTask } from '../api/index.js'
const tasks = ref([])
const searchQ = ref('')
const searchTags = ref('')
const searchStatus = ref('')
const loadData = async () => {
const params = {}
if (searchQ.value) params.q = searchQ.value
if (searchTags.value) params.tags = searchTags.value
if (searchStatus.value) params.status = searchStatus.value
const res = await listTasks(params)
tasks.value = res.data
}
// Edit
const editTask = ref(null)
const editForm = ref({ name: '', description: '', tags: [] })
const tagInput = ref('')
const addTag = () => {
const val = tagInput.value.replace(/,/g, '').trim()
if (val && !editForm.value.tags.includes(val)) editForm.value.tags.push(val)
tagInput.value = ''
}
const showEdit = (t) => {
editForm.value = {
name: t.name,
description: t.description || '',
tags: [...(t.tags || [])],
}
editTask.value = t
}
const handleEdit = async () => {
try {
await updateTask(editTask.value.id, editForm.value)
editTask.value = null
await loadData()
const { ElMessage } = await import('element-plus')
ElMessage.success('已保存')
} catch (e) { console.error(e) }
}
const handleDelete = async (row) => {
try {
await deleteTask(row.id)
await loadData()
} catch (e) { console.error(e) }
}
onMounted(loadData)
</script>
<style scoped>
.tasks-page {}
.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; }
/* Search */
.search-bar {
display: flex; gap: 10px; padding: 12px 20px;
align-items: center;
}
.search-input-wrap {
display: flex; align-items: center; gap: 8px; flex: 1;
padding: 0 12px;
border-radius: 8px;
border: 1px solid var(--border-color);
background: rgba(255,255,255,0.04);
color: var(--text-muted);
}
.search-input-wrap svg { flex-shrink: 0; }
.search-input {
flex: 1;
padding: 8px 0;
background: none;
border: none;
outline: none;
color: var(--text-primary);
font-size: 13px;
}
.search-input::placeholder { color: var(--text-muted); }
/* 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-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; }
.cell-link:hover { text-decoration: underline; }
.cell-link-btn { background: none; border: none; font-size: 12px; 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-gray { background: rgba(255,255,255,0.05); color: var(--text-secondary); }
/* Tag Pills */
.tag-pill {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
background: rgba(102,126,234,0.12);
color: #a8b4ff;
margin-right: 4px;
margin-bottom: 2px;
white-space: nowrap;
}
/* 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: 480px; max-width: 90vw;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 14px;
overflow: hidden;
}
.modal-wide { width: 560px; }
.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; }
.modal-footer {
display: flex; justify-content: flex-end; gap: 8px;
padding: 16px 20px; border-top: 1px solid var(--border-color);
}
.form-fields { display: flex; flex-direction: column; gap: 16px; }
.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;
}
.input:focus { border-color: var(--accent-1); }
select.input { cursor: pointer; }
.tags-edit {
display: flex; flex-wrap: wrap; gap: 4px;
padding: 6px 8px;
border-radius: 8px;
border: 1px solid var(--border-color);
background: rgba(255,255,255,0.04);
min-height: 36px;
align-items: center;
}
.tag-remove { background: none; border: none; color: inherit; cursor: pointer; padding: 0; margin-left: 2px; opacity: 0.6; }
.tag-remove:hover { opacity: 1; }
.tag-input {
flex: 1; min-width: 100px;
background: none; border: none; outline: none;
color: var(--text-primary);
font-size: 12px;
}
.tag-input::placeholder { color: var(--text-muted); }
.btn-primary { 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); }
</style>