瀏覽代碼

feat✨: 页面完成

gitboyzcf 2 月之前
父節點
當前提交
1809c00bc9

+ 0 - 2
src/api/index.ts

@@ -29,8 +29,6 @@ generators().forEach((generator: any) => {
 })
 
 export function useRequest() {
-  console.log(map)
-
   return map as ApiType
 }
 

+ 15 - 0
src/api/modules/api.ts

@@ -0,0 +1,15 @@
+import { request } from '../request'
+export type apiT = {
+  API_ALERT_ESEARCH_GET: (params?: object) => Promise<unknown>;
+}
+const apiAll: apiT = {
+  API_ALERT_ESEARCH_GET(params = {}) {
+    return request({
+      url: '/api/alert/ESearch',
+      method: 'get',
+      params,
+    })
+  }
+}
+
+export default apiAll

+ 2 - 2
src/api/service.ts

@@ -1,4 +1,4 @@
-import axios from "axios";
+import axios, { type AxiosResponse } from "axios";
 
 import { httpLogError, requestError, throttleToLogin } from "./utils";
 export function createService() {
@@ -13,7 +13,7 @@ export function createService() {
   );
 
   request.interceptors.response.use(
-    (response) => {
+    (response: AxiosResponse) => {
       const dataAxios = response.data;
       // 这个状态码是和后端约定的
       const { code, data } = dataAxios;

+ 12 - 12
src/router/record.ts

@@ -25,18 +25,6 @@ export const routeRecordRaw: MenuMixedOptions[] = [
     },
     component: 'monitoring-platform/index',
   },
-  {
-    path: 'statistical-analysis',
-    name: 'StatisticalAnalysis',
-    icon: 'iconify-[hugeicons--analysis-text-link]',
-    label: '统计分析',
-    meta: {
-      componentName: 'StatisticalAnalysis',
-      title: '统计分析',
-      showTab: true,
-    },
-    component: 'statistical-analysis/index',
-  },
   {
     path: 'alarm-management',
     name: 'AlarmManagement',
@@ -49,6 +37,18 @@ export const routeRecordRaw: MenuMixedOptions[] = [
     },
     component: 'alarm-management/index',
   },
+  {
+    path: 'statistical-analysis',
+    name: 'StatisticalAnalysis',
+    icon: 'iconify-[hugeicons--analysis-text-link]',
+    label: '统计分析',
+    meta: {
+      componentName: 'StatisticalAnalysis',
+      title: '统计分析',
+      showTab: true,
+    },
+    component: 'statistical-analysis/index',
+  },
   {
     path: 'data-table',
     name: 'dataTable',

+ 142 - 0
src/stores/alarm.ts

@@ -0,0 +1,142 @@
+import dayjs from 'dayjs'
+import { acceptHMRUpdate, defineStore, storeToRefs } from 'pinia'
+
+import { pinia } from '.'
+
+export interface Alarm {
+  id: number
+  location: string
+  timestamp: string
+  img: string
+  type: string
+}
+
+const formatDate = (date: Date, day: number = 0) => {
+  return dayjs(date).subtract(day, 'day').format('YYYY-MM-DD HH:mm:ss')
+}
+
+export const useAlarmStore = defineStore('alarm', {
+  state: () => ({
+    alarms: [
+      {
+        id: 1,
+        location: 'A3跑道',
+        timestamp: formatDate(new Date()),
+        img: 'https://picsum.photos/id/237/200/200',
+        type: 'large',
+      },
+      {
+        id: 2,
+        location: 'A1跑道',
+        timestamp: formatDate(new Date(), 1),
+        img: 'https://picsum.photos/id/237/200/200',
+        type: 'medium',
+      },
+      {
+        id: 3,
+        location: 'A2跑道',
+        timestamp: formatDate(new Date(), 2),
+        img: 'https://picsum.photos/id/237/200/200',
+        type: 'small',
+      },
+      {
+        id: 4,
+        location: 'A4跑道',
+        timestamp: formatDate(new Date(), 3),
+        img: 'https://picsum.photos/id/238/200/200',
+        type: 'large',
+      },
+    ] as Alarm[],
+    aiList: [] as Alarm[],
+    ip:
+      import.meta.env.VITE_APP_API_BASEURL === '/'
+        ? window.location.origin
+        : import.meta.env.VITE_APP_API_BASEURL,
+  }),
+  getters: {
+    ipF(state) {
+      return state.ip.split('//')[1]
+    },
+  },
+  actions: {
+    addAlarm(alarm: Alarm) {
+      this.alarms.push(alarm)
+    },
+    removeAlarm(alarm: Alarm) {
+      this.alarms = this.alarms.filter((a) => a.id !== alarm.id)
+    },
+    // 连接报警信息
+    async connectAlarmWS() {
+      // setInterval(() => {
+      //   const data = {
+      //     AlarmTime: 1709274691,
+      //     CfgId: 95,
+      //     Type: 'human',
+      //     SerialNumber: '5cc8e284-55c9-4916-b493-20a222d3386f',
+      //     AlarmId: 1516,
+      //     Res: {
+      //       '16535ccf-ab52-4553-a494-f168f5144bd2%7C9%7C1546372969796': [
+      //         { x: 525, y: 1775, w: 1615, h: 2067 }
+      //       ],
+      //       'd29e7346-8d8d-44ff-918b-ca0928687f75|0|1709628059861': [
+      //         { x: 525, y: 1775, w: 1615, h: 2067 }
+      //       ]
+      //     }
+      //   }
+      //   this.aiList.unshift({
+      //     ...data,
+      //     state: 0,
+      //     AlgorithmName: data.Type,
+      //     timeFormat: dayjs(parseInt(`${data.AlarmTime}`.padEnd(13, '0'))).format(
+      //       'YYYY-MM-DD HH:mm:ss'
+      //     )
+      //   })
+      //   if (this.aiList.length >= 30) {
+      //     this.aiList.splice(20, 10)
+      //   }
+      // }, 1000)
+      let websocket
+      // eslint-disable-next-line @typescript-eslint/no-this-alias
+      const that = this
+      ;(() => {
+        // websocket = new WebSocket(`wss://${this.ipF}/api/Ai-Message`)
+        websocket = new WebSocket(`wss://${this.ipF}/api/alert/ws`)
+        websocket.onopen = function () {
+          console.log('Ai/Ai-Message 连接已经建立')
+        }
+        websocket.onmessage = function (e) {
+          const data = JSON.parse(e.data)
+          // console.log(data)
+          that.aiList.unshift({
+            ...data,
+            state: 0,
+            AlgorithmName: data.Type,
+            timeFormat: dayjs(parseInt(`${data.AlarmTime}`.padEnd(13, '0'))).format(
+              'YYYY-MM-DD HH:mm:ss',
+            ),
+          })
+
+          if (that.aiList.length >= 30) {
+            that.aiList.splice(20, 10)
+          }
+        }
+        websocket.onerror = function () {
+          console.log('Ai/Alarm 发生错误')
+        }
+        websocket.onclose = function () {
+          console.log('Ai/Alarm 连接断开')
+        }
+      })()
+    },
+  },
+})
+
+export function toRefsAlarmStore() {
+  return {
+    ...storeToRefs(useAlarmStore(pinia)),
+  }
+}
+
+if (import.meta.hot) {
+  import.meta.hot.accept(acceptHMRUpdate(useAlarmStore, import.meta.hot))
+}

+ 1 - 0
src/stores/index.ts

@@ -4,6 +4,7 @@ const pinia = createPinia()
 
 export { pinia }
 
+export * from './alarm'
 export * from './preferences'
 export * from './system'
 export * from './tabs'

+ 28 - 0
src/views/alarm-management/components/ActionModal.vue

@@ -0,0 +1,28 @@
+<script setup lang="ts">
+// import { NForm, NFormItem, NInput, NSelect, NButton, NInputNumber, NDatePicker } from 'naive-ui'
+import { onMounted } from 'vue'
+
+// import { useComponentThemeOverrides, useResettableReactive } from '@/composables'
+import type { AlarmInfo } from '../index.vue'
+// import type { FormRules } from 'naive-ui'
+
+const { data } = defineProps<{
+  data: Partial<AlarmInfo>
+}>()
+
+const emits = defineEmits<{
+  (e: 'submit' | 'update', value: Partial<AlarmInfo>): void
+  (e: 'cancel'): void
+}>()
+
+
+onMounted(() => {
+  console.log(data);
+  console.log(emits);
+  
+})
+</script>
+<template>
+  <div class="p-2">
+  </div>
+</template>

+ 507 - 5
src/views/alarm-management/index.vue

@@ -1,9 +1,511 @@
 <template>
-  <div>
-    告警管理
-  </div>
-</template>
+  <ScrollContainer
+    wrapper-class="flex flex-col gap-y-2"
+    :scrollable="isMaxLg"
+  >
+    <NCard
+      :size="isMaxMd ? 'small' : undefined"
+      class="flex-1"
+      content-class="flex flex-col"
+    >
+      <div class="mb-2 flex justify-end gap-x-4 max-xl:mb-4 max-xl:flex-wrap">
+        <NForm
+          ref="formRef"
+          :model="form"
+          :inline="!isMaxLg"
+          label-placement="left"
+          class="!grid min-lg:grid-cols-3"
+          :label-width="isMaxLg ? 70 : undefined"
+        >
+          <NFormItem
+            label="关键字"
+            path="keyword"
+          >
+            <NInput
+              v-model:value="form.keyword"
+              clearable
+            />
+          </NFormItem>
+          <NFormItem
+            label="告警状态"
+            path="status"
+          >
+            <NSelect
+              v-model:value="form.status"
+              :options="statusOptions"
+              style="min-width: 88px"
+              clearable
+            />
+          </NFormItem>
+          <NFormItem
+            label="告警类型"
+            path="alarm_type"
+          >
+            <NSelect
+              v-model:value="form.alarm_type"
+              :options="typeOptions"
+              style="min-width: 88px"
+              clearable
+            />
+          </NFormItem>
+          <NFormItem
+            label="时间范围"
+            path="status"
+          >
+            <NDatePicker
+              ref="nDatePickerRef"
+              type="datetimerange"
+              clearable
+              v-model:value="nDatePickerV"
+              @update:value="(v: number[])=> {
+                form.start_time = v ? v[0] : null
+                form.end_time = v ? v[1] : null
+              }"
+            />
+          </NFormItem>
+          <NFormItem class="place-self-end min-lg:col-span-2 min-lg:row-span-2">
+            <div class="flex gap-2">
+              <!-- <NButton
+            type="success"
+            @click="createOrEditData()"
+          >
+            <template #icon>
+              <span class="iconify ph--plus-circle" />
+            </template>
+            新增数据
+          </NButton> -->
+              <NButton
+                type="info"
+                @click="handleQueryClick"
+                :loading="isRequestLoading"
+                :disabled="isRequestLoading"
+              >
+                <template #icon>
+                  <span class="iconify ph--magnifying-glass" />
+                </template>
+                查询
+              </NButton>
+              <NButton
+                type="warning"
+                @click="() => (resetForm(), (nDatePickerV = null), getDataList())"
+              >
+                <template #icon>
+                  <span class="iconify ph--arrow-clockwise" />
+                </template>
+                重置
+              </NButton>
+            </div>
+          </NFormItem>
+        </NForm>
+      </div>
+      <div class="flex flex-1 flex-col">
+        <NDataTable
+          class="flex-1"
+          ref="dataTableRef"
+          v-model:checked-row-keys="checkedRowKeys"
+          :remote="true"
+          :flex-height="!isMaxLg"
+          :scroll-x="enableScrollX ? 1800 : 0"
+          :columns="columns"
+          :data="dataList"
+          :row-key="(row) => row.id"
+          :loading="isRequestLoading"
+          :striped="enableStriped"
+          :row-props="rowProps"
+          :single-line="enableSingleLine"
+        />
+        <div class="mt-3 flex items-end justify-end max-xl:flex-col max-xl:gap-y-2">
+          <NPagination
+            v-bind="pagination"
+            :prefix="paginationPrefix"
+            :page-slot="isMaxMd ? 5 : undefined"
+            :show-quick-jump-dropdown="!isMaxMd"
+            :show-quick-jumper="!isMaxMd"
+            :show-size-picker="!isMaxMd"
+          />
+        </div>
+      </div>
+    </NCard>
+    <NDropdown
+      placement="bottom-start"
+      trigger="manual"
+      v-bind="dropdownOptions"
+      :show="enableContextmenu && showDropdown"
+      />
+    </ScrollContainer>
+  </template>
+
+<script setup lang="tsx">
+import dayjs from 'dayjs'
+import {
+  NButton,
+  NDataTable,
+  NCard,
+  NForm,
+  NFormItem,
+  NInput,
+  NSelect,
+  NPopconfirm,
+  useMessage,
+  useModal,
+  NPagination,
+  NDropdown,
+  NTag,
+  NNumberAnimation,
+  NDatePicker,
+} from 'naive-ui'
+import { reactive, ref, useTemplateRef, nextTick } from 'vue'
+
+import { useRequest } from '@/api'
+import { ScrollContainer } from '@/components'
+import { useInjection, useComponentModifier, useResettableReactive } from '@/composables'
+import { mediaQueryInjectionKey } from '@/injection'
+
+import ActionModal from './components/ActionModal.vue'
+
+import type { DataTableColumns, PaginationProps, DropdownProps } from 'naive-ui'
+
+export interface formInfo {
+  keyword: string // 关键词搜索:匹配event_id或image_file_name字段,默认空字符串(不搜索)
+  usb_camera_index: number // 摄像头索引筛选:精确匹配指定摄像头,默认-1(不筛选)
+  alarm_type: number | null // 告警类型筛选:精确匹配类型(如UAV=0),默认-1(不筛选,所有类型)
+  status: number | null // 告警状态筛选:精确匹配状态,默认-1(不筛选,所有状态)
+  start_time: number | null // 事件开始时间戳:秒级,筛选事件时间≥该值,默认0(不限制开始时间)
+  end_time: number | null // 事件结束时间戳:秒级,筛选事件时间≤该值,默认0(不限制结束时间)
+  min_confidence: number | null // 最小置信度:筛选置信度≥该值,默认0.0(不限制置信度)
+  page: number // 页码:从1开始,默认1(第一页)
+  page_size: number // 每页条数:默认20条/页
+  sort_field: string // 排序字段:默认按事件时间戳(event_timestamp)排序
+  sort_asc: boolean // 是否升序:默认false(降序,最新事件在前)
+}
+
+export interface AlarmInfo {
+  alert_id: string
+  usb_camera_index: number
+  image_file_name: string
+  degree: number
+  event_timestamp: number
+  target_x: number
+  target_y: number
+  target_width: number
+  target_height: number
+  confidence_score: number
+  alarm_type: number
+  pan_angle: number
+  tilt_angle: number
+  event_id: string
+  status: number
+  create_time: number
+}
+
+enum AlarmStatus {
+  '未处理' = 0,
+  '处理中' = 1,
+  '已忽略' = 2,
+  '已处理' = 3,
+}
 
-<script setup lang="ts">
 defineOptions({ name: 'AlarmManagement' })
+
+const { API_ALERT_ESEARCH_GET } = useRequest()
+
+const { isMaxMd, isMaxLg } = useInjection(mediaQueryInjectionKey)
+
+const formRef = useTemplateRef<InstanceType<typeof NForm>>('formRef')
+
+const dataTableRef = useTemplateRef<InstanceType<typeof NDataTable>>('dataTableRef')
+
+const nDatePickerRef = useTemplateRef<InstanceType<typeof NDatePicker>>('nDatePickerRef')
+
+const message = useMessage()
+
+const modal = useModal()
+
+const { getPopconfirmModifier } = useComponentModifier()
+
+const [form, , resetForm] = useResettableReactive<Partial<formInfo>>({
+  keyword: '',
+  start_time: null,
+  end_time: null,
+  alarm_type: null,
+  status: null,
+  page: 1,
+  page_size: 10,
+  sort_asc: false,
+  sort_field: 'event_timestamp',
+})
+
+const nDatePickerV = ref<[number, number] | null>(null)
+
+// const rules: FormRules = {
+//   sex: {
+//     required: true,
+//     message: '请选择性别',
+//   },
+// }
+
+const statusOptions = [
+  { label: AlarmStatus[0], value: 0 },
+  { label: AlarmStatus[1], value: 1 },
+  { label: AlarmStatus[2], value: 2 },
+  { label: AlarmStatus[3], value: 3 },
+]
+
+const typeOptions: any = []
+
+const isRequestLoading = ref(false)
+const enableStriped = ref(false)
+const enableScrollX = ref(false)
+const enableSingleLine = ref(false)
+const enableContextmenu = ref(true)
+const showDropdown = ref(false)
+const contextmenuId = ref<number | string | null>(null)
+
+const dataList = ref<AlarmInfo[]>([])
+
+const checkedRowKeys = ref<Array<number | string>>([])
+
+const CellActions = (row: AlarmInfo) => (
+  <div class='flex gap-2'>
+    <NButton
+      secondary
+      type='primary'
+      size='small'
+      onClick={() => createOrEditData(row)}
+    >
+      编辑
+    </NButton>
+    <NPopconfirm
+      {...getPopconfirmModifier()}
+      positiveText='确定'
+      negativeText='取消'
+      onPositiveClick={() => {
+        message.success('点击了删除')
+      }}
+    >
+      {{
+        default: () => '确认删除吗?',
+        trigger: () => (
+          <NButton
+            secondary
+            type='error'
+            size='small'
+          >
+            删除
+          </NButton>
+        ),
+      }}
+    </NPopconfirm>
+  </div>
+)
+
+const columns: DataTableColumns<AlarmInfo> = [
+  {
+    key: 'number',
+    title: '编号',
+    width: 100,
+    align: 'center',
+  },
+  {
+    key: 'alarm_type',
+    width: 160,
+    align: 'center',
+    title: '告警类型',
+  },
+  {
+    key: 'status',
+    title: '告警状态',
+    align: 'center',
+    width: 120,
+    render: (row) => {
+      let type: 'default' | 'success' | 'error' | 'warning' | 'info' | 'primary' | undefined =
+        'default'
+      switch (row.status) {
+        case AlarmStatus['未处理']:
+          type = 'error'
+          break
+        case AlarmStatus['处理中']:
+          type = 'warning'
+          break
+        case AlarmStatus['已忽略']:
+          type = 'info'
+          break
+        case AlarmStatus['已处理']:
+          type = 'success'
+          break
+        default:
+          type = 'default'
+      }
+
+      return <NTag type={type}>{AlarmStatus[row.status]}</NTag>
+    },
+  },
+  {
+    key: 'event_timestamp_f',
+    title: '告警时间',
+    align: 'center',
+    defaultSortOrder: 'descend',
+    sorter: (row1, row2) => row1.event_timestamp - row2.event_timestamp,
+    customNextSortOrder: (order) => {
+      if (order === 'ascend') return 'descend'
+      return 'ascend'
+    },
+  },
+  {
+    key: 'image_file_name',
+    title: '告警图片',
+    align: 'center',
+  },
+  {
+    width: 140,
+    key: 'actions',
+    align: 'center',
+    title: '操作',
+    fixed: 'right',
+    render: (row) => <CellActions {...row} />,
+  },
+]
+
+function rowProps(row: AlarmInfo) {
+  return {
+    onContextmenu: (e: MouseEvent) => {
+      e.preventDefault()
+      showDropdown.value = false
+      nextTick().then(() => {
+        contextmenuId.value = row.event_id
+        showDropdown.value = true
+        dropdownOptions.x = e.clientX
+        dropdownOptions.y = e.clientY
+      })
+    },
+  }
+}
+
+const pagination = reactive<PaginationProps>({
+  page: 1,
+  pageSize: 10,
+  showSizePicker: true,
+  pageSizes: [10, 20, 50, 100],
+  itemCount: 0,
+  showQuickJumper: true,
+  showQuickJumpDropdown: true,
+  onUpdatePage: (page: number) => {
+    pagination.page = page
+    form.page = page
+    getDataList()
+  },
+  onUpdatePageSize: (pageSize: number) => {
+    pagination.pageSize = pageSize
+    pagination.page = 1
+    form.page_size = pageSize
+    getDataList()
+  },
+})
+
+const prevUserListTotal = ref(0)
+
+const paginationPrefix: PaginationProps['prefix'] = (info) => {
+  const { itemCount } = info
+  return (
+    itemCount && (
+      <div>
+        <span>总&nbsp;</span>
+        <NNumberAnimation
+          from={prevUserListTotal.value}
+          to={itemCount}
+          onFinish={() => {
+            prevUserListTotal.value = itemCount
+          }}
+        />
+        <span>&nbsp;条</span>
+      </div>
+    )
+  )
+}
+
+const dropdownOptions = reactive<DropdownProps>({
+  x: 0,
+  y: 0,
+  options: [
+    {
+      label: '编辑',
+      key: 'edit',
+    },
+    {
+      label: () => <span class='text-red-500'>删除</span>,
+      key: 'delete',
+    },
+  ],
+  onClickoutside: () => {
+    showDropdown.value = false
+  },
+  onSelect: () => {
+    message.info(`id: ${contextmenuId.value}`)
+    showDropdown.value = false
+  },
+})
+
+
+function createOrEditData(data?: AlarmInfo) {
+  const title = data ? '编辑数据' : '新增数据'
+
+  const handleSubmitClick = () => {
+    message.success('点击了提交')
+    m.destroy()
+  }
+
+  function handleUpdateClick() {
+    message.info('点击了更新')
+    m.destroy()
+  }
+
+  function handleCancelClick() {
+    m.destroy()
+  }
+
+  const m = modal.create({
+    autoFocus: false,
+    title,
+    preset: 'card',
+    draggable: true,
+    style: {
+      width: '500px',
+      ...(isMaxMd.value ? { marginInline: '16px' } : {}),
+    },
+    content: () => (
+      <ActionModal
+        data={{}}
+        onSubmit={handleSubmitClick}
+        onUpdate={handleUpdateClick}
+        onCancel={handleCancelClick}
+      />
+    ),
+  })
+}
+
+const handleQueryClick = () => {
+  formRef.value?.validate((errors) => {
+    if (!errors) {
+      getDataList()
+    }
+  })
+}
+
+async function getDataList() {
+  isRequestLoading.value = true
+  const res = (await API_ALERT_ESEARCH_GET(form).finally(() => {
+    isRequestLoading.value = false
+  })) as { items: AlarmInfo[]; page: number; page_size: number; total: number }
+  dataList.value = res.items.map((item, index) => ({
+    ...item,
+    event_timestamp_f: dayjs(item.event_timestamp).format('YYYY-MM-DD HH:mm:ss'),
+    number:
+      pagination.page &&
+      pagination.pageSize &&
+      (pagination.page - 1) * pagination.pageSize + index + 1,
+  }))
+  pagination.itemCount = res.total
+}
+
+getDataList()
 </script>

File diff suppressed because it is too large
+ 273 - 667
src/views/dashboard/index.vue


+ 371 - 0
src/views/statistical-analysis/components/activityPattern.vue

@@ -0,0 +1,371 @@
+<template>
+  <div class="flex h-full flex-col gap-4">
+    <div class="grid grid-cols-1 gap-4 overflow-hidden max-sm:gap-2 lg:grid-cols-12">
+      <div class="col-span-1 lg:col-span-12">
+        <NCard content-style="padding: 0;">
+          <div
+            class="rounded bg-naive-card px-5 pt-5 pb-3 transition-[background-color]"
+            style="height: 400px"
+          >
+            <div
+              ref="monthlyRadarChart"
+              class="h-full"
+            />
+          </div>
+        </NCard>
+      </div>
+    </div>
+    <div class="grid flex-1 grid-cols-1 gap-4 overflow-hidden max-sm:gap-2 lg:grid-cols-12">
+      <div class="col-span-1 lg:col-span-12 flex gap-4 h-full max-lg:flex-col">
+        <NCard v-for="item in countData" :key="item.label">
+          <div class="flex flex-col items-center justify-center gap-2 h-full">
+            <h3 class="font-semibold">{{ item.label }}</h3>
+            <span class="text-lg text-gray-500" :style="{ color: item.color }">{{ item.time }}</span>
+            <span class="text-sm text-gray-500">{{ item.description }}</span>
+          </div>
+        </NCard>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import chroma from 'chroma-js'
+import * as echarts from 'echarts'
+
+import { toRefsPreferencesStore } from '@/stores'
+const { isDark, themeColor } = toRefsPreferencesStore()
+import { NCard } from 'naive-ui'
+import { onMounted, onUnmounted, reactive, ref } from 'vue'
+
+import twc from '@/utils/tailwindColor'
+
+import type { ECharts } from 'echarts'
+
+
+const countData = reactive([
+  {
+    label: '高峰时段',
+    time: '6:00 - 8:00',
+    description: '活动较多',
+    color: '#FBBF24',
+  },
+  {
+    label: '低峰时段',
+    time: '10:00 - 17:00',
+    description: '活动较少',
+    color: '#34D399',
+  },
+  {
+    label: '平均持续时间',
+    time: '3.5分钟',
+    description: '单次出现',
+    color: '#9810fa',
+  }
+])
+
+
+const createTooltipConfig = (formatter?: any) => ({
+  trigger: 'axis',
+  backgroundColor: isDark.value ? twc.neutral[750] : '#fff',
+  borderWidth: 0,
+  padding: 8,
+  extraCssText: 'box-shadow: none;',
+  textStyle: {
+    color: isDark.value ? twc.neutral[400] : twc.neutral[600],
+    fontSize: 12,
+  },
+  axisPointer: {
+    type: 'none',
+  },
+  ...(formatter && { formatter }),
+})
+
+const monthlyRadarChart = ref<HTMLDivElement | null>(null)
+let monthlyRadarChartInstance: ECharts | null = null
+let monthlyRadarChartResizeHandler: (() => void) | null = null
+
+const chartDataManager = {
+  getCurrentDayData: () => {
+    return new Promise((resolve) => {
+      // 24小时活动分布
+      resolve([
+        {
+          label: '00:00',
+          value: [100],
+        },
+        {
+          label: '01:00',
+          value: [120],
+        },
+        {
+          label: '02:00',
+          value: [80],
+        },
+        {
+          label: '03:00',
+          value: [90],
+        },
+        {
+          label: '04:00',
+          value: [110],
+        },
+        {
+          label: '05:00',
+          value: [130],
+        },
+        {
+          label: '06:00',
+          value: [150],
+        },
+        {
+          label: '07:00',
+          value: [170],
+        },
+        {
+          label: '08:00',
+          value: [190],
+        },
+        {
+          label: '09:00',
+          value: [210],
+        },
+        {
+          label: '10:00',
+          value: [230],
+        },
+        {
+          label: '11:00',
+          value: [250],
+        },
+        {
+          label: '12:00',
+          value: [270],
+        },
+        {
+          label: '13:00',
+          value: [290],
+        },
+        {
+          label: '14:00',
+          value: [310],
+        },
+        {
+          label: '15:00',
+          value: [330],
+        },
+        {
+          label: '16:00',
+          value: [350],
+        },
+        {
+          label: '17:00',
+          value: [670],
+        },
+        {
+          label: '18:00',
+          value: [390],
+        },
+        {
+          label: '19:00',
+          value: [510],
+        },
+        {
+          label: '20:00',
+          value: [230],
+        },
+        {
+          label: '21:00',
+          value: [150],
+        },
+        {
+          label: '22:00',
+          value: [470],
+        },
+        {
+          label: '23:00',
+          value: [190],
+        },
+      ])
+    })
+  },
+}
+async function initMonthlyRadarChart() {
+  if (!monthlyRadarChart.value) return
+
+  // const now = new Date()
+  // const currentDay = now.getDay()
+
+  const currentDayData = (await chartDataManager.getCurrentDayData()) as Array<{
+    label: string
+    value: number
+  }>
+  const lineX = currentDayData.map((item) => item.label)
+  const lineY = currentDayData.map((item) => item.value)
+  const legend = [
+    {
+      name: '大型鸟类',
+      color: themeColor.value,
+      data: lineY.map((data: any) => data[0]),
+    },
+  ]
+
+  const chart = echarts.init(monthlyRadarChart.value)
+
+  const option = {
+    title: [
+      {
+        text: '24小时活动模式',
+        left: 0,
+        top: 0,
+        textStyle: {
+          fontSize: 18,
+          color: isDark.value ? twc.neutral[400] : twc.neutral[600],
+          fontWeight: 'normal',
+        },
+      },
+    ],
+    tooltip: createTooltipConfig((params: any) => {
+      const date = params[0].axisValue
+      let result = `<div>${date}数据</div>`
+      params.forEach((item: any) => {
+        const value = item.value.toLocaleString()
+        result += `
+        <div style="display: flex; align-items: center; margin-top: 4px;">
+          <span style="display:inline-block; margin-right:4px; width:10px; height:10px; border-radius:50%; background-color:${item.color};"></span>
+          <span style="margin-right: 10px">${item.seriesName}</span>
+          <span>${value}</span>
+        </div>
+      `
+      })
+      return result
+    }),
+    legend: {
+      show: false,
+      top: 6,
+      right: 22,
+      itemGap: 16,
+      icon: 'circle',
+      itemWidth: 12,
+      itemHeight: 12,
+      textStyle: {
+        color: isDark.value ? twc.neutral[400] : twc.neutral[600],
+        fontSize: 14,
+      },
+      data: legend.map((item) => item.name),
+    },
+    grid: {
+      left: 20,
+      right: 20,
+      top: 72,
+      bottom: 0,
+      containLabel: true,
+    },
+    xAxis: {
+      type: 'category',
+      boundaryGap: false,
+      data: lineX,
+      axisLine: { show: false },
+      axisTick: { show: false },
+      axisLabel: {
+        color: isDark.value ? twc.neutral[400] : twc.neutral[600],
+        fontSize: 12,
+      },
+      splitLine: {
+        show: true,
+        lineStyle: {
+          type: 'dashed',
+          color: isDark.value ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.08)',
+          width: 1,
+        },
+      },
+    },
+    yAxis: {
+      type: 'value',
+      min: 0,
+      axisLine: { show: false },
+      axisTick: { show: false },
+      axisLabel: {
+        color: isDark.value ? twc.neutral[400] : twc.neutral[600],
+        fontSize: 11,
+        formatter(value: number) {
+          if (value === 0) return '0'
+          return `${(value / 1000).toFixed(0)},000`
+        },
+      },
+      splitLine: {
+        show: true,
+        lineStyle: {
+          type: 'dashed',
+          color: isDark.value ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.08)',
+          width: 1,
+        },
+      },
+    },
+    series: legend.map((line, idx) => ({
+      name: line.name,
+      type: 'line',
+      data: line.data,
+      symbol: 'circle',
+      showSymbol: false,
+      smooth: true,
+      lineStyle: {
+        color: line.color,
+        width: idx < 2 ? 2 : 1,
+        type: idx < 2 ? 'solid' : 'dashed',
+      },
+      itemStyle: {
+        color: line.color,
+      },
+      emphasis: {
+        focus: 'series',
+        symbolSize: 6,
+        itemStyle: {
+          color: line.color,
+          borderColor: line.color,
+          borderWidth: 2,
+        },
+      },
+      areaStyle: {
+        color: {
+          type: 'linear',
+          x: 0,
+          y: 0,
+          x2: 0,
+          y2: 1,
+          colorStops: [
+            { offset: 0, color: chroma(line.color).alpha(0.12).hex() },
+            { offset: 1, color: chroma(line.color).alpha(0.02).hex() },
+          ],
+        },
+      },
+    })),
+
+    animationDuration: 1000,
+    animationEasing: 'cubicOut' as const,
+    animationDelay(idx: number) {
+      return idx * 100
+    },
+  }
+
+  chart.setOption(option)
+  monthlyRadarChartInstance = chart
+  monthlyRadarChartResizeHandler = () => chart.resize()
+  window.addEventListener('resize', monthlyRadarChartResizeHandler, { passive: true })
+}
+
+onMounted(() => {
+  initMonthlyRadarChart()
+})
+
+onUnmounted(() => {
+  if (monthlyRadarChartInstance) {
+    if (monthlyRadarChartResizeHandler) {
+      window.removeEventListener('resize', monthlyRadarChartResizeHandler)
+      monthlyRadarChartResizeHandler = null
+    }
+    monthlyRadarChartInstance.dispose()
+    monthlyRadarChartInstance = null
+  }
+})
+</script>

+ 58 - 0
src/views/statistical-analysis/components/analysisReport.vue

@@ -0,0 +1,58 @@
+<template>
+  <NCard
+    content-style="height: 100%"
+    class="h-full"
+  >
+    <template #header>
+      <div class="">
+        <span
+          class="font-[sans-serif] text-[16px] font-normal text-neutral-600 dark:text-neutral-400"
+          >智能分析报告</span
+        >
+      </div>
+    </template>
+
+    <div class="flex h-full flex-col gap-4">
+      <NAlert
+        v-for="(item, index) in msg"
+        :key="index"
+        :title="item.name"
+        :type="item.type"
+      >
+        {{ item.content }}
+      </NAlert>
+    </div>
+  </NCard>
+</template>
+
+<script setup lang="ts">
+import { NCard, NAlert } from 'naive-ui'
+import { ref } from 'vue'
+
+const msg = ref<
+  {
+    type: 'info' | 'success' | 'warning' | 'default' | 'error' | undefined
+    name: string
+    content: string
+  }[]
+>([
+  {
+    type: 'info',
+    name: '趋势分析',
+    content:
+      '本月鸟类活动相比上月增加12%,主要集中在早晨6-8点和傍晚18-20点。大型鸟类出现频率有所上升,建议加强相关时段的监控力度。',
+  },
+  {
+    type: 'warning',
+    name: '风险提示',
+    content:
+      '近期大型鸟类(翼展>1m)出现次数增加25%,建议在高峰时段增加驱鸟设备的使用频率,并制定相应的应急预案。',
+  },
+  {
+    type: 'success',
+    name: '优化建议',
+    content:
+      '系统识别准确率达到94%,建议继续完善小型鸟类的识别算法,特别是在光线较暗的时段。可考虑增加红外监控设备。',
+  },
+])
+</script>

+ 155 - 0
src/views/statistical-analysis/components/speciesDistribution.vue

@@ -0,0 +1,155 @@
+<template>
+  <div class="grid grid-cols-1 gap-4 overflow-hidden max-sm:gap-2 lg:grid-cols-12 h-full">
+    <div class="col-span-1 lg:col-span-7 h-full">
+      <NCard content-style="padding: 0;height: 100%" class="h-full">
+        <div
+          class="rounded bg-naive-card px-5 pt-5 pb-3 transition-[background-color]"
+          style="height: 100%"
+        >
+          <div
+            ref="monthlyRadarChart"
+            class="h-full"
+          />
+        </div>
+      </NCard>
+    </div>
+    <div class="col-span-1 lg:col-span-5">
+      <NCard class="h-full">
+        <template #header>
+          <div class="">
+            <span
+              class="font-[sans-serif] text-[16px] font-normal text-neutral-600 dark:text-neutral-400"
+              >种类统计详情</span
+            >
+          </div>
+        </template>
+        <div class="flex h-full flex-col justify-around gap-y-2">
+          <NCard
+            v-for="item in chartData"
+            :key="item.name"
+          >
+            <div class="flex justify-between">
+              <div class="flex items-center gap-2">
+                <span
+                  class="block size-4 rounded-4xl"
+                  :style="{ backgroundColor: item.color }"
+                ></span>
+                <span>{{ item.name }}</span>
+              </div>
+              {{
+                ((item.value / chartData.reduce((sum, cur) => sum + cur.value, 0)) * 100).toFixed(
+                  2,
+                )
+              }}%
+            </div>
+          </NCard>
+        </div>
+      </NCard>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import * as echarts from 'echarts'
+
+import { toRefsPreferencesStore } from '@/stores'
+const { isDark } = toRefsPreferencesStore()
+import { NCard } from 'naive-ui'
+import { onMounted, onUnmounted, onUpdated, ref } from 'vue'
+
+import twc from '@/utils/tailwindColor'
+
+import type { ECharts } from 'echarts'
+
+const monthlyRadarChart = ref<HTMLDivElement | null>(null)
+let monthlyRadarChartInstance: ECharts | null = null
+let monthlyRadarChartResizeHandler: (() => void) | null = null
+
+type ChartDataItem = { name: string; value: number; color: string }
+const chartData = ref<ChartDataItem[]>([])
+const chartDataManager = {
+  getCurrentDayData: () => {
+    return new Promise((resolve) => {
+      resolve([
+        { value: 175, name: '燕子', color: '#5470c6' },
+        { value: 30, name: '麻雀', color: '#91cc75' },
+        { value: 20, name: '鸽子', color: '#fac858' },
+        { value: 10, name: '乌鸦', color: '#ee6666' },
+        { value: 5, name: '未知', color: '#73c0de' },
+      ])
+    })
+  },
+}
+
+async function initMonthlyRadarChart() {
+  if (!monthlyRadarChart.value) return
+
+  // const now = new Date()
+  // const currentDay = now.getDay()
+
+  const currentDayData = await chartDataManager.getCurrentDayData()
+  chartData.value = currentDayData as ChartDataItem[]
+
+  const chart = echarts.init(monthlyRadarChart.value)
+
+  const option = {
+    color: (currentDayData as ChartDataItem[]).map((item) => item.color),
+    title: {
+      text: '鸟类种类分布',
+      textStyle: {
+        fontSize: 15,
+        color: isDark.value ? twc.neutral[400] : twc.neutral[600],
+        fontWeight: 'normal',
+      },
+    },
+    legend: {
+      left: 'right',
+      icon: 'circle',
+      textStyle: {
+        color: isDark.value ? twc.neutral[400] : twc.neutral[600],
+      },
+    },
+    series: [
+      {
+        name: 'Access From',
+        type: 'pie',
+        top: 30,
+        radius: '65%',
+        data: currentDayData,
+        label: {
+          show: true,
+          color: 'inherit',
+          formatter: '{b}: {d} %',
+        },
+
+        emphasis: {
+          itemStyle: {
+            shadowBlur: 10,
+            shadowOffsetX: 0,
+            shadowColor: 'rgba(0, 0, 0, 0.5)',
+          },
+        },
+      },
+    ],
+  }
+
+  chart.setOption(option)
+  monthlyRadarChartInstance = chart
+  monthlyRadarChartResizeHandler = () => chart.resize()
+  window.addEventListener('resize', monthlyRadarChartResizeHandler, { passive: true })
+}
+onMounted(() => {
+  initMonthlyRadarChart()
+})
+
+onUnmounted(() => {
+  if (monthlyRadarChartInstance) {
+    if (monthlyRadarChartResizeHandler) {
+      window.removeEventListener('resize', monthlyRadarChartResizeHandler)
+      monthlyRadarChartResizeHandler = null
+    }
+    monthlyRadarChartInstance.dispose()
+    monthlyRadarChartInstance = null
+  }
+})
+</script>

+ 756 - 0
src/views/statistical-analysis/components/trendAnalysis.vue

@@ -0,0 +1,756 @@
+<template>
+  <div class="flex flex-col gap-4 h-full">
+    <div class="grid grid-cols-1 gap-4 overflow-hidden max-sm:gap-2 lg:grid-cols-12 flex-1">
+      <div class="col-span-1 lg:col-span-5">
+        <NCard content-style="padding: 0;">
+          <div
+            class="rounded bg-naive-card px-5 pt-5 pb-3 transition-[background-color]"
+            style="height: 270px"
+          >
+            <div
+              ref="monthlyRadarChart"
+              class="h-full"
+            />
+          </div>
+        </NCard>
+      </div>
+      <div class="col-span-1 lg:col-span-7">
+        <NCard content-style="padding: 0;">
+          <div
+            class="rounded bg-naive-card p-5 transition-[background-color]"
+            style="height: 270px; position: relative"
+          >
+            <div
+              ref="highestRevenueChart"
+              class="h-full"
+            />
+          </div>
+        </NCard>
+      </div>
+    </div>
+    <div class="grid grid-cols-1 gap-4 overflow-hidden max-sm:gap-2 lg:grid-cols-12 flex-1">
+      <div class="col-span-1 lg:col-span-12">
+        <NCard content-style="padding: 0;">
+          <div
+            class="rounded bg-naive-card px-5 pt-5 pb-4.5 shadow-xs transition-[background-color]"
+            style="height: 270px"
+          >
+            <div
+              ref="revenueChart"
+              class="h-full"
+            />
+          </div>
+        </NCard>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import chroma from 'chroma-js'
+import * as echarts from 'echarts'
+
+import { toRefsPreferencesStore } from '@/stores'
+const { isDark, themeColor } = toRefsPreferencesStore()
+import { NCard } from 'naive-ui'
+import { onMounted, onUnmounted, ref } from 'vue'
+
+import twc from '@/utils/tailwindColor'
+
+import type { ECharts } from 'echarts'
+
+const revenueChart = ref<HTMLDivElement | null>(null)
+let revenueChartInstance: ECharts | null = null
+let revenueChartResizeHandler: (() => void) | null = null
+
+const monthlyRadarChart = ref<HTMLDivElement | null>(null)
+let monthlyRadarChartInstance: ECharts | null = null
+let monthlyRadarChartResizeHandler: (() => void) | null = null
+
+const highestRevenueChart = ref<HTMLDivElement | null>(null)
+let highestRevenueChartInstance: ECharts | null = null
+let highestRevenueChartResizeHandler: (() => void) | null = null
+let collapseResizeTimeout: ReturnType<typeof setTimeout> | null = null
+
+const createTooltipConfig = (formatter?: any) => ({
+  trigger: 'axis',
+  backgroundColor: isDark.value ? twc.neutral[750] : '#fff',
+  borderWidth: 0,
+  padding: 8,
+  extraCssText: 'box-shadow: none;',
+  textStyle: {
+    color: isDark.value ? twc.neutral[400] : twc.neutral[600],
+    fontSize: 12,
+  },
+  axisPointer: {
+    type: 'none',
+  },
+  ...(formatter && { formatter }),
+})
+
+const chartDataManager = {
+  get30DayLine: () => {
+    return new Promise((resolve) => {
+      // 30天活动分布
+      resolve([
+        {
+          label: '1月',
+          value: [111, 333, 444, 555],
+        },
+        {
+          label: '2月',
+          value: [222, 444, 555, 666],
+        },
+        {
+          label: '3月',
+          value: [333, 555, 666, 777],
+        },
+        {
+          label: '4月',
+          value: [444, 666, 777, 888],
+        },
+        {
+          label: '5月',
+          value: [555, 777, 888, 999],
+        },
+        {
+          label: '6月',
+          value: [555, 777, 888, 999],
+        },
+        {
+          label: '7月',
+          value: [555, 777, 888, 999],
+        },
+        {
+          label: '8月',
+          value: [666, 888, 999, 1000],
+        },
+        {
+          label: '9月',
+          value: [777, 999, 1000, 1100],
+        },
+        {
+          label: '10月',
+          value: [555, 777, 888, 999],
+        },
+        {
+          label: '11月',
+          value: [444, 666, 777, 888],
+        },
+        {
+          label: '12月',
+          value: [555, 777, 888, 999],
+        },
+      ])
+    })
+  },
+
+  getLowestWeekLine: () => {
+    return new Promise((resolve) => {
+      resolve([
+        {
+          label: '周一',
+          value: [1000, 5000, 8000, 1000],
+        },
+        {
+          label: '周二',
+          value: [4000, 6000, 9000, 1000],
+        },
+        {
+          label: '周三',
+          value: [3000, 7000, 10000, 5000],
+        },
+        {
+          label: '周四',
+          value: [5000, 8000, 12000, 6000],
+        },
+        {
+          label: '周五',
+          value: [4000, 6000, 9000, 1000],
+        },
+        {
+          label: '周六',
+          value: [6000, 8000, 10000, 2000],
+        },
+        {
+          label: '周日',
+          value: [7000, 9000, 600, 3000],
+        },
+      ])
+    })
+  },
+
+  getCurrentDayData: () => {
+    return new Promise((resolve) => {
+      resolve([
+        {
+          label: '1月',
+          value: [111],
+        },
+        {
+          label: '2月',
+          value: [222],
+        },
+        {
+          label: '3月',
+          value: [333],
+        },
+        {
+          label: '4月',
+          value: [444],
+        },
+        {
+          label: '5月',
+          value: [555],
+        },
+        {
+          label: '6月',
+          value: [555],
+        },
+        {
+          label: '7月',
+          value: [555],
+        },
+        {
+          label: '8月',
+          value: [666],
+        },
+        {
+          label: '9月',
+          value: [777],
+        },
+        {
+          label: '10月',
+          value: [555],
+        },
+        {
+          label: '11月',
+          value: [444],
+        },
+        {
+          label: '12月',
+          value: [555],
+        },
+      ])
+    })
+  },
+}
+
+async function initRevenueChart() {
+  if (!revenueChart.value) return
+
+  const lineData = (await chartDataManager.getLowestWeekLine()) as Array<{
+    label: string
+    value: number
+  }>
+  const lineX = lineData.map((item) => item.label)
+  const lineY = lineData.map((item) => item.value)
+  const legend = [
+    {
+      name: '大型鸟类',
+      color: '#EF4444', // 红色
+      data: lineY.map((data: any) => data[0]),
+    },
+    {
+      name: '中型鸟类',
+      color: '#F59E0B', // 黄色
+      data: lineY.map((data: any) => data[1]),
+    },
+    {
+      name: '小型鸟类',
+      color: '#3B82F6', // 蓝色
+      data: lineY.map((data: any) => data[2]),
+    },
+    {
+      name: '未知',
+      color: '#10B981', // 绿色
+      data: lineY.map((data: any) => data[3]),
+    },
+  ]
+
+  const chart = echarts.init(revenueChart.value)
+
+  const option = {
+    title: [
+      {
+        text: '近期活动趋势',
+        left: 0,
+        top: 0,
+        textStyle: {
+          fontSize: 18,
+          color: isDark.value ? twc.neutral[400] : twc.neutral[600],
+          fontWeight: 'normal',
+        },
+      },
+    ],
+    tooltip: createTooltipConfig((params: any) => {
+      const date = params[0].axisValue
+      let result = `<div>${date}数据</div>`
+      params.forEach((item: any) => {
+        const value = item.value.toLocaleString()
+        result += `
+        <div style="display: flex; align-items: center; margin-top: 4px;">
+          <span style="display:inline-block; margin-right:4px; width:10px; height:10px; border-radius:50%; background-color:${item.color};"></span>
+          <span style="margin-right: 10px">${item.seriesName}</span>
+          <span>${value}</span>
+        </div>
+      `
+      })
+      return result
+    }),
+    legend: {
+      top: 6,
+      right: 22,
+      itemGap: 16,
+      icon: 'circle',
+      itemWidth: 12,
+      itemHeight: 12,
+      textStyle: {
+        color: isDark.value ? twc.neutral[400] : twc.neutral[600],
+        fontSize: 14,
+      },
+      data: legend.map((item) => item.name),
+    },
+    grid: {
+      left: 20,
+      right: 20,
+      top: 72,
+      bottom: 0,
+      containLabel: true,
+    },
+    xAxis: {
+      type: 'category',
+      boundaryGap: false,
+      data: lineX,
+      axisLine: { show: false },
+      axisTick: { show: false },
+      axisLabel: {
+        color: isDark.value ? twc.neutral[400] : twc.neutral[600],
+        fontSize: 12,
+      },
+      splitLine: {
+        show: true,
+        lineStyle: {
+          type: 'dashed',
+          color: isDark.value ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.08)',
+          width: 1,
+        },
+      },
+    },
+    yAxis: {
+      type: 'value',
+      min: 0,
+      axisLine: { show: false },
+      axisTick: { show: false },
+      axisLabel: {
+        color: isDark.value ? twc.neutral[400] : twc.neutral[600],
+        fontSize: 11,
+        formatter(value: number) {
+          if (value === 0) return '0'
+          return `${(value / 1000).toFixed(0)},000`
+        },
+      },
+      splitLine: {
+        show: true,
+        lineStyle: {
+          type: 'dashed',
+          color: isDark.value ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.08)',
+          width: 1,
+        },
+      },
+    },
+    series: legend.map((line, idx) => ({
+      name: line.name,
+      type: 'line',
+      data: line.data,
+      symbol: 'circle',
+      showSymbol: false,
+      smooth: true,
+      lineStyle: {
+        color: line.color,
+        width: idx < 2 ? 2 : 1,
+        type: idx < 2 ? 'solid' : 'dashed',
+      },
+      itemStyle: {
+        color: line.color,
+      },
+      emphasis: {
+        focus: 'series',
+        symbolSize: 6,
+        itemStyle: {
+          color: line.color,
+          borderColor: line.color,
+          borderWidth: 2,
+        },
+      },
+      areaStyle: {
+        color: {
+          type: 'linear',
+          x: 0,
+          y: 0,
+          x2: 0,
+          y2: 1,
+          colorStops: [
+            { offset: 0, color: chroma(line.color).alpha(0.12).hex() },
+            { offset: 1, color: chroma(line.color).alpha(0.02).hex() },
+          ],
+        },
+      },
+    })),
+
+    animationDuration: 1000,
+    animationEasing: 'cubicOut' as const,
+    animationDelay(idx: number) {
+      return idx * 100
+    },
+  }
+
+  chart.setOption(option)
+
+  revenueChartInstance = chart
+  revenueChartResizeHandler = () => chart.resize()
+  window.addEventListener('resize', revenueChartResizeHandler, { passive: true })
+}
+
+async function initMonthlyRadarChart() {
+  if (!monthlyRadarChart.value) return
+
+  // const now = new Date()
+  // const currentDay = now.getDay()
+
+  const currentDayData = (await chartDataManager.getCurrentDayData()) as Array<{
+    label: string
+    value: number
+  }>
+  const lineX = currentDayData.map((item) => item.label)
+  const lineY = currentDayData.map((item) => item.value)
+  const legend = [
+    {
+      name: '大型鸟类',
+      color: themeColor.value, 
+      data: lineY.map((data: any) => data[0]),
+    },
+  ]
+
+  const chart = echarts.init(monthlyRadarChart.value)
+
+  const option = {
+    title: [
+      {
+        text: '月度检测趋势',
+        left: 0,
+        top: 0,
+        textStyle: {
+          fontSize: 18,
+          color: isDark.value ? twc.neutral[400] : twc.neutral[600],
+          fontWeight: 'normal',
+        },
+      },
+    ],
+    tooltip: createTooltipConfig((params: any) => {
+      const date = params[0].axisValue
+      let result = `<div>${date}数据</div>`
+      params.forEach((item: any) => {
+        const value = item.value.toLocaleString()
+        result += `
+        <div style="display: flex; align-items: center; margin-top: 4px;">
+          <span style="display:inline-block; margin-right:4px; width:10px; height:10px; border-radius:50%; background-color:${item.color};"></span>
+          <span style="margin-right: 10px">${item.seriesName}</span>
+          <span>${value}</span>
+        </div>
+      `
+      })
+      return result
+    }),
+    legend: {
+      show: false,
+      top: 6,
+      right: 22,
+      itemGap: 16,
+      icon: 'circle',
+      itemWidth: 12,
+      itemHeight: 12,
+      textStyle: {
+        color: isDark.value ? twc.neutral[400] : twc.neutral[600],
+        fontSize: 14,
+      },
+      data: legend.map((item) => item.name),
+    },
+    grid: {
+      left: 20,
+      right: 20,
+      top: 72,
+      bottom: 0,
+      containLabel: true,
+    },
+    xAxis: {
+      type: 'category',
+      boundaryGap: false,
+      data: lineX,
+      axisLine: { show: false },
+      axisTick: { show: false },
+      axisLabel: {
+        color: isDark.value ? twc.neutral[400] : twc.neutral[600],
+        fontSize: 12,
+      },
+      splitLine: {
+        show: true,
+        lineStyle: {
+          type: 'dashed',
+          color: isDark.value ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.08)',
+          width: 1,
+        },
+      },
+    },
+    yAxis: {
+      type: 'value',
+      min: 0,
+      axisLine: { show: false },
+      axisTick: { show: false },
+      axisLabel: {
+        color: isDark.value ? twc.neutral[400] : twc.neutral[600],
+        fontSize: 11,
+        formatter(value: number) {
+          if (value === 0) return '0'
+          return `${(value / 1000).toFixed(0)},000`
+        },
+      },
+      splitLine: {
+        show: true,
+        lineStyle: {
+          type: 'dashed',
+          color: isDark.value ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.08)',
+          width: 1,
+        },
+      },
+    },
+    series: legend.map((line, idx) => ({
+      name: line.name,
+      type: 'line',
+      data: line.data,
+      symbol: 'circle',
+      showSymbol: false,
+      smooth: true,
+      lineStyle: {
+        color: line.color,
+        width: idx < 2 ? 2 : 1,
+        type: idx < 2 ? 'solid' : 'dashed',
+      },
+      itemStyle: {
+        color: line.color,
+      },
+      emphasis: {
+        focus: 'series',
+        symbolSize: 6,
+        itemStyle: {
+          color: line.color,
+          borderColor: line.color,
+          borderWidth: 2,
+        },
+      },
+      areaStyle: {
+        color: {
+          type: 'linear',
+          x: 0,
+          y: 0,
+          x2: 0,
+          y2: 1,
+          colorStops: [
+            { offset: 0, color: chroma(line.color).alpha(0.12).hex() },
+            { offset: 1, color: chroma(line.color).alpha(0.02).hex() },
+          ],
+        },
+      },
+    })),
+
+    animationDuration: 1000,
+    animationEasing: 'cubicOut' as const,
+    animationDelay(idx: number) {
+      return idx * 100
+    },
+  }
+
+  chart.setOption(option)
+  monthlyRadarChartInstance = chart
+  monthlyRadarChartResizeHandler = () => chart.resize()
+  window.addEventListener('resize', monthlyRadarChartResizeHandler, { passive: true })
+}
+
+async function initHighestRevenueChart() {
+  if (!highestRevenueChart.value) return
+
+  const thirtyDayLine = (await chartDataManager.get30DayLine()) as Array<{
+    label: string
+    value: number
+  }>
+  const xAxisData = thirtyDayLine.map((item) => item.label)
+  const seriesData = thirtyDayLine.map((item) => item.value)
+
+  const legend = [
+    {
+      name: '大型鸟类',
+      color: '#EF4444', // 红色
+      data: seriesData.map((data: any) => data[0]),
+    },
+    {
+      name: '中型鸟类',
+      color: '#F59E0B', // 黄色
+      data: seriesData.map((data: any) => data[1]),
+    },
+    {
+      name: '小型鸟类',
+      color: '#3B82F6', // 蓝色
+      data: seriesData.map((data: any) => data[2]),
+    },
+    {
+      name: '未知',
+      color: '#10B981', // 绿色
+      data: seriesData.map((data: any) => data[3]),
+    },
+  ]
+
+  const chart = echarts.init(highestRevenueChart.value)
+
+  const option = {
+    title: {
+      text: '按体型分类趋势',
+      textStyle: {
+        fontSize: 15,
+        color: isDark.value ? twc.neutral[400] : twc.neutral[600],
+        fontWeight: 'normal',
+      },
+    },
+    tooltip: {
+      trigger: 'axis',
+      axisPointer: { type: 'none' },
+      backgroundColor: isDark.value ? twc.neutral[750] : '#fff',
+      borderWidth: 0,
+      padding: 8,
+      extraCssText: 'box-shadow: none;',
+      textStyle: {
+        color: isDark.value ? twc.neutral[400] : twc.neutral[600],
+        fontSize: 12,
+      },
+      formatter(params: any) {
+        const date = params[0].axisValue
+        let result = `<div>${date}</div>`
+        params.forEach((item: any) => {
+          result += `
+          <div style="display: flex; align-items: center; margin-top: 4px;">
+              <span style="display:inline-block; margin-right:4px; width:10px; height:10px; border-radius:50%; background-color:${item.color};"></span>
+              <span style="margin-right: 10px">${item.seriesName}</span>
+              <span>${item.value.toLocaleString()}</span>
+          </div>
+              `
+        })
+        return result
+      },
+    },
+    grid: {
+      left: '3%',
+      right: '4%',
+      bottom: '3%',
+      containLabel: true,
+    },
+    legend: {
+      top: 6,
+      right: 22,
+      itemGap: 16,
+      icon: 'circle',
+      itemWidth: 12,
+      itemHeight: 12,
+      textStyle: {
+        color: isDark.value ? twc.neutral[400] : twc.neutral[600],
+        fontSize: 14,
+      },
+      data: legend.map((item) => item.name),
+    },
+    xAxis: [
+      {
+        type: 'category',
+        data: xAxisData,
+        axisTick: {
+          alignWithLabel: true,
+        },
+        splitLine: {
+          show: true,
+          lineStyle: {
+            type: 'dashed',
+            color: isDark.value ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.08)',
+            width: 1,
+          },
+        },
+      },
+    ],
+    yAxis: [
+      {
+        type: 'value',
+        splitLine: {
+          show: true,
+          lineStyle: {
+            type: 'dashed',
+            color: isDark.value ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.08)',
+            width: 1,
+          },
+        },
+      },
+    ],
+    series: legend.map((item) => ({
+      emphasis: {
+        focus: 'series',
+      },
+      stack: '总量',
+      name: item.name,
+      type: 'bar',
+      barWidth: '60%',
+      color: item.color,
+      data: item.data,
+    })),
+  }
+
+  chart.setOption(option)
+  highestRevenueChartInstance = chart
+  highestRevenueChartResizeHandler = () => chart.resize()
+  window.addEventListener('resize', highestRevenueChartResizeHandler, { passive: true })
+}
+
+onMounted(() => {
+  initRevenueChart()
+  initMonthlyRadarChart()
+  initHighestRevenueChart()
+})
+
+onUnmounted(() => {
+  if (revenueChartInstance) {
+    if (revenueChartResizeHandler) {
+      window.removeEventListener('resize', revenueChartResizeHandler)
+      revenueChartResizeHandler = null
+    }
+    revenueChartInstance.dispose()
+    revenueChartInstance = null
+  }
+
+  if (monthlyRadarChartInstance) {
+    if (monthlyRadarChartResizeHandler) {
+      window.removeEventListener('resize', monthlyRadarChartResizeHandler)
+      monthlyRadarChartResizeHandler = null
+    }
+    monthlyRadarChartInstance.dispose()
+    monthlyRadarChartInstance = null
+  }
+
+  if (highestRevenueChartInstance) {
+    if (highestRevenueChartResizeHandler) {
+      window.removeEventListener('resize', highestRevenueChartResizeHandler)
+      highestRevenueChartResizeHandler = null
+    }
+    highestRevenueChartInstance.dispose()
+    highestRevenueChartInstance = null
+  }
+
+  if (collapseResizeTimeout !== null) {
+    clearTimeout(collapseResizeTimeout)
+    collapseResizeTimeout = null
+  }
+})
+</script>

