174 lines
6.0 KiB
Vue
174 lines
6.0 KiB
Vue
<template>
|
||
<div>
|
||
<h2 class="h4 mb-4">知识库操作日志</h2>
|
||
|
||
<!-- 筛选区 -->
|
||
<div class="card card-body mb-4 shadow-sm">
|
||
<div class="row g-2 align-items-end">
|
||
<div class="col-6 col-md-3">
|
||
<label class="form-label small mb-0">操作类型</label>
|
||
<select v-model="filter.action" class="form-select form-select-sm">
|
||
<option value="">全部</option>
|
||
<option value="upload">upload(上传)</option>
|
||
<option value="download">download(下载)</option>
|
||
<option value="delete">delete(删除)</option>
|
||
<option value="create_kb">create_kb(创建知识库)</option>
|
||
<option value="delete_kb">delete_kb(删除知识库)</option>
|
||
<option value="permission_change">permission_change(权限变更)</option>
|
||
</select>
|
||
</div>
|
||
<div class="col-6 col-md-2">
|
||
<label class="form-label small mb-0">操作者 ID</label>
|
||
<input v-model.number="filter.actor_id" type="number" class="form-control form-control-sm" placeholder="留空=全部" />
|
||
</div>
|
||
<div class="col-6 col-md-2">
|
||
<label class="form-label small mb-0">知识库 ID</label>
|
||
<input v-model.number="filter.kb_id" type="number" class="form-control form-control-sm" placeholder="留空=全部" />
|
||
</div>
|
||
<div class="col-auto d-flex gap-2">
|
||
<button class="btn btn-sm btn-primary" @click="applyFilter">搜索</button>
|
||
<button class="btn btn-sm btn-outline-secondary" @click="resetFilter">重置</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="loading" class="text-muted">加载中…</div>
|
||
<div v-else>
|
||
<div class="table-responsive">
|
||
<table class="table table-sm table-hover bg-white shadow-sm align-middle small">
|
||
<thead class="table-light">
|
||
<tr>
|
||
<th>时间</th>
|
||
<th>操作者</th>
|
||
<th>操作</th>
|
||
<th>被操作用户</th>
|
||
<th>部门</th>
|
||
<th>知识库</th>
|
||
<th>文件</th>
|
||
<th>IP</th>
|
||
<th>详情</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="r in items" :key="r.id">
|
||
<td class="text-nowrap text-muted">{{ fmt(r.created_at) }}</td>
|
||
<td>
|
||
<span class="fw-semibold">{{ r.actor_name || r.actor_id }}</span>
|
||
</td>
|
||
<td>
|
||
<span class="badge" :class="actionBadge(r.action)">{{ r.action }}</span>
|
||
</td>
|
||
<td>{{ r.target_name || (r.target_user_id ? '#' + r.target_user_id : '—') }}</td>
|
||
<td>{{ r.department_name || (r.department_id ? '#' + r.department_id : '—') }}</td>
|
||
<td>{{ r.kb_name || (r.kb_id ? '#' + r.kb_id : '—') }}</td>
|
||
<td class="text-truncate" style="max-width:140px" :title="r.file_name">
|
||
{{ r.file_name || '—' }}
|
||
</td>
|
||
<td>{{ r.ip || '—' }}</td>
|
||
<td>
|
||
<span v-if="r.metadata" class="text-muted small">{{ metaStr(r.metadata) }}</span>
|
||
<span v-else>—</span>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
<p v-if="!items.length" class="text-muted small">暂无日志记录。</p>
|
||
</div>
|
||
|
||
<!-- 分页 -->
|
||
<nav v-if="total > pageSize" class="mt-2">
|
||
<ul class="pagination pagination-sm">
|
||
<li class="page-item" :class="{ disabled: page <= 1 }">
|
||
<a class="page-link" href="#" @click.prevent="page > 1 && page-- && load()">上一页</a>
|
||
</li>
|
||
<li class="page-item disabled">
|
||
<span class="page-link">{{ page }} / {{ totalPages }}(共 {{ total }} 条)</span>
|
||
</li>
|
||
<li class="page-item" :class="{ disabled: page >= totalPages }">
|
||
<a class="page-link" href="#" @click.prevent="page < totalPages && page++ && load()">下一页</a>
|
||
</li>
|
||
</ul>
|
||
</nav>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { computed, onMounted, reactive, ref } from "vue";
|
||
import http from "../api/http";
|
||
|
||
const items = ref([]);
|
||
const total = ref(0);
|
||
const loading = ref(false);
|
||
const page = ref(1);
|
||
const pageSize = 30;
|
||
|
||
const filter = reactive({ action: "", actor_id: null, kb_id: null });
|
||
const applied = reactive({ action: "", actor_id: null, kb_id: null });
|
||
|
||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)));
|
||
|
||
async function load() {
|
||
loading.value = true;
|
||
try {
|
||
const params = { page: page.value, page_size: pageSize };
|
||
if (applied.action) params.action = applied.action;
|
||
if (applied.actor_id) params.actor_id = applied.actor_id;
|
||
if (applied.kb_id) params.kb_id = applied.kb_id;
|
||
const { data } = await http.get("/admin/audit-logs", { params });
|
||
const raw = data?.data;
|
||
items.value = raw?.items || [];
|
||
total.value = raw?.total || 0;
|
||
} catch (e) {
|
||
console.error(e);
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
function applyFilter() {
|
||
Object.assign(applied, filter);
|
||
page.value = 1;
|
||
load();
|
||
}
|
||
|
||
function resetFilter() {
|
||
Object.assign(filter, { action: "", actor_id: null, kb_id: null });
|
||
Object.assign(applied, { action: "", actor_id: null, kb_id: null });
|
||
page.value = 1;
|
||
load();
|
||
}
|
||
|
||
function fmt(t) {
|
||
if (!t) return "—";
|
||
try { return new Date(t).toLocaleString("zh-CN"); } catch { return t; }
|
||
}
|
||
|
||
function actionBadge(action) {
|
||
const m = {
|
||
upload: "bg-success",
|
||
download: "bg-info text-dark",
|
||
delete: "bg-danger",
|
||
archive: "bg-secondary",
|
||
create_kb: "bg-primary",
|
||
delete_kb: "bg-danger",
|
||
permission_change: "bg-warning text-dark",
|
||
};
|
||
return m[action] || "bg-secondary";
|
||
}
|
||
|
||
function metaStr(meta) {
|
||
if (!meta) return "";
|
||
try {
|
||
const obj = typeof meta === "string" ? JSON.parse(meta) : meta;
|
||
return Object.entries(obj)
|
||
.map(([k, v]) => `${k}=${JSON.stringify(v)}`)
|
||
.join(" ");
|
||
} catch {
|
||
return String(meta);
|
||
}
|
||
}
|
||
|
||
onMounted(load);
|
||
</script>
|