This commit is contained in:
fuenmao
2026-08-28 11:09:20 +08:00
parent d0d7ca8e69
commit 14515ccdd6
8 changed files with 1073 additions and 254 deletions
+18 -2
View File
@@ -228,12 +228,28 @@ export const onboardingTaskApi = {
}, },
/** 使用后台登录令牌读取文件流,供入职材料下载和图片预览使用。 */ /** 使用后台登录令牌读取文件流,供入职材料下载和图片预览使用。 */
downloadFile(fileId: number) { async downloadFile(fileId: number) {
return fileHttp.get<any>( const response: any = await fileHttp.get<any>(
"download", "download",
{ id: fileId }, { id: fileId },
{ responseType: "blob", loading: false, cancel: false } { responseType: "blob", loading: false, cancel: false }
); );
const blob = response?.data;
const contentType = String(response?.headers?.["content-type"] || blob?.type || "");
if (blob instanceof Blob && contentType.toLowerCase().includes("application/json")) {
try {
const result = JSON.parse(await blob.text());
throw new Error(result?.msg || "文件下载失败");
} catch (error) {
if (error instanceof SyntaxError) {
throw new Error("文件下载失败");
}
throw error;
}
}
return response;
}, },
create(params: Record<string, unknown>) { create(params: Record<string, unknown>) {
+3 -31
View File
@@ -1,7 +1,7 @@
<template> <template>
<div v-if="columns.length" class="card table-search"> <div v-if="columns.length" class="card table-search">
<el-form ref="formRef" :model="searchParam"> <el-form ref="formRef" :model="searchParam">
<Grid ref="gridRef" :collapsed="collapsed" :gap="[20, 0]" :cols="searchCol"> <Grid ref="gridRef" :collapsed="false" :gap="[20, 0]" :cols="searchCol">
<GridItem v-for="(item, index) in columns" :key="item.prop" v-bind="getResponsive(item)" :index="index"> <GridItem v-for="(item, index) in columns" :key="item.prop" v-bind="getResponsive(item)" :index="index">
<el-form-item> <el-form-item>
<template #label> <template #label>
@@ -20,12 +20,6 @@
<div class="operation"> <div class="operation">
<el-button type="primary" :icon="Search" @click="search"> 搜索 </el-button> <el-button type="primary" :icon="Search" @click="search"> 搜索 </el-button>
<el-button :icon="Delete" @click="reset"> 重置 </el-button> <el-button :icon="Delete" @click="reset"> 重置 </el-button>
<el-button v-if="showCollapse" type="primary" link class="search-isOpen" @click="collapsed = !collapsed">
{{ collapsed ? "展开" : "合并" }}
<el-icon class="el-icon--right">
<component :is="collapsed ? ArrowDown : ArrowUp"></component>
</el-icon>
</el-button>
</div> </div>
</GridItem> </GridItem>
</Grid> </Grid>
@@ -33,10 +27,10 @@
</div> </div>
</template> </template>
<script setup lang="ts" name="SearchForm"> <script setup lang="ts" name="SearchForm">
import { computed, ref } from "vue"; import { ref } from "vue";
import { ColumnProps } from "@/components/ProTable/interface"; import { ColumnProps } from "@/components/ProTable/interface";
import { BreakPoint } from "@/components/Grid/interface"; import { BreakPoint } from "@/components/Grid/interface";
import { Delete, Search, ArrowDown, ArrowUp } from "@element-plus/icons-vue"; import { Delete, Search } from "@element-plus/icons-vue";
import SearchFormItem from "./components/SearchFormItem.vue"; import SearchFormItem from "./components/SearchFormItem.vue";
import Grid from "@/components/Grid/index.vue"; import Grid from "@/components/Grid/index.vue";
import GridItem from "@/components/Grid/components/GridItem.vue"; import GridItem from "@/components/Grid/components/GridItem.vue";
@@ -68,27 +62,5 @@ const getResponsive = (item: ColumnProps) => {
}; };
}; };
// 是否默认折叠搜索项
const collapsed = ref(true);
// 获取响应式断点
const gridRef = ref(); const gridRef = ref();
const breakPoint = computed<BreakPoint>(() => gridRef.value?.breakPoint);
// 判断是否显示 展开/合并 按钮
const showCollapse = computed(() => {
let show = false;
props.columns.reduce((prev, current) => {
prev +=
(current.search![breakPoint.value]?.span ?? current.search?.span ?? 1) +
(current.search![breakPoint.value]?.offset ?? current.search?.offset ?? 0);
if (typeof props.searchCol !== "number") {
if (prev >= props.searchCol[breakPoint.value]) show = true;
} else {
if (prev >= props.searchCol) show = true;
}
return prev;
}, 0);
return show;
});
</script> </script>
+138 -7
View File
@@ -12,13 +12,26 @@
</el-button> </el-button>
</template> </template>
<template #companyId="scope">
<el-tag effect="plain" type="primary">
{{ scope.row.companyName || '-' }}
</el-tag>
</template>
<template #departmentId="scope">
<el-tag v-if="scope.row.departmentName" effect="light" type="warning">
{{ scope.row.departmentName }}
</el-tag>
<span v-else class="text-gray-400">-</span>
</template>
<template #contractType="scope"> <template #contractType="scope">
<el-tag effect="plain" type="primary"> <el-tag effect="plain" type="primary">
{{ scope.row.contractType }} {{ scope.row.contractType }}
</el-tag> </el-tag>
</template> </template>
<template #positionNames="scope"> <template #positionId="scope">
<el-space wrap> <el-space wrap>
<el-tag v-for="name in scope.row.positionNames || []" :key="name" size="small"> <el-tag v-for="name in scope.row.positionNames || []" :key="name" size="small">
{{ name }} {{ name }}
@@ -498,6 +511,31 @@ const templateEditing = ref(false);
const companies = ref<OnboardingCompanyOption[]>([]); const companies = ref<OnboardingCompanyOption[]>([]);
const departments = ref<OnboardingDepartmentOption[]>([]); const departments = ref<OnboardingDepartmentOption[]>([]);
const positions = ref<OnboardingPositionOption[]>([]); const positions = ref<OnboardingPositionOption[]>([]);
const searchCompanies = ref<OnboardingCompanyOption[]>([]);
const searchDepartments = ref<OnboardingDepartmentOption[]>([]);
const searchPositions = ref<OnboardingPositionOption[]>([]);
const companySearchOptions = computed(() =>
searchCompanies.value.map(c => ({
label: c.companyName,
value: c.id
}))
);
const departmentSearchOptions = computed(() =>
searchDepartments.value.map(d => ({
label: d.departmentName,
value: d.id
}))
);
const positionSearchOptions = computed(() =>
searchPositions.value.map(p => ({
label: p.positionName,
value: p.id
}))
);
const versions = ref<OnboardingContractVersion[]>([]); const versions = ref<OnboardingContractVersion[]>([]);
const versionVisible = ref(false); const versionVisible = ref(false);
const variablesVisible = ref(false); const variablesVisible = ref(false);
@@ -560,11 +598,38 @@ const versionForm = reactive<Partial<OnboardingContractVersion>>(emptyVersion())
const variableForm = reactive<Partial<OnboardingTemplateVariable>>(emptyVariable()); const variableForm = reactive<Partial<OnboardingTemplateVariable>>(emptyVariable());
const templateColumns: ColumnProps<OnboardingContractTemplate>[] = [ const templateColumns: ColumnProps<OnboardingContractTemplate>[] = [
{ prop: "companyName", label: "所属公司", minWidth: 160 }, {
{ prop: "departmentName", label: "所属部门", minWidth: 140 }, prop: "companyId",
label: "所属公司",
minWidth: 160,
enum: companySearchOptions,
search: {
el: "select",
props: { filterable: true, clearable: true, placeholder: "请选择所属公司" }
}
},
{
prop: "departmentId",
label: "所属部门",
minWidth: 140,
enum: departmentSearchOptions,
search: {
el: "select",
props: { filterable: true, clearable: true, placeholder: "请选择所属部门" }
}
},
{
prop: "positionId",
label: "适用岗位",
minWidth: 200,
enum: positionSearchOptions,
search: {
el: "select",
props: { filterable: true, clearable: true, placeholder: "请选择适用岗位" }
}
},
{ prop: "templateCode", label: "模板编码", minWidth: 160, search: { el: "input" } }, { prop: "templateCode", label: "模板编码", minWidth: 160, search: { el: "input" } },
{ prop: "templateName", label: "模板名称", minWidth: 200, search: { el: "input" } }, { prop: "templateName", label: "模板名称", minWidth: 200, search: { el: "input" } },
{ prop: "positionNames", label: "适用岗位", minWidth: 200 },
{ prop: "contractType", label: "合同类型", width: 140, search: { el: "input" } }, { prop: "contractType", label: "合同类型", width: 140, search: { el: "input" } },
{ {
prop: "status", prop: "status",
@@ -737,9 +802,47 @@ async function removeVersion(row: OnboardingContractVersion) {
ElMessage.success("版本删除成功"); ElMessage.success("版本删除成功");
} }
function downloadDocx(fileId?: number) { /**
* 使用后台登录令牌读取文件流,避免直接打开受保护接口时出现 401。
* 移动端使用独立的入职 Token 公共接口,不经过这里。
*/
async function downloadDocx(fileId?: number) {
if (!fileId) return ElMessage.warning("暂无 DOCX 源文件"); if (!fileId) return ElMessage.warning("暂无 DOCX 源文件");
window.open(`/api/sys/dev/file/download?id=${fileId}`, "_blank"); try {
const response: any = await onboardingTaskApi.downloadFile(fileId);
const blob = response?.data;
if (!(blob instanceof Blob)) {
throw new Error("服务器未返回有效的文件流");
}
const disposition = String(response?.headers?.["content-disposition"] || "");
const encodedName =
disposition.match(/filename\*=UTF-8\'\'([^;]+)/i)?.[1] ||
disposition.match(/filename="?([^";]+)"?/i)?.[1];
let fileName = "合同模板.docx";
if (encodedName) {
try {
fileName = decodeURIComponent(encodedName.replace(/\+/g, "%20"));
} catch {
fileName = encodedName;
}
}
const objectUrl = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = objectUrl;
link.download = fileName;
document.body.appendChild(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 0);
} catch (error: any) {
ElMessage.error(
error?.msg ||
error?.message ||
"DOCX 源文件下载失败"
);
}
} }
type EpTagType = "success" | "warning" | "info" | "primary" | "danger"; type EpTagType = "success" | "warning" | "info" | "primary" | "danger";
@@ -806,7 +909,35 @@ async function saveVariable() {
ElMessage.success("变量已确认并保存"); ElMessage.success("变量已确认并保存");
} }
onMounted(loadCompanies); async function loadSearchOptions() {
await loadCompanies();
searchCompanies.value = companies.value;
const deptList: OnboardingDepartmentOption[] = [];
const posList: OnboardingPositionOption[] = [];
if (companies.value && companies.value.length > 0) {
const promises = companies.value.map(async comp => {
const [depRes, posRes] = await Promise.all([
onboardingTaskApi.listDepartments(comp.id),
onboardingTaskApi.listPositions(comp.id)
]);
if (depRes.data) deptList.push(...depRes.data);
if (posRes.data) posList.push(...posRes.data);
});
await Promise.all(promises);
}
const uniqueDeptMap = new Map<number, OnboardingDepartmentOption>();
deptList.forEach(d => uniqueDeptMap.set(d.id, d));
searchDepartments.value = Array.from(uniqueDeptMap.values());
const uniquePosMap = new Map<number, OnboardingPositionOption>();
posList.forEach(p => uniquePosMap.set(p.id, p));
searchPositions.value = Array.from(uniquePosMap.values());
}
onMounted(loadSearchOptions);
</script> </script>
<style scoped> <style scoped>
@@ -1,12 +1,60 @@
<template> <template>
<div class="template-panel"> <div class="template-panel">
<div class="toolbar"> <div class="toolbar">
<el-input v-model="query.keyword" clearable placeholder="搜索模板编码或名称" class="keyword-input" <el-input
@keyup.enter="loadTemplates" /> v-model="query.keyword"
<el-select v-model="query.companyId" clearable filterable placeholder="全部公司" class="company-filter"> clearable
placeholder="搜索模板编码或名称"
class="keyword-input"
@keyup.enter="loadTemplates"
@clear="loadTemplates"
/>
<el-select
v-model="query.companyId"
clearable
filterable
placeholder="全部公司"
class="company-filter"
@change="onQueryCompanyChange"
@clear="onQueryCompanyChange"
>
<el-option v-for="item in companies" :key="item.id" :label="item.companyName" :value="item.id" /> <el-option v-for="item in companies" :key="item.id" :label="item.companyName" :value="item.id" />
</el-select> </el-select>
<el-button type="primary" @click="loadTemplates">查询</el-button> <el-select
v-model="query.departmentId"
clearable
filterable
placeholder="全部部门"
class="department-filter"
@change="onQueryDepartmentChange"
@clear="onQueryDepartmentChange"
>
<el-option v-for="item in queryDepartments" :key="item.id" :label="item.departmentName" :value="item.id" />
</el-select>
<el-select
v-model="query.positionId"
clearable
filterable
placeholder="全部岗位"
class="position-filter"
@change="loadTemplates"
@clear="loadTemplates"
>
<el-option v-for="item in queryPositions" :key="item.id" :label="item.positionName" :value="item.id" />
</el-select>
<el-select
v-model="query.status"
clearable
placeholder="模板状态"
class="status-filter"
@change="loadTemplates"
@clear="loadTemplates"
>
<el-option label="全部状态" value="" />
<el-option label="启用" value="enable" />
<el-option label="停用" value="disable" />
</el-select>
<el-button type="primary" icon="Search" @click="loadTemplates">查询</el-button>
<el-button icon="Refresh" @click="resetQuery">重置</el-button> <el-button icon="Refresh" @click="resetQuery">重置</el-button>
<div class="toolbar-spacer" /> <div class="toolbar-spacer" />
<el-button type="primary" icon="Plus" @click="openCreate">新增入职模板</el-button> <el-button type="primary" icon="Plus" @click="openCreate">新增入职模板</el-button>
@@ -235,6 +283,7 @@ import {
OnboardingDepartmentOption, OnboardingDepartmentOption,
OnboardingMaterialType, OnboardingMaterialType,
OnboardingPosition, OnboardingPosition,
OnboardingPositionOption,
OnboardingTemplate, OnboardingTemplate,
onboardingTaskApi onboardingTaskApi
} from "@/api/modules/biz/onboarding"; } from "@/api/modules/biz/onboarding";
@@ -246,6 +295,8 @@ const rows = ref<OnboardingTemplate[]>([]);
const total = ref(0); const total = ref(0);
const companies = ref<OnboardingCompany[]>([]); const companies = ref<OnboardingCompany[]>([]);
const queryDepartments = ref<OnboardingDepartmentOption[]>([]);
const queryPositions = ref<OnboardingPositionOption[]>([]);
const departments = ref<OnboardingDepartmentOption[]>([]); const departments = ref<OnboardingDepartmentOption[]>([]);
const positions = ref<OnboardingPosition[]>([]); const positions = ref<OnboardingPosition[]>([]);
const contractTemplates = ref<OnboardingContractTemplate[]>([]); const contractTemplates = ref<OnboardingContractTemplate[]>([]);
@@ -255,6 +306,9 @@ const versionOptions = reactive<Record<number, OnboardingContractVersion[]>>({})
const query = reactive({ const query = reactive({
keyword: "", keyword: "",
companyId: undefined as number | undefined, companyId: undefined as number | undefined,
departmentId: undefined as number | undefined,
positionId: undefined as number | undefined,
status: "enable" as string | undefined,
pageNum: 1, pageNum: 1,
pageSize: 20 pageSize: 20
}); });
@@ -273,6 +327,30 @@ const form = reactive<OnboardingTemplate>({
materials: [] materials: []
}); });
async function loadAllQueryOptions() {
if (!companies.value || companies.value.length === 0) return;
const deptList: OnboardingDepartmentOption[] = [];
const posList: OnboardingPositionOption[] = [];
const promises = companies.value.map(async comp => {
const [depRes, posRes] = await Promise.all([
onboardingTaskApi.listDepartments(comp.id).catch(() => ({ data: [] })),
onboardingTaskApi.listPositions(comp.id).catch(() => ({ data: [] }))
]);
if (depRes.data) deptList.push(...depRes.data);
if (posRes.data) posList.push(...posRes.data);
});
await Promise.all(promises);
const uniqueDeptMap = new Map<number, OnboardingDepartmentOption>();
deptList.forEach(d => uniqueDeptMap.set(d.id, d));
queryDepartments.value = Array.from(uniqueDeptMap.values());
const uniquePosMap = new Map<number, OnboardingPositionOption>();
posList.forEach(p => uniquePosMap.set(p.id, p));
queryPositions.value = Array.from(uniquePosMap.values());
}
async function loadBaseData() { async function loadBaseData() {
const [companyRes, materialRes] = await Promise.all([ const [companyRes, materialRes] = await Promise.all([
onboardingTaskApi.companyPage({ pageNum: 1, pageSize: 100, status: "enable" }).catch(() => ({ data: { list: [] } })), onboardingTaskApi.companyPage({ pageNum: 1, pageSize: 100, status: "enable" }).catch(() => ({ data: { list: [] } })),
@@ -280,6 +358,7 @@ async function loadBaseData() {
]); ]);
companies.value = companyRes.data?.list || []; companies.value = companyRes.data?.list || [];
materialTypes.value = materialRes.data || []; materialTypes.value = materialRes.data || [];
await loadAllQueryOptions();
} }
async function loadTemplates() { async function loadTemplates() {
@@ -293,10 +372,61 @@ async function loadTemplates() {
} }
} }
async function onQueryCompanyChange(companyId?: number) {
query.departmentId = undefined;
query.positionId = undefined;
if (!companyId) {
await loadAllQueryOptions();
} else {
const [depRes, posRes] = await Promise.all([
onboardingTaskApi.listDepartments(companyId).catch(() => ({ data: [] })),
onboardingTaskApi.listPositions(companyId).catch(() => ({ data: [] }))
]);
queryDepartments.value = depRes.data || [];
queryPositions.value = (posRes.data || []).map(p => ({
id: p.id,
positionName: p.positionName,
companyId: companyId,
departmentId: p.departmentId
} as any));
}
loadTemplates();
}
async function onQueryDepartmentChange(departmentId?: number) {
query.positionId = undefined;
if (!departmentId) {
if (query.companyId) {
const posRes = await onboardingTaskApi.listPositions(query.companyId).catch(() => ({ data: [] }));
queryPositions.value = (posRes.data || []).map(p => ({
id: p.id,
positionName: p.positionName,
companyId: query.companyId!,
departmentId: p.departmentId
} as any));
} else {
await loadAllQueryOptions();
}
} else {
const posRes = await onboardingTaskApi.listPositionsByDepartment(departmentId).catch(() => ({ data: [] }));
queryPositions.value = (posRes.data || []).map(p => ({
id: p.id,
positionName: p.positionName,
companyId: p.companyId,
departmentId: departmentId
} as any));
}
loadTemplates();
}
function resetQuery() { function resetQuery() {
query.keyword = ""; query.keyword = "";
query.companyId = undefined; query.companyId = undefined;
query.departmentId = undefined;
query.positionId = undefined;
query.status = "enable";
query.pageNum = 1; query.pageNum = 1;
loadAllQueryOptions();
loadTemplates(); loadTemplates();
} }
@@ -521,7 +651,19 @@ onMounted(async () => {
} }
.company-filter { .company-filter {
width: 180px; width: 170px;
}
.department-filter {
width: 160px;
}
.position-filter {
width: 160px;
}
.status-filter {
width: 120px;
} }
.toolbar-spacer { .toolbar-spacer {
+307 -47
View File
@@ -1,43 +1,133 @@
<!-- <!--
* @Description: 详情 * @Description: 操作日志详情
* @Author: huguodong * @Author: huguodong
* @Date: 2023-12-15 15:44:16 * @Date: 2023-12-15 15:44:16
!--> !-->
<template> <template>
<form-container v-model="visible" title="日志详情" form-size="650px"> <form-container v-model="visible" title="操作日志详情" form-size="980px">
<el-descriptions :column="1" border class="mb-2"> <div class="log-detail-box" v-loading="loading">
<el-descriptions-item label="名称">{{ logInfo.name }}</el-descriptions-item> <!-- 异常告警条(仅异常日志显示) -->
<el-descriptions-item label="IP地址">{{ logInfo.opIp }}</el-descriptions-item> <div v-if="logInfo.category === 'EXCEPTION' || logInfo.exeMessage" class="exception-alert-box">
<el-descriptions-item label="地址">{{ logInfo.opAddress }}</el-descriptions-item> <div class="exception-header">
<el-descriptions-item label="请求地址">{{ logInfo.reqUrl }}</el-descriptions-item> <el-icon color="#ef4444" :size="18"><WarningFilled /></el-icon>
<el-descriptions-item label="操作类">{{ logInfo.className }}</el-descriptions-item> <span class="exception-title">异常堆栈 / 错误信息</span>
<el-descriptions-item label="操作方法">{{ logInfo.methodName }}</el-descriptions-item> <el-button link type="danger" size="small" :icon="CopyDocument" @click="copyText(logInfo.exeMessage || '')">复制异常</el-button>
<el-descriptions-item label="浏览器">{{ logInfo.opBrowser }}</el-descriptions-item> </div>
<el-descriptions-item label="设备">{{ logInfo.opOs }}</el-descriptions-item> <pre class="exception-content">{{ logInfo.exeMessage || '发生未知异常' }}</pre>
<el-descriptions-item label="时间">{{ logInfo.opTime }}</el-descriptions-item> </div>
<el-descriptions-item label="用户">{{ logInfo.opUser }}</el-descriptions-item>
<el-descriptions-item label="账号">{{ logInfo.opAccount }}</el-descriptions-item> <!-- 1. 基本与环境信息 -->
</el-descriptions> <el-card shadow="never" class="detail-card">
<el-space direction="vertical" class="mb-2 w-full"> <template #header>
<span>请求参数:</span> <div class="card-title">基本信息与客户端</div>
<code-high-light language="JSON" class="w-600px" :code="paramJson" /> </template>
</el-space> <el-descriptions :column="3" border size="small">
<el-space direction="vertical" class="mb-2" style="width: 100%"> <el-descriptions-item label="操作名称" :span="2">
返回结果: <span class="font-bold text-slate-800">{{ logInfo.name || '-' }}</span>
<code-high-light autodetect class="w-600px" :code="resultJson"></code-high-light> </el-descriptions-item>
</el-space> <el-descriptions-item label="执行状态">
<el-tag :type="logInfo.category === 'EXCEPTION' ? 'danger' : 'success'" size="small">
{{ logInfo.category === 'EXCEPTION' ? '执行异常' : '执行成功' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="操作人员">
{{ logInfo.opUser || '-' }}
</el-descriptions-item>
<el-descriptions-item label="操作账号">
{{ logInfo.opAccount || '-' }}
</el-descriptions-item>
<el-descriptions-item label="操作时间">
{{ logInfo.opTime || '-' }}
</el-descriptions-item>
<el-descriptions-item label="IP 地址">
<code class="code-badge">{{ logInfo.opIp || '-' }}</code>
</el-descriptions-item>
<el-descriptions-item label="地理位置">
{{ logInfo.opAddress || '-' }}
</el-descriptions-item>
<el-descriptions-item label="客户端环境">
{{ logInfo.opBrowser || '-' }} / {{ logInfo.opOs || '-' }}
</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- 2. 请求与调用定位 -->
<el-card shadow="never" class="detail-card mt-3">
<template #header>
<div class="card-title">请求与类方法</div>
</template>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="请求 URL" :span="2">
<div class="req-url-box">
<el-tag size="small" :type="reqMethodTag(logInfo.reqMethod)" effect="dark" class="mr-2">
{{ logInfo.reqMethod || 'POST' }}
</el-tag>
<span class="url-text">{{ logInfo.reqUrl || '-' }}</span>
</div>
</el-descriptions-item>
<el-descriptions-item label="操作类">
<span class="code-font" :title="logInfo.className">{{ logInfo.className || '-' }}</span>
</el-descriptions-item>
<el-descriptions-item label="操作方法">
<span class="code-font font-bold text-primary">{{ logInfo.methodName || '-' }}</span>
</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- 3. 请求参数 与 返回结果(左右双栏并排) -->
<el-row :gutter="12" class="mt-3">
<el-col :span="12">
<el-card shadow="never" class="detail-card code-card">
<template #header>
<div class="card-header-flex">
<span class="card-title">请求参数 (ParamJson)</span>
<el-button link type="primary" size="small" :icon="CopyDocument" :disabled="!paramFormatted" @click="copyText(paramFormatted)">
复制参数
</el-button>
</div>
</template>
<div class="code-wrapper">
<code-high-light v-if="paramFormatted" language="json" :code="paramFormatted" />
<div v-else class="text-placeholder">无请求参数</div>
</div>
</el-card>
</el-col>
<el-col :span="12">
<el-card shadow="never" class="detail-card code-card">
<template #header>
<div class="card-header-flex">
<span class="card-title">返回结果 (ResultJson)</span>
<el-button link type="primary" size="small" :icon="CopyDocument" :disabled="!resultFormatted" @click="copyText(resultFormatted)">
复制结果
</el-button>
</div>
</template>
<div class="code-wrapper">
<code-high-light v-if="resultFormatted" language="json" :code="resultFormatted" />
<div v-else class="text-placeholder">无返回结果</div>
</div>
</el-card>
</el-col>
</el-row>
</div>
<template #footer> <template #footer>
<el-button type="primary" @click="onClose"> 确定 </el-button> <el-button @click="onClose">关 闭</el-button>
</template> </template>
</form-container> </form-container>
</template> </template>
<script setup lang="ts" name="detail"> <script setup lang="ts" name="detail">
import { ref } from "vue";
import { WarningFilled, CopyDocument } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import { OpLog, opLogApi } from "@/api"; import { OpLog, opLogApi } from "@/api";
const visible = ref(false); //是否显示表单 const visible = ref(false);
const loading = ref(false);
// 表单参数
const logInfo = ref<OpLog.OpLogInfo>({ const logInfo = ref<OpLog.OpLogInfo>({
name: "", name: "",
opIp: "", opIp: "",
@@ -60,40 +150,210 @@ const logInfo = ref<OpLog.OpLogInfo>({
resultJson: "" resultJson: ""
}); });
const paramJson = ref<string>(); const paramFormatted = ref<string>("");
const resultJson = ref<string>(); const resultFormatted = ref<string>("");
/** function safeJsonFormat(raw?: string | null): string {
* 打开表单 if (!raw || typeof raw !== "string" || !raw.trim()) return "";
* @param props 表单参数 try {
*/ const parsed = JSON.parse(raw);
function onOpen(record: OpLog.OpLogInfo) { return JSON.stringify(parsed, null, 2);
logInfo.value = record; } catch {
visible.value = true; //显示表单 return raw;
opLogApi.detail({ id: record.id }).then(res => { }
logInfo.value = res.data; }
if (res.data.paramJson) paramJson.value = JSON.stringify(JSON.parse(res.data.paramJson), null, 2);
if (res.data.exeMessage) { function reqMethodTag(method?: string) {
resultJson.value = res.data.exeMessage; const m = (method || "").toUpperCase();
} else { if (m === "POST") return "success";
if (res.data.resultJson) resultJson.value = JSON.stringify(JSON.parse(res.data.resultJson), null, 2); if (m === "PUT") return "warning";
if (m === "DELETE") return "danger";
return "info";
}
async function onOpen(record: OpLog.OpLogInfo) {
logInfo.value = { ...record };
paramFormatted.value = safeJsonFormat(record.paramJson);
resultFormatted.value = safeJsonFormat(record.resultJson);
visible.value = true;
loading.value = true;
try {
const res = await opLogApi.detail({ id: record.id });
if (res.data) {
logInfo.value = res.data;
paramFormatted.value = safeJsonFormat(res.data.paramJson);
resultFormatted.value = safeJsonFormat(res.data.resultJson);
} }
}); } catch (err: any) {
ElMessage.error(err?.message || "获取日志详情失败");
} finally {
loading.value = false;
}
}
function copyText(text: string) {
if (!text) return;
if (navigator.clipboard) {
navigator.clipboard.writeText(text).then(() => {
ElMessage.success("已复制到剪贴板");
});
} else {
const input = document.createElement("textarea");
input.value = text;
document.body.appendChild(input);
input.select();
document.execCommand("copy");
document.body.removeChild(input);
ElMessage.success("已复制到剪贴板");
}
} }
/** 关闭表单*/
function onClose() { function onClose() {
visible.value = false; visible.value = false;
} }
// 暴露给父组件的方法
defineExpose({ defineExpose({
onOpen onOpen
}); });
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
:deep(.el-space__item) { .log-detail-box {
width: 100% !important; display: flex;
flex-direction: column;
max-height: calc(86vh - 120px);
overflow-y: auto;
padding: 4px 6px;
}
.detail-card {
border-radius: 8px;
border: 1px solid #e2e8f0;
:deep(.el-card__header) {
padding: 10px 14px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
}
:deep(.el-card__body) {
padding: 12px;
}
}
.code-card {
display: flex;
flex-direction: column;
:deep(.el-card__body) {
padding: 8px;
flex: 1;
}
}
.card-title {
font-weight: 600;
font-size: 13px;
color: #1e293b;
}
.card-header-flex {
display: flex;
align-items: center;
justify-content: space-between;
}
.exception-alert-box {
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 8px;
padding: 12px 14px;
margin-bottom: 12px;
}
.exception-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.exception-title {
font-weight: 600;
color: #b91c1c;
font-size: 13px;
flex: 1;
}
.exception-content {
margin: 0;
font-family: Consolas, Monaco, monospace;
font-size: 12px;
color: #991b1b;
white-space: pre-wrap;
word-break: break-all;
max-height: 180px;
overflow-y: auto;
background: #fff;
padding: 8px 12px;
border-radius: 6px;
border: 1px solid #fee2e2;
}
.font-bold {
font-weight: 600;
}
.code-badge {
font-family: monospace;
background: #f1f5f9;
padding: 2px 6px;
border-radius: 4px;
font-size: 12px;
}
.code-font {
font-family: Consolas, Monaco, monospace;
font-size: 12px;
}
.text-primary {
color: var(--el-color-primary);
}
.req-url-box {
display: flex;
align-items: center;
word-break: break-all;
}
.url-text {
font-family: Consolas, Monaco, monospace;
font-size: 12px;
color: #334155;
}
.code-wrapper {
height: 280px;
overflow-y: auto;
border-radius: 6px;
background: #1e1e1e;
}
.text-placeholder {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #94a3b8;
font-size: 13px;
background: #f8fafc;
border-radius: 6px;
}
.mt-3 {
margin-top: 12px;
}
.mr-2 {
margin-right: 8px;
} }
</style> </style>
+167 -69
View File
@@ -4,74 +4,116 @@
* @Date: 2023-12-15 15:44:27 * @Date: 2023-12-15 15:44:27
!--> !-->
<template> <template>
<div> <div class="log-page-container">
<el-row :gutter="10" class="mb-2"> <el-row :gutter="12" class="mb-3">
<el-col :span="16"> <el-col :span="16">
<el-card header="周统计" shadow="never"> <el-card header="近七日操作趋势" shadow="never" class="chart-card">
<column-chat /> <column-chat />
</el-card> </el-card>
</el-col> </el-col>
<el-col :span="8"> <el-col :span="8">
<el-card header="总比例" shadow="never"> <el-card header="日志类型占比" shadow="never" class="chart-card">
<pie-chat /> <pie-chat />
</el-card> </el-card>
</el-col> </el-col>
</el-row> </el-row>
<el-row>
<div class="table-box min-h-400px">
<ProTable
ref="proTable"
title="系统字典"
class="table"
:tool-button="false"
:columns="columns"
:request-api="opLogApi.page"
:init-param="initParam"
>
<!-- 表格 header 按钮 -->
<template #tableHeader>
<el-space>
<el-radio-group v-model="initParam.category" class="mb-15px">
<el-radio-button v-for="(item, index) in visLogTypeOptions" :key="index" :label="item.value">{{ item.label }}</el-radio-button>
</el-radio-group>
<el-input v-model="initParam.searchKey" placeholder="请输入字典名称" class="mb-15px">
<template #append>
<el-button :icon="Search" class="el-input-button" />
</template>
</el-input>
<s-button type="danger" plain :opt="FormOptEnum.DELETE" @click="onDelete('清空当前日志')">清空</s-button>
</el-space>
</template>
<!-- 操作 --> <div class="table-box">
<template #operation="scope"> <ProTable
<s-button link :opt="FormOptEnum.VIEW" @click="onOpen(scope.row)"> 详情 </s-button> ref="proTable"
</template> title="操作日志列表"
</ProTable> class="table"
<!-- 日志详情 --> :tool-button="false"
<Detail ref="detailRef" /> :columns="columns"
</div> :request-api="opLogApi.page"
</el-row> :init-param="initParam"
>
<!-- 表格 header 工具栏 -->
<template #tableHeader>
<div class="header-tools">
<el-radio-group v-model="initParam.category" @change="onCategoryChange">
<el-radio-button v-for="(item, index) in visLogTypeOptions" :key="index" :label="item.value">
{{ item.label }}
</el-radio-button>
</el-radio-group>
<el-input
v-model="initParam.searchKey"
placeholder="搜索操作名称 / IP / 账号"
clearable
style="width: 260px"
@clear="handleSearch"
@keyup.enter="handleSearch"
>
<template #append>
<el-button :icon="Search" class="search-btn" @click="handleSearch" />
</template>
</el-input>
<el-button :icon="Refresh" @click="handleSearch">刷新</el-button>
<s-button type="danger" plain :opt="FormOptEnum.DELETE" @click="onDelete('清空当前日志')">清空日志</s-button>
</div>
</template>
<!-- 操作名称 -->
<template #name="scope">
<span class="font-medium text-slate-800">{{ scope.row.name || '-' }}</span>
</template>
<!-- 分类/状态 -->
<template #category="scope">
<el-tag :type="scope.row.category === 'EXCEPTION' ? 'danger' : 'success'" size="small" effect="light">
{{ scope.row.category === 'EXCEPTION' ? '异常' : '正常' }}
</el-tag>
</template>
<!-- 操作用户 -->
<template #opUser="scope">
<div class="user-cell">
<span class="user-name">{{ scope.row.opUser || '-' }}</span>
<span v-if="scope.row.opAccount" class="user-account text-secondary">({{ scope.row.opAccount }})</span>
</div>
</template>
<!-- IP与地点 -->
<template #opIp="scope">
<el-tag size="small" type="info" class="ip-tag">{{ scope.row.opIp || '-' }}</el-tag>
</template>
<!-- 类与方法 -->
<template #methodName="scope">
<code class="method-code">{{ scope.row.methodName || '-' }}</code>
</template>
<!-- 操作 -->
<template #operation="scope">
<el-button link type="primary" :icon="View" @click="onOpen(scope.row)"> 详情 </el-button>
</template>
</ProTable>
<!-- 日志详情 -->
<Detail ref="detailRef" />
</div>
</div> </div>
</template> </template>
<script setup lang="ts" name="sysOpLog"> <script setup lang="tsx" name="sysOpLog">
import { reactive, ref } from "vue";
import ColumnChat from "./components/columnChat.vue"; import ColumnChat from "./components/columnChat.vue";
import PieChat from "./components/pieChat.vue"; import PieChat from "./components/pieChat.vue";
import Detail from "./components/detail.vue"; import Detail from "./components/detail.vue";
import { ColumnProps, ProTableInstance } from "@/components/ProTable/interface"; import { ColumnProps, ProTableInstance } from "@/components/ProTable/interface";
import { Search } from "@element-plus/icons-vue"; import { Search, Refresh, View } from "@element-plus/icons-vue";
import { OpLog, opLogApi } from "@/api"; import { OpLog, opLogApi } from "@/api";
import { FormOptEnum } from "@/enums"; import { FormOptEnum } from "@/enums";
import { useHandleData } from "@/hooks/useHandleData"; import { useHandleData } from "@/hooks/useHandleData";
// 左侧表格初始化条件
interface InitParam { interface InitParam {
category?: string; //字典分类 category?: string;
searchKey?: string; //关键字 searchKey?: string;
} }
// 访问日志类型
let visLogTypeOptions = [ const visLogTypeOptions = [
{ {
label: "操作日志", label: "操作日志",
value: "OPERATE" value: "OPERATE"
@@ -81,47 +123,103 @@ let visLogTypeOptions = [
value: "EXCEPTION" value: "EXCEPTION"
} }
]; ];
// 如果表格需要初始化请求参数,直接定义传给 ProTable(之后每次请求都会自动带上该参数,此参数更改之后也会一直带上,改变此参数会自动刷新表格数据)
const initParam = reactive<InitParam>({ category: visLogTypeOptions[0].value }); //主表格初始化参数
// 获取 ProTable 元素,调用其获取刷新数据方法(还能获取到当前查询参数,方便导出携带参数) const initParam = reactive<InitParam>({ category: visLogTypeOptions[0].value, searchKey: "" });
const proTable = ref<ProTableInstance>(); const proTable = ref<ProTableInstance>();
// 表格配置项 // 表格配置项
const columns: ColumnProps<OpLog.OpLogInfo>[] = [ const columns: ColumnProps<OpLog.OpLogInfo>[] = [
{ prop: "name", label: "名称" }, { prop: "name", label: "操作名称", minWidth: 150, align: "left" },
{ prop: "opIp", label: "IP地址" }, { prop: "category", label: "状态", width: 90, align: "center" },
{ prop: "opAddress", label: "地址" }, { prop: "opUser", label: "操作人员", minWidth: 140, align: "left" },
{ prop: "className", label: "类名称" }, { prop: "opIp", label: "IP地址", width: 140, align: "center" },
{ prop: "methodName", label: "方法名称" }, { prop: "opAddress", label: "归属地", minWidth: 130, align: "left" },
{ prop: "opUser", label: "用户" }, { prop: "className", label: "操作类", minWidth: 180, showOverflowTooltip: true, align: "left" },
{ prop: "opAccount", label: "账号" }, { prop: "methodName", label: "操作方法", minWidth: 130, showOverflowTooltip: true, align: "left" },
{ prop: "opTime", label: "操作时间" }, { prop: "opTime", label: "操作时间", width: 170, align: "center" },
{ prop: "operation", label: "操作", fixed: "right", width: 100 } { prop: "operation", label: "操作", fixed: "right", width: 90, align: "center" }
]; ];
const detailRef = ref<InstanceType<typeof Detail> | null>(null); //详情引用 const detailRef = ref<InstanceType<typeof Detail> | null>(null);
function onCategoryChange() {
handleSearch();
}
function handleSearch() {
proTable.value?.pageable && (proTable.value.pageable.pageNum = 1);
proTable.value?.refresh();
}
/**
* 打开表单
* @param record 记录
*/
function onOpen(record: OpLog.OpLogInfo) { function onOpen(record: OpLog.OpLogInfo) {
detailRef.value?.onOpen(record); detailRef.value?.onOpen(record);
} }
/**
* 清空日志
*/
async function onDelete(msg: string) { async function onDelete(msg: string) {
// 二次确认 => 请求api => 刷新表格
await useHandleData(opLogApi.delete, initParam.category, msg); await useHandleData(opLogApi.delete, initParam.category, msg);
proTable.value?.refresh(); //刷新主表格 proTable.value?.refresh();
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
:deep(.el-card__header) { .log-page-container {
padding: 9px; display: flex;
flex-direction: column;
gap: 8px;
}
.chart-card {
border-radius: 8px;
:deep(.el-card__header) {
padding: 12px 16px;
font-weight: 600;
font-size: 14px;
color: #1e293b;
border-bottom: 1px solid #f1f5f9;
}
}
.header-tools {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.search-btn {
cursor: pointer;
}
.font-medium {
font-weight: 500;
}
.user-cell {
display: flex;
align-items: center;
gap: 4px;
}
.user-name {
font-weight: 500;
}
.text-secondary {
color: #64748b;
font-size: 12px;
}
.ip-tag {
font-family: monospace;
}
.method-code {
font-family: Consolas, Monaco, monospace;
font-size: 12px;
background: #f1f5f9;
padding: 2px 6px;
border-radius: 4px;
color: #0f172a;
} }
</style> </style>
+126 -24
View File
@@ -1,32 +1,76 @@
<!-- <!--
* @Description: 详情 * @Description: 访问日志详情
* @Author: huguodong * @Author: huguodong
* @Date: 2023-12-15 15:44:38 * @Date: 2023-12-15 15:44:38
!--> !-->
<template> <template>
<form-container v-model="visible" title="日志详情" form-size="600px"> <form-container v-model="visible" title="访问日志详情" form-size="820px">
<el-descriptions :column="1" border class="mb-2"> <div class="log-detail-box">
<el-descriptions-item label="名称">{{ logInfo.name }}</el-descriptions-item> <!-- 1. 访问事件与用户信息 -->
<el-descriptions-item label="IP地址">{{ logInfo.opIp }}</el-descriptions-item> <el-card shadow="never" class="detail-card">
<el-descriptions-item label="地址">{{ logInfo.opAddress }}</el-descriptions-item> <template #header>
<el-descriptions-item label="浏览器">{{ logInfo.opBrowser }}</el-descriptions-item> <div class="card-title">事件与访问主体</div>
<el-descriptions-item label="设备">{{ logInfo.opOs }}</el-descriptions-item> </template>
<el-descriptions-item label="时间">{{ logInfo.opTime }}</el-descriptions-item> <el-descriptions :column="2" border size="small">
<el-descriptions-item label="用户">{{ logInfo.opUser }}</el-descriptions-item> <el-descriptions-item label="事件名称" :span="2">
<el-descriptions-item label="账号">{{ logInfo.opAccount }}</el-descriptions-item> <span class="font-bold text-slate-800">{{ logInfo.name || '-' }}</span>
</el-descriptions> </el-descriptions-item>
<el-descriptions-item label="事件类型">
<el-tag :type="logInfo.category === 'LOGIN' ? 'primary' : 'warning'" size="small">
{{ logInfo.category === 'LOGIN' ? '用户登录' : '用户登出' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="访问时间">
{{ logInfo.opTime || '-' }}
</el-descriptions-item>
<el-descriptions-item label="访问人员">
{{ logInfo.opUser || '-' }}
</el-descriptions-item>
<el-descriptions-item label="访问账号">
{{ logInfo.opAccount || '-' }}
</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- 2. 网络与设备环境 -->
<el-card shadow="never" class="detail-card mt-3">
<template #header>
<div class="card-title">网络与终端环境</div>
</template>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="IP 地址">
<div class="ip-cell">
<code class="code-badge">{{ logInfo.opIp || '-' }}</code>
<el-button v-if="logInfo.opIp" link type="primary" size="small" :icon="CopyDocument" @click="copyText(logInfo.opIp)">复制</el-button>
</div>
</el-descriptions-item>
<el-descriptions-item label="地理位置">
{{ logInfo.opAddress || '-' }}
</el-descriptions-item>
<el-descriptions-item label="客户端浏览器">
{{ logInfo.opBrowser || '-' }}
</el-descriptions-item>
<el-descriptions-item label="操作系统">
{{ logInfo.opOs || '-' }}
</el-descriptions-item>
</el-descriptions>
</el-card>
</div>
<template #footer> <template #footer>
<el-button type="primary" @click="onClose"> 确定 </el-button> <el-button @click="onClose">关 闭</el-button>
</template> </template>
</form-container> </form-container>
</template> </template>
<script setup lang="ts" name="detail"> <script setup lang="ts" name="detail">
import { ref } from "vue";
import { CopyDocument } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import { VisLog } from "@/api"; import { VisLog } from "@/api";
const visible = ref(false); //是否显示表单 const visible = ref(false);
// 表单参数
const logInfo = ref<VisLog.VisLogInfo>({ const logInfo = ref<VisLog.VisLogInfo>({
name: "", name: "",
opIp: "", opIp: "",
@@ -42,24 +86,82 @@ const logInfo = ref<VisLog.VisLogInfo>({
createTime: "" createTime: ""
}); });
/**
* 打开表单
* @param props 表单参数
*/
function onOpen(record: VisLog.VisLogInfo) { function onOpen(record: VisLog.VisLogInfo) {
logInfo.value = record; logInfo.value = { ...record };
visible.value = true; //显示表单 visible.value = true;
}
function copyText(text: string) {
if (!text) return;
if (navigator.clipboard) {
navigator.clipboard.writeText(text).then(() => {
ElMessage.success("已复制到剪贴板");
});
} else {
const input = document.createElement("textarea");
input.value = text;
document.body.appendChild(input);
input.select();
document.execCommand("copy");
document.body.removeChild(input);
ElMessage.success("已复制到剪贴板");
}
} }
/** 关闭表单*/
function onClose() { function onClose() {
visible.value = false; visible.value = false;
} }
// 暴露给父组件的方法
defineExpose({ defineExpose({
onOpen onOpen
}); });
</script> </script>
<style lang="scss" scoped></style> <style lang="scss" scoped>
.log-detail-box {
display: flex;
flex-direction: column;
padding: 4px 6px;
}
.detail-card {
border-radius: 8px;
border: 1px solid #e2e8f0;
:deep(.el-card__header) {
padding: 10px 14px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
}
:deep(.el-card__body) {
padding: 12px;
}
}
.card-title {
font-weight: 600;
font-size: 13px;
color: #1e293b;
}
.font-bold {
font-weight: 600;
}
.ip-cell {
display: flex;
align-items: center;
gap: 8px;
}
.code-badge {
font-family: monospace;
background: #f1f5f9;
padding: 2px 6px;
border-radius: 4px;
font-size: 12px;
}
.mt-3 {
margin-top: 12px;
}
</style>
+167 -69
View File
@@ -4,74 +4,120 @@
* @Date: 2023-12-15 15:44:33 * @Date: 2023-12-15 15:44:33
!--> !-->
<template> <template>
<div> <div class="log-page-container">
<el-row :gutter="10" class="mb-2"> <el-row :gutter="12" class="mb-3">
<el-col :span="16"> <el-col :span="16">
<el-card header="周统计" shadow="never"> <el-card header="近七日访问趋势" shadow="never" class="chart-card">
<line-chat /> <line-chat />
</el-card> </el-card>
</el-col> </el-col>
<el-col :span="8"> <el-col :span="8">
<el-card header="总比例" shadow="never"> <el-card header="访问类型占比" shadow="never" class="chart-card">
<pie-chat /> <pie-chat />
</el-card> </el-card>
</el-col> </el-col>
</el-row> </el-row>
<el-row>
<div class="table-box min-h-400px">
<ProTable
ref="proTable"
title="系统字典"
class="table"
:tool-button="false"
:columns="columns"
:request-api="visLogApi.page"
:init-param="initParam"
>
<!-- 表格 header 按钮 -->
<template #tableHeader>
<el-space>
<el-radio-group v-model="initParam.category" class="mb-15px">
<el-radio-button v-for="(item, index) in visLogTypeOptions" :key="index" :label="item.value">{{ item.label }}</el-radio-button>
</el-radio-group>
<el-input v-model="initParam.searchKey" placeholder="请输入字典名称" class="mb-15px">
<template #append>
<el-button :icon="Search" class="el-input-button" />
</template>
</el-input>
<s-button type="danger" plain :opt="FormOptEnum.DELETE" @click="onDelete('清空当前日志')">清空</s-button>
</el-space>
</template>
<!-- 操作 --> <div class="table-box">
<template #operation="scope"> <ProTable
<s-button link :opt="FormOptEnum.VIEW" @click="onOpen(scope.row)"> 详情 </s-button> ref="proTable"
</template> title="访问日志列表"
</ProTable> class="table"
<!-- 日志详情 --> :tool-button="false"
<Detail ref="detailRef" /> :columns="columns"
</div> :request-api="visLogApi.page"
</el-row> :init-param="initParam"
>
<!-- 表格 header 工具栏 -->
<template #tableHeader>
<div class="header-tools">
<el-radio-group v-model="initParam.category" @change="onCategoryChange">
<el-radio-button v-for="(item, index) in visLogTypeOptions" :key="index" :label="item.value">
{{ item.label }}
</el-radio-button>
</el-radio-group>
<el-input
v-model="initParam.searchKey"
placeholder="搜索日志名称 / 访问IP / 账号"
clearable
style="width: 260px"
@clear="handleSearch"
@keyup.enter="handleSearch"
>
<template #append>
<el-button :icon="Search" class="search-btn" @click="handleSearch" />
</template>
</el-input>
<el-button :icon="Refresh" @click="handleSearch">刷新</el-button>
<s-button type="danger" plain :opt="FormOptEnum.DELETE" @click="onDelete('清空当前日志')">清空日志</s-button>
</div>
</template>
<!-- 名称 -->
<template #name="scope">
<span class="font-medium text-slate-800">{{ scope.row.name || '-' }}</span>
</template>
<!-- 分类/类型 -->
<template #category="scope">
<el-tag :type="scope.row.category === 'LOGIN' ? 'primary' : 'warning'" size="small" effect="light">
{{ scope.row.category === 'LOGIN' ? '登录' : '登出' }}
</el-tag>
</template>
<!-- 操作人员 -->
<template #opUser="scope">
<div class="user-cell">
<span class="user-name">{{ scope.row.opUser || '-' }}</span>
<span v-if="scope.row.opAccount" class="user-account text-secondary">({{ scope.row.opAccount }})</span>
</div>
</template>
<!-- IP与地点 -->
<template #opIp="scope">
<el-tag size="small" type="info" class="ip-tag">{{ scope.row.opIp || '-' }}</el-tag>
</template>
<!-- 浏览器与系统 -->
<template #opBrowser="scope">
<span class="env-text">{{ scope.row.opBrowser || '-' }}</span>
</template>
<template #opOs="scope">
<span class="env-text">{{ scope.row.opOs || '-' }}</span>
</template>
<!-- 操作 -->
<template #operation="scope">
<el-button link type="primary" :icon="View" @click="onOpen(scope.row)"> 详情 </el-button>
</template>
</ProTable>
<!-- 日志详情 -->
<Detail ref="detailRef" />
</div>
</div> </div>
</template> </template>
<script setup lang="ts" name="sysVisLog"> <script setup lang="tsx" name="sysVisLog">
import { reactive, ref } from "vue";
import LineChat from "./components/lineChat.vue"; import LineChat from "./components/lineChat.vue";
import PieChat from "./components/pieChat.vue"; import PieChat from "./components/pieChat.vue";
import Detail from "./components/detail.vue"; import Detail from "./components/detail.vue";
import { ColumnProps, ProTableInstance } from "@/components/ProTable/interface"; import { ColumnProps, ProTableInstance } from "@/components/ProTable/interface";
import { Search } from "@element-plus/icons-vue"; import { Search, Refresh, View } from "@element-plus/icons-vue";
import { VisLog, visLogApi } from "@/api"; import { VisLog, visLogApi } from "@/api";
import { FormOptEnum } from "@/enums"; import { FormOptEnum } from "@/enums";
import { useHandleData } from "@/hooks/useHandleData"; import { useHandleData } from "@/hooks/useHandleData";
// 左侧表格初始化条件
interface InitParam { interface InitParam {
category?: string; //字典分类 category?: string;
searchKey?: string; //关键字 searchKey?: string;
} }
// 访问日志类型
let visLogTypeOptions = [ const visLogTypeOptions = [
{ {
label: "登录日志", label: "登录日志",
value: "LOGIN" value: "LOGIN"
@@ -81,47 +127,99 @@ let visLogTypeOptions = [
value: "LOGOUT" value: "LOGOUT"
} }
]; ];
// 如果表格需要初始化请求参数,直接定义传给 ProTable(之后每次请求都会自动带上该参数,此参数更改之后也会一直带上,改变此参数会自动刷新表格数据)
const initParam = reactive<InitParam>({ category: visLogTypeOptions[0].value }); //主表格初始化参数
// 获取 ProTable 元素,调用其获取刷新数据方法(还能获取到当前查询参数,方便导出携带参数) const initParam = reactive<InitParam>({ category: visLogTypeOptions[0].value, searchKey: "" });
const proTable = ref<ProTableInstance>(); const proTable = ref<ProTableInstance>();
// 表格配置项 // 表格配置项
const columns: ColumnProps<VisLog.VisLogInfo>[] = [ const columns: ColumnProps<VisLog.VisLogInfo>[] = [
{ prop: "name", label: "名称" }, { prop: "name", label: "日志名称", minWidth: 150, align: "left" },
{ prop: "opIp", label: "IP地址" }, { prop: "category", label: "事件类型", width: 100, align: "center" },
{ prop: "opAddress", label: "地址" }, { prop: "opUser", label: "访问人员", minWidth: 140, align: "left" },
{ prop: "opBrowser", label: "浏览器" }, { prop: "opIp", label: "访问IP", width: 140, align: "center" },
{ prop: "opOs", label: "操作系统" }, { prop: "opAddress", label: "登录地点", minWidth: 130, align: "left" },
{ prop: "opUser", label: "用户" }, { prop: "opBrowser", label: "浏览器", minWidth: 130, showOverflowTooltip: true, align: "left" },
{ prop: "opAccount", label: "账号" }, { prop: "opOs", label: "操作系统", minWidth: 130, showOverflowTooltip: true, align: "left" },
{ prop: "opTime", label: "操作时间" }, { prop: "opTime", label: "访问时间", width: 170, align: "center" },
{ prop: "operation", label: "操作", fixed: "right", width: 100 } { prop: "operation", label: "操作", fixed: "right", width: 90, align: "center" }
]; ];
const detailRef = ref<InstanceType<typeof Detail> | null>(null); //详情引用 const detailRef = ref<InstanceType<typeof Detail> | null>(null);
function onCategoryChange() {
handleSearch();
}
function handleSearch() {
proTable.value?.pageable && (proTable.value.pageable.pageNum = 1);
proTable.value?.refresh();
}
/**
* 打开表单
* @param record 记录
*/
function onOpen(record: VisLog.VisLogInfo) { function onOpen(record: VisLog.VisLogInfo) {
detailRef.value?.onOpen(record); detailRef.value?.onOpen(record);
} }
/**
* 清空日志
*/
async function onDelete(msg: string) { async function onDelete(msg: string) {
// 二次确认 => 请求api => 刷新表格
await useHandleData(visLogApi.delete, initParam.category, msg); await useHandleData(visLogApi.delete, initParam.category, msg);
proTable.value?.refresh(); //刷新主表格 proTable.value?.refresh();
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
:deep(.el-card__header) { .log-page-container {
padding: 9px; display: flex;
flex-direction: column;
gap: 8px;
}
.chart-card {
border-radius: 8px;
:deep(.el-card__header) {
padding: 12px 16px;
font-weight: 600;
font-size: 14px;
color: #1e293b;
border-bottom: 1px solid #f1f5f9;
}
}
.header-tools {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.search-btn {
cursor: pointer;
}
.font-medium {
font-weight: 500;
}
.user-cell {
display: flex;
align-items: center;
gap: 4px;
}
.user-name {
font-weight: 500;
}
.text-secondary {
color: #64748b;
font-size: 12px;
}
.ip-tag {
font-family: monospace;
}
.env-text {
font-size: 13px;
color: #475569;
} }
</style> </style>