Browse Source

✨ feat: 前端新增绑定解绑审计查询能力

新增用户侧与管理员侧绑定解绑记录入口,支持账号行内查看与全局审计页面展示,便于快速追踪绑定操作。

Made-with: Cursor
Pchen0 1 month ago
parent
commit
1b46bebfb8

+ 28 - 1
src/api/lepao.js

@@ -13,7 +13,10 @@ const api = {
   GetRecordDetail: '/Lepao/GetRecordDetail',
   GetRecordDetail: '/Lepao/GetRecordDetail',
   AdminRecords: '/Admin/Lepao/Records',
   AdminRecords: '/Admin/Lepao/Records',
   AdminGetRecordDetail: '/Admin/Lepao/GetRecordDetail',
   AdminGetRecordDetail: '/Admin/Lepao/GetRecordDetail',
-  BeginFaceReco: '/Face/BeginFaceReco'
+  BeginFaceReco: '/Face/BeginFaceReco',
+  BindAuditList: '/Lepao/BindAudit/List',
+  AdminBindAuditByAccount: '/Admin/Lepao/BindAudit/ByAccount',
+  AdminBindAuditList: '/Admin/Lepao/BindAudit/List'
 }
 }
 
 
 export function addAccount (parameter) {
 export function addAccount (parameter) {
@@ -134,4 +137,28 @@ export function getAdminCountLedger (parameter) {
     method: 'get',
     method: 'get',
     params: parameter
     params: parameter
   })
   })
+}
+
+export function getBindAuditList (parameter) {
+  return request({
+    url: api.BindAuditList,
+    method: 'get',
+    params: parameter
+  })
+}
+
+export function getAdminBindAuditByAccount (parameter) {
+  return request({
+    url: api.AdminBindAuditByAccount,
+    method: 'get',
+    params: parameter
+  })
+}
+
+export function getAdminBindAuditList (parameter) {
+  return request({
+    url: api.AdminBindAuditList,
+    method: 'get',
+    params: parameter
+  })
 }
 }

+ 123 - 1
src/pages/admin/lepaoAccount/accountList.vue

@@ -180,6 +180,7 @@
                             <a-doption @click="editAccount(record)"><icon-edit /> 编辑账号</a-doption>
                             <a-doption @click="editAccount(record)"><icon-edit /> 编辑账号</a-doption>
                             <a-doption @click="faceRecoRef.openModal(record)"><icon-video-camera /> 人脸采集</a-doption>
                             <a-doption @click="faceRecoRef.openModal(record)"><icon-video-camera /> 人脸采集</a-doption>
                             <a-doption @click="bindBotRef.openModal(record)"><icon-robot-add /> 绑定智能机器人</a-doption>
                             <a-doption @click="bindBotRef.openModal(record)"><icon-robot-add /> 绑定智能机器人</a-doption>
+                            <a-doption @click="openBindAudit(record)"><icon-history /> 绑定解绑记录</a-doption>
                             <a-doption @click="SingleRun(record)"><icon-play-circle /> 开始单次乐跑</a-doption>
                             <a-doption @click="SingleRun(record)"><icon-play-circle /> 开始单次乐跑</a-doption>
                             <a-doption @click="ChangeAutoRun(record)"><icon-translate /> {{ record.auto_run ? '关闭' :
                             <a-doption @click="ChangeAutoRun(record)"><icon-translate /> {{ record.auto_run ? '关闭' :
                                 '开启' }}自动乐跑</a-doption>
                                 '开启' }}自动乐跑</a-doption>
@@ -243,11 +244,53 @@
 
 
     <faceModal :faceInfo="faceInfo" ref="faceRecoRef" />
     <faceModal :faceInfo="faceInfo" ref="faceRecoRef" />
     <bindBot ref="bindBotRef" />
     <bindBot ref="bindBotRef" />
