feat: 按照系统设置分类拆分日志页面(操作记录、收发信记录、运行日志)
Some checks failed
Build and Release / security (push) Has been cancelled
Security Checks / security (push) Has been cancelled
Build and Release / build (amd64, darwin) (push) Has been cancelled
Build and Release / build (amd64, linux) (push) Has been cancelled
Build and Release / build (amd64, windows) (push) Has been cancelled
Build and Release / build (arm64, darwin) (push) Has been cancelled
Build and Release / build (arm64, linux) (push) Has been cancelled
Build and Release / build (arm64, windows) (push) Has been cancelled

This commit is contained in:
smail 2026-10-19 22:58:23 +08:00
parent bbf26f91c5
commit 8cef8a9c61
11 changed files with 1150 additions and 2 deletions

View File

@ -131,6 +131,13 @@ export function useLogs() {
aborter?.abort()
})
function selectSection(section) {
if (['operation', 'mail', 'runtime'].includes(section)) {
category.value = section
}
}
return {
category, categories, loading, error, items, lines, offset,
canFilterActor,
@ -138,6 +145,6 @@ export function useLogs() {
hasMore, actorId, dateRange, detail, details, isRuntime,
runtimeDetail, runtimeRows, runtimeSummary, visibleCount, pageRange,
currentCategory, resultType, runtimeRowClass, timeParts,
formatTime, resultLabel, actionLabel, displayActor, displayObject, displayOutcome, search, recent, next, previous
formatTime, resultLabel, actionLabel, displayActor, displayObject, displayOutcome, search, recent, next, previous, selectSection
}
}

View File

@ -182,8 +182,13 @@ const {
hasMore, actorId, dateRange, detail, details, isRuntime,
runtimeDetail, runtimeRows, runtimeSummary, pageRange,
currentCategory, resultType, runtimeRowClass, timeParts,
formatTime, resultLabel, actionLabel, displayActor, displayObject, displayOutcome, search, recent, next, previous
formatTime, resultLabel, actionLabel, displayActor, displayObject, displayOutcome, search, recent, next, previous, selectSection
} = useLogs()
defineExpose({
selectSection,
category
})
</script>
<style scoped src="./LogSettings.css"></style>

View File

