first commit

This commit is contained in:
zc
2026-02-26 17:31:18 +08:00
commit f1b87df6ca
803 changed files with 297148 additions and 0 deletions

View File

@@ -0,0 +1,78 @@
<template>
<div class="gi_table_page">
<!-- <a-row justify="space-between" align="center" class="header">
<a-space wrap>
<div class="title">系统日志</div>
</a-space>
</a-row> -->
<a-tabs v-model:active-key="activeKey" type="card-gutter" size="large" @change="change">
<a-tab-pane key="1">
<template #title><icon-lock /> 登录日志</template>
</a-tab-pane>
<a-tab-pane key="2">
<template #title><icon-find-replace /> 操作日志</template>
</a-tab-pane>
</a-tabs>
<keep-alive>
<component :is="PaneMap[activeKey]" />
</keep-alive>
</div>
</template>
<script setup lang="ts">
import LoginLog from './login/index.vue'
import OperationLog from './operation/index.vue'
const route = useRoute()
const router = useRouter()
const PaneMap: Record<string, Component> = {
1: LoginLog,
2: OperationLog,
}
const activeKey = ref('1')
watch(
() => route.query,
() => {
if (route.query.tabKey) {
activeKey.value = String(route.query.tabKey)
}
},
{ immediate: true },
)
const change = (key: string | number) => {
activeKey.value = key as string
router.replace({ path: route.path, query: { tabKey: key } })
}
</script>
<style scoped lang="scss">
:deep(.arco-tabs .arco-tabs-nav-type-card-gutter .arco-tabs-tab-active) {
box-shadow: inset 0 2px 0 rgb(var(--primary-6)), inset -1px 0 0 var(--color-border-2),
inset 1px 0 0 var(--color-border-2);
position: relative;
}
:deep(.arco-tabs-nav-type-card-gutter .arco-tabs-tab) {
border-radius: var(--border-radius-medium) var(--border-radius-medium) 0 0;
}
:deep(.arco-tabs-type-card-gutter > .arco-tabs-content) {
border: none;
}
:deep(.arco-tabs-nav::before) {
left: -20px;
right: -20px;
}
:deep(.arco-tabs) {
overflow: visible;
}
:deep(.arco-tabs-nav) {
overflow: visible;
}
</style>

View File