+
+    <a-modal v-model:visible="bindAuditVisible" title="绑定解绑记录" :footer="false" width="980px" draggable>
+        <a-table :data="bindAuditData" :columns="bindAuditColumns" :loading="bindAuditLoading" :pagination="false"
+            :scroll="{ y: 420 }">
+            <template #lepao_user="{ record }">
+                <a-space>
+                    <a-avatar :size="26">
+                        <img :src="record.lepao_avatar || 'https://lepao-cloud.xxoo365.top/view.php/25aa126dc406974ff3579a99a2c6501a.png'" />
+                    </a-avatar>
+                    {{ record.lepao_name || '-' }}
+                </a-space>
+            </template>
+            <template #owner_user="{ record }">
+                <a-space>
+                    <a-avatar :size="26">
+                        <img :src="record.owner_avatar || 'https://lepao-cloud.xxoo365.top/view.php/25aa126dc406974ff3579a99a2c6501a.png'" />
+                    </a-avatar>
+                    {{ record.owner_username || '-' }}
+                </a-space>
+            </template>
+            <template #action="{ record }">
+                <a-tag>{{ actionLabel(record.action) }}</a-tag>
+            </template>
+            <template #operator_user="{ record }">
+                <a-space>
+                    <a-avatar :size="26">
+                        <img :src="record.operator_avatar || 'https://lepao-cloud.xxoo365.top/view.php/25aa126dc406974ff3579a99a2c6501a.png'" />
+                    </a-avatar>
+                    {{ record.operator_username || '-' }}
+                </a-space>
+            </template>
+            <template #detail_json="{ record }">
+                {{ formatDetail(record.detail_json) }}
+            </template>
+            <template #created_at="{ record }">
+                {{ stramptoTime(record.created_at) }}
+            </template>
+            <template #empty>
+                <a-empty description="暂无绑定解绑记录" />
+            </template>
+        </a-table>
+    </a-modal>
 </template>
 </template>
 
 
 <script setup>
 <script setup>
 import { ref, reactive, onMounted, h } from 'vue'
 import { ref, reactive, onMounted, h } from 'vue'
-import { addAccount, adminAccountList, deleteAccount, changeAutoRun, singleRun, adminUpdateAccountInfo } from '@/api/lepao'
+import { addAccount, adminAccountList, deleteAccount, changeAutoRun, singleRun, adminUpdateAccountInfo, getAdminBindAuditByAccount, getAdminBindAuditList } from '@/api/lepao'
 import { Modal, Notification, Message } from '@arco-design/web-vue'
 import { Modal, Notification, Message } from '@arco-design/web-vue'
 import faceModal from '@/components/FaceModal/faceModal.vue'
 import faceModal from '@/components/FaceModal/faceModal.vue'
 import bindBot from '@/components/BindBot/bindBot.vue'
 import bindBot from '@/components/BindBot/bindBot.vue'
@@ -255,6 +298,9 @@ import { getSemesterTimestamps } from '@/utils/util'
 
 
 const faceRecoRef = ref(null)
 const faceRecoRef = ref(null)
 const bindBotRef = ref(null)
 const bindBotRef = ref(null)
+const bindAuditVisible = ref(false)
+const bindAuditLoading = ref(false)
+const bindAuditData = ref([])
 
 
 const queryData = reactive({
 const queryData = reactive({
     area: '',
     area: '',
@@ -399,6 +445,17 @@ const columns = [
         width: 90
         width: 90
     }]
     }]
 
 
+const bindAuditColumns = [
+    { title: '学号', dataIndex: 'student_num', width: 140 },
+    { title: '乐跑账号', slotName: 'lepao_user', width: 180 },
+    { title: '所属用户', slotName: 'owner_user', width: 180 },
+    { title: '动作', slotName: 'action', width: 120 },
+    { title: '来源', dataIndex: 'source', width: 140 },
+    { title: '操作者', slotName: 'operator_user', width: 180 },
+    { title: '详情', slotName: 'detail_json', width: 220, ellipsis: true, tooltip: true },
+    { title: '时间', slotName: 'created_at', width: 180 }
+]
+
 const search = () => {
 const search = () => {
     pagination.current = 1
     pagination.current = 1
     getAccounts()
     getAccounts()
@@ -623,6 +680,71 @@ const UpdateAccountInfo = async (record) => {
     }
     }
 }
 }
 
 