@ -0,0 +1,113 @@
.log-subpage-container {
display: flex;
flex-direction: column;
gap: 16px;
min-height: 100%;
}
.log-filters {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
}
.mail-scope {
width: 140px;
}
.log-filter-actions {
display: inline-flex;
gap: 8px;
margin-left: auto;
}
.log-content {
display: flex;
flex-direction: column;
gap: 12px;
min-height: 380px;
}
.log-table-heading {
display: flex;
justify-content: space-between;
align-items: center;
}
.log-table-title strong {
font-size: 15px;
color: var(--pm-text-primary, #1e293b);
}
.log-file-meta {
margin-left: 8px;
font-size: 13px;
color: var(--pm-text-secondary, #64748b);
}
.log-table {
border: 1px solid var(--pm-border-color, #e2e8f0);
border-radius: 8px;
overflow: hidden;
}
.log-time {
display: flex;
flex-direction: column;
font-size: 12px;
line-height: 1.3;
}
.log-action {
display: block;
font-size: 13px;
color: var(--pm-text-primary, #1e293b);
}
.log-message {
font-size: 12px;
color: var(--pm-text-secondary, #64748b);
word-break: break-all;
}
.log-source {
font-size: 11px;
color: var(--pm-text-muted, #94a3b8);
}
.log-level-tag {
font-size: 11px;
}
.log-pagination {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 8px;
font-size: 13px;
color: var(--pm-text-secondary, #64748b);
}
.log-page-actions {
display: inline-flex;
gap: 8px;
}
.log-detail-grid {
display: grid;
grid-template-columns: 96px 1fr;
gap: 8px 12px;
margin: 16px 0;
font-size: 13px;
}
.log-detail-grid dt {
color: var(--pm-text-secondary, #64748b);
}
.log-detail-grid dd {
margin: 0;
color: var(--pm-text-primary, #1e293b);
word-break: break-all;
}

View File

@ -0,0 +1,146 @@
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { http } from '@/utils/axios'
import { useGlobalStatusStore } from '@/stores/useGlobalStatusStore'
import { timeParts } from '../RuntimeLog.js'
export function useMailLogs() {
const store = useGlobalStatusStore()
const canFilterActor = computed(() => ['super_admin', 'admin'].includes(store.role))
const loading = ref(false)
const error = ref('')
const items = ref([])
const offset = ref(0)
const hasMore = ref(false)
const actorId = ref('')
const mailScope = ref('all')
const canFilterSystemMail = computed(() => store.role === 'super_admin')
const systemMailSelected = computed(() => canFilterSystemMail.value && mailScope.value === 'system')
const recentRange = () => {
const end = new Date()
return [new Date(end.getTime() - 24 * 60 * 60 * 1000), end]
}
const dateRange = ref(recentRange())
const detail = ref(null)
const pageSize = 10
let requestId = 0
let aborter
const previousOffsets = []
let appliedFilters = {}
const formatTime = value => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—'
const resultLabel = value => ({ success: '成功', failure: '失败', unknown: '未确认', accepted: '对端接收', pending: '待验证', started: '已开始', recorded: '状态记录', partial: '部分接收', discarded: '已丢弃', skipped: '已跳过' }[value] || value || '未记录')
const actionLabel = value => ({ update: '保存配置', rollback: '回滚配置', migration: '迁移配置' }[value] || value || '未记录操作')
const displayObject = item => item.payload?.object || item.payload?.stage || '未记录对象'
const displayOutcome = item => item.payload?.outcome || '历史记录未提供结果说明'
const displayActor = item => !item.actorId || item.actorId === '0' ? '系统' : (item.payload?.actorName || item.actorId)
const details = computed(() => detail.value?.payload || {})
const pageRange = computed(() => items.value.length ? \第 ${offset.value + 1}–${offset.value + items.value.length} 条\ : '暂无记录')
const resultType = value => ({ success: 'success', accepted: 'primary', failure: 'danger', pending: 'warning', partial: 'warning', discarded: 'warning', started: 'primary' }[value] || 'info')
function requireData(result) {
if (result?.errorNo !== 0 || !result.data) throw new Error(result?.errorMsg || '日志查询失败')
return result.data
}
async function load() {
const id = ++requestId
aborter?.abort()
aborter = new AbortController()
loading.value = true
error.value = ''
try {
const params = { ...appliedFilters, offset: offset.value, limit: pageSize }
const data = requireData(await http.get('/api/v1/logs', { params, signal: aborter.signal }))
if (id !== requestId) return
items.value = data.items || []
hasMore.value = Boolean(data.hasMore)
} catch (cause) {
if (id === requestId && cause?.code !== 'ERR_CANCELED') {
error.value = cause.message || '日志查询失败'
items.value = []
hasMore.value = false
}
} finally {
if (id === requestId) loading.value = false
}
}
function search() {
if (!dateRange.value?.length) dateRange.value = recentRange()
const [from, to] = (dateRange.value || []).map(value => new Date(value))
if (!from || !to || !Number.isFinite(from.getTime()) || !Number.isFinite(to.getTime()) || from > to) {
++requestId
aborter?.abort()
loading.value = false
error.value = '请选择有效的起止时间'
items.value = []
hasMore.value = false
return
}
appliedFilters = { kind: 'mail', from: from.toISOString(), to: to.toISOString() }
if (systemMailSelected.value) {
appliedFilters.systemOnly = true
} else if (canFilterActor.value && actorId.value.trim()) {
appliedFilters.actorId = actorId.value.trim()
}
offset.value = 0
previousOffsets.length = 0
detail.value = null
return load()
}
function recent() {
dateRange.value = recentRange()
return search()
}
function next() {
if (loading.value || !hasMore.value) return
previousOffsets.push(offset.value)
offset.value += pageSize
return load()
}
function previous() {
if (loading.value || offset.value === 0) return
offset.value = previousOffsets.pop() ?? 0
return load()
}
watch(mailScope, search)
onMounted(search)
onBeforeUnmount(() => {
++requestId
aborter?.abort()
})
return {
loading,
error,
items,
offset,
hasMore,
actorId,
canFilterActor,
mailScope,
canFilterSystemMail,
systemMailSelected,
dateRange,
detail,
details,
pageRange,
resultType,
timeParts,
formatTime,
resultLabel,
actionLabel,
displayActor,
displayObject,
displayOutcome,
search,
recent,
next,
previous
}
}

View File

@ -0,0 +1,162 @@
<template>
<div class="log-subpage-container">
<div class="log-filters">
<el-date-picker
v-model="dateRange"
type="datetimerange"
start-placeholder="开始时间"
end-placeholder="结束时间"
format="YYYY-MM-DD HH:mm"
aria-label="收发信记录时间范围"
/>
<el-select
v-if="canFilterSystemMail"
v-model="mailScope"
class="mail-scope"
aria-label="收发信记录范围"
>
<el-option label="全部收发信" value="all" />
<el-option label="系统收发信" value="system" />
</el-select>
<el-input
v-if="canFilterActor && !systemMailSelected"
v-model="actorId"
clearable
placeholder="操作者编号"
aria-label="操作者编号"
@keyup.enter="search"
/>
<div class="log-filter-actions">
<el-button :disabled="loading" @click="recent">最近 24 小时</el-button>
<el-button type="primary" :loading="loading" @click="search">查询</el-button>
</div>
</div>
<el-alert v-if="error" :title="error" type="error" :closable="false" show-icon />
<div v-loading="loading" class="log-content" :aria-busy="loading">
<div class="log-table-heading">
<div class="log-table-title">
<strong>收发信记录</strong>
<span class="log-file-meta">最新在前 · 每页 10 条</span>
</div>
</div>
<el-table
:data="items"
class="log-table"
height="100%"
row-key="id"
stripe
empty-text="暂无收发信记录"
@row-click="item => detail = item"
>
<el-table-column label="时间(本地)" width="116">
<template #default="{ row }">
<div class="log-time">
<strong>{{ timeParts(row.createdAt).time }}</strong>
<span>{{ timeParts(row.createdAt).date }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="操作与结果说明" min-width="170">
<template #default="{ row }">
<strong class="log-action">{{ actionLabel(row.action) }}</strong>
<div class="log-message">{{ displayOutcome(row) }}</div>
<div class="log-source">{{ displayObject(row) }}</div>
</template>
</el-table-column>
<el-table-column label="操作者" min-width="85" show-overflow-tooltip>
<template #default="{ row }">{{ displayActor(row) }}</template>
</el-table-column>
<el-table-column label="结果" width="82" align="center">
<template #default="{ row }">
<el-tag :type="resultType(row.result)" size="small" class="log-level-tag">
{{ resultLabel(row.result) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="详情" width="60" align="center">
<template #default="{ row }">
<el-button link type="primary" @click.stop="detail = row">查看</el-button>
</template>
</el-table-column>
</el-table>
</div>
<footer class="log-pagination">
<span aria-live="polite">{{ pageRange }}</span>
<div class="log-page-actions">
<el-button size="small" :disabled="loading || offset === 0" @click="previous">上一页</el-button>
<el-button size="small" :disabled="loading || !hasMore" @click="next">下一页</el-button>
</div>
</footer>
<el-dialog
:model-value="Boolean(detail)"
title="收发信记录详情"
width="min(760px, 92vw)"
append-to-body
@close="detail = null"
>
<template v-if="detail">
<p>{{ formatTime(detail.createdAt) }} · {{ displayActor(detail) }} · {{ actionLabel(detail.action) }}</p>
<dl class="log-detail-grid">
<dt>触发操作</dt>
<dd>{{ actionLabel(detail.action) }}</dd>
<dt>触发来源</dt>
<dd>{{ details.trigger || '未记录' }}</dd>
<dt>处理阶段</dt>
<dd>{{ displayObject(detail) }}</dd>
<dt>操作结果</dt>
<dd>{{ resultLabel(detail.result) }}</dd>
<dt>结果说明</dt>
<dd>{{ displayOutcome(detail) }}</dd>
<dt>发件人</dt>
<dd>{{ details.sender || '—' }}</dd>
<dt>收件人数</dt>
<dd>{{ details.recipientCount }}</dd>
<dt>邮件大小</dt>
<dd>{{ details.bytes }} 字节</dd>
<dt>对端接收人数</dt>
<dd>{{ details.acceptedCount ?? '未记录' }}</dd>
</dl>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { useMailLogs } from './MailLogs.js'
const {
loading,
error,
items,
offset,
hasMore,
actorId,
canFilterActor,
mailScope,
canFilterSystemMail,
systemMailSelected,
dateRange,
detail,
details,
pageRange,
resultType,
timeParts,
formatTime,
resultLabel,
actionLabel,
displayActor,
displayObject,
displayOutcome,
search,
recent,
next,
previous
} = useMailLogs()
</script>
<style scoped src="./MailLogs.css"></style>

View File

@ -0,0 +1,109 @@
.log-subpage-container {
display: flex;
flex-direction: column;
gap: 16px;
min-height: 100%;
}
.log-filters {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
}
.log-filter-actions {
display: inline-flex;
gap: 8px;
margin-left: auto;
}
.log-content {
display: flex;
flex-direction: column;
gap: 12px;
min-height: 380px;
}
.log-table-heading {
display: flex;
justify-content: space-between;
align-items: center;
}
.log-table-title strong {
font-size: 15px;
color: var(--pm-text-primary, #1e293b);
}
.log-file-meta {
margin-left: 8px;
font-size: 13px;
color: var(--pm-text-secondary, #64748b);
}
.log-table {
border: 1px solid var(--pm-border-color, #e2e8f0);
border-radius: 8px;
overflow: hidden;
}
.log-time {
display: flex;
flex-direction: column;
font-size: 12px;
line-height: 1.3;
}
.log-action {
display: block;
font-size: 13px;
color: var(--pm-text-primary, #1e293b);
}
.log-message {
font-size: 12px;
color: var(--pm-text-secondary, #64748b);
word-break: break-all;
}
.log-source {
font-size: 11px;
color: var(--pm-text-muted, #94a3b8);
}
.log-level-tag {
font-size: 11px;
}
.log-pagination {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 8px;
font-size: 13px;
color: var(--pm-text-secondary, #64748b);
}
.log-page-actions {
display: inline-flex;
gap: 8px;
}
.log-detail-grid {
display: grid;
grid-template-columns: 96px 1fr;
gap: 8px 12px;
margin: 16px 0;
font-size: 13px;
}
.log-detail-grid dt {
color: var(--pm-text-secondary, #64748b);
}
.log-detail-grid dd {
margin: 0;
color: var(--pm-text-primary, #1e293b);
word-break: break-all;
}

View File

@ -0,0 +1,136 @@
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { http } from '@/utils/axios'
import { useGlobalStatusStore } from '@/stores/useGlobalStatusStore'
import { timeParts } from '../RuntimeLog.js'
export function useOperationLogs() {
const store = useGlobalStatusStore()
const canFilterActor = computed(() => ['super_admin', 'admin'].includes(store.role))
const loading = ref(false)
const error = ref('')
const items = ref([])
const offset = ref(0)
const hasMore = ref(false)
const actorId = ref('')
const recentRange = () => {
const end = new Date()
return [new Date(end.getTime() - 24 * 60 * 60 * 1000), end]
}
const dateRange = ref(recentRange())
const detail = ref(null)
const pageSize = 10
let requestId = 0
let aborter
const previousOffsets = []
let appliedFilters = {}
const formatTime = value => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—'
const resultLabel = value => ({ success: '成功', failure: '失败', unknown: '未确认', accepted: '对端接收', pending: '待验证', started: '已开始', recorded: '状态记录', partial: '部分接收', discarded: '已丢弃', skipped: '已跳过' }[value] || value || '未记录')
const actionLabel = value => ({ update: '保存配置', rollback: '回滚配置', migration: '迁移配置' }[value] || value || '未记录操作')
const displayObject = item => item.payload?.object || item.payload?.stage || '未记录对象'
const displayOutcome = item => item.payload?.outcome || '历史记录未提供结果说明'
const displayActor = item => !item.actorId || item.actorId === '0' ? '系统' : (item.payload?.actorName || item.actorId)
const details = computed(() => detail.value?.payload || {})
const pageRange = computed(() => items.value.length ? \第 ${offset.value + 1}–${offset.value + items.value.length} 条\ : '暂无记录')
const resultType = value => ({ success: 'success', accepted: 'primary', failure: 'danger', pending: 'warning', partial: 'warning', discarded: 'warning', started: 'primary' }[value] || 'info')
function requireData(result) {
if (result?.errorNo !== 0 || !result.data) throw new Error(result?.errorMsg || '日志查询失败')
return result.data
}
async function load() {
const id = ++requestId
aborter?.abort()
aborter = new AbortController()
loading.value = true
error.value = ''
try {
const params = { ...appliedFilters, offset: offset.value, limit: pageSize }
const data = requireData(await http.get('/api/v1/logs', { params, signal: aborter.signal }))
if (id !== requestId) return
items.value = data.items || []
hasMore.value = Boolean(data.hasMore)
} catch (cause) {
if (id === requestId && cause?.code !== 'ERR_CANCELED') {
error.value = cause.message || '日志查询失败'
items.value = []
hasMore.value = false
}
} finally {
if (id === requestId) loading.value = false
}
}
function search() {
if (!dateRange.value?.length) dateRange.value = recentRange()
const [from, to] = (dateRange.value || []).map(value => new Date(value))
if (!from || !to || !Number.isFinite(from.getTime()) || !Number.isFinite(to.getTime()) || from > to) {
++requestId
aborter?.abort()
loading.value = false
error.value = '请选择有效的起止时间'
items.value = []
hasMore.value = false
return
}
appliedFilters = { kind: 'operation', from: from.toISOString(), to: to.toISOString() }
if (canFilterActor.value && actorId.value.trim()) {
appliedFilters.actorId = actorId.value.trim()
}
offset.value = 0
previousOffsets.length = 0
detail.value = null
return load()
}
function recent() {
dateRange.value = recentRange()
return search()
}
function next() {
if (loading.value || !hasMore.value) return
previousOffsets.push(offset.value)
offset.value += pageSize
return load()
}
function previous() {
if (loading.value || offset.value === 0) return
offset.value = previousOffsets.pop() ?? 0
return load()
}
onMounted(search)
onBeforeUnmount(() => {
++requestId
aborter?.abort()
})
return {
loading,
error,
items,
offset,
hasMore,
actorId,
canFilterActor,
dateRange,
detail,
details,
pageRange,
resultType,
timeParts,
formatTime,
resultLabel,
actionLabel,
displayActor,
displayObject,
displayOutcome,
search,
recent,
next,
previous
}
}

View File

@ -0,0 +1,148 @@
<template>
<div class="log-subpage-container">
<div class="log-filters">
<el-date-picker
v-model="dateRange"
type="datetimerange"
start-placeholder="开始时间"
end-placeholder="结束时间"
format="YYYY-MM-DD HH:mm"
aria-label="操作记录时间范围"
/>
<el-input
v-if="canFilterActor"
v-model="actorId"
clearable
placeholder="操作者编号"
aria-label="操作者编号"
@keyup.enter="search"
/>
<div class="log-filter-actions">
<el-button :disabled="loading" @click="recent">最近 24 小时</el-button>
<el-button type="primary" :loading="loading" @click="search">查询</el-button>
</div>
</div>
<el-alert v-if="error" :title="error" type="error" :closable="false" show-icon />
<div v-loading="loading" class="log-content" :aria-busy="loading">
<div class="log-table-heading">
<div class="log-table-title">
<strong>操作记录</strong>
<span class="log-file-meta">最新在前 · 每页 10 条</span>
</div>
</div>
<el-table
:data="items"
class="log-table"
height="100%"
row-key="id"
stripe
empty-text="暂无操作记录"
@row-click="item => detail = item"
>
<el-table-column label="时间(本地)" width="116">
<template #default="{ row }">
<div class="log-time">
<strong>{{ timeParts(row.createdAt).time }}</strong>
<span>{{ timeParts(row.createdAt).date }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="操作与结果说明" min-width="170">
<template #default="{ row }">
<strong class="log-action">{{ actionLabel(row.action) }}</strong>
<div class="log-message">{{ displayOutcome(row) }}</div>
<div class="log-source">{{ displayObject(row) }}</div>
</template>
</el-table-column>
<el-table-column label="操作者" min-width="85" show-overflow-tooltip>
<template #default="{ row }">{{ displayActor(row) }}</template>
</el-table-column>
<el-table-column label="结果" width="82" align="center">
<template #default="{ row }">
<el-tag :type="resultType(row.result)" size="small" class="log-level-tag">
{{ resultLabel(row.result) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="详情" width="60" align="center">
<template #default="{ row }">
<el-button link type="primary" @click.stop="detail = row">查看</el-button>
</template>
</el-table-column>
</el-table>
</div>
<footer class="log-pagination">
<span aria-live="polite">{{ pageRange }}</span>
<div class="log-page-actions">
<el-button size="small" :disabled="loading || offset === 0" @click="previous">上一页</el-button>
<el-button size="small" :disabled="loading || !hasMore" @click="next">下一页</el-button>
</div>
</footer>
<el-dialog
:model-value="Boolean(detail)"
title="操作记录详情"
width="min(760px, 92vw)"
append-to-body
@close="detail = null"
>
<template v-if="detail">
<p>{{ formatTime(detail.createdAt) }} · {{ displayActor(detail) }} · {{ actionLabel(detail.action) }}</p>
<dl class="log-detail-grid">
<dt>触发操作</dt>
<dd>{{ actionLabel(detail.action) }}</dd>
<dt>触发来源</dt>
<dd>{{ details.trigger || '未记录' }}</dd>
<dt>操作对象</dt>
<dd>{{ displayObject(detail) }}</dd>
<dt>操作结果</dt>
<dd>{{ resultLabel(detail.result) }}</dd>
<dt>结果说明</dt>
<dd>{{ displayOutcome(detail) }}</dd>
<dt>权限角色</dt>
<dd>{{ details.actorRole || '—' }}</dd>
<dt>结果代码</dt>
<dd>{{ details.errorCode ?? '—' }}</dd>
<dt>HTTP 状态</dt>
<dd>{{ details.httpStatus || '未记录' }}</dd>
</dl>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { useOperationLogs } from './OperationLogs.js'
const {
loading,
error,
items,
offset,
hasMore,
actorId,
canFilterActor,
dateRange,
detail,
details,
pageRange,
resultType,
timeParts,
formatTime,
resultLabel,
actionLabel,
displayActor,
displayObject,
displayOutcome,
search,
recent,
next,
previous
} = useOperationLogs()
</script>
<style scoped src="./OperationLogs.css"></style>

View File

@ -0,0 +1,7 @@
@import '../LogSettings.css';
.log-subpage-container {
display: flex;
flex-direction: column;
height: 100%;
}

View File

@ -0,0 +1,160 @@
import { computed, onMounted, ref } from 'vue'
import { http } from '@/utils/axios'
import { parseRuntimeLine, summarizeRuntime, timeParts } from '../RuntimeLog.js'
const PAGE_SIZE = 10
export function useRuntimeLogs() {
const loading = ref(false)
const error = ref('')
const items = ref([])
const offset = ref(0)
const hasMore = ref(false)
const recentRange = () => {
const end = new Date()
return [new Date(end.getTime() - 24 * 60 * 60 * 1000), end]
}
const dateRange = ref(recentRange())
const runtimeDetail = ref(null)
let requestId = 0
let aborter
const previousOffsets = []
let appliedFilters = {}
const runtimeRows = computed(() => items.value.map(line => parseRuntimeLine(line, timeParts)))
const runtimeSummary = computed(() => summarizeRuntime(runtimeRows.value))
const pageRange = computed(() => {
if (items.value.length === 0) {
return '????'
}
const start = offset.value + 1
const end = offset.value + items.value.length
return `? ${start}-${end} ?`
})
function resultType(result) {
if (result === 'success') return 'success'
if (result === 'failure' || result === 'failed') return 'danger'
if (result === 'blocked') return 'warning'
return 'info'
}
function resultLabel(result) {
return {
success: '??',
failure: '??',
failed: '??',
blocked: '???',
unknown: '???',
accepted: '????',
pending: '???',
started: '???',
recorded: '????',
partial: '????',
discarded: '???',
skipped: '???'
}[result] || result || '???'
}
function runtimeRowClass({ row }) {
if (row.type === 'danger') return 'log-row-danger'
if (row.type === 'warning') return 'log-row-warning'
return ''
}
function formatTime(isoString) {
if (!isoString) return '?'
const date = new Date(isoString)
if (isNaN(date.getTime())) return isoString
return date.toLocaleString('zh-CN', { hour12: false })
}
function requireData(result) {
if (result?.errorNo !== 0 || !result.data) throw new Error(result?.errorMsg || '??????')
return result.data
}
async function loadLogs() {
const id = ++requestId
aborter?.abort()
aborter = new AbortController()
loading.value = true
error.value = ''
try {
const params = { ...appliedFilters, offset: offset.value, limit: PAGE_SIZE }
const data = requireData(await http.get('/api/v1/logs', { params, signal: aborter.signal }))
if (id !== requestId) return
items.value = data.items || []
hasMore.value = Boolean(data.hasMore)
} catch (cause) {
if (id === requestId && cause?.code !== 'ERR_CANCELED') {
error.value = cause.message || '??????'
items.value = []
hasMore.value = false
}
} finally {
if (id === requestId) loading.value = false
}
}
function search() {
if (!dateRange.value?.length) dateRange.value = recentRange()
const [from, to] = (dateRange.value || []).map(val => new Date(val))
if (!from || !to || !Number.isFinite(from.getTime()) || !Number.isFinite(to.getTime()) || from > to) {
++requestId
aborter?.abort()
loading.value = false
error.value = '??????????'
items.value = []
hasMore.value = false
return
}
appliedFilters = { kind: 'runtime', from: from.toISOString(), to: to.toISOString() }
offset.value = 0
previousOffsets.length = 0
runtimeDetail.value = null
return loadLogs()
}
function recent() {
dateRange.value = recentRange()
return search()
}
function next() {
if (loading.value || !hasMore.value) return
previousOffsets.push(offset.value)
offset.value += PAGE_SIZE
return loadLogs()
}
function previous() {
if (loading.value || offset.value === 0) return
offset.value = previousOffsets.pop() ?? 0
return loadLogs()
}
onMounted(search)
return {
loading,
error,
items,
offset,
hasMore,
dateRange,
runtimeDetail,
runtimeRows,
runtimeSummary,
pageRange,
resultType,
runtimeRowClass,
formatTime,
resultLabel,
search,
recent,
next,
previous
}
}

View File

@ -0,0 +1,155 @@
<template>
<div class="log-subpage-container">
<div class="log-filters">
<el-date-picker
v-model="dateRange"
type="datetimerange"
start-placeholder="????"
end-placeholder="????"
format="YYYY-MM-DD HH:mm"
aria-label="????????"
/>
<div class="log-filter-actions">
<el-button :disabled="loading" @click="recent">?? 24 ??</el-button>
<el-button type="primary" :loading="loading" @click="search">??</el-button>
</div>
</div>
<el-alert v-if="error" :title="error" type="error" :closable="false" show-icon />
<div v-loading="loading" class="log-content" :aria-busy="loading">
<div class="log-table-heading">
<div class="log-table-title">
<strong>????</strong>
<span class="log-file-meta">???? ? ?? 10 ?</span>
</div>
<div class="log-summary" aria-label="??????">
<span>?? {{ runtimeSummary.total }} ?</span>
<span class="log-stat-info"><i aria-hidden="true" />?? {{ runtimeSummary.info }}</span>
<span class="log-stat-warning"><i aria-hidden="true" />?? {{ runtimeSummary.warning }}</span>
<span class="log-stat-danger"><i aria-hidden="true" />?? {{ runtimeSummary.error }}</span>
</div>
</div>
<el-table
:data="runtimeRows"
class="log-table runtime-table"
height="100%"
row-key="sequence"
:row-class-name="runtimeRowClass"
empty-text="??????"
@row-click="row => runtimeDetail = row"
>
<el-table-column label="??????" width="116">
<template #default="{ row }">
<div class="log-time">
<strong>{{ row.time }}</strong>
<span>{{ row.date }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="??" width="76" align="center">
<template #default="{ row }">
<el-tag :type="row.type" size="small" effect="light" class="log-level-tag">
{{ row.label }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="???????" min-width="170">
<template #default="{ row }">
<strong class="log-action">{{ row.action }}</strong>
<div class="log-message">{{ row.outcome }}</div>
<div v-if="row.object" class="log-source">???{{ row.object }}</div>
</template>
</el-table-column>
<el-table-column label="??" width="92" align="center">
<template #default="{ row }">
<el-tag :type="resultType(row.result)" size="small" class="log-level-tag">
{{ resultLabel(row.result) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="??" width="60" align="center">
<template #default="{ row }">
<el-button link type="primary" :aria-label="'??? ' + row.sequence + ' ?????'" @click.stop="runtimeDetail = row">
??
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<footer class="log-pagination">
<span aria-live="polite">{{ pageRange }}</span>
<div class="log-page-actions">
<el-button size="small" :disabled="loading || offset === 0" @click="previous">???</el-button>
<el-button size="small" :disabled="loading || !hasMore" @click="next">???</el-button>
</div>
</footer>
<el-dialog
:model-value="Boolean(runtimeDetail)"
title="??????"
width="min(760px, 92vw)"
append-to-body
@close="runtimeDetail = null"
>
<div v-if="runtimeDetail" class="runtime-detail">
<div class="runtime-detail-heading">
<el-tag :type="runtimeDetail.type" effect="light">{{ runtimeDetail.label }}</el-tag>
<span>{{ formatTime(runtimeDetail.createdAt) }}</span>
<span class="runtime-detail-sequence">? {{ runtimeDetail.sequence }} ?</span>
</div>
<dl class="log-detail-grid runtime-detail-meta">
<dt>????</dt>
<dd>{{ runtimeDetail.action }}</dd>
<dt>????</dt>
<dd>{{ runtimeDetail.object || '???' }}</dd>
<dt>????</dt>
<dd>{{ resultLabel(runtimeDetail.result) }}</dd>
<dt>????</dt>
<dd>{{ runtimeDetail.outcome }}</dd>
<dt>????</dt>
<dd>{{ runtimeDetail.source || '???' }}</dd>
<template v-if="runtimeDetail.requestId">
<dt>????</dt>
<dd>{{ runtimeDetail.requestId }}</dd>
</template>
</dl>
<h3>????</h3>
<pre class="runtime-detail-message">{{ runtimeDetail.message }}</pre>
<details class="runtime-raw">
<summary>????</summary>
<pre>{{ runtimeDetail.raw }}</pre>
</details>
</div>
</el-dialog>
</div>
</template>
<script setup>
import { useRuntimeLogs } from './RuntimeLogs.js'
const {
loading,
error,
items,
offset,
hasMore,
dateRange,
runtimeDetail,
runtimeRows,
runtimeSummary,
pageRange,
resultType,
runtimeRowClass,
formatTime,
resultLabel,
search,
recent,
next,
previous
} = useRuntimeLogs()
</script>
<style scoped src="./RuntimeLogs.css"></style>