Browse Source

feat(assistant): 新增AI助手功能模块,包括质控分析报告、绩效分析报告及数据可视化看板

新增AI助手主界面、质控分析报告和绩效分析报告组件,实现数据查询、图表展示及导出功能。包含勤务组织、质控活动和风险隐患三大模块,支持年/季/月时间维度查询。添加ECharts图表展示及Word导出功能,优化响应式布局和交互体验。
huoyi 1 month ago
parent
commit
317d4067b4
37 changed files with 6081 additions and 15 deletions
  1. 6 1
      package.json
  2. 109 0
      src/api/assistant/assistant.js
  3. 1 0
      src/assets/icons/svg/robot.svg
  4. BIN
      src/assets/images/btnActive.png
  5. BIN
      src/assets/images/btnBg.png
  6. BIN
      src/assets/images/btnIcon.png
  7. BIN
      src/assets/images/cardBg.png
  8. BIN
      src/assets/images/down.png
  9. BIN
      src/assets/images/itemBg.png
  10. BIN
      src/assets/images/mbtj/enfj.png
  11. BIN
      src/assets/images/mbtj/enfp.png
  12. BIN
      src/assets/images/mbtj/entj.png
  13. BIN
      src/assets/images/mbtj/entp.png
  14. BIN
      src/assets/images/mbtj/esfj.png
  15. BIN
      src/assets/images/mbtj/esfp.png
  16. BIN
      src/assets/images/mbtj/estj.png
  17. BIN
      src/assets/images/mbtj/estp.png
  18. BIN
      src/assets/images/mbtj/infj.png
  19. BIN
      src/assets/images/mbtj/infp.png
  20. BIN
      src/assets/images/mbtj/intj.png
  21. BIN
      src/assets/images/mbtj/intp.png
  22. BIN
      src/assets/images/mbtj/isfj.png
  23. BIN
      src/assets/images/mbtj/isfp.png
  24. BIN
      src/assets/images/mbtj/istj.png
  25. BIN
      src/assets/images/mbtj/istp.png
  26. 61 0
      src/assets/images/mbtj/load.js
  27. BIN
      src/assets/images/rowBg.png
  28. BIN
      src/assets/images/sex1.png
  29. BIN
      src/assets/images/sex2.png
  30. BIN
      src/assets/images/up.png
  31. 33 14
      src/hooks/chart.js
  32. 767 0
      src/views/assistant/components/dataBoard.vue
  33. 1026 0
      src/views/assistant/components/dutyOrganization.vue
  34. 1495 0
      src/views/assistant/components/performanceAnalysis.vue
  35. 1044 0
      src/views/assistant/components/qualityControl.vue
  36. 996 0
      src/views/assistant/components/riskHazard.vue
  37. 543 0
      src/views/assistant/index.vue

+ 6 - 1
package.json

@@ -19,6 +19,7 @@
19 19
     "url": "http://git.sundot.cn/airport/airport-web.git"
20 20
   },
21 21
   "dependencies": {
22
+    "@babel/runtime": "^7.28.6",
22 23
     "@codemirror/basic-setup": "^0.20.0",
23 24
     "@codemirror/lang-sql": "^6.9.0",
24 25
     "@codemirror/language": "^6.11.1",
@@ -30,13 +31,17 @@
30 31
     "axios": "1.9.0",
31 32
     "clipboard": "2.0.11",
32 33
     "codemirror-editor-vue3": "^2.8.0",
34
+    "docx": "^9.5.1",
33 35
     "echarts": "5.6.0",
34 36
     "element-plus": "2.9.9",
35
-    "file-saver": "2.0.5",
37
+    "file-saver": "^2.0.5",
36 38
     "fuse.js": "6.6.2",
39
+    "html2canvas": "^1.4.1",
37 40
     "js-beautify": "1.14.11",
38 41
     "js-cookie": "3.0.5",
39 42
     "jsencrypt": "3.3.2",
43
+    "jspdf": "^4.1.0",
44
+    "jspdf-autotable": "^5.0.7",
40 45
     "moment": "^2.30.1",
41 46
     "nprogress": "0.2.0",
42 47
     "pinia": "3.0.2",

+ 109 - 0
src/api/assistant/assistant.js

@@ -0,0 +1,109 @@
1
+import request from '@/utils/request'
2
+// 质控活动
3
+export function getAnalysisReport(data) {
4
+  return request({
5
+    url: '/system/analysisReport/check',
6
+    method: 'post',
7
+    data: data
8
+  })
9
+}
10
+//出勤人次分析
11
+export function getCalculate(data) {
12
+  return request({
13
+    url: '/quality/attendance/calculate',
14
+    method: 'post',
15
+    data: data
16
+  })
17
+}
18
+//出勤人次趋势数据
19
+export function getCalculateTrendData(data) {
20
+  return request({
21
+    url: '/quality/attendance/trend-data',
22
+    method: 'post',
23
+    data: data
24
+  })
25
+}
26
+//获取资质等级分布饼状图数据
27
+export function getQualificationPieChart(query) {
28
+  return request({
29
+    url: '/statistics/qualification/pie-chart',
30
+    method: 'get',
31
+    params: query
32
+  })
33
+}
34
+//获取资质等级分布柱状图数据
35
+export function getQualificationBarChart(query) {
36
+  return request({
37
+    url: '/statistics/qualification/bar-chart',
38
+    method: 'get',
39
+    params: query
40
+  })
41
+}
42
+
43
+
44
+
45
+//获取物品分类统计
46
+export function getCategoryStats(query) {
47
+  return request({
48
+    url: '/quality/item-category-stats/category-stats',
49
+    method: 'get',
50
+    params: query
51
+  })
52
+}
53
+//获取查获时段趋势图
54
+export function getSeizureTimeTrend(query) {
55
+  return request({
56
+    url: '/quality/item-category-stats/seizure-time-trend',
57
+    method: 'get',
58
+    params: query
59
+  })
60
+}
61
+//获取隐匿夹带部位分布统计
62
+export function getConcealmentPositionStats(query) {
63
+  return request({
64
+    url: '/quality/item-category-stats/concealment-position-stats',
65
+    method: 'get',
66
+    params: query
67
+  })
68
+}
69
+//获取岗位分类统计
70
+export function getPostCategoryStats(query) {
71
+  return request({
72
+    url: '/quality/item-category-stats/post-category-stats',
73
+    method: 'get',
74
+    params: query
75
+  })
76
+}
77
+//获取通道排名统计
78
+export function getChannelRankingStats(query) {
79
+  return request({
80
+    url: '/quality/item-category-stats/channel-ranking-stats',
81
+    method: 'get',
82
+    params: query
83
+  })
84
+}
85
+//获取各科室查获排名
86
+export function getDepartmentRanking(query) {
87
+  return request({
88
+    url: '/quality/item-category-stats/brigade-ranking',
89
+    method: 'get',
90
+    params: query
91
+  })
92
+}
93
+
94
+//基于时间维度的绩效统计查询趋势图
95
+export function getCalculateByTime(data) {
96
+  return request({
97
+    url: '/performance/dimension/calculate-by-time',
98
+    method: 'post',
99
+    data: data
100
+  })
101
+}
102
+//基于时间维度的绩效统计查询列表
103
+export function getCalculateByTimeList(data) {
104
+  return request({
105
+    url: '/performance/dimension/calculate-by-time-list',
106
+    method: 'post',
107
+    data: data
108
+  })
109
+}

File diff suppressed because it is too large
+ 1 - 0
src/assets/icons/svg/robot.svg


BIN
src/assets/images/btnActive.png


BIN
src/assets/images/btnBg.png


BIN
src/assets/images/btnIcon.png


BIN
src/assets/images/cardBg.png


BIN
src/assets/images/down.png


BIN
src/assets/images/itemBg.png


BIN
src/assets/images/mbtj/enfj.png


BIN
src/assets/images/mbtj/enfp.png


BIN
src/assets/images/mbtj/entj.png


BIN
src/assets/images/mbtj/entp.png


BIN
src/assets/images/mbtj/esfj.png


BIN
src/assets/images/mbtj/esfp.png


BIN
src/assets/images/mbtj/estj.png


BIN
src/assets/images/mbtj/estp.png


BIN
src/assets/images/mbtj/infj.png


BIN
src/assets/images/mbtj/infp.png


BIN
src/assets/images/mbtj/intj.png


BIN
src/assets/images/mbtj/intp.png


BIN
src/assets/images/mbtj/isfj.png


BIN
src/assets/images/mbtj/isfp.png


BIN
src/assets/images/mbtj/istj.png


BIN
src/assets/images/mbtj/istp.png


+ 61 - 0
src/assets/images/mbtj/load.js

@@ -0,0 +1,61 @@
1
+
2
+import enfj from './enfj.png'
3
+import enfp from './enfp.png'
4
+import entj from './entj.png'
5
+import entp from './entp.png'
6
+import esfj from './esfj.png'
7
+import esfp from './enfp.png'
8
+import estj from './entj.png'
9
+import estp from './enfp.png'
10
+import infj from './infj.png'
11
+import infp from './infp.png'
12
+import intj from './intj.png'
13
+import intp from './intp.png'
14
+import isfj from './isfj.png'
15
+import isfp from './infp.png'
16
+import istj from './intj.png'
17
+import istp from './infp.png'
18
+
19
+const mbtjImgMap = {
20
+  enfj,
21
+  enfp,
22
+  entj,
23
+  entp,
24
+  esfj,
25
+  esfp,
26
+  estj,
27
+  estp,
28
+  infj,
29
+  infp,
30
+  intj,
31
+  intp,
32
+  isfj,
33
+  isfp,
34
+  istj,
35
+  istp
36
+}
37
+const getMbtjImgPath = (code) => {
38
+  const curImgPath = mbtjImgMap[String(code).substring(0, 4).toLocaleLowerCase()]
39
+  if (curImgPath) return curImgPath
40
+  return ''
41
+}
42
+
43
+export {
44
+  enfj,
45
+  enfp,
46
+  entj,
47
+  entp,
48
+  esfj,
49
+  esfp,
50
+  estj,
51
+  estp,
52
+  infj,
53
+  infp,
54
+  intj,
55
+  intp,
56
+  isfj,
57
+  isfp,
58
+  istj,
59
+  istp,
60
+  getMbtjImgPath
61
+}

BIN
src/assets/images/rowBg.png


BIN
src/assets/images/sex1.png


BIN
src/assets/images/sex2.png


BIN
src/assets/images/up.png


+ 33 - 14
src/hooks/chart.js

@@ -1,24 +1,46 @@
1
-import { onMounted, onUnmounted, ref } from 'vue';
1
+import { ref, shallowRef } from 'vue';
2 2
 import { useResizeObserver } from '@vueuse/core';
3 3
 import * as echarts from 'echarts';
4 4
 
5 5
 export function useEcharts(domRef) {
6
-  const chartInstance = ref(null);
6
+  const chartInstance = shallowRef(null);
7
+  const initialized = ref(false);
7 8
 
8 9
   // 初始化图表
9 10
   const initChart = () => {
10
-    if (!domRef.value) return;
11
-    chartInstance.value = echarts.init(domRef.value, 'macarons');
11
+    if (!domRef.value || initialized.value) return;
12
+    try {
13
+      chartInstance.value = echarts.init(domRef.value, 'macarons');
14
+      initialized.value = true;
15
+    } catch (error) {
16
+      console.error('ECharts初始化失败:', error);
17
+    }
12 18
   };
13 19
 
14 20
   const setOption = (option) => {
21
+    if (!chartInstance.value && !initialized.value) {
22
+      initChart();
23
+    }
15 24
     if (!chartInstance.value) return;
16
-    chartInstance.value.setOption(option);
25
+    
26
+    try {
27
+      chartInstance.value.setOption(option);
28
+      // 仅在需要时调整大小,避免重复调用
29
+      if (domRef.value && domRef.value.offsetHeight > 0) {
30
+        chartInstance.value.resize();
31
+      }
32
+    } catch (error) {
33
+      console.error('ECharts设置选项失败:', error);
34
+    }
17 35
   };
18 36
 
19 37
   const handleResize = () => {
20 38
     if (!chartInstance.value) return;
21
-    chartInstance.value.resize();
39
+    try {
40
+      chartInstance.value.resize();
41
+    } catch (err) {
42
+      console.log(err);
43
+    }
22 44
   };
23 45
 
24 46
   // 使用vueuse的resizeObserver监听容器大小变化
@@ -26,21 +48,18 @@ export function useEcharts(domRef) {
26 48
     handleResize();
27 49
   });
28 50
 
29
-  // 组件挂载时初始化
30
-  onMounted(() => {
31
-    initChart();
32
-  });
33
-
34
-  // 组件卸载时销毁
35
-  onUnmounted(() => {
51
+  const dispose = () => {
36 52
     if (chartInstance.value) {
37 53
       chartInstance.value.dispose();
38 54
       chartInstance.value = null;
55
+      initialized.value = false;
39 56
     }
40
-  });
57
+  };
41 58
 
42 59
   return {
43 60
     chartInstance,
44 61
     setOption,
62
+    dispose,
63
+    initChart
45 64
   };
46 65
 }

