Files
certificate-system/frontend/src/views/AdminLogs.vue
2026-06-06 19:32:24 +08:00

291 lines
9.5 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.
<template>
<section>
<header class="page-head">
<div>
<h2>操作日志</h2>
<p>记录后台关键动作支持筛选导出和查看完整详情</p>
</div>
<div class="head-actions">
<el-button @click="loadLogs">刷新</el-button>
<el-button @click="cleanupLogs">清理过期日志</el-button>
<el-button type="primary" @click="exportLogs">导出日志</el-button>
</div>
</header>
<el-card class="panel" shadow="never">
<p class="retention-note">日志保留策略普通后台日志公开查询和公开下载日志保留 180 高风险后台操作保留 365 </p>
<div class="filters">
<el-input v-model.trim="filters.keyword" clearable placeholder="搜索摘要、对象ID、详情" @keyup.enter="loadLogs" />
<el-select v-model="filters.action" clearable placeholder="操作类型">
<el-option v-for="item in actionOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<el-select v-model="filters.object_type" clearable placeholder="对象类型">
<el-option label="项目" value="project_course" />
<el-option label="学员" value="learner" />
<el-option label="证书" value="certificate" />
<el-option label="导入批次" value="import_batch" />
<el-option label="公开访问" value="public_access" />
</el-select>
<el-select v-model="filters.risk" clearable placeholder="风险等级">
<el-option label="高风险" value="high" />
<el-option label="中风险" value="medium" />
<el-option label="低风险" value="low" />
</el-select>
<el-button type="primary" @click="loadLogs">搜索</el-button>
<el-button @click="resetFilters">重置</el-button>
</div>
<div v-if="totalPages > 1" class="pager">
<el-button :disabled="page <= 1" @click="loadLogs(page - 1)">上一页</el-button>
<template v-for="item in pageItems" :key="item">
<span v-if="item === '...'" class="ellipsis">...</span>
<el-button v-else :type="item === page ? 'primary' : 'default'" @click="loadLogs(Number(item))">{{ item }}</el-button>
</template>
<el-button :disabled="page >= totalPages" @click="loadLogs(page + 1)">下一页</el-button>
<span class="total"> {{ total }} </span>
</div>
<el-table :data="logs" border>
<el-table-column prop="created_at" label="时间" width="190" />
<el-table-column prop="admin_user_id" label="操作人ID" width="100" />
<el-table-column prop="action_label" label="操作" width="150" />
<el-table-column prop="object_label" label="对象" width="100" />
<el-table-column prop="object_id" label="对象ID" width="110" />
<el-table-column label="风险" width="100">
<template #default="{ row }">
<el-tag :type="riskTag(row.risk)">{{ row.risk_label }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="summary" label="摘要" min-width="260" show-overflow-tooltip />
<el-table-column label="操作" width="90">
<template #default="{ row }">
<el-button size="small" @click="openDetail(row)">详情</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="totalPages > 1" class="pager bottom">
<el-button :disabled="page <= 1" @click="loadLogs(page - 1)">上一页</el-button>
<template v-for="item in pageItems" :key="`bottom-${item}`">
<span v-if="item === '...'" class="ellipsis">...</span>
<el-button v-else :type="item === page ? 'primary' : 'default'" @click="loadLogs(Number(item))">{{ item }}</el-button>
</template>
<el-button :disabled="page >= totalPages" @click="loadLogs(page + 1)">下一页</el-button>
<span class="total"> {{ total }} </span>
</div>
</el-card>
<el-dialog v-model="detailVisible" title="操作日志详情" width="720px">
<el-descriptions v-if="current" :column="1" border>
<el-descriptions-item label="时间">{{ current.created_at }}</el-descriptions-item>
<el-descriptions-item label="操作人ID">{{ current.admin_user_id || "-" }}</el-descriptions-item>
<el-descriptions-item label="操作">{{ current.action_label }}</el-descriptions-item>
<el-descriptions-item label="对象">{{ current.object_label }} #{{ current.object_id || "-" }}</el-descriptions-item>
<el-descriptions-item label="风险等级">
<el-tag :type="riskTag(current.risk)">{{ current.risk_label }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="摘要">{{ current.summary }}</el-descriptions-item>
</el-descriptions>
<pre class="detail-json">{{ prettyDetail }}</pre>
</el-dialog>
</section>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { http, type OperationLog } from "../api";
import { downloadFile } from "../download";
const logs = ref<OperationLog[]>([]);
const current = ref<OperationLog | null>(null);
const detailVisible = ref(false);
const filters = reactive({ keyword: "", action: "", object_type: "", risk: "" });
const page = ref(1);
const pageSize = 20;
const total = ref(0);
const totalPages = ref(1);
const actionOptions = [
{ value: "create_project", label: "新增项目" },
{ value: "update_project", label: "修改项目" },
{ value: "create_learner", label: "新增学员" },
{ value: "update_learner", label: "修改学员" },
{ value: "delete_learner", label: "删除学员" },
{ value: "create_certificate", label: "新增证书" },
{ value: "void_certificate", label: "作废证书" },
{ value: "regenerate_public_token", label: "重置证书链接" },
{ value: "upload_import_file", label: "上传导入文件" },
{ value: "confirm_import_batch", label: "确认导入" },
{ value: "delete_import_batch", label: "删除导入批次" },
{ value: "export_certificates", label: "导出证书" },
{ value: "public_search_certificate", label: "公开查询证书" },
{ value: "public_download_certificate", label: "公开下载证书" },
];
const prettyDetail = computed(() => {
if (!current.value?.detail_json) return "{}";
try {
return JSON.stringify(JSON.parse(current.value.detail_json), null, 2);
} catch {
return current.value.detail_json;
}
});
const pageItems = computed<Array<number | string>>(() => compactPages(page.value, totalPages.value));
function queryParams() {
const params = new URLSearchParams();
Object.entries(filters).forEach(([key, value]) => {
if (value) params.set(key, value);
});
params.set("page", String(page.value));
params.set("page_size", String(pageSize));
return params.toString();
}
function compactPages(current: number, totalPageCount: number) {
const pages = new Set<number>();
pages.add(1);
for (let item = current - 2; item <= current + 2; item += 1) {
if (item > 1 && item < totalPageCount) pages.add(item);
}
if (totalPageCount > 1) pages.add(totalPageCount);
const sorted = Array.from(pages).sort((a, b) => a - b);
const result: Array<number | string> = [];
sorted.forEach((item, index) => {
if (index > 0 && item - sorted[index - 1] > 1) result.push("...");
result.push(item);
});
return result;
}
function riskTag(risk?: string | null) {
if (risk === "high") return "danger";
if (risk === "medium") return "warning";
return "success";
}
function openDetail(row: OperationLog) {
current.value = row;
detailVisible.value = true;
}
function resetFilters() {
filters.keyword = "";
filters.action = "";
filters.object_type = "";
filters.risk = "";
loadLogs(1);
}
async function loadLogs(nextPage = 1) {
page.value = nextPage;
const query = queryParams();
const { data } = await http.get<{ items: OperationLog[]; total: number; page: number; page_size: number; total_pages: number }>(`/admin/logs${query ? `?${query}` : ""}`);
logs.value = data.items;
total.value = data.total;
page.value = data.page;
totalPages.value = data.total_pages;
}
async function exportLogs() {
const query = queryParams();
await downloadFile(`/admin/logs/export${query ? `?${query}` : ""}`, "operation-logs.xlsx");
}
async function cleanupLogs() {
await http.post("/admin/logs/cleanup");
await loadLogs();
}
onMounted(loadLogs);
</script>
<style scoped>
.page-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
margin-bottom: 16px;
}
.page-head p {
margin: 6px 0 0;
color: #64748b;
}
.head-actions {
display: flex;
gap: 8px;
}
.panel {
border-radius: 8px;
}
.retention-note {
margin: 0 0 12px;
color: #64748b;
font-size: 13px;
}
.filters {
display: grid;
grid-template-columns: minmax(220px, 1fr) repeat(3, 160px) auto auto;
gap: 10px;
margin-bottom: 14px;
}
.filters :deep(.el-select__wrapper) {
padding-left: 14px;
padding-right: 14px;
}
.filters :deep(.el-select__suffix) {
margin-left: 14px;
}
.pager {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 6px;
flex-wrap: wrap;
margin: 12px 0;
}
.pager.bottom {
margin-top: 14px;
margin-bottom: 0;
}
.ellipsis,
.total {
color: #64748b;
font-size: 13px;
}
.ellipsis {
padding: 0 4px;
}
.detail-json {
margin: 14px 0 0;
max-height: 360px;
overflow: auto;
white-space: pre-wrap;
border: 1px solid #dfe9ec;
border-radius: 8px;
background: #f8fafc;
padding: 14px;
}
@media (max-width: 1100px) {
.filters {
grid-template-columns: 1fr 1fr;
}
}
</style>