591 lines
13 KiB
Vue
591 lines
13 KiB
Vue
<script setup>
|
||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||
import { useCronStore } from '@/stores/cron'
|
||
import { useI18n } from '@/composables/useI18n'
|
||
|
||
const { t, locale } = useI18n()
|
||
const cronStore = useCronStore()
|
||
|
||
const showModal = ref(false)
|
||
const editingTask = ref(null)
|
||
const form = ref({
|
||
name: '',
|
||
cron: '',
|
||
url: '',
|
||
method: 'GET',
|
||
headers: '',
|
||
body: '',
|
||
enabled: true
|
||
})
|
||
|
||
let schedulerTimer = null
|
||
|
||
onMounted(() => {
|
||
cronStore.loadTasks()
|
||
startScheduler()
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
stopScheduler()
|
||
})
|
||
|
||
function openModal(task = null) {
|
||
if (task) {
|
||
editingTask.value = task.id
|
||
form.value = { ...task }
|
||
} else {
|
||
editingTask.value = null
|
||
form.value = {
|
||
name: '',
|
||
cron: '',
|
||
url: '',
|
||
method: 'GET',
|
||
headers: '',
|
||
body: '',
|
||
enabled: true
|
||
}
|
||
}
|
||
showModal.value = true
|
||
}
|
||
|
||
function closeModal() {
|
||
showModal.value = false
|
||
editingTask.value = null
|
||
}
|
||
|
||
async function saveTask() {
|
||
if (!form.value.name || !form.value.cron || !form.value.url) {
|
||
alert('请填写必填项:任务名称、Cron表达式、请求URL')
|
||
return
|
||
}
|
||
|
||
if (!validateCron(form.value.cron)) {
|
||
alert('Cron表达式格式不正确')
|
||
return
|
||
}
|
||
|
||
if (editingTask.value) {
|
||
await cronStore.updateTask(editingTask.value, form.value)
|
||
} else {
|
||
await cronStore.createTask(form.value)
|
||
}
|
||
|
||
closeModal()
|
||
}
|
||
|
||
async function deleteTask(id) {
|
||
if (confirm('确定要删除这个任务吗?')) {
|
||
await cronStore.deleteTask(id)
|
||
}
|
||
}
|
||
|
||
async function toggleTask(id) {
|
||
await cronStore.toggleTask(id)
|
||
}
|
||
|
||
function validateCron(cron) {
|
||
const parts = cron.trim().split(/\s+/)
|
||
if (parts.length < 5 || parts.length > 6) return false
|
||
return parts.every(part => /^[^\s]+$/.test(part) && part !== '')
|
||
}
|
||
|
||
function formatTime(date) {
|
||
if (!date) return '-'
|
||
return new Date(date).toLocaleString(locale.value === 'en' ? 'en-US' : 'zh-CN')
|
||
}
|
||
|
||
async function executeTask(task) {
|
||
await cronStore.executeTask(task.id)
|
||
}
|
||
|
||
function startScheduler() {
|
||
schedulerTimer = setInterval(() => {
|
||
const now = new Date()
|
||
cronStore.tasks.forEach(task => {
|
||
if (!task.enabled || !task.nextRun) return
|
||
|
||
const nextRun = new Date(task.nextRun)
|
||
if (now >= nextRun) {
|
||
cronStore.executeTask(task.id)
|
||
}
|
||
})
|
||
}, 10000)
|
||
}
|
||
|
||
function stopScheduler() {
|
||
if (schedulerTimer) {
|
||
clearInterval(schedulerTimer)
|
||
schedulerTimer = null
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="cron-page">
|
||
<div class="cron-header">
|
||
<h1>{{ t('cron.title') }}</h1>
|
||
<button class="btn-primary" @click="openModal()">+ {{ t('cron.newTask') }}</button>
|
||
</div>
|
||
|
||
<div class="cron-tips">
|
||
<h3>{{ t('cron.usage') }}</h3>
|
||
<ul>
|
||
<li>{{ t('cron.cronFormat') }}</li>
|
||
<li>{{ t('cron.supportedSymbols') }}</li>
|
||
<li>{{ t('cron.headersFormat') }}</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div class="task-list">
|
||
<div v-if="cronStore.tasks.length === 0" class="empty-state">
|
||
<p>{{ t('cron.empty') }}</p>
|
||
</div>
|
||
|
||
<div v-for="task in cronStore.tasks" :key="task.id" class="task-card">
|
||
<div class="task-main">
|
||
<div class="task-info">
|
||
<div class="task-name">
|
||
{{ task.name }}
|
||
<span v-if="!task.enabled" class="badge-disabled">{{ t('cron.disabled') }}</span>
|
||
<span v-if="cronStore.taskStatus[task.id] === 'running'" class="badge-running">{{ t('cron.running') }}</span>
|
||
<span v-else-if="cronStore.taskStatus[task.id] === 'success'" class="badge-success">{{ t('cron.success') }}</span>
|
||
<span v-else-if="cronStore.taskStatus[task.id] === 'error'" class="badge-error">{{ t('cron.error') }}</span>
|
||
</div>
|
||
<div class="task-cron">{{ task.cron }}</div>
|
||
<div class="task-request">
|
||
<span class="method-badge" :class="'method-' + task.method.toLowerCase()">{{ task.method }}</span>
|
||
{{ task.url }}
|
||
</div>
|
||
</div>
|
||
<div class="task-stats">
|
||
<div class="stat-item">
|
||
<span class="stat-label">{{ t('cron.nextRun') }}</span>
|
||
<span class="stat-value">{{ task.nextRun || '-' }}</span>
|
||
</div>
|
||
<div class="stat-item">
|
||
<span class="stat-label">{{ t('cron.lastRun') }}</span>
|
||
<span class="stat-value">{{ task.lastRun || '-' }}</span>
|
||
</div>
|
||
<div class="stat-item">
|
||
<span class="stat-label">{{ t('cron.runCount') }}</span>
|
||
<span class="stat-value">{{ task.runCount || 0 }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="task-actions">
|
||
<button class="btn-action" @click="executeTask(task)" :disabled="cronStore.taskStatus[task.id] === 'running'">
|
||
{{ cronStore.taskStatus[task.id] === 'running' ? t('cron.executing') : t('cron.runNow') }}
|
||
</button>
|
||
<button class="btn-action" @click="toggleTask(task.id)">
|
||
{{ task.enabled ? t('cron.disable') : t('cron.enable') }}
|
||
</button>
|
||
<button class="btn-action" @click="openModal(task)">{{ t('cron.edit') }}</button>
|
||
<button class="btn-action btn-danger" @click="deleteTask(task.id)">{{ t('cron.delete') }}</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
|
||
<div class="modal">
|
||
<div class="modal-header">
|
||
<h2>{{ editingTask ? t('cron.editTask') : t('cron.newTask') }}</h2>
|
||
<button class="btn-close" @click="closeModal">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="form-group">
|
||
<label>{{ t('cron.taskName') }} *</label>
|
||
<input v-model="form.name" type="text" :placeholder="t('cron.taskNamePlaceholder')" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>{{ t('cron.cronExpression') }} *</label>
|
||
<input v-model="form.cron" type="text" placeholder="*/5 * * * *" />
|
||
<span class="form-hint">{{ t('cron.cronHint') }}</span>
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label>{{ t('cron.requestMethod') }}</label>
|
||
<select v-model="form.method">
|
||
<option value="GET">GET</option>
|
||
<option value="POST">POST</option>
|
||
<option value="PUT">PUT</option>
|
||
<option value="PATCH">PATCH</option>
|
||
<option value="DELETE">DELETE</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group flex-1">
|
||
<label>{{ t('cron.requestURL') }} *</label>
|
||
<input v-model="form.url" type="text" placeholder="https://api.example.com/webhook" />
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>{{ t('cron.requestHeaders') }} (JSON)</label>
|
||
<input v-model="form.headers" type="text" placeholder='{"Content-Type": "application/json"}' />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>{{ t('cron.requestBody') }}</label>
|
||
<textarea v-model="form.body" rows="3" placeholder='{"key": "value"}'></textarea>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="checkbox-label">
|
||
<input v-model="form.enabled" type="checkbox" />
|
||
{{ t('cron.enableTask') }}
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn-secondary" @click="closeModal">{{ t('cron.cancel') }}</button>
|
||
<button class="btn-primary" @click="saveTask">{{ t('cron.save') }}</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.cron-page {
|
||
max-width: 1000px;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
.cron-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 2rem;
|
||
padding-bottom: 1rem;
|
||
border-bottom: 1px solid var(--color-border);
|
||
}
|
||
|
||
.cron-header h1 {
|
||
font-size: 1.8rem;
|
||
}
|
||
|
||
.btn-primary {
|
||
background: var(--color-primary);
|
||
color: white;
|
||
border: none;
|
||
padding: 0.6rem 1.2rem;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-size: 1rem;
|
||
}
|
||
|
||
.btn-primary:hover {
|
||
opacity: 0.9;
|
||
}
|
||
|
||
.cron-tips {
|
||
background: var(--color-background);
|
||
border: 1px solid var(--color-border);
|
||
border-radius: 8px;
|
||
padding: 1rem 1.5rem;
|
||
margin-bottom: 2rem;
|
||
}
|
||
|
||
.cron-tips h3 {
|
||
margin-bottom: 0.5rem;
|
||
font-size: 1rem;
|
||
}
|
||
|
||
.cron-tips ul {
|
||
list-style: none;
|
||
padding: 0;
|
||
margin: 0;
|
||
}
|
||
|
||
.cron-tips li {
|
||
line-height: 1.8;
|
||
color: var(--color-text-secondary);
|
||
}
|
||
|
||
.cron-tips code {
|
||
background: var(--color-border);
|
||
padding: 0.1rem 0.4rem;
|
||
border-radius: 3px;
|
||
font-size: 0.9em;
|
||
}
|
||
|
||
.task-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 1rem;
|
||
}
|
||
|
||
.empty-state {
|
||
text-align: center;
|
||
padding: 3rem;
|
||
color: var(--color-text-secondary);
|
||
background: var(--color-background);
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.task-card {
|
||
background: var(--color-background);
|
||
border: 1px solid var(--color-border);
|
||
border-radius: 8px;
|
||
padding: 1.5rem;
|
||
}
|
||
|
||
.task-main {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 2rem;
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.task-info {
|
||
flex: 1;
|
||
}
|
||
|
||
.task-name {
|
||
font-size: 1.2rem;
|
||
font-weight: bold;
|
||
margin-bottom: 0.5rem;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.badge-disabled {
|
||
background: #999;
|
||
color: white;
|
||
padding: 0.1rem 0.5rem;
|
||
border-radius: 3px;
|
||
font-size: 0.75rem;
|
||
font-weight: normal;
|
||
}
|
||
|
||
.badge-running {
|
||
background: #f59e0b;
|
||
color: white;
|
||
padding: 0.1rem 0.5rem;
|
||
border-radius: 3px;
|
||
font-size: 0.75rem;
|
||
font-weight: normal;
|
||
}
|
||
|
||
.badge-success {
|
||
background: #22c55e;
|
||
color: white;
|
||
padding: 0.1rem 0.5rem;
|
||
border-radius: 3px;
|
||
font-size: 0.75rem;
|
||
font-weight: normal;
|
||
}
|
||
|
||
.badge-error {
|
||
background: #ef4444;
|
||
color: white;
|
||
padding: 0.1rem 0.5rem;
|
||
border-radius: 3px;
|
||
font-size: 0.75rem;
|
||
font-weight: normal;
|
||
}
|
||
|
||
.task-cron {
|
||
font-family: monospace;
|
||
color: var(--color-primary);
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
|
||
.task-request {
|
||
color: var(--color-text-secondary);
|
||
font-size: 0.9rem;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.method-badge {
|
||
padding: 0.1rem 0.4rem;
|
||
border-radius: 3px;
|
||
font-size: 0.75rem;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.method-get { background: #22c55e; color: white; }
|
||
.method-post { background: #3b82f6; color: white; }
|
||
.method-put { background: #f59e0b; color: white; }
|
||
.method-patch { background: #8b5cf6; color: white; }
|
||
.method-delete { background: #ef4444; color: white; }
|
||
|
||
.task-stats {
|
||
display: flex;
|
||
gap: 2rem;
|
||
text-align: center;
|
||
}
|
||
|
||
.stat-item {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.25rem;
|
||
}
|
||
|
||
.stat-label {
|
||
font-size: 0.75rem;
|
||
color: var(--color-text-secondary);
|
||
}
|
||
|
||
.stat-value {
|
||
font-size: 0.9rem;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.task-actions {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
padding-top: 1rem;
|
||
border-top: 1px solid var(--color-border);
|
||
}
|
||
|
||
.btn-action {
|
||
background: var(--color-border);
|
||
border: none;
|
||
padding: 0.4rem 0.8rem;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-size: 0.85rem;
|
||
}
|
||
|
||
.btn-action:hover {
|
||
background: #ccc;
|
||
}
|
||
|
||
.btn-action:disabled {
|
||
opacity: 0.6;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.btn-danger {
|
||
background: #fee2e2;
|
||
color: #dc2626;
|
||
}
|
||
|
||
.btn-danger:hover {
|
||
background: #fecaca;
|
||
}
|
||
|
||
.modal-overlay {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background: rgba(0, 0, 0, 0.5);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 1000;
|
||
}
|
||
|
||
.modal {
|
||
background: var(--color-background);
|
||
border-radius: 8px;
|
||
width: 90%;
|
||
max-width: 500px;
|
||
max-height: 90vh;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.modal-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 1rem 1.5rem;
|
||
border-bottom: 1px solid var(--color-border);
|
||
}
|
||
|
||
.modal-header h2 {
|
||
font-size: 1.2rem;
|
||
margin: 0;
|
||
}
|
||
|
||
.btn-close {
|
||
background: none;
|
||
border: none;
|
||
font-size: 1.5rem;
|
||
cursor: pointer;
|
||
color: var(--color-text-secondary);
|
||
}
|
||
|
||
.modal-body {
|
||
padding: 1.5rem;
|
||
}
|
||
|
||
.form-group {
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.form-group label {
|
||
display: block;
|
||
margin-bottom: 0.3rem;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.form-group input,
|
||
.form-group select,
|
||
.form-group textarea {
|
||
width: 100%;
|
||
padding: 0.5rem;
|
||
border: 1px solid var(--color-border);
|
||
border-radius: 4px;
|
||
background: var(--color-background);
|
||
color: var(--color-text);
|
||
font-size: 1rem;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.form-group input:focus,
|
||
.form-group select:focus,
|
||
.form-group textarea:focus {
|
||
outline: none;
|
||
border-color: var(--color-primary);
|
||
}
|
||
|
||
.form-hint {
|
||
font-size: 0.8rem;
|
||
color: var(--color-text-secondary);
|
||
margin-top: 0.25rem;
|
||
display: block;
|
||
}
|
||
|
||
.form-row {
|
||
display: flex;
|
||
gap: 1rem;
|
||
}
|
||
|
||
.flex-1 {
|
||
flex: 1;
|
||
}
|
||
|
||
.checkbox-label {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.checkbox-label input {
|
||
width: auto;
|
||
}
|
||
|
||
.modal-footer {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 0.5rem;
|
||
padding: 1rem 1.5rem;
|
||
border-top: 1px solid var(--color-border);
|
||
}
|
||
|
||
.btn-secondary {
|
||
background: var(--color-border);
|
||
border: none;
|
||
padding: 0.5rem 1rem;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.btn-secondary:hover {
|
||
background: #ccc;
|
||
}
|
||
</style> |