zjk
2026-05-09 7ad8af83cccaff98c3d30ec36fc70de17aa17bdc
feat(system): 新增系统操作日志功能模块

- 添加系统操作日志路由及页面组件
- 实现操作日志查询、分页和导出功能
- 新增操作日志通用弹窗组件
- 添加对应的API服务接口
- 优化页面样式布局
3个文件已添加
2个文件已修改
790 ■■■■■ 已修改文件
src/components/custom/OperationLogModal.vue 330 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/router/routes.static.ts 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/service/api/systemOperationLog.ts 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/styles/robotaxi.scss 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/system/operationLog/index.vue 422 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/custom/OperationLogModal.vue
New file
@@ -0,0 +1,330 @@
<script setup lang="ts">
import type { DataTableColumns, FormInst, SelectOption } from 'naive-ui'
import dayjs from 'dayjs'
import Pagination from '@/components/common/Pagination.vue'
import SearchPanel from '@/components/custom/SearchPanel.vue'
interface OperationLogRow {
  operatorName?: string
  account?: string
  operationType?: number | string
  operationContent?: string
  operationIp?: string
  operationTime?: string
  createTime?: string
}
interface QueryPayload {
  operatorName?: string
  account?: string
  operationType?: number
  operationContent?: string
  operationIp?: string
  startTime?: string
  endTime?: string
  pageNum: number
  pageSize: number
}
interface FilterModel {
  operatorName: string
  account: string
  operationType: number | null
  operationContent: string
  operationIp: string
  operationTime: [number, number] | null
}
const props = withDefaults(defineProps<{
  show: boolean
  loading?: boolean
  data: OperationLogRow[]
  total: number
  pageNum: number
  pageSize: number
  title?: string
  showExport?: boolean
  operationTypeOptions?: SelectOption[]
}>(), {
  loading: false,
  title: '操作日志',
  showExport: true,
  operationTypeOptions: () => [
    { label: '新增', value: 1 },
    { label: '编辑', value: 2 },
    { label: '删除', value: 3 },
    { label: '导入', value: 4 },
    { label: '导出', value: 5 },
    { label: '批量冻结', value: 6 },
  ],
})
const emit = defineEmits<{
  'update:show': [show: boolean]
  'search': [payload: QueryPayload]
  'reset': [payload: QueryPayload]
  'page-change': [payload: QueryPayload]
  'export': [payload: QueryPayload]
}>()
const formRef = ref<FormInst | null>(null)
const isOperationTypeOpen = ref(false)
const filterModel = ref<FilterModel>({
  operatorName: '',
  account: '',
  operationType: null,
  operationContent: '',
  operationIp: '',
  operationTime: null,
})
const currentPage = ref(props.pageNum)
const currentPageSize = ref(props.pageSize)
watch(() => props.pageNum, (val) => {
  currentPage.value = val
})
watch(() => props.pageSize, (val) => {
  currentPageSize.value = val
})
const operationTypeLabelMap = computed(() => {
  return (props.operationTypeOptions || []).reduce<Record<string, string>>((result, item) => {
    result[String(item.value)] = String(item.label ?? item.value)
    return result
  }, {})
})
const columns = computed<DataTableColumns<OperationLogRow>>(() => [
  {
    title: '序号',
    key: 'index',
    width: 80,
    render: (_, index) => (currentPage.value - 1) * currentPageSize.value + index + 1,
  },
  {
    title: '操作人',
    key: 'operatorName',
    width: 120,
    ellipsis: true,
  },
  {
    title: '账号',
    key: 'account',
    width: 160,
    ellipsis: true,
  },
  {
    title: '操作类型',
    key: 'operationType',
    width: 120,
    render: row => operationTypeLabelMap.value[String(row.operationType)] || row.operationType || '-',
  },
  {
    title: '操作内容',
    key: 'operationContent',
    width: 320,
    ellipsis: true,
  },
  {
    title: '操作IP',
    key: 'operationIp',
    width: 140,
    ellipsis: true,
  },
  {
    title: '操作时间',
    key: 'operationTime',
    width: 170,
    ellipsis: true,
    render: (row) => {
      const timeText = row.operationTime || row.createTime
      return timeText ? dayjs(timeText).format('YYYY/MM/DD HH:mm') : '-'
    },
  },
])
function buildPayload(pageNum = currentPage.value, pageSize = currentPageSize.value): QueryPayload {
  const payload: QueryPayload = { pageNum, pageSize }
  // 统一清洗查询条件,避免把空字符串交给父页面
  if (filterModel.value.operatorName.trim())
    payload.operatorName = filterModel.value.operatorName.trim()
  if (filterModel.value.account.trim())
    payload.account = filterModel.value.account.trim()
  if (filterModel.value.operationType !== null)
    payload.operationType = filterModel.value.operationType
  if (filterModel.value.operationContent.trim())
    payload.operationContent = filterModel.value.operationContent.trim()
  if (filterModel.value.operationIp.trim())
    payload.operationIp = filterModel.value.operationIp.trim()
  if (filterModel.value.operationTime) {
    payload.startTime = dayjs(filterModel.value.operationTime[0]).startOf('day').format('YYYY-MM-DD HH:mm:ss')
    payload.endTime = dayjs(filterModel.value.operationTime[1]).endOf('day').format('YYYY-MM-DD HH:mm:ss')
  }
  return payload
}
function handleSearch() {
  currentPage.value = 1
  emit('search', buildPayload(1, currentPageSize.value))
}
function handleReset() {
  filterModel.value = {
    operatorName: '',
    account: '',
    operationType: null,
    operationContent: '',
    operationIp: '',
    operationTime: null,
  }
  currentPage.value = 1
  emit('reset', buildPayload(1, currentPageSize.value))
}
function handlePageChange(page: number, pageSize: number) {
  currentPage.value = page
  currentPageSize.value = pageSize
  emit('page-change', buildPayload(page, pageSize))
}
function handleExport() {
  emit('export', buildPayload())
}
function handleUpdateShow(show: boolean) {
  emit('update:show', show)
}
function handleOperationTypeShow(show: boolean) {
  isOperationTypeOpen.value = show
}
</script>
<template>
  <n-modal
    :show="show"
    preset="card"
    :title="title"
    class="operation-log-modal"
    :style="{ width: '1200px' }"
    :bordered="false"
    size="huge"
    closable
    @update:show="handleUpdateShow"
  >
    <div class="roboTaxi w-full h-full">
      <div class="roboTaxi-form-card mb-12px">
        <NForm ref="formRef" :model="filterModel" label-placement="left" inline :show-feedback="false">
          <SearchPanel>
            <NGi>
              <NFormItem label="操作人" path="operatorName">
                <NInput v-model:value="filterModel.operatorName" placeholder="请输入" />
              </NFormItem>
            </NGi>
            <NGi>
              <NFormItem label="账号" path="account">
                <NInput v-model:value="filterModel.account" placeholder="请输入" />
              </NFormItem>
            </NGi>
            <NGi>
              <NFormItem label="操作类型" path="operationType">
                <NSelect
                  v-model:value="filterModel.operationType"
                  :options="operationTypeOptions"
                  clearable
                  placeholder="请选择"
                  @update:show="handleOperationTypeShow"
                >
                  <template #arrow>
                    <img
                      src="https://huishou-1323682843.cos.ap-nanjing.myqcloud.com/images/e120f81c-9f15-4e1a-9938-22252898edf9.png"
                      :class="`arrow-icon ${isOperationTypeOpen ? 'arrow-icon--open' : ''}`"
                      style="width: 16px; height: 16px;"
                      alt="arrow"
                    >
                  </template>
                </NSelect>
              </NFormItem>
            </NGi>
            <NGi>
              <NFormItem label="操作内容" path="operationContent">
                <NInput v-model:value="filterModel.operationContent" placeholder="请输入" />
              </NFormItem>
            </NGi>
            <NGi>
              <NFormItem label="操作IP" path="operationIp">
                <NInput v-model:value="filterModel.operationIp" placeholder="请输入" />
              </NFormItem>
            </NGi>
            <NGi>
              <NFormItem label="操作时间" path="operationTime">
                <NDatePicker
                  v-model:value="filterModel.operationTime"
                  type="daterange"
                  clearable
                  format="yyyy/MM/dd"
                  start-placeholder="开始时间"
                  end-placeholder="结束时间"
                  separator="-"
                  close-on-select
                  :actions="[]"
                />
              </NFormItem>
            </NGi>
            <template #action>
              <NButton @click="handleReset">
                重置
              </NButton>
              <NButton type="primary" @click="handleSearch">
                查询
              </NButton>
            </template>
          </SearchPanel>
        </NForm>
      </div>
      <NCard class="robotaxi-table-card" content-style="display: flex; flex-direction: column; min-height: 420px;">
        <template #header>
          <n-flex justify="flex-end" align="center">
            <NButton v-if="showExport" @click="handleExport">
              导出
            </NButton>
          </n-flex>
        </template>
        <div class="flex-1 flex flex-col h-full">
          <NDataTable
            class="flex-1"
            :loading="loading"
            :bordered="false"
            :data="data"
            :columns="columns"
            :scroll-x="1080"
            :header-height="18"
            :row-height="18"
            flex-height
          >
            <template #empty>
              <div class="flex-center">
                <img
                  src="/src/assets/element-img/no-data.png"
                  style="width:123px;height:125px"
                  @error="(e: any) => { e.target.src = 'https://huishou-1323682843.cos.ap-nanjing.myqcloud.com/images/9bed493b-a6aa-4ef6-ac10-7f67f5c6adc5.png' }"
                >
              </div>
            </template>
          </NDataTable>
          <Pagination
            :count="total"
            :page="currentPage"
            :page-size="currentPageSize"
            class="robotaxi-pagination mt-12px"
            @change="handlePageChange"
          />
        </div>
      </NCard>
    </div>
  </n-modal>
