You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
378 lines
8.4 KiB
378 lines
8.4 KiB
<script setup lang="ts">
|
|
interface TaskRow {
|
|
id: string
|
|
name: string
|
|
cronExpression: string
|
|
type: string
|
|
enabled: number
|
|
}
|
|
|
|
const { data, refresh } = await useHttpFetch("/api/scheduler/tasks")
|
|
const stats = await useHttpFetch("/api/scheduler/stats")
|
|
|
|
const taskList = computed<TaskRow[]>(() => (data.value as any)?.list ?? [])
|
|
const registeredFunctions = computed<string[]>(() => (data.value as any)?.registeredFunctions ?? [])
|
|
const statsData = computed(() => (stats.data.value ?? {}) as {
|
|
totalTasks: number
|
|
enabledTasks: number
|
|
activeJobs: number
|
|
last24hExecutions: number
|
|
})
|
|
|
|
const showCreateModal = ref(false)
|
|
const editingTask = ref<TaskRow | null>(null)
|
|
|
|
function statusBadge(enabled: number) {
|
|
return enabled
|
|
? { label: "运行中", class: "badge-active" }
|
|
: { label: "已暂停", class: "badge-paused" }
|
|
}
|
|
|
|
async function handleDelete(id: string) {
|
|
if (!confirm("确定删除此任务?")) return
|
|
await $fetch(`/api/scheduler/tasks/${id}`, { method: "DELETE" })
|
|
refresh()
|
|
}
|
|
|
|
async function handleToggle(id: string, enabled: boolean) {
|
|
await $fetch(`/api/scheduler/tasks/${id}/toggle`, {
|
|
method: "POST",
|
|
body: { enabled },
|
|
})
|
|
refresh()
|
|
}
|
|
|
|
async function handleTrigger(id: string) {
|
|
await $fetch(`/api/scheduler/tasks/${id}/trigger`, { method: "POST" })
|
|
}
|
|
|
|
function openEdit(task: any) {
|
|
editingTask.value = task
|
|
showCreateModal.value = true
|
|
}
|
|
|
|
function openCreate() {
|
|
editingTask.value = null
|
|
showCreateModal.value = true
|
|
}
|
|
|
|
function onModalClose() {
|
|
showCreateModal.value = false
|
|
editingTask.value = null
|
|
refresh()
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="scheduler-page">
|
|
<header class="page-header">
|
|
<div class="page-header-content">
|
|
<h1 class="page-title">定时任务</h1>
|
|
<p class="page-subtitle">管理和监控定时执行的后台任务</p>
|
|
</div>
|
|
<button class="btn-primary" @click="openCreate">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<line x1="12" y1="5" x2="12" y2="19" stroke-linecap="round" />
|
|
<line x1="5" y1="12" x2="19" y2="12" stroke-linecap="round" />
|
|
</svg>
|
|
创建任务
|
|
</button>
|
|
</header>
|
|
|
|
<!-- Stats bar -->
|
|
<div class="stats-grid">
|
|
<div class="stat-card">
|
|
<div class="stat-label">总数</div>
|
|
<div class="stat-value">{{ statsData.totalTasks ?? 0 }}</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">运行中</div>
|
|
<div class="stat-value">{{ statsData.enabledTasks ?? 0 }}</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">执行中</div>
|
|
<div class="stat-value">{{ statsData.activeJobs ?? 0 }}</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">24小时执行</div>
|
|
<div class="stat-value">{{ statsData.last24hExecutions ?? 0 }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Task table -->
|
|
<div class="table-card">
|
|
<table class="data-table">
|
|
<thead>
|
|
<tr>
|
|
<th class="text-left">名称</th>
|
|
<th class="text-left">Cron</th>
|
|
<th class="text-left">类型</th>
|
|
<th class="text-left">状态</th>
|
|
<th class="text-right">操作</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-if="taskList.length === 0">
|
|
<td colspan="5" class="text-center">暂无任务</td>
|
|
</tr>
|
|
<tr v-for="t in taskList" :key="t.id" class="table-row">
|
|
<td class="name-cell">{{ t.name }}</td>
|
|
<td class="cron-cell">{{ t.cronExpression }}</td>
|
|
<td class="type-cell">
|
|
<span :class="['type-badge', t.type === 'function' ? 'type-function' : 'type-http']">
|
|
{{ t.type }}
|
|
</span>
|
|
</td>
|
|
<td class="status-cell">
|
|
<span :class="['status-badge', statusBadge(t.enabled).class]">
|
|
{{ statusBadge(t.enabled).label }}
|
|
</span>
|
|
</td>
|
|
<td class="actions-cell">
|
|
<div class="action-buttons">
|
|
<button class="action-btn" @click="handleTrigger(t.id)">触发</button>
|
|
<button class="action-btn" @click="handleToggle(t.id, !t.enabled)">
|
|
{{ t.enabled ? '暂停' : '恢复' }}
|
|
</button>
|
|
<NuxtLink :to="`/admin/scheduler/${t.id}`" class="action-btn">详情</NuxtLink>
|
|
<button class="action-btn" @click="openEdit(t)">编辑</button>
|
|
<button class="action-btn action-btn-danger" @click="handleDelete(t.id)">删除</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Create/Edit Modal -->
|
|
<SchedulerTaskModal
|
|
v-if="showCreateModal"
|
|
:task="editingTask"
|
|
:registered-functions="registeredFunctions"
|
|
@close="onModalClose"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.scheduler-page {
|
|
padding: 40px;
|
|
}
|
|
|
|
.page-header {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: space-between;
|
|
margin-bottom: 32px;
|
|
}
|
|
|
|
.page-title {
|
|
font-family: var(--font-display);
|
|
font-size: 32px;
|
|
font-weight: 400;
|
|
color: var(--color-ink);
|
|
letter-spacing: -0.3px;
|
|
margin: 0 0 6px 0;
|
|
}
|
|
|
|
.page-subtitle {
|
|
font-size: 15px;
|
|
color: var(--color-muted);
|
|
margin: 0;
|
|
}
|
|
|
|
.btn-primary {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 10px 18px;
|
|
background: var(--color-primary);
|
|
color: var(--color-on-primary);
|
|
border: none;
|
|
border-radius: 8px;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
transition: background 0.15s ease;
|
|
}
|
|
|
|
.btn-primary:hover {
|
|
background: var(--color-primary-active);
|
|
}
|
|
|
|
.btn-primary svg {
|
|
width: 16px;
|
|
height: 16px;
|
|
}
|
|
|
|
.stats-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, 1fr);
|
|
gap: 16px;
|
|
margin-bottom: 32px;
|
|
}
|
|
|
|
.stat-card {
|
|
background: var(--color-surface-card);
|
|
border-radius: 12px;
|
|
padding: 20px;
|
|
}
|
|
|
|
.stat-label {
|
|
font-size: 13px;
|
|
color: var(--color-muted);
|
|
font-weight: 500;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.stat-value {
|
|
font-family: var(--font-display);
|
|
font-size: 28px;
|
|
font-weight: 400;
|
|
color: var(--color-ink);
|
|
line-height: 1;
|
|
}
|
|
|
|
.table-card {
|
|
background: var(--color-surface-card);
|
|
border-radius: 12px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.data-table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
}
|
|
|
|
.data-table th {
|
|
padding: 14px 16px;
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
color: var(--color-muted);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
background: var(--color-surface-soft);
|
|
border-bottom: 1px solid var(--color-hairline);
|
|
text-align: left;
|
|
}
|
|
|
|
.data-table td {
|
|
padding: 14px 16px;
|
|
font-size: 14px;
|
|
color: var(--color-body-strong);
|
|
border-bottom: 1px solid var(--color-hairline);
|
|
}
|
|
|
|
.table-row:last-child td {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.table-row:hover td {
|
|
background: var(--color-surface-soft);
|
|
}
|
|
|
|
.name-cell {
|
|
font-weight: 500;
|
|
color: var(--color-ink);
|
|
}
|
|
|
|
.cron-cell {
|
|
font-family: 'JetBrains Mono', monospace;
|
|
font-size: 13px;
|
|
color: var(--color-muted);
|
|
}
|
|
|
|
.type-badge {
|
|
display: inline-block;
|
|
padding: 3px 10px;
|
|
border-radius: 9999px;
|
|
font-size: 12px;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.type-function {
|
|
background: rgba(93, 184, 166, 0.15);
|
|
color: var(--color-accent-teal);
|
|
}
|
|
|
|
.type-http {
|
|
background: rgba(232, 165, 90, 0.15);
|
|
color: var(--color-accent-amber);
|
|
}
|
|
|
|
.status-badge {
|
|
display: inline-block;
|
|
padding: 3px 10px;
|
|
border-radius: 9999px;
|
|
font-size: 12px;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.badge-active {
|
|
background: rgba(93, 184, 166, 0.15);
|
|
color: var(--color-accent-teal);
|
|
}
|
|
|
|
.badge-paused {
|
|
background: var(--color-hairline);
|
|
color: var(--color-muted);
|
|
}
|
|
|
|
.actions-cell {
|
|
text-align: right;
|
|
}
|
|
|
|
.action-buttons {
|
|
display: flex;
|
|
gap: 4px;
|
|
justify-content: flex-end;
|
|
}
|
|
|
|
.action-btn {
|
|
padding: 6px 12px;
|
|
font-size: 12px;
|
|
font-weight: 500;
|
|
color: var(--color-muted);
|
|
background: transparent;
|
|
border: none;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
text-decoration: none;
|
|
transition: all 0.15s ease;
|
|
}
|
|
|
|
.action-btn:hover {
|
|
background: var(--color-hairline);
|
|
color: var(--color-ink);
|
|
}
|
|
|
|
.action-btn-danger:hover {
|
|
background: rgba(198, 69, 69, 0.1);
|
|
color: #c64545;
|
|
}
|
|
|
|
@media (max-width: 1024px) {
|
|
.stats-grid {
|
|
grid-template-columns: repeat(2, 1fr);
|
|
}
|
|
}
|
|
|
|
@media (max-width: 640px) {
|
|
.scheduler-page {
|
|
padding: 24px;
|
|
}
|
|
|
|
.page-header {
|
|
flex-direction: column;
|
|
gap: 16px;
|
|
}
|
|
|
|
.stats-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.action-buttons {
|
|
flex-wrap: wrap;
|
|
}
|
|
}
|
|
</style>
|