增加登录日志功能

This commit is contained in:
2026-08-26 15:01:35 +08:00
parent a4f7a97763
commit e60cd00395
9 changed files with 1008 additions and 1 deletions
+58
View File
@@ -0,0 +1,58 @@
import request from "@/utils/request";
/**
* 获取登录日志列表
* @param {Object} params 查询参数
* @param {number} params.page 页码
* @param {number} params.pageSize 每页数量
* @param {string} params.keyword 关键词搜索(账号/姓名/租户/IP)
* @param {string} params.status 状态筛选
* @param {string} params.loginType 登录方式筛选
* @param {string} params.startTime 开始时间
* @param {string} params.endTime 结束时间
* @returns {Promise}
*/
export function getLoginLogs(params) {
return request({
url: "/backend/loginLogs",
method: "get",
params,
});
}
/**
* 获取登录日志详情
* @param {number|string} id 日志ID
* @returns {Promise}
*/
export function getLoginLogDetail(id) {
return request({
url: `/backend/loginLogs/${id}`,
method: "get",
});
}
/**
* 删除登录日志
* @param {number|string} id 日志ID
* @returns {Promise}
*/
export function deleteLoginLog(id) {
return request({
url: `/backend/loginLogs/${id}`,
method: "delete",
});
}
/**
* 批量删除登录日志
* @param {Array} ids 日志ID数组
* @returns {Promise}
*/
export function batchDeleteLoginLogs(ids) {
return request({
url: "/backend/loginLogs/batchDelete",
method: "post",
data: { ids },
});
}
@@ -0,0 +1,161 @@
<template>
<el-drawer v-model="visible" title="登录日志详情" size="50%">
<div class="log-detail" v-if="log">
<el-descriptions :column="2" border>
<el-descriptions-item label="日志ID" label-width="150px">
{{ log.id }}
</el-descriptions-item>
<el-descriptions-item label="登录状态" label-width="150px">
<el-tag :type="log.status === 1 ? 'success' : 'danger'" size="small">
{{ log.status === 1 ? "成功" : "失败" }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="用户ID" label-width="150px">
{{ log.user_id }}
</el-descriptions-item>
<el-descriptions-item label="用户姓名" label-width="150px">
{{ log.user_name || "-" }}
</el-descriptions-item>
<el-descriptions-item label="登录账号" label-width="150px">
{{ log.account }}
</el-descriptions-item>
<el-descriptions-item label="租户名称" label-width="150px">
{{ log.tenant_name || "-" }}
</el-descriptions-item>
<el-descriptions-item label="登录方式" label-width="150px">
<el-tag type="primary" size="small">
{{ getLoginTypeLabel(log.login_type) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="登录时间" label-width="150px">
{{ log.create_time }}
</el-descriptions-item>
<el-descriptions-item label="IP地址" label-width="150px">
{{ log.ip || "-" }}
</el-descriptions-item>
<el-descriptions-item label="结果信息" label-width="150px">
{{ log.message || "-" }}
</el-descriptions-item>
<el-descriptions-item label="用户代理" :span="2" label-width="150px">
<div class="user-agent-text">{{ log.user_agent || "无" }}</div>
</el-descriptions-item>
</el-descriptions>
</div>
<div v-else class="loading-container">
<el-empty description="加载中..." />
</div>
</el-drawer>
</template>
<script setup lang="ts">
import { ref, watch } from "vue";
import { getLoginLogDetail } from "@/api/loginLog";
interface LoginLog {
id: number;
tid: number | null;
user_id: number;
account: string;
user_name: string;
tenant_name: string;
login_type: string;
status: number;
message: string;
ip: string;
user_agent: string;
create_time: string;
}
const props = defineProps({
modelValue: {
type: Boolean,
default: false,
},
logId: {
type: Number,
default: undefined,
},
});
const emit = defineEmits(["update:modelValue"]);
const visible = ref(false);
const log = ref<LoginLog | null>(null);
// 登录方式标签
const getLoginTypeLabel = (type: string): string => {
const typeMap: Record<string, string> = {
password: "密码登录",
sms: "短信验证码",
email: "邮箱验证码",
};
return typeMap[type] || type;
};
// 获取日志详情
const fetchLogDetail = async (logId?: number) => {
const targetId = logId || props.logId;
if (!targetId) {
return;
}
log.value = null; // 清空之前的数据
try {
const res = await getLoginLogDetail(targetId);
if (res.code === 200) {
log.value = res.data;
} else {
console.error("获取登录日志详情失败:", res?.msg || "未知错误");
log.value = null;
}
} catch (error: any) {
console.error("获取登录日志详情失败", error);
log.value = null;
}
};
// 监听 modelValue 变化
watch(
() => props.modelValue,
(newVal) => {
visible.value = newVal;
if (newVal && props.logId) {
fetchLogDetail();
}
}
);
// 监听 visible 变化
watch(visible, (newVal) => {
if (!newVal) {
emit("update:modelValue", false);
log.value = null;
}
});
// 暴露方法给父组件
defineExpose({
open: (logId: number) => {
if (logId) {
fetchLogDetail(logId);
}
},
});
</script>
<style lang="less" scoped>
.log-detail {
padding: 20px;
}
.user-agent-text {
word-break: break-all;
color: #606266;
font-size: 12px;
}
.loading-container {
padding: 40px;
text-align: center;
}
</style>
+443
View File
@@ -0,0 +1,443 @@
<template>
<div class="container-box">
<div class="header-bar">
<h2>登录日志</h2>
<div class="header-actions">
<el-button @click="refresh">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
</div>
</div>
<el-divider></el-divider>
<!-- 搜索筛选 -->
<div class="filter-bar">
<el-form :inline="true" :model="filterForm" class="filter-form">
<el-form-item label="关键词">
<el-input
v-model="filterForm.keyword"
placeholder="搜索账号、姓名、租户、IP"
clearable
style="width: 200px"
@clear="handleSearch"
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item label="状态">
<el-select
v-model="filterForm.status"
placeholder="请选择状态"
clearable
style="width: 120px"
@change="handleSearch"
>
<el-option label="成功" value="1" />
<el-option label="失败" value="0" />
</el-select>
</el-form-item>
<el-form-item label="登录方式">
<el-select
v-model="filterForm.loginType"
placeholder="请选择登录方式"
clearable
style="width: 140px"
@change="handleSearch"
>
<el-option label="密码登录" value="password" />
<el-option label="短信验证码" value="sms" />
<el-option label="邮箱验证码" value="email" />
</el-select>
</el-form-item>
<el-form-item label="时间范围">
<el-date-picker
v-model="dateRange"
type="datetimerange"
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 350px"
@change="handleDateRangeChange"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
搜索
</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</div>
<!-- 登录日志列表 -->
<el-table
:data="logs"
style="width: 100%"
v-loading="loading"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55" align="center" />
<el-table-column prop="id" label="ID" width="60" align="center" />
<el-table-column
prop="user_name"
label="用户姓名"
width="110"
align="center"
/>
<el-table-column
prop="account"
label="登录账号"
width="130"
align="center"
/>
<el-table-column
prop="tenant_name"
label="租户名称"
width="130"
align="center"
/>
<el-table-column prop="login_type" label="登录方式" width="100" align="center">
<template #default="scope">
<el-tag type="primary" size="small">
{{ getLoginTypeLabel(scope.row.login_type) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="80" align="center">
<template #default="scope">
<el-tag
:type="scope.row.status === 1 ? 'success' : 'danger'"
size="small"
>
{{ scope.row.status === 1 ? "成功" : "失败" }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="message"
label="结果信息"
min-width="140"
show-overflow-tooltip
/>
<el-table-column prop="ip" label="IP地址" width="130" align="center" />
<el-table-column
prop="create_time"
label="登录时间"
width="180"
align="center"
/>
<el-table-column label="操作" width="120" align="center" fixed="right">
<template #default="scope">
<el-button
size="small"
type="primary"
link
@click="handleViewDetail(scope.row)"
>
详情
</el-button>
<el-button
size="small"
type="danger"
link
@click="handleDelete(scope.row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 批量删除 -->
<div class="batch-bar" v-if="selectedLogs.length > 0">
<el-button type="danger" plain @click="handleBatchDelete">
<el-icon><Delete /></el-icon>
批量删除 ({{ selectedLogs.length }})
</el-button>
</div>
<!-- 分页 -->
<div class="pagination-bar">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</div>
<!-- 详情对话框 -->
<DetailDialog
ref="detailDialogRef"
v-model="detailDialogVisible"
:log-id="currentLogId"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, nextTick } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Refresh, Delete, Search } from "@element-plus/icons-vue";
import {
getLoginLogs,
deleteLoginLog,
batchDeleteLoginLogs,
} from "@/api/loginLog";
import DetailDialog from "./components/detail.vue";
interface LoginLog {
id: number;
tid: number | null;
user_id: number;
account: string;
user_name: string;
tenant_name: string;
login_type: string;
status: number;
message: string;
ip: string;
user_agent: string;
create_time: string;
}
const page = ref(1);
const pageSize = ref(20);
const total = ref(0);
const loading = ref(false);
const logs = ref<LoginLog[]>([]);
const selectedLogs = ref<LoginLog[]>([]);
// 筛选表单
const filterForm = ref({
keyword: "",
status: "",
loginType: "",
startTime: "",
endTime: "",
});
const dateRange = ref<[string, string] | null>(null);
// 详情对话框
const detailDialogVisible = ref(false);
const detailDialogRef = ref<any>(null);
const currentLogId = ref<number | undefined>(undefined);
// 登录方式标签
const getLoginTypeLabel = (type: string): string => {
const typeMap: Record<string, string> = {
password: "密码登录",
sms: "短信验证码",
email: "邮箱验证码",
};
return typeMap[type] || type;
};
// 获取登录日志列表
const fetchLogs = async () => {
loading.value = true;
try {
const params: any = {
page: page.value,
pageSize: pageSize.value,
};
if (filterForm.value.keyword) {
params.keyword = filterForm.value.keyword;
}
if (filterForm.value.status !== "") {
params.status = filterForm.value.status;
}
if (filterForm.value.loginType) {
params.loginType = filterForm.value.loginType;
}
if (filterForm.value.startTime) {
params.startTime = filterForm.value.startTime;
}
if (filterForm.value.endTime) {
params.endTime = filterForm.value.endTime;
}
const res = await getLoginLogs(params);
if (res.code === 200) {
logs.value = res.data.list || [];
total.value = res.data.total || 0;
} else {
ElMessage.error(res.msg || "获取登录日志失败");
}
} catch (error: any) {
ElMessage.error(error?.message || "获取登录日志失败");
} finally {
loading.value = false;
}
};
// 刷新
const refresh = () => {
fetchLogs();
};
// 搜索
const handleSearch = () => {
page.value = 1;
fetchLogs();
};
// 重置
const handleReset = () => {
filterForm.value = {
keyword: "",
status: "",
loginType: "",
startTime: "",
endTime: "",
};
dateRange.value = null;
page.value = 1;
fetchLogs();
};
// 时间范围变化
const handleDateRangeChange = (val: [string, string] | null) => {
if (val && val.length === 2) {
filterForm.value.startTime = val[0];
filterForm.value.endTime = val[1];
} else {
filterForm.value.startTime = "";
filterForm.value.endTime = "";
}
handleSearch();
};
// 分页改变
const handlePageChange = (val: number) => {
page.value = val;
fetchLogs();
};
// 每页数量改变
const handleSizeChange = (val: number) => {
pageSize.value = val;
page.value = 1;
fetchLogs();
};
// 选择改变
const handleSelectionChange = (selection: LoginLog[]) => {
selectedLogs.value = selection;
};
// 删除单条
const handleDelete = (log: LoginLog) => {
ElMessageBox.confirm("确定删除该条登录日志吗?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
}).then(async () => {
try {
const res = await deleteLoginLog(log.id);
if (res.code === 200) {
ElMessage.success("删除成功");
fetchLogs();
} else {
ElMessage.error(res.msg || "删除失败");
}
} catch (error: any) {
ElMessage.error(error?.message || "删除失败");
}
});
};
// 批量删除
const handleBatchDelete = () => {
const ids = selectedLogs.value.map((item) => item.id);
ElMessageBox.confirm(
`确定删除选中的 ${ids.length} 条登录日志吗?`,
"提示",
{
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
}
).then(async () => {
try {
const res = await batchDeleteLoginLogs(ids);
if (res.code === 200) {
ElMessage.success("批量删除成功");
fetchLogs();
} else {
ElMessage.error(res.msg || "批量删除失败");
}
} catch (error: any) {
ElMessage.error(error?.message || "批量删除失败");
}
});
};
// 查看详情
const handleViewDetail = (log: LoginLog) => {
currentLogId.value = log.id;
detailDialogVisible.value = true;
nextTick(() => {
if (detailDialogRef.value && detailDialogRef.value.open) {
detailDialogRef.value.open(log.id);
}
});
};
onMounted(() => {
fetchLogs();
});
</script>
<style lang="less" scoped>
.container-box {
padding: 20px;
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.header-actions {
display: flex;
gap: 10px;
}
}
.filter-bar {
margin-bottom: 20px;
padding: 20px;
border-radius: 4px;
.filter-form {
margin: 0;
}
}
.batch-bar {
margin-top: 12px;
}
.pagination-bar {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
</style>