feat(profile): 重构个人中心页面并优化日志模块

- 重构个人中心页面布局和样式
- 优化日志模块类型定义和页面展示
- 添加用户统计接口
- 更新环境配置切换到线上API
This commit is contained in:
Ray.Hao
2026-03-23 21:34:57 +08:00
parent e49ca1ef54
commit 834df6cd83
8 changed files with 425 additions and 319 deletions

View File

@@ -6,13 +6,9 @@ VITE_APP_TITLE=vue3-element-admin
VITE_APP_BASE_API=/dev-api VITE_APP_BASE_API=/dev-api
# 接口地址 # 接口地址
# VITE_APP_API_URL=https://api.youlai.tech # 线上 VITE_APP_API_URL=https://api.youlai.tech # 线上
# VITE_APP_API_URL=https://api.youlai.tech/v2 # 线上(多租户) # VITE_APP_API_URL=https://api.youlai.tech/v2 # 线上(多租户)
VITE_APP_API_URL=http://localhost:8000 # 本地 # VITE_APP_API_URL=http://localhost:8000 # 本地
# SSE 端点(默认 /api/v1/sse/connect
# VITE_APP_SSE_ENDPOINT=/api/v1/sse/connect
# 启用 Mock 服务(true:开启 false:关闭) # 启用 Mock 服务(true:开启 false:关闭)
VITE_MOCK_DEV_SERVER=false VITE_MOCK_DEV_SERVER=false

View File

