feat(system-logs): 两端平台新增服务日志查看页面
- tenant-platform: /system-logs 页面,私有化模式下侧边栏显示;支持服务切换、 行数选择、刷新间隔配置(5s/10s/30s/1min)、自动滚动 - ops-platform: /system-logs 页面,始终可见;复用相同交互,通过 ROLE_OPS 接口获取日志 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
这个提交包含在:
父节点
e999d4d443
当前提交
ad734ff204
@ -279,4 +279,13 @@ export const opsApi = {
|
||||
|
||||
sendPushTestOffline: (payload: { appKey: string; userId: string; title: string; body: string; payload?: string }) =>
|
||||
client.post<{ data: PushTestResult }>('/ops/push/test-offline', payload),
|
||||
|
||||
getRunningServices: () =>
|
||||
client.get<{ data: string[] }>('/ops/system/services'),
|
||||
|
||||
fetchServiceLogs: (service: string, lines: number) =>
|
||||
client.get<string>(`/ops/system/logs/${encodeURIComponent(service)}`, {
|
||||
params: { lines },
|
||||
responseType: 'text',
|
||||
}),
|
||||
}
|
||||
|
||||
@ -19,6 +19,7 @@ const router = createRouter({
|
||||
{ path: 'push', component: () => import('@/views/push/PushDiagnosticsView.vue') },
|
||||
{ path: 'operation-logs', component: () => import('@/views/logs/OperationLogView.vue') },
|
||||
{ path: 'risk-control', component: () => import('@/views/risk/RiskControlView.vue') },
|
||||
{ path: 'system-logs', component: () => import('@/views/system/ServerLogsView.vue') },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@ -66,7 +66,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Avatar, Bell, Menu, TrendCharts, Grid, Document, Warning, Promotion } from '@element-plus/icons-vue'
|
||||
import { Avatar, Bell, Menu, Monitor, TrendCharts, Grid, Document, Warning, Promotion } from '@element-plus/icons-vue'
|
||||
|
||||
const router = useRouter()
|
||||
const isMobile = ref(false)
|
||||
@ -79,6 +79,7 @@ const navItems = computed(() => [
|
||||
{ path: '/apps', label: '应用管理', icon: Grid },
|
||||
{ path: '/push', label: 'Push 诊断', icon: Promotion },
|
||||
{ path: '/operation-logs', label: '操作日志', icon: Document },
|
||||
{ path: '/system-logs', label: '服务日志', icon: Monitor },
|
||||
{ path: '/risk-control', label: '风控配置', icon: Warning },
|
||||
])
|
||||
|
||||
|
||||
@ -0,0 +1,292 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>服务日志</h2>
|
||||
<p class="subtitle">查看各服务的运行日志,支持定时自动刷新。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card style="margin-bottom: 12px">
|
||||
<el-skeleton v-if="loadingServices" :rows="1" animated style="margin-bottom:12px" />
|
||||
<template v-else-if="services.length === 0">
|
||||
<el-empty description="未检测到正在运行的服务" :image-size="60" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-tabs v-model="activeService" type="card" class="service-tabs" @tab-change="onServiceChange">
|
||||
<el-tab-pane v-for="svc in services" :key="svc" :label="svc" :name="svc" />
|
||||
</el-tabs>
|
||||
|
||||
<div class="controls">
|
||||
<div class="controls-left">
|
||||
<span class="control-label">显示行数</span>
|
||||
<el-select v-model="lineCount" style="width:100px" size="small" @change="fetchLogs">
|
||||
<el-option :value="100" label="100 行" />
|
||||
<el-option :value="200" label="200 行" />
|
||||
<el-option :value="500" label="500 行" />
|
||||
<el-option :value="1000" label="1000 行" />
|
||||
</el-select>
|
||||
|
||||
<span class="control-label">刷新间隔</span>
|
||||
<el-select v-model="refreshInterval" style="width:110px" size="small" @change="onIntervalChange">
|
||||
<el-option :value="0" label="手动" />
|
||||
<el-option :value="5" label="5 秒" />
|
||||
<el-option :value="10" label="10 秒" />
|
||||
<el-option :value="30" label="30 秒" />
|
||||
<el-option :value="60" label="1 分钟" />
|
||||
</el-select>
|
||||
|
||||
<el-button
|
||||
v-if="refreshInterval > 0"
|
||||
:type="autoRefreshing ? 'danger' : 'success'"
|
||||
size="small"
|
||||
@click="toggleAutoRefresh"
|
||||
>
|
||||
{{ autoRefreshing ? '停止刷新' : '开始刷新' }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="controls-right">
|
||||
<el-checkbox v-model="autoScroll" size="small">自动滚动</el-checkbox>
|
||||
<el-button size="small" :loading="fetching" @click="fetchLogs">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
<el-button size="small" @click="clearLog">清除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-bar">
|
||||
<template v-if="lastFetchTime">
|
||||
<span>最后更新:{{ lastFetchTime }}</span>
|
||||
<span class="sep">·</span>
|
||||
<span>{{ lineCount }} 行</span>
|
||||
<span v-if="autoRefreshing" class="live-badge">
|
||||
<span class="live-dot" />
|
||||
实时刷新
|
||||
</span>
|
||||
<span v-if="fetchError" class="error-badge">{{ fetchError }}</span>
|
||||
</template>
|
||||
<template v-else-if="fetching">
|
||||
<span>加载中...</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span style="color:#c0c4cc">尚未加载</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</el-card>
|
||||
|
||||
<el-card v-if="services.length > 0" class="log-card">
|
||||
<div class="log-wrap">
|
||||
<pre ref="logEl" class="log-content">{{ logContent || (fetching ? '加载中...' : '暂无日志,点击刷新按钮获取。') }}</pre>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import { opsApi } from '@/api/ops'
|
||||
|
||||
const services = ref<string[]>([])
|
||||
const activeService = ref('')
|
||||
const loadingServices = ref(false)
|
||||
|
||||
const lineCount = ref(200)
|
||||
const refreshInterval = ref(0)
|
||||
const autoRefreshing = ref(false)
|
||||
const autoScroll = ref(true)
|
||||
|
||||
const logContent = ref('')
|
||||
const fetching = ref(false)
|
||||
const lastFetchTime = ref('')
|
||||
const fetchError = ref('')
|
||||
const logEl = ref<HTMLPreElement | null>(null)
|
||||
|
||||
let refreshTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
onMounted(async () => {
|
||||
loadingServices.value = true
|
||||
try {
|
||||
const res = await opsApi.getRunningServices()
|
||||
services.value = res.data.data
|
||||
if (services.value.length > 0) {
|
||||
activeService.value = services.value[0]
|
||||
await fetchLogs()
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error('获取服务列表失败')
|
||||
} finally {
|
||||
loadingServices.value = false
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopAutoRefresh()
|
||||
})
|
||||
|
||||
async function onServiceChange() {
|
||||
logContent.value = ''
|
||||
lastFetchTime.value = ''
|
||||
fetchError.value = ''
|
||||
if (autoRefreshing.value) {
|
||||
stopAutoRefresh()
|
||||
await fetchLogs()
|
||||
startAutoRefresh()
|
||||
} else {
|
||||
await fetchLogs()
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchLogs() {
|
||||
if (!activeService.value || fetching.value) return
|
||||
fetching.value = true
|
||||
fetchError.value = ''
|
||||
try {
|
||||
const res = await opsApi.fetchServiceLogs(activeService.value, lineCount.value)
|
||||
logContent.value = res.data as unknown as string
|
||||
lastFetchTime.value = new Date().toLocaleTimeString()
|
||||
if (autoScroll.value) {
|
||||
await nextTick()
|
||||
scrollToBottom()
|
||||
}
|
||||
} catch (e: any) {
|
||||
fetchError.value = e?.message ?? '获取失败'
|
||||
} finally {
|
||||
fetching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function clearLog() {
|
||||
logContent.value = ''
|
||||
lastFetchTime.value = ''
|
||||
fetchError.value = ''
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
if (logEl.value) logEl.value.scrollTop = logEl.value.scrollHeight
|
||||
}
|
||||
|
||||
function onIntervalChange() {
|
||||
stopAutoRefresh()
|
||||
if (refreshInterval.value > 0) startAutoRefresh()
|
||||
}
|
||||
|
||||
function toggleAutoRefresh() {
|
||||
autoRefreshing.value ? stopAutoRefresh() : startAutoRefresh()
|
||||
}
|
||||
|
||||
function startAutoRefresh() {
|
||||
if (refreshInterval.value <= 0) return
|
||||
autoRefreshing.value = true
|
||||
fetchLogs()
|
||||
refreshTimer = setInterval(fetchLogs, refreshInterval.value * 1000)
|
||||
}
|
||||
|
||||
function stopAutoRefresh() {
|
||||
autoRefreshing.value = false
|
||||
if (refreshTimer !== null) {
|
||||
clearInterval(refreshTimer)
|
||||
refreshTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
watch(lineCount, () => {
|
||||
if (autoRefreshing.value) {
|
||||
stopAutoRefresh()
|
||||
startAutoRefresh()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.subtitle {
|
||||
margin: 6px 0 0;
|
||||
color: #606266;
|
||||
}
|
||||
.service-tabs {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.service-tabs :deep(.el-tabs__header) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
padding: 8px 0;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.controls-left,
|
||||
.controls-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.control-label {
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.status-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
min-height: 20px;
|
||||
}
|
||||
.sep { color: #dcdfe6; }
|
||||
.live-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: #67c23a;
|
||||
font-weight: 500;
|
||||
}
|
||||
.live-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #67c23a;
|
||||
animation: blink 1.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.2; }
|
||||
}
|
||||
.error-badge { color: #f56c6c; }
|
||||
.log-card :deep(.el-card__body) { padding: 0; }
|
||||
.log-wrap {
|
||||
background: #0d1117;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.log-content {
|
||||
margin: 0;
|
||||
padding: 14px 16px;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', Consolas, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.65;
|
||||
color: #c9d1d9;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
height: 520px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
@ -43,6 +43,26 @@ async function streamOperation(
|
||||
if (buf) onLine(buf)
|
||||
}
|
||||
|
||||
export async function getRunningServices(): Promise<string[]> {
|
||||
const token = localStorage.getItem('token') ?? ''
|
||||
const res = await fetch(`${BASE}/system/services`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const json = await res.json()
|
||||
return json.data as string[]
|
||||
}
|
||||
|
||||
export async function fetchServiceLogs(service: string, lines: number): Promise<string> {
|
||||
const token = localStorage.getItem('token') ?? ''
|
||||
const res = await fetch(
|
||||
`${BASE}/system/logs/${encodeURIComponent(service)}?lines=${lines}`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return res.text()
|
||||
}
|
||||
|
||||
export async function getSystemVersion(): Promise<{ currentVersion: string }> {
|
||||
const token = localStorage.getItem('token') ?? ''
|
||||
const res = await fetch(`${BASE}/system/version`, {
|
||||
|
||||
@ -101,6 +101,10 @@ const router = createRouter({
|
||||
path: 'services/license/:appKey?',
|
||||
component: () => import('@/views/license/LicenseManagementView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'system-logs',
|
||||
component: () => import('@/views/system/ServerLogsView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'accounts',
|
||||
component: () => import('@/views/accounts/SubAccountView.vue'),
|
||||
|
||||
@ -23,6 +23,7 @@
|
||||
<el-menu-item index="/services/license"><el-icon><Key /></el-icon><span>授权管理</span></el-menu-item>
|
||||
</el-sub-menu>
|
||||
<el-menu-item index="/security"><el-icon><Lock /></el-icon><span>安全中心</span></el-menu-item>
|
||||
<el-menu-item v-if="isPrivateDeploy" index="/system-logs"><el-icon><Monitor /></el-icon><span>服务日志</span></el-menu-item>
|
||||
<el-menu-item index="/operation-logs"><el-icon><Document /></el-icon><span>操作日志</span></el-menu-item>
|
||||
<el-menu-item v-if="auth.user?.type === 'MAIN'" index="/accounts"><el-icon><User /></el-icon><span>子账号管理</span></el-menu-item>
|
||||
</el-menu>
|
||||
@ -58,6 +59,7 @@
|
||||
<el-menu-item index="/services/license"><el-icon><Key /></el-icon><span>授权管理</span></el-menu-item>
|
||||
</el-sub-menu>
|
||||
<el-menu-item index="/security"><el-icon><Lock /></el-icon><span>安全中心</span></el-menu-item>
|
||||
<el-menu-item v-if="isPrivateDeploy" index="/system-logs"><el-icon><Monitor /></el-icon><span>服务日志</span></el-menu-item>
|
||||
<el-menu-item index="/operation-logs"><el-icon><Document /></el-icon><span>操作日志</span></el-menu-item>
|
||||
<el-menu-item v-if="auth.user?.type === 'MAIN'" index="/accounts"><el-icon><User /></el-icon><span>子账号管理</span></el-menu-item>
|
||||
</el-menu>
|
||||
@ -106,13 +108,15 @@
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Bell, ChatDotRound, Document, Grid, Key, List, Lock, Menu, Odometer, Upload, User } from '@element-plus/icons-vue'
|
||||
import { Bell, ChatDotRound, Document, Grid, Key, List, Lock, Menu, Monitor, Odometer, Upload, User } from '@element-plus/icons-vue'
|
||||
import { getDeploymentStatus } from '@/api/system'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const isMobile = ref(false)
|
||||
const drawerVisible = ref(false)
|
||||
const isPrivateDeploy = ref(false)
|
||||
|
||||
const openedMenus = computed(() =>
|
||||
route.path.startsWith('/services/') ? ['services'] : [],
|
||||
@ -143,9 +147,15 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
updateViewport()
|
||||
window.addEventListener('resize', updateViewport)
|
||||
try {
|
||||
const status = await getDeploymentStatus()
|
||||
isPrivateDeploy.value = status.mode === 'PRIVATE'
|
||||
} catch {
|
||||
isPrivateDeploy.value = false
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
|
||||
@ -0,0 +1,321 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>服务日志</h2>
|
||||
<p class="subtitle">查看各服务的运行日志,支持定时自动刷新。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card style="margin-bottom: 12px">
|
||||
<!-- 服务选项卡 -->
|
||||
<el-skeleton v-if="loadingServices" :rows="1" animated style="margin-bottom:12px" />
|
||||
<template v-else-if="services.length === 0">
|
||||
<el-empty description="未检测到正在运行的服务" :image-size="60" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-tabs v-model="activeService" type="card" class="service-tabs" @tab-change="onServiceChange">
|
||||
<el-tab-pane v-for="svc in services" :key="svc" :label="svc" :name="svc" />
|
||||
</el-tabs>
|
||||
|
||||
<!-- 控制栏 -->
|
||||
<div class="controls">
|
||||
<div class="controls-left">
|
||||
<span class="control-label">显示行数</span>
|
||||
<el-select v-model="lineCount" style="width:100px" size="small" @change="fetchLogs">
|
||||
<el-option :value="100" label="100 行" />
|
||||
<el-option :value="200" label="200 行" />
|
||||
<el-option :value="500" label="500 行" />
|
||||
<el-option :value="1000" label="1000 行" />
|
||||
</el-select>
|
||||
|
||||
<span class="control-label">刷新间隔</span>
|
||||
<el-select v-model="refreshInterval" style="width:110px" size="small" @change="onIntervalChange">
|
||||
<el-option :value="0" label="手动" />
|
||||
<el-option :value="5" label="5 秒" />
|
||||
<el-option :value="10" label="10 秒" />
|
||||
<el-option :value="30" label="30 秒" />
|
||||
<el-option :value="60" label="1 分钟" />
|
||||
</el-select>
|
||||
|
||||
<el-button
|
||||
v-if="refreshInterval > 0"
|
||||
:type="autoRefreshing ? 'danger' : 'success'"
|
||||
size="small"
|
||||
@click="toggleAutoRefresh"
|
||||
>
|
||||
{{ autoRefreshing ? '停止刷新' : '开始刷新' }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="controls-right">
|
||||
<el-checkbox v-model="autoScroll" size="small">自动滚动</el-checkbox>
|
||||
<el-button size="small" :loading="fetching" @click="fetchLogs">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
<el-button size="small" @click="clearLog">清除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 状态栏 -->
|
||||
<div class="status-bar">
|
||||
<template v-if="lastFetchTime">
|
||||
<span>最后更新:{{ lastFetchTime }}</span>
|
||||
<span class="sep">·</span>
|
||||
<span>{{ lineCount }} 行</span>
|
||||
<span v-if="autoRefreshing" class="live-badge">
|
||||
<span class="live-dot" />
|
||||
实时刷新
|
||||
</span>
|
||||
<span v-if="fetchError" class="error-badge">{{ fetchError }}</span>
|
||||
</template>
|
||||
<template v-else-if="fetching">
|
||||
<span>加载中...</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span style="color:#c0c4cc">尚未加载</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</el-card>
|
||||
|
||||
<!-- 日志显示区 -->
|
||||
<el-card v-if="services.length > 0" class="log-card">
|
||||
<div class="log-wrap">
|
||||
<pre ref="logEl" class="log-content">{{ logContent || (fetching ? '加载中...' : '暂无日志,点击刷新按钮获取。') }}</pre>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import { getRunningServices, fetchServiceLogs } from '@/api/system'
|
||||
|
||||
const services = ref<string[]>([])
|
||||
const activeService = ref('')
|
||||
const loadingServices = ref(false)
|
||||
|
||||
const lineCount = ref(200)
|
||||
const refreshInterval = ref(0)
|
||||
const autoRefreshing = ref(false)
|
||||
const autoScroll = ref(true)
|
||||
|
||||
const logContent = ref('')
|
||||
const fetching = ref(false)
|
||||
const lastFetchTime = ref('')
|
||||
const fetchError = ref('')
|
||||
const logEl = ref<HTMLPreElement | null>(null)
|
||||
|
||||
let refreshTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
// ── 初始化 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
onMounted(async () => {
|
||||
loadingServices.value = true
|
||||
try {
|
||||
services.value = await getRunningServices()
|
||||
if (services.value.length > 0) {
|
||||
activeService.value = services.value[0]
|
||||
await fetchLogs()
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error('获取服务列表失败')
|
||||
} finally {
|
||||
loadingServices.value = false
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopAutoRefresh()
|
||||
})
|
||||
|
||||
// ── 服务切换 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function onServiceChange() {
|
||||
logContent.value = ''
|
||||
lastFetchTime.value = ''
|
||||
fetchError.value = ''
|
||||
if (autoRefreshing.value) {
|
||||
stopAutoRefresh()
|
||||
await fetchLogs()
|
||||
startAutoRefresh()
|
||||
} else {
|
||||
await fetchLogs()
|
||||
}
|
||||
}
|
||||
|
||||
// ── 日志获取 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function fetchLogs() {
|
||||
if (!activeService.value || fetching.value) return
|
||||
fetching.value = true
|
||||
fetchError.value = ''
|
||||
try {
|
||||
const text = await fetchServiceLogs(activeService.value, lineCount.value)
|
||||
logContent.value = text
|
||||
lastFetchTime.value = new Date().toLocaleTimeString()
|
||||
if (autoScroll.value) {
|
||||
await nextTick()
|
||||
scrollToBottom()
|
||||
}
|
||||
} catch (e: any) {
|
||||
fetchError.value = e?.message ?? '获取失败'
|
||||
} finally {
|
||||
fetching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function clearLog() {
|
||||
logContent.value = ''
|
||||
lastFetchTime.value = ''
|
||||
fetchError.value = ''
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
if (logEl.value) {
|
||||
logEl.value.scrollTop = logEl.value.scrollHeight
|
||||
}
|
||||
}
|
||||
|
||||
// ── 自动刷新 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function onIntervalChange() {
|
||||
stopAutoRefresh()
|
||||
if (refreshInterval.value > 0) {
|
||||
startAutoRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAutoRefresh() {
|
||||
if (autoRefreshing.value) {
|
||||
stopAutoRefresh()
|
||||
} else {
|
||||
startAutoRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
function startAutoRefresh() {
|
||||
if (refreshInterval.value <= 0) return
|
||||
autoRefreshing.value = true
|
||||
fetchLogs()
|
||||
refreshTimer = setInterval(fetchLogs, refreshInterval.value * 1000)
|
||||
}
|
||||
|
||||
function stopAutoRefresh() {
|
||||
autoRefreshing.value = false
|
||||
if (refreshTimer !== null) {
|
||||
clearInterval(refreshTimer)
|
||||
refreshTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
watch(lineCount, () => {
|
||||
if (autoRefreshing.value) {
|
||||
stopAutoRefresh()
|
||||
startAutoRefresh()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.subtitle {
|
||||
margin: 6px 0 0;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.service-tabs {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.service-tabs :deep(.el-tabs__header) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
padding: 8px 0;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.controls-left,
|
||||
.controls-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.control-label {
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
min-height: 20px;
|
||||
}
|
||||
.sep {
|
||||
color: #dcdfe6;
|
||||
}
|
||||
.live-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: #67c23a;
|
||||
font-weight: 500;
|
||||
}
|
||||
.live-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #67c23a;
|
||||
animation: blink 1.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.2; }
|
||||
}
|
||||
.error-badge {
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
.log-card :deep(.el-card__body) {
|
||||
padding: 0;
|
||||
}
|
||||
.log-wrap {
|
||||
background: #0d1117;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.log-content {
|
||||
margin: 0;
|
||||
padding: 14px 16px;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', Consolas, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.65;
|
||||
color: #c9d1d9;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
height: 520px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
正在加载...
在新工单中引用
屏蔽一个用户