Files
dy/frontend/src/views/SystemLogs.vue
T
2026-08-07 17:51:57 +08:00

842 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { ref, computed, onMounted } from 'vue'
import api from '../api'
import { message, Modal } from 'ant-design-vue'
import { useIsMobile } from '../composables/useIsMobile'
import {
ReloadOutlined,
FilterOutlined,
DeleteOutlined,
BugOutlined,
ClockCircleOutlined,
UserOutlined,
DownOutlined,
UpOutlined,
CopyOutlined
} from '@ant-design/icons-vue'
import { useAuthStore } from '../stores/auth'
import MessageBubble from '../components/MessageBubble.vue'
import {
parseMessageContent,
extractUrlsFromDetail
} from '../utils/messageContent'
const auth = useAuthStore()
const isMobile = useIsMobile()
const detailModalWidth = computed(() => (isMobile.value ? 'calc(100vw - 32px)' : 820))
const logs = ref([])
const accounts = ref([])
const loading = ref(false)
const filterAccount = ref(undefined)
const filterLevel = ref(undefined)
const filterCategory = ref(undefined)
const expandedIds = ref(new Set())
const detailModalVisible = ref(false)
const detailModalText = ref('')
const detailModalLevel = ref('info')
const currentPage = ref(1)
const pageSize = ref(15)
const formatTime = (value) => {
if (!value) return '--'
const normalized = /[zZ]|[+-]\d{2}:?\d{2}$/.test(value) ? value : `${value}Z`
const d = new Date(normalized)
return isNaN(d.getTime()) ? value : d.toLocaleString()
}
const levelOptions = [
{ value: 'error', label: '错误' },
{ value: 'warning', label: '警告' },
{ value: 'success', label: '成功' },
{ value: 'info', label: '信息' }
]
const categoryOptions = [
{ value: 'send', label: '发送' },
{ value: 'recv', label: '接收' },
{ value: 'ws', label: '实时连接' },
{ value: 'poll', label: '会话轮询' },
{ value: 'auth', label: '鉴权/凭证' },
{ value: 'system', label: '系统' }
]
const paginatedLogs = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return logs.value.slice(start, start + pageSize.value)
})
const fetchLogs = async () => {
try {
loading.value = true
const params = new URLSearchParams({ limit: '300' })
if (filterAccount.value) params.append('account_id', filterAccount.value)
if (filterLevel.value) params.append('level', filterLevel.value)
if (filterCategory.value) params.append('category', filterCategory.value)
const res = await api.get(`/system-logs?${params.toString()}`)
logs.value = res.data
currentPage.value = 1
expandedIds.value = new Set()
} catch (error) {
message.error('获取系统诊断日志失败')
} finally {
loading.value = false
}
}
const fetchAccounts = async () => {
try {
const res = await api.get(`/account-options`)
accounts.value = res.data
} catch (error) {
console.error(error)
}
}
const clearLogs = () => {
Modal.confirm({
title: '确认清空系统诊断日志?',
content: '将同时清除内存缓冲区与数据库中的历史诊断记录,此操作不可恢复。',
okText: '清空',
okType: 'danger',
cancelText: '取消',
async onOk() {
try {
await api.delete(`/system-logs`)
message.success('已清空诊断日志')
fetchLogs()
} catch (error) {
message.error('清空失败')
}
}
})
}
const levelColor = (level) => {
const map = { error: 'error', warning: 'warning', success: 'success', info: 'processing' }
return map[level] || 'default'
}
const levelLabel = (level) => {
const map = { error: '错误', warning: '警告', success: '成功', info: '信息' }
return map[level] || level
}
const categoryLabel = (category) => {
const found = categoryOptions.find(c => c.value === category)
return found ? found.label : category
}
const categoryColor = (category) => {
const map = {
send: 'purple',
recv: 'cyan',
ws: 'geekblue',
poll: 'blue',
auth: 'gold',
system: 'default'
}
return map[category] || 'default'
}
const getAccountName = (accountId) => {
if (!accountId) return '全局'
const acc = accounts.value.find(a => a.id === accountId)
return acc ? (acc.username || `账号 #${acc.id}`) : `账号 #${accountId}`
}
const accountSelectOptions = computed(() =>
accounts.value.map((acc) => ({
value: acc.id,
label: acc.username || acc.phone || `账号 #${acc.id}`
}))
)
const isLongDetail = (detail) => (detail || '').length > 160
const isExpanded = (id) => expandedIds.value.has(id)
const toggleExpand = (id) => {
const next = new Set(expandedIds.value)
if (next.has(id)) next.delete(id)
else next.add(id)
expandedIds.value = next
}
const openDetailModal = (detail, level = 'info') => {
detailModalText.value = detail || ''
detailModalLevel.value = level || 'info'
detailModalVisible.value = true
}
const formatDetailLines = (detail) => {
const text = (detail || '').trim()
if (!text) return []
const byNewline = text.split(/\r?\n/).map(s => s.trim()).filter(Boolean)
if (byNewline.length > 1) return byNewline
const bySemicolon = text.split(/;\s+(?=[A-Za-z_[\u4e00-\u9fa5])/).map(s => s.trim()).filter(Boolean)
if (bySemicolon.length > 1) return bySemicolon
const byPipe = text.split(/\s\|\s+/).map(s => s.trim()).filter(Boolean)
if (byPipe.length > 1) return byPipe
if (text.length > 180) {
return text.match(/.{1,120}(\s|$)/g)?.map(s => s.trim()).filter(Boolean) || [text]
}
return [text]
}
const detailModalLines = computed(() => formatDetailLines(detailModalText.value))
const copyDetail = async (text) => {
try {
await navigator.clipboard.writeText(text || '')
message.success('已复制到剪贴板')
} catch {
message.error('复制失败')
}
}
const extractMessageFromDetail = (detail) => {
const text = String(detail || '')
if (!text) return null
const patterns = [
/收到[:]\s*([^(|\n]+)/,
/发送[:]\s*([^(|\n]+)/,
/内容[:]\s*([^(|\n]+)/,
/会话\s+[^:]+[:]\s*(.+)/
]
for (const pattern of patterns) {
const match = text.match(pattern)
if (match?.[1]) {
const candidate = match[1].trim()
if (candidate) return parseMessageContent(candidate)
}
}
if (text.trim().startsWith('{')) {
return parseMessageContent(text.trim())
}
return null
}
const detailMessageContent = (detail) => {
const msg = extractMessageFromDetail(detail)
if (!msg) return ''
if (msg.type === 'text') return msg.text || ''
return JSON.stringify(msg)
}
const isRichDetailMessage = (detail) => {
const msg = extractMessageFromDetail(detail)
return msg && msg.type !== 'text'
}
const mediaUrlsInDetail = (detail) => extractUrlsFromDetail(detail).slice(0, 3)
onMounted(() => {
fetchAccounts()
fetchLogs()
})
</script>
<template>
<div class="syslogs-container">
<div class="page-header glass-card">
<div class="header-info">
<h2 style="margin: 0;">
<BugOutlined style="margin-right: 8px; color: #c084fc;" />
系统诊断日志
</h2>
<p class="header-desc">
追踪每个账号私信收发实时连接鉴权等链路事件收发失败时这里会记录具体原因
</p>
</div>
<div class="header-actions">
<a-space size="middle" wrap>
<a-select
v-model:value="filterAccount"
placeholder="账号"
style="width: 180px;"
allow-clear
show-search
option-filter-prop="label"
:options="accountSelectOptions"
@change="fetchLogs"
>
<template #suffixIcon><FilterOutlined /></template>
</a-select>
<a-select
v-model:value="filterLevel"
placeholder="级别"
style="width: 120px;"
allow-clear
@change="fetchLogs"
>
<a-select-option v-for="opt in levelOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</a-select-option>
</a-select>
<a-select
v-model:value="filterCategory"
placeholder="类型"
style="width: 130px;"
allow-clear
@change="fetchLogs"
>
<a-select-option v-for="opt in categoryOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</a-select-option>
</a-select>
<a-button type="primary" class="gradient-btn" @click="fetchLogs">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
<a-button v-if="auth.canClearSystemLogs" danger @click="clearLogs">
<template #icon><DeleteOutlined /></template>
清空
</a-button>
</a-space>
</div>
</div>
<div class="glass-card log-panel">
<a-spin :spinning="loading">
<div v-if="logs.length" class="log-feed">
<div
v-for="log in paginatedLogs"
:key="log.id"
class="log-item"
:class="`log-item--${log.level || 'info'}`"
>
<div class="log-item-main">
<div class="log-item-top">
<div class="log-tags">
<a-tag :color="levelColor(log.level)">{{ levelLabel(log.level) }}</a-tag>
<a-tag :color="categoryColor(log.category)">{{ categoryLabel(log.category) }}</a-tag>
</div>
<div class="log-meta">
<span class="log-meta-item">
<ClockCircleOutlined />
{{ formatTime(log.created_at) }}
</span>
<span class="log-meta-item">
<UserOutlined />
{{ getAccountName(log.account_id) }}
</span>
</div>
</div>
<div class="log-event">{{ log.event }}</div>
<div v-if="log.detail" class="log-detail-wrap">
<div
v-if="(log.category === 'recv' || log.category === 'send') && isRichDetailMessage(log.detail)"
class="log-media-preview"
>
<MessageBubble
:content="detailMessageContent(log.detail)"
compact
/>
</div>
<div
v-else-if="mediaUrlsInDetail(log.detail).length"
class="log-media-thumbs"
>
<img
v-for="(url, idx) in mediaUrlsInDetail(log.detail)"
:key="`${log.id}-media-${idx}`"
:src="url"
alt="消息媒体"
class="log-media-thumb"
loading="lazy"
/>
</div>
<pre
class="log-detail"
:class="{
'log-detail--error': log.level === 'error',
'log-detail--collapsed': isLongDetail(log.detail) && !isExpanded(log.id)
}"
>{{ log.detail }}</pre>
<div v-if="isLongDetail(log.detail)" class="log-detail-actions">
<a-button type="link" size="small" class="detail-action-btn" @click="toggleExpand(log.id)">
<template #icon>
<UpOutlined v-if="isExpanded(log.id)" />
<DownOutlined v-else />
</template>
{{ isExpanded(log.id) ? '收起' : '展开' }}
</a-button>
<a-button type="link" size="small" class="detail-action-btn" @click="openDetailModal(log.detail, log.level)">
查看全文
</a-button>
<a-button type="link" size="small" class="detail-action-btn" @click="copyDetail(log.detail)">
<template #icon><CopyOutlined /></template>
复制
</a-button>
</div>
</div>
</div>
</div>
</div>
<div v-else class="empty-state">
<BugOutlined class="empty-icon" />
<p>暂无诊断日志启动托管后将实时记录收发链路事件</p>
</div>
<div v-if="logs.length > pageSize" class="log-pagination">
<a-pagination
v-model:current="currentPage"
:total="logs.length"
:page-size="pageSize"
:show-size-changer="false"
show-less-items
/>
</div>
</a-spin>
</div>
<a-modal
v-model:visible="detailModalVisible"
:footer="null"
:width="detailModalWidth"
destroyOnClose
wrap-class-name="log-detail-modal-wrap"
class="log-detail-modal"
>
<template #title>
<div class="detail-modal-title">
<span>日志详情</span>
<a-tag :color="levelColor(detailModalLevel)">{{ levelLabel(detailModalLevel) }}</a-tag>
</div>
</template>
<div class="detail-modal-toolbar">
<span class="detail-modal-meta"> {{ detailModalLines.length }} · {{ detailModalText.length }} 字符</span>
<a-button size="small" class="detail-copy-btn" @click="copyDetail(detailModalText)">
<template #icon><CopyOutlined /></template>
复制全文
</a-button>
</div>
<div class="detail-code-view" :class="`detail-code-view--${detailModalLevel}`">
<div
v-for="(line, index) in detailModalLines"
:key="index"
class="detail-line"
>
<span class="line-no">{{ index + 1 }}</span>
<code class="line-text">{{ line }}</code>
</div>
</div>
</a-modal>
</div>
</template>
<style scoped>
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24px;
flex-wrap: wrap;
gap: 16px;
}
.header-desc {
color: var(--text-secondary);
margin-top: 4px;
font-size: 0.9rem;
}
.gradient-btn {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--accent-pink) 100%) !important;
border: none !important;
height: 32px;
border-radius: 8px;
font-weight: 600;
}
.log-panel {
margin-top: 24px;
padding: 16px;
border-radius: 12px;
min-height: 320px;
}
.log-feed {
display: flex;
flex-direction: column;
gap: 12px;
}
.log-item {
border-radius: 10px;
border: 1px solid var(--border-light);
background: rgba(255, 255, 255, 0.02);
overflow: hidden;
transition: background 0.2s, border-color 0.2s;
}
.log-item:hover {
background: rgba(255, 255, 255, 0.04);
}
.log-item--error {
border-left: 3px solid #ef4444;
}
.log-item--warning {
border-left: 3px solid #f59e0b;
}
.log-item--success {
border-left: 3px solid #22c55e;
}
.log-item--info {
border-left: 3px solid #6366f1;
}
.log-item-main {
padding: 14px 16px;
}
.log-item-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 10px;
}
.log-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.log-meta {
display: flex;
flex-wrap: wrap;
gap: 14px;
font-size: 0.82rem;
color: var(--text-secondary);
}
.log-meta-item {
display: inline-flex;
align-items: center;
gap: 5px;
white-space: nowrap;
}
.log-event {
color: #f3f4f6;
font-size: 0.95rem;
font-weight: 600;
line-height: 1.5;
word-break: break-word;
}
.log-detail-wrap {
margin-top: 10px;
}
.log-detail {
margin: 0;
padding: 10px 12px;
border-radius: 8px;
background: hsla(230, 20%, 6%, 0.85);
border: 1px solid rgba(255, 255, 255, 0.08);
color: #cbd5e1;
font-family: Consolas, Monaco, 'Courier New', monospace;
font-size: 12px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: anywhere;
}
.log-detail--error {
color: #fecaca;
background: hsla(360, 60%, 8%, 0.9);
border-color: rgba(248, 113, 113, 0.25);
}
.log-detail--collapsed {
max-height: 4.8em;
overflow: hidden;
position: relative;
}
.log-detail--collapsed::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 1.6em;
background: linear-gradient(transparent, hsla(230, 20%, 8%, 0.98));
pointer-events: none;
}
.log-detail-actions {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 6px;
}
.log-detail-wrap {
margin-top: 8px;
}
.log-media-preview,
.log-media-thumbs {
margin-bottom: 8px;
}
.log-media-thumbs {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.log-media-thumb {
width: 72px;
height: 72px;
object-fit: cover;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.08);
}
.detail-action-btn {
color: #c084fc !important;
padding-left: 0;
}
.log-pagination {
display: flex;
justify-content: center;
margin-top: 16px;
padding-top: 8px;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 56px 0;
color: var(--text-secondary);
}
.empty-icon {
font-size: 3rem;
color: var(--text-muted);
margin-bottom: 12px;
}
.detail-modal-title {
display: flex;
align-items: center;
gap: 10px;
color: var(--text-primary);
font-weight: 600;
}
.detail-modal-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.detail-modal-meta {
font-size: 0.82rem;
color: var(--text-secondary);
}
.detail-copy-btn {
background: rgba(192, 132, 252, 0.12) !important;
border: 1px solid rgba(192, 132, 252, 0.35) !important;
color: #e9d5ff !important;
border-radius: 8px !important;
}
.detail-code-view {
max-height: 62vh;
overflow: auto;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: hsl(230, 22%, 7%);
}
.detail-code-view--error {
border-color: rgba(248, 113, 113, 0.25);
box-shadow: inset 0 0 0 1px rgba(248, 113, 113, 0.06);
}
.detail-line {
display: flex;
gap: 12px;
padding: 10px 14px;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
}
.detail-line:last-child {
border-bottom: none;
}
.detail-line:hover {
background: rgba(255, 255, 255, 0.02);
}
.line-no {
flex-shrink: 0;
width: 28px;
text-align: right;
font-size: 11px;
line-height: 1.65;
color: var(--text-muted);
user-select: none;
font-family: Consolas, Monaco, monospace;
}
.line-text {
flex: 1;
margin: 0;
font-family: Consolas, Monaco, 'Courier New', monospace;
font-size: 12px;
line-height: 1.65;
color: #e2e8f0;
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: anywhere;
background: transparent;
}
.detail-code-view--error .line-text {
color: #fecaca;
}
:deep(.ant-select-selector) {
background: rgba(255, 255, 255, 0.03) !important;
border-color: var(--border-light) !important;
color: #fff !important;
border-radius: 8px !important;
}
:deep(.ant-select-selection-placeholder) {
color: var(--text-muted) !important;
}
:deep(.ant-select-selection-item) {
color: #fff !important;
}
:deep(.ant-tag-default) {
color: #e2e8f0 !important;
background: rgba(148, 163, 184, 0.2) !important;
border-color: rgba(203, 213, 225, 0.35) !important;
}
.log-pagination :deep(.ant-pagination-item),
.log-pagination :deep(.ant-pagination-item-link) {
background: rgba(255, 255, 255, 0.03) !important;
border-color: var(--border-light) !important;
color: var(--text-secondary) !important;
}
.log-pagination :deep(.ant-pagination-item-active) {
border-color: var(--primary-color) !important;
}
.log-pagination :deep(.ant-pagination-item-active a) {
color: #c084fc !important;
}
@media (max-width: 768px) {
.page-header {
flex-direction: column;
align-items: stretch;
padding: 16px;
}
.header-actions {
width: 100%;
}
.header-actions :deep(.ant-space) {
width: 100%;
}
.header-actions :deep(.ant-select) {
width: 100% !important;
min-width: 0 !important;
}
.header-actions :deep(.ant-space-item) {
flex: 1 1 calc(50% - 6px);
min-width: 0;
}
.gradient-btn {
width: 100%;
}
.log-panel {
margin-top: 16px;
padding: 12px;
}
.log-item-top {
flex-direction: column;
align-items: flex-start;
}
.detail-modal-toolbar {
flex-direction: column;
align-items: flex-start;
}
.detail-copy-btn {
width: 100%;
}
}
</style>
<style>
.log-detail-modal-wrap .ant-modal-content {
background: hsl(230, 20%, 11%) !important;
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.45);
border-radius: 14px;
overflow: hidden;
}
.log-detail-modal-wrap .ant-modal-header {
background: transparent !important;
border-bottom: 1px solid rgba(255, 255, 255, 0.06) !important;
padding: 16px 20px !important;
}
.log-detail-modal-wrap .ant-modal-body {
padding: 16px 20px 20px !important;
background: transparent !important;
}
.log-detail-modal-wrap .ant-modal-close {
color: var(--text-secondary) !important;
}
.log-detail-modal-wrap .ant-modal-close:hover {
color: #fff !important;
}
</style>