feat: add web application

This commit is contained in:
richjohnrambo
2026-10-10 20:55:59 +08:00
parent 3eb5d78875
commit 50fcfd2d3d
571 changed files with 73992 additions and 135 deletions
+80
View File
@@ -0,0 +1,80 @@
import { h } from 'vue';
import { NTag } from 'naive-ui';
export const columns = [
{
title: '模块',
key: 'module',
render(row) {
return h(
NTag,
{
style: {
marginRight: '6px',
},
type: row.module == 'admin' ? 'info' : 'success',
bordered: false,
},
{
default: () => row.module,
}
);
},
},
{
title: '操作人',
key: 'memberName',
render(row) {
if (row.memberId === 0) {
return row.memberName;
}
return row.memberName + '(' + row.memberId + ')';
},
},
{
title: '请求方式',
key: 'method',
},
{
title: '请求路径',
key: 'url',
},
{
title: '访问IP',
key: 'ip',
},
// {
// title: 'IP地区',
// key: 'region',
// },
{
title: '状态码',
key: 'errorCode',
render(row) {
return h(
NTag,
{
style: {
marginRight: '6px',
},
type: row.errorCode == 0 ? 'success' : 'warning',
bordered: false,
},
{
default: () => row.errorMsg + '(' + row.errorCode + ')',
}
);
},
},
{
title: 'Goroutine耗时',
key: 'takeUpTime',
render(row) {
return row.takeUpTime + ' ms';
},
},
{
title: '访问时间',
key: 'createdAt',
},
];
+292
View File
@@ -0,0 +1,292 @@
<template>
<n-card :bordered="false" class="proCard">
<BasicForm @register="register" @submit="handleSubmit" @reset="handleReset">
<template #statusSlot="{ model, field }">
<n-input v-model:value="model[field]" />
</template>
</BasicForm>
<BasicTable
:openChecked="true"
:columns="columns"
:request="loadDataTable"
:row-key="(row) => row.id"
ref="actionRef"
:actionColumn="actionColumn"
@update:checked-row-keys="onCheckedRow"
:scroll-x="1090"
>
<template #tableTitle>
<n-button type="error" @click="batchDelete" :disabled="batchDeleteDisabled">
<template #icon>
<n-icon>
<DeleteOutlined />
</n-icon>
</template>
批量删除
</n-button>
</template>
<template #toolbar>
<n-button type="primary" @click="reloadTable">ems-log刷新数据</n-button>
</template>
</BasicTable>
</n-card>
</template>
<script lang="ts" setup>
import { h, reactive, ref } from 'vue';
import { useDialog, useMessage } from 'naive-ui';
import { BasicTable, TableAction } from '@/components/Table';
import { BasicForm, FormSchema, useForm } from '@/components/Form/index';
import { getLogList, Delete } from '@/api/log/log';
import { columns } from './columns';
import { useRouter } from 'vue-router';
import { DeleteOutlined } from '@vicons/antd';
const dialog = useDialog();
const batchDeleteDisabled = ref(true);
const checkedIds = ref([]);
const schemas: FormSchema[] = [
{
field: 'member_id',
component: 'NInput',
label: '操作人',
componentProps: {
placeholder: '请输入操作人ID',
onInput: (e: any) => {
console.log(e);
},
},
rules: [{ trigger: ['blur'] }],
},
{
field: 'url',
component: 'NInput',
label: '接口路径',
componentProps: {
placeholder: '请输入接口路径',
onInput: (e: any) => {
console.log(e);
},
},
},
{
field: 'ip',
component: 'NInput',
label: '访问IP',
componentProps: {
placeholder: '请输入IP地址',
onInput: (e: any) => {
console.log(e);
},
},
},
{
field: 'method',
component: 'NSelect',
label: '请求方式',
componentProps: {
placeholder: '请选择请求方式',
options: [
{
label: 'GET',
value: 'GET',
},
{
label: 'POST',
value: 'POST',
},
],
onUpdateValue: (e: any) => {
console.log(e);
},
},
},
{
field: 'created_at',
component: 'NDatePicker',
label: '访问时间',
componentProps: {
type: 'datetimerange',
clearable: true,
// defaultValue: [new Date() - 86400000 * 30, new Date()],
onUpdateValue: (e: any) => {
console.log(e);
},
},
},
{
field: 'take_up_time',
component: 'NSelect',
label: '请求耗时',
componentProps: {
placeholder: '请选择请求耗时',
options: [
{
label: '50ms内',
value: '50',
},
{
label: '100ms内',
value: '100',
},
{
label: '200ms内',
value: '200',
},
{
label: '500ms内',
value: '500',
},
],
onUpdateValue: (e: any) => {
console.log(e);
},
},
},
{
field: 'error_code',
component: 'NSelect',
label: '状态码',
componentProps: {
placeholder: '请选择状态码',
options: [
{
label: '0 成功',
value: '0',
},
{
label: '-1 失败',
value: '-1',
},
],
onUpdateValue: (e: any) => {
console.log(e);
},
},
},
];
const router = useRouter();
const formRef: any = ref(null);
const message = useMessage();
const actionRef = ref();
const formParams = ref({});
const params = ref({
pageSize: 10,
});
const actionColumn = reactive({
width: 220,
title: '操作',
key: 'action',
// fixed: 'right',
render(record) {
return h(TableAction as any, {
style: 'button',
actions: [
{
label: '查看详情',
onClick: handleEdit.bind(null, record),
},
{
label: '删除',
onClick: handleDelete.bind(null, record),
},
],
});
},
});
const [register, {}] = useForm({
gridProps: { cols: '1 s:1 m:2 l:3 xl:4 2xl:4' },
labelWidth: 80,
schemas,
});
function onCheckedRow(rowKeys) {
console.log(rowKeys);
if (rowKeys.length > 0) {
batchDeleteDisabled.value = false;
} else {
batchDeleteDisabled.value = true;
}
checkedIds.value = rowKeys;
}
function handleDelete(record: Recordable) {
console.log('点击了删除', record);
dialog.warning({
title: '警告',
content: '你确定要删除?',
positiveText: '确定',
negativeText: '取消',
onPositiveClick: () => {
Delete(record)
.then((_res) => {
message.success('操作成功');
reloadTable();
})
.catch((e: Error) => {
// message.error(e.message ?? '操作失败');
});
},
onNegativeClick: () => {
// message.error('取消');
},
});
}
function batchDelete() {
dialog.warning({
title: '警告',
content: '你确定要删除?',
positiveText: '确定',
negativeText: '取消',
onPositiveClick: () => {
Delete({ id: checkedIds.value })
.then((_res) => {
message.success('操作成功');
reloadTable();
})
.catch((e: Error) => {
message.error(e.message ?? '操作失败');
});
},
onNegativeClick: () => {
// message.error('取消');
},
});
}
const loadDataTable = async (res) => {
return await getLogList({ ...formParams.value, ...params.value, ...res });
};
function reloadTable() {
actionRef.value.reload();
}
function handleEdit(record: Recordable) {
console.log('点击了编辑', record);
router.push({ name: 'ems_view', params: { id: record.id } });
}
function handleSubmit(values: Recordable) {
console.log(values);
formParams.value = values;
reloadTable();
}
function handleReset(values: Recordable) {
console.log(values);
formParams.value = {};
reloadTable();
}
</script>
<style lang="less" scoped></style>
+136
View File
@@ -0,0 +1,136 @@
<template>
<div>
<n-card
:bordered="false"
class="proCard mt-4"
size="small"
:segmented="{ content: true }"
:title="data.id ? '日志详情 ID:' + data.id : '日志详情'"
>
<n-descriptions label-placement="left" class="py-2">
<n-descriptions-item label="请求方式">{{ data.method }}</n-descriptions-item>
<n-descriptions-item>
<template #label>请求地址</template>
{{ data.url }}
</n-descriptions-item>
<n-descriptions-item label="请求耗时">{{ data.takeUpTime }} ms</n-descriptions-item>
<n-descriptions-item label="访问IP">{{ data.ip }}</n-descriptions-item>
<n-descriptions-item label="IP归属地">河南 郑州</n-descriptions-item>
<n-descriptions-item label="链路ID">{{ data.reqId }}</n-descriptions-item>
<n-descriptions-item label="响应时间">{{
timestampToTime(data.timestamp)
}}</n-descriptions-item>
<n-descriptions-item label="创建时间">{{ data.createdAt }}</n-descriptions-item>
</n-descriptions>
</n-card>
<n-card
:bordered="false"
class="proCard mt-4"
size="small"
:segmented="{ content: true }"
title="访问代理"
>
{{ data.userAgent }}
</n-card>
<n-card
:bordered="false"
class="proCard mt-4"
size="small"
:segmented="{ content: true }"
title="报错信息"
>
<n-descriptions label-placement="left" class="py-2">
<n-descriptions-item label="报错状态码"> {{ data.errorCode }} </n-descriptions-item>
<n-descriptions-item label="报错消息">
<n-tag type="success"> {{ data.errorMsg }} </n-tag>
</n-descriptions-item>
<n-descriptions-item label="报错日志">
<n-tag type="success"> {{ data.errorData }} </n-tag>
</n-descriptions-item>
</n-descriptions>
</n-card>
<n-card
:bordered="false"
class="proCard mt-4"
size="small"
:segmented="{ content: true }"
title="Header请求头"
>
<JsonViewer
:value="JSON.parse(data.headerData ?? '{}')"
:expand-depth="5"
copyable
boxed
sort
style="width: 100%; min-width: 3.125rem"
/>
</n-card>
<n-card
:bordered="false"
class="proCard mt-4"
size="small"
:segmented="{ content: true }"
title="GET参数"
>
<JsonViewer
:value="JSON.parse(data.getData ?? '{}')"
:expand-depth="5"
copyable
boxed
sort
style="width: 100%; min-width: 3.125rem"
/>
</n-card>
<n-card
:bordered="false"
class="proCard mt-4"
size="small"
:segmented="{ content: true }"
title="POST参数"
>
<JsonViewer
:value="JSON.parse(data.postData ?? '{}')"
:expand-depth="5"
copyable
boxed
sort
style="width: 100%; min-width: 3.125rem"
/>
</n-card>
</div>
</template>
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import { JsonViewer } from 'vue3-json-viewer';
import 'vue3-json-viewer/dist/vue3-json-viewer.css';
import { useRouter } from 'vue-router';
import { useMessage } from 'naive-ui';
import { View } from '@/api/log/log';
import { timestampToTime } from '@/utils/dateUtil';
const message = useMessage();
const router = useRouter();
const logId = Number(router.currentRoute.value.params.id);
onMounted(async () => {
if (logId === undefined || logId < 1) {
message.error('ID不正确,请检查!');
return;
}
await getInfo();
});
const data = ref({});
const getInfo = async () => {
data.value = await View({ id: logId });
};
</script>
<style lang="less" scoped></style>
+48
View File
@@ -0,0 +1,48 @@
import { h } from 'vue';
import Column from './components/Column.vue';
export const columns = [
{
title: '记录ID',
key: 'id',
width: 100,
},
{
title: '访客',
key: 'name',
width: 180,
render(row) {
return h(Column, {
state: row,
column: 'visitor',
});
},
},
{
title: '请求接口',
key: 'name',
width: 260,
render(row) {
return h(Column, {
state: row,
column: 'request',
});
},
},
{
title: '接口响应',
key: 'name',
width: 260,
render(row) {
return h(Column, {
state: row,
column: 'response',
});
},
},
{
title: '访问时间',
key: 'createdAt',
width: 180,
},
];
@@ -0,0 +1,77 @@
<template>
<div class="box">
<template v-if="column === 'visitor'">
<div class="flex flex-col">
<div class="flex flex-row mb-1">
<div class="text">
{{
state.memberId === 0
? state.memberName
: state.memberName + '(' + state.memberId + ')'
}}
</div>
</div>
<div class="flex flex-row">
<div>访问IP:</div>
<div class="text">{{ state.ip }}</div>
</div>
</div>
<div class="text">
{{ state.cityLabel !== '' ? state.cityLabel : '局域网' }}
</div>
</template>
<template v-if="column === 'request'">
<div class="flex flex-col">
<div class="flex flex-row">
<div class="text mr-1 mb-1">
<n-button :type="state.method === 'GET' ? 'tertiary' : 'primary'" size="tiny">
{{ state.method }}
</n-button>
</div>
<div class="text">{{ state.url }}</div>
</div>
<div class="flex flex-row">
<div class="text">{{ state.tags }}</div>
</div>
</div>
<div>
{{ state.summary }}
</div>
</template>
<template v-if="column === 'response'">
<div class="flex flex-col">
<div class="flex flex-row">
<div class="text mr-1 mb-1">
<n-button v-if="state.errorCode === 0" type="tertiary" size="tiny">
{{ state.errorMsg }}
</n-button>
<n-button v-else type="error" size="tiny">
{{ state.errorCode }} → {{ state.errorMsg }}
</n-button>
</div>
</div>
<div class="flex flex-row">
<div class="text">处理耗时:{{ state.takeUpTime }}ms</div>
</div>
<div class="flex flex-row">
<div class="text">响应时间:{{ timestampToTime(state.timestamp) }}</div>
</div>
</div>
</template>
</div>
</template>
<script lang="ts" setup>
import { basicProps } from './props';
import { timestampToTime } from '@/utils/dateUtil';
const props = defineProps(basicProps);
</script>
<style lang="less" scoped>
.text {
display: inline;
}
</style>
+12
View File
@@ -0,0 +1,12 @@
import { State } from '@/views/log/log/model';
export const basicProps = {
state: {
type: State,
default: null,
},
column: {
type: String,
default: '',
},
};
+156
View File
@@ -0,0 +1,156 @@
<template>
<div>
<div class="n-layout-page-header">
<n-card :bordered="false" title="访问日志">
全局访问日志记录了系统中后台人员和客户端的操作记录以及服务响应情况
</n-card>
</div>
<n-card :bordered="false" class="proCard">
<BasicForm @register="register" @submit="handleSubmit" @reset="handleReset">
<template #statusSlot="{ model, field }">
<n-input v-model:value="model[field]" />
</template>
</BasicForm>
<BasicTable
:openChecked="true"
:columns="columns"
:request="loadDataTable"
:row-key="(row) => row.id"
ref="actionRef"
:actionColumn="actionColumn"
@update:checked-row-keys="onCheckedRow"
:scroll-x="scrollX"
:resizeHeightOffset="-20000"
>
<template #tableTitle>
<n-button type="error" @click="batchDelete" :disabled="batchDeleteDisabled">
<template #icon>
<n-icon>
<DeleteOutlined />
</n-icon>
</template>
批量删除
</n-button>
</template>
</BasicTable>
</n-card>
</div>
</template>
<script lang="ts" setup>
import { computed, h, onMounted, reactive, ref } from 'vue';
import { useDialog, useMessage } from 'naive-ui';
import { BasicTable, TableAction } from '@/components/Table';
import { BasicForm, useForm } from '@/components/Form/index';
import { getLogList, Delete } from '@/api/log/log';
import { columns } from './columns';
import { useRouter } from 'vue-router';
import { DeleteOutlined } from '@vicons/antd';
import { adaTableScrollX } from '@/utils/hotgo';
import { loadOptions, schemas } from './model';
const dialog = useDialog();
const batchDeleteDisabled = ref(true);
const checkedIds = ref([]);
const router = useRouter();
const message = useMessage();
const actionRef = ref();
const formParams = ref({});
const actionColumn = reactive({
width: 160,
title: '操作',
key: 'action',
fixed: 'right',
render(record) {
return h(TableAction as any, {
style: 'button',
actions: [
{
label: '查看详情',
onClick: handleEdit.bind(null, record),
},
{
label: '删除',
onClick: handleDelete.bind(null, record),
},
],
});
},
});
const scrollX = computed(() => {
return adaTableScrollX(columns, actionColumn.width);
});
const [register, {}] = useForm({
gridProps: { cols: '1 s:1 m:2 l:3 xl:4 2xl:4' },
labelWidth: 80,
schemas,
});
function onCheckedRow(rowKeys) {
console.log(rowKeys);
batchDeleteDisabled.value = rowKeys.length <= 0;
checkedIds.value = rowKeys;
}
function handleDelete(record: Recordable) {
dialog.warning({
title: '警告',
content: '你确定要删除?',
positiveText: '确定',
negativeText: '取消',
onPositiveClick: () => {
Delete(record).then((_res) => {
message.success('操作成功');
reloadTable();
});
},
});
}
function batchDelete() {
dialog.warning({
title: '警告',
content: '你确定要删除?',
positiveText: '确定',
negativeText: '取消',
onPositiveClick: () => {
Delete({ id: checkedIds.value }).then((_res) => {
message.success('操作成功');
reloadTable();
});
},
});
}
const loadDataTable = async (res) => {
return await getLogList({ ...formParams.value, ...res });
};
function reloadTable() {
actionRef.value.reload();
}
function handleEdit(record: Recordable) {
router.push({ name: 'log_view', params: { id: record.id } });
}
function handleSubmit(values: Recordable) {
formParams.value = values;
reloadTable();
}
function handleReset(values: Recordable) {
formParams.value = {};
reloadTable();
}
onMounted(() => {
loadOptions();
});
</script>
<style lang="less" scoped></style>
+151
View File
@@ -0,0 +1,151 @@
import { FormSchema } from '@/components/Form';
import { ref } from 'vue';
import { defRangeShortcuts } from '@/utils/dateUtil';
import { useDictStore } from '@/store/modules/dict';
const dict = useDictStore();
export class State {
id = 0;
reqId = '';
appId = '';
merchantId = 0;
memberId = 0;
method = '';
module = '';
url = '';
getData: Record<string, any> | null = null;
postData: Record<string, string[]> | null = null;
headerData: Record<string, string[]> | null = null;
ip = '';
provinceId = 0;
cityId = 0;
errorCode = 0;
errorMsg = '';
errorData: string[] = [];
userAgent = '';
takeUpTime = 0;
timestamp = 0;
status = 0;
createdAt = '';
updatedAt = '';
cityLabel = '';
tags = '';
summary = '';
description = '';
}
export const schemas = ref<FormSchema[]>([
{
field: 'reqId',
component: 'NInput',
label: '链路ID',
componentProps: {
placeholder: '请输入链路ID',
onInput: (e: any) => {
console.log(e);
},
},
},
{
field: 'complexMemberId',
component: 'ComplexMemberPicker',
label: '操作人',
componentProps: {
placeholder: '请选择操作人',
onInput: (e: any) => {
console.log(e);
},
},
},
{
field: 'url',
component: 'NInput',
label: '接口路径',
componentProps: {
placeholder: '请输入接口路径',
onInput: (e: any) => {
console.log(e);
},
},
},
{
field: 'keyword',
component: 'NInput',
label: '关键词',
componentProps: {
placeholder: '请输入关键词',
onInput: (e: any) => {
console.log(e);
},
},
},
{
field: 'ip',
component: 'NInput',
label: '访问IP',
componentProps: {
placeholder: '请输入IP地址',
onInput: (e: any) => {
console.log(e);
},
},
},
{
field: 'method',
component: 'NSelect',
label: '请求方式',
componentProps: {
placeholder: '请选择请求方式',
options: dict.getOption('HTTPMethod'),
onUpdateValue: (e: any) => {
console.log(e);
},
},
},
{
field: 'createdAt',
component: 'NDatePicker',
label: '访问时间',
componentProps: {
type: 'datetimerange',
clearable: true,
shortcuts: defRangeShortcuts(),
onUpdateValue: (e: any) => {
console.log(e);
},
},
},
{
field: 'takeUpTime',
component: 'NSelect',
label: '请求耗时',
componentProps: {
placeholder: '请选择请求耗时',
options: dict.getOption('HTTPHandlerTime'),
onUpdateValue: (e: any) => {
console.log(e);
},
},
},
{
field: 'errorCode',
component: 'NSelect',
label: '状态码',
labelMessage: '支持填入自定义状态码',
componentProps: {
placeholder: '请选择状态码',
options: dict.getOption('HTTPApiCode'),
filterable: true,
tag: true,
onUpdateValue: (e: any) => {
console.log(e);
},
},
},
]);
// 加载字典数据选项
export function loadOptions() {
dict.loadOptions(['HTTPMethod', 'HTTPHandlerTime', 'HTTPApiCode']);
}
+181
View File
@@ -0,0 +1,181 @@
<template>
<div>
<n-spin :show="loading" description="请稍候...">
<n-card
:bordered="false"
class="proCard mt-4"
size="small"
:segmented="{ content: true }"
:title="data.id ? '日志详情 ID:' + data.id : '日志详情'"
>
<template #header-extra>
<n-button icon-placement="right" @click="goBackOrToPage({ name: 'log' })">
<template #icon>
<n-icon>
<ArrowRightOutlined />
</n-icon>
</template>
返回
</n-button>
</template>
<n-descriptions label-placement="left" class="py-2" :column="settingStore.isMobile ? 1 : 3">
<n-descriptions-item label="请求方式">{{ data.method }}</n-descriptions-item>
<n-descriptions-item>
<template #label>请求地址</template>
{{ data.url }}
</n-descriptions-item>
<n-descriptions-item label="接口名称"
>{{ data.tags }} / {{ data.summary }}</n-descriptions-item
>
<n-descriptions-item label="访问IP">{{ data.ip }}</n-descriptions-item>
<n-descriptions-item label="IP归属地">{{ data.cityLabel }}</n-descriptions-item>
<n-descriptions-item label="链路ID">{{ data.reqId }}</n-descriptions-item>
<n-descriptions-item label="请求耗时">{{ data.takeUpTime }} ms</n-descriptions-item>
<n-descriptions-item label="响应时间">{{
data.timestamp > 0 ? timestampToTime(data.timestamp) : '--'
}}</n-descriptions-item>
<n-descriptions-item label="访问时间">{{ data.createdAt }}</n-descriptions-item>
</n-descriptions>
</n-card>
<n-card
:bordered="false"
class="proCard mt-4"
size="small"
:segmented="{ content: true }"
title="访问代理"
>
{{ data.userAgent }}
</n-card>
<n-card
:bordered="false"
class="proCard mt-4"
size="small"
:segmented="{ content: true }"
title="报错信息"
>
<n-descriptions label-placement="left" class="py-2">
<n-descriptions-item label="错误状态码"> {{ data.errorCode }} </n-descriptions-item>
<n-descriptions-item label="错误提示">
<n-tag type="error"> {{ data.errorMsg }} </n-tag>
</n-descriptions-item>
</n-descriptions>
</n-card>
<n-card
:bordered="false"
class="proCard mt-4"
size="small"
:segmented="{ content: true }"
title="堆栈打印"
>
<JsonViewer
:value="data.errorData"
:expand-depth="5"
copyable
boxed
sort
class="json-width"
/>
</n-card>
<n-card
:bordered="false"
class="proCard mt-4"
size="small"
:segmented="{ content: true }"
title="Header请求头"
>
<JsonViewer
:value="data.headerData"
:expand-depth="5"
copyable
boxed
sort
class="json-width"
/>
</n-card>
<n-card
:bordered="false"
class="proCard mt-4"
size="small"
:segmented="{ content: true }"
title="GET参数"
>
<JsonViewer
:value="data.getData"
:expand-depth="5"
copyable
boxed
sort
class="json-width"
/>
</n-card>
<n-card
:bordered="false"
class="proCard mt-4"
size="small"
:segmented="{ content: true }"
title="POST参数"
>
<JsonViewer
:value="data.postData"
:expand-depth="5"
copyable
boxed
sort
class="json-width"
/>
</n-card>
</n-spin>
</div>
</template>
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import { JsonViewer } from 'vue3-json-viewer';
import 'vue3-json-viewer/dist/vue3-json-viewer.css';
import { useRouter } from 'vue-router';
import { useMessage } from 'naive-ui';
import { View } from '@/api/log/log';
import { timestampToTime } from '@/utils/dateUtil';
import { ArrowRightOutlined } from '@vicons/antd';
import { goBackOrToPage } from '@/utils/urlUtils';
import { State } from '@/views/log/log/model';
import { useProjectSettingStore } from '@/store/modules/projectSetting';
const message = useMessage();
const router = useRouter();
const settingStore = useProjectSettingStore();
const params = router.currentRoute.value.params;
const loading = ref(false);
const data = ref<State>(new State());
const getInfo = () => {
loading.value = true;
View(params)
.then((res) => {
data.value = res as unknown as State;
})
.finally(() => {
loading.value = false;
});
};
onMounted(() => {
if (!params.id) {
message.error('日志ID不正确,请检查!');
return;
}
getInfo();
});
</script>
<style lang="less" scoped>
::v-deep(.json-width) {
width: 100%;
min-width: 3.125rem;
}
</style>
+179
View File
@@ -0,0 +1,179 @@
<template>
<div>
<div class="n-layout-page-header">
<n-card :bordered="false" title="登录日志"> 在这里会记录管理后台所有的来访登录情况 </n-card>
</div>
<n-card :bordered="false" class="proCard">
<BasicForm
@register="register"
@submit="reloadTable"
@reset="reloadTable"
@keyup.enter="reloadTable"
ref="searchFormRef"
>
<template #statusSlot="{ model, field }">
<n-input v-model:value="model[field]" />
</template>
</BasicForm>
<BasicTable
:openChecked="true"
:columns="columns"
:request="loadDataTable"
:row-key="(row) => row.id"
ref="actionRef"
:actionColumn="actionColumn"
@update:checked-row-keys="onCheckedRow"
:scroll-x="scrollX"
:resizeHeightOffset="-20000"
>
<template #tableTitle>
<n-button
type="error"
@click="handleBatchDelete"
:disabled="batchDeleteDisabled"
class="min-left-space"
v-if="hasPermission(['/loginLog/delete'])"
>
<template #icon>
<n-icon>
<DeleteOutlined />
</n-icon>
</template>
批量删除
</n-button>
<n-button
type="primary"
@click="handleExport"
class="min-left-space"
v-if="hasPermission(['/loginLog/export'])"
>
<template #icon>
<n-icon>
<ExportOutlined />
</n-icon>
</template>
导出
</n-button>
</template>
</BasicTable>
</n-card>
</div>
</template>
<script lang="ts" setup>
import { computed, h, onMounted, reactive, ref } from 'vue';
import { useDialog, useMessage } from 'naive-ui';
import { BasicTable, TableAction } from '@/components/Table';
import { BasicForm, useForm } from '@/components/Form/index';
import { usePermission } from '@/hooks/web/usePermission';
import { List, Export, Delete } from '@/api/loginLog';
import { columns, schemas, loadOptions } from './model';
import { ExportOutlined, DeleteOutlined } from '@vicons/antd';
import { useRouter } from 'vue-router';
import { adaTableScrollX } from '@/utils/hotgo';
const { hasPermission } = usePermission();
const router = useRouter();
const actionRef = ref();
const dialog = useDialog();
const message = useMessage();
const searchFormRef = ref<any>({});
const batchDeleteDisabled = ref(true);
const checkedIds = ref([]);
const actionColumn = reactive({
width: 160,
title: '操作',
key: 'action',
fixed: 'right',
render(record) {
return h(TableAction as any, {
style: 'button',
actions: [
{
label: '查看详情',
onClick: handleView.bind(null, record),
auth: ['/loginLog/view'],
ifShow: () => {
return record.sysLogId > 0;
},
},
{
label: '删除',
onClick: handleDelete.bind(null, record),
auth: ['/loginLog/delete'],
},
],
});
},
});
const scrollX = computed(() => {
return adaTableScrollX(columns, actionColumn.width);
});
const [register, {}] = useForm({
gridProps: { cols: '1 s:1 m:2 l:3 xl:4 2xl:4' },
labelWidth: 80,
schemas,
});
const loadDataTable = async (res) => {
return await List({ ...searchFormRef.value?.formModel, ...res });
};
function onCheckedRow(rowKeys) {
batchDeleteDisabled.value = rowKeys.length <= 0;
checkedIds.value = rowKeys;
}
function reloadTable() {
actionRef.value.reload();
}
function handleView(record: Recordable) {
router.push({ name: 'log_view', params: { id: record.sysLogId } });
}
function handleDelete(record: Recordable) {
dialog.warning({
title: '警告',
content: '你确定要删除?',
positiveText: '确定',
negativeText: '取消',
onPositiveClick: () => {
Delete(record).then((_res) => {
message.success('删除成功');
reloadTable();
});
},
});
}
function handleBatchDelete() {
dialog.warning({
title: '警告',
content: '你确定要批量删除?',
positiveText: '确定',
negativeText: '取消',
onPositiveClick: () => {
Delete({ id: checkedIds.value }).then((_res) => {
message.success('删除成功');
reloadTable();
});
},
});
}
function handleExport() {
message.loading('正在导出列表...', { duration: 1200 });
Export(searchFormRef.value?.formModel);
}
onMounted(() => {
loadOptions();
});
</script>
<style lang="less" scoped></style>
+140
View File
@@ -0,0 +1,140 @@
import { h, ref } from 'vue';
import { FormSchema } from '@/components/Form';
import { defRangeShortcuts } from '@/utils/dateUtil';
import { NTag } from 'naive-ui';
import { useDictStore } from '@/store/modules/dict';
import { renderOptionTag } from '@/utils';
const dict = useDictStore();
export const schemas = ref<FormSchema[]>([
{
field: 'username',
component: 'NInput',
label: '用户名',
componentProps: {
placeholder: '请输入用户名',
onUpdateValue: (e: any) => {
console.log(e);
},
},
},
{
field: 'loginIp',
component: 'NInput',
label: 'IP地址',
componentProps: {
placeholder: '请输入IP地址',
onUpdateValue: (e: any) => {
console.log(e);
},
},
},
{
field: 'status',
component: 'NSelect',
label: '状态',
defaultValue: null,
componentProps: {
placeholder: '请选择状态',
options: dict.getOption('sys_login_status'),
onUpdateValue: (e: any) => {
console.log(e);
},
},
},
{
field: 'loginAt',
component: 'NDatePicker',
label: '登录时间',
componentProps: {
type: 'datetimerange',
clearable: true,
shortcuts: defRangeShortcuts(),
onUpdateValue: (e: any) => {
console.log(e);
},
},
},
]);
export const columns = [
{
title: '记录ID',
key: 'id',
width: 80,
},
{
title: '用户名',
key: 'username',
width: 120,
render(row) {
return h(
NTag,
{
style: {
marginRight: '6px',
},
type: 'info',
bordered: false,
},
{
default: () => row.username,
}
);
},
},
{
title: '登录IP',
key: 'loginIp',
width: 160,
},
{
title: 'IP归属地',
key: 'cityLabel',
width: 200,
},
{
title: '浏览器',
key: 'browser',
width: 200,
},
{
title: '操作系统',
key: 'os',
width: 150,
},
{
title: '状态',
key: 'status',
render(row) {
return renderOptionTag('sys_login_status', row.status);
},
width: 150,
},
{
title: '提示信息',
key: 'errMsg',
render(row) {
if (row.errMsg !== '') {
return row.errMsg;
}
if (row.status === 1) {
return '登录成功';
}
return ``;
},
width: 200,
},
{
title: '登录时间',
key: 'loginAt',
width: 180,
},
];
// 加载字典数据选项
export function loadOptions() {
dict.loadOptions(['sys_login_status']);
}
+278
View File
@@ -0,0 +1,278 @@
<template>
<div>
<div class="n-layout-page-header">
<n-card :bordered="false" title="短信记录">
在这里,您可以方便地查看平台的所有短信发送记录
</n-card>
</div>
<n-card :bordered="false" class="proCard">
<BasicForm
@register="register"
@submit="handleSubmit"
@reset="handleReset"
ref="searchFormRef"
>
<template #statusSlot="{ model, field }">
<n-input v-model:value="model[field]" />
</template>
</BasicForm>
<BasicTable
:openChecked="true"
:columns="columns"
:request="loadDataTable"
:row-key="(row) => row.id"
ref="actionRef"
:actionColumn="actionColumn"
@update:checked-row-keys="onCheckedRow"
:scroll-x="scrollX"
:resizeHeightOffset="-20000"
>
<template #tableTitle>
<n-button type="error" @click="batchDelete" :disabled="batchDeleteDisabled">
<template #icon>
<n-icon>
<DeleteOutlined />
</n-icon>
</template>
批量删除
</n-button>
</template>
</BasicTable>
</n-card>
</div>
</template>
<script lang="ts" setup>
import { computed, h, onMounted, reactive, ref } from 'vue';
import { useDialog, useMessage } from 'naive-ui';
import { BasicTable, TableAction } from '@/components/Table';
import { BasicForm, FormSchema, useForm } from '@/components/Form/index';
import { getLogList, Delete } from '@/api/log/smslog';
import { DeleteOutlined } from '@vicons/antd';
import { adaTableScrollX } from '@/utils/hotgo';
import { defRangeShortcuts } from '@/utils/dateUtil';
import { useDictStore } from '@/store/modules/dict';
import { renderOptionTag } from '@/utils';
const dict = useDictStore();
const columns = [
{
title: 'ID',
key: 'id',
width: 100,
},
{
title: '事件模板',
key: 'event',
render(row) {
return renderOptionTag('config_sms_template', row.event);
},
width: 150,
},
{
title: '手机号',
key: 'mobile',
render(row) {
return row.mobile;
},
width: 180,
},
{
title: '验证码或短信内容',
key: 'code',
width: 200,
},
{
title: '验证次数',
key: 'times',
width: 100,
},
{
title: '发送者IP',
key: 'ip',
width: 200,
},
{
title: '状态',
key: 'status',
render(row) {
return renderOptionTag('CodeStatusOptions', row.status);
},
width: 100,
},
{
title: '发送时间',
key: 'createdAt',
width: 180,
},
{
title: '更新时间',
key: 'updatedAt',
width: 180,
},
];
const dialog = useDialog();
const message = useMessage();
const actionRef = ref();
const batchDeleteDisabled = ref(true);
const checkedIds = ref([]);
const searchFormRef = ref<any>({});
const schemas = ref<FormSchema[]>([
{
field: 'event',
component: 'NSelect',
label: '事件模板',
componentProps: {
placeholder: '请选择事件模板',
options: dict.getOption('config_sms_template'),
onUpdateValue: (e: any) => {
console.log(e);
},
},
},
{
field: 'mobile',
component: 'NInput',
label: '手机号',
componentProps: {
placeholder: '请输入手机号',
onInput: (e: any) => {
console.log(e);
},
},
rules: [{ trigger: ['blur'] }],
},
{
field: 'ip',
component: 'NInput',
label: '发送者IP',
componentProps: {
placeholder: '请输入IP',
onInput: (e: any) => {
console.log(e);
},
},
},
{
field: 'status',
component: 'NSelect',
label: '状态',
componentProps: {
placeholder: '请选择状态',
options: dict.getOption('CodeStatusOptions'),
onUpdateValue: (e: any) => {
console.log(e);
},
},
},
{
field: 'createdAt',
component: 'NDatePicker',
label: '发送时间',
componentProps: {
type: 'datetimerange',
clearable: true,
shortcuts: defRangeShortcuts(),
onUpdateValue: (e: any) => {
console.log(e);
},
},
},
]);
const actionColumn = reactive({
width: 80,
title: '操作',
key: 'action',
fixed: 'right',
render(record) {
return h(TableAction as any, {
style: 'button',
actions: [
{
label: '删除',
onClick: handleDelete.bind(null, record),
},
],
});
},
});
const scrollX = computed(() => {
return adaTableScrollX(columns, actionColumn.width);
});
const [register, {}] = useForm({
gridProps: { cols: '1 s:1 m:2 l:3 xl:4 2xl:4' },
labelWidth: 80,
schemas,
});
function onCheckedRow(rowKeys) {
batchDeleteDisabled.value = rowKeys.length <= 0;
checkedIds.value = rowKeys;
}
function handleDelete(record: Recordable) {
console.log('点击了删除', record);
dialog.warning({
title: '警告',
content: '你确定要删除?',
positiveText: '确定',
negativeText: '不确定',
onPositiveClick: () => {
Delete(record).then((_res) => {
message.success('操作成功');
reloadTable();
});
},
});
}
function batchDelete() {
dialog.warning({
title: '警告',
content: '你确定要删除?',
positiveText: '确定',
negativeText: '不确定',
onPositiveClick: () => {
Delete({ id: checkedIds.value }).then((_res) => {
message.success('操作成功');
reloadTable();
});
},
});
}
const loadDataTable = async (res) => {
return await getLogList({ ...searchFormRef.value?.formModel, ...res });
};
function reloadTable() {
actionRef.value.reload();
}
function handleSubmit(values: Recordable) {
console.log(values);
reloadTable();
}
function handleReset(values: Recordable) {
console.log(values);
reloadTable();
}
function loadOptions() {
dict.loadOptions(['config_sms_template', 'CodeStatusOptions']);
}
onMounted(() => {
loadOptions();
});
</script>
<style lang="less" scoped></style>