- 更新 LogsPage 和 StatsPage 组件 - 添加 taskSource 工具模块 - 更新 db/tasks.py - 重新构建前端静态资源
292 lines
8.7 KiB
Vue
292 lines
8.7 KiB
Vue
<script setup>
|
|
import { computed, onMounted, ref } from 'vue'
|
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
|
|
|
import { fetchAllUsers } from '../api/users'
|
|
import { clearOldTaskLogs, fetchTaskLogs } from '../api/tasks'
|
|
import { getTaskSourceMeta } from '../utils/taskSource'
|
|
|
|
const pageSize = 20
|
|
|
|
const loading = ref(false)
|
|
const logs = ref([])
|
|
const total = ref(0)
|
|
const currentPage = ref(1)
|
|
|
|
const usersLoading = ref(false)
|
|
const userOptions = ref([])
|
|
|
|
const dateFilter = ref('')
|
|
const statusFilter = ref('')
|
|
const sourceFilter = ref('')
|
|
const userIdFilter = ref('')
|
|
const accountFilter = ref('')
|
|
|
|
const totalPages = computed(() => Math.max(1, Math.ceil((total.value || 0) / pageSize)))
|
|
|
|
function formatDuration(seconds) {
|
|
if (seconds === null || seconds === undefined) return '-'
|
|
const n = Number(seconds)
|
|
if (!Number.isFinite(n)) return '-'
|
|
if (n < 60) return `${n}秒`
|
|
return `${Math.floor(n / 60)}分${n % 60}秒`
|
|
}
|
|
|
|
function sourceMeta(source) {
|
|
const meta = getTaskSourceMeta(source)
|
|
return { key: meta.group, label: meta.label, type: meta.type, tooltip: meta.tooltip }
|
|
}
|
|
|
|
function statusMeta(status) {
|
|
if (status === 'success') return { label: '成功', type: 'success' }
|
|
if (status === 'failed') return { label: '失败', type: 'danger' }
|
|
return { label: status || '-', type: 'info' }
|
|
}
|
|
|
|
async function loadUsers() {
|
|
usersLoading.value = true
|
|
try {
|
|
const users = await fetchAllUsers()
|
|
userOptions.value = (users || []).map((u) => ({ id: u.id, username: u.username }))
|
|
} catch {
|
|
userOptions.value = []
|
|
} finally {
|
|
usersLoading.value = false
|
|
}
|
|
}
|
|
|
|
async function load() {
|
|
loading.value = true
|
|
try {
|
|
const offset = (currentPage.value - 1) * pageSize
|
|
const params = {
|
|
limit: pageSize,
|
|
offset,
|
|
}
|
|
if (dateFilter.value) params.date = dateFilter.value
|
|
if (statusFilter.value) params.status = statusFilter.value
|
|
if (sourceFilter.value) params.source = sourceFilter.value
|
|
if (userIdFilter.value) params.user_id = userIdFilter.value
|
|
if (accountFilter.value) params.account = accountFilter.value
|
|
|
|
const data = await fetchTaskLogs(params)
|
|
logs.value = data?.logs || []
|
|
total.value = data?.total || 0
|
|
} catch {
|
|
logs.value = []
|
|
total.value = 0
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function onFilter() {
|
|
currentPage.value = 1
|
|
load()
|
|
}
|
|
|
|
function onReset() {
|
|
dateFilter.value = ''
|
|
statusFilter.value = ''
|
|
sourceFilter.value = ''
|
|
userIdFilter.value = ''
|
|
accountFilter.value = ''
|
|
currentPage.value = 1
|
|
load()
|
|
}
|
|
|
|
async function onClearOld() {
|
|
let days
|
|
try {
|
|
const res = await ElMessageBox.prompt('请输入要清理多少天前的日志(默认30天)', '清理旧日志', {
|
|
inputValue: '30',
|
|
confirmButtonText: '下一步',
|
|
cancelButtonText: '取消',
|
|
inputValidator: (v) => {
|
|
const n = parseInt(String(v), 10)
|
|
return Number.isFinite(n) && n >= 1
|
|
},
|
|
inputErrorMessage: '请输入有效的天数(大于0的整数)',
|
|
})
|
|
days = parseInt(String(res.value), 10)
|
|
} catch {
|
|
return
|
|
}
|
|
|
|
try {
|
|
await ElMessageBox.confirm(`确定要删除 ${days} 天前的所有日志吗?此操作不可恢复!`, '二次确认', {
|
|
confirmButtonText: '删除',
|
|
cancelButtonText: '取消',
|
|
type: 'warning',
|
|
})
|
|
} catch {
|
|
return
|
|
}
|
|
|
|
try {
|
|
const res = await clearOldTaskLogs(days)
|
|
ElMessage.success(res?.message || '清理成功')
|
|
currentPage.value = 1
|
|
await load()
|
|
} catch {
|
|
// handled by interceptor
|
|
}
|
|
}
|
|
|
|
onMounted(async () => {
|
|
await loadUsers()
|
|
await load()
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="page-stack">
|
|
<div class="app-page-title">
|
|
<h2>任务日志</h2>
|
|
<div class="toolbar">
|
|
<el-button @click="load">刷新</el-button>
|
|
</div>
|
|
</div>
|
|
|
|
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card">
|
|
<div class="filters">
|
|
<el-date-picker
|
|
v-model="dateFilter"
|
|
type="date"
|
|
value-format="YYYY-MM-DD"
|
|
placeholder="日期"
|
|
style="width: 150px"
|
|
/>
|
|
<el-select v-model="statusFilter" placeholder="状态" style="width: 120px">
|
|
<el-option label="全部" value="" />
|
|
<el-option label="成功" value="success" />
|
|
<el-option label="失败" value="failed" />
|
|
</el-select>
|
|
<el-select v-model="sourceFilter" placeholder="来源" style="width: 120px">
|
|
<el-option label="全部" value="" />
|
|
<el-option label="手动" value="manual" />
|
|
<el-option label="定时任务(系统)" value="scheduled" />
|
|
<el-option label="定时任务(用户)" value="user_scheduled" />
|
|
<el-option label="手动(批量)" value="batch" />
|
|
<el-option label="手动(截图)" value="manual_screenshot" />
|
|
<el-option label="手动(立即)" value="immediate" />
|
|
<el-option label="手动(恢复)" value="resumed" />
|
|
</el-select>
|
|
<el-select
|
|
v-model="userIdFilter"
|
|
placeholder="用户"
|
|
style="width: 140px"
|
|
:loading="usersLoading"
|
|
filterable
|
|
clearable
|
|
>
|
|
<el-option label="全部" value="" />
|
|
<el-option v-for="u in userOptions" :key="u.id" :label="u.username" :value="String(u.id)" />
|
|
</el-select>
|
|
<el-input v-model="accountFilter" placeholder="账号关键字" style="width: 170px" clearable />
|
|
<el-button type="primary" @click="onFilter">筛选</el-button>
|
|
<el-button @click="onReset">重置</el-button>
|
|
<el-button type="danger" plain @click="onClearOld">清理旧日志</el-button>
|
|
</div>
|
|
</el-card>
|
|
|
|
<el-card shadow="never" :body-style="{ padding: '16px' }" class="card">
|
|
<div class="table-wrap">
|
|
<el-table :data="logs" v-loading="loading" style="width: 100%">
|
|
<el-table-column prop="created_at" label="时间" width="180" />
|
|
<el-table-column label="来源" width="110">
|
|
<template #default="{ row }">
|
|
<el-tooltip
|
|
v-if="sourceMeta(row.source).tooltip"
|
|
:content="sourceMeta(row.source).tooltip"
|
|
placement="top"
|
|
:show-after="300"
|
|
>
|
|
<el-tag :type="sourceMeta(row.source).type" effect="light">{{ sourceMeta(row.source).label }}</el-tag>
|
|
</el-tooltip>
|
|
<el-tag v-else :type="sourceMeta(row.source).type" effect="light">{{ sourceMeta(row.source).label }}</el-tag>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column prop="user_username" label="用户" width="140" />
|
|
<el-table-column prop="username" label="账号" width="160" />
|
|
<el-table-column prop="browse_type" label="浏览类型" width="120" />
|
|
<el-table-column label="状态" width="90">
|
|
<template #default="{ row }">
|
|
<el-tag :type="statusMeta(row.status).type" effect="light">{{ statusMeta(row.status).label }}</el-tag>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column label="内容/附件" width="110">
|
|
<template #default="{ row }">{{ row.total_items }} / {{ row.total_attachments }}</template>
|
|
</el-table-column>
|
|
<el-table-column label="用时" width="90">
|
|
<template #default="{ row }">{{ formatDuration(row.duration) }}</template>
|
|
</el-table-column>
|
|
<el-table-column label="失败原因" min-width="220">
|
|
<template #default="{ row }">
|
|
<el-tooltip :content="row.error_message || ''" placement="top" :show-after="300">
|
|
<span class="ellipsis">{{ row.error_message || '-' }}</span>
|
|
</el-tooltip>
|
|
</template>
|
|
</el-table-column>
|
|
</el-table>
|
|
</div>
|
|
|
|
<div class="pagination">
|
|
<el-pagination
|
|
v-model:current-page="currentPage"
|
|
:page-size="pageSize"
|
|
:total="total"
|
|
layout="prev, pager, next, jumper, ->, total"
|
|
@current-change="load"
|
|
/>
|
|
<div class="page-hint app-muted">第 {{ currentPage }} / {{ totalPages }} 页</div>
|
|
</div>
|
|
</el-card>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.page-stack {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
}
|
|
|
|
.card {
|
|
border-radius: var(--app-radius);
|
|
border: 1px solid var(--app-border);
|
|
}
|
|
|
|
.filters {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 10px;
|
|
align-items: center;
|
|
}
|
|
|
|
.table-wrap {
|
|
overflow-x: auto;
|
|
}
|
|
|
|
.ellipsis {
|
|
display: inline-block;
|
|
max-width: 100%;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.pagination {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 10px;
|
|
margin-top: 14px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.page-hint {
|
|
font-size: 12px;
|
|
}
|
|
</style>
|