+ 133 - 3
src/views/statistical-analysis/index.vue

@@ -1,14 +1,144 @@
 <template>
   <ScrollContainer
-    wrapper-class=""
-    :scrollable="false"
+    wrapper-class="flex flex-col"
+    :scrollable="isMaxLg"
   >
-    统计分析
+    <div class="grid grid-cols-1 gap-4 max-sm:gap-2 md:grid-cols-2 lg:grid-cols-4">
+      <NCard
+        v-for="item in topData"
+        :key="item.label"
+      >
+        <div class="flex items-center justify-between">
+          <div>
+            <div class="mb-1.5 flex items-center text-2xl text-neutral-700 dark:text-neutral-400">
+              <NNumberAnimation
+                :to="item.value"
+                show-separator
+              />
+              <span class="text-lg text-neutral-500 dark:text-neutral-400">{{ item.suffix }}</span>
+            </div>
+            <span class="text-neutral-500 dark:text-neutral-400">{{ item.label }}</span>
+          </div>
+          <div>
+            <template v-if="item.trend === 'up'">
+              <div class="text-green-500">
+                <span class="mr-1 iconify align-middle ph--trend-up-bold"></span>
+                <NNumberAnimation
+                  :to="item.trendValue"
+                  show-separator
+                />
+                <span>%</span>
+              </div>
+            </template>
+            <template v-else>
+              <div class="flex items-center text-red-500">
+                <span class="mr-1 iconify align-middle ph--trend-down-bold"></span>
+                <NNumberAnimation
+                  :to="item.trendValue"
+                  show-separator
+                />
+                <span>%</span>
+              </div>
+            </template>
+          </div>
+        </div>
+      </NCard>
+    </div>
+    <NCard class="mt-4 h-full">
+      <div class="flex items-center justify-between h-full">
+        <NTabs
+          type="segment"
+          size="small"
+          animated
+          class="h-full"
+          pane-wrapper-class="flex-1"
+          pane-class="h-full"
+        >
+          <template #suffix
+            ><NSelect
+              v-model:value="dateSelect"
+              style="min-width: 120px"
+              :options="dateOptions"
+          /></template>
+          <NTabPane
+            v-for="(item, i) in tabsData"
+            :name="item.label"
+            :tab="item.label"
+            :key="i"
+          >
+            <component :is="item.component"></component>
+          </NTabPane>
+        </NTabs>
+      </div>
+    </NCard>
   </ScrollContainer>
 </template>
 
 <script setup lang="ts">
