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
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:
parent
bbf26f91c5
commit
8cef8a9c61
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
113
frontend/src/views/components/LogSettings/MailLogs/MailLogs.css
Normal file
113
frontend/src/views/components/LogSettings/MailLogs/MailLogs.css
Normal 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;
|
||||
}
|
||||
146
frontend/src/views/components/LogSettings/MailLogs/MailLogs.js
Normal file
146
frontend/src/views/components/LogSettings/MailLogs/MailLogs.js
Normal 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
|
||||
}
|
||||
}
|
||||
162
frontend/src/views/components/LogSettings/MailLogs/MailLogs.vue
Normal file
162
frontend/src/views/components/LogSettings/MailLogs/MailLogs.vue
Normal 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>
|
||||
@ -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;
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
@ -0,0 +1,7 @@
|
||||
@import '../LogSettings.css';
|
||||
|
||||
.log-subpage-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
Loading…
Reference in New Issue
Block a user