Files
gym-manage/gym-manage-cuit/src/views/OperationLog.vue
T

1173 lines
28 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 lang="ts">
import { ref, onMounted } from 'vue'
import {
getOperationLogs,
exportOperationLogs,
type OperationLog,
} from '@/api/operationLog'
import AppLayout from '@/components/AppLayout.vue'
const logs = ref<OperationLog[]>([])
const loading = ref(false)
const exporting = ref(false)
const totalElements = ref(0)
const currentPage = ref(0)
const pageSize = ref(15)
const totalPages = ref(0)
const keyword = ref('')
const filterUsername = ref('')
const filterModule = ref('')
const filterStatus = ref('')
const startDate = ref('')
const endDate = ref('')
const showDetail = ref(false)
const detailLog = ref<OperationLog | null>(null)
const showRawData = ref(false)
const statusOptions = [
{ label: '全部状态', value: '' },
{ label: '操作成功', value: '0' },
{ label: '操作失败', value: '1' },
]
const moduleOptions = [
{ label: '全部模块', value: '' },
{ label: '用户管理', value: '用户管理' },
{ label: '角色管理', value: '角色管理' },
{ label: '菜单管理', value: '菜单管理' },
{ label: '字典管理', value: '字典管理' },
{ label: '配置管理', value: '配置管理' },
{ label: '会员管理', value: '会员管理' },
{ label: '会员卡管理', value: '会员卡管理' },
{ label: '团课管理', value: '团课管理' },
{ label: '签到管理', value: '签到管理' },
{ label: '认证', value: '认证' },
{ label: '公告管理', value: '公告管理' },
{ label: '消息管理', value: '消息管理' },
{ label: '数据字典', value: '数据字典' },
{ label: '权限管理', value: '权限管理' },
]
// ---- 业务语言翻译 ----
/** 将操作模块拆分为 module 和 action */
function parseOperation(log: OperationLog): { module: string; action: string } {
const parts = log.operation.split(' - ')
return {
module: parts[0] || '',
action: parts.slice(1).join(' - ') || parts[0] || '',
}
}
/** 将 HTTP 方法 + 路径翻译为业务描述 */
function translateHttpMethod(log: OperationLog): string {
const { action } = parseOperation(log)
const method = log.method || ''
if (method.includes('POST') || method.includes('PUT') || method.includes('DELETE') || method.includes('PATCH')) {
const httpMethod = method.split(' ')[0] || ''
const methodMap: Record<string, string> = {
POST: '新增操作',
PUT: '修改操作',
DELETE: '删除操作',
PATCH: '修改操作',
}
return methodMap[httpMethod] || action || '未知操作'
}
return '查询操作'
}
/** 将参数 JSON 转为可读的键值列表 */
function parseParamsToReadable(params: string | null): Array<{ key: string; value: string }> {
if (!params) return []
try {
// Try parsing as JSON first
const obj = JSON.parse(params)
if (Array.isArray(obj)) {
return obj.length > 0
? [{ key: '数据列表', value: `共 ${obj.length} 条记录` }]
: []
}
if (typeof obj === 'object' && obj !== null) {
return Object.entries(obj)
.filter(([, v]) => v !== null && v !== undefined && v !== '')
.map(([k, v]) => ({
key: translateFieldName(k),
value: formatFieldValue(k, v),
}))
}
return [{ key: '参数', value: String(obj) }]
} catch {
// URL query string style: key=value&key2=value2
if (params.includes('=') || params.includes('&')) {
const pairs = params.split('&').filter(Boolean)
return pairs.map((pair) => {
const [k, v] = pair.split('=')
return { key: translateFieldName(k || ''), value: decodeURIComponent(v || '') }
})
}
// Plain text
return [{ key: '内容', value: params }]
}
}
/** 字段名映射为中文 */
function translateFieldName(key: string): string {
const map: Record<string, string> = {
id: '编号',
username: '用户名',
nickname: '昵称',
password: '密码',
phone: '手机号',
email: '邮箱',
gender: '性别',
birthday: '生日',
address: '地址',
avatar: '头像',
roleName: '角色名称',
roleKey: '角色标识',
roleSort: '排序',
status: '状态',
menuName: '菜单名称',
menuType: '菜单类型',
orderNum: '排序号',
component: '组件路径',
perms: '权限标识',
parentId: '上级编号',
configKey: '配置键',
configValue: '配置值',
dictName: '字典名称',
dictType: '字典类型',
dictLabel: '字典标签',
dictValue: '字典值',
memberNo: '会员编号',
cardName: '卡片名称',
cardType: '卡片类型',
price: '价格',
totalTimes: '总次数',
validityDays: '有效天数',
expireTime: '到期时间',
content: '内容',
title: '标题',
reason: '原因',
page: '页码',
size: '每页条数',
sort: '排序字段',
order: '排序方式',
keyword: '关键字',
startTime: '开始时间',
endTime: '结束时间',
}
return map[key] || key
}
/** 将字段值格式化为可读文本 */
function formatFieldValue(key: string, value: unknown): string {
if (value === null || value === undefined) return '--'
if (key === 'gender') return value === 1 ? '男' : value === 2 ? '女' : '未知'
if (key === 'status') return value === 0 || value === '0' ? '启用' : '停用'
if (key === 'order') return value === 'desc' ? '降序' : '升序'
return String(value)
}
/** 将执行结果翻译为业务语言 */
function translateResult(result: string | null): string {
if (!result) return '无记录'
// HTTP 200 (45ms) -> "操作成功"
const match = result.match(/HTTP\s*(\d+)\s*\((\d+)ms\)/)
if (match) {
const code = parseInt(match[1]!)
const ms = parseInt(match[2]!)
if (code >= 200 && code < 300) {
return `操作成功${ms > 0 ? `(系统处理耗时 ${formatDuration(ms)}` : ''}`
}
return `操作异常(状态码 ${code}`
}
return result
}
// ---- 原有函数 ----
async function fetchLogs() {
loading.value = true
try {
const params: Record<string, string | number> = {
page: currentPage.value,
size: pageSize.value,
sort: 'createdAt',
order: 'desc',
}
if (keyword.value) params.keyword = keyword.value
if (filterUsername.value) params.username = filterUsername.value
if (filterModule.value) params.operation = filterModule.value
if (filterStatus.value) params.status = filterStatus.value
if (startDate.value) params.startTime = startDate.value + 'T00:00:00'
if (endDate.value) params.endTime = endDate.value + 'T23:59:59'
const result = await getOperationLogs(params)
logs.value = result.content || []
totalElements.value = result.totalElements
totalPages.value = result.totalPages
} catch {
logs.value = []
} finally {
loading.value = false
}
}
function handleSearch() {
currentPage.value = 0
fetchLogs()
}
function handleReset() {
keyword.value = ''
filterUsername.value = ''
filterModule.value = ''
filterStatus.value = ''
startDate.value = ''
endDate.value = ''
currentPage.value = 0
fetchLogs()
}
function goPage(page: number) {
if (page < 0 || page >= totalPages.value) return
currentPage.value = page
fetchLogs()
}
function showDetailModal(log: OperationLog) {
detailLog.value = log
showRawData.value = false
showDetail.value = true
}
function closeDetailModal() {
showDetail.value = false
detailLog.value = null
showRawData.value = false
}
function formatTime(t: string | null): string {
if (!t) return '--'
return new Date(t).toLocaleString('zh-CN')
}
function formatDuration(ms: number): string {
if (ms < 1000) return ms + ' 毫秒'
if (ms < 60000) return (ms / 1000).toFixed(1) + ' 秒'
return (ms / 60000).toFixed(1) + ' 分钟'
}
function statusText(status: string) {
return status === '0' ? '成功' : '失败'
}
function statusBadge(status: string) {
return status === '0' ? 'success' : 'error'
}
async function handleExport() {
exporting.value = true
try {
const params: Record<string, string> = {}
if (filterUsername.value) params.username = filterUsername.value
if (filterModule.value) params.operation = filterModule.value
if (filterStatus.value) params.status = filterStatus.value
if (keyword.value) params.keyword = keyword.value
if (startDate.value) params.startTime = startDate.value + 'T00:00:00'
if (endDate.value) params.endTime = endDate.value + 'T23:59:59'
const blob = await exportOperationLogs(params)
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
const now = new Date()
const filename = `操作日志_${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}_${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}.xlsx`
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
} catch {
// 导出失败
} finally {
exporting.value = false
}
}
onMounted(fetchLogs)
</script>
<template>
<AppLayout>
<!-- 筛选栏 -->
<div class="filter-bar">
<div class="filter-row">
<input v-model="keyword" class="filter-input" placeholder="输入关键词搜索..." @keyup.enter="handleSearch" />
<input v-model="filterUsername" class="filter-input short" placeholder="操作人" />
<select v-model="filterModule" class="filter-select">
<option v-for="o in moduleOptions" :key="o.value" :value="o.value">{{ o.label }}</option>
</select>
<select v-model="filterStatus" class="filter-select">
<option v-for="o in statusOptions" :key="o.value" :value="o.value">{{ o.label }}</option>
</select>
<input v-model="startDate" type="date" class="filter-input date" />
<span class="date-sep"></span>
<input v-model="endDate" type="date" class="filter-input date" />
<button class="btn btn-primary" @click="handleSearch">
<i class="fas fa-search"></i> 查询
</button>
<button class="btn btn-outline" @click="handleReset">
<i class="fas fa-redo"></i> 重置
</button>
<div class="filter-spacer"></div>
<button class="btn btn-export" :disabled="exporting" @click="handleExport">
<i class="fas fa-download"></i>
{{ exporting ? '导出中...' : '导出报表' }}
</button>
</div>
</div>
<!-- 加载骨架 -->
<div v-if="loading" class="table-container">
<table class="data-table">
<thead>
<tr>
<th style="width: 90px">操作人</th>
<th style="width: 100px">操作类型</th>
<th style="width: 160px">操作描述</th>
<th style="width: 110px">操作模块</th>
<th style="width: 90px">状态</th>
<th style="width: 80px">耗时</th>
<th style="width: 150px">操作时间</th>
<th style="width: 70px">详情</th>
</tr>
</thead>
</table>
<div v-for="i in 8" :key="'sk-' + i" class="sk-row">
<div class="sk-cell skeleton"></div>
</div>
</div>
<!-- 空状态 -->
<div v-else-if="!logs.length" class="empty-section">
<div class="empty-state">
<i class="fas fa-clipboard-list empty-icon"></i>
<p>暂无操作记录</p>
<span class="empty-hint">系统中尚未产生任何操作日志</span>
</div>
</div>
<!-- 表格 -->
<div v-else class="table-container">
<table class="data-table">
<thead>
<tr>
<th style="width: 90px">操作人</th>
<th style="width: 100px">操作类型</th>
<th style="width: 160px">操作描述</th>
<th style="width: 110px">操作模块</th>
<th style="width: 90px">状态</th>
<th style="width: 80px">耗时</th>
<th style="width: 150px">操作时间</th>
<th style="width: 70px">详情</th>
</tr>
</thead>
<tbody>
<tr v-for="log in logs" :key="log.id">
<td class="cell-username">
<span class="avatar-dot">{{ log.username?.charAt(0) || '?' }}</span>
{{ log.username }}
</td>
<td>
<span class="badge-action" :class="translateHttpMethod(log).includes('新增') ? 'action-create'
: translateHttpMethod(log).includes('修改') ? 'action-update'
: translateHttpMethod(log).includes('删除') ? 'action-delete'
: 'action-read'">
{{ translateHttpMethod(log) }}
</span>
</td>
<td class="cell-desc" :title="parseOperation(log).action">{{ parseOperation(log).action }}</td>
<td>
<span class="module-tag">{{ parseOperation(log).module }}</span>
</td>
<td>
<span class="badge" :class="statusBadge(log.status)">
{{ statusText(log.status) }}
</span>
</td>
<td class="cell-duration">{{ formatDuration(log.duration) }}</td>
<td class="cell-time">{{ formatTime(log.createdAt) }}</td>
<td>
<button class="btn-detail" @click="showDetailModal(log)">
<i class="fas fa-eye"></i>
</button>
</td>
</tr>
</tbody>
</table>
<!-- 分页 -->
<div class="pagination">
<span class="page-info"> {{ totalElements }} 条记录</span>
<div class="page-controls">
<button :disabled="currentPage <= 0" @click="goPage(0)">
<i class="fas fa-angle-double-left"></i>
</button>
<button :disabled="currentPage <= 0" @click="goPage(currentPage - 1)">
<i class="fas fa-angle-left"></i>
</button>
<span class="page-num">{{ currentPage + 1 }} / {{ totalPages || 1 }}</span>
<button :disabled="currentPage >= totalPages - 1" @click="goPage(currentPage + 1)">
<i class="fas fa-angle-right"></i>
</button>
<button :disabled="currentPage >= totalPages - 1" @click="goPage(totalPages - 1)">
<i class="fas fa-angle-double-right"></i>
</button>
</div>
</div>
</div>
<!-- 详情弹窗 -->
<Teleport to="body">
<transition name="modal">
<div v-if="showDetail" class="modal-overlay" @click.self="closeDetailModal">
<div class="modal-content">
<div class="modal-header">
<h3><i class="fas fa-info-circle modal-header-icon"></i>操作详情</h3>
<button class="modal-close" @click="closeDetailModal">
<i class="fas fa-times"></i>
</button>
</div>
<div v-if="detailLog" class="modal-body">
<!-- ===== 业务概要 ===== -->
<div class="summary-card">
<div class="summary-icon" :class="statusBadge(detailLog.status)">
<i :class="statusBadge(detailLog.status) === 'success' ? 'fas fa-check-circle' : 'fas fa-exclamation-circle'"></i>
</div>
<div class="summary-text">
<div class="summary-title">{{ translateResult(detailLog.result) }}</div>
<div class="summary-subtitle">
{{ parseOperation(detailLog).module }} {{ parseOperation(detailLog).action }}
</div>
<div class="summary-meta">
操作人<strong>{{ detailLog.username }}</strong>
&nbsp;|&nbsp;
时间{{ formatTime(detailLog.createdAt) }}
&nbsp;|&nbsp;
耗时{{ formatDuration(detailLog.duration) }}
</div>
</div>
</div>
<!-- ===== 操作详情 ===== -->
<div class="detail-section">
<h4><i class="fas fa-list-ul"></i> 操作详情</h4>
<div class="param-list" v-if="parseParamsToReadable(detailLog.params).length">
<div class="param-item" v-for="p in parseParamsToReadable(detailLog.params)" :key="p.key">
<span class="param-key">{{ p.key }}</span>
<span class="param-eq">=</span>
<span class="param-value">{{ p.value }}</span>
</div>
</div>
<div v-else class="no-data">本次操作无额外参数</div>
</div>
<!-- ===== 错误信息 ===== -->
<div v-if="detailLog.errorMsg" class="detail-section error-section">
<h4><i class="fas fa-exclamation-triangle"></i> 错误信息</h4>
<div class="error-msg">{{ detailLog.errorMsg }}</div>
</div>
<!-- ===== 技术详情折叠 ===== -->
<div class="raw-toggle" :class="{ expanded: showRawData }">
<button class="raw-toggle-btn" @click="showRawData = !showRawData">
<i class="fas" :class="showRawData ? 'fa-chevron-up' : 'fa-chevron-down'"></i>
技术详情供技术人员排查用
</button>
<div v-if="showRawData" class="raw-content">
<div class="raw-field">
<span class="raw-label">记录编号</span>
<span class="raw-value">{{ detailLog.id }}</span>
</div>
<div class="raw-field">
<span class="raw-label">接口地址</span>
<code class="raw-code">{{ detailLog.method }}</code>
</div>
<div class="raw-field">
<span class="raw-label">客户端 IP</span>
<span class="raw-value">{{ detailLog.ip }}</span>
</div>
<div class="raw-field">
<span class="raw-label">原始参数</span>
<pre class="raw-pre">{{ detailLog.params || '(无)' }}</pre>
</div>
<div class="raw-field">
<span class="raw-label">原始结果</span>
<pre class="raw-pre">{{ detailLog.result || '(无)' }}</pre>
</div>
</div>
</div>
</div>
</div>
</div>
</transition>
</Teleport>
</AppLayout>
</template>
<style scoped>
/* ---- filter bar ---- */
.filter-bar {
margin-bottom: 20px;
}
.filter-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.filter-input {
padding: 8px 14px;
border-radius: 12px;
border: 1px solid #e2e8f0;
font-size: 13px;
color: #1e293b;
outline: none;
background: white;
transition: border-color 0.15s;
}
.filter-input:focus {
border-color: #4f7cff;
}
.filter-input.short {
width: 130px;
}
.filter-input.date {
width: 145px;
}
.filter-select {
padding: 8px 14px;
border-radius: 12px;
border: 1px solid #e2e8f0;
font-size: 13px;
color: #1e293b;
outline: none;
background: white;
cursor: pointer;
}
.filter-spacer {
flex: 1;
}
.date-sep {
font-size: 13px;
color: #8b9ab0;
}
.btn {
padding: 8px 18px;
border-radius: 20px;
border: 1px solid #e2e8f0;
background: white;
font-weight: 500;
font-size: 13px;
color: #1e293b;
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
transition: 0.15s;
white-space: nowrap;
}
.btn-primary {
background: #4f7cff;
border-color: #4f7cff;
color: white;
}
.btn-primary:hover { background: #3b6bf0; }
.btn-outline:hover {
background: #f0f5ff;
border-color: #4f7cff;
color: #4f7cff;
}
.btn-export {
background: #10b981;
border-color: #10b981;
color: white;
}
.btn-export:hover { background: #059669; }
.btn-export:disabled { opacity: 0.6; cursor: not-allowed; }
/* ---- table ---- */
.table-container {
background: white;
border-radius: 20px;
border: 1px solid #edf2f9;
overflow: hidden;
animation: fadeIn 0.35s ease;
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table thead th {
text-align: left;
padding: 12px 14px;
font-size: 12px;
font-weight: 600;
color: #6b7d94;
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 1px solid #edf2f9;
background: #fafbfd;
}
.data-table tbody td {
padding: 12px 14px;
font-size: 13px;
color: #1e293b;
border-bottom: 1px solid #f1f5fa;
vertical-align: middle;
}
.data-table tbody tr:last-child td {
border-bottom: none;
}
.data-table tbody tr:hover {
background: #f8fafd;
}
.cell-username {
font-weight: 500;
display: flex;
align-items: center;
gap: 8px;
}
.avatar-dot {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
background: #f0f5ff;
color: #4f7cff;
font-weight: 700;
font-size: 12px;
flex-shrink: 0;
}
.cell-desc {
font-weight: 500;
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cell-duration {
font-weight: 500;
color: #6b7d94;
}
.cell-time {
font-size: 12px;
color: #8b9ab0;
}
/* ---- module tag ---- */
.module-tag {
display: inline-block;
padding: 2px 10px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
background: #eff6ff;
color: #3b6bf0;
border: 1px solid #bfdbfe;
}
/* ---- action badge ---- */
.badge-action {
display: inline-block;
padding: 3px 10px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
}
.badge-action.action-create {
background: #d1fae5;
color: #065f46;
}
.badge-action.action-update {
background: #fef3c7;
color: #92400e;
}
.badge-action.action-delete {
background: #fee2e2;
color: #991b1b;
}
.badge-action.action-read {
background: #e0e7ff;
color: #3730a3;
}
/* ---- badge ---- */
.badge {
display: inline-block;
padding: 3px 10px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
}
.badge.success {
background: #d1fae5;
color: #065f46;
}
.badge.error {
background: #fee2e2;
color: #991b1b;
}
/* ---- detail button ---- */
.btn-detail {
width: 32px;
height: 32px;
border-radius: 10px;
border: 1px solid #e2e8f0;
background: white;
color: #6b7d94;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: 0.15s;
font-size: 13px;
}
.btn-detail:hover {
background: #f0f5ff;
border-color: #4f7cff;
color: #4f7cff;
}
/* ---- pagination ---- */
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 20px;
border-top: 1px solid #edf2f9;
}
.page-info {
font-size: 13px;
color: #8b9ab0;
}
.page-controls {
display: flex;
align-items: center;
gap: 8px;
}
.page-controls button {
width: 32px;
height: 32px;
border-radius: 10px;
border: 1px solid #e2e8f0;
background: white;
color: #3e4e62;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: 0.15s;
font-size: 12px;
}
.page-controls button:hover:not(:disabled) {
background: #f0f5ff;
border-color: #4f7cff;
color: #4f7cff;
}
.page-controls button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.page-num {
font-size: 13px;
font-weight: 600;
color: #1e293b;
padding: 0 8px;
}
/* ---- skeleton ---- */
.skeleton {
background: linear-gradient(90deg, #eef2f8 25%, #e0e7f0 50%, #eef2f8 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 8px;
}
.sk-row {
padding: 14px 20px;
border-bottom: 1px solid #f1f5fa;
}
.sk-cell {
height: 16px;
width: 70%;
}
/* ---- empty ---- */
.empty-section {
display: flex;
justify-content: center;
padding: 80px 0;
}
.empty-state {
text-align: center;
color: #8b9ab0;
}
.empty-icon {
font-size: 48px;
margin-bottom: 16px;
display: block;
}
.empty-state p {
font-size: 15px;
font-weight: 500;
color: #3e4e62;
margin-bottom: 6px;
}
.empty-hint {
font-size: 13px;
color: #94a3b8;
}
/* ---- modal ---- */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(11, 26, 51, 0.4);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: white;
border-radius: 24px;
width: 640px;
max-height: 85vh;
overflow-y: auto;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px;
border-bottom: 1px solid #edf2f9;
}
.modal-header h3 {
font-weight: 600;
font-size: 16px;
color: #0b1a33;
display: flex;
align-items: center;
}
.modal-header-icon {
margin-right: 10px;
color: #4f7cff;
}
.modal-close {
width: 36px;
height: 36px;
border-radius: 12px;
border: 1px solid #e2e8f0;
background: white;
color: #6b7d94;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.modal-close:hover {
background: #fee2e2;
border-color: #fecaca;
color: #ef4444;
}
.modal-body {
padding: 20px 24px;
}
/* ---- summary card ---- */
.summary-card {
display: flex;
gap: 16px;
padding: 18px;
background: #f8fafd;
border-radius: 16px;
border: 1px solid #edf2f9;
margin-bottom: 20px;
align-items: flex-start;
}
.summary-icon {
font-size: 28px;
flex-shrink: 0;
}
.summary-icon.success {
color: #10b981;
}
.summary-icon.error {
color: #ef4444;
}
.summary-text {
flex: 1;
}
.summary-title {
font-weight: 600;
font-size: 15px;
color: #1e293b;
margin-bottom: 4px;
}
.summary-subtitle {
font-size: 13px;
color: #6b7d94;
margin-bottom: 8px;
}
.summary-meta {
font-size: 12px;
color: #8b9ab0;
}
.summary-meta strong {
color: #3e4e62;
}
/* ---- detail section ---- */
.detail-section {
margin-bottom: 18px;
}
.detail-section h4 {
font-size: 13px;
font-weight: 600;
color: #3e4e62;
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 6px;
}
.error-section h4 {
color: #dc2626;
}
.param-list {
background: #f8fafd;
border: 1px solid #edf2f9;
border-radius: 12px;
overflow: hidden;
}
.param-item {
display: flex;
align-items: center;
padding: 10px 14px;
border-bottom: 1px solid #f1f5fa;
font-size: 13px;
}
.param-item:last-child {
border-bottom: none;
}
.param-key {
font-weight: 600;
color: #3e4e62;
min-width: 90px;
flex-shrink: 0;
}
.param-eq {
color: #cbd5e1;
margin: 0 8px;
}
.param-value {
color: #4f7cff;
word-break: break-all;
}
.no-data {
font-size: 13px;
color: #8b9ab0;
padding: 14px;
background: #f8fafd;
border-radius: 12px;
border: 1px solid #edf2f9;
text-align: center;
}
.error-msg {
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 12px;
padding: 14px;
font-size: 13px;
color: #991b1b;
line-height: 1.5;
}
/* ---- raw toggle ---- */
.raw-toggle {
margin-top: 20px;
border: 1px solid #e2e8f0;
border-radius: 14px;
overflow: hidden;
}
.raw-toggle-btn {
width: 100%;
padding: 12px 16px;
background: #fafbfd;
border: none;
font-size: 13px;
font-weight: 500;
color: #6b7d94;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
transition: background 0.15s;
}
.raw-toggle-btn:hover {
background: #f0f5ff;
color: #4f7cff;
}
.raw-toggle.expanded .raw-toggle-btn {
border-bottom: 1px solid #edf2f9;
color: #4f7cff;
}
.raw-content {
padding: 16px;
}
.raw-field {
margin-bottom: 14px;
}
.raw-field:last-child {
margin-bottom: 0;
}
.raw-label {
display: block;
font-size: 11px;
font-weight: 600;
color: #8b9ab0;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 6px;
}
.raw-value {
font-size: 13px;
color: #1e293b;
}
.raw-code {
display: block;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 12px;
padding: 10px 14px;
background: #f1f5f9;
border-radius: 8px;
color: #475569;
word-break: break-all;
}
.raw-pre {
font-family: 'Consolas', 'Monaco', monospace;
font-size: 12px;
padding: 10px 14px;
background: #f1f5f9;
border-radius: 8px;
color: #475569;
white-space: pre-wrap;
word-break: break-all;
max-height: 180px;
overflow-y: auto;
line-height: 1.5;
margin: 0;
}
/* ---- animations ---- */
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.modal-enter-active { animation: fadeIn 0.2s ease; }
.modal-leave-active { animation: fadeIn 0.15s ease reverse; }
</style>