+const actionLabel = (action) => {
+    const map = {
+        platform_bind: '平台绑定',
+        platform_unbind: '平台解绑',
+        bot_bind: '机器人绑定',
+        bot_unbind: '机器人解绑'
+    }
+    return map[action] || action
+}
+
+const formatDetail = (detail) => {
+    if (!detail) return '-'
+    if (typeof detail === 'string') return detail
+    return Object.keys(detail).map(key => `${key}:${detail[key]}`).join(' ; ')
+}
+
+const openBindAudit = async (record) => {
+    bindAuditVisible.value = true
+    bindAuditLoading.value = true
+    bindAuditData.value = []
+    try {
+        const studentNum = String(record.student_num || '')
+        if (!studentNum) {
+            Message.warning('该账号缺少学号,无法查询审计记录')
+            return
+        }
+
+        const res = await getAdminBindAuditByAccount({
+            student_num: studentNum,
+            pagesize: 100,
+            current: 1
+        })
+        if (!res || res.code !== 0) {
+            Notification.error({
+                title: '获取绑定解绑记录失败',
+                content: res?.msg ?? '请稍后再试'
+            })
+            return
+        }
+        const primaryData = Array.isArray(res.data) ? res.data : []
+        if (primaryData.length > 0) {
+            bindAuditData.value = primaryData
+            return
+        }
+
+        const fallbackRes = await getAdminBindAuditList({
+            student_num: studentNum,
+            pagesize: 100,
+            current: 1
+        })
+        const fallbackData = (fallbackRes && fallbackRes.code === 0 && Array.isArray(fallbackRes.data)) ? fallbackRes.data : []
+        bindAuditData.value = fallbackData
+        if (!bindAuditData.value.length) {
+            Message.info('该账号暂无绑定解绑记录')
+        }
+    } catch (error) {
+        Notification.error({
+            title: '获取绑定解绑记录失败',
+            content: error.message || '请稍后再试'
+        })
+    } finally {
+        bindAuditLoading.value = false
+    }
+}
+
 onMounted(() => {
 onMounted(() => {
     queryData.queryTime = getSemesterTimestamps()
     queryData.queryTime = getSemesterTimestamps()
     getAccounts()
     getAccounts()

+ 231 - 0
src/pages/admin/lepaoBindAudit/index.vue

@@ -0,0 +1,231 @@
+<template>
+  <div class="container">
+    <Breadcrumb :items="['网站管理', '绑定解绑审计']" />
+    <a-card title="绑定解绑审计">
+      <a-row>
+        <a-col :flex="'1000px'">
+          <a-form :model="query" :label-col-props="{ span: 6 }" :wrapper-col-props="{ span: 18 }" label-align="left">
+            <a-row :gutter="16">
+              <a-col :span="12">
+                <a-form-item field="student_num" label="学号">
+                  <a-input v-model="query.student_num" placeholder="请输入学号" allow-clear />
+                </a-form-item>
+              </a-col>
+              <a-col :span="12">
+                <a-form-item field="owner_uuid" label="所属用户UUID">
+                  <a-input v-model="query.owner_uuid" placeholder="用于精确筛选(可选)" allow-clear />
+                </a-form-item>
+              </a-col>
+              <a-col :span="12">
+                <a-form-item field="operator_uuid" label="操作者UUID">
+                  <a-input v-model="query.operator_uuid" placeholder="用于精确筛选(可选)" allow-clear />
+                </a-form-item>
+              </a-col>
+              <a-col :span="12">
+                <a-form-item field="action" label="动作">
+                  <a-select v-model="query.action" placeholder="请选择动作" allow-clear>
+                    <a-option value="">全部动作</a-option>
+                    <a-option value="platform_bind">平台绑定</a-option>
+                    <a-option value="platform_unbind">平台解绑</a-option>
+                    <a-option value="bot_bind">机器人绑定</a-option>
+                    <a-option value="bot_unbind">机器人解绑</a-option>
+                  </a-select>
+                </a-form-item>
+              </a-col>
+              <a-col :span="12">
+                <a-form-item field="source" label="来源">
+                  <a-select v-model="query.source" placeholder="请选择来源" allow-clear>
+                    <a-option value="">全部来源</a-option>
+                    <a-option value="user_api">用户接口</a-option>
+                    <a-option value="admin_api">管理员接口</a-option>
+                    <a-option value="service_api">客服接口</a-option>
+                    <a-option value="mcp_qq">机器人MCP</a-option>
+                    <a-option value="mcp_work_order">工单MCP</a-option>
+                  </a-select>
+                </a-form-item>
+              </a-col>
+              <a-col :span="12">
+                <a-form-item field="queryTime" label="时间范围">
+                  <a-range-picker v-model="query.queryTime" show-time format="YYYY-MM-DD HH:mm" value-format="x" />
+                </a-form-item>
+              </a-col>
+            </a-row>
+          </a-form>
+        </a-col>
+        <a-divider style="height: 84px" direction="vertical" />
+        <a-col :flex="1">
+          <a-space direction="vertical" :size="18">
+            <a-button type="primary" @click="search">搜索</a-button>
+            <a-button @click="reset">重置</a-button>
+          </a-space>
+        </a-col>
+      </a-row>
+
+      <a-table
+        class="table"
+        :data="data"
+        :loading="loading"
+        :pagination="{
+          showPageSize: true,
+          showJumper: true,
+          showTotal: true,
+          pageSize: pagination.pagesize,
+          current: pagination.current,
+          total: pagination.total
+        }"
+        @page-change="onPageChange"
+        @page-size-change="onPageSizeChange"
+      >
+        <template #columns>
+          <a-table-column title="学号" data-index="student_num" :width="130" />
+          <a-table-column title="乐跑账号" :width="180">
+            <template #cell="{ record }">
+              <a-space>
+                <a-avatar :size="26">
+                  <img :src="record.lepao_avatar || 'https://lepao-cloud.xxoo365.top/view.php/25aa126dc406974ff3579a99a2c6501a.png'" />
+                </a-avatar>
+                {{ record.lepao_name || '-' }}
+              </a-space>
+            </template>
+          </a-table-column>
+          <a-table-column title="所属用户" :width="180">
+            <template #cell="{ record }">
+              <a-space>
+                <a-avatar :size="26">
+                  <img :src="record.owner_avatar || 'https://lepao-cloud.xxoo365.top/view.php/25aa126dc406974ff3579a99a2c6501a.png'" />
+                </a-avatar>
+                {{ record.owner_username || '-' }}
+              </a-space>
+            </template>
+          </a-table-column>
+          <a-table-column title="动作" :width="130">
+            <template #cell="{ record }">
+              <a-tag>{{ actionLabel(record.action) }}</a-tag>
+            </template>
+          </a-table-column>
+          <a-table-column title="来源" data-index="source" :width="150" />
+          <a-table-column title="操作者" :width="180">
+            <template #cell="{ record }">
+              <a-space>
+                <a-avatar :size="26">
+                  <img :src="record.operator_avatar || 'https://lepao-cloud.xxoo365.top/view.php/25aa126dc406974ff3579a99a2c6501a.png'" />
+                </a-avatar>
+                {{ record.operator_username || '-' }}
+              </a-space>
+            </template>
+          </a-table-column>
+          <a-table-column title="详情" :width="220" ellipsis tooltip>
+            <template #cell="{ record }">{{ formatDetail(record.detail_json) }}</template>
+          </a-table-column>
+          <a-table-column title="时间" :width="180">
+            <template #cell="{ record }">{{ formatTime(record.created_at) }}</template>
+          </a-table-column>
+        </template>
+      </a-table>
+    </a-card>
+  </div>
+</template>
+
+<script setup>
+import { reactive, ref, onMounted } from 'vue'
+import { Notification } from '@arco-design/web-vue'
+import { getAdminBindAuditList } from '@/api/lepao'
+
+const loading = ref(false)
+const data = ref([])
+const query = reactive({
+  student_num: '',
+  owner_uuid: '',
+  operator_uuid: '',
+  action: '',
+  source: '',
+  queryTime: []
+})
+const pagination = reactive({
+  total: 0,
+  current: 1,
+  pagesize: 20
+})
+
+const actionLabel = (action) => {
+  const map = {
+    platform_bind: '平台绑定',
+    platform_unbind: '平台解绑',
+    bot_bind: '机器人绑定',
+    bot_unbind: '机器人解绑'
+  }
+  return map[action] || action
+}
+
+const formatDetail = (detail) => {
+  if (!detail) return '-'
+  if (typeof detail === 'string') return detail
+  return Object.keys(detail).map(key => `${key}:${detail[key]}`).join(' ; ')
+}
+
+const formatTime = (time) => new Date(time).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
+
+const getList = async () => {
+  try {
+    loading.value = true
+    const res = await getAdminBindAuditList({
+      ...query,
+      current: pagination.current,
+      pagesize: pagination.pagesize
+    })
+    if (!res || res.code !== 0)
+      return Notification.error({
+        title: '获取审计记录失败',
+        content: res?.msg ?? '请稍后再试'
+      })
+    data.value = res.data || []
+    pagination.total = res.pagination?.total || 0
+  } catch (error) {
+    Notification.error({
+      title: '获取审计记录失败',
+      content: error.message || '请稍后再试'
+    })
+  } finally {
+    loading.value = false
+  }
+}
+
+const search = () => {
+  pagination.current = 1
+  getList()
+}
+
+const reset = () => {
+  query.student_num = ''
+  query.owner_uuid = ''
+  query.operator_uuid = ''
+  query.action = ''
+  query.source = ''
+  query.queryTime = []
+  pagination.current = 1
+  getList()
+}
+
+const onPageChange = (page) => {
+  pagination.current = page
+  getList()
+}
+const onPageSizeChange = (size) => {
+  pagination.pagesize = size
+  pagination.current = 1
+  getList()
+}
+
+onMounted(() => {
+  getList()
+})
+</script>
+
+<style scoped>
+.container {
+  padding: 0 20px 20px 20px;
+}
+.table {
+  margin-top: 16px;
+}
+</style>

+ 57 - 1
src/pages/lepao/accountList/index.vue

@@ -270,6 +270,7 @@
                   <a-doption @click="SingleRun(record)"><icon-play-circle /> 开始单次乐跑</a-doption>
                   <a-doption @click="SingleRun(record)"><icon-play-circle /> 开始单次乐跑</a-doption>
                   <a-doption @click="ChangeAutoRun(record)"><icon-translate /> {{ record.auto_run ? '关闭' :
                   <a-doption @click="ChangeAutoRun(record)"><icon-translate /> {{ record.auto_run ? '关闭' :
                     '开启' }}自动乐跑</a-doption>
                     '开启' }}自动乐跑</a-doption>
+                  <a-doption @click="openBindAudit()"><icon-history /> 绑定解绑记录</a-doption>
                   <a-doption @click="UpdateSelfAccount(record)"><icon-refresh /> 更新账号信息</a-doption>
                   <a-doption @click="UpdateSelfAccount(record)"><icon-refresh /> 更新账号信息</a-doption>
                   <a-doption @click="DeleteAccount(record)"><icon-minus-circle /> 解绑账号</a-doption>
                   <a-doption @click="DeleteAccount(record)"><icon-minus-circle /> 解绑账号</a-doption>
                 </template>
                 </template>
@@ -348,11 +349,39 @@
 
 
   <faceModal :faceInfo="faceInfo" ref="faceRecoRef" />
   <faceModal :faceInfo="faceInfo" ref="faceRecoRef" />
   <bindBot ref="bindBotRef" />
   <bindBot ref="bindBotRef" />
+
+  <a-modal v-model:visible="bindAuditVisible" title="我的绑定解绑记录" :footer="false" width="900px" draggable>
+    <a-table :data="bindAuditData" :loading="bindAuditLoading" :pagination="false" :scroll="{ y: 420 }">
+      <a-table-column title="学号" data-index="student_num" :width="150" />
+      <a-table-column title="乐跑账号" :width="180">
+        <template #cell="{ record }">
+          <a-space>
+            <a-avatar :size="26">
+              <img :src="record.lepao_avatar || 'https://lepao-cloud.xxoo365.top/view.php/25aa126dc406974ff3579a99a2c6501a.png'" />
+            </a-avatar>
+            {{ record.lepao_name || '-' }}
+          </a-space>
+        </template>
+      </a-table-column>
+      <a-table-column title="类型" :width="130">
+        <template #cell="{ record }">
+          <a-tag :color="record.action === 'system_unbind' ? 'orangered' : 'arcoblue'">
+            {{ record.action_label || '-' }}
+          </a-tag>
+        </template>
+      </a-table-column>
+      <a-table-column title="时间" :width="200">
+        <template #cell="{ record }">
+          {{ stramptoTime(record.created_at) }}
+        </template>
+      </a-table-column>
+    </a-table>
+  </a-modal>
 </template>
 </template>
 
 
 <script setup>
 <script setup>
 import { ref, reactive, onUnmounted, onMounted, h } from 'vue'
 import { ref, reactive, onUnmounted, onMounted, h } from 'vue'
-import { accountList, deleteAccount, addAccount, changeAutoRun, singleRun, updateSelfAccount } from '@/api/lepao'
+import { accountList, deleteAccount, addAccount, changeAutoRun, singleRun, updateSelfAccount, getBindAuditList } from '@/api/lepao'
 import { Modal, Notification, Message } from '@arco-design/web-vue'
 import { Modal, Notification, Message } from '@arco-design/web-vue'
 import { IconSearch } from '@arco-design/web-vue/es/icon'
 import { IconSearch } from '@arco-design/web-vue/es/icon'
 import userCard from '@/components/userCard/userCard.vue'
 import userCard from '@/components/userCard/userCard.vue'
@@ -368,6 +397,9 @@ const email = ref([])
 const faceInfo = ref({})
 const faceInfo = ref({})
 const faceRecoRef = ref(null)
 const faceRecoRef = ref(null)
 const bindBotRef = ref(null)
 const bindBotRef = ref(null)
+const bindAuditVisible = ref(false)
+const bindAuditLoading = ref(false)
+const bindAuditData = ref([])
 
 
 const queryDataForm = reactive({
 const queryDataForm = reactive({
   area: '',
   area: '',
@@ -721,6 +753,30 @@ const GetNotice = async () => {
   notice.value = res
   notice.value = res
 }
 }
 
 
+const openBindAudit = async () => {
+  bindAuditVisible.value = true
+  bindAuditLoading.value = true
+  try {
+    const res = await getBindAuditList({
+      pagesize: 100,
+      current: 1
+    })
+    if (!res || res.code !== 0)
+      return Notification.error({
+        title: '获取绑定解绑记录失败',
+        content: res?.msg ?? '请稍后再试'
+      })
+    bindAuditData.value = res.data || []
+  } catch (error) {
+    Notification.error({
+      title: '获取绑定解绑记录失败',
+      content: error.message || '请稍后再试'
+    })
+  } finally {
+    bindAuditLoading.value = false
+  }
+}
+
 const SingleRun = async (item) => {
 const SingleRun = async (item) => {
   if (item.state !== 1)
   if (item.state !== 1)
     return Notification.warning({
     return Notification.warning({