1111
This commit is contained in:
@@ -228,12 +228,28 @@ export const onboardingTaskApi = {
|
||||
},
|
||||
|
||||
/** 使用后台登录令牌读取文件流,供入职材料下载和图片预览使用。 */
|
||||
downloadFile(fileId: number) {
|
||||
return fileHttp.get<any>(
|
||||
async downloadFile(fileId: number) {
|
||||
const response: any = await fileHttp.get<any>(
|
||||
"download",
|
||||
{ id: fileId },
|
||||
{ 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>) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div v-if="columns.length" class="card table-search">
|
||||
<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">
|
||||
<el-form-item>
|
||||
<template #label>
|
||||
@@ -20,12 +20,6 @@
|
||||
<div class="operation">
|
||||
<el-button type="primary" :icon="Search" @click="search"> 搜索 </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>
|
||||
</GridItem>
|
||||
</Grid>
|
||||
@@ -33,10 +27,10 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts" name="SearchForm">
|
||||
import { computed, ref } from "vue";
|
||||
import { ref } from "vue";
|
||||
import { ColumnProps } from "@/components/ProTable/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 Grid from "@/components/Grid/index.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 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>
|
||||
|
||||
@@ -12,13 +12,26 @@
|
||||
</el-button>
|
||||
</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">
|
||||
<el-tag effect="plain" type="primary">
|
||||
{{ scope.row.contractType }}
|
||||
</el-tag>
|
||||
</template>
|
||||
|
||||
<template #positionNames="scope">
|
||||
<template #positionId="scope">
|
||||
<el-space wrap>
|
||||
<el-tag v-for="name in scope.row.positionNames || []" :key="name" size="small">
|
||||
{{ name }}
|
||||
@@ -498,6 +511,31 @@ const templateEditing = ref(false);
|
||||
const companies = ref<OnboardingCompanyOption[]>([]);
|
||||
const departments = ref<OnboardingDepartmentOption[]>([]);
|
||||
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 versionVisible = ref(false);
|
||||
const variablesVisible = ref(false);
|
||||
@@ -560,11 +598,38 @@ const versionForm = reactive<Partial<OnboardingContractVersion>>(emptyVersion())
|
||||
const variableForm = reactive<Partial<OnboardingTemplateVariable>>(emptyVariable());
|
||||
|
||||
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: "templateName", label: "模板名称", minWidth: 200, search: { el: "input" } },
|
||||
{ prop: "positionNames", label: "适用岗位", minWidth: 200 },
|
||||
{ prop: "contractType", label: "合同类型", width: 140, search: { el: "input" } },
|
||||
{
|
||||
prop: "status",
|
||||
@@ -737,9 +802,47 @@ async function removeVersion(row: OnboardingContractVersion) {
|
||||
ElMessage.success("版本删除成功");
|
||||
}
|
||||
|
||||
function downloadDocx(fileId?: number) {
|
||||
/**
|
||||
* 使用后台登录令牌读取文件流,避免直接打开受保护接口时出现 401。
|
||||
* 移动端使用独立的入职 Token 公共接口,不经过这里。
|
||||
*/
|
||||
async function downloadDocx(fileId?: number) {
|
||||
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";
|
||||
@@ -806,7 +909,35 @@ async function saveVariable() {
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,12 +1,60 @@
|
||||
<template>
|
||||
<div class="template-panel">
|
||||
<div class="toolbar">
|
||||
<el-input v-model="query.keyword" clearable placeholder="搜索模板编码或名称" class="keyword-input"
|
||||
@keyup.enter="loadTemplates" />
|
||||
<el-select v-model="query.companyId" clearable filterable placeholder="全部公司" class="company-filter">
|
||||
<el-input
|
||||
v-model="query.keyword"
|
||||
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-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>
|
||||
<div class="toolbar-spacer" />
|
||||
<el-button type="primary" icon="Plus" @click="openCreate">新增入职模板</el-button>
|
||||
@@ -235,6 +283,7 @@ import {
|
||||
OnboardingDepartmentOption,
|
||||
OnboardingMaterialType,
|
||||
OnboardingPosition,
|
||||
OnboardingPositionOption,
|
||||
OnboardingTemplate,
|
||||
onboardingTaskApi
|
||||
} from "@/api/modules/biz/onboarding";
|
||||
@@ -246,6 +295,8 @@ const rows = ref<OnboardingTemplate[]>([]);
|
||||
const total = ref(0);
|
||||
|
||||
const companies = ref<OnboardingCompany[]>([]);
|
||||
const queryDepartments = ref<OnboardingDepartmentOption[]>([]);
|
||||
const queryPositions = ref<OnboardingPositionOption[]>([]);
|
||||
const departments = ref<OnboardingDepartmentOption[]>([]);
|
||||
const positions = ref<OnboardingPosition[]>([]);
|
||||
const contractTemplates = ref<OnboardingContractTemplate[]>([]);
|
||||
@@ -255,6 +306,9 @@ const versionOptions = reactive<Record<number, OnboardingContractVersion[]>>({})
|
||||
const query = reactive({
|
||||
keyword: "",
|
||||
companyId: undefined as number | undefined,
|
||||
departmentId: undefined as number | undefined,
|
||||
positionId: undefined as number | undefined,
|
||||
status: "enable" as string | undefined,
|
||||
pageNum: 1,
|
||||
pageSize: 20
|
||||
});
|
||||
@@ -273,6 +327,30 @@ const form = reactive<OnboardingTemplate>({
|
||||
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() {
|
||||
const [companyRes, materialRes] = await Promise.all([
|
||||
onboardingTaskApi.companyPage({ pageNum: 1, pageSize: 100, status: "enable" }).catch(() => ({ data: { list: [] } })),
|
||||
@@ -280,6 +358,7 @@ async function loadBaseData() {
|
||||
]);
|
||||
companies.value = companyRes.data?.list || [];
|
||||
materialTypes.value = materialRes.data || [];
|
||||
await loadAllQueryOptions();
|
||||
}
|
||||
|
||||
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() {
|
||||
query.keyword = "";
|
||||
query.companyId = undefined;
|
||||
query.departmentId = undefined;
|
||||
query.positionId = undefined;
|
||||
query.status = "enable";
|
||||
query.pageNum = 1;
|
||||
loadAllQueryOptions();
|
||||
loadTemplates();
|
||||
}
|
||||
|
||||
@@ -521,7 +651,19 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.company-filter {
|
||||
width: 180px;
|
||||
width: 170px;
|
||||
}
|
||||
|
||||
.department-filter {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.position-filter {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.status-filter {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.toolbar-spacer {
|
||||
|
||||
@@ -1,43 +1,133 @@
|
||||
<!--
|
||||
* @Description: 详情
|
||||
* @Description: 操作日志详情
|
||||
* @Author: huguodong
|
||||
* @Date: 2023-12-15 15:44:16
|
||||
!-->
|
||||
<template>
|
||||
<form-container v-model="visible" title="日志详情" form-size="650px">
|
||||
<el-descriptions :column="1" border class="mb-2">
|
||||
<el-descriptions-item label="名称">{{ logInfo.name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="IP地址">{{ logInfo.opIp }}</el-descriptions-item>
|
||||
<el-descriptions-item label="地址">{{ logInfo.opAddress }}</el-descriptions-item>
|
||||
<el-descriptions-item label="请求地址">{{ logInfo.reqUrl }}</el-descriptions-item>
|
||||
<el-descriptions-item label="操作类">{{ logInfo.className }}</el-descriptions-item>
|
||||
<el-descriptions-item label="操作方法">{{ logInfo.methodName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="浏览器">{{ logInfo.opBrowser }}</el-descriptions-item>
|
||||
<el-descriptions-item label="设备">{{ logInfo.opOs }}</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-space direction="vertical" class="mb-2 w-full">
|
||||
<span>请求参数:</span>
|
||||
<code-high-light language="JSON" class="w-600px" :code="paramJson" />
|
||||
</el-space>
|
||||
<el-space direction="vertical" class="mb-2" style="width: 100%">
|
||||
返回结果:
|
||||
<code-high-light autodetect class="w-600px" :code="resultJson"></code-high-light>
|
||||
</el-space>
|
||||
<form-container v-model="visible" title="操作日志详情" form-size="980px">
|
||||
<div class="log-detail-box" v-loading="loading">
|
||||
<!-- 异常告警条(仅异常日志显示) -->
|
||||
<div v-if="logInfo.category === 'EXCEPTION' || logInfo.exeMessage" class="exception-alert-box">
|
||||
<div class="exception-header">
|
||||
<el-icon color="#ef4444" :size="18"><WarningFilled /></el-icon>
|
||||
<span class="exception-title">异常堆栈 / 错误信息</span>
|
||||
<el-button link type="danger" size="small" :icon="CopyDocument" @click="copyText(logInfo.exeMessage || '')">复制异常</el-button>
|
||||
</div>
|
||||
<pre class="exception-content">{{ logInfo.exeMessage || '发生未知异常' }}</pre>
|
||||
</div>
|
||||
|
||||
<!-- 1. 基本与环境信息 -->
|
||||
<el-card shadow="never" class="detail-card">
|
||||
<template #header>
|
||||
<div class="card-title">基本信息与客户端</div>
|
||||
</template>
|
||||
<el-descriptions :column="3" border size="small">
|
||||
<el-descriptions-item label="操作名称" :span="2">
|
||||
<span class="font-bold text-slate-800">{{ logInfo.name || '-' }}</span>
|
||||
</el-descriptions-item>
|
||||
<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>
|
||||
<el-button type="primary" @click="onClose"> 确定 </el-button>
|
||||
<el-button @click="onClose">关 闭</el-button>
|
||||
</template>
|
||||
</form-container>
|
||||
</template>
|
||||
|
||||
<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";
|
||||
|
||||
const visible = ref(false); //是否显示表单
|
||||
const visible = ref(false);
|
||||
const loading = ref(false);
|
||||
|
||||
// 表单参数
|
||||
const logInfo = ref<OpLog.OpLogInfo>({
|
||||
name: "",
|
||||
opIp: "",
|
||||
@@ -60,40 +150,210 @@ const logInfo = ref<OpLog.OpLogInfo>({
|
||||
resultJson: ""
|
||||
});
|
||||
|
||||
const paramJson = ref<string>();
|
||||
const resultJson = ref<string>();
|
||||
const paramFormatted = ref<string>("");
|
||||
const resultFormatted = ref<string>("");
|
||||
|
||||
/**
|
||||
* 打开表单
|
||||
* @param props 表单参数
|
||||
*/
|
||||
function onOpen(record: OpLog.OpLogInfo) {
|
||||
logInfo.value = record;
|
||||
visible.value = true; //显示表单
|
||||
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) {
|
||||
resultJson.value = res.data.exeMessage;
|
||||
} else {
|
||||
if (res.data.resultJson) resultJson.value = JSON.stringify(JSON.parse(res.data.resultJson), null, 2);
|
||||
function safeJsonFormat(raw?: string | null): string {
|
||||
if (!raw || typeof raw !== "string" || !raw.trim()) return "";
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return JSON.stringify(parsed, null, 2);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
function reqMethodTag(method?: string) {
|
||||
const m = (method || "").toUpperCase();
|
||||
if (m === "POST") return "success";
|
||||
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() {
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
// 暴露给父组件的方法
|
||||
defineExpose({
|
||||
onOpen
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.el-space__item) {
|
||||
width: 100% !important;
|
||||
.log-detail-box {
|
||||
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>
|
||||
|
||||
@@ -4,74 +4,116 @@
|
||||
* @Date: 2023-12-15 15:44:27
|
||||
!-->
|
||||
<template>
|
||||
<div>
|
||||
<el-row :gutter="10" class="mb-2">
|
||||
<div class="log-page-container">
|
||||
<el-row :gutter="12" class="mb-3">
|
||||
<el-col :span="16">
|
||||
<el-card header="周统计" shadow="never">
|
||||
<el-card header="近七日操作趋势" shadow="never" class="chart-card">
|
||||
<column-chat />
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-card header="总比例" shadow="never">
|
||||
<el-card header="日志类型占比" shadow="never" class="chart-card">
|
||||
<pie-chat />
|
||||
</el-card>
|
||||
</el-col>
|
||||
</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>
|
||||
|
||||
<!-- 操作 -->
|
||||
<template #operation="scope">
|
||||
<s-button link :opt="FormOptEnum.VIEW" @click="onOpen(scope.row)"> 详情 </s-button>
|
||||
</template>
|
||||
</ProTable>
|
||||
<!-- 日志详情 -->
|
||||
<Detail ref="detailRef" />
|
||||
</div>
|
||||
</el-row>
|
||||
<div class="table-box">
|
||||
<ProTable
|
||||
ref="proTable"
|
||||
title="操作日志列表"
|
||||
class="table"
|
||||
:tool-button="false"
|
||||
:columns="columns"
|
||||
:request-api="opLogApi.page"
|
||||
: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>
|
||||
</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 PieChat from "./components/pieChat.vue";
|
||||
import Detail from "./components/detail.vue";
|
||||
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 { FormOptEnum } from "@/enums";
|
||||
import { useHandleData } from "@/hooks/useHandleData";
|
||||
|
||||
// 左侧表格初始化条件
|
||||
interface InitParam {
|
||||
category?: string; //字典分类
|
||||
searchKey?: string; //关键字
|
||||
category?: string;
|
||||
searchKey?: string;
|
||||
}
|
||||
// 访问日志类型
|
||||
let visLogTypeOptions = [
|
||||
|
||||
const visLogTypeOptions = [
|
||||
{
|
||||
label: "操作日志",
|
||||
value: "OPERATE"
|
||||
@@ -81,47 +123,103 @@ let visLogTypeOptions = [
|
||||
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 columns: ColumnProps<OpLog.OpLogInfo>[] = [
|
||||
{ prop: "name", label: "名称" },
|
||||
{ prop: "opIp", label: "IP地址" },
|
||||
{ prop: "opAddress", label: "地址" },
|
||||
{ prop: "className", label: "类名称" },
|
||||
{ prop: "methodName", label: "方法名称" },
|
||||
{ prop: "opUser", label: "用户" },
|
||||
{ prop: "opAccount", label: "账号" },
|
||||
{ prop: "opTime", label: "操作时间" },
|
||||
{ prop: "operation", label: "操作", fixed: "right", width: 100 }
|
||||
{ prop: "name", label: "操作名称", minWidth: 150, align: "left" },
|
||||
{ prop: "category", label: "状态", width: 90, align: "center" },
|
||||
{ prop: "opUser", label: "操作人员", minWidth: 140, align: "left" },
|
||||
{ prop: "opIp", label: "IP地址", width: 140, align: "center" },
|
||||
{ prop: "opAddress", label: "归属地", minWidth: 130, align: "left" },
|
||||
{ prop: "className", label: "操作类", minWidth: 180, showOverflowTooltip: true, align: "left" },
|
||||
{ prop: "methodName", label: "操作方法", minWidth: 130, showOverflowTooltip: true, align: "left" },
|
||||
{ prop: "opTime", label: "操作时间", width: 170, align: "center" },
|
||||
{ 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) {
|
||||
detailRef.value?.onOpen(record);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空日志
|
||||
*/
|
||||
async function onDelete(msg: string) {
|
||||
// 二次确认 => 请求api => 刷新表格
|
||||
await useHandleData(opLogApi.delete, initParam.category, msg);
|
||||
proTable.value?.refresh(); //刷新主表格
|
||||
proTable.value?.refresh();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.el-card__header) {
|
||||
padding: 9px;
|
||||
.log-page-container {
|
||||
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>
|
||||
|
||||
@@ -1,32 +1,76 @@
|
||||
<!--
|
||||
* @Description: 详情
|
||||
* @Description: 访问日志详情
|
||||
* @Author: huguodong
|
||||
* @Date: 2023-12-15 15:44:38
|
||||
!-->
|
||||
<template>
|
||||
<form-container v-model="visible" title="日志详情" form-size="600px">
|
||||
<el-descriptions :column="1" border class="mb-2">
|
||||
<el-descriptions-item label="名称">{{ logInfo.name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="IP地址">{{ logInfo.opIp }}</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-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>
|
||||
<form-container v-model="visible" title="访问日志详情" form-size="820px">
|
||||
<div class="log-detail-box">
|
||||
<!-- 1. 访问事件与用户信息 -->
|
||||
<el-card shadow="never" class="detail-card">
|
||||
<template #header>
|
||||
<div class="card-title">事件与访问主体</div>
|
||||
</template>
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="事件名称" :span="2">
|
||||
<span class="font-bold text-slate-800">{{ logInfo.name || '-' }}</span>
|
||||
</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>
|
||||
<el-button type="primary" @click="onClose"> 确定 </el-button>
|
||||
<el-button @click="onClose">关 闭</el-button>
|
||||
</template>
|
||||
</form-container>
|
||||
</template>
|
||||
|
||||
<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";
|
||||
|
||||
const visible = ref(false); //是否显示表单
|
||||
const visible = ref(false);
|
||||
|
||||
// 表单参数
|
||||
const logInfo = ref<VisLog.VisLogInfo>({
|
||||
name: "",
|
||||
opIp: "",
|
||||
@@ -42,24 +86,82 @@ const logInfo = ref<VisLog.VisLogInfo>({
|
||||
createTime: ""
|
||||
});
|
||||
|
||||
/**
|
||||
* 打开表单
|
||||
* @param props 表单参数
|
||||
*/
|
||||
function onOpen(record: VisLog.VisLogInfo) {
|
||||
logInfo.value = record;
|
||||
visible.value = true; //显示表单
|
||||
logInfo.value = { ...record };
|
||||
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() {
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
// 暴露给父组件的方法
|
||||
defineExpose({
|
||||
onOpen
|
||||
});
|
||||
</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>
|
||||
|
||||
@@ -4,74 +4,120 @@
|
||||
* @Date: 2023-12-15 15:44:33
|
||||
!-->
|
||||
<template>
|
||||
<div>
|
||||
<el-row :gutter="10" class="mb-2">
|
||||
<div class="log-page-container">
|
||||
<el-row :gutter="12" class="mb-3">
|
||||
<el-col :span="16">
|
||||
<el-card header="周统计" shadow="never">
|
||||
<el-card header="近七日访问趋势" shadow="never" class="chart-card">
|
||||
<line-chat />
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-card header="总比例" shadow="never">
|
||||
<el-card header="访问类型占比" shadow="never" class="chart-card">
|
||||
<pie-chat />
|
||||
</el-card>
|
||||
</el-col>
|
||||
</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>
|
||||
|
||||
<!-- 操作 -->
|
||||
<template #operation="scope">
|
||||
<s-button link :opt="FormOptEnum.VIEW" @click="onOpen(scope.row)"> 详情 </s-button>
|
||||
</template>
|
||||
</ProTable>
|
||||
<!-- 日志详情 -->
|
||||
<Detail ref="detailRef" />
|
||||
</div>
|
||||
</el-row>
|
||||
<div class="table-box">
|
||||
<ProTable
|
||||
ref="proTable"
|
||||
title="访问日志列表"
|
||||
class="table"
|
||||
:tool-button="false"
|
||||
:columns="columns"
|
||||
:request-api="visLogApi.page"
|
||||
: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>
|
||||
</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 PieChat from "./components/pieChat.vue";
|
||||
import Detail from "./components/detail.vue";
|
||||
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 { FormOptEnum } from "@/enums";
|
||||
import { useHandleData } from "@/hooks/useHandleData";
|
||||
|
||||
// 左侧表格初始化条件
|
||||
interface InitParam {
|
||||
category?: string; //字典分类
|
||||
searchKey?: string; //关键字
|
||||
category?: string;
|
||||
searchKey?: string;
|
||||
}
|
||||
// 访问日志类型
|
||||
let visLogTypeOptions = [
|
||||
|
||||
const visLogTypeOptions = [
|
||||
{
|
||||
label: "登录日志",
|
||||
value: "LOGIN"
|
||||
@@ -81,47 +127,99 @@ let visLogTypeOptions = [
|
||||
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 columns: ColumnProps<VisLog.VisLogInfo>[] = [
|
||||
{ prop: "name", label: "名称" },
|
||||
{ prop: "opIp", label: "IP地址" },
|
||||
{ prop: "opAddress", label: "地址" },
|
||||
{ prop: "opBrowser", label: "浏览器" },
|
||||
{ prop: "opOs", label: "操作系统" },
|
||||
{ prop: "opUser", label: "用户" },
|
||||
{ prop: "opAccount", label: "账号" },
|
||||
{ prop: "opTime", label: "操作时间" },
|
||||
{ prop: "operation", label: "操作", fixed: "right", width: 100 }
|
||||
{ prop: "name", label: "日志名称", minWidth: 150, align: "left" },
|
||||
{ prop: "category", label: "事件类型", width: 100, align: "center" },
|
||||
{ prop: "opUser", label: "访问人员", minWidth: 140, align: "left" },
|
||||
{ prop: "opIp", label: "访问IP", width: 140, align: "center" },
|
||||
{ prop: "opAddress", label: "登录地点", minWidth: 130, align: "left" },
|
||||
{ prop: "opBrowser", label: "浏览器", minWidth: 130, showOverflowTooltip: true, align: "left" },
|
||||
{ prop: "opOs", label: "操作系统", minWidth: 130, showOverflowTooltip: true, align: "left" },
|
||||
{ prop: "opTime", label: "访问时间", width: 170, align: "center" },
|
||||
{ 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) {
|
||||
detailRef.value?.onOpen(record);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空日志
|
||||
*/
|
||||
async function onDelete(msg: string) {
|
||||
// 二次确认 => 请求api => 刷新表格
|
||||
await useHandleData(visLogApi.delete, initParam.category, msg);
|
||||
proTable.value?.refresh(); //刷新主表格
|
||||
proTable.value?.refresh();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.el-card__header) {
|
||||
padding: 9px;
|
||||
.log-page-container {
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user