</template>
src/router/routes.static.ts
@@ -124,6 +124,19 @@
    pid: 9,
  },
  {
    name: 'systemOperationLog',
    path: '/systemOperationLog',
    title: '系统操作日志',
    requiresAuth: true,
    icon: 'icon-park-outline:log',
    menuType: 'page',
    componentPath: '/system/operationLog/index.vue',
    id: 12,
    keepAlive: true,
    pid: null,
    fullScreen: false,
  },
  {
    name: 'testPlanAdd',
    path: '/testDemo/testPlanList/add',
    title: '新增测试计划',
src/service/api/systemOperationLog.ts
New file
@@ -0,0 +1,19 @@
import { request } from '@/service/http'
/**
 * 获取系统操作日志分页列表
 * @param data 查询参数
 */
export function fetchSystemOperationLogPageList(data: any) {
    return request.Post<Service.ResponseResult<any>>('/operation/api/v1/systemOperationLog/pageList', data)
}
/**
 * 导出系统操作日志
 * @param data 查询参数
 */
export function exportSystemOperationLog(data: any) {
    const method = request.Post('/operation/api/v1/systemOperationLog/exportExcel', data, { responseType: 'blob' })
    method.meta = { ...method.meta, isBlob: true }
    return method
}
src/styles/robotaxi.scss
@@ -33,6 +33,7 @@
.roboTaxi {
  height: calc(100vh - 76px - 24px);
  // height: calc(100vh - 35px - 40px);
  display: flex;
  flex-direction: column;
@@ -80,6 +81,11 @@
    padding-top: 0;
  }
  .n-card-content {
    padding: 12px;
    padding-top: 0;
  }
  .n-base-selection-input__content {
    font-size: 13px;
  }
src/views/system/operationLog/index.vue
New file
@@ -0,0 +1,422 @@
<script setup lang="tsx">
import type { DataTableColumns, FormInst } from 'naive-ui'
import { NButton, NCard, NDataTable, NForm, NFormItem, NGi, NInput, NSelect, NTooltip } from 'naive-ui'
import dayjs from 'dayjs'
import SearchPanel from '@/components/custom/SearchPanel.vue'
import Pagination from '@/components/common/Pagination.vue'
import { useBoolean } from '@/hooks'
import { downloadFile } from '@/utils'
interface QueryModel {
  functionModule: string
  operatorName: string
  account: string
  operationType: number | null
  operationContent: string
  operationIp: string
  operationTime: [number, number] | null
  pageNum: number
  pageSize: number
}
interface OperationLogItem {
  functionModule?: string
  operatorName?: string
  account?: string
  operationType?: number | string
  operationContent?: string
  operationIp?: string
  operationTime?: string
  createTime?: string
}
const { bool: loading, setTrue: startLoading, setFalse: endLoading } = useBoolean(false)
const message = window.$message
const initialModel: QueryModel = {
  functionModule: '',
  operatorName: '',
  account: '',
  operationType: null,
  operationContent: '',
  operationIp: '',
  operationTime: null,
  pageNum: 1,
  pageSize: 10,
}
const model = ref<QueryModel>({ ...initialModel })
const formRef = ref<FormInst | null>(null)
const isOperationTypeOpen = ref(false)
const operationTypeOptions = [
  { label: '新增', value: 1 },
  { label: '编辑', value: 2 },
  { label: '删除', value: 3 },
  { label: '导入', value: 4 },
  { label: '导出', value: 5 },
  { label: '批量冻结', value: 6 },
]
const operationTypeLabelMap: Record<number, string> = {
  1: '新增',
  2: '编辑',
  3: '删除',
  4: '导入',
  5: '导出',
  6: '批量冻结',
}
function createMockData(total = 120): OperationLogItem[] {
  const moduleList = ['白名单', '运营', '交易', '资产', '活动', '系统配置']
  const operatorList = ['杨倩芳', '曹敏倩', '罗凯艺', '丁汉辉', '刘丽丽', '方一强']
  const accountList = ['h33@china.com', 'five@eastday.com', 'bubj@cmju56.com', 's2724133@net.com.cn', 'mbajbigs@t2n.com', '1396827528']
  const contentList = [
    '新增账号“1198726442”',
    '编辑账号“1198726442”',
    '删除了 3 条无效数据',
    '导入 126 条用户数据',
    '导出了筛选后的报表',
    '批量冻结 8 个账号',
  ]
  const ipList = ['198.235.14.9', '172.16.12.8', '10.10.22.33', '221.12.33.44', '36.110.5.7', '47.102.88.100']
  const typeList = [1, 2, 3, 4, 5, 6]
  return Array.from({ length: total }, (_, index) => {
    const seed = index % 6
    return {
      functionModule: moduleList[seed],
      operatorName: operatorList[seed],
      account: accountList[seed],
      operationType: typeList[seed],
      operationContent: contentList[seed],
      operationIp: ipList[seed],
      operationTime: dayjs().subtract(index * 13, 'minute').format('YYYY-MM-DD HH:mm:ss'),
    }
  })
}
const mockSourceData = ref<OperationLogItem[]>(createMockData())
const filterItems = computed(() => [
  {
    label: '功能模块',
    path: 'functionModule',
    render: () => <NInput v-model:value={model.value.functionModule} placeholder="请输入" />,
  },
  {
    label: '操作人',
    path: 'operatorName',
    render: () => <NInput v-model:value={model.value.operatorName} placeholder="请输入" />,
  },
  {
    label: '账号',
    path: 'account',
    render: () => <NInput v-model:value={model.value.account} placeholder="请输入" />,
  },
  {
    label: '操作类型',
    path: 'operationType',
    render: () => (
      <NSelect
        v-model:value={model.value.operationType}
        options={operationTypeOptions}
        clearable
        placeholder="请选择"
        onUpdate:show={(value: boolean) => isOperationTypeOpen.value = value}
      >
        {{
          arrow: () => (
            <img
              src="https://huishou-1323682843.cos.ap-nanjing.myqcloud.com/images/e120f81c-9f15-4e1a-9938-22252898edf9.png"
              class={`arrow-icon ${isOperationTypeOpen.value ? 'arrow-icon--open' : ''}`}
              style={{ width: '16px', height: '16px' }}
              alt="arrow"
            />
          ),
          empty: () => (
            <div class="flex-center">
              <img src="/src/assets/element-img/no-data.png" style={{ width: '50%' }} />
            </div>
          ),
        }}
      </NSelect>
    ),
  },
  {
    label: '操作内容',
    path: 'operationContent',
    render: () => <NInput v-model:value={model.value.operationContent} placeholder="请输入" />,
  },
  {
    label: '操作IP',
    path: 'operationIp',
    render: () => <NInput v-model:value={model.value.operationIp} placeholder="请输入" />,
  },
  {
    label: '操作时间',
    path: 'operationTime',
    render: () => (
      <n-date-picker
        v-model:value={model.value.operationTime}
        type="daterange"
        clearable
        format="yyyy/MM/dd"
        start-placeholder="开始时间"
        end-placeholder="结束时间"
        separator="-"
        close-on-select={true}
        actions={[]}
      />
    ),
  },
])
const listData = ref<OperationLogItem[]>([])
const count = ref(0)
const columns = computed<DataTableColumns<OperationLogItem>>(() => [
  {
    title: '序号',
    key: 'index',
    width: 60,
    resizable: true,
    render: (_, index) => <span>{(model.value.pageNum - 1) * model.value.pageSize + index + 1}</span>,
  },
  {
    title: '功能模块',
    key: 'functionModule',
    width: 160,
    ellipsis: {
      tooltip: true,
    },
    resizable: true,
  },
  {
    title: '操作人',
    key: 'operatorName',
    width: 100,
    ellipsis: true,
    resizable: true,
  },
  {
    title: '账号',
    key: 'account',
    width: 150,
    ellipsis: true,
    resizable: true,
  },
  {
    title: '操作类型',
    key: 'operationType',
    width: 120,
    resizable: true,
    render: (row) => {
      const value = row.operationType
      return <span>{typeof value === 'number' ? (operationTypeLabelMap[value] || value) : (value || '-')}</span>
    },
  },
  {
    title: '操作内容',
    key: 'operationContent',
    width: 200,
    ellipsis: true,
    resizable: true,
    render: row => (
      <NTooltip trigger="hover" placement="top" max-width="560px">
        {{
          trigger: () => <span class="cursor-pointer">{row.operationContent || '-'}</span>,
          default: () => row.operationContent || '-',
        }}
      </NTooltip>
    ),
  },
  {
    title: '操作IP',
    key: 'operationIp',
    width: 140,
    ellipsis: true,
    resizable: true,
  },
  {
    title: '操作时间',
    key: 'operationTime',
    width: 120,
    ellipsis: true,
    render: (row) => {
      const timeText = row.operationTime || row.createTime
      return <span>{timeText ? dayjs(timeText).format('YYYY/MM/DD HH:mm') : '-'}</span>
    },
  },
])
function buildQueryParams() {
  const params: Record<string, any> = {
    pageNum: model.value.pageNum,
    pageSize: model.value.pageSize,
  }
  if (model.value.functionModule.trim())
    params.functionModule = model.value.functionModule.trim()
  if (model.value.operatorName.trim())
    params.operatorName = model.value.operatorName.trim()
  if (model.value.account.trim())
    params.account = model.value.account.trim()
  if (model.value.operationType !== null)
    params.operationType = model.value.operationType
  if (model.value.operationContent.trim())
    params.operationContent = model.value.operationContent.trim()
  if (model.value.operationIp.trim())
    params.operationIp = model.value.operationIp.trim()
  if (model.value.operationTime) {
    params.startTime = dayjs(model.value.operationTime[0]).startOf('day').format('YYYY-MM-DD HH:mm:ss')
    params.endTime = dayjs(model.value.operationTime[1]).endOf('day').format('YYYY-MM-DD HH:mm:ss')
  }
  return params
}
function getFilteredData() {
  const params = buildQueryParams()
  return mockSourceData.value.filter((item) => {
    if (params.functionModule && !String(item.functionModule || '').includes(params.functionModule))
      return false
    if (params.operatorName && !String(item.operatorName || '').includes(params.operatorName))
      return false
    if (params.account && !String(item.account || '').includes(params.account))
      return false
    if (params.operationType !== undefined && Number(item.operationType) !== Number(params.operationType))
      return false
    if (params.operationContent && !String(item.operationContent || '').includes(params.operationContent))
      return false
    if (params.operationIp && !String(item.operationIp || '').includes(params.operationIp))
      return false
    if (params.startTime && params.endTime) {
      const currentTime = dayjs(item.operationTime || item.createTime).valueOf()
      const startTime = dayjs(params.startTime).valueOf()
      const endTime = dayjs(params.endTime).valueOf()
      if (currentTime < startTime || currentTime > endTime)
        return false
    }
    return true
  })
}
async function getList() {
  startLoading()
  try {
    const filteredData = getFilteredData()
    count.value = filteredData.length
    const start = (model.value.pageNum - 1) * model.value.pageSize
    const end = start + model.value.pageSize
    listData.value = filteredData.slice(start, end)
    await Promise.resolve()
  }
  finally {
    endLoading()
  }
}
function handleSearch() {
  model.value.pageNum = 1
  getList()
}
function handleResetSearch() {
  model.value = { ...initialModel }
  handleSearch()
}
function changePage(page: number, size: number) {
  model.value.pageNum = page
  model.value.pageSize = size
  getList()
}
async function handleExport() {
  const exportList = getFilteredData()
  const headers = ['序号', '功能模块', '操作人', '账号', '操作类型', '操作内容', '操作IP', '操作时间']
  const lines = exportList.map((item, index) => {
    const values = [
      String(index + 1),
      item.functionModule || '-',
      item.operatorName || '-',
      item.account || '-',
      operationTypeLabelMap[Number(item.operationType)] || String(item.operationType || '-'),
      item.operationContent || '-',
      item.operationIp || '-',
      item.operationTime || item.createTime || '-',
    ]
    return values.map(value => `"${String(value).replace(/"/g, '""')}"`).join(',')
  })
  const csvText = `\uFEFF${headers.join(',')}\n${lines.join('\n')}`
  const blob = new Blob([csvText], { type: 'text/csv;charset=utf-8;' })
  downloadFile(blob, `系统操作日志_${dayjs().format('YYYYMMDDHHmmss')}.csv`)
  message?.success('导出成功')
}
onMounted(() => {
  getList()
})
</script>
<template>
  <div class="roboTaxi w-full h-full">
    <div class="roboTaxi-form-card mb-12px">
      <NForm ref="formRef" :model="model" label-placement="left" inline :show-feedback="false">
        <SearchPanel>
          <NGi v-for="item in filterItems" :key="item.path" class="n-grid-item">
            <NFormItem :label="item.label" :path="item.path">
              <component :is="item.render" />
            </NFormItem>
          </NGi>
          <template #action>
            <NButton @click="handleResetSearch">
              {{ $t('common.reset') }}
            </NButton>
            <NButton type="primary" @click="handleSearch">
              {{ $t('common.search') }}
            </NButton>
          </template>
        </SearchPanel>
      </NForm>
    </div>
    <NCard class="flex-1 robotaxi-table-card">
      <template #header>
        <n-flex justify="flex-end" align="center">
          <NButton @click="handleExport">
            导出
          </NButton>
        </n-flex>
      </template>
      <div class="flex-1 flex flex-col h-full">
        <NDataTable
          class="flex-1"
          :columns="columns"
          :data="listData"
          :loading="loading"
          flex-height
          :bordered="false"
          :scroll-x="1280"
          :header-height="18"
          :row-height="18"
        >
          <template #empty>
            <div class="flex-center">
              <img
                src="/src/assets/element-img/no-data.png"
                style="width:123px;height:125px"
                @error="(e: any) => { e.target.src = 'https://huishou-1323682843.cos.ap-nanjing.myqcloud.com/images/9bed493b-a6aa-4ef6-ac10-7f67f5c6adc5.png' }"
              >
            </div>
          </template>
        </NDataTable>
        <Pagination
          :count="count"
          :page="model.pageNum"
          :page-size="model.pageSize"
          class="robotaxi-pagination mt-12px"
          @change="changePage"
        />
      </div>
    </NCard>
  </div>
</template>