291 lines
9.4 KiB
Vue
291 lines
9.4 KiB
Vue
<script setup lang="ts">
|
|
import {
|
|
CheckCircle2,
|
|
Clock3,
|
|
Loader2,
|
|
RefreshCw,
|
|
ScrollText,
|
|
Search,
|
|
XCircle,
|
|
} from "@lucide/vue";
|
|
import { computed, onMounted, reactive, ref } from "vue";
|
|
|
|
import { ApiError } from "../api/http";
|
|
import { listOperationLogs } from "../api/operationLogs";
|
|
import type { OperationLog, OperationLogListResponse } from "../api/types";
|
|
import StatCard from "../components/StatCard.vue";
|
|
|
|
const loading = ref(false);
|
|
const errorMessage = ref("");
|
|
const page = ref(1);
|
|
const pageSize = ref(20);
|
|
const response = ref<OperationLogListResponse>({
|
|
items: [],
|
|
total: 0,
|
|
page: 1,
|
|
page_size: 20,
|
|
});
|
|
|
|
const filters = reactive({
|
|
keyword: "",
|
|
username: "",
|
|
module: "",
|
|
action: "",
|
|
status: "",
|
|
});
|
|
|
|
const moduleOptions = [
|
|
{ value: "", label: "全部模块" },
|
|
{ value: "auth", label: "认证" },
|
|
{ value: "payroll", label: "工资" },
|
|
{ value: "system", label: "系统" },
|
|
];
|
|
|
|
const actionOptions = [
|
|
{ value: "", label: "全部动作" },
|
|
{ value: "login", label: "用户登录" },
|
|
{ value: "profile.update", label: "修改资料" },
|
|
{ value: "avatar.update", label: "修改头像" },
|
|
{ value: "password.change", label: "修改密码" },
|
|
{ value: "user.create", label: "创建用户" },
|
|
{ value: "user.list", label: "查看用户" },
|
|
{ value: "employee.list", label: "查看员工" },
|
|
{ value: "employee.page", label: "分页员工" },
|
|
{ value: "employee.create", label: "新增员工" },
|
|
{ value: "employee.update", label: "更新员工" },
|
|
{ value: "salary_profile.list", label: "查看薪资" },
|
|
{ value: "salary_profile.upsert", label: "保存薪资" },
|
|
{ value: "payroll.excel.calculate", label: "Excel计算" },
|
|
{ value: "payroll.dingtalk.calculate", label: "钉钉计算" },
|
|
{ value: "payroll.job.view", label: "查看任务" },
|
|
{ value: "payroll.file.download", label: "下载结果" },
|
|
{ value: "operation_log.view", label: "查看日志" },
|
|
];
|
|
|
|
const logs = computed(() => response.value.items);
|
|
const totalPages = computed(() => Math.max(1, Math.ceil(response.value.total / pageSize.value)));
|
|
const successCount = computed(() => logs.value.filter((log) => log.status === "success").length);
|
|
const failedCount = computed(() => logs.value.filter((log) => log.status === "failed").length);
|
|
const latestLogAction = computed(() => (logs.value[0] ? actionLabel(logs.value[0].action) : "暂无"));
|
|
|
|
onMounted(loadLogs);
|
|
|
|
async function loadLogs(): Promise<void> {
|
|
loading.value = true;
|
|
errorMessage.value = "";
|
|
try {
|
|
response.value = await listOperationLogs({
|
|
page: page.value,
|
|
page_size: pageSize.value,
|
|
keyword: filters.keyword.trim(),
|
|
username: filters.username.trim(),
|
|
module: filters.module,
|
|
action: filters.action,
|
|
status: filters.status,
|
|
});
|
|
page.value = response.value.page;
|
|
pageSize.value = response.value.page_size;
|
|
} catch (error) {
|
|
errorMessage.value = error instanceof ApiError ? error.message : "操作日志加载失败";
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
function searchLogs(): void {
|
|
page.value = 1;
|
|
loadLogs();
|
|
}
|
|
|
|
function resetFilters(): void {
|
|
Object.assign(filters, {
|
|
keyword: "",
|
|
username: "",
|
|
module: "",
|
|
action: "",
|
|
status: "",
|
|
});
|
|
searchLogs();
|
|
}
|
|
|
|
function previousPage(): void {
|
|
if (page.value <= 1) {
|
|
return;
|
|
}
|
|
page.value -= 1;
|
|
loadLogs();
|
|
}
|
|
|
|
function nextPage(): void {
|
|
if (page.value >= totalPages.value) {
|
|
return;
|
|
}
|
|
page.value += 1;
|
|
loadLogs();
|
|
}
|
|
|
|
function formatDate(value: string): string {
|
|
return new Date(value).toLocaleString();
|
|
}
|
|
|
|
function moduleLabel(module: string): string {
|
|
return moduleOptions.find((item) => item.value === module)?.label || module || "-";
|
|
}
|
|
|
|
function actionLabel(action: string): string {
|
|
return actionOptions.find((item) => item.value === action)?.label || action || "-";
|
|
}
|
|
|
|
function roleLabel(role: string): string {
|
|
const labels: Record<string, string> = {
|
|
superuser: "超级用户",
|
|
manager: "管理者",
|
|
viewer: "查看权限",
|
|
};
|
|
return labels[role] || role || "匿名";
|
|
}
|
|
|
|
function statusLabel(status: string): string {
|
|
return status === "success" ? "成功" : "失败";
|
|
}
|
|
|
|
function statusClass(status: string): string {
|
|
return status === "success" ? "green" : "red";
|
|
}
|
|
|
|
function targetLabel(log: OperationLog): string {
|
|
if (!log.target_type && !log.target_id) {
|
|
return "-";
|
|
}
|
|
return [log.target_type, log.target_id].filter(Boolean).join(" / ");
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="view-stack">
|
|
<header class="page-header">
|
|
<div>
|
|
<h1>操作日志</h1>
|
|
<p>查看登录、工资计算、用户管理和下载等关键操作。</p>
|
|
</div>
|
|
<button class="secondary-button" type="button" :disabled="loading" @click="loadLogs">
|
|
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
|
|
<RefreshCw v-else :size="18" aria-hidden="true" />
|
|
<span>刷新</span>
|
|
</button>
|
|
</header>
|
|
|
|
<section class="stats-grid">
|
|
<StatCard title="日志总数" :value="String(response.total)" :icon="ScrollText" tone="blue" meta="全部记录" />
|
|
<StatCard title="本页成功" :value="String(successCount)" :icon="CheckCircle2" tone="green" meta="当前筛选" />
|
|
<StatCard title="本页失败" :value="String(failedCount)" :icon="XCircle" tone="red" meta="当前筛选" />
|
|
<StatCard title="最新操作" :value="latestLogAction" :icon="Clock3" tone="neutral" meta="当前页" />
|
|
</section>
|
|
|
|
<section class="panel">
|
|
<div class="panel-header">
|
|
<div>
|
|
<h2>筛选条件</h2>
|
|
<p>按操作人、模块、动作、状态或关键字查询</p>
|
|
</div>
|
|
</div>
|
|
<form class="form-grid log-filter-form" @submit.prevent="searchLogs">
|
|
<label class="field">
|
|
<span>关键字</span>
|
|
<input v-model="filters.keyword" type="search" placeholder="动作、对象、详情..." />
|
|
</label>
|
|
<label class="field">
|
|
<span>操作人</span>
|
|
<input v-model="filters.username" type="text" placeholder="用户名" />
|
|
</label>
|
|
<label class="field">
|
|
<span>模块</span>
|
|
<select v-model="filters.module">
|
|
<option v-for="item in moduleOptions" :key="item.value" :value="item.value">{{ item.label }}</option>
|
|
</select>
|
|
</label>
|
|
<label class="field">
|
|
<span>动作</span>
|
|
<select v-model="filters.action">
|
|
<option v-for="item in actionOptions" :key="item.value" :value="item.value">{{ item.label }}</option>
|
|
</select>
|
|
</label>
|
|
<label class="field">
|
|
<span>状态</span>
|
|
<select v-model="filters.status">
|
|
<option value="">全部状态</option>
|
|
<option value="success">成功</option>
|
|
<option value="failed">失败</option>
|
|
</select>
|
|
</label>
|
|
<div class="form-actions align-right span-row">
|
|
<button class="secondary-button" type="button" :disabled="loading" @click="resetFilters">重置</button>
|
|
<button class="primary-button" type="submit" :disabled="loading">
|
|
<Loader2 v-if="loading" class="spin" :size="18" aria-hidden="true" />
|
|
<Search v-else :size="18" aria-hidden="true" />
|
|
<span>{{ loading ? "查询中" : "查询" }}</span>
|
|
</button>
|
|
</div>
|
|
</form>
|
|
<p v-if="errorMessage" class="form-error">{{ errorMessage }}</p>
|
|
</section>
|
|
|
|
<section class="panel table-panel">
|
|
<div class="panel-header">
|
|
<div>
|
|
<h2>日志列表</h2>
|
|
<p>第 {{ page }} / {{ totalPages }} 页,共 {{ response.total }} 条</p>
|
|
</div>
|
|
</div>
|
|
<div class="table-wrap">
|
|
<table class="data-table operation-log-table">
|
|
<thead>
|
|
<tr>
|
|
<th>时间</th>
|
|
<th>操作人</th>
|
|
<th>模块/动作</th>
|
|
<th>对象</th>
|
|
<th>状态</th>
|
|
<th>来源</th>
|
|
<th>详情</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="log in logs" :key="log.id">
|
|
<td class="mono">{{ formatDate(log.created_at) }}</td>
|
|
<td>
|
|
<strong>{{ log.username || "匿名" }}</strong>
|
|
<span>{{ roleLabel(log.role) }}</span>
|
|
</td>
|
|
<td>
|
|
<strong>{{ moduleLabel(log.module) }}</strong>
|
|
<span>{{ actionLabel(log.action) }}</span>
|
|
</td>
|
|
<td class="mono">{{ targetLabel(log) }}</td>
|
|
<td>
|
|
<span class="status-chip" :class="statusClass(log.status)">{{ statusLabel(log.status) }}</span>
|
|
</td>
|
|
<td>
|
|
<strong>{{ log.ip_address || "-" }}</strong>
|
|
<span :title="log.user_agent">{{ log.user_agent || "-" }}</span>
|
|
</td>
|
|
<td class="log-detail-cell" :title="log.detail">{{ log.detail || "-" }}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div v-if="!logs.length && !loading" class="empty-state">
|
|
<p>暂无操作日志</p>
|
|
</div>
|
|
<div class="form-actions align-right">
|
|
<button class="secondary-button" type="button" :disabled="loading || page <= 1" @click="previousPage">
|
|
上一页
|
|
</button>
|
|
<button class="secondary-button" type="button" :disabled="loading || page >= totalPages" @click="nextPage">
|
|
下一页
|
|
</button>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</template>
|