+import { NCard, NSelect, NTabs, NTabPane, NNumberAnimation } from 'naive-ui'
+import { defineAsyncComponent, reactive, ref } from 'vue'
+
+import { useInjection } from '@/composables'
+import { mediaQueryInjectionKey } from '@/injection'
+const { isMaxLg } = useInjection(mediaQueryInjectionKey)
+
+
 import { ScrollContainer } from '@/components'
 
 defineOptions({ name: 'StatisticalAnalysis' })
+
+const topData = reactive([
+  { label: '总检测数', value: 1000, trend: 'up', trendValue: 15 },
+  { label: '日均检测数', value: 500, trend: 'down', trendValue: -5 },
+  { label: '平均翼展', value: 0.72, suffix: 'm', trend: 'up', trendValue: 2 },
+  { label: '识别准确率', value: 94, suffix: '%', trend: 'up', trendValue: 1 },
+])
+
+const dateSelect = ref<string>('30')
+const dateOptions = reactive([
+  { label: '近7天', value: '7' },
+  { label: '近30天', value: '30' },
+  { label: '近三月', value: '90' },
+  { label: '近半年', value: '180' },
+  { label: '近一年', value: '365' },
+])
+
+const tabsData = [
+  {
+    label: '趋势分析',
+    component: defineAsyncComponent(
+      () => import('@/views/statistical-analysis/components/trendAnalysis.vue'),
+    ),
+  },
+  {
+    label: '种类分布',
+    component: defineAsyncComponent(
+      () => import('@/views/statistical-analysis/components/speciesDistribution.vue'),
+    ),
+  },
+  {
+    label: '活动规律',
+    component: defineAsyncComponent(
+      () => import('@/views/statistical-analysis/components/activityPattern.vue'),
+    ),
+  },
+  {
+    label: '分析报告',
+    component: defineAsyncComponent(
+      () => import('@/views/statistical-analysis/components/analysisReport.vue'),
+    ),
+  },
+]
 </script>
+<style scoped>
+@reference "tailwindcss";
+
+@media (max-width: 640px) {
+  :deep(.n-tabs-nav--segment-type) {
+    @apply flex-wrap;
+  }
+  :deep(.n-tabs-nav__suffix) {
+    @apply my-1 w-full !pl-0;
+  }
+}
+</style>

Some files were not shown because too many files changed in this diff