291 lines
9.5 KiB
Vue
291 lines
9.5 KiB
Vue
<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>
|