+ 767 - 0
src/views/assistant/components/dataBoard.vue

@@ -0,0 +1,767 @@
1
+<template>
2
+  <div class="data-board-container">
3
+    <!-- 头部导航 -->
4
+    <div class="header">
5
+      <div class="header-left">
6
+        <el-button link class="back-btn" @click="handleBack">
7
+          <el-icon>
8
+            <ArrowLeft />
9
+          </el-icon>
10
+          <span>返回AI助手</span>
11
+        </el-button>
12
+      </div>
13
+      <div class="header-right">
14
+        <el-button class="export-btn" @click="handleExport">
15
+          <el-icon>
16
+            <Download />
17
+          </el-icon>
18
+          <span>导出文档</span>
19
+        </el-button>
20
+      </div>
21
+    </div>
22
+
23
+    <!-- 查询表单区域 -->
24
+    <div class="query-form">
25
+      <div class="form-container">
26
+        <el-form :model="queryForm" label-width="80px">
27
+          <el-form-item label="查询时间">
28
+            <div class="form-row">
29
+              <div class="form-content">
30
+                <el-select v-model="queryForm.dateRangeQueryType" placeholder="请选择时间类型"
31
+                  @change="handledateRangeQueryTypeChange" class="select-field">
32
+                  <el-option label="年" value="YEAR" />
33
+                  <el-option label="季度" value="QUARTER" />
34
+                  <el-option label="月" value="MONTH" />
35
+                </el-select>
36
+                <el-select v-model="queryForm.year" placeholder="请选择年份" v-if="queryForm.dateRangeQueryType"
37
+                  class="select-field">
38
+                  <el-option v-for="year in yearOptions" :key="year" :label="year + '年'" :value="year" />
39
+                </el-select>
40
+                <el-select v-model="queryForm.quarter" placeholder="请选择季度"
41
+                  v-if="queryForm.dateRangeQueryType === 'QUARTER'" class="select-field">
42
+                  <el-option label="第一季度" value="1" />
43
+                  <el-option label="第二季度" value="2" />
44
+                  <el-option label="第三季度" value="3" />
45
+                  <el-option label="第四季度" value="4" />
46
+                </el-select>
47
+                <el-select v-model="queryForm.month" placeholder="请选择月份" v-if="queryForm.dateRangeQueryType === 'MONTH'"
48
+                  class="select-field">
49
+                  <el-option v-for="month in monthOptions" :key="month" :label="month + '月'" :value="month" />
50
+                </el-select>
51
+              </div>
52
+              <div class="form-actions">
53
+                <el-button type="primary" @click="handleQuery">查询</el-button>
54
+                <el-button @click="handleReset">重置</el-button>
55
+              </div>
56
+            </div>
57
+          </el-form-item>
58
+        </el-form>
59
+      </div>
60
+    </div>
61
+
62
+    <!-- 勤务组织组件 -->
63
+    <DutyOrganization :query-form="currentQueryParams" />
64
+
65
+    <!-- 质控活动组件 -->
66
+    <QualityControl :query-form="currentQueryParams" />
67
+
68
+    <!-- 风险隐患组件 -->
69
+    <RiskHazard :query-form="currentQueryParams" />
70
+  </div>
71
+</template>
72
+
73
+<script setup>
74
+import { ref, computed, onMounted } from 'vue'
75
+import { ArrowLeft, Download } from '@element-plus/icons-vue'
76
+import { ElMessage, ElLoading } from 'element-plus'
77
+import html2canvas from 'html2canvas'
78
+import jsPDF from 'jspdf'
79
+import { Document, Packer, Paragraph, ImageRun, HeadingLevel, AlignmentType } from 'docx'
80
+import { saveAs } from 'file-saver'
81
+import DutyOrganization from './dutyOrganization.vue'
82
+import QualityControl from './qualityControl.vue'
83
+import RiskHazard from './riskHazard.vue'
84
+
85
+// 定义emit事件
86
+const emit = defineEmits(['back'])
87
+
88
+// 生成年份选项(近10年)
89
+const currentYear = new Date().getFullYear()
90
+
91
+// 查询表单数据
92
+const queryForm = ref({
93
+  dateRangeQueryType: 'YEAR',
94
+  year: currentYear,
95
+  quarter: '',
96
+  month: ''
97
+})
98
+
99
+// 当前查询参数(只有点击查询按钮时才更新)
100
+const currentQueryParams = ref({
101
+  dateRangeQueryType: 'YEAR',
102
+  year: currentYear,
103
+  quarter: '',
104
+  month: ''
105
+})
106
+const yearOptions = computed(() => {
107
+  const years = []
108
+  for (let i = currentYear; i >= currentYear - 9; i--) {
109
+    years.push(i)
110
+  }
111
+  return years
112
+})
113
+
114
+// 月份选项
115
+const monthOptions = ref([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12])
116
+
117
+// 时间类型变化处理
118
+const handledateRangeQueryTypeChange = () => {
119
+  // 根据选择的时间类型清空相关字段
120
+  if (queryForm.value.dateRangeQueryType === 'QUARTER') {
121
+    // 选择季度时清空月份
122
+    queryForm.value.month = ''
123
+  } else if (queryForm.value.dateRangeQueryType === 'MONTH') {
124
+    // 选择月份时清空季度
125
+    queryForm.value.quarter = ''
126
+  } else if (queryForm.value.dateRangeQueryType === 'YEAR') {
127
+    // 选择年时清空季度和月份
128
+    queryForm.value.quarter = ''
129
+    queryForm.value.month = ''
130
+  }
131
+}
132
+
133
+// 返回按钮点击事件
134
+const handleBack = () => {
135
+  emit('back')
136
+}
137
+
138
+// 查询处理
139
+const handleQuery = () => {
140
+  console.log('查询参数:', queryForm.value)
141
+  // 将查询表单值复制到当前查询参数
142
+  currentQueryParams.value = { ...queryForm.value }
143
+  console.log('当前查询参数已更新:', currentQueryParams.value)
144
+}
145
+
146
+// 重置表单
147
+const handleReset = () => {
148
+  queryForm.value = {
149
+    dateRangeQueryType: 'YEAR',
150
+    year: currentYear,
151
+    quarter: '',
152
+    month: ''
153
+  }
154
+  // 重置后自动查询接口
155
+  handleQuery()
156
+}
157
+const handleExport = async () => {
158
+
159
+  const btn = document.querySelector('.export-btn');
160
+  const loading = ElLoading.service({
161
+    lock: true,
162
+    text: '正在生成Word文档...',
163
+    background: 'rgba(0, 0, 0, 0.7)'
164
+  })
165
+
166
+  btn.disabled = true;
167
+  btn.textContent = '导出中...';
168
+
169
+  try {
170
+
171
+    // 获取所有组件的大标题和面板项
172
+    const sectionTitles = document.querySelectorAll('.section-title h2');
173
+    const panelItems = document.querySelectorAll('.panel-item');
174
+    const children = [];
175
+
176
+    // 生成动态文档标题
177
+    let reportTitle = '质控分析报告';
178
+    
179
+    if (queryForm.value.year) {
180
+      if (queryForm.value.dateRangeQueryType === 'YEAR') {
181
+        reportTitle = `${queryForm.value.year}年质控分析报告`;
182
+      } else if (queryForm.value.dateRangeQueryType === 'QUARTER' && queryForm.value.quarter) {
183
+        const quarterNames = ['', '第一季度', '第二季度', '第三季度', '第四季度'];
184
+        reportTitle = `${queryForm.value.year}年${quarterNames[parseInt(queryForm.value.quarter)]}质控分析报告`;
185
+      } else if (queryForm.value.dateRangeQueryType === 'MONTH' && queryForm.value.month) {
186
+        reportTitle = `${queryForm.value.year}年${queryForm.value.month}月质控分析报告`;
187
+      }
188
+    }
189
+
190
+    // 添加文档标题
191
+    children.push(
192
+      new Paragraph({
193
+        text: reportTitle,
194
+        heading: HeadingLevel.TITLE,
195
+        alignment: AlignmentType.CENTER,
196
+      }),
197
+      new Paragraph({
198
+        text: `导出时间: ${new Date().toLocaleString('zh-CN')}`,
199
+        alignment: AlignmentType.CENTER,
200
+      }),
201
+      new Paragraph({ text: '' }) // 空行
202
+    );
203
+
204
+    // 按组件顺序处理:先处理大标题,再处理对应的面板项
205
+    let panelIndex = 0;
206
+    for (let i = 0; i < sectionTitles.length; i++) {
207
+      const sectionTitle = sectionTitles[i];
208
+      const sectionName = sectionTitle.textContent;
209
+      
210
+      // 添加组件大标题
211
+      children.push(
212
+        new Paragraph({
213
+          text: `${i + 1}. ${sectionName}`,
214
+          heading: HeadingLevel.HEADING_1,
215
+        }),
216
+        new Paragraph({ text: '' }) // 空行
217
+      );
218
+
219
+      // 处理该组件下的所有面板项
220
+      const componentPanelItems = [];
221
+      while (panelIndex < panelItems.length) {
222
+        const panelItem = panelItems[panelIndex];
223
+        
224
+        // 检查这个面板项是否属于当前组件
225
+        const panelSectionTitle = panelItem.closest('.quality-control, .duty-organization, .risk-hazard')?.querySelector('.section-title h2');
226
+        if (panelSectionTitle && panelSectionTitle.textContent === sectionName) {
227
+          componentPanelItems.push(panelItem);
228
+          panelIndex++;
229
+        } else {
230
+          break; // 遇到下一个组件的面板项,停止处理
231
+        }
232
+      }
233
+
234
+      // 处理当前组件下的面板项
235
+      for (let j = 0; j < componentPanelItems.length; j++) {
236
+        const panelItem = componentPanelItems[j];
237
+       
238
+       // 获取面板项标题
239
+       const titleElement = panelItem.querySelector('.panel-header h2, .panel-header h3');
240
+       const title = titleElement?.textContent || `图表${j + 1}`;
241
+       
242
+       // 添加面板项标题
243
+       children.push(
244
+         new Paragraph({
245
+           text: `${i + 1}.${j + 1} ${title}`,
246
+           heading: HeadingLevel.HEADING_2,
247
+         }),
248
+         new Paragraph({ text: '' }) // 在小标题后添加空行
249
+       );
250
+
251
+       // 获取面板项中的所有相关元素,按DOM顺序排列
252
+       const contentElements = panelItem.querySelectorAll('.stat-card, .describe-card, .chart-container');
253
+       
254
+       // 按DOM顺序处理每个元素
255
+       for (let k = 0; k < contentElements.length; k++) {
256
+         const element = contentElements[k];
257
+         
258
+         if (element.classList.contains('stat-card') || element.classList.contains('describe-card')) {
259
+           // 处理描述内容卡片
260
+           const description = element.textContent || '';
261
+           if (description) {
262
+             children.push(
263
+               new Paragraph({
264
+                 text: description,
265
+                 alignment: AlignmentType.LEFT,
266
+               }),
267
+               new Paragraph({ text: '' }) // 空行
268
+             );
269
+           }
270
+         } else if (element.classList.contains('chart-container')) {
271
+           // 处理图表容器
272
+           // 截图
273
+           const canvas = await html2canvas(element, {
274
+             backgroundColor: '#ffffff',
275
+             scale: 2, // 高清截图
276
+             logging: false
277
+           });
278
+
279
+           // 转换为 base64
280
+           const imageData = canvas.toDataURL('image/png');
281
+           const base64Data = imageData.replace(/^data:image\/png;base64,/, '');
282
+
283
+           // 计算保持宽高比的尺寸
284
+           const maxWidth = 500; // Word中最大宽度
285
+           const originalWidth = canvas.width;
286
+           const originalHeight = canvas.height;
287
+           const aspectRatio = originalWidth / originalHeight;
288
+
289
+           let finalWidth = maxWidth;
290
+           let finalHeight = maxWidth / aspectRatio;
291
+
292
+           // 如果高度超过限制,按高度缩放
293
+           const maxHeight = 400;
294
+           if (finalHeight > maxHeight) {
295
+             finalHeight = maxHeight;
296
+             finalWidth = maxHeight * aspectRatio;
297
+           }
298
+
299
+           // 添加图片(保持原始宽高比)
300
+           children.push(
301
+             new Paragraph({
302
+               children: [
303
+                 new ImageRun({
304
+                   data: Uint8Array.from(atob(base64Data), c => c.charCodeAt(0)),
305
+                   transformation: {
306
+                     width: Math.round(finalWidth),
307
+                     height: Math.round(finalHeight)
308
+                   }
309
+                 })
310
+               ],
311
+               alignment: AlignmentType.CENTER,
312
+             }),
313
+             new Paragraph({ text: '' }) // 图片后空行
314
+           );
315
+
316
+           // 小延迟,让UI更新
317
+           await new Promise(resolve => setTimeout(resolve, 100));
318
+         }
319
+       }
320
+
321
+      // 获取表格容器
322
+      const tableContainer = panelItem.querySelector('.table-container');
323
+      if (tableContainer) {
324
+        // 截图
325
+        const canvas = await html2canvas(tableContainer, {
326
+          backgroundColor: '#ffffff',
327
+          scale: 2, // 高清截图
328
+          logging: false
329
+        });
330
+
331
+        // 转换为 base64
332
+        const imageData = canvas.toDataURL('image/png');
333
+        const base64Data = imageData.replace(/^data:image\/png;base64,/, '');
334
+
335
+        // 计算保持宽高比的尺寸
336
+        const maxWidth = 500; // Word中最大宽度
337
+        const originalWidth = canvas.width;
338
+        const originalHeight = canvas.height;
339
+        const aspectRatio = originalWidth / originalHeight;
340
+
341
+        let finalWidth = maxWidth;
342
+        let finalHeight = maxWidth / aspectRatio;
343
+
344
+        // 如果高度超过限制,按高度缩放
345
+        const maxHeight = 400;
346
+        if (finalHeight > maxHeight) {
347
+          finalHeight = maxHeight;
348
+          finalWidth = maxHeight * aspectRatio;
349
+        }
350
+
351
+        // 添加表格图片(保持原始宽高比)
352
+        children.push(
353
+          new Paragraph({
354
+            children: [
355
+              new ImageRun({
356
+                data: Uint8Array.from(atob(base64Data), c => c.charCodeAt(0)),
357
+                transformation: {
358
+                  width: Math.round(finalWidth),
359
+                  height: Math.round(finalHeight)
360
+                }
361
+              })
362
+            ],
363
+            alignment: AlignmentType.CENTER,
364
+          }),
365
+          new Paragraph({ text: '' }) // 图片后空行
366
+        );
367
+
368
+        // 小延迟,让UI更新
369
+        await new Promise(resolve => setTimeout(resolve, 100));
370
+      }
371
+      }
372
+    }
373
+
374
+    // 创建文档
375
+    const doc = new Document({
376
+      sections: [{
377
+        properties: {},
378
+        children: children
379
+      }]
380
+    });
381
+
382
+    // 生成并下载
383
+    const blob = await Packer.toBlob(doc);
384
+    const fileName = `质控分析报告_${new Date().toISOString().slice(0, 10)}.docx`;
385
+    saveAs(blob, fileName);
386
+
387
+    ElMessage.success('Word文档导出成功');
388
+
389
+  } catch (error) {
390
+    console.error('导出失败:', error);
391
+    ElMessage.error('导出失败: ' + error.message);
392
+  } finally {
393
+    btn.disabled = false;
394
+    btn.textContent = '导出文档';
395
+    loading.close();
396
+  }
397
+}
398
+
399
+// 导出PDF功能
400
+// const handleExport1 = async () => {
401
+//   const loading = ElLoading.service({
402
+//     lock: true,
403
+//     text: '正在生成PDF文档...',
404
+//     background: 'rgba(0, 0, 0, 0.7)'
405
+//   })
406
+
407
+//   try {
408
+//     // 等待所有图表完全渲染
409
+//     await new Promise(resolve => setTimeout(resolve, 3000))
410
+
411
+//     // 获取查询条件下面的内容(不包括头部导航和查询表单)
412
+//     const contentElement = document.querySelector('.data-board-container')
413
+//     if (!contentElement) {
414
+//       throw new Error('未找到导出内容')
415
+//     }
416
+
417
+//     // 创建PDF
418
+//     const pdf = new jsPDF({
419
+//       orientation: 'portrait',
420
+//       unit: 'mm',
421
+//       format: 'a4'
422
+//     })
423
+
424
+//     // 设置PDF样式
425
+//     const pageWidth = pdf.internal.pageSize.getWidth()
426
+//     const pageHeight = pdf.internal.pageSize.getHeight()
427
+//     const margin = 15
428
+//     const contentWidth = pageWidth - 2 * margin
429
+
430
+//     let currentY = margin
431
+
432
+//     // 添加报告标题 - 使用图片方式支持中文
433
+//     const titleElement = document.createElement('div')
434
+//     titleElement.style.position = 'fixed'
435
+//     titleElement.style.left = '-9999px'
436
+//     titleElement.style.top = '0'
437
+//     titleElement.style.fontSize = '18px'
438
+//     titleElement.style.fontWeight = 'bold'
439
+//     titleElement.style.fontFamily = 'sans-serif' // 使用系统默认字体以确保中文支持
440
+//     titleElement.style.color = 'black'
441
+//     titleElement.style.whiteSpace = 'nowrap'
442
+//     titleElement.textContent = '质控分析报告'
443
+//     document.body.appendChild(titleElement)
444
+
445
+//     const titleCanvas = await html2canvas(titleElement, {
446
+//       scale: 2,
447
+//       useCORS: true,
448
+//       allowTaint: true,
449
+//       backgroundColor: '#ffffff',
450
+//       logging: false
451
+//     })
452
+
453
+//     const titleImgData = titleCanvas.toDataURL('image/png')
454
+//     const titleImgWidth = Math.min(100, contentWidth) // 限制标题宽度
455
+//     const titleImgHeight = (titleCanvas.height * titleImgWidth) / titleCanvas.width
456
+
457
+//     pdf.addImage(titleImgData, 'PNG', margin, currentY, titleImgWidth, titleImgHeight)
458
+//     document.body.removeChild(titleElement)
459
+
460
+//     currentY += titleImgHeight + 5
461
+
462
+//     // 添加生成时间 - 使用图片方式支持中文
463
+//     const timeElement = document.createElement('div')
464
+//     timeElement.style.position = 'fixed'
465
+//     timeElement.style.left = '-9999px'
466
+//     timeElement.style.top = '0'
467
+//     timeElement.style.fontSize = '10px'
468
+//     timeElement.style.fontFamily = 'sans-serif'
469
+//     timeElement.style.color = 'black'
470
+//     timeElement.style.whiteSpace = 'nowrap'
471
+//     timeElement.textContent = `生成时间: ${new Date().toLocaleString()}`
472
+//     document.body.appendChild(timeElement)
473
+
474
+//     const timeCanvas = await html2canvas(timeElement, {
475
+//       scale: 2,
476
+//       useCORS: true,
477
+//       allowTaint: true,
478
+//       backgroundColor: '#ffffff',
479
+//       logging: false
480
+//     })
481
+
482
+//     const timeImgData = timeCanvas.toDataURL('image/png')
483
+//     const timeImgWidth = Math.min(120, contentWidth) // 限制时间信息宽度
484
+//     const timeImgHeight = (timeCanvas.height * timeImgWidth) / timeCanvas.width
485
+
486
+//     pdf.addImage(timeImgData, 'PNG', margin, currentY, timeImgWidth, timeImgHeight)
487
+//     document.body.removeChild(timeElement)
488
+
489
+//     currentY += timeImgHeight + 10
490
+
491
+//     currentY += 15
492
+
493
+//     // 获取查询条件后面的所有内容元素
494
+//     const contentSections = Array.from(contentElement.children).slice(2); // 跳过头部和查询表单
495
+
496
+//     // 用于跟踪已处理的元素,避免重复
497
+//     const processedElements = new Set();
498
+
499
+//     for (let i = 0; i < contentSections.length; i++) {
500
+//       const section = contentSections[i]
501
+
502
+//       // 检查是否需要分页
503
+//       if (currentY > pageHeight - 50) {
504
+//         pdf.addPage()
505
+//         currentY = margin
506
+//       }
507
+
508
+//       // 获取该section中的所有元素,按DOM顺序处理
509
+//       const allElements = Array.from(section.querySelectorAll('h2, h3, .stat-card, .describe-card, .echarts-chart, .table-container'));
510
+
511
+//       for (let idx = 0; idx < allElements.length; idx++) {
512
+//         const element = allElements[idx];
513
+
514
+//         // 使用元素的位置和内容来判断是否已处理
515
+//         const elementKey = `${element.tagName}-${element.className}-${element.getBoundingClientRect().top}-${element.getBoundingClientRect().left}-${element.innerText.substring(0, 20)}`;
516
+//         if (processedElements.has(elementKey)) {
517
+//           continue; // 跳过已处理的元素
518
+//         }
519
+
520
+//         // 标记为已处理,防止重复处理
521
+//         processedElements.add(elementKey);
522
+
523
+//         // 根据元素类型分别处理
524
+//         if (element.classList.contains('echarts-chart')) {
525
+//           // 处理ECharts图表
526
+//           // 确保图表可见
527
+//           element.style.visibility = 'visible'
528
+//           element.style.opacity = '1'
529
+//           element.style.display = 'block'
530
+
531
+//           // 等待图表渲染完成
532
+//           await new Promise(resolve => setTimeout(resolve, 500))
533
+
534
+//           try {
535
+//             // 尝试通过ECharts实例获取图表数据
536
+//             let chartData = null
537
+
538
+//             // 使用ECharts自带的getDataURL
539
+//             if (window.echarts && window.echarts.getInstanceByDom) {
540
+//               const instance = window.echarts.getInstanceByDom(element)
541
+//               if (instance) {
542
+//                 chartData = instance.getDataURL({
543
+//                   type: 'png',
544
+//                   pixelRatio: 2,
545
+//                   backgroundColor: '#fff'
546
+//                 })
547
+
548
+//                 // 强制刷新图表
549
+//                 instance.resize()
550
+//               }
551
+//             }
552
+
553
+//             if (chartData) {
554
+//               // 计算合适的图表高度,保持宽高比
555
+//               const img = new Image()
556
+//               img.src = chartData
557
+
558
+//               // 由于异步问题,我们使用固定比例
559
+//               const imgHeight = 80 // 固定图表高度
560
+
561
+//               // 检查是否需要分页
562
+//               if (currentY + imgHeight > pageHeight - margin) {
563
+//                 pdf.addPage()
564
+//                 currentY = margin
565
+//               }
566
+
567
+//               // 添加图表到PDF
568
+//               pdf.addImage(chartData, 'PNG', margin, currentY, contentWidth, imgHeight)
569
+//               currentY += imgHeight + 10
570
+//             } else {
571
+//               // 如果无法获取图表数据,尝试使用html2canvas
572
+//               const canvas = await html2canvas(element, {
573
+//                 scale: 2,
574
+//                 useCORS: true,
575
+//                 allowTaint: true,
576
+//                 backgroundColor: '#ffffff',
577
+//                 logging: false
578
+//               })
579
+
580
+//               const chartImgData = canvas.toDataURL('image/png')
581
+//               const imgHeight = 80 // 固定图表高度
582
+
583
+//               // 检查是否需要分页
584
+//               if (currentY + imgHeight > pageHeight - margin) {
585
+//                 pdf.addPage()
586
+//                 currentY = margin
587
+//               }
588
+
589
+//               // 添加图表到PDF
590
+//               pdf.addImage(chartImgData, 'PNG', margin, currentY, contentWidth, imgHeight)
591
+//               currentY += imgHeight + 10
592
+//             }
593
+//           } catch (error) {
594
+//             console.error(`图表导出失败:`, error)
595
+//             // 添加错误提示
596
+//             pdf.setFontSize(10)
597
+//             pdf.setTextColor(255, 0, 0) // 红色
598
+//             pdf.text(`图表导出失败`, margin, currentY)
599
+//             pdf.setTextColor(0, 0, 0) // 重置为黑色
600
+//             currentY += 10
601
+//           }
602
+//         } else {
603
+//           // 处理文本内容(标题、统计卡片、描述等)- 转换为图片以支持中文
604
+//           const text = element.innerText?.trim()
605
+
606
+//           if (text && text.length > 0) {
607
+//             // 创建一个临时元素来确保正确的样式显示
608
+//             const tempElement = document.createElement('div');
609
+//             tempElement.innerHTML = element.innerHTML;
610
+//             tempElement.style.position = 'fixed';
611
+//             tempElement.style.left = '-9999px';
612
+//             tempElement.style.top = '0';
613
+//             tempElement.style.width = '600px'; // 设置一个合适的宽度
614
+//             tempElement.style.padding = '10px';
615
+//             tempElement.style.boxSizing = 'border-box';
616
+
617
+//             // 根据原始元素的标签类型应用适当的样式
618
+//             if (element.tagName === 'H2') {
619
+//               tempElement.style.fontSize = '18px';
620
+//               tempElement.style.fontWeight = 'bold';
621
+//               tempElement.style.marginBottom = '10px';
622
+//             } else if (element.tagName === 'H3') {
623
+//               tempElement.style.fontSize = '14px';
624
+//               tempElement.style.fontWeight = 'bold';
625
+//               tempElement.style.marginBottom = '8px';
626
+//             } else {
627
+//               tempElement.style.fontSize = '12px';
628
+//               tempElement.style.lineHeight = '1.5';
629
+//             }
630
+
631
+//             document.body.appendChild(tempElement);
632
+
633
+//             // 使用html2canvas将文本元素转换为图片,以正确显示中文
634
+//             const canvas = await html2canvas(tempElement, {
635
+//               scale: 2,
636
+//               useCORS: true,
637
+//               allowTaint: true,
638
+//               backgroundColor: '#ffffff',
639
+//               logging: false
640
+//             })
641
+
642
+//             const textImgData = canvas.toDataURL('image/png')
643
+//             const imgWidth = contentWidth
644
+//             const imgHeight = (canvas.height * imgWidth) / canvas.width
645
+
646
+//             // 检查是否需要分页
647
+//             if (currentY + imgHeight > pageHeight - margin) {
648
+//               pdf.addPage()
649
+//               currentY = margin
650
+//             }
651
+
652
+//             // 添加文本图片到PDF
653
+//             pdf.addImage(textImgData, 'PNG', margin, currentY, imgWidth, imgHeight)
654
+//             currentY += imgHeight + 5
655
+
656
+//             // 移除临时元素
657
+//             document.body.removeChild(tempElement);
658
+//           }
659
+//         }
660
+//       }
661
+
662
+//       // 组件之间添加分隔
663
+//       currentY += 10
664
+//     }
665
+
666
+//     // 保存PDF
667
+//     pdf.save('质控分析报告_' + new Date().toISOString().slice(0, 10) + '.pdf')
668
+
669
+//     ElMessage.success('PDF导出成功')
670
+
671
+//   } catch (error) {
672
+//     console.error('导出失败:', error)
673
+//     ElMessage.error('导出失败: ' + error.message)
674
+//   } finally {
675
+//     loading.close()
676
+//   }
677
+// }
678
+</script>
679
+
680
+<style scoped>
681
+.data-board-container {
682
+  /* height: 100vh; */
683
+  background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
684
+  padding: 20px;
685
+}
686
+
687
+.header {
688
+  display: flex;
689
+  justify-content: space-between;
690
+  align-items: center;
691
+  padding: 0 16px 16px 16px;
692
+}
693
+
694
+.back-btn {
695
+  color: #3D3D3D;
696
+  font-size: 14px;
697
+}
698
+
699
+.back-btn:hover {
700
+  color: #3D3D3D;
701
+}
702
+
703
+.back-btn .el-icon-back {
704
+  margin-right: 8px;
705
+}
706
+
707
+.export-btn {
708
+  background-color: transparent;
709
+}
710
+
711
+.export-btn:hover {
712
+  background-color: rgba(0, 0, 0, 0.05);
713
+}
714
+
715
+.query-form {
716
+  background: white;
717
+  border-radius: 8px;
718
+  padding: 24px 24px 5px 24px;
719
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
720
+}
721
+
722
+.form-container {
723
+  width: 100%;
724
+}
725
+
726
+.form-row {
727
+  display: flex;
728
+  align-items: center;
729
+  justify-content: space-between;
730
+  gap: 20px;
731
+}
732
+
733
+.form-content {
734
+  display: flex;
735
+  align-items: center;
736
+  gap: 16px;
737
+  flex: 1;
738
+}
739
+
740
+.form-actions {
741
+  display: flex;
742
+  align-items: center;
743
+  gap: 12px;
744
+  flex-shrink: 0;
745
+}
746
+
747
+.select-field {
748
+  min-width: 120px;
749
+}
750
+
751
+/* 响应式设计 */
752
+@media (max-width: 768px) {
753
+  .data-board-container {
754
+    padding: 16px;
755
+  }
756
+
757
+  .header {
758
+    flex-direction: column;
759
+    gap: 16px;
760
+    align-items: flex-start;
761
+  }
762
+
763
+  .header-right {
764
+    align-self: flex-end;
765
+  }
766
+}
767
+</style>

File diff suppressed because it is too large
+ 1026 - 0
src/views/assistant/components/dutyOrganization.vue


File diff suppressed because it is too large
+ 1495 - 0
src/views/assistant/components/performanceAnalysis.vue


File diff suppressed because it is too large
+ 1044 - 0
src/views/assistant/components/qualityControl.vue


+ 996 - 0
src/views/assistant/components/riskHazard.vue

@@ -0,0 +1,996 @@
1
+<template>
2
+  <div class="risk-hazard">
3
+    <!-- 风险隐患标题 -->
4
+    <div class="section-title">
5
+      <h2>风险隐患</h2>
6
+    </div>
7
+
8
+    <!-- 六个部分布局,一行三个,共两行 -->
9
+    <div class="six-panel-layout">
10
+      <!-- 第一行 -->
11
+      <div class="panel-row">
12
+        <!-- 第一个:查获违禁品类别 -->
13
+        <div class="panel-item">
14
+          <div class="panel-header">
15
+            <h3>查获违禁品类别</h3>
16
+          </div>
17
+
18
+          <!-- 描述卡片 -->
19
+          <div class="describe-card">
20
+            <div class="describe-content">
21
+              {{ categoryStatsDescription }}
22
+            </div>
23
+          </div>
24
+
25
+          <!-- 饼图 -->
26
+          <div class="chart-container">
27
+            <div ref="captureRankChartRef" class="echarts-chart"></div>
28
+          </div>
29
+        </div>
30
+
31
+        <!-- 第二个:问题发现统计 -->
32
+        <div class="panel-item">
33
+          <div class="panel-header">
34
+            <h3>问题发现统计</h3>
35
+          </div>
36
+
37
+          <!-- 描述卡片 -->
38
+          <div class="describe-card">
39
+            <div class="describe-content">
40
+              {{ seizureTimeTrendDescription }}
41
+            </div>
42
+          </div>
43
+
44
+          <!-- 折线图 -->
45
+          <div class="chart-container">
46
+            <div ref="problemDiscoveryChartRef" class="echarts-chart"></div>
47
+          </div>
48
+        </div>
49
+
50
+        <!-- 第三个:隐匿物品查获部位 -->
51
+        <div class="panel-item">
52
+          <div class="panel-header">
53
+            <h3>隐匿物品查获部位</h3>
54
+          </div>
55
+
56
+          <!-- 描述卡片 -->
57
+          <div class="describe-card">
58
+            <div class="describe-content">
59
+              {{ concealmentPositionStatsDescription }}
60
+            </div>
61
+          </div>
62
+
63
+          <!-- 饼图 -->
64
+          <div class="chart-container">
65
+            <div ref="problemRectificationChartRef" class="echarts-chart"></div>
66
+          </div>
67
+        </div>
68
+      </div>
69
+
70
+      <!-- 第二行 -->
71
+      <div class="panel-row">
72
+        <!-- 第四个:查获排名表格 -->
73
+        <div class="panel-item">
74
+          <div class="panel-header">
75
+            <h3>查获排名</h3>
76
+          </div>
77
+
78
+          <!-- 描述卡片 -->
79
+          <div class="describe-card">
80
+            <div class="describe-content">
81
+              {{ departmentRankingDescription }}
82
+            </div>
83
+          </div>
84
+
85
+          <!-- 表格 -->
86
+          <div class="table-container">
87
+            <el-table :data="captureRankData" style="width: 100%" size="small">
88
+              <el-table-column prop="rank" label="排名" align="center" />
89
+              <el-table-column prop="department" label="大队" />
90
+              <el-table-column prop="percentage" label="占比" align="center">
91
+                <template #default="{ row }">
92
+                  <span>{{ row.percentage }}%</span>
93
+                </template>
94
+              </el-table-column>
95
+              <el-table-column prop="count" label="数量" align="center" />
96
+            </el-table>
97
+          </div>
98
+        </div>
99
+
100
+        <!-- 第五个:查获岗位分布 -->
101
+        <div class="panel-item">
102
+          <div class="panel-header">
103
+            <h3>查获岗位分布</h3>
104
+          </div>
105
+
106
+          <!-- 描述卡片 -->
107
+          <div class="describe-card">
108
+            <div class="describe-content">
109
+              {{ postCategoryStatsDescription }}
110
+            </div>
111
+          </div>
112
+
113
+          <!-- 饼图 -->
114
+          <div class="chart-container">
115
+            <div ref="rectificationStatsChartRef" class="echarts-chart"></div>
116
+          </div>
117
+        </div>
118
+
119
+        <!-- 第六个:查获通道TOP5 -->
120
+        <div class="panel-item">
121
+          <div class="panel-header">
122
+            <h3>查获通道TOP5</h3>
123
+          </div>
124
+
125
+          <!-- 描述卡片 -->
126
+          <div class="describe-card">
127
+            <div class="describe-content">
128
+              {{ channelRankingStatsDescription }}
129
+            </div>
130
+          </div>
131
+
132
+          <!-- 表格 -->
133
+          <div class="table-container">
134
+            <el-table :data="captureChannelData" style="width: 100%" size="small">
135
+              <el-table-column prop="channel" label="查获通道" />
136
+              <el-table-column prop="count" label="查获数量" align="center" />
137
+              <el-table-column prop="area" label="所在区域" />
138
+            </el-table>
139
+          </div>
140
+        </div>
141
+      </div>
142
+    </div>
143
+  </div>
144
+</template>
145
+
146
+<script setup>
147
+import { ref, onMounted, onUnmounted, watch } from 'vue'
148
+import { useEcharts } from '@/hooks/chart.js'
149
+import {
150
+  getCategoryStats,
151
+  getSeizureTimeTrend,
152
+  getConcealmentPositionStats,
153
+  getDepartmentRanking,
154
+  getPostCategoryStats,
155
+  getChannelRankingStats
156
+} from '@/api/assistant/assistant.js'
157
+
158
+// 定义props接收queryForm参数
159
+const props = defineProps({
160
+  queryForm: {
161
+    type: Object,
162
+    default: () => ({
163
+      dateRangeQueryType: '',
164
+      year: '',
165
+      quarter: '',
166
+      month: ''
167
+    })
168
+  }
169
+})
170
+
171
+// 图表容器引用
172
+const captureRankChartRef = ref(null)
173
+const problemDiscoveryChartRef = ref(null)
174
+const problemRectificationChartRef = ref(null)
175
+const rectificationStatsChartRef = ref(null)
176
+
177
+// 图表实例
178
+const { setOption: setCaptureRankOption, dispose: disposeCaptureRank } = useEcharts(captureRankChartRef)
179
+const { setOption: setProblemDiscoveryOption, dispose: disposeProblemDiscovery } = useEcharts(problemDiscoveryChartRef)
180
+const { setOption: setProblemRectificationOption, dispose: disposeProblemRectification } = useEcharts(problemRectificationChartRef)
181
+const { setOption: setRectificationStatsOption, dispose: disposeRectificationStats } = useEcharts(rectificationStatsChartRef)
182
+
183
+// 六个API接口的响应式数据
184
+const categoryStatsData = ref({})
185
+const seizureTimeTrendData = ref({})
186
+const concealmentPositionStatsData = ref({})
187
+const departmentRankingData = ref({})
188
+const postCategoryStatsData = ref({})
189
+const channelRankingStatsData = ref({})
190
+
191
+// 计算属性:动态生成查获违禁品类别描述
192
+const categoryStatsDescription = computed(() => {
193
+  
194
+  if (!categoryStatsData.value || !categoryStatsData.value || !Array.isArray(categoryStatsData.value)) {
195
+    return '查获物品以[占比最高物品类型]为主,占比达[X]%,其次为[第二高占比物品类型]([X]%)、[第三高占比物品类型]([X]%),需重点强化对应物品的安检识别与管控力度。'
196
+  }
197
+
198
+  const categoryData = categoryStatsData.value
199
+  
200
+  // 1. 按数量排序,获取前三名
201
+  const sortedData = [...categoryData].sort((a, b) => (b.quantity || 0) - (a.quantity || 0))
202
+  
203
+  // 2. 计算总数量
204
+  const totalCount = sortedData.reduce((sum, item) => sum + (item.quantity || 0), 0)
205
+  
206
+  // 3. 获取前三名数据
207
+  const top1 = sortedData[0] || { categoryNameOne: '未知类型', quantity: 0 }
208
+  const top2 = sortedData[1] || { categoryNameOne: '未知类型', quantity: 0 }
209
+  const top3 = sortedData[2] || { categoryNameOne: '未知类型', quantity: 0 }
210
+  
211
+  // 4. 计算百分比
212
+  const top1Percentage = totalCount > 0 ? ((top1.quantity / totalCount) * 100).toFixed(2) : '0.00'
213
+  const top2Percentage = totalCount > 0 ? ((top2.quantity / totalCount) * 100).toFixed(2) : '0.00'
214
+  const top3Percentage = totalCount > 0 ? ((top3.quantity / totalCount) * 100).toFixed(2) : '0.00'
215
+  
216
+  // 5. 生成描述文字
217
+  return `查获物品以${top1.categoryNameOne  || '未知类型'}为主,占比达${top1Percentage}%,其次为${top2.categoryNameOne  || '未知类型'}(${top2Percentage}%)、${top3.categoryNameOne  || '未知类型'}(${top3Percentage}%),需重点强化对应物品的安检识别与管控力度。`
218
+})
219
+
220
+// 计算属性:动态生成查获时间趋势描述
221
+  const seizureTimeTrendDescription = computed(() => {
222
+    if (!seizureTimeTrendData.value || !seizureTimeTrendData.value || !Array.isArray(seizureTimeTrendData.value)) {
223
+      return '高峰时段:XX:XX、XX:XX左右(平均查获量达XX件),需强化对应时段的安检力度。低峰时段:XX:XX、XX:XX后(平均查获量XX件)'
224
+    }
225
+
226
+    const trendData = seizureTimeTrendData.value
227
+    
228
+    // 1. 按查获量排序,找出最高和最低的两个时段
229
+    const sortedData = [...trendData].sort((a, b) => (b.total || 0) - (a.total || 0))
230
+    
231
+    // 2. 获取最高的两个时段
232
+    const peakHours = sortedData.slice(0, 2).map(item => item.hourOfDay || '').filter(Boolean)
233
+    
234
+    // 3. 获取最低的两个时段
235
+    const lowHours = sortedData.slice(-2).map(item => item.hourOfDay || '').filter(Boolean)
236
+    
237
+    // 4. 计算高峰时段平均查获量
238
+    const peakData = trendData.filter(item => peakHours.some(hour => item.hourOfDay?.includes(hour)))
239
+    const peakAvg = peakData.length > 0 
240
+      ? Math.round(peakData.reduce((sum, item) => sum + (item.total || 0), 0) / peakData.length)
241
+      : 0
242
+    
243
+    // 5. 计算低峰时段平均查获量
244
+    const lowData = trendData.filter(item => lowHours.some(hour => item.hourOfDay?.includes(hour)))
245
+    const lowAvg = lowData.length > 0 
246
+      ? Math.round(lowData.reduce((sum, item) => sum + (item.total || 0), 0) / lowData.length)
247
+      : 0
248
+    
249
+    // 6. 生成描述文字
250
+    return `高峰时段:${peakHours.join('、')}左右(平均查获量达${peakAvg}件),需强化对应时段的安检力度。低峰时段:${lowHours.join('、')}后(平均查获量${lowAvg}件)`
251
+  })
252
+
253
+  // 计算属性:动态生成隐匿物品查获部位描述
254
+  const concealmentPositionStatsDescription = computed(() => {
255
+    if (!concealmentPositionStatsData.value || !Array.isArray(concealmentPositionStatsData.value)) {
256
+      return '违禁品主要藏匿于"XX",是安检搜检的重点部位。'
257
+    }
258
+
259
+    const positionData = concealmentPositionStatsData.value
260
+    
261
+    // 1. 按查获量排序,找出查获量最高的部位
262
+    const sortedData = [...positionData].sort((a, b) => (b.count || 0) - (a.count || 0))
263
+    
264
+    // 2. 获取查获量最高的部位
265
+    const topPosition = sortedData[0] || { positionName: 'XX', count: 0 }
266
+    
267
+    // 3. 生成描述文字
268
+    return `违禁品主要藏匿于"${topPosition.positionName || 'XX'}",是安检搜检的重点部位。`
269
+  })
270
+
271
+  // 计算属性:动态生成大队查获排名描述
272
+  const departmentRankingDescription = computed(() => {
273
+    
274
+    if (!departmentRankingData.value || !Array.isArray(departmentRankingData.value)) {
275
+      return 'XXXX是违禁品查获的主力大队,查获违禁物品数量为XX,占比XX%。'
276
+    }
277
+
278
+    const rankingData = departmentRankingData.value
279
+    
280
+    // 1. 按查获量排序,找出查获量最高的大队
281
+    const sortedData = [...rankingData].sort((a, b) => (b.seizureCount || 0) - (a.seizureCount || 0))
282
+    
283
+    // 2. 获取查获量最高的大队
284
+    const topDepartment = sortedData[0] || { departmentName: 'XXXX', seizureCount: 234 }
285
+    
286
+  
287
+    // 5. 生成描述文字
288
+    return `${topDepartment.departmentName || 'XXXX'}是违禁品查获的主力大队,查获违禁物品数量为${topDepartment.seizureCount || 'XX'},占比${topDepartment.currentRatio}%。`
289
+  })
290
+
291
+  // 计算属性:动态生成查获岗位分布描述
292
+  const postCategoryStatsDescription = computed(() => {
293
+    if (!postCategoryStatsData.value || !Array.isArray(postCategoryStatsData.value)) {
294
+      return '"XX"的查获数量最多,为XX件,占比XX%。'
295
+    }
296
+
297
+    const postData = postCategoryStatsData.value
298
+    
299
+    // 1. 按查获量排序,找出查获量最高的岗位
300
+    const sortedData = [...postData].sort((a, b) => (b.quantity || 0) - (a.quantity || 0))
301
+    
302
+    // 2. 获取查获量最高的岗位
303
+    const topPost = sortedData[0] || { postName: 'XX', quantity: 124 }
304
+    
305
+    // 3. 计算总查获量
306
+    const totalCount = postData.reduce((sum, item) => sum + (item.quantity || 0), 0)
307
+    
308
+    // 4. 计算占比
309
+    const percentage = totalCount > 0 ? ((topPost.quantity / totalCount) * 100).toFixed(0) : '45'
310
+    
311
+    // 5. 生成描述文字
312
+    return `"${topPost.postName || 'XX'}"的查获数量最多,为${topPost.quantity || 124}件,占比${percentage}%。`
313
+  })
314
+
315
+  // 计算属性:动态生成查获通道TOP5描述
316
+  const channelRankingStatsDescription = computed(() => {
317
+    if (!channelRankingStatsData.value || !Array.isArray(channelRankingStatsData.value)) {
318
+      return '违禁物品查获主要集中于XXXX,查获数量为XX,占比XX%。'
319
+    }
320
+
321
+    const channelData = channelRankingStatsData.value
322
+    
323
+    // 1. 按查获量排序,找出查获量最高的通道
324
+    const sortedData = [...channelData].sort((a, b) => (b.seizureQuantity || 0) - (a.seizureQuantity || 0))
325
+    
326
+    // 2. 获取查获量最高的通道
327
+    const topChannel = sortedData[0] || { channelName: 'XXXX', seizureQuantity: 123 }
328
+    
329
+    // 3. 计算总查获量
330
+    const totalCount = channelData.reduce((sum, item) => sum + (item.seizureQuantity || 0), 0)
331
+    
332
+    // 4. 计算占比
333
+    const percentage = totalCount > 0 ? ((topChannel.seizureQuantity / totalCount) * 100).toFixed(0) : '23'
334
+    
335
+    // 5. 生成描述文字
336
+    return `违禁物品查获主要集中于${topChannel.channelName || 'XXXX'},查获数量为${topChannel.seizureQuantity || 'XX'},占比${percentage}%。`
337
+  })
338
+
339
+// 表格数据
340
+const captureRankData = ref([])
341
+const captureChannelData = ref([])
342
+
343
+// 处理query参数,当dateRangeQueryType为YEAR时添加yearOnYear: true
344
+const processQueryParams = (queryParams) => {
345
+  const processedParams = { ...queryParams }
346
+  if (processedParams.dateRangeQueryType === 'YEAR') {
347
+    processedParams.yearOnYear = true
348
+  } else {
349
+    processedParams.chainRatio = true
350
+    processedParams.yearOnYear = true
351
+  }
352
+  return processedParams
353
+}
354
+
355
+// 调用API获取风险隐患数据
356
+const fetchRiskHazardData = async (queryParams) => {
357
+  try {
358
+    // 处理query参数
359
+    const processedParams = processQueryParams(queryParams)
360
+
361
+    // 按顺序调用六个API接口
362
+
363
+    // 1. 查获违禁品类别统计
364
+    const categoryStatsResponse = await getCategoryStats(processedParams)
365
+    console.log('查获违禁品类别统计:', categoryStatsResponse)
366
+    categoryStatsData.value = categoryStatsResponse.data?.categoryStats || []
367
+
368
+    // 2. 查获时间趋势
369
+    const seizureTimeTrendResponse = await getSeizureTimeTrend(processedParams)
370
+    console.log('查获时间趋势:', seizureTimeTrendResponse)
371
+    seizureTimeTrendData.value = seizureTimeTrendResponse?.data || []
372
+
373
+    // 3. 隐匿物品查获部位统计
374
+    const concealmentPositionStatsResponse = await getConcealmentPositionStats(processedParams)
375
+    console.log('隐匿物品查获部位统计:', concealmentPositionStatsResponse)
376
+    concealmentPositionStatsData.value = concealmentPositionStatsResponse?.data?.positionStats || []
377
+
378
+    // 4. 大队查获排名(表格数据)
379
+    const departmentRankingResponse = await getDepartmentRanking(processedParams)
380
+    console.log('大队查获排名:', departmentRankingResponse)
381
+    departmentRankingData.value = departmentRankingResponse.data || []
382
+
383
+    // 5. 查获岗位分布统计
384
+    const postCategoryStatsResponse = await getPostCategoryStats(processedParams)
385
+    console.log('查获岗位分布统计:', postCategoryStatsResponse)
386
+    postCategoryStatsData.value = postCategoryStatsResponse?.data?.postStats || []
387
+
388
+    // 6. 查获通道TOP5(表格数据)
389
+    const channelRankingStatsResponse = await getChannelRankingStats(processedParams)
390
+    console.log('查获通道TOP5:', channelRankingStatsResponse)
391
+    channelRankingStatsData.value = channelRankingStatsResponse?.data?.channelRankings || []
392
+
393
+    // 更新图表和表格数据
394
+    updateChartsWithData()
395
+
396
+  } catch (error) {
397
+    console.error('获取风险隐患数据失败:', error)
398
+  }
399
+}
400
+
401
+// 查获违禁品类别饼图配置
402
+const captureRankOptions = {
403
+  tooltip: {
404
+    trigger: 'item',
405
+    formatter: '{a} <br/>{b}: {c}件 ({d}%)'
406
+  },
407
+  legend: {
408
+    orient: 'vertical',
409
+    left: 'left',
410
+    top: 'center',
411
+    textStyle: {
412
+      color: '#333',
413
+      fontSize: 12
414
+    }
415
+  },
416
+  color: ['#FF9F43', '#54C6EB', '#A3D9B1', '#FF6B9D', '#9B59B6'],
417
+  series: [
418
+    {
419
+      name: '违禁品类别',
420
+      type: 'pie',
421
+      radius: '60%',
422
+      center: ['60%', '50%'],
423
+      avoidLabelOverlap: false,
424
+      itemStyle: {
425
+        borderRadius: 10,
426
+        borderColor: '#fff',
427
+        borderWidth: 2
428
+      },
429
+      label: {
430
+        show: true,
431
+        formatter: '{b}: {c}件',
432
+        fontSize: 12
433
+      },
434
+      emphasis: {
435
+        label: {
436
+          show: true,
437
+          fontSize: 14,
438
+          fontWeight: 'bold'
439
+        },
440
+        itemStyle: {
441
+          shadowBlur: 10,
442
+          shadowOffsetX: 0,
443
+          shadowColor: 'rgba(0, 0, 0, 0.5)'
444
+        }
445
+      },
446
+      labelLine: {
447
+        show: true,
448
+        length: 10,
449
+        length2: 20
450
+      },
451
+      data: []
452
+    }
453
+  ]
454
+}
455
+
456
+// 问题发现折线图配置
457
+const problemDiscoveryOptions = {
458
+  tooltip: {
459
+    trigger: 'axis',
460
+    axisPointer: {
461
+      type: 'shadow'
462
+    },
463
+    formatter: function (params) {
464
+      return `${params[0].name}<br/>问题数量: <span style="color:#FF6B6B;font-weight:bold">${params[0].data}</span>`
465
+    }
466
+  },
467
+  grid: {
468
+    left: '3%',
469
+    right: '4%',
470
+    bottom: '3%',
471
+    containLabel: true
472
+  },
473
+  xAxis: {
474
+    type: 'category',
475
+    data: ['1月', '2月', '3月', '4月', '5月', '6月', '7月'],
476
+    axisLine: {
477
+      lineStyle: {
478
+        color: '#999'
479
+      }
480
+    },
481
+    axisLabel: {
482
+      fontSize: 12
483
+    }
484
+  },
485
+  yAxis: {
486
+    type: 'value',
487
+    name: '问题数量',
488
+    axisLine: {
489
+      lineStyle: {
490
+        color: '#999'
491
+      }
492
+    },
493
+    splitLine: {
494
+      lineStyle: {
495
+        color: '#f0f0f0'
496
+      }
497
+    }
498
+  },
499
+  series: [
500
+    {
501
+      name: '问题发现',
502
+      type: 'line',
503
+      smooth: true,
504
+      symbol: 'circle',
505
+      symbolSize: 6,
506
+      itemStyle: {
507
+        color: '#FF6B6B'
508
+      },
509
+      lineStyle: {
510
+        color: '#FF6B6B',
511
+        width: 3
512
+      },
513
+      label: {
514
+        show: true,
515
+        position: 'top',
516
+        formatter: '{c}'
517
+      },
518
+      data: []
519
+    }
520
+  ]
521
+}
522
+
523
+// 隐匿物品查获部位饼图配置
524
+const problemRectificationOptions = {
525
+  tooltip: {
526
+    trigger: 'item',
527
+    formatter: '{a} <br/>{b}: {c}件 ({d}%)'
528
+  },
529
+  legend: {
530
+    orient: 'vertical',
531
+    left: 'left',
532
+    top: 'center',
533
+    textStyle: {
534
+      color: '#333',
535
+      fontSize: 12
536
+    }
537
+  },
538
+  color: ['#FF9F43', '#54C6EB', '#A3D9B1', '#FF6B9D', '#9B59B6'],
539
+  series: [
540
+    {
541
+      name: '查获部位',
542
+      type: 'pie',
543
+      radius: '70%',
544
+      center: ['50%', '50%'],
545
+      avoidLabelOverlap: false,
546
+      itemStyle: {
547
+        borderRadius: 10,
548
+        borderColor: '#fff',
549
+        borderWidth: 2
550
+      },
551
+      label: {
552
+        show: true,
553
+        formatter: '{b}: {c}件',
554
+        fontSize: 12
555
+      },
556
+      emphasis: {
557
+        label: {
558
+          show: true,
559
+          fontSize: 14,
560
+          fontWeight: 'bold'
561
+        },
562
+        itemStyle: {
563
+          shadowBlur: 10,
564
+          shadowOffsetX: 0,
565
+          shadowColor: 'rgba(0, 0, 0, 0.5)'
566
+        }
567
+      },
568
+      labelLine: {
569
+        show: true,
570
+        length: 10,
571
+        length2: 20
572
+      },
573
+      data: []
574
+    }
575
+  ]
576
+}
577
+
578
+// 查获岗位分布饼图配置
579
+const rectificationStatsOptions = {
580
+  tooltip: {
581
+    trigger: 'item',
582
+    formatter: '{a} <br/>{b}: {c}件 ({d}%)'
583
+  },
584
+  legend: {
585
+    orient: 'vertical',
586
+    left: 'left',
587
+    top: 'center',
588
+    textStyle: {
589
+      color: '#333',
590
+      fontSize: 12
591
+    }
592
+  },
593
+  color: ['#FF9F43', '#54C6EB', '#A3D9B1', '#FF6B9D', '#9B59B6'],
594
+  series: [
595
+    {
596
+      name: '岗位分布',
597
+      type: 'pie',
598
+      radius: '70%',
599
+      center: ['60%', '50%'],
600
+      avoidLabelOverlap: false,
601
+      itemStyle: {
602
+        borderRadius: 10,
603
+        borderColor: '#fff',
604
+        borderWidth: 2
605
+      },
606
+      label: {
607
+        show: true,
608
+        formatter: '{b}: {c}件',
609
+        fontSize: 12
610
+      },
611
+      emphasis: {
612
+        label: {
613
+          show: true,
614
+          fontSize: 14,
615
+          fontWeight: 'bold'
616
+        },
617
+        itemStyle: {
618
+          shadowBlur: 10,
619
+          shadowOffsetX: 0,
620
+          shadowColor: 'rgba(0, 0, 0, 0.5)'
621
+        }
622
+      },
623
+      labelLine: {
624
+        show: true,
625
+        length: 10,
626
+        length2: 20
627
+      },
628
+      data: []
629
+    }
630
+  ]
631
+}
632
+
633
+// 初始化图表
634
+onMounted(() => {
635
+  // 确保DOM完全渲染后再初始化图表
636
+  const initCharts = () => {
637
+    // 查获违禁品类别饼图
638
+    if (captureRankChartRef.value && captureRankChartRef.value.offsetHeight > 0) {
639
+      setCaptureRankOption(captureRankOptions)
640
+    }
641
+
642
+    // 问题发现折线图
643
+    if (problemDiscoveryChartRef.value && problemDiscoveryChartRef.value.offsetHeight > 0) {
644
+      setProblemDiscoveryOption(problemDiscoveryOptions)
645
+    }
646
+
647
+    // 隐匿物品查获部位饼图
648
+    if (problemRectificationChartRef.value && problemRectificationChartRef.value.offsetHeight > 0) {
649
+      setProblemRectificationOption(problemRectificationOptions)
650
+    }
651
+
652
+    // 查获岗位分布饼图
653
+    if (rectificationStatsChartRef.value && rectificationStatsChartRef.value.offsetHeight > 0) {
654
+      setRectificationStatsOption(rectificationStatsOptions)
655
+    }
656
+  }
657
+
658
+  // 立即尝试初始化
659
+  initCharts()
660
+
661
+  // 如果容器高度为0,延迟重试
662
+  setTimeout(() => {
663
+    initCharts()
664
+  }, 200)
665
+})
666
+
667
+// 监听queryForm参数变化,调用API获取数据
668
+watch(() => props.queryForm, (newQueryForm) => {
669
+  // 只有当所有必要的查询参数都存在时才调用API
670
+  if (newQueryForm.dateRangeQueryType && newQueryForm.year) {
671
+    fetchRiskHazardData(newQueryForm)
672
+  }
673
+}, { deep: true, immediate: true })
674
+
675
+// 根据API数据更新图表和表格数据
676
+const updateChartsWithData = () => {
677
+  // 1. 更新查获违禁品类别饼图
678
+  updateCategoryStatsChart()
679
+
680
+  // 2. 更新查获时间趋势折线图
681
+  updateSeizureTimeTrendChart()
682
+
683
+  // 3. 更新隐匿物品查获部位饼图
684
+  updateConcealmentPositionStatsChart()
685
+
686
+  // 4. 更新大队查获排名表格
687
+  updateDepartmentRankingTable()
688
+
689
+  // 5. 更新查获岗位分布饼图
690
+  updatePostCategoryStatsChart()
691
+
692
+  // 6. 更新查获通道TOP5表格
693
+  updateChannelRankingStatsTable()
694
+
695
+  console.log('风险隐患数据已更新,图表和表格已刷新')
696
+}
697
+
698
+// 更新查获违禁品类别饼图数据
699
+const updateCategoryStatsChart = () => {
700
+  if (categoryStatsData.value && categoryStatsData.value) {
701
+    const pieData = categoryStatsData.value
702
+
703
+    // 转换数据格式
704
+    const formattedData = pieData.map(item => ({
705
+      name: item.categoryNameOne || '未知类别',
706
+      value: item.quantity || 0
707
+    }))
708
+
709
+    // 更新饼图数据
710
+    captureRankOptions.series[0].data = formattedData
711
+
712
+    // 重新设置图表选项
713
+    setCaptureRankOption(captureRankOptions)
714
+
715
+    console.log('查获违禁品类别饼图已更新:', formattedData)
716
+  } else {
717
+    // 无数据时清空图表
718
+    captureRankOptions.series[0].data = []
719
+    setCaptureRankOption(captureRankOptions)
720
+  }
721
+}
722
+
723
+// 更新查获时间趋势折线图数据
724
+const updateSeizureTimeTrendChart = () => {
725
+  if (seizureTimeTrendData.value && seizureTimeTrendData.value) {
726
+    const trendData = seizureTimeTrendData.value
727
+
728
+    // 提取横坐标和时间数据
729
+    const xAxisData = trendData.map(item => item.hourOfDay || '未知时间')
730
+    const seriesData = trendData.map(item => item.total || 0)
731
+
732
+    // 更新图表配置
733
+    problemDiscoveryOptions.xAxis.data = xAxisData
734
+    problemDiscoveryOptions.series[0].data = seriesData
735
+
736
+    // 重新设置图表选项
737
+    setProblemDiscoveryOption(problemDiscoveryOptions)
738
+
739
+    console.log('查获时间趋势折线图已更新:', { xAxis: xAxisData, series: seriesData })
740
+  } else {
741
+    // 无数据时清空图表
742
+    problemDiscoveryOptions.xAxis.data = []
743
+    problemDiscoveryOptions.series[0].data = []
744
+    setProblemDiscoveryOption(problemDiscoveryOptions)
745
+  }
746
+}
747
+
748
+// 更新隐匿物品查获部位饼图数据
749
+const updateConcealmentPositionStatsChart = () => {
750
+  if (concealmentPositionStatsData.value && concealmentPositionStatsData.value) {
751
+    const pieData = concealmentPositionStatsData.value
752
+
753
+    // 转换数据格式
754
+    const formattedData = pieData.map(item => ({
755
+      name: item.positionName || '未知部位',
756
+      value: item.quantity || 0
757
+    }))
758
+
759
+    // 更新饼图数据
760
+    problemRectificationOptions.series[0].data = formattedData
761
+
762
+    // 重新设置图表选项
763
+    setProblemRectificationOption(problemRectificationOptions)
764
+
765
+    console.log('隐匿物品查获部位饼图已更新:', formattedData)
766
+  } else {
767
+    // 无数据时清空图表
768
+    problemRectificationOptions.series[0].data = []
769
+    setProblemRectificationOption(problemRectificationOptions)
770
+  }
771
+}
772
+
773
+// 更新大队查获排名表格数据
774
+const updateDepartmentRankingTable = () => {
775
+  if (departmentRankingData.value && departmentRankingData.value) {
776
+    const tableData = departmentRankingData.value
777
+
778
+    // 转换数据格式
779
+    const formattedData = tableData.map((item, index) => ({
780
+      rank: index + 1,
781
+      department: item.brigadeName || '未知大队',
782
+      percentage: item.currentRatio || 0,
783
+      count: item.seizureCount || 0
784
+    }))
785
+
786
+    // 更新表格数据
787
+    captureRankData.value = formattedData
788
+
789
+    console.log('大队查获排名表格已更新:', formattedData)
790
+  } else {
791
+    // 无数据时清空表格
792
+    captureRankData.value = []
793
+  }
794
+}
795
+
796
+// 更新查获岗位分布饼图数据
797
+const updatePostCategoryStatsChart = () => {
798
+  if (postCategoryStatsData.value && postCategoryStatsData.value) {
799
+    const pieData = postCategoryStatsData.value
800
+
801
+    // 转换数据格式
802
+    const formattedData = pieData.map(item => ({
803
+      name: item.postName || '未知岗位',
804
+      value: item.quantity || 0
805
+    }))
806
+
807
+    // 更新饼图数据
808
+    rectificationStatsOptions.series[0].data = formattedData
809
+
810
+    // 重新设置图表选项
811
+    setRectificationStatsOption(rectificationStatsOptions)
812
+
813
+    console.log('查获岗位分布饼图已更新:', formattedData)
814
+  } else {
815
+    // 无数据时清空图表
816
+    rectificationStatsOptions.series[0].data = []
817
+    setRectificationStatsOption(rectificationStatsOptions)
818
+  }
819
+}
820
+
821
+// 更新查获通道TOP5表格数据
822
+const updateChannelRankingStatsTable = () => {
823
+  if (channelRankingStatsData.value && channelRankingStatsData.value) {
824
+    const tableData = channelRankingStatsData.value
825
+
826
+    // 转换数据格式
827
+    const formattedData = tableData.map(item => ({
828
+      channel: item.channelName || '未知通道',
829
+      count: item.seizureQuantity || 0,
830
+      area: item.regionalName || '未知区域'
831
+    }))
832
+
833
+    // 更新表格数据
834
+    captureChannelData.value = formattedData
835
+
836
+    console.log('查获通道TOP5表格已更新:', formattedData)
837
+  } else {
838
+    // 无数据时清空表格
839
+    captureChannelData.value = []
840
+  }
841
+}
842
+
843
+// 组件卸载时销毁图表
844
+onUnmounted(() => {
845
+  disposeCaptureRank()
846
+  disposeProblemDiscovery()
847
+  disposeProblemRectification()
848
+  disposeRectificationStats()
849
+})
850
+</script>
851
+
852
+<style scoped>
853
+.risk-hazard {
854
+  width: 100%;
855
+}
856
+
857
+/* 风险隐患标题 */
858
+.section-title {
859
+  margin: 14px 0 14px 0;
860
+  text-align: left;
861
+}
862
+
863
+.section-title h2 {
864
+  font-size: 24px;
865
+  font-weight: 600;
866
+  color: #333;
867
+  margin: 0;
868
+}
869
+
870
+/* 六个部分布局 */
871
+.six-panel-layout {
872
+  display: flex;
873
+  flex-direction: column;
874
+  gap: 16px;
875
+}
876
+
877
+.panel-row {
878
+  display: flex;
879
+  gap: 16px;
880
+}
881
+
882
+.panel-item {
883
+  flex: 1;
884
+  background: white;
885
+  border-radius: 8px;
886
+  padding: 20px;
887
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
888
+  display: flex;
889
+  flex-direction: column;
890
+  gap: 16px;
891
+}
892
+
893
+.panel-header {
894
+  padding-bottom: 0;
895
+}
896
+
897
+.panel-header h3 {
898
+  font-size: 16px;
899
+  font-weight: 600;
900
+  color: #333;
901
+  margin: 0;
902
+}
903
+
904
+/* 描述卡片样式 */
905
+.describe-card {
906
+  background: #F8F8F8;
907
+  border: 1px dashed #CDCCCC;
908
+  border-radius: 6px;
909
+  padding: 7px 10px;
910
+  text-align: left;
911
+}
912
+
913
+.describe-content {
914
+
915
+  font-size: 13px;
916
+  color: #666;
917
+  line-height: 1.5;
918
+}
919
+
920
+.stat-number {
921
+  font-weight: 600;
922
+  color: #557DDB;
923
+  font-size: 16px;
924
+}
925
+
926
+.stat-trend {
927
+  font-weight: 500;
928
+}
929
+
930
+.stat-trend.up {
931
+  color: #67C23A;
932
+}
933
+
934
+.stat-trend.down {
935
+  color: #F56C6C;
936
+}
937
+
938
+/* 图表容器 */
939
+.chart-container {
940
+  /* background: #F8F8F8;
941
+  border-radius: 6px;
942
+  padding: 16px; */
943
+  min-height: 200px;
944
+  height: 250px;
945
+  position: relative;
946
+  box-sizing: border-box;
947
+  overflow: hidden;
948
+  display: flex;
949
+  align-items: center;
950
+  justify-content: center;
951
+  flex-shrink: 0;
952
+  /* 防止被压缩 */
953
+}
954
+
955
+/* 表格容器 */
956
+.table-container {
957
+  /* background: #F8F8F8; */
958
+  flex: 1;
959
+  border: 1px solid #E4E7ED;
960
+  border-radius: 4px;
961
+
962
+}
963
+
964
+/* 表格偶数行背景色 */
965
+.table-container :deep(.el-table__body tr:nth-child(even)) {
966
+  background-color: #F8F8F8;
967
+}
968
+
969
+/* 表格样式 - 列头和列内容 */
970
+.table-container :deep(.el-table__header th),
971
+.table-container :deep(.el-table__body td) {
972
+  font-weight: 500;
973
+  font-size: 13px;
974
+  color: #333333;
975
+}
976
+
977
+.percentage {
978
+  color: #557DDB;
979
+  font-weight: 600;
980
+}
981
+
982
+/* ECharts图表样式 */
983
+.echarts-chart {
984
+  width: 100% !important;
985
+  height: 100% !important;
986
+  min-height: 200px;
987
+  display: block;
988
+}
989
+
990
+/* 响应式设计 */
991
+@media (max-width: 768px) {
992
+  .panel-row {
993
+    flex-direction: column;
994
+  }
995
+}
996
+</style>

+ 543 - 0
src/views/assistant/index.vue

@@ -0,0 +1,543 @@
1
+<template>
2
+  <div class="ai-assist-wrapper">
3
+    <div class="ai-assist-container" v-if="!showDataBoard && !showPerformanceAnalysis">
4
+
5
+      <!-- 消息区域 -->
6
+      <div class="messages-area" ref="messagesRef">
7
+        <div v-if="messages.length === 0" class="welcome-message">
8
+          <h2>您好,今天有什么可以帮到您</h2>
9
+        </div>
10
+        <div v-else class="messages-list">
11
+          <div v-for="msg in messages" :key="msg.id" :class="['message-item', msg.type]">
12
+
13
+            <!-- 用户消息 -->
14
+            <div v-if="msg.type === 'user'" class="user-bubble">{{ msg.text }}</div>
15
+
16
+            <!-- AI 回复 -->
17
+            <div v-else class="assistant-bubble">
18
+              <!-- 进度步骤 -->
19
+              <div v-if="msg.steps.length" class="progress-steps">
20
+                <div v-for="step in msg.steps" :key="step.step" :class="['step-item', step.status]">
21
+                  <span class="step-icon">
22
+                    <svg v-if="step.status === 'running'" class="spin-icon" viewBox="0 0 24 24" fill="none">
23
+                      <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" stroke-dasharray="32" stroke-dashoffset="10"/>
24
+                    </svg>
25
+                    <svg v-else viewBox="0 0 24 24" fill="none">
26
+                      <path d="M5 13l4 4L19 7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
27
+                    </svg>
28
+                  </span>
29
+                  <span class="step-name">{{ step.name }}</span>
30
+                  <span v-if="step.detail" class="step-detail">{{ step.detail }}</span>
31
+                  <span v-if="step.duration != null" class="step-duration">{{ step.duration }}s</span>
32
+                </div>
33
+              </div>
34
+
35
+              <!-- 结果内容 -->
36
+              <div v-if="msg.result" class="result-content">
37
+
38
+                <!-- 知识问答 -->
39
+                <div v-if="msg.result.query_type === 'knowledge'" class="knowledge-answer">
40
+                  <div class="answer-text">{{ msg.result.answer }}</div>
41
+                  <div v-if="msg.result.sources && msg.result.sources.length" class="sources">
42
+                    <span class="sources-label">参考来源:</span>
43
+                    <el-tag v-for="s in msg.result.sources" :key="s.filename" size="small" class="source-tag">
44
+                      {{ s.filename }}
45
+                    </el-tag>
46
+                  </div>
47
+                </div>
48
+
49
+                <!-- 数据查询 / 混合查询 -->
50
+                <div v-if="msg.result.query_type === 'data' || msg.result.query_type === 'hybrid'">
51
+                  <div v-if="msg.result.answer" class="knowledge-answer" style="margin-bottom: 12px;">
52
+                    <div class="answer-text">{{ msg.result.answer }}</div>
53
+                  </div>
54
+                  <div class="result-summary">{{ msg.result.message }}</div>
55
+                  <el-table
56
+                    v-if="msg.result.rows && msg.result.rows.length"
57
+                    :data="msg.result.rows"
58
+                    border
59
+                    size="small"
60
+                    max-height="360"
61
+                    class="result-table"
62
+                  >
63
+                    <el-table-column
64
+                      v-for="col in msg.result.columns"
65
+                      :key="col"
66
+                      :prop="col"
67
+                      :label="col"
68
+                      min-width="100"
69
+                    />
70
+                  </el-table>
71
+                  <div v-else-if="msg.result.rows" class="no-data">暂无数据</div>
72
+                </div>
73
+
74
+                <!-- 耗时 -->
75
+                <div v-if="msg.result.timing && msg.result.timing.total" class="timing-info">
76
+                  共耗时 {{ msg.result.timing.total }}s
77
+                </div>
78
+              </div>
79
+
80
+              <!-- 错误 -->
81
+              <div v-if="msg.status === 'error'" class="error-message">
82
+                <svg viewBox="0 0 24 24" fill="none" width="16" height="16">
83
+                  <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2"/>
84
+                  <path d="M12 8v4m0 4h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
85
+                </svg>
86
+                {{ msg.errorText }}
87
+              </div>
88
+            </div>
89
+          </div>
90
+        </div>
91
+      </div>
92
+
93
+      <!-- 底部输入区 -->
94
+      <div class="chat-container">
95
+        <div class="quick-action">
96
+          <el-button class="report-btn" @click="handleReportClick">质控分析报告</el-button>
97
+          <el-button class="report-btn" @click="handlePerformanceClick">绩效分析报告</el-button>
98
+        </div>
99
+        <div class="input-container">
100
+          <el-input
101
+            v-model="inputMessage"
102
+            type="textarea"
103
+            :rows="3"
104
+            placeholder="请输入您的问题,按 Enter 发送..."
105
+            class="chat-input"
106
+            @keydown.enter.exact.prevent="handleSend"
107
+          />
108
+          <el-button
109
+            :disabled="!inputMessage.trim() || isLoading"
110
+            circle
111
+            class="send-btn"
112
+            @click="handleSend"
113
+          >
114
+            <svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18">
115
+              <path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/>
116
+            </svg>
117
+          </el-button>
118
+        </div>
119
+      </div>
120
+    </div>
121
+
122
+    <DataBoard v-if="showDataBoard" @back="handleBack" />
123
+    <PerformanceAnalysis v-if="showPerformanceAnalysis" @back="handleBack" />
124
+  </div>
125
+</template>
126
+
127
+<script setup>
128
+import { ref, nextTick, onMounted, onActivated, onDeactivated } from 'vue'
129
+import DataBoard from './components/dataBoard.vue'
130
+import PerformanceAnalysis from './components/performanceAnalysis.vue'
131
+import useUserStore from '@/store/modules/user'
132
+
133
+const userStore = useUserStore()
134
+const AI_SERVICE_URL = import.meta.env.VITE_AI_SERVICE_URL || 'http://localhost:8000'
135
+
136
+const inputMessage = ref('')
137
+const isLoading = ref(false)
138
+const messages = ref([])
139
+const messagesRef = ref(null)
140
+const showDataBoard = ref(false)
141
+const showPerformanceAnalysis = ref(false)
142
+
143
+onMounted(() => resetState())
144
+onActivated(() => resetState())
145
+onDeactivated(() => resetState())
146
+
147
+const resetState = () => {
148
+  showDataBoard.value = false
149
+  showPerformanceAnalysis.value = false
150
+  inputMessage.value = ''
151
+  messages.value = []
152
+  isLoading.value = false
153
+}
154
+
155
+const scrollToBottom = async () => {
156
+  await nextTick()
157
+  if (messagesRef.value) {
158
+    messagesRef.value.scrollTop = messagesRef.value.scrollHeight
159
+  }
160
+}
161
+
162
+const handleSend = async () => {
163
+  const text = inputMessage.value.trim()
164
+  if (!text || isLoading.value) return
165
+
166
+  inputMessage.value = ''
167
+  isLoading.value = true
168
+
169
+  // 添加用户消息
170
+  messages.value.push({ id: Date.now(), type: 'user', text })
171
+  await scrollToBottom()
172
+
173
+  // 添加 AI 占位消息
174
+  const assistantMsg = {
175
+    id: Date.now() + 1,
176
+    type: 'assistant',
177
+    status: 'loading',
178
+    steps: [],
179
+    result: null,
180
+    errorText: ''
181
+  }
182
+  messages.value.push(assistantMsg)
183
+  await scrollToBottom()
184
+
185
+  try {
186
+    const response = await fetch(`${AI_SERVICE_URL}/api/smart-query-stream`, {
187
+      method: 'POST',
188
+      headers: { 'Content-Type': 'application/json' },
189
+      body: JSON.stringify({
190
+        question: text,
191
+        user_id: String(userStore.id || 'anonymous'),
192
+        llm_type: 'claude'
193
+      })
194
+    })
195
+
196
+    if (!response.ok) {
197
+      throw new Error(`服务请求失败 (${response.status})`)
198
+    }
199
+
200
+    const reader = response.body.getReader()
201
+    const decoder = new TextDecoder()
202
+    let buffer = ''
203
+
204
+    while (true) {
205
+      const { done, value } = await reader.read()
206
+      if (done) break
207
+
208
+      buffer += decoder.decode(value, { stream: true })
209
+      // SSE 事件以两个换行符分隔
210
+      const blocks = buffer.split('\n\n')
211
+      buffer = blocks.pop() // 保留未完整的块
212
+
213
+      for (const block of blocks) {
214
+        if (!block.trim()) continue
215
+        const lines = block.split('\n')
216
+        let eventType = ''
217
+        let dataStr = ''
218
+        for (const line of lines) {
219
+          if (line.startsWith('event: ')) eventType = line.slice(7).trim()
220
+          else if (line.startsWith('data: ')) dataStr = line.slice(6).trim()
221
+        }
222
+        if (!dataStr) continue
223
+
224
+        let data
225
+        try { data = JSON.parse(dataStr) } catch { continue }
226
+
227
+        if (eventType === 'progress') {
228
+          const existing = assistantMsg.steps.find(s => s.step === data.step)
229
+          if (existing) {
230
+            Object.assign(existing, data)
231
+          } else {
232
+            assistantMsg.steps.push({ ...data })
233
+          }
234
+          // 触发响应式更新
235
+          messages.value = [...messages.value]
236
+          await scrollToBottom()
237
+
238
+        } else if (eventType === 'result') {
239
+          // 将所有仍在 running 的步骤标记为 done
240
+          assistantMsg.steps.forEach(s => { if (s.status === 'running') s.status = 'done' })
241
+          assistantMsg.result = data
242
+          assistantMsg.status = 'done'
243
+          messages.value = [...messages.value]
244
+          await scrollToBottom()
245
+
246
+        } else if (eventType === 'error') {
247
+          assistantMsg.status = 'error'
248
+          assistantMsg.errorText = data.message || '请求失败,请稍后重试'
249
+          messages.value = [...messages.value]
250
+        }
251
+      }
252
+    }
253
+
254
+    // 若未收到 result 事件
255
+    if (assistantMsg.status === 'loading') {
256
+      assistantMsg.status = 'error'
257
+      assistantMsg.errorText = '未收到服务响应,请检查 AI 服务是否正常运行'
258
+      messages.value = [...messages.value]
259
+    }
260
+
261
+  } catch (e) {
262
+    assistantMsg.status = 'error'
263
+    assistantMsg.errorText = e.message || '网络错误,请检查 AI 服务连接'
264
+    messages.value = [...messages.value]
265
+  } finally {
266
+    isLoading.value = false
267
+    await scrollToBottom()
268
+  }
269
+}
270
+
271
+const handleReportClick = () => { showDataBoard.value = true }
272
+const handlePerformanceClick = () => { showPerformanceAnalysis.value = true }
273
+const handleBack = () => {
274
+  showDataBoard.value = false
275
+  showPerformanceAnalysis.value = false
276
+}
277
+</script>
278
+
279
+<style scoped>
280
+.ai-assist-wrapper {
281
+  background: #f5f5f5;
282
+  min-height: 100vh;
283
+}
284
+
285
+.ai-assist-container {
286
+  height: 100vh;
287
+  display: flex;
288
+  flex-direction: column;
289
+  background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
290
+  padding: 0 20px 20px;
291
+  box-sizing: border-box;
292
+}
293
+
294
+/* ===== 消息区域 ===== */
295
+.messages-area {
296
+  flex: 1;
297
+  overflow-y: auto;
298
+  padding: 20px 0;
299
+}
300
+
301
+.welcome-message {
302
+  height: 100%;
303
+  display: flex;
304
+  align-items: center;
305
+  justify-content: center;
306
+}
307
+
308
+.welcome-message h2 {
309
+  color: #333;
310
+  font-size: 28px;
311
+  font-weight: 400;
312
+  margin: 0;
313
+  opacity: 0.8;
314
+}
315
+
316
+.messages-list {
317
+  display: flex;
318
+  flex-direction: column;
319
+  gap: 16px;
320
+}
321
+
322
+.message-item {
323
+  display: flex;
324
+}
325
+
326
+.message-item.user {
327
+  justify-content: flex-end;
328
+}
329
+
330
+.message-item.assistant {
331
+  justify-content: flex-start;
332
+}
333
+
334
+/* 用户气泡 */
335
+.user-bubble {
336
+  max-width: 70%;
337
+  background: #557DDB;
338
+  color: #fff;
339
+  padding: 10px 16px;
340
+  border-radius: 18px 18px 4px 18px;
341
+  font-size: 14px;
342
+  line-height: 1.6;
343
+  word-break: break-word;
344
+}
345
+
346
+/* AI 气泡 */
347
+.assistant-bubble {
348
+  max-width: 80%;
349
+  background: #fff;
350
+  padding: 14px 16px;
351
+  border-radius: 18px 18px 18px 4px;
352
+  box-shadow: 0 2px 8px rgba(0,0,0,0.08);
353
+  font-size: 14px;
354
+  line-height: 1.6;
355
+}
356
+
357
+/* ===== 进度步骤 ===== */
358
+.progress-steps {
359
+  display: flex;
360
+  flex-direction: column;
361
+  gap: 6px;
362
+  margin-bottom: 10px;
363
+}
364
+
365
+.step-item {
366
+  display: flex;
367
+  align-items: center;
368
+  gap: 6px;
369
+  font-size: 13px;
370
+  color: #888;
371
+}
372
+
373
+.step-item.done {
374
+  color: #67c23a;
375
+}
376
+
377
+.step-item.running {
378
+  color: #557DDB;
379
+}
380
+
381
+.step-icon {
382
+  width: 16px;
383
+  height: 16px;
384
+  flex-shrink: 0;
385
+  display: flex;
386
+  align-items: center;
387
+  justify-content: center;
388
+}
389
+
390
+.spin-icon {
391
+  animation: spin 1s linear infinite;
392
+}
393
+
394
+@keyframes spin {
395
+  from { transform: rotate(0deg); }
396
+  to { transform: rotate(360deg); }
397
+}
398
+
399
+.step-detail {
400
+  color: #aaa;
401
+  font-size: 12px;
402
+}
403
+
404
+.step-duration {
405
+  color: #aaa;
406
+  font-size: 12px;
407
+  margin-left: auto;
408
+}
409
+
410
+/* ===== 结果内容 ===== */
411
+.result-content {
412
+  margin-top: 4px;
413
+}
414
+
415
+.knowledge-answer .answer-text {
416
+  color: #333;
417
+  white-space: pre-wrap;
418
+  line-height: 1.8;
419
+}
420
+
421
+.sources {
422
+  margin-top: 10px;
423
+  display: flex;
424
+  align-items: center;
425
+  flex-wrap: wrap;
426
+  gap: 6px;
427
+}
428
+
429
+.sources-label {
430
+  font-size: 12px;
431
+  color: #999;
432
+}
433
+
434
+.source-tag {
435
+  font-size: 12px;
436
+}
437
+
438
+.result-summary {
439
+  color: #666;
440
+  font-size: 13px;
441
+  margin-bottom: 8px;
442
+}
443
+
444
+.result-table {
445
+  width: 100%;
446
+}
447
+
448
+.no-data {
449
+  color: #999;
450
+  font-size: 13px;
451
+  text-align: center;
452
+  padding: 12px 0;
453
+}
454
+
455
+.timing-info {
456
+  margin-top: 10px;
457
+  font-size: 12px;
458
+  color: #bbb;
459
+  text-align: right;
460
+}
461
+
462
+.error-message {
463
+  display: flex;
464
+  align-items: center;
465
+  gap: 6px;
466
+  color: #f56c6c;
467
+  font-size: 13px;
468
+}
469
+
470
+/* ===== 底部输入区 ===== */
471
+.chat-container {
472
+  flex-shrink: 0;
473
+}
474
+
475
+.quick-action {
476
+  margin-bottom: 10px;
477
+  display: flex;
478
+  gap: 8px;
479
+}
480
+
481
+.report-btn {
482
+  color: #557DDB;
483
+  border: 1px solid #557DDB;
484
+  padding: 8px 16px;
485
+  border-radius: 4px;
486
+  font-size: 14px;
487
+}
488
+
489
+.report-btn:hover {
490
+  background-color: #557DDB;
491
+  color: white;
492
+}
493
+
494
+.input-container {
495
+  position: relative;
496
+}
497
+
498
+.chat-input :deep(.el-textarea__inner) {
499
+  border: 1px solid #e0e0e0;
500
+  border-radius: 12px;
501
+  padding: 16px;
502
+  font-size: 14px;
503
+  resize: none;
504
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
505
+  padding-right: 60px;
506
+}
507
+
508
+.send-btn {
509
+  position: absolute;
510
+  right: 8px;
511
+  bottom: 8px;
512
+  background-color: #557DDB;
513
+  color: white;
514
+  border: none;
515
+  display: flex;
516
+  align-items: center;
517
+  justify-content: center;
518
+  z-index: 10;
519
+}
520
+
521
+.send-btn:hover:not(.is-disabled) {
522
+  background-color: #3a6bd9;
523
+}
524
+
525
+.send-btn.is-disabled {
526
+  background-color: #c0c4cc;
527
+  cursor: not-allowed;
528
+}
529
+
530
+@media (max-width: 768px) {
531
+  .ai-assist-container {
532
+    padding: 0 16px 16px;
533
+  }
534
+
535
+  .welcome-message h2 {
536
+    font-size: 24px;
537
+  }
538
+
539
+  .user-bubble, .assistant-bubble {
540
+    max-width: 95%;
541
+  }
542
+}
543
+</style>