feat: add ops scheduler control center
This commit is contained in:
@@ -49,6 +49,7 @@
|
||||
import {
|
||||
AuditOutlined,
|
||||
BookOutlined,
|
||||
ClockCircleOutlined,
|
||||
ControlOutlined,
|
||||
CrownOutlined,
|
||||
FileSearchOutlined,
|
||||
@@ -89,6 +90,7 @@ const menuLeaves: MenuLeaf[] = [
|
||||
{ key: '/compliance/stats', label: '合规统计', path: '/compliance/stats' },
|
||||
{ key: '/accounts', label: '操作员管理', path: '/accounts' },
|
||||
{ key: '/jobs', label: '任务中心', path: '/jobs' },
|
||||
{ key: '/scheduler', label: '调度中心', path: '/scheduler' },
|
||||
{ key: '/audits', label: '审计日志', path: '/audits' },
|
||||
]
|
||||
|
||||
@@ -155,6 +157,11 @@ const menuItems = computed<ItemType[]>(() => [
|
||||
label: '任务中心',
|
||||
icon: () => h(PartitionOutlined),
|
||||
},
|
||||
{
|
||||
key: '/scheduler',
|
||||
label: '调度中心',
|
||||
icon: () => h(ClockCircleOutlined),
|
||||
},
|
||||
{
|
||||
key: '/audits',
|
||||
label: '审计日志',
|
||||
|
||||
@@ -49,6 +49,12 @@ export const router = createRouter({
|
||||
component: () => import('@/views/JobsView.vue'),
|
||||
meta: { title: '任务中心' },
|
||||
},
|
||||
{
|
||||
path: 'scheduler',
|
||||
name: 'scheduler',
|
||||
component: () => import('@/views/SchedulerView.vue'),
|
||||
meta: { title: '调度中心' },
|
||||
},
|
||||
{
|
||||
path: 'site-domain-mappings',
|
||||
name: 'site-domain-mappings',
|
||||
|
||||
@@ -0,0 +1,731 @@
|
||||
<template>
|
||||
<div class="scheduler-page">
|
||||
<div class="scheduler-header">
|
||||
<div>
|
||||
<h2 class="ops-page-title">调度中心</h2>
|
||||
<div class="scheduler-header__meta">系统周期任务启停、配置、手动触发与运行追踪</div>
|
||||
</div>
|
||||
<a-space>
|
||||
<a-button :loading="instancesLoading" @click="loadInstances">
|
||||
<template #icon><ClusterOutlined /></template>
|
||||
实例
|
||||
</a-button>
|
||||
<a-button type="primary" :loading="loading" @click="reload">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<div class="scheduler-summary">
|
||||
<div class="summary-tile">
|
||||
<span>任务总数</span>
|
||||
<strong>{{ total }}</strong>
|
||||
</div>
|
||||
<div class="summary-tile summary-tile--green">
|
||||
<span>已启用</span>
|
||||
<strong>{{ enabledCount }}</strong>
|
||||
</div>
|
||||
<div class="summary-tile summary-tile--gold">
|
||||
<span>运行中</span>
|
||||
<strong>{{ runningCount }}</strong>
|
||||
</div>
|
||||
<div class="summary-tile summary-tile--red">
|
||||
<span>最近失败</span>
|
||||
<strong>{{ failedCount }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ops-card">
|
||||
<div class="ops-toolbar scheduler-toolbar">
|
||||
<a-input
|
||||
v-model:value="filter.keyword"
|
||||
placeholder="搜索任务名 / key"
|
||||
allow-clear
|
||||
style="width: 260px"
|
||||
@press-enter="resetAndReload"
|
||||
/>
|
||||
<a-select
|
||||
v-model:value="filter.category"
|
||||
:options="categoryOptions"
|
||||
allow-clear
|
||||
placeholder="分类"
|
||||
style="width: 160px"
|
||||
@change="resetAndReload"
|
||||
/>
|
||||
<a-select
|
||||
v-model:value="filter.enabled"
|
||||
:options="enabledOptions"
|
||||
allow-clear
|
||||
placeholder="启用状态"
|
||||
style="width: 140px"
|
||||
@change="resetAndReload"
|
||||
/>
|
||||
<a-button @click="resetAndReload">查询</a-button>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="rows"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
row-key="job_key"
|
||||
:scroll="{ x: 1320 }"
|
||||
@change="onTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'job'">
|
||||
<div class="job-cell">
|
||||
<div class="job-title">
|
||||
<span>{{ record.display_name }}</span>
|
||||
<a-tag :color="categoryColor(record.category)">{{ categoryLabel(record.category) }}</a-tag>
|
||||
</div>
|
||||
<code>{{ record.job_key }}</code>
|
||||
<div class="muted">{{ record.description || '—' }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'enabled'">
|
||||
<a-switch
|
||||
:checked="record.enabled"
|
||||
:loading="savingKey === record.job_key"
|
||||
checked-children="启用"
|
||||
un-checked-children="暂停"
|
||||
@change="(checked: boolean) => toggleJob(record, checked)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'schedule'">
|
||||
<div>{{ scheduleText(record) }}</div>
|
||||
<div class="muted">超时 {{ record.timeout_seconds }}s</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'runtime'">
|
||||
<a-tag v-if="record.running_run" color="processing">运行中</a-tag>
|
||||
<a-tag v-else :color="runStatusColor(record.last_run?.status)">
|
||||
{{ runStatusLabel(record.last_run?.status) }}
|
||||
</a-tag>
|
||||
<div class="muted">
|
||||
{{ record.last_run ? formatDate(record.last_run.started_at) : '暂无运行记录' }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'stats'">
|
||||
<div class="mono-line">{{ statsSummary(record.last_run?.stats) }}</div>
|
||||
<div v-if="record.pending_triggers" class="muted">待触发 {{ record.pending_triggers }}</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<div class="table-actions-row">
|
||||
<a-tooltip title="编辑配置">
|
||||
<a-button type="text" class="action-btn action-edit" @click="openEdit(record)">
|
||||
<SettingOutlined />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip title="立即运行">
|
||||
<a-popconfirm title="确认立即运行该调度任务?" ok-text="运行" cancel-text="取消" @confirm="triggerJob(record, 'run')">
|
||||
<a-button type="text" class="action-btn action-play" :loading="triggeringKey === `${record.job_key}:run`">
|
||||
<PlayCircleOutlined />
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</a-tooltip>
|
||||
<a-tooltip title="Dry-run">
|
||||
<a-button type="text" class="action-btn action-warn" :loading="triggeringKey === `${record.job_key}:dry-run`" @click="triggerJob(record, 'dry-run')">
|
||||
<ExperimentOutlined />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip title="运行记录">
|
||||
<a-button type="text" class="action-btn" @click="openRuns(record)">
|
||||
<HistoryOutlined />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
|
||||
<a-drawer v-model:open="editOpen" width="620" :title="editTitle" :destroy-on-close="true">
|
||||
<a-form v-if="editing" layout="vertical">
|
||||
<a-form-item label="启用状态">
|
||||
<a-switch v-model:checked="editForm.enabled" checked-children="启用" un-checked-children="暂停" />
|
||||
</a-form-item>
|
||||
<a-form-item label="调度类型">
|
||||
<a-segmented v-model:value="editForm.schedule_type" :options="scheduleTypeOptions" />
|
||||
</a-form-item>
|
||||
<a-form-item v-if="editForm.schedule_type === 'cron'" label="Cron 表达式">
|
||||
<a-input v-model:value="editForm.cron_expr" placeholder="例如 0 30 2 * * *" />
|
||||
</a-form-item>
|
||||
<a-form-item label="间隔秒数">
|
||||
<a-input-number v-model:value="editForm.interval_seconds" :min="1" :max="86400" style="width: 100%" />
|
||||
</a-form-item>
|
||||
<a-form-item label="超时秒数">
|
||||
<a-input-number v-model:value="editForm.timeout_seconds" :min="1" :max="3600" style="width: 100%" />
|
||||
</a-form-item>
|
||||
<a-form-item label="批大小">
|
||||
<a-input-number v-model:value="editForm.batch_size" :min="1" :max="100000" style="width: 100%" />
|
||||
</a-form-item>
|
||||
<a-form-item label="时区">
|
||||
<a-input v-model:value="editForm.timezone" />
|
||||
</a-form-item>
|
||||
<a-form-item label="高级配置 JSON">
|
||||
<a-textarea v-model:value="editConfigText" :rows="12" spellcheck="false" class="config-editor" />
|
||||
</a-form-item>
|
||||
<div class="drawer-actions">
|
||||
<a-button @click="editOpen = false">取消</a-button>
|
||||
<a-button type="primary" :loading="savingKey === editing.job_key" @click="saveEdit">保存</a-button>
|
||||
</div>
|
||||
</a-form>
|
||||
</a-drawer>
|
||||
|
||||
<a-drawer v-model:open="runsOpen" width="820" :title="runsTitle" :destroy-on-close="true">
|
||||
<a-table
|
||||
:columns="runColumns"
|
||||
:data-source="runRows"
|
||||
:loading="runsLoading"
|
||||
:pagination="runPagination"
|
||||
row-key="id"
|
||||
:scroll="{ x: 900 }"
|
||||
@change="onRunTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="runStatusColor(record.status)">{{ runStatusLabel(record.status) }}</a-tag>
|
||||
<div class="muted">{{ record.trigger_type }}</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'time'">
|
||||
<div>{{ formatDate(record.started_at) }}</div>
|
||||
<div class="muted">{{ formatMs(record.duration_ms) }}</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'stats'">
|
||||
<pre class="run-json">{{ formatJSON(record.stats) }}</pre>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'error'">
|
||||
<span class="error-text">{{ record.error_message || '—' }}</span>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-drawer>
|
||||
|
||||
<a-drawer v-model:open="instancesOpen" width="720" title="Scheduler 实例" :destroy-on-close="true">
|
||||
<a-table :columns="instanceColumns" :data-source="instances" :loading="instancesLoading" row-key="instance_id" :pagination="false">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'instance'">
|
||||
<div class="mono-line">{{ record.instance_id }}</div>
|
||||
<div class="muted">{{ record.hostname }} · {{ record.process }}</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'seen'">
|
||||
<div>{{ formatDate(record.last_seen_at) }}</div>
|
||||
<div class="muted">启动 {{ formatDate(record.started_at) }}</div>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ClusterOutlined,
|
||||
ExperimentOutlined,
|
||||
HistoryOutlined,
|
||||
PlayCircleOutlined,
|
||||
ReloadOutlined,
|
||||
SettingOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import type { TablePaginationConfig } from 'ant-design-vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
|
||||
import { showOpsError } from '@/lib/errors'
|
||||
import { http } from '@/lib/http'
|
||||
|
||||
interface SchedulerRun {
|
||||
id: number
|
||||
job_key: string
|
||||
trigger_type: string
|
||||
status: string
|
||||
config_version: number
|
||||
stats: Record<string, unknown>
|
||||
error_message?: string | null
|
||||
started_at: string
|
||||
finished_at?: string | null
|
||||
duration_ms?: number | null
|
||||
}
|
||||
|
||||
interface SchedulerJob {
|
||||
job_key: string
|
||||
display_name: string
|
||||
category: string
|
||||
description?: string | null
|
||||
enabled: boolean
|
||||
schedule_type: string
|
||||
interval_seconds: number
|
||||
cron_expr?: string | null
|
||||
timezone: string
|
||||
timeout_seconds: number
|
||||
batch_size?: number | null
|
||||
max_concurrency: number
|
||||
config: Record<string, unknown>
|
||||
version: number
|
||||
updated_at: string
|
||||
last_run?: SchedulerRun | null
|
||||
running_run?: SchedulerRun | null
|
||||
pending_triggers: number
|
||||
}
|
||||
|
||||
interface SchedulerInstance {
|
||||
instance_id: string
|
||||
hostname: string
|
||||
process: string
|
||||
build_version?: string | null
|
||||
started_at: string
|
||||
last_seen_at: string
|
||||
metadata: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface ListResult<T> {
|
||||
items: T[]
|
||||
total: number
|
||||
page: number
|
||||
size: number
|
||||
}
|
||||
|
||||
const categoryOptions = [
|
||||
{ label: '监控', value: 'monitoring' },
|
||||
{ label: '内容', value: 'content' },
|
||||
{ label: '知识库', value: 'knowledge' },
|
||||
{ label: '生成', value: 'generation' },
|
||||
]
|
||||
|
||||
const enabledOptions = [
|
||||
{ label: '启用', value: 'true' },
|
||||
{ label: '暂停', value: 'false' },
|
||||
]
|
||||
|
||||
const scheduleTypeOptions = [
|
||||
{ label: '间隔', value: 'interval' },
|
||||
{ label: 'Cron', value: 'cron' },
|
||||
{ label: '手动', value: 'manual' },
|
||||
]
|
||||
|
||||
const columns = [
|
||||
{ title: '任务', key: 'job', width: 360, fixed: 'left' },
|
||||
{ title: '开关', key: 'enabled', width: 120 },
|
||||
{ title: '调度', key: 'schedule', width: 180 },
|
||||
{ title: '运行态', key: 'runtime', width: 190 },
|
||||
{ title: '最近结果', key: 'stats', width: 280 },
|
||||
{ title: '操作', key: 'actions', width: 172, fixed: 'right' },
|
||||
]
|
||||
|
||||
const runColumns = [
|
||||
{ title: '状态', key: 'status', width: 130 },
|
||||
{ title: '时间', key: 'time', width: 190 },
|
||||
{ title: '统计', key: 'stats', width: 360 },
|
||||
{ title: '错误', key: 'error', width: 220 },
|
||||
]
|
||||
|
||||
const instanceColumns = [
|
||||
{ title: '实例', key: 'instance' },
|
||||
{ title: '心跳', key: 'seen', width: 220 },
|
||||
]
|
||||
|
||||
const filter = reactive({
|
||||
keyword: '',
|
||||
category: undefined as string | undefined,
|
||||
enabled: undefined as string | undefined,
|
||||
})
|
||||
const rows = ref<SchedulerJob[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const size = ref(50)
|
||||
const loading = ref(false)
|
||||
const savingKey = ref('')
|
||||
const triggeringKey = ref('')
|
||||
const editOpen = ref(false)
|
||||
const editing = ref<SchedulerJob | null>(null)
|
||||
const editConfigText = ref('')
|
||||
const editForm = reactive({
|
||||
enabled: true,
|
||||
schedule_type: 'interval',
|
||||
interval_seconds: 300,
|
||||
cron_expr: '',
|
||||
timeout_seconds: 30,
|
||||
batch_size: null as number | null,
|
||||
timezone: 'Asia/Shanghai',
|
||||
})
|
||||
const runsOpen = ref(false)
|
||||
const runsJob = ref<SchedulerJob | null>(null)
|
||||
const runRows = ref<SchedulerRun[]>([])
|
||||
const runsLoading = ref(false)
|
||||
const runPage = ref(1)
|
||||
const runSize = ref(20)
|
||||
const runTotal = ref(0)
|
||||
const instancesOpen = ref(false)
|
||||
const instances = ref<SchedulerInstance[]>([])
|
||||
const instancesLoading = ref(false)
|
||||
|
||||
const pagination = computed<TablePaginationConfig>(() => ({
|
||||
current: page.value,
|
||||
pageSize: size.value,
|
||||
total: total.value,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ['20', '50', '100', '200'],
|
||||
}))
|
||||
|
||||
const runPagination = computed<TablePaginationConfig>(() => ({
|
||||
current: runPage.value,
|
||||
pageSize: runSize.value,
|
||||
total: runTotal.value,
|
||||
showSizeChanger: true,
|
||||
}))
|
||||
|
||||
const enabledCount = computed(() => rows.value.filter((row) => row.enabled).length)
|
||||
const runningCount = computed(() => rows.value.filter((row) => row.running_run).length)
|
||||
const failedCount = computed(() => rows.value.filter((row) => row.last_run?.status === 'failed').length)
|
||||
const editTitle = computed(() => (editing.value ? `编辑 · ${editing.value.display_name}` : '编辑调度任务'))
|
||||
const runsTitle = computed(() => (runsJob.value ? `运行记录 · ${runsJob.value.display_name}` : '运行记录'))
|
||||
|
||||
async function reload() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: Record<string, unknown> = { page: page.value, size: size.value }
|
||||
if (filter.keyword.trim()) params.keyword = filter.keyword.trim()
|
||||
if (filter.category) params.category = filter.category
|
||||
if (filter.enabled) params.enabled = filter.enabled
|
||||
const result = await http.get<ListResult<SchedulerJob>>('/scheduler/jobs', params)
|
||||
rows.value = result.items
|
||||
total.value = result.total
|
||||
} catch (error) {
|
||||
showOpsError(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetAndReload() {
|
||||
page.value = 1
|
||||
void reload()
|
||||
}
|
||||
|
||||
function onTableChange(pag: TablePaginationConfig) {
|
||||
page.value = pag.current ?? 1
|
||||
size.value = pag.pageSize ?? 50
|
||||
void reload()
|
||||
}
|
||||
|
||||
async function toggleJob(row: SchedulerJob, enabled: boolean) {
|
||||
savingKey.value = row.job_key
|
||||
try {
|
||||
const action = enabled ? 'enable' : 'disable'
|
||||
const updated = await http.post<SchedulerJob>(`/scheduler/jobs/${row.job_key}/${action}`)
|
||||
patchJob(updated)
|
||||
message.success(enabled ? '已启用' : '已暂停')
|
||||
} catch (error) {
|
||||
showOpsError(error)
|
||||
} finally {
|
||||
savingKey.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function openEdit(row: SchedulerJob) {
|
||||
editing.value = row
|
||||
editForm.enabled = row.enabled
|
||||
editForm.schedule_type = row.schedule_type
|
||||
editForm.interval_seconds = row.interval_seconds
|
||||
editForm.cron_expr = row.cron_expr ?? ''
|
||||
editForm.timeout_seconds = row.timeout_seconds
|
||||
editForm.batch_size = row.batch_size ?? null
|
||||
editForm.timezone = row.timezone
|
||||
editConfigText.value = JSON.stringify(row.config ?? {}, null, 2)
|
||||
editOpen.value = true
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!editing.value) return
|
||||
let config: Record<string, unknown>
|
||||
try {
|
||||
config = JSON.parse(editConfigText.value || '{}') as Record<string, unknown>
|
||||
} catch {
|
||||
message.warning('高级配置必须是合法 JSON')
|
||||
return
|
||||
}
|
||||
savingKey.value = editing.value.job_key
|
||||
try {
|
||||
const updated = await http.patch<SchedulerJob>(`/scheduler/jobs/${editing.value.job_key}`, {
|
||||
enabled: editForm.enabled,
|
||||
schedule_type: editForm.schedule_type,
|
||||
interval_seconds: editForm.interval_seconds,
|
||||
cron_expr: editForm.cron_expr,
|
||||
timeout_seconds: editForm.timeout_seconds,
|
||||
batch_size: editForm.batch_size,
|
||||
timezone: editForm.timezone,
|
||||
config,
|
||||
})
|
||||
patchJob(updated)
|
||||
editOpen.value = false
|
||||
message.success('已保存')
|
||||
} catch (error) {
|
||||
showOpsError(error)
|
||||
} finally {
|
||||
savingKey.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerJob(row: SchedulerJob, action: 'run' | 'dry-run') {
|
||||
triggeringKey.value = `${row.job_key}:${action}`
|
||||
try {
|
||||
await http.post(`/scheduler/jobs/${row.job_key}/${action}`, {
|
||||
reason: action === 'dry-run' ? 'ops console dry-run' : 'ops console manual run',
|
||||
})
|
||||
message.success(action === 'dry-run' ? '已提交 dry-run' : '已提交运行')
|
||||
await reload()
|
||||
} catch (error) {
|
||||
showOpsError(error)
|
||||
} finally {
|
||||
triggeringKey.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function openRuns(row: SchedulerJob) {
|
||||
runsJob.value = row
|
||||
runPage.value = 1
|
||||
runsOpen.value = true
|
||||
void loadRuns()
|
||||
}
|
||||
|
||||
async function loadRuns() {
|
||||
if (!runsJob.value) return
|
||||
runsLoading.value = true
|
||||
try {
|
||||
const result = await http.get<ListResult<SchedulerRun>>(`/scheduler/jobs/${runsJob.value.job_key}/runs`, {
|
||||
page: runPage.value,
|
||||
size: runSize.value,
|
||||
})
|
||||
runRows.value = result.items
|
||||
runTotal.value = result.total
|
||||
} catch (error) {
|
||||
showOpsError(error)
|
||||
} finally {
|
||||
runsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onRunTableChange(pag: TablePaginationConfig) {
|
||||
runPage.value = pag.current ?? 1
|
||||
runSize.value = pag.pageSize ?? 20
|
||||
void loadRuns()
|
||||
}
|
||||
|
||||
async function loadInstances() {
|
||||
instancesOpen.value = true
|
||||
instancesLoading.value = true
|
||||
try {
|
||||
const result = await http.get<{ items: SchedulerInstance[] }>('/scheduler/instances')
|
||||
instances.value = result.items
|
||||
} catch (error) {
|
||||
showOpsError(error)
|
||||
} finally {
|
||||
instancesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function patchJob(item: SchedulerJob) {
|
||||
const index = rows.value.findIndex((row) => row.job_key === item.job_key)
|
||||
if (index >= 0) rows.value[index] = { ...rows.value[index], ...item }
|
||||
}
|
||||
|
||||
function scheduleText(row: SchedulerJob): string {
|
||||
const runAt = row.config?.run_at_local
|
||||
if (typeof runAt === 'string' && runAt) return `每日 ${runAt}`
|
||||
if (row.schedule_type === 'manual') return '手动'
|
||||
if (row.schedule_type === 'cron') return row.cron_expr || 'Cron'
|
||||
return `每 ${formatSeconds(row.interval_seconds)}`
|
||||
}
|
||||
|
||||
function categoryLabel(value: string): string {
|
||||
return categoryOptions.find((item) => item.value === value)?.label ?? value
|
||||
}
|
||||
|
||||
function categoryColor(value: string): string {
|
||||
if (value === 'monitoring') return 'blue'
|
||||
if (value === 'generation') return 'purple'
|
||||
if (value === 'knowledge') return 'green'
|
||||
if (value === 'content') return 'geekblue'
|
||||
return 'default'
|
||||
}
|
||||
|
||||
function runStatusLabel(value?: string | null): string {
|
||||
if (!value) return '未运行'
|
||||
const labels: Record<string, string> = {
|
||||
running: '运行中',
|
||||
success: '成功',
|
||||
failed: '失败',
|
||||
skipped: '跳过',
|
||||
}
|
||||
return labels[value] ?? value
|
||||
}
|
||||
|
||||
function runStatusColor(value?: string | null): string {
|
||||
if (value === 'success') return 'green'
|
||||
if (value === 'failed') return 'red'
|
||||
if (value === 'running') return 'processing'
|
||||
if (value === 'skipped') return 'default'
|
||||
return 'default'
|
||||
}
|
||||
|
||||
function statsSummary(stats?: Record<string, unknown> | null): string {
|
||||
if (!stats) return '—'
|
||||
const pairs = Object.entries(stats)
|
||||
.filter(([key]) => ['deleted_count', 'candidate_count', 'created_task_count', 'claimed_count', 'expired_task_count', 'anomaly_count'].includes(key))
|
||||
.map(([key, value]) => `${key}=${String(value)}`)
|
||||
return pairs.length ? pairs.join(' · ') : '—'
|
||||
}
|
||||
|
||||
function formatDate(value?: string | null): string {
|
||||
if (!value) return '—'
|
||||
return dayjs(value).format('YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
|
||||
function formatSeconds(seconds: number): string {
|
||||
if (seconds < 60) return `${seconds}s`
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`
|
||||
return `${Math.floor(seconds / 3600)}h`
|
||||
}
|
||||
|
||||
function formatMs(value?: number | null): string {
|
||||
if (value == null) return '—'
|
||||
if (value < 1000) return `${value}ms`
|
||||
return `${Math.round(value / 100) / 10}s`
|
||||
}
|
||||
|
||||
function formatJSON(value: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(value ?? {}, null, 2)
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void reload()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.scheduler-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.scheduler-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.scheduler-header__meta,
|
||||
.muted {
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.scheduler-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.summary-tile {
|
||||
border: 1px solid #dbe4ef;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
padding: 16px 18px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.summary-tile strong {
|
||||
font-size: 26px;
|
||||
line-height: 1;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.summary-tile--green strong {
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.summary-tile--gold strong {
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.summary-tile--red strong {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.scheduler-toolbar {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.job-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.job-title {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.mono-line,
|
||||
.job-cell code {
|
||||
font-family: 'SFMono-Regular', Consolas, monospace;
|
||||
font-size: 12px;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.table-actions-row {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.config-editor,
|
||||
.run-json {
|
||||
font-family: 'SFMono-Regular', Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.run-json {
|
||||
max-height: 140px;
|
||||
overflow: auto;
|
||||
margin: 0;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.drawer-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.scheduler-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.scheduler-summary {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user