financial_system/frontend/src/views/OperationLogsView.vue
2026-06-18 13:21:20 +08:00

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>