@@ -1,13 +1,13 @@
import request from "@/utils/request"; import request from "@/utils/request";
import type { VisitTrendQueryParams, VisitTrendDetail, VisitStatsDetail } from "@/types/api"; import type { VisitTrendQueryParams, VisitTrendDetail, VisitStatsDetail } from "@/types/api";
const STATISTICS_BASE_URL = "/api/v1/statistics"; const STATISTICS_BASE_URL = "/api/v1/logs";
const StatisticsAPI = { const StatisticsAPI = {
/** 获取访问趋势统计 */ /** 获取访问趋势统计 */
getVisitTrend(queryParams: VisitTrendQueryParams) { getVisitTrend(queryParams: VisitTrendQueryParams) {
return request<any, VisitTrendDetail>({ return request<any, VisitTrendDetail>({
url: `${STATISTICS_BASE_URL}/visits/trend`, url: `${STATISTICS_BASE_URL}/views/trend`,
method: "get", method: "get",
params: queryParams, params: queryParams,
}); });
@@ -15,7 +15,7 @@ const StatisticsAPI = {
/** 获取访问概览统计 */ /** 获取访问概览统计 */
getVisitOverview() { getVisitOverview() {
return request<any, VisitStatsDetail>({ return request<any, VisitStatsDetail>({
url: `${STATISTICS_BASE_URL}/visits/overview`, url: `${STATISTICS_BASE_URL}/views`,
method: "get", method: "get",
}); });
}, },

View File

@@ -11,6 +11,9 @@ import type {
MobileUpdateForm, MobileUpdateForm,
EmailUpdateForm, EmailUpdateForm,
OptionItem, OptionItem,
UserEventQueryParams,
UserEventItem,
LoginDeviceItem,
} from "@/types/api"; } from "@/types/api";
const USER_BASE_URL = "/api/v1/users"; const USER_BASE_URL = "/api/v1/users";

View File

@@ -67,7 +67,7 @@ export function useNotice() {
// ============================================ // ============================================
function setupSubscription() { function setupSubscription() {
if (unsubscribe || !isConnected.value) return; if (unsubscribe) return;
// 订阅新通知事件 // 订阅新通知事件
unsubscribe = on(NOTICE_EVENT, (data: any) => { unsubscribe = on(NOTICE_EVENT, (data: any) => {

View File

@@ -6,7 +6,7 @@ import type { BaseQueryParams } from "./common";
/** 日志分页查询参数 */ /** 日志分页查询参数 */
export interface LogQueryParams extends BaseQueryParams { export interface LogQueryParams extends BaseQueryParams {
/** 搜索关键字 */ /** 搜索关键字(IP/操作人) */
keywords?: string; keywords?: string;
/** 操作时间 */ /** 操作时间 */
createTime?: [string, string]; createTime?: [string, string];
@@ -15,29 +15,39 @@ export interface LogQueryParams extends BaseQueryParams {
/** 日志分页对象 */ /** 日志分页对象 */
export interface LogItem { export interface LogItem {
/** 日志ID */ /** 日志ID */
id: string; id: number;
/** 日志模块 */ /** 模块 */
module: string; module?: string;
/** 日志内容 */ /** 操作类型 */
content: string; actionType?: string;
/** 操作标题 */
title?: string;
/** 自定义日志内容 */
content?: string;
/** 操作人ID */
operatorId?: number;
/** 操作人名称 */
operatorName?: string;
/** 请求路径 */ /** 请求路径 */
requestUri?: string; requestUri?: string;
/** 请求方法 */ /** 请求方法 */
method?: string; requestMethod?: string;
/** IP地址 */ /** IP地址 */
ip: string; ip?: string;
/** 地区 */ /** 地区 */
region: string; region?: string;
/** 设备 */
device?: string;
/** 浏览器 */ /** 浏览器 */
browser: string; browser?: string;
/** 终端系统 */ /** 操作系统 */
os: string; os?: string;
/** 状态0失败 1成功 */
status?: number;
/** 执行时间(毫秒) */ /** 执行时间(毫秒) */
executionTime: number; executionTime?: number;
/** 创建人ID */ /** 错误信息 */
createBy?: string; errorMsg?: string;
/** 操作时间 */ /** 操作时间 */
createTime?: string; createTime?: string;
/** 操作人 */
operator: string;
} }

View File

@@ -106,10 +106,10 @@ const formComponents = {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 100%; width: 100%;
height: auto;
min-height: 100vh; min-height: 100vh;
padding: clamp(1rem, 3vw, 2rem); padding: clamp(1rem, 3vw, 2rem);
overflow: hidden; overflow-x: hidden;
overflow-y: auto;
background-color: #f5f7ff; background-color: #f5f7ff;
&::before { &::before {

View File

@@ -1,163 +1,195 @@
<template> <template>
<div class="profile-container"> <div class="p-4">
<el-row :gutter="20"> <el-row :gutter="20">
<!-- 左侧个人信息卡片 --> <!-- 左侧个人信息卡片 -->
<el-col :span="8"> <el-col :xs="24" :sm="24" :md="8" :lg="6">
<el-card class="user-card"> <div class="left-column">
<div class="user-info"> <!-- 用户信息卡片 -->
<div class="avatar-wrapper"> <el-card class="user-card">
<el-avatar :src="userStore.userInfo.avatar" :size="100" /> <div class="user-info">
<el-button <div class="avatar-wrapper">
type="info" <el-avatar :src="userStore.userInfo.avatar" :size="100" />
class="avatar-edit-btn" <el-button
circle type="info"
:icon="Camera" class="avatar-edit-btn"
size="small" circle
@click="triggerFileUpload" :icon="Camera"
/> size="small"
<input @click="triggerFileUpload"
ref="fileInput" />
type="file" <input
style="display: none" ref="fileInput"
accept="image/*" type="file"
@change="handleFileChange" style="display: none"
/> accept="image/*"
@change="handleFileChange"
/>
</div>
<div class="user-name">
<span class="nickname">{{ userProfile.nickname }}</span>
<el-icon class="edit-icon" @click="handleOpenDialog(DialogType.ACCOUNT)">
<Edit />
</el-icon>
</div>
<div class="user-role">{{ userProfile.roleNames }}</div>
<el-descriptions :column="1" size="small" border class="profile-desc">
<el-descriptions-item>
<template #label>
<div class="cell-item">
<el-icon><User /></el-icon>
用户名
</div>
</template>
<span>{{ userProfile.username }}</span>
<el-icon v-if="userProfile.gender === 1" class="gender-icon male">
<Male />
</el-icon>
<el-icon v-else class="gender-icon female"><Female /></el-icon>
</el-descriptions-item>
<el-descriptions-item>
<template #label>
<div class="cell-item">
<el-icon><Iphone /></el-icon>
手机号码
</div>
</template>
<span :class="{ 'text-muted': !userProfile.mobile }">
{{ userProfile.mobile || "未绑定" }}
</span>
</el-descriptions-item>
<el-descriptions-item>
<template #label>
<div class="cell-item">
<el-icon><Message /></el-icon>
邮箱
</div>
</template>
<span :class="{ 'text-muted': !userProfile.email }">
{{ userProfile.email || "未绑定" }}
</span>
</el-descriptions-item>
<el-descriptions-item>
<template #label>
<div class="cell-item">
<el-icon><OfficeBuilding /></el-icon>
部门
</div>
</template>
{{ userProfile.deptName || "-" }}
</el-descriptions-item>
<el-descriptions-item>
<template #label>
<div class="cell-item">
<el-icon><Timer /></el-icon>
创建时间
</div>
</template>
{{ userProfile.createTime }}
</el-descriptions-item>
</el-descriptions>
</div> </div>
<div class="user-name"> </el-card>
<span class="nickname">{{ userProfile.nickname }}</span> </div>
<el-icon class="edit-icon" @click="handleOpenDialog(DialogType.ACCOUNT)">
<Edit />
</el-icon>
</div>
<div class="user-role">{{ userProfile.roleNames }}</div>
</div>
<el-divider />
<div class="user-stats">
<div class="stat-item">
<div class="stat-value">0</div>
<div class="stat-label">待办</div>
</div>
<div class="stat-item">
<div class="stat-value">0</div>
<div class="stat-label">消息</div>
</div>
<div class="stat-item">
<div class="stat-value">0</div>
<div class="stat-label">通知</div>
</div>
</div>
</el-card>
</el-col> </el-col>
<!-- 右侧信息卡片 --> <!-- 右侧信息卡片 -->
<el-col :span="16"> <el-col :xs="24" :sm="24" :md="16" :lg="18">
<el-card class="info-card"> <div class="right-column">
<template #header> <!-- 安全设置卡片 -->
<div class="card-header"> <el-card class="security-card">
<span>账号信息</span> <template #header>
</div> <div class="card-header">
</template> <span class="card-header-title">
<el-descriptions :column="1" border> <el-icon><Key /></el-icon>
<el-descriptions-item label="用户名"> 安全设置
{{ userProfile.username }} </span>
<el-icon v-if="userProfile.gender === 1" class="gender-icon male"> </div>
<Male /> </template>
</el-icon> <div class="security-list">
<el-icon v-else class="gender-icon female"> <div class="security-item">
<Female /> <div class="security-left">
</el-icon> <div class="security-icon password">
</el-descriptions-item> <el-icon><Lock /></el-icon>
<el-descriptions-item label="手机号码"> </div>
{{ userProfile.mobile || "未绑定" }} <div class="security-content">
</el-descriptions-item> <div class="security-title">账户密码</div>
<el-descriptions-item label="邮箱"> <div class="security-desc">定期修改密码有助于保护账户安全</div>
{{ userProfile.email || "未绑定" }} </div>
</el-descriptions-item> </div>
<el-descriptions-item label="部门"> <el-button type="primary" link @click="handleOpenDialog(DialogType.PASSWORD)">
{{ userProfile.deptName }} 修改
</el-descriptions-item> </el-button>
<el-descriptions-item label="创建时间"> </div>
{{ userProfile.createTime }}
</el-descriptions-item>
</el-descriptions>
</el-card>
<el-card class="security-card"> <div class="security-item">
<template #header> <div class="security-left">
<div class="card-header"> <div class="security-icon mobile">
<span>安全设置</span> <el-icon><Iphone /></el-icon>
</div> </div>
</template> <div class="security-content">
<div class="security-item"> <div class="security-title">手机号</div>
<div class="security-info"> <div class="security-desc">{{ mobileSecurityDesc }}</div>
<div class="security-title">账户密码</div> </div>
<div class="security-desc">定期修改密码有助于保护账户安全</div> </div>
</div> <div class="security-actions">
<el-button type="primary" link @click="() => handleOpenDialog(DialogType.PASSWORD)"> <el-button
修改 v-if="userProfile.mobile"
</el-button> type="primary"
</div> link
@click="handleOpenDialog(DialogType.MOBILE)"
>
更换
</el-button>
<el-button
v-if="userProfile.mobile"
type="danger"
link
@click="handleUnbindMobile"
>
解绑
</el-button>
<el-button
v-else
type="primary"
link
@click="handleOpenDialog(DialogType.MOBILE)"
>
绑定
</el-button>
</div>
</div>
<div class="security-item"> <div class="security-item">
<div class="security-info"> <div class="security-left">
<div class="security-title">手机号</div> <div class="security-icon email">
<div class="security-desc"> <el-icon><Message /></el-icon>
{{ mobileSecurityDesc }} </div>
<div class="security-content">
<div class="security-title">邮箱</div>
<div class="security-desc">{{ emailSecurityDesc }}</div>
</div>
</div>
<div class="security-actions">
<el-button
v-if="userProfile.email"
type="primary"
link
@click="handleOpenDialog(DialogType.EMAIL)"
>
更换
</el-button>
<el-button v-if="userProfile.email" type="danger" link @click="handleUnbindEmail">
解绑
</el-button>
<el-button v-else type="primary" link @click="handleOpenDialog(DialogType.EMAIL)">
绑定
</el-button>
</div>
</div> </div>
</div> </div>
<div class="flex items-center gap-2"> </el-card>
<el-button </div>
v-if="userProfile.mobile"
type="primary"
link
@click="() => handleOpenDialog(DialogType.MOBILE)"
>
更换
</el-button>
<el-button v-if="userProfile.mobile" type="danger" link @click="handleUnbindMobile">
解绑
</el-button>
<el-button
v-else
type="primary"
link
@click="() => handleOpenDialog(DialogType.MOBILE)"
>
绑定
</el-button>
</div>
</div>
<div class="security-item">
<div class="security-info">
<div class="security-title">邮箱</div>
<div class="security-desc">
{{ emailSecurityDesc }}
</div>
</div>
<div class="flex items-center gap-2">
<el-button
v-if="userProfile.email"
type="primary"
link
@click="() => handleOpenDialog(DialogType.EMAIL)"
>
更换
</el-button>
<el-button v-if="userProfile.email" type="danger" link @click="handleUnbindEmail">
解绑
</el-button>
<el-button
v-else
type="primary"
link
@click="() => handleOpenDialog(DialogType.EMAIL)"
>
绑定
</el-button>
</div>
</div>
</el-card>
</el-col> </el-col>
</el-row> </el-row>
@@ -282,7 +314,19 @@ import FileAPI from "@/api/file";
import { useUserStoreHook } from "@/store"; import { useUserStoreHook } from "@/store";
import { redirectToLogin } from "@/utils/auth"; import { redirectToLogin } from "@/utils/auth";
import { Camera } from "@element-plus/icons-vue"; import {
Camera,
Edit,
Lock,
Iphone,
Message,
User,
Male,
Female,
OfficeBuilding,
Timer,
Key,
} from "@element-plus/icons-vue";
const userStore = useUserStoreHook(); const userStore = useUserStoreHook();
@@ -298,8 +342,9 @@ const enum DialogType {
const dialogState = reactive({ const dialogState = reactive({
visible: false, visible: false,
title: "", title: "",
type: "" as DialogType, // 修改账号资料,修改密码、绑定手机、绑定邮箱" type: "" as DialogType,
}); });
const userProfileFormRef = ref(); const userProfileFormRef = ref();
const passwordChangeFormRef = ref(); const passwordChangeFormRef = ref();
const mobileBindingFormRef = ref(); const mobileBindingFormRef = ref();
@@ -316,7 +361,6 @@ const mobileTimer = ref();
const emailCountdown = ref(0); const emailCountdown = ref(0);
const emailTimer = ref(); const emailTimer = ref();
// 修改密码校验规则
const passwordChangeRules = { const passwordChangeRules = {
oldPassword: [{ required: true, message: "请输入原密码", trigger: "blur" }], oldPassword: [{ required: true, message: "请输入原密码", trigger: "blur" }],
newPassword: [{ required: true, message: "请输入新密码", trigger: "blur" }], newPassword: [{ required: true, message: "请输入新密码", trigger: "blur" }],
@@ -379,24 +423,21 @@ function maskEmail(email?: string) {
const mobileSecurityDesc = computed(() => { const mobileSecurityDesc = computed(() => {
return userProfile.value.mobile return userProfile.value.mobile
? `已绑定:${maskMobile(userProfile.value.mobile)}` ? `已绑定:${maskMobile(userProfile.value.mobile)}`
: "未绑定手机号"; : "未绑定手机号,建议立即绑定";
}); });
const emailSecurityDesc = computed(() => { const emailSecurityDesc = computed(() => {
return userProfile.value.email ? `已绑定:${maskEmail(userProfile.value.email)}` : "未绑定邮箱"; return userProfile.value.email
? `已绑定:${maskEmail(userProfile.value.email)}`
: "未绑定邮箱,建议立即绑定";
}); });
/**
* 打开弹窗
* @param type 弹窗类型 ACCOUNT: 账号资料 PASSWORD: 修改密码 MOBILE: 绑定手机 EMAIL: 绑定邮箱
*/
const handleOpenDialog = (type: DialogType) => { const handleOpenDialog = (type: DialogType) => {
dialogState.type = type; dialogState.type = type;
dialogState.visible = true; dialogState.visible = true;
switch (type) { switch (type) {
case DialogType.ACCOUNT: case DialogType.ACCOUNT:
dialogState.title = "账号资料"; dialogState.title = "编辑资料";
// 初始化表单数据
userProfileForm.nickname = userProfile.value.nickname; userProfileForm.nickname = userProfile.value.nickname;
userProfileForm.avatar = userProfile.value.avatar; userProfileForm.avatar = userProfile.value.avatar;
userProfileForm.gender = userProfile.value.gender; userProfileForm.gender = userProfile.value.gender;
@@ -459,25 +500,18 @@ async function handleUnbindEmail() {
} }
} }
/**
* 发送手机验证码
*/
function handleSendMobileCode() { function handleSendMobileCode() {
if (!mobileUpdateForm.mobile) { if (!mobileUpdateForm.mobile) {
ElMessage.error("请输入手机号"); ElMessage.error("请输入手机号");
return; return;
} }
// 验证手机号格式
const reg = /^1[3-9]\d{9}$/; const reg = /^1[3-9]\d{9}$/;
if (!reg.test(mobileUpdateForm.mobile)) { if (!reg.test(mobileUpdateForm.mobile)) {
ElMessage.error("手机号格式不正确"); ElMessage.error("手机号格式不正确");
return; return;
} }
// 发送短信验证码
UserAPI.sendMobileCode(mobileUpdateForm.mobile).then(() => { UserAPI.sendMobileCode(mobileUpdateForm.mobile).then(() => {
ElMessage.success("验证码发送成功"); ElMessage.success("验证码发送成功");
// 倒计时 60s 重新发送
mobileCountdown.value = 60; mobileCountdown.value = 60;
mobileTimer.value = setInterval(() => { mobileTimer.value = setInterval(() => {
if (mobileCountdown.value > 0) { if (mobileCountdown.value > 0) {
@@ -489,25 +523,19 @@ function handleSendMobileCode() {
}); });
} }
/**
* 发送邮箱验证码
*/
function handleSendEmailCode() { function handleSendEmailCode() {
if (!emailUpdateForm.email) { if (!emailUpdateForm.email) {
ElMessage.error("请输入邮箱"); ElMessage.error("请输入邮箱");
return; return;
} }
// 验证邮箱格式
const reg = /\w[-\w.+]*@([A-Za-z0-9][-A-Za-z0-9]+\.)+[A-Za-z]{2,14}/; const reg = /\w[-\w.+]*@([A-Za-z0-9][-A-Za-z0-9]+\.)+[A-Za-z]{2,14}/;
if (!reg.test(emailUpdateForm.email)) { if (!reg.test(emailUpdateForm.email)) {
ElMessage.error("邮箱格式不正确"); ElMessage.error("邮箱格式不正确");
return; return;
} }
// 发送邮箱验证码
UserAPI.sendEmailCode(emailUpdateForm.email).then(() => { UserAPI.sendEmailCode(emailUpdateForm.email).then(() => {
ElMessage.success("验证码发送成功"); ElMessage.success("验证码发送成功");
// 倒计时 60s 重新发送
emailCountdown.value = 60; emailCountdown.value = 60;
emailTimer.value = setInterval(() => { emailTimer.value = setInterval(() => {
if (emailCountdown.value > 0) { if (emailCountdown.value > 0) {
@@ -519,9 +547,6 @@ function handleSendEmailCode() {
}); });
} }
/**
* 提交表单
*/
const handleSubmit = async () => { const handleSubmit = async () => {
try { try {
if (dialogState.type === DialogType.ACCOUNT) { if (dialogState.type === DialogType.ACCOUNT) {
@@ -561,9 +586,6 @@ const handleSubmit = async () => {
} }
}; };
/**
* 取消
*/
const handleCancel = () => { const handleCancel = () => {
dialogState.visible = false; dialogState.visible = false;
if (dialogState.type === DialogType.ACCOUNT) { if (dialogState.type === DialogType.ACCOUNT) {
@@ -587,18 +609,14 @@ const handleFileChange = async (event: Event) => {
const target = event.target as HTMLInputElement; const target = event.target as HTMLInputElement;
const file = target.files ? target.files[0] : null; const file = target.files ? target.files[0] : null;
if (file) { if (file) {
// 调用文件上传API
const data = await FileAPI.uploadFile(file); const data = await FileAPI.uploadFile(file);
// 更新用户信息
await UserAPI.updateProfile({ await UserAPI.updateProfile({
avatar: data.url, avatar: data.url,
}); });
// 更新用户头像
userStore.userInfo.avatar = data.url; userStore.userInfo.avatar = data.url;
} }
}; };
/** 加载用户信息 */
const loadUserProfile = async () => { const loadUserProfile = async () => {
const data = await UserAPI.getProfile(); const data = await UserAPI.getProfile();
userProfile.value = data; userProfile.value = data;
@@ -625,21 +643,27 @@ onBeforeUnmount(() => {
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.profile-container { .left-column {
min-height: calc(100vh - 84px); display: flex;
padding: 20px; flex-direction: column;
background: var(--el-fill-color-blank); gap: 16px;
} }
.right-column {
display: flex;
flex-direction: column;
gap: 16px;
}
// 用户信息卡片
.user-card { .user-card {
.user-info { .user-info {
padding: 20px 0;
text-align: center; text-align: center;
.avatar-wrapper { .avatar-wrapper {
position: relative; position: relative;
display: inline-block; display: inline-block;
margin-bottom: 16px; margin-bottom: 12px;
.avatar-edit-btn { .avatar-edit-btn {
position: absolute; position: absolute;
@@ -656,7 +680,7 @@ onBeforeUnmount(() => {
} }
.user-name { .user-name {
margin-bottom: 8px; margin-bottom: 4px;
.nickname { .nickname {
font-size: 18px; font-size: 18px;
@@ -665,10 +689,10 @@ onBeforeUnmount(() => {
} }
.edit-icon { .edit-icon {
margin-left: 8px; margin-left: 6px;
color: var(--el-text-color-secondary); color: var(--el-text-color-secondary);
cursor: pointer; cursor: pointer;
transition: all 0.3s ease; transition: color 0.2s;
&:hover { &:hover {
color: var(--el-color-primary); color: var(--el-color-primary);
@@ -677,115 +701,128 @@ onBeforeUnmount(() => {
} }
.user-role { .user-role {
font-size: 14px; margin-bottom: 16px;
font-size: 13px;
color: var(--el-text-color-secondary); color: var(--el-text-color-secondary);
} }
}
.user-stats { .profile-desc {
display: flex; text-align: left;
justify-content: space-around;
padding: 16px 0;
.stat-item { .cell-item {
text-align: center; display: flex;
gap: 6px;
.stat-value { align-items: center;
font-size: 20px;
font-weight: 600;
color: var(--el-text-color-primary);
} }
.stat-label { .text-muted {
margin-top: 4px; color: var(--el-text-color-placeholder);
font-size: 12px; }
color: var(--el-text-color-secondary);
.gender-icon {
font-size: 16px;
&.male {
color: var(--el-color-primary);
}
&.female {
color: #f56c6c;
}
} }
} }
} }
} }
.info-card, // 右侧卡片通用样式
.security-card { .security-card {
margin-bottom: 20px;
.card-header { .card-header {
font-size: 16px; display: flex;
font-weight: 600; align-items: center;
color: var(--el-text-color-primary); justify-content: space-between;
.card-header-title {
display: flex;
gap: 8px;
align-items: center;
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
}
} }
} }
// 安全设置
.security-list {
display: flex;
flex-direction: column;
}
.security-item { .security-item {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 16px 0; padding: 16px;
border-bottom: 1px solid var(--el-border-color-lighter);
transition: background 0.2s ease;
.security-info { &:last-child {
border-bottom: none;
}
&:hover {
background: var(--el-fill-color-light);
border-radius: 8px;
}
.security-left {
display: flex;
gap: 12px;
align-items: center;
}
.security-icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
font-size: 20px;
border-radius: 8px;
&.password {
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
&.mobile {
color: var(--el-color-success);
background: var(--el-color-success-light-9);
}
&.email {
color: var(--el-color-warning);
background: var(--el-color-warning-light-9);
}
}
.security-content {
.security-title { .security-title {
margin-bottom: 4px; margin-bottom: 4px;
font-size: 16px; font-size: 15px;
font-weight: 500; font-weight: 500;
color: var(--el-text-color-primary); color: var(--el-text-color-primary);
} }
.security-desc { .security-desc {
font-size: 14px; font-size: 13px;
color: var(--el-text-color-secondary); color: var(--el-text-color-secondary);
} }
} }
}
.el-descriptions { .security-actions {
.el-descriptions__label { display: flex;
font-weight: 500; gap: 8px;
color: var(--el-text-color-regular);
}
.el-descriptions__content {
color: var(--el-text-color-primary);
}
.gender-icon {
margin-left: 8px;
font-size: 16px;
&.male {
color: #409eff;
}
&.female {
color: #f56c6c;
}
}
}
.el-dialog {
.el-dialog__header {
padding: 20px;
margin: 0;
border-bottom: 1px solid var(--el-border-color-light);
}
.el-dialog__body {
padding: 30px 20px;
}
.el-dialog__footer {
padding: 20px;
border-top: 1px solid var(--el-border-color-light);
}
}
// 响应式适配
@media (max-width: 768px) {
.profile-container {
padding: 10px;
}
.el-col {
width: 100%;
} }
} }
</style> </style>

View File

@@ -5,7 +5,7 @@
<el-form-item prop="keywords" label="关键字"> <el-form-item prop="keywords" label="关键字">
<el-input <el-input
v-model="queryParams.keywords" v-model="queryParams.keywords"
placeholder="日志内容" placeholder="IP/操作人"
clearable clearable
@keyup.enter="handleQuery" @keyup.enter="handleQuery"
/> />
@@ -39,15 +39,31 @@
border border
class="data-table__content" class="data-table__content"
> >
<el-table-column label="操作时间" prop="createTime" width="220" /> <el-table-column label="操作标题" prop="title" min-width="180" show-overflow-tooltip />
<el-table-column label="操作人" prop="operator" width="120" /> <el-table-column label="状态" prop="status" width="80" align="center">
<el-table-column label="日志模块" prop="module" width="100" /> <template #default="{ row }">
<el-table-column label="日志内容" prop="content" min-width="200" /> <el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small">
<el-table-column label="IP 地址" prop="ip" width="150" /> {{ row.status === 1 ? "成功" : "失败" }}
<el-table-column label="地区" prop="region" width="150" /> </el-tag>
<el-table-column label="浏览器" prop="browser" width="150" /> </template>
<el-table-column label="终端系统" prop="os" width="200" show-overflow-tooltip /> </el-table-column>
<el-table-column label="执行时间(ms)" prop="executionTime" width="150" /> <el-table-column label="IP地址" prop="ip" width="140" />
<el-table-column label="请求路径" prop="requestUri" min-width="180" show-overflow-tooltip />
<el-table-column label="请求方法" prop="requestMethod" width="100" align="center">
<template #default="{ row }">
<el-tag :type="getMethodTagType(row.requestMethod)" size="small" effect="plain">
{{ row.requestMethod }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="执行时间(ms)" prop="executionTime" width="120" align="center" />
<el-table-column label="操作人" prop="operatorName" width="120" />
<el-table-column label="操作时间" prop="createTime" width="180" />
<el-table-column label="操作" width="80" align="center" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="handleDetail(row)">详情</el-button>
</template>
</el-table-column>
</el-table> </el-table>
<pagination <pagination
@@ -58,6 +74,39 @@
@pagination="fetchData" @pagination="fetchData"
/> />
</el-card> </el-card>
<!-- 详情弹窗 -->
<el-dialog v-model="detailVisible" title="日志详情" width="720px">
<el-descriptions :column="2" border>
<el-descriptions-item label="操作标题" :span="2">
{{ detailData.title }}
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="detailData.status === 1 ? 'success' : 'danger'" size="small">
{{ detailData.status === 1 ? "成功" : "失败" }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="执行时间">
{{ detailData.executionTime }}ms
</el-descriptions-item>
<el-descriptions-item label="操作人">{{ detailData.operatorName }}</el-descriptions-item>
<el-descriptions-item label="操作时间">{{ detailData.createTime }}</el-descriptions-item>
<el-descriptions-item label="IP地址">{{ detailData.ip }}</el-descriptions-item>
<el-descriptions-item label="请求方法">{{ detailData.requestMethod }}</el-descriptions-item>
<el-descriptions-item label="请求路径" :span="2">
{{ detailData.requestUri }}
</el-descriptions-item>
<el-descriptions-item label="浏览器">{{ detailData.browser }}</el-descriptions-item>
<el-descriptions-item label="操作系统">{{ detailData.os }}</el-descriptions-item>
<el-descriptions-item label="自定义内容" :span="2">
<div v-if="detailData.content" class="whitespace-pre-wrap">{{ detailData.content }}</div>
<span v-else class="color-text-placeholder"></span>
</el-descriptions-item>
<el-descriptions-item v-if="detailData.errorMsg" label="错误信息" :span="2">
<span class="color-danger">{{ detailData.errorMsg }}</span>
</el-descriptions-item>
</el-descriptions>
</el-dialog>
</div> </div>
</template> </template>
@@ -69,7 +118,18 @@ defineOptions({
import LogAPI from "@/api/system/log"; import LogAPI from "@/api/system/log";
import type { LogItem, LogQueryParams } from "@/types/api"; import type { LogItem, LogQueryParams } from "@/types/api";
import type { FormInstance } from "element-plus"; import type { FormInstance, TagProps } from "element-plus";
function getMethodTagType(method: string): TagProps["type"] {
const map: Record<string, TagProps["type"]> = {
GET: undefined,
POST: "success",
PUT: "warning",
DELETE: "danger",
PATCH: "info",
};
return map[method?.toUpperCase()] ?? "info";
}
// 表单引用 // 表单引用
const queryFormRef = ref<FormInstance>(); const queryFormRef = ref<FormInstance>();
@@ -87,9 +147,10 @@ const pageData = ref<LogItem[]>();
const total = ref(0); const total = ref(0);
const loading = ref(false); const loading = ref(false);
/** // 详情弹窗
* 加载日志列表数据 const detailVisible = ref(false);
*/ const detailData = ref<Partial<LogItem>>({});
function fetchData(): void { function fetchData(): void {
loading.value = true; loading.value = true;
LogAPI.getPage(queryParams) LogAPI.getPage(queryParams)
@@ -102,17 +163,11 @@ function fetchData(): void {
}); });
} }
/**
* 查询按钮点击事件
*/
function handleQuery(): void { function handleQuery(): void {
queryParams.pageNum = 1; queryParams.pageNum = 1;
fetchData(); fetchData();
} }
/**
* 重置查询
*/
function handleResetQuery(): void { function handleResetQuery(): void {
queryFormRef.value?.resetFields(); queryFormRef.value?.resetFields();
queryParams.pageNum = 1; queryParams.pageNum = 1;
@@ -120,6 +175,11 @@ function handleResetQuery(): void {
fetchData(); fetchData();
} }
function handleDetail(row: LogItem): void {
detailData.value = row;
detailVisible.value = true;
}
onMounted(() => { onMounted(() => {
handleQuery(); handleQuery();
}); });