feat(admin): migrate admin UI to Vue3
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
<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'
|
||||
|
||||
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 map = {
|
||||
manual: { label: '手动', type: 'success' },
|
||||
scheduled: { label: '定时', type: 'primary' },
|
||||
immediate: { label: '即时', type: 'warning' },
|
||||
resumed: { label: '恢复', type: 'info' },
|
||||
}
|
||||
return map[source] || { label: source || '手动', type: 'info' }
|
||||
}
|
||||
|
||||
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="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="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :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>
|
||||
|
||||
Reference in New Issue
Block a user