Browse Source

✨ feat: 引入MQ队列查看功能

Pchen. 2 months ago
parent
commit
731db774a8
3 changed files with 379 additions and 0 deletions
  1. 20 0
      src/api/mq.js
  2. 350 0
      src/pages/admin/mqQueue/index.vue
  3. 9 0
      src/router/index.js

+ 20 - 0
src/api/mq.js

@@ -0,0 +1,20 @@
+import request from '../utils/request'
+
+const api = {
+  GetQueueTasks: '/Admin/MQ/GetQueueTasks'
+}
+
+/**
+ * 管理端 MQ 队列窥视 / 概览
+ * @param {Object} parameter
+ * @param {string} [parameter.summary] 传 '1' 或 'true' 返回所有允许队列的 messageCount / consumerCount
+ * @param {string} [parameter.queue] 队列名(须在后端白名单内)
+ * @param {number|string} [parameter.limit] 窥视条数 1–100,默认由后端处理
+ */
+export function adminGetQueueTasks (parameter) {
+  return request({
+    url: api.GetQueueTasks,
+    method: 'get',
+    params: parameter
+  })
+}

+ 350 - 0
src/pages/admin/mqQueue/index.vue

@@ -0,0 +1,350 @@
+<template>
+  <div class="container">
+    <Breadcrumb :items="['网站管理', '任务队列']" />
+
+    <a-card title="队列概览" class="card-block">
+      <template #extra>
+        <a-space>
+          <span class="muted" v-if="summaryFetchedAt">更新于 {{ formatTime(summaryFetchedAt) }}</span>
+          <span class="muted">自动刷新</span>
+          <a-switch v-model="autoRefresh" type="round" />
+          <a-select v-model="refreshIntervalSec" :style="{ width: '110px' }" :disabled="!autoRefresh">
+            <a-option :value="3">每 3 秒</a-option>
+            <a-option :value="5">每 5 秒</a-option>
+            <a-option :value="10">每 10 秒</a-option>
+            <a-option :value="30">每 30 秒</a-option>
+          </a-select>
+          <a-button type="primary" :loading="summaryLoading" @click="loadSummary">
+            <template #icon><icon-refresh /></template>
+            刷新概览
+          </a-button>
+        </a-space>
+      </template>
+
+      <a-table
+        :data="summaryRows"
+        :loading="summaryLoading"
+        :pagination="false"
+        :bordered="false"
+        row-key="name"
+        class="table"
+      >
+        <template #columns>
+          <a-table-column title="队列名称" data-index="name" :width="260" ellipsis tooltip />
+          <a-table-column title="待处理消息数" data-index="messageCount" :width="140">
+            <template #cell="{ record }">
+              <span v-if="record.messageCount != null">{{ record.messageCount }}</span>
+              <span v-else class="muted">—</span>
+            </template>
+          </a-table-column>
+          <a-table-column title="消费者数" data-index="consumerCount" :width="120">
+            <template #cell="{ record }">
+              <span v-if="record.consumerCount != null">{{ record.consumerCount }}</span>
+              <span v-else class="muted">—</span>
+            </template>
+          </a-table-column>
+          <a-table-column title="说明" data-index="error">
+            <template #cell="{ record }">
+              <a-typography-text v-if="record.error" type="danger">{{ record.error }}</a-typography-text>
+              <span v-else class="muted">正常</span>
+            </template>
+          </a-table-column>
+        </template>
+      </a-table>
+    </a-card>
+
+    <a-card title="队列消息窥视(reject_requeue_true,不消费)" class="card-block">
+      <a-row :gutter="16" align="center">
+        <a-col :span="6">
+          <a-form-item label="队列" :label-col-props="{ span: 6 }" :wrapper-col-props="{ span: 18 }">
+            <a-select v-model="peekForm.queue" placeholder="选择队列">
+              <a-option v-for="q in ALLOWED_QUEUES" :key="q" :value="q">{{ q }}</a-option>
+            </a-select>
+          </a-form-item>
+        </a-col>
+        <a-col :span="5">
+          <a-form-item label="条数" :label-col-props="{ span: 8 }" :wrapper-col-props="{ span: 16 }">
+            <a-input-number v-model="peekForm.limit" :min="1" :max="100" mode="button" />
+          </a-form-item>
+        </a-col>
+        <a-col :span="13">
+          <a-space>
+            <a-button type="primary" :loading="peekLoading" @click="loadPeek">
+              <template #icon><icon-unordered-list /></template>
+              拉取窥视
+            </a-button>
+            <span v-if="peekMeta.messageCount != null" class="meta">
+              队列深度:{{ peekMeta.messageCount }},消费者:{{ peekMeta.consumerCount ?? '—' }},本次条数:{{ peekMeta.peekLimit ?? '—' }}
+            </span>
+            <span v-if="peekFetchedAt" class="muted">更新于 {{ formatTime(peekFetchedAt) }}</span>
+          </a-space>
+        </a-col>
+      </a-row>
+
+      <a-alert v-if="peekMeta.managementError" type="warning" style="margin-bottom: 12px">
+        {{ peekMeta.managementError }}
+      </a-alert>
+
+      <a-table
+        :data="peekTasks"
+        :loading="peekLoading"
+        :pagination="false"
+        :bordered="false"
+        row-key="__key"
+        :scroll="{ x: 1200 }"
+        class="table"
+      >
+        <template #columns>
+          <a-table-column title="再投递" data-index="redelivered" :width="88">
+            <template #cell="{ record }">
+              <a-tag v-if="record.redelivered" color="orange">是</a-tag>
+              <a-tag v-else color="green">否</a-tag>
+            </template>
+          </a-table-column>
+          <a-table-column title="路由键" data-index="routing_key" :width="160" ellipsis tooltip />
+          <a-table-column title="交换机" data-index="exchange" :width="140" ellipsis tooltip />
+          <a-table-column title="MessageId" :width="180" ellipsis tooltip>
+            <template #cell="{ record }">
+              {{ record.properties?.messageId ?? '—' }}
+            </template>
+          </a-table-column>
+          <a-table-column title="时间戳" :width="160">
+            <template #cell="{ record }">
+              {{ record.properties?.timestamp != null ? String(record.properties.timestamp) : '—' }}
+            </template>
+          </a-table-column>
+          <a-table-column title="负载摘要" data-index="payload">
+            <template #cell="{ record }">
+              <span class="payload-preview">{{ payloadPreview(record.payload) }}</span>
+              <a-button type="text" size="small" @click="openPayloadModal(record)">完整 JSON</a-button>
+            </template>
+          </a-table-column>
+        </template>
+      </a-table>
+    </a-card>
+
+    <a-modal v-model:visible="payloadVisible" title="消息负载" width="800px" :footer="false" draggable>
+      <pre class="json-pre">{{ payloadModalText }}</pre>
+    </a-modal>
+  </div>
+</template>
+
+<script setup>
+import { ref, reactive, watch, onMounted, onUnmounted } from 'vue'
+import { adminGetQueueTasks } from '@/api/mq'
+import { Notification } from '@arco-design/web-vue'
+
+/** 与后端 ALLOWED_QUEUES 保持一致,用于下拉与展示 */
+const ALLOWED_QUEUES = [
+  'runforge_task_queue',
+  'runforge_task_result_queue',
+  'runforge_task_dead_queue',
+  'runforge_message_queue',
+  'order_payment_check',
+  'mq_health_check'
+]
+
+const summaryLoading = ref(false)
+const summaryRows = ref([])
+const summaryFetchedAt = ref(null)
+
+const peekLoading = ref(false)
+const peekTasks = ref([])
+const peekFetchedAt = ref(null)
+const peekMeta = reactive({
+  messageCount: null,
+  consumerCount: null,
+  peekLimit: null,
+  managementError: null
+})
+
+const peekForm = reactive({
+  queue: 'runforge_task_queue',
+  limit: 30
+})
+
+const autoRefresh = ref(false)
+const refreshIntervalSec = ref(5)
+let pollTimer = null
+
+const payloadVisible = ref(false)
+const payloadModalText = ref('')
+
+const formatTime = (ts) => {
+  if (ts == null) return ''
+  return new Date(ts).toLocaleString('zh-CN', {
+    year: 'numeric',
+    month: '2-digit',
+    day: '2-digit',
+    hour: '2-digit',
+    minute: '2-digit',
+    second: '2-digit'
+  })
+}
+
+const payloadPreview = (payload) => {
+  if (payload == null) return '—'
+  const s = typeof payload === 'string' ? payload : JSON.stringify(payload)
+  return s.length > 100 ? `${s.slice(0, 100)}…` : s
+}
+
+const openPayloadModal = (record) => {
+  try {
+    payloadModalText.value =
+      typeof record.payload === 'string'
+        ? record.payload
+        : JSON.stringify(record.payload, null, 2)
+  } catch {
+    payloadModalText.value = String(record.payload)
+  }
+  payloadVisible.value = true
+}
+
+const loadSummary = async (silent = false) => {
+  if (!silent) summaryLoading.value = true
+  try {
+    const res = await adminGetQueueTasks({ summary: '1' })
+    if (!res || res.code !== 0) {
+      Notification.error({
+        title: '获取队列概览失败',
+        content: res?.msg ?? '请稍后再试'
+      })
+      return
+    }
+    const queues = res.data?.queues || {}
+    summaryRows.value = ALLOWED_QUEUES.map((name) => {
+      const info = queues[name] || {}
+      return {
+        name,
+        messageCount: info.messageCount ?? null,
+        consumerCount: info.consumerCount ?? null,
+        error: info.error
+      }
+    })
+    summaryFetchedAt.value = res.data?.fetchedAt ?? Date.now()
+  } catch (e) {
+    Notification.error({
+      title: '获取队列概览失败',
+      content: e.message || '请稍后再试'
+    })
+  } finally {
+    if (!silent) summaryLoading.value = false
+  }
+}
+
+const loadPeek = async (silent = false) => {
+  if (!silent) peekLoading.value = true
+  try {
+    const res = await adminGetQueueTasks({
+      queue: peekForm.queue,
+      limit: peekForm.limit
+    })
+    if (!res || res.code !== 0) {
+      Notification.error({
+        title: '窥视队列失败',
+        content: res?.msg ?? '请稍后再试'
+      })
+      return
+    }
+    const d = res.data || {}
+    peekMeta.messageCount = d.messageCount ?? null
+    peekMeta.consumerCount = d.consumerCount ?? null
+    peekMeta.peekLimit = d.peekLimit ?? null
+    peekMeta.managementError = d.managementError || null
+    peekFetchedAt.value = d.fetchedAt ?? Date.now()
+    const list = Array.isArray(d.tasks) ? d.tasks : []
+    peekTasks.value = list.map((row, i) => ({
+      ...row,
+      __key: `${peekForm.queue}-${peekFetchedAt.value}-${i}`
+    }))
+  } catch (e) {
+    Notification.error({
+      title: '窥视队列失败',
+      content: e.message || '请稍后再试'
+    })
+  } finally {
+    if (!silent) peekLoading.value = false
+  }
+}
+
+const refreshAll = (silent = false) => {
+  loadSummary(silent)
+  loadPeek(silent)
+}
+
+const clearPoll = () => {
+  if (pollTimer) {
+    clearInterval(pollTimer)
+    pollTimer = null
+  }
+}
+
+const setupPoll = () => {
+  clearPoll()
+  if (!autoRefresh.value) return
+  pollTimer = setInterval(() => {
+    refreshAll(true)
+  }, refreshIntervalSec.value * 1000)
+}
+
+watch([autoRefresh, refreshIntervalSec], () => {
+  setupPoll()
+  if (autoRefresh.value) refreshAll(true)
+})
+
+onMounted(() => {
+  refreshAll(false)
+  setupPoll()
+})
+
+onUnmounted(() => {
+  clearPoll()
+})
+</script>
+
+<style scoped>
+.container {
+  padding: 0 20px 20px 20px;
+}
+
+.card-block {
+  margin-bottom: 16px;
+}
+
+.table {
+  margin-top: 8px;
+}
+
+.muted {
+  color: var(--color-text-3);
+  font-size: 13px;
+}
+
+.meta {
+  font-size: 13px;
+  color: var(--color-text-2);
+}
+
+.payload-preview {
+  display: inline-block;
+  max-width: 360px;
+  margin-right: 8px;
+  vertical-align: middle;
+  color: var(--color-text-2);
+  font-size: 13px;
+  word-break: break-all;
+}
+
+.json-pre {
+  margin: 0;
+  max-height: 480px;
+  overflow: auto;
+  padding: 12px;
+  font-size: 12px;
+  line-height: 1.5;
+  white-space: pre-wrap;
+  word-break: break-all;
+  background: var(--color-fill-2);
+  border-radius: var(--border-radius-medium);
+}
+</style>

+ 9 - 0
src/router/index.js

@@ -318,6 +318,15 @@ const routes = [
                     permission: ['admin', 'service']
                     permission: ['admin', 'service']
                 }
                 }
             },
             },
+            {
+                path: 'mqQueue',
+                name: 'admin.mq.queue',
+                component: () => import('../pages/admin/mqQueue/index.vue'),
+                meta: {
+                    title: '任务队列',
+                    permission: ['admin', 'service']
+                }
+            },
             {
             {
                 path: 'reqLog',
                 path: 'reqLog',
                 name: 'admin.log.reqLog',
                 name: 'admin.log.reqLog',