@@ -0,0 +1,138 @@
<template>
<GiTable
row-key="id"
:data="dataList"
:columns="columns"
:loading="loading"
:scroll="{ x: '100%', y: '100%', minWidth: 1000 }"
:pagination="pagination"
:disabled-tools="['size', 'setting']"
@filter-change="filterChange"
@refresh="search"
>
<template #toolbar-left>
<a-input v-model="queryForm.createUserString" placeholder="搜索登录用户" allow-clear @change="search">
<template #prefix><icon-search /></template>
</a-input>
<a-input v-model="queryForm.ip" placeholder="搜索登录 IP 或地点" allow-clear @change="search">
<template #prefix><icon-search /></template>
</a-input>
<DateRangePicker v-model="queryForm.createTime" @change="search" />
<a-button @click="reset">
<template #icon><icon-refresh /></template>
<template #default>重置</template>
</a-button>
</template>
<template #toolbar-right>
<a-button v-permission="['monitor:log:export']" @click="onExport">
<template #icon><icon-download /></template>
<template #default>导出</template>
</a-button>
</template>
<template #status="{ record }">
<a-tag v-if="record.status === 1" color="green">
<GiDot type="success" style="width: 5px; height: 5px" />
<span style="margin-left: 5px">成功</span>
</a-tag>
<a-tooltip v-else :content="record.errorMsg">
<a-tag color="red" style="cursor: pointer">
<GiDot type="danger" style="width: 5px; height: 5px" />
<span style="margin-left: 5px">失败</span>
</a-tag>
</a-tooltip>
</template>
</GiTable>
</template>
<script setup lang="ts">
import dayjs from 'dayjs'
import { type LogQuery, exportLoginLog, listLog } from '@/apis/monitor'
import type { TableInstanceColumns } from '@/components/GiTable/type'
import DateRangePicker from '@/components/DateRangePicker/index.vue'
import { useDownload, useTable } from '@/hooks'
defineOptions({ name: 'LoginLog' })
const queryForm = reactive<LogQuery>({
module: '登录',
createTime: [
dayjs().subtract(6, 'day').startOf('day').format('YYYY-MM-DD HH:mm:ss'),
dayjs().endOf('day').format('YYYY-MM-DD HH:mm:ss'),
],
sort: ['createTime,desc'],
})
const {
tableData: dataList,
loading,
pagination,
search,
} = useTable((page) => listLog({ ...queryForm, ...page }), { immediate: true })
const columns: TableInstanceColumns[] = [
{
title: '序号',
width: 66,
align: 'center',
render: ({ rowIndex }) => h('span', {}, rowIndex + 1 + (pagination.current - 1) * pagination.pageSize),
},
{ title: '登录时间', dataIndex: 'createTime', width: 180 },
{ title: '用户昵称', dataIndex: 'createUserString', ellipsis: true, tooltip: true },
{ title: '登录行为', dataIndex: 'description' },
{
title: '状态',
slotName: 'status',
align: 'center',
filterable: {
filters: [
{
text: '成功',
value: '1',
},
{
text: '失败',
value: '2',
},
],
filter: () => {
return true
},
alignLeft: true,
},
},
{ title: '登录 IP', dataIndex: 'ip', ellipsis: true, tooltip: true },
{ title: '登录地点', dataIndex: 'address', ellipsis: true, tooltip: true },
{ title: '浏览器', dataIndex: 'browser', ellipsis: true, tooltip: true },
{ title: '终端系统', dataIndex: 'os', ellipsis: true, tooltip: true },
]
// 重置
const reset = () => {
queryForm.ip = undefined
queryForm.createUserString = undefined
queryForm.createTime = [
dayjs().subtract(6, 'day').startOf('day').format('YYYY-MM-DD HH:mm:ss'),
dayjs().endOf('day').format('YYYY-MM-DD HH:mm:ss'),
]
queryForm.status = undefined
search()
}
// 过滤查询
const filterChange = (dataIndex, filteredValues) => {
try {
const slotName = columns[dataIndex.split('_').pop()].slotName as string
queryForm[slotName] = filteredValues.join(',')
search()
} catch (error) {
search()
}
}
// 导出
const onExport = () => {
useDownload(() => exportLoginLog(queryForm))
}
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,102 @@
<template>
<a-drawer v-model:visible="visible" title="日志详情" :width="720" :footer="false">
<a-descriptions title="基本信息" :column="2" size="large" class="general-description">
<a-descriptions-item label="日志 ID">{{ dataDetail?.id }}</a-descriptions-item>
<a-descriptions-item label="Trace ID"><a-typography-paragraph :copyable="!!dataDetail?.traceId">{{ dataDetail?.traceId }}</a-typography-paragraph></a-descriptions-item>
<a-descriptions-item label="操作人">{{ dataDetail?.createUserString }}</a-descriptions-item>
<a-descriptions-item label="操作时间">{{ dataDetail?.createTime }}</a-descriptions-item>
<a-descriptions-item label="操作内容">{{ dataDetail?.description }}</a-descriptions-item>
<a-descriptions-item label="所属模块">{{ dataDetail?.module }}</a-descriptions-item>
<a-descriptions-item label="操作 IP"><a-typography-paragraph :copyable="!!dataDetail?.ip">{{ dataDetail?.ip }}</a-typography-paragraph></a-descriptions-item>
<a-descriptions-item label="操作地点">{{ dataDetail?.address }}</a-descriptions-item>
<a-descriptions-item label="浏览器">{{ dataDetail?.browser }}</a-descriptions-item>
<a-descriptions-item label="终端系统">{{ dataDetail?.os }}</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag v-if="dataDetail?.status === 1" color="green">成功</a-tag>
<a-tag v-else color="red">失败</a-tag>
</a-descriptions-item>
<a-descriptions-item label="耗时">
<a-tag v-if="dataDetail?.timeTaken > 500" color="red"> {{ dataDetail?.timeTaken }}ms </a-tag>
<a-tag v-else-if="dataDetail?.timeTaken > 200" color="orange"> {{ dataDetail?.timeTaken }}ms </a-tag>
<a-tag v-else color="green">{{ dataDetail?.timeTaken }} ms</a-tag>
</a-descriptions-item>
<a-descriptions-item label="请求 URI" :span="2">
<a-typography-paragraph :copyable="!!dataDetail?.requestUrl">{{ dataDetail?.requestUrl }}</a-typography-paragraph>
</a-descriptions-item>
</a-descriptions>
<a-descriptions
title="响应信息"
:column="2"
size="large"
class="general-description http"
style="margin-top: 20px; position: relative"
>
<a-descriptions-item :span="2">
<a-tabs type="card">
<a-tab-pane key="1" title="响应头">
<JsonPretty v-if="dataDetail?.responseHeaders" :json="dataDetail?.responseHeaders" />
<span v-else></span>
</a-tab-pane>
<a-tab-pane key="2" title="响应体">
<JsonPretty v-if="dataDetail?.responseBody" :json="dataDetail?.responseBody" />
<span v-else></span>
</a-tab-pane>
</a-tabs>
</a-descriptions-item>
</a-descriptions>
<a-descriptions
title="请求信息"
:column="2"
size="large"
class="general-description http"
style="margin-top: 20px; position: relative"
>
<a-descriptions-item :span="2">
<a-tabs type="card">
<a-tab-pane key="1" title="请求头">
<JsonPretty v-if="dataDetail?.requestHeaders" :json="dataDetail?.requestHeaders" />
<span v-else></span>
</a-tab-pane>
<a-tab-pane key="2" title="请求体">
<JsonPretty v-if="dataDetail?.requestBody" :json="dataDetail?.requestBody" />
<span v-else></span>
</a-tab-pane>
</a-tabs>
</a-descriptions-item>
</a-descriptions>
</a-drawer>
</template>
<script setup lang="ts">
import { type LogDetailResp, getLog as getDetail } from '@/apis/monitor'
const dataId = ref('')
const dataDetail = ref<LogDetailResp>()
const visible = ref(false)
// 查询详情
const getDataDetail = async () => {
const { data } = await getDetail(dataId.value)
dataDetail.value = data
}
// 打开
const onOpen = async (id: string) => {
dataId.value = id
await getDataDetail()
visible.value = true
}
defineExpose({ onOpen })
</script>
<style scoped lang="scss">
.http :deep(.arco-descriptions-item-label-block) {
padding-right: 0;
}
:deep(.arco-tabs-content) {
padding-top: 5px;
padding-left: 15px;
}
</style>

View File

@@ -0,0 +1,157 @@
<template>
<GiTable
row-key="id"
:data="dataList"
:columns="columns"
:loading="loading"
:scroll="{ x: '100%', y: '100%', minWidth: 1000 }"
:pagination="pagination"
column-resizable
:disabled-tools="['size', 'setting']"
@filter-change="filterChange"
@refresh="search"
>
<template #toolbar-left>
<a-input v-model="queryForm.createUserString" placeholder="搜索操作人" allow-clear @change="search">
<template #prefix><icon-search /></template>
</a-input>
<a-input v-model="queryForm.ip" placeholder="搜索操作 IP 或地点" allow-clear @change="search">
<template #prefix><icon-search /></template>
</a-input>
<DateRangePicker v-model="queryForm.createTime" @change="search" />
<a-button @click="reset">
<template #icon><icon-refresh /></template>
<template #default>重置</template>
</a-button>
</template>
<template #toolbar-right>
<a-button v-permission="['monitor:log:export']" @click="onExport">
<template #icon><icon-download /></template>
<template #default>导出</template>
</a-button>
</template>
<template v-if="has.hasPermOr(['monitor:log:detail'])" #createTime="{ record }">
<a-link @click="onDetail(record)">{{ record.createTime }}</a-link>
</template>
<template #status="{ record }">
<a-tag v-if="record.status === 1" color="green">
<GiDot type="success" style="width: 5px; height: 5px"></GiDot>
<span style="margin-left: 5px">成功</span>
</a-tag>
<a-tooltip v-else :content="record.errorMsg">
<a-tag color="red" style="cursor: pointer">
<GiDot type="danger" style="width: 5px; height: 5px"></GiDot>
<span style="margin-left: 5px">失败</span>
</a-tag>
</a-tooltip>
</template>
<template #timeTaken="{ record }">
<a-tag v-if="record.timeTaken > 500" color="red">{{ record.timeTaken }}ms</a-tag>
<a-tag v-else-if="record.timeTaken > 200" color="orange">{{ record.timeTaken }}ms</a-tag>
<a-tag v-else color="green">{{ record.timeTaken }} ms</a-tag>
</template>
</GiTable>
<OperationLogDetailDrawer ref="OperationLogDetailDrawerRef" />
</template>
<script setup lang="ts">
import dayjs from 'dayjs'
import OperationLogDetailDrawer from './OperationLogDetailDrawer.vue'
import { type LogQuery, type LogResp, exportOperationLog, listLog } from '@/apis/monitor'
import type { TableInstanceColumns } from '@/components/GiTable/type'
import DateRangePicker from '@/components/DateRangePicker/index.vue'
import { useDownload, useTable } from '@/hooks'
import has from '@/utils/has'
defineOptions({ name: 'OperationLog' })
const queryForm = reactive<LogQuery>({
createTime: [
dayjs().subtract(6, 'day').startOf('day').format('YYYY-MM-DD HH:mm:ss'),
dayjs().endOf('day').format('YYYY-MM-DD HH:mm:ss'),
],
sort: ['createTime,desc'],
})
const {
loading,
tableData: dataList,
pagination,
search,
} = useTable((page) => listLog({ ...queryForm, ...page }), { immediate: true })
const columns: TableInstanceColumns[] = [
{
title: '序号',
width: 66,
align: 'center',
render: ({ rowIndex }) => h('span', {}, rowIndex + 1 + (pagination.current - 1) * pagination.pageSize),
},
{ title: '操作时间', dataIndex: 'createTime', slotName: 'createTime', width: 180 },
{ title: '操作人', dataIndex: 'createUserString', ellipsis: true, tooltip: true },
{ title: '操作内容', dataIndex: 'description', ellipsis: true, tooltip: true },
{ title: '所属模块', dataIndex: 'module', align: 'center', ellipsis: true, tooltip: true },
{
title: '状态',
dataIndex: 'status',
slotName: 'status',
align: 'center',
filterable: {
filters: [
{
text: '成功',
value: '1',
},
{
text: '失败',
value: '2',
},
],
filter: () => true,
alignLeft: true,
},
},
{ title: '操作 IP', dataIndex: 'ip', ellipsis: true, tooltip: true },
{ title: '操作地点', dataIndex: 'address', ellipsis: true, tooltip: true },
{ title: '耗时', dataIndex: 'timeTaken', slotName: 'timeTaken', align: 'center' },
{ title: '浏览器', dataIndex: 'browser', ellipsis: true, tooltip: true },
{ title: '终端系统', dataIndex: 'os', ellipsis: true, tooltip: true },
]
// 重置
const reset = () => {
queryForm.description = undefined
queryForm.ip = undefined
queryForm.createUserString = undefined
queryForm.createTime = [
dayjs().subtract(6, 'day').startOf('day').format('YYYY-MM-DD HH:mm:ss'),
dayjs().endOf('day').format('YYYY-MM-DD HH:mm:ss'),
]
queryForm.status = undefined
search()
}
// 过滤查询
const filterChange = (dataIndex, filteredValues) => {
try {
const slotName = columns[dataIndex.split('_').pop()].slotName as string
queryForm[slotName] = filteredValues.join(',')
search()
} catch (error) {
search()
}
}
// 导出
const onExport = () => {
useDownload(() => exportOperationLog(queryForm))
}
const OperationLogDetailDrawerRef = ref<InstanceType<typeof OperationLogDetailDrawer>>()
// 详情
const onDetail = (item: LogResp) => {
OperationLogDetailDrawerRef.value?.onOpen(item.id)
}
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,111 @@
<template>
<div class="gi_table_page">
<GiTable
title=""
row-key="id"
:data="dataList"
:columns="columns"
:loading="loading"
:scroll="{ x: '100%', y: '100%', minWidth: 1000 }"
:pagination="pagination"
:disabled-tools="['size']"
@refresh="search"
>
<template #toolbar-left>
<a-input-search v-model="queryForm.nickname" placeholder="搜索用户名/昵称" allow-clear @search="search" />
<DateRangePicker v-model="queryForm.loginTime" @change="search" />
<a-button @click="reset">
<template #icon><icon-refresh /></template>
<template #default>重置</template>
</a-button>
</template>
<template #nickname="{ record }">{{ record.nickname }}({{ record.username }})</template>
<template #action="{ record }">
<a-space>
<a-popconfirm
type="warning"
content="是否确定强退该用户?"
:ok-button-props="{ status: 'danger' }"
@ok="handleKickout(record.token)"
>
<a-link
v-permission="['monitor:online:kickout']"
status="danger"
:title="currentToken === record.token ? '不能强退自己' : '强退'"
:disabled="currentToken === record.token"
>
强退
</a-link>
</a-popconfirm>
</a-space>
</template>
</GiTable>
</div>
</template>
<script setup lang="ts">
import { Message } from '@arco-design/web-vue'
import { type OnlineUserQuery, kickout, listOnlineUser } from '@/apis/monitor'
import type { TableInstanceColumns } from '@/components/GiTable/type'
import DateRangePicker from '@/components/DateRangePicker/index.vue'
import { useUserStore } from '@/stores'
import { useTable } from '@/hooks'
import { isMobile } from '@/utils'
import has from '@/utils/has'
defineOptions({ name: 'MonitorOnline' })
const userStore = useUserStore()
const currentToken = userStore.token
const queryForm = reactive<OnlineUserQuery>({
sort: ['createTime,desc'],
})
const {
tableData: dataList,
loading,
pagination,
search,
} = useTable((page) => listOnlineUser({ ...queryForm, ...page }), { immediate: true })
const columns: TableInstanceColumns[] = [
{
title: '序号',
width: 66,
align: 'center',
render: ({ rowIndex }) => h('span', {}, rowIndex + 1 + (pagination.current - 1) * pagination.pageSize),
},
{ title: '用户昵称', dataIndex: 'nickname', slotName: 'nickname', ellipsis: true, tooltip: true },
{ title: '登录 IP', dataIndex: 'ip', ellipsis: true, tooltip: true },
{ title: '登录地点', dataIndex: 'address', ellipsis: true, tooltip: true },
{ title: '浏览器', dataIndex: 'browser', ellipsis: true, tooltip: true },
{ title: '终端系统', dataIndex: 'os', ellipsis: true, tooltip: true },
{ title: '登录时间', dataIndex: 'loginTime', width: 180 },
{ title: '最后活跃时间', dataIndex: 'lastActiveTime', width: 180 },
{
title: '操作',
dataIndex: 'action',
slotName: 'action',
align: 'center',
fixed: !isMobile() ? 'right' : undefined,
show: has.hasPermOr(['monitor:online:kickout']),
},
]
// 重置
const reset = () => {
queryForm.nickname = undefined
queryForm.loginTime = undefined
search()
}
// 强退
const handleKickout = (token: string) => {
kickout(token).then(() => {
search()
Message.success('强退成功')
})
}
</script>
<style scoped lang="scss"></style>