wangxx 4 miesięcy temu
rodzic
commit
e314f401f0
94 zmienionych plików z 32789 dodań i 0 usunięć
  1. 182 0
      src/pages/announcement/announcementDetail.vue
  2. 204 0
      src/pages/announcement/index.vue
  3. 182 0
      src/pages/announcement/noticeDetail.vue
  4. 724 0
      src/pages/attendance/components/AddAttendancePersonnelModal.vue
  5. 273 0
      src/pages/attendance/components/AttendanceControl.vue
  6. 565 0
      src/pages/attendance/components/MaintainAreaOrMemberModal.vue
  7. 275 0
      src/pages/attendance/components/SearchView.vue
  8. 340 0
      src/pages/attendance/components/SelectArea.vue
  9. 75 0
      src/pages/attendance/components/SelectData.vue
  10. 59 0
      src/pages/attendance/components/TimePicker.vue
  11. 55 0
      src/pages/attendance/components/UserAvatar.vue
  12. 510 0
      src/pages/attendance/components/WorkingGroup.vue
  13. 368 0
      src/pages/attendance/index.vue
  14. 214 0
      src/pages/attendance/stats.vue
  15. 78 0
      src/pages/attendanceStatistics/components/TableList.vue
  16. 525 0
      src/pages/attendanceStatistics/index.vue
  17. 315 0
      src/pages/checklist/components/SelectPerson.vue
  18. 121 0
      src/pages/checklist/components/SubmitResult.vue
  19. 215 0
      src/pages/checklist/components/unqualified.vue
  20. 881 0
      src/pages/checklist/index.vue
  21. 43 0
      src/pages/common/textview/index.vue
  22. 34 0
      src/pages/common/webview/index.vue
  23. 785 0
      src/pages/daily-exam/answer/index.vue
  24. 451 0
      src/pages/daily-exam/task-list/index.vue
  25. 100 0
      src/pages/eikonStatistics/components/Attendance.vue
  26. 134 0
      src/pages/eikonStatistics/components/LearningGrowth.vue
  27. 171 0
      src/pages/eikonStatistics/components/MemberDetails.vue
  28. 262 0
      src/pages/eikonStatistics/components/Qualification.vue
  29. 352 0
      src/pages/eikonStatistics/components/SubjectiveImpression.vue
  30. 96 0
      src/pages/eikonStatistics/components/WorkExperience.vue
  31. 246 0
      src/pages/eikonStatistics/components/WorkOutput.vue
  32. 123 0
      src/pages/eikonStatistics/components/box.vue
  33. 977 0
      src/pages/eikonStatistics/components/collaboration.vue
  34. 143 0
      src/pages/eikonStatistics/components/eikon-collapse-item.vue
  35. 450 0
      src/pages/eikonStatistics/components/general-overview.vue
  36. 786 0
      src/pages/eikonStatistics/components/standard-execution.vue
  37. 143 0
      src/pages/eikonStatistics/components/switch-tab.vue
  38. 387 0
      src/pages/eikonStatistics/components/topInfo.vue
  39. 528 0
      src/pages/eikonStatistics/index.vue
  40. 426 0
      src/pages/exam-list/index.vue
  41. 383 0
      src/pages/exam/components/question-card.vue
  42. 165 0
      src/pages/exam/components/question-done.vue
  43. 275 0
      src/pages/exam/components/question-result.vue
  44. 462 0
      src/pages/exam/index.vue
  45. 205 0
      src/pages/home/components/announcement.vue
  46. 110 0
      src/pages/home/components/myToDo.vue
  47. 157 0
      src/pages/home/components/notice.vue
  48. 124 0
      src/pages/home/components/task.vue
  49. 372 0
      src/pages/home/index.vue
  50. 36 0
      src/pages/index.vue
  51. 330 0
      src/pages/inspectionChecklist/index.vue
  52. 272 0
      src/pages/inspectionRecord/detail.vue
  53. 1181 0
      src/pages/inspectionRecord/index.vue
  54. 252 0
      src/pages/inspectionRecordList/index.vue
  55. 109 0
      src/pages/inspectionStatistics/components/DataCard.vue
  56. 220 0
      src/pages/inspectionStatistics/components/InspectionExecution.vue
  57. 536 0
      src/pages/inspectionStatistics/components/InspectionPlan.vue
  58. 980 0
      src/pages/inspectionStatistics/components/ProblemDiscovery.vue
  59. 383 0
      src/pages/inspectionStatistics/components/ProblemRectification.vue
  60. 89 0
      src/pages/inspectionStatistics/components/StaticsTab.vue
  61. 94 0
      src/pages/inspectionStatistics/index.vue
  62. 249 0
      src/pages/login.vue
  63. 75 0
      src/pages/mine/about/index.vue
  64. 618 0
      src/pages/mine/avatar/index.vue
  65. 112 0
      src/pages/mine/help/index.vue
  66. 194 0
      src/pages/mine/index.vue
  67. 127 0
      src/pages/mine/info/edit.vue
  68. 44 0
      src/pages/mine/info/index.vue
  69. 85 0
      src/pages/mine/pwd/index.vue
  70. 78 0
      src/pages/mine/setting/index.vue
  71. 634 0
      src/pages/myToDoList/index.vue
  72. 551 0
      src/pages/personCount/index.vue
  73. 142 0
      src/pages/personal-center/index.vue
  74. 183 0
      src/pages/problemRect/components/RejectModal.vue
  75. 843 0
      src/pages/problemRect/index.vue
  76. 0 0
      src/pages/problemRect/问题记录及整改单
  77. 199 0
      src/pages/questionStatistics/index.vue
  78. 189 0
      src/pages/register.vue
  79. 164 0
      src/pages/seizeStatistics/components/switch-tab.vue
  80. 1492 0
      src/pages/seizeStatistics/index.vue
  81. 1238 0
      src/pages/seizedReported/index.vue
  82. 1531 0
      src/pages/seizedReportedVoice/index.vue
  83. 732 0
      src/pages/seizureRecord/detail.vue
  84. 528 0
      src/pages/seizureRecord/index.vue
  85. 41 0
      src/pages/train/index.vue
  86. 225 0
      src/pages/voiceSubmissionDraft/index.vue
  87. 223 0
      src/pages/work/index.vue
  88. 205 0
      src/pages/workDocu/index.vue
  89. 509 0
      src/pages/workDocu/workDocuDetail.vue
  90. 441 0
      src/pages/workProfile/components/management-push.vue
  91. 334 0
      src/pages/workProfile/components/organization-support.vue
  92. 150 0
      src/pages/workProfile/components/profile-collapse-item.vue
  93. 622 0
      src/pages/workProfile/components/work-output.vue
  94. 498 0
      src/pages/workProfile/index.vue

+ 182 - 0
src/pages/announcement/announcementDetail.vue

@@ -0,0 +1,182 @@
1
+<template>
2
+    <home-container :customStyle="{ background: 'none' }" :customHomeStyle="{ overflow: 'hidden' }">
3
+        <view class="detail-container" v-if="detailData">
4
+            <!-- 公告标题 -->
5
+            <view class="title">{{ detailData.noticeTitle }}</view>
6
+            
7
+            <!-- 发布信息 -->
8
+            <view class="meta-info">
9
+                <text class="author">{{ detailData.createUser || '管理员' }}</text>
10
+                <text class="date">{{ formatDate(detailData.createTime) }}</text>
11
+            </view>
12
+            
13
+            <!-- 分隔线 -->
14
+            <view class="divider"></view>
15
+            
16
+            <!-- 富文本内容 -->
17
+            <view class="content" v-html="detailData.noticeContent"></view>
18
+        </view>
19
+        
20
+        <!-- 加载状态 -->
21
+        <view v-else class="loading">加载中...</view>
22
+    </home-container>
23
+</template>
24
+
25
+<script>
26
+import HomeContainer from "@/components/HomeContainer.vue";
27
+import { getNoticeDetail } from "@/api/announcement/announcement.js";
28
+
29
+
30
+export default {
31
+    components: {
32
+        HomeContainer
33
+    },
34
+    data() {
35
+        return {
36
+            detailData: null,
37
+            loading: true
38
+        };
39
+    },
40
+    onLoad(options) {
41
+        
42
+        // 从路由参数中获取公告ID
43
+        if (options && options.id) {
44
+            this.getDetailData(options.id);
45
+        } else {
46
+            console.error('公告ID不存在');
47
+            uni.showToast({
48
+                title: '获取公告详情失败',
49
+                icon: 'none'
50
+            });
51
+        }
52
+    },
53
+    methods: {
54
+        // 格式化日期
55
+        formatDate(timeString) {
56
+            if (!timeString) return '';
57
+            const date = new Date(timeString);
58
+            const year = date.getFullYear();
59
+            const month = String(date.getMonth() + 1).padStart(2, '0');
60
+            const day = String(date.getDate()).padStart(2, '0');
61
+            const hour = String(date.getHours()).padStart(2, '0');
62
+            const minute = String(date.getMinutes()).padStart(2, '0');
63
+            return `${year}-${month}-${day} ${hour}:${minute}`;
64
+        },
65
+        
66
+        // 处理富文本中的图片
67
+        handleRichTextImages(html) {
68
+            if (!html) return '';
69
+            
70
+            // 为所有img标签添加style="width:90%",同时保留原有的style属性
71
+            return html.replace(/<img([^>]+)>/g, (match, attributes) => {
72
+                // 检查是否已有style属性
73
+                if (attributes.includes('style=')) {
74
+                    // 如果已有style属性,在其末尾添加width设置
75
+                    return `<img${attributes.replace(/style=["']([^"']*)["']/, (styleMatch, styleValue) => {
76
+                        return `style="${styleValue} width:90%;"`;
77
+                    })}>`;
78
+                } else {
79
+                    // 如果没有style属性,添加一个包含width设置的style属性
80
+                    return `<img${attributes} style="width:90%;">`;
81
+                }
82
+            });
83
+        },
84
+
85
+        // 获取公告详情数据
86
+        async getDetailData(id) {
87
+            try {
88
+                this.loading = true;
89
+                const response = await getNoticeDetail(id);
90
+                
91
+                // 深拷贝响应数据
92
+                this.detailData = JSON.parse(JSON.stringify(response.data));
93
+                
94
+                // 处理noticeContent中的图片样式
95
+                if (this.detailData.noticeContent) {
96
+                    this.detailData.noticeContent = this.handleRichTextImages(this.detailData.noticeContent);
97
+                }
98
+            } catch (error) {
99
+                console.error('获取公告详情失败:', error);
100
+                uni.showToast({
101
+                    title: '获取公告详情失败',
102
+                    icon: 'none'
103
+                });
104
+            } finally {
105
+                this.loading = false;
106
+            }
107
+        }
108
+    }
109
+};
110
+</script>
111
+
112
+<style lang="scss" scoped>
113
+.detail-container {
114
+    padding: 32rpx;
115
+    background-color: #ffffff;
116
+    min-height: calc(100vh - 177rpx);
117
+    box-sizing: border-box;
118
+    
119
+    // 标题样式
120
+    .title {
121
+        font-size: 36rpx;
122
+        font-weight: bold;
123
+        color: #333333;
124
+        line-height: 52rpx;
125
+        margin-bottom: 24rpx;
126
+    }
127
+    
128
+    // 元信息样式
129
+    .meta-info {
130
+        display: flex;
131
+        justify-content: space-between;
132
+        align-items: center;
133
+        margin-bottom: 24rpx;
134
+        
135
+        .author,
136
+        .date {
137
+            font-size: 28rpx;
138
+            color: #666666;
139
+        }
140
+    }
141
+    
142
+    // 分隔线样式
143
+    .divider {
144
+        height: 1rpx;
145
+        background-color: #e5e5e5;
146
+        margin: 32rpx 0;
147
+    }
148
+    
149
+    // 富文本内容样式
150
+    .content {
151
+        font-size: 28rpx;
152
+        color: #333333;
153
+        line-height: 48rpx;
154
+        
155
+        // 富文本内部样式
156
+        :deep() {
157
+            p {
158
+                margin-bottom: 20rpx;
159
+            }
160
+            img {
161
+                max-width: 100%;
162
+                height: auto;
163
+                margin: 16rpx 0;
164
+            }
165
+            a {
166
+                color: #007aff;
167
+                text-decoration: underline;
168
+            }
169
+        }
170
+    }
171
+}
172
+
173
+// 加载状态样式
174
+.loading {
175
+    display: flex;
176
+    justify-content: center;
177
+    align-items: center;
178
+    height: 400rpx;
179
+    font-size: 28rpx;
180
+    color: #999999;
181
+}
182
+</style>

+ 204 - 0
src/pages/announcement/index.vue

@@ -0,0 +1,204 @@
1
+<template>
2
+    <home-container :customStyle="{ background: 'none' }" :customHomeStyle="{ overflow: 'hidden' }">
3
+        <view class="announcement-list">
4
+            <scroll-view scroll-y="true" @refresherrefresh="onRefresh" refresher-enabled
5
+                :refresher-triggered="refresherTriggered" @scrolltolower="loadMore" :style="{ height: '100%' }"
6
+                enable-back-to-top="true">
7
+                <list-card v-for="item in list" :key="item.id" @click="navigateToDetail(item)">
8
+                    <template #title>
9
+                        <view class="list-title">
10
+                            {{ item.noticeTitle }}
11
+                        </view>
12
+                    </template>
13
+                    <template #default>
14
+                        <view class="list-info">
15
+                            <view class="list-meta">
16
+                                <view class="author">{{ item.createUser || '管理员' }}</view>
17
+                                <view class="date">{{ formatDate(item.createTime) }}</view>
18
+                            </view>
19
+                            <view class="content">
20
+                                {{ stripHtmlTags(item.noticeContent) }}
21
+                            </view>
22
+                        </view>
23
+                    </template>
24
+                </list-card>
25
+
26
+                <!-- 加载状态提示 -->
27
+                <view v-if="loading" class="load-more">
28
+                    <text>加载中...</text>
29
+                </view>
30
+                <view v-else-if="!hasMore && list.length > 0" class="no-more">
31
+                    <text>没有更多数据了</text>
32
+                </view>
33
+                <view v-else-if="list.length === 0" class="no-data">
34
+                    <text>暂无公告</text>
35
+                </view>
36
+            </scroll-view>
37
+        </view>
38
+    </home-container>
39
+</template>
40
+
41
+<script>
42
+import HomeContainer from "@/components/HomeContainer.vue";
43
+import ListCard from "@/components/list-card/list-card.vue";
44
+import { getNoticeList } from "@/api/announcement/announcement.js";
45
+
46
+export default {
47
+    components: {
48
+        HomeContainer,
49
+        ListCard
50
+    },
51
+    data() {
52
+        return {
53
+            list: [],
54
+            // 分页参数
55
+            pageNum: 1,
56
+            pageSize: 10,
57
+            total: 0,
58
+            loading: false,
59
+            hasMore: true,
60
+            // scroll-view下拉刷新状态控制
61
+            refresherTriggered: false
62
+        }
63
+    },
64
+    methods: {
65
+        // 格式化日期
66
+        formatDate(timeString) {
67
+            if (!timeString) return '';
68
+            const date = new Date(timeString);
69
+            const year = date.getFullYear();
70
+            const month = String(date.getMonth() + 1).padStart(2, '0');
71
+            const day = String(date.getDate()).padStart(2, '0');
72
+            return `${year}-${month}-${day}`;
73
+        },
74
+
75
+        // 去除HTML标签,只保留纯文本
76
+        stripHtmlTags(html) {
77
+            if (!html) return '';
78
+            // 使用正则表达式去除HTML标签
79
+            return html.replace(/<[^>]+>/g, '');
80
+        },
81
+        // 跳转到详情页(如果有的话)
82
+        navigateToDetail(item) {
83
+            // 这里可以根据需要跳转到公告详情页
84
+            // 目前先打印日志
85
+            uni.navigateTo({
86
+                url: '/pages/announcement/announcementDetail?id=' + item.noticeId
87
+            });
88
+
89
+        },
90
+        onRefresh() {
91
+            this.pageNum = 1;
92
+            this.refresherTriggered = true;
93
+            this.loadData();
94
+        },
95
+        // 加载数据方法
96
+        async loadData() {
97
+            if (this.loading) return;
98
+
99
+            this.loading = true;
100
+            try {
101
+                const query = {
102
+                    pageNum: this.pageNum,
103
+                    pageSize: this.pageSize,
104
+                    noticeType: 2, // 根据之前的组件,这里固定为2
105
+                    status: 0
106
+                };
107
+
108
+                let response = await getNoticeList(query);
109
+
110
+                // 处理响应数据
111
+                const data = response.rows || response.list || [];
112
+
113
+                if (this.pageNum === 1) {
114
+                    this.list = data;
115
+                } else {
116
+                    this.list = [...this.list, ...data];
117
+                }
118
+
119
+                this.total = response.total || 0;
120
+                this.hasMore = this.list.length < this.total;
121
+            } catch (error) {
122
+                console.error('加载公告数据失败:', error);
123
+                uni.showToast({
124
+                    title: '加载失败',
125
+                    icon: 'none'
126
+                });
127
+            } finally {
128
+                this.loading = false;
129
+                // 重置下拉刷新状态
130
+                this.refresherTriggered = false;
131
+                uni.stopPullDownRefresh();
132
+            }
133
+        },
134
+        // 加载更多数据
135
+        loadMore() {
136
+            if (this.loading || !this.hasMore) return;
137
+            this.pageNum++;
138
+            this.loadData();
139
+        }
140
+    },
141
+    mounted() {
142
+        this.loadData();
143
+    },
144
+    onShow() {
145
+        // 页面再次展示时刷新数据
146
+        this.pageNum = 1;
147
+        this.loadData();
148
+    }
149
+}
150
+</script>
151
+
152
+<style lang="scss" scoped>
153
+.announcement-list {
154
+    height: calc(100vh - 177rpx);
155
+    margin-bottom: 20rpx;
156
+
157
+    // 标题样式
158
+    .list-title {
159
+        font-size: 32rpx;
160
+        font-weight: bold;
161
+        color: #333333;
162
+    }
163
+
164
+    // 信息样式
165
+    .list-info {
166
+        .list-meta {
167
+            display: flex;
168
+            justify-content: flex-start;
169
+            margin-bottom: 11px;
170
+            font-size: 16px;
171
+            color: #666666;
172
+            align-items: center;
173
+
174
+            .author {
175
+                margin-right: 30rpx;
176
+            }
177
+        }
178
+
179
+        .content {
180
+            font-size: 28rpx;
181
+            color: #666666;
182
+            line-height: 42rpx;
183
+            word-break: break-all;
184
+            display: -webkit-box;
185
+            -webkit-line-clamp: 2;
186
+            -webkit-box-orient: vertical;
187
+            overflow: hidden;
188
+            text-overflow: ellipsis;
189
+        }
190
+    }
191
+
192
+    /* 加载状态样式 */
193
+    .load-more,
194
+    .no-more,
195
+    .no-data {
196
+        display: flex;
197
+        justify-content: center;
198
+        align-items: center;
199
+        padding: 40rpx;
200
+        font-size: 28rpx;
201
+        color: #999;
202
+    }
203
+}
204
+</style>

+ 182 - 0
src/pages/announcement/noticeDetail.vue

@@ -0,0 +1,182 @@
1
+<template>
2
+    <home-container :customStyle="{ background: 'none' }" :customHomeStyle="{ overflow: 'hidden' }">
3
+        <view class="detail-container" v-if="detailData">
4
+            <!-- 公告标题 -->
5
+            <view class="title">{{ detailData.noticeTitle }}</view>
6
+            
7
+            <!-- 发布信息 -->
8
+            <view class="meta-info">
9
+                <text class="author">{{ detailData.createUser || '管理员' }}</text>
10
+                <text class="date">{{ formatDate(detailData.createTime) }}</text>
11
+            </view>
12
+            
13
+            <!-- 分隔线 -->
14
+            <view class="divider"></view>
15
+            
16
+            <!-- 富文本内容 -->
17
+            <view class="content" v-html="detailData.noticeContent"></view>
18
+        </view>
19
+        
20
+        <!-- 加载状态 -->
21
+        <view v-else class="loading">加载中...</view>
22
+    </home-container>
23
+</template>
24
+
25
+<script>
26
+import HomeContainer from "@/components/HomeContainer.vue";
27
+import { getNoticeDetail } from "@/api/announcement/announcement.js";
28
+
29
+
30
+export default {
31
+    components: {
32
+        HomeContainer
33
+    },
34
+    data() {
35
+        return {
36
+            detailData: null,
37
+            loading: true
38
+        };
39
+    },
40
+    onLoad(options) {
41
+        
42
+        // 从路由参数中获取公告ID
43
+        if (options && options.id) {
44
+            this.getDetailData(options.id);
45
+        } else {
46
+            console.error('公告ID不存在');
47
+            uni.showToast({
48
+                title: '获取公告详情失败',
49
+                icon: 'none'
50
+            });
51
+        }
52
+    },
53
+    methods: {
54
+        // 格式化日期
55
+        formatDate(timeString) {
56
+            if (!timeString) return '';
57
+            const date = new Date(timeString);
58
+            const year = date.getFullYear();
59
+            const month = String(date.getMonth() + 1).padStart(2, '0');
60
+            const day = String(date.getDate()).padStart(2, '0');
61
+            const hour = String(date.getHours()).padStart(2, '0');
62
+            const minute = String(date.getMinutes()).padStart(2, '0');
63
+            return `${year}-${month}-${day} ${hour}:${minute}`;
64
+        },
65
+        
66
+        // 处理富文本中的图片
67
+        handleRichTextImages(html) {
68
+            if (!html) return '';
69
+            
70
+            // 为所有img标签添加style="width:90%",同时保留原有的style属性
71
+            return html.replace(/<img([^>]+)>/g, (match, attributes) => {
72
+                // 检查是否已有style属性
73
+                if (attributes.includes('style=')) {
74
+                    // 如果已有style属性,在其末尾添加width设置
75
+                    return `<img${attributes.replace(/style=["']([^"']*)["']/, (styleMatch, styleValue) => {
76
+                        return `style="${styleValue} width:90%;"`;
77
+                    })}>`;
78
+                } else {
79
+                    // 如果没有style属性,添加一个包含width设置的style属性
80
+                    return `<img${attributes} style="width:90%;">`;
81
+                }
82
+            });
83
+        },
84
+
85
+        // 获取公告详情数据
86
+        async getDetailData(id) {
87
+            try {
88
+                this.loading = true;
89
+                const response = await getNoticeDetail(id);
90
+                
91
+                // 深拷贝响应数据
92
+                this.detailData = JSON.parse(JSON.stringify(response.data));
93
+                
94
+                // 处理noticeContent中的图片样式
95
+                if (this.detailData.noticeContent) {
96
+                    this.detailData.noticeContent = this.handleRichTextImages(this.detailData.noticeContent);
97
+                }
98
+            } catch (error) {
99
+                console.error('获取公告详情失败:', error);
100
+                uni.showToast({
101
+                    title: '获取公告详情失败',
102
+                    icon: 'none'
103
+                });
104
+            } finally {
105
+                this.loading = false;
106
+            }
107
+        }
108
+    }
109
+};
110
+</script>
111
+
112
+<style lang="scss" scoped>
113
+.detail-container {
114
+    padding: 32rpx;
115
+    background-color: #ffffff;
116
+    min-height: calc(100vh - 177rpx);
117
+    box-sizing: border-box;
118
+    
119
+    // 标题样式
120
+    .title {
121
+        font-size: 36rpx;
122
+        font-weight: bold;
123
+        color: #333333;
124
+        line-height: 52rpx;
125
+        margin-bottom: 24rpx;
126
+    }
127
+    
128
+    // 元信息样式
129
+    .meta-info {
130
+        display: flex;
131
+        justify-content: space-between;
132
+        align-items: center;
133
+        margin-bottom: 24rpx;
134
+        
135
+        .author,
136
+        .date {
137
+            font-size: 28rpx;
138
+            color: #666666;
139
+        }
140
+    }
141
+    
142
+    // 分隔线样式
143
+    .divider {
144
+        height: 1rpx;
145
+        background-color: #e5e5e5;
146
+        margin: 32rpx 0;
147
+    }
148
+    
149
+    // 富文本内容样式
150
+    .content {
151
+        font-size: 28rpx;
152
+        color: #333333;
153
+        line-height: 48rpx;
154
+        
155
+        // 富文本内部样式
156
+        :deep() {
157
+            p {
158
+                margin-bottom: 20rpx;
159
+            }
160
+            img {
161
+                max-width: 100%;
162
+                height: auto;
163
+                margin: 16rpx 0;
164
+            }
165
+            a {
166
+                color: #007aff;
167
+                text-decoration: underline;
168
+            }
169
+        }
170
+    }
171
+}
172
+
173
+// 加载状态样式
174
+.loading {
175
+    display: flex;
176
+    justify-content: center;
177
+    align-items: center;
178
+    height: 400rpx;
179
+    font-size: 28rpx;
180
+    color: #999999;
181
+}
182
+</style>

+ 724 - 0
src/pages/attendance/components/AddAttendancePersonnelModal.vue

@@ -0,0 +1,724 @@
1
+<template>
2
+  <view class="addAttendancePersonnelModal">
3
+    <view class="slot-content" @click.stop="openModal">
4
+      <slot></slot>
5
+    </view>
6
+    <u-popup :show="show" mode="center" :round="8">
7
+      <view class="modal-content">
8
+        <view class="title">
9
+          <text>{{ notkezhang ? '当班人员' : '负责区域' }}</text>
10
+          <u-icon name="close" color="#666666" size="20" @click="close" />
11
+        </view>
12
+        <view class="title-cell" v-if="notkezhang">
13
+          <SelectData ref="selectData" @search="searchUsers" @searchViewShowEvent="searchViewShowHandler" />
14
+        </view>
15
+        <view class="title-cell" v-if="!searchViewShow && selectedUser && selectedUser.length">
16
+          <view class="title-text">{{ `班组成员(${selectedUser.length}人)` }}</view>
17
+          <view class="personnel-list">
18
+            <view class="personnel-item" v-for="item of selectedUser" :key="item.userId"
19
+              @click="removeSelcetUser(item)">
20
+              <view class="personnel-img">
21
+                <UserAvatar :userName="item.nickName || item.userName" :avatarLink="item.avatar" />
22
+              </view>
23
+              <view class="personnel-name">{{ item.nickName || item.userName }}</view>
24
+              <view class="personnel-close" v-if="notkezhang">
25
+                <u-icon name="close" color="#666666" size="14" />
26
+              </view>
27
+            </view>
28
+          </view>
29
+        </view>
30
+        <view class="title-cell" v-if="notkezhang && searchViewShow">
31
+          <view style="margin-bottom: 15px;" v-if="addWorkUsers && addWorkUsers.length">
32
+            <view class="title-text">
33
+              {{ `添加人员(${addWorkUsers.length}人)` }}
34
+            </view>
35
+            <view class="personnel-list" v-if="addWorkUsers && addWorkUsers.length">
36
+              <view class="personnel-item" v-for="item of addWorkUsers" :key="item.userId" @click="selectAddUser(item)">
37
+                <view class="personnel-img">
38
+                  <UserAvatar :userName="item.nickName" :avatarLink="item.avatar" />
39
+                </view>
40
+                <view class="personnel-name">{{ item.nickName }}</view>
41
+                <view class="personnel-close">
42
+                  <u-icon name="close" color="#666666" size="14" />
43
+                </view>
44
+              </view>
45
+            </view>
46
+          </view>
47
+          <view class="title-text">
48
+            {{ `搜索结果(${allUsers.length}人)` }}
49
+          </view>
50
+          <view class="personnel-list" v-if="allUsers && allUsers.length">
51
+            <view class="personnel-item" v-for="item of allUsers" :key="item.userId" @click="selectAddUser(item)">
52
+              <view class="personnel-img">
53
+                <UserAvatar :userName="item.nickName" :avatarLink="item.avatar" />
54
+              </view>
55
+              <view class="personnel-name">{{ item.nickName }}</view>
56
+            </view>
57
+          </view>
58
+          <view v-else class="empty"></view>
59
+        </view>
60
+        <view class="title-cell" v-if="!searchViewShow">
61
+          <view class="title-text">{{ notkezhang ? '选择上通道时间' : '选择上区域时间' }}</view>
62
+          <uni-datetime-picker type="datetime" v-model="currentTime" />
63
+        </view>
64
+        <view class="title-cell" v-if="!searchViewShow">
65
+          <view class="title-text" style="display: flex; align-items: center;">{{ notkezhang ? '选择通道' : '选择区域' }}
66
+            <view v-if="!userInfo.roles.includes('banzuzhang')">
67
+              <SelectArea :notkezhang="notkezhang" @selected="selectedArea">
68
+                <u-icon name="plus-circle" color="#2A70D1" size="25" />
69
+              </SelectArea>
70
+            </view>
71
+          </view>
72
+          <view class="">
73
+            <uniDataPicker v-if="userInfo.roles.includes('banzuzhang')" :localdata="channelList"
74
+              :popup-title="notkezhang ? '请选择上岗通道' : '请选择上岗区域'" v-model="channelOrRegional"
75
+              @change="onLocationChange" />
76
+
77
+            <view class="selected-areas" v-if="selectedAreas && selectedAreas.length">
78
+              <view class="area-item" v-for="area in selectedAreas" :key="area.id">
79
+                <view class="area-label">{{ area.label }}<u-icon name="close" color="#666666" size="14"
80
+                    @click="removeArea(area)" /></view>
81
+                <text-tag :tags="area.children" @remove="removeSubArea($event, area.id)" />
82
+              </view>
83
+            </view>
84
+          </view>
85
+        </view>
86
+
87
+        <view class="footer-btn">
88
+          <view v-if="searchViewShow" class="custom-btn-normal" @click="appendWorkUsers"
89
+            :class="{ disabled: addWorkUsers.length === 0 }">添加人员</view>
90
+          <view v-else class="custom-btn-normal" style="margin-top: 10rpx;" @click="invokerSubmitPostDuty"
91
+            :class="{ disabled: selectedUserIds.length === 0 }">{{ notkezhang ? '确认上通道' : '确认上区域' }}</view>
92
+          <u-modal :show="errModalShow" @confirm="errModalShow = false" width="80vw">
93
+            <view class="modal-slot-content">
94
+              {{ errModalContent }}
95
+            </view>
96
+          </u-modal>
97
+        </view>
98
+      </view>
99
+    </u-popup>
100
+  </view>
101
+</template>
102
+
103
+<script>
104
+import SelectData from './SelectData'
105
+import SelectArea from './SelectArea'
106
+import TimePicker from './TimePicker'
107
+import {
108
+  getUserList,
109
+  addPostRecord,
110
+  getPostRecordList,
111
+  dataConfigTree,
112
+  memberList,
113
+  queryLastTime
114
+} from "@/api/attendance/attendance"
115
+import moment from 'moment'
116
+import uniDataPicker from '@/uni_modules/uni-data-picker/components/uni-data-picker/uni-data-picker'
117
+import { formatTime, formatName, isAfterTodayStart } from '@/utils/formatUtils'
118
+import { generateRandomDigits } from '@/utils/handler'
119
+import UserAvatar from './UserAvatar'
120
+import { getHandleAreaData } from '@/utils/common'
121
+export default {
122
+  components: { uniDataPicker, SelectData, TimePicker, UserAvatar, SelectArea },
123
+  props: {
124
+    userInfo: {
125
+      type: Object,
126
+      default: () => ({})
127
+    },
128
+    disabled: {
129
+      type: Boolean,
130
+      default: false
131
+    },
132
+    notkezhang: {
133
+      type: Boolean,
134
+      default: undefined
135
+    },
136
+    selectedMember: {
137
+      type: Array,
138
+      default: () => []
139
+    }
140
+  },
141
+  data() {
142
+    return {
143
+      show: false,
144
+      searchFocus: false,
145
+      channelOrRegional: '',
146
+      channelOrRegionalName: '',
147
+      channelList: [
148
+        { value: 0, text: "通道A" },
149
+      ],
150
+      selectedUser: [], //选中的人员
151
+      selectedAreas: [], // 选中的区域
152
+      allUsers: [],
153
+      addWorkUsers: [], // 额外添加的人员 
154
+      teamUsers: [],
155
+      searchViewShow: false,
156
+      isSubmittingPost: false,
157
+      currentTime: undefined,
158
+      errModalShow: false,
159
+      errModalContent: ''
160
+    }
161
+  },
162
+  computed: {
163
+    selectedUserIds() {
164
+      return (this.selectedUser || []).map(item => item.userId)
165
+    },
166
+    //单选获取的区域
167
+    positions() {
168
+      return this.notkezhang ? {
169
+        regionalCode: '',
170
+        regionalName: '',
171
+        channelCode: this.channelOrRegional,
172
+        channelName: this.channelOrRegionalName
173
+      } : {
174
+        regionalCode: this.channelOrRegional,
175
+        regionalName: this.channelOrRegionalName,
176
+        channelCode: '',
177
+        channelName: ''
178
+      }
179
+    },
180
+    //多选获得的区域
181
+    getSelectArea() {
182
+      let area = []
183
+      this.selectedAreas.forEach(item => {
184
+        item.children.forEach(child => {
185
+          area.push(this.notkezhang ? {
186
+            regionalCode: '',
187
+            regionalName: '',
188
+            channelCode: child.code,
189
+            channelName: child.label,
190
+            terminlCode: item.code,
191
+            terminlName: item.label
192
+          } : {
193
+            regionalCode: child.code,
194
+            regionalName: child.label,
195
+            channelCode: '',
196
+            channelName: '',
197
+            terminlCode: item.code,
198
+            terminlName: item.label
199
+          })
200
+        })
201
+      })
202
+      return area
203
+    }
204
+  },
205
+  watch: {
206
+    show(newValue) {
207
+      if (newValue) {
208
+        if (this.userInfo.roles.includes('kezhang')) {
209
+          this.getQueryLastTime()
210
+        }
211
+        this.currentTime = formatTime(new Date())
212
+
213
+        if (this.notkezhang) {
214
+          if (this.userInfo.roles.includes('banzuzhang')) {
215
+            memberList({
216
+              attendanceTeamId: this.userInfo.teamsId,
217
+              attendanceDate: moment().format('YYYY-MM-DD')
218
+            }).then(res => {
219
+              console.log(res.rows, "res.rows")
220
+              this.selectedUser = res.rows || []
221
+
222
+            })
223
+            return
224
+          }
225
+          this.loadTeamUsers().then(() => {
226
+            // 选中全组人员  
227
+            this.teamUsers.forEach((item) => {
228
+              this.selectUserHandler(item, false)
229
+            })
230
+          })
231
+
232
+        } else {
233
+          this.loadTeamUsers().then(() => {
234
+            // 查自己 
235
+            const curUser = this.teamUsers.find((item) => {
236
+              return item.userId === this.userInfo.userId
237
+            })
238
+            this.selectUserHandler(curUser, false)
239
+          })
240
+
241
+        }
242
+      } else {
243
+        this.allUsers = []
244
+      }
245
+    },
246
+    notkezhang: {
247
+      handler(newValue) {
248
+        const level = newValue ? 3 : 2
249
+        this.invokerDataConfigTree(level)
250
+      },
251
+      immediate: true
252
+    }
253
+  },
254
+  methods: {
255
+    //查询最后一次上岗的区域
256
+    async getQueryLastTime() {
257
+      let res = await queryLastTime({
258
+        userId: this.userInfo.userId,
259
+      })
260
+      this.selectedAreas = getHandleAreaData(res.data || [], this.notkezhang)
261
+      console.log(this.selectedAreas, "this.selectedAreas")
262
+    },
263
+    convertTree(list = []) {
264
+      return list.map(node => ({
265
+        text: node.label,
266
+        value: node.code,
267
+        children: node.children ? this.convertTree(node.children) : null
268
+      }))
269
+    },
270
+    invokerDataConfigTree(level) {
271
+      return dataConfigTree(level).then(res => {
272
+        this.channelList = this.convertTree(res.data || [])
273
+      }).catch(() => {
274
+        this.channelList = []
275
+      })
276
+    },
277
+    onLocationChange({ detail }) {
278
+      const { value } = detail
279
+      this.channelOrRegionalName = value.map(item => item.text).join('/')
280
+    },
281
+    openModal() {
282
+      if (this.disabled || (this.selectedMember.length === 0 && this.notkezhang)) return
283
+      this.show = true
284
+    },
285
+    close() {
286
+      this.show = false
287
+    },
288
+    //获取多选的区域
289
+    selectedArea(areas) {
290
+
291
+      let res = areas.filter(area => {
292
+        return area.children.some((item) => item.checked)
293
+      })
294
+      res = res.map(item => {
295
+        return {
296
+          ...item,
297
+          children: item.children.filter(child => child.checked)
298
+        }
299
+      })
300
+      this.selectedAreas = res
301
+    },
302
+    removeArea(area) {
303
+      this.selectedAreas = this.selectedAreas.filter(a => a.id !== area.id)
304
+    },
305
+    removeSubArea(tags, id) {
306
+      console.log(tags, id)
307
+      // debugger
308
+      const area = this.selectedAreas.find(a => a.id === id)
309
+      area.children = tags
310
+
311
+    },
312
+    searchViewShowHandler(show) { // 是否展示搜索用户界面
313
+      this.searchViewShow = show || this.addWorkUsers.length
314
+    },
315
+    selectAddUser(selectItem) { // 移除额外添加人员
316
+      const index = this.addWorkUsers.findIndex(item => item.userId === selectItem.userId)
317
+      if (index >= 0) {
318
+        this.addWorkUsers.splice(index, 1)
319
+      } else {
320
+        this.addWorkUsers.push(selectItem)
321
+      }
322
+    },
323
+    appendWorkUsers() {
324
+      if (this.addWorkUsers.length) {
325
+        this.addWorkUsers.forEach(item => {
326
+          this.selectUserHandler(item, false)
327
+        })
328
+        this.addWorkUsers = []
329
+        this.searchViewShow = false
330
+        this.$refs.selectData.clearInput()
331
+      }
332
+    },
333
+    removeSelcetUser(selectItem, onlyCheck = false) {
334
+      const index = this.selectedUser.findIndex(item => item.userId === selectItem.userId)
335
+      if (index >= 0) {
336
+        !onlyCheck && this.selectedUser.splice(index, 1)
337
+        return undefined
338
+      }
339
+      return selectItem
340
+    },
341
+    checkUserStatus(selectItem) {
342
+      return getPostRecordList({
343
+        userId: selectItem.userId,
344
+        pageNum: 1,
345
+        pageSize: 1
346
+      }).then(res => {
347
+        if (Array(res.rows) && res.rows.length && res.rows[0].checkOutTime === '2000-01-01 00:00:00') {
348
+          if (isAfterTodayStart(res.rows[0].checkInTime)) {
349
+            return `${selectItem.nickName || selectItem.userName} 尚未下通道;`
350
+          }
351
+        } else {
352
+          return ''
353
+        }
354
+      })
355
+    },
356
+    selectUserHandler(selectItem, unique = true) {
357
+      // 🔥 重要:先检查用户是否可以上通道(最新记录的checkOutTime必须不是默认时间)
358
+      const addItem = this.removeSelcetUser(selectItem, !unique)
359
+
360
+      if (addItem) {
361
+        this.selectedUser.push(addItem)
362
+      }
363
+    },
364
+    invokerSubmitPostDuty() {
365
+      if (this.selectedUser.length === 0) {
366
+        uni.showToast({
367
+          title: '请选择至少一位人员',
368
+          icon: 'none'
369
+        });
370
+        return;
371
+      }
372
+      if ((!this.notkezhang && this.getSelectArea.length == 0) || (this.notkezhang && !this.channelOrRegional)) {
373
+        uni.showToast({
374
+          title: this.notkezhang ? '请选择上岗通道' : '请选择上岗区域',
375
+          icon: 'none'
376
+        });
377
+        return;
378
+      }
379
+      const checkUserStatusALL = this.selectedUser.map(item => {
380
+        return this.checkUserStatus(item)
381
+      })
382
+      uni.showLoading({ title: '正在查询人员状态...' });
383
+      Promise.all(checkUserStatusALL).then((res) => {
384
+        const checkResult = res.filter(Boolean)
385
+        if (checkResult.length) {
386
+          this.errModalContent = checkResult.join('\n')
387
+          this.errModalShow = true
388
+        } else {
389
+          this.submitPostDuty()
390
+        }
391
+      }).finally(() => {
392
+        uni.hideLoading()
393
+      })
394
+    },
395
+    // 修改:提交上通道记录到后端,checkOutTime设为2000年1月1日0点
396
+    submitPostDuty() {
397
+      if (this.isSubmittingPost) return;
398
+      this.isSubmittingPost = true;
399
+      uni.showLoading({ title: '正在上通道...' });
400
+      const currentTime = this.currentTime
401
+      // 获取当前用户信息
402
+      const currentUserInfo = this.userInfo;
403
+      // 创建随机工作组id
404
+      const groupId = generateRandomDigits()
405
+      // 为每个选中的用户创建上通道记录
406
+      const promises = this.selectedUser.map((item) => {
407
+        let postRecordList = []
408
+        //如果是科长就是走多选逻辑,如果是班组长就走单选逻辑
409
+        if (!this.notkezhang) {
410
+          postRecordList = this.getSelectArea.map(ele => {
411
+            return {
412
+              userId: item.userId,
413
+              userName: item.nickName || item.userName,
414
+              checkInTime: formatTime(currentTime, 'YYYY-MM-DD hh:mm:ss'),
415
+              // 🔥 重要:上通道时传入默认下岗时间(2000年1月1日0点)
416
+              checkOutTime: '2000-01-01 00:00:00',
417
+              attendanceDate: formatTime(currentTime, 'YYYY-MM-DD'),
418
+              // 🔥 使用从getInfo获取的完整用户信息
419
+              attendanceTeamId: currentUserInfo.teamsId || currentUserInfo.deptId,
420
+              attendanceTeamName: currentUserInfo.teamsName || '未知班组',
421
+              attendanceDepartmentId: currentUserInfo.departmentId,
422
+              attendanceDepartmentName: currentUserInfo.departmentName || '未知部门',
423
+              attendanceStationId: currentUserInfo.stationId,
424
+              attendanceStationName: currentUserInfo.stationName || '机场',
425
+              remark: '手动添加上通道记录',
426
+              terminlCode: '',
427
+              terminlName: '',
428
+              positionCode: '',
429
+              positionName: '',
430
+              shiftCode: groupId,
431
+              shiftName: '',
432
+              ...ele
433
+            }
434
+          })
435
+        } else {
436
+          //如果是班组长走单选逻辑
437
+          postRecordList = [{
438
+            userId: item.userId,
439
+            userName: item.nickName || item.userName,
440
+            checkInTime: formatTime(currentTime, 'YYYY-MM-DD hh:mm:ss'),
441
+            // 🔥 重要:上通道时传入默认下岗时间(2000年1月1日0点)
442
+            checkOutTime: '2000-01-01 00:00:00',
443
+            attendanceDate: formatTime(currentTime, 'YYYY-MM-DD'),
444
+            // 🔥 使用从getInfo获取的完整用户信息
445
+            attendanceTeamId: currentUserInfo.teamsId || currentUserInfo.deptId,
446
+            attendanceTeamName: currentUserInfo.teamsName || '未知班组',
447
+            attendanceDepartmentId: currentUserInfo.departmentId,
448
+            attendanceDepartmentName: currentUserInfo.departmentName || '未知部门',
449
+            attendanceStationId: currentUserInfo.stationId,
450
+            attendanceStationName: currentUserInfo.stationName || '机场',
451
+            remark: '手动添加上通道记录',
452
+            terminlCode: '',
453
+            terminlName: '',
454
+            positionCode: '',
455
+            positionName: '',
456
+            shiftCode: groupId,
457
+            shiftName: '',
458
+            ...this.positions
459
+          }];
460
+        }
461
+        console.log(postRecordList, "postRecordList")
462
+        debugger
463
+
464
+        return addPostRecord(postRecordList)
465
+      });
466
+
467
+      Promise.all(promises).then((results) => {
468
+        // 检查是否有失败的记录
469
+        const failedCount = results.filter(result => !result || result.code !== 200).length;
470
+        const successCount = this.selectedUser.length - failedCount;
471
+
472
+        if (successCount > 0) {
473
+          uni.showToast({
474
+            title: `成功上通道${successCount}人${failedCount > 0 ? `,${failedCount}人失败` : ''}`,
475
+            icon: successCount === this.selectedUser.length ? 'success' : 'none',
476
+            duration: 2000
477
+          });
478
+
479
+          // 清空已添加的用户列表
480
+          this.selectedUser = [];
481
+          // 重置通道信息
482
+          this.channelOrRegional = '',
483
+            this.channelOrRegionalName = '',
484
+
485
+            this.$emit('updateRecord', () => {
486
+              this.show = false
487
+            })
488
+        }
489
+      }).catch((error) => {
490
+        uni.showToast({
491
+          title: '上通道失败:请稍后重试',
492
+          icon: 'error',
493
+          duration: 2000
494
+        });
495
+      }).finally(() => {
496
+        uni.hideLoading();
497
+        this.isSubmittingPost = false;
498
+      })
499
+    },
500
+
501
+    //搜索用户
502
+    async searchUsers(value) {
503
+      const keyword = value.trim();
504
+      if (!keyword) {
505
+        this.allUsers = [];
506
+        return;
507
+      }
508
+      try {
509
+        // 调用用户搜索接口,不限制部门
510
+        const response = await getUserList({
511
+          nickName: keyword,    // 按昵称搜索
512
+          status: '0'          // 只获取正常状态的用户
513
+        });
514
+        if (response && response.code === 200) {
515
+          this.allUsers = (response.rows || []).map(item => {
516
+            return {
517
+              ...item,
518
+              nickName: formatName(item.nickName)
519
+            }
520
+          });
521
+        } else {
522
+          this.allUsers = [];
523
+        }
524
+      } catch (error) {
525
+        this.allUsers = [];
526
+        uni.showToast({
527
+          title: '搜索失败,请重试',
528
+          icon: 'none',
529
+          duration: 2000
530
+        });
531
+      }
532
+    },
533
+
534
+    // 加载同组用户
535
+    async loadTeamUsers() {
536
+      try {
537
+        const currentUserInfo = this.userInfo || {}
538
+        const currentUserDeptId = currentUserInfo.deptId || currentUserInfo.teamsId;
539
+        // 调用用户列表接口,传递deptId获取同组用户
540
+        const response = await getUserList({
541
+          deptId: currentUserDeptId, // 🔥 使用从getInfo获取的deptId
542
+          status: '0' // 只获取正常状态的用户
543
+        });
544
+
545
+        if (response && response.code === 200) {
546
+          this.teamUsers = (response.rows || []).map(item => {
547
+            return {
548
+              ...item,
549
+              nickName: formatName(item.nickName)
550
+            }
551
+          })
552
+        } else {
553
+          throw new Error(response.msg || '获取同组用户失败');
554
+        }
555
+      } catch (error) {
556
+        uni.showToast({
557
+          title: '加载同组用户失败',
558
+          icon: 'none',
559
+          duration: 2000
560
+        });
561
+        // 设置空数组,避免页面报错
562
+        this.teamUsers = [];
563
+      }
564
+    },
565
+  },
566
+}
567
+</script>
568
+
569
+<style lang="scss" scoped>
570
+.addAttendancePersonnelModal {
571
+  .slot-content {}
572
+
573
+  .empty {
574
+    margin: 0 auto;
575
+    width: 160px;
576
+    height: 155px;
577
+    background: url("../../../static/images/Empty.png") no-repeat;
578
+    background-size: cover;
579
+  }
580
+
581
+  .modal-content {
582
+    width: 90vw;
583
+    max-height: 75vh;
584
+    padding: 10px 0;
585
+
586
+    .title {
587
+      height: 24px;
588
+      font-weight: 400;
589
+      font-size: 18px;
590
+      color: #333333;
591
+      line-height: 21px;
592
+      display: flex;
593
+      justify-content: space-between;
594
+      align-items: center;
595
+      padding: 15px;
596
+      box-sizing: border-box;
597
+    }
598
+
599
+    .title-cell {
600
+      padding: 10px 15px;
601
+      box-sizing: border-box;
602
+
603
+      .selected-areas {
604
+        .area-item {
605
+          .area-label {
606
+            height: 65rpx;
607
+            line-height: 65rpx;
608
+            font-weight: 400;
609
+            font-size: 18px;
610
+            display: flex;
611
+            align-items: center;
612
+          }
613
+        }
614
+      }
615
+
616
+      .title-text {
617
+        height: 16px;
618
+        font-weight: 400;
619
+        font-size: 14px;
620
+        color: #333333;
621
+        line-height: 16px;
622
+        text-align: left;
623
+        margin-bottom: 14px;
624
+
625
+      }
626
+    }
627
+
628
+    .personnel-list {
629
+      display: flex;
630
+      flex-wrap: wrap;
631
+      row-gap: 8px;
632
+      column-gap: 6px;
633
+
634
+      .personnel-item {
635
+        width: fit-content;
636
+        height: 34px;
637
+        background: #F0F0F0;
638
+        border-radius: 6px;
639
+        display: flex;
640
+        align-items: center;
641
+        column-gap: 6px;
642
+        padding: 0 5px;
643
+
644
+        .personnel-img {
645
+          width: 24px;
646
+          height: 24px;
647
+          border-radius: 6px;
648
+          overflow: hidden;
649
+        }
650
+
651
+        .personnel-name {
652
+          width: fit-content;
653
+          font-weight: 400;
654
+          font-size: 13px;
655
+          color: #3D3D3D;
656
+          text-align: left;
657
+          font-style: normal;
658
+          text-transform: none;
659
+        }
660
+
661
+        .personnel-close {
662
+          width: 16px;
663
+        }
664
+
665
+        .radio-button {
666
+          width: 14px;
667
+          height: 14px;
668
+          border: 1px solid #2196F3;
669
+          background: #fff;
670
+          border-radius: 50%;
671
+          position: relative;
672
+          transition: border-color 0.2s ease;
673
+        }
674
+
675
+        /* 激活状态 - 蓝色边框 */
676
+        .radio-button.active {
677
+          border-color: #496CF4;
678
+        }
679
+
680
+        /* 激活状态 - 中间的蓝点 */
681
+        .radio-button.active::after {
682
+          content: '';
683
+          position: absolute;
684
+          top: 50%;
685
+          left: 50%;
686
+          transform: translate(-50%, -50%);
687
+          width: 7px;
688
+          height: 7px;
689
+          background-color: #2196F3;
690
+          border-radius: 50%;
691
+        }
692
+
693
+        /* 聚焦状态 */
694
+        .radio-button:focus {
695
+          outline: none;
696
+          box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.3);
697
+        }
698
+
699
+        /* 悬停效果 */
700
+        .radio-button:hover .radio-button:not(.active) {
701
+          border-color: #999;
702
+        }
703
+
704
+        /* 禁用状态 */
705
+        .radio-button.disabled {
706
+          opacity: 0.5;
707
+          border-color: #999;
708
+          background: #f0f0f0;
709
+          cursor: not-allowed;
710
+        }
711
+      }
712
+    }
713
+
714
+    .footer-btn {
715
+      padding: 10px 15px;
716
+
717
+      .modal-slot-content {
718
+        white-space: pre;
719
+
720
+      }
721
+    }
722
+  }
723
+}
724
+</style>

Plik diff jest za duży
+ 273 - 0
src/pages/attendance/components/AttendanceControl.vue


+ 565 - 0
src/pages/attendance/components/MaintainAreaOrMemberModal.vue

@@ -0,0 +1,565 @@
1
+<template>
2
+    <view class="teamMemberModal">
3
+        <view class="slot-content">
4
+            <view class="title">工作区域/班组成员</view>
5
+            <view class="content" v-if="selectedMember.length > 0">
6
+                <view class="title-cell">
7
+                    <view class="title-text">工作区域</view>
8
+                    <view class="terminal-list">
9
+                        {{ selectedMember[0].terminlName || '航站楼' }}
10
+                    </view>
11
+                </view>
12
+                <view class="member-cell" v-if="selectedMember && selectedMember.length">
13
+                    <view class="title-text">班组成员</view>
14
+                    <view class="personnel-list">
15
+                        <view class="personnel-item" v-for="item of selectedMember" :key="item.userId">
16
+                            <view class="personnel-img">
17
+                                <UserAvatar :userName="item.userName" :avatarLink="item.avatar" />
18
+                            </view>
19
+                            <view class="personnel-name">{{ item.userName }}</view>
20
+                        </view>
21
+                    </view>
22
+                </view>
23
+            </view>
24
+            <view v-else class="custom-btn" @click="openModal">维护工作区域/班组成员</view>
25
+        </view>
26
+        <u-popup :show="show" mode="center" :round="8">
27
+            <view class="modal-content">
28
+                <view class="title">
29
+                    <text>班组成员</text>
30
+                    <u-icon name="close" color="#666666" size="20" @click="close" />
31
+                </view>
32
+
33
+                <view class="search-box">
34
+                    <SelectData ref="selectData" @search="searchUsers" @searchViewShowEvent="searchViewShowHandler" />
35
+                </view>
36
+
37
+                <view class="title-cell" v-if="searchViewShow">
38
+                    <view style="margin-bottom: 15px;" v-if="addWorkUsers && addWorkUsers.length">
39
+                        <view class="title-text">
40
+                            {{ `添加人员(${addWorkUsers.length}人)` }}
41
+                        </view>
42
+                        <view class="personnel-list" v-if="addWorkUsers && addWorkUsers.length">
43
+                            <view class="personnel-item" v-for="item of addWorkUsers" :key="item.userId"
44
+                                @click="selectAddUser(item)">
45
+                                <view class="personnel-img">
46
+                                    <UserAvatar :userName="item.nickName" :avatarLink="item.avatar" />
47
+                                </view>
48
+                                <view class="personnel-name">{{ item.nickName }}</view>
49
+                                <view class="personnel-close">
50
+                                    <u-icon name="close" color="#666666" size="14" />
51
+                                </view>
52
+                            </view>
53
+                        </view>
54
+                    </view>
55
+                    <view class="title-text">
56
+                        {{ `搜索结果(${allUsers.length}人)` }}
57
+                    </view>
58
+                    <view class="personnel-list" v-if="allUsers && allUsers.length">
59
+                        <view class="personnel-item" v-for="item of allUsers" :key="item.userId"
60
+                            @click="selectAddUser(item)">
61
+                            <view class="personnel-img">
62
+                                <UserAvatar :userName="item.nickName" :avatarLink="item.avatar" />
63
+                            </view>
64
+                            <view class="personnel-name">{{ item.nickName }}</view>
65
+                        </view>
66
+                    </view>
67
+                    <view v-else class="empty"></view>
68
+                </view>
69
+
70
+                <view class="title-cell" v-if="!searchViewShow && teamMembers && teamMembers.length">
71
+                    <view class="title-text">{{ `班组成员(${teamMembers.length}人)` }}</view>
72
+                    <view class="personnel-list">
73
+                        <view class="personnel-item" v-for="item of teamMembers" :key="item.userId">
74
+                            <view class="personnel-img">
75
+                                <UserAvatar :userName="item.nickName" :avatarLink="item.avatar" />
76
+                            </view>
77
+                            <view class="personnel-name">{{ item.nickName }}</view>
78
+                            <view class="personnel-close" @click="removeMember(item)">
79
+                                <u-icon name="close" color="#666666" size="14" />
80
+                            </view>
81
+                        </view>
82
+                    </view>
83
+                </view>
84
+
85
+                <view class="title-cell" v-if="!searchViewShow">
86
+                    <view class="title-text">工作区域</view>
87
+                    <view class="terminal-list">
88
+                        <view class="terminal-item" v-for="terminal in channelList" :key="terminal.value"
89
+                            :class="{ active: selectedTerminal === terminal.value }"
90
+                            @click="selectTerminal(terminal.value)">
91
+                            {{ terminal.text }}
92
+                        </view>
93
+                    </view>
94
+                </view>
95
+
96
+                <view class="footer-btn">
97
+                    <view v-if="searchViewShow" class="custom-btn-normal" @click="appendWorkUsers"
98
+                        :class="{ disabled: addWorkUsers.length === 0 }">添加人员</view>
99
+                    <view v-else class="custom-btn-normal" @click="confirm" :class="{ disabled: !selectedTerminal }">确认
100
+                    </view>
101
+                </view>
102
+            </view>
103
+        </u-popup>
104
+    </view>
105
+</template>
106
+
107
+<script>
108
+import UserAvatar from './UserAvatar'
109
+import { dataConfigTree, memberList, addMember, getUserList } from "@/api/attendance/attendance"
110
+import { formatName } from '@/utils/formatUtils'
111
+import SelectData from './SelectData'
112
+import moment from 'moment'
113
+export default {
114
+    components: { UserAvatar, SelectData },
115
+    props: {
116
+        userInfo: {
117
+            type: Object,
118
+            default: () => { }
119
+        }
120
+    },
121
+    data() {
122
+        return {
123
+            show: false,
124
+            searchText: '',
125
+            selectedTerminal: '',
126
+            teamMembers: [],//新增里面显示的member
127
+            channelList: [],
128
+            selectedMember: [],//已选择班组数据
129
+
130
+            userOptions: [],
131
+            allUsers: [],//所有的人员
132
+            searchViewShow: false,//搜索人员
133
+            addWorkUsers: [], // 额外添加的人员 
134
+            teamUsers: [],//同组用户
135
+        }
136
+    },
137
+    computed: {
138
+        notkezhang() {
139
+            // console.log(!this.userInfo.roles || !this.userInfo.roles.includes('kezhang'), "!this.userInfo.roles || !this.userInfo.roles.includes('kezhang')")
140
+            return !this.userInfo.roles || !this.userInfo.roles.includes('kezhang')
141
+        },
142
+    },
143
+    mounted() {
144
+        this.invokerDataConfigTree(3)
145
+        console.log(this.userInfo, "userInfo")
146
+
147
+        //获取选中的成员
148
+        this.getSelectedMember()
149
+    },
150
+    methods: {
151
+        selectAddUser(selectItem) { // 移除额外添加人员
152
+            console.log(selectItem, "selectItem",this.addWorkUsers)
153
+            
154
+            const index = this.addWorkUsers.findIndex(item => item.userId === selectItem.userId)
155
+            if (index >= 0) {
156
+                this.addWorkUsers.splice(index, 1)
157
+            } else {
158
+                this.addWorkUsers.push(selectItem)
159
+            }
160
+        },
161
+        appendWorkUsers() {
162
+            
163
+            console.log("appendWorkUsers",this.addWorkUsers,this.teamMembers)
164
+            if (this.addWorkUsers.length) {
165
+                this.addWorkUsers.forEach(item => {
166
+                    if (!this.teamMembers.some(member => member.userId === item.userId)) {
167
+                        this.teamMembers.push(item)
168
+                    }
169
+                })
170
+                this.addWorkUsers = []
171
+                this.searchViewShow = false
172
+                this.$refs.selectData.clearInput()
173
+            }
174
+        },
175
+        searchViewShowHandler(show) { // 是否展示搜索用户界面
176
+
177
+            if (this.addWorkUsers.length) {
178
+                this.searchViewShow = show || this.addWorkUsers.length
179
+            } else {
180
+                this.searchViewShow = true
181
+            }
182
+        },
183
+        //获取默认的teammember
184
+        getTeamMember() {
185
+            
186
+            this.loadTeamUsers().then(() => {
187
+                // 选中全组人员  
188
+                this.teamMembers = this.teamUsers
189
+            })
190
+        },
191
+        // 加载同组用户
192
+        async loadTeamUsers() {
193
+            try {
194
+                const currentUserInfo = this.userInfo || {}
195
+                const currentUserDeptId = currentUserInfo.deptId || currentUserInfo.teamsId;
196
+                // 调用用户列表接口,传递deptId获取同组用户
197
+                const response = await getUserList({
198
+                    deptId: currentUserDeptId, // 🔥 使用从getInfo获取的deptId
199
+                    status: '0' // 只获取正常状态的用户
200
+                });
201
+
202
+                if (response && response.code === 200) {
203
+                    this.teamUsers = (response.rows || []).map(item => {
204
+                        return {
205
+                            ...item,
206
+                            nickName: formatName(item.nickName)
207
+                        }
208
+                    })
209
+                } else {
210
+                    throw new Error(response.msg || '获取同组用户失败');
211
+                }
212
+            } catch (error) {
213
+                uni.showToast({
214
+                    title: '加载同组用户失败',
215
+                    icon: 'none',
216
+                    duration: 2000
217
+                });
218
+                // 设置空数组,避免页面报错
219
+                this.teamUsers = [];
220
+            }
221
+        },
222
+        //回显member
223
+        getSelectedMember() {
224
+            
225
+            memberList({
226
+                attendanceTeamId: this.userInfo.teamsId,
227
+                attendanceDate: moment().format('YYYY-MM-DD')
228
+            }).then(res => {
229
+
230
+                this.selectedMember = res.rows || []
231
+                this.$emit('update-member', this.selectedMember)
232
+            })
233
+
234
+
235
+
236
+        },
237
+        //新增时候显示的member
238
+        async initMember() {
239
+            const currentUserInfo = this.userInfo || {}
240
+            const currentUserDeptId = currentUserInfo.deptId || currentUserInfo.teamsId;
241
+            // 调用用户列表接口,传递deptId获取同组用户
242
+            const response = await getUserList({
243
+                deptId: currentUserDeptId, // 🔥 使用从getInfo获取的deptId
244
+                status: '0' // 只获取正常状态的用户
245
+            });
246
+            console.log(response, "response")
247
+            if (response && response.code === 200) {
248
+                this.userOptions = (response.rows || []).map(item => ({
249
+                    ...item,
250
+                    value: item.userId,
251
+                    text: formatName(item.nickName)
252
+                }));
253
+            } else {
254
+                throw new Error(response.msg || '获取同组用户失败');
255
+            }
256
+        },
257
+        handleUserSelect(selected) {
258
+            this.teamMembers = this.userOptions
259
+                .filter(option => selected.map(item => item.value).includes(option.value))
260
+                .map(option => ({
261
+                    ...option,
262
+                    userId: option.value,
263
+                    nickName: option.text,
264
+                    avatar: ''
265
+                }));
266
+        },
267
+        convertTree(list = []) {
268
+            return list.map(node => ({
269
+                text: node.label,
270
+                value: node.code,
271
+                children: node.children ? this.convertTree(node.children) : null
272
+            }))
273
+        },
274
+        invokerDataConfigTree(level) {
275
+            return dataConfigTree(level).then(res => {
276
+                this.channelList = this.convertTree(res.data || [])
277
+                console.log(this.channelList, "this.channelList")
278
+            }).catch(() => {
279
+                this.channelList = []
280
+            })
281
+        },
282
+        //搜索用户
283
+        async searchUsers(value) {
284
+
285
+            const keyword = value.trim();
286
+            if (!keyword) {
287
+                this.allUsers = [];
288
+                return;
289
+            }
290
+            try {
291
+                // 调用用户搜索接口,不限制部门
292
+                const response = await getUserList({
293
+                    nickName: keyword,    // 按昵称搜索
294
+                    status: '0'          // 只获取正常状态的用户
295
+                });
296
+                if (response && response.code === 200) {
297
+                    this.allUsers = (response.rows || []).map(item => {
298
+                        return {
299
+                            ...item,
300
+                            nickName: formatName(item.nickName)
301
+                        }
302
+                    });
303
+                } else {
304
+                    this.allUsers = [];
305
+                }
306
+            } catch (error) {
307
+                this.allUsers = [];
308
+                uni.showToast({
309
+                    title: '搜索失败,请重试',
310
+                    icon: 'none',
311
+                    duration: 2000
312
+                });
313
+            }
314
+        },
315
+        close() {
316
+
317
+            this.show = false
318
+        },
319
+        removeMember(member) {
320
+            this.teamMembers = this.teamMembers.filter(item => item.userId !== member.userId)
321
+
322
+        },
323
+        selectTerminal(terminal) {
324
+            this.selectedTerminal = terminal
325
+        },
326
+        confirm() {
327
+            if (!this.selectedTerminal) return
328
+            let selectTerminal = this.channelList.find(item => item.value === this.selectedTerminal)
329
+            let res = this.teamMembers.map(item => {
330
+                return {
331
+                    ...item,
332
+                    nickName: item.nickName.replace(/\s+/g, ""),
333
+                    attendanceTeamId: this.userInfo.teamsId,
334
+                    attendanceTeamName: this.userInfo.teamsName,
335
+                    terminlCode: selectTerminal.value,
336
+                    terminlName: selectTerminal.text,
337
+                    userName: item.nickName.replace(/\s+/g, ""),
338
+                    userId: item.userId,
339
+                }
340
+            })
341
+
342
+            addMember(res).then(res => {
343
+                uni.showToast({ title: '提交成功', icon: 'success' });
344
+                this.close()
345
+                this.getSelectedMember()
346
+            })
347
+        },
348
+        openModal() {
349
+            this.show = true;
350
+            this.initMember()
351
+            this.getTeamMember()
352
+        }
353
+    }
354
+}
355
+</script>
356
+
357
+<style lang="scss" scoped>
358
+.teamMemberModal {
359
+    .slot-content {
360
+
361
+        width: 100%;
362
+        background: #FFFFFF;
363
+        box-shadow: 0px 4px 10px 0px rgba(0, 0, 0, 0.08);
364
+        border-radius: 16px 16px 16px 16px;
365
+        border: 1px solid #F0F8FF;
366
+        padding: 15px;
367
+        box-sizing: border-box;
368
+
369
+        .title {
370
+            height: 48rpx;
371
+            font-weight: 400;
372
+            font-size: 33rpx;
373
+            color: #333333;
374
+            line-height: 42rpx;
375
+            display: flex;
376
+            justify-content: space-between;
377
+            align-items: center;
378
+            // padding: 30rpx;
379
+            box-sizing: border-box;
380
+        }
381
+
382
+        .content {
383
+            // padding: 0 46rpx;
384
+
385
+
386
+            .title-cell {
387
+                display: flex;
388
+                height: 36px;
389
+                line-height: 36px;
390
+                justify-content: space-between;
391
+                color: #222222;
392
+                font-size: 27rpx;
393
+                // padding: 10rpx 0;
394
+
395
+                .terminal-list {
396
+                    color: #6C6C6C;
397
+                }
398
+            }
399
+
400
+            .member-cell {
401
+                display: flex;
402
+
403
+
404
+                color: #222222;
405
+                font-size: 27rpx;
406
+
407
+                flex-direction: column;
408
+
409
+                .title-text {
410
+                    height: 36px;
411
+                    line-height: 36px;
412
+                }
413
+
414
+                .personnel-list {
415
+                    display: flex;
416
+                    flex-wrap: wrap;
417
+                    row-gap: 19rpx;
418
+                    column-gap: 19rpx;
419
+
420
+                    .personnel-item {
421
+                        width: fit-content;
422
+                        height: 68rpx;
423
+                        background: #F0F0F0;
424
+                        border-radius: 12rpx;
425
+                        display: flex;
426
+                        align-items: center;
427
+                        column-gap: 12rpx;
428
+                        padding: 0 10rpx;
429
+
430
+                        .personnel-img {
431
+                            width: 48rpx;
432
+                            height: 48rpx;
433
+                            border-radius: 12rpx;
434
+                            overflow: hidden;
435
+                        }
436
+
437
+                        .personnel-name {
438
+                            width: fit-content;
439
+                            font-weight: 400;
440
+                            font-size: 26rpx;
441
+                            color: #3D3D3D;
442
+                            text-align: left;
443
+                            font-style: normal;
444
+                            text-transform: none;
445
+                        }
446
+
447
+                        .personnel-close {
448
+                            width: 32rpx;
449
+                        }
450
+                    }
451
+                }
452
+            }
453
+        }
454
+
455
+    }
456
+
457
+    .modal-content {
458
+        width: 90vw;
459
+        max-height: 75vh;
460
+        padding: 20rpx 0;
461
+
462
+        .title {
463
+            height: 48rpx;
464
+            font-weight: 400;
465
+            font-size: 36rpx;
466
+            color: #333333;
467
+            line-height: 42rpx;
468
+            display: flex;
469
+            justify-content: space-between;
470
+            align-items: center;
471
+            padding: 30rpx;
472
+            box-sizing: border-box;
473
+        }
474
+
475
+        .search-box {
476
+            padding: 20rpx 30rpx;
477
+        }
478
+
479
+        .title-cell {
480
+            padding: 20rpx 30rpx;
481
+            box-sizing: border-box;
482
+
483
+            .title-text {
484
+                height: 32rpx;
485
+                font-weight: 400;
486
+                font-size: 28rpx;
487
+                color: #333333;
488
+                line-height: 32rpx;
489
+                text-align: left;
490
+                margin-bottom: 28rpx;
491
+                text-indent: 10rpx;
492
+            }
493
+        }
494
+
495
+        .personnel-list {
496
+            display: flex;
497
+            flex-wrap: wrap;
498
+            row-gap: 16rpx;
499
+            column-gap: 12rpx;
500
+
501
+            .personnel-item {
502
+                width: fit-content;
503
+                height: 68rpx;
504
+                background: #F0F0F0;
505
+                border-radius: 12rpx;
506
+                display: flex;
507
+                align-items: center;
508
+                column-gap: 12rpx;
509
+                padding: 0 10rpx;
510
+
511
+                .personnel-img {
512
+                    width: 48rpx;
513
+                    height: 48rpx;
514
+                    border-radius: 12rpx;
515
+                    overflow: hidden;
516
+                }
517
+
518
+                .personnel-name {
519
+                    width: fit-content;
520
+                    font-weight: 400;
521
+                    font-size: 26rpx;
522
+                    color: #3D3D3D;
523
+                    text-align: left;
524
+                    font-style: normal;
525
+                    text-transform: none;
526
+                }
527
+
528
+                .personnel-close {
529
+                    width: 32rpx;
530
+                }
531
+            }
532
+        }
533
+
534
+        .terminal-list {
535
+            display: flex;
536
+            flex-direction: row;
537
+            gap: 20rpx;
538
+
539
+            .terminal-item {
540
+                padding: 20rpx;
541
+                border: 1px solid #ddd;
542
+                border-radius: 12rpx;
543
+                text-align: center;
544
+                cursor: pointer;
545
+                transition: all 0.3s;
546
+
547
+                &.active {
548
+                    background-color: #f0f0f0;
549
+                    border-color: #2196F3;
550
+                }
551
+
552
+                &:hover {
553
+                    background-color: #f5f5f5;
554
+                }
555
+            }
556
+        }
557
+
558
+        .footer-btn {
559
+            padding: 20rpx 30rpx;
560
+
561
+            
562
+        }
563
+    }
564
+}
565
+</style>

+ 275 - 0
src/pages/attendance/components/SearchView.vue

@@ -0,0 +1,275 @@
1
+<template>
2
+  <view class="">
3
+    <view class="search-input-container" @click="toggle">
4
+      <view class="search-input placeholder" readonly>
5
+        {{ placeholder }}
6
+      </view>
7
+    </view>
8
+    <uni-popup ref="popup" type="bottom" background-color="#fff" borderRadius="8px" @change="change">
9
+      <view class="selected-area">
10
+        <view class="dialog-caption" hover-class="none" :hover-stop-propagation="false">
11
+          <view class="title-area">
12
+            <text class="dialog-title">{{ title }}</text>
13
+          </view>
14
+          <view class="dialog-close" @click="handleClose">
15
+            <view class="dialog-close-plus" data-id="close"></view>
16
+            <view class="dialog-close-plus dialog-close-rotate" data-id="close"></view>
17
+          </view>
18
+        </view>
19
+        <view class="inputView">
20
+          <SearchInput @search="search" :placeholder="placeholder"/>
21
+        </view>
22
+        <view class="content">
23
+          <scroll-view v-if="dataList && dataList.length" class="list" :scroll-y="true">
24
+            <view class="item" :class="{'is-disabled': !!item.disable}" v-for="(item, j) in dataList" :key="j"
25
+              @click="updateValue(item)">
26
+              <text class="item-text">{{item[map.text]}}</text>
27
+              <!-- 暂无多选需求 -->
28
+              <view class="check" v-if="false"></view>
29
+            </view>
30
+          </scroll-view>
31
+          <view v-else class="content-empty">
32
+            <u-loading-icon v-if="loading" text="加载中" textSize="18"></u-loading-icon>
33
+            <template v-else>
34
+              <view class="empty"></view>
35
+              <view class="empty-text">{{ placeholder }}</view>
36
+            </template>
37
+          </view>
38
+        </view>
39
+      </view>
40
+    </uni-popup>
41
+  </view>
42
+</template>
43
+
44
+<script>
45
+import SearchInput from './SelectData.vue'
46
+export default {
47
+  props: {
48
+    modelValue: {
49
+      type: String | Number | Array,
50
+      default: ''
51
+    },
52
+    load: {
53
+      type: Function,
54
+      default: () => Promise.resolve([])
55
+    },
56
+    map: {
57
+      type: Object,
58
+      default: ({ text: 'text', value: 'value' })
59
+    },
60
+    title: {
61
+      type: String,
62
+      default: '请选择'
63
+    },
64
+    placeholder: {
65
+      type: String,
66
+      default: '请输入'
67
+    },
68
+  },
69
+  components: { SearchInput },
70
+  data () {
71
+    return {
72
+      dataList: [],
73
+      loading: false,
74
+    }
75
+  },
76
+  methods: {
77
+    search (value) {
78
+      this.loading = true
79
+      this.load(value).then(res => {
80
+        this.dataList = res
81
+      }).finally(() => {
82
+        this.loading = false
83
+      })
84
+    },
85
+    toggle () {
86
+      this.$refs.popup.open()
87
+    },
88
+    handleClose () {
89
+      this.$refs.popup.close()
90
+    },
91
+    change (e) {
92
+      if (e.show) {
93
+        // this.search('')
94
+      } else {
95
+        this.loading = false
96
+        this.dataList = []
97
+      }
98
+    },
99
+    updateValue (item) {
100
+      this.$emit('update:modelValue', item[this.map.value])
101
+      this.$emit('change', item)
102
+      this.handleClose()
103
+    }
104
+  }
105
+}
106
+</script>
107
+
108
+<style lang="scss" scoped>
109
+.search-input-container {
110
+  position: relative;
111
+
112
+  .search-input {
113
+    width: 100%;
114
+    height: 35px;
115
+    border: 1px solid #ddd;
116
+    border-radius: 6px;
117
+    padding: 0 40px 0 12px;
118
+    font-size: 14px;
119
+    color: #333;
120
+    line-height: 35px;
121
+
122
+    &.placeholder {
123
+      font-size: 12px;
124
+      color: #808080;
125
+    }
126
+  }
127
+
128
+  .search-icon {
129
+    position: absolute;
130
+    right: 12px;
131
+    top: 50%;
132
+    transform: translateY(-50%);
133
+    font-size: 16px;
134
+  }
135
+}
136
+
137
+.selected-area {
138
+  height: 80vh;
139
+  overflow: hidden;
140
+  display: flex;
141
+  flex-direction: column;
142
+
143
+  .title-area {
144
+    /* #ifndef APP-NVUE */
145
+    display: flex;
146
+    /* #endif */
147
+    align-items: center;
148
+    /* #ifndef APP-NVUE */
149
+    margin: auto;
150
+    /* #endif */
151
+    padding: 0 10px;
152
+  }
153
+
154
+  .dialog-caption {
155
+    position: relative;
156
+    /* #ifndef APP-NVUE */
157
+    display: flex;
158
+    /* #endif */
159
+    flex-direction: row;
160
+    /* border-bottom: 1px solid #f0f0f0; */
161
+  }
162
+
163
+  .dialog-title {
164
+    /* font-weight: bold; */
165
+    line-height: 44px;
166
+  }
167
+
168
+  .dialog-close {
169
+    position: absolute;
170
+    top: 0;
171
+    right: 0;
172
+    bottom: 0;
173
+    /* #ifndef APP-NVUE */
174
+    display: flex;
175
+    /* #endif */
176
+    flex-direction: row;
177
+    align-items: center;
178
+    padding: 0 15px;
179
+  }
180
+
181
+  .dialog-close-plus {
182
+    width: 16px;
183
+    height: 2px;
184
+    background-color: #666;
185
+    border-radius: 2px;
186
+    transform: rotate(45deg);
187
+  }
188
+
189
+  .dialog-close-rotate {
190
+    position: absolute;
191
+    transform: rotate(-45deg);
192
+  }
193
+
194
+  .icon-clear {
195
+    display: flex;
196
+    align-items: center;
197
+  }
198
+  .inputView {
199
+    padding: 0 15px 10px;
200
+    border-bottom: 1px solid #f8f8f8;
201
+    margin: 2px;
202
+  }
203
+  .content {
204
+    overflow: hidden;
205
+    flex: 1;
206
+  }
207
+  .content-empty {
208
+    overflow: hidden;
209
+    margin-top: 10%;
210
+    height: 100%;
211
+    width: 100%;
212
+    display: flex;
213
+    align-items: center;
214
+    flex-direction: column;
215
+    row-gap: 20px;
216
+    color: #999;
217
+  }
218
+  .list {
219
+    width: 100%;
220
+    height: 100%;
221
+  }
222
+
223
+  .item {
224
+    padding: 12px 15px;
225
+    /* border-bottom: 1px solid #f0f0f0; */
226
+    /* #ifndef APP-NVUE */
227
+    display: flex;
228
+    /* #endif */
229
+    flex-direction: row;
230
+    justify-content: space-between;
231
+  }
232
+
233
+  .is-disabled {
234
+    opacity: .5;
235
+  }
236
+
237
+  .item-text {
238
+    /* flex: 1; */
239
+    color: #333333;
240
+  }
241
+
242
+  .item-text-overflow {
243
+    width: 280px;
244
+    /* fix nvue */
245
+    overflow: hidden;
246
+    /* #ifndef APP-NVUE */
247
+    width: 20em;
248
+    white-space: nowrap;
249
+    text-overflow: ellipsis;
250
+    -o-text-overflow: ellipsis;
251
+    /* #endif */
252
+  }
253
+
254
+	.check {
255
+		margin-right: 5px;
256
+		border: 2px solid #007aff;
257
+		border-left: 0;
258
+		border-top: 0;
259
+		height: 12px;
260
+		width: 6px;
261
+		transform-origin: center;
262
+		/* #ifndef APP-NVUE */
263
+		transition: all 0.3s;
264
+		/* #endif */
265
+		transform: rotate(45deg);
266
+	}
267
+  .empty {
268
+    margin: 0 auto;
269
+    width: 160px;
270
+    height: 155px;
271
+    background: url("../../../static/images/Empty.png") no-repeat;
272
+    background-size: cover;
273
+  }
274
+}
275
+</style>

+ 340 - 0
src/pages/attendance/components/SelectArea.vue

@@ -0,0 +1,340 @@
1
+<template>
2
+    <view class="selectAreaModal">
3
+        <view class="slot-content" @click.stop="openModal">
4
+            <slot></slot>
5
+        </view>
6
+        <u-popup :show="show" mode="center" :round="8">
7
+            <view class="modal-content">
8
+                <view class="title">
9
+                    <text>选择区域</text>
10
+                    <u-icon name="close" color="#666666" size="20" @click="close" />
11
+                </view>
12
+                <view class="terminal-list">
13
+
14
+                    <div class="tabs">
15
+                        <div v-for="(item, index) in terminals" :key="item.id"
16
+                            :class="{ active: currentTab === item.id }" class="tabs-item" @click="tabClick(item.id)">
17
+                            <u-checkbox-group @change="handleTerminalChange(item)">
18
+                                <u-checkbox v-model="item.checked" @change="handleTerminalCheck(item)"></u-checkbox>
19
+                            </u-checkbox-group>
20
+                            {{ item.label }}
21
+                        </div>
22
+                    </div>
23
+
24
+                    <view v-for="(terminal, index) in terminals" :key="terminal.id" class="area-list"
25
+                        v-show="currentTab === terminal.id">
26
+                        <u-checkbox-group>
27
+                            <view v-for="area in terminal.children" :key="area.id" class="area-item">
28
+                                <u-checkbox :label="area.label" :checked="area.checked"
29
+                                    @change="handleAreaCheck(area)"></u-checkbox>
30
+                            </view>
31
+                        </u-checkbox-group>
32
+                    </view>
33
+                </view>
34
+                <view class="footer-btn">
35
+                    <view class="custom-btn-normal" @click="submitSelectedAreas">确认选择</view>
36
+                </view>
37
+            </view>
38
+        </u-popup>
39
+    </view>
40
+</template>
41
+
42
+<script>
43
+import { formatTime } from '@/utils/formatUtils'
44
+import { dataConfigTree } from "@/api/attendance/attendance"
45
+
46
+export default {
47
+    components: {},
48
+    props: {
49
+        disabled: {
50
+            type: Boolean,
51
+            default: false
52
+        }
53
+    },
54
+    data() {
55
+        return {
56
+            show: false,
57
+            terminals: [],
58
+
59
+            currentTab: 0,
60
+            searchFocus: false,
61
+
62
+        }
63
+    },
64
+    computed: {
65
+
66
+    },
67
+    watch: {
68
+
69
+        show(newValue) {
70
+            if (newValue) {
71
+                const level = this.notkezhang ? 3 : 2
72
+
73
+                this.loadTerminals(level)
74
+            }
75
+        },
76
+        notkezhang: {
77
+            handler(newValue) {
78
+                const level = newValue ? 3 : 2
79
+
80
+                this.loadTerminals(level)
81
+            },
82
+            immediate: true
83
+        }
84
+    },
85
+    methods: {
86
+
87
+        handleTerminalChange(terminalObj) {
88
+            const curTerminal = this.terminals.find((item) => item.id == terminalObj.id)
89
+            for (let i = 0; i < curTerminal.children.length; i++) {
90
+                const child = curTerminal.children[i];
91
+                console.log(child, "child")
92
+                this.$set(child, 'checked', !child.checked)
93
+                this.$forceUpdate()
94
+            }
95
+        },
96
+        handleAreaCheck(area) {
97
+            area.checked = !area.checked
98
+        },
99
+        submitSelectedAreas() {
100
+            this.$emit('selected', this.terminals)
101
+            this.show = false
102
+        },
103
+        loadTerminals(level) {
104
+            dataConfigTree(level).then(res => {
105
+                console.log(res, "res")
106
+                this.terminals = res.data
107
+                this.currentTab = this.terminals[0].id
108
+            })
109
+
110
+        },
111
+        tabClick(id) {
112
+
113
+            this.currentTab = id;
114
+
115
+        },
116
+        handleTerminalCheck(terminal) {
117
+
118
+            this.$nextTick(() => {
119
+
120
+                const curItem = this.terminals.find((item) => item.id == terminal.id)
121
+                this.$set(curItem, 'checked', true)
122
+
123
+                for (let i = 0; i < curItem.children.length; i++) {
124
+                    this.$set(curItem.children[i], 'checked', !!curItem.children[i].checked)
125
+                }
126
+
127
+                this.$forceUpdate()
128
+            })
129
+
130
+
131
+        },
132
+        openModal() {
133
+            if (this.disabled) return
134
+            this.show = true
135
+        },
136
+        close() {
137
+            this.show = false
138
+        },
139
+    },
140
+}
141
+</script>
142
+
143
+<style lang="scss" scoped>
144
+.u-checkbox-group--row {
145
+    flex-direction: column !important;
146
+}
147
+
148
+.selectAreaModal {
149
+    .slot-content {}
150
+
151
+    .empty {
152
+        margin: 0 auto;
153
+        width: 160px;
154
+        height: 155px;
155
+        background: url("../../../static/images/Empty.png") no-repeat;
156
+        background-size: cover;
157
+    }
158
+
159
+    .modal-content {
160
+        width: 90vw;
161
+        max-height: 75vh;
162
+        padding: 10px 0;
163
+
164
+        .terminal-list {
165
+            padding: 0px 30rpx;
166
+
167
+            .tabs {
168
+                display: flex;
169
+                padding: 0rpx 0 0rpx;
170
+                margin-top: 32rpx;
171
+                font-size: 28rpx;
172
+                color: #666666;
173
+                line-height: 32rpx;
174
+                gap: 48rpx;
175
+
176
+                .tabs-item {
177
+                    display: flex;
178
+                    align-items: center;
179
+                    position: relative;
180
+
181
+                    &:after {
182
+                        content: "";
183
+                        position: absolute;
184
+                        bottom: -12rpx;
185
+                        left: 0;
186
+                        width: 50%;
187
+                        height: 6rpx;
188
+                        background: #fff;
189
+                        border-radius: 12rpx;
190
+                    }
191
+
192
+                    &.active {
193
+                        font-size: 36rpx;
194
+                        color: #2A70D1;
195
+                        line-height: 42rpx;
196
+
197
+                        &:after {
198
+                            background: #2A70D1;
199
+                        }
200
+                    }
201
+                }
202
+            }
203
+
204
+            .area-list {
205
+                padding: 15rpx 0;
206
+
207
+                .area-item {
208
+                    display: flex;
209
+                    height: 60rpx;
210
+                    line-height: 60rpx;
211
+                }
212
+            }
213
+        }
214
+
215
+        .title {
216
+            height: 24px;
217
+            font-weight: 400;
218
+            font-size: 18px;
219
+            color: #333333;
220
+            line-height: 21px;
221
+            display: flex;
222
+            justify-content: space-between;
223
+            align-items: center;
224
+            padding: 15px;
225
+            box-sizing: border-box;
226
+        }
227
+
228
+        .title-cell {
229
+            padding: 10px 15px;
230
+            box-sizing: border-box;
231
+
232
+            .title-text {
233
+                height: 16px;
234
+                font-weight: 400;
235
+                font-size: 14px;
236
+                color: #333333;
237
+                line-height: 16px;
238
+                text-align: left;
239
+                margin-bottom: 14px;
240
+                text-indent: 5px;
241
+            }
242
+        }
243
+
244
+        .personnel-list {
245
+            display: flex;
246
+            flex-wrap: wrap;
247
+            row-gap: 8px;
248
+            column-gap: 6px;
249
+
250
+            .personnel-item {
251
+                width: fit-content;
252
+                height: 34px;
253
+                background: #F0F0F0;
254
+                border-radius: 6px;
255
+                display: flex;
256
+                align-items: center;
257
+                column-gap: 6px;
258
+                padding: 0 5px;
259
+
260
+                .personnel-img {
261
+                    width: 24px;
262
+                    height: 24px;
263
+                    border-radius: 6px;
264
+                    overflow: hidden;
265
+                }
266
+
267
+                .personnel-name {
268
+                    width: fit-content;
269
+                    font-weight: 400;
270
+                    font-size: 13px;
271
+                    color: #3D3D3D;
272
+                    text-align: left;
273
+                    font-style: normal;
274
+                    text-transform: none;
275
+                }
276
+
277
+                .personnel-close {
278
+                    width: 16px;
279
+                }
280
+
281
+                .radio-button {
282
+                    width: 14px;
283
+                    height: 14px;
284
+                    border: 1px solid #2196F3;
285
+                    background: #fff;
286
+                    border-radius: 50%;
287
+                    position: relative;
288
+                    transition: border-color 0.2s ease;
289
+                }
290
+
291
+                /* 激活状态 - 蓝色边框 */
292
+                .radio-button.active {
293
+                    border-color: #496CF4;
294
+                }
295
+
296
+                /* 激活状态 - 中间的蓝点 */
297
+                .radio-button.active::after {
298
+                    content: '';
299
+                    position: absolute;
300
+                    top: 50%;
301
+                    left: 50%;
302
+                    transform: translate(-50%, -50%);
303
+                    width: 7px;
304
+                    height: 7px;
305
+                    background-color: #2196F3;
306
+                    border-radius: 50%;
307
+                }
308
+
309
+                /* 聚焦状态 */
310
+                .radio-button:focus {
311
+                    outline: none;
312
+                    box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.3);
313
+                }
314
+
315
+                /* 悬停效果 */
316
+                .radio-button:hover .radio-button:not(.active) {
317
+                    border-color: #999;
318
+                }
319
+
320
+                /* 禁用状态 */
321
+                .radio-button.disabled {
322
+                    opacity: 0.5;
323
+                    border-color: #999;
324
+                    background: #f0f0f0;
325
+                    cursor: not-allowed;
326
+                }
327
+            }
328
+        }
329
+
330
+        .footer-btn {
331
+            padding: 10px 15px;
332
+
333
+            .modal-slot-content {
334
+                white-space: pre;
335
+
336
+            }
337
+        }
338
+    }
339
+}
340
+</style>

+ 75 - 0
src/pages/attendance/components/SelectData.vue

@@ -0,0 +1,75 @@
1
+<template>
2
+  <view class="search-input-container">
3
+    <input
4
+      class="search-input"
5
+      v-model="searchKeyword"
6
+      :placeholder="placeholder"
7
+      @focus="inputFocus"
8
+      @blur="inputBlur"
9
+      @input="searchUsers"
10
+    />
11
+  </view>
12
+</template>
13
+
14
+<script>
15
+import { debounce } from '@/utils/handler'
16
+export default {
17
+  props: {
18
+    placeholder: {
19
+      type: String,
20
+      default: '请输入人员姓名'
21
+    },
22
+    
23
+  },
24
+  data () {
25
+    this.searchUsers = debounce(() => {
26
+      this.$emit('search', this.searchKeyword);
27
+    }, 200);
28
+    return {
29
+      searchKeyword: '',
30
+    }
31
+  },
32
+  
33
+  methods: {
34
+    inputFocus () {
35
+      this.$emit('searchViewShowEvent', true)
36
+    },
37
+    inputBlur () {
38
+      const searchViewShow = this.searchKeyword ? true : false
39
+      this.$emit('searchViewShowEvent', searchViewShow)
40
+    },
41
+    clearInput () {
42
+      this.searchKeyword = ''
43
+    }
44
+  },
45
+}
46
+</script>
47
+
48
+<style lang="scss" scoped>
49
+
50
+.search-input-container {
51
+  position: relative;
52
+
53
+  .search-input {
54
+    width: 100%;
55
+    height: 35px;
56
+    border: 1px solid #ddd;
57
+    border-radius: 6px;
58
+    padding: 0 40px 0 12px;
59
+    font-size: 14px;
60
+    color: #333;
61
+
62
+    &::placeholder {
63
+      color: #999;
64
+    }
65
+  }
66
+
67
+  .search-icon {
68
+    position: absolute;
69
+    right: 12px;
70
+    top: 50%;
71
+    transform: translateY(-50%);
72
+    font-size: 16px;
73
+  }
74
+}
75
+</style>

+ 59 - 0
src/pages/attendance/components/TimePicker.vue

@@ -0,0 +1,59 @@
1
+<template>
2
+  <view class="timePicker-container" @click="datetimePickerShow">
3
+    <input class="timePicker-input" type="text" v-model="viewValue" readonly />
4
+    <u-datetime-picker
5
+      :show="timePickerShow"
6
+      v-model="currentTime"
7
+      mode="time"
8
+      @confirm="datetimePickerConfirm"
9
+      @cancel="datetimePickerClose"
10
+      @close="datetimePickerClose"
11
+    />
12
+  </view>
13
+</template>
14
+
15
+<script>
16
+import { formatTime } from '@/utils/formatUtils'
17
+export default {
18
+  data () {
19
+    return {
20
+      timePickerShow: false,
21
+      currentTime: formatTime(new Date(), 'hh:mm'),
22
+      viewValue: formatTime(new Date(), 'hh:mm'),
23
+    }
24
+  },
25
+  methods: {
26
+    datetimePickerShow () {
27
+      this.timePickerShow = true
28
+    },
29
+    datetimePickerClose () {
30
+      this.timePickerShow = false
31
+    },
32
+    datetimePickerConfirm (result) {
33
+      const { value } = result
34
+
35
+      this.datetimePickerClose()
36
+    }
37
+  },
38
+}
39
+</script>
40
+
41
+<style lang="scss" scoped>
42
+.timePicker-container {
43
+  position: relative;
44
+
45
+  .timePicker-input {
46
+    width: 100%;
47
+    height: 35px;
48
+    border: 1px solid #ddd;
49
+    border-radius: 6px;
50
+    padding: 0 40px 0 12px;
51
+    font-size: 14px;
52
+    color: #333;
53
+
54
+    &::placeholder {
55
+      color: #999;
56
+    }
57
+  }
58
+}
59
+</style>

+ 55 - 0
src/pages/attendance/components/UserAvatar.vue

@@ -0,0 +1,55 @@
1
+<template>
2
+  <view class="user-avatar">
3
+    <image class="user-avatar-content" v-if="avatarLink && showImg" :src="avatarLink" @load="imageLoad" @error="imageLoadError" />
4
+    <view class="user-avatar-content" :style="{ background: getAvatarGradient(userName) }"  v-else>{{ getUserIndexChat() }}</view>
5
+  </view>
6
+</template>
7
+
8
+<script>
9
+import { getAvatarGradient } from '@/utils/handler'
10
+export default {
11
+  props: {
12
+    avatarLink: {
13
+      type: String,
14
+      default: ''
15
+    },
16
+    userName: {
17
+      type: String,
18
+      default: ''
19
+    }
20
+  },
21
+  data () {
22
+    return {
23
+      showImg: true
24
+    }
25
+  },
26
+  methods: {
27
+    getAvatarGradient,
28
+    getUserIndexChat () {
29
+      return this.userName ? this.userName.split('')[0] : '用'
30
+    },
31
+    imageLoad () {
32
+      this.showImg = true
33
+    },
34
+    imageLoadError () {
35
+      this.showImg = false
36
+    }
37
+  }
38
+}
39
+</script>
40
+
41
+<style lang="scss" scoped>
42
+.user-avatar {
43
+  width: 100%;
44
+  height: 100%;
45
+  &-content {
46
+    width: 100%;
47
+    height: 100%;
48
+    display: flex;
49
+    align-items: center;
50
+    justify-content: center;
51
+    color: #fff;
52
+    font-weight: 500;
53
+  }
54
+}
55
+</style>

+ 510 - 0
src/pages/attendance/components/WorkingGroup.vue

@@ -0,0 +1,510 @@
1
+<template>
2
+  <view class="workGroup-list">
3
+    <view class="working-group" v-for="({ data, info }, index) of historyList" :key="index">
4
+      <view>
5
+        <view class="title" v-if="!info.isCheckIn">{{ '开始上通道/负责区域' }}</view>
6
+        <view v-if="!info.isCheckIn" class="content">
7
+          <view class="empty" style="margin: 20px auto 0;"></view>
8
+          <view class="empty-text">当前未上通道/区域</view>
9
+        </view>
10
+        <view v-else class="content">
11
+          <view class="content-cell" v-if="notkezhang">
12
+            <text class="content-cell-label">通道</text>
13
+            <text class="content-cell-value">{{ info.channelName || info.channelCode || '--' }}</text>
14
+          </view>
15
+          <view class="content-cell" v-if="notkezhang">
16
+            <text class="content-cell-label">当班人员</text>
17
+            <text></text>
18
+          </view>
19
+          <view class="content-workings" v-if="notkezhang">
20
+            <view class="personnel-list" v-if="data.length">
21
+              <view class="personnel-item" v-for="item of data" :key="item.id">
22
+                <view class="personnel-img">
23
+                  <UserAvatar :userName="item.userName" :avatarLink="item.avatar" />
24
+                </view>
25
+                <view class="personnel-name">{{ item.userName }}</view>
26
+              </view>
27
+            </view>
28
+            <view class="empty" v-else></view>
29
+          </view>
30
+          <view class="content-cell" style="height: auto;display: flex;flex-direction: column;align-items: flex-start;"
31
+            v-if="!notkezhang">
32
+            <text class="content-cell-label">负责区域</text>
33
+            <!-- <text class="content-cell-value">{{ info.regionalName || info.regionalCode || '--' }}</text> -->
34
+            <view class="selected-areas" v-if="getHandleAreaData(data) && getHandleAreaData(data).length && !notkezhang">
35
+              <view class="area-item" v-for="area in getHandleAreaData(data)" :key="area.code">
36
+                <view class="area-label">{{ area.label }}</view>
37
+                <text-tag :showClose="false" :tags="area.children" :labelColumn="'regionalName'" />
38
+              </view>
39
+            </view>
40
+          </view>
41
+          <view class="content-cell">
42
+            <text class="content-cell-label">上通道时间</text>
43
+            <text class="content-cell-value">{{ info.checkInTimeFormat }}</text>
44
+          </view>
45
+          <view class="content-cell" v-if="info.isCheckIn">
46
+            <text class="content-cell-label">下通道时间</text>
47
+            <text class="content-cell-value">{{ info.checkOutTime || '-:-' }}</text>
48
+          </view>
49
+        </view>
50
+      </view>
51
+      <!-- :disabled="!attendanceInfo.checkInTime || checkOutStatus" -->
52
+      <AddAttendancePersonnelModal v-if="index === 0" :disabled="checkOutStatus" :notkezhang="notkezhang"
53
+        :userInfo="userInfo" @updateRecord="invokerGetPostRecordList" :selectedMember="selectedMember">
54
+        <!-- :class="{ disabled: !attendanceInfo.checkInTime }"  不加控制逻辑  科长没有选人可以上通道,但是其他人必须选人才能选人上通道 -->
55
+        <view v-if="authority" class="custom-btn" @click="openModal"
56
+          :class="{ disabled: selectedMember.length === 0 && !userInfo.roles.includes('kezhang') }">
57
+          {{ checkOutStatus ? '下通道' : '上通道' }}
58
+        </view>
59
+      </AddAttendancePersonnelModal>
60
+    </view>
61
+    <u-popup :show="checkOutWork" mode="center" :round="8">
62
+      <view class="modal-content">
63
+        <view class="title">
64
+          <text>员工下通道</text>
65
+          <u-icon name="close" color="#666666" size="20" @click="close" />
66
+        </view>
67
+        <view class="title-cell">
68
+          <view class="title-text">{{ '选择下通道时间' }}</view>
69
+          <uni-datetime-picker type="datetime" v-model="currentTime" />
70
+        </view>
71
+        <view class="title-cell">
72
+          <view class="custom-btn-normal" @click="invokerUpdatePostRecord" :class="{ disabled: !currentTime }">确认下通道
73
+          </view>
74
+        </view>
75
+      </view>
76
+    </u-popup>
77
+  </view>
78
+</template>
79
+
80
+<script>
81
+import { listgroupbyTimeanduserid, updatePostRecord } from "@/api/attendance/attendance"
82
+import { formatTime, formatName } from '@/utils/formatUtils'
83
+import AddAttendancePersonnelModal from './AddAttendancePersonnelModal';
84
+import UserAvatar from './UserAvatar'
85
+export default {
86
+  components: { AddAttendancePersonnelModal, UserAvatar },
87
+  props: {
88
+    userInfo: {
89
+      type: Object,
90
+      default: () => ({})
91
+    },
92
+    attendanceInfo: { // 考勤信息
93
+      type: Object,
94
+      default: () => ({})
95
+    },
96
+    selectedMember: {
97
+      type: Array,
98
+      default: () => []
99
+    }
100
+  },
101
+  data() {
102
+    return {
103
+      curUserWorkingGroupData: {
104
+        passage: '',
105
+        checkInTime: '',
106
+        checkOutTime: '',
107
+        isCheckIn: false,
108
+      },
109
+      checkOutStatus: false,
110
+      checkOutWork: false,
111
+      currentTime: undefined,
112
+      workingGroupId: '',
113
+      historyList: [], //班组工作记录 todo 现在只能做科长
114
+   
115
+    }
116
+  },
117
+  computed: {
118
+    notkezhang() {
119
+      return !this.userInfo.roles || !this.userInfo.roles.includes('kezhang')
120
+    },
121
+    authority() { // 上通道权限
122
+      return this.userInfo.roles && (
123
+        this.userInfo.roles.includes('kezhang') ||
124
+        this.userInfo.roles.includes('banzuzhang')
125
+      )
126
+    }
127
+  },
128
+  methods: {
129
+    openModal() {
130
+      if (!this.checkOutStatus) {
131
+        return;
132
+      }
133
+      this.currentTime = formatTime(new Date())
134
+      this.checkOutWork = true
135
+    },
136
+    close() {
137
+      this.currentTime = undefined
138
+      this.checkOutWork = false
139
+    },
140
+    isCheckOutJobs(curUserWorkingInfo = this.curUserWorkingGroupData) {
141
+
142
+      return Boolean(curUserWorkingInfo.checkInTime) && (!curUserWorkingInfo.checkOutTime || curUserWorkingInfo.checkOutTime === '2000-01-01 00:00:00')
143
+    },
144
+    getHandleAreaData(data) {
145
+      let louObj = {};
146
+      let areaArr = [];
147
+      data.forEach(element => {
148
+
149
+        let name = `${element.terminlName}-${element.terminlCode}`
150
+        if (!louObj[name]) {
151
+          louObj[name] = [];
152
+        }
153
+        louObj[name].push(element);
154
+      });
155
+
156
+      Object.keys(louObj).forEach(key => {
157
+        areaArr.push({
158
+          label: key.split('-')[0],
159
+          code: key.split('-')[1],
160
+          children: louObj[key]
161
+        });
162
+      });
163
+
164
+      return areaArr;
165
+    },
166
+    invokerGetPostRecordList(callback) {
167
+      return listgroupbyTimeanduserid().then(res => {
168
+        if (res.code === 200) {
169
+          const resData = (res.data || [])
170
+          this.historyList = resData.reduce((cur, item, index) => {
171
+            let info = {}
172
+            const workDataList = item.records.map((attr) => {
173
+              const result = {
174
+                ...attr,
175
+                isCheckIn: Boolean(attr.checkInTime), // 是否上过通道
176
+                checkInTimeFormat: formatTime(attr.checkInTime, 'hh:mm:ss'),
177
+                checkOutTime: attr.checkOutTime === '2000-01-01 00:00:00' ? '' : formatTime(attr.checkOutTime, 'hh:mm:ss'),
178
+                userName: formatName(attr.userName),
179
+              }
180
+              if (attr.userId === this.userInfo.userId) {
181
+                info = result
182
+              }
183
+
184
+              if (index === 0) { // 取最新纪录
185
+                this.curUserWorkingGroupData = info
186
+                this.workingGroupId = item.shiftCode
187
+              }
188
+              return result
189
+            })
190
+            cur.push({
191
+              info: info,
192
+              data: workDataList,
193
+              workingGroupId: item.shiftCode
194
+            })
195
+            return cur
196
+          }, [])
197
+
198
+
199
+
200
+        
201
+
202
+          this.checkOutStatus = this.isCheckOutJobs(this.curUserWorkingGroupData)
203
+
204
+          if (!this.checkOutStatus) {
205
+            this.historyList = [{
206
+              info: {},
207
+              data: [],
208
+              workingGroupId: undefined
209
+            }, ...this.historyList]
210
+          }
211
+        }
212
+      }).catch(() => {
213
+        return Promise.resolve()
214
+      }).finally(() => {
215
+        callback && callback()
216
+      })
217
+    },
218
+
219
+    createRecordData(curUserRecordInfo) {
220
+      // 🔥 重要:计算工作时长
221
+      const checkTime = new Date(this.currentTime);
222
+      const workDurationMinutes = Math.floor((Date.now() - checkTime.getTime()) / 1000 / 60);
223
+      // 🔥 重要:构建更新数据
224
+      let res = [];
225
+      //如果是科长走多选逻辑,如果是班组长走单选逻辑
226
+      if (!this.notkezhang) {
227
+        console.log(this.historyList)
228
+
229
+        this.historyList.forEach(item => {
230
+          item.data.forEach(element => {
231
+            res.push(
232
+              {
233
+                // 基本字段
234
+                ...curUserRecordInfo,
235
+                isCheckIn: undefined,
236
+                revision: (curUserRecordInfo.revision || 1) + 1,
237
+                // 时间相关
238
+                checkInTime: typeof curUserRecordInfo.checkInTime === 'string' ? curUserRecordInfo.checkInTime : formatTime(new Date(curUserRecordInfo.checkInTime)),
239
+                attendanceDate: typeof curUserRecordInfo.attendanceDate === 'string' ? curUserRecordInfo.attendanceDate : formatTime(new Date(curUserRecordInfo.attendanceDate), 'YYYY-MM-DD'),
240
+                workDuration: workDurationMinutes, // 重新计算工作时长
241
+                overDuration: curUserRecordInfo.overDuration || 0,
242
+                // 组织架构信息
243
+                attendanceTeamId: curUserRecordInfo.attendanceTeamId || null,
244
+                attendanceTeamName: curUserRecordInfo.attendanceTeamName || '',
245
+                attendanceDepartmentId: curUserRecordInfo.attendanceDepartmentId || null,
246
+                attendanceDepartmentName: curUserRecordInfo.attendanceDepartmentName || '',
247
+                attendanceStationId: curUserRecordInfo.attendanceStationId || null,
248
+                attendanceStationName: curUserRecordInfo.attendanceStationName || '',
249
+                remark: curUserRecordInfo.remark || '手动添加上通道记录',
250
+                // 审计字段
251
+                createBy: curUserRecordInfo.createBy || (this.userInfo.userId + ''),
252
+                createTime: typeof curUserRecordInfo.createTime === 'string' ? curUserRecordInfo.createTime : formatTime(new Date(curUserRecordInfo.createTime)),
253
+                updateBy: (this.userInfo.userId + ''),
254
+                updateTime: formatTime(new Date()),
255
+                ...element,
256
+                checkOutTime: formatTime(checkTime), // 设置实际下岗时间
257
+              }
258
+            )
259
+          })
260
+        })
261
+      } else {
262
+        res = [{
263
+          // 基本字段
264
+          ...curUserRecordInfo,
265
+          isCheckIn: undefined,
266
+          revision: (curUserRecordInfo.revision || 1) + 1,
267
+          // 时间相关
268
+          checkInTime: typeof curUserRecordInfo.checkInTime === 'string' ? curUserRecordInfo.checkInTime : formatTime(new Date(curUserRecordInfo.checkInTime)),
269
+          checkOutTime: formatTime(checkTime), // 设置实际下岗时间
270
+          attendanceDate: typeof curUserRecordInfo.attendanceDate === 'string' ? curUserRecordInfo.attendanceDate : formatTime(new Date(curUserRecordInfo.attendanceDate), 'YYYY-MM-DD'),
271
+          workDuration: workDurationMinutes, // 重新计算工作时长
272
+          overDuration: curUserRecordInfo.overDuration || 0,
273
+          // 组织架构信息
274
+          attendanceTeamId: curUserRecordInfo.attendanceTeamId || null,
275
+          attendanceTeamName: curUserRecordInfo.attendanceTeamName || '',
276
+          attendanceDepartmentId: curUserRecordInfo.attendanceDepartmentId || null,
277
+          attendanceDepartmentName: curUserRecordInfo.attendanceDepartmentName || '',
278
+          attendanceStationId: curUserRecordInfo.attendanceStationId || null,
279
+          attendanceStationName: curUserRecordInfo.attendanceStationName || '',
280
+          remark: curUserRecordInfo.remark || '手动添加上通道记录',
281
+          // 审计字段
282
+          createBy: curUserRecordInfo.createBy || (this.userInfo.userId + ''),
283
+          createTime: typeof curUserRecordInfo.createTime === 'string' ? curUserRecordInfo.createTime : formatTime(new Date(curUserRecordInfo.createTime)),
284
+          updateBy: (this.userInfo.userId + ''),
285
+          updateTime: formatTime(new Date())
286
+        }]
287
+      }
288
+      console.log(res, "res")
289
+
290
+      return res;
291
+    },
292
+
293
+    invokerUpdatePostRecord() {
294
+      if (this.checkOutStatus) {
295
+        // 科长下区域
296
+        if (!this.notkezhang) {
297
+          uni.showLoading({ title: '正在下通道...' });
298
+          updatePostRecord(this.createRecordData(this.curUserWorkingGroupData)).then(res => {
299
+            if (res && res.code === 200) {
300
+              uni.showToast({
301
+                title: '下通道完成!',
302
+                icon: 'success',
303
+                duration: 2000
304
+              });
305
+            } else {
306
+              uni.showToast({
307
+                title: `下通道失败!${res.msg || '未知错误'}`,
308
+                icon: 'none',
309
+                duration: 2000
310
+              });
311
+            }
312
+            this.close()
313
+            return this.invokerGetPostRecordList()
314
+          }).catch(error => {
315
+            uni.showToast({
316
+              title: '下通道失败:' + (error.msg || '请重试'),
317
+              icon: 'error',
318
+              duration: 2000
319
+            });
320
+          }).finally(() => {
321
+            uni.hideLoading();
322
+          })
323
+        } else {// 当班人员下通道
324
+          uni.showLoading({ title: '正在批量下通道...' });
325
+          // 获取当前最新的工作组数据
326
+          const recordList = this.historyList.find(item => item.workingGroupId === this.workingGroupId) || { data: [] }
327
+          const promises = recordList.data.map(item => {
328
+            return updatePostRecord(this.createRecordData(item)).then(res => {
329
+              if (res && res.code === 200) {
330
+                return `${item.userName}: 下通道完成!`
331
+              } else {
332
+                return `${item.userName}下通道失败: ${res.msg || '未知错误'}`
333
+              }
334
+            }).catch((err) => {
335
+              return Promise.resolve(`${item.userName}下通道失败: 网络错误!`)
336
+            })
337
+          })
338
+          Promise.all(promises).then((res) => {
339
+            uni.showModal({
340
+              title: '批量下通道结果',
341
+              content: `${res.join('\n')}`,
342
+              showCancel: false,
343
+              confirmText: '确定'
344
+            });
345
+            this.close()
346
+            return this.invokerGetPostRecordList()
347
+          }).finally(() => {
348
+            uni.hideLoading();
349
+          })
350
+        }
351
+      }
352
+    }
353
+  },
354
+}
355
+</script>
356
+
357
+<style lang="scss" scoped>
358
+.workGroup-list {
359
+  display: flex;
360
+  flex-direction: column;
361
+  row-gap: 15px;
362
+}
363
+
364
+.working-group {
365
+  width: 100%;
366
+  background: #FFFFFF;
367
+  box-shadow: 0px 4px 10px 0px rgba(0, 0, 0, 0.08);
368
+  border-radius: 16px 16px 16px 16px;
369
+  border: 1px solid #F0F8FF;
370
+  padding: 15px;
371
+  box-sizing: border-box;
372
+
373
+  .empty {
374
+    margin: 0 auto;
375
+    width: 160px;
376
+    height: 155px;
377
+    background: url("../../../static/images/Empty.png") no-repeat;
378
+    background-size: cover;
379
+  }
380
+
381
+  .empty-text {
382
+    font-weight: 400;
383
+    font-size: 14px;
384
+    color: #3D3D3D;
385
+    line-height: 16px;
386
+    text-align: center;
387
+    font-style: normal;
388
+    text-transform: none;
389
+    margin-bottom: 20px;
390
+  }
391
+
392
+  .title {
393
+    font-weight: 400;
394
+    font-size: 18px;
395
+    color: #333333;
396
+    line-height: 24px;
397
+    text-align: left;
398
+    font-style: normal;
399
+    text-transform: none;
400
+    padding: 5px 0;
401
+  }
402
+
403
+  .content-cell {
404
+    display: flex;
405
+    align-items: center;
406
+    justify-content: space-between;
407
+    font-weight: 400;
408
+    font-size: 14px;
409
+    font-style: normal;
410
+    text-transform: none;
411
+    width: 100%;
412
+    height: 36px;
413
+
414
+    .content-cell-label {
415
+      color: #222222;
416
+    }
417
+
418
+    .content-cell-value {
419
+      color: #999999;
420
+      padding-right: 5px;
421
+    }
422
+
423
+    .selected-areas {
424
+      .area-item {
425
+        .area-label {
426
+          height: 72rpx;
427
+          line-height: 72rpx;
428
+        }
429
+      }
430
+    }
431
+
432
+
433
+  }
434
+
435
+  .content-workings {
436
+    display: flex;
437
+    padding: 10px 0;
438
+
439
+    .personnel-list {
440
+      display: flex;
441
+      flex-wrap: wrap;
442
+      row-gap: 10px;
443
+      column-gap: 10px;
444
+
445
+      .personnel-item {
446
+        width: fit-content;
447
+        height: 34px;
448
+        background: #F0F0F0;
449
+        border-radius: 6px;
450
+        display: flex;
451
+        align-items: center;
452
+        column-gap: 8px;
453
+        padding: 0 10px;
454
+
455
+        .personnel-img {
456
+          width: 24px;
457
+          height: 24px;
458
+          border-radius: 6px;
459
+          overflow: hidden;
460
+        }
461
+
462
+        .personnel-name {
463
+          width: fit-content;
464
+          font-weight: 400;
465
+          font-size: 13px;
466
+          color: #3D3D3D;
467
+          text-align: left;
468
+          font-style: normal;
469
+          text-transform: none;
470
+        }
471
+      }
472
+    }
473
+  }
474
+}
475
+
476
+.modal-content {
477
+  width: 90vw;
478
+  max-height: 75vh;
479
+  padding: 10px 0;
480
+
481
+  .title {
482
+    height: 24px;
483
+    font-weight: 400;
484
+    font-size: 18px;
485
+    color: #333333;
486
+    line-height: 21px;
487
+    display: flex;
488
+    justify-content: space-between;
489
+    align-items: center;
490
+    padding: 15px;
491
+    box-sizing: border-box;
492
+  }
493
+
494
+  .title-cell {
495
+    padding: 10px 15px;
496
+    box-sizing: border-box;
497
+
498
+    .title-text {
499
+      height: 16px;
500
+      font-weight: 400;
501
+      font-size: 14px;
502
+      color: #333333;
503
+      line-height: 16px;
504
+      text-align: left;
505
+      margin-bottom: 14px;
506
+      text-indent: 5px;
507
+    }
508
+  }
509
+}
510
+</style>

+ 368 - 0
src/pages/attendance/index.vue

@@ -0,0 +1,368 @@
1
+<template>
2
+  <home-container :customStyle="{ backgroundPositionY: '-50px' }">
3
+    <view class="attendance-container">
4
+      <!-- 顶部个人信息栏 -->
5
+      <view class="user-header" v-if="currentUser">
6
+        <view class="user-avatar">
7
+          <image v-if="currentUser.avatar" :src="currentUser.avatar" class="cu-avatar xl round"></image>
8
+        </view>
9
+        <view class="user-info">
10
+          <text class="user-name">{{ `${userInfoAndRoles.nickName} ${userInfoAndRoles.userName}` }}</text>
11
+          <text v-if="userInfo.userInfo.stationName" class="position-item">{{ userInfo.userInfo.stationName }}</text>
12
+          <text v-if="userInfo.userInfo.stationName && userInfo.userInfo.departmentName" class="separator">/</text>
13
+          <text v-if="userInfo.userInfo.departmentName" class="position-item">{{
14
+            userInfo.userInfo.departmentName }}</text>
15
+          <text v-if="userInfo.userInfo.departmentName && userInfo.userInfo.teamsName" class="separator">/</text>
16
+          <text v-if="userInfo.userInfo.teamsName" class="position-item">{{ userInfo.userInfo.teamsName }}</text>
17
+        </view>
18
+        <view class="stats-btn" @click="showStats">
19
+          <uni-icons type="calendar" size="24" color="#666"></uni-icons>
20
+          <text class="btn-text">打卡统计</text>
21
+        </view>
22
+      </view>
23
+
24
+      <!-- 新界面打卡 -->
25
+      <AttendanceControl :attendanceInfo="attendanceInfo" :checkInPosiInfo="checkInPosiInfo"
26
+        @attendanceHandler="handleCheckIn" />
27
+      <!-- 工作区域/班组成员 -->
28
+      <MaintainAreaOrMemberModal ref="maintainAreaOrMemberModal" v-if="showMaintain" :userInfo="userInfoAndRoles"
29
+        @update-member="updateMember" />
30
+      <!-- 新界面工作组状态 -->
31
+      <WorkingGroup ref="workingGroup" style="margin: 16px 0 24px 0" :attendanceInfo="attendanceInfo"
32
+        :loadUserInfoOver="loadUserInfoOver" :userInfo="userInfoAndRoles" :selectedMember="selectedMember" />
33
+    </view>
34
+  </home-container>
35
+</template>
36
+
37
+<script>
38
+import HomeContainer from "@/components/HomeContainer.vue";
39
+import AttendanceControl from './components/AttendanceControl'
40
+import MaintainAreaOrMemberModal from './components/MaintainAreaOrMemberModal'
41
+import WorkingGroup from './components/WorkingGroup'
42
+import { addAttendance, areaList } from "@/api/attendance/attendance"
43
+import { getInfo } from "@/api/login"
44
+import { formatTime as formatTimehandler } from '@/utils/formatUtils'
45
+import { getAttendanceList } from "@/api/attendance/attendance"
46
+import { isInRangeOptimized, wgs84ToGcj02 } from '@/utils/handler'
47
+
48
+export default {
49
+  components: { HomeContainer, AttendanceControl, WorkingGroup, MaintainAreaOrMemberModal },
50
+
51
+  computed: {
52
+    currentUser() {
53
+      return this.$store.state.user;
54
+    },
55
+    userInfoAndRoles() {
56
+      return {
57
+        ...this.userInfo.userInfo,
58
+        roles: this.userInfo.roles
59
+      }
60
+    },
61
+    showMaintain() {
62
+      return this.userInfo.roles.includes('banzuzhang')
63
+    }
64
+  },
65
+  data() {
66
+    return {
67
+      yesterday: false,
68
+      checkInType: 'morning', // morning/afternoon
69
+      isCheckedIn: false,
70
+      checkInBtnText: '打卡',
71
+      currentTime: this.formatTime(new Date()),
72
+      locationIndex: 0,
73
+
74
+      locations: [
75
+        '民航机场',
76
+        '东方雨虹新材料装备研发总部基地',
77
+        '北京阳光汇点数码总部',
78
+        '海淀区研发中心'
79
+      ],
80
+      groupIndex: 0,
81
+      groups: [
82
+        '技术部',
83
+        '产品部',
84
+        '运营部',
85
+        '市场部'
86
+      ],
87
+      records: [],          // 今日真实打卡列表
88
+      yesterdayRecords: [], // 昨天真实打卡列表
89
+      userInfo: {
90
+        userInfo: {},
91
+        roles: []
92
+      },
93
+      checkInPosiInfo: [],
94
+      attendanceInfo: {
95
+        attendanceDate: '', // 当前日期
96
+        checkInTime: '', //上班打卡时间
97
+        checkOutTime: '', //下班打卡时间
98
+        items: [], // 打开记录 第一条为最早上班打卡时间 最后一条为最晚下班打卡时间
99
+      },
100
+      loadUserInfoOver: false,
101
+      selectedMember: [],
102
+      result: null
103
+    }
104
+  },
105
+  mounted() {
106
+    this.updateCurrentTime();
107
+    this.checkCheckInStatus();
108
+
109
+    // app 在前台时获取位置信息
110
+    uni.onLocationChange((res) => {
111
+      this.result = {latitude:res.latitude, longitude:res.longitude}
112
+    });
113
+  },
114
+  async onLoad() {
115
+    this.loadUserInfoOver = false
116
+    areaList().then(res => {
117
+      this.checkInPosiInfo = res.data.map(item => {
118
+        return {
119
+          longitude: item.longitude,
120
+          latitude: item.latitude,
121
+          radius: item.radius,
122
+          address: item.address
123
+        }
124
+      })
125
+    })
126
+    const userInfo = await getInfo()
127
+    this.userInfo = userInfo || {}
128
+
129
+    this.loadUserInfoOver = true
130
+    this.$nextTick(() => {
131
+      this.$refs.workingGroup.invokerGetPostRecordList()
132
+    })
133
+    // await this.loadYesterdayRecord();  // 1. 先查昨天
134
+    // this.updateCurrentTime();          // 2. 再开始计时
135
+    await this.loadTodayRecord();      // 3. 查今天
136
+
137
+  },
138
+  methods: {
139
+
140
+    updateMember(member) {
141
+      this.selectedMember = member
142
+    },
143
+    async loadYesterdayRecord() {
144
+      const yesterday = new Date();
145
+      yesterday.setDate(yesterday.getDate() - 1);
146
+      const start = this.formatDate(yesterday);
147
+
148
+      const { data } = await getAttendanceList({ type: 1, checkInDate: start, userId: this.$store.state.user.id });
149
+      this.yesterdayRecords = data?.[0]?.items || [];
150
+      // this.yesterdayRecords = data[0].items || [];
151
+
152
+      // 逻辑:昨天缺下班 && 距上班 ≤ 20 小时
153
+      const lastMorning = this.yesterdayRecords.find(r => r.checkInType == 'CLOCK_IN'); // 1-上班
154
+      const lastEvening = this.yesterdayRecords.find(r => r.checkInType == 'CLOCK_OUT'); // 2-下班
155
+
156
+      if (lastMorning && !lastEvening) {
157
+        // 确保时间字符串格式正确
158
+        const checkInTime = new Date(lastMorning.checkInTime);
159
+        if (isNaN(checkInTime.getTime())) {
160
+          console.error('无效的时间格式');
161
+          return;
162
+        }
163
+
164
+        // 计算时间差(毫秒)
165
+        // const gap = Date.now() - checkInTime.getTime();
166
+        const gap = Date.now() - new Date(lastMorning.checkInTime).getTime();
167
+        if (gap <= 12 * 60 * 60 * 1000) {
168
+          this.yesterday = true;
169
+          // 12 小时内
170
+          this.checkInType = 'afternoon';
171
+          this.checkInBtnText = '下班打卡';
172
+
173
+          this.records = this.yesterdayRecords.map(it => ({
174
+            type: it.checkInType === 'CLOCK_IN' ? '上班打卡' : '下班打卡',
175
+            time: it.checkInTime,   // 取 HH:mm:ss
176
+            location: it.checkInPosition || '--',
177
+            status: '正常',                  // 如需迟到逻辑,可在此判断
178
+            checkInDate: it.checkInDate
179
+          }));
180
+          if (this.yesterdayRecords.length > 0) {
181
+            this.checkInType = 'afternoon'
182
+          }
183
+
184
+          // 刷新按钮状态
185
+          this.checkCheckInStatus();
186
+        }
187
+      }
188
+    },
189
+    async loadTodayRecord() {
190
+      if (this.yesterday) {
191
+        return;
192
+      }
193
+      const today = this.formatDate(new Date());
194
+      const { data } = await getAttendanceList({
195
+        type: 1,
196
+        checkInDate: today,
197
+        userId: this.$store.state.user.id
198
+      });
199
+
200
+      // new
201
+      if (Array.isArray(data) && data.length) {
202
+        const items = data[0]
203
+        const firstCheckOnTime = items.items.filter(item => item.checkInType === 'CLOCK_IN' && item.checkInTime).sort((a, b) => {
204
+          return new Date(a.checkInTime).getTime() - new Date(b.checkInTime)
205
+        })[0] || {}
206
+        const latestCheckOutTime = items.items.filter(item => item.checkInType === 'CLOCK_OUT').sort((a, b) => {
207
+          return new Date(b.checkInTime) - new Date(a.checkInTime).getTime()
208
+        })[0] || {}
209
+        this.attendanceInfo = {
210
+          ...items,
211
+          checkInTime: formatTimehandler(firstCheckOnTime.checkInTime, 'hh:mm:ss', { defaultResult: '' }),
212
+          checkOutTime: formatTimehandler(latestCheckOutTime.checkInTime, 'hh:mm:ss', { defaultResult: '' })
213
+        }
214
+      }
215
+
216
+      // 接口 data 可能是多条(按天),我们取第一条里的 items
217
+      const todayData = data && data.length ? data[0] : null;
218
+      const items = todayData ? todayData.items : [];
219
+
220
+      // 映射成页面需要的格式
221
+      this.records = items.map(it => ({
222
+        type: it.checkInType === 'CLOCK_IN' ? '上班打卡' : '下班打卡',
223
+        time: it.checkInTime,   // 取 HH:mm:ss
224
+        location: it.checkInPosition || '--',
225
+        status: '正常',
226
+        checkInDate: it.checkInDate
227
+
228
+      }));
229
+      if (items.length > 0) {
230
+        this.checkInType = 'afternoon'
231
+      }
232
+
233
+      // 刷新按钮状态
234
+      this.checkCheckInStatus();
235
+    },
236
+
237
+    // 通用时间格式化
238
+    formatTime(date) {
239
+      const h = String(date.getHours()).padStart(2, '0');
240
+      const m = String(date.getMinutes()).padStart(2, '0');
241
+      const s = String(date.getSeconds()).padStart(2, '0');
242
+      return `${h}:${m}:${s}`;
243
+    },
244
+
245
+    // 日期格式化(昨天、今天查询用)
246
+    formatDate(date) {
247
+      const y = date.getFullYear();
248
+      const m = String(date.getMonth() + 1).padStart(2, '0');
249
+      const d = String(date.getDate()).padStart(2, '0');
250
+      return `${y}-${m}-${d}`;
251
+    },
252
+    updateCurrentTime() {
253
+      setInterval(() => {
254
+        this.currentTime = this.formatTime(new Date());
255
+
256
+        // 自动切换上下班打卡类型
257
+        // const hours = new Date().getHours();
258
+        // this.checkInType = hours < 12 ? 'morning' : 'afternoon';
259
+        this.checkInBtnText = this.isCheckedIn ? '已打卡' : '打卡';
260
+      }, 1000);
261
+    },
262
+    checkCheckInStatus() {
263
+      const now = new Date();
264
+      const type = now.getHours() < 12 ? 1 : 2;
265
+      this.isCheckedIn = this.records.some(r => r.type === type);
266
+      this.checkInBtnText = this.isCheckedIn ? '已打卡' : '打卡';
267
+    },
268
+    changeGroup(e) {
269
+      this.groupIndex = e.detail.value;
270
+    },
271
+    async handleCheckIn(type, checkInPosition) {
272
+      if (this.isCheckedIn) return;
273
+      // if (this.attendanceInfo.checkInTime) { // 上班有卡 没下通道禁止打卡
274
+      //   if (this.$refs.workingGroup.isCheckOutJobs()) {
275
+      //     return uni.showToast({ title: '请先下岗位再下班打卡!', icon: 'none' });
276
+      //   }
277
+      // } 这个限制 业务不要
278
+      uni.showLoading({ title: '打卡中...' });
279
+      try {
280
+        await addAttendance({
281
+          userId: this.$store.state.user.id,
282
+          checkInDate: this.formatDate(new Date()),
283
+          checkInType: type, // 1 上班 2 下班
284
+          remark: '',
285
+          checkInPosition: checkInPosition.address,
286
+          longitude: checkInPosition.longitude,
287
+          latitude: checkInPosition.latitude,
288
+        });
289
+
290
+        await this.loadTodayRecord(); // 重新拉取今日
291
+        uni.showToast({ title: '打卡成功', icon: 'success' });
292
+      } finally {
293
+        uni.hideLoading();
294
+      }
295
+    },
296
+    showStats() {
297
+      uni.navigateTo({ url: '/pages/attendance/stats' });
298
+    }
299
+  }
300
+
301
+}
302
+</script>
303
+
304
+<style lang="scss" scoped>
305
+.attendance-container {
306
+  padding: 5px;
307
+  padding-top: 60px;
308
+  min-height: 100vh;
309
+}
310
+
311
+/* 顶部个人信息 */
312
+.user-header {
313
+  display: flex;
314
+  align-items: center;
315
+  background-color: #fff;
316
+  padding: 15px;
317
+  margin-bottom: 20px;
318
+  height: 96px;
319
+  background: #FFFFFF;
320
+  box-shadow: 0px 4px 16px 0px rgba(0, 0, 0, 0.06);
321
+  border-radius: 16px 16px 16px 16px;
322
+
323
+  .user-avatar {
324
+    width: 50px;
325
+    height: 50px;
326
+    border-radius: 25px;
327
+    overflow: hidden;
328
+    margin-right: 12px;
329
+
330
+    image {
331
+      width: 100%;
332
+      height: 100%;
333
+    }
334
+  }
335
+
336
+  .user-info {
337
+    flex: 1;
338
+
339
+    .user-name {
340
+      font-size: 18px;
341
+      font-weight: bold;
342
+      color: #333;
343
+      display: block;
344
+      margin-bottom: 8px;
345
+    }
346
+
347
+    .user-position {
348
+      font-size: 14px;
349
+      color: #666;
350
+    }
351
+  }
352
+
353
+  .stats-btn {
354
+    display: flex;
355
+    flex-direction: column;
356
+    align-items: center;
357
+    justify-content: center;
358
+    font-size: 14px;
359
+    color: #666;
360
+
361
+    .btn-text {
362
+      margin-top: 4px;
363
+      font-size: 12px;
364
+      color: #666;
365
+    }
366
+  }
367
+}
368
+</style>

+ 214 - 0
src/pages/attendance/stats.vue

@@ -0,0 +1,214 @@
1
+<template>
2
+  <view class="stats-container">
3
+    <!-- 用户信息 -->
4
+    <view class="user-header">
5
+      <view class="user-avatar">
6
+        <image v-if="currentUser.avatar" :src="currentUser.avatar" class="cu-avatar xl round"></image>
7
+      </view>
8
+      <view class="user-info">
9
+        <text class="user-name">{{ currentUser.name }}</text>
10
+      </view>
11
+    </view>
12
+
13
+    <!-- 日历选择器 -->
14
+    <uni-calendar class="uni-calendar--hook card-radius" :selected="markedDates" :showMonth="true"
15
+      @change="handleDateChange" @monthSwitch="handleMonthSwitch" />
16
+
17
+    <!-- 打卡记录展示 -->
18
+    <TimelineRecord class="card-radius" :records="filteredRecords" v-if="filteredRecords.length > 0" />
19
+    <view v-else class="no-data">
20
+      <text>暂无打卡记录</text>
21
+    </view>
22
+  </view>
23
+</template>
24
+
25
+<script>
26
+import TimelineRecord from '@/components/timeline-record/TimelineRecord.vue';
27
+import { getAttendanceList } from "@/api/attendance/attendance"
28
+
29
+export default {
30
+  components: {
31
+    TimelineRecord
32
+  },
33
+  computed: {
34
+    currentUser() {
35
+      return this.$store.state.user || {
36
+        name: '未知用户',
37
+        department: '未知部门',
38
+        position: '未知职位'
39
+      };
40
+    }
41
+  },
42
+  data() {
43
+    return {
44
+      currentDate: new Date(),
45
+      markedDates: [],
46
+      allRecords: [],
47
+      filteredRecords: []
48
+    };
49
+  },
50
+  mounted() {
51
+    this.loadAttendanceData();
52
+  },
53
+  methods: {
54
+    async loadAttendanceData() {
55
+      try {
56
+        const { startDate, endDate } = this.getMonthRange();
57
+        const res = await getAttendanceList({
58
+          userId: this.$store.state.user.id,
59
+          type: 2,
60
+          startDate,
61
+          endDate
62
+        });
63
+
64
+        if (res.code === 200) {
65
+          this.allRecords = res.data;
66
+          this.updateMarkedDates();
67
+          this.filterRecordsByDate(this.currentDate);
68
+        }
69
+      } catch (error) {
70
+        console.error('加载考勤数据失败:', error);
71
+        uni.showToast({
72
+          title: '数据加载失败',
73
+          icon: 'none'
74
+        });
75
+      }
76
+    },
77
+
78
+    
79
+updateMarkedDates() {
80
+  const today = new Date().toISOString().split('T')[0];
81
+  this.markedDates = this.allRecords
82
+    .filter(record => record.attendanceDate)
83
+    .map(record => {
84
+		console.log(record,'12312313213')
85
+      const isToday = record.attendanceDate.split('T')[0] === today;
86
+      const hasCheckIn = !!record.checkInTime;
87
+      const hasCheckOut = !!record.checkOutTime;
88
+      
89
+      let status;
90
+      let custom = '';
91
+      if (isToday) {
92
+        status = ''; // 当天记录始终显示正常
93
+      } else if (hasCheckIn && hasCheckOut) {
94
+        status = '正常'; // 完整打卡记录
95
+		custom = 'blue'
96
+      } else if (hasCheckIn) {
97
+        status = '异常'; // 只有上班打卡
98
+      } else {
99
+        return null; // 无效记录过滤
100
+      }
101
+
102
+      return {
103
+        date: record.attendanceDate.split('T')[0],
104
+        info: status,
105
+		color:custom
106
+      };
107
+    })
108
+    .filter(Boolean); // 移除null值
109
+},
110
+
111
+
112
+    filterRecordsByDate(date) {
113
+      const dateStr = this.formatDate(date);
114
+      
115
+      this.filteredRecords = this.allRecords.filter(
116
+        record => record.attendanceDate.includes(dateStr)
117
+      );
118
+    },
119
+
120
+    getMonthRange() {
121
+      const year = this.currentDate.getFullYear();
122
+      const month = this.currentDate.getMonth();
123
+      return {
124
+        startDate: this.formatDate(new Date(year, month, 1)),
125
+        endDate: this.formatDate(new Date(year, month + 1, 0))
126
+      };
127
+    },
128
+
129
+    formatDate(date) {
130
+      const y = date.getFullYear();
131
+      const m = String(date.getMonth() + 1).padStart(2, '0');
132
+      const d = String(date.getDate()).padStart(2, '0');
133
+      return `${y}-${m}-${d}`;
134
+    },
135
+
136
+    handleDateChange(detail) {
137
+      if (!detail || !detail.fulldate) {
138
+        console.error('日期参数异常:', detail);
139
+        return;
140
+      }
141
+      this.currentDate = new Date(detail.fulldate);
142
+      this.filterRecordsByDate(this.currentDate);
143
+    },
144
+
145
+    handleMonthSwitch({ month }) {
146
+      this.currentDate = new Date(
147
+        this.currentDate.getFullYear(),
148
+        month - 1,
149
+        this.currentDate.getDate()
150
+      );
151
+      this.loadAttendanceData();
152
+    }
153
+  }
154
+};
155
+</script>
156
+
157
+<style lang="scss" scoped>
158
+.stats-container {
159
+  padding: 20rpx;
160
+  background-color: #f7f7f7;
161
+  min-height: 100vh;
162
+}
163
+
164
+.user-header {
165
+  display: flex;
166
+  align-items: center;
167
+  padding: 20rpx;
168
+  margin-bottom: 20rpx;
169
+  background-color: #fff;
170
+  border-radius: 16rpx;
171
+
172
+  .user-avatar {
173
+    width: 100rpx;
174
+    height: 100rpx;
175
+    border-radius: 50%;
176
+    overflow: hidden;
177
+    margin-right: 20rpx;
178
+
179
+    image {
180
+      width: 100%;
181
+      height: 100%;
182
+    }
183
+  }
184
+
185
+  .user-info {
186
+    flex: 1;
187
+
188
+    .user-name {
189
+      font-size: 36rpx;
190
+      font-weight: bold;
191
+      color: #333;
192
+    }
193
+
194
+    .user-position {
195
+      font-size: 28rpx;
196
+      color: #999;
197
+    }
198
+  }
199
+}
200
+
201
+.card-radius {
202
+  border-radius: 16rpx;
203
+  overflow: hidden;
204
+  margin-bottom: 20rpx;
205
+}
206
+
207
+.no-data {
208
+  padding: 40rpx;
209
+  text-align: center;
210
+  color: #999;
211
+  background-color: #fff;
212
+  border-radius: 16rpx;
213
+}
214
+</style>

+ 78 - 0
src/pages/attendanceStatistics/components/TableList.vue

@@ -0,0 +1,78 @@
1
+<template>
2
+  <div class="table-container">
3
+    <table class="data-table">
4
+      <thead>
5
+        <tr>
6
+          <th v-for="column in columns" :key="column.props">{{ column.title }}</th>
7
+        </tr>
8
+      </thead>
9
+      <tbody>
10
+        <tr v-for="(row, index) in data" :key="index"
11
+          :class="{ 'odd-row': (index + 1) % 2 !== 0, 'even-row': (index + 1) % 2 === 0 }">
12
+          
13
+          <td v-for="column in columns" :key="`${column.props}-${index}`">{{ row[column.props] }}</td>
14
+        </tr>
15
+      </tbody>
16
+    </table>
17
+  </div>
18
+</template>
19
+
20
+<script>
21
+export default {
22
+  name: 'TableList',
23
+  props: {
24
+    columns: {
25
+      type: Array,
26
+      default: () => []
27
+
28
+    },
29
+    data: {
30
+      type: Array,
31
+      default: () => []
32
+    }
33
+  },
34
+  created() {
35
+    console.log(this.columns,this.data);
36
+  }
37
+};
38
+</script>
39
+
40
+<style lang="scss" scoped>
41
+.table-container {
42
+  width: 100%;
43
+  overflow-x: auto;
44
+}
45
+
46
+.data-table {
47
+  width: 100%;
48
+  border-collapse: collapse;
49
+  border: 4rpx dashed #ccc;
50
+  border-radius: 16rpx;
51
+
52
+  th,
53
+  td {
54
+    padding: 12rpx;
55
+    height: 48rpx;
56
+    text-align: left;
57
+    border-bottom: 1rpx dashed #ccc;
58
+  }
59
+
60
+  th {
61
+    background-color: #f5f5f5;
62
+    font-weight: bold;
63
+    color: #666;
64
+  }
65
+
66
+  .odd-row {
67
+    background-color: #fff !important;
68
+  }
69
+
70
+  .even-row {
71
+    background-color: #f5f5f5 !important;
72
+  }
73
+
74
+  tr:hover {
75
+    background-color: #e0e0e0;
76
+  }
77
+}
78
+</style>

+ 525 - 0
src/pages/attendanceStatistics/index.vue

@@ -0,0 +1,525 @@
1
+<template>
2
+    <home-container :customStyle="{ background: 'none' }">
3
+        <view class="container">
4
+            <!-- 顶部tabs -->
5
+            <h-tabs v-if="roles.includes('kezhang')" v-model="currentTab" :tabs="tabList" value-key="type"
6
+                label-key="title" @change="handleTabChange" />
7
+
8
+            <!-- 通道开放情况 -->
9
+            <view class="card" :style="{ paddingBottom: currentTab !== 'all' ? '100rpx' : '24rpx' }">
10
+                <view class="card-header">通道开放情况</view>
11
+                <view class="chart-container">
12
+                    <view class="pie-chart" id="pieChart"></view>
13
+                </view>
14
+
15
+                <view v-if="currentTab === 'all'" style="margin-top: 120rpx;" class="bar-chart" id="barChart1"></view>
16
+            </view>
17
+
18
+            <!-- 人员情况 -->
19
+            <view v-if="currentTab === 'all'" class="card">
20
+                <view class="card-header">人员情况</view>
21
+                <view class="bar-chart" id="barChart2"></view>
22
+            </view>
23
+
24
+            <!-- 明细情况 -->
25
+            <view class="card-header" style="padding-left: 10rpx;">明细情况</view>
26
+            <view class="card-detail">
27
+                <uni-collapse v-if="isShowBarChart1">
28
+                    <uni-collapse-item v-for="(item, index) in terminlDetail" :key="index" :title="item.terminlName"
29
+                        :open="true">
30
+                        <view class="detail-content">
31
+                            <view v-if="item.region.length > 0">在岗班组</view>
32
+                            <view class="detail-row" v-for="(regionItem, i) in item.region" :key="i">
33
+                                <view class="detail-title">{{ regionItem.regionalName }}&nbsp;&nbsp;开放数量:{{
34
+                                    regionItem.openChannelCount }}</view>
35
+                                <TableList :data="regionItem.detailList" :columns="[{
36
+                                    props: 'channelName',
37
+                                    title: '通道名称'
38
+                                }, {
39
+                                    props: 'onDutyTeamName',
40
+                                    title: '在岗班组'
41
+                                }, {
42
+                                    props: 'onDutyTime',
43
+                                    title: '上岗时间'
44
+                                }, {
45
+                                    props: 'onDutyCount',
46
+                                    title: '在岗人数'
47
+                                }]" />
48
+                            </view>
49
+                            <view v-if="item.waitList.length > 0" style="margin-bottom: 10rpx;">空闲班组</view>
50
+                            <TableList v-if="item.waitList.length > 0" :data="item.waitList" :columns="[{
51
+                                props: 'waitTeamName',
52
+                                title: '待岗班组'
53
+                            }, {
54
+                                props: 'waitCount',
55
+                                title: '待岗人数'
56
+                            }, {
57
+                                props: 'checkOutTime',
58
+                                title: '下通道时间'
59
+                            }]" />
60
+                        </view>
61
+                    </uni-collapse-item>
62
+                </uni-collapse>
63
+                <view v-else="!isShowBarChart1">
64
+                    <no-data text="暂无数据" icon-type="search" icon-size="60" icon-color="#C0C4CC" />
65
+                </view>
66
+            </view>
67
+        </view>
68
+    </home-container>
69
+</template>
70
+
71
+<script>
72
+import HomeContainer from "@/components/HomeContainer.vue";
73
+import TableList from "./components/TableList.vue";
74
+import * as echarts from 'echarts';
75
+import { getChannelStatistics } from '@/api/attendanceStatistics/attendanceStatistics';
76
+import HTabs from "@/components/h-tabs/h-tabs.vue";
77
+
78
+export default {
79
+    components: { HomeContainer, TableList, HTabs },
80
+    data() {
81
+        return {
82
+            currentTab: 'my', // 默认显示我的区域
83
+            tabList: [
84
+                {
85
+                    type: 'my',
86
+                    title: '我的区域'
87
+                },
88
+                {
89
+                    type: 'all',
90
+                    title: '全部区域'
91
+                }
92
+            ],
93
+            terminlDetail: [],
94
+            mainDetail: {},
95
+            pieChart: null,
96
+            barChart1: null,
97
+            barChart2: null,
98
+            roles: this.$store.state.user.roles
99
+        }
100
+    },
101
+    computed: {
102
+        isShowPieChart() {
103
+            const values = Object.values(this.mainDetail || {});
104
+            console.log('mainDetail values:', values, 'all empty check:', values.every(item => !item));
105
+            return !values.every(item => !item);
106
+        },
107
+        isShowBarChart1() {
108
+            return this.terminlDetail.length > 0;
109
+        },
110
+    },
111
+    mounted() {
112
+        this.fetchData("1");
113
+
114
+        if (!this.roles.includes('kezhang')) {
115
+            this.currentTab = 'all'
116
+        }
117
+    },
118
+    methods: {
119
+        handleTabChange(newValue, tab) {
120
+            this.currentTab = newValue;
121
+
122
+            this.fetchData(this.currentTab == 'my' ? "1" : "2")
123
+        },
124
+        async fetchData(status) {
125
+
126
+            try {
127
+                console.log('正在请求考勤统计数据,status:', status);
128
+                const response = await getChannelStatistics(status);
129
+                console.log('接口返回数据:', response);
130
+
131
+                const { channelCount, maintainCount, onDutyCount, terminlDetail } = response;
132
+
133
+                console.log('解析后的数据:', { channelCount, maintainCount, onDutyCount, terminlDetail });
134
+
135
+                this.terminlDetail = terminlDetail || [];
136
+                this.mainDetail = {
137
+                    channelCount: channelCount || 0,
138
+                    maintainCount: maintainCount || 0,
139
+                    onDutyCount: onDutyCount || 0,
140
+
141
+                }
142
+                console.log(this.mainDetail, 22)
143
+                this.$nextTick(() => {
144
+                    this.initCharts();
145
+                })
146
+
147
+            } catch (e) {
148
+                console.error('获取考勤数据失败', e);
149
+            }
150
+        },
151
+        initCharts() {
152
+            const pieDom = document.getElementById('pieChart');
153
+            const barDom1 = document.getElementById('barChart1');
154
+            const barDom2 = document.getElementById('barChart2');
155
+
156
+            if (pieDom) {
157
+                this.pieChart = echarts.init(pieDom);
158
+            }
159
+            if (barDom1) {
160
+                this.barChart1 = echarts.init(barDom1);
161
+            }
162
+            if (barDom2) {
163
+                this.barChart2 = echarts.init(barDom2);
164
+            }
165
+            this.$nextTick(() => {
166
+
167
+                this.updateCharts();
168
+            });
169
+        },
170
+        updateCharts() {
171
+
172
+            let openRate = 0;
173
+            let openData = 0;
174
+            let closeData = 0;
175
+            if (this.roles.includes('kezhang') && this.currentTab == 'my') {
176
+
177
+                openData = this.terminlDetail.reduce((item, cur) => {
178
+                    return item + cur.onDutyChannelCount;
179
+                }, 0);
180
+                let allData = this.terminlDetail.reduce((item, cur) => {
181
+                    return item + cur.channelCount;
182
+                }, 0);
183
+                closeData = allData - openData;
184
+                openRate = (openData / allData) * 100;
185
+            } else {
186
+                let allData = this.terminlDetail.reduce((item, cur) => {
187
+                    return item + cur.channelCount;
188
+                }, 0);
189
+
190
+                // openData = this.mainDetail.onDutyCount;
191
+                openData = this.terminlDetail.reduce((item, cur) => {
192
+                    return item + cur.onDutyChannelCount;
193
+                }, 0);
194
+                openRate = (openData / allData) * 100;
195
+                closeData = allData - openData;
196
+
197
+            }
198
+
199
+            // 检查数据是否有效
200
+            const hasValidData = openData > 0 || closeData > 0;
201
+
202
+            // 环形图配置
203
+            this.pieChart && this.pieChart.setOption({
204
+                series: [{
205
+                    type: 'pie',
206
+                    radius: ['55%', '70%'],
207
+                    avoidLabelOverlap: false,
208
+                    // label: { show: false },
209
+                    data: hasValidData ? [
210
+                        { value: openRate, name: '开放 ' + openData, itemStyle: { color: '#5972C0' } },
211
+                        { value: 100 - openRate, name: '未开放 ' + closeData, itemStyle: { color: 'rgba(89, 114, 192, 0.5)' } }
212
+                    ] : [],
213
+                    silent: !hasValidData // 无数据时禁用交互
214
+                }],
215
+                graphic: hasValidData ? [{
216
+                    type: 'text',
217
+                    left: 'center',
218
+                    top: '40%',
219
+                    style: {
220
+                        text: '开放率',
221
+                        font: 'normal 16px sans-serif',
222
+                        fill: '#333'
223
+                    }
224
+                }, {
225
+                    type: 'text',
226
+                    left: 'center',
227
+                    top: '50%',
228
+                    style: {
229
+                        text: openRate.toFixed(2) + '%',
230
+                        font: 'bold 24px sans-serif',
231
+                        fill: '#333'
232
+                    }
233
+                }] : [{
234
+                    // 无数据时显示提示
235
+                    type: 'text',
236
+                    left: 'center',
237
+                    top: 'center',
238
+                    style: {
239
+                        text: '暂无数据',
240
+                        font: '16px sans-serif',
241
+                        fill: '#999'
242
+                    }
243
+                }]
244
+            });
245
+
246
+            let firstTerminal = 'T1航站楼';
247
+            let secondTerminal = 'T2航站楼';
248
+            let terminalName = [firstTerminal, secondTerminal]
249
+
250
+            // 安全检查:确保 terminlDetail 存在且为数组
251
+            let barT1 = this.terminlDetail && Array.isArray(this.terminlDetail)
252
+                ? this.terminlDetail.filter(item => item.terminlName === firstTerminal)[0]
253
+                : null;
254
+            let barT2 = this.terminlDetail && Array.isArray(this.terminlDetail)
255
+                ? this.terminlDetail.filter(item => item.terminlName === secondTerminal)[0]
256
+                : null;
257
+
258
+            let batT1Open = barT1?.onDutyChannelCount;//T1航站楼开放人数
259
+            let batT2Open = barT2?.onDutyChannelCount;//T2航站楼开放人数
260
+            let batT1Close = barT1 ? barT1.channelCount - barT1.onDutyChannelCount : 0;//T1航站楼未开放人数
261
+            let batT2Close = barT2 ? barT2.channelCount - barT2.onDutyChannelCount : 0;//T2航站楼未开放人数
262
+            console.log(batT1Open, batT2Open, "batT2Open")
263
+
264
+            // 检查条形图数据是否有效
265
+            const hasBarData = (batT1Open > 0 || batT1Close > 0 || batT2Open > 0 || batT2Close > 0);
266
+
267
+            // 通道开放情况正负条形图
268
+            if (this.currentTab === 'all') {
269
+                this.barChart1 && this.barChart1.setOption({
270
+                    tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
271
+                    legend: {
272
+                        data: ['开放', '未开放'],
273
+                        top: '15%'
274
+                    },
275
+                    grid: {
276
+                        left: '4%',
277
+                        right: '4%',
278
+                        bottom: '4%',
279
+                        containLabel: true,
280
+                        backgroundColor: 'transparent'
281
+                    },
282
+                    xAxis: {
283
+                        type: 'value',
284
+                        axisLine: { show: true },
285
+                        axisTick: { show: false },
286
+                        axisLabel: {
287
+                            show: false,
288
+                            formatter: function (value) {
289
+                                return Math.abs(value);
290
+                            }
291
+                        },
292
+                        splitLine: { show: false },
293
+                        min: function (value) {
294
+                            return value.min - (value.max - value.min) * 0.3;
295
+                        },
296
+                        max: function (value) {
297
+                            return value.max + (value.max - value.min) * 0.3;
298
+                        }
299
+                    },
300
+                    yAxis: {
301
+                        type: 'category',
302
+                        axisLine: { show: false },
303
+                        axisTick: { show: false },
304
+                        data: terminalName,
305
+                        splitLine: { show: false }
306
+                    },
307
+                    series: hasBarData ? [
308
+                        {
309
+                            name: '开放',
310
+                            type: 'bar',
311
+                            data: [-batT1Open, -batT2Open],
312
+                            stack: 'total',
313
+                            barWidth: '30%',
314
+                            itemStyle: { color: '#6E65F1' },
315
+                            label: {
316
+                                show: function (params) {
317
+                                    return Math.abs(params.value) !== 0;
318
+                                },
319
+                                position: 'left',
320
+                                formatter: function (params) {
321
+                                    return Math.abs(params.value) === 0 ? '' : Math.abs(params.value);
322
+                                }
323
+                            }
324
+                        },
325
+                        {
326
+                            name: '未开放',
327
+                            type: 'bar',
328
+                            stack: 'total',
329
+                            barWidth: '30%',
330
+                            data: [batT1Close, batT2Close],
331
+                            itemStyle: { color: '#D4D1FB' },
332
+                            label: {
333
+                                show: function (params) {
334
+                                    return Math.abs(params.value) !== 0;
335
+                                },
336
+                                position: 'right',
337
+                                formatter: function (params) {
338
+                                    return Math.abs(params.value) === 0 ? '' : Math.abs(params.value);
339
+                                }
340
+                            }
341
+                        }
342
+                    ] : [],
343
+                    graphic: !hasBarData ? [{
344
+                        // 无数据时显示提示
345
+                        type: 'text',
346
+                        left: 'center',
347
+                        top: 'center',
348
+                        style: {
349
+                            text: '暂无数据',
350
+                            font: '16px sans-serif',
351
+                            fill: '#999'
352
+                        }
353
+                    }] : []
354
+                });
355
+            }
356
+
357
+
358
+            let batT1NormalPerson = barT1?.onDutyCount;//T1航站楼在岗人数
359
+            let batT2NormalPerson = barT2?.onDutyCount;//T2航站楼在岗人数
360
+            let batT1FreePerson = barT1 ? barT1.openChannelCount - barT1.onDutyCount : 0;//T1航站楼空闲人数
361
+            let batT2FreePerson = barT2 ? barT2.openChannelCount - barT2.onDutyCount : 0;//T2航站楼空闲人数
362
+            console.log(batT1NormalPerson, batT2NormalPerson, batT1FreePerson, batT2FreePerson, "batT2NormalPerson")
363
+            // 检查条形图数据是否有效
364
+            const hasBar2Data = (batT1NormalPerson > 0 || batT2NormalPerson > 0 || batT1FreePerson > 0 || batT2FreePerson > 0);
365
+            // 人员情况正负条形图
366
+            if (this.currentTab === 'all') {
367
+                this.barChart2 && this.barChart2.setOption({
368
+                    tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
369
+                    legend: { data: ['在岗人数', '空闲人数'], top: '15%' },
370
+                    grid: {
371
+                        left: '4%',
372
+                        right: '4%',
373
+                        bottom: '4%',
374
+                        containLabel: true,
375
+                        backgroundColor: 'transparent'
376
+                    },
377
+                    xAxis: {
378
+                        type: 'value',
379
+                        axisLine: { show: true },
380
+                        axisTick: { show: false },
381
+                        axisLabel: {
382
+                            show: false,
383
+                            formatter: function (value) {
384
+                                return Math.abs(value);
385
+                            }
386
+                        },
387
+                        splitLine: { show: false },
388
+                        min: function (value) {
389
+                            return value.min - (value.max - value.min) * 0.3;
390
+                        },
391
+                        max: function (value) {
392
+                            return value.max + (value.max - value.min) * 0.3;
393
+                        }
394
+                    },
395
+                    yAxis: {
396
+                        type: 'category',
397
+                        axisLine: { show: false },
398
+                        axisTick: { show: false },
399
+                        data: terminalName,
400
+                        splitLine: { show: false }
401
+                    },
402
+                    series: hasBar2Data ? [
403
+                        {
404
+                            name: '在岗人数',
405
+                            type: 'bar',
406
+                            data: [-batT1NormalPerson, -batT2NormalPerson],
407
+                            stack: 'total',
408
+                            barWidth: '30%',
409
+                            itemStyle: { color: '#6E65F1' },
410
+                            label: {
411
+                                show: true,
412
+                                position: 'left',
413
+                                formatter: function (params) {
414
+                                    return Math.abs(params.value) === 0 ? '' : Math.abs(params.value);
415
+                                }
416
+                            }
417
+                        },
418
+                        {
419
+                            name: '空闲人数',
420
+                            type: 'bar',
421
+                            stack: 'total',
422
+                            barWidth: '30%',
423
+                            data: [batT1FreePerson, batT2FreePerson],
424
+                            itemStyle: { color: '#D4D1FB' },
425
+                            label: {
426
+                                show: true,
427
+                                position: 'right',
428
+                                formatter: function (params) {
429
+                                    return Math.abs(params.value) === 0 ? '' : Math.abs(params.value);
430
+                                }
431
+                            }
432
+                        }
433
+                    ] : [],
434
+                    graphic: !hasBar2Data ? [{
435
+                        // 无数据时显示提示
436
+                        type: 'text',
437
+                        left: 'center',
438
+                        top: 'center',
439
+                        style: {
440
+                            text: '暂无数据',
441
+                            font: '16px sans-serif',
442
+                            fill: '#999'
443
+                        }
444
+                    }] : []
445
+                });
446
+            }
447
+
448
+
449
+        }
450
+    }
451
+}
452
+</script>
453
+
454
+<style lang="scss" scoped>
455
+.container {
456
+    padding: 20rpx;
457
+    display: flex;
458
+    flex-direction: column;
459
+    gap: 20rpx;
460
+    background: none !important;
461
+}
462
+
463
+.card-detail {
464
+    padding: 0;
465
+    background: #fff;
466
+    border-radius: 16rpx;
467
+    box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
468
+}
469
+
470
+.card {
471
+    background: #fff;
472
+    border-radius: 16rpx;
473
+    padding: 24rpx;
474
+    box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
475
+}
476
+
477
+.card-header {
478
+    font-size: 32rpx;
479
+    font-weight: bold;
480
+    margin-bottom: 20rpx;
481
+    color: #333;
482
+}
483
+
484
+.chart-container {
485
+    position: relative;
486
+    height: 300rpx;
487
+    margin-bottom: 20rpx;
488
+}
489
+
490
+.pie-chart,
491
+.bar-chart {
492
+    width: 100% !important;
493
+    height: 400rpx;
494
+}
495
+
496
+
497
+
498
+
499
+.open-rate {
500
+    position: absolute;
501
+    top: 50%;
502
+    left: 50%;
503
+    transform: translate(-50%, -50%);
504
+    font-size: 28rpx;
505
+    color: #666;
506
+}
507
+
508
+.detail-content {
509
+    padding: 20rpx;
510
+}
511
+
512
+.detail-row {
513
+    display: flex;
514
+    justify-content: space-between;
515
+    margin-bottom: 10rpx;
516
+    font-size: 26rpx;
517
+    color: #666;
518
+    flex-direction: column;
519
+
520
+    .detail-title {
521
+        margin: 10rpx 0;
522
+        color: #6E65F1;
523
+    }
524
+}
525
+</style>

+ 315 - 0
src/pages/checklist/components/SelectPerson.vue

@@ -0,0 +1,315 @@
1
+<template>
2
+  <view class="select-person-modal">
3
+    <view class="slot-content" @click.stop="openModal">
4
+      <slot></slot>
5
+    </view>
6
+    <u-popup :show="show" mode="center" :round="8" @close="close">
7
+      <view class="modal-content">
8
+        <view class="title">
9
+          <text>选择人员</text>
10
+          <u-icon name="close" color="#666666" size="20" @click="close" />
11
+        </view>
12
+
13
+        <view class="title-cell">
14
+          <SelectData ref="selectData" @search="searchUsers" @searchViewShowEvent="searchViewShowHandler" />
15
+        </view>
16
+
17
+        <view class="title-cell" v-if="!searchViewShow && selectedUsers && selectedUsers.length">
18
+          <view class="title-text">{{ `已选人员(${selectedUsers.length}人)` }}</view>
19
+          <list-user :data="selectedUsers" :show-delete="true" @delete="handleRemoveSelectedUser" />
20
+        </view>
21
+
22
+        <view class="title-cell" v-if="searchViewShow">
23
+          <view style="margin-bottom: 15px;" v-if="addUsers && addUsers.length">
24
+            <view class="title-text">
25
+              {{ `添加人员(${addUsers.length}人)` }}
26
+            </view>
27
+            <list-user :data="addUsers" :show-delete="true" @delete="handleRemoveAddUser" />
28
+          </view>
29
+
30
+          <view class="title-text">
31
+            {{ `搜索结果(${searchResults.length}人)` }}
32
+          </view>
33
+          <list-user v-if="searchResults && searchResults.length" :data="searchResults" :show-delete="false"
34
+            @delete="handleRemoveSearchResult" @click="handleSearchResultClick" />
35
+          <view v-else class="empty">
36
+            <text v-if="searchKeyword">暂无搜索结果</text>
37
+            <text v-else>请输入关键词搜索</text>
38
+          </view>
39
+        </view>
40
+
41
+        <view class="footer-btn">
42
+          <view v-if="searchViewShow" class="custom-btn-normal" @click="appendUsers"
43
+            :class="{ disabled: addUsers.length === 0 }">添加人员</view>
44
+          <view v-else class="custom-btn-normal" @click="confirmSelection"
45
+            :class="{ disabled: selectedUsers.length === 0 }">确认选择</view>
46
+        </view>
47
+      </view>
48
+    </u-popup>
49
+  </view>
50
+</template>
51
+
52
+<script>
53
+import SelectData from '@/pages/attendance/components/SelectData'
54
+import UserAvatar from '@/pages/attendance/components/UserAvatar'
55
+import { formatName } from '@/utils/formatUtils'
56
+import {
57
+  getUserList,
58
+} from "@/api/attendance/attendance"
59
+export default {
60
+  components: { SelectData, UserAvatar },
61
+  props: {
62
+    // 初始选中的人员列表
63
+    value: {
64
+      type: Array,
65
+      default: () => []
66
+    },
67
+    // 是否多选
68
+    multiple: {
69
+      type: Boolean,
70
+      default: true
71
+    },
72
+    // 占位符文本
73
+    placeholder: {
74
+      type: String,
75
+      default: '点击选择人员'
76
+    },
77
+    // 是否禁用
78
+    disabled: {
79
+      type: Boolean,
80
+      default: false
81
+    }
82
+  },
83
+  data() {
84
+    return {
85
+      show: false,
86
+      selectedUsers: [], // 选中的人员
87
+      searchResults: [], // 搜索结果
88
+      addUsers: [], // 待添加的人员
89
+      searchViewShow: false,
90
+      searchKeyword: ''
91
+    }
92
+  },
93
+  watch: {
94
+    value: {
95
+      handler(newValue) {
96
+        this.selectedUsers = [...newValue]
97
+      },
98
+      immediate: true
99
+    },
100
+    show(newValue) {
101
+      if (newValue) {
102
+        this.searchViewShow = false
103
+        this.searchResults = []
104
+        this.addUsers = []
105
+        this.searchKeyword = ''
106
+        if (this.$refs.selectData) {
107
+          this.$refs.selectData.clearInput()
108
+        }
109
+      }
110
+    }
111
+  },
112
+  methods: {
113
+    openModal() {
114
+      if (this.disabled) return
115
+      this.show = true
116
+    },
117
+    close() {
118
+      this.show = false
119
+    },
120
+
121
+    searchViewShowHandler(show) {
122
+      this.searchViewShow = show || this.addUsers.length
123
+    },
124
+
125
+    // 搜索用户
126
+    async searchUsers(keyword) {
127
+   
128
+      this.searchKeyword = keyword.trim()
129
+      if (!this.searchKeyword) {
130
+        this.searchResults = []
131
+        return
132
+      }
133
+
134
+      try {
135
+        const response = await getUserList({
136
+          nickName: this.searchKeyword,
137
+          status: '0'
138
+        })
139
+        
140
+
141
+        if (response && response.code === 200) {
142
+          this.searchResults = (response.rows || []).map(item => ({
143
+            ...item,
144
+            nickName: formatName(item.nickName),
145
+            // 确保包含部门信息
146
+            deptName: item.dept?.deptName || item.deptName || ''
147
+          }))
148
+        } else {
149
+          this.searchResults = []
150
+          uni.showToast({
151
+            title: '搜索失败,请重试',
152
+            icon: 'none',
153
+            duration: 2000
154
+          })
155
+        }
156
+      } catch (error) {
157
+        this.searchResults = []
158
+        uni.showToast({
159
+          title: '搜索失败,请重试',
160
+          icon: 'none',
161
+          duration: 2000
162
+        })
163
+      }
164
+    },
165
+
166
+    // 选择待添加人员
167
+    selectAddUser(user) {
168
+      const index = this.addUsers.findIndex(item => item.userId === user.userId)
169
+      if (index >= 0) {
170
+        this.addUsers.splice(index, 1)
171
+      } else {
172
+        this.addUsers.push(user)
173
+      }
174
+    },
175
+
176
+    // 添加人员到选中列表
177
+    appendUsers() {
178
+      if (this.addUsers.length) {
179
+        this.addUsers.forEach(user => {
180
+          // 确保添加的人员数据包含部门信息
181
+          const userWithDept = {
182
+            ...user,
183
+            dept_name: user.dept?.deptName || user.deptName || ''
184
+          };
185
+          this.selectUser(userWithDept, false)
186
+        })
187
+        this.addUsers = []
188
+        this.searchViewShow = false
189
+        if (this.$refs.selectData) {
190
+          this.$refs.selectData.clearInput()
191
+        }
192
+      }
193
+    },
194
+
195
+    // 移除选中人员
196
+    removeSelectedUser(user, onlyCheck = false) {
197
+      const index = this.selectedUsers.findIndex(item => item.userId === user.userId)
198
+      if (index >= 0) {
199
+        if (!onlyCheck) {
200
+          this.selectedUsers.splice(index, 1)
201
+        }
202
+        return undefined
203
+      }
204
+      return user
205
+    },
206
+
207
+    // 处理删除已选人员
208
+    handleRemoveSelectedUser({ deletedItem, index, newData }) {
209
+      this.selectedUsers = newData
210
+    },
211
+
212
+    // 处理删除待添加人员
213
+    handleRemoveAddUser({ deletedItem, index, newData }) {
214
+      this.addUsers = newData
215
+    },
216
+
217
+    // 处理删除搜索结果(虽然不应该有删除操作,但为了完整性)
218
+    handleRemoveSearchResult({ deletedItem, index, newData }) {
219
+      this.searchResults = newData
220
+    },
221
+
222
+    // 选择人员处理
223
+    selectUser(user, unique = true) {
224
+      const addItem = this.removeSelectedUser(user, !unique)
225
+      if (addItem) {
226
+        this.selectedUsers.push(addItem)
227
+      }
228
+    },
229
+
230
+    // 确认选择
231
+    confirmSelection() {
232
+      if (this.selectedUsers.length === 0) {
233
+        uni.showToast({
234
+          title: '请选择至少一位人员',
235
+          icon: 'none'
236
+        })
237
+        return
238
+      }
239
+
240
+    
241
+      this.$emit('change', this.selectedUsers)
242
+      this.$emit('confirm', this.selectedUsers)
243
+
244
+      this.show = false
245
+
246
+      uni.showToast({
247
+        title: `已选择${this.selectedUsers.length}人`,
248
+        icon: 'success',
249
+        duration: 1500
250
+      })
251
+    },
252
+
253
+    // 处理搜索结果点击
254
+    handleSearchResultClick({ clickedItem, index, data }) {
255
+      if (clickedItem) {
256
+        this.selectAddUser(clickedItem)
257
+      }
258
+    },
259
+  }
260
+}
261
+</script>
262
+
263
+<style lang="scss" scoped>
264
+.select-person-modal {
265
+  .slot-content {
266
+    display: inline-block;
267
+  }
268
+
269
+  .empty {
270
+    text-align: center;
271
+    padding: 20px;
272
+    color: #999;
273
+    font-size: 14px;
274
+  }
275
+
276
+  .modal-content {
277
+    width: 90vw;
278
+    max-height: 75vh;
279
+    padding: 10px 0;
280
+    overflow-y: auto;
281
+
282
+    .title {
283
+      height: 24px;
284
+      font-weight: 400;
285
+      font-size: 18px;
286
+      color: #333333;
287
+      line-height: 21px;
288
+      display: flex;
289
+      justify-content: space-between;
290
+      align-items: center;
291
+      padding: 15px;
292
+      box-sizing: border-box;
293
+      border-bottom: 1px solid #f0f0f0;
294
+    }
295
+
296
+    .title-cell {
297
+      padding: 15px;
298
+
299
+      .title-text {
300
+        font-size: 16px;
301
+        font-weight: 500;
302
+        color: #333;
303
+        margin-bottom: 12px;
304
+      }
305
+    }
306
+
307
+    .footer-btn {
308
+      padding: 15px;
309
+      border-top: 1px solid #f0f0f0;
310
+
311
+
312
+    }
313
+  }
314
+}
315
+</style>

+ 121 - 0
src/pages/checklist/components/SubmitResult.vue

@@ -0,0 +1,121 @@
1
+<template>
2
+    <u-popup ref="popup" :show="showPopup" mode="center" :mask-close-able="false" :round="16">
3
+        <view class="submit-result-modal">
4
+            <!-- 成功图片 -->
5
+            <image src="/static/images/submitOK.png" class="success-image" mode="aspectFit" />
6
+
7
+            <!-- 成功提示文字 -->
8
+            <text class="success-text">提交成功</text>
9
+
10
+            <!-- 按钮区域 -->
11
+            <view class="button-group">
12
+                <view class="custom-btn-white" @click="handleBackToList">
13
+                    返回列表
14
+                </view>
15
+                <view class="custom-btn-normal" @click="handleContinueSubmit">
16
+                    继续提交
17
+                </view>
18
+            </view>
19
+        </view>
20
+    </u-popup>
21
+</template>
22
+
23
+<script>
24
+export default {
25
+    name: 'SubmitResult',
26
+    data() {
27
+        return {
28
+            showPopup: false
29
+        };
30
+    },
31
+    methods: {
32
+        // 打开模态框
33
+        open() {
34
+            this.showPopup = true;
35
+        },
36
+
37
+        // 关闭模态框
38
+        close() {
39
+            this.showPopup = false;
40
+        },
41
+
42
+        // 返回列表
43
+        handleBackToList() {
44
+            this.close();
45
+           
46
+            uni.navigateBack({ delta: 1 });
47
+        },
48
+
49
+        // 继续提交
50
+        handleContinueSubmit() {
51
+            this.close();
52
+            // 触发继续提交事件
53
+            this.$emit('continue-submit');
54
+        }
55
+    }
56
+}
57
+</script>
58
+
59
+<style scoped>
60
+.submit-result-modal {
61
+    background: #ffffff;
62
+    border-radius: 16rpx;
63
+    padding: 60rpx 40rpx 40rpx;
64
+    width: 560rpx;
65
+    display: flex;
66
+    flex-direction: column;
67
+    align-items: center;
68
+}
69
+
70
+.success-image {
71
+    width: 400rpx;
72
+    height: 400rpx;
73
+    margin-bottom: 32rpx;
74
+}
75
+
76
+.success-text {
77
+    font-size: 36rpx;
78
+    font-weight: 600;
79
+    color: #333333;
80
+    margin-bottom: 48rpx;
81
+    text-align: center;
82
+}
83
+
84
+.button-group {
85
+    display: flex;
86
+    justify-content: space-between;
87
+    width: 100%;
88
+    gap: 20rpx;
89
+}
90
+
91
+.btn {
92
+    flex: 1;
93
+    height: 80rpx;
94
+    border-radius: 12rpx;
95
+    font-size: 28rpx;
96
+    font-weight: 500;
97
+    border: none;
98
+    display: flex;
99
+    align-items: center;
100
+    justify-content: center;
101
+}
102
+
103
+.btn-primary {
104
+    background: #2979FF;
105
+    color: #ffffff;
106
+}
107
+
108
+.btn-primary:active {
109
+    background: #1E62D9;
110
+}
111
+
112
+.btn-secondary {
113
+    background: #F2F2F2;
114
+    color: #666666;
115
+    border: 1px solid #E0E0E0;
116
+}
117
+
118
+.btn-secondary:active {
119
+    background: #E8E8E8;
120
+}
121
+</style>

+ 215 - 0
src/pages/checklist/components/unqualified.vue

@@ -0,0 +1,215 @@
1
+<template>
2
+  <view class="unqualified-container">
3
+    <!-- 头部标题和加号 -->
4
+    <template v-if="formData.checkedLevel !== 'PERSONNEL_LEVEL'">
5
+      <view class="header">
6
+        <text class="title">不合格人员</text>
7
+        <!-- 人员选择组件,加号放在插槽中 -->
8
+        <SelectPerson ref="selectPerson" :value="localPersonnelData" @input="handlePersonnelSelect" :disabled="disabled"
9
+          @confirm="handlePersonnelConfirm">
10
+          <uni-icons type="plus" size="24" :color="disabled ? '#999' : '#409EFF'" class="add-icon" />
11
+        </SelectPerson>
12
+      </view>
13
+
14
+      <!-- 人员列表 -->
15
+      <view class="personnel-list" v-if="localPersonnelData && localPersonnelData.length > 0">
16
+        <list-user :data="localPersonnelData" :show-delete="showDelete" @delete="handlePersonnelDelete"
17
+          :disabled="disabled" />
18
+      </view>
19
+
20
+      <view class="empty-tip" v-else>
21
+        <text>暂无不合格人员</text>
22
+      </view>
23
+    </template>
24
+
25
+    <!-- 问题描述输入框 -->
26
+    <view class="problem-input">
27
+      <uni-easyinput type="textarea" :placeholder="placeholder" v-model="problemDescription" @input="handleProblemInput"
28
+        :maxlength="-1" :autoHeight="true" :disabled="disabled" />
29
+    </view>
30
+  </view>
31
+</template>
32
+
33
+<script>
34
+import ListUser from '@/components/list-user/list-user.vue'
35
+import uniIcons from '@/uni_modules/uni-icons/components/uni-icons/uni-icons.vue'
36
+import uniEasyinput from '@/uni_modules/uni-easyinput/components/uni-easyinput/uni-easyinput.vue'
37
+import SelectPerson from './SelectPerson.vue'
38
+
39
+export default {
40
+  name: 'UnqualifiedPersonnel',
41
+  components: {
42
+    ListUser,
43
+    uniIcons,
44
+    uniEasyinput,
45
+    SelectPerson
46
+  },
47
+  props: {
48
+    formData: {
49
+      type: Object,
50
+      default: () => { }
51
+    },
52
+    roles: {
53
+      type: Array,
54
+      default: () => []
55
+    },
56
+    // 人员数据
57
+    personnelData: {
58
+      type: Array,
59
+      default: () => []
60
+    },
61
+    // 是否显示删除按钮
62
+    showDelete: {
63
+      type: Boolean,
64
+      default: true
65
+    },
66
+    // 问题描述
67
+    value: {
68
+      type: String,
69
+      default: ''
70
+    },
71
+    // 输入框占位符
72
+    placeholder: {
73
+      type: String,
74
+      default: '请输入问题描述'
75
+    },
76
+    // 父项目索引
77
+    parentIndex: {
78
+      type: Number,
79
+      default: -1
80
+    },
81
+    // 子项目索引
82
+    childIndex: {
83
+      type: Number,
84
+      default: -1
85
+    },
86
+    // 禁用状态
87
+    disabled: {
88
+      type: Boolean,
89
+      default: false
90
+    }
91
+  },
92
+  data() {
93
+    return {
94
+      problemDescription: this.value,
95
+      localPersonnelData: [...this.personnelData] // 创建本地副本
96
+    }
97
+  },
98
+  watch: {
99
+    value(newVal) {
100
+      this.problemDescription = newVal
101
+    },
102
+    personnelData(newVal) {
103
+      // 当父组件更新personnelData时,同步更新本地数据
104
+      this.localPersonnelData = [...newVal]
105
+    }
106
+  },
107
+  methods: {
108
+    // 处理人员选择
109
+    handlePersonnelSelect(selectedUsers) {
110
+      this.localPersonnelData = [...selectedUsers]
111
+
112
+      this.emitPersonnelChange()
113
+    },
114
+
115
+    // 回显 不合格人员
116
+    handlePersonnelConfirm(selectedUsers) {
117
+      this.localPersonnelData = [...selectedUsers]
118
+      this.emitPersonnelChange()
119
+    },
120
+
121
+
122
+
123
+    // 处理人员删除
124
+    handlePersonnelDelete({ item, index, newData }) {
125
+      this.localPersonnelData.splice(index, 1)
126
+
127
+      this.emitPersonnelChange()
128
+    },
129
+
130
+    // 处理问题描述输入
131
+    handleProblemInput(value) {
132
+      this.problemDescription = value
133
+
134
+      this.emitPersonnelChange()
135
+    },
136
+
137
+    // 向外传递完整的对象
138
+    emitPersonnelChange() {
139
+      let personnelData = this.localPersonnelData.map(item => ({
140
+        ...item,
141
+        userName: item.nickName
142
+      }))
143
+      const changeData = {
144
+        parentIndex: this.parentIndex,
145
+        childIndex: this.childIndex,
146
+        personnelData: [...personnelData],
147
+        problemDescription: this.problemDescription
148
+      }
149
+      this.$emit('personnel-change', changeData)
150
+    }
151
+  }
152
+}
153
+</script>
154
+
155
+<style lang="scss" scoped>
156
+.unqualified-container {
157
+  background: #F5F5F5;
158
+  border-radius: 12rpx;
159
+  padding: 32rpx;
160
+  margin: 24rpx 0;
161
+}
162
+
163
+.header {
164
+  display: flex;
165
+  justify-content: space-between;
166
+  align-items: center;
167
+  margin-bottom: 24rpx;
168
+
169
+  .title {
170
+    font-size: 32rpx;
171
+    font-weight: 600;
172
+    color: #333333;
173
+    line-height: 44rpx;
174
+  }
175
+
176
+  .add-icon {
177
+    padding: 8rpx;
178
+    background: #FFFFFF;
179
+    border-radius: 50%;
180
+    box-shadow: 0 4rpx 12rpx rgba(64, 158, 255, 0.3);
181
+    cursor: pointer;
182
+  }
183
+}
184
+
185
+.personnel-list {
186
+  margin-bottom: 24rpx;
187
+}
188
+
189
+.empty-tip {
190
+  text-align: center;
191
+  padding: 48rpx 0;
192
+  color: #999999;
193
+  font-size: 28rpx;
194
+}
195
+
196
+.problem-input {
197
+  ::v-deep .uni-easyinput__content {
198
+    background: #FFFFFF;
199
+    border-radius: 12rpx;
200
+    padding: 24rpx;
201
+    min-height: 120rpx;
202
+  }
203
+
204
+  ::v-deep .uni-textarea-textarea {
205
+    font-size: 28rpx;
206
+    line-height: 40rpx;
207
+    color: #333333;
208
+  }
209
+
210
+  ::v-deep .uni-easyinput__placeholder {
211
+    color: #999999;
212
+    font-size: 28rpx;
213
+  }
214
+}
215
+</style>

+ 881 - 0
src/pages/checklist/index.vue

@@ -0,0 +1,881 @@
1
+<template>
2
+    <home-container>
3
+        <view class="report-container">
4
+            <!-- 表单区域 -->
5
+            <uni-forms ref="form" :rules="rules" :modelValue="formData" label-position="top" err-show-type="modal">
6
+                <!-- 基本信息分组 (默认展开) -->
7
+                <view class="card">
8
+                    <uni-collapse :accordion="false" :value="['group1']">
9
+                        <h-collapse-item name="group1" :show-animation="true"
10
+                            :iconUrl="'../../static/images/icon/jiben.png'" :title="'基本信息'">
11
+                            <uni-forms-item label="任务编号" name="taskCode">
12
+                                <uni-easyinput v-model="formData.taskCode" placeholder="系统自动生成"
13
+                                    :disabled="true"></uni-easyinput>
14
+                            </uni-forms-item>
15
+                            <uni-forms-item label="检查人" name="checkerName">
16
+                                <uni-easyinput v-model="formData.checkerName" placeholder="请输入检查人姓名" :disabled="true" />
17
+                            </uni-forms-item>
18
+                            <uni-forms-item label="执行频率" name="frequency">
19
+                                <uni-easyinput :value="`${formData.ruleTypeDesc}${formData.ruleTypeNum}次`"
20
+                                    placeholder="请输入执行频率" :disabled="true" />
21
+                            </uni-forms-item>
22
+                            <uni-forms-item label="任务简介" name="description">
23
+                                <uni-easyinput type="textarea" v-model="formData.description" placeholder="请输入任务简介"
24
+                                    :disabled="true" />
25
+                            </uni-forms-item>
26
+                            <uni-forms-item :label="getCheckLabel" name="checkedDepartmentId" required>
27
+                                <uni-data-picker v-if="getCheckLabel == '被检查科'" :localdata="departments"
28
+                                    :popup-title="`请选择${getCheckLabel}`" v-model="formData.checkedDepartmentId"
29
+                                    @change="handlecheckedDepartmentIdChange" :readonly="formDisabled" />
30
+                                <uni-data-picker v-if="getCheckLabel == '被检查班组'" :localdata="teams"
31
+                                    :popup-title="`请选择${getCheckLabel}`" v-model="formData.checkedTeamId"
32
+                                    @change="handlecheckedTeamIdChange" :readonly="formDisabled" />
33
+                                <fuzzy-select v-if="getCheckLabel == '被检查人'" v-model="formData.checkedPersonnelId"
34
+                                    :options="userOptions" placeholder="请输入被检查人姓名搜索" data-value="userId"
35
+                                    data-text="nickName" @change="handleCheckedSelect" :disabled="formDisabled" />
36
+                            </uni-forms-item>
37
+                            <uni-forms-item label="检查地点" name="checkLocation" required>
38
+                                <uni-data-picker :localdata="location_options" popup-title="请选择检查地点"
39
+                                    v-model="formData.checkLocation" @change="handleCheckLocationChange"
40
+                                    :readonly="formDisabled" />
41
+                            </uni-forms-item>
42
+                            <uni-forms-item label="检查时间" name="checkTime" required>
43
+                                <uni-datetime-picker type="datetime" :start="startDate" v-model="formData.checkTime"
44
+                                    :disabled="formDisabled" />
45
+                            </uni-forms-item>
46
+                        </h-collapse-item>
47
+                    </uni-collapse>
48
+                </view>
49
+
50
+                <!-- 巡检项目分组 -->
51
+                <view class="card">
52
+                    <uni-collapse :accordion="false" :value="['group2']">
53
+                        <h-collapse-item title="巡检项目" name="group2" :show-animation="true"
54
+                            :iconUrl="'../../static/images/icon/xunjian.png'" ref="xunjianCollapseItem">
55
+                            <view style="padding: 0 15px 15px 15px;">
56
+                                <!-- 巡检项目描述卡片 -->
57
+                                <list-card v-for="(item, index) in formData.checkProjectItemList" :key="item.id">
58
+                                    <template #title>
59
+                                        <view class="item-title">
60
+                                            {{ item.categoryNameOne }}/{{ item.categoryNameTwo }}
61
+                                        </view>
62
+                                    </template>
63
+                                    <view v-for="(ele, i) in item.children" :key="ele.id">
64
+                                        <view class="list-row-space-between">
65
+                                            <view class="list-label">
66
+                                                {{ ele.projectName }}
67
+                                            </view>
68
+                                            <view class="list-value">
69
+                                                <TextSwitch class="text-switch" v-model="ele.scoreLevel"
70
+                                                    uncheck_text="不符合" uncheck_value="UNQUALIFIED" checked_text="符合"
71
+                                                    checked_value="QUALIFIED" :disabled="formDisabled" />
72
+                                            </view>
73
+                                        </view>
74
+                                        <!-- 不合格人员组件 -->
75
+                                        <view v-if="ele.scoreLevel == 'UNQUALIFIED'">
76
+                                            <unqualified-personnel v-model="ele.problemDescription" :formData="formData"
77
+                                                :personnel-data="ele.checkUserList || []" :parent-index="index"
78
+                                                :child-index="i" @personnel-change="handlePersonnelChange"
79
+                                                :disabled="formDisabled" />
80
+                                        </view>
81
+                                    </view>
82
+                                </list-card>
83
+
84
+
85
+                                <!-- 不合格图片上传 -->
86
+                                <view class="upload-section" v-if="!allItemsQualified">
87
+                                    <text class="upload-title">上传不合格图片</text>
88
+                                    <uni-file-picker v-model="formData.baseAttachmentList" limit="8" title="最多上传8张"
89
+                                        :image-styles="imageStyles" fileMediatype="image" mode="grid" @select="onSelect"
90
+                                        :disabled="formDisabled" />
91
+                                </view>
92
+                            </view>
93
+                        </h-collapse-item>
94
+                    </uni-collapse>
95
+                </view>
96
+
97
+                <!-- 整改要求分组 -->
98
+                <view class="card" v-if="rectificationSuggestionsRequired">
99
+                    <uni-collapse :accordion="false" :value="['group3']">
100
+                        <h-collapse-item title="整改要求" name="group3" :show-animation="true"
101
+                            :iconUrl="'../../static/images/icon/zhenggai.png'">
102
+                            <uni-forms-item label="责任人" name="responsibleUserId" required>
103
+                                <fuzzy-select v-model="formData.responsibleUserId" :options="userOptions"
104
+                                    placeholder="请输入责任人姓名搜索" data-value="userId" data-text="nickName"
105
+                                    @change="handleUserSelect" :disabled="formDisabled" />
106
+                            </uni-forms-item>
107
+
108
+                            <uni-forms-item label="整改期限" name="rectificationDeadline" required>
109
+                                <uni-datetime-picker type="datetime" :start="startDate"
110
+                                    v-model="formData.rectificationDeadline" :disabled="formDisabled" />
111
+                            </uni-forms-item>
112
+
113
+                            <uni-forms-item label="整改要求" name="rectificationSuggestions" required>
114
+                                <uni-easyinput type="textarea" v-model="formData.rectificationSuggestions"
115
+                                    placeholder="请输入整改要求" :disabled="formDisabled" />
116
+                            </uni-forms-item>
117
+                        </h-collapse-item>
118
+                    </uni-collapse>
119
+                </view>
120
+
121
+                <!-- 提交按钮 -->
122
+                <view class="button-group" v-if="formData.checkRecordStatus == 'DRAFT' || type == 'task'">
123
+                    <view class="custom-btn-white" @click="saveDraft">保存草稿</view>
124
+                    <view class="custom-btn-normal" @click="submitForm">立刻提交</view>
125
+                </view>
126
+            </uni-forms>
127
+        </view>
128
+
129
+        <SubmitResult ref="submitResult" @continue-submit="handleContinueSubmit" />
130
+    </home-container>
131
+</template>
132
+
133
+<script>
134
+import HomeContainer from "@/components/HomeContainer.vue";
135
+import ListCard from "@/components/list-card/list-card.vue";
136
+import { getCheckTask } from "@/api/check/checkTask.js"
137
+import TextSwitch from "@/components/text-switch/text-switch.vue"
138
+import { checkedLevelEnums } from "@/utils/enums.js"
139
+import UnqualifiedPersonnel from "./components/unqualified.vue"
140
+import { treeSelectByType } from "@/api/system/common"
141
+import { buildTeamOptions, uploadFile, buildDepartmentOptions } from '@/utils/common'
142
+import useDictMixin from '@/utils/dict'
143
+import { addDraftInspection, submitInspection, getInspectionListById } from '@/api/check/checkReward.js'
144
+import SubmitResult from './components/SubmitResult.vue'
145
+import { selectDeptLeaderByUserId } from '@/api/approve/approve.js'
146
+import config from '@/config'
147
+import { getToken } from '@/utils/auth'
148
+import { listAllUser } from "@/api/system/user.js"
149
+import { formatTime } from '@/utils/formatUtils'
150
+import { getDeptList, getDeptManager } from "@/api/system/dept/dept.js"
151
+import FuzzySelect from "@/components/fuzzy-select/fuzzy-select.vue"
152
+export default {
153
+    components: { HomeContainer, ListCard, TextSwitch, UnqualifiedPersonnel, FuzzySelect, SubmitResult },
154
+    mixins: [useDictMixin],
155
+    computed: {
156
+        currentUser() {
157
+            return this.$store.state.user;
158
+        },
159
+        notkezhang() {
160
+            let roles = this.$store.state.user.roles
161
+            return !roles || !roles.includes('kezhang')
162
+        },
163
+        roles() {
164
+            return this.$store.state.user.roles
165
+        },
166
+        rectificationSuggestionsRequired() {
167
+            let res = this.formData.checkProjectItemList || []
168
+            if (res.length == 0) {
169
+                return false
170
+            }
171
+            return res.some(item => {
172
+                return item.children.some(child => child.scoreLevel == 'UNQUALIFIED')
173
+            })
174
+        },
175
+        // 判断是否所有检查项都符合
176
+        allItemsQualified() {
177
+            let res = this.formData.checkProjectItemList || []
178
+            if (res.length == 0) {
179
+                return false
180
+            }
181
+            return res.every(item => {
182
+                return item.children.every(child => child.scoreLevel == 'QUALIFIED')
183
+            })
184
+        },
185
+        formDisabled() {
186
+            return this.formData.checkRecordStatus != 'DRAFT' && this.type == 'document'
187
+        },
188
+        getCheckLabel() {
189
+            if (this.formData.checkedLevel == checkedLevelEnums.TEAM_LEVEL) {
190
+                return '被检查班组'
191
+            }
192
+            if (this.formData.checkedLevel == checkedLevelEnums.PERSONNEL_LEVEL) {
193
+                this.getDeptLeader()
194
+                return '被检查人'
195
+            }
196
+            if (this.formData.checkedLevel == checkedLevelEnums.DEPARTMENT_LEVEL) {
197
+                return '被检查科'
198
+            }
199
+        },
200
+        // 验证规则
201
+        rules() {
202
+            let changeRule = {}
203
+            if (this.formData.checkedLevel == checkedLevelEnums.TEAM_LEVEL) {
204
+                changeRule = {
205
+                    checkedTeamId: {
206
+                        rules: [{ required: true, errorMessage: '请选择被检查班组' }]
207
+                    }
208
+                }
209
+            }
210
+            if (this.formData.checkedLevel == checkedLevelEnums.PERSONNEL_LEVEL) {
211
+                changeRule = {
212
+                    checkedUserId: {
213
+                        rules: [{ required: true, errorMessage: '请选择被检查人' }]
214
+                    }
215
+                }
216
+            }
217
+            return {
218
+                ...changeRule,
219
+                checkLocation: {
220
+                    rules: [
221
+                        {
222
+                            required: true,
223
+                            errorMessage: '请选择检查地点'
224
+                        },
225
+                        {
226
+                            validateFunction: (rule, value, data, callback) => {
227
+                                if (!value) {
228
+                                    // 检查是否选择了完整的检查地点(终端、区域、通道)
229
+                                    callback('请选择完整的检查地点');
230
+                                } else {
231
+                                    callback();
232
+                                }
233
+                            }
234
+                        }
235
+                    ]
236
+                },
237
+                checkTime: {
238
+                    rules: [{ required: true, errorMessage: '请选择检查时间' }]
239
+                },
240
+                responsibleUserId: {
241
+                    rules: [{ required: true, errorMessage: '请输入责任人姓名' }]
242
+                },
243
+                rectificationDeadline: {
244
+                    rules: [{ required: true, errorMessage: '请选择整改期限' }]
245
+                },
246
+                rectificationSuggestions: {
247
+                    rules: [{ required: true, errorMessage: '请输入整改要求' }]
248
+                }
249
+            }
250
+        },
251
+    },
252
+    data() {
253
+        return {
254
+            teams: [],//被检查班组选项
255
+            departments: [],//被检查科选项
256
+            userOptions: [],//全部的人
257
+            checkcheckedTeamIdOptions: [],
258
+            // 表单数据
259
+            formData: {
260
+                // 基本信息
261
+                checkerName: '',
262
+                checkerId: '',
263
+                frequency: '',
264
+                problemDescription: '',
265
+                checkedTeamId: '',
266
+                checkLocation: '',
267
+                checkTime: formatTime(new Date(), 'YYYY-MM-DD hh:mm:ss'),
268
+
269
+                // 巡检项目
270
+                baseAttachmentList: [],
271
+                // 整改要求
272
+                responsibleUserId: '',
273
+                rectificationDeadline: '',
274
+                rectificationSuggestions: ''
275
+            },
276
+
277
+            location_options: [],
278
+            // 其他数据
279
+            startDate: '2020-01-01',
280
+
281
+            imageStyles: {
282
+                width: 80,
283
+                height: 80,
284
+                border: {
285
+                    color: '#eee',
286
+                    width: '1px',
287
+                    style: 'solid'
288
+                }
289
+            },
290
+            taskId: '',
291
+            type: '',
292
+            deptTree: [],
293
+            //被检查人的对象,在检查级别为班组的时候,需要塞到checkUserList里传给后端
294
+            checkedUserObj: {}
295
+        }
296
+    },
297
+    onLoad(options) {
298
+
299
+        this.taskId = options.id; // 获取路由参数中的id
300
+        this.type = options.type; // 获取路由参数中的type
301
+
302
+        this.initPageData();
303
+    },
304
+    mounted() {
305
+
306
+
307
+    },
308
+    methods: {
309
+        getDeptLeader() {
310
+            selectDeptLeaderByUserId({
311
+                userId: this.currentUser.id,
312
+                deptType: 'DEPARTMENT'
313
+            }).then(res => {
314
+
315
+                if (res.code == 200) {
316
+                    this.formData.responsibleUserId = res.data.userId;
317
+                    this.formData.responsibleUserName = res.data.nickName;
318
+                }
319
+            })
320
+        },
321
+        // 被检查人选择
322
+        handleCheckedSelect(e) {
323
+            this.formData.checkedPersonnelId = e;
324
+            let checkedUser = this.userOptions.find(item => item.userId == e);
325
+            this.formData.checkedPersonnelName = checkedUser?.nickName;
326
+            this.checkedUserObj = checkedUser;
327
+        },
328
+        // 继续提交
329
+        handleContinueSubmit() {
330
+            // 清空表单数据
331
+            this.formData.checkedTeamId = '';
332
+            this.formData.checkLocation = [];
333
+            this.formData.checkTime = '';
334
+
335
+            // 清空巡检项目数据
336
+            if (this.formData.checkProjectItemList && this.formData.checkProjectItemList.length > 0) {
337
+                this.formData.checkProjectItemList.forEach(group => {
338
+                    if (group.children && group.children.length > 0) {
339
+                        group.children.forEach(ele => {
340
+                            // 清空检查人员列表
341
+                            ele.checkUserList = [];
342
+                            // 将评分等级设置为符合
343
+                            ele.scoreLevel = 'QUALIFIED';
344
+                        });
345
+                    }
346
+                });
347
+            }
348
+
349
+            // 清空附件列表
350
+            this.formData.baseAttachmentList = [];
351
+
352
+            // 清空整改要求数据
353
+            this.formData.responsibleUserId = '';
354
+            this.formData.rectificationDeadline = '';
355
+            this.formData.rectificationSuggestions = '';
356
+
357
+            // 重置相关字段
358
+            this.formData.checkedTeamName = '';
359
+            this.formData.responsibleUserName = '';
360
+            this.formData.terminlName = '';
361
+            this.formData.terminlCode = '';
362
+            this.formData.regionalName = '';
363
+            this.formData.regionalCode = '';
364
+            this.formData.channelName = '';
365
+            this.formData.channelCode = '';
366
+
367
+            console.log('表单已清空,可以继续提交新数据');
368
+        },
369
+        // 检查地点选择
370
+        handleCheckLocationChange(e) {
371
+            let res = e.detail.value;
372
+            this.formData.terminlName = res[0]?.text;
373
+            this.formData.terminlCode = res[0]?.value;
374
+            this.formData.regionalName = res[1]?.text;
375
+            this.formData.regionalCode = res[1]?.value;
376
+            this.formData.channelName = res[2]?.text;
377
+            this.formData.channelCode = res[2]?.value;
378
+        },
379
+        // 检查科/班组选择
380
+        handlecheckedTeamIdChange(e) {
381
+            const { text, value } = e.detail.value[0];
382
+            let newText = text.split('/')
383
+            this.$set(this.formData, 'checkedTeamName', newText[newText.length - 1]);
384
+
385
+            // 在树状结构中查找指定id的上一级id
386
+            const findParentIdInTree = (tree, targetId, parent = null) => {
387
+                for (const node of tree) {
388
+                    // 如果当前节点就是目标节点,返回父级信息
389
+                    if (node.id === targetId) {
390
+                        return parent ? {
391
+                            label: parent.label,
392
+                            id: parent.id
393
+                        } : null;
394
+                    }
395
+                    // 如果当前节点有子节点,递归查找
396
+                    if (node.children && node.children.length > 0) {
397
+                        const result = findParentIdInTree(node.children, targetId, node);
398
+                        if (result !== null) {
399
+                            return result;
400
+                        }
401
+                    }
402
+                }
403
+                return null;
404
+            };
405
+
406
+
407
+
408
+            // 直接从teams数据中查找
409
+            const { label, id } = findParentIdInTree(this.deptTree, value);
410
+
411
+
412
+            // 如果有找到上一级DEPARTMENT对象,可以在这里使用它
413
+            if (label && id) {
414
+                // 可以根据需要将部门信息设置到formData中
415
+                this.$set(this.formData, 'checkedDepartmentId', id);
416
+                this.$set(this.formData, 'checkedDepartmentName', label.trim());
417
+            } else {
418
+                console.log('未找到符合条件的上一级DEPARTMENT对象');
419
+            }
420
+
421
+            getDeptManager(value).then(res => {
422
+                console.log(res?.data?.userId, "111res")
423
+                this.$set(this.formData, 'responsibleUserId', res?.data?.userId || '');
424
+                this.$set(this.formData, 'responsibleUserName', res?.data?.nickName || '');
425
+            })
426
+        },
427
+        // 检查科选择
428
+        handlecheckedDepartmentIdChange(e) {
429
+
430
+            const { text, value } = e.detail.value[0];
431
+
432
+            let newText = text.split('/')
433
+            this.$set(this.formData, 'checkedDepartmentName', newText[newText.length - 1].trim());
434
+            getDeptManager(value).then(res => {
435
+                console.log(res?.data?.userId, "111res")
436
+                this.$set(this.formData, 'responsibleUserId', res?.data?.userId || '');
437
+                this.$set(this.formData, 'responsibleUserName', res?.data?.nickName || '');
438
+            })
439
+        },
440
+        // 加载字典数据
441
+        async loadDictData() {
442
+            const user = await listAllUser();
443
+            this.userOptions = user.data.map(item => ({
444
+                ...item,
445
+                nickName: `${item.nickName}(${item.userName})`,
446
+            })) || [];
447
+            const deptTree = await getDeptList();
448
+            this.deptTree = deptTree.data || [];
449
+            this.teams = buildTeamOptions(deptTree.data || []);
450
+            this.departments = buildDepartmentOptions(deptTree.data || []);
451
+            console.log(this.departments, "this.departments")
452
+            const [positionRes] = await Promise.all([
453
+                treeSelectByType("POSITION", 3),
454
+            ]);
455
+
456
+            const convertTree = (list = []) =>
457
+                list.map(node => ({
458
+                    text: node.label,
459
+                    value: node.code,
460
+                    children: node.children ? convertTree(node.children) : null
461
+                }))
462
+
463
+            this.location_options = convertTree(positionRes.data || []);
464
+            // console.log(this.location_options, this.teams)
465
+            const dict = await this.useDict('check_checked_level');
466
+            this.checkcheckedTeamIdOptions = dict.check_checked_level || [];
467
+        },
468
+        async initPageData() {
469
+            try {
470
+                uni.showLoading({ title: '加载中...', mask: true });
471
+
472
+                // 如果有任务ID,获取巡检任务详情
473
+                if (this.taskId) {
474
+                    //如果是任务就调任务的详情接口,如果是检查单就调用检查单的接口
475
+                    let api = this.type === 'task' ? getCheckTask : getInspectionListById;
476
+                    const inspectionDetail = await api(this.taskId);
477
+                    this.fillFormData(inspectionDetail);
478
+                }
479
+                await this.loadDictData();
480
+
481
+                // // 获取部门列表
482
+                // const deptTree = await getDeptList();
483
+                // this.teams = this.buildDepartmentOptions(deptTree.data || []);
484
+
485
+                // // 获取位置列表
486
+                // const positionRes = await treeSelectByType("POSITION", 3);
487
+                // this.location_options = this.convertTree(positionRes.data || []);
488
+
489
+                uni.hideLoading();
490
+            } catch (err) {
491
+                uni.hideLoading();
492
+                uni.showToast({ title: '加载失败', icon: 'none' });
493
+                console.error('初始化数据失败:', err);
494
+            }
495
+        },
496
+        // buildDepartmentOptions(tree = []) {
497
+        //     const result = [];
498
+
499
+        //     function dfs(node, path = []) {
500
+        //         const currentPath = [...path, node.label];
501
+        //         // 如果是部门或班组节点
502
+        //         if (node.deptType === 'DEPARTMENT' || node.deptType === 'TEAMS') {
503
+        //             result.push({
504
+        //                 text: currentPath.join(' / '),
505
+        //                 value: node.id
506
+        //             });
507
+        //         }
508
+        //         // 继续递归子节点
509
+        //         if (node.children && Array.isArray(node.children)) {
510
+        //             node.children.forEach(child => dfs(child, currentPath));
511
+        //         }
512
+        //     }
513
+
514
+        //     tree.forEach(root => dfs(root));
515
+        //     return result;
516
+        // },
517
+        convertTree(list = []) {
518
+            return list.map(node => ({
519
+                text: node.label,
520
+                value: node.code,
521
+                children: node.children ? this.convertTree(node.children) : null
522
+            }));
523
+        },
524
+        // 选择文件后手动上传
525
+        async onSelect(event) {
526
+            const file = await uploadFile(event);
527
+            console.log(file, "file", this.formData)
528
+
529
+            this.formData.baseAttachmentList.push({
530
+                url: file.url,
531
+                name: file.newFileName,
532
+                attachmentName: file.newFileName,
533
+                attachmentUrl: file.url,
534
+                extname: file.newFileName.split('.').pop()
535
+            });
536
+        },
537
+        // 填充表单数据
538
+        fillFormData(inspectionDetail) {
539
+            if (!inspectionDetail || !inspectionDetail.data) return;
540
+
541
+            const data = inspectionDetail.data;
542
+
543
+            // 计算当前时间加4天
544
+            const currentDate = new Date();
545
+            currentDate.setDate(currentDate.getDate() + 4);
546
+            const fourDaysLater = currentDate.toISOString().replace('T', ' ').substring(0, 19);
547
+
548
+            // 按categoryCodeOne和categoryCodeTwo分组处理checkProjectItemList
549
+            const groupedCheckProjectItemList = data.checkProjectItemList && data.checkProjectItemList.reduce((groups, item) => {
550
+                const groupKey = `${item.categoryCodeOne}_${item.categoryCodeTwo}`;
551
+                if (!groups[groupKey]) {
552
+                    groups[groupKey] = {
553
+                        categoryCodeOne: item.categoryCodeOne,
554
+                        categoryCodeTwo: item.categoryCodeTwo,
555
+                        categoryNameOne: item.categoryNameOne,
556
+                        categoryNameTwo: item.categoryNameTwo,
557
+                        children: []
558
+                    };
559
+                }
560
+                groups[groupKey].children.push({
561
+                    ...item,
562
+                    scoreLevel: item.scoreLevel || 'QUALIFIED'
563
+                });
564
+                return groups;
565
+            }, {});
566
+
567
+            // 将分组对象转换为数组
568
+            const checkProjectItemListArray = groupedCheckProjectItemList ? Object.values(groupedCheckProjectItemList) : [];
569
+
570
+            // 填充基本信息
571
+            this.formData = {
572
+                ...data,
573
+                checkTime: data.checkTime || formatTime(new Date(), 'YYYY-MM-DD hh:mm:ss'),
574
+                checkerName: this.currentUser.userInfo.nickName || this.currentUser.userInfo.userName || '',
575
+                checkerId: this.currentUser.userInfo.userId || '',
576
+                rectificationDeadline: fourDaysLater,
577
+                baseAttachmentList: (data.baseAttachmentList && data.baseAttachmentList.map(item => ({
578
+                    ...item,
579
+                    url: item.attachmentUrl,
580
+                    name: item.attachmentName,
581
+                    extname: item.extname
582
+                }))) || [],
583
+                checkedTeamId: Number(data.checkedTeamId),
584
+                checkLocation: [{ text: data.terminlName, value: data.terminlCode }, { text: data.regionalName, value: data.regionalCode }, { text: data.channelName, value: data.channelCode }],
585
+                checkProjectItemList: checkProjectItemListArray
586
+            }
587
+
588
+
589
+
590
+            console.log(this.formData, "this.formData回显")
591
+        },
592
+
593
+        // 处理添加不合格人员
594
+        handleAddPersonnel(index) {
595
+            console.log('添加不合格人员,项目索引:', index);
596
+
597
+            this.$nextTick(() => {
598
+                this.$refs.xunjianCollapseItem.updateCollapseHeight();
599
+            })
600
+            // 这里可以打开人员选择弹窗或跳转到人员选择页面
601
+            uni.showToast({ title: '打开人员选择', icon: 'none' });
602
+        },
603
+
604
+        // 处理人员数据变更
605
+        handlePersonnelChange(changeData) {
606
+            const { parentIndex, childIndex, personnelData, problemDescription } = changeData;
607
+            console.log(personnelData, "personnelData")
608
+            if (parentIndex >= 0 && childIndex >= 0 &&
609
+                this.formData.checkProjectItemList &&
610
+                this.formData.checkProjectItemList[parentIndex] &&
611
+                this.formData.checkProjectItemList[parentIndex].children &&
612
+                this.formData.checkProjectItemList[parentIndex].children[childIndex]) {
613
+
614
+                // 更新对应子项目的检查人员列表和任务简介
615
+                this.formData.checkProjectItemList[parentIndex].children[childIndex].checkUserList = personnelData;
616
+                this.formData.checkProjectItemList[parentIndex].children[childIndex].problemDescription = problemDescription;
617
+                console.log('更新不合格人员数据:', parentIndex, childIndex, personnelData, problemDescription);
618
+            }
619
+        },
620
+
621
+        // 处理用户选择
622
+        handleUserSelect(value) {
623
+
624
+            const selectedUser = this.userOptions.find(user => user.userId === value);
625
+
626
+            if (selectedUser) {
627
+                this.formData.responsibleUserId = selectedUser.userId;
628
+                this.formData.responsibleUserName = selectedUser.nickName;
629
+            }
630
+        },
631
+        formateData() {
632
+            let checkedUserObj = {}
633
+            if (JSON.stringify(this.checkedUserObj) !== '{}') {
634
+                checkedUserObj = {
635
+                    ...this.checkedUserObj,
636
+                    userName: this.checkedUserObj.nickName.split('(')[0] || '',
637
+                }
638
+            }
639
+
640
+
641
+            const originalCheckProjectItemList = this.formData.checkProjectItemList
642
+                ? this.formData.checkProjectItemList.flatMap(group =>
643
+                    group.children.map(child => ({
644
+                        ...child,
645
+                        categoryCodeOne: group.categoryCodeOne,
646
+                        categoryCodeTwo: group.categoryCodeTwo,
647
+                        categoryNameOne: group.categoryNameOne,
648
+                        categoryNameTwo: group.categoryNameTwo,
649
+                        //如果是班组,自动赋值被检查人给checkUserList
650
+                        ...(this.formData.checkLevel == 'TEAM_LEVEL' && child.scoreLevel == "UNQUALIFIED" ? { checkUserList: [checkedUserObj] } : {}),
651
+                    }))
652
+                )
653
+                : [];
654
+
655
+            let payload = {
656
+                ...this.formData,
657
+                checkedTeamName: (this.formData.checkedTeamName|| '').trim() ,
658
+                checkProjectItemList: originalCheckProjectItemList
659
+            };
660
+
661
+            delete payload.id;
662
+            console.log(payload, "payload")
663
+            // debugger
664
+            return payload;
665
+        },
666
+        // 保存草稿
667
+        saveDraft() {
668
+            // this.$refs.form.validate().then(res => {
669
+            uni.showLoading({ title: '保存中...', mask: true });
670
+
671
+
672
+            const payload = this.formateData();
673
+
674
+
675
+            addDraftInspection({ ...payload, id: this.taskId })
676
+                .then(() => {
677
+                    uni.showToast({ title: '草稿保存成功', icon: 'success' });
678
+                    console.log('草稿保存成功:',);
679
+                    setTimeout(() => uni.navigateBack(), 1500);
680
+                })
681
+                .catch((error) => {
682
+                    uni.showToast({ title: '草稿保存失败,请稍后重试!', icon: 'none' });
683
+                    console.error('草稿保存失败:', error);
684
+                });
685
+
686
+            // }).catch(err => {
687
+            //     console.log('表单验证失败:', err);
688
+            //     uni.showToast({ title: '请填写完整表单信息', icon: 'none' });
689
+            // });
690
+        },
691
+
692
+        // 提交表单
693
+        submitForm() {
694
+            this.$refs.form.validate().then(res => {
695
+                // 检查所有不合格项是否都填写了相关人员
696
+                let hasError = false;
697
+                let errorMessage = '';
698
+                //被检查级别科 班组
699
+                if (this.formData.checkProjectItemList && this.formData.checkProjectItemList.length > 0 && this.getCheckLabel != '被检查人') {
700
+                    for (let groupIndex = 0; groupIndex < this.formData.checkProjectItemList.length; groupIndex++) {
701
+                        const group = this.formData.checkProjectItemList[groupIndex];
702
+                        if (group.children && group.children.length > 0) {
703
+                            for (let itemIndex = 0; itemIndex < group.children.length; itemIndex++) {
704
+                                const item = group.children[itemIndex];
705
+                                if (item.scoreLevel === 'UNQUALIFIED' &&
706
+                                    ((!item.checkUserList || item.checkUserList.length === 0) && (!item.problemDescription || item.problemDescription.trim() === ''))) {
707
+                                    hasError = true;
708
+                                    errorMessage = `第${groupIndex + 1}组项目 "${item.projectName}" 标记为不符合,请选择相关人员或填写问题描述`;
709
+                                    break;
710
+                                }
711
+                            }
712
+                            if (hasError) break;
713
+                        }
714
+                    }
715
+                }
716
+
717
+                // 如果有错误,弹出提示并阻止提交
718
+                if (hasError) {
719
+                    uni.showToast({ title: errorMessage, icon: 'none' });
720
+                    return;
721
+                }
722
+
723
+                // 验证通过,继续提交
724
+                uni.showLoading({ title: '提交中...', mask: true });
725
+
726
+                const payload = this.formateData();
727
+
728
+                submitInspection({ ...payload, ...(this.type == 'task' ? {} : { id: this.taskId }) })
729
+                    .then(() => {
730
+                        uni.showToast({ title: '提交成功', icon: 'success' });
731
+                        this.$refs.submitResult.open();
732
+                    })
733
+                    .catch((error) => {
734
+                        uni.showToast({ title: '操作失败,请稍后重试!', icon: 'none' });
735
+                        console.error('提交失败:', error);
736
+                    });
737
+
738
+            }).catch(err => {
739
+                console.log('表单验证失败:', err);
740
+            });
741
+        }
742
+
743
+    }
744
+}
745
+</script>
746
+
747
+<style lang="scss" scoped>
748
+.report-container {
749
+    min-height: 100vh;
750
+    padding-top: 35px;
751
+
752
+    .card {
753
+        border-radius: 8px;
754
+        margin: 15px 0;
755
+        overflow: hidden;
756
+        box-shadow: rgba(0, 0, 0, 0.08) 0px 0px 3px 1px;
757
+    }
758
+}
759
+
760
+/* 折叠面板标题 */
761
+.collapse-title {
762
+    display: flex;
763
+    flex-direction: column;
764
+    padding: 12px 15px;
765
+
766
+    .collapse-summary {
767
+        font-size: 12px;
768
+        color: #999;
769
+        margin-top: 4px;
770
+    }
771
+}
772
+
773
+/* 表单项样式 */
774
+::v-deep .uni-forms-item {
775
+    margin-bottom: 0;
776
+    padding: 0 15px;
777
+
778
+    .uni-forms-item__label {
779
+        padding: 12px 0 8px;
780
+        font-size: 14px;
781
+        color: #666;
782
+        width: auto !important;
783
+    }
784
+
785
+    .uni-forms-item__content {
786
+        padding: 0 0 12px;
787
+        border-bottom: 1px solid #f0f0f0;
788
+    }
789
+
790
+    &:last-child .uni-forms-item__content {
791
+        border-bottom: none;
792
+    }
793
+}
794
+
795
+.submit-btn {
796
+    margin-top: 20px;
797
+    width: calc(100% - 30px);
798
+    margin-left: 15px;
799
+}
800
+
801
+/* 底部弹窗样式调整 */
802
+::v-deep .uni-popup__wrapper {
803
+    border-radius: 16px 16px 0 0;
804
+
805
+    .uni-popup__wrapper-box {
806
+        max-height: 70vh;
807
+        overflow-y: auto;
808
+    }
809
+
810
+    .uni-data-pickerview {
811
+        padding-bottom: 20px;
812
+    }
813
+}
814
+
815
+:v-deep .uni-input-input:disabled {
816
+    background-color: #f5f5f5;
817
+}
818
+
819
+/* 巡检项目卡片样式 */
820
+.item-title {
821
+    display: flex;
822
+    justify-content: space-between;
823
+    align-items: center;
824
+    width: 100%;
825
+    font-size: 16px;
826
+    font-weight: bold;
827
+    color: #333;
828
+}
829
+
830
+.item-content {
831
+    margin-top: 12px;
832
+
833
+    .item-description {
834
+        display: flex;
835
+        flex-direction: column;
836
+
837
+        .description-label {
838
+            font-size: 14px;
839
+            color: #666;
840
+            margin-bottom: 4px;
841
+        }
842
+
843
+        .description-text {
844
+            font-size: 14px;
845
+            color: #333;
846
+            line-height: 1.5;
847
+        }
848
+    }
849
+}
850
+
851
+.upload-section {
852
+    margin-top: 20px;
853
+    padding-top: 15px;
854
+    border-top: 1px solid #f0f0f0;
855
+
856
+    .upload-title {
857
+        display: block;
858
+        font-size: 16px;
859
+        font-weight: bold;
860
+        color: #333;
861
+        margin-bottom: 12px;
862
+    }
863
+}
864
+
865
+/* 按钮组样式 - 左右布局 */
866
+.button-group {
867
+    display: flex;
868
+    justify-content: space-between;
869
+    align-items: center;
870
+    padding: 0 15px;
871
+    margin-top: 20px;
872
+    gap: 15px;
873
+}
874
+
875
+.button-group .custom-btn-white,
876
+.button-group .custom-btn-normal {
877
+    flex: 1;
878
+    margin-top: 0;
879
+    width: auto;
880
+}
881
+</style>

+ 43 - 0
src/pages/common/textview/index.vue

@@ -0,0 +1,43 @@
1
+<template>
2
+  <view>
3
+    <uni-card class="view-title" :title="title">
4
+      <text class="uni-body view-content">{{ content }}</text>
5
+    </uni-card>
6
+  </view>
7
+</template>
8
+
9
+<script>
10
+  export default {
11
+    data() {
12
+      return {
13
+        title: '',
14
+        content: ''
15
+      }
16
+    },
17
+    onLoad(options) {
18
+      this.title = options.title
19
+      this.content = options.content
20
+      uni.setNavigationBarTitle({
21
+        title: options.title
22
+      })
23
+    }
24
+  }
25
+</script>
26
+
27
+<style scoped>
28
+  page {
29
+    background-color: #ffffff;
30
+  }
31
+
32
+  .view-title {
33
+    font-weight: bold;
34
+  }
35
+
36
+  .view-content {
37
+    font-size: 26rpx;
38
+    padding: 12px 5px 0;
39
+    color: #333;
40
+    line-height: 24px;
41
+    font-weight: normal;
42
+  }
43
+</style>

+ 34 - 0
src/pages/common/webview/index.vue

@@ -0,0 +1,34 @@
1
+<template>
2
+  <view v-if="params.url">
3
+    <web-view :webview-styles="webviewStyles" :src="`${params.url}`"></web-view>
4
+  </view>
5
+</template>
6
+
7
+<script>
8
+  export default {
9
+    data() {
10
+      return {
11
+        params: {},
12
+        webviewStyles: {
13
+          progress: {
14
+            color: "#FF3333"
15
+          }
16
+        }
17
+      }
18
+    },
19
+    props: {
20
+      src: {
21
+        type: [String],
22
+        default: null
23
+      }
24
+    },
25
+    onLoad(event) {
26
+      this.params = event
27
+      if (event.title) {
28
+        uni.setNavigationBarTitle({
29
+          title: event.title
30
+        })
31
+      }
32
+    }
33
+  }
34
+</script>

+ 785 - 0
src/pages/daily-exam/answer/index.vue

@@ -0,0 +1,785 @@
1
+<template>
2
+  <home-container v-if="pageReady">
3
+    <div class="daily-answer-container">
4
+      <!-- 答题模式 -->
5
+      <div v-if="mode === 'answer'" class="answer-mode">
6
+        <div class="header">
7
+          <div class="header-info">
8
+            <div class="title">每日答题</div>
9
+            <div class="progress">第 {{ currentIndex + 1 }}/{{ questions.length }} 题</div>
10
+          </div>
11
+          <div class="timer">
12
+            <text class="timer-icon">⏱</text>
13
+            <text class="timer-text">{{ formattedTime }}</text>
14
+          </div>
15
+        </div>
16
+
17
+        <div v-if="currentQuestion" class="question-card">
18
+          <div class="question-type">{{ getQuestionTypeText(currentQuestion.questionType) }}</div>
19
+          <div class="question-content">{{ currentQuestion.questionContent }}</div>
20
+
21
+          <div class="options">
22
+            <div
23
+              v-for="(option, idx) in getOptions(currentQuestion)"
24
+              :key="idx"
25
+              :class="['option-item', { active: isSelected(idx) }]"
26
+              @click="selectOption(idx)">
27
+              <div class="option-label">{{ getOptionLabel(idx) }}</div>
28
+              <div class="option-content">{{ option }}</div>
29
+              <div v-if="isSelected(idx)" class="option-check">✓</div>
30
+            </div>
31
+          </div>
32
+        </div>
33
+
34
+        <div class="navigation">
35
+          <div v-if="currentIndex > 0" class="btn btn-secondary" @click="prevQuestion">上一题</div>
36
+          <div v-if="currentIndex < questions.length - 1" class="btn btn-primary" @click="nextQuestion">下一题</div>
37
+          <div v-if="currentIndex === questions.length - 1" class="btn btn-primary" @click="showSubmitConfirm">提交答卷</div>
38
+        </div>
39
+
40
+        <!-- 答题卡 -->
41
+        <div class="answer-card">
42
+          <div class="answer-card-title">答题卡</div>
43
+          <div class="answer-card-grid">
44
+            <div
45
+              v-for="(q, idx) in questions"
46
+              :key="idx"
47
+              :class="[
48
+                'answer-card-item',
49
+                { answered: userAnswers[q.questionId] !== undefined },
50
+                { current: idx === currentIndex }
51
+              ]"
52
+              @click="jumpToQuestion(idx)">
53
+              {{ idx + 1 }}
54
+            </div>
55
+          </div>
56
+        </div>
57
+      </div>
58
+
59
+      <!-- 结果模式 -->
60
+      <div v-if="mode === 'result'" class="result-mode">
61
+        <div class="result-header">
62
+          <div class="result-score">
63
+            <div class="score-value">{{ result.totalScore }}</div>
64
+            <div class="score-label">总分</div>
65
+          </div>
66
+          <div class="result-stats">
67
+            <div class="stat-item">
68
+              <div class="stat-value correct">{{ result.correctCount }}</div>
69
+              <div class="stat-label">正确</div>
70
+            </div>
71
+            <div class="stat-item">
72
+              <div class="stat-value wrong">{{ result.wrongCount }}</div>
73
+              <div class="stat-label">错误</div>
74
+            </div>
75
+            <div class="stat-item">
76
+              <div class="stat-value">{{ result.totalCount }}</div>
77
+              <div class="stat-label">总题数</div>
78
+            </div>
79
+          </div>
80
+        </div>
81
+
82
+        <div class="result-questions">
83
+          <div v-for="(q, idx) in questions" :key="idx" class="result-question-item">
84
+            <div class="result-question-header">
85
+              <div class="question-num">第 {{ idx + 1 }} 题 ({{ getQuestionTypeText(q.questionType) }})</div>
86
+              <div :class="['question-result', q.isCorrect ? 'correct' : 'wrong']">
87
+                {{ q.isCorrect ? '✓ 正确' : '✗ 错误' }}
88
+              </div>
89
+            </div>
90
+            <div class="question-content">{{ q.questionContent }}</div>
91
+            <div class="question-answers">
92
+              <div class="answer-row">
93
+                <text class="answer-label">你的答案:</text>
94
+                <text :class="['answer-value', q.isCorrect ? 'correct' : 'wrong']">
95
+                  {{ formatAnswer(q.userAnswer) || '未作答' }}
96
+                </text>
97
+              </div>
98
+              <div v-if="!q.isCorrect" class="answer-row">
99
+                <text class="answer-label">正确答案:</text>
100
+                <text class="answer-value correct">{{ formatAnswer(q.correctAnswer) }}</text>
101
+              </div>
102
+              <div class="answer-row">
103
+                <text class="answer-label">得分:</text>
104
+                <text :class="['answer-value', q.isCorrect ? 'correct' : '']">{{ q.actualScore }}/{{ q.totalScore }}分</text>
105
+              </div>
106
+            </div>
107
+          </div>
108
+        </div>
109
+
110
+        <div class="result-footer">
111
+          <div class="btn btn-primary" @click="backToList">返回列表</div>
112
+        </div>
113
+      </div>
114
+    </div>
115
+  </home-container>
116
+</template>
117
+
118
+<script>
119
+import HomeContainer from "@/components/HomeContainer.vue";
120
+import { startTask, submitAnswer, getTaskDetail, getRemainingTime } from "@/api/exam/dailyExam";
121
+
122
+export default {
123
+  components: { HomeContainer },
124
+  data() {
125
+    return {
126
+      pageReady: false,
127
+      mode: 'answer', // answer: 答题模式, result: 结果模式
128
+      taskId: '',
129
+      status: '', // start: 开始答题, continue: 继续答题, result: 查看结果
130
+
131
+      questions: [],
132
+      currentIndex: 0,
133
+      userAnswers: {}, // { questionId: answer }
134
+
135
+      timeLeft: 0, // 剩余时间(秒)
136
+      timer: null,
137
+
138
+      result: {
139
+        totalScore: 0,
140
+        correctCount: 0,
141
+        wrongCount: 0,
142
+        totalCount: 0
143
+      }
144
+    }
145
+  },
146
+  computed: {
147
+    currentQuestion() {
148
+      return this.questions[this.currentIndex] || null;
149
+    },
150
+    formattedTime() {
151
+      const minutes = Math.floor(this.timeLeft / 60);
152
+      const seconds = this.timeLeft % 60;
153
+      return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
154
+    }
155
+  },
156
+  onLoad(options) {
157
+    this.taskId = options.taskId;
158
+    this.status = options.status;
159
+
160
+    if (this.status === 'result') {
161
+      this.mode = 'result';
162
+      this.loadResult();
163
+    } else {
164
+      this.mode = 'answer';
165
+      this.startAnswer();
166
+    }
167
+  },
168
+  beforeDestroy() {
169
+    if (this.timer) {
170
+      clearInterval(this.timer);
171
+    }
172
+  },
173
+  methods: {
174
+    async startAnswer() {
175
+      try {
176
+        const response = await startTask(this.taskId);
177
+        this.questions = response.data.questions || [];
178
+        this.timeLeft = response.data.remainingTime || 0;
179
+
180
+        // 如果是继续答题,恢复之前的答案
181
+        this.questions.forEach(q => {
182
+          if (q.userAnswer) {
183
+            this.userAnswers[q.questionId] = q.userAnswer;
184
+          }
185
+        });
186
+
187
+        this.startTimer();
188
+        this.pageReady = true;
189
+      } catch (error) {
190
+        console.error('开始答题失败', error);
191
+        uni.showToast({
192
+          title: error.msg || '开始答题失败',
193
+          icon: 'none'
194
+        });
195
+        setTimeout(() => {
196
+          uni.navigateBack();
197
+        }, 1500);
198
+      }
199
+    },
200
+
201
+    async loadResult() {
202
+      try {
203
+        const response = await getTaskDetail(this.taskId);
204
+        const task = response.task;
205
+        const details = response.details || [];
206
+
207
+        console.log('原始数据:', { task, details });
208
+
209
+        this.questions = details.map(d => ({
210
+          questionId: d.dtdQuesId,
211
+          questionIndex: d.dtdQuestionNo,
212
+          questionContent: d.dtdQuesContent,
213
+          questionType: d.dtdQuesType,
214
+          optionA: d.dtdOptionA,
215
+          optionB: d.dtdOptionB,
216
+          optionC: d.dtdOptionC,
217
+          optionD: d.dtdOptionD,
218
+          userAnswer: d.dtdUserAnswer,
219
+          correctAnswer: d.dtdCorrectAnswer,
220
+          isCorrect: d.dtdIsCorrect === true,
221
+          totalScore: d.dtdScore,        // 题目总分
222
+          actualScore: d.dtdActualScore  // 实际得分
223
+        }));
224
+
225
+        console.log('处理后的questions:', this.questions);
226
+
227
+        this.result = {
228
+          totalScore: task.dtTotalScore || 0,
229
+          correctCount: details.filter(d => d.dtdIsCorrect === true).length,
230
+          wrongCount: details.filter(d => d.dtdIsCorrect === false && d.dtdUserAnswer).length,
231
+          totalCount: details.length
232
+        };
233
+
234
+        console.log('统计结果:', this.result);
235
+
236
+        this.pageReady = true;
237
+      } catch (error) {
238
+        console.error('加载结果失败', error);
239
+        uni.showToast({
240
+          title: '加载结果失败',
241
+          icon: 'none'
242
+        });
243
+      }
244
+    },
245
+
246
+    startTimer() {
247
+      this.timer = setInterval(() => {
248
+        if (this.timeLeft > 0) {
249
+          this.timeLeft--;
250
+        } else {
251
+          clearInterval(this.timer);
252
+          this.autoSubmit();
253
+        }
254
+      }, 1000);
255
+    },
256
+
257
+    getQuestionTypeText(type) {
258
+      const typeMap = {
259
+        '1': '单选题',
260
+        '2': '多选题',
261
+        '3': '判断题',
262
+        'SINGLE': '单选题',
263
+        'MULTIPLE': '多选题',
264
+        'JUDGMENT': '判断题'
265
+      };
266
+      return typeMap[type] || '题目';
267
+    },
268
+
269
+    formatAnswer(answer) {
270
+      if (!answer) return '';
271
+
272
+      // 如果是数字格式 "1,2,3" 或 "1",转换为选项字母 "A,B,C"
273
+      if (/^\d+(,\d+)*$/.test(answer)) {
274
+        return answer.split(',').map(num => {
275
+          const index = parseInt(num) - 1;
276
+          return String.fromCharCode(65 + index); // 65是'A'的ASCII码
277
+        }).join(',');
278
+      }
279
+
280
+      // 如果已经是字母格式 "A" 或 "A,B,C",直接返回
281
+      // 但需要处理可能的格式问题(去除空格,统一大写)
282
+      return answer.toString().toUpperCase().replace(/\s/g, '');
283
+    },
284
+
285
+    getOptions(question) {
286
+      const options = [];
287
+      if (question.optionA) options.push(question.optionA);
288
+      if (question.optionB) options.push(question.optionB);
289
+      if (question.optionC) options.push(question.optionC);
290
+      if (question.optionD) options.push(question.optionD);
291
+      return options;
292
+    },
293
+
294
+    getOptionLabel(index) {
295
+      return String.fromCharCode(65 + index); // A, B, C, D
296
+    },
297
+
298
+    isMultipleChoice(questionType) {
299
+      // 判断是否为多选题
300
+      return questionType === '2' || questionType === 'MULTIPLE';
301
+    },
302
+
303
+    isSelected(optionIndex) {
304
+      const answer = this.userAnswers[this.currentQuestion.questionId];
305
+      if (!answer) return false;
306
+
307
+      const label = this.getOptionLabel(optionIndex);
308
+      if (this.isMultipleChoice(this.currentQuestion.questionType)) {
309
+        // 多选题
310
+        return answer.includes(label);
311
+      } else {
312
+        // 单选题或判断题
313
+        return answer === label;
314
+      }
315
+    },
316
+
317
+    selectOption(optionIndex) {
318
+      const label = this.getOptionLabel(optionIndex);
319
+      const questionId = this.currentQuestion.questionId;
320
+
321
+      if (this.isMultipleChoice(this.currentQuestion.questionType)) {
322
+        // 多选题
323
+        let answer = this.userAnswers[questionId] || '';
324
+        if (answer.includes(label)) {
325
+          // 取消选择
326
+          answer = answer.replace(label, '');
327
+        } else {
328
+          // 添加选择
329
+          answer += label;
330
+        }
331
+        // 按字母顺序排序
332
+        answer = answer.split('').sort().join('');
333
+        this.$set(this.userAnswers, questionId, answer || undefined);
334
+      } else {
335
+        // 单选题或判断题
336
+        this.$set(this.userAnswers, questionId, label);
337
+      }
338
+    },
339
+
340
+    prevQuestion() {
341
+      if (this.currentIndex > 0) {
342
+        this.currentIndex--;
343
+      }
344
+    },
345
+
346
+    nextQuestion() {
347
+      if (this.currentIndex < this.questions.length - 1) {
348
+        this.currentIndex++;
349
+      }
350
+    },
351
+
352
+    jumpToQuestion(index) {
353
+      this.currentIndex = index;
354
+    },
355
+
356
+    showSubmitConfirm() {
357
+      const unanswered = this.questions.filter(q => !this.userAnswers[q.questionId]).length;
358
+      const message = unanswered > 0
359
+        ? `还有 ${unanswered} 题未作答,确定提交吗?`
360
+        : '确定提交答卷吗?';
361
+
362
+      uni.showModal({
363
+        title: '提示',
364
+        content: message,
365
+        success: (res) => {
366
+          if (res.confirm) {
367
+            this.submitAnswers(1); // 1: 手动提交
368
+          }
369
+        }
370
+      });
371
+    },
372
+
373
+    // 将字母答案转换为数字答案 (A→1, B→2, C→3, D→4)
374
+    convertAnswerToNumber(answer) {
375
+      if (!answer) return '';
376
+
377
+      // 如果已经是数字格式,直接返回
378
+      if (/^\d+(,\d+)*$/.test(answer)) {
379
+        return answer;
380
+      }
381
+
382
+      // 将字母转换为数字
383
+      // "A" → "1", "ABC" → "1,2,3", "B,C" → "2,3"
384
+
385
+      // 先处理可能的逗号分隔格式
386
+      let letters = [];
387
+      if (answer.includes(',')) {
388
+        // 如果有逗号,按逗号分割
389
+        letters = answer.split(',').map(s => s.trim());
390
+      } else {
391
+        // 如果没有逗号,将每个字符分开(支持 "ABC" 这种格式)
392
+        letters = answer.split('').filter(c => /[A-Za-z]/.test(c));
393
+      }
394
+
395
+      return letters.map(letter => {
396
+        const charCode = letter.trim().toUpperCase().charCodeAt(0);
397
+        return (charCode - 64).toString(); // A=65, 65-64=1
398
+      }).join(',');
399
+    },
400
+
401
+    async submitAnswers(submitType) {
402
+      try {
403
+        console.log('原始答案:', this.userAnswers);
404
+
405
+        const answers = this.questions.map(q => {
406
+          const originalAnswer = this.userAnswers[q.questionId] || '';
407
+          const convertedAnswer = this.convertAnswerToNumber(originalAnswer);
408
+
409
+          console.log(`题目 ${q.questionId}: "${originalAnswer}" → "${convertedAnswer}"`);
410
+
411
+          return {
412
+            questionId: q.questionId,
413
+            userAnswer: convertedAnswer
414
+          };
415
+        });
416
+
417
+        console.log('提交的答案(转换后):', answers);
418
+
419
+        const response = await submitAnswer({
420
+          taskId: this.taskId,
421
+          answers: answers,
422
+          submitType: submitType
423
+        });
424
+
425
+        uni.showToast({
426
+          title: '提交成功',
427
+          icon: 'success'
428
+        });
429
+
430
+        setTimeout(() => {
431
+          // 跳转到结果页面
432
+          uni.redirectTo({
433
+            url: `/pages/daily-exam/answer/index?taskId=${this.taskId}&status=result`
434
+          });
435
+        }, 1500);
436
+      } catch (error) {
437
+        console.error('提交失败', error);
438
+        uni.showToast({
439
+          title: error.msg || '提交失败',
440
+          icon: 'none'
441
+        });
442
+      }
443
+    },
444
+
445
+    autoSubmit() {
446
+      uni.showToast({
447
+        title: '答题时间已到,自动提交',
448
+        icon: 'none'
449
+      });
450
+      setTimeout(() => {
451
+        this.submitAnswers(2); // 2: 自动超时提交
452
+      }, 1500);
453
+    },
454
+
455
+    backToList() {
456
+      uni.navigateBack();
457
+    }
458
+  }
459
+}
460
+</script>
461
+
462
+<style lang="scss" scoped>
463
+.daily-answer-container {
464
+  padding: 200rpx 0 40rpx;
465
+}
466
+
467
+.answer-mode {
468
+  .header {
469
+    display: flex;
470
+    justify-content: space-between;
471
+    align-items: center;
472
+    padding: 32rpx;
473
+    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
474
+    border-radius: 16rpx;
475
+    margin-bottom: 32rpx;
476
+
477
+    .header-info {
478
+      .title {
479
+        font-size: 36rpx;
480
+        font-weight: bold;
481
+        color: #FFFFFF;
482
+        margin-bottom: 8rpx;
483
+      }
484
+
485
+      .progress {
486
+        font-size: 24rpx;
487
+        color: rgba(255, 255, 255, 0.8);
488
+      }
489
+    }
490
+
491
+    .timer {
492
+      display: flex;
493
+      align-items: center;
494
+      padding: 16rpx 24rpx;
495
+      background: rgba(255, 255, 255, 0.2);
496
+      border-radius: 40rpx;
497
+
498
+      .timer-icon {
499
+        font-size: 28rpx;
500
+        margin-right: 8rpx;
501
+      }
502
+
503
+      .timer-text {
504
+        font-size: 28rpx;
505
+        font-weight: bold;
506
+        color: #FFFFFF;
507
+      }
508
+    }
509
+  }
510
+
511
+  .question-card {
512
+    padding: 40rpx;
513
+    background: #FFFFFF;
514
+    border-radius: 16rpx;
515
+    box-shadow: 0 4rpx 16rpx 0 rgba(0, 0, 0, 0.08);
516
+    margin-bottom: 32rpx;
517
+
518
+    .question-type {
519
+      display: inline-block;
520
+      padding: 8rpx 24rpx;
521
+      background: rgba(102, 126, 234, 0.1);
522
+      color: #667eea;
523
+      border-radius: 40rpx;
524
+      font-size: 24rpx;
525
+      margin-bottom: 24rpx;
526
+    }
527
+
528
+    .question-content {
529
+      font-size: 32rpx;
530
+      color: #333333;
531
+      line-height: 48rpx;
532
+      margin-bottom: 32rpx;
533
+    }
534
+
535
+    .options {
536
+      .option-item {
537
+        display: flex;
538
+        align-items: center;
539
+        padding: 24rpx;
540
+        background: #F5F5F5;
541
+        border-radius: 12rpx;
542
+        margin-bottom: 16rpx;
543
+        position: relative;
544
+        border: 2rpx solid transparent;
545
+
546
+        &.active {
547
+          background: rgba(102, 126, 234, 0.1);
548
+          border-color: #667eea;
549
+
550
+          .option-label {
551
+            background: #667eea;
552
+            color: #FFFFFF;
553
+          }
554
+        }
555
+
556
+        .option-label {
557
+          width: 56rpx;
558
+          height: 56rpx;
559
+          display: flex;
560
+          align-items: center;
561
+          justify-content: center;
562
+          background: #CCCCCC;
563
+          color: #FFFFFF;
564
+          border-radius: 50%;
565
+          font-size: 28rpx;
566
+          font-weight: bold;
567
+          margin-right: 16rpx;
568
+          flex-shrink: 0;
569
+        }
570
+
571
+        .option-content {
572
+          flex: 1;
573
+          font-size: 28rpx;
574
+          color: #333333;
575
+          line-height: 40rpx;
576
+        }
577
+
578
+        .option-check {
579
+          font-size: 32rpx;
580
+          color: #667eea;
581
+          margin-left: 16rpx;
582
+        }
583
+      }
584
+    }
585
+  }
586
+
587
+  .navigation {
588
+    display: flex;
589
+    gap: 24rpx;
590
+    margin-bottom: 32rpx;
591
+
592
+    .btn {
593
+      flex: 1;
594
+    }
595
+  }
596
+
597
+  .answer-card {
598
+    padding: 32rpx;
599
+    background: #FFFFFF;
600
+    border-radius: 16rpx;
601
+    box-shadow: 0 4rpx 16rpx 0 rgba(0, 0, 0, 0.08);
602
+
603
+    .answer-card-title {
604
+      font-size: 32rpx;
605
+      font-weight: bold;
606
+      color: #333333;
607
+      margin-bottom: 24rpx;
608
+    }
609
+
610
+    .answer-card-grid {
611
+      display: grid;
612
+      grid-template-columns: repeat(5, 1fr);
613
+      gap: 16rpx;
614
+
615
+      .answer-card-item {
616
+        aspect-ratio: 1;
617
+        display: flex;
618
+        align-items: center;
619
+        justify-content: center;
620
+        background: #F5F5F5;
621
+        border-radius: 8rpx;
622
+        font-size: 28rpx;
623
+        color: #999999;
624
+
625
+        &.answered {
626
+          background: #667eea;
627
+          color: #FFFFFF;
628
+        }
629
+
630
+        &.current {
631
+          border: 2rpx solid #667eea;
632
+        }
633
+      }
634
+    }
635
+  }
636
+}
637
+
638
+.result-mode {
639
+  .result-header {
640
+    padding: 40rpx;
641
+    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
642
+    border-radius: 16rpx;
643
+    margin-bottom: 32rpx;
644
+
645
+    .result-score {
646
+      text-align: center;
647
+      margin-bottom: 32rpx;
648
+
649
+      .score-value {
650
+        font-size: 96rpx;
651
+        font-weight: bold;
652
+        color: #FFFFFF;
653
+        line-height: 1;
654
+        margin-bottom: 16rpx;
655
+      }
656
+
657
+      .score-label {
658
+        font-size: 28rpx;
659
+        color: rgba(255, 255, 255, 0.8);
660
+      }
661
+    }
662
+
663
+    .result-stats {
664
+      display: flex;
665
+      justify-content: space-around;
666
+
667
+      .stat-item {
668
+        text-align: center;
669
+
670
+        .stat-value {
671
+          font-size: 48rpx;
672
+          font-weight: bold;
673
+          color: #FFFFFF;
674
+          margin-bottom: 8rpx;
675
+
676
+          &.correct {
677
+            color: #4CAF50;
678
+          }
679
+
680
+          &.wrong {
681
+            color: #FF5252;
682
+          }
683
+        }
684
+
685
+        .stat-label {
686
+          font-size: 24rpx;
687
+          color: rgba(255, 255, 255, 0.8);
688
+        }
689
+      }
690
+    }
691
+  }
692
+
693
+  .result-questions {
694
+    .result-question-item {
695
+      padding: 32rpx;
696
+      background: #FFFFFF;
697
+      border-radius: 16rpx;
698
+      box-shadow: 0 4rpx 16rpx 0 rgba(0, 0, 0, 0.08);
699
+      margin-bottom: 24rpx;
700
+
701
+      .result-question-header {
702
+        display: flex;
703
+        justify-content: space-between;
704
+        align-items: center;
705
+        margin-bottom: 16rpx;
706
+
707
+        .question-num {
708
+          font-size: 28rpx;
709
+          color: #666666;
710
+        }
711
+
712
+        .question-result {
713
+          font-size: 26rpx;
714
+          padding: 8rpx 20rpx;
715
+          border-radius: 40rpx;
716
+
717
+          &.correct {
718
+            background: rgba(76, 175, 80, 0.1);
719
+            color: #4CAF50;
720
+          }
721
+
722
+          &.wrong {
723
+            background: rgba(255, 82, 82, 0.1);
724
+            color: #FF5252;
725
+          }
726
+        }
727
+      }
728
+
729
+      .question-content {
730
+        font-size: 30rpx;
731
+        color: #333333;
732
+        line-height: 44rpx;
733
+        margin-bottom: 20rpx;
734
+      }
735
+
736
+      .question-answers {
737
+        .answer-row {
738
+          font-size: 28rpx;
739
+          line-height: 40rpx;
740
+          margin-bottom: 8rpx;
741
+
742
+          .answer-label {
743
+            color: #999999;
744
+          }
745
+
746
+          .answer-value {
747
+            font-weight: bold;
748
+
749
+            &.correct {
750
+              color: #4CAF50;
751
+            }
752
+
753
+            &.wrong {
754
+              color: #FF5252;
755
+            }
756
+          }
757
+        }
758
+      }
759
+    }
760
+  }
761
+
762
+  .result-footer {
763
+    margin-top: 32rpx;
764
+  }
765
+}
766
+
767
+.btn {
768
+  padding: 24rpx 0;
769
+  border-radius: 12rpx;
770
+  font-size: 32rpx;
771
+  text-align: center;
772
+  font-weight: bold;
773
+
774
+  &.btn-primary {
775
+    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
776
+    color: #FFFFFF;
777
+  }
778
+
779
+  &.btn-secondary {
780
+    background: #FFFFFF;
781
+    border: 2rpx solid #667eea;
782
+    color: #667eea;
783
+  }
784
+}
785
+</style>

+ 451 - 0
src/pages/daily-exam/task-list/index.vue

@@ -0,0 +1,451 @@
1
+<template>
2
+  <home-container :customStyle="{backgroundPosition:'0px -65px'}">
3
+    <div class="daily-task-container">
4
+      <!-- 顶部个人信息栏 -->
5
+      <view class="user-header" v-if="currentUser">
6
+        <view class="user-avatar">
7
+          <image v-if="currentUser.avatar" :src="currentUser.avatar" class="cu-avatar xl round"></image>
8
+        </view>
9
+        <view class="user-info">
10
+          <text class="user-name">{{ `${userInfoAndRoles.nickName} ${userInfoAndRoles.userName}` }}</text>
11
+          <text v-if="userInfo.userInfo.stationName" class="position-item">{{ userInfo.userInfo.stationName }}</text>
12
+          <text v-if="userInfo.userInfo.stationName && userInfo.userInfo.departmentName" class="separator">/</text>
13
+          <text v-if="userInfo.userInfo.departmentName" class="position-item">{{
14
+            userInfo.userInfo.departmentName}}</text>
15
+          <text v-if="userInfo.userInfo.departmentName && userInfo.userInfo.teamsName" class="separator">/</text>
16
+          <text v-if="userInfo.userInfo.teamsName" class="position-item">{{ userInfo.userInfo.teamsName }}</text>
17
+        </view>
18
+      </view>
19
+      <!--   <h-search v-model="keyword" placeholder="请输入题目名称" :showAction="true" actionText="搜索" 
20
+                @search="handleSearch" @custom="handleSearch" @clear="handleClear" />-->
21
+
22
+
23
+      <h-tabs v-model="currentTab" :tabs="tabList" value-key="type" label-key="title" @change="handleTabChange"
24
+        style="margin-top: 60rpx;" />
25
+
26
+      <div class="exam-list">
27
+        <div v-for="item in filteredTaskList" :key="item.dtId" class="exam-item">
28
+          <div class="exam-item-title">
29
+            {{ formatBusinessDate(item.dtBusinessDate) }}
30
+            <div :class="item.dtStatus" class="exam-item-status">
31
+              {{ getStatusText(item.dtStatus) }}
32
+            </div>
33
+          </div>
34
+          <div class="exam-item-desc">
35
+            <div class="exam-item-desc-item time">{{ item.dtTimeLimit / 60 }}分钟</div>
36
+          </div>
37
+          <div class="exam-item-btn">
38
+            <div class="exam-item-btn-score">
39
+              <span>{{ item.questionCount || 0 }}题</span>
40
+              <span v-if="item.dtStatus === 'COMPLETED'">
41
+                <i>{{ item.dtTotalScore }}</i>
42
+                /{{ item.fullScore }}分
43
+              </span>
44
+              <span v-if="item.dtStatus !== 'COMPLETED'">{{ item.fullScore }}分</span>
45
+            </div>
46
+            <span v-if="item.dtStatus === 'PENDING'" class="btn" @click="startTask(item)">开始答题</span>
47
+            <span v-if="item.dtStatus === 'IN_PROGRESS'" class="btn" @click="continueTask(item)">继续答题</span>
48
+            <span v-if="item.dtStatus === 'COMPLETED'" class="btn done" @click="viewResult(item)">查看结果</span>
49
+            <span v-if="item.dtStatus === 'EXPIRED'" class="btn timeout">已过期</span>
50
+          </div>
51
+        </div>
52
+
53
+        <div v-if="filteredTaskList.length === 0" class="empty-state">
54
+          <div class="empty-icon">📋</div>
55
+          <div class="empty-text">暂无答题任务</div>
56
+          <div class="empty-desc">完成考勤打卡后将自动生成任务</div>
57
+        </div>
58
+      </div>
59
+    </div>
60
+  </home-container>
61
+</template>
62
+
63
+<script>
64
+import HomeContainer from "@/components/HomeContainer.vue";
65
+import { getMyTasks, hasPendingTask, getAllTasks, getPendingTasks, getCompletedTasks, getExpiredTasks } from "@/api/exam/dailyExam";
66
+import { getInfo } from "@/api/login"
67
+
68
+export default {
69
+  components: { HomeContainer },
70
+  computed: {
71
+    currentUser() {
72
+      return this.$store.state.user;
73
+    },
74
+    userInfoAndRoles() {
75
+      return {
76
+        ...this.userInfo.userInfo,
77
+        roles: this.userInfo.roles
78
+      }
79
+    }
80
+  },
81
+  data() {
82
+    return {
83
+      taskList: [],
84
+      filteredTaskList: [],
85
+      keyword: '',
86
+      currentTab: 'all',
87
+      pageNum: 1,
88
+      pageSize: 20,
89
+      userInfo: {
90
+        userInfo: {},
91
+        roles: []
92
+      },
93
+      tabList: [
94
+        {
95
+          type: 'all',
96
+          title: '全部'
97
+        },
98
+        {
99
+          type: 'PENDING',
100
+          title: '待答题'
101
+        },
102
+        {
103
+          type: 'COMPLETED',
104
+          title: '已完成'
105
+        },
106
+        {
107
+          type: 'EXPIRED',
108
+          title: '已过期'
109
+        }
110
+      ],
111
+      statusMap: {
112
+        PENDING: '待答题',
113
+        IN_PROGRESS: '答题中',
114
+        COMPLETED: '已完成',
115
+        EXPIRED: '已过期'
116
+      }
117
+    }
118
+  },
119
+  async onLoad() {
120
+    const userInfo = await getInfo()
121
+    this.userInfo = userInfo || {}
122
+    this.loadTaskList();
123
+  },
124
+  onShow() {
125
+    // 每次显示页面时都重新加载任务列表,确保状态是最新的
126
+    this.loadTaskList();
127
+  },
128
+  methods: {
129
+    async loadTaskList() {
130
+      try {
131
+        let response;
132
+        const params = {
133
+          pageNum: this.pageNum,
134
+          pageSize: this.pageSize
135
+        };
136
+
137
+        // 根据当前tab选择不同的API接口
138
+        switch (this.currentTab) {
139
+          case 'all':
140
+            response = await getAllTasks(params);
141
+            break;
142
+          case 'PENDING':
143
+            response = await getPendingTasks(params);
144
+            break;
145
+          case 'COMPLETED':
146
+            response = await getCompletedTasks(params);
147
+            break;
148
+          case 'EXPIRED':
149
+            response = await getExpiredTasks(params);
150
+            break;
151
+          default:
152
+            response = await getAllTasks(params);
153
+        }
154
+
155
+        this.taskList = response.rows || [];
156
+        this.filterTaskList();
157
+      } catch (error) {
158
+        console.error('获取任务列表失败', error);
159
+        uni.showToast({
160
+          title: '获取任务列表失败',
161
+          icon: 'none'
162
+        });
163
+      }
164
+    },
165
+
166
+    handleSearch(val) {
167
+      if (!val) {
168
+        this.filterTaskList();
169
+        return;
170
+      }
171
+      // 本地模糊匹配
172
+      this.filteredTaskList = this.taskList.filter(item =>
173
+        this.formatBusinessDate(item.dtBusinessDate).includes(val)
174
+      );
175
+    },
176
+
177
+    handleClear() {
178
+      this.keyword = '';
179
+      this.filterTaskList();
180
+    },
181
+
182
+    handleTabChange(tabType) {
183
+      this.currentTab = tabType;
184
+      this.pageNum = 1; // 重置页码
185
+      this.loadTaskList(); // 重新请求数据
186
+    },
187
+
188
+    filterTaskList() {
189
+      if (this.currentTab === 'all') {
190
+        // 显示全部
191
+        this.filteredTaskList = [...this.taskList];
192
+      } else {
193
+        // 只显示当前状态
194
+        this.filteredTaskList = this.taskList.filter(item => item.dtStatus === this.currentTab);
195
+      }
196
+
197
+      // 如果有搜索关键词,进行过滤
198
+      if (this.keyword) {
199
+        this.filteredTaskList = this.filteredTaskList.filter(item =>
200
+          this.formatBusinessDate(item.dtBusinessDate).includes(this.keyword)
201
+        );
202
+      }
203
+    },
204
+
205
+    formatDay(dateStr) {
206
+      if (!dateStr) return '';
207
+      const date = new Date(dateStr);
208
+      return date.getDate();
209
+    },
210
+
211
+    formatMonth(dateStr) {
212
+      if (!dateStr) return '';
213
+      const date = new Date(dateStr);
214
+      const month = date.getMonth() + 1;
215
+      return `${month}月`;
216
+    },
217
+
218
+    formatBusinessDate(dateStr) {
219
+      if (!dateStr) return '';
220
+      const date = new Date(dateStr);
221
+      const year = date.getFullYear();
222
+      const month = date.getMonth() + 1;
223
+      const day = date.getDate();
224
+      return `${year}年${month}月${day}日`;
225
+    },
226
+
227
+    getStatusText(status) {
228
+      return this.statusMap[status] || status;
229
+    },
230
+
231
+    getStatusClass(status) {
232
+      const classMap = {
233
+        PENDING: 'pending',
234
+        IN_PROGRESS: 'in-progress',
235
+        COMPLETED: 'completed',
236
+        EXPIRED: 'expired'
237
+      };
238
+      return classMap[status] || '';
239
+    },
240
+
241
+    startTask(item) {
242
+      uni.navigateTo({
243
+        url: `/pages/daily-exam/answer/index?taskId=${item.dtId}&status=start`
244
+      });
245
+    },
246
+
247
+    continueTask(item) {
248
+      uni.navigateTo({
249
+        url: `/pages/daily-exam/answer/index?taskId=${item.dtId}&status=continue`
250
+      });
251
+    },
252
+
253
+    viewResult(item) {
254
+      uni.navigateTo({
255
+        url: `/pages/daily-exam/answer/index?taskId=${item.dtId}&status=result`
256
+      });
257
+    }
258
+  }
259
+}
260
+</script>
261
+
262
+<style lang="scss" scoped>
263
+.daily-task-container {
264
+  padding: 0;
265
+}
266
+
267
+/* 顶部个人信息 */
268
+.user-header {
269
+  display: flex;
270
+  align-items: center;
271
+  background-color: #fff;
272
+  padding: 15px;
273
+  margin-top: 50px;
274
+  margin-bottom: 20px;
275
+  height: 96px;
276
+  background: #FFFFFF;
277
+  box-shadow: 0px 4px 16px 0px rgba(0, 0, 0, 0.06);
278
+  border-radius: 16px 16px 16px 16px;
279
+
280
+  .user-avatar {
281
+    width: 50px;
282
+    height: 50px;
283
+    border-radius: 25px;
284
+    overflow: hidden;
285
+    margin-right: 12px;
286
+
287
+    image {
288
+      width: 100%;
289
+      height: 100%;
290
+    }
291
+  }
292
+
293
+  .user-info {
294
+    flex: 1;
295
+
296
+    .user-name {
297
+      font-size: 18px;
298
+      font-weight: bold;
299
+      color: #333;
300
+      display: block;
301
+      margin-bottom: 8px;
302
+    }
303
+
304
+    .position-item {
305
+      font-size: 14px;
306
+      color: #666;
307
+    }
308
+
309
+    .separator {
310
+      font-size: 14px;
311
+      color: #666;
312
+      margin: 0 4px;
313
+    }
314
+  }
315
+}
316
+
317
+
318
+
319
+.exam-list {
320
+  .exam-item {
321
+    display: flex;
322
+    flex-flow: column;
323
+    justify-content: space-between;
324
+    padding: 40rpx;
325
+    background: #FFFFFF;
326
+    box-shadow: 0 8rpx 20rpx 0 rgba(0, 0, 0, 0.08);
327
+    border-radius: 16rpx;
328
+    border: 2rpx solid #F0F8FF;
329
+    margin-top: 32rpx;
330
+  }
331
+
332
+  .exam-item-title {
333
+    display: flex;
334
+    justify-content: space-between;
335
+    font-size: 32rpx;
336
+    color: #333333;
337
+    line-height: 38rpx;
338
+
339
+    .exam-item-status {
340
+      background: rgba(42, 112, 209, 0.12);
341
+      border-radius: 80rpx;
342
+      padding: 8rpx 28rpx;
343
+      font-size: 24rpx;
344
+      color: #2A70D1;
345
+      line-height: 28rpx;
346
+
347
+      &.COMPLETED {
348
+        background: rgba(78, 166, 26, 0.12);
349
+        color: #4EA61A;
350
+      }
351
+
352
+      &.EXPIRED {
353
+        background: rgba(234, 73, 73, 0.12);
354
+        color: #EA4949;
355
+      }
356
+    }
357
+  }
358
+
359
+  .exam-item-desc {
360
+    display: flex;
361
+    gap: 48rpx;
362
+    margin: 40rpx 0 30rpx;
363
+
364
+    .exam-item-desc-item {
365
+      display: flex;
366
+      padding-left: 40rpx;
367
+      position: relative;
368
+
369
+      &:after {
370
+        content: "";
371
+        width: 32rpx;
372
+        height: 32rpx;
373
+        position: absolute;
374
+        left: 0;
375
+        top: 50%;
376
+        transform: translateY(-50%);
377
+        background: url("../../../static/images/clock-icon.png");
378
+        background-size: 32rpx;
379
+      }
380
+    }
381
+  }
382
+
383
+  .exam-item-btn {
384
+    display: flex;
385
+    justify-content: space-between;
386
+    font-size: 28rpx;
387
+    color: #666666;
388
+    line-height: 32rpx;
389
+    align-items: flex-end;
390
+
391
+    .exam-item-btn-score {
392
+      display: flex;
393
+      gap: 40rpx;
394
+      align-items: flex-end;
395
+
396
+      i {
397
+        font-style: normal;
398
+        font-size: 36rpx;
399
+        color: #4CA518;
400
+        line-height: 44rpx;
401
+      }
402
+    }
403
+
404
+    .btn {
405
+      padding: 10rpx 28rpx;
406
+      background: #2A70D1;
407
+      border-radius: 80rpx;
408
+      font-size: 24rpx;
409
+      color: #FFFFFF;
410
+      line-height: 28rpx;
411
+      border: 2px solid #FFFFFF;
412
+
413
+      &.done {
414
+        background: #FFFFFF;
415
+        border-color: #2A70D1;
416
+        color: #2A70D1;
417
+      }
418
+
419
+      &.timeout {
420
+        background: #FFFFFF;
421
+        border-color: #C4C4C5;
422
+        color: #999999;
423
+      }
424
+    }
425
+  }
426
+}
427
+
428
+.empty-state {
429
+  display: flex;
430
+  flex-direction: column;
431
+  align-items: center;
432
+  justify-content: center;
433
+  padding: 120rpx 0;
434
+
435
+  .empty-icon {
436
+    font-size: 120rpx;
437
+    margin-bottom: 32rpx;
438
+  }
439
+
440
+  .empty-text {
441
+    font-size: 32rpx;
442
+    color: #666666;
443
+    margin-bottom: 16rpx;
444
+  }
445
+
446
+  .empty-desc {
447
+    font-size: 26rpx;
448
+    color: #999999;
449
+  }
450
+}
451
+</style>

+ 100 - 0
src/pages/eikonStatistics/components/Attendance.vue

@@ -0,0 +1,100 @@
1
+<template>
2
+  <uni-collapse>
3
+    <eikon-collapse-item title="出勤投入" name="attendance" :borderColor="'#00A5BC'">
4
+      <div class="collapse-content">
5
+
6
+        <!-- isZhanZhang为true时显示统计表格 -->
7
+        <div v-if="isZhanZhang">
8
+          <statistic-table :columns="attendanceColumns" :data="stationAttendanceData" />
9
+        </div>
10
+
11
+        <!-- isPersonal为true时显示原有内容 -->
12
+        <div v-else-if="isPersonal">
13
+          <div class="content-item">
14
+            <div class="item-label">出勤天数:</div>
15
+            <div class="item-value">{{ attendanceData && attendanceData.workingDate ? attendanceData.workingDate : '-' }}</div>
16
+          </div>
17
+          <div class="content-item">
18
+            <div class="item-label">上岗时长:</div>
19
+            <div class="item-value">{{ attendanceData && attendanceData.workingHours ? attendanceData.workingHours : '-' }}H</div>
20
+          </div>
21
+        </div>
22
+
23
+
24
+        <div v-else>
25
+          <div class="content-item">
26
+            <div class="item-label">出勤人均天数:</div>
27
+            <div class="item-value">{{ attendanceData && attendanceData.workingDate ? attendanceData.workingDate.averagePersonnel || '-' : '-' }}</div>
28
+          </div>
29
+          <div class="content-item">
30
+            <div class="item-label">上岗人均时长:</div>
31
+            <div class="item-value">{{ attendanceData && attendanceData.workingHours ? attendanceData.workingHours.averagePersonnel || '-' : '-' }}小时</div>
32
+          </div>
33
+        </div>
34
+
35
+      </div>
36
+    </eikon-collapse-item>
37
+  </uni-collapse>
38
+</template>
39
+
40
+<script>
41
+import { mapState, mapGetters } from 'vuex'
42
+import EikonCollapseItem from "./eikon-collapse-item.vue";
43
+import StatisticTable from "@/components/statistic-table/statistic-table.vue";
44
+
45
+export default {
46
+  name: "Attendance",
47
+  components: {
48
+    EikonCollapseItem,
49
+    StatisticTable
50
+  },
51
+  computed: {
52
+    ...mapGetters('eikonLevel', ['isZhanZhang', 'isPersonal']),
53
+    ...mapState({
54
+      attendanceData: state => state.eikonLevel.attendanceData,
55
+      stationAttendanceData: state => state.eikonLevel.stationAttendanceData
56
+    }),
57
+  },
58
+  data() {
59
+    return {
60
+      // 出勤统计表格列配置
61
+      attendanceColumns: [
62
+        { props: 'dimension', title: '统计维度' },
63
+        { props: 'avgWorkingDays', title: '人均出勤天数' },
64
+        { props: 'avgWorkingHours', title: '人均上岗时长' }
65
+      ],
66
+
67
+    }
68
+  },
69
+
70
+}
71
+</script>
72
+
73
+<style lang="scss" scoped>
74
+.collapse-content {
75
+  padding: 20rpx;
76
+}
77
+
78
+.content-item {
79
+  display: flex;
80
+  justify-content: space-between;
81
+  align-items: center;
82
+  margin-bottom: 16rpx;
83
+
84
+  &:last-child {
85
+    margin-bottom: 0;
86
+  }
87
+}
88
+
89
+.item-label {
90
+  font-size: 26rpx;
91
+  color: #333333;
92
+  font-weight: 500;
93
+}
94
+
95
+.item-value {
96
+  font-size: 26rpx;
97
+  color: #999999;
98
+  font-weight: 500;
99
+}
100
+</style>

+ 134 - 0
src/pages/eikonStatistics/components/LearningGrowth.vue

@@ -0,0 +1,134 @@
1
+<template>
2
+  <uni-collapse>
3
+    <eikon-collapse-item title="学习成长" name="learningGrowth" :borderColor="'#E435F3'">
4
+      <div class="collapse-content">
5
+        <div class="learning-ranking-section">
6
+          <!-- <div class="ranking-header">
7
+            <SwitchTab :tabList="switchOptions" :defaultActive="learningSwitchValue"
8
+              @tabChange="handleLearningTabChange" />
9
+          </div> -->
10
+          <statistic-table :columns="learningTableColumns" :data="learningTableData" />
11
+        </div>
12
+      </div>
13
+    </eikon-collapse-item>
14
+  </uni-collapse>
15
+</template>
16
+
17
+<script>
18
+import EikonCollapseItem from "./eikon-collapse-item.vue";
19
+import SwitchTab from "./switch-tab.vue"
20
+import { mapState, mapGetters } from 'vuex'
21
+import StatisticTable from "@/components/statistic-table/statistic-table.vue";
22
+
23
+export default {
24
+  name: "LearningGrowth",
25
+  components: {
26
+    EikonCollapseItem,
27
+    SwitchTab,
28
+    StatisticTable
29
+  },
30
+  data() {
31
+    return {
32
+      // 学习成长相关数据
33
+      learningSwitchValue: 0,
34
+      learningTableData: []
35
+    }
36
+  },
37
+  computed: {
38
+    ...mapState({
39
+      growthPortraitData: state => state.eikonLevel.growthPortraitData,
40
+      currentLevel: state => state.eikonLevel.currentLevel,
41
+    }),
42
+    ...mapGetters('eikonLevel', ['isPersonal', 'isBanZuZhang']),
43
+    learningTableColumns() {
44
+      return [
45
+        ...(this.isPersonal ? [{ props: 'name', title: '姓名' }] : [{ props: 'name', title: '部门' }]),
46
+        { props: 'averageScore', title: '平均分' },
47
+        { props: 'highestScore', title: '最高分' },
48
+        { props: 'lowestScore', title: '最低分' }
49
+      ]
50
+    },
51
+    switchOptions() {
52
+      if (this.isPersonal) {
53
+        return [
54
+          { value: 0, label: '全站' },
55
+          { value: 1, label: '本科' },
56
+          { value: 2, label: '本班' }
57
+        ]
58
+      }
59
+      if (this.isBanZuZhang) {
60
+        return [
61
+          { value: 0, label: '全站' },
62
+          { value: 1, label: '本科' },
63
+
64
+        ]
65
+      }
66
+      return []
67
+    },
68
+  },
69
+  watch: {
70
+    growthPortraitData: {
71
+      handler(newVal, oldVal) {
72
+
73
+        this.handleData(newVal)
74
+      },
75
+    }
76
+  },
77
+  methods: {
78
+    handleData(newVal) {
79
+      // 检查newVal是否为null或undefined
80
+      if (!newVal) {
81
+        this.learningTableData = [];
82
+        return;
83
+      }
84
+      
85
+      let rank = '';
86
+      if (this.learningSwitchValue === 0) {
87
+        rank = `${newVal.stationRanking || '-'}/${newVal.stationTotal || '-'}`;
88
+      }
89
+      if (this.learningSwitchValue === 1) {
90
+        rank = `${newVal.departmentRanking || '-'}/${newVal.departmentTotal || '-'}`;
91
+      }
92
+      if (this.learningSwitchValue === 2) {
93
+        rank = `${newVal.teamRanking || '-'}/${newVal.teamTotal || '-'}`;
94
+      }
95
+      this.learningTableData = newVal
96
+      //  [
97
+      //   { dimension: '平均分', score: newVal.averageScore || '-', },
98
+      //   { dimension: '最高分', score: newVal.highestScore || '-', },
99
+      //   { dimension: '最低分', score: newVal.lowestScore || '-', },
100
+      // ...(this.currentLevel === 'station' ? [] : [{ dimension: '名次', score: rank || '-', }])
101
+      // ]
102
+    },
103
+    // 学习成长切换处理
104
+    handleLearningTabChange(event) {
105
+      console.log('学习成长切换:', event);
106
+      this.learningSwitchValue = event.value;
107
+      // 根据切换的值更新学习统计数据
108
+      this.handleData(this.growthPortraitData);
109
+    },
110
+
111
+
112
+  }
113
+}
114
+</script>
115
+
116
+<style lang="scss" scoped>
117
+.collapse-content {
118
+  padding: 20rpx;
119
+}
120
+
121
+/* 学习成长样式 */
122
+.learning-ranking-section .ranking-header {
123
+  display: flex;
124
+  justify-content: space-between;
125
+  align-items: center;
126
+  margin-bottom: 20rpx;
127
+}
128
+
129
+.learning-ranking-section .ranking-content {
130
+  background: #f8f9fa;
131
+  border-radius: 12rpx;
132
+  margin: 30rpx 0;
133
+}
134
+</style>

+ 171 - 0
src/pages/eikonStatistics/components/MemberDetails.vue

@@ -0,0 +1,171 @@
1
+<template>
2
+  <uni-collapse>
3
+    <eikon-collapse-item :title="title" name="memberDetails" :borderColor="'#2F54EB'">
4
+      <div class="collapse-content">
5
+        <statistic-table :columns="memberTableColumns" :data="memberTableData">
6
+          <template v-slot:column-name="{ row, index }">
7
+            <span class="clickable-name" @click="handleNameClick(row)">
8
+              {{ row.name }}
9
+            </span>
10
+          </template>
11
+        </statistic-table>
12
+      </div>
13
+    </eikon-collapse-item>
14
+  </uni-collapse>
15
+</template>
16
+
17
+<script>
18
+import EikonCollapseItem from "./eikon-collapse-item.vue";
19
+import StatisticTable from "@/components/statistic-table/statistic-table.vue";
20
+import { mapState, mapGetters, mapActions } from "vuex";
21
+
22
+import useDictMixin from "@/utils/dict";
23
+export default {
24
+  name: "MemberDetails",
25
+  components: { EikonCollapseItem, StatisticTable },
26
+  mixins: [useDictMixin],
27
+  data() {
28
+    return {
29
+      memberTableData: []
30
+    }
31
+  },
32
+  watch: {
33
+    detailData: {
34
+      handler(newVal, oldVal) {
35
+        // 检查newVal是否为null或undefined
36
+        if (!newVal) {
37
+          this.memberTableData = [];
38
+          return;
39
+        }
40
+        
41
+        if (this.isBanZuZhang) {
42
+          this.handlePersonalData(newVal)
43
+        } else {
44
+          this.memberTableData = newVal.map((item) => {
45
+            return {
46
+              ...item,
47
+              avgWorkingHours: `${item.avgWorkingHours}H`
48
+            }
49
+          })
50
+        }
51
+      },
52
+    }
53
+  },
54
+  computed: {
55
+    ...mapState({
56
+      currentLevel: state => state.eikonLevel.currentLevel,
57
+      detailData: state => state.eikonLevel.detailData,
58
+    }),
59
+    ...mapGetters('eikonLevel', ['isBanZuZhang', 'isKeZhang', 'isZhanZhang']),
60
+    title() {
61
+      if (this.currentLevel === 'team') {
62
+        return '成员明细'
63
+      }
64
+      if (this.currentLevel === 'department') {
65
+        return '班组明细'
66
+      }
67
+      if (this.currentLevel === 'station') {
68
+        return '科室明细'
69
+      }
70
+    },
71
+    memberTableColumns() {
72
+      if (this.currentLevel === 'team') {
73
+        return [
74
+          { props: 'name', title: '姓名', slot: true },
75
+          { props: 'qualificationLevel', title: '资质等级' },
76
+          { props: 'avgWorkingDays', title: '出勤天数' },
77
+          { props: 'avgWorkingHours', title: '上岗时长' },
78
+          { props: 'avgSeizureCount', title: '查获数量' },
79
+          { props: 'checkCount', title: '巡检问题数' },
80
+          { props: 'testScore', title: '测试平均分' },
81
+        ]
82
+      }
83
+      if (this.currentLevel === 'department') {
84
+        return [
85
+          { props: 'name', title: '班组', slot: true },
86
+          { props: 'qualificationLevel', title: '资质等级1级' },
87
+          { props: 'avgWorkingDays', title: '人均出勤天数' },
88
+          { props: 'avgWorkingHours', title: '人均上岗时长' },
89
+          { props: 'avgSeizureCount', title: '人均查获数量' },
90
+          { props: 'checkCount', title: '人均巡检问题数' },
91
+          { props: 'testScore', title: '测试平均分' },
92
+        ]
93
+      }
94
+      if (this.currentLevel === 'station') {
95
+        return [
96
+          { props: 'name', title: '科室', slot: true },
97
+          { props: 'qualificationLevel', title: '资质等级1级' },
98
+          { props: 'avgWorkingDays', title: '人均出勤天数' },
99
+          { props: 'avgWorkingHours', title: '人均上岗时长' },
100
+          { props: 'avgSeizureCount', title: '人均查获数量' },
101
+          { props: 'checkCount', title: '人均巡检问题数' },
102
+          { props: 'testScore', title: '测试平均分' },
103
+        ]
104
+      }
105
+    }
106
+  },
107
+  methods: {
108
+    ...mapActions('eikonLevel', ['changeLevel', 'loadData']),
109
+    // 处理性格特征圆形数据
110
+    async handlePersonalData(newVal) {
111
+      // 检查newVal是否为null或undefined
112
+      if (!newVal) {
113
+        this.memberTableData = [];
114
+        return;
115
+      }
116
+      
117
+      const res = [...newVal]
118
+
119
+      // 批量获取资质等级字典数据
120
+      const dict = await this.useDict('sys_user_qualification_level')
121
+      const qualificationDict = dict['sys_user_qualification_level'] || []
122
+
123
+      // 将qualificationLevel字段转换为文字显示
124
+      for (let i = 0; i < res.length; i++) {
125
+        const item = res[i];
126
+        if (item.qualificationLevel !== undefined && item.qualificationLevel !== null) {
127
+          const dictItem = qualificationDict.find(d => d.value === item.qualificationLevel.toString())
128
+          item.qualificationLevel = dictItem ? dictItem.label : item.qualificationLevel
129
+        }
130
+        item.avgWorkingHours = `${item.avgWorkingHours}H`
131
+      }
132
+
133
+      this.memberTableData = res
134
+    },
135
+
136
+
137
+
138
+    async handleNameClick(row) {
139
+      // 根据当前角色和级别决定点击行为
140
+      if (this.isBanZuZhang) {
141
+        this.changeLevel({ level: 'personal', levelId: row.id })
142
+      }
143
+      if (this.isKeZhang) {
144
+        this.changeLevel({ level: 'team', levelId: row.id })
145
+      }
146
+      if (this.isZhanZhang) {
147
+        this.changeLevel({ level: 'department', levelId: row.id })
148
+      }
149
+      await this.loadData()
150
+      this.$emit('nameClick', row)
151
+    }
152
+
153
+  }
154
+}
155
+</script>
156
+
157
+<style lang="scss" scoped>
158
+.collapse-content {
159
+  padding: 20rpx;
160
+}
161
+
162
+.clickable-name {
163
+  color: #1890ff;
164
+  cursor: pointer;
165
+
166
+
167
+  &:hover {
168
+    color: #40a9ff;
169
+  }
170
+}
171
+</style>

+ 262 - 0
src/pages/eikonStatistics/components/Qualification.vue

@@ -0,0 +1,262 @@
1
+<template>
2
+  <uni-collapse>
3
+    <eikon-collapse-item title="资质能力" name="qualification" :open="true" :borderColor="'#B68FF7'">
4
+      <div class="collapse-content">
5
+        <template v-if="isPersonal">
6
+          <div class="content-item">
7
+            <div class="item-label">资质等级:</div>
8
+          </div>
9
+          <div class="content-item">
10
+            <div class="item-value">{{ localSystemData.qualificationLevel }}</div>
11
+          </div>
12
+          <div class="content-item">
13
+            <div class="item-label">可上岗岗位:</div>
14
+          </div>
15
+          <div class="content-item">
16
+            <div class="item-value">{{ localSystemData.availablePositionsStr }}</div>
17
+          </div>
18
+          <div class="content-item">
19
+            <div class="item-label">政治面貌:</div>
20
+            <div class="item-value">{{ localSystemData.politicalStatus }}</div>
21
+          </div>
22
+          <div class="content-item">
23
+            <div class="item-label">学历:</div>
24
+            <div class="item-value">{{ localSystemData.education }}</div>
25
+          </div>
26
+          <div class="content-item">
27
+            <div class="item-label">年龄:</div>
28
+            <div class="item-value">{{ localSystemData.age }}岁</div>
29
+          </div>
30
+          <div class="content-item">
31
+            <div class="item-label">是否通过政审:</div>
32
+            <div class="item-value">{{ localSystemData.isPoliticalReviewPassed ? '是' : '否' }}</div>
33
+          </div>
34
+          <div class="content-item">
35
+            <div class="item-label">身体健康情况:</div>
36
+            <div class="item-value">{{ !localSystemData.isHealthy ? '健康' : localSystemData.isHealthy }}</div>
37
+          </div>
38
+          <div class="content-item">
39
+            <div class="item-label">有无行政处罚:</div>
40
+            <div class="item-value">{{ !localSystemData.hasAdministrativePenalty ? '无' : localSystemData.hasAdministrativePenalty }}</div>
41
+          </div>
42
+        </template>
43
+        <template v-else>
44
+          <div class="content-item">
45
+            <div class="item-label">资质等级:</div>
46
+          </div>
47
+          <div class="content-item">
48
+            <div class="item-value">{{ localSystemData.qualificationLevelSum }}</div>
49
+          </div>
50
+          <div class="content-item">
51
+            <div class="item-label">可上岗岗位数:</div>
52
+          </div>
53
+          <div class="content-item">
54
+            <div class="item-value">{{ localSystemData.availablePositionsSum }}</div>
55
+          </div>
56
+          <div class="content-item">
57
+            <div class="item-label">政治面貌:</div>
58
+            <div class="item-value">{{ localSystemData.politicalStatusSum }}</div>
59
+          </div>
60
+          <div class="content-item">
61
+            <div class="item-label">学历:</div>
62
+            <div class="item-value">{{ localSystemData.educationSum }}</div>
63
+          </div>
64
+          <div class="content-item">
65
+            <div class="item-label">平均年龄:</div>
66
+            <div class="item-value">{{ localSystemData.averageAge }}岁&nbsp;最高{{ localSystemData.maxAge }}岁&nbsp;最低{{
67
+              localSystemData.minAge }}岁</div>
68
+          </div>
69
+          <div class="content-item">
70
+            <div class="item-label">通过政审人数:</div>
71
+            <div class="item-value">{{ localSystemData.passedKoroughCountSum }}人</div>
72
+          </div>
73
+          <div class="content-item">
74
+            <div class="item-label">身体健康人数:</div>
75
+            <div class="item-value">{{ localSystemData.healthyCountSum }}人</div>
76
+          </div>
77
+          <div class="content-item">
78
+            <div class="item-label">行政处罚人数:</div>
79
+            <div class="item-value">{{ localSystemData.penalizedCountSum }}人</div>
80
+          </div>
81
+        </template>
82
+      </div>
83
+    </eikon-collapse-item>
84
+  </uni-collapse>
85
+</template>
86
+
87
+<script>
88
+import EikonCollapseItem from "./eikon-collapse-item.vue";
89
+import { mapState, mapGetters } from 'vuex'
90
+import useDictMixin from "@/utils/dict"
91
+import { getDictLabelByValue } from "@/utils/common"
92
+export default {
93
+  name: "Qualification",
94
+  components: {
95
+    EikonCollapseItem
96
+  },
97
+  mixins: [useDictMixin],
98
+  data() {
99
+    return {
100
+      localSystemData: {}
101
+    }
102
+  },
103
+  computed: {
104
+    ...mapState({
105
+      systemData: state => state.eikonLevel.systemData,
106
+    }),
107
+    ...mapGetters('eikonLevel', ['isPersonal'])
108
+  },
109
+  watch: {
110
+    systemData: {
111
+      handler(newVal, oldVal) {
112
+        if (this.isPersonal) {
113
+          this.handlePersonalData(newVal)
114
+        } else {
115
+          this.handleDeptData(newVal)
116
+        }
117
+      },
118
+    }
119
+  },
120
+  methods: {
121
+    async handleDeptData(newVal) {
122
+      // 检查newVal是否为null或undefined
123
+      if (!newVal) {
124
+        this.localSystemData = {};
125
+        return;
126
+      }
127
+      
128
+      const res = { ...newVal };
129
+      const { qualificationLevelStats, positionCompetencyStats, politicalStatusStats, educationStats, ageStats, politicalReviewStats, physicalHealthStats, administrativePenaltyStats } = newVal
130
+
131
+      // 检查qualificationLevelStats是否存在且是数组
132
+      if (!qualificationLevelStats || !Array.isArray(qualificationLevelStats)) {
133
+        return;
134
+      }
135
+
136
+      // 使用useDict获取资质等级字典
137
+      const dict = await this.useDict('sys_user_qualification_level')
138
+      const qualificationDict = dict['sys_user_qualification_level'] || []
139
+
140
+      // 按照字典顺序遍历,确保从一级开始显示
141
+      for (const dictItem of qualificationDict) {
142
+        const statItem = qualificationLevelStats.find(item => item.levelName === dictItem.value)
143
+        if (statItem) {
144
+          res.qualificationLevelSum = `${res.qualificationLevelSum ? res.qualificationLevelSum + '、' : ''}${dictItem.label} ${statItem.count}人`
145
+        }
146
+      }
147
+
148
+      let availablePositionsSum = []
149
+      for (const item of positionCompetencyStats) {
150
+        availablePositionsSum.push(`${item.postName} ${item.competentCount}人`)
151
+      }
152
+      res.availablePositionsSum = availablePositionsSum.join('、')
153
+
154
+      let politicalStatusArr = []
155
+      for (const item of politicalStatusStats) {
156
+        if (item.politicalStatus) {
157
+          let politicalStatus = await getDictLabelByValue('sys_user_political_status', item.politicalStatus)
158
+          politicalStatusArr.push({
159
+            politicalStatus,
160
+            totalCount: item.totalCount
161
+          })
162
+        }
163
+      }
164
+      let politicalStatusObj = politicalStatusArr.find(item => item.politicalStatus === "中共党员")
165
+      if (politicalStatusObj) {
166
+        res.politicalStatusSum = `${politicalStatusObj.politicalStatus} ${politicalStatusObj.totalCount}人`
167
+      } else {
168
+        res.politicalStatusSum = '-';
169
+      }
170
+
171
+      // 处理学历统计信息
172
+      if (educationStats) {
173
+        const educationParts = [];
174
+
175
+        // 按学历层次从高到低排序显示
176
+        if (educationStats.masterCount > 0) {
177
+          educationParts.push(`硕士 ${educationStats.masterCount}人`);
178
+        }
179
+        if (educationStats.bachelorCount > 0) {
180
+          educationParts.push(`本科 ${educationStats.bachelorCount}人`);
181
+        }
182
+        if (educationStats.collegeCount > 0) {
183
+          educationParts.push(`专科 ${educationStats.collegeCount}人`);
184
+        }
185
+        if (educationStats.highSchoolCount > 0) {
186
+          educationParts.push(`高中 ${educationStats.highSchoolCount}人`);
187
+        }
188
+        if (educationStats.middleSchoolCount > 0) {
189
+          educationParts.push(`初中 ${educationStats.middleSchoolCount}人`);
190
+        }
191
+        if (educationStats.primarySchoolCount > 0) {
192
+          educationParts.push(`小学 ${educationStats.primarySchoolCount}人`);
193
+        }
194
+
195
+        res.educationSum = educationParts.length > 0 ? educationParts.join('、') : '-';
196
+      } else {
197
+        res.educationSum = '-';
198
+      }
199
+      res.averageAge = ageStats.averageAge ? ageStats.averageAge : '-'
200
+      res.minAge = ageStats.minAge ? ageStats.minAge : '-'
201
+      res.maxAge = ageStats.maxAge ? ageStats.maxAge : '-'
202
+      res.passedKoroughCountSum = politicalReviewStats && politicalReviewStats.passedCount || 0;
203
+      res.healthyCountSum = physicalHealthStats && physicalHealthStats.healthyCount || 0;
204
+      res.penalizedCountSum = administrativePenaltyStats && administrativePenaltyStats.penalizedCount || 0;
205
+      this.localSystemData = res;
206
+    },
207
+    async handlePersonalData(newVal) {
208
+      // 检查newVal是否为null或undefined
209
+      if (!newVal) {
210
+        this.localSystemData = {};
211
+        return;
212
+      }
213
+      
214
+      // 创建新对象而不是直接修改传入的对象
215
+      const res = { ...newVal };
216
+      //资质等级
217
+      res.qualificationLevel = await getDictLabelByValue('sys_user_qualification_level', newVal
218
+        && newVal.qualificationLevel)
219
+      //政治面貌
220
+      res.politicalStatus = await getDictLabelByValue('sys_user_political_status', newVal
221
+        && newVal.politicalStatus)
222
+      //学历
223
+      res.education = await getDictLabelByValue('sys_user_schooling', newVal
224
+        && newVal.education)
225
+      res.availablePositionsStr = res.availablePositions && res.availablePositions.map(item => item.postName).join('、')
226
+      res.passedKoroughCount =
227
+        this.localSystemData = res;
228
+
229
+    }
230
+  }
231
+}
232
+</script>
233
+
234
+<style lang="scss" scoped>
235
+.collapse-content {
236
+  padding: 20rpx;
237
+}
238
+
239
+.content-item {
240
+  display: flex;
241
+  justify-content: space-between;
242
+  align-items: center;
243
+  margin-bottom: 16rpx;
244
+
245
+  &:last-child {
246
+    margin-bottom: 0;
247
+  }
248
+}
249
+
250
+.item-label {
251
+  font-size: 26rpx;
252
+  color: #333333;
253
+  font-weight: 500;
254
+  min-width: 120rpx;
255
+}
256
+
257
+.item-value {
258
+  font-size: 26rpx;
259
+  color: #999999;
260
+  font-weight: 500;
261
+}
262
+</style>

+ 352 - 0
src/pages/eikonStatistics/components/SubjectiveImpression.vue

@@ -0,0 +1,352 @@
1
+<template>
2
+  <uni-collapse>
3
+    <eikon-collapse-item title="主观印象" name="subjectiveImpression" :borderColor="'#FF997C'">
4
+      <div class="collapse-content">
5
+        <!-- 当isPersonal为false时显示图表 -->
6
+        <view v-show="!isPersonal" class="chart-container">
7
+          <view id="subjectiveChart" class="echarts-chart"></view>
8
+        </view>
9
+
10
+        <!-- 当isPersonal为true时显示四个关键词评价区块 -->
11
+        <view v-show="isPersonal" class="keyword-evaluation">
12
+          <!-- 自我关键词评价 -->
13
+          <div class="content-item" v-if="selfKeywords.length > 0">
14
+            <div class="item-label">自我关键词评价</div>
15
+          </div>
16
+          <div class="content-item">
17
+            <text-tag :tags="selfKeywords" :showClose="false" />
18
+          </div>
19
+
20
+          <!-- 同事关键词评价 -->
21
+          <div class="content-item" v-if="colleagueKeywords.length > 0">
22
+            <div class="item-label">同事关键词评价</div>
23
+          </div>
24
+          <div class="content-item">
25
+            <text-tag :tags="colleagueKeywords" :showClose="false" />
26
+          </div>
27
+
28
+
29
+          <!-- 上级关键词评价 -->
30
+          <div class="content-item" v-if="superiorKeywords.length > 0">
31
+            <div class="item-label">上级关键词评价</div>
32
+          </div>
33
+          <div class="content-item">
34
+            <text-tag :tags="superiorKeywords" :showClose="false" />
35
+          </div>
36
+
37
+
38
+          <!-- 下级关键词评价 -->
39
+          <div class="content-item" v-if="!userRoles.includes('SecurityCheck') && subordinateKeywords.length > 0">
40
+            <div class="item-label">下级关键词评价</div>
41
+          </div>
42
+          <div class="content-item">
43
+            <text-tag :tags="subordinateKeywords" :showClose="false" />
44
+          </div>
45
+
46
+        </view>
47
+      </div>
48
+    </eikon-collapse-item>
49
+  </uni-collapse>
50
+</template>
51
+
52
+<script>
53
+import EikonCollapseItem from "./eikon-collapse-item.vue";
54
+import * as echarts from 'echarts';
55
+import TextTag from "@/components/text-tag/text-tag.vue";
56
+import { mapGetters, mapState } from 'vuex'
57
+import useDictMixin from '@/utils/dict'
58
+export default {
59
+  name: "SubjectiveImpression",
60
+  components: {
61
+    EikonCollapseItem,
62
+    TextTag
63
+  },
64
+  mixins: [useDictMixin],
65
+  computed: {
66
+    ...mapState({
67
+      collaborationData: state => state.eikonLevel.collaborationData,
68
+      userRoles: state => state.user.roles || [],
69
+    }),
70
+    ...mapGetters('eikonLevel', ['isPersonal'])
71
+  },
72
+  data() {
73
+    return {
74
+      chartData: [
75
+        { name: '工作态度', value: 92 },
76
+        { name: '责任心', value: 95 },
77
+        { name: '创新意识', value: 88 },
78
+        { name: '团队协作', value: 90 },
79
+        { name: '沟通能力', value: 85 }
80
+      ],
81
+      chartInstance: null,
82
+      // 关键词评价数据
83
+      selfKeywords: [],
84
+      colleagueKeywords: [],
85
+      superiorKeywords: [],
86
+      subordinateKeywords: []
87
+    }
88
+  },
89
+  watch: {
90
+    collaborationData: {
91
+      handler(newVal, oldVal) {
92
+        
93
+        if (newVal) {
94
+          
95
+          if (this.isPersonal) {
96
+            this.handlePersonalData(newVal)
97
+          } else {
98
+            this.handleDeptData(newVal)
99
+          }
100
+        }
101
+      },
102
+    }
103
+  },
104
+
105
+  methods: {
106
+    handleDeptData(newVal) {
107
+      if (!newVal || !newVal.subjectiveImpressionList) {
108
+        return;
109
+      }
110
+
111
+
112
+      // 处理主观印象数据,转换为图表格式
113
+      const impressionList = newVal.subjectiveImpressionList;
114
+
115
+      // 提取所有子项数据,用于外层环形图
116
+      const allChildrenData = [];
117
+
118
+      // 提取主分类数据,用于内层环形图
119
+      const mainCategoryData = [];
120
+
121
+      impressionList.forEach(category => {
122
+        // 主分类数据(使用count作为值)
123
+        mainCategoryData.push({
124
+          value: category.count || 0,
125
+          name: category.name
126
+        });
127
+
128
+        // 子项数据
129
+        if (category.children && category.children.length > 0) {
130
+          category.children.forEach(child => {
131
+            allChildrenData.push({
132
+              value: child.count || 0,
133
+              name: child.name
134
+            });
135
+          });
136
+        }
137
+      });
138
+
139
+      // 更新图表数据
140
+      // this.$nextTick(() => {
141
+        this.updateChartData(mainCategoryData, allChildrenData);
142
+      // })
143
+    },
144
+
145
+    updateChartData(innerData, outerData) {
146
+      const chartDom = document.getElementById('subjectiveChart');
147
+      if (!chartDom) return;
148
+
149
+      try {
150
+        console.log(this.chartInstance,"this.chartInstance")
151
+        // 如果图表实例不存在,先初始化
152
+        if (!this.chartInstance) {
153
+          this.chartInstance = echarts.init(chartDom);
154
+
155
+          // 监听窗口变化,重新渲染图表
156
+          window.addEventListener('resize', () => {
157
+            if (this.chartInstance) {
158
+              this.chartInstance.resize();
159
+            }
160
+          });
161
+        }
162
+
163
+        // 清除图表之前的状态,确保不会残留缺省图
164
+        this.chartInstance.clear();
165
+
166
+        // 设置图表配置 - 嵌套环形图
167
+        const option = {
168
+          tooltip: {
169
+            trigger: 'item',
170
+            formatter: '{b}: {c}次 ({d}%)'
171
+          },
172
+          legend: {
173
+            show: false
174
+          },
175
+          series: [
176
+            {
177
+              type: 'pie',
178
+              selectedMode: 'single',
179
+              radius: [0, '50%'],
180
+              label: {
181
+                position: 'inner',
182
+                fontSize: 14,
183
+                show: true,
184
+                formatter: function (params) {
185
+                  // 对四个字的中文分类名称进行折行处理
186
+                  const name = params.name;
187
+                  if (name && name.length === 4) {
188
+                    return name.substring(0, 2) + '\n' + name.substring(2, 4);
189
+                  }
190
+                  return name;
191
+                }
192
+              },
193
+              labelLine: {
194
+                show: false
195
+              },
196
+              itemStyle: {
197
+                borderWidth: 2,
198
+                borderColor: '#fff'
199
+              },
200
+              data: innerData
201
+            },
202
+            {
203
+              type: 'pie',
204
+              radius: ['55%', '75%'],
205
+              label: {
206
+                position: 'outer',
207
+                show: true,
208
+                formatter: '{b}: {c}'
209
+              },
210
+              labelLine: {
211
+                show: true,
212
+                length: 10,
213
+                length2: 10
214
+              },
215
+              itemStyle: {
216
+                borderWidth: 2,
217
+                borderColor: '#fff'
218
+              },
219
+              data: outerData
220
+            }
221
+          ]
222
+        }
223
+
224
+        this.chartInstance.setOption(option, true); // 使用true参数确保不合并配置
225
+      } catch (error) {
226
+        console.error('更新主观印象图表失败:', error);
227
+      }
228
+    },
229
+    async handlePersonalData(newVal) {
230
+      // 安全地处理newVal可能为null的情况
231
+      const safeNewVal = newVal || {};
232
+
233
+      const dict = await this.useDict(
234
+        'sys_user_personality_trait',
235
+        'sys_user_capability_performance',
236
+        'sys_user_interpersonal_interaction',
237
+        'sys_user_growth_potential',
238
+      )
239
+      let dictArr = Object.values(dict).reduce((prev, cur) => prev.concat(cur), []);
240
+      let tagTextStyle = { marginRight: '0', fontSize: '21rpx' }
241
+      // 处理自我关键词评价
242
+      let self = ['selfAssessmentCapabilityPerformance', 'selfAssessmentGrowthPotential', 'selfAssessmentInterpersonalInteraction', 'selfAssessmentPersonalityTrait']
243
+      this.selfKeywords = self.map(item => {
244
+        const dictItem = dictArr.find(d => d.value === safeNewVal[item]);
245
+        return {
246
+          label: dictItem ? dictItem.label : '',
247
+          tagItemStyle: { backgroundColor: '#0D995B1A', color: '#48A838', borderRadius: '0' },
248
+          tagTextStyle
249
+        }
250
+      }).filter(item => item.label);
251
+
252
+      // 处理同事关键词评价
253
+      let colleague = ['colleagueCommentsCapabilityPerformance', 'colleagueCommentsGrowthPotential', 'colleagueCommentsInterpersonalInteraction', 'colleagueCommentsPersonalityTrait']
254
+      this.colleagueKeywords = colleague.map(item => {
255
+        const dictItem = dictArr.find(d => d.value === safeNewVal[item]);
256
+        return {
257
+          label: dictItem ? dictItem.label : '',
258
+          tagItemStyle: { backgroundColor: '#F6A9661A', color: '#ED8D1A', borderRadius: '0' },
259
+          tagTextStyle
260
+        }
261
+      }).filter(item => item.label);
262
+
263
+      // 处理上级关键词评价
264
+      let superior = ['superiorEvaluationCapabilityPerformance', 'superiorEvaluationGrowthPotential', 'superiorEvaluationInterpersonalInteraction', 'superiorEvaluationPersonalityTrait']
265
+      this.superiorKeywords = superior.map(item => {
266
+        const dictItem = dictArr.find(d => d.value === safeNewVal[item]);
267
+        return {
268
+          label: dictItem ? dictItem.label : '',
269
+          tagItemStyle: { backgroundColor: '#02A6BD1A', color: '#00A5BC', borderRadius: '0' },
270
+          tagTextStyle
271
+        }
272
+      }).filter(item => item.label);
273
+
274
+      // 处理下级关键词评价
275
+      let subordinate = ['subordinateEvaluationCapabilityPerformance', 'subordinateEvaluationGrowthPotential', 'subordinateEvaluationInterpersonalInteraction', 'subordinateEvaluationPersonalityTrait']
276
+      this.subordinateKeywords = subordinate.map(item => {
277
+        const dictItem = dictArr.find(d => d.value === safeNewVal[item]);
278
+        return {
279
+          label: dictItem ? dictItem.label : '',
280
+          tagItemStyle: { backgroundColor: '#2E81FF1A', color: '#2C80FF', borderRadius: '0' },
281
+          tagTextStyle
282
+        }
283
+      }).filter(item => item.label);
284
+
285
+    },
286
+
287
+  },
288
+  beforeDestroy() {
289
+    if (this.chartInstance) {
290
+      this.chartInstance.dispose();
291
+    }
292
+  }
293
+}
294
+</script>
295
+
296
+<style lang="scss" scoped>
297
+.collapse-content {
298
+  padding: 20rpx 30rpx;
299
+}
300
+
301
+.chart-container {
302
+  height: 400rpx;
303
+  padding: 16rpx 0;
304
+
305
+  .echarts-chart {
306
+    width: 100%;
307
+    height: 100%;
308
+  }
309
+}
310
+
311
+.content-item {
312
+  display: flex;
313
+  justify-content: space-between;
314
+  align-items: center;
315
+  margin-bottom: 16rpx;
316
+
317
+  &:last-child {
318
+    margin-bottom: 0;
319
+  }
320
+}
321
+
322
+.item-label {
323
+  font-size: 26rpx;
324
+  color: #333333;
325
+  font-weight: 500;
326
+}
327
+
328
+.item-value {
329
+  font-size: 26rpx;
330
+  color: #999999;
331
+  font-weight: 500;
332
+}
333
+
334
+/* 关键词评价区块样式 */
335
+.keyword-evaluation {}
336
+
337
+.evaluation-section {
338
+  background: #f8f9fa;
339
+  border-radius: 16rpx;
340
+  padding: 24rpx;
341
+  box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
342
+}
343
+
344
+.section-title {
345
+  font-size: 28rpx;
346
+  font-weight: 600;
347
+  color: #333333;
348
+  margin-bottom: 16rpx;
349
+  padding-bottom: 8rpx;
350
+  border-bottom: 2rpx solid #e8e8e8;
351
+}
352
+</style>

+ 96 - 0
src/pages/eikonStatistics/components/WorkExperience.vue

@@ -0,0 +1,96 @@
1
+<template>
2
+  <uni-collapse>
3
+    <eikon-collapse-item title="工作履历" name="workExperience" :borderColor="'#ED8D1A'">
4
+      <div class="collapse-content" v-if="!isPersonal">
5
+        <div class="content-item">
6
+          <div class="item-label">平均工作年限:</div>
7
+          <div class="item-value">{{ systemData && systemData.workYearsStats ? systemData.workYearsStats.averageWorkYears || 0 : 0 }}</div>
8
+        </div>
9
+        <div class="content-item">
10
+          <div class="item-label">平均安检工作年限:</div>
11
+          <div class="item-value">{{ systemData && systemData.securityWorkYearsStats ? systemData.securityWorkYearsStats.averageWorkYears || 0 : 0 }}</div>
12
+        </div>
13
+        <div class="content-item">
14
+          <div class="item-label">曾担任过班组长人数:</div>
15
+          <div class="item-value">{{ systemData && systemData.securityWorkPositionStats ? systemData.securityWorkPositionStats.teamLeaderCount || 0 : 0 }}</div>
16
+        </div>
17
+        <div class="content-item">
18
+          <div class="item-label">工作奖励次数:</div>
19
+          <div class="item-value">{{ systemData && systemData.workRewardsStats ? systemData.workRewardsStats.totalPersonTimes || 0 : 0 }}</div>
20
+        </div>
21
+        <div class="content-item">
22
+          <div class="item-label">工作处罚次数:</div>
23
+          <div class="item-value">{{ systemData && systemData.workPenaltiesStats ? systemData.workPenaltiesStats.totalPersonTimes || 0 : 0 }}</div>
24
+        </div>
25
+      </div>
26
+      <div class="collapse-content" v-else>
27
+        <div class="content-item">
28
+          <div class="item-label">工作年限:</div>
29
+          <div class="item-value">{{ systemData ? systemData.workYears || 0 : 0 }}</div>
30
+        </div>
31
+        <div class="content-item">
32
+          <div class="item-label">安检工作年限:</div>
33
+          <div class="item-value">{{ systemData ? systemData.securityWorkYears || 0 : 0 }}</div>
34
+        </div>
35
+        <div class="content-item">
36
+          <div class="item-label">曾任安检最高职务:</div>
37
+          <div class="item-value">{{ systemData ? systemData.securityWorkPosition || '' : '' }}</div>
38
+        </div>
39
+        <div class="content-item">
40
+          <div class="item-label">工作奖励次数:</div>
41
+          <div class="item-value">{{ systemData ? systemData.workRewards || 0 : 0 }}</div>
42
+        </div>
43
+        <div class="content-item">
44
+          <div class="item-label">工作处罚次数:</div>
45
+          <div class="item-value">{{ systemData ? systemData.workPenalties || 0 : 0 }}</div>
46
+        </div>
47
+      </div>
48
+    </eikon-collapse-item>
49
+  </uni-collapse>
50
+</template>
51
+
52
+<script>
53
+import EikonCollapseItem from "./eikon-collapse-item.vue";
54
+import { mapState } from 'vuex'
55
+export default {
56
+  name: "WorkExperience",
57
+  components: {
58
+    EikonCollapseItem
59
+  },
60
+  computed: {
61
+    ...mapState({
62
+      systemData: state => state.eikonLevel.systemData,
63
+      isPersonal: state => state.eikonLevel.isPersonal
64
+    })
65
+  }
66
+}
67
+</script>
68
+
69
+<style lang="scss" scoped>
70
+.collapse-content {
71
+  padding: 20rpx;
72
+}
73
+
74
+.content-item {
75
+  display: flex;
76
+  justify-content: space-between;
77
+  align-items: center;
78
+  margin-bottom: 16rpx;
79
+
80
+  &:last-child {
81
+    margin-bottom: 0;
82
+  }
83
+}
84
+
85
+.item-label {
86
+  font-size: 26rpx;
87
+  color: #333333;
88
+  font-weight: 500;
89
+}
90
+
91
+.item-value {
92
+  font-size: 26rpx;
93
+  color: #999999;
94
+  font-weight: 500;
95
+}
96
+</style>

+ 246 - 0
src/pages/eikonStatistics/components/WorkOutput.vue

@@ -0,0 +1,246 @@
1
+<template>
2
+  <uni-collapse>
3
+    <eikon-collapse-item title="工作产出" name="workOutput" :borderColor="'#F14C4C'">
4
+      <div class="collapse-content">
5
+        <!-- isZhanZhang为false时显示原有内容 -->
6
+        <div v-if="!isZhanZhang">
7
+          <!-- 查获排名 -->
8
+          <div class="seize-ranking-section">
9
+            <div class="ranking-header">
10
+              <div class="ranking-title">查获排名</div>
11
+              <SwitchTab :tabList="switchOptions" :defaultActive="switchValue" @tabChange="handleTabChange" />
12
+            </div>
13
+
14
+            <div class="ranking-content">
15
+              <h-rank-line :percentage="seizeRankingPercentage">
16
+                <text class="progress-text">
17
+                  <text class="progress-rank">第{{ extraData.rank || 0 }}名</text>
18
+                  /
19
+                  <text class="progress-count">{{extraData.rankCount || 0 }}</text>
20
+                </text>
21
+              </h-rank-line>
22
+            </div>
23
+          </div>
24
+          <div class="content-item">
25
+            <div class="item-label">有效查获数量:</div>
26
+            <div class="item-value">{{ isPersonal ? (itemData ? itemData.effectiveSeizureCount || 0 : 0) :
27
+              (itemData && itemData.effectiveSeizureCount ? itemData.effectiveSeizureCount.totalCount || 0 : 0) }}</div>
28
+          </div>
29
+          <div class="content-item">
30
+            <div class="item-label">移交公安查获数量:</div>
31
+            <div class="item-value">{{ isPersonal ? (itemData ? itemData.referToPoliceCount || 0 : 0) :
32
+              (itemData && itemData.referToPoliceCount ? itemData.referToPoliceCount.count || 0 : 0) }}</div>
33
+          </div>
34
+          <div class="content-item">
35
+            <div class="item-label">故意隐匿数量:</div>
36
+            <div class="item-value">{{ isPersonal ? (itemData ? itemData.willfulConcealmentCount || 0 : 0) :
37
+              (itemData && itemData.willfulConcealmentCount ? itemData.willfulConcealmentCount.count || 0 : 0) }}</div>
38
+          </div>
39
+        </div>
40
+
41
+        <!-- isZhanZhang为true时显示统计表格 -->
42
+        <div v-else>
43
+          <statistic-table :columns="workOutputColumns" :data="stationSiteData" />
44
+        </div>
45
+      </div>
46
+    </eikon-collapse-item>
47
+  </uni-collapse>
48
+</template>
49
+
50
+<script>
51
+import EikonCollapseItem from "./eikon-collapse-item.vue";
52
+import SwitchTab from "./switch-tab.vue"
53
+import HRankLine from "@/components/h-rank-line/h-rank-line.vue";
54
+import StatisticTable from "@/components/statistic-table/statistic-table.vue";
55
+import { mapGetters, mapState } from 'vuex'
56
+import { getRankInfo } from '@/api/eikonStatistics/eikonStatistics'
57
+export default {
58
+  name: "WorkOutput",
59
+  components: {
60
+    EikonCollapseItem,
61
+    SwitchTab,
62
+    HRankLine,
63
+  },
64
+  computed: {
65
+    ...mapState({
66
+      userInfo: state => state.user.userInfo,
67
+      itemData: state => state.eikonLevel.itemData,
68
+      stationSiteData: state => state.eikonLevel.stationSiteData,
69
+      currentLevel: state => state.eikonLevel.currentLevel,
70
+      currentLevelId: state => state.eikonLevel.currentLevelId,
71
+    }),
72
+    ...mapGetters('eikonLevel', ['isZhanZhang', 'isPersonal', 'isBanZuZhang']),
73
+    switchOptions() {
74
+      if (this.isPersonal) {
75
+        return [
76
+          { value: 'station', label: '全站' },
77
+          { value: 'department', label: '本科' },
78
+          { value: 'team', label: '本班' }
79
+        ]
80
+      }
81
+      if (this.isBanZuZhang) {
82
+        return [
83
+          { value: 'station', label: '全站' },
84
+          { value: 'department', label: '本科' },
85
+        ]
86
+      }
87
+      return []
88
+    },
89
+  },
90
+  data() {
91
+    return {
92
+      // switch-tab配置
93
+      switchValue: 'station',
94
+      // 查获排名百分比
95
+      seizeRankingPercentage: 0,
96
+      // 工作产出表格列配置
97
+      workOutputColumns: [
98
+        {
99
+          title: '统计维度',
100
+          props: 'deptName',
101
+          align: 'left'
102
+        },
103
+        {
104
+          title: '有效查获总数',
105
+          props: 'effectiveSeizureCount',
106
+          align: 'center'
107
+        },
108
+        {
109
+          title: '移交公安总数',
110
+          props: 'policeTotal',
111
+          align: 'center'
112
+        },
113
+        {
114
+          title: '故意隐匿总数',
115
+          props: 'concealTotal',
116
+          align: 'center'
117
+        }
118
+      ],
119
+
120
+      extraData: {}
121
+    }
122
+  },
123
+  mounted() {
124
+    this.loadSeizeRankingData(this.switchValue);
125
+  },
126
+  watch: {
127
+    currentLevel(newVal, oldVal) {
128
+      if (newVal !== oldVal) {
129
+        this.loadSeizeRankingData(this.switchValue);
130
+      }
131
+    }
132
+  },
133
+  methods: {
134
+    // switch-tab切换处理
135
+    handleTabChange(event) {
136
+      this.switchValue = event.value;
137
+      // 根据切换的值重新加载查获排名数据
138
+      this.loadSeizeRankingData(event.value);
139
+    },
140
+    // 加载查获排名数据
141
+    loadSeizeRankingData(range) {
142
+      let params = { level: range }
143
+      if (this.isPersonal) {
144
+        params.userId = this.currentLevelId
145
+      } else {
146
+        params.deptId = this.currentLevelId
147
+      }
148
+      getRankInfo(params).then(res => {
149
+        this.extraData = res.data || {};
150
+        // console.log(this.extraData, "this.extraData")
151
+        this.seizeRankingPercentage = this.extraData.rankCount > 0 ? Number((this.extraData.rank / this.extraData.rankCount * 100).toFixed(2)) : 0;
152
+
153
+      })
154
+    }
155
+  }
156
+}
157
+</script>
158
+
159
+<style lang="scss" scoped>
160
+.collapse-content {
161
+  padding: 20rpx;
162
+}
163
+
164
+.content-item {
165
+  display: flex;
166
+  justify-content: space-between;
167
+  align-items: center;
168
+  margin-bottom: 16rpx;
169
+
170
+  &:last-child {
171
+    margin-bottom: 0;
172
+  }
173
+}
174
+
175
+.item-label {
176
+  font-size: 26rpx;
177
+  color: #333333;
178
+  font-weight: 500;
179
+}
180
+
181
+.item-value {
182
+  font-size: 26rpx;
183
+  color: #999999;
184
+  font-weight: 500;
185
+}
186
+
187
+/* 查获排名样式 */
188
+.seize-ranking-section {}
189
+
190
+.ranking-header {
191
+  display: flex;
192
+  justify-content: space-between;
193
+  align-items: center;
194
+  margin-bottom: 20rpx;
195
+}
196
+
197
+.ranking-content {
198
+  margin: 33rpx 0;
199
+}
200
+
201
+.ranking-title {
202
+  font-size: 26rpx;
203
+  color: #333333;
204
+  font-weight: 500;
205
+}
206
+
207
+.progress-text {
208
+  font-size: 26rpx;
209
+  color: #999999;
210
+  line-height: 1.5;
211
+}
212
+
213
+.progress-rank {
214
+  font-size: 26rpx;
215
+  font-weight: 500;
216
+  color: #333333;
217
+  line-height: 1.5;
218
+}
219
+.progress-count {
220
+  font-size: 26rpx;
221
+  font-weight: 500;
222
+  color: #999999;
223
+  line-height: 1.5;
224
+}
225
+
226
+
227
+
228
+// .ranking-content {
229
+//   background: #f8f9fa;
230
+//   border-radius: 12rpx;
231
+//   padding: 20rpx;
232
+// }
233
+
234
+// /* 查获排名进度条样式 */
235
+// .ranking-progress {
236
+//   margin-top: 20rpx;
237
+//   padding: 20rpx;
238
+//   background: #ffffff;
239
+//   border-radius: 12rpx;
240
+
241
+//   .progress-text {
242
+//     font-size: 28rpx;
243
+//     color: #333;
244
+//     font-weight: 500;
245
+//   }
246
+// }</style>

+ 123 - 0
src/pages/eikonStatistics/components/box.vue

@@ -0,0 +1,123 @@
1
+<template>
2
+  <view class="box-container">
3
+    <view 
4
+      v-for="(item, index) in data" 
5
+      :key="index" 
6
+      class="box-item"
7
+      :style="{ 
8
+        'grid-column': `span ${item.span || 1}`,
9
+        'background-color': item.bgColor || '#fff'
10
+      }"
11
+    >
12
+      <!-- 第一个内容区域 -->
13
+      <view class="box-content top">
14
+        <slot name="top" :item="item" :index="index">
15
+          <text class="content-text">{{ item.top || '顶部内容' }}</text>
16
+        </slot>
17
+      </view>
18
+      
19
+      <!-- 第二个内容区域 -->
20
+      <view class="box-content middle">
21
+        <slot name="middle" :item="item" :index="index">
22
+          <text class="content-text">{{ item.middle || '中间内容' }}</text>
23
+        </slot>
24
+      </view>
25
+      
26
+      <!-- 第三个内容区域 -->
27
+      <view class="box-content bottom">
28
+        <slot name="bottom" :item="item" :index="index">
29
+          <text class="content-text">{{ item.bottom || '底部内容' }}</text>
30
+        </slot>
31
+      </view>
32
+    </view>
33
+  </view>
34
+</template>
35
+
36
+<script>
37
+export default {
38
+  name: 'BoxGrid',
39
+  props: {
40
+    // 数据数组
41
+    data: {
42
+      type: Array,
43
+      default: () => []
44
+    },
45
+    // 每行显示的列数
46
+    columns: {
47
+      type: Number,
48
+      default: 3
49
+    },
50
+    // 间距
51
+    gap: {
52
+      type: String,
53
+      default: '20rpx'
54
+    },
55
+    // 圆角大小
56
+    borderRadius: {
57
+      type: String,
58
+      default: '16rpx'
59
+    },
60
+    // 内边距
61
+    padding: {
62
+      type: String,
63
+      default: '24rpx'
64
+    }
65
+  },
66
+  computed: {
67
+    // 动态计算grid布局
68
+    gridStyle() {
69
+      return {
70
+        'grid-template-columns': `repeat(${this.columns}, 1fr)`,
71
+        'gap': this.gap
72
+      }
73
+    }
74
+  }
75
+}
76
+</script>
77
+
78
+<style scoped>
79
+.box-container {
80
+  display: grid;
81
+  width: 100%;
82
+}
83
+
84
+.box-item {
85
+  border-radius: v-bind(borderRadius);
86
+  padding: v-bind(padding);
87
+  box-shadow: 0 4rpx 12rpx 0 rgba(0, 0, 0, 0.08);
88
+  border: 1rpx solid #f0f0f0;
89
+  display: flex;
90
+  flex-direction: column;
91
+  justify-content: space-between;
92
+  min-height: 200rpx;
93
+}
94
+
95
+.box-content {
96
+  display: flex;
97
+  align-items: center;
98
+  justify-content: center;
99
+  flex: 1;
100
+}
101
+
102
+.box-content.top {
103
+  font-size: 28rpx;
104
+  font-weight: 600;
105
+  color: #333;
106
+}
107
+
108
+.box-content.middle {
109
+  font-size: 32rpx;
110
+  font-weight: bold;
111
+  color: #1890ff;
112
+}
113
+
114
+.box-content.bottom {
115
+  font-size: 24rpx;
116
+  color: #666;
117
+}
118
+
119
+.content-text {
120
+  text-align: center;
121
+  word-break: break-all;
122
+}
123
+</style>

+ 977 - 0
src/pages/eikonStatistics/components/collaboration.vue

@@ -0,0 +1,977 @@
1
+<template>
2
+  <uni-collapse>
3
+    <eikon-collapse-item title="协同配合" name="collaboration" :borderColor="'#2C80FF'">
4
+      <div class="collapse-content">
5
+        <!-- isPersonal为false时显示原有内容 -->
6
+        <div v-if="!isPersonal">
7
+          <!-- 团队配合 -->
8
+          <div class="content-item" v-if="teamData && teamData.length > 0">
9
+            <div class="item-label">团队配合</div>
10
+          </div>
11
+
12
+
13
+          <statistic-table v-if="teamData && teamData.length > 0" :columns="teamColumns" :data="teamData" />
14
+
15
+
16
+          <!-- 性格特征 -->
17
+          <div class="content-item" style="margin-top: 30rpx;">
18
+            <div class="item-label">性格特征</div>
19
+          </div>
20
+
21
+          <!-- 圆形布局 -->
22
+          <div class="scatter-chart">
23
+            <canvas ref="characterCanvas" class="character-canvas"></canvas>
24
+          </div>
25
+
26
+          <!-- 工作风格 -->
27
+          <div class="content-item">
28
+            <div class="item-label">工作风格</div>
29
+          </div>
30
+          <!-- 饼图 -->
31
+          <div class="pie-chart">
32
+            <div ref="workStylePieChart" class="chart-container"></div>
33
+          </div>
34
+
35
+
36
+          <!-- 性别分布 - 左右布局 -->
37
+          <div class="content-item" style="margin-bottom: 0;">
38
+            <div class="item-label">性别</div>
39
+            <div class="item-value"> <h-legend :legend-data="genderData" :colorSize="20" :textSize="24" /></div>
40
+          </div>
41
+          <div class="content-item" style="margin-bottom: 30rpx;">
42
+            <h-line :data="genderData" :total="genderData.reduce((acc, cur) => acc + cur.value, 0)" :itemWidth="300"
43
+              :lineHeight="'40'" />
44
+          </div>
45
+
46
+          <!-- <div class="content-item">
47
+            <div class="item-label">生肖</div>
48
+          </div> -->
49
+
50
+          <!-- 生肖统计表格 -->
51
+          <!-- <statistic-table :columns="zodiacColumns" :data="zodiacData" /> -->
52
+
53
+
54
+
55
+          <!-- 血型分布 - 左右布局 -->
56
+          <!-- <div class="content-item" style="margin-bottom: 0;margin-top: 30rpx;">
57
+            <div class="item-label">血型</div>
58
+            <div class="item-value"> <h-legend :legend-data="bloodTypeData" :colorSize="20" :textSize="24" />
59
+            </div>
60
+          </div> -->
61
+
62
+          <!-- 
63
+          <div class="content-item" style="margin-bottom: 30rpx;">
64
+            <h-line :data="bloodTypeData" :total="bloodTypeData.reduce((acc, cur) => acc + cur.value, 0)"
65
+              :itemWidth="300" :lineHeight="'40'" />
66
+          </div> -->
67
+
68
+          <!-- <div class="content-item">
69
+            <div class="item-label">星座</div>
70
+          </div> -->
71
+
72
+
73
+          <!-- 星座统计表格 -->
74
+          <!-- <statistic-table :columns="constellationColumns" :data="constellationData" /> -->
75
+
76
+
77
+
78
+
79
+
80
+
81
+        </div>
82
+
83
+        <!-- isPersonal为true时显示左右布局的六个部分 -->
84
+        <div v-else class="personal-layout">
85
+          <!-- 团队配合 -->
86
+          <div class="content-item">
87
+            <div class="item-label">团队配合</div>
88
+            <div class="item-value">{{(localCollaborationData.teamCooperationList
89
+              && localCollaborationData.teamCooperationList.length > 0) &&
90
+              localCollaborationData.teamCooperationList.map(item => item.nickName).join('、')}}</div>
91
+          </div>
92
+
93
+          <!-- 性格特征 -->
94
+          <div class="content-item">
95
+            <div class="item-label">性格特征</div>
96
+            <div class="item-value">{{ localCollaborationData.characterCharacteristics }}</div>
97
+          </div>
98
+          <!-- 工作风格 -->
99
+          <div class="content-item">
100
+            <div class="item-label">工作风格</div>
101
+            <div class="item-value">{{ localCollaborationData.workingStyle }}</div>
102
+          </div>
103
+          <!-- 性别 -->
104
+          <div class="content-item">
105
+            <div class="item-label">性别</div>
106
+            <div class="item-value">{{ localCollaborationData.sex }}</div>
107
+          </div>
108
+
109
+
110
+
111
+          <!-- 生肖 -->
112
+          <!-- <div class="content-item">
113
+            <div class="item-label">生肖</div>
114
+            <div class="item-value">{{ localCollaborationData.zodiac }}</div>
115
+          </div> -->
116
+
117
+          <!-- 星座 -->
118
+          <!-- <div class="content-item">
119
+            <div class="item-label">星座</div>
120
+            <div class="item-value">{{ localCollaborationData.constellation }}</div>
121
+          </div> -->
122
+
123
+
124
+
125
+
126
+
127
+        </div>
128
+      </div>
129
+    </eikon-collapse-item>
130
+  </uni-collapse>
131
+</template>
132
+
133
+<script>
134
+import EikonCollapseItem from "./eikon-collapse-item.vue";
135
+import HLegend from "@/components/h-legend/h-legend.vue";
136
+import HLine from "@/components/h-line/h-line.vue";
137
+import StatisticTable from "@/components/statistic-table/statistic-table.vue";
138
+import * as echarts from 'echarts';
139
+import { mapGetters, mapState } from 'vuex'
140
+import { getDictLabelByValue } from "@/utils/common"
141
+import useDictMixin from '@/utils/dict'
142
+export default {
143
+  name: "Collaboration",
144
+  components: {
145
+    EikonCollapseItem,
146
+    HLegend,
147
+    HLine,
148
+    StatisticTable
149
+  },
150
+  mixins: [useDictMixin],
151
+  computed: {
152
+    ...mapState('eikonLevel', ['collaborationData']),
153
+    ...mapGetters('eikonLevel', ['isPersonal'])
154
+  },
155
+  data() {
156
+    return {
157
+      localCollaborationData: {},
158
+      // 性别分布数据
159
+      genderData: [],
160
+      bloodTypeData: [],
161
+      // 生肖统计表格数据
162
+      zodiacColumns: [
163
+        { props: 'zodiac', title: '生肖' },
164
+        { props: 'count', title: '人数' }
165
+      ],
166
+      zodiacData: [],
167
+      // 星座统计表格数据
168
+      constellationColumns: [
169
+        { props: 'constellation', title: '星座' },
170
+        { props: 'count', title: '人数' }
171
+      ],
172
+      constellationData: [],
173
+      // 团队配合统计表格数据
174
+      teamColumns: [
175
+        { props: 'name', title: '姓名' },
176
+        { props: 'collaborator', title: '最佳配合人员' },
177
+      ],
178
+      teamData: [],
179
+      // 性格特征圆形数据
180
+      characterCircles: []
181
+    }
182
+  },
183
+
184
+  watch: {
185
+    collaborationData(newVal, oldVal) {
186
+      if (newVal) {
187
+
188
+        if (this.isPersonal) {
189
+          this.handlePersonalData(newVal)
190
+        } else {
191
+          this.handleDeptData(newVal)
192
+        }
193
+      }
194
+    }
195
+  },
196
+  mounted() {
197
+    // 添加窗口大小变化监听器,用于重绘canvas
198
+    this.handleResize = () => {
199
+      if (this.characterCircles && this.characterCircles.length > 0) {
200
+        this.drawCharacterCircles(this.characterCircles);
201
+      }
202
+    };
203
+
204
+    window.addEventListener('resize', this.handleResize);
205
+  },
206
+  beforeDestroy() {
207
+    // 移除监听器
208
+    if (this.handleResize) {
209
+      window.removeEventListener('resize', this.handleResize);
210
+    }
211
+  },
212
+  methods: {
213
+    async handleDeptData(newVal) {
214
+      const { teamCooperationList, sexList, constellationList, blooGroupList, zodiacList, characterCharacteristicsList, workingStyleList } = newVal;
215
+
216
+      // 获取生肖字典
217
+      const dict = await this.useDict('sys_user_zodiac');
218
+      const zodiacDict = dict['sys_user_zodiac'] || [];
219
+
220
+      this.genderData = sexList.map(item => ({
221
+        name: item.name,
222
+        value: item.count,
223
+        color: item.name === '男' ? '#7AA3F5' : '#B68FF7'
224
+      }))
225
+      this.constellationData = constellationList.map(item => ({
226
+        constellation: item.name,
227
+        count: item.count,
228
+      }))
229
+      this.bloodTypeData = blooGroupList.map(item => ({
230
+        name: item.name,
231
+        value: item.count,
232
+        color: item.name === 'A型' ? '#B68FF7' : item.name === 'B型' ? '#FFB30F' : item.name === 'O型' ? '#7AA3F5' : '#FA5238'
233
+      }))
234
+      this.initWorkStylePieChart(workingStyleList.map(item => ({
235
+        value: item.count,
236
+        name: item.name
237
+      })));
238
+
239
+      // 处理性格特征圆形数据
240
+      this.handleCharacterCircles(characterCharacteristicsList || []);
241
+
242
+      // 处理生肖数据,将name转换为中文
243
+      if (zodiacList && Array.isArray(zodiacList)) {
244
+        this.zodiacData = zodiacList.map(item => {
245
+          // 在字典中查找对应的中文名称
246
+          const dictItem = zodiacDict.find(d => d.value === item.name);
247
+          return {
248
+            zodiac: dictItem ? dictItem.label : item.name,
249
+            count: item.count
250
+          };
251
+        });
252
+      } else {
253
+        this.zodiacData = [];
254
+      }
255
+      // 添加空值检查
256
+      if (!teamCooperationList || !Array.isArray(teamCooperationList)) {
257
+        this.teamData = [];
258
+        return;
259
+      }
260
+
261
+      this.teamData = teamCooperationList.map(item => {
262
+        // 检查item和teamCooperationList是否存在
263
+        if (!item || !item.teamCooperationList || !Array.isArray(item.teamCooperationList)) {
264
+          return {
265
+            name: item?.nickName || '未知',
266
+            collaborator: '无'
267
+          };
268
+        }
269
+
270
+        return {
271
+          name: item.nickName || '未知',
272
+          collaborator: item.teamCooperationList.map(team => team.nickName).join(',') || '无'
273
+        };
274
+      });
275
+
276
+      console.log(this.teamData, "this.teamData");
277
+    },
278
+    async handlePersonalData(newVal) {
279
+      const res = { ...newVal };
280
+
281
+      res.sex = await getDictLabelByValue('sys_user_sex', newVal && newVal.sex)
282
+      res.zodiac = await getDictLabelByValue('sys_user_zodiac', newVal && newVal.zodiac)
283
+      res.constellation = await getDictLabelByValue('sys_user_constellation', newVal && newVal.constellation)
284
+      res.characterCharacteristics = await getDictLabelByValue('sys_user_character_characteristics', newVal && newVal.characterCharacteristics)
285
+      res.workingStyle = await getDictLabelByValue('sys_user_working_style', newVal && newVal.workingStyle)
286
+
287
+      this.localCollaborationData = res
288
+    },
289
+
290
+    // 处理性格特征圆形数据 - 使用canvas绘制
291
+    handleCharacterCircles(characterCharacteristicsList) {
292
+      if (!characterCharacteristicsList || characterCharacteristicsList.length === 0) {
293
+        this.characterCircles = [];
294
+        this.drawCharacterCircles([]);
295
+        return;
296
+      }
297
+
298
+      // 计算圆形布局
299
+      const circles = this.generateCircleLayout(characterCharacteristicsList);
300
+      this.characterCircles = circles;
301
+
302
+      // 使用canvas绘制圆形
303
+      this.$nextTick(() => {
304
+        this.drawCharacterCircles(circles);
305
+      });
306
+    },
307
+
308
+    // 生成圆形布局算法 - 使用力导向布局确保不重叠
309
+    generateCircleLayout(data) {
310
+      if (!data || data.length === 0) return [];
311
+
312
+      // 获取容器尺寸
313
+      const container = document.querySelector('.scatter-chart');
314
+      if (!container) return [];
315
+
316
+      const containerWidth = container.clientWidth || 400;
317
+      const containerHeight = container.clientHeight || 300;
318
+      const centerX = containerWidth / 2;
319
+      const centerY = containerHeight / 2;
320
+      const maxRadius = Math.min(containerWidth, containerHeight) / 2.5; // 增加可用空间
321
+
322
+      // 计算总人数
323
+      const totalCount = data.reduce((sum, item) => sum + (item.count || 0), 0);
324
+      if (totalCount === 0) return [];
325
+
326
+      // 按人数排序
327
+      const sortedData = [...data].sort((a, b) => (b.count || 0) - (a.count || 0));
328
+
329
+      const circles = [];
330
+      const goldenAngle = Math.PI * (3 - Math.sqrt(5)); // 黄金角度
331
+
332
+      // 初始化圆形位置(使用黄金角度分布)
333
+      for (let i = 0; i < sortedData.length; i++) {
334
+        const item = sortedData[i];
335
+        const count = item.count || 0;
336
+
337
+        // 计算半径(基于人数比例)- 增加最小半径确保文字完全展示
338
+        const radiusRatio = count / totalCount;
339
+        const baseRadius = Math.max(35, Math.min(80, 35 + (radiusRatio * 50)));
340
+        const circleRadius = baseRadius;
341
+
342
+        // 根据性格特征类型生成颜色
343
+        const color = this.generateCharacterColor(item.code);
344
+
345
+        // 计算初始位置(使用黄金角度分布)
346
+        let angle = i * goldenAngle;
347
+        let distance = Math.min(0.8, 0.15 + i * 0.1) * maxRadius;
348
+        let x = centerX + distance * Math.cos(angle);
349
+        let y = centerY + distance * Math.sin(angle);
350
+
351
+        // 确保初始位置在边界内
352
+        x = Math.max(circleRadius + 5, Math.min(containerWidth - circleRadius - 5, x));
353
+        y = Math.max(circleRadius + 5, Math.min(containerHeight - circleRadius - 5, y));
354
+
355
+        circles.push({
356
+          name: item.name || '未知',
357
+          count: count,
358
+          code: item.code || '', // 添加code属性
359
+          x: x,
360
+          y: y,
361
+          radius: circleRadius,
362
+          vx: 0, // x方向速度
363
+          vy: 0,  // y方向速度
364
+          color: color // 添加颜色属性
365
+        });
366
+      }
367
+
368
+      // 力导向布局迭代(500次最大迭代)
369
+      const maxIterations = 500;
370
+      const extraSpacing = 20; // 额外间距增加到20px
371
+      const repulsionForce = 30; // 排斥力大小
372
+      const boundaryForce = 10; // 边界排斥力
373
+      const damping = 0.95; // 阻尼因子,避免震荡
374
+
375
+      for (let iteration = 0; iteration < maxIterations; iteration++) {
376
+        let hasOverlap = false;
377
+
378
+        // 计算每个圆形之间的排斥力
379
+        for (let i = 0; i < circles.length; i++) {
380
+          const circleA = circles[i];
381
+          circleA.vx = 0;
382
+          circleA.vy = 0;
383
+
384
+          // 与其他圆形的排斥力
385
+          for (let j = 0; j < circles.length; j++) {
386
+            if (i === j) continue;
387
+
388
+            const circleB = circles[j];
389
+            const dx = circleA.x - circleB.x;
390
+            const dy = circleA.y - circleB.y;
391
+            const distance = Math.sqrt(dx * dx + dy * dy);
392
+            const minDistance = circleA.radius + circleB.radius + extraSpacing;
393
+
394
+            if (distance < minDistance) {
395
+              hasOverlap = true;
396
+              // 计算排斥力(与距离成反比)
397
+              const force = repulsionForce * (minDistance - distance) / distance;
398
+              circleA.vx += force * dx / distance;
399
+              circleA.vy += force * dy / distance;
400
+            }
401
+          }
402
+
403
+          // 边界排斥力
404
+          const leftBoundary = circleA.radius + 5;
405
+          const rightBoundary = containerWidth - circleA.radius - 5;
406
+          const topBoundary = circleA.radius + 5;
407
+          const bottomBoundary = containerHeight - circleA.radius - 5;
408
+
409
+          if (circleA.x < leftBoundary) {
410
+            circleA.vx += boundaryForce * (leftBoundary - circleA.x);
411
+          } else if (circleA.x > rightBoundary) {
412
+            circleA.vx += boundaryForce * (rightBoundary - circleA.x);
413
+          }
414
+
415
+          if (circleA.y < topBoundary) {
416
+            circleA.vy += boundaryForce * (topBoundary - circleA.y);
417
+          } else if (circleA.y > bottomBoundary) {
418
+            circleA.vy += boundaryForce * (bottomBoundary - circleA.y);
419
+          }
420
+        }
421
+
422
+        // 应用速度和位置更新
423
+        for (let i = 0; i < circles.length; i++) {
424
+          const circle = circles[i];
425
+
426
+          // 应用阻尼
427
+          circle.vx *= damping;
428
+          circle.vy *= damping;
429
+
430
+          // 更新位置
431
+          circle.x += circle.vx;
432
+          circle.y += circle.vy;
433
+
434
+          // 确保位置在边界内
435
+          circle.x = Math.max(circle.radius + 5, Math.min(containerWidth - circle.radius - 5, circle.x));
436
+          circle.y = Math.max(circle.radius + 5, Math.min(containerHeight - circle.radius - 5, circle.y));
437
+        }
438
+
439
+        // 如果没有重叠,提前结束迭代
440
+        if (!hasOverlap && iteration > 100) {
441
+          break;
442
+        }
443
+      }
444
+
445
+      // 最终检查并处理任何剩余的重叠(使用网格布局作为备用)
446
+      const gridSize = Math.ceil(Math.sqrt(circles.length));
447
+      const cellWidth = containerWidth / (gridSize + 1);
448
+      const cellHeight = containerHeight / (gridSize + 1);
449
+
450
+      for (let i = 0; i < circles.length; i++) {
451
+        const circle = circles[i];
452
+
453
+        // 检查是否还有重叠
454
+        let hasOverlap = false;
455
+        for (let j = 0; j < circles.length; j++) {
456
+          if (i === j) continue;
457
+
458
+          const otherCircle = circles[j];
459
+          const dx = circle.x - otherCircle.x;
460
+          const dy = circle.y - otherCircle.y;
461
+          const distance = Math.sqrt(dx * dx + dy * dy);
462
+
463
+          if (distance < circle.radius + otherCircle.radius + 5) {
464
+            hasOverlap = true;
465
+            break;
466
+          }
467
+        }
468
+
469
+        // 如果仍有重叠,使用网格布局
470
+        if (hasOverlap) {
471
+          const row = Math.floor(i / gridSize);
472
+          const col = i % gridSize;
473
+          circle.x = (col + 1) * cellWidth;
474
+          circle.y = (row + 1) * cellHeight;
475
+        }
476
+
477
+        // 计算字体大小 - 使用固定字体大小,不随圆形大小变化
478
+        const fontSize = 14;
479
+
480
+        // 转换为最终格式
481
+        circles[i] = {
482
+          name: circle.name,
483
+          count: circle.count,
484
+          code: circle.code, // 保留code属性
485
+          left: (circle.x / containerWidth) * 100,
486
+          top: (circle.y / containerHeight) * 100,
487
+          size: circle.radius * 2,
488
+          fontSize: fontSize,
489
+          color: circle.color // 保留颜色属性
490
+        };
491
+      }
492
+
493
+      return circles;
494
+    },
495
+
496
+    // 创建canvas元素的方法
497
+    createCanvasElement() {
498
+      // 创建新的canvas元素
499
+      const newCanvas = document.createElement('canvas');
500
+      newCanvas.className = 'character-canvas';
501
+      newCanvas.style.width = '100%';
502
+      newCanvas.style.height = '100%';
503
+
504
+      // 查找容器
505
+      const container = document.querySelector('.scatter-chart');
506
+      if (!container) {
507
+        console.warn('无法找到canvas容器');
508
+        return null;
509
+      }
510
+
511
+      // 清空容器并添加新的canvas
512
+      container.innerHTML = '';
513
+      container.appendChild(newCanvas);
514
+
515
+      // 更新ref引用
516
+      this.$refs.characterCanvas = newCanvas;
517
+
518
+      // 检查新的canvas是否支持getContext
519
+      if (newCanvas.getContext && typeof newCanvas.getContext === 'function') {
520
+        console.log('新的canvas元素创建成功,支持getContext方法');
521
+        return newCanvas;
522
+      } else {
523
+        console.warn('新的canvas元素仍然不支持getContext方法');
524
+        return null;
525
+      }
526
+    },
527
+
528
+    initWorkStylePieChart(data) {
529
+      const chartDom = this.$refs.workStylePieChart;
530
+      if (!chartDom) return;
531
+
532
+      const chart = echarts.init(chartDom);
533
+      const option = {
534
+
535
+        tooltip: {
536
+          trigger: 'item',
537
+          formatter: '{a} <br/>{b}: {c} ({d}%)'
538
+        },
539
+        series: [{
540
+          name: '工作风格',
541
+          type: 'pie',
542
+          radius: '40%',
543
+          avoidLabelOverlap: false,
544
+          label: {
545
+            show: true,
546
+            formatter: function (params) {
547
+              // 支持折行显示,将名称和百分比分开显示
548
+              return '{b|' + params.name + '}\n{d|' + params.percent + '%}';
549
+            },
550
+            rich: {
551
+              b: {
552
+                fontSize: 12,
553
+                lineHeight: 16,
554
+                align: 'center'
555
+              },
556
+              d: {
557
+                fontSize: 14,
558
+                fontWeight: 'bold',
559
+                color: '#333',
560
+                lineHeight: 16,
561
+                align: 'center'
562
+              }
563
+            }
564
+          },
565
+          emphasis: {
566
+            label: {
567
+              show: true,
568
+              fontSize: 14,
569
+              fontWeight: 'bold'
570
+            }
571
+          },
572
+          labelLine: {
573
+            show: true
574
+          },
575
+          data: data
576
+        }]
577
+      };
578
+
579
+      chart.setOption(option);
580
+
581
+      // 响应式调整
582
+      window.addEventListener('resize', () => {
583
+        chart.resize();
584
+      });
585
+    },
586
+
587
+    // 根据性格特征类型生成颜色
588
+    generateCharacterColor(code) {
589
+      // 外向型(E)使用橙色#FF8000,内向型(I)使用蓝色#4371FF
590
+      if (code && code.startsWith('E')) {
591
+        return '#FF8000'; // 外向型 - 橙色
592
+      } else if (code && code.startsWith('I')) {
593
+        return '#4371FF'; // 内向型 - 蓝色
594
+      } else {
595
+        // 默认颜色(如果code为空或不匹配)
596
+        return '#999999'; // 灰色
597
+      }
598
+    },
599
+
600
+    // 使用canvas绘制性格特征圆形
601
+    drawCharacterCircles(circles) {
602
+      // 检查数据是否有效
603
+      if (!circles || circles.length === 0) {
604
+        console.warn('圆形数据为空,清空画布', { circlesLength: circles ? circles.length : 0 });
605
+
606
+        // 清空画布
607
+        this.clearCharacterCanvas();
608
+        return;
609
+      }
610
+
611
+      // 确保canvas元素存在且是有效的DOM元素
612
+      let canvas = this.$refs.characterCanvas;
613
+
614
+      // 如果通过ref获取失败,尝试通过选择器获取(兼容移动端环境)
615
+      if (!canvas || typeof canvas !== 'object') {
616
+        canvas = document.querySelector('.character-canvas');
617
+      }
618
+
619
+      // 检查canvas元素是否有效
620
+      if (!canvas || typeof canvas !== 'object') {
621
+        console.warn('Canvas元素不存在或类型不正确,尝试创建新的canvas元素', {
622
+          canvasExists: !!canvas,
623
+          canvasType: typeof canvas
624
+        });
625
+
626
+        // 创建新的canvas元素
627
+        canvas = this.createCanvasElement();
628
+        if (!canvas) {
629
+          console.warn('无法创建canvas元素,跳过绘制');
630
+          return;
631
+        }
632
+      }
633
+
634
+      // 检查canvas是否支持2D上下文(移动端兼容性处理)
635
+      if (!canvas.getContext || typeof canvas.getContext !== 'function') {
636
+        console.warn('Canvas不支持getContext方法,尝试创建新的canvas元素', {
637
+          hasGetContext: !!canvas.getContext,
638
+          getContextType: typeof canvas.getContext
639
+        });
640
+
641
+        // 创建新的canvas元素
642
+        canvas = this.createCanvasElement();
643
+        if (!canvas) {
644
+          console.warn('无法创建canvas元素,跳过绘制');
645
+          return;
646
+        }
647
+      }
648
+
649
+      try {
650
+        // 获取canvas上下文
651
+        const ctx = canvas.getContext('2d');
652
+
653
+        // 设置canvas尺寸,确保圆形是正圆
654
+        const container = canvas.parentElement;
655
+        if (container) {
656
+          // 获取容器尺寸
657
+          const containerWidth = container.clientWidth;
658
+          const containerHeight = container.clientHeight;
659
+
660
+          // 设置设备像素比,提高canvas分辨率以解决文字模糊问题
661
+          const devicePixelRatio = window.devicePixelRatio || 1;
662
+
663
+          // 设置canvas的实际尺寸(考虑设备像素比)
664
+          canvas.width = containerWidth * devicePixelRatio;
665
+          canvas.height = containerHeight * devicePixelRatio;
666
+
667
+          // 设置canvas的CSS尺寸为容器尺寸
668
+          canvas.style.width = containerWidth + 'px';
669
+          canvas.style.height = containerHeight + 'px';
670
+
671
+          // 缩放上下文以匹配设备像素比
672
+          ctx.scale(devicePixelRatio, devicePixelRatio);
673
+        }
674
+
675
+        const width = container ? container.clientWidth : canvas.width;
676
+        const height = container ? container.clientHeight : canvas.height;
677
+
678
+        // 清除画布
679
+        ctx.clearRect(0, 0, width, height);
680
+
681
+        // 设置字体渲染质量
682
+        ctx.imageSmoothingEnabled = true;
683
+        ctx.imageSmoothingQuality = 'high';
684
+
685
+        // 绘制每个圆形
686
+        circles.forEach(circle => {
687
+          // 计算canvas中的位置(将百分比转换为像素)
688
+          const x = (circle.left / 100) * width;
689
+          const y = (circle.top / 100) * height;
690
+          const radius = circle.size / 2;
691
+
692
+          // 绘制圆形背景
693
+          ctx.beginPath();
694
+          ctx.arc(x, y, radius, 0, Math.PI * 2);
695
+
696
+          // 使用圆形对象中的颜色属性
697
+          ctx.fillStyle = circle.color + 'B8'; // B8表示72%透明度
698
+          ctx.fill();
699
+
700
+          // 添加边框
701
+          ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)';
702
+          ctx.lineWidth = 2;
703
+          ctx.stroke();
704
+
705
+          // 设置文字样式
706
+          ctx.fillStyle = 'white';
707
+          ctx.textAlign = 'center';
708
+          ctx.textBaseline = 'middle';
709
+
710
+          // 优化字体渲染质量
711
+          ctx.fontKerning = 'normal';
712
+          ctx.textRendering = 'geometricPrecision';
713
+
714
+          // 分三行显示:第一行显示前四个字符,第二行显示剩余字符,第三行显示人数
715
+          const namePart1 = circle.name.substring(0, 4); // 前四个字符
716
+          const namePart2 = circle.name.substring(4);   // 剩余字符
717
+          const countText = `${circle.count}人`;
718
+
719
+          // 设置字体大小 - 根据圆形大小自适应(放大字号)
720
+          const baseFontSize = Math.min(circle.fontSize, radius * 0.7);
721
+          const nameFontSize = baseFontSize;
722
+          const countFontSize = baseFontSize * 0.8;
723
+
724
+          // 设置字体
725
+          ctx.font = `bold ${nameFontSize}px -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif`;
726
+
727
+          // 测量各段文字宽度
728
+          const part1Metrics = ctx.measureText(namePart1);
729
+          const part2Metrics = ctx.measureText(namePart2);
730
+          const countMetrics = ctx.measureText(countText);
731
+
732
+          // 检查文字宽度是否超出圆形直径的80%
733
+          const maxWidth = radius * 1.6;
734
+          let actualNameFontSize = nameFontSize;
735
+          let actualCountFontSize = countFontSize;
736
+
737
+          if (part1Metrics.width > maxWidth || part2Metrics.width > maxWidth) {
738
+            actualNameFontSize = nameFontSize * (maxWidth / Math.max(part1Metrics.width, part2Metrics.width));
739
+          }
740
+
741
+          if (countMetrics.width > maxWidth) {
742
+            actualCountFontSize = countFontSize * (maxWidth / countMetrics.width);
743
+          }
744
+
745
+          // 确保最小字体大小
746
+          actualNameFontSize = Math.max(8, actualNameFontSize);
747
+          actualCountFontSize = Math.max(6, actualCountFontSize);
748
+
749
+          // 计算行高
750
+          const lineHeight = actualNameFontSize * 1.2;
751
+
752
+          // 计算三行的垂直位置(整体向上移动)
753
+          const totalHeight = lineHeight * 2.5; // 三行总高度
754
+          const startY = y - totalHeight / 2 + lineHeight * 0.3;
755
+
756
+          // 绘制第一行(前四个字符)
757
+          ctx.font = `bold ${actualNameFontSize}px -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif`;
758
+          ctx.fillText(namePart1, x, startY);
759
+
760
+          // 绘制第二行(剩余字符)
761
+          if (namePart2) {
762
+            ctx.fillText(namePart2, x, startY + lineHeight);
763
+          }
764
+
765
+          // 绘制第三行(人数)
766
+          ctx.font = `${actualCountFontSize}px -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif`;
767
+          ctx.fillText(countText, x, startY + lineHeight * 2);
768
+        });
769
+      } catch (error) {
770
+        console.error('绘制canvas圆形时出错:', error);
771
+      }
772
+    },
773
+
774
+    // 清空性格特征画布
775
+    clearCharacterCanvas() {
776
+      // 获取canvas元素
777
+      let canvas = this.$refs.characterCanvas;
778
+
779
+      // 如果通过ref获取失败,尝试通过选择器获取
780
+      if (!canvas || typeof canvas !== 'object') {
781
+        canvas = document.querySelector('.character-canvas');
782
+      }
783
+
784
+      // 检查canvas元素是否有效
785
+      if (!canvas || typeof canvas !== 'object') {
786
+        console.warn('Canvas元素不存在,无法清空画布');
787
+        return;
788
+      }
789
+
790
+      // 检查canvas是否支持2D上下文
791
+      if (!canvas.getContext || typeof canvas.getContext !== 'function') {
792
+        console.warn('Canvas不支持getContext方法,无法清空画布');
793
+        return;
794
+      }
795
+
796
+      try {
797
+        // 获取canvas上下文
798
+        const ctx = canvas.getContext('2d');
799
+
800
+        // 获取canvas尺寸
801
+        const width = canvas.width || canvas.clientWidth;
802
+        const height = canvas.height || canvas.clientHeight;
803
+
804
+        // 清除整个画布
805
+        ctx.clearRect(0, 0, width, height);
806
+
807
+        console.log('性格特征画布已清空');
808
+      } catch (error) {
809
+        console.error('清空画布时出错:', error);
810
+      }
811
+    }
812
+  }
813
+}
814
+</script>
815
+
816
+<style lang="scss" scoped>
817
+.collapse-content {
818
+  padding: 20rpx;
819
+}
820
+
821
+.content-item {
822
+  display: flex;
823
+  justify-content: space-between;
824
+  align-items: center;
825
+  margin-bottom: 16rpx;
826
+
827
+  &:last-child {
828
+    margin-bottom: 0;
829
+  }
830
+}
831
+
832
+.item-label {
833
+  font-size: 26rpx;
834
+  color: #333333;
835
+  font-weight: 500;
836
+}
837
+
838
+.item-value {
839
+  font-size: 26rpx;
840
+  color: #999999;
841
+  font-weight: 500;
842
+}
843
+
844
+
845
+/* 性别分布布局 */
846
+.gender-layout {
847
+  display: flex;
848
+  justify-content: space-between;
849
+  align-items: center;
850
+  margin-bottom: 20rpx;
851
+}
852
+
853
+.gender-left {
854
+  flex: 1;
855
+}
856
+
857
+.gender-right {
858
+  flex: 1;
859
+  display: flex;
860
+  justify-content: flex-end;
861
+}
862
+
863
+.gender-label {
864
+  font-size: 28rpx;
865
+  color: #333333;
866
+  font-weight: 500;
867
+}
868
+
869
+/* 血型分布布局 */
870
+.blood-type-layout {
871
+  display: flex;
872
+  justify-content: space-between;
873
+  align-items: center;
874
+  margin-bottom: 20rpx;
875
+}
876
+
877
+.blood-type-left {
878
+  flex: 1;
879
+}
880
+
881
+.blood-type-right {
882
+  flex: 1;
883
+  display: flex;
884
+  justify-content: flex-end;
885
+}
886
+
887
+.blood-type-label {
888
+  font-size: 28rpx;
889
+  color: #333333;
890
+  font-weight: 500;
891
+}
892
+
893
+/* 生肖和星座展示 */
894
+.zodiac-section,
895
+.constellation-section {
896
+  display: flex;
897
+  justify-content: space-between;
898
+  align-items: center;
899
+  margin: 20rpx 0;
900
+  padding: 20rpx;
901
+  background-color: #f8f9fa;
902
+  border-radius: 10rpx;
903
+}
904
+
905
+.zodiac-label,
906
+.constellation-label {
907
+  font-size: 28rpx;
908
+  color: #666666;
909
+  font-weight: 500;
910
+}
911
+
912
+.zodiac-value,
913
+.constellation-value {
914
+  font-size: 28rpx;
915
+  color: #2C80FF;
916
+  font-weight: 600;
917
+}
918
+
919
+/* 性格特征和工作风格 */
920
+.personality-traits,
921
+.work-style {
922
+  display: flex;
923
+  flex-wrap: wrap;
924
+  gap: 16rpx;
925
+  margin-bottom: 20rpx;
926
+}
927
+
928
+.trait-item,
929
+.style-item {
930
+  padding: 12rpx 24rpx;
931
+  background-color: #e6f7ff;
932
+  border: 1rpx solid #91d5ff;
933
+  border-radius: 20rpx;
934
+  font-size: 24rpx;
935
+  color: #1890ff;
936
+  font-weight: 500;
937
+}
938
+
939
+/* 图表容器样式 */
940
+.scatter-chart,
941
+.pie-chart {
942
+  height: 600rpx;
943
+  margin-bottom: 20rpx;
944
+  display: flex;
945
+  justify-content: center;
946
+  align-items: center;
947
+}
948
+
949
+.scatter-chart {
950
+  width: 100%;
951
+  padding: 50rpx;
952
+  /* 大幅增加内边距提供更多安全距离 */
953
+  position: relative;
954
+  min-height: 800rpx;
955
+  /* 大幅增加最小高度提供更多空间 */
956
+  box-sizing: border-box;
957
+}
958
+
959
+.character-canvas {
960
+  width: 100%;
961
+  height: 100%;
962
+  display: block;
963
+}
964
+
965
+.chart-container {
966
+  width: 100%;
967
+  height: 100%;
968
+  display: flex;
969
+  justify-content: center;
970
+  align-items: center;
971
+}
972
+
973
+/* 统计表格样式 */
974
+.statistic-table {
975
+  margin-top: 20rpx;
976
+}
977
+</style>

+ 143 - 0
src/pages/eikonStatistics/components/eikon-collapse-item.vue

@@ -0,0 +1,143 @@
1
+<template>
2
+    <view class="eikon-collapse-item" :style="{ borderTopColor: borderColor }">
3
+        <uni-collapse-item :name="name" :show-animation="showAnimation" :disabled="disabled"  :open="open" ref="collapseItem"
4
+            @change="handleCollapseChange">
5
+            <template #title>
6
+                <view class="eikon-collapse-item-title">
7
+                    <view class="title-left-bar" :style="{ backgroundColor: borderColor }"></view>
8
+                    <view v-if="iconUrl" class="title-icon"
9
+                        :style="{ backgroundImage: iconUrl ? 'url(' + iconUrl + ')' : '' }"></view>
10
+                    <text class="title-text">{{ title }}</text>
11
+                </view>
12
+            </template>
13
+            <view ref="contentSlot">
14
+                <slot></slot>
15
+            </view>
16
+        </uni-collapse-item>
17
+    </view>
18
+</template>
19
+
20
+<script>
21
+export default {
22
+    name: 'EikonCollapseItem',
23
+    props: {
24
+        // 折叠面板名称
25
+        name: {
26
+            type: String,
27
+            default: ''
28
+        },
29
+        // 标题文本
30
+        title: {
31
+            type: String,
32
+            default: ''
33
+        },
34
+        // 是否显示动画
35
+        showAnimation: {
36
+            type: Boolean,
37
+            default: true
38
+        },
39
+        // 是否禁用
40
+        disabled: {
41
+            type: Boolean,
42
+            default: false
43
+        },
44
+        // 是否默认展开
45
+        open: {
46
+            type: Boolean,
47
+            default: true
48
+        },
49
+        // 图标URL地址(可选)
50
+        iconUrl: {
51
+            type: String,
52
+            default: ''
53
+        },
54
+        // 边框和左侧竖条的颜色
55
+        borderColor: {
56
+            type: String,
57
+            default: '#4873E3'
58
+        }
59
+    },
60
+    mounted() {
61
+
62
+    },
63
+    methods: {
64
+        handleCollapseChange(e) {
65
+            // 当折叠面板状态变化时,如果是展开状态,重新计算高度
66
+            if (e.detail.show) {
67
+                this.$nextTick(() => {
68
+                    this.updateCollapseHeight();
69
+                });
70
+            }
71
+        },
72
+
73
+        updateCollapseHeight() {
74
+            // 简化高度计算逻辑,避免干扰uni-collapse-item的默认行为
75
+            if (this.$refs.collapseItem && this.$refs.collapseItem.resize) {
76
+                // 使用uni-collapse-item提供的resize方法
77
+                this.$nextTick(() => {
78
+                    this.$refs.collapseItem.resize();
79
+                });
80
+            }
81
+        }
82
+    },
83
+    beforeDestroy() {
84
+        if (this.contentObserver) {
85
+            this.contentObserver.disconnect();
86
+        }
87
+    }
88
+}
89
+</script>
90
+
91
+<style lang="scss" scoped>
92
+.eikon-collapse-item {
93
+    border-top: 6rpx solid;
94
+    box-shadow: 0px 4px 10px 0px rgba(0, 0, 0, 0.08);
95
+    border-radius: 32rpx;
96
+    margin-bottom: 24rpx;
97
+    overflow: hidden;
98
+}
99
+
100
+::v-deep .uni-collapse-item__title {
101
+    height: 96rpx !important;
102
+    padding-left: 28rpx;
103
+}
104
+
105
+// 确保内容区域在展开时高度自适应
106
+::v-deep .uni-collapse-item__wrap {
107
+    // 保持原有行为,不设置固定高度
108
+}
109
+
110
+::v-deep .uni-collapse-item__content {
111
+    // 移除任何可能影响动画的高度限制
112
+    // 让uni-collapse-item自己管理高度
113
+}
114
+
115
+.eikon-collapse-item-title {
116
+    display: flex;
117
+    align-items: center;
118
+    position: relative;
119
+
120
+    .title-left-bar {
121
+       
122
+        width: 8rpx;
123
+        height: 32rpx;
124
+        border-radius: 4rpx;
125
+        margin-right: 16rpx;
126
+    }
127
+
128
+    .title-icon {
129
+        width: 40rpx;
130
+        height: 40rpx;
131
+        margin-right: 8px;
132
+        background-size: contain;
133
+        background-repeat: no-repeat;
134
+        background-position: center;
135
+    }
136
+
137
+    .title-text {
138
+        font-size: 30rpx;
139
+        color: #333;
140
+        font-weight: 500;
141
+    }
142
+}
143
+</style>

+ 450 - 0
src/pages/eikonStatistics/components/general-overview.vue

@@ -0,0 +1,450 @@
1
+<template>
2
+  <view class="general-overview">
3
+    <!-- 左右镜像布局容器 -->
4
+    <view class="mirror-layout">
5
+      <!-- 左边重叠view -->
6
+      <view class="left-stack">
7
+        <view class="stack-item item-1">
8
+          <view class="item-content">
9
+            <view class="item-label">资质能力</view>
10
+            <view class="item-number">{{ !isPersonal ? '一级' : '' }}{{ localOverviewData.qualificationLevel }}{{
11
+              !isPersonal ?
12
+                '人' : '' }}</view>
13
+            <view class="item-description">资质等级</view>
14
+          </view>
15
+        </view>
16
+        <view class="stack-item item-2">
17
+          <view class="item-content">
18
+            <view class="item-label">出勤投入</view>
19
+            <view class="item-number">{{ localOverviewData.avgWorkingHours || 0 }}H</view>
20
+            <view class="item-description">{{ isPersonal ? '上岗时长' : '人均上岗时长' }}</view>
21
+          </view>
22
+        </view>
23
+        <view class="stack-item item-3">
24
+          <view class="item-content">
25
+            <view class="item-label">标准执行</view>
26
+            <view class="item-number">{{ localOverviewData.checkCount || 0 }}</view>
27
+            <view class="item-description">巡检问题总次数</view>
28
+          </view>
29
+        </view>
30
+        <view class="stack-item item-4">
31
+          <view class="item-content">
32
+            <view class="item-label">协调配合</view>
33
+            <view class="item-number">{{ localOverviewData.workingStyle }}</view>
34
+            <view class="item-description">{{ isPersonal ? '工作风格' : '高频风格' }}</view>
35
+          </view>
36
+        </view>
37
+      </view>
38
+
39
+      <!-- 右边镜像重叠view -->
40
+      <view class="right-stack">
41
+        <view class="stack-item item-5">
42
+          <view class="item-content">
43
+            <view class="item-label">工作履历</view>
44
+            <view class="item-number">{{ localOverviewData.workYears || 0 }}</view>
45
+            <!-- <view class="item-number">{{ isPersonal ? localOverviewData.workYears || 0 :
46
+              systemData.securityWorkYearsStats && systemData.securityWorkYearsStats.averageWorkYears || 0 }}</view> -->
47
+            <view class="item-description">{{ isPersonal ? '安检工作年限' : '平均安检工作年限' }}</view>
48
+          </view>
49
+        </view>
50
+        <view class="stack-item item-6">
51
+          <view class="item-content">
52
+            <view class="item-label">工作产出</view>
53
+            <view class="item-number">{{ localOverviewData.avgSeizureCount || 0 }}</view>
54
+            <view class="item-description">有效查获数据</view>
55
+          </view>
56
+        </view>
57
+        <view class="stack-item item-7">
58
+          <view class="item-content">
59
+            <view class="item-label">学习成长</view>
60
+            <view class="item-number">{{ localOverviewData.learningGrowthScore || 0 }}</view>
61
+            <view class="item-description">平均分</view>
62
+          </view>
63
+        </view>
64
+        <view class="stack-item item-8">
65
+          <view class="item-content">
66
+            <view class="item-label">主观印象</view>
67
+            <view class="item-number">{{ localOverviewData.subjectiveImpression || '' }}</view>
68
+            <view class="item-description">高频词</view>
69
+          </view>
70
+        </view>
71
+      </view>
72
+    </view>
73
+  </view>
74
+</template>
75
+
76
+<script>
77
+import { getDictLabelByValue } from "@/utils/common"
78
+import { mapState, mapGetters } from 'vuex'
79
+import useDictMixin from '@/utils/dict'
80
+export default {
81
+  name: 'GeneralOverview',
82
+  data() {
83
+    return {
84
+      localOverviewData: {}
85
+    }
86
+  },
87
+  mixins: [useDictMixin],
88
+  computed: {
89
+    ...mapState({
90
+      overviewData: state => state.eikonLevel.overviewData,
91
+      systemData: state => state.eikonLevel.systemData,
92
+    }),
93
+    ...mapGetters('eikonLevel', ['isPersonal']),
94
+  },
95
+  watch: {
96
+    overviewData: {
97
+      handler(newVal, oldVal) {
98
+        if (this.isPersonal) {
99
+          this.handlePersonalData(newVal)
100
+        } else {
101
+          this.handleDeptData(newVal)
102
+        }
103
+      },
104
+    },
105
+    systemData: {
106
+      handler(newVal, oldVal) {
107
+        if (!this.isPersonal) {
108
+          this.handleSystemData(newVal)
109
+        }
110
+
111
+      }
112
+    }
113
+  },
114
+  methods: {
115
+    async handleSystemData(newVal) {
116
+      if (newVal) {
117
+        const { qualificationLevelStats } = newVal;
118
+        let levelOne = qualificationLevelStats && qualificationLevelStats.find(item => item.levelName === 'LEVEL_ONE')?.count || 0;
119
+        this.localOverviewData.qualificationLevel = levelOne;
120
+        // this.localOverviewData.workYears = newVal.workYearsStats && newVal.workYearsStats.averageWorkYears;
121
+      }
122
+    },
123
+    async handleDeptData(newVal) {
124
+      const res = { ...newVal };
125
+
126
+      const dict = await this.useDict(
127
+        'sys_user_personality_trait',
128
+        'sys_user_capability_performance',
129
+        'sys_user_interpersonal_interaction',
130
+        'sys_user_growth_potential',
131
+      )
132
+      let dictArr = Object.values(dict).reduce((prev, cur) => prev.concat(cur), [])
133
+
134
+      res.subjectiveImpression = dictArr.find(item => item.value === res.subjectiveImpression)?.label || '';
135
+      res.workingStyle = await getDictLabelByValue('sys_user_working_style', newVal
136
+        && newVal.workingStyle)
137
+      delete res.qualificationLevel
138
+      // delete res.workYears;
139
+
140
+      this.localOverviewData = { ...this.localOverviewData, ...res };
141
+    },
142
+    async handlePersonalData(newVal) {
143
+      const res = { ...newVal };
144
+
145
+      res.qualificationLevel = await getDictLabelByValue('sys_user_qualification_level', newVal
146
+        && newVal.qualificationLevel)
147
+
148
+      res.workingStyle = await getDictLabelByValue('sys_user_working_style', newVal
149
+        && newVal.workingStyle)
150
+
151
+      const dict = await this.useDict(
152
+        'sys_user_personality_trait',
153
+        'sys_user_capability_performance',
154
+        'sys_user_interpersonal_interaction',
155
+        'sys_user_growth_potential',
156
+      )
157
+      let dictArr = Object.values(dict).reduce((prev, cur) => prev.concat(cur), [])
158
+
159
+      res.subjectiveImpression = dictArr.find(item => item.value === res.subjectiveImpression)?.label || '';
160
+
161
+      this.localOverviewData = res;
162
+    }
163
+  }
164
+}
165
+</script>
166
+
167
+<style lang="scss" scoped>
168
+.general-overview {
169
+  padding: 20rpx 0;
170
+}
171
+
172
+.mirror-layout {
173
+  display: flex;
174
+  justify-content: space-between;
175
+  align-items: flex-start;
176
+  position: relative;
177
+}
178
+
179
+.left-stack,
180
+.right-stack {
181
+  position: relative;
182
+  width: 49%;
183
+  height: 880rpx;
184
+}
185
+
186
+.stack-item {
187
+  position: absolute;
188
+  left: 0;
189
+  top: 0;
190
+  width: 100%;
191
+
192
+
193
+  background: #ffffff;
194
+
195
+  &.item-1 {
196
+    z-index: 1;
197
+    height: 880rpx;
198
+    border-right: 4rpx solid #B790F7;
199
+    border-bottom: 4rpx solid #B790F7;
200
+    border-radius: 0 50rpx 0 0;
201
+
202
+
203
+    .item-content {
204
+      display: flex;
205
+      flex-direction: column;
206
+      align-items: flex-start;
207
+      padding-left: 40rpx;
208
+      justify-content: space-evenly;
209
+      border-radius: 0 50rpx;
210
+      border: 4rpx solid #B790F7;
211
+      margin-right: -4rpx;
212
+    }
213
+  }
214
+
215
+  &.item-2 {
216
+    z-index: 2;
217
+    height: 670rpx;
218
+    top: 200rpx;
219
+    width: 95%;
220
+    border-right: 4rpx solid #39CFCA;
221
+    border-bottom: 4rpx solid #39CFCA;
222
+    border-radius: 0 50rpx 0 0;
223
+
224
+    .item-content {
225
+      display: flex;
226
+      flex-direction: column;
227
+      align-items: flex-start;
228
+      padding-left: 40rpx;
229
+      justify-content: space-evenly;
230
+      border-radius: 0 50rpx;
231
+      border: 4rpx solid #39CFCA;
232
+      margin-right: -4rpx;
233
+    }
234
+  }
235
+
236
+  &.item-3 {
237
+    z-index: 3;
238
+    height: 460rpx;
239
+    top: 400rpx;
240
+    width: 90%;
241
+    border-right: 4rpx solid #4BA93B;
242
+    border-bottom: 4rpx solid #4BA93B;
243
+    border-radius: 0 50rpx 0 0;
244
+
245
+    .item-content {
246
+      display: flex;
247
+      flex-direction: column;
248
+      align-items: flex-start;
249
+      padding-left: 40rpx;
250
+      justify-content: space-evenly;
251
+      border-radius: 0 50rpx;
252
+      border: 4rpx solid #4BA93B;
253
+      margin-right: -4rpx;
254
+    }
255
+  }
256
+
257
+  &.item-4 {
258
+    z-index: 4;
259
+    height: 250rpx;
260
+    width: 85%;
261
+    top: 600rpx;
262
+    border-right: 4rpx solid #2E81FF;
263
+    border-bottom: 4rpx solid #2E81FF;
264
+    border-radius: 0 50rpx 0 0;
265
+
266
+    .item-content {
267
+      display: flex;
268
+      flex-direction: column;
269
+      align-items: flex-start;
270
+      padding-left: 40rpx;
271
+      justify-content: space-evenly;
272
+      border-radius: 0 50rpx;
273
+      border: 4rpx solid #2E81FF;
274
+      margin-right: -4rpx;
275
+    }
276
+  }
277
+}
278
+
279
+.right-stack {
280
+  .stack-item {
281
+    left: auto;
282
+    right: 0;
283
+
284
+    &.item-5 {
285
+      z-index: 1;
286
+      height: 880rpx;
287
+      border-left: 4rpx solid #ED8E1C;
288
+      border-bottom: 4rpx solid #ED8E1C;
289
+      border-radius: 50rpx 0 0 0;
290
+
291
+      .item-content {
292
+        display: flex;
293
+        flex-direction: column;
294
+        align-items: flex-end;
295
+        padding-right: 40rpx;
296
+        justify-content: space-evenly;
297
+        border-radius: 50rpx 0;
298
+        border: 4rpx solid #ED8E1C;
299
+        margin-left: -4rpx;
300
+      }
301
+    }
302
+
303
+    &.item-6 {
304
+      z-index: 2;
305
+      height: 670rpx;
306
+      top: 200rpx;
307
+      width: 95%;
308
+      border-left: 4rpx solid #F14E4E;
309
+      border-bottom: 4rpx solid #F14E4E;
310
+      border-radius: 50rpx 0 0 0;
311
+
312
+      .item-content {
313
+        display: flex;
314
+        flex-direction: column;
315
+        align-items: flex-end;
316
+        padding-right: 40rpx;
317
+        justify-content: space-evenly;
318
+        border-radius: 50rpx 0;
319
+        border: 4rpx solid #F14E4E;
320
+        margin-left: -4rpx;
321
+      }
322
+    }
323
+
324
+    &.item-7 {
325
+      z-index: 3;
326
+      height: 460rpx;
327
+      top: 400rpx;
328
+      width: 90%;
329
+      border-left: 4rpx solid #E437F3;
330
+      border-bottom: 4rpx solid #E437F3;
331
+      border-radius: 50rpx 0 0 0;
332
+
333
+      .item-content {
334
+        display: flex;
335
+        flex-direction: column;
336
+        align-items: flex-end;
337
+        padding-right: 40rpx;
338
+        justify-content: space-evenly;
339
+        border-radius: 50rpx 0;
340
+        border: 4rpx solid #E437F3;
341
+        margin-left: -4rpx;
342
+      }
343
+    }
344
+
345
+    &.item-8 {
346
+      z-index: 4;
347
+      height: 250rpx;
348
+      width: 85%;
349
+      top: 600rpx;
350
+      border-left: 4rpx solid #FF9B7E;
351
+      border-bottom: 4rpx solid #FF9B7E;
352
+      border-radius: 50rpx 0 0 0;
353
+
354
+      .item-content {
355
+        display: flex;
356
+        flex-direction: column;
357
+        align-items: flex-end;
358
+        padding-right: 40rpx;
359
+        justify-content: space-evenly;
360
+        border-radius: 50rpx 0;
361
+        border: 4rpx solid #FF9B7E;
362
+        margin-left: -4rpx;
363
+      }
364
+    }
365
+  }
366
+}
367
+
368
+.item-content {
369
+  text-align: center;
370
+  height: 180rpx;
371
+  color: #333333;
372
+}
373
+
374
+.item-number {
375
+  font-size: 25rpx;
376
+  font-weight: bold;
377
+
378
+}
379
+
380
+.item-label {
381
+  font-size: 28rpx;
382
+  font-weight: bold;
383
+  color: #333333;
384
+  position: relative;
385
+
386
+
387
+  &::before {
388
+    content: '';
389
+    position: absolute;
390
+    left: -16%;
391
+    top: 50%;
392
+    transform: translateY(-50%);
393
+    width: 8rpx;
394
+    height: 28rpx;
395
+    border-radius: 2rpx;
396
+  }
397
+}
398
+
399
+.left-stack {
400
+  .item-1 .item-label::before {
401
+    background: #B68FF7;
402
+  }
403
+
404
+  .item-2 .item-label::before {
405
+    background: #39CFCA;
406
+  }
407
+
408
+  .item-3 .item-label::before {
409
+    background: #4BA93B;
410
+  }
411
+
412
+  .item-4 .item-label::before {
413
+    background: #2E81FF;
414
+  }
415
+}
416
+
417
+.right-stack {
418
+  .item-label {
419
+    padding-left: 0;
420
+    padding-right: 16rpx;
421
+
422
+    &::before {
423
+      left: auto;
424
+      right: 0;
425
+    }
426
+  }
427
+
428
+  .item-5 .item-label::before {
429
+    background: #ED8E1C;
430
+  }
431
+
432
+  .item-6 .item-label::before {
433
+    background: #F14E4E;
434
+  }
435
+
436
+  .item-7 .item-label::before {
437
+    background: #E437F3;
438
+  }
439
+
440
+  .item-8 .item-label::before {
441
+    background: #FF9B7E;
442
+  }
443
+}
444
+
445
+.item-description {
446
+  font-size: 24rpx;
447
+
448
+  color: #999999;
449
+}
450
+</style>

+ 786 - 0
src/pages/eikonStatistics/components/standard-execution.vue

@@ -0,0 +1,786 @@
1
+<template>
2
+  <uni-collapse>
3
+    <eikon-collapse-item title="标准执行" name="standardExecution" :borderColor="'#48A838'">
4
+      <view class="collapse-content">
5
+        <view class="content-item" v-if="!isZhanZhang">
6
+          <view class="item-label">巡检排名</view>
7
+          <view class="item-value">
8
+            <SwitchTab :tabList="switchOptions" :defaultActive="inspectionSwitchValue"
9
+              @tabChange="handleInspectionTabChange" />
10
+          </view>
11
+
12
+        </view>
13
+        <view class="ranking-content" v-if="!isZhanZhang">
14
+          <h-rank-line :percentage="percentage">
15
+            <view class="ranking-text">
16
+              <text class="ranking-rank">第{{ rankData.rank || 0 }}名</text>
17
+              /
18
+              <text class="ranking-count">{{ rankData.rankCount || 0 }}</text>
19
+            </view>
20
+          </h-rank-line>
21
+        </view>
22
+
23
+        <view class="content-item">
24
+          <view class="item-label">巡检总问题数</view>
25
+          <view class="item-value">{{ portraitData && portraitData.sumCount ? portraitData.sumCount : 0 }}</view>
26
+        </view>
27
+
28
+        <view class="content-item">
29
+          <view class="item-label">问题分布项</view>
30
+        </view>
31
+
32
+        <!-- 问题分布雷达图 -->
33
+        <view class="radar-chart">
34
+          <div ref="issueRadarChart" class="chart-container"></div>
35
+        </view>
36
+
37
+        <view class="content-item">
38
+          <view class="item-label">整改统计</view>
39
+          <view class="item-value">
40
+            <h-legend :legend-data="rectificationData" :colorSize="20" :textSize="24" />
41
+          </view>
42
+        </view>
43
+
44
+        <!-- 整改统计 - 左右布局 -->
45
+        <view class="content-item">
46
+          <h-line :data="rectificationData" :total="rectificationTotal" :itemWidth="300" :lineHeight="'40'" />
47
+        </view>
48
+
49
+        <view class="content-item" v-if="!isZhanZhang">
50
+          <view class="item-label">测试排名</view>
51
+          <view class="item-value">
52
+            <SwitchTab :tabList="switchOptions" :defaultActive="testSwitchValue" @tabChange="handleTestTabChange" />
53
+          </view>
54
+        </view>
55
+
56
+        <view class="ranking-content" v-if="!isZhanZhang">
57
+          <h-rank-line :percentage="testPercentage">
58
+            <view class="ranking-text"><text class="ranking-rank">第{{ testRankData.rank || 0 }}名</text>/<text
59
+                class="ranking-count">{{
60
+                  testRankData.rankCount || 0 }}</text></view>
61
+          </h-rank-line>
62
+        </view>
63
+
64
+        <!-- 测试平均分 - 左右布局 -->
65
+        <view class="content-item">
66
+          <view class="item-label">测试平均分</view>
67
+          <view class="item-value">{{ profileData && profileData.scoreStats && profileData.scoreStats.totalAvgScore
68
+            || profileData && profileData.avgScore || 0 }}</view>
69
+        </view>
70
+
71
+        <view class="content-item" v-if="isZhanZhang">
72
+          <view class="item-label">测试总错题数</view>
73
+          <view class="item-value">{{ profileData && profileData.totalErrors ? profileData.totalErrors : 0 }}</view>
74
+        </view>
75
+
76
+
77
+        <!-- 错题分布雷达图 -->
78
+        <view class="radar-chart">
79
+          <div ref="wrongQuestionRadarChart" class="chart-container"></div>
80
+        </view>
81
+      </view>
82
+    </eikon-collapse-item>
83
+  </uni-collapse>
84
+</template>
85
+
86
+<script>
87
+import EikonCollapseItem from "./eikon-collapse-item.vue";
88
+import SwitchTab from "./switch-tab.vue"
89
+import HRankLine from "@/components/h-rank-line/h-rank-line.vue";
90
+import HLine from "@/components/h-line/h-line.vue";
91
+import HLegend from "@/components/h-legend/h-legend.vue";
92
+import * as echarts from 'echarts';
93
+import { mapState, mapGetters } from 'vuex'
94
+
95
+export default {
96
+  name: "StandardExecution",
97
+  components: {
98
+    EikonCollapseItem,
99
+    SwitchTab,
100
+    HRankLine,
101
+    HLine,
102
+    HLegend
103
+  },
104
+  data() {
105
+    return {
106
+      rankData: {
107
+        rank: 0,
108
+        rankCount: 0
109
+      },
110
+      testRankData: {
111
+        rank: 0,
112
+        rankCount: 0
113
+      },
114
+      // switch-tab配置
115
+      inspectionSwitchValue: 0,
116
+      testSwitchValue: 0,
117
+
118
+      // 测试排名数据
119
+      testRanking: {
120
+        percentage: 92
121
+      },
122
+      // 测试平均分
123
+      testAverageScore: 88.5,
124
+      // 整改统计数据
125
+      rectificationData: [],
126
+      rectificationTotal: 0
127
+    }
128
+  },
129
+  computed: {
130
+    ...mapState({
131
+      portraitData: state => state.eikonLevel.portraitData,
132
+      profileData: state => state.eikonLevel.profileData,
133
+      isPersonal: state => state.eikonLevel.isPersonal,
134
+      isBanZuZhang: state => state.eikonLevel.isBanZuZhang,
135
+      isKeZhang: state => state.eikonLevel.isKeZhang,
136
+      isZhanZhang: state => state.eikonLevel.isZhanZhang,
137
+      currentLevel: state => state.eikonLevel.currentLevel,
138
+    }),
139
+    ...mapGetters('eikonLevel', ['isZhanZhang', 'isPersonal']),
140
+    switchOptions() {
141
+      if (this.isPersonal) {
142
+        return [
143
+          { value: 0, label: '全站' },
144
+          { value: 1, label: '本科' },
145
+          { value: 2, label: '本班' }
146
+        ]
147
+      }
148
+      if (this.isBanZuZhang) {
149
+        return [
150
+          { value: 0, label: '全站' },
151
+          { value: 1, label: '本科' },
152
+
153
+        ]
154
+      }
155
+      return []
156
+    },
157
+    percentage() {
158
+      return this.rankData.rankCount > 0 ? Number((this.rankData.rank / this.rankData.rankCount * 100).toFixed(2)) : 0;
159
+    },
160
+    testPercentage() {
161
+      return this.testRankData.rankCount > 0 ? Number((this.testRankData.rank / this.testRankData.rankCount * 100).toFixed(2)) : 0;
162
+    }
163
+  },
164
+  watch: {
165
+    portraitData: {
166
+      handler(newData) {
167
+        if (newData && Object.keys(newData).length > 0) {
168
+          this.$nextTick(() => {
169
+            this.handleInspectionTabChange({ value: this.inspectionSwitchValue });
170
+            if (this.issueRadarChart) {
171
+              this.issueRadarChart.dispose();
172
+              this.issueRadarChart = null;
173
+            }
174
+            this.initIssueRadarChart();
175
+            this.initLine();
176
+          });
177
+        }
178
+      },
179
+    },
180
+    profileData: {
181
+      handler(newData) {
182
+        if (newData && Object.keys(newData).length > 0) {
183
+          this.$nextTick(() => {
184
+            this.handleTestTabChange({ value: this.testSwitchValue });
185
+            if (this.wrongQuestionRadarChart) {
186
+              this.wrongQuestionRadarChart.dispose();
187
+              this.wrongQuestionRadarChart = null;
188
+            }
189
+            this.initWrongQuestionRadarChart();
190
+          });
191
+        }
192
+      },
193
+    }
194
+  },
195
+  mounted() {
196
+    this.$nextTick(() => {
197
+      try {
198
+        this.handleInspectionTabChange({ value: this.inspectionSwitchValue });
199
+        this.handleTestTabChange({ value: this.testSwitchValue });
200
+
201
+        this.initLine()
202
+      } catch (error) {
203
+        console.error('Error in nextTick:', error);
204
+      }
205
+    });
206
+  },
207
+
208
+  methods: {
209
+
210
+    initLine() {
211
+      // 安全地处理portraitData可能为undefined的情况
212
+      const portraitData = this.portraitData || {};
213
+      const onTimeCompletedCount = portraitData.onTimeCompletedCount || 0;
214
+      const overTimeCompletedCount = portraitData.overTimeCompletedCount || 0;
215
+
216
+      // 确保rectificationData是数组
217
+      if (!Array.isArray(this.rectificationData)) {
218
+        this.rectificationData = [];
219
+      }
220
+
221
+      this.rectificationData = [
222
+        { value: onTimeCompletedCount, color: '#FA8C16', name: '按期整改' },
223
+        { value: overTimeCompletedCount, color: '#FFB30F', name: '超期整改' },
224
+      ]
225
+      this.rectificationTotal = onTimeCompletedCount + overTimeCompletedCount
226
+    },
227
+    
228
+
229
+   
230
+
231
+    // 初始化问题分布雷达图
232
+    initIssueRadarChart() {
233
+      const chartDom = this.$refs.issueRadarChart;
234
+      if (!chartDom) return;
235
+
236
+      // 清除容器内容
237
+      chartDom.innerHTML = '';
238
+
239
+      const chart = echarts.init(chartDom);
240
+      this.issueRadarChart = chart;
241
+
242
+      // 安全地处理portraitData可能为undefined的情况
243
+      const portraitData = this.portraitData || {};
244
+      const checkLargeScreenCommonDtoList = portraitData.checkLargeScreenCommonDtoList || [];
245
+      let maxTotal = 0;
246
+      let indicator = [];
247
+      let seriesData = [];
248
+      if (this.currentLevel === 'station') {
249
+        let res = checkLargeScreenCommonDtoList.filter((item) => {
250
+          return item && item.deptName !== '总数'
251
+        });
252
+
253
+        // 获取所有不重复的name作为雷达图维度
254
+        const uniqueNames = [...new Set(res.map(item => item.name))];
255
+        maxTotal = res.length > 0 ? Math.max(...res.map(item => item.total || 0)) : 1;
256
+        indicator = uniqueNames.map(name => ({
257
+          name: name,
258
+          max: maxTotal
259
+        }));
260
+        let obj = {};
261
+
262
+        res.forEach((item) => {
263
+          if (obj[item.deptName]) {
264
+            obj[item.deptName].push(item.total || 0);
265
+          } else {
266
+            obj[item.deptName] = [item.total || 0];
267
+          }
268
+        })
269
+
270
+
271
+        Object.keys(obj).forEach((key, index) => {
272
+
273
+          seriesData.push({
274
+            value: obj[key],
275
+            name: key,
276
+            // areaStyle: {
277
+            //   color: color.area
278
+            // },
279
+
280
+            // itemStyle: {
281
+            //   color: color.line
282
+            // }
283
+          })
284
+        })
285
+
286
+      } else {
287
+
288
+        let res = checkLargeScreenCommonDtoList.filter((item) => {
289
+          return item && item.deptName === '总数'
290
+        });
291
+
292
+
293
+
294
+        maxTotal = res.length > 0 ? Math.max(...res.map(item => item.total || 0)) : 1;
295
+        indicator = res.map((item) => {
296
+          return { name: item.name, max: maxTotal || 1 }
297
+        })
298
+        seriesData = [{
299
+          value: res.map((item) => {
300
+            return item.total || 0
301
+          }),
302
+          name: '问题分布',
303
+          // areaStyle: {
304
+          //   color: 'rgba(72, 115, 227, 0.3)'
305
+          // },
306
+          lineStyle: {
307
+            color: '#4873E3'
308
+          },
309
+          // itemStyle: {
310
+          //   color: '#4873E3'
311
+          // }
312
+        }]
313
+      }
314
+
315
+
316
+      // console.log('雷达图数据:', indicator, seriesData)
317
+
318
+      const option = {
319
+        ...(this.currentLevel === 'station' ? {
320
+          legend: {
321
+            data: seriesData.map(item => item.name),
322
+            top: 0,
323
+            textStyle: {
324
+              color: '#666',
325
+              fontSize: 12
326
+            }
327
+          }
328
+        } : {
329
+          legend: {
330
+            show: false
331
+          }
332
+        })
333
+        ,
334
+        radar: {
335
+          indicator: indicator,
336
+          shape: 'polygon',
337
+          splitNumber: 4,
338
+          radius: '55%',
339
+          center: ['50%', '55%'], // 缩小雷达图并居中,为label留出空间
340
+          axisName: {
341
+            color: '#666',
342
+            fontSize: 12
343
+          },
344
+          splitLine: {
345
+            lineStyle: {
346
+              color: 'rgba(0, 0, 0, 0.1)'
347
+            }
348
+          },
349
+          splitArea: {
350
+            show: true,
351
+            areaStyle: {
352
+              color: ['rgba(255, 255, 255, 0.8)', 'rgba(255, 255, 255, 0.4)']
353
+            }
354
+          }
355
+        },
356
+        series: [{
357
+          type: 'radar',
358
+          data: seriesData,
359
+          symbol: 'none',
360
+          emphasis: {
361
+            lineStyle: {
362
+              width: 4
363
+            }
364
+          }
365
+        }],
366
+        tooltip: {
367
+          trigger: 'item'
368
+        }
369
+      };
370
+
371
+      chart.setOption(option);
372
+
373
+      // 响应式调整
374
+      window.addEventListener('resize', () => {
375
+        chart.resize();
376
+      });
377
+    },
378
+
379
+    // 初始化错题分布雷达图
380
+    initWrongQuestionRadarChart() {
381
+      const chartDom = this.$refs.wrongQuestionRadarChart;
382
+      if (!chartDom) return;
383
+
384
+      // 清除容器内容
385
+      chartDom.innerHTML = '';
386
+
387
+      const chart = echarts.init(chartDom);
388
+      this.wrongQuestionRadarChart = chart;
389
+      // 定义六个固定的雷达图维度
390
+      const radarDimensions = [
391
+        '操作执行',
392
+        '传达学习',
393
+        '班组管理',
394
+        '工作纪律',
395
+        '设施设备',
396
+        '通道样貌'
397
+      ];
398
+
399
+      // 从profileData获取错题分类数据
400
+      const profileData = this.profileData || {};
401
+      let categoryErrors = [];
402
+      let indicator = [];
403
+      let seriesData = [];
404
+      let legend = [];
405
+
406
+      if (this.currentLevel == 'station') {
407
+        categoryErrors = (profileData.deptCategoryErrors || []).map(item => {
408
+          return {
409
+            ...item,
410
+            categoryErrors: (item.categoryErrors || []).filter(childItem => childItem.categoryName !== '未分类')
411
+          }
412
+        });
413
+
414
+        const maxErrorCount = Math.max(...categoryErrors.map(dept => {
415
+          const matchedCategory = dept.categoryErrors.map(cat => cat.errorCount);
416
+          return Math.max(...matchedCategory);
417
+        }));
418
+        // 构建indicator数据 - 使用固定的六个维度
419
+        indicator = radarDimensions.map(dimension => {
420
+          return {
421
+            name: dimension,
422
+            max: maxErrorCount
423
+          };
424
+        });
425
+        legend = categoryErrors.map(item => item.deptName);
426
+
427
+        // 构建series数据 - 每个部门一条数据线
428
+        const colorPalette = [
429
+          { area: 'rgba(72, 115, 227, 0.3)', line: '#4873E3' }, // 蓝色
430
+          { area: 'rgba(255, 107, 107, 0.3)', line: '#FF6B6B' }, // 红色
431
+          { area: 'rgba(76, 175, 80, 0.3)', line: '#4CAF50' },   // 绿色
432
+          { area: 'rgba(255, 152, 0, 0.3)', line: '#FF9800' },   // 橙色
433
+          { area: 'rgba(156, 39, 176, 0.3)', line: '#9C27B0' },  // 紫色
434
+          { area: 'rgba(0, 188, 212, 0.3)', line: '#00BCD4' },   // 青色
435
+          { area: 'rgba(244, 67, 54, 0.3)', line: '#F44336' },   // 深红色
436
+          { area: 'rgba(139, 195, 74, 0.3)', line: '#8BC34A' }   // 浅绿色
437
+        ];
438
+
439
+        seriesData = categoryErrors.map((dept, index) => {
440
+          const colorIndex = index % colorPalette.length;
441
+          const color = colorPalette[colorIndex];
442
+
443
+          // 为每个维度获取该部门的错误数量
444
+          const values = radarDimensions.map(dimension => {
445
+            const matchedCategory = dept.categoryErrors.find(cat => cat.categoryName === dimension);
446
+            return matchedCategory ? matchedCategory.errorCount : 0;
447
+          });
448
+
449
+          return {
450
+            value: values,
451
+            name: dept.deptName,
452
+            // areaStyle: {
453
+            //   show: false // 去掉面积遮盖
454
+            // },
455
+            lineStyle: {
456
+              color: color.line,
457
+              width: 2
458
+            },
459
+            // itemStyle: {
460
+            //   color: color.line
461
+            // },
462
+            label: {
463
+              show: true, // 在每个角上显示数值
464
+              formatter: function (params) {
465
+                return params.value;
466
+              },
467
+              color: color.line,
468
+              fontSize: 10
469
+            }
470
+          };
471
+        });
472
+
473
+      } else {
474
+        categoryErrors = (profileData.categoryErrors || []).filter(item =>
475
+          item.categoryName !== '未分类'
476
+        );
477
+        // 构建indicator数据
478
+        indicator = radarDimensions.map(dimension => {
479
+          // 查找对应维度的错误数量
480
+          const matchedCategory = categoryErrors.find(item => item.categoryName === dimension);
481
+          const errorCount = matchedCategory ? matchedCategory.errorCount : 0;
482
+
483
+          // 计算最大值(当前错误数量的1.5倍,最小为10)
484
+          const maxValue = Math.max(errorCount * 1.5, 10);
485
+
486
+          return {
487
+            name: dimension,
488
+            max: maxValue
489
+          };
490
+        });
491
+        // 构建series数据
492
+        seriesData = [{
493
+          value: radarDimensions.map(dimension => {
494
+            const matchedCategory = categoryErrors.find(item => item.categoryName === dimension);
495
+            return matchedCategory ? matchedCategory.errorCount : 0;
496
+          }),
497
+          name: '错题分布',
498
+          // areaStyle: {
499
+          //   show: false // 去掉面积遮盖
500
+          // },
501
+          lineStyle: {
502
+            color: '#FF6B6B',
503
+            width: 2
504
+          },
505
+          // itemStyle: {
506
+          //   color: '#FF6B6B'
507
+          // },
508
+          label: {
509
+            show: true, // 在每个角上显示数值
510
+            formatter: function (params) {
511
+              return params.value;
512
+            },
513
+            color: '#FF6B6B',
514
+            fontSize: 10
515
+          }
516
+        }];
517
+      }
518
+
519
+      const option = {
520
+        ...(this.currentLevel == 'station' ? {
521
+          legend: {
522
+            data: legend,
523
+            top: 0,
524
+            textStyle: {
525
+              color: '#666',
526
+              fontSize: 12
527
+            }
528
+          }
529
+        } : { legend: { show: false } }),
530
+        radar: {
531
+          indicator: indicator,
532
+          shape: 'polygon',
533
+          splitNumber: 4,
534
+          radius: '55%',
535
+          center: ['50%', '55%'], // 缩小雷达图并居中,为label留出空间
536
+          axisName: {
537
+            color: '#666',
538
+            fontSize: 12
539
+          },
540
+          splitLine: {
541
+            lineStyle: {
542
+              color: 'rgba(0, 0, 0, 0.1)'
543
+            }
544
+          },
545
+          splitArea: {
546
+            show: true,
547
+            areaStyle: {
548
+              color: ['rgba(255, 255, 255, 0.8)', 'rgba(255, 255, 255, 0.4)']
549
+            }
550
+          }
551
+        },
552
+        series: [{
553
+          type: 'radar',
554
+          data: seriesData,
555
+          symbol: 'none',
556
+          emphasis: {
557
+            lineStyle: {
558
+              width: 4
559
+            }
560
+          }
561
+        }],
562
+        ...(this.currentLevel == 'station' ? {
563
+          tooltip: {
564
+            trigger: 'item',
565
+            formatter: function (params) {
566
+              const values = params.value;
567
+              const dimensionLabels = radarDimensions.map((dimension, index) => {
568
+                return `${dimension}: ${values[index]}题`;
569
+              });
570
+              return `${params.name}<br/>${dimensionLabels.join('<br/>')}`;
571
+
572
+            }.bind(this),
573
+            extraCssText: 'white-space: pre-line; max-width: 300px;'
574
+          }
575
+        } : {
576
+          tooltip: {
577
+            trigger: 'item'
578
+          }
579
+        })
580
+
581
+      };
582
+
583
+      chart.setOption(option);
584
+
585
+      // 响应式调整
586
+      window.addEventListener('resize', () => {
587
+        chart.resize();
588
+      });
589
+    },
590
+
591
+    // 巡检排名切换处理
592
+    handleInspectionTabChange(event) {
593
+      console.log('巡检排名切换:', event);
594
+      this.inspectionSwitchValue = event.value;
595
+      // 安全地处理portraitData可能为undefined的情况
596
+      const portraitData = this.portraitData || {};
597
+      const stationRanking = portraitData.stationRanking || 0;
598
+      const stationTotal = portraitData.stationTotal || 0;
599
+      const departmentRanking = portraitData.departmentRanking || 0;
600
+      const departmentTotal = portraitData.departmentTotal || 0;
601
+      const teamRanking = portraitData.teamRanking || 0;
602
+      const teamTotal = portraitData.teamTotal || 0;
603
+
604
+      if (this.inspectionSwitchValue == 0) {
605
+        this.rankData = {
606
+          rank: stationRanking,
607
+          rankCount: stationTotal
608
+        }
609
+      } else if (this.inspectionSwitchValue == 1) {
610
+        this.rankData = {
611
+          rank: departmentRanking,
612
+          rankCount: departmentTotal
613
+        }
614
+      } else if (this.inspectionSwitchValue == 2) {
615
+        this.rankData = {
616
+          rank: teamRanking,
617
+          rankCount: teamTotal
618
+        }
619
+      }
620
+
621
+
622
+    },
623
+
624
+    // 测试排名切换处理
625
+    handleTestTabChange(event) {
626
+      console.log('测试排名切换:', event);
627
+      this.testSwitchValue = event.value;
628
+      const { rankingStats } = this.profileData || {};
629
+
630
+
631
+      if (this.testSwitchValue == 0) {
632
+        this.testRankData = {
633
+          rank: rankingStats?.siteRanking || 0,
634
+          rankCount: this.isKeZhang ? rankingStats?.siteTotalDepts || 0 : rankingStats?.siteTotalUsers || 0
635
+        }
636
+      } else if (this.testSwitchValue == 1) {
637
+        this.testRankData = {
638
+          rank: rankingStats?.deptRanking || 0,
639
+          rankCount: rankingStats?.deptTotalUsers || 0
640
+        }
641
+      } else if (this.testSwitchValue == 2) {
642
+        this.testRankData = {
643
+          rank: rankingStats?.teamRanking || 0,
644
+          rankCount: rankingStats?.teamTotalUsers || 0
645
+        }
646
+      }
647
+    },
648
+
649
+
650
+
651
+
652
+  }
653
+}
654
+</script>
655
+
656
+<style scoped lang="scss">
657
+.loading-container {
658
+  display: flex;
659
+  justify-content: center;
660
+  align-items: center;
661
+  height: 200rpx;
662
+
663
+  .loading-content {
664
+    display: flex;
665
+    flex-direction: column;
666
+    align-items: center;
667
+
668
+    .loading-spinner {
669
+      width: 40rpx;
670
+      height: 40rpx;
671
+      border: 4rpx solid #f3f3f3;
672
+      border-top: 4rpx solid #48A838;
673
+      border-radius: 50%;
674
+      animation: spin 1s linear infinite;
675
+      margin-bottom: 20rpx;
676
+    }
677
+
678
+    .loading-text {
679
+      font-size: 26rpx;
680
+      color: #999;
681
+    }
682
+  }
683
+}
684
+
685
+@keyframes spin {
686
+  0% {
687
+    transform: rotate(0deg);
688
+  }
689
+
690
+  100% {
691
+    transform: rotate(360deg);
692
+  }
693
+}
694
+
695
+.collapse-content {
696
+  padding: 20rpx;
697
+}
698
+
699
+.content-item {
700
+  display: flex;
701
+  justify-content: space-between;
702
+  align-items: center;
703
+  margin-bottom: 16rpx;
704
+
705
+  &:last-child {
706
+    margin-bottom: 0;
707
+  }
708
+}
709
+
710
+.item-label {
711
+  font-size: 26rpx;
712
+  color: #333333;
713
+  font-weight: 500;
714
+}
715
+
716
+.item-value {
717
+  font-size: 26rpx;
718
+  color: #999999;
719
+  font-weight: 500;
720
+}
721
+
722
+
723
+
724
+
725
+
726
+.ranking-content {
727
+  margin: 30rpx 0;
728
+
729
+  .ranking-text {
730
+    font-size: 26rpx;
731
+    color: #999999;
732
+    font-weight: 500;
733
+
734
+    .ranking-rank {
735
+      font-size: 26rpx;
736
+      font-weight: 500;
737
+      color: #333333;
738
+    }
739
+
740
+    .ranking-count {
741
+      font-size: 26rpx;
742
+      font-weight: 500;
743
+      color: #999999;
744
+    }
745
+  }
746
+}
747
+
748
+
749
+
750
+.issues-distribution {
751
+  margin-bottom: 30rpx;
752
+
753
+  .distribution-item {
754
+    display: flex;
755
+    justify-content: space-between;
756
+    align-items: center;
757
+    padding: 16rpx 20rpx;
758
+    margin-bottom: 12rpx;
759
+    background: #fff;
760
+    border-radius: 8rpx;
761
+
762
+    .distribution-name {
763
+      font-size: 26rpx;
764
+      color: #666;
765
+    }
766
+
767
+    .distribution-value {
768
+      font-size: 26rpx;
769
+      color: #333;
770
+      font-weight: 500;
771
+    }
772
+  }
773
+}
774
+
775
+.radar-chart {
776
+  height: 400rpx;
777
+  background: #fff;
778
+  border-radius: 12rpx;
779
+  margin-bottom: 20rpx;
780
+
781
+  .chart-container {
782
+    width: 100%;
783
+    height: 100%;
784
+  }
785
+}
786
+</style>

+ 143 - 0
src/pages/eikonStatistics/components/switch-tab.vue

@@ -0,0 +1,143 @@
1
+<template>
2
+  <view class="capsule-tab-container">
3
+    <view v-for="(item, index) in tabList" :key="index" class="capsule-tab-item" :class="{
4
+      'capsule-tab-active': activeIndex == item.value,
5
+      'capsule-tab-first': index == 0,
6
+      'capsule-tab-last': index == tabList.length - 1
7
+    }" @click="handleTabClick(index, item)">
8
+      <text class="capsule-tab-text">{{ item.label || item.text || item.name }}</text>
9
+    </view>
10
+  </view>
11
+</template>
12
+
13
+<script>
14
+export default {
15
+  name: 'SwitchTab',
16
+  props: {
17
+    // tab数据数组
18
+    tabList: {
19
+      type: Array,
20
+      default: () => [],
21
+      required: true
22
+    },
23
+    // 默认激活的索引
24
+    defaultActive: {
25
+      type: String|Number,
26
+      default: ''
27
+    }
28
+  },
29
+  data() {
30
+    return {
31
+      activeIndex: this.defaultActive
32
+    }
33
+  },
34
+  watch: {
35
+    defaultActive: {
36
+      handler(newVal) {
37
+
38
+        this.activeIndex = newVal
39
+      },
40
+      immediate: true
41
+    }
42
+  },
43
+  methods: {
44
+    // 处理tab点击事件
45
+    handleTabClick(index, item) {
46
+      if (this.activeIndex === item.value) {
47
+        return
48
+      }
49
+
50
+      this.activeIndex = item.value;
51
+      
52
+      // 向外传递被点击的对象
53
+      this.$emit('tabChange', {
54
+        index,
55
+        item,
56
+        value: item.value || item.id || index
57
+      })
58
+    },
59
+
60
+    // 外部调用的方法:切换tab
61
+    switchTab(index) {
62
+      if (index >= 0 && index < this.tabList.length && index !== this.activeIndex) {
63
+        this.activeIndex = index
64
+        const item = this.tabList[index]
65
+        this.$emit('tabChange', {
66
+          index,
67
+          item,
68
+          value: item.value || item.id || index
69
+        })
70
+      }
71
+    },
72
+
73
+    // 外部调用的方法:获取当前激活的tab
74
+    getCurrentTab() {
75
+      return {
76
+        index: this.activeIndex,
77
+        item: this.tabList[this.activeIndex],
78
+        value: this.tabList[this.activeIndex]?.value || this.tabList[this.activeIndex]?.id || this.activeIndex
79
+      }
80
+    }
81
+  }
82
+}
83
+</script>
84
+
85
+<style lang="scss" scoped>
86
+.capsule-tab-container {
87
+  display: inline-flex;
88
+  background: #f5f5f5;
89
+  border-radius: 32rpx;
90
+  padding: 2rpx;
91
+  box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
92
+}
93
+
94
+.capsule-tab-item {
95
+  position: relative;
96
+  padding: 8rpx 30rpx;
97
+  border-radius: 30rpx;
98
+  cursor: pointer;
99
+  transition: all 0.3s ease;
100
+  user-select: none;
101
+
102
+  // 第一个和最后一个的特殊圆角处理
103
+  //   &.capsule-tab-first {
104
+  //     border-top-right-radius: 0;
105
+  //     border-bottom-right-radius: 0;
106
+  //   }
107
+
108
+  //   &.capsule-tab-last {
109
+  //     border-top-left-radius: 0;
110
+  //     border-bottom-left-radius: 0;
111
+  //   }
112
+
113
+  // 非激活状态
114
+  &:not(.capsule-tab-active) {
115
+    background: transparent;
116
+
117
+    .capsule-tab-text {
118
+      color: #666666;
119
+      font-weight: normal;
120
+    }
121
+
122
+
123
+  }
124
+
125
+  // 激活状态
126
+  &.capsule-tab-active {
127
+    background: #0299FA;
128
+    box-shadow: 0 2rpx 8rpx rgba(24, 144, 255, 0.3);
129
+
130
+    .capsule-tab-text {
131
+      color: #ffffff;
132
+     
133
+    }
134
+  }
135
+}
136
+
137
+.capsule-tab-text {
138
+  font-size: 22rpx;
139
+  line-height: 1;
140
+  transition: all 0.3s ease;
141
+}
142
+
143
+</style>

+ 387 - 0
src/pages/eikonStatistics/components/topInfo.vue

@@ -0,0 +1,387 @@
1
+<template>
2
+    <view class="top-info-wrapper">
3
+        <view class="top-info-container">
4
+        <view class="info-content">
5
+            <!-- 左侧信息区域 -->
6
+            <view class="left-info">
7
+                <!-- 上面:根据角色显示部门信息 -->
8
+                <view class="department-info">
9
+                    <text class="department-value">{{ currentDepartmentPath }}</text>
10
+                </view>
11
+
12
+                <!-- 下面:根据是否个人显示不同信息 -->
13
+                <view class="user-info">
14
+                    <text v-if="isPersonal" class="user-name">姓名:{{ currentLevelUserName }}</text>
15
+                    <text v-else class="stat-count">统计人数:{{ currentLevelUserCount }}</text>
16
+                </view>
17
+            </view>
18
+
19
+            <!-- 右侧切换按钮 -->
20
+            <view v-if="!userRoles.includes('SecurityCheck')&&currentTab != 'team'" class="right-switch" @click="openPopup">
21
+                <image src="/static/images/icon/switch.png" mode="aspectFit" class="switch-image" />
22
+            </view>
23
+        </view>
24
+
25
+        <!-- 切换弹窗 -->
26
+        <uni-popup ref="SelectPopup" type="center" :mask-click="false">
27
+            <view class="popup-container">
28
+                <view class="popup-header">
29
+                    <text class="popup-title">选择</text>
30
+                    <view class="popup-close" @click="closePopup">×</view>
31
+                </view>
32
+
33
+                <!-- 搜索框 -->
34
+                <view class="search-section">
35
+                    <uni-easyinput v-model="searchKeyword" :placeholder="userRoles.includes('banzuzhang') ? '请输入姓名搜索' : '请输入部门名称、姓名搜索'" :clearable="true" />
36
+                </view>
37
+
38
+                <!-- 部门树 -->
39
+                <view class="tree-section">
40
+                    <scroll-view scroll-y="true" class="tree-scroll">
41
+                        <mix-tree :list="filteredTreeData" :expandAll="!!searchKeyword" @treeItemClick="handleNodeSelect" />
42
+                    </scroll-view>
43
+                </view>
44
+
45
+                <!-- 按钮区域 -->
46
+                <view class="button-section">
47
+                    <view class="btn-cancel" @click="closePopup">取消</view>
48
+                    <view class="btn-confirm" @click="confirmSelection">确定</view>
49
+                </view>
50
+            </view>
51
+        </uni-popup>
52
+        </view>
53
+    </view>
54
+</template>
55
+
56
+<script>
57
+import { mapState, mapGetters } from 'vuex'
58
+import mixTree from '@/uni_modules/mix-tree/components/mix-tree/mix-tree.vue'
59
+import { getDeptUserTree,getUserInfoById } from '@/api/system/user'
60
+export default {
61
+    name: 'TopInfo',
62
+    components: {
63
+        mixTree
64
+    },
65
+    props:{ 
66
+        currentTab: {
67
+            type: String,
68
+            default: ''
69
+        }
70
+    },
71
+    data() {
72
+        return {
73
+            searchKeyword: '',
74
+            selectedNode: null,
75
+            // 模拟部门树数据
76
+            departmentTree: [],
77
+            allLabels: [],
78
+            // 存储通过currentLevelId获取的用户姓名
79
+            currentLevelUserName: ''
80
+        }
81
+    },
82
+    computed: {
83
+        ...mapState({
84
+            userInfo: state => state.user.userInfo,
85
+            userRoles: state => state.user.roles,
86
+            currentDepartmentPath: state => state.eikonLevel.currentDepartmentPath,
87
+            currentLevelId: state => state.eikonLevel.currentLevelId,
88
+            currentLevelUserCount: state => state.eikonLevel.currentLevelUserCount
89
+        }),
90
+        ...mapGetters('eikonLevel', ['isPersonal', 'isZhanZhang', 'isKeZhang', 'isBanZuZhang']),
91
+        // 获取部门标签文本
92
+        getDepartmentLabel() {
93
+            if (this.isBanZuZhang) return '班组'
94
+            if (this.isKeZhang) return '科室'
95
+            if (this.isZhanZhang) return '站'
96
+            return '部门'
97
+        },
98
+        
99
+        // 过滤后的树数据
100
+        filteredTreeData() {
101
+            if (!this.searchKeyword) {
102
+                // 没有搜索关键词时,设置第一层节点的showChild为true
103
+                return this.departmentTree.map(node => ({
104
+                    ...node,
105
+                    showChild: true  // 设置第一层节点为展开状态
106
+                }))
107
+            }
108
+
109
+            const filterTree = (nodes) => {
110
+                if (!nodes || !Array.isArray(nodes)) {
111
+                    return []
112
+                }
113
+
114
+                return nodes.filter(node => {
115
+                    const matches = node.label && node.label.toLowerCase().includes(this.searchKeyword.toLowerCase())
116
+                    if (node.children && node.children.length > 0) {
117
+                        const filteredChildren = filterTree(node.children)
118
+                        return matches || filteredChildren.length > 0
119
+                    }
120
+                    return matches
121
+                }).map(node => ({
122
+                    ...node,
123
+                    children: node.children && node.children.length > 0 ? filterTree(node.children) : undefined
124
+                }))
125
+            }
126
+
127
+            return filterTree(this.departmentTree)
128
+        }
129
+    },
130
+    methods: {
131
+        // 根据currentLevelId获取用户信息
132
+        async getUserInfoByLevelId() {
133
+            if (this.isPersonal && this.currentLevelId) {
134
+                try {
135
+                    const res = await getUserInfoById(this.currentLevelId)
136
+                    if (res.data) {
137
+                        this.currentLevelUserName = res.data.nickName || res.data.userName || '未知用户'
138
+                    }
139
+                } catch (error) {
140
+                    console.error('获取用户信息失败:', error)
141
+                    this.currentLevelUserName = '未知用户'
142
+                }
143
+            } else {
144
+                this.currentLevelUserName = ''
145
+            }
146
+        },
147
+        
148
+        // 打开弹窗
149
+        openPopup() {
150
+            this.$refs.SelectPopup.open();
151
+
152
+            this.searchKeyword = ''
153
+            this.selectedNode = null
154
+            this.getDeptTree()
155
+        },
156
+
157
+        // 关闭弹窗
158
+        closePopup() {
159
+            this.$refs.SelectPopup.close();
160
+            this.searchKeyword = ''
161
+            this.selectedNode = null
162
+        },
163
+        // 节点选择
164
+        handleNodeSelect(item) {
165
+            console.log('item', item)
166
+            // mix-tree组件返回的是扁平化后的节点数据,包含id、name、parentId等信息
167
+            this.selectedNode = item
168
+        },
169
+        // 确认选择
170
+        confirmSelection() {
171
+            if (this.selectedNode) {
172
+                // 获取选中节点的所有父级 label(包含本级)
173
+                const allLabels = this.getAllLabels(this.selectedNode)
174
+                console.log('选中节点的完整路径 label:', allLabels)
175
+                this.allLabels = allLabels;
176
+                // 将完整路径信息传递给事件
177
+
178
+                this.$emit('department-change', this.selectedNode)
179
+            }
180
+            this.closePopup()
181
+        },
182
+        // 获取节点在树结构中的所有 label(包含本级)
183
+        getAllLabels(node) {
184
+            console.log('node', node)
185
+            if (!node || !this.departmentTree.length) {
186
+                return []
187
+            }
188
+
189
+            const findNodePath = (treeNodes, targetId, path = []) => {
190
+                for (const treeNode of treeNodes) {
191
+                    // 如果找到目标节点,返回包含本级 label 的路径
192
+                    if (treeNode.id === targetId) {
193
+                        return [...path, treeNode.label]
194
+                    }
195
+
196
+                    // 如果有子节点,递归查找
197
+                    if (treeNode.children && treeNode.children.length > 0) {
198
+                        const newPath = [...path, treeNode.label]
199
+                        const result = findNodePath(treeNode.children, targetId, newPath)
200
+                        if (result) {
201
+                            return result
202
+                        }
203
+                    }
204
+                }
205
+                return null
206
+            }
207
+
208
+            const fullPath = findNodePath(this.departmentTree, node.id)
209
+
210
+            return fullPath || []
211
+        },
212
+        async getDeptTree() {
213
+
214
+
215
+            let params = {}
216
+            if (this.userRoles.includes('test')) {
217
+                params.deptId = this.userInfo.stationId
218
+            }
219
+            if (this.userRoles.includes('kezhang')) {
220
+                params.deptId = this.userInfo.departmentId
221
+            }
222
+            if (this.userRoles.includes('banzuzhang')) {
223
+                params.deptId = this.userInfo.teamsId
224
+            }
225
+            let res = await getDeptUserTree(params)
226
+            this.departmentTree = res.data
227
+        }
228
+    },
229
+    
230
+   
231
+    
232
+    // 监听currentLevelId和isPersonal的变化
233
+    watch: {
234
+        currentLevelId: {
235
+            handler() {
236
+                this.getUserInfoByLevelId()
237
+            },
238
+            immediate: true
239
+        },
240
+       
241
+    }
242
+}
243
+</script>
244
+
245
+
246
+
247
+
248
+
249
+<style lang="scss" scoped>
250
+.top-info-wrapper {
251
+    width: 100%;
252
+    padding: 40rpx;
253
+}
254
+
255
+.top-info-container {
256
+    background: #fff;
257
+    border-radius: 16rpx;
258
+    padding: 32rpx;
259
+    box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.08);
260
+}
261
+
262
+.info-content {
263
+    display: flex;
264
+    justify-content: space-between;
265
+    align-items: center;
266
+}
267
+
268
+.left-info {
269
+    flex: 1;
270
+}
271
+
272
+.department-info {
273
+    margin-bottom: 16rpx;
274
+
275
+    .department-label {
276
+        font-size: 28rpx;
277
+        color: #666;
278
+        font-weight: 500;
279
+    }
280
+
281
+    .department-value {
282
+        font-size: 32rpx;
283
+        color: #333;
284
+        font-weight: 600;
285
+    }
286
+}
287
+
288
+.user-info {
289
+
290
+    .user-name,
291
+    .stat-count {
292
+        font-size: 26rpx;
293
+        color: #999;
294
+    }
295
+}
296
+
297
+.right-switch {
298
+
299
+
300
+    .switch-image {
301
+        width: 40rpx;
302
+        height: 40rpx;
303
+    }
304
+
305
+
306
+
307
+}
308
+
309
+.popup-container {
310
+    width: 600rpx;
311
+    background: #fff;
312
+    border-radius: 16rpx;
313
+    padding: 32rpx;
314
+}
315
+
316
+.popup-header {
317
+    display: flex;
318
+    justify-content: space-between;
319
+    align-items: center;
320
+    margin-bottom: 24rpx;
321
+
322
+    .popup-title {
323
+        font-size: 32rpx;
324
+        font-weight: 600;
325
+        color: #333;
326
+    }
327
+
328
+    .popup-close {
329
+        font-size: 40rpx;
330
+        color: #999;
331
+        cursor: pointer;
332
+
333
+        &:active {
334
+            color: #666;
335
+        }
336
+    }
337
+}
338
+
339
+.search-section {
340
+    margin-bottom: 24rpx;
341
+}
342
+
343
+.tree-section {
344
+
345
+    margin-bottom: 32rpx;
346
+
347
+    .tree-scroll {
348
+        height: 500rpx;
349
+    }
350
+}
351
+
352
+/* mix-tree组件自带样式,无需额外定义 */
353
+
354
+.button-section {
355
+    display: flex;
356
+    justify-content: flex-end;
357
+    gap: 24rpx;
358
+
359
+    .btn-cancel,
360
+    .btn-confirm {
361
+        padding: 20rpx 40rpx;
362
+        border-radius: 8rpx;
363
+        font-size: 28rpx;
364
+        font-weight: 500;
365
+        border: none;
366
+        cursor: pointer;
367
+    }
368
+
369
+    .btn-cancel {
370
+        background: #f5f5f5;
371
+        color: #666;
372
+
373
+        &:active {
374
+            background: #e8e8e8;
375
+        }
376
+    }
377
+
378
+    .btn-confirm {
379
+        background: #4873E3;
380
+        color: #fff;
381
+
382
+        &:active {
383
+            background: #3a5bc7;
384
+        }
385
+    }
386
+}
387
+</style>

+ 528 - 0
src/pages/eikonStatistics/index.vue

@@ -0,0 +1,528 @@
1
+<template>
2
+  <home-container ref="homeContainer" :customStyle="{ background: 'none' }" @scroll="handleScroll"
3
+    @scroll-end="handleScrollEnd">
4
+    <div class="eikon-statistics-container">
5
+      <!-- 个人模式显示选项卡 -->
6
+      <h-tabs v-if="hasTabs" v-model="currentTab" :tabs="tabList" value-key="type" label-key="title"
7
+        @change="handleTabChange" class="h-tabs-fixed" />
8
+
9
+      <topInfo @department-change="handleDepartmentChange"
10
+        :class="['top-info-fixed', hasTabs ? 'with-tabs' : 'without-tabs']" :currentTab="currentTab" />
11
+
12
+      <!-- 总体概览和详细内容区域 -->
13
+      <div class="content-section" :class="hasTabs ? 'with-tabs' : 'without-tabs'">
14
+        <!-- 总体概览 -->
15
+        <div class="overview-section">
16
+          <div class="section-title">总体概览</div>
17
+          <general-overview :overview-data="overviewData" />
18
+        </div>
19
+
20
+        <!-- 详细内容 -->
21
+        <div class="detail-section">
22
+          <div class="section-title">详细内容</div>
23
+
24
+          <!-- 资质能力 -->
25
+          <qualification />
26
+
27
+          <!-- 工作履历 -->
28
+          <work-experience />
29
+
30
+          <!-- 出勤投入 -->
31
+          <attendance />
32
+
33
+          <!-- 工作产出 -->
34
+          <work-output />
35
+
36
+          <!-- 标准执行 -->
37
+          <standard-execution />
38
+
39
+          <!-- 学习成长 -->
40
+          <learning-growth />
41
+
42
+          <!-- 协同配合 -->
43
+          <collaboration />
44
+
45
+          <!-- 主观印象 -->
46
+          <subjective-impression />
47
+
48
+          <!-- 成员明细 -->
49
+          <member-details v-if="!isPersonal" @nameClick="handleNameClick" />
50
+        </div>
51
+      </div>
52
+    </div>
53
+
54
+    <!-- 返回上一级按钮 -->
55
+    <div v-if="hasOldLevel" class="back-button" :class="{ 'back-button-hidden': isScrolling }">
56
+      <div class="back-circle" @click="goBack">
57
+        <div class="back-icon">
58
+          <uni-icons type="back" size="40rpx" color="#ffffff"></uni-icons>
59
+        </div>
60
+        <div class="back-text">返回上一级</div>
61
+      </div>
62
+    </div>
63
+  </home-container>
64
+</template>
65
+
66
+<script>
67
+import { mapActions, mapState, mapGetters } from 'vuex'
68
+import HomeContainer from "@/components/HomeContainer.vue";
69
+import HTabs from "@/components/h-tabs/h-tabs.vue";
70
+import { getInfo } from "@/api/login"
71
+import topInfo from "@/pages/eikonStatistics/components/topInfo.vue"
72
+import GeneralOverview from "@/pages/eikonStatistics/components/general-overview.vue"
73
+import StandardExecution from "@/pages/eikonStatistics/components/standard-execution.vue"
74
+import Collaboration from "@/pages/eikonStatistics/components/collaboration.vue"
75
+import Qualification from "@/pages/eikonStatistics/components/Qualification.vue"
76
+import WorkExperience from "@/pages/eikonStatistics/components/WorkExperience.vue"
77
+import Attendance from "@/pages/eikonStatistics/components/Attendance.vue"
78
+import WorkOutput from "@/pages/eikonStatistics/components/WorkOutput.vue"
79
+import LearningGrowth from "@/pages/eikonStatistics/components/LearningGrowth.vue"
80
+import SubjectiveImpression from "@/pages/eikonStatistics/components/SubjectiveImpression.vue"
81
+import MemberDetails from "@/pages/eikonStatistics/components/MemberDetails.vue"
82
+import { getModuleMetrics } from "@/api/eikonStatistics/eikonStatistics"
83
+
84
+export default {
85
+  components: { HomeContainer, HTabs, topInfo, GeneralOverview, StandardExecution, Collaboration, Qualification, WorkExperience, Attendance, WorkOutput, LearningGrowth, SubjectiveImpression, MemberDetails },
86
+  data() {
87
+    return {
88
+      currentTab: 'personal', // 当前选中的选项卡
89
+      isScrolling: false, // 是否正在滚动
90
+      // 选项卡列表
91
+      tabList: [
92
+        {
93
+          type: 'personal',
94
+          title: '个人'
95
+        },
96
+        {
97
+          type: 'team',
98
+          title: '班组'
99
+        }
100
+      ],
101
+      // 概览数据
102
+      overviewData: {
103
+        // 左侧数据
104
+        qualificationAbility: 0,
105
+        attendanceInput: 0,
106
+        standardExecution: 0,
107
+        coordination: 0,
108
+        // 右侧数据
109
+        workHistory: 0,
110
+        workOutput: 0,
111
+        learningGrowth: 0,
112
+        subjectiveImpression: 0
113
+      },
114
+
115
+    };
116
+  },
117
+  computed: {
118
+    ...mapState({
119
+      userInfo: state => state.user.userInfo,
120
+      userRoles: state => state.user.roles || [],
121
+      currentLevel: state => state.eikonLevel.currentLevel,
122
+      currentLevelId: state => state.eikonLevel.currentLevelId,
123
+      oldLevel: state => state.eikonLevel.oldLevel,
124
+      oldLevelId: state => state.eikonLevel.oldLevelId,
125
+      allData: state => state.eikonLevel.allData,
126
+      systemData: state => state.eikonLevel.systemData
127
+    }),
128
+    ...mapGetters('eikonLevel', ['isPersonal', 'isZhanZhang', 'isKeZhang', 'isBanZuZhang']),
129
+    hasOldLevel() {
130
+      return this.oldLevel && this.oldLevelId
131
+    },
132
+    hasTabs() {
133
+      return this.userRoles.includes('SecurityCheck') || this.userRoles.includes('banzuzhang')
134
+    }
135
+  },
136
+  async onLoad() {
137
+    // 设置初始级别
138
+    await this.setInitialLevel();
139
+    // 加载初始数据
140
+    await this.loadPageData();
141
+    console.log(this.userInfo, "userInfo")
142
+  },
143
+
144
+  methods: {
145
+    ...mapActions('eikonLevel', ['changeLevel', 'initLevel', 'saveAllData', 'loadData']),
146
+    //根据不同的登录角色,赋予初始级别
147
+    async setInitialLevel() {
148
+      let isZhanZhang = this.userRoles && this.userRoles.includes('test')
149
+      let isKeZhang = this.userRoles && this.userRoles.includes('kezhang')
150
+      let isZhiJian = this.userRoles && this.userRoles.includes('zhijianke')
151
+
152
+      if (isKeZhang || isZhanZhang ||isZhiJian) {
153
+        this.initLevel({ level: isKeZhang ? 'department' : 'station', levelId: isKeZhang ? this.userInfo.departmentId : this.userInfo.stationId })
154
+      } else {
155
+        this.initLevel({ level: 'personal', levelId: this.userInfo.userId })
156
+      }
157
+
158
+    },
159
+
160
+    // 选项卡切换处理
161
+    async handleTabChange(tabType, tabItem) {
162
+      this.currentTab = tabType;
163
+      if (tabType === 'team') {
164
+        this.initLevel({ level: 'team', levelId: this.userInfo.teamsId })
165
+      } else {
166
+        this.initLevel({ level: 'personal', levelId: this.userInfo.userId })
167
+      }
168
+      // 根据选项卡类型加载不同的数据
169
+      await this.loadPageData();
170
+
171
+      // 滚动到顶部
172
+      this.scrollToTop()
173
+    },
174
+    handleNameClick(row) {
175
+      // 滚动到顶部
176
+      this.scrollToTop()
177
+    },
178
+    scrollToTop() {
179
+      this.$nextTick(() => {
180
+        if (this.$refs.homeContainer && this.$refs.homeContainer.scrollToTop) {
181
+          this.$refs.homeContainer.scrollToTop();
182
+        }
183
+      });
184
+    },
185
+    // 部门选择变化处理
186
+    async handleDepartmentChange(selectedDepartment) {
187
+      console.log('部门选择变化:', selectedDepartment);
188
+      const { deptType } = selectedDepartment
189
+      if (deptType == 'STATION') {
190
+        this.initLevel({ level: 'station', levelId: selectedDepartment.id })
191
+      }
192
+      if (deptType == 'DEPARTMENT') {
193
+        this.initLevel({ level: 'department', levelId: selectedDepartment.id })
194
+      }
195
+      if (deptType == 'TEAMS') {
196
+        this.initLevel({ level: 'team', levelId: selectedDepartment.id })
197
+      }
198
+      if (!deptType) {
199
+        this.initLevel({ level: 'personal', levelId: selectedDepartment.id })
200
+      }
201
+      // 重新加载数据
202
+      await this.loadPageData();
203
+    },
204
+
205
+    // 加载数据方法
206
+    async loadPageData() {
207
+      try {
208
+        await this.loadData()
209
+      } catch (error) {
210
+        console.error('加载数据失败:', error);
211
+        uni.showToast({
212
+          title: '数据加载失败',
213
+          icon: 'none'
214
+        });
215
+      }
216
+    },
217
+
218
+    // 滚动处理
219
+    handleScroll() {
220
+      this.isScrolling = true
221
+    },
222
+
223
+    // 滚动停止处理
224
+    handleScrollEnd() {
225
+      this.isScrolling = false
226
+    },
227
+
228
+    // 返回上一级
229
+    async goBack() {
230
+      this.initLevel({
231
+        level: this.oldLevel,
232
+        levelId: this.oldLevelId
233
+      })
234
+      // 重新加载数据
235
+      await this.loadPageData();
236
+    }
237
+
238
+  }
239
+}</script>
240
+
241
+<style lang="scss" scoped>
242
+.eikon-statistics-container {
243
+  padding: 20rpx;
244
+}
245
+
246
+// 固定选项卡样式
247
+.h-tabs-fixed {
248
+  padding: 22rpx 44rpx;
249
+  position: fixed;
250
+  top: 78rpx;
251
+  left: 0;
252
+  right: 0;
253
+  z-index: 998;
254
+  background: #ffffff;
255
+
256
+}
257
+
258
+// 固定顶部信息样式
259
+.top-info-fixed {
260
+  position: fixed;
261
+  left: 0;
262
+  right: 0;
263
+  z-index: 998;
264
+  background: #ffffff;
265
+ 
266
+}
267
+
268
+// 当h-tabs显示时,topInfo的top位置
269
+.top-info-fixed.with-tabs {
270
+  top: 160rpx;
271
+}
272
+
273
+// 当h-tabs不显示时,topInfo的top位置
274
+.top-info-fixed.without-tabs {
275
+  top: 78rpx;
276
+
277
+}
278
+
279
+// 总体概览和详细内容区域样式
280
+.content-section {
281
+  /* 为固定的h-tabs和topInfo预留空间 */
282
+}
283
+
284
+// 当h-tabs显示时,content-section的margin-top
285
+.content-section.with-tabs {
286
+  margin-top: 290rpx;
287
+}
288
+
289
+// 当h-tabs不显示时,content-section的margin-top
290
+.content-section.without-tabs {
291
+  margin-top: 210rpx;
292
+}
293
+
294
+.overview-section,
295
+.detail-section {
296
+  margin-bottom: 40rpx;
297
+}
298
+
299
+.section-title {
300
+  font-size: 32rpx;
301
+  font-weight: bold;
302
+  color: #333333;
303
+  text-align: left;
304
+  margin-bottom: 24rpx;
305
+}
306
+
307
+.overview-content {
308
+  display: flex;
309
+  justify-content: space-between;
310
+  background: #ffffff;
311
+  border-radius: 16rpx;
312
+  padding: 32rpx;
313
+  box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
314
+}
315
+
316
+.overview-item {
317
+  text-align: center;
318
+  flex: 1;
319
+}
320
+
321
+.overview-number {
322
+  font-size: 48rpx;
323
+  font-weight: bold;
324
+  color: #1890ff;
325
+  margin-bottom: 8rpx;
326
+}
327
+
328
+.overview-label {
329
+  font-size: 24rpx;
330
+  color: #666666;
331
+}
332
+
333
+
334
+
335
+.detail-item {
336
+  padding: 24rpx 0;
337
+  border-bottom: 1rpx solid #f0f0f0;
338
+
339
+  &:last-child {
340
+    border-bottom: none;
341
+  }
342
+}
343
+
344
+.detail-header {
345
+  display: flex;
346
+  justify-content: space-between;
347
+  align-items: center;
348
+  margin-bottom: 16rpx;
349
+}
350
+
351
+.detail-title {
352
+  font-size: 28rpx;
353
+  font-weight: 500;
354
+  color: #333333;
355
+}
356
+
357
+.detail-time {
358
+  font-size: 24rpx;
359
+  color: #999999;
360
+}
361
+
362
+.detail-description {
363
+  font-size: 26rpx;
364
+  color: #666666;
365
+  line-height: 1.5;
366
+}
367
+
368
+/* 折叠面板内容样式 */
369
+.collapse-content {
370
+  padding: 20rpx;
371
+}
372
+
373
+.content-item {
374
+  display: flex;
375
+  align-items: flex-start;
376
+  margin-bottom: 16rpx;
377
+
378
+  &:last-child {
379
+    margin-bottom: 0;
380
+  }
381
+}
382
+
383
+.item-label {
384
+  font-size: 26rpx;
385
+  color: #333333;
386
+  font-weight: 500;
387
+  min-width: 120rpx;
388
+  margin-right: 16rpx;
389
+}
390
+
391
+.item-value {
392
+  font-size: 26rpx;
393
+  color: #999999;
394
+  flex: 1;
395
+  line-height: 1.5;
396
+}
397
+
398
+/* 查获排名样式 */
399
+.seize-ranking-section {
400
+  margin-top: 30rpx;
401
+  border-top: 1rpx solid #f0f0f0;
402
+  padding-top: 20rpx;
403
+}
404
+
405
+.ranking-header {
406
+  display: flex;
407
+  justify-content: space-between;
408
+  align-items: center;
409
+  margin-bottom: 20rpx;
410
+}
411
+
412
+.ranking-title {
413
+  font-size: 28rpx;
414
+  font-weight: bold;
415
+  color: #333333;
416
+}
417
+
418
+.ranking-content {
419
+  background: #f8f9fa;
420
+  border-radius: 12rpx;
421
+  padding: 20rpx;
422
+}
423
+
424
+.ranking-list {
425
+  display: flex;
426
+  flex-direction: column;
427
+  gap: 16rpx;
428
+}
429
+
430
+.ranking-item {
431
+  display: flex;
432
+  align-items: center;
433
+  padding: 12rpx 16rpx;
434
+  background: #ffffff;
435
+  border-radius: 8rpx;
436
+  box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
437
+}
438
+
439
+.rank-number {
440
+  width: 40rpx;
441
+  height: 40rpx;
442
+  background: #4873E3;
443
+  color: #ffffff;
444
+  border-radius: 50%;
445
+  display: flex;
446
+  align-items: center;
447
+  justify-content: center;
448
+  font-size: 24rpx;
449
+  font-weight: bold;
450
+  margin-right: 16rpx;
451
+}
452
+
453
+.rank-name {
454
+  flex: 1;
455
+  font-size: 26rpx;
456
+  color: #333333;
457
+  font-weight: 500;
458
+}
459
+
460
+.rank-count {
461
+  font-size: 26rpx;
462
+  color: #4873E3;
463
+  font-weight: bold;
464
+}
465
+
466
+/* 查获排名进度条样式 */
467
+.ranking-progress {
468
+  margin-top: 20rpx;
469
+  padding: 20rpx;
470
+  background: #ffffff;
471
+  border-radius: 12rpx;
472
+
473
+  .progress-text {
474
+    font-size: 28rpx;
475
+    color: #333;
476
+    font-weight: 500;
477
+  }
478
+}
479
+
480
+/* 学习成长样式 */
481
+.learning-ranking-section {}
482
+
483
+/* 返回按钮样式 */
484
+.back-button {
485
+  position: fixed;
486
+  right: 30rpx;
487
+  bottom: 120rpx;
488
+  z-index: 999;
489
+  transition: transform 0.3s ease-in-out;
490
+}
491
+
492
+.back-button-hidden {
493
+  transform: translateX(120rpx);
494
+}
495
+
496
+.back-circle {
497
+  width: 120rpx;
498
+  height: 120rpx;
499
+  background: linear-gradient(135deg, #4873E3 0%, #6A8EE8 100%);
500
+  border-radius: 50%;
501
+  display: flex;
502
+  flex-direction: column;
503
+  align-items: center;
504
+  justify-content: center;
505
+  box-shadow: 0 8rpx 24rpx rgba(72, 115, 227, 0.3);
506
+  cursor: pointer;
507
+  transition: all 0.3s ease;
508
+
509
+}
510
+
511
+.back-circle:active {
512
+  transform: scale(0.95);
513
+  box-shadow: 0 4rpx 12rpx rgba(72, 115, 227, 0.4);
514
+}
515
+
516
+.back-icon {
517
+  margin-bottom: 8rpx;
518
+}
519
+
520
+.back-text {
521
+  font-size: 20rpx;
522
+  color: #ffffff;
523
+  font-weight: 500;
524
+  text-align: center;
525
+  margin-bottom: 20rpx;
526
+  line-height: 1.2;
527
+}
528
+</style>

+ 426 - 0
src/pages/exam-list/index.vue

@@ -0,0 +1,426 @@
1
+<template>
2
+  <home-container>
3
+    <div class="exam-list-container">
4
+      <h-search v-model="keyword" placeholder="请输入测试名称" :showAction="true" actionText="搜索" @search="handleSearch"
5
+        @custom="handleSearch" @clear="handleClear" />
6
+
7
+      <!-- 每日答题入口 -->
8
+      <!-- <div class="daily-exam-entry" @click="gotoDailyExam">
9
+        <div class="daily-exam-icon">📝</div>
10
+        <div class="daily-exam-content">
11
+          <div class="daily-exam-title">每日答题</div>
12
+          <div class="daily-exam-desc">每天坚持练习,提升业务能力</div>
13
+        </div>
14
+        <div class="daily-exam-arrow">›</div>
15
+      </div> -->
16
+
17
+      <div class="tabs">
18
+        <div v-for="item in tabList" :key="item.type" :class="{ active: item.type === currentTab }" class="tabs-item"
19
+          @click="tabClick(item)">
20
+          {{ item.title }}
21
+        </div>
22
+      </div>
23
+
24
+      <div class="exam-list">
25
+        <div v-for="item in filteredExamList" :key="item.id" class="exam-item">
26
+          <div class="exam-item-title">
27
+            {{ item.title }}
28
+            <div :class="item.status" class="exam-item-status">
29
+              {{ statusMap[item.status] }}
30
+            </div>
31
+          </div>
32
+          <div class="exam-item-desc">
33
+            <div class="exam-item-desc-item time">{{ item.time/60 }}分钟</div>
34
+            <!-- <div class="exam-item-desc-item date">{{ item.date }}</div> -->
35
+            <!-- <div class="exam-item-desc-item level">{{ item.level }}</div> -->
36
+          </div>
37
+          <div class="exam-item-btn">
38
+            <div class="exam-item-btn-score">
39
+              <span>{{ item.questionNum }}题</span>
40
+              <span v-if="item.status === 'done'">
41
+                <i>{{ item.score }}分</i>
42
+                /{{ item.allScore }}分
43
+              </span>
44
+              <span v-if="item.status !== 'done'">{{ item.allScore }}分</span>
45
+            </div>
46
+            <span v-if="item.status === 'pending'" class="btn" @click="handleSubmit(item)">开始测试</span>
47
+            <span v-if="item.status === 'done'" class="btn done" @click="handleSubmit(item)">查看详情</span>
48
+            <span v-if="item.status === 'timeout'" class="btn timeout">已过期</span>
49
+          </div>
50
+        </div>
51
+      </div>
52
+    </div>
53
+  </home-container>
54
+</template>
55
+
56
+<script>
57
+import HomeContainer from "@/components/HomeContainer.vue";
58
+import { serializeData } from "@/utils/common";
59
+import { getExamRecordList } from "@/api/exam/exam"
60
+import { getUserExamRecordList } from "@/api/exam/exam"
61
+
62
+
63
+
64
+export default {
65
+  components: { HomeContainer, },
66
+  data() {
67
+    return {
68
+      keyword: '',
69
+      filteredExamList: [],
70
+
71
+      tabList: [
72
+        {
73
+          type: 'all',
74
+          title: '全部'
75
+        },
76
+        {
77
+          type: 'pending',
78
+          title: '待测试'
79
+        },
80
+        {
81
+          type: 'done',
82
+          title: '已完成'
83
+        },
84
+        {
85
+          type: 'timeout',
86
+          title: '已过期'
87
+        }
88
+      ],
89
+      currentTab: 'all',
90
+      statusMap: {
91
+        pending: '待测试',
92
+        done: '已完成',
93
+        timeout: '已过期'
94
+      },
95
+      examList: [
96
+        // {
97
+        //   id: 1,
98
+        //   title: '2025年第二季度安全知识考核',
99
+        //   status: 'pending',
100
+        //   time: '60分钟',
101
+        //   date: '截止日期',
102
+        //   level: '中级',
103
+        //   questionNum: 20,
104
+        //   allScore: '100'
105
+        // },
106
+        // {
107
+        //   id: 2,
108
+        //   title: '行李安检新技术培训考核',
109
+        //   status: 'done',
110
+        //   time: '60分钟',
111
+        //   date: '截止日期',
112
+        //   level: '中级',
113
+        //   questionNum: 20,
114
+        //   allScore: '100',
115
+        //   score: '80'
116
+        // },
117
+        // {
118
+        //   id: 3,
119
+        //   title: '2025年第二季度安全知识考核',
120
+        //   status: 'timeout',
121
+        //   time: '60分钟',
122
+        //   date: '截止日期',
123
+        //   level: '中级',
124
+        //   questionNum: 20,
125
+        //   allScore: '100'
126
+        // }
127
+      ]
128
+    }
129
+  },
130
+  mounted() {
131
+    // this.initDate(); // 初始化数据
132
+    // this.filterExamList(); // 首次过滤
133
+  },
134
+  async onLoad(options) {
135
+    this.status = options.status;
136
+    await this.initDate();
137
+    this.filterExamList(); // 首次过滤
138
+
139
+
140
+  },
141
+  methods: {
142
+    handleSearch(val) {
143
+      console.log("val",val)
144
+      if (!val) {
145
+        this.filteredExamList = [...this.examList];
146
+        return;
147
+      }
148
+      // 本地模糊匹配
149
+      this.filteredExamList = this.examList.filter(item =>
150
+        item.title.includes(val)
151
+      );
152
+      console.log("this.filteredExamList",this.filteredExamList)
153
+    },
154
+    handleClear() {
155
+      this.keyword = '';
156
+      this.filteredExamList = [...this.examList];
157
+    },
158
+    tabClick(tab) {
159
+      this.currentTab = tab.type; // 记录当前激活的 tab
160
+      this.filterExamList();
161
+    },
162
+    filterExamList() {
163
+      if (this.currentTab === 'all') {
164
+        // 显示全部
165
+        this.filteredExamList = [...this.examList];
166
+      } else {
167
+        // 只显示当前状态
168
+        this.filteredExamList = this.examList.filter(item => item.status === this.currentTab);
169
+      }
170
+    },
171
+    handleSubmit(item) {
172
+      console.info("🚀🚀 ~ file:index line:122 -----", item)
173
+      uni.navigateTo({
174
+        url: '/pages/exam/index' + serializeData({
175
+          id: item.id,
176
+          status: item.status,
177
+          erId: item.erId,
178
+          exTime: item.time/60
179
+        }),
180
+      });
181
+    },
182
+    gotoDailyExam() {
183
+      uni.navigateTo({
184
+        url: '/pages/daily-exam/task-list/index'
185
+      });
186
+    },
187
+    async initDate() {
188
+      try {
189
+
190
+        const response1 = await getUserExamRecordList({ "clientType": "client_type_kg_app" });
191
+        const nameToIdMap = new Map(
192
+          (response1.model?.list || []).map(r => [r.erExId, {"erId":r.erId,"score":r.erScore}])
193
+        );
194
+        console.log("nameToIdMap", nameToIdMap)
195
+
196
+        const response = await getExamRecordList({ "clientType": "client_type_kg_app", "pageNum": 1, "pageSize": 20 });
197
+        console.log("response", response)
198
+        this.examList = response.model.list.map(item => ({
199
+          id: item.exId,
200
+          title: item.exName,
201
+          status: nameToIdMap.has(item.exId) ? 'done' : 'pending',
202
+          time: item.exExamTime,
203
+          date: '截止日期',
204
+          level: '中级',
205
+          questionNum: item.tpQuesNum,
206
+          allScore: item.tpTotalScore,
207
+          score: nameToIdMap.get(item.exId)?.score || 0,
208
+          erId: nameToIdMap.get(item.exId)?.erId || null
209
+        }));
210
+      } catch (error) {
211
+        console.error('获取测试记录失败', error);
212
+      }
213
+    }
214
+  }
215
+}
216
+</script>
217
+
218
+<style lang="scss" scoped>
219
+.exam-list-container {
220
+  padding: 200rpx 0 0;
221
+
222
+  .daily-exam-entry {
223
+    display: flex;
224
+    align-items: center;
225
+    padding: 32rpx;
226
+    margin: 32rpx 0;
227
+    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
228
+    border-radius: 16rpx;
229
+    box-shadow: 0 8rpx 20rpx 0 rgba(102, 126, 234, 0.3);
230
+
231
+    .daily-exam-icon {
232
+      width: 80rpx;
233
+      height: 80rpx;
234
+      display: flex;
235
+      align-items: center;
236
+      justify-content: center;
237
+      font-size: 48rpx;
238
+      background: rgba(255, 255, 255, 0.2);
239
+      border-radius: 16rpx;
240
+      margin-right: 24rpx;
241
+    }
242
+
243
+    .daily-exam-content {
244
+      flex: 1;
245
+
246
+      .daily-exam-title {
247
+        font-size: 32rpx;
248
+        font-weight: bold;
249
+        color: #FFFFFF;
250
+        line-height: 40rpx;
251
+        margin-bottom: 8rpx;
252
+      }
253
+
254
+      .daily-exam-desc {
255
+        font-size: 24rpx;
256
+        color: rgba(255, 255, 255, 0.8);
257
+        line-height: 32rpx;
258
+      }
259
+    }
260
+
261
+    .daily-exam-arrow {
262
+      font-size: 64rpx;
263
+      color: rgba(255, 255, 255, 0.6);
264
+      line-height: 1;
265
+    }
266
+  }
267
+
268
+  .tabs {
269
+    display: flex;
270
+    padding: 62rpx 0 32rpx;
271
+    margin-top: 32rpx;
272
+    font-size: 28rpx;
273
+    color: #666666;
274
+    line-height: 32rpx;
275
+    gap: 48rpx;
276
+
277
+    .tabs-item {
278
+      position: relative;
279
+
280
+
281
+      &:after {
282
+        content: "";
283
+        position: absolute;
284
+        bottom: -12rpx;
285
+        left: 0;
286
+        width: 100%;
287
+        height: 6rpx;
288
+        background: #fff;
289
+        border-radius: 12rpx;
290
+        ;
291
+      }
292
+
293
+      &.active {
294
+        font-size: 36rpx;
295
+        color: #2A70D1;
296
+        line-height: 42rpx;
297
+
298
+        &:after {
299
+          background: #2A70D1;
300
+        }
301
+      }
302
+    }
303
+  }
304
+
305
+  .exam-list {
306
+    .exam-item {
307
+      display: flex;
308
+      flex-flow: column;
309
+      justify-content: space-between;
310
+      padding: 40rpx;
311
+      background: #FFFFFF;
312
+      box-shadow: 0 8rpx 20rpx 0 rgba(0, 0, 0, 0.08);
313
+      border-radius: 16rpx;
314
+      border: 2rpx solid #F0F8FF;
315
+      margin-top: 32rpx;
316
+    }
317
+
318
+    .exam-item-title {
319
+      display: flex;
320
+      justify-content: space-between;
321
+      font-size: 32rpx;
322
+      color: #333333;
323
+      line-height: 38rpx;
324
+
325
+      .exam-item-status {
326
+        background: rgba(42, 112, 209, 0.12);
327
+        border-radius: 80rpx;
328
+        padding: 8rpx 28rpx;
329
+        font-size: 24rpx;
330
+        color: #2A70D1;
331
+        line-height: 28rpx;
332
+
333
+        &.done {
334
+          background: rgba(78, 166, 26, 0.12);
335
+          color: #4EA61A;
336
+        }
337
+
338
+        &.timeout {
339
+          background: rgba(234, 73, 73, 0.12);
340
+          color: #EA4949;
341
+        }
342
+      }
343
+    }
344
+
345
+    .exam-item-desc {
346
+      display: flex;
347
+      gap: 48rpx;
348
+      margin: 40rpx 0 30rpx;
349
+
350
+      .exam-item-desc-item {
351
+        display: flex;
352
+        padding-left: 40rpx;
353
+        position: relative;
354
+
355
+        &:after {
356
+          content: "";
357
+          width: 32rpx;
358
+          height: 32rpx;
359
+          position: absolute;
360
+          left: 0;
361
+          top: 50%;
362
+          transform: translateY(-50%);
363
+          background: url("../../static/images/clock-icon.png");
364
+          background-size: 32rpx;
365
+        }
366
+
367
+        &.date {
368
+          &:after {
369
+            background-image: url("../../static/images/date-icon.png");
370
+          }
371
+        }
372
+
373
+        &.level {
374
+          &:after {
375
+            background-image: url("../../static/images/level-icon.png");
376
+          }
377
+        }
378
+      }
379
+    }
380
+
381
+    .exam-item-btn {
382
+      display: flex;
383
+      justify-content: space-between;
384
+      font-size: 28rpx;
385
+      color: #666666;
386
+      line-height: 32rpx;
387
+      align-items: flex-end;
388
+
389
+      .exam-item-btn-score {
390
+        display: flex;
391
+        gap: 40rpx;
392
+        align-items: flex-end;
393
+
394
+        i {
395
+          font-style: normal;
396
+          font-size: 36rpx;
397
+          color: #4CA518;
398
+          line-height: 44rpx;
399
+        }
400
+      }
401
+
402
+      .btn {
403
+        padding: 10rpx 28rpx;
404
+        background: #2A70D1;
405
+        border-radius: 80rpx;
406
+        font-size: 24rpx;
407
+        color: #FFFFFF;
408
+        line-height: 28rpx;
409
+        border: 2px solid #FFFFFF;
410
+
411
+        &.done {
412
+          background: #FFFFFF;
413
+          border-color: #2A70D1;
414
+          color: #2A70D1;
415
+        }
416
+
417
+        &.timeout {
418
+          background: #FFFFFF;
419
+          border-color: #C4C4C5;
420
+          color: #999999;
421
+        }
422
+      }
423
+    }
424
+  }
425
+}
426
+</style>

+ 383 - 0
src/pages/exam/components/question-card.vue

@@ -0,0 +1,383 @@
1
+<!--
2
+ * @description: 
3
+ -->
4
+<template>
5
+  <div>
6
+    <div class="type">
7
+      <div class="bg">
8
+        <div v-if="answerList[currentQuestion]" class="question-type">
9
+          {{ answerList[currentQuestion].quType }}
10
+        </div>
11
+      </div>
12
+      <div class="bg1">
13
+
14
+        <div class="question-type">
15
+          第{{ currentQuestion + 1 }}题
16
+          <span>/共{{ answerList.length }}题</span>
17
+        </div>
18
+      </div>
19
+    </div>
20
+    <div class="question">
21
+      <template>
22
+        <div v-if="answerList[currentQuestion]" class="question-title">{{ answerList[currentQuestion].title }}</div>
23
+        <div class="question-answer">
24
+          <div class="question-answer-item">
25
+            <div v-if="answerList[currentQuestion] && answerList[currentQuestion].answer"
26
+              v-for="(el, idx) in answerList[currentQuestion].answer"
27
+              :class="{ 
28
+                active: isOptionSelected(currentQuestion, idx),
29
+              }"
30
+              class="question-answer-item-title" @click="chooseAnswer(el, currentQuestion, idx)">
31
+              <span class="num">{{ getOptionLabel(idx) }}</span>
32
+              {{ el.content }}
33
+             
34
+            </div>
35
+          </div>
36
+        </div>
37
+      </template>
38
+
39
+      <div class="choose-question">
40
+        <div v-if="currentQuestion > 0" class="btn line" @click="preQuestion">上一题</div>
41
+        <div v-if="currentQuestion < answerList.length - 1" class="btn" @click="nextQuestion">下一题</div>
42
+      </div>
43
+      <div class="time">倒计时 {{ formattedTime }}</div>
44
+      <div class="tips"><span>无需答题,直接交卷</span></div>
45
+      <div class="btn line none" @click="submit">提交答卷</div>
46
+    </div>
47
+  </div>
48
+</template>
49
+
50
+<script>
51
+export default {
52
+  name: "QuestionCard",
53
+  props: {
54
+    answerList: {
55
+      type: Array,
56
+      default: () => []
57
+    },
58
+    exTime: {         // 新增
59
+      type: Number,
60
+      default: 0
61
+    }
62
+  },
63
+  data() {
64
+    return {
65
+      timeLeft: 0, // 45 分钟,单位秒
66
+      timer: null,
67
+
68
+      activeIndex: [], // 改为存储数组,支持多选
69
+      currentQuestion: 0
70
+    }
71
+  },
72
+  computed: {
73
+    formattedTime() {
74
+      const minutes = Math.floor(this.timeLeft / 60);
75
+      const seconds = this.timeLeft % 60;
76
+      return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
77
+    },
78
+    // 判断当前题目是否为多选题
79
+    isMultipleChoice() {
80
+      const currentQuestion = this.answerList[this.currentQuestion];
81
+      return currentQuestion && currentQuestion.quType && 
82
+             (currentQuestion.quType.includes('多选') || 
83
+              currentQuestion.quType.includes('multiple') ||
84
+              currentQuestion.quType === '2'); // 假设2表示多选题
85
+    }
86
+  },
87
+  mounted() {
88
+      this.timeLeft = this.exTime*60 || 0;
89
+
90
+    this.startCountdown();
91
+  },
92
+  beforeDestroy() {
93
+    if (this.timer) {
94
+      clearInterval(this.timer);
95
+    }
96
+  },
97
+  methods: {
98
+    startCountdown() {
99
+      this.timer = setInterval(() => {
100
+        if (this.timeLeft > 0) {
101
+          this.timeLeft--;
102
+        } else {
103
+          clearInterval(this.timer);
104
+          this.$emit("timeout"); // 可选:通知父组件时间到
105
+          uni.showToast({ title: '测试时间结束', icon: 'none' });
106
+        }
107
+      }, 1000);
108
+    },
109
+    getOptionLabel(index) {
110
+      return String.fromCharCode(65 + index); // 65 是 'A' 的 Unicode 编码
111
+    },
112
+    // 判断选项是否被选中
113
+    isOptionSelected(questionIndex, optionIndex) {
114
+      const selectedOptions = this.activeIndex[questionIndex];
115
+      if (!selectedOptions) return false;
116
+      
117
+      if (Array.isArray(selectedOptions)) {
118
+        return selectedOptions.some(option => option.eroConnNo === optionIndex);
119
+      } else {
120
+        return selectedOptions.eroConnNo === optionIndex;
121
+      }
122
+    },
123
+    // 选择答案(支持多选)
124
+    chooseAnswer(el, questionIndex, optionIndex) {
125
+      const currentQuestion = this.answerList[questionIndex];
126
+      const isMultiple = currentQuestion && currentQuestion.quType && 
127
+                       (currentQuestion.quType.includes('多选') || 
128
+                        currentQuestion.quType.includes('multiple') ||
129
+                        currentQuestion.quType === '2');
130
+      
131
+      if (isMultiple) {
132
+        // 多选题逻辑
133
+        let selectedOptions = this.activeIndex[questionIndex] || [];
134
+        
135
+        // 如果selectedOptions不是数组(可能是旧数据),转换为数组
136
+        if (!Array.isArray(selectedOptions)) {
137
+          selectedOptions = selectedOptions.eroConnNo !== undefined ? [selectedOptions] : [];
138
+        }
139
+        
140
+        // 检查是否已经选中
141
+        const existingIndex = selectedOptions.findIndex(option => option.eroConnNo === optionIndex);
142
+        
143
+        if (existingIndex > -1) {
144
+          // 如果已经选中,移除
145
+          selectedOptions.splice(existingIndex, 1);
146
+        } else {
147
+          // 如果未选中,添加
148
+          selectedOptions.push({ 
149
+            "quId": el.quId, 
150
+            "eroConnNo": optionIndex, 
151
+            "eroSort": optionIndex // 使用optionIndex作为eroSort
152
+          });
153
+        }
154
+        
155
+        this.$set(this.activeIndex, questionIndex, selectedOptions);
156
+      } else {
157
+        // 单选题逻辑(保持原有逻辑)
158
+        this.$set(this.activeIndex, questionIndex, { 
159
+          "quId": el.quId, 
160
+          "eroConnNo": optionIndex, 
161
+          "eroSort": optionIndex // 使用optionIndex作为eroSort
162
+        });
163
+      }
164
+      
165
+      this.$emit("chooseAnswer", this.activeIndex);
166
+    },
167
+    submit() {
168
+        const leftTimeSec = this.exTime*60 - this.timeLeft;        // 剩余秒数
169
+
170
+      this.$emit("submit",leftTimeSec)
171
+    },
172
+    preQuestion() {
173
+      console.log(this.currentQuestion)
174
+      this.currentQuestion = this.currentQuestion === 0 ? 0 : this.currentQuestion - 1;
175
+      this.$emit("currentQuestion", this.currentQuestion)
176
+    },
177
+    nextQuestion(idx) {
178
+
179
+      this.currentQuestion = this.currentQuestion + 1
180
+      this.$emit("currentQuestion", this.currentQuestion)
181
+
182
+    },
183
+    selectQuestion(idx) {
184
+      this.currentQuestion = idx; // 更新当前题目索引
185
+      this.$emit("currentQuestion", this.currentQuestion); // 向父组件传递新的题目索引
186
+    },
187
+    getExamTime(exTime) {
188
+      this.timeLeft = exTime;
189
+
190
+    }
191
+  }
192
+}
193
+</script>
194
+
195
+<style lang="scss" scoped>
196
+.type {
197
+  position: relative;
198
+  height: 120rpx;
199
+  padding: 0 42rpx;
200
+  overflow: hidden;
201
+
202
+  .bg {
203
+    position: absolute;
204
+    left: 0;
205
+    width: 60%;
206
+    height: 100%;
207
+    background: linear-gradient(-130deg, transparent 80rpx, #D2EBFA 0, #D2EBFA 0);
208
+    border-radius: 56rpx 256rpx 0 0;
209
+    z-index: 1;
210
+    padding: 40rpx 0 0 28rpx;
211
+  }
212
+
213
+  .bg1 {
214
+    position: absolute;
215
+    width: 50%;
216
+    top: 20rpx;
217
+    right: 0;
218
+    height: 100%;
219
+    background: linear-gradient(180deg, #4583F0 0%, #4883F6 100%);
220
+    border-radius: 56rpx 56rpx 0 0;
221
+    padding: 40rpx 0 0 42rpx;
222
+
223
+    .question-type {
224
+      padding-right: 28rpx;
225
+      font-weight: bold;
226
+      font-size: 32rpx;
227
+      color: #FFFFFF;
228
+      line-height: 40rpx;
229
+      text-align: right;
230
+
231
+      span {
232
+        font-size: 26rpx;
233
+        color: rgba(255, 255, 255, 0.6);
234
+        line-height: 32rpx;
235
+      }
236
+    }
237
+  }
238
+
239
+  .question-type {
240
+    font-size: 40rpx;
241
+    line-height: 48rpx;
242
+    color: #496CF4;
243
+
244
+    span {
245
+      margin-left: 14rpx;
246
+      font-size: 26rpx;
247
+      color: #6896F6;
248
+      line-height: 30rpx;
249
+    }
250
+  }
251
+}
252
+
253
+.question {
254
+  padding: 56rpx 48rpx;
255
+  background: #FFFFFF;
256
+  border-radius: 0 32rpx 32rpx 32rpx;
257
+  box-shadow: 0 8rpx 20rpx 0 rgba(0, 0, 0, 0.06);
258
+
259
+  .question-title {
260
+    font-weight: 400;
261
+    font-size: 32rpx;
262
+    color: #3D3D3D;
263
+    line-height: 38rpx;
264
+  }
265
+
266
+  .question-answer-item-title {
267
+    display: flex;
268
+    align-items: center;
269
+    background: #F1F7FE;
270
+    border-radius: 80rpx;
271
+    padding: 16rpx 48rpx 16rpx 16rpx;
272
+    font-size: 28rpx;
273
+    color: #3D3D3D;
274
+    line-height: 34rpx;
275
+    margin-top: 36rpx;
276
+    border: 2rpx solid rgba(255, 255, 255, 0.5);
277
+    position: relative;
278
+
279
+    .num {
280
+      display: inline-flex;
281
+      align-items: center;
282
+      justify-content: center;
283
+      width: 64rpx;
284
+      height: 64rpx;
285
+      background: linear-gradient(135deg, #6E9BFC 0%, #496CF4 100%);
286
+      box-shadow: 4rpx 4rpx 16rpx 0 rgba(76, 112, 246, 0.3);
287
+      border-radius: 80rpx;
288
+      margin-right: 24rpx;
289
+      font-size: 28rpx;
290
+      color: #FFFFFF;
291
+    }
292
+
293
+    &.active {
294
+      background: #F1F7FE;
295
+      box-shadow: 0 0 20rpx 0 rgba(76, 112, 246, 0.6);
296
+      border-radius: 80rpx;
297
+      border-color: rgba(76, 112, 246, 0.5);
298
+
299
+      &:after {
300
+        content: "";
301
+        position: absolute;
302
+        right: 48rpx;
303
+        display: inline-flex;
304
+        width: 48rpx;
305
+        height: 48rpx;
306
+        background: url("../../../static/images/dui.png") no-repeat;
307
+        background-size: 100%;
308
+      }
309
+
310
+      
311
+    }
312
+
313
+  
314
+  }
315
+}
316
+
317
+.choose-question {
318
+  display: flex;
319
+  gap: 32rpx;
320
+  padding-top: 32rpx;
321
+
322
+  .btn {
323
+    flex: 1;
324
+  }
325
+}
326
+
327
+.btn,
328
+.time {
329
+  margin: 36rpx 0;
330
+  border-radius: 80rpx;
331
+  font-weight: bold;
332
+  font-size: 32rpx;
333
+  color: #FFFFFF;
334
+  text-align: center;
335
+
336
+  &.none {
337
+    margin-bottom: 0;
338
+  }
339
+}
340
+
341
+.btn {
342
+  border: 2px solid #fff;
343
+  padding: 18rpx 0;
344
+  background: linear-gradient(135deg, #6E9BFC 0%, #496CF4 100%);
345
+
346
+  &.line {
347
+    background: inherit;
348
+    border-color: #496CF4;
349
+    color: #496CF4;
350
+  }
351
+}
352
+
353
+.time {
354
+  margin: 32rpx 0;
355
+  color: #333333;
356
+}
357
+
358
+.tips {
359
+  font-weight: 400;
360
+  font-size: 24rpx;
361
+  color: #999999;
362
+  text-align: center;
363
+  position: relative;
364
+
365
+  &:after {
366
+    content: "";
367
+    display: inline-block;
368
+    width: 100%;
369
+    position: absolute;
370
+    top: 50%;
371
+    left: 0;
372
+    border-bottom: 2rpx dashed #E5E5E5;
373
+    transform: translateY(-50%);
374
+  }
375
+
376
+  span {
377
+    position: relative;
378
+    padding: 0 8rpx;
379
+    background: #fff;
380
+    z-index: 2;
381
+  }
382
+}
383
+</style>

+ 165 - 0
src/pages/exam/components/question-done.vue

@@ -0,0 +1,165 @@
1
+<!--
2
+ * @description: 
3
+ -->
4
+<template>
5
+  <div class="question-done">
6
+    <view id="top_Echart" class="top_Echart"></view>
7
+
8
+    <div class="question-result">
9
+      <div class="question-result-item">
10
+        测试得分
11
+        <span class="result"> {{ questResult.erScore }}</span>
12
+      </div>
13
+      <div class="question-result-item">
14
+        用时
15
+        <span class="result"> {{ questResult.erExamTime }}</span>
16
+      </div>
17
+      <div class="question-result-item">
18
+        错问题数
19
+        <span class="result"> {{ questResult.erIncorrectCount }}</span>
20
+      </div>
21
+      <div class="question-result-item">
22
+        正确率
23
+        <span class="result"> {{ questResult.erCorrect }}%</span>
24
+      </div>
25
+    </div>
26
+  </div>
27
+</template>
28
+
29
+<script>
30
+import * as echarts from 'echarts';
31
+
32
+export default {
33
+  name: "QuestionDone",
34
+  props: {
35
+    doneData: {
36
+
37
+    }
38
+  },
39
+  data() {
40
+    return {
41
+      questResult: {},
42
+      echart: null,
43
+      chartOption: {
44
+        
45
+      },
46
+    }
47
+  },
48
+  mounted() {
49
+    // this.initEcharts();
50
+  },
51
+  methods: {
52
+    // 初始化图表
53
+    initEcharts() {
54
+      this.echart = echarts.init(document.getElementById('top_Echart'));
55
+      this.echart.setOption(this.chartOption);
56
+    },
57
+
58
+    initDone(data) {
59
+      this.questResult = data;
60
+      const totalSeconds = data.erExamTime;
61
+const minutes = Math.floor(totalSeconds / 60);
62
+const seconds = totalSeconds % 60;
63
+const formattedTime = `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
64
+this.questResult.erExamTime = formattedTime;
65
+      if (data) {
66
+        const correct = data.erBingoCount;
67
+        const incorrect = data.erIncorrectCount;
68
+        const blank = data.erNotFilledCount;
69
+        // const total = correct + incorrect + blank;
70
+        console.log(correct)
71
+                console.log(incorrect)
72
+
73
+                        console.log(blank)
74
+
75
+        //                         console.log(total)
76
+        console.log("data========",data)
77
+
78
+        const rate = data.erRate
79
+
80
+        this.chartOption = {
81
+          legend: {
82
+            top: 'center',
83
+            right: '30rpx',
84
+            orient: 'vertical',
85
+          },
86
+          series: [
87
+            {
88
+              type: 'pie',
89
+              radius: ['60%', '80%'],
90
+              center: ['30%', '50%'],
91
+              avoidLabelOverlap: false,
92
+              label: {
93
+                position: 'center',
94
+                formatter: `{pie|${rate}%}\n{text|正确率}`,
95
+                rich: {
96
+                  pie: {
97
+                    color: '#222',
98
+                    fontSize: 24,
99
+                    fontWeight: 'bold'
100
+                  },
101
+                  text: {
102
+                    color: '#333',
103
+                    fontSize: 12,
104
+                  }
105
+                }
106
+              },
107
+              data: [
108
+                { value: correct, name: '正确', itemStyle: { color: '#4EA61A' } },
109
+                { value: incorrect, name: '错误', itemStyle: { color: '#EA4949' } },
110
+                { value: blank, name: '未答', itemStyle: { color: '#E4E4E4' } },
111
+              ]
112
+            }
113
+          ],
114
+        },
115
+
116
+
117
+
118
+
119
+          this.initEcharts()
120
+
121
+      }
122
+    }
123
+  }
124
+}
125
+</script>
126
+
127
+<style lang="scss" scoped>
128
+.question-done {
129
+  padding: 96rpx 48rpx;
130
+  background: #FFFFFF;
131
+  box-shadow: 0 8rpx 32rpx 0 rgba(0, 0, 0, 0.1);
132
+  border-radius: 32rpx;
133
+
134
+  .top_Echart {
135
+    width: 100%;
136
+    height: 300rpx;
137
+  }
138
+
139
+  .question-result {
140
+    display: flex;
141
+    align-items: center;
142
+    justify-content: space-between;
143
+
144
+    padding: 36rpx 48rpx 28rpx;
145
+    background: #F1F7FE;
146
+    border-radius: 24rpx;
147
+    font-size: 24rpx;
148
+    color: #666666;
149
+    line-height: 28rpx;
150
+
151
+    .question-result-item {
152
+      display: flex;
153
+      flex-flow: column;
154
+      align-items: center;
155
+
156
+      .result {
157
+        margin-top: 16rpx;
158
+        font-size: 40rpx;
159
+        color: #3D3D3D;
160
+        line-height: 48rpx;
161
+      }
162
+    }
163
+  }
164
+}
165
+</style>

+ 275 - 0
src/pages/exam/components/question-result.vue

@@ -0,0 +1,275 @@
1
+<!--
2
+ * @description: 
3
+ -->
4
+<template>
5
+  <div>
6
+    <div class="type">
7
+      <div class="bg">
8
+        <div v-if="resultDetail" class="question-type">
9
+          {{ resultDetail.quType }}
10
+        </div>
11
+      </div>
12
+      <div v-if="resultDetail" class="bg1">
13
+
14
+        <div v-if="resultDetail" class="question-type">
15
+          第{{ resultDetail.eroTpqSort + 1 }}题
16
+
17
+        </div>
18
+      </div>
19
+    </div>
20
+    <div class="question">
21
+      <template>
22
+        <div v-if="resultDetail" class="question-title">{{ resultDetail.title }}</div>
23
+        <div class="question-answer">
24
+          <div class="question-answer-item">
25
+            <div v-if="resultDetail.answer" v-for="(el, idx) in resultDetail.answer"
26
+              :class="{ active: el.eroConnNo !== '-1' }" class="question-answer-item-title">
27
+              <span class="num">{{ getOptionLabel(el.eroSort) }}</span>
28
+              {{ el.eroQoOption }}
29
+            </div>
30
+          </div>
31
+        </div>
32
+      </template>
33
+
34
+      <view class="correct-answer" v-if="resultDetail.answer">
35
+        <text class="label">正确答案:</text>
36
+        <text>{{correctOptions.map(opt => getOptionLabel(opt.eroSort)).join(',')}}</text>
37
+
38
+      </view>
39
+
40
+
41
+    </div>
42
+  </div>
43
+</template>
44
+
45
+<script>
46
+export default {
47
+  name: "QuestionResult",
48
+  computed: {
49
+    correctOptions() {
50
+      return this.resultDetail?.answer?.filter(item => item.eroQoCorrect) ?? [];
51
+    }
52
+  },
53
+
54
+  data() {
55
+    return {
56
+      resultDetail: {},
57
+      activeIndex: [],
58
+      currentQuestion: 0
59
+    }
60
+  },
61
+  onLoad() {
62
+    console.log("answerList", answerList)
63
+  },
64
+  methods: {
65
+    getOptionLabel(index) {
66
+      return String.fromCharCode(65 + index); // 65 是 'A' 的 Unicode 编码
67
+    },
68
+    chooseAnswer(el, index, idx) {
69
+      this.$set(this.activeIndex, index, { "quId": el.quId, "eroConnNo": idx, "eroSort": el.num });
70
+      this.$emit("chooseAnswer", this.activeIndex)
71
+    },
72
+
73
+    initResult(data) {
74
+      console.log("22222")
75
+      this.resultDetail = data;
76
+      console.log("this.resultDetail", this.resultDetail)
77
+
78
+      //  this.currentQuestion = idx; // 更新当前题目索引
79
+      //   this.$emit("currentQuestion", this.currentQuestion); // 向父组件传递新的题目索引
80
+    }
81
+  }
82
+}
83
+</script>
84
+
85
+<style lang="scss" scoped>
86
+.type {
87
+  position: relative;
88
+  height: 120rpx;
89
+  padding: 0 42rpx;
90
+  overflow: hidden;
91
+
92
+  .bg {
93
+    position: absolute;
94
+    left: 0;
95
+    width: 60%;
96
+    height: 100%;
97
+    background: linear-gradient(-130deg, transparent 80rpx, #D2EBFA 0, #D2EBFA 0);
98
+    border-radius: 56rpx 256rpx 0 0;
99
+    z-index: 1;
100
+    padding: 40rpx 0 0 28rpx;
101
+  }
102
+
103
+  .bg1 {
104
+    position: absolute;
105
+    width: 50%;
106
+    top: 20rpx;
107
+    right: 0;
108
+    height: 100%;
109
+    background: linear-gradient(180deg, #4583F0 0%, #4883F6 100%);
110
+    border-radius: 56rpx 56rpx 0 0;
111
+    padding: 40rpx 0 0 42rpx;
112
+
113
+    .question-type {
114
+      padding-right: 28rpx;
115
+      font-weight: bold;
116
+      font-size: 32rpx;
117
+      color: #FFFFFF;
118
+      line-height: 40rpx;
119
+      text-align: right;
120
+
121
+      span {
122
+        font-size: 26rpx;
123
+        color: rgba(255, 255, 255, 0.6);
124
+        line-height: 32rpx;
125
+      }
126
+    }
127
+  }
128
+
129
+  .question-type {
130
+    font-size: 40rpx;
131
+    line-height: 48rpx;
132
+    color: #496CF4;
133
+
134
+    span {
135
+      margin-left: 14rpx;
136
+      font-size: 26rpx;
137
+      color: #6896F6;
138
+      line-height: 30rpx;
139
+    }
140
+  }
141
+}
142
+
143
+.question {
144
+  padding: 56rpx 48rpx;
145
+  background: #FFFFFF;
146
+  border-radius: 0 32rpx 32rpx 32rpx;
147
+  box-shadow: 0 8rpx 20rpx 0 rgba(0, 0, 0, 0.06);
148
+
149
+  .question-title {
150
+    font-weight: 400;
151
+    font-size: 32rpx;
152
+    color: #3D3D3D;
153
+    line-height: 38rpx;
154
+  }
155
+
156
+  .question-answer-item-title {
157
+    display: flex;
158
+    align-items: center;
159
+    background: #F1F7FE;
160
+    border-radius: 80rpx;
161
+    padding: 16rpx 48rpx 16rpx 16rpx;
162
+    font-size: 28rpx;
163
+    color: #3D3D3D;
164
+    line-height: 34rpx;
165
+    margin-top: 36rpx;
166
+    border: 2rpx solid rgba(255, 255, 255, 0.5);
167
+
168
+    .num {
169
+      display: inline-flex;
170
+      align-items: center;
171
+      justify-content: center;
172
+      width: 64rpx;
173
+      height: 64rpx;
174
+      background: linear-gradient(135deg, #6E9BFC 0%, #496CF4 100%);
175
+      box-shadow: 4rpx 4rpx 16rpx 0 rgba(76, 112, 246, 0.3);
176
+      border-radius: 80rpx;
177
+      margin-right: 24rpx;
178
+      font-size: 28rpx;
179
+      color: #FFFFFF;
180
+    }
181
+
182
+    &.active {
183
+      background: #F1F7FE;
184
+      box-shadow: 0 0 20rpx 0 rgba(76, 112, 246, 0.6);
185
+      border-radius: 80rpx;
186
+      border-color: rgba(76, 112, 246, 0.5);
187
+      position: relative;
188
+
189
+      &:after {
190
+        content: "";
191
+        position: absolute;
192
+        right: 48rpx;
193
+        display: inline-flex;
194
+        width: 48rpx;
195
+        height: 48rpx;
196
+        background: url("../../../static/images/dui.png") no-repeat;
197
+        background-size: 100%;
198
+      }
199
+    }
200
+  }
201
+}
202
+
203
+.choose-question {
204
+  display: flex;
205
+  gap: 32rpx;
206
+  padding-top: 32rpx;
207
+
208
+  .btn {
209
+    flex: 1;
210
+  }
211
+}
212
+
213
+.btn,
214
+.time {
215
+  margin: 36rpx 0;
216
+  border-radius: 80rpx;
217
+  font-weight: bold;
218
+  font-size: 32rpx;
219
+  color: #FFFFFF;
220
+  text-align: center;
221
+
222
+  &.none {
223
+    margin-bottom: 0;
224
+  }
225
+}
226
+
227
+.btn {
228
+  border: 2px solid #fff;
229
+  padding: 18rpx 0;
230
+  background: linear-gradient(135deg, #6E9BFC 0%, #496CF4 100%);
231
+
232
+  &.line {
233
+    background: inherit;
234
+    border-color: #496CF4;
235
+    color: #496CF4;
236
+  }
237
+}
238
+
239
+.time {
240
+  margin: 32rpx 0;
241
+  color: #333333;
242
+}
243
+
244
+.tips {
245
+  font-weight: 400;
246
+  font-size: 24rpx;
247
+  color: #999999;
248
+  text-align: center;
249
+  position: relative;
250
+
251
+  &:after {
252
+    content: "";
253
+    display: inline-block;
254
+    width: 100%;
255
+    position: absolute;
256
+    top: 50%;
257
+    left: 0;
258
+    border-bottom: 2rpx dashed #E5E5E5;
259
+    transform: translateY(-50%);
260
+  }
261
+
262
+  span {
263
+    position: relative;
264
+    padding: 0 8rpx;
265
+    background: #fff;
266
+    z-index: 2;
267
+  }
268
+
269
+  .correct-answer {
270
+    margin-top: 40rpx;
271
+    font-size: 32rpx;
272
+    color: #4CAF50;
273
+  }
274
+}
275
+</style>

+ 462 - 0
src/pages/exam/index.vue

@@ -0,0 +1,462 @@
1
+<template>
2
+  <home-container v-if="pageReady">
3
+    <div class="header-title">
4
+      <div class="title">{{ exName }}</div>
5
+      <div class="sub-title">测试时间:{{ exTime }}分钟 总分:{{ totalScore }}分</div>
6
+
7
+      <question-done v-if="status === 'done'" ref="QuestionDone" />
8
+      <question-card v-else ref="QuestionCard" :ex-time="exTime" :answer-list="answerList" @chooseAnswer="chooseAnswer"
9
+        @currentQuestion="cur => currentQuestion = cur" @submit="handleSubmit" />
10
+
11
+      <div v-if="status !== 'done'" class="question-card">
12
+        <div class="title">
13
+          答题卡
14
+          <div class="right-question-card">
15
+            <div class="question-status">
16
+              <div class="question-status-item done">
17
+                已答
18
+              </div>
19
+              <div class="question-status-item current">
20
+                当前
21
+              </div>
22
+              <div class="question-status-item">
23
+                未答
24
+              </div>
25
+            </div>
26
+          </div>
27
+        </div>
28
+
29
+        <div v-if="status !== 'done'" class="question-num">
30
+          <div v-for="(num, idx) in questionCount" :class="{
31
+            done: currentQuestion > idx,
32
+            current: currentQuestion === idx
33
+          }" class="question-num-item" @click="goToQuestion(idx)">
34
+            <div class="question-num-item-title">
35
+              {{ idx + 1 }}
36
+            </div>
37
+          </div>
38
+        </div>
39
+      </div>
40
+
41
+      <div v-if="status === 'done'" class="question-card">
42
+        <div class="title">
43
+          答题卡
44
+          <div class="right-question-card">
45
+            <div class="question-status">
46
+              <div class="question-status-item done">
47
+                正确
48
+              </div>
49
+              <div class="question-status-item mistake">
50
+                错误
51
+              </div>
52
+              <div class="question-status-item">
53
+                未答
54
+              </div>
55
+            </div>
56
+          </div>
57
+        </div>
58
+
59
+        <div v-if="status === 'done'" class="question-num">
60
+          <div v-for="(num, idx) in answeringSituation" :class="{
61
+            done: num.eroCorrectStatus == 'ALL_CORRECT' && num.eroCorrectStatus != 'NOT_FILLED',
62
+            mistake: num.eroCorrectStatus != 'ALL_CORRECT' && (num.eroCorrectStatus == 'PART_CORRECT'||num.eroCorrectStatus == 'INCORRECT') &&  num.eroCorrectStatus != 'NOT_FILLED'
63
+          }" class="question-num-item" @click="goToResult(num)">
64
+            <div class="question-num-item-title">
65
+              {{ idx + 1 }}
66
+            </div>
67
+          </div>
68
+        </div>
69
+      </div>
70
+
71
+      <div v-if="resultShow" class="question-card">
72
+        <question-result ref="QuestionResult" :answer-list="answerList" />
73
+      </div>
74
+
75
+
76
+    </div>
77
+
78
+
79
+  </home-container>
80
+</template>
81
+<script>
82
+import HomeContainer from "@/components/HomeContainer.vue";
83
+import QuestionCard from "@/pages/exam/components/question-card.vue";
84
+import QuestionDone from "@/pages/exam/components/question-done.vue";
85
+import QuestionResult from "@/pages/exam/components/question-result.vue";
86
+import { getExamIntro } from "@/api/exam/exam"
87
+import { applyExam } from "@/api/exam/exam"
88
+import { submitExam } from "@/api/exam/exam"
89
+import { getExam } from "@/api/exam/exam"
90
+import { getToken } from '@/utils/auth'
91
+
92
+
93
+
94
+export default {
95
+  components: { QuestionDone, QuestionCard, HomeContainer, QuestionResult, },
96
+  data() {
97
+    return {
98
+      pageReady: false,   // 新增
99
+
100
+      resultDetail: [],
101
+      answeringSituation: [],
102
+      exName: '',
103
+      exTime: '40',
104
+      totalScore: '',
105
+      questionCount: 0,
106
+      answerList: [
107
+      ],
108
+      currentQuestion: 1,
109
+      status: 'done',
110
+      resultShow: false,
111
+      options: [],
112
+      exId: '',
113
+      erId: '',
114
+      erExamTime: 10,
115
+    }
116
+  },
117
+  onLoad(options) {
118
+ 
119
+
120
+    this.status = options.status
121
+    const id = options.id;
122
+    const erId = options.erId;
123
+    this.exTime = options.exTime;
124
+    //    console.log("this.status",this.status)
125
+    // if(this.status === 'done'){
126
+    //   uni.navigateTo({
127
+    //     url: '/pages/exam-list/index'
128
+    //   })
129
+    // }
130
+    if (erId && erId !== 'null' && erId !== 'undefined') {
131
+      this.exId = id;
132
+      this.erId = erId;
133
+      this.showExDetail()
134
+    } else {
135
+      this.initData(id)
136
+    }
137
+  },
138
+  methods: {
139
+    chooseAnswer(ops) {
140
+      this.options = ops;
141
+    },
142
+    async handleSubmit(leftTimeSec = 0) {
143
+      try {
144
+        console.log("leftTimeSec",leftTimeSec)
145
+        
146
+        // 转换options格式为后端期望的格式
147
+        const formattedOptions = this.formatOptionsForBackend(this.options);
148
+        
149
+        await submitExam({ "clientType": "client_type_kg_app", "exId": this.exId, "erId": this.erId, "erExamTime": leftTimeSec, "options": formattedOptions });
150
+        const response = await getExam({ "erId": this.erId, "clientType": "client_type_kg_app" });
151
+        this.status = 'done'
152
+        this.answeringSituation = response.model.question
153
+
154
+        const exData = {
155
+          "erRate": response.model.erCorrect,
156
+          "erScore": response.model.erScore,
157
+          "erExamTime": response.model.erExamTime,
158
+          "erCorrect": response.model.erCorrect,
159
+          "erBingoCount": response.model.erBingoCount, //正确的
160
+          "erIncorrectCount": response.model.erIncorrectCount, //错误的
161
+          "erNotFilledCount":response.model.erNotFilledCount //未作答
162
+        };
163
+        this.$nextTick(() => {
164
+          if (this.$refs.QuestionDone) {
165
+            this.$refs.QuestionDone.initDone(exData); // 调用子组件的 nextQuestion 方法
166
+          } else {
167
+            setTimeout(() => {
168
+              this.$refs.QuestionDone?.initDone(exData);
169
+            }, 1000);
170
+          }
171
+        });
172
+      } finally {
173
+        this.pageReady = true;      // 关键:放开渲染
174
+      }
175
+    },
176
+    // 格式化options为后端期望的格式
177
+    formatOptionsForBackend(options) {
178
+      if (!options || !Array.isArray(options)) return [];
179
+      
180
+      const result = [];
181
+      
182
+      options.forEach((option, index) => {
183
+        if (Array.isArray(option)) {
184
+          // 多选题:将数组中的每个选项转换为单独的对象
185
+          option.forEach(opt => {
186
+            result.push({
187
+              quId: opt.quId,
188
+              eroConnNo: opt.eroConnNo,
189
+              eroSort: opt.eroSort
190
+            });
191
+          });
192
+        } else if (option && typeof option === 'object') {
193
+          // 单选题:直接添加对象
194
+          result.push({
195
+            quId: option.quId,
196
+            eroConnNo: option.eroConnNo,
197
+            eroSort: option.eroSort
198
+          });
199
+        }
200
+      });
201
+      
202
+      return result;
203
+    },
204
+    async showExDetail() {
205
+      try {
206
+        
207
+        // await submitExam({ "clientType": "client_type_kg_app", "exId": this.exId, "erId": this.erId, "erExamTime": this.erExamTime, "options": this.options });
208
+        const response = await getExam({ "erId": this.erId, "clientType": "client_type_kg_app" });
209
+        this.status = 'done'
210
+        this.answeringSituation = response.model.question
211
+        this.totalScore = response.model.erTpTotalScore
212
+
213
+        const exData = {
214
+          "erRate": response.model.erCorrect,
215
+          "erScore": response.model.erScore,
216
+          "erExamTime": response.model.erExamTime,
217
+          "erCorrect": response.model.erCorrect,
218
+          "erBingoCount": response.model.erBingoCount, //正确的
219
+          "erIncorrectCount": response.model.erIncorrectCount, //错误的
220
+          "erNotFilledCount":response.model.erNotFilledCount //未作答
221
+        };
222
+        this.$nextTick(() => {
223
+          if (this.$refs.QuestionDone) {
224
+            this.$refs.QuestionDone.initDone(exData); // 调用子组件的 nextQuestion 方法
225
+          } else {
226
+            setTimeout(() => {
227
+              this.$refs.QuestionDone?.initDone(exData);
228
+            }, 1000);
229
+          }
230
+        });
231
+      } finally {
232
+        this.pageReady = true;      // 关键:放开渲染
233
+      }
234
+
235
+
236
+
237
+      // this.status = 'done'
238
+    },
239
+    goToQuestion(idx) {
240
+      this.currentQuestion = idx;
241
+
242
+      this.$nextTick(() => {
243
+        if (this.$refs.QuestionCard) {
244
+          this.$refs.QuestionCard.selectQuestion(idx); // 调用子组件的 nextQuestion 方法
245
+        } else {
246
+          console.error('子组件未正确引用');
247
+        }
248
+      });
249
+
250
+
251
+
252
+    },
253
+    async initData(id) {
254
+      try {
255
+        const response = await getExamIntro({ "id": id, "clientType": "client_type_kg_app" });
256
+        this.exName = response.model.exName,
257
+          this.exTime = response.model.exExamTime / 60
258
+        this.totalScore = response.model.totalScore,
259
+          this.questionCount = response.model.questionCount
260
+      } catch (error) {
261
+        console.error('获取测试记录失败', error);
262
+      }
263
+      try {
264
+        const response = await applyExam({ "id": id, "pcId": "h5", "clientType": "client_type_kg_app" })
265
+        const quTypeMap = {
266
+          SINGLE: '单选',
267
+          MULTIPLE: '多选',
268
+          JUDGMENT: '判断',
269
+          FILL_BLANK: '填空',
270
+          CONNECTION: '连线'
271
+        };
272
+        this.answerList = response.model.question.map(item => ({
273
+          exExamTime: response.model.exExamTime,
274
+          quId: item.quId,
275
+          quType: quTypeMap[item.quType],
276
+          exName: response.model.exName,
277
+          title: item.quContent,
278
+          answer: item.options.map(option => ({
279
+            quId: item.quId,
280
+            num: option.sort,
281
+            content: option.qoOption
282
+          }))
283
+
284
+        }));
285
+        this.erId = response.model.erId;
286
+        this.exId = response.model.exId
287
+
288
+
289
+      } finally {
290
+        this.pageReady = true;      // 关键:放开渲染
291
+      }
292
+
293
+    },
294
+    goToResult(result) {
295
+      const quTypeMap = {
296
+        SINGLE: '单选',
297
+        MULTIPLE: '多选',
298
+        JUDGMENT: '判断',
299
+        FILL_BLANK: '填空',
300
+        CONNECTION: '连线'
301
+      };
302
+      this.resultDetail = {
303
+        quType: quTypeMap[result.eroQuType],
304
+        title: result.eroQuContent,
305
+        eroTpqSort: result.eroTpqSort,
306
+        answer: result.option
307
+
308
+      };
309
+      this.$nextTick(() => {
310
+        if (this.$refs.QuestionResult) {
311
+          this.$refs.QuestionResult.initResult(this.resultDetail); // 调用子组件的 nextQuestion 方法
312
+        } else {
313
+          setTimeout(() => {
314
+            this.$refs.QuestionResult?.initResult(this.resultDetail);
315
+          }, 1000);
316
+        }
317
+      });
318
+      this.resultShow = true;
319
+
320
+    }
321
+  },
322
+}
323
+</script>
324
+
325
+<style lang="scss" scoped>
326
+.header-title {
327
+  padding: 40rpx 0 20rpx 20rpx;
328
+  z-index: 2;
329
+  position: relative;
330
+
331
+  &:after {
332
+    content: "";
333
+    position: absolute;
334
+    z-index: 2;
335
+    top: 0;
336
+    right: 0;
337
+    width: 220rpx;
338
+    height: 500rpx;
339
+    background-image: url("../../static/images/exam.png");
340
+    background-repeat: no-repeat;
341
+    background-size: 220rpx;
342
+    background-position: right 40rpx;
343
+  }
344
+
345
+  .title {
346
+    font-weight: 400;
347
+    font-size: 40rpx;
348
+    color: #FFFFFF;
349
+    line-height: 56rpx;
350
+  }
351
+
352
+  .sub-title {
353
+    margin-top: 20rpx;
354
+    font-size: 28rpx;
355
+    color: rgba(255, 255, 255, 0.9);
356
+    line-height: 40rpx;
357
+    padding-bottom: 56rpx;
358
+  }
359
+}
360
+
361
+
362
+.question-card {
363
+  padding: 40rpx;
364
+  background: #FFFFFF;
365
+  box-shadow: 0 8rpx 32rpx 0 rgba(0, 0, 0, 0.1);
366
+  border-radius: 32rpx;
367
+  margin-top: 66rpx;
368
+
369
+  .title {
370
+    display: flex;
371
+    align-items: center;
372
+    justify-content: space-between;
373
+    font-size: 36rpx;
374
+    color: #222222;
375
+    line-height: 42rpx;
376
+    margin-bottom: 48rpx;
377
+  }
378
+
379
+  .question-status {
380
+    display: flex;
381
+    align-items: center;
382
+    gap: 32rpx;
383
+
384
+    .question-status-item {
385
+      font-size: 26rpx;
386
+      color: #666666;
387
+      line-height: 30rpx;
388
+      padding-left: 36rpx;
389
+      position: relative;
390
+
391
+      &:after {
392
+        content: "";
393
+        width: 24rpx;
394
+        height: 24rpx;
395
+        border-radius: 8rpx;
396
+        border: 2rpx solid #C5C5C5;
397
+        position: absolute;
398
+        left: 0;
399
+        top: 50%;
400
+        transform: translateY(-50%);
401
+      }
402
+
403
+      &.done {
404
+        &:after {
405
+          border-color: #5F88F8;
406
+          background: #5F88F8;
407
+        }
408
+      }
409
+
410
+      &.current {
411
+        &:after {
412
+          border-color: #D5E1FF;
413
+          background: #D5E1FF;
414
+        }
415
+      }
416
+
417
+      &.mistake {
418
+        &:after {
419
+          border-color: #fe0303;
420
+          background: #fe0303;
421
+        }
422
+      }
423
+    }
424
+  }
425
+
426
+  .question-num {
427
+    display: flex;
428
+    align-items: center;
429
+    flex-flow: wrap;
430
+    gap: 28rpx;
431
+
432
+    .question-num-item {
433
+      display: flex;
434
+      align-items: center;
435
+      justify-content: center;
436
+      width: 64rpx;
437
+      height: 64rpx;
438
+      background: #FFFEFD;
439
+      border-radius: 80rpx;
440
+      border: 2rpx solid #D6D6D6;
441
+      font-size: 28rpx;
442
+      color: #C4C4C4;
443
+
444
+      &.done {
445
+        background: linear-gradient(135deg, #6E9BFC 0%, #496CF4 100%);
446
+        color: #FFFFFF;
447
+      }
448
+
449
+      &.current {
450
+        background: #D5E1FF;
451
+        color: #2A70D1;
452
+      }
453
+
454
+      &.mistake {
455
+        border-color: #fe0303;
456
+        background: #fe0303;
457
+
458
+      }
459
+    }
460
+  }
461
+}
462
+</style>

+ 205 - 0
src/pages/home/components/announcement.vue

@@ -0,0 +1,205 @@
1
+<template>
2
+  <div class="announcement-container">
3
+    <head-title subTitle="了解更多安检知识" title="最新公告"></head-title>
4
+
5
+    <!-- 列表为空时显示的状态 -->
6
+    <div v-if="list.length === 0" class="empty-state">
7
+      <view class="empty"></view>
8
+      <text class="empty-text">暂无公告</text>
9
+    </div>
10
+
11
+    <!-- 有数据时显示列表 -->
12
+    <div v-else>
13
+      <div class="list">
14
+        <div v-for="(item, index) in list.slice(0, 2)" :key="item.id"
15
+          :class="[item.status, index === 0 ? 'first-item' : 'second-item']" class="list-item"
16
+          @click="navigateToDetail(item)">
17
+          <div class="list-item-title">
18
+            <text class="text-grey1">{{ item.noticeTitle }}</text>
19
+          </div>
20
+          <div class="list-item-content">{{ stripHtmlTags(item.noticeContent) }}</div>
21
+        </div>
22
+      </div>
23
+
24
+      <div class="more-list" @click="navigateToMore">查看全部</div>
25
+    </div>
26
+  </div>
27
+</template>
28
+<script>
29
+
30
+import HeadTitle from "@/components/HeadTitle.vue";
31
+import { getNoticeList } from "@/api/announcement/announcement.js";
32
+
33
+export default {
34
+  components: {
35
+    HeadTitle
36
+  },
37
+  data() {
38
+    return {
39
+      list: []
40
+    };
41
+  },
42
+  mounted() {
43
+    // 组件首次加载时获取数据
44
+    // this.getAnnouncementList();
45
+  },
46
+  methods: {
47
+    // 提供给父组件调用的刷新方法
48
+    refreshData() {
49
+      this.getAnnouncementList();
50
+    },
51
+    
52
+    navigateToDetail(item) {
53
+      console.log(item);
54
+      uni.navigateTo({
55
+        url: '/pages/announcement/announcementDetail?id=' + item.noticeId
56
+      });
57
+    },
58
+    // 去除HTML标签,只保留纯文本
59
+    stripHtmlTags(html) {
60
+      if (!html) return '';
61
+      // 使用正则表达式去除HTML标签
62
+      return html.replace(/<[^>]+>/g, '');
63
+    },
64
+    navigateToMore() {
65
+      uni.navigateTo({
66
+        url: '/pages/announcement/index'
67
+      });
68
+    },
69
+    async getAnnouncementList() {
70
+      try {
71
+        const res = await getNoticeList({
72
+          pageNum: 1,
73
+          pageSize: 2,
74
+          noticeType: 2,
75
+          status: '0'
76
+        });
77
+
78
+        // 处理API返回的数据,转换为组件需要的格式
79
+        if (res && res.rows) {
80
+          this.list = res.rows
81
+        }
82
+      } catch (error) {
83
+        console.error('获取公告列表失败:', error);
84
+        // 出错时使用默认数据
85
+        this.list = [];
86
+      }
87
+    },
88
+    formatTime(timeString) {
89
+      // 简单的时间格式化函数,根据实际API返回格式调整
90
+      if (!timeString) return '';
91
+
92
+      const now = new Date();
93
+      const past = new Date(timeString);
94
+      const diffMinutes = Math.floor((now - past) / (1000 * 60));
95
+
96
+      if (diffMinutes < 60) {
97
+        return `${diffMinutes}分钟之前`;
98
+      } else if (diffMinutes < 60 * 24) {
99
+        return `${Math.floor(diffMinutes / 60)}小时之前`;
100
+      } else {
101
+        return `${Math.floor(diffMinutes / (60 * 24))}天之前`;
102
+      }
103
+    }
104
+  }
105
+};
106
+</script>
107
+
108
+<style lang="scss" scoped>
109
+.more-list {
110
+  padding: 30rpx 30rpx 0 30rpx;
111
+  font-weight: 400;
112
+  font-size: 30rpx;
113
+  color: #666666;
114
+
115
+  text-align: center;
116
+}
117
+
118
+.announcement-container {
119
+  border-radius: 16rpx;
120
+  padding: 20rpx 20rpx;
121
+  box-shadow: 0 8rpx 20rpx 0 rgba(0, 0, 0, 0.08);
122
+}
123
+
124
+// 空状态样式
125
+.empty-state {
126
+  display: flex;
127
+  flex-direction: column;
128
+  align-items: center;
129
+  justify-content: center;
130
+  padding: 80rpx 0;
131
+  background: #FFFFFF;
132
+  //box-shadow: 0 8rpx 20rpx 0 rgba(0, 0, 0, 0.08);
133
+  margin-top: 28rpx;
134
+  margin-bottom: 28rpx;
135
+  border-radius: 16rpx;
136
+
137
+  .empty {
138
+    margin: 0 auto;
139
+    width: 160px;
140
+    height: 155px;
141
+    background: url("../../../static/images/Empty.png") no-repeat;
142
+    background-size: cover;
143
+  }
144
+
145
+  .empty-text {
146
+    font-size: 28rpx;
147
+    color: #999999;
148
+  }
149
+}
150
+
151
+// 列表样式
152
+.list {
153
+  .list-item {
154
+    margin-top: 28rpx;
155
+    padding: 20rpx 34rpx;
156
+    font-weight: bold;
157
+    font-size: 28rpx;
158
+    color: #222222;
159
+    background: #FFFFFF;
160
+    line-height: 32rpx;
161
+    //box-shadow: 0 8rpx 20rpx 0 rgba(0, 0, 0, 0.08);
162
+    border-radius: 0 24rpx 24rpx 0;
163
+    // border-left: 20rpx solid #F0F8FF;
164
+    // border-top: 2rpx solid #F0F8FF;
165
+    // border-right: 2rpx solid #F0F8FF;
166
+    // border-bottom: 2rpx solid #F0F8FF;
167
+
168
+    // 第一个容器的边框颜色
169
+    &.first-item {
170
+      border-left: 10rpx solid #FD7474;
171
+    }
172
+
173
+    // 第二个容器的边框颜色
174
+    &.second-item {
175
+      border-left: 10rpx solid #2A70D2;
176
+    }
177
+
178
+    .list-item-title {
179
+      margin-bottom: 20rpx;
180
+
181
+      .text-grey2 {
182
+        font-weight: 400;
183
+        font-size: 24rpx;
184
+        color: #666666;
185
+        line-height: 28rpx;
186
+      }
187
+    }
188
+
189
+    .list-item-content {
190
+      font-weight: 400;
191
+      font-size: 24rpx;
192
+      color: #999999;
193
+      line-height: 28rpx;
194
+    }
195
+
196
+    &.error {
197
+      border-left: 5rpx solid #FD7474;
198
+    }
199
+
200
+    &.active {
201
+      border-left: 5rpx solid #2A70D1;
202
+    }
203
+  }
204
+}
205
+</style>

+ 110 - 0
src/pages/home/components/myToDo.vue

@@ -0,0 +1,110 @@
1
+<template>
2
+    <div>
3
+        <div class="header">
4
+            <div class="title">今日待办
5
+                <div class="knowledge">了解更多安检知识</div>
6
+            </div>
7
+            <div class="more" @click="goToMyToDo">
8
+                查看更多
9
+                <uni-icons type="arrowright" size="16" color="#999"></uni-icons>
10
+            </div>
11
+        </div>
12
+
13
+        <div class="list">
14
+            <list-card v-for="item in list" :key="item.id" :class="item.status" class="list-item">
15
+                <template #title>
16
+                    <view class="list-item-title">
17
+                        <div class="text-grey1">{{ item.title }}</div>
18
+                        <list-icon :text="'移交公安'"
19
+                            :divStyle="{ backgroundColor: item.type === 'task' ? '#FEF8F8' : '#FEF8F8', color: '#F55B5D', border: '2rpx dashed #F55B5D' }" />
20
+                    </view>
21
+                </template>
22
+                <view class="list-item-content">
23
+                    <view class="list-label">截止:</view>
24
+                    <view class="list-value">{{ item.desc }}</view>
25
+                </view>
26
+            </list-card>
27
+        </div>
28
+    </div>
29
+</template>
30
+<script>
31
+import { listCheckTask } from "@/api/check/checkTask";
32
+export default {
33
+    data() {
34
+        return {
35
+            list: [
36
+                {
37
+                    id: 1,
38
+                    title: "设备故障通知",
39
+                    time: "10分钟之前",
40
+                    content: "国内出发B区X光机出现故障,技术人员正在维修,请相关人员注意。",
41
+                    status: 'error'
42
+                },
43
+                {
44
+                    id: 2,
45
+                    title: "排版调整通知",
46
+                    time: "10分钟之前",
47
+                    content: "7月15日安检员排班有调整,请各位员工及时查看个人排班信息。",
48
+                    status: 'active'
49
+                }
50
+            ]
51
+        };
52
+    },
53
+    methods: {
54
+        goToMyToDo() {
55
+            uni.navigateTo({
56
+                url: '/pages/myToDoList/index'
57
+            });
58
+        },
59
+    }
60
+};
61
+</script>
62
+
63
+<style lang="scss" scoped>
64
+.header {
65
+    display: flex;
66
+    justify-content: space-between;
67
+    align-items: center;
68
+    padding: 24rpx 0;
69
+
70
+    .title {
71
+        font-size: 32rpx;
72
+        font-weight: bold;
73
+        color: #333;
74
+
75
+        .knowledge {
76
+            font-size: 24rpx;
77
+            color: #999;
78
+        }
79
+    }
80
+
81
+    .more {
82
+        display: flex;
83
+        align-items: center;
84
+        font-size: 24rpx;
85
+        color: #999;
86
+    }
87
+}
88
+
89
+.list {
90
+    .list-item {
91
+        .list-item-title {
92
+            width: 100%;
93
+            display: flex;
94
+            justify-content: space-between;
95
+
96
+        }
97
+
98
+        .list-item-content {
99
+            font-weight: 400;
100
+            font-size: 24rpx;
101
+            color: #666666;
102
+            line-height: 28rpx;
103
+
104
+            .mr-32 {
105
+                margin-right: 32rpx;
106
+            }
107
+        }
108
+    }
109
+}
110
+</style>

+ 157 - 0
src/pages/home/components/notice.vue

@@ -0,0 +1,157 @@
1
+<template>
2
+  <div class="tips" @click="navigateToDetail(currentNotice)">
3
+    <img alt="" src="@/static/images/notice.png">
4
+    <div class="notice-container">
5
+      <transition name="slide-up-down" mode="out-in">
6
+        <div :key="currentNotice && currentNotice.noticeId" class="notice-content">
7
+          {{ currentNotice && currentNotice.noticeTitle ? currentNotice.noticeTitle : '暂无通知' }}
8
+        </div>
9
+      </transition>
10
+    </div>
11
+  </div>
12
+</template>
13
+
14
+<script>
15
+import { getNoticeList } from "@/api/announcement/announcement.js";
16
+
17
+export default {
18
+  name: 'Notice',
19
+  data() {
20
+    return {
21
+      noticeList: [],
22
+      currentIndex: 0,
23
+      currentNotice: null,
24
+      timer: null
25
+    };
26
+  },
27
+  mounted() {
28
+    // 组件首次加载时获取数据
29
+    // this.fetchNoticeData();
30
+  },
31
+  destroyed() {
32
+    // 清理定时器
33
+    if (this.timer) {
34
+      clearInterval(this.timer);
35
+    }
36
+  },
37
+  methods: {
38
+    // 提供给父组件调用的刷新方法
39
+    refreshData() {
40
+      this.fetchNoticeData();
41
+    },
42
+
43
+    // 获取公告数据
44
+    async fetchNoticeData() {
45
+
46
+      try {
47
+        const response = await getNoticeList({
48
+          status: '0',
49
+          noticeType: 1,
50
+          pageSize: 999
51
+        });
52
+
53
+        // 假设response.data包含公告列表
54
+        this.noticeList = response.rows || [];
55
+        if (this.noticeList.length > 0) {
56
+          this.currentNotice = this.noticeList[0];
57
+          if (this.timer) {
58
+            clearInterval(this.timer);
59
+          }
60
+          // 数据加载完成后启动轮播
61
+          this.startCarousel();
62
+        }
63
+      } catch (error) {
64
+        console.error('获取公告数据失败:', error);
65
+      }
66
+    },
67
+
68
+    // 开始轮播
69
+    startCarousel() {
70
+      if (this.noticeList.length <= 1) return;
71
+
72
+      this.timer = setInterval(() => {
73
+        this.currentIndex = (this.currentIndex + 1) % this.noticeList.length;
74
+        this.currentNotice = this.noticeList[this.currentIndex];
75
+      }, 3000); // 5秒轮播一次
76
+    },
77
+
78
+    // 跳转到详情页
79
+    navigateToDetail(notice) {
80
+      if (!notice || !notice.noticeId) return;
81
+
82
+      uni.navigateTo({
83
+        url: '/pages/announcement/noticeDetail?id=' + notice.noticeId
84
+      });
85
+    }
86
+  }
87
+}
88
+</script>
89
+
90
+<style lang="scss" scoped>
91
+.tips {
92
+  display: flex;
93
+  align-items: center;
94
+  padding: 16rpx 28rpx;
95
+  background: #FFF4F4;
96
+  border-radius: 32rpx;
97
+  font-size: 24rpx;
98
+  color: #222222;
99
+  line-height: 28rpx;
100
+  margin-bottom: 32rpx;
101
+  cursor: pointer; // 鼠标指针变为手型
102
+  transition: all 0.3s ease; // 添加过渡效果
103
+  overflow: hidden;
104
+
105
+  img {
106
+    width: 66rpx;
107
+    padding-right: 28rpx;
108
+    border-right: 1px solid rgba(0, 0, 0, 0.1);
109
+    margin-right: 28rpx;
110
+    flex-shrink: 0;
111
+  }
112
+
113
+  .notice-container {
114
+    flex: 1;
115
+    overflow: hidden;
116
+    height: 28rpx;
117
+    position: relative;
118
+  }
119
+
120
+  .notice-content {
121
+    position: absolute;
122
+    top: 0;
123
+    left: 0;
124
+    width: 100%;
125
+    white-space: nowrap;
126
+    overflow: hidden;
127
+    text-overflow: ellipsis;
128
+  }
129
+
130
+  // 上下滚动动画
131
+  .slide-up-down-enter-active,
132
+  .slide-up-down-leave-active {
133
+    transition: all 0.5s ease;
134
+  }
135
+
136
+  .slide-up-down-enter-from {
137
+    transform: translateY(30rpx);
138
+    opacity: 0;
139
+  }
140
+
141
+  .slide-up-down-leave-to {
142
+    transform: translateY(-30rpx);
143
+    opacity: 0;
144
+  }
145
+
146
+  // 鼠标悬停效果
147
+  &:hover {
148
+    background: #FFE8E8; // 背景色变深
149
+    transform: scale(1.01); // 轻微放大
150
+  }
151
+
152
+  // 点击效果
153
+  &:active {
154
+    transform: scale(0.99); // 轻微缩小
155
+  }
156
+}
157
+</style>

+ 124 - 0
src/pages/home/components/task.vue

@@ -0,0 +1,124 @@
1
+<template>
2
+  <div>
3
+    <head-title subTitle="了解更多安检知识" title="今日待办"></head-title>
4
+
5
+    <div class="list">
6
+      <div v-for="item in list" :key="item.id" :class="item.status" class="list-item">
7
+        <div class="list-item-title">
8
+          <text class="text-grey1">{{ item.title }}</text>
9
+          <text :class="item.status" class="btn">{{ status[item.status] }}</text>
10
+        </div>
11
+        <div class="list-item-content">
12
+          <span class="mr-32">{{ item.content }}</span>
13
+          <span>{{ item.time }}</span>
14
+        </div>
15
+      </div>
16
+    </div>
17
+
18
+    <div class="more-list">查看全部</div>
19
+  </div>
20
+</template>
21
+<script>
22
+
23
+import HeadTitle from "@/components/HeadTitle.vue";
24
+
25
+export default {
26
+  components: {
27
+    HeadTitle
28
+  },
29
+  data() {
30
+    return {
31
+      list: [
32
+        {
33
+          id: 1,
34
+          title: "安检门性能测试",
35
+          time: "10:00-10:30",
36
+          content: "国际出发A区",
37
+          status: 'pending'
38
+        },
39
+        {
40
+          id: 2,
41
+          title: "行李传送带检查",
42
+          time: "10:00-10:30",
43
+          content: "国际出发B区",
44
+          status: 'timeout'
45
+        },
46
+        {
47
+          id: 3,
48
+          title: "安检门性能测试",
49
+          time: "10:00-10:30",
50
+          content: "国际出发A区",
51
+          status: 'done'
52
+        }
53
+      ],
54
+      status: {
55
+        pending: '处理中',
56
+        timeout: '已超时',
57
+        done: '已处理'
58
+      }
59
+    };
60
+  },
61
+  methods: {}
62
+};
63
+</script>
64
+
65
+<style lang="scss" scoped>
66
+
67
+.list {
68
+  .list-item {
69
+    margin-top: 28rpx;
70
+    padding: 32rpx 34rpx;
71
+    font-weight: bold;
72
+    font-size: 28rpx;
73
+    color: #222222;
74
+    background: #FFFFFF;
75
+    line-height: 32rpx;
76
+    box-shadow: 0 8rpx 20rpx 0 rgba(0, 0, 0, 0.08);
77
+    border-radius: 0 24rpx 24rpx 0;
78
+    border: 2rpx solid #F0F8FF;
79
+
80
+    .list-item-title {
81
+      display: flex;
82
+      justify-content: space-between;
83
+      margin-bottom: 24rpx;
84
+
85
+      .btn {
86
+        background: rgba(247, 124, 8, 0.04);
87
+        border-radius: 8rpx;
88
+        border: 2rpx dashed #F77C08;
89
+        padding: 8rpx 16rpx;
90
+        font-weight: 400;
91
+        font-size: 24rpx;
92
+        line-height: 28rpx;
93
+        text-transform: none;
94
+      }
95
+
96
+      .pending {
97
+        border-color: #F77C08;
98
+        color: #F77C08;
99
+      }
100
+
101
+      .timeout {
102
+        border-color: #ED0909;
103
+        color: #ED0909;
104
+      }
105
+
106
+      .done {
107
+        border-color: #048C36;
108
+        color: #048C36;
109
+      }
110
+    }
111
+
112
+    .list-item-content {
113
+      font-weight: 400;
114
+      font-size: 24rpx;
115
+      color: #666666;
116
+      line-height: 28rpx;
117
+
118
+      .mr-32 {
119
+        margin-right: 32rpx;
120
+      }
121
+    }
122
+  }
123
+}
124
+</style>

+ 372 - 0
src/pages/home/index.vue

@@ -0,0 +1,372 @@
1
+<template>
2
+  <home-container>
3
+    <div class="title">
4
+      <user-info :name="checkerName" :sub-title="userInfo" />
5
+
6
+      <div>
7
+        <span class="btn">
8
+          <img alt="" src="@/static/images/clock.png">{{ todayCheckInTime }}
9
+        </span>
10
+        <span class="btn">
11
+          <img alt="" src="@/static/images/status.png">执勤中
12
+        </span>
13
+      </div>
14
+    </div>
15
+
16
+    <div class="tab">
17
+      <div v-for="tab in tabs" :key="tab.id" :class="{ 'unread': tab.hasUnread, 'item': true }"
18
+        @click="goToPage(tab.appUrl)">
19
+        <img alt="" :src="tab.workbenchIcon">
20
+        <view class="text">{{ tab.appName }}</view>
21
+      </div>
22
+    </div>
23
+    <div class="work">
24
+      <!-- <div class="item">
25
+        <div class="text">
26
+          一键报警
27
+          <span>紧急情况</span>
28
+        </div>
29
+        <div class="icon"><img alt="" src="@/static/images/done.png"></div>
30
+      </div> -->
31
+      <div class="item" @click="goworkDocu">
32
+        <div class="text">
33
+          工作手册
34
+          <span>查询规则制度</span>
35
+        </div>
36
+        <div class="icon"><img alt="" src="@/static/images/pending.png"></div>
37
+      </div>
38
+    </div>
39
+
40
+    <Notice ref="notice" />
41
+    <!--  今日待办  -->
42
+    <!-- <myToDo /> -->
43
+
44
+    <!--  最新公告  -->
45
+    <announcement ref="announcement" />
46
+    <!-- 
47
+    <img alt="" class="banner" src="@/static/images/banner.png"> -->
48
+    <!--  今日待办  -->
49
+    <!-- <task /> -->
50
+  </home-container>
51
+</template>
52
+<script>
53
+import HomeContainer from "@/components/HomeContainer.vue";
54
+import UserInfo from "@/components/UserInfo.vue";
55
+import HeadTitle from "@/components/HeadTitle.vue";
56
+import Announcement from "@/pages/home/components/announcement.vue";
57
+import myToDo from "@/pages/home/components/myToDo.vue";
58
+import Task from "@/pages/home/components/task.vue";
59
+import Notice from "@/pages/home/components/notice.vue";
60
+import { getUserProfile,getAppListByRoleId } from "@/api/system/user";
61
+import { getAttendanceList } from "@/api/attendance/attendance"
62
+import { getUnreadCount } from "@/api/check/checkTask"
63
+import { checkRolePermission } from "@/utils/common.js";
64
+
65
+export default {
66
+  components: { Task, Announcement, myToDo, HeadTitle, UserInfo, HomeContainer, Notice },
67
+  data() {
68
+    return {
69
+      checkerId: this.$store.state.user.id,
70
+      userInfo: '',
71
+      todayCheckInTime: '未打卡',
72
+      unreadCount: 0,
73
+      appList: [] // 存储接口返回的应用列表
74
+    }
75
+  },
76
+  computed: {
77
+    checkerName() {
78
+      if (this.$store.state.user) {
79
+        if (this.$store.state.user.userInfo && this.$store.state.user.userInfo.nickName) {
80
+          return this.$store.state.user.userInfo.nickName
81
+        }
82
+      }
83
+      return this.$store.state.user.name
84
+    },
85
+    role() {
86
+      return this.$store?.state?.user?.roles[0]
87
+    },
88
+    tabs() {
89
+      // 如果接口返回了应用列表,则使用接口数据
90
+      if (this.appList && this.appList.length > 0) {
91
+        return this.appList.map((app, index) => {
92
+          // 根据应用名称映射到对应的路径和图标
93
+          return {
94
+            ...app,
95
+            showUnread: app.appName === '巡视检查' ? this.unreadCount > 0 : false
96
+          };
97
+        });
98
+      }
99
+    }
100
+  },
101
+
102
+  onShow() {
103
+
104
+    this.updateCurrentDate();
105
+    this.getUnreadCount()
106
+
107
+    // 页面显示时,刷新子组件的数据 - 使用$nextTick确保DOM已渲染完成
108
+    this.$nextTick(() => {
109
+      console.log("notice====", this.$refs.notice)
110
+      this.$refs.notice?.refreshData();
111
+      this.$refs.announcement?.refreshData();
112
+    });
113
+
114
+  },
115
+  onLoad() {
116
+    this.getUser()
117
+    this.loadTodayRecord();
118
+
119
+  },
120
+  methods: {
121
+    goworkDocu() {
122
+      uni.navigateTo({
123
+        url: '/pages/workDocu/index'
124
+      });
125
+    },
126
+    async getUnreadCount() {
127
+      const res = await getUnreadCount();
128
+      this.unreadCount = res.data
129
+    },
130
+    async getUser() {
131
+      const data = await getUserProfile();
132
+      console.log("data====", data)
133
+      //data.postGroup + "  " +
134
+      this.userInfo = data.data.dept.deptName
135
+      await this.loadAppList(data.data);
136
+      // this.formData.securityLocationText = data.data.dept.deptName
137
+      // this.formData.positionText = data.postGroup
138
+    },
139
+    formatDate(date) {
140
+      const year = date.getFullYear();
141
+      const month = (date.getMonth() + 1).toString().padStart(2, '0');
142
+      const day = date.getDate().toString().padStart(2, '0');
143
+      const week = ['日', '一', '二', '三', '四', '五', '六'][date.getDay()];
144
+      return `${year}年${month}月${day}日 星期${week}`;
145
+    },
146
+    updateCurrentDate() {
147
+      this.currentDate = this.formatDate(new Date());
148
+    },
149
+    handleGridClick(item) {
150
+      console.log('点击了宫格:', item);
151
+      if (item.path) {
152
+        uni.navigateTo({
153
+          url: item.path
154
+        });
155
+      } else {
156
+        this.$modal.showToast('功能开发中,敬请期待');
157
+      }
158
+    },
159
+    goToAttendance() {
160
+      uni.navigateTo({
161
+        url: '/pages/attendance/index'
162
+      });
163
+    },
164
+    changeGrid(e) {
165
+      const index = e.detail.index;
166
+      const item = this.items[index];
167
+      this.handleGridClick(item);
168
+    },
169
+    goToPage(path) {
170
+      // if (checkRolePermission(this.role, path)) {
171
+        uni.navigateTo({ url: path });
172
+      // } else {
173
+      //   this.$modal.showToast('此角色不需要查看该功能,无需操作~');
174
+      // }
175
+    },
176
+    async loadAppList(userInfo) {
177
+      const roleId = userInfo.roles && userInfo.roles.length ? userInfo.roles[0].roleId : null;
178
+
179
+      try {
180
+        if (roleId) {
181
+          const res = await getAppListByRoleId(roleId);
182
+          if (res.code === 200) {
183
+            //全部应用
184
+            const sysAppList = res.sysAppList;
185
+            const checkedAppIdList = res.checkedAppIdList;
186
+            this.appList = sysAppList.filter(app => checkedAppIdList.includes(app.appId) && app.homePage == '1');
187
+          }
188
+        }
189
+      } catch (error) {
190
+        console.error('获取应用列表失败:', error);
191
+        this.appList = [];
192
+      }
193
+    },
194
+
195
+
196
+
197
+    async loadTodayRecord() {
198
+      if (this.yesterday) {
199
+        return;
200
+      }
201
+      const today = this.formatDate(new Date());
202
+      const { data } = await getAttendanceList({
203
+        type: 1,
204
+        checkInDate: today,
205
+        userId: this.$store.state.user.id
206
+      });
207
+
208
+      // 接口 data 可能是多条(按天),我们取第一条里的 items
209
+      const todayData = data && data.length ? data[0] : null;
210
+      const items = todayData ? todayData.items : [];
211
+      const lastMorning = items.find(r => r.checkInType == 'CLOCK_IN');
212
+      if (lastMorning) {
213
+        console.log(lastMorning);
214
+        this.todayCheckInTime = '已打卡  ' + lastMorning.checkInTime
215
+
216
+      } else {
217
+
218
+      }
219
+
220
+
221
+    },
222
+    // 日期格式化(昨天、今天查询用)
223
+    formatDate(date) {
224
+      const y = date.getFullYear();
225
+      const m = String(date.getMonth() + 1).padStart(2, '0');
226
+      const d = String(date.getDate()).padStart(2, '0');
227
+      return `${y}-${m}-${d}`;
228
+    },
229
+  }
230
+}
231
+</script>
232
+
233
+<style lang="scss" scoped>
234
+img {
235
+  display: inline-flex;
236
+}
237
+
238
+.title {
239
+  padding: 40rpx 20rpx 20rpx;
240
+}
241
+
242
+.btn {
243
+  display: inline-flex;
244
+  align-items: center;
245
+  padding: 10rpx 16rpx;
246
+  font-weight: 400;
247
+  font-size: 24rpx;
248
+  color: #FFFFFF;
249
+  line-height: 28rpx;
250
+  background: rgba(255, 255, 255, 0.18);
251
+  border-radius: 8rpx;
252
+
253
+  img {
254
+    display: inline-block;
255
+    width: 32rpx;
256
+    height: 32rpx;
257
+  }
258
+
259
+  &:not(:last-child) {
260
+    margin-right: 48rpx;
261
+  }
262
+}
263
+
264
+.tab {
265
+  display: flex;
266
+  align-items: center;
267
+  justify-content: space-between;
268
+  padding: 40rpx;
269
+  background: #FFFFFF;
270
+  box-shadow: 0 8rpx 32rpx 0 rgba(0, 0, 0, 0.06);
271
+  border-radius: 32rpx;
272
+
273
+  .item {
274
+    display: flex;
275
+    align-items: center;
276
+    justify-content: center;
277
+    flex-flow: column;
278
+    font-weight: 600;
279
+    font-size: 20rpx;
280
+    color: #3D3D3D;
281
+
282
+    img {
283
+      display: inline-block;
284
+      width: 120rpx;
285
+      height: 120rpx;
286
+    }
287
+    .text {
288
+      margin-top: 10rpx;
289
+    }
290
+  }
291
+
292
+  .unread {
293
+    position: relative;
294
+  }
295
+
296
+  .unread::after {
297
+    content: '';
298
+    position: absolute;
299
+    top: 0;
300
+    right: 0;
301
+    width: 24rpx;
302
+    height: 24rpx;
303
+    background: #FF4D4F;
304
+    border-radius: 50%;
305
+  }
306
+}
307
+
308
+.work {
309
+  display: flex;
310
+  flex-shrink: 2;
311
+  margin: 32rpx 0;
312
+  gap: 26rpx;
313
+
314
+  .item {
315
+    justify-content: space-between;
316
+    flex-grow: 1;
317
+    display: flex;
318
+    padding: 20rpx 30rpx 20rpx 40rpx;
319
+    background: #FFFFFF;
320
+    box-shadow: 0 8rpx 20rpx 0 rgba(0, 0, 0, 0.06);
321
+    border-radius: 28rpx;
322
+  }
323
+
324
+  .text {
325
+    display: flex;
326
+    flex-flow: column;
327
+    font-weight: bold;
328
+    font-size: 32rpx;
329
+    color: #3D3D3D;
330
+    line-height: 44rpx;
331
+    justify-content: center;
332
+
333
+    span {
334
+      margin-top: 12rpx;
335
+      font-weight: 400;
336
+      font-size: 24rpx;
337
+      color: #999999;
338
+      line-height: 28rpx;
339
+    }
340
+  }
341
+
342
+  .icon {
343
+    display: inline-flex;
344
+    width: 112rpx;
345
+    height: 112rpx;
346
+  }
347
+}
348
+
349
+.tips {
350
+  display: flex;
351
+  align-items: center;
352
+  padding: 16rpx 28rpx;
353
+  background: #FFF4F4;
354
+  border-radius: 32rpx;
355
+  font-size: 24rpx;
356
+  color: #222222;
357
+  line-height: 28rpx;
358
+  margin-bottom: 32rpx;
359
+
360
+  img {
361
+    width: 66rpx;
362
+    padding-right: 28rpx;
363
+    border-right: 1px solid rgba(0, 0, 0, 0.1);
364
+    margin-right: 28rpx;
365
+  }
366
+}
367
+
368
+.banner {
369
+  width: 100%;
370
+  margin-bottom: 32rpx;
371
+}
372
+</style>

+ 36 - 0
src/pages/index.vue

@@ -0,0 +1,36 @@
1
+<template>
2
+  <view class="content">
3
+    <image class="logo" src="@/static/logo.png"></image>
4
+    <view class="text-area">
5
+      <text class="title">安检分级质控系统</text>
6
+    </view>
7
+  </view>
8
+</template>
9
+
10
+<style scoped>
11
+  .content {
12
+    display: flex;
13
+    flex-direction: column;
14
+    align-items: center;
15
+    justify-content: center;
16
+  }
17
+
18
+  .logo {
19
+    height: 200rpx;
20
+    width: 200rpx;
21
+    margin-top: 200rpx;
22
+    margin-left: auto;
23
+    margin-right: auto;
24
+    margin-bottom: 50rpx;
25
+  }
26
+
27
+  .text-area {
28
+    display: flex;
29
+    justify-content: center;
30
+  }
31
+
32
+  .title {
33
+    font-size: 36rpx;
34
+    color: #8f8f94;
35
+  }
36
+</style>

+ 330 - 0
src/pages/inspectionChecklist/index.vue

@@ -0,0 +1,330 @@
1
+<template>
2
+    <home-container :customStyle="{ background: 'none' }">
3
+        <div class="inspection-list-container">
4
+            <h-search v-model="taskName" placeholder="搜索任务" @search="handleSearch" @custom="handleSearch"
5
+                @clear="handleClear" />
6
+            <h-tabs v-model="currentTab" :tabs="tabList" value-key="type" label-key="title" @change="handleTabChange"
7
+                style="margin-top: 60rpx;" />
8
+
9
+            <div class="inspection-list">
10
+                <scroll-view scroll-y="true" @refresherrefresh="onRefresh" refresher-enabled
11
+                    :refresher-triggered="refresherTriggered" @scrolltolower="loadMore" :style="{ height: '100%' }">
12
+                    <list-card v-for="(item, index) in list" :key="`${item.id}_${index}`"
13
+                        @click="navigateToDetail(item.id)">
14
+                        <template #title>
15
+                            <div class="list-title">
16
+                                {{ item.taskName }}
17
+                                <view v-if="item.read == false" class="unread-dot"></view>
18
+                            </div>
19
+                        </template>
20
+                        <template #icon v-if="item.checkRecordStatus === 'DRAFT'">
21
+                            <list-icon
22
+                                :divStyle="{ backgroundColor: item.checkRecordStatus === 'DRAFT' ? '#03B847' : '#FF9900' }"
23
+                                :text="item.checkRecordStatus === 'DRAFT' ? '草稿' : '正式'" />
24
+                        </template>
25
+                        <template v-if="currentTab === 'task'">
26
+                            <view class="list-row">
27
+                                <view class="list-label">开始时间:</view>
28
+                                <view class="list-value">{{ item.checkStartTime }}</view>
29
+                            </view>
30
+                            <view class="list-row">
31
+                                <view class="list-label">结束时间:</view>
32
+                                <view class="list-value">{{ item.checkEndTime }}</view>
33
+                            </view>
34
+                            <view class="list-row">
35
+                                <view class="list-label">执行频率:</view>
36
+                                <view class="list-value">{{ item.ruleTypeDesc }}{{ item.ruleTypeNum }}次</view>
37
+                            </view>
38
+                            <view class="list-row">
39
+                                <view class="list-label">任务描述:</view>
40
+
41
+                            </view>
42
+                            <view class="list-row" style="height: auto;line-height: normal !important;">
43
+                                <view class="list-value-truncate">{{ item.description }}</view>
44
+                            </view>
45
+                        </template>
46
+                        <template v-if="currentTab === 'document'">
47
+                            <view class="list-row">
48
+                                <view class="list-label">检查人:</view>
49
+                                <view class="list-value">{{ item.checkerName }}</view>
50
+                            </view>
51
+                            <view class="list-row">
52
+                                <view class="list-label">任务编号:</view>
53
+                                <view class="list-value">{{ item.taskCode }}</view>
54
+                            </view>
55
+                            <view class="list-row">
56
+                                <view class="list-label">巡检编号:</view>
57
+                                <view class="list-value">{{ item.documentCode }}</view>
58
+                            </view>
59
+                        </template>
60
+                    </list-card>
61
+
62
+                    <!-- 加载状态提示 -->
63
+                    <view v-if="loading" class="load-more">
64
+                        <text>加载中...</text>
65
+                    </view>
66
+                    <view v-else-if="!hasMore && list.length > 0" class="no-more">
67
+                        <text>没有更多数据了</text>
68
+                    </view>
69
+                    <view v-else-if="list.length === 0" class="no-data">
70
+                        <text>暂无数据</text>
71
+                    </view>
72
+                </scroll-view>
73
+            </div>
74
+        </div>
75
+    </home-container>
76
+</template>
77
+
78
+<script>
79
+import HomeContainer from "@/components/HomeContainer.vue";
80
+import HTabs from "@/components/h-tabs/h-tabs.vue";
81
+import { listCheckTask } from "@/api/check/checkTask";
82
+import { getInspectionList } from "@/api/check/checkReward";
83
+import useDictMixin from '@/utils/dict';
84
+
85
+export default {
86
+    components: { HomeContainer, HTabs },
87
+    mixins: [useDictMixin],
88
+    data() {
89
+        return {
90
+            taskName: '',
91
+            list: [],
92
+            currentTab: 'task',
93
+            // 分页参数
94
+            pageNum: 1,
95
+            pageSize: 10,
96
+            total: 0,
97
+            loading: false,
98
+            hasMore: true,
99
+            // scroll-view下拉刷新状态控制
100
+            refresherTriggered: false,
101
+            // 字典数据
102
+            checkTaskStatusOptions: [],
103
+            checkLevelOptions: [],
104
+            taskUnreadCount: 0,//任务未读
105
+        }
106
+    },
107
+    computed: {
108
+        currentUser() {
109
+            return this.$store.state.user;
110
+        },
111
+        tabList() {
112
+            return [
113
+                {
114
+                    type: 'task',
115
+                    title: '任务入口',
116
+                    unreadCount: this.taskUnreadCount
117
+                },
118
+                {
119
+                    type: 'document',
120
+                    title: '我的单据'
121
+                }
122
+            ]
123
+        },
124
+    },
125
+    onShow: async function () {
126
+        await this.init();
127
+    },
128
+    methods: {
129
+        async init() {
130
+            await this.loadDictData();
131
+            this.loadData();
132
+            console.log(this.currentUser, "this.currentUser")
133
+        },
134
+        // 加载字典数据
135
+        async loadDictData() {
136
+            try {
137
+                const dict = await this.useDict('check_task_status', 'check_level');
138
+                this.checkTaskStatusOptions = dict.check_task_status || [];
139
+                this.checkLevelOptions = dict.check_level || [];
140
+            } catch (error) {
141
+                console.error('加载字典数据失败:', error);
142
+                uni.showToast({
143
+                    title: '字典加载失败',
144
+                    icon: 'none'
145
+                });
146
+            }
147
+        },
148
+        navigateToDetail(id) {
149
+            uni.navigateTo({
150
+                url: `/pages/checklist/index?id=${id}&type=${this.currentTab}`
151
+            });
152
+        },
153
+        async loadData() {
154
+            if (this.loading) return;
155
+
156
+            this.loading = true;
157
+            try {
158
+                const query = {
159
+                    pageNum: this.pageNum,
160
+                    pageSize: this.pageSize
161
+                };
162
+
163
+                // 根据tab添加不同参数
164
+                if (this.currentTab === 'task') {
165
+                    let levelObj = {
166
+                        'banzuzhang': "TEAM_LEVEL",
167
+                        'kezhang': "DEPARTMENT_LEVEL",
168
+                        'zhanzhang': "STATION_LEVEL",
169
+                    }
170
+                    query.status = this.checkTaskStatusOptions.find(item => item.label === '进行中')?.value;
171
+                    query.checkLevel = levelObj[this.currentUser.roles[0]];
172
+                } else if (this.currentTab === 'document') {
173
+                    // console.log(this.currentUser,"this.currentUser")
174
+                    query.checkerId = this.currentUser.id; // 使用登录人ID
175
+                }
176
+
177
+                // 添加搜索关键词
178
+                if (this.taskName) {
179
+                    query.taskName = this.taskName;
180
+                }
181
+
182
+                // 根据不同的tab调用不同的API接口
183
+                let res;
184
+                if (this.currentTab === 'task') {
185
+                    res = await listCheckTask(query);
186
+                    let res1 = await listCheckTask({
187
+                        ...query,
188
+                        pageNum: 1,
189
+                        pageSize: 9999
190
+                    });;
191
+                    this.taskUnreadCount = res1.rows.filter(item => item.read == false).length;
192
+                } else if (this.currentTab === 'document') {
193
+                    res = await getInspectionList(query);
194
+                }
195
+
196
+                if (this.pageNum === 1) {
197
+                    this.list = res.rows || [];
198
+                } else {
199
+                    this.list = [...this.list, ...(res.rows || [])];
200
+                }
201
+
202
+                this.total = res.total || 0;
203
+                this.hasMore = this.list.length < this.total;
204
+
205
+            } catch (error) {
206
+                console.error('加载数据失败:', error);
207
+                uni.showToast({
208
+                    title: '加载失败',
209
+                    icon: 'none'
210
+                });
211
+            } finally {
212
+                this.loading = false;
213
+                // 重置下拉刷新状态
214
+                this.refresherTriggered = false;
215
+                uni.stopPullDownRefresh();
216
+            }
217
+        },
218
+        loadMore() {
219
+            if (this.loading || !this.hasMore) return;
220
+            this.pageNum++;
221
+            this.loadData();
222
+        },
223
+        handleSearch(val) {
224
+            this.pageNum = 1;
225
+            this.taskName = val || '';
226
+            this.loadData();
227
+        },
228
+        handleClear() {
229
+            this.pageNum = 1;
230
+            this.taskName = '';
231
+            this.loadData();
232
+        },
233
+        handleTabChange(newValue) {
234
+            this.pageNum = 1;
235
+            this.currentTab = newValue;
236
+            this.loadData();
237
+        },
238
+        onRefresh() {
239
+            this.pageNum = 1;
240
+            this.refresherTriggered = true;
241
+            this.loadData();
242
+        }
243
+    }
244
+}
245
+</script>
246
+
247
+<style lang="scss" scoped>
248
+.inspection-list-container {
249
+    // padding: 200rpx 0 0;
250
+    background: none !important;
251
+
252
+
253
+
254
+    .inspection-list {
255
+        height: calc(100vh - 400rpx);
256
+        overflow-y: auto;
257
+
258
+        .inspection-item {
259
+            display: flex;
260
+            flex-flow: column;
261
+            justify-content: space-between;
262
+            padding: 40rpx;
263
+            background: #FFFFFF;
264
+            box-shadow: 0 8rpx 20rpx 0 rgba(0, 0, 0, 0.08);
265
+            border-radius: 16rpx;
266
+            border: 2rpx solid #F0F8FF;
267
+            margin-top: 32rpx;
268
+        }
269
+
270
+        .inspection-item-title {
271
+            font-size: 32rpx;
272
+            color: #333333;
273
+            line-height: 38rpx;
274
+        }
275
+
276
+        .inspection-item-desc {
277
+            margin: 20rpx 0 0;
278
+            font-size: 28rpx;
279
+            color: #666666;
280
+        }
281
+
282
+        /* 加载状态样式 */
283
+        .load-more,
284
+        .no-more,
285
+        .no-data {
286
+            display: flex;
287
+            justify-content: center;
288
+            align-items: center;
289
+            padding: 40rpx;
290
+            font-size: 28rpx;
291
+            color: #999;
292
+        }
293
+
294
+        .load-more {
295
+            flex-direction: column;
296
+            gap: 20rpx;
297
+        }
298
+    }
299
+}
300
+
301
+.list-title {
302
+    position: relative;
303
+}
304
+
305
+.unread-dot {
306
+    position: absolute;
307
+    top: -4rpx;
308
+    right: -20rpx;
309
+    width: 16rpx;
310
+    height: 16rpx;
311
+    background-color: #FF4D4F;
312
+    border-radius: 50%;
313
+}
314
+
315
+
316
+
317
+/* 三行文字截断样式 */
318
+.list-value-truncate {
319
+    flex: 1;
320
+    color: #999999;
321
+    word-break: break-all;
322
+    /* 三行文字截断 - 超过三行显示省略号 */
323
+    display: -webkit-box;
324
+    -webkit-line-clamp: 2;
325
+    -webkit-box-orient: vertical;
326
+    overflow: hidden;
327
+    text-overflow: ellipsis;
328
+    max-height: 120rpx; /* 3行 * 40rpx行高 */
329
+}
330
+</style>

+ 272 - 0
src/pages/inspectionRecord/detail.vue

@@ -0,0 +1,272 @@
1
+<template>
2
+    <home-container>
3
+
4
+        <view class="container">
5
+
6
+
7
+            <!-- <scroll-view scroll-y class="content"> -->
8
+            <view class="content">
9
+                <!-- 基础信息 -->
10
+                <uni-card title="基础信息">
11
+                    <view class="row">
12
+                        <text class="label">任务名称:</text>
13
+                        <text class="value">{{ inspection.documentName || '未命名' }}</text>
14
+                    </view>
15
+                    <view class="row">
16
+                        <text class="label">检查人:</text>
17
+                        <text class="value">{{ inspection.checkerName }}</text>
18
+                    </view>
19
+                    <view class="row">
20
+                        <text class="label">检查时间:</text>
21
+                        <text class="value">{{ inspection.checkTime }}</text>
22
+                    </view>
23
+                    <view class="row">
24
+                        <text class="label">评分:</text>
25
+                        <text class="value">{{ inspection.scoreTotal }} 分</text>
26
+                    </view>
27
+                    <view class="row">
28
+                        <text class="label">状态:</text>
29
+                        <!-- <text class="value status" :style="{ color: getColor(inspection.correctionStatus) }">
30
+                            {{ inspection.correctionStatusDesc }}
31
+                        </text> -->
32
+
33
+                        <text class="value status" :style="{ color: inspection.statusColor }">
34
+                            {{ inspection.correctionStatusDesc }}
35
+                        </text>
36
+                    </view>
37
+                </uni-card>
38
+
39
+                <uni-card title="负面问题记录">
40
+                    <view v-for="(it, i) in problems || []" :key="i" class="issue">
41
+                        <view v-if="it.categoryNameOne" class="reward">
42
+                            问题类型:{{ it.categoryNameOne + "/" + it.categoryNameTwo }}
43
+                        </view>
44
+                        <view v-if="it.problemDescription" class="reward">
45
+                            问题描述:{{ it.problemDescription }}
46
+                        </view>
47
+                        <view class="users">
48
+                            涉及人员:
49
+                            {{(it.checkUserList || []).map(u => u.userName).join(',')}} </view>
50
+                    </view>
51
+                </uni-card>
52
+
53
+                <!-- 问题/优秀事例 -->
54
+                <uni-card title="优秀事例记录">
55
+                    <view v-for="(it, i) in goodExamples || []" :key="i" class="issue">
56
+                        <view v-if="it.categoryNameOne" class="reward">
57
+                            问题类型:{{ it.categoryNameOne + "/" + it.categoryNameTwo }}
58
+                        </view>
59
+                        <view v-if="it.problemDescription" class="reward">
60
+                            问题描述:{{ it.problemDescription }}
61
+                        </view>
62
+                        <view class="users">
63
+                            涉及人员:
64
+                            {{(it.checkUserList || []).map(u => u.userName).join(',')}} </view>
65
+                        <view v-if="it.rewardSuggestion" class="reward">
66
+                            奖励建议:{{ it.rewardSuggestion }}
67
+                        </view>
68
+                    </view>
69
+                </uni-card>
70
+
71
+
72
+
73
+                <!-- 总体评价 -->
74
+                <uni-card title="总体评价">
75
+                    <text class="value">{{ inspection.comprehensiveEvaluation || '无' }}</text>
76
+                </uni-card>
77
+
78
+                <uni-card title="附件列表">
79
+                    <view v-for="(file, idx) in inspection.baseAttachmentList" :key="idx" class="file-item"
80
+                        @tap="handleDownload(file)">
81
+                        <uni-icons type="paperclip" size="18" color="#007aff" class="icon" />
82
+                        <text class="file-name">{{ file.attachmentName }}</text>
83
+                        <text class="file-size" v-if="file.fileSize">
84
+                            ({{ (file.fileSize / 1024).toFixed(1) }} KB)
85
+                        </text>
86
+                    </view>
87
+
88
+                    <!-- <view v-if="!inspection.baseAttachmentList.length" class="no-file">
89
+                        暂无附件
90
+                    </view> -->
91
+                </uni-card>
92
+            </view>
93
+            <!-- </scroll-view> -->
94
+        </view>
95
+    </home-container>
96
+
97
+</template>
98
+
99
+<script>
100
+import HomeContainer from "@/components/HomeContainer.vue";
101
+
102
+import { getInspectionListById } from '@/api/check/checkReward.js'
103
+
104
+export default {
105
+    components: { HomeContainer },
106
+
107
+    data() {
108
+        return {
109
+            inspection: {},
110
+            problems: [],
111
+            goodExamples: []
112
+        }
113
+    },
114
+    onLoad(options) {
115
+        const id = Number(options.id); // 确保是数字
116
+        this.fetchDetail(id);
117
+        console.log(id); // 现在就是真正的 id
118
+
119
+    },
120
+    methods: {
121
+        handleDownload(file) {
122
+            const url = file.attachmentUrl;
123
+            if (!url) return;
124
+
125
+            // 如果是图片,直接预览;否则下载
126
+            if (/\.(jpg|jpeg|png|gif)$/i.test(url)) {
127
+                uni.previewImage({ urls: [url] });
128
+            } else {
129
+                uni.showLoading({ title: '下载中…' });
130
+                uni.downloadFile({
131
+                    url,
132
+                    success: (res) => {
133
+                        if (res.statusCode === 200) {
134
+                            uni.openDocument({
135
+                                filePath: res.tempFilePath,
136
+                                showMenu: true,
137
+                                fail: () => uni.showToast({ title: '无法打开文件', icon: 'none' })
138
+                            });
139
+                        } else {
140
+                            uni.showToast({ title: '下载失败', icon: 'none' });
141
+                        }
142
+                    },
143
+                    complete: () => uni.hideLoading()
144
+                });
145
+            }
146
+        },
147
+        async fetchDetail(id) {
148
+            uni.showLoading({ title: '加载中' });
149
+            const res = await getInspectionListById(id);
150
+            // 后端返回 { code, msg, data }
151
+            this.inspection = res.data || {};
152
+
153
+
154
+            const scoreMap = {
155
+                0: { desc: '不合格', color: '#ff4d4f' },
156
+                1: { desc: '不合格', color: '#ff4d4f' },
157
+                2: { desc: '不合格', color: '#ff4d4f' },
158
+                3: { desc: '合格', color: '#52c41a' },
159
+                4: { desc: '合格', color: '#52c41a' },
160
+                5: { desc: '优秀', color: '#1890ff' }
161
+            }
162
+            const score = this.inspection.scoreTotal ?? 0
163
+            const info = scoreMap[score] || { desc: '未知', color: '#666' }
164
+            this.inspection.correctionStatusDesc = info.desc
165
+            this.inspection.statusColor = info.color
166
+
167
+
168
+
169
+
170
+            this.problems = res.data.checkProjectItemList?.filter(item => item.evaluationType === '0') || [];
171
+            this.goodExamples = res.data.checkProjectItemList?.filter(item => item.evaluationType === '1') || [];
172
+
173
+            uni.hideLoading();
174
+        },
175
+
176
+        getColor(status) {
177
+            const map = { QUALIFIED: '#52c41a', UNQUALIFIED: '#ff4d4f', PENDING: '#faad14' }
178
+            return map[status] || '#666'
179
+        },
180
+        preview(url) {
181
+            uni.previewImage({ urls: [url] })
182
+        }
183
+    }
184
+}
185
+</script>
186
+
187
+<style lang="scss" scoped>
188
+.container {
189
+    padding: 20rpx;
190
+    background-color: #f5f5f5;
191
+}
192
+
193
+.content {
194
+    // height: calc(100vh - 88rpx);
195
+    padding-top: 180rpx;
196
+
197
+}
198
+
199
+.row {
200
+    display: flex;
201
+    margin-bottom: 16rpx;
202
+
203
+    .label {
204
+        width: 160rpx;
205
+        color: #666;
206
+    }
207
+
208
+    .value {
209
+        flex: 1;
210
+    }
211
+}
212
+
213
+.issue {
214
+    margin-bottom: 20rpx;
215
+    padding: 20rpx;
216
+    background: #fff;
217
+    border-radius: 8rpx;
218
+
219
+    .title {
220
+        font-weight: bold;
221
+        margin-bottom: 10rpx;
222
+    }
223
+
224
+    .users,
225
+    .reward {
226
+        font-size: 26rpx;
227
+        color: #666;
228
+        margin-top: 8rpx;
229
+    }
230
+}
231
+
232
+.file {
233
+    display: flex;
234
+    align-items: center;
235
+    margin-bottom: 10rpx;
236
+}
237
+
238
+.status {
239
+    font-weight: bold;
240
+}
241
+
242
+.file-item {
243
+    display: flex;
244
+    align-items: center;
245
+    padding: 20rpx 0;
246
+    border-bottom: 1rpx solid #eee;
247
+
248
+    &:last-child {
249
+        border-bottom: none;
250
+    }
251
+
252
+    .icon {
253
+        margin-right: 12rpx;
254
+    }
255
+
256
+    .file-name {
257
+        flex: 1;
258
+        color: #007aff;
259
+    }
260
+
261
+    .file-size {
262
+        font-size: 24rpx;
263
+        color: #999;
264
+    }
265
+}
266
+
267
+.no-file {
268
+    color: #999;
269
+    text-align: center;
270
+    padding: 30rpx 0;
271
+}
272
+</style>

Plik diff jest za duży
+ 1181 - 0
src/pages/inspectionRecord/index.vue


+ 252 - 0
src/pages/inspectionRecordList/index.vue

@@ -0,0 +1,252 @@
1
+<template>
2
+  <home-container>
3
+
4
+    <view class="container">
5
+      <!-- 搜索栏 -->
6
+      <view class="search-bar-wrapper">
7
+
8
+        <uni-search-bar v-model="searchText" placeholder="输入任务名称搜索" @confirm="onSearch" @clear="onSearch"
9
+          @input="onSearch" />
10
+      </view>
11
+
12
+      <!-- 列表区域 -->
13
+      <scroll-view scroll-y class="list-container" @scrolltolower="loadMore">
14
+        <view class="task-item" v-for="(task, index) in inspectionList" :key="index" @click="navigateToDetail(task.id)">
15
+          <view class="item-header">
16
+            <text class="task-name">{{ task.documentName || '未命名任务' }}</text>
17
+            <!-- <text class="task-status" :style="{ color: getStatusColor(task.correctionStatus) }">
18
+              {{ task.correctionStatusDesc || '未知状态' }}
19
+            </text> -->
20
+
21
+            <text class="task-status" :style="{ color: task.statusColor }">
22
+              {{ task.correctionStatusDesc }}
23
+            </text>
24
+          </view>
25
+
26
+          <view class="item-content">
27
+            <view class="info-row">
28
+              <uni-icons type="person" size="16" color="#666"></uni-icons>
29
+              <text class="info-text">检查人: {{ task.checkerName || '未知' }}</text>
30
+            </view>
31
+
32
+            <view class="info-row">
33
+              <uni-icons type="calendar" size="16" color="#666"></uni-icons>
34
+              <text class="info-text">截至日期: {{ formatDate(task.checkTime) }}</text>
35
+            </view>
36
+
37
+            <view class="info-row">
38
+              <uni-icons type="paperclip" size="16" color="#666"></uni-icons>
39
+              <text class="info-text">任务编号: {{ task.id || '未分配' }}</text>
40
+            </view>
41
+
42
+            <view class="info-row">
43
+              <uni-icons type="calendar" size="16" color="#666"></uni-icons>
44
+              <text class="info-text">发布时间: {{ formatDate(task.checkTime) }}</text>
45
+            </view>
46
+          </view>
47
+        </view>
48
+
49
+        <uni-load-more :status="loadingStatus" :content-text="{
50
+          contentdown: '上拉加载更多',
51
+          contentrefresh: '正在加载...',
52
+          contentnomore: '没有更多数据了'
53
+        }" />
54
+      </scroll-view>
55
+    </view>
56
+  </home-container>
57
+</template>
58
+
59
+<script>
60
+import HomeContainer from "@/components/HomeContainer.vue";
61
+import { getInspectionList } from '@/api/check/checkReward.js'
62
+export default {
63
+  components: { HomeContainer },
64
+
65
+  data() {
66
+    return {
67
+      inspectionList: [],
68
+      loadingStatus: 'more',
69
+      pageNum: 1,
70
+      pageSize: 10,
71
+      searchText: '',
72
+      total: 0
73
+    }
74
+  },
75
+  onLoad() {
76
+    this.loadData()
77
+  },
78
+  methods: {
79
+    async loadData(refresh = false) {
80
+      if (this.loadingStatus === 'loading') return;
81
+      this.loadingStatus = 'loading';
82
+
83
+      try {
84
+        // 组装参数
85
+        const res = await getInspectionList({
86
+          pageNum: this.pageNum,
87
+          pageSize: this.pageSize,
88
+          checkerId: this.$store.state.user.id,
89
+          // 后端如果支持关键词字段,可改为 keyword / searchValue / documentName 等
90
+          documentName: this.searchText || ''
91
+        });
92
+        console.log("res=======", res)
93
+
94
+        res.rows.forEach(row => {
95
+          const { desc, color } = this.mapScoreToStatus(row.scoreTotal);
96
+          row.correctionStatusDesc = desc;
97
+          row.statusColor = color;
98
+        });
99
+
100
+        this.total = res.total;
101
+        if (refresh) {
102
+          this.inspectionList = res.rows;
103
+        } else {
104
+          this.inspectionList = [...this.inspectionList, ...res.rows];
105
+        }
106
+        this.loadingStatus =
107
+          res.rows.length < this.pageSize ? 'noMore' : 'more';
108
+        // console.log("loadingStatus",this.loadingStatus)
109
+      } catch (e) {
110
+        // uni.showToast({ title: '加载失败', icon: 'none' });
111
+        this.loadingStatus = 'more';
112
+      } finally {
113
+        this.triggered = false;
114
+      }
115
+    },
116
+
117
+    mapScoreToStatus(score) {
118
+      if (score >= 0 && score <= 2) return { desc: '不合格', color: '#ff4d4f' };
119
+      if (score >= 3 && score <= 4) return { desc: '合格', color: '#52c41a' };
120
+      if (score === 5) return { desc: '优秀', color: '#1890ff' };
121
+      return { desc: '未知', color: '#666' };
122
+    },
123
+
124
+    /* 搜索(支持回车、清空、实时) */
125
+    onSearch(e) {
126
+      this.searchText = e.value.trim();
127
+      this.pageNum = 1;
128
+      this.loadData(true);
129
+    },
130
+    /* 下拉刷新 */
131
+    onRefresh() {
132
+      this.pageNum = 1;
133
+      this.loadData(true);
134
+    },
135
+    /* 上拉加载更多 */
136
+    loadMore() {
137
+      // console.log("上拉")
138
+      if (this.loadingStatus === 'noMore') return;
139
+      // console.log("又进去了")
140
+      this.pageNum += 1;
141
+      this.loadData();
142
+    },
143
+
144
+
145
+
146
+    handleSearch() {
147
+      console.log("搜索")
148
+      this.pageNum = 1
149
+      this.loadData()
150
+    },
151
+
152
+    handleInput(e) {
153
+      this.searchText = e.value
154
+    },
155
+
156
+    navigateToDetail(id) {
157
+      uni.navigateTo({
158
+        url: '/pages/inspectionRecord/detail?id=' + id
159
+      })
160
+    },
161
+
162
+    formatDate(date) {
163
+      if (!date) return '未知'
164
+      return new Date(date).toISOString().slice(0, 10);
165
+    },
166
+
167
+    getStatusColor(status) {
168
+      const map = {
169
+        QUALIFIED: '#52c41a',
170
+        UNQUALIFIED: '#ff4d4f',
171
+        PENDING: '#faad14'
172
+      }
173
+      return map[status] || '#666'
174
+    }
175
+  }
176
+}
177
+</script>
178
+
179
+<style lang="scss" scoped>
180
+.container {
181
+  padding: 20rpx;
182
+  height: 100vh;
183
+  display: flex;
184
+  flex-direction: column;
185
+}
186
+
187
+/* 搜索栏容器 */
188
+.search-bar-wrapper {
189
+  margin-top: 140rpx;
190
+  /* 控制搜索框离顶部的距离 */
191
+  // padding: 0 20rpx;
192
+}
193
+
194
+.search-bar {
195
+  padding: 20rpx;
196
+  background-color: #fff;
197
+}
198
+
199
+.list-container {
200
+  flex: 1;
201
+  padding: 0 20rpx;
202
+  height: calc(100vh - 120rpx);
203
+  border: 2px solid transparent;
204
+  // width: 100%;
205
+}
206
+
207
+.task-item {
208
+  background-color: #fff;
209
+  border-radius: 12rpx;
210
+  padding: 24rpx;
211
+  margin-bottom: 20rpx;
212
+  box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
213
+  border: 1rpx solid #ddd;
214
+
215
+}
216
+
217
+.item-header {
218
+  display: flex;
219
+  justify-content: space-between;
220
+  align-items: center;
221
+  margin-bottom: 20rpx;
222
+}
223
+
224
+.task-name {
225
+  font-size: 32rpx;
226
+  font-weight: bold;
227
+  color: #333;
228
+}
229
+
230
+.task-status {
231
+  font-size: 26rpx;
232
+  padding: 4rpx 12rpx;
233
+  border-radius: 20rpx;
234
+  background-color: #f5f5f5;
235
+}
236
+
237
+.item-content {
238
+  padding-left: 10rpx;
239
+}
240
+
241
+.info-row {
242
+  display: flex;
243
+  align-items: center;
244
+  margin-bottom: 12rpx;
245
+}
246
+
247
+.info-text {
248
+  font-size: 26rpx;
249
+  color: #666;
250
+  margin-left: 10rpx;
251
+}
252
+</style>

+ 109 - 0
src/pages/inspectionStatistics/components/DataCard.vue

@@ -0,0 +1,109 @@
1
+<template>
2
+  <view class="data-card-container">
3
+    <view class="card-grid">
4
+      <view v-for="(item, index) in cardData" :key="index" class="card-item">
5
+        <view class="card-content">
6
+          <!-- 左侧内容 -->
7
+          <view class="left-content">
8
+            <view class="label">{{ item.label }}</view>
9
+            <view class="value">{{ item.value }}</view>
10
+          </view>
11
+
12
+          <!-- 右侧图片 -->
13
+          <view class="right-content">
14
+            <image :src="item.image" class="card-image" mode="aspectFit" />
15
+          </view>
16
+        </view>
17
+      </view>
18
+    </view>
19
+  </view>
20
+</template>
21
+
22
+<script>
23
+export default {
24
+  name: 'DataCard',
25
+  props: {
26
+    // 卡片数据数组
27
+    cardData: {
28
+      type: Array,
29
+      default: () => []
30
+    }
31
+  },
32
+  data() {
33
+    return {
34
+      // 示例数据
35
+
36
+    }
37
+  },
38
+  computed: {
39
+
40
+  }
41
+}
42
+</script>
43
+
44
+<style lang="scss" scoped>
45
+.data-card-container {
46
+  padding: 24rpx 0;
47
+}
48
+
49
+.card-grid {
50
+  display: grid;
51
+  grid-template-columns: repeat(2, 1fr) !important; // 每行两个等宽列
52
+  gap: 24rpx;
53
+
54
+  .card-item {
55
+    min-width: 0;
56
+    box-sizing: border-box;
57
+
58
+    .card-content {
59
+      background: #fff;
60
+      border-radius: 16rpx;
61
+      padding: 32rpx 24rpx;
62
+      box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
63
+      display: flex;
64
+      align-items: center;
65
+      justify-content: space-between;
66
+      height: 160rpx;
67
+
68
+      .left-content {
69
+        flex: 1;
70
+        display: flex;
71
+        flex-direction: column;
72
+        justify-content: center;
73
+
74
+        .label {
75
+          font-size: 24rpx;
76
+          color: #666;
77
+          margin-bottom: 8rpx;
78
+          font-weight: 400;
79
+        }
80
+
81
+        .value {
82
+          font-size: 45rpx;
83
+          color: #333;
84
+          font-weight: bold;
85
+          line-height: 1.2;
86
+        }
87
+      }
88
+
89
+      .right-content {
90
+        flex-shrink: 0;
91
+        margin-left: 24rpx;
92
+
93
+        .card-image {
94
+          width: 80rpx;
95
+          height: 80rpx;
96
+          border-radius: 8rpx;
97
+        }
98
+      }
99
+    }
100
+  }
101
+}
102
+
103
+// 响应式适配
104
+@media (max-width: 750px) {
105
+  .card-grid {
106
+    grid-template-columns: 1fr; // 小屏幕下每行一个
107
+  }
108
+}
109
+</style>

+ 220 - 0
src/pages/inspectionStatistics/components/InspectionExecution.vue

@@ -0,0 +1,220 @@
1
+<template>
2
+  <view class="inspection-execution">
3
+    <!-- 当天任务执行情况 -->
4
+    <view class="daily-task-card">
5
+      <text class="daily-task-title">当天任务执行情况</text>
6
+      <!-- 站级任务明细 -->
7
+      <view class="task-details" v-if="dataStation.length > 0">
8
+        <view class="task-detail-item">
9
+          <text class="task-label">站级任务明细</text>
10
+        </view>
11
+        <view v-for="item in dataStation" :key="item.taskCode">
12
+          <view class="task-info">
13
+            <text>{{ item.taskName }}&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;每日{{ item.ruleTypeNum }}次</text>
14
+            <text>完成状况:{{ `${item.notStartedCount}/${item.inProgressCount}/${item.completedCount}` }}</text>
15
+          </view>
16
+          <statistic-table v-if="item.checkLargeScreenInspectionExecuteItemDtoList.length > 0" :columns="tableColumns"
17
+            :data="item.checkLargeScreenInspectionExecuteItemDtoList">
18
+            <template #column-completionPercentage="{ row, index }">
19
+              <view class="progress-container">
20
+                <u-line-progress :percentage="row.completionPercentage" :showText="false"
21
+                  :activeColor="getProgressColor(row.completionPercentage)" height="20rpx"
22
+                  width="200rpx"></u-line-progress>
23
+                <text class="progress-text">{{ row.completionPercentage }}%</text>
24
+              </view>
25
+            </template>
26
+          </statistic-table>
27
+        </view>
28
+      </view>
29
+
30
+      <!-- 科级任务明细 -->
31
+      <view class="task-details" v-if="dataDepartment.length > 0">
32
+        <view class="task-detail-item">
33
+          <text class="task-label">科级任务明细</text>
34
+        </view>
35
+        <view v-for="item in dataDepartment" :key="item.taskCode">
36
+          <view class="task-info">
37
+            <text>{{ item.taskName }}&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;每日{{ item.ruleTypeNum }}次</text>
38
+            <text v-if="!isKeZhang">完成状况:{{ `${item.notStartedCount}/${item.inProgressCount}/${item.completedCount}` }}</text>
39
+          </view>
40
+          <statistic-table v-if="item.checkLargeScreenInspectionExecuteItemDtoList.length > 0" :columns="tableColumns"
41
+            :data="item.checkLargeScreenInspectionExecuteItemDtoList">
42
+            <template #column-completionPercentage="{ row, index }">
43
+              <view class="progress-container">
44
+                <u-line-progress :percentage="row.completionPercentage" :showText="false"
45
+                  :activeColor="getProgressColor(row.completionPercentage)" height="20rpx"
46
+                  width="200rpx"></u-line-progress>
47
+                <text class="progress-text">{{ row.completionPercentage }}%</text>
48
+              </view>
49
+            </template>
50
+          </statistic-table>
51
+        </view>
52
+      </view>
53
+
54
+      <!-- 班组任务明细 -->
55
+      <view class="task-details" v-if="dataTeam.length > 0">
56
+        <view class="task-detail-item">
57
+          <text class="task-label">班组任务明细</text>
58
+        </view>
59
+        <view v-for="item in dataTeam" :key="item.taskCode">
60
+          <view class="task-info">
61
+            <text>{{ item.taskName }}&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;每日{{ item.ruleTypeNum }}次</text>
62
+            <text v-if="!isBanZuZhang">完成状况:{{ `${item.notStartedCount}/${item.inProgressCount}/${item.completedCount}` }}</text>
63
+          </view>
64
+          <statistic-table v-if="item.checkLargeScreenInspectionExecuteItemDtoList.length > 0" :columns="tableColumns"
65
+            :data="item.checkLargeScreenInspectionExecuteItemDtoList">
66
+            <template #column-completionPercentage="{ row, index }">
67
+              <view class="progress-container">
68
+                <u-line-progress :percentage="row.completionPercentage" :showText="false"
69
+                  :activeColor="getProgressColor(row.completionPercentage)" height="20rpx"
70
+                  width="200rpx"></u-line-progress>
71
+                <text class="progress-text">{{ row.completionPercentage }}%</text>
72
+              </view>
73
+            </template>
74
+          </statistic-table>
75
+        </view>
76
+      </view>
77
+    </view>
78
+  </view>
79
+</template>
80
+
81
+<script>
82
+
83
+import { checkedLevelEnums } from "@/utils/enums.js"
84
+import { getExecutionStatusTotal } from '@/api/inspectionStatistics/inspectionStatistics'
85
+export default {
86
+  data() {
87
+    return {
88
+      tableColumns: [
89
+        { title: '姓名', props: 'userName' },
90
+        { title: '今日完成比例', props: 'completionPercentage', slot: true },
91
+        { title: '整改单数', props: 'checkOrderCount' },
92
+        { title: '不合格项数', props: 'rectificationOrderCount' },
93
+      ],
94
+      // 站级任务明细数据
95
+      dataStation: [],
96
+      // 科级任务明细数据
97
+      dataDepartment: [],
98
+      // 班组任务明细数据
99
+      dataTeam: []
100
+    }
101
+  },
102
+  computed: {
103
+    // 班组长
104
+    isBanZuZhang() {
105
+      let roles = this.$store.state.user.roles;
106
+      return roles && roles.includes('banzuzhang')
107
+    },
108
+    isKeZhang(){
109
+      let roles = this.$store.state.user.roles;
110
+      return roles && roles.includes('kezhang')
111
+    }
112
+  },
113
+  mounted() {
114
+    this.loadExecutionStatusData();
115
+  },
116
+  methods: {
117
+    // 根据完成比例返回对应的颜色
118
+    getProgressColor(percentage) {
119
+      if (percentage >= 100) {
120
+        return '#2B7BFF'; // 大于等于100%时使用蓝色
121
+      } else if (percentage > 0 && percentage < 100) {
122
+        return '#03AF43'; // 0-100%之间使用绿色
123
+      } else {
124
+        return '#E40808'; // 0%时使用红色
125
+      }
126
+    },
127
+
128
+    // 加载执行状态数据
129
+    async loadExecutionStatusData() {
130
+      try {
131
+        console.log('开始请求getExecutionStatusTotal接口...');
132
+        const response = await getExecutionStatusTotal();
133
+        console.log('getExecutionStatusTotal接口返回结果:', response);
134
+
135
+        if (response && response.code === 200) {
136
+          console.log('接口数据详情:', response.data);
137
+          this.dataStation = response.data.filter(item => item.checkLevel === checkedLevelEnums.STATION_LEVEL)
138
+          this.dataDepartment = response.data.filter(item => item.checkLevel === checkedLevelEnums.DEPARTMENT_LEVEL)
139
+          this.dataTeam = response.data.filter(item => item.checkLevel === checkedLevelEnums.TEAM_LEVEL)
140
+
141
+          console.log(this.dataTeam, "this.dataTeam")
142
+        } else {
143
+          console.log('接口返回异常:', response);
144
+        }
145
+      } catch (error) {
146
+        console.error('请求getExecutionStatusTotal接口失败:', error);
147
+      }
148
+    }
149
+  }
150
+}
151
+</script>
152
+
153
+<style lang="scss" scoped>
154
+.inspection-execution {
155
+  .daily-task-card {
156
+    background: #fff;
157
+    border-radius: 16rpx;
158
+    padding: 24rpx;
159
+    margin-bottom: 24rpx;
160
+    box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
161
+
162
+    .daily-task-title {
163
+      display: block;
164
+      font-size: 28rpx;
165
+      font-weight: bold;
166
+      color: #333;
167
+      margin-bottom: 20rpx;
168
+    }
169
+
170
+    .task-details:not(:last-child) {
171
+      margin-bottom: 20rpx;
172
+    }
173
+
174
+    .task-details {
175
+
176
+      .task-info {
177
+        color: #4873E3;
178
+        font-size: 27rpx;
179
+        height: 80rpx;
180
+        line-height: 80rpx;
181
+        font-weight: 500;
182
+        display: flex;
183
+        justify-content: space-between;
184
+      }
185
+
186
+      .task-detail-item {
187
+        flex: 1;
188
+        text-align: left;
189
+        height: 50rpx;
190
+        padding-left: 10rpx;
191
+        line-height: 50rpx;
192
+        border-radius: 8rpx;
193
+
194
+        background: linear-gradient(to right, #4873E3, #fff);
195
+
196
+        .task-label {
197
+          font-size: 27rpx;
198
+          color: #fff;
199
+          font-weight: 500;
200
+        }
201
+
202
+
203
+      }
204
+
205
+      .progress-container {
206
+        display: flex;
207
+        align-items: center;
208
+        gap: 16rpx;
209
+
210
+        .progress-text {
211
+          font-size: 24rpx;
212
+          color: #4873E3;
213
+          font-weight: 500;
214
+          min-width: 60rpx;
215
+        }
216
+      }
217
+    }
218
+  }
219
+}
220
+</style>

+ 536 - 0
src/pages/inspectionStatistics/components/InspectionPlan.vue

@@ -0,0 +1,536 @@
1
+<template>
2
+  <view class="inspection-stats-charts">
3
+    <!-- 时间筛选器 -->
4
+    <view class="time-filter-section">
5
+      <view class="filter-row">
6
+        <StaticsTab v-model="activeTimeRange" :tabs="timeRangeOptions" active-color="#4873E3"
7
+          @change="handleTimeRangeChange" />
8
+        <view v-if="activeTimeRange === 'custom'" class="custom-time-section">
9
+          <uni-datetime-picker v-model="dateRange" type="daterange" rangeSeparator="至" @change="handleDateRangeChange"
10
+            class="date-range-picker" :clear-icon="false"/>
11
+          <text class="days-count">共 {{ totalDays }} 天</text>
12
+        </view>
13
+      </view>
14
+    </view>
15
+
16
+    <!-- 计划安排总览 - 面积图 -->
17
+    <view class="stats-card">
18
+      <view class="chart-container">
19
+        <text class="chart-title">计划安排总览</text>
20
+        <view class="chart-wrapper">
21
+          <!-- 使用ECharts实现的面积图 -->
22
+          <view id="lineChart" class="echarts-canvas"></view>
23
+        </view>
24
+      </view>
25
+    </view>
26
+
27
+    <!-- 日常任务检查指标累计分布 - 雷达图 -->
28
+    <view class="stats-card">
29
+      <view class="chart-container">
30
+        <text class="chart-title">日常任务检查指标累计分布</text>
31
+        <view class="chart-wrapper">
32
+          <!-- 使用ECharts实现的雷达图 -->
33
+          <view id="radarChart" class="echarts-canvas"></view>
34
+        </view>
35
+      </view>
36
+    </view>
37
+
38
+    <!-- 任务明细统计表 -->
39
+    <view class="stats-card">
40
+      <view class="table-container">
41
+        <text class="chart-title">任务明细统计</text>
42
+        <statistic-table :columns="tableColumns" :data="tableData">
43
+          <template #column-frequency="{ row, index }">
44
+            {{ `${row.ruleTypeDesc}${row.ruleTypeNum}次` }}
45
+          </template>
46
+        </statistic-table>
47
+      </view>
48
+    </view>
49
+  </view>
50
+</template>
51
+
52
+<script>
53
+
54
+import { calculateTimeRange } from '@/utils/formatUtils.js';
55
+import StaticsTab from './StaticsTab.vue';
56
+import * as echarts from 'echarts';
57
+import { getPlanOverview, getDailyTaskMetrics, getTaskDetailStats } from '@/api/inspectionStatistics/inspectionStatistics.js';
58
+export default {
59
+  name: 'InspectionStatsCharts',
60
+  components: {
61
+    StaticsTab
62
+  },
63
+  props: {
64
+    // 时间范围选项数组
65
+    timeRanges: {
66
+      type: Array,
67
+      default: () => ['近一周', '近一月', '近90天']
68
+    }
69
+  },
70
+  data() {
71
+    return {
72
+      activeTimeRange: 'month',
73
+      timeRangeOptions: [
74
+        { title: '本年', type: 'year' },
75
+        { title: '本月', type: 'month' },
76
+        { title: '自定义', type: 'custom' }
77
+      ],
78
+      dateRange: [],
79
+      startDate: '',
80
+      endDate: '',
81
+      totalDays: 0,
82
+      tableColumns: [
83
+        { title: '开始时间', props: 'checkStartTime' },
84
+        { title: '任务名称', props: 'taskName' },
85
+        { title: '检查级别', props: 'checkedLevelDesc' },
86
+        { title: '执行频率', props: 'frequency', slot: true },
87
+        { title: '结束时间', props: 'checkEndTime' }
88
+      ],
89
+      tableData: [],
90
+      radarData: {
91
+        indicators: [
92
+          { name: '安全检查', max: 100 },
93
+          { name: '设备检查', max: 100 },
94
+          { name: '环境检查', max: 100 },
95
+          { name: '流程检查', max: 100 },
96
+          { name: '文档检查', max: 100 }
97
+        ],
98
+        data: []
99
+      },
100
+      lineChart: null,
101
+      radarChart: null
102
+    };
103
+  },
104
+
105
+  mounted() {
106
+    this.setDefaultDates()
107
+    this.$nextTick(() => {
108
+      this.initCharts();
109
+
110
+      this.renderCharts();
111
+    });
112
+  },
113
+
114
+  methods: {
115
+    setDefaultDates() {
116
+      const today = new Date()
117
+      const firstDayOfMonth = new Date(today.getFullYear(), today.getMonth(), 1)
118
+
119
+      // 使用本地时间格式化,避免UTC时间转换问题
120
+      this.startDate = this.formatLocalDate(firstDayOfMonth)
121
+      this.endDate = this.formatLocalDate(today)
122
+     
123
+      // 初始化dateRange
124
+      this.dateRange = [this.startDate, this.endDate]
125
+      this.calculateDays()
126
+    },
127
+
128
+    // 格式化本地日期为YYYY-MM-DD格式
129
+    formatLocalDate(date) {
130
+      const year = date.getFullYear();
131
+      const month = String(date.getMonth() + 1).padStart(2, '0');
132
+      const day = String(date.getDate()).padStart(2, '0');
133
+      return `${year}-${month}-${day}`;
134
+    },
135
+
136
+    calculateDays() {
137
+      if (this.startDate && this.endDate) {
138
+        const start = new Date(this.startDate)
139
+        const end = new Date(this.endDate)
140
+        const diffTime = Math.abs(end - start)
141
+        const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1
142
+        this.totalDays = diffDays
143
+      }
144
+    },
145
+
146
+    handleDateRangeChange(e) {
147
+      if (e && Array.isArray(e) && e.length === 2) {
148
+        this.startDate = e[0]
149
+        this.endDate = e[1]
150
+        this.calculateDays()
151
+        // 自定义时间范围改变时重新请求数据
152
+        this.renderCharts()
153
+      }
154
+    },
155
+
156
+    // 处理时间范围变化
157
+    handleTimeRangeChange(timeRange) {
158
+     
159
+      // 使用calculateTimeRange计算时间范围
160
+      const timeRangeResult = calculateTimeRange(timeRange, this.dateRange)
161
+      
162
+      this.startDate = timeRangeResult.startDate
163
+      this.endDate = timeRangeResult.endDate
164
+      this.dateRange = [this.startDate, this.endDate]
165
+      this.calculateDays()
166
+
167
+      this.renderCharts();
168
+    },
169
+
170
+
171
+
172
+    // 初始化ECharts实例
173
+    initCharts() {
174
+      // 面积图初始化
175
+      const lineChartDom = document.getElementById('lineChart');
176
+      if (lineChartDom) {
177
+        this.lineChart = echarts.init(lineChartDom);
178
+      }
179
+
180
+      // 雷达图初始化
181
+      const radarChartDom = document.getElementById('radarChart');
182
+      if (radarChartDom) {
183
+        this.radarChart = echarts.init(radarChartDom);
184
+      }
185
+    },
186
+
187
+    // 渲染面积图
188
+    async renderLineChart(query) {
189
+      if (!this.lineChart) return;
190
+      
191
+      // 清除图表之前的状态,确保不会残留缺省图
192
+      this.lineChart.clear();
193
+      
194
+      let res = await getPlanOverview(query)
195
+      let data = {}
196
+      if (res && res.data) {
197
+        res.data.forEach(item => {
198
+          if (!data[item.typeDesc]) {
199
+            data[item.typeDesc] = []
200
+          }
201
+          data[item.typeDesc].push({
202
+            date: item.date,
203
+            value: item.total
204
+          })
205
+        })
206
+      }
207
+      console.log(data, "data")
208
+      let xAxisData = Object.values(data)[0]?.map(key => key.date) || []
209
+
210
+
211
+      const option = {
212
+        xAxis: {
213
+          type: 'category',
214
+          boundaryGap: false,
215
+          data: xAxisData
216
+        },
217
+        legend: {
218
+          data: Object.keys(data),
219
+          textStyle: {
220
+            color: '#666'
221
+          }
222
+        },
223
+        yAxis: {
224
+          type: 'value'
225
+        },
226
+        series: Object.keys(data).map(key => ({
227
+          data: data[key].map(item => item.value),
228
+          type: 'line',
229
+          name: key
230
+        })),
231
+        grid: {
232
+          left: '3%',
233
+          right: '10%',
234
+          bottom: '3%',
235
+
236
+          containLabel: true
237
+        }
238
+      };
239
+
240
+      this.lineChart.setOption(option);
241
+    },
242
+
243
+    // 设置空雷达图状态
244
+    setEmptyRadarChart() {
245
+      if (!this.radarChart) return;
246
+
247
+      const option = {
248
+        title: {
249
+          text: '暂无数据',
250
+          left: 'center',
251
+          top: 'center',
252
+          textStyle: {
253
+            color: '#999',
254
+            fontSize: 16
255
+          }
256
+        },
257
+        radar: {
258
+          indicator: []
259
+        },
260
+        series: []
261
+      };
262
+
263
+      this.radarChart.setOption(option);
264
+    },
265
+
266
+    // 渲染雷达图
267
+    async renderRadarChart(query) {
268
+
269
+      if (!this.radarChart) return;
270
+      
271
+      // 清除图表之前的状态,确保不会残留缺省图
272
+      this.radarChart.clear();
273
+      
274
+      let response = await getDailyTaskMetrics(query)
275
+      if (response.code !== 200 || !response.data) {
276
+        console.error('获取雷达图数据失败:', response)
277
+        // 设置空数据状态
278
+        this.setEmptyRadarChart()
279
+        return;
280
+      }
281
+
282
+      const { data } = response;
283
+      console.log(data, "data")
284
+
285
+      // 检查数据是否为空
286
+      if (!data || data.length === 0) {
287
+        console.warn('雷达图数据为空')
288
+        this.setEmptyRadarChart()
289
+        return;
290
+      }
291
+
292
+      // 计算最大值,用于设置雷达图指标范围
293
+      const maxValue = data.length > 0 ? Math.max(...data.map(item => item.total || 0)) : 0;
294
+
295
+      let indicator = data.map(item => ({
296
+        name: `${item.name} ${item.total || 0}`,
297
+        max: maxValue
298
+      }))
299
+
300
+      // 雷达图数据格式修正
301
+      let seriesData = [{
302
+        value: data.map(item => item.total || 0),
303
+        name: '计划执行指标'
304
+      }]
305
+
306
+
307
+      const option = {
308
+        radar: {
309
+          indicator: indicator,
310
+          radius: '65%',
311
+          splitNumber: 5,
312
+          axisName: {
313
+            color: '#666',
314
+            fontSize: 12
315
+          },
316
+          splitLine: {
317
+            lineStyle: {
318
+              color: 'rgba(211, 211, 211, 0.5)'
319
+            }
320
+          },
321
+          splitArea: {
322
+            show: true,
323
+            areaStyle: {
324
+              color: ['rgba(244, 244, 244, 0.1)', 'rgba(244, 244, 244, 0.2)']
325
+            }
326
+          },
327
+          axisLine: {
328
+            lineStyle: {
329
+              color: 'rgba(211, 211, 211, 0.5)'
330
+            }
331
+          }
332
+        },
333
+        series: [{
334
+          type: 'radar',
335
+          data: seriesData,
336
+          areaStyle: {
337
+            color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
338
+              { offset: 0, color: 'rgba(24, 144, 255, 0.6)' },
339
+              { offset: 1, color: 'rgba(24, 144, 255, 0.1)' }
340
+            ])
341
+          },
342
+          lineStyle: {
343
+            width: 2,
344
+            color: '#1890ff'
345
+          },
346
+          // 移除雷达图每个角上的圆点
347
+          symbol: 'none',
348
+          // label: {
349
+          //   show: true,
350
+          //   formatter: '{c}'
351
+          // }
352
+        }]
353
+      };
354
+
355
+
356
+      this.radarChart.setOption(option);
357
+
358
+    },
359
+
360
+    //获取表格数据
361
+    async getTaskDetailStats(query) {
362
+      let res = await getTaskDetailStats(query)
363
+      if (res && res.data) {
364
+        this.tableData = res.data
365
+      }
366
+    },
367
+
368
+    // 渲染所有图表
369
+    renderCharts() {
370
+      const query = calculateTimeRange(this.activeTimeRange, this.dateRange);
371
+
372
+      this.renderLineChart(query);
373
+      this.renderRadarChart(query);
374
+      this.getTaskDetailStats(query)
375
+    }
376
+  }
377
+};
378
+</script>
379
+
380
+<style lang="scss" scoped>
381
+.inspection-stats-charts {
382
+  padding: 26rpx;
383
+
384
+  .stats-card {
385
+    background: rgba(255, 255, 255, 0.95);
386
+    border-radius: 16rpx;
387
+    margin-bottom: 24rpx;
388
+    box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
389
+    padding: 24rpx;
390
+  }
391
+
392
+  .time-filter {
393
+    margin-bottom: 32rpx;
394
+
395
+    .time-tabs {
396
+      ::v-deep .h-tabs {
397
+        justify-content: center;
398
+        gap: 48rpx;
399
+
400
+        .h-tabs-item {
401
+          padding: 16rpx 32rpx;
402
+          font-size: 28rpx;
403
+
404
+          &.active {
405
+            font-weight: bold;
406
+          }
407
+        }
408
+      }
409
+    }
410
+  }
411
+
412
+  .time-filter-section {
413
+    margin-bottom: 20rpx;
414
+    padding: 0 20rpx;
415
+  }
416
+
417
+  .filter-row {
418
+    display: flex;
419
+    align-items: center;
420
+    flex-direction: row;
421
+
422
+    .custom-time-section {
423
+      position: relative;
424
+      display: flex;
425
+      align-items: center;
426
+      gap: 24rpx;
427
+      margin-top: 2rpx;
428
+      width: 350rpx;
429
+
430
+      .date-range-picker {
431
+
432
+        // min-width: 200rpx;
433
+        ::v-deep .icon-calendar {
434
+          display: none !important;
435
+        }
436
+
437
+        ::v-deep .uni-date-range {
438
+          font-size: 16rpx !important;
439
+          background: transparent !important;
440
+          color: black !important;
441
+
442
+
443
+        }
444
+
445
+        ::v-deep .uni-date-x {
446
+          height: 25rpx !important;
447
+          line-height: 25rpx !important;
448
+
449
+          .range-separator {
450
+            font-size: 20rpx !important;
451
+            padding: 0 !important
452
+          }
453
+
454
+          .uni-date__x-input {
455
+            height: 25rpx !important;
456
+            line-height: 25rpx !important;
457
+          }
458
+        }
459
+
460
+        ::v-deep .uni-date-x--border {
461
+          border: none !important;
462
+        }
463
+
464
+
465
+      }
466
+
467
+      .days-count {
468
+        position: absolute;
469
+        right: -72rpx;
470
+         top:-8rpx;
471
+        font-size: 25rpx;
472
+        color: black;
473
+        font-weight: 500;
474
+        white-space: nowrap;
475
+      }
476
+    }
477
+  }
478
+
479
+
480
+
481
+
482
+
483
+  .chart-container {
484
+    margin-bottom: 40rpx;
485
+
486
+    .chart-title {
487
+      display: block;
488
+      font-size: 28rpx;
489
+      font-weight: bold;
490
+      color: #333;
491
+      margin-bottom: 20rpx;
492
+      padding-left: 12rpx;
493
+
494
+    }
495
+
496
+    .chart-wrapper {
497
+      height: 300rpx;
498
+
499
+      display: flex;
500
+      align-items: center;
501
+      justify-content: center;
502
+
503
+
504
+      .echarts-canvas {
505
+        width: 100%;
506
+        height: 100%;
507
+      }
508
+
509
+      .chart-placeholder {
510
+        text-align: center;
511
+        color: #999;
512
+
513
+        .placeholder-text {
514
+          display: block;
515
+          margin-top: 16rpx;
516
+          font-size: 24rpx;
517
+        }
518
+      }
519
+    }
520
+  }
521
+
522
+  .table-container {
523
+    .chart-title {
524
+      display: block;
525
+      font-size: 28rpx;
526
+      font-weight: bold;
527
+      color: #333;
528
+      margin-bottom: 20rpx;
529
+      padding-left: 12rpx;
530
+
531
+    }
532
+
533
+
534
+  }
535
+}
536
+</style>

+ 980 - 0
src/pages/inspectionStatistics/components/ProblemDiscovery.vue

@@ -0,0 +1,980 @@
1
+<template>
2
+  <view class="problem-discovery">
3
+    <!-- 时间筛选区域 -->
4
+    <view class="time-filter-section">
5
+      <view class="filter-row">
6
+        <StaticsTab v-model="activeTimeRange" :tabs="timeRangeOptions" active-color="#4873E3"
7
+          @change="handleTimeRangeChange" />
8
+        <view v-if="activeTimeRange === 'custom'" class="custom-time-section">
9
+          <uni-datetime-picker v-model="dateRange" type="daterange" rangeSeparator="至" @change="handleDateRangeChange"
10
+            class="date-range-picker" :clear-icon="false" />
11
+          <text class="days-count">共 {{ totalDays }} 天</text>
12
+        </view>
13
+      </view>
14
+    </view>
15
+
16
+    <!-- 总体问题分布 -->
17
+    <view class="chart-section">
18
+      <view class="section-title">总体问题分布</view>
19
+      <view class="chart-container">
20
+        <view class="pie-chart" id="pieChart"></view>
21
+      </view>
22
+    </view>
23
+
24
+
25
+
26
+    <view v-if="isZhanZhang">
27
+      <!-- 问题分布对比 -->
28
+      <view class="chart-section">
29
+        <view class="section-title">问题分布对比</view>
30
+        <view class="radarChart-container">
31
+          <view class="radar-chart" id="radarChart"></view>
32
+        </view>
33
+      </view>
34
+      <view v-for="(item, index) in pieChartData" :key="index">
35
+        <!-- 问题对比 -->
36
+        <view class="chart-section" v-if="item.problemCompareData && item.problemCompareData.length > 0">
37
+          <view class="section-title">{{ item.name }}-问题对比</view>
38
+          <view class="table-container">
39
+            <statistic-table :columns="problemCompareColumns" :data="item.problemCompareData" />
40
+          </view>
41
+          <view class="section-title" style="margin-top: 20rpx;">{{ item.name }}-问题趋势</view>
42
+          <view class="chart-container">
43
+            <view class="line-chart" :id="'lineChart' + index"></view>
44
+          </view>
45
+        </view>
46
+
47
+        <!-- 问题趋势 -->
48
+        <!-- <view class="chart-section">
49
+         
50
+        </view> -->
51
+      </view>
52
+    </view>
53
+
54
+
55
+    <view v-else>
56
+      <view v-for="(item, index) in pieChartData" :key="index">
57
+        <!-- 问题对比 -->
58
+        <view class="chart-section">
59
+          <view class="section-title">{{ item.name }}-问题明细</view>
60
+          <view class="chart-container">
61
+            <view class="pie-chart" :id="'pieChart' + index"></view>
62
+          </view>
63
+          <view class="section-title" style="margin-top: 20rpx;">{{ item.name }}-问题趋势</view>
64
+          <view class="chart-container">
65
+            <view class="line-chart" :id="'lineChart' + index"></view>
66
+          </view>
67
+        </view>
68
+
69
+        <!-- 问题趋势 -->
70
+        <!-- <view class="chart-section">
71
+       
72
+        </view> -->
73
+      </view>
74
+    </view>
75
+  </view>
76
+</template>
77
+
78
+<script>
79
+import StaticsTab from './StaticsTab.vue'
80
+import { calculateTimeRange } from '@/utils/formatUtils.js';
81
+import * as echarts from 'echarts'
82
+import {
83
+  getProblemDistribution,
84
+  getProblemDistributionComparison,
85
+  getChannelProblemComparison,
86
+  getChannelProblemTrend
87
+} from '@/api/inspectionStatistics/inspectionStatistics'
88
+
89
+export default {
90
+  name: 'ProblemDiscovery',
91
+  components: {
92
+    StaticsTab,
93
+
94
+  },
95
+  data() {
96
+    return {
97
+      activeTimeRange: 'month',
98
+      timeRangeOptions: [
99
+        { title: '本年', type: 'year' },
100
+        { title: '本月', type: 'month' },
101
+        { title: '自定义', type: 'custom' }
102
+      ],
103
+      dateRange: [],
104
+      startDate: '',
105
+      endDate: '',
106
+      totalDays: 0,
107
+
108
+      // 表格配置
109
+      problemCompareColumns: [
110
+        { title: '分类', props: 'category' },
111
+      ],
112
+      problemCompareData: [],
113
+      pieChartData: [],//保存总体问题数据,方便遍历
114
+      // 图表实例
115
+      pieChart: null,
116
+      radarChart: null,
117
+      lineChart: null
118
+    }
119
+  },
120
+  computed: {
121
+    // 站长
122
+    isZhanZhang() {
123
+      let roles = this.$store.state.user.roles;
124
+      console.log('用户角色:', roles)
125
+      return roles && roles.includes('test')
126
+    },
127
+  },
128
+  mounted() {
129
+    this.setDefaultDates()
130
+    this.$nextTick(() => {
131
+      this.initCharts()
132
+    })
133
+  },
134
+  methods: {
135
+    // 加载通道面貌问题对比数据
136
+    async loadChannelProblemComparison(categoryCodeOne) {
137
+      try {
138
+        console.log('开始请求getChannelProblemComparison接口...')
139
+        const response = await getChannelProblemComparison({
140
+          startDate: this.startDate,
141
+          endDate: this.endDate,
142
+          categoryCodeOne: categoryCodeOne
143
+        })
144
+
145
+
146
+        if (response && response.code === 200) {
147
+          // console.log('通道面貌问题对比数据详情:', response.data)
148
+          //分类的值
149
+          let nameArr = []
150
+          let res = []
151
+          response.data.forEach(item => {
152
+            if (!this.problemCompareColumns.find(col => col.props === item.deptId)) {
153
+              //设置列
154
+              this.problemCompareColumns.push({
155
+                title: item.deptName,//旅检一科
156
+                props: item.deptId,//220
157
+              })
158
+            }
159
+            if (!nameArr.find(ele => ele.code === item.code) && item.total > 0) {
160
+              nameArr.push({
161
+                code: item.code,//00000145
162
+                name: item.name,//语言规范
163
+              })
164
+            }
165
+          })
166
+          nameArr.forEach(element => {
167
+            let categoryArr = response.data.filter(item => item.code == element.code)
168
+            let obj = {
169
+              category: element.name,
170
+            }
171
+
172
+            // 为每个部门添加问题数量
173
+            categoryArr.forEach(item => {
174
+              obj[item.deptId] = item.total
175
+            })
176
+
177
+            res.push(obj)
178
+          })
179
+
180
+          // console.log('处理后的数据:', res, '列配置:', this.problemCompareColumns)
181
+          return res;
182
+          // this.problemCompareData = res
183
+        }
184
+      } catch (error) {
185
+        console.error('请求getChannelProblemComparison接口失败:', error)
186
+        // 降级方案:使用默认数据
187
+
188
+      }
189
+    },
190
+
191
+    // 加载通道面貌问题趋势数据
192
+    loadChannelProblemTrend(arr) {
193
+      // 确保arr是有效的数组
194
+      if (!arr || !Array.isArray(arr) || arr.length === 0) {
195
+        return {
196
+          legend: [],
197
+          date: [],
198
+          series: []
199
+        }
200
+      }
201
+
202
+      let res = {}
203
+      let legend = []
204
+      let date = []
205
+      
206
+      // 先收集所有日期,确保日期顺序一致
207
+      arr.forEach(item => {
208
+        if (item.date && !date.includes(item.date)) {
209
+          date.push(item.date)
210
+        }
211
+      })
212
+      
213
+      // 按日期排序
214
+      date.sort((a, b) => new Date(a) - new Date(b))
215
+      
216
+      // 按名称分组数据,确保每个名称的数据长度与日期数组一致
217
+      arr.forEach(item => {
218
+        if (item.name && item.date) {
219
+          if (!legend.includes(item.name)) {
220
+            legend.push(item.name)
221
+            res[item.name] = new Array(date.length).fill(0) // 初始化数组,填充0
222
+          }
223
+          
224
+          const dateIndex = date.indexOf(item.date)
225
+          if (dateIndex !== -1) {
226
+            res[item.name][dateIndex] = item.total || 0
227
+          }
228
+        }
229
+      })
230
+      console.log('处理后的数据:', res, '日期:', date, '名称:', legend)
231
+      // 生成series数据,确保与legend顺序一致
232
+      let series = legend.map(name => ({
233
+        name: name,
234
+        type: 'line',
235
+    
236
+        data: res[name] || []
237
+      }))
238
+
239
+      return {
240
+        legend: legend,
241
+        date: date,
242
+        series: series
243
+      }
244
+    },
245
+    setDefaultDates() {
246
+      const today = new Date()
247
+      // 本月第一天
248
+      const firstDayOfMonth = new Date(today.getFullYear(), today.getMonth(), 1)
249
+
250
+      // 使用本地时间格式化,避免UTC时间转换问题
251
+      this.startDate = this.formatLocalDate(firstDayOfMonth)
252
+      this.endDate = this.formatLocalDate(today)
253
+      // 初始化dateRange
254
+      this.dateRange = [this.startDate, this.endDate]
255
+      this.calculateDays()
256
+    },
257
+    // 格式化本地日期为YYYY-MM-DD格式
258
+    formatLocalDate(date) {
259
+      const year = date.getFullYear();
260
+      const month = String(date.getMonth() + 1).padStart(2, '0');
261
+      const day = String(date.getDate()).padStart(2, '0');
262
+      return `${year}-${month}-${day}`;
263
+    },
264
+
265
+
266
+    handleDateRangeChange(e) {
267
+      if (e && Array.isArray(e) && e.length === 2) {
268
+        this.startDate = e[0]
269
+        this.endDate = e[1]
270
+        this.calculateDays()
271
+        // 自定义时间范围改变时重新请求数据
272
+        this.refreshChartData()
273
+      }
274
+    },
275
+
276
+    handleTimeRangeChange(timeRange) {
277
+      // 使用calculateTimeRange计算时间范围
278
+      const timeRangeResult = calculateTimeRange(timeRange, this.dateRange)
279
+      this.startDate = timeRangeResult.startDate
280
+      this.endDate = timeRangeResult.endDate
281
+      this.dateRange = [this.startDate, this.endDate]
282
+      this.calculateDays()
283
+
284
+      // 重新请求数据
285
+      this.refreshChartData()
286
+    },
287
+
288
+    // 刷新图表数据
289
+    async refreshChartData() {
290
+      console.log('刷新图表数据,时间范围:', this.startDate, '至', this.endDate)
291
+      try {
292
+
293
+        // 重新渲染图表
294
+        this.refreshCharts()
295
+      } catch (error) {
296
+        console.error('刷新图表数据失败:', error)
297
+      }
298
+    },
299
+
300
+    // 刷新图表
301
+    refreshCharts() {
302
+      if (this.pieChart) {
303
+        this.pieChart.dispose()
304
+        this.pieChart = null
305
+      }
306
+      if (this.radarChart) {
307
+        this.radarChart.dispose()
308
+        this.radarChart = null
309
+      }
310
+      if (this.lineChart) {
311
+        this.lineChart.dispose()
312
+        this.lineChart = null
313
+      }
314
+
315
+      // 销毁所有遍历的折线图和环形图
316
+      if (this.pieChartData && this.pieChartData.length > 0) {
317
+        this.pieChartData.forEach(item => {
318
+          if (item.trendChart) {
319
+            item.trendChart.dispose()
320
+            item.trendChart = null
321
+          }
322
+          if (item.pieChartDetail) {
323
+            item.pieChartDetail.dispose()
324
+            item.pieChartDetail = null
325
+          }
326
+        })
327
+      }
328
+
329
+      this.$nextTick(() => {
330
+        this.initPieChart()
331
+        this.initRadarChart()
332
+        this.initLineChart()
333
+      })
334
+    },
335
+
336
+    calculateDays() {
337
+      if (this.startDate && this.endDate) {
338
+        const start = new Date(this.startDate)
339
+        const end = new Date(this.endDate)
340
+        const diffTime = Math.abs(end - start)
341
+        this.totalDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1
342
+      }
343
+    },
344
+
345
+    async initCharts() {
346
+      try {
347
+        // 初始化饼图
348
+        this.initPieChart()
349
+        // 初始化雷达图
350
+        this.initRadarChart()
351
+        // 初始化问题趋势折线图
352
+        this.initLineChart()
353
+      } catch (error) {
354
+        console.error('图表数据加载失败:', error)
355
+      }
356
+    },
357
+    //处理列表数据
358
+    handleProblemDto(arr) {
359
+      let nameArr = []
360
+      let res = []
361
+      arr.forEach(item => {
362
+        if (!this.problemCompareColumns.find(col => col.props === item.deptId)) {
363
+          //设置列
364
+          this.problemCompareColumns.push({
365
+            title: item.deptName,//旅检一科
366
+            props: item.deptId,//220
367
+          })
368
+        }
369
+        if (!nameArr.find(ele => ele.code === item.code) && item.total > 0) {
370
+          nameArr.push({
371
+            code: item.code,//00000145
372
+            name: item.name,//语言规范
373
+          })
374
+        }
375
+      })
376
+      nameArr.forEach(element => {
377
+        let categoryArr = arr.filter(item => item.code == element.code)
378
+        let obj = {
379
+          category: element.name,
380
+        }
381
+
382
+        // 为每个部门添加问题数量
383
+        categoryArr.forEach(item => {
384
+          obj[item.deptId] = item.total
385
+        })
386
+
387
+        res.push(obj)
388
+      })
389
+      return res;
390
+    },
391
+    //获取站长遍历的图表和列表
392
+    async getZhanZhangChartAndList() {
393
+      for (let i = 0; i < this.pieChartData.length; i++) {
394
+        //分类
395
+
396
+        //获取问题对比数据
397
+        let problemCompareData = this.handleProblemDto(this.pieChartData[i].checkLargeScreenProblemDtoList)
398
+        console.log(problemCompareData, "problemCompareData")
399
+        this.$set(this.pieChartData[i], 'problemCompareData', problemCompareData || [])
400
+
401
+        //获取问题趋势数据
402
+        let problemTrendData = this.loadChannelProblemTrend(this.pieChartData[i].checkLargeScreenProblemTrendDtoList)
403
+        console.log(problemTrendData, "problemTrendData")
404
+        this.$set(this.pieChartData[i], 'problemTrendData', problemTrendData || [])
405
+
406
+        // 初始化对应的折线图
407
+        if (problemTrendData) {
408
+          this.$nextTick(() => {
409
+            // 使用setTimeout确保DOM已经完全渲染
410
+            setTimeout(() => {
411
+              this.initTrendChart(i, problemTrendData)
412
+            }, 100)
413
+          })
414
+        }
415
+      }
416
+    },
417
+    // 处理问题明细数据,用于环形图展示
418
+    handleProblemDetailDto(arr) {
419
+      if (!arr || !Array.isArray(arr)) {
420
+        console.warn('handleProblemDetailDto: 传入的数据不是数组或为空')
421
+        return []
422
+      }
423
+
424
+      // 按问题类型分组统计
425
+      const problemTypeMap = {}
426
+      arr.forEach(item => {
427
+        if (item.name && item.total > 0) {
428
+          if (!problemTypeMap[item.name]) {
429
+            problemTypeMap[item.name] = 0
430
+          }
431
+          problemTypeMap[item.name] += item.total
432
+        }
433
+      })
434
+
435
+      // 转换为环形图数据格式
436
+      const pieData = Object.keys(problemTypeMap).map(name => ({
437
+        name: name,
438
+        value: problemTypeMap[name]
439
+      }))
440
+
441
+      console.log('环形图数据:', pieData)
442
+      return pieData
443
+    },
444
+    getOtherPieAndLine() {
445
+      for (let i = 0; i < this.pieChartData.length; i++) {
446
+        //分类
447
+
448
+        //获取问题明细数据(环形图数据)
449
+        let problemDetailData = this.handleProblemDetailDto(this.pieChartData[i].checkLargeScreenProblemDtoList)
450
+        console.log(problemDetailData, "problemDetailData")
451
+        this.$set(this.pieChartData[i], 'problemDetailData', problemDetailData || [])
452
+
453
+        //获取问题趋势数据
454
+        let problemTrendData = this.loadChannelProblemTrend(this.pieChartData[i].checkLargeScreenProblemTrendDtoList)
455
+        console.log(problemTrendData, "problemTrendData")
456
+        this.$set(this.pieChartData[i], 'problemTrendData', problemTrendData || [])
457
+
458
+        // 初始化对应的环形图
459
+        if (problemDetailData && problemDetailData.length > 0) {
460
+          this.$nextTick(() => {
461
+            // 使用setTimeout确保DOM已经完全渲染
462
+            setTimeout(() => {
463
+              this.initPieChartDetail(i, problemDetailData)
464
+            }, 100)
465
+          })
466
+        }
467
+
468
+        // 初始化对应的折线图
469
+        if (problemTrendData) {
470
+          this.$nextTick(() => {
471
+            // 使用setTimeout确保DOM已经完全渲染
472
+            setTimeout(() => {
473
+              this.initTrendChart(i, problemTrendData)
474
+            }, 100)
475
+          })
476
+        }
477
+      }
478
+    },
479
+    // 初始化遍历的环形图
480
+    initPieChartDetail(index, pieData) {
481
+      const chartDom = document.getElementById('pieChart' + index)
482
+      if (!chartDom) {
483
+        console.error('环形图容器未找到:', 'pieChart' + index)
484
+        return
485
+      }
486
+
487
+      try {
488
+        const chart = echarts.init(chartDom)
489
+        const option = {
490
+
491
+          tooltip: {
492
+            trigger: 'item',
493
+            formatter: '{a} <br/>{b}: {c} ({d}%)'
494
+          },
495
+          // legend: {
496
+          //   orient: 'vertical',
497
+          //   left: 'left',
498
+          //   top: 'middle',
499
+          //   textStyle: {
500
+          //     color: '#666'
501
+          //   }
502
+          // },
503
+          series: [
504
+            {
505
+              name: '问题明细',
506
+              type: 'pie',
507
+              radius: ['30%', '50%'], // 环形图设置
508
+              center: ['50%', '50%'],
509
+              data: pieData,
510
+              label: {
511
+                show: true,
512
+                formatter: '{b} {c}'
513
+              },
514
+              emphasis: {
515
+                itemStyle: {
516
+                  shadowBlur: 10,
517
+                  shadowOffsetX: 0,
518
+                  shadowColor: 'rgba(0, 0, 0, 0.5)'
519
+                }
520
+              }
521
+            }
522
+          ]
523
+        }
524
+
525
+        chart.setOption(option)
526
+
527
+        // 保存图表实例到对应的数据项中
528
+        this.$set(this.pieChartData[index], 'pieChartDetail', chart)
529
+        console.log('环形图初始化成功:', 'pieChart' + index)
530
+      } catch (error) {
531
+        console.error('环形图初始化失败:', error)
532
+      }
533
+    },
534
+
535
+    // 初始化遍历的折线图
536
+    initTrendChart(index, trendData) {
537
+      const chartDom = document.getElementById('lineChart' + index)
538
+      if (!chartDom) {
539
+        console.error('折线图容器未找到:', 'lineChart' + index)
540
+        return
541
+      }
542
+
543
+      try {
544
+        const chart = echarts.init(chartDom)
545
+        const option = {
546
+          tooltip: {
547
+            trigger: 'axis'
548
+          },
549
+          legend: {
550
+            data: trendData.legend
551
+          },
552
+          grid: {
553
+            left: '3%',
554
+            right: '4%',
555
+            bottom: '3%',
556
+            containLabel: true
557
+          },
558
+          xAxis: {
559
+            type: 'category',
560
+            boundaryGap: false,
561
+            data: trendData.date
562
+          },
563
+          yAxis: {
564
+            type: 'value'
565
+          },
566
+          series: trendData.series
567
+        }
568
+
569
+        chart.setOption(option)
570
+
571
+        // 保存图表实例到对应的数据项中
572
+        this.$set(this.pieChartData[index], 'trendChart', chart)
573
+      } catch (error) {
574
+        console.error('折线图初始化失败:', error)
575
+      }
576
+    },
577
+
578
+    async initPieChart() {
579
+      // 使用document.getElementById获取DOM元素,确保在uni-app中正确获取
580
+      const chartDom = document.getElementById('pieChart')
581
+      if (!chartDom) {
582
+        console.error('饼图容器未找到')
583
+        return
584
+      }
585
+
586
+      // 如果图表实例已存在,先清除之前的状态
587
+      if (this.pieChart) {
588
+        this.pieChart.clear();
589
+      }
590
+
591
+      const response = await getProblemDistribution(calculateTimeRange(this.activeTimeRange, this.dateRange))
592
+
593
+
594
+
595
+      if (response && response.code === 200) {
596
+        this.pieChartData = response.data;
597
+        //获取站长遍历的图表和列表
598
+        if (this.isZhanZhang) {
599
+          await this.getZhanZhangChartAndList()
600
+        } else {
601
+          await this.getOtherPieAndLine()
602
+        }
603
+
604
+        try {
605
+          this.pieChart = echarts.init(chartDom)
606
+          const option = {
607
+            title: {
608
+              text: '总体问题分布',
609
+              left: 'center',
610
+              textStyle: {
611
+                color: '#fff'
612
+              }
613
+            },
614
+            tooltip: {
615
+              trigger: 'item',
616
+              formatter: '{a} <br/>{b}: {c} ({d}%)'
617
+            },
618
+            legend: {
619
+              show: false
620
+            },
621
+            // grid: {
622
+            //   left: '10%',
623
+            //   right: '10%',
624
+            //   top: '15%',
625
+            //   bottom: '10%',
626
+            //   containLabel: true
627
+            // },
628
+            series: [
629
+              {
630
+                name: '问题分布',
631
+                type: 'pie',
632
+                radius: '50%',
633
+                // center: ['35%', '50%'],
634
+                data: response.data.map(item => ({
635
+                  name: item.name,
636
+                  value: item.total
637
+                })),
638
+                label: {
639
+                  show: true,
640
+                  formatter: function (params) {
641
+                    return params.name + '\n' + params.percent + '%'
642
+                  }
643
+                },
644
+                emphasis: {
645
+                  itemStyle: {
646
+                    shadowBlur: 10,
647
+                    shadowOffsetX: 0,
648
+                    shadowColor: 'rgba(0, 0, 0, 0.5)'
649
+                  }
650
+                }
651
+              }
652
+            ]
653
+          }
654
+
655
+          this.pieChart.setOption(option)
656
+        } catch (error) {
657
+          console.error('饼图初始化失败:', error)
658
+        }
659
+      }
660
+
661
+
662
+
663
+    },
664
+
665
+    async initRadarChart() {
666
+      // 检查雷达图容器是否存在(根据用户角色条件显示)
667
+      if (!this.isZhanZhang) {
668
+        console.log('当前用户角色不显示雷达图')
669
+        return
670
+      }
671
+
672
+      const chartDom = document.getElementById('radarChart')
673
+      if (!chartDom) {
674
+        console.error('雷达图容器未找到')
675
+        return
676
+      }
677
+
678
+      // 如果图表实例已存在,先清除之前的状态
679
+      if (this.radarChart) {
680
+        this.radarChart.clear();
681
+      }
682
+
683
+      const response = await getProblemDistributionComparison({
684
+        startDate: this.startDate,
685
+        endDate: this.endDate
686
+      })
687
+      if (response && response.code === 200) {
688
+        console.log('通道面貌问题对比数据详情:', response.data)
689
+        let indicator = [];
690
+        let indicatorName = [];
691
+        let legend = [];
692
+        let data = [];
693
+        let deptIds = [];
694
+        response.data.forEach(item => {
695
+          if (indicator.filter(ele => ele == item.code).length == 0) {
696
+            indicator.push(item.code)
697
+          }
698
+          if (indicatorName.filter(ele => ele == item.name).length == 0) {
699
+            if (!indicatorName.includes(item.name)) {
700
+              indicatorName.push(item.name)
701
+            }
702
+          }
703
+          if (legend.filter(ele => ele == item.deptName).length == 0) {
704
+            legend.push(item.deptName)
705
+          }
706
+          if (deptIds.filter(ele => ele == item.deptId).length == 0) {
707
+            deptIds.push(item.deptId)
708
+          }
709
+
710
+        })
711
+        // console.log(indicatorName, deptIds, 111111111111, response.data)
712
+        // 定义不同部门的颜色数组
713
+        const colorList = ['#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de', '#3ba272', '#fc8452', '#9a60b4', '#ea7ccc']
714
+
715
+        data = deptIds.map((item, index) => {
716
+          const getAll = response.data.filter(ele => ele.deptId == item)
717
+          // console.log(getAll, "getAll")
718
+          return {
719
+            name: getAll[0].deptName,
720
+            value: indicator.map(code => getAll.find(ele => ele.code == code).total),
721
+            // 为每个数据系列设置不同的颜色
722
+            areaStyle: {
723
+              color: colorList[index % colorList.length],
724
+              opacity: 0.3
725
+            },
726
+            lineStyle: {
727
+              color: colorList[index % colorList.length],
728
+              width: 2
729
+            },
730
+            itemStyle: {
731
+              color: colorList[index % colorList.length],
732
+              borderWidth: 2
733
+            }
734
+          }
735
+        })
736
+
737
+        // console.log(indicatorName, "data", data, indicatorName)
738
+        try {
739
+          this.radarChart = echarts.init(chartDom)
740
+          const option = {
741
+            tooltip: {},
742
+            grid: {
743
+              top: '10%',
744
+              bottom: '20%',
745
+              left: '10%',
746
+              right: '10%'
747
+            },
748
+            legend: {
749
+              data: legend,
750
+              bottom: 0,
751
+              // textStyle: {
752
+              //   color: '#666'
753
+              // }
754
+            },
755
+            radar: {
756
+              indicator: indicatorName.map(item => ({ name: item })),
757
+              radius: '60%'
758
+            },
759
+            series: [
760
+              {
761
+                type: 'radar',
762
+                data: data,
763
+                symbol: 'none',
764
+              }
765
+            ]
766
+          }
767
+
768
+          this.radarChart.setOption(option)
769
+        } catch (error) {
770
+          console.error('雷达图初始化失败:', error)
771
+        }
772
+      }
773
+
774
+
775
+    },
776
+
777
+    async initLineChart() {
778
+      if (!this.isZhanZhang) {
779
+        console.log('当前用户角色不显示雷达图')
780
+        return
781
+      }
782
+      const chartDom = document.getElementById('lineChart')
783
+      if (!chartDom) {
784
+        console.error('折线图容器未找到')
785
+        return
786
+      }
787
+
788
+      // 如果图表实例已存在,先清除之前的状态
789
+      if (this.lineChart) {
790
+        this.lineChart.clear();
791
+      }
792
+
793
+      const response = await getChannelProblemTrend({
794
+        startDate: this.startDate,
795
+        endDate: this.endDate
796
+      })
797
+
798
+
799
+      if (response && response.code === 200) {
800
+        console.log('通道面貌问题趋势数据详情:', response.data)
801
+        let res = {}
802
+        let legend = []
803
+        let date = []
804
+        response.data.forEach(item => {
805
+          if (!date.includes(item.date)) {
806
+            date.push(item.date)
807
+          }
808
+          if (!legend.includes(item.name)) {
809
+            legend.push(item.name)
810
+          }
811
+          if (!res[item.name]) {
812
+            res[item.name] = []
813
+          }
814
+          res[item.name].push(item.total)
815
+        })
816
+        let data = Object.keys(res).map(key => ({
817
+          name: key,
818
+          type: 'line',
819
+
820
+          data: res[key]
821
+        }))
822
+        // console.log(legend, date, res)
823
+
824
+        try {
825
+          this.lineChart = echarts.init(chartDom)
826
+          const option = {
827
+            tooltip: {
828
+              trigger: 'axis'
829
+            },
830
+            legend: {
831
+              data: legend
832
+            },
833
+            grid: {
834
+              left: '3%',
835
+              right: '4%',
836
+              bottom: '3%',
837
+              containLabel: true
838
+            },
839
+            xAxis: {
840
+              type: 'category',
841
+              boundaryGap: false,
842
+              data: date
843
+            },
844
+            yAxis: {
845
+              type: 'value'
846
+            },
847
+            series: data
848
+          }
849
+
850
+          this.lineChart.setOption(option)
851
+        } catch (error) {
852
+          console.error('折线图初始化失败:', error)
853
+        }
854
+      }
855
+
856
+    },
857
+    resizeCharts() {
858
+      if (this.pieChart) this.pieChart.resize()
859
+      if (this.radarChart) this.radarChart.resize()
860
+      if (this.lineChart) this.lineChart.resize()
861
+    },
862
+    beforeDestroy() {
863
+      if (this.pieChart) this.pieChart.dispose()
864
+      if (this.radarChart) this.radarChart.dispose()
865
+      if (this.lineChart) this.lineChart.dispose()
866
+    }
867
+  }
868
+}
869
+</script>
870
+
871
+<style lang="scss" scoped>
872
+.problem-discovery {
873
+  padding: 24rpx;
874
+
875
+  .time-filter-section {
876
+    margin-bottom: 32rpx;
877
+
878
+    .filter-row {
879
+      display: flex;
880
+      align-items: center;
881
+      flex-direction: row;
882
+
883
+      .custom-time-section {
884
+        position: relative;
885
+        display: flex;
886
+        align-items: center;
887
+        gap: 24rpx;
888
+        margin-top: 2rpx;
889
+        width: 350rpx;
890
+
891
+        .date-range-picker {
892
+
893
+          // min-width: 200rpx;
894
+          ::v-deep .icon-calendar {
895
+            display: none !important;
896
+          }
897
+
898
+          ::v-deep .uni-date-range {
899
+            font-size: 16rpx !important;
900
+            background: transparent !important;
901
+            color: black !important;
902
+
903
+
904
+          }
905
+
906
+          ::v-deep .uni-date-x {
907
+            height: 25rpx !important;
908
+            line-height: 25rpx !important;
909
+
910
+            .range-separator {
911
+              font-size: 20rpx !important;
912
+              padding: 0 !important
913
+            }
914
+
915
+            .uni-date__x-input {
916
+              height: 25rpx !important;
917
+              line-height: 25rpx !important;
918
+            }
919
+          }
920
+
921
+          ::v-deep .uni-date-x--border {
922
+            border: none !important;
923
+          }
924
+
925
+
926
+        }
927
+
928
+        .days-count {
929
+          position: absolute;
930
+          right: -72rpx;
931
+          top:-8rpx;
932
+          font-size: 25rpx;
933
+          color: black;
934
+          font-weight: 500;
935
+          white-space: nowrap;
936
+        }
937
+      }
938
+    }
939
+  }
940
+
941
+  .chart-section {
942
+    background: #fff;
943
+    border-radius: 16rpx;
944
+    padding: 24rpx;
945
+    margin-bottom: 24rpx;
946
+    box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
947
+
948
+    .section-title {
949
+      font-size: 28rpx;
950
+      font-weight: bold;
951
+      color: #333;
952
+      margin-bottom: 20rpx;
953
+    }
954
+
955
+    .radarChart-container {
956
+      height: 500rpx;
957
+
958
+      .radar-chart {
959
+        height: 500rpx;
960
+      }
961
+    }
962
+
963
+    .chart-container {
964
+      height: 400rpx;
965
+
966
+      .pie-chart,
967
+      .line-chart {
968
+        width: 100%;
969
+        height: 100%;
970
+      }
971
+
972
+
973
+    }
974
+
975
+    .table-container {
976
+      margin-top: 16rpx;
977
+    }
978
+  }
979
+}
980
+</style>

+ 383 - 0
src/pages/inspectionStatistics/components/ProblemRectification.vue

@@ -0,0 +1,383 @@
1
+<template>
2
+  <view class="problem-rectification">
3
+    <!-- 时间筛选区域 -->
4
+    <view class="time-filter-section">
5
+      <view class="filter-row">
6
+        <StaticsTab v-model="activeTimeRange" :tabs="timeRangeOptions" active-color="#4873E3"
7
+          @change="handleTimeRangeChange" />
8
+
9
+
10
+        <view v-if="activeTimeRange === 'custom'" class="custom-time-section">
11
+          <uni-datetime-picker v-model="dateRange" type="daterange" rangeSeparator="至" @change="handleDateRangeChange"
12
+            class="date-range-picker" :clear-icon="false" />
13
+          <text class="days-count">共 {{ totalDays }} 天</text>
14
+        </view>
15
+      </view>
16
+    </view>
17
+
18
+    <DataCard v-if="!isBanZuZhangOrKeZhang" title="问题整改" :cardData="cardData" />
19
+
20
+
21
+    <view class="chart-section">
22
+      <!-- <view class="section-title">通道面貌-问题趋势</view> -->
23
+      <view class="chart-container">
24
+        <view class="stacked-bar-chart" id="stackedBarChart"></view>
25
+      </view>
26
+    </view>
27
+
28
+  </view>
29
+</template>
30
+
31
+<script>
32
+import StaticsTab from './StaticsTab.vue'
33
+import DataCard from './DataCard.vue'
34
+import { getRectificationStatusTotal, getRectificationStatusComparison } from '@/api/inspectionStatistics/inspectionStatistics.js'
35
+import { calculateTimeRange } from '@/utils/formatUtils.js';
36
+import * as echarts from 'echarts'
37
+export default {
38
+  name: 'ProblemRectification',
39
+  components: {
40
+    StaticsTab,
41
+    DataCard
42
+
43
+  },
44
+  data() {
45
+    return {
46
+      activeTimeRange: 'month',
47
+      timeRangeOptions: [
48
+        { title: '本年', type: 'year' },
49
+        { title: '本月', type: 'month' },
50
+        { title: '自定义', type: 'custom' }
51
+      ],
52
+      dateRange: [],
53
+      startDate: '',
54
+      endDate: '',
55
+      totalDays: 0,
56
+      cardData: [],
57
+      // 堆叠柱状图实例
58
+      stackedBarChart: null
59
+    }
60
+  },
61
+  computed: {
62
+    isBanZuZhangOrKeZhang() {
63
+      let roles = this.$store.state.user.roles;
64
+      return roles && (roles.includes('banzuzhang') || roles.includes('kezhang'))
65
+    },
66
+  },
67
+  mounted() {
68
+    this.setDefaultDates()
69
+    this.getProblemRectificationData()
70
+    this.$nextTick(() => {
71
+      this.initStackedBarChart()
72
+    })
73
+  },
74
+
75
+  methods: {
76
+    handleTimeRangeChange(timeRange) {
77
+      const timeRangeResult = calculateTimeRange(timeRange, this.dateRange)
78
+      this.startDate = timeRangeResult.startDate
79
+      this.endDate = timeRangeResult.endDate
80
+      this.dateRange = [this.startDate, this.endDate]
81
+      this.calculateDays()
82
+      this.getProblemRectificationData()
83
+      this.initStackedBarChart()
84
+    },
85
+    getProblemRectificationData() {
86
+
87
+      getRectificationStatusTotal(calculateTimeRange(this.activeTimeRange, this.dateRange))
88
+        .then(res => {
89
+          const { data } = res
90
+
91
+          this.cardData = [
92
+            {
93
+              label: '按期已完成',
94
+              value: data?.onTimeCompletedCount || '0',
95
+              image: '/static/images/blue-box.png'
96
+            },
97
+            {
98
+              label: '超期已完成',
99
+              value: data?.overTimeCompletedCount || '0',
100
+              image: '/static/images/pink-box.png'
101
+            },
102
+            {
103
+              label: '按期整改中',
104
+              value: data?.onTimeUnfinishedCount || '0',
105
+              image: '/static/images/green-paper.png'
106
+            },
107
+            {
108
+              label: '超期整改中',
109
+              value: data?.overTimeUnfinishedCount || '0',
110
+              image: '/static/images/yellow-paper.png'
111
+            }
112
+          ]
113
+        })
114
+    },
115
+    setDefaultDates() {
116
+      const today = new Date()
117
+      const firstDayOfMonth = new Date(today.getFullYear(), today.getMonth(), 1)
118
+
119
+      this.startDate = firstDayOfMonth.toISOString().split('T')[0]
120
+      this.endDate = today.toISOString().split('T')[0]
121
+      // 初始化dateRange
122
+      this.dateRange = [this.startDate, this.endDate]
123
+      this.calculateDays()
124
+    },
125
+
126
+    handleDateRangeChange(e) {
127
+      if (e && Array.isArray(e) && e.length === 2) {
128
+        this.startDate = e[0]
129
+        this.endDate = e[1]
130
+        this.calculateDays()
131
+        this.getProblemRectificationData()
132
+        this.initStackedBarChart()
133
+      }
134
+    },
135
+
136
+    calculateDays() {
137
+      if (this.startDate && this.endDate) {
138
+        const start = new Date(this.startDate)
139
+        const end = new Date(this.endDate)
140
+        const diffTime = Math.abs(end - start)
141
+        this.totalDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1
142
+      }
143
+    },
144
+
145
+    async initStackedBarChart() {
146
+      const chartDom = document.getElementById('stackedBarChart')
147
+      if (!chartDom) {
148
+        console.error('堆叠柱状图容器未找到')
149
+        return
150
+      }
151
+
152
+      try {
153
+        // 如果图表实例已存在,先清除之前的状态
154
+        if (this.stackedBarChart) {
155
+          this.stackedBarChart.clear();
156
+        }
157
+
158
+        let query = calculateTimeRange(this.activeTimeRange, this.dateRange);
159
+        let res = await getRectificationStatusComparison(query)
160
+        const { data } = res;
161
+
162
+        // 检查数据是否为空
163
+        if (!data || data.length === 0) {
164
+          this.setEmptyStackedBarChart()
165
+          return
166
+        }
167
+
168
+        // 处理数据为堆积图格式
169
+        let xAxisData = [];
170
+        let seriesData = [];
171
+
172
+        // 定义四种状态的数据字段映射
173
+        const statusConfig = [
174
+          { key: 'onTimeCompletedCount', name: '按期完成' },
175
+          { key: 'overTimeCompletedCount', name: '超期完成' },
176
+          { key: 'onTimeUnfinishedCount', name: '按期整改中' },
177
+          { key: 'overTimeUnfinishedCount', name: '超期整改中' }
178
+        ];
179
+
180
+        // 提取部门名称作为x轴数据
181
+        xAxisData = data.map(item => item.deptName);
182
+
183
+        // 为每种状态创建系列数据
184
+        statusConfig.forEach(status => {
185
+          const seriesItem = {
186
+            name: status.name,
187
+            type: 'bar',
188
+            stack: 'total',
189
+            emphasis: {
190
+              focus: 'series'
191
+            },
192
+            data: data.map(item => item[status.key] || 0),
193
+            label: {
194
+              show: true,
195
+              position: 'inside',
196
+              formatter: function (params) {
197
+                // 只显示非零值
198
+                return params.value > 0 ? params.value : '';
199
+              },
200
+              color: '#fff',
201
+              fontSize: 12,
202
+              fontWeight: 'bold'
203
+            }
204
+          };
205
+          seriesData.push(seriesItem);
206
+        });
207
+
208
+        console.log('堆积图数据:', { xAxisData, seriesData });
209
+
210
+        this.stackedBarChart = echarts.init(chartDom)
211
+        const option = {
212
+          tooltip: {
213
+            trigger: 'axis',
214
+            axisPointer: {
215
+              type: 'shadow'
216
+            }
217
+          },
218
+          legend: {
219
+            data: statusConfig.map(item => item.name)
220
+          },
221
+          grid: {
222
+            left: '3%',
223
+            right: '4%',
224
+            bottom: '3%',
225
+            containLabel: true
226
+          },
227
+          xAxis: {
228
+            type: 'category',
229
+            data: xAxisData,
230
+            axisLabel: {
231
+              interval: 0,
232
+              rotate: 45 // 如果部门名称太长可以旋转显示
233
+            }
234
+          },
235
+          yAxis: {
236
+            type: 'value'
237
+          },
238
+          series: seriesData
239
+        }
240
+
241
+        this.stackedBarChart.setOption(option)
242
+      } catch (error) {
243
+        console.error('堆叠柱状图初始化失败:', error)
244
+        this.setEmptyStackedBarChart()
245
+      }
246
+    },
247
+
248
+    // 设置空堆叠柱状图状态
249
+    setEmptyStackedBarChart() {
250
+      const chartDom = document.getElementById('stackedBarChart')
251
+
252
+      if (!chartDom) return
253
+
254
+      // 如果图表实例不存在,先初始化
255
+      if (!this.stackedBarChart) {
256
+        this.stackedBarChart = echarts.init(chartDom)
257
+      }
258
+
259
+      const option = {
260
+        title: {
261
+          text: '暂无数据',
262
+          left: 'center',
263
+          top: 'center',
264
+          textStyle: {
265
+            color: '#999',
266
+            fontSize: 16
267
+          }
268
+        },
269
+        xAxis: {
270
+          show: false
271
+        },
272
+        yAxis: {
273
+          show: false
274
+        },
275
+        series: []
276
+      }
277
+
278
+      this.stackedBarChart.setOption(option)
279
+    }
280
+  },
281
+  beforeDestroy() {
282
+    {
283
+      if (this.stackedBarChart) this.stackedBarChart.dispose()
284
+    }
285
+  }
286
+
287
+}
288
+
289
+
290
+</script>
291
+
292
+<style lang="scss" scoped>
293
+.problem-rectification {
294
+  padding: 24rpx;
295
+
296
+  .time-filter-section {
297
+    margin-bottom: 32rpx;
298
+
299
+    .filter-row {
300
+      display: flex;
301
+      align-items: center;
302
+      flex-direction: row;
303
+
304
+      .custom-time-section {
305
+        position: relative;
306
+        display: flex;
307
+        align-items: center;
308
+        gap: 24rpx;
309
+        margin-top: 2rpx;
310
+        width: 350rpx;
311
+
312
+        .date-range-picker {
313
+          ::v-deep .icon-calendar {
314
+            display: none !important;
315
+          }
316
+
317
+          ::v-deep .uni-date-range {
318
+            font-size: 16rpx !important;
319
+            background: transparent !important;
320
+            color: black !important;
321
+          }
322
+
323
+          ::v-deep .uni-date-x {
324
+            height: 25rpx !important;
325
+            line-height: 25rpx !important;
326
+
327
+            .range-separator {
328
+              font-size: 20rpx !important;
329
+              padding: 0 !important
330
+            }
331
+
332
+            .uni-date__x-input {
333
+              height: 25rpx !important;
334
+              line-height: 25rpx !important;
335
+            }
336
+          }
337
+
338
+          ::v-deep .uni-date-x--border {
339
+            border: none !important;
340
+          }
341
+        }
342
+
343
+        .days-count {
344
+          position: absolute;
345
+          right: -72rpx;
346
+          top: -8rpx;
347
+          font-size: 25rpx;
348
+          color: black;
349
+          font-weight: 500;
350
+          white-space: nowrap;
351
+
352
+
353
+        }
354
+      }
355
+    }
356
+  }
357
+
358
+  .chart-section {
359
+    background: #fff;
360
+    border-radius: 16rpx;
361
+    padding: 24rpx;
362
+    margin-bottom: 24rpx;
363
+    box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
364
+
365
+    .section-title {
366
+      font-size: 28rpx;
367
+      font-weight: bold;
368
+      color: #333;
369
+      margin-bottom: 20rpx;
370
+    }
371
+
372
+    .chart-container {
373
+      height: 1200rpx;
374
+
375
+      .stacked-bar-chart {
376
+        width: 100%;
377
+        height: 100%;
378
+      }
379
+    }
380
+  }
381
+
382
+}
383
+</style>

+ 89 - 0
src/pages/inspectionStatistics/components/StaticsTab.vue

@@ -0,0 +1,89 @@
1
+<template>
2
+  <view class="statics-tab">
3
+    <view v-for="(item, index) in tabs" :key="index" class="tab-item" :class="{ active: value === item.type }"
4
+      @click="handleTabClick(item)">
5
+      <text class="tab-text">{{ item.title }}</text>
6
+    </view>
7
+  </view>
8
+</template>
9
+
10
+<script>
11
+export default {
12
+  name: 'StaticsTab',
13
+  props: {
14
+    // 当前选中的tab值
15
+    value: {
16
+      type: String,
17
+      default: ''
18
+    },
19
+    // tab选项数组
20
+    tabs: {
21
+      type: Array,
22
+      default: () => []
23
+    },
24
+    // 激活状态的颜色
25
+    activeColor: {
26
+      type: String,
27
+      default: '#1890ff'
28
+    }
29
+  },
30
+
31
+  methods: {
32
+    // 处理tab点击
33
+    handleTabClick(item) {
34
+      this.$emit('input', item.type);
35
+      this.$emit('change', item.type, item);
36
+    }
37
+  }
38
+};
39
+</script>
40
+
41
+<style lang="scss" scoped>
42
+.statics-tab {
43
+  display: flex;
44
+  justify-content: flex-start;
45
+  // gap: 5rpx;
46
+
47
+  .tab-item {
48
+    font-size: 22rpx;
49
+    padding: 6rpx 11rpx;
50
+    
51
+    // height: 43rpx;
52
+    // line-height: 43rpx;
53
+    border-radius: 50rpx;
54
+    background-color: transparent;
55
+    transition: all 0.3s ease;
56
+    cursor: pointer;
57
+
58
+    .tab-text {
59
+      font-size: 22rpx;
60
+      color: black;
61
+      transition: color 0.3s ease;
62
+    }
63
+
64
+    // 激活状态
65
+    &.active {
66
+      background-color: var(--active-color, #7091EB);
67
+
68
+      .tab-text {
69
+        color: #fff;
70
+        font-weight: bold;
71
+      }
72
+    }
73
+
74
+    // 悬停效果
75
+    &:hover:not(.active) {
76
+      background-color: #f5f5f5;
77
+
78
+      .tab-text {
79
+        color: #fff;
80
+      }
81
+    }
82
+
83
+    // 激活状态样式
84
+    &:active {
85
+      transform: scale(0.95);
86
+    }
87
+  }
88
+}
89
+</style>

+ 94 - 0
src/pages/inspectionStatistics/index.vue

@@ -0,0 +1,94 @@
1
+<template>
2
+	<home-container :customStyle="{ background: 'none', paddingTop: '0' }">
3
+		<view class="inspection-statistics">
4
+
5
+			<!-- 选项卡 -->
6
+			<h-tabs v-model="activeTab" :tabs="tabs" :activeColor="'#2A70D1'" value-key="value" label-key="label"
7
+				@change="handleTabChange" />
8
+
9
+			<!-- 内容区域 -->
10
+			<view class="content">
11
+				<!-- 巡检计划 -->
12
+				<InspectionPlan v-if="activeTab === 0" />
13
+
14
+				<!-- 巡检执行 -->
15
+				<InspectionExecution v-if="activeTab === 1" />
16
+
17
+				<!-- 问题发现 -->
18
+				<ProblemDiscovery v-if="activeTab === 2" />
19
+
20
+				<!-- 问题整改 -->
21
+				<ProblemRectification v-if="activeTab === 3" />
22
+			</view>
23
+		</view>
24
+	</home-container>
25
+
26
+</template>
27
+
28
+<script>
29
+
30
+import InspectionPlan from './components/InspectionPlan.vue'
31
+import InspectionExecution from './components/InspectionExecution.vue'
32
+import ProblemDiscovery from './components/ProblemDiscovery.vue'
33
+import ProblemRectification from './components/ProblemRectification.vue'
34
+import HomeContainer from "@/components/HomeContainer.vue";
35
+export default {
36
+	components: {
37
+		HomeContainer,
38
+		InspectionPlan,
39
+		InspectionExecution,
40
+		ProblemDiscovery,
41
+		ProblemRectification
42
+	},
43
+
44
+	data() {
45
+		return {
46
+			activeTab: 0, // 当前激活的选项卡
47
+
48
+		}
49
+	},
50
+	mounted() {
51
+		// 初始化选项卡
52
+		this.activeTab = this.isBanZuZhang ? 1 : 0
53
+	},
54
+	computed: {
55
+		isBanZuZhang() {
56
+			let roles = this.$store.state.user.roles;
57
+			console.log(roles)
58
+			return roles && roles.includes('banzuzhang')
59
+		},
60
+		tabs() {
61
+			return [
62
+				...(this.isBanZuZhang ? [] : [{ label: '巡检计划', value: 0, style: { 'color': 'black' }, }]),
63
+				{ label: '巡检执行', value: 1, style: { 'color': 'black' }, },
64
+				{ label: '问题发现', value: 2, style: { 'color': 'black' }, },
65
+				{ label: '问题整改', value: 3, style: { 'color': 'black' }, }
66
+			]
67
+		}
68
+
69
+	},
70
+	methods: {
71
+		// 选项卡切换事件
72
+		handleTabChange(index) {
73
+			this.activeTab = index
74
+			console.log('切换到选项卡:', this.tabs[index].label)
75
+		}
76
+	}
77
+}
78
+</script>
79
+
80
+<style lang="scss" scoped>
81
+.inspection-statistics {
82
+	min-height: 100vh;
83
+	padding-top: 40px;
84
+
85
+	.h-tabs {
86
+		position: fixed;
87
+		top: 80rpx;
88
+        z-index: 999;
89
+		padding-top: 20rpx;
90
+		background-color: #fff !important;
91
+	}
92
+
93
+}
94
+</style>

+ 249 - 0
src/pages/login.vue

@@ -0,0 +1,249 @@
1
+<template>
2
+  <home-container>
3
+    <view class="normal-login-container">
4
+      <view class="logo-content">
5
+        <!-- <image style="width: 100rpx;height: 100rpx;" :src="globalConfig.appInfo.logo" mode="widthFix">
6
+      </image> -->
7
+        <text class="title" style="display: block;">您好,</text>
8
+        <text class="title" style="display: block;">欢迎使用机场安检</text>
9
+
10
+      </view>
11
+      <view class="login-form-content">
12
+        <view class="input-item flex align-center">
13
+          <view class="iconfont icon-user icon"></view>
14
+          <input v-model="loginForm.username" class="input" type="text" placeholder="请输入账号" maxlength="30" />
15
+        </view>
16
+        <view class="input-item flex align-center">
17
+          <view class="iconfont icon-password icon"></view>
18
+          <input v-model="loginForm.password" type="password" class="input" placeholder="请输入密码" maxlength="20" />
19
+        </view>
20
+        <view class="input-item flex align-center" style="width: 60%;margin: 0px;" v-if="captchaEnabled">
21
+          <view class="iconfont icon-code icon"></view>
22
+          <input v-model="loginForm.code" type="number" class="input" placeholder="请输入验证码" maxlength="4" />
23
+          <view class="login-code">
24
+            <image :src="codeUrl" @click="getCode" class="login-code-img"></image>
25
+          </view>
26
+        </view>
27
+        <view class="action-btn">
28
+          <button @click="handleLogin" class="login-btn cu-btn block bg-blue lg round">登录</button>
29
+        </view>
30
+        <view class="reg text-center" v-if="register">
31
+          <text class="text-grey1">没有账号?</text>
32
+          <text @click="handleUserRegister" class="text-blue">立即注册</text>
33
+        </view>
34
+        <view class="xieyi text-center">
35
+          <text class="text-grey1">登录即代表同意</text>
36
+          <text @click="handleUserAgrement" class="text-blue">《用户协议》</text>
37
+          <text @click="handlePrivacy" class="text-blue">《隐私协议》</text>
38
+        </view>
39
+      </view>
40
+
41
+    </view>
42
+  </home-container>
43
+
44
+</template>
45
+
46
+<script>
47
+import HomeContainer from "@/components/HomeContainer.vue";
48
+import { bufferConverter } from '@/utils/handler'
49
+import { getCodeImg } from '@/api/login'
50
+import { getToken } from '@/utils/auth'
51
+import { showMessageTabRedDot } from "@/utils/common.js"
52
+
53
+export default {
54
+  components: { HomeContainer },
55
+
56
+  data() {
57
+    return {
58
+      codeUrl: "",
59
+      captchaEnabled: true,
60
+      // 用户注册开关
61
+      register: false,
62
+      globalConfig: getApp().globalData.config,
63
+      loginForm: {
64
+        username: "",
65
+        password: "",
66
+        code: "",
67
+        uuid: ""
68
+      }
69
+    }
70
+  },
71
+  created() {
72
+    this.getCode()
73
+  },
74
+  onLoad() {
75
+    //#ifdef H5
76
+    if (getToken()) {
77
+      this.$tab.reLaunch('/pages/home/index')
78
+    }
79
+    this.getStorage()
80
+    //#endif
81
+  },
82
+  methods: {
83
+    getStorage() {
84
+      let info = uni.getStorageSync('loginInfo')
85
+      if (info) {
86
+        info = bufferConverter.base64ToString(info)
87
+        try {
88
+          info = JSON.parse(info)
89
+          this.loginForm.username = info.username
90
+          this.loginForm.password = info.password
91
+        } catch {
92
+          this.loginForm.username = ''
93
+          this.loginForm.password = ''
94
+        }
95
+      }
96
+    },
97
+    // 用户注册
98
+    handleUserRegister() {
99
+      this.$tab.redirectTo(`/pages/register`)
100
+    },
101
+    // 隐私协议
102
+    handlePrivacy() {
103
+      let site = this.globalConfig.appInfo.agreements[0]
104
+      this.$tab.navigateTo(`/pages/common/webview/index?title=${site.title}&url=${site.url}`)
105
+    },
106
+    // 用户协议
107
+    handleUserAgrement() {
108
+      let site = this.globalConfig.appInfo.agreements[1]
109
+      this.$tab.navigateTo(`/pages/common/webview/index?title=${site.title}&url=${site.url}`)
110
+    },
111
+    // 获取图形验证码
112
+    getCode() {
113
+      getCodeImg().then(res => {
114
+        this.captchaEnabled = res.captchaEnabled === undefined ? true : res.captchaEnabled
115
+        if (this.captchaEnabled) {
116
+          this.codeUrl = 'data:image/gif;base64,' + res.img
117
+          this.loginForm.uuid = res.uuid
118
+        }
119
+      })
120
+    },
121
+    // 登录方法
122
+    async handleLogin() {
123
+      if (this.loginForm.username === "") {
124
+        this.$modal.msgError("请输入账号")
125
+      } else if (this.loginForm.password === "") {
126
+        this.$modal.msgError("请输入密码")
127
+      } else if (this.loginForm.code === "" && this.captchaEnabled) {
128
+        this.$modal.msgError("请输入验证码")
129
+      } else {
130
+        this.$modal.loading("登录中,请耐心等待...")
131
+        this.pwdLogin()
132
+      }
133
+    },
134
+    // 密码登录
135
+    async pwdLogin() {
136
+      this.$store.dispatch('Login', this.loginForm).then(() => {
137
+        // 密码现在是明文传输 展示转base64 存本地 做保存密码需求
138
+        const info = bufferConverter.stringToBase64(JSON.stringify({
139
+          username: this.loginForm.username,
140
+          password: this.loginForm.password,
141
+        }))
142
+        uni.setStorageSync('loginInfo', info)
143
+        this.$modal.closeLoading()
144
+        this.loginSuccess()
145
+      }).catch((res) => {
146
+        uni.showToast({
147
+          title: res.msg,
148
+          icon: 'none'
149
+        });
150
+        if (this.captchaEnabled) {
151
+          this.getCode()
152
+        }
153
+      }).finally(() => {
154
+        this.$modal.closeLoading()
155
+      })
156
+    },
157
+    // 登录成功后,处理函数
158
+    loginSuccess(result) {
159
+      // 设置用户信息
160
+      this.$store.dispatch('GetInfo').then(res => {
161
+        // 登录成功后调用showMessageTabRedDot更新消息tab红点状态
162
+        showMessageTabRedDot()
163
+        this.$tab.reLaunch('/pages/home/index')
164
+      })
165
+    }
166
+  }
167
+}
168
+</script>
169
+
170
+<style lang="scss" scoped>
171
+page {
172
+  background-color: #ffffff;
173
+}
174
+
175
+.normal-login-container {
176
+  width: 100%;
177
+
178
+  .logo-content {
179
+    width: 100%;
180
+    font-size: 21px;
181
+    text-align: left;
182
+    padding-top: 15%;
183
+    padding-left: 30rpx;
184
+
185
+    image {
186
+      border-radius: 4px;
187
+    }
188
+
189
+    .title {
190
+      margin-left: 10px;
191
+    }
192
+  }
193
+
194
+  .login-form-content {
195
+    text-align: center;
196
+    margin: 20px auto;
197
+    margin-top: 15%;
198
+    width: 80%;
199
+
200
+    .input-item {
201
+      margin: 20px auto;
202
+      background-color: #f5f6f7;
203
+      height: 45px;
204
+      border-radius: 20px;
205
+
206
+      .icon {
207
+        font-size: 38rpx;
208
+        margin-left: 10px;
209
+        color: #999;
210
+      }
211
+
212
+      .input {
213
+        width: 100%;
214
+        font-size: 14px;
215
+        line-height: 20px;
216
+        text-align: left;
217
+        padding-left: 15px;
218
+      }
219
+
220
+    }
221
+
222
+    .login-btn {
223
+      margin-top: 40px;
224
+      height: 45px;
225
+    }
226
+
227
+    .reg {
228
+      margin-top: 15px;
229
+    }
230
+
231
+    .xieyi {
232
+      color: #333;
233
+      margin-top: 20px;
234
+    }
235
+
236
+    .login-code {
237
+      height: 38px;
238
+      float: right;
239
+
240
+      .login-code-img {
241
+        height: 38px;
242
+        position: absolute;
243
+        margin-left: 10px;
244
+        width: 200rpx;
245
+      }
246
+    }
247
+  }
248
+}
249
+</style>

+ 75 - 0
src/pages/mine/about/index.vue

@@ -0,0 +1,75 @@
1
+<template>
2
+  <view class="about-container">
3
+    <view class="header-section text-center">
4
+      <image style="width: 150rpx;height: 150rpx;" src="/static/logo200.png" mode="widthFix">
5
+      </image>
6
+      <uni-title type="h2" title="安检分级质控系统移动端"></uni-title>
7
+    </view>
8
+
9
+    <view class="content-section">
10
+      <view class="menu-list">
11
+        <view class="list-cell list-cell-arrow">
12
+          <view class="menu-item-box">
13
+            <view>版本信息</view>
14
+            <view class="text-right">v{{version}}</view>
15
+          </view>
16
+        </view>
17
+        <view class="list-cell list-cell-arrow">
18
+          <view class="menu-item-box">
19
+            <view>官方邮箱</view>
20
+            <view class="text-right">ruoyi@xx.com</view>
21
+          </view>
22
+        </view>
23
+        <view class="list-cell list-cell-arrow">
24
+          <view class="menu-item-box">
25
+            <view>服务热线</view>
26
+            <view class="text-right">400-999-9999</view>
27
+          </view>
28
+        </view>
29
+        <view class="list-cell list-cell-arrow">
30
+          <view class="menu-item-box">
31
+            <view>公司网站</view>
32
+            <view class="text-right">
33
+              <uni-link :href="url" :text="url" showUnderLine="false"></uni-link>
34
+            </view>
35
+          </view>
36
+        </view>
37
+      </view>
38
+    </view>
39
+
40
+    <view class="copyright">
41
+      <view>Copyright &copy; 2025 ruoyi.vip All Rights Reserved.</view>
42
+    </view>
43
+  </view>
44
+</template>
45
+
46
+<script>
47
+  export default {
48
+    data() {
49
+      return {
50
+        url: getApp().globalData.config.appInfo.site_url,
51
+        version: getApp().globalData.config.appInfo.version
52
+      }
53
+    }
54
+  }
55
+</script>
56
+
57
+<style lang="scss" scoped>
58
+  page {
59
+    background-color: #f8f8f8;
60
+  }
61
+
62
+  .copyright {
63
+    margin-top: 50rpx;
64
+    text-align: center;
65
+    line-height: 60rpx;
66
+    color: #999;
67
+  }
68
+
69
+  .header-section {
70
+    display: flex;
71
+    padding: 30rpx 0 0;
72
+    flex-direction: column;
73
+    align-items: center;
74
+  }
75
+</style>

+ 618 - 0
src/pages/mine/avatar/index.vue

@@ -0,0 +1,618 @@
1
+<template>
2
+	<view class="container">
3
+		<view class="page-body uni-content-info">
4
+			<view class='cropper-content'>
5
+				<view v-if="isShowImg" class="uni-corpper" :style="'width:'+cropperInitW+'px;height:'+cropperInitH+'px;background:#000'">
6
+					<view class="uni-corpper-content" :style="'width:'+cropperW+'px;height:'+cropperH+'px;left:'+cropperL+'px;top:'+cropperT+'px'">
7
+						<image :src="imageSrc" :style="'width:'+cropperW+'px;height:'+cropperH+'px'"></image>
8
+						<view class="uni-corpper-crop-box" @touchstart.stop="contentStartMove" @touchmove.stop="contentMoveing" @touchend.stop="contentTouchEnd"
9
+						    :style="'left:'+cutL+'px;top:'+cutT+'px;right:'+cutR+'px;bottom:'+cutB+'px'">
10
+							<view class="uni-cropper-view-box">
11
+								<view class="uni-cropper-dashed-h"></view>
12
+								<view class="uni-cropper-dashed-v"></view>
13
+								<view class="uni-cropper-line-t" data-drag="top" @touchstart.stop="dragStart" @touchmove.stop="dragMove"></view>
14
+								<view class="uni-cropper-line-r" data-drag="right" @touchstart.stop="dragStart" @touchmove.stop="dragMove"></view>
15
+								<view class="uni-cropper-line-b" data-drag="bottom" @touchstart.stop="dragStart" @touchmove.stop="dragMove"></view>
16
+								<view class="uni-cropper-line-l" data-drag="left" @touchstart.stop="dragStart" @touchmove.stop="dragMove"></view>
17
+								<view class="uni-cropper-point point-t" data-drag="top" @touchstart.stop="dragStart" @touchmove.stop="dragMove"></view>
18
+								<view class="uni-cropper-point point-tr" data-drag="topTight"></view>
19
+								<view class="uni-cropper-point point-r" data-drag="right" @touchstart.stop="dragStart" @touchmove.stop="dragMove"></view>
20
+								<view class="uni-cropper-point point-rb" data-drag="rightBottom" @touchstart.stop="dragStart" @touchmove.stop="dragMove"></view>
21
+								<view class="uni-cropper-point point-b" data-drag="bottom" @touchstart.stop="dragStart" @touchmove.stop="dragMove" @touchend.stop="dragEnd"></view>
22
+								<view class="uni-cropper-point point-bl" data-drag="bottomLeft"></view>
23
+								<view class="uni-cropper-point point-l" data-drag="left" @touchstart.stop="dragStart" @touchmove.stop="dragMove"></view>
24
+								<view class="uni-cropper-point point-lt" data-drag="leftTop"></view>
25
+							</view>
26
+						</view>
27
+					</view>
28
+				</view>
29
+			</view>
30
+			<view class='cropper-config'>
31
+				<button type="primary reverse" @click="getImage" style='margin-top: 30rpx;'> 选择头像 </button>
32
+				<button type="warn" @click="getImageInfo" style='margin-top: 30rpx;'> 提交 </button>
33
+			</view>
34
+			<canvas canvas-id="myCanvas" :style="'position:absolute;border: 1px solid red; width:'+imageW+'px;height:'+imageH+'px;top:-9999px;left:-9999px;'"></canvas>
35
+		</view>
36
+	</view>
37
+</template>
38
+
39
+<script>
40
+  import config from '@/config'
41
+  import store from "@/store"
42
+  import { uploadAvatar } from "@/api/system/user"
43
+  
44
+  const baseUrl = config.baseUrl
45
+	let sysInfo = uni.getSystemInfoSync()
46
+	let SCREEN_WIDTH = sysInfo.screenWidth
47
+	let PAGE_X, // 手按下的x位置
48
+		PAGE_Y, // 手按下y的位置 
49
+		PR = sysInfo.pixelRatio, // dpi
50
+		T_PAGE_X, // 手移动的时候x的位置
51
+		T_PAGE_Y, // 手移动的时候Y的位置
52
+		CUT_L, // 初始化拖拽元素的left值
53
+		CUT_T, // 初始化拖拽元素的top值
54
+		CUT_R, // 初始化拖拽元素的
55
+		CUT_B, // 初始化拖拽元素的
56
+		CUT_W, // 初始化拖拽元素的宽度
57
+		CUT_H, //  初始化拖拽元素的高度
58
+		IMG_RATIO, // 图片比例
59
+		IMG_REAL_W, // 图片实际的宽度
60
+		IMG_REAL_H, // 图片实际的高度
61
+		DRAFG_MOVE_RATIO = 1, //移动时候的比例,
62
+		INIT_DRAG_POSITION = 100, // 初始化屏幕宽度和裁剪区域的宽度之差,用于设置初始化裁剪的宽度
63
+		DRAW_IMAGE_W = sysInfo.screenWidth // 设置生成的图片宽度
64
+
65
+	export default {
66
+		/**
67
+		 * 页面的初始数据
68
+		 */
69
+		data() {
70
+			return {
71
+				imageSrc: store.getters.avatar,
72
+				isShowImg: false,
73
+				// 初始化的宽高
74
+				cropperInitW: SCREEN_WIDTH,
75
+				cropperInitH: SCREEN_WIDTH,
76
+				// 动态的宽高
77
+				cropperW: SCREEN_WIDTH,
78
+				cropperH: SCREEN_WIDTH,
79
+				// 动态的left top值
80
+				cropperL: 0,
81
+				cropperT: 0,
82
+
83
+				transL: 0,
84
+				transT: 0,
85
+
86
+				// 图片缩放值
87
+				scaleP: 0,
88
+				imageW: 0,
89
+				imageH: 0,
90
+
91
+				// 裁剪框 宽高
92
+				cutL: 0,
93
+				cutT: 0,
94
+				cutB: SCREEN_WIDTH,
95
+				cutR: '100%',
96
+				qualityWidth: DRAW_IMAGE_W,
97
+				innerAspectRadio: DRAFG_MOVE_RATIO
98
+			}
99
+		},
100
+		/**
101
+		 * 生命周期函数--监听页面初次渲染完成
102
+		 */
103
+		onReady: function () {
104
+			this.loadImage()
105
+		},
106
+		methods: {
107
+			setData: function (obj) {
108
+				let that = this
109
+				Object.keys(obj).forEach(function (key) {
110
+					that.$set(that.$data, key, obj[key])
111
+				})
112
+			},
113
+			getImage: function () {
114
+				var _this = this
115
+				uni.chooseImage({
116
+					success: function (res) {
117
+						_this.setData({
118
+							imageSrc: res.tempFilePaths[0],
119
+						})
120
+						_this.loadImage()
121
+					},
122
+				})
123
+			},
124
+			loadImage: function () {
125
+				var _this = this
126
+
127
+				uni.getImageInfo({
128
+					src: _this.imageSrc,
129
+					success: function success(res) {
130
+						IMG_RATIO = 1 / 1
131
+						if (IMG_RATIO >= 1) {
132
+							IMG_REAL_W = SCREEN_WIDTH
133
+							IMG_REAL_H = SCREEN_WIDTH / IMG_RATIO
134
+						} else {
135
+							IMG_REAL_W = SCREEN_WIDTH * IMG_RATIO
136
+							IMG_REAL_H = SCREEN_WIDTH
137
+						}
138
+						let minRange = IMG_REAL_W > IMG_REAL_H ? IMG_REAL_W : IMG_REAL_H
139
+						INIT_DRAG_POSITION = minRange > INIT_DRAG_POSITION ? INIT_DRAG_POSITION : minRange
140
+						// 根据图片的宽高显示不同的效果   保证图片可以正常显示
141
+						if (IMG_RATIO >= 1) {
142
+							let cutT = Math.ceil((SCREEN_WIDTH / IMG_RATIO - (SCREEN_WIDTH / IMG_RATIO - INIT_DRAG_POSITION)) / 2)
143
+							let cutB = cutT
144
+							let cutL = Math.ceil((SCREEN_WIDTH - SCREEN_WIDTH + INIT_DRAG_POSITION) / 2)
145
+							let cutR = cutL
146
+							_this.setData({
147
+								cropperW: SCREEN_WIDTH,
148
+								cropperH: SCREEN_WIDTH / IMG_RATIO,
149
+								// 初始化left right
150
+								cropperL: Math.ceil((SCREEN_WIDTH - SCREEN_WIDTH) / 2),
151
+								cropperT: Math.ceil((SCREEN_WIDTH - SCREEN_WIDTH / IMG_RATIO) / 2),
152
+								cutL: cutL,
153
+								cutT: cutT,
154
+								cutR: cutR,
155
+								cutB: cutB,
156
+								// 图片缩放值
157
+								imageW: IMG_REAL_W,
158
+								imageH: IMG_REAL_H,
159
+								scaleP: IMG_REAL_W / SCREEN_WIDTH,
160
+								qualityWidth: DRAW_IMAGE_W,
161
+								innerAspectRadio: IMG_RATIO
162
+							})
163
+						} else {
164
+							let cutL = Math.ceil((SCREEN_WIDTH * IMG_RATIO - (SCREEN_WIDTH * IMG_RATIO)) / 2)
165
+							let cutR = cutL
166
+							let cutT = Math.ceil((SCREEN_WIDTH - INIT_DRAG_POSITION) / 2)
167
+							let cutB = cutT
168
+							_this.setData({
169
+								cropperW: SCREEN_WIDTH * IMG_RATIO,
170
+								cropperH: SCREEN_WIDTH,
171
+								// 初始化left right
172
+								cropperL: Math.ceil((SCREEN_WIDTH - SCREEN_WIDTH * IMG_RATIO) / 2),
173
+								cropperT: Math.ceil((SCREEN_WIDTH - SCREEN_WIDTH) / 2),
174
+
175
+								cutL: cutL,
176
+								cutT: cutT,
177
+								cutR: cutR,
178
+								cutB: cutB,
179
+								// 图片缩放值
180
+								imageW: IMG_REAL_W,
181
+								imageH: IMG_REAL_H,
182
+								scaleP: IMG_REAL_W / SCREEN_WIDTH,
183
+								qualityWidth: DRAW_IMAGE_W,
184
+								innerAspectRadio: IMG_RATIO
185
+							})
186
+						}
187
+						_this.setData({
188
+							isShowImg: true
189
+						})
190
+						uni.hideLoading()
191
+					}
192
+				})
193
+			},
194
+			// 拖动时候触发的touchStart事件
195
+			contentStartMove(e) {
196
+				PAGE_X = e.touches[0].pageX
197
+				PAGE_Y = e.touches[0].pageY
198
+			},
199
+
200
+			// 拖动时候触发的touchMove事件
201
+			contentMoveing(e) {
202
+				var _this = this
203
+				var dragLengthX = (PAGE_X - e.touches[0].pageX) * DRAFG_MOVE_RATIO
204
+				var dragLengthY = (PAGE_Y - e.touches[0].pageY) * DRAFG_MOVE_RATIO
205
+				// 左移
206
+				if (dragLengthX > 0) {
207
+					if (this.cutL - dragLengthX < 0) dragLengthX = this.cutL
208
+				} else {
209
+					if (this.cutR + dragLengthX < 0) dragLengthX = -this.cutR
210
+				}
211
+
212
+				if (dragLengthY > 0) {
213
+					if (this.cutT - dragLengthY < 0) dragLengthY = this.cutT
214
+				} else {
215
+					if (this.cutB + dragLengthY < 0) dragLengthY = -this.cutB
216
+				}
217
+				this.setData({
218
+					cutL: this.cutL - dragLengthX,
219
+					cutT: this.cutT - dragLengthY,
220
+					cutR: this.cutR + dragLengthX,
221
+					cutB: this.cutB + dragLengthY
222
+				})
223
+
224
+				PAGE_X = e.touches[0].pageX
225
+				PAGE_Y = e.touches[0].pageY
226
+			},
227
+
228
+			contentTouchEnd() {
229
+
230
+			},
231
+
232
+			// 获取图片
233
+			getImageInfo() {
234
+				var _this = this
235
+				uni.showLoading({
236
+					title: '图片生成中...',
237
+				})
238
+				// 将图片写入画布
239
+				const ctx = uni.createCanvasContext('myCanvas')
240
+				ctx.drawImage(_this.imageSrc, 0, 0, IMG_REAL_W, IMG_REAL_H)
241
+				ctx.draw(true, () => {
242
+					// 获取画布要裁剪的位置和宽度   均为百分比 * 画布中图片的宽度    保证了在微信小程序中裁剪的图片模糊  位置不对的问题 canvasT = (_this.cutT / _this.cropperH) * (_this.imageH / pixelRatio)
243
+					var canvasW = ((_this.cropperW - _this.cutL - _this.cutR) / _this.cropperW) * IMG_REAL_W
244
+					var canvasH = ((_this.cropperH - _this.cutT - _this.cutB) / _this.cropperH) * IMG_REAL_H
245
+					var canvasL = (_this.cutL / _this.cropperW) * IMG_REAL_W
246
+					var canvasT = (_this.cutT / _this.cropperH) * IMG_REAL_H
247
+					uni.canvasToTempFilePath({
248
+						x: canvasL,
249
+						y: canvasT,
250
+						width: canvasW,
251
+						height: canvasH,
252
+						destWidth: canvasW,
253
+						destHeight: canvasH,
254
+						quality: 0.5,
255
+						canvasId: 'myCanvas',
256
+						success: function (res) {
257
+							uni.hideLoading()
258
+							let data = {name: 'avatarfile', filePath: res.tempFilePath}
259
+							uploadAvatar(data).then(response => {
260
+								store.commit('SET_AVATAR', baseUrl + response.imgUrl)
261
+								uni.showToast({ title: "修改成功", icon: 'success' })
262
+								uni.navigateBack()
263
+							})
264
+						}
265
+					})
266
+				})
267
+			},
268
+			// 设置大小的时候触发的touchStart事件
269
+			dragStart(e) {
270
+				T_PAGE_X = e.touches[0].pageX
271
+				T_PAGE_Y = e.touches[0].pageY
272
+				CUT_L = this.cutL
273
+				CUT_R = this.cutR
274
+				CUT_B = this.cutB
275
+				CUT_T = this.cutT
276
+			},
277
+
278
+			// 设置大小的时候触发的touchMove事件
279
+			dragMove(e) {
280
+				var _this = this
281
+				var dragType = e.target.dataset.drag
282
+				switch (dragType) {
283
+					case 'right':
284
+						var dragLength = (T_PAGE_X - e.touches[0].pageX) * DRAFG_MOVE_RATIO
285
+						if (CUT_R + dragLength < 0) dragLength = -CUT_R
286
+						this.setData({
287
+							cutR: CUT_R + dragLength
288
+						})
289
+						break
290
+					case 'left':
291
+						var dragLength = (T_PAGE_X - e.touches[0].pageX) * DRAFG_MOVE_RATIO
292
+						if (CUT_L - dragLength < 0) dragLength = CUT_L
293
+						if ((CUT_L - dragLength) > (this.cropperW - this.cutR)) dragLength = CUT_L - (this.cropperW - this.cutR)
294
+						this.setData({
295
+							cutL: CUT_L - dragLength
296
+						})
297
+						break
298
+					case 'top':
299
+						var dragLength = (T_PAGE_Y - e.touches[0].pageY) * DRAFG_MOVE_RATIO
300
+						if (CUT_T - dragLength < 0) dragLength = CUT_T
301
+						if ((CUT_T - dragLength) > (this.cropperH - this.cutB)) dragLength = CUT_T - (this.cropperH - this.cutB)
302
+						this.setData({
303
+							cutT: CUT_T - dragLength
304
+						})
305
+						break
306
+					case 'bottom':
307
+						var dragLength = (T_PAGE_Y - e.touches[0].pageY) * DRAFG_MOVE_RATIO
308
+						if (CUT_B + dragLength < 0) dragLength = -CUT_B
309
+						this.setData({
310
+							cutB: CUT_B + dragLength
311
+						})
312
+						break
313
+					case 'rightBottom':
314
+						var dragLengthX = (T_PAGE_X - e.touches[0].pageX) * DRAFG_MOVE_RATIO
315
+						var dragLengthY = (T_PAGE_Y - e.touches[0].pageY) * DRAFG_MOVE_RATIO
316
+
317
+						if (CUT_B + dragLengthY < 0) dragLengthY = -CUT_B
318
+						if (CUT_R + dragLengthX < 0) dragLengthX = -CUT_R
319
+						let cutB = CUT_B + dragLengthY
320
+						let cutR = CUT_R + dragLengthX
321
+
322
+						this.setData({
323
+							cutB: cutB,
324
+							cutR: cutR
325
+						})
326
+						break
327
+					default:
328
+						break
329
+				}
330
+			}
331
+		}
332
+	}
333
+</script>
334
+
335
+<style scoped>
336
+	.cropper-config {
337
+		padding: 20rpx 40rpx;
338
+	}
339
+
340
+	.cropper-content {
341
+		min-height: 750rpx;
342
+		width: 100%;
343
+	}
344
+
345
+	.uni-corpper {
346
+		position: relative;
347
+		overflow: hidden;
348
+		-webkit-user-select: none;
349
+		-moz-user-select: none;
350
+		-ms-user-select: none;
351
+		user-select: none;
352
+		-webkit-tap-highlight-color: transparent;
353
+		-webkit-touch-callout: none;
354
+		box-sizing: border-box;
355
+	}
356
+
357
+	.uni-corpper-content {
358
+		position: relative;
359
+	}
360
+
361
+	.uni-corpper-content image {
362
+		display: block;
363
+		width: 100%;
364
+		min-width: 0 !important;
365
+		max-width: none !important;
366
+		height: 100%;
367
+		min-height: 0 !important;
368
+		max-height: none !important;
369
+		image-orientation: 0deg !important;
370
+		margin: 0 auto;
371
+	}
372
+
373
+	/* 移动图片效果 */
374
+	.uni-cropper-drag-box {
375
+		position: absolute;
376
+		top: 0;
377
+		right: 0;
378
+		bottom: 0;
379
+		left: 0;
380
+		cursor: move;
381
+		background: rgba(0, 0, 0, 0.6);
382
+		z-index: 1;
383
+	}
384
+
385
+	/* 内部的信息 */
386
+	.uni-corpper-crop-box {
387
+		position: absolute;
388
+		background: rgba(255, 255, 255, 0.3);
389
+		z-index: 2;
390
+	}
391
+
392
+	.uni-corpper-crop-box .uni-cropper-view-box {
393
+		position: relative;
394
+		display: block;
395
+		width: 100%;
396
+		height: 100%;
397
+		overflow: visible;
398
+		outline: 1rpx solid #69f;
399
+		outline-color: rgba(102, 153, 255, .75)
400
+	}
401
+
402
+	/* 横向虚线 */
403
+	.uni-cropper-dashed-h {
404
+		position: absolute;
405
+		top: 33.33333333%;
406
+		left: 0;
407
+		width: 100%;
408
+		height: 33.33333333%;
409
+		border-top: 1rpx dashed rgba(255, 255, 255, 0.5);
410
+		border-bottom: 1rpx dashed rgba(255, 255, 255, 0.5);
411
+	}
412
+
413
+	/* 纵向虚线 */
414
+	.uni-cropper-dashed-v {
415
+		position: absolute;
416
+		left: 33.33333333%;
417
+		top: 0;
418
+		width: 33.33333333%;
419
+		height: 100%;
420
+		border-left: 1rpx dashed rgba(255, 255, 255, 0.5);
421
+		border-right: 1rpx dashed rgba(255, 255, 255, 0.5);
422
+	}
423
+
424
+	/* 四个方向的线  为了之后的拖动事件*/
425
+	.uni-cropper-line-t {
426
+		position: absolute;
427
+		display: block;
428
+		width: 100%;
429
+		background-color: #69f;
430
+		top: 0;
431
+		left: 0;
432
+		height: 1rpx;
433
+		opacity: 0.1;
434
+		cursor: n-resize;
435
+	}
436
+
437
+	.uni-cropper-line-t::before {
438
+		content: '';
439
+		position: absolute;
440
+		top: 50%;
441
+		right: 0rpx;
442
+		width: 100%;
443
+		-webkit-transform: translate3d(0, -50%, 0);
444
+		transform: translate3d(0, -50%, 0);
445
+		bottom: 0;
446
+		height: 41rpx;
447
+		background: transparent;
448
+		z-index: 11;
449
+	}
450
+
451
+	.uni-cropper-line-r {
452
+		position: absolute;
453
+		display: block;
454
+		background-color: #69f;
455
+		top: 0;
456
+		right: 0rpx;
457
+		width: 1rpx;
458
+		opacity: 0.1;
459
+		height: 100%;
460
+		cursor: e-resize;
461
+	}
462
+
463
+	.uni-cropper-line-r::before {
464
+		content: '';
465
+		position: absolute;
466
+		top: 0;
467
+		left: 50%;
468
+		width: 41rpx;
469
+		-webkit-transform: translate3d(-50%, 0, 0);
470
+		transform: translate3d(-50%, 0, 0);
471
+		bottom: 0;
472
+		height: 100%;
473
+		background: transparent;
474
+		z-index: 11;
475
+	}
476
+
477
+	.uni-cropper-line-b {
478
+		position: absolute;
479
+		display: block;
480
+		width: 100%;
481
+		background-color: #69f;
482
+		bottom: 0;
483
+		left: 0;
484
+		height: 1rpx;
485
+		opacity: 0.1;
486
+		cursor: s-resize;
487
+	}
488
+
489
+	.uni-cropper-line-b::before {
490
+		content: '';
491
+		position: absolute;
492
+		top: 50%;
493
+		right: 0rpx;
494
+		width: 100%;
495
+		-webkit-transform: translate3d(0, -50%, 0);
496
+		transform: translate3d(0, -50%, 0);
497
+		bottom: 0;
498
+		height: 41rpx;
499
+		background: transparent;
500
+		z-index: 11;
501
+	}
502
+
503
+	.uni-cropper-line-l {
504
+		position: absolute;
505
+		display: block;
506
+		background-color: #69f;
507
+		top: 0;
508
+		left: 0;
509
+		width: 1rpx;
510
+		opacity: 0.1;
511
+		height: 100%;
512
+		cursor: w-resize;
513
+	}
514
+
515
+	.uni-cropper-line-l::before {
516
+		content: '';
517
+		position: absolute;
518
+		top: 0;
519
+		left: 50%;
520
+		width: 41rpx;
521
+		-webkit-transform: translate3d(-50%, 0, 0);
522
+		transform: translate3d(-50%, 0, 0);
523
+		bottom: 0;
524
+		height: 100%;
525
+		background: transparent;
526
+		z-index: 11;
527
+	}
528
+
529
+	.uni-cropper-point {
530
+		width: 5rpx;
531
+		height: 5rpx;
532
+		background-color: #69f;
533
+		opacity: .75;
534
+		position: absolute;
535
+		z-index: 3;
536
+	}
537
+
538
+	.point-t {
539
+		top: -3rpx;
540
+		left: 50%;
541
+		margin-left: -3rpx;
542
+		cursor: n-resize;
543
+	}
544
+
545
+	.point-tr {
546
+		top: -3rpx;
547
+		left: 100%;
548
+		margin-left: -3rpx;
549
+		cursor: n-resize;
550
+	}
551
+
552
+	.point-r {
553
+		top: 50%;
554
+		left: 100%;
555
+		margin-left: -3rpx;
556
+		margin-top: -3rpx;
557
+		cursor: n-resize;
558
+	}
559
+
560
+	.point-rb {
561
+		left: 100%;
562
+		top: 100%;
563
+		-webkit-transform: translate3d(-50%, -50%, 0);
564
+		transform: translate3d(-50%, -50%, 0);
565
+		cursor: n-resize;
566
+		width: 36rpx;
567
+		height: 36rpx;
568
+		background-color: #69f;
569
+		position: absolute;
570
+		z-index: 1112;
571
+		opacity: 1;
572
+	}
573
+
574
+	.point-b {
575
+		left: 50%;
576
+		top: 100%;
577
+		margin-left: -3rpx;
578
+		margin-top: -3rpx;
579
+		cursor: n-resize;
580
+	}
581
+
582
+	.point-bl {
583
+		left: 0%;
584
+		top: 100%;
585
+		margin-left: -3rpx;
586
+		margin-top: -3rpx;
587
+		cursor: n-resize;
588
+	}
589
+
590
+	.point-l {
591
+		left: 0%;
592
+		top: 50%;
593
+		margin-left: -3rpx;
594
+		margin-top: -3rpx;
595
+		cursor: n-resize;
596
+	}
597
+
598
+	.point-lt {
599
+		left: 0%;
600
+		top: 0%;
601
+		margin-left: -3rpx;
602
+		margin-top: -3rpx;
603
+		cursor: n-resize;
604
+	}
605
+
606
+	/* 裁剪框预览内容 */
607
+	.uni-cropper-viewer {
608
+		position: relative;
609
+		width: 100%;
610
+		height: 100%;
611
+		overflow: hidden;
612
+	}
613
+
614
+	.uni-cropper-viewer image {
615
+		position: absolute;
616
+		z-index: 2;
617
+	}
618
+</style>

+ 112 - 0
src/pages/mine/help/index.vue

@@ -0,0 +1,112 @@
1
+<template>
2
+  <view class="help-container">
3
+    <view v-for="(item, findex) in list" :key="findex" :title="item.title" class="list-title">
4
+      <view class="text-title">
5
+        <view :class="item.icon"></view>{{ item.title }}
6
+      </view>
7
+      <view class="childList">
8
+        <view v-for="(child, zindex) in item.childList" :key="zindex" class="question" hover-class="hover"
9
+          @click="handleText(child)">
10
+          <view class="text-item">{{ child.title }}</view>
11
+          <view class="line" v-if="zindex !== item.childList.length - 1"></view>
12
+        </view>
13
+      </view>
14
+    </view>
15
+  </view>
16
+</template>
17
+
18
+<script>
19
+  export default {
20
+    data() {
21
+      return {
22
+        list: [{
23
+            icon: 'iconfont icon-github',
24
+            title: '若依问题',
25
+            childList: [{
26
+              title: '若依开源吗?',
27
+              content: '开源'
28
+            }, {
29
+              title: '若依可以商用吗?',
30
+              content: '可以'
31
+            }, {
32
+              title: '若依官网地址多少?',
33
+              content: 'http://ruoyi.vip'
34
+            }, {
35
+              title: '若依文档地址多少?',
36
+              content: 'http://doc.ruoyi.vip'
37
+            }]
38
+          },
39
+          {
40
+            icon: 'iconfont icon-help',
41
+            title: '其他问题',
42
+            childList: [{
43
+              title: '如何退出登录?',
44
+              content: '请点击[我的] - [应用设置] - [退出登录]即可退出登录',
45
+            }, {
46
+              title: '如何修改用户头像?',
47
+              content: '请点击[我的] - [选择头像] - [点击提交]即可更换用户头像',
48
+            }, {
49
+              title: '如何修改登录密码?',
50
+              content: '请点击[我的] - [应用设置] - [修改密码]即可修改登录密码',
51
+            }]
52
+          }
53
+        ]
54
+      }
55
+    },
56
+    methods: {
57
+      handleText(item) {
58
+        this.$tab.navigateTo(`/pages/common/textview/index?title=${item.title}&content=${item.content}`)
59
+      }
60
+    }
61
+  }
62
+</script>
63
+
64
+<style lang="scss" scoped>
65
+  page {
66
+    background-color: #f8f8f8;
67
+  }
68
+
69
+  .help-container {
70
+    margin-bottom: 100rpx;
71
+    padding: 30rpx;
72
+  }
73
+
74
+  .list-title {
75
+    margin-bottom: 30rpx;
76
+  }
77
+
78
+  .childList {
79
+    background: #ffffff;
80
+    box-shadow: 0px 0px 10rpx rgba(193, 193, 193, 0.2);
81
+    border-radius: 16rpx;
82
+    margin-top: 10rpx;
83
+  }
84
+
85
+  .line {
86
+    width: 100%;
87
+    height: 1rpx;
88
+    background-color: #F5F5F5;
89
+  }
90
+
91
+  .text-title {
92
+    color: #303133;
93
+    font-size: 32rpx;
94
+    font-weight: bold;
95
+    margin-left: 10rpx;
96
+
97
+    .iconfont {
98
+      font-size: 16px;
99
+      margin-right: 10rpx;
100
+    }
101
+  }
102
+
103
+  .text-item {
104
+    font-size: 28rpx;
105
+    padding: 24rpx;
106
+  }
107
+
108
+  .question {
109
+    color: #606266;
110
+    font-size: 28rpx;
111
+  }
112
+</style>

+ 194 - 0
src/pages/mine/index.vue

@@ -0,0 +1,194 @@
1
+<template>
2
+  <view class="mine-container" :style="{height: `${windowHeight}px`}">
3
+    <!--顶部个人信息栏-->
4
+    <view class="header-section">
5
+      <view class="flex padding justify-between">
6
+        <view class="flex align-center">
7
+          <view v-if="!avatar" class="cu-avatar xl round bg-white">
8
+            <view class="iconfont icon-people text-gray icon"></view>
9
+          </view>
10
+          <image v-if="avatar" @click="handleToAvatar" :src="avatar" class="cu-avatar xl round" mode="widthFix">
11
+          </image>
12
+          <view v-if="!name" @click="handleToLogin" class="login-tip">
13
+            点击登录
14
+          </view>
15
+          <view v-if="name" @click="handleToInfo" class="user-info">
16
+            <view class="u_title">
17
+              用户名:{{ name }}
18
+            </view>
19
+          </view>
20
+        </view>
21
+        <view @click="handleToInfo" class="flex align-center">
22
+          <text>个人信息</text>
23
+          <view class="iconfont icon-right"></view>
24
+        </view>
25
+      </view>
26
+    </view>
27
+
28
+    <view class="content-section">
29
+      <view class="mine-actions grid col-4 text-center">
30
+        <view class="action-item" @click="handleJiaoLiuQun">
31
+          <view class="iconfont icon-friendfill text-pink icon"></view>
32
+          <text class="text">交流群</text>
33
+        </view>
34
+        <view class="action-item" @click="handleBuilding">
35
+          <view class="iconfont icon-service text-blue icon"></view>
36
+          <text class="text">在线客服</text>
37
+        </view>
38
+        <view class="action-item" @click="handleBuilding">
39
+          <view class="iconfont icon-community text-mauve icon"></view>
40
+          <text class="text">反馈社区</text>
41
+        </view>
42
+        <view class="action-item" @click="handleBuilding">
43
+          <view class="iconfont icon-dianzan text-green icon"></view>
44
+          <text class="text">点赞我们</text>
45
+        </view>
46
+      </view>
47
+
48
+      <view class="menu-list">
49
+        <view class="list-cell list-cell-arrow" @click="handleToEditInfo">
50
+          <view class="menu-item-box">
51
+            <view class="iconfont icon-user menu-icon"></view>
52
+            <view>编辑资料</view>
53
+          </view>
54
+        </view>
55
+        <view class="list-cell list-cell-arrow" @click="handleHelp">
56
+          <view class="menu-item-box">
57
+            <view class="iconfont icon-help menu-icon"></view>
58
+            <view>常见问题</view>
59
+          </view>
60
+        </view>
61
+        <view class="list-cell list-cell-arrow" @click="handleAbout">
62
+          <view class="menu-item-box">
63
+            <view class="iconfont icon-aixin menu-icon"></view>
64
+            <view>关于我们</view>
65
+          </view>
66
+        </view>
67
+        <view class="list-cell list-cell-arrow" @click="handleToSetting">
68
+          <view class="menu-item-box">
69
+            <view class="iconfont icon-setting menu-icon"></view>
70
+            <view>应用设置</view>
71
+          </view>
72
+        </view>
73
+      </view>
74
+
75
+    </view>
76
+  </view>
77
+</template>
78
+
79
+<script>
80
+  export default {
81
+    data() {
82
+      return {}
83
+    },
84
+    computed: {
85
+      avatar() {
86
+        return this.$store.state.user.avatar
87
+      },
88
+      windowHeight() {
89
+        return uni.getSystemInfoSync().windowHeight - 50
90
+      },
91
+      name () {
92
+        if (this.$store.state.user) {
93
+          if (this.$store.state.user.userInfo && this.$store.state.user.userInfo.nickName) {
94
+            return this.$store.state.user.userInfo.nickName
95
+          }
96
+        }
97
+        return this.$store.state.user.name
98
+      }
99
+    },
100
+    methods: {
101
+      handleToInfo() {
102
+        this.$tab.navigateTo('/pages/mine/info/index')
103
+      },
104
+      handleToEditInfo() {
105
+        this.$tab.navigateTo('/pages/mine/info/edit')
106
+      },
107
+      handleToSetting() {
108
+        this.$tab.navigateTo('/pages/mine/setting/index')
109
+      },
110
+      handleToLogin() {
111
+        this.$tab.reLaunch('/pages/login')
112
+      },
113
+      handleToAvatar() {
114
+        this.$tab.navigateTo('/pages/mine/avatar/index')
115
+      },
116
+      handleHelp() {
117
+        this.$tab.navigateTo('/pages/mine/help/index')
118
+      },
119
+      handleAbout() {
120
+        this.$tab.navigateTo('/pages/mine/about/index')
121
+      },
122
+      handleJiaoLiuQun() {
123
+        this.$modal.showToast('QQ群:①133713780(满)、②146013835(满)、③189091635')
124
+      },
125
+      handleBuilding() {
126
+        this.$modal.showToast('模块建设中~')
127
+      }
128
+    }
129
+  }
130
+</script>
131
+
132
+<style lang="scss" scoped>
133
+  page {
134
+    background-color: #f5f6f7;
135
+  }
136
+
137
+  .mine-container {
138
+    width: 100%;
139
+    height: 100%;
140
+
141
+
142
+    .header-section {
143
+      padding: 15px 15px 45px 15px;
144
+      background-color: #3c96f3;
145
+      color: white;
146
+
147
+      .login-tip {
148
+        font-size: 18px;
149
+        margin-left: 10px;
150
+      }
151
+
152
+      .cu-avatar {
153
+        border: 2px solid #eaeaea;
154
+
155
+        .icon {
156
+          font-size: 40px;
157
+        }
158
+      }
159
+
160
+      .user-info {
161
+        margin-left: 15px;
162
+
163
+        .u_title {
164
+          font-size: 18px;
165
+          line-height: 30px;
166
+        }
167
+      }
168
+    }
169
+
170
+    .content-section {
171
+      position: relative;
172
+      top: -50px;
173
+
174
+      .mine-actions {
175
+        margin: 15px 15px;
176
+        padding: 20px 0px;
177
+        border-radius: 8px;
178
+        background-color: white;
179
+
180
+        .action-item {
181
+          .icon {
182
+            font-size: 28px;
183
+          }
184
+
185
+          .text {
186
+            display: block;
187
+            font-size: 13px;
188
+            margin: 8px 0px;
189
+          }
190
+        }
191
+      }
192
+    }
193
+  }
194
+</style>

+ 127 - 0
src/pages/mine/info/edit.vue

@@ -0,0 +1,127 @@
1
+<template>
2
+  <view class="container">
3
+    <view class="example">
4
+      <uni-forms ref="form" :model="user" labelWidth="80px">
5
+        <uni-forms-item label="用户昵称" name="nickName">
6
+          <uni-easyinput v-model="user.nickName" placeholder="请输入昵称" />
7
+        </uni-forms-item>
8
+        <uni-forms-item label="手机号码" name="phonenumber">
9
+          <uni-easyinput v-model="user.phonenumber" placeholder="请输入手机号码" />
10
+        </uni-forms-item>
11
+        <uni-forms-item label="邮箱" name="email">
12
+          <uni-easyinput v-model="user.email" placeholder="请输入邮箱" />
13
+        </uni-forms-item>
14
+        <uni-forms-item label="性别" name="sex" required>
15
+          <uni-data-checkbox v-model="user.sex" :localdata="sexs" />
16
+        </uni-forms-item>
17
+      </uni-forms>
18
+      <button type="primary" @click="submit">提交</button>
19
+    </view>
20
+  </view>
21
+</template>
22
+
23
+<script>
24
+  import { getUserProfile } from "@/api/system/user"
25
+  import { updateUserProfile } from "@/api/system/user"
26
+
27
+  export default {
28
+    data() {
29
+      return {
30
+        user: {
31
+          nickName: "",
32
+          phonenumber: "",
33
+          email: "",
34
+          sex: ""
35
+        },
36
+        sexs: [{
37
+          text: '男',
38
+          value: "0"
39
+        }, {
40
+          text: '女',
41
+          value: "1"
42
+        }],
43
+        rules: {
44
+          nickName: {
45
+            rules: [{
46
+              required: true,
47
+              errorMessage: '用户昵称不能为空'
48
+            }]
49
+          },
50
+          phonenumber: {
51
+            rules: [{
52
+              required: true,
53
+              errorMessage: '手机号码不能为空'
54
+            }, {
55
+              pattern: /^1[3|4|5|6|7|8|9][0-9]\d{8}$/,
56
+              errorMessage: '请输入正确的手机号码'
57
+            }]
58
+          },
59
+          email: {
60
+            rules: [{
61
+              required: true,
62
+              errorMessage: '邮箱地址不能为空'
63
+            }, {
64
+              format: 'email',
65
+              errorMessage: '请输入正确的邮箱地址'
66
+            }]
67
+          }
68
+        }
69
+      }
70
+    },
71
+    onLoad() {
72
+      this.getUser()
73
+    },
74
+    onReady() {
75
+      this.$refs.form.setRules(this.rules)
76
+    },
77
+    methods: {
78
+      getUser() {
79
+        getUserProfile().then(response => {
80
+          this.user = response.data
81
+        })
82
+      },
83
+      submit(ref) {
84
+        this.$refs.form.validate().then(res => {
85
+          updateUserProfile(this.user).then(response => {
86
+            this.$modal.msgSuccess("修改成功")
87
+          })
88
+        })
89
+      }
90
+    }
91
+  }
92
+</script>
93
+
94
+<style lang="scss" scoped>
95
+  page {
96
+    background-color: #ffffff;
97
+  }
98
+
99
+  .example {
100
+    padding: 15px;
101
+    background-color: #fff;
102
+  }
103
+
104
+  .segmented-control {
105
+    margin-bottom: 15px;
106
+  }
107
+
108
+  .button-group {
109
+    margin-top: 15px;
110
+    display: flex;
111
+    justify-content: space-around;
112
+  }
113
+
114
+  .form-item {
115
+    display: flex;
116
+    align-items: center;
117
+    flex: 1;
118
+  }
119
+
120
+  .button {
121
+    display: flex;
122
+    align-items: center;
123
+    height: 35px;
124
+    line-height: 35px;
125
+    margin-left: 10px;
126
+  }
127
+</style>

+ 44 - 0
src/pages/mine/info/index.vue

@@ -0,0 +1,44 @@
1
+<template>
2
+  <view class="container">
3
+    <uni-list>
4
+      <uni-list-item showExtraIcon="true" :extraIcon="{type: 'person-filled'}" title="昵称" :rightText="user.nickName" />
5
+      <uni-list-item showExtraIcon="true" :extraIcon="{type: 'phone-filled'}" title="手机号码" :rightText="user.phonenumber" />
6
+      <uni-list-item showExtraIcon="true" :extraIcon="{type: 'email-filled'}" title="邮箱" :rightText="user.email" />
7
+      <uni-list-item showExtraIcon="true" :extraIcon="{type: 'auth-filled'}" title="岗位" :rightText="postGroup" />
8
+      <uni-list-item showExtraIcon="true" :extraIcon="{type: 'staff-filled'}" title="角色" :rightText="roleGroup" />
9
+      <uni-list-item showExtraIcon="true" :extraIcon="{type: 'calendar-filled'}" title="创建日期" :rightText="user.createTime" />
10
+    </uni-list>
11
+  </view>
12
+</template>
13
+
14
+<script>
15
+  import { getUserProfile } from "@/api/system/user"
16
+
17
+  export default {
18
+    data() {
19
+      return {
20
+        user: {},
21
+        roleGroup: "",
22
+        postGroup: ""
23
+      }
24
+    },
25
+    onLoad() {
26
+      this.getUser()
27
+    },
28
+    methods: {
29
+      getUser() {
30
+        getUserProfile().then(response => {
31
+          this.user = response.data
32
+          this.roleGroup = response.roleGroup
33
+          this.postGroup = response.postGroup
34
+        })
35
+      }
36
+    }
37
+  }
38
+</script>
39
+
40
+<style lang="scss">
41
+  page {
42
+    background-color: #ffffff;
43
+  }
44
+</style>

+ 85 - 0
src/pages/mine/pwd/index.vue

@@ -0,0 +1,85 @@
1
+<template>
2
+  <view class="pwd-retrieve-container">
3
+    <uni-forms ref="form" :value="user" labelWidth="80px">
4
+      <uni-forms-item name="oldPassword" label="旧密码">
5
+        <uni-easyinput type="password" v-model="user.oldPassword" placeholder="请输入旧密码" />
6
+      </uni-forms-item>
7
+      <uni-forms-item name="newPassword" label="新密码">
8
+        <uni-easyinput type="password" v-model="user.newPassword" placeholder="请输入新密码" />
9
+      </uni-forms-item>
10
+      <uni-forms-item name="confirmPassword" label="确认密码">
11
+        <uni-easyinput type="password" v-model="user.confirmPassword" placeholder="请确认新密码" />
12
+      </uni-forms-item>
13
+      <button type="primary" @click="submit">提交</button>
14
+    </uni-forms>
15
+  </view>
16
+</template>
17
+
18
+<script>
19
+  import { updateUserPwd } from "@/api/system/user"
20
+
21
+  export default {
22
+    data() {
23
+      return {
24
+        user: {
25
+          oldPassword: undefined,
26
+          newPassword: undefined,
27
+          confirmPassword: undefined
28
+        },
29
+        rules: {
30
+          oldPassword: {
31
+            rules: [{
32
+              required: true,
33
+              errorMessage: '旧密码不能为空'
34
+            }]
35
+          },
36
+          newPassword: {
37
+            rules: [{
38
+                required: true,
39
+                errorMessage: '新密码不能为空',
40
+              },
41
+              {
42
+                minLength: 6,
43
+                maxLength: 20,
44
+                errorMessage: '长度在 6 到 20 个字符'
45
+              }
46
+            ]
47
+          },
48
+          confirmPassword: {
49
+            rules: [{
50
+                required: true,
51
+                errorMessage: '确认密码不能为空'
52
+              }, {
53
+                validateFunction: (rule, value, data) => data.newPassword === value,
54
+                errorMessage: '两次输入的密码不一致'
55
+              }
56
+            ]
57
+          }
58
+        }
59
+      }
60
+    },
61
+    onReady() {
62
+      this.$refs.form.setRules(this.rules)
63
+    },
64
+    methods: {
65
+      submit() {
66
+        this.$refs.form.validate().then(res => {
67
+          updateUserPwd(this.user.oldPassword, this.user.newPassword).then(response => {
68
+            this.$modal.msgSuccess("修改成功")
69
+          })
70
+        })
71
+      }
72
+    }
73
+  }
74
+</script>
75
+
76
+<style lang="scss" scoped>
77
+  page {
78
+    background-color: #ffffff;
79
+  }
80
+
81
+  .pwd-retrieve-container {
82
+    padding-top: 36rpx;
83
+    padding: 15px;
84
+  }
85
+</style>

+ 78 - 0
src/pages/mine/setting/index.vue

@@ -0,0 +1,78 @@
1
+<template>
2
+  <view class="setting-container" :style="{height: `${windowHeight}px`}">
3
+    <view class="menu-list">
4
+      <view class="list-cell list-cell-arrow" @click="handleToPwd">
5
+        <view class="menu-item-box">
6
+          <view class="iconfont icon-password menu-icon"></view>
7
+          <view>修改密码</view>
8
+        </view>
9
+      </view>
10
+      <view class="list-cell list-cell-arrow" @click="handleToUpgrade">
11
+        <view class="menu-item-box">
12
+          <view class="iconfont icon-refresh menu-icon"></view>
13
+          <view>检查更新</view>
14
+        </view>
15
+      </view>
16
+      <view class="list-cell list-cell-arrow" @click="handleCleanTmp">
17
+        <view class="menu-item-box">
18
+          <view class="iconfont icon-clean menu-icon"></view>
19
+          <view>清理缓存</view>
20
+        </view>
21
+      </view>
22
+    </view>
23
+    <view class="cu-list menu">
24
+      <view class="cu-item item-box">
25
+        <view class="content text-center" @click="handleLogout">
26
+          <text class="text-black">退出登录</text>
27
+        </view>
28
+      </view>
29
+    </view>
30
+  </view>
31
+</template>
32
+
33
+<script>
34
+  export default {
35
+    data() {
36
+      return {
37
+        windowHeight: uni.getSystemInfoSync().windowHeight
38
+      }
39
+    },
40
+    methods: {
41
+      handleToPwd() {
42
+        this.$tab.navigateTo('/pages/mine/pwd/index')
43
+      },
44
+      handleToUpgrade() {
45
+        this.$modal.showToast('模块建设中~')
46
+      },
47
+      handleCleanTmp() {
48
+        this.$modal.showToast('模块建设中~')
49
+      },
50
+      handleLogout() {
51
+        this.$modal.confirm('确定注销并退出系统吗?').then(() => {
52
+          this.$store.dispatch('LogOut').then(() => {}).finally(()=>{
53
+            this.$tab.reLaunch('/pages/home/index')
54
+          })
55
+        })
56
+      }
57
+    }
58
+  }
59
+</script>
60
+
61
+<style lang="scss" scoped>
62
+  .page {
63
+    background-color: #f8f8f8;
64
+  }
65
+
66
+  .item-box {
67
+    background-color: #FFFFFF;
68
+    margin: 30rpx;
69
+    display: flex;
70
+    flex-direction: row;
71
+    justify-content: center;
72
+    align-items: center;
73
+    padding: 10rpx;
74
+    border-radius: 8rpx;
75
+    color: #303133;
76
+    font-size: 32rpx;
77
+  }
78
+</style>

+ 634 - 0
src/pages/myToDoList/index.vue

@@ -0,0 +1,634 @@
1
+<template>
2
+    <home-container :customStyle="{ background: 'none' }" :customHomeStyle="{ overflow: 'hidden' }">
3
+        <!-- <view class="mytodo-container"> -->
4
+        <!-- <u-search v-model="keyword" placeholder="搜索任务" @search="handleSearch" @custom="handleSearch"
5
+                @clear="handleClear" /> -->
6
+        <h-tabs :tabs="tabList" v-model="currentTab" @change="handleTabChange" />
7
+        <view class="mytodo-list">
8
+            <scroll-view scroll-y="true" @refresherrefresh="onRefresh" refresher-enabled
9
+                :refresher-triggered="refresherTriggered" @scrolltolower="loadMore" :style="{ height: '100%' }"
10
+                enable-back-to-top="true">
11
+                <u-checkbox-group class="checkbox-group">
12
+                    <list-card v-for="item in filteredList" :key="item.id" :showChecked="true"
13
+                        @click="navigateToDetail(item)">
14
+                        <template #checkbox
15
+                            v-if="item.instance && item.instance.businessType === 'SEIZURE_REPORT' && currentTab === 'todo'">
16
+                            <u-checkbox style="margin-top: 6rpx;" :checked="item.checked" :key="item.id"
17
+                                @change="itemChange(item)" />
18
+                        </template>
19
+                        <template #title>
20
+                            <view v-if="item.instance && item.instance.businessType === 'SEIZURE_REPORT'"
21
+                                class="list-title">
22
+                                {{ getFormData(item, 'forbiddenTypeText') }}
23
+                                <view v-if="item.isRead === '0'" class="unread-dot"></view>
24
+                            </view>
25
+                            <view v-else class="list-title">
26
+                                {{ `${getFormData(item, 'taskName')}-${item.taskName || item.title}` }}
27
+                                <view v-if="item.isRead === '0'" class="unread-dot"></view>
28
+                            </view>
29
+                        </template>
30
+                        <template #icon>
31
+                            <list-icon
32
+                                v-if="item.instance && item.instance.businessType === 'SEIZURE_REPORT' && getFormData(item, 'handlingMethod') === 'TRANSFER_TO_AIRPORT_POLICE'"
33
+                                :divStyle="{ backgroundColor: '#FEF8F8', color: '#F55B5D', border: '2rpx dashed #F55B5D' }"
34
+                                :text="'移交公安'" />
35
+                        </template>
36
+                        <template v-if="item.instance && item.instance.businessType === 'SEIZURE_REPORT'">
37
+                            <view class="list-row">
38
+                                <view class="list-label">位置:</view>
39
+                                <view class="list-value">{{ getFormData(item, 'channelName') }}</view>
40
+                            </view>
41
+                            <view class="list-row" style="height: auto;align-items: flex-start;">
42
+                                <view class="list-label">查获人:</view>
43
+                                <view class="list-value list-value-clamp-2">{{ `${getFormData(item, 'reportTeamText')} /
44
+                                    ${getFormData(item, 'inspectUserName')}` }}</view>
45
+                            </view>
46
+                            <view class="list-row">
47
+                                <view class="list-label">查获时间:</view>
48
+                                <view class="list-value">{{ getFormData(item, 'seizureTime') }}</view>
49
+                            </view>
50
+                        </template>
51
+                        <template v-else>
52
+                            <view class="list-row" v-if="item.instance &&
53
+                                item.instance.businessType">
54
+                                <view class="list-label">{{ `被检查${getLabel(item.instance &&
55
+                                    item.instance.businessType)}:` }}</view>
56
+                                <view class="list-value">{{ getChecked(item) }}</view>
57
+                            </view>
58
+                            <view class="list-row">
59
+                                <view class="list-label">整改要求:</view>
60
+                                <!-- <view class="list-value list-value-clamp-1">{{ getFormData(item, 'rectificationSuggestions') }}</view> -->
61
+                            </view>
62
+                            <view class="list-row" style="height: auto;line-height: normal !important;">
63
+                                <view class="list-value-truncate">{{ getFormData(item, 'rectificationSuggestions') }}
64
+                                </view>
65
+                            </view>
66
+                            <view class="list-row">
67
+                                <view class="list-label">截止时间:</view>
68
+                                <view class="list-value">{{ getFormData(item, 'rectificationDeadline') }}</view>
69
+                            </view>
70
+                        </template>
71
+                        <template #footer>
72
+                            <div
73
+                                :class="['card-tag', item.instance && item.instance.businessType === 'SEIZURE_REPORT' ? 'seizure-tag' : 'rectification-tag']">
74
+                                {{ item.instance && item.instance.businessType === 'SEIZURE_REPORT' ? '查获' : '整改' }}
75
+                            </div>
76
+                        </template>
77
+
78
+
79
+                    </list-card>
80
+                </u-checkbox-group>
81
+
82
+                <!-- 加载状态提示 -->
83
+                <view v-if="loading" class="load-more">
84
+                    <text>加载中...</text>
85
+                </view>
86
+                <view v-else-if="!hasMore && list.length > 0" class="no-more">
87
+                    <text>没有更多数据了</text>
88
+                </view>
89
+                <view v-else-if="list.length === 0" class="no-data">
90
+                    <text>暂无数据</text>
91
+                </view>
92
+            </scroll-view>
93
+        </view>
94
+        <view class="fixed-actions" v-if="currentTab === 'todo'">
95
+            <u-checkbox-group>
96
+                <u-checkbox :value="'all'" class="select-all" @change="handleSelectAll">全选</u-checkbox>
97
+            </u-checkbox-group>
98
+            <view class="batch-btn-container">
99
+                <view class="custom-btn-white batch-btn" :disabled="selectedItems.length === 0" @click="batchReject">
100
+                    批量驳回</view>
101
+                <view class="custom-btn-normal batch-btn" :disabled="selectedItems.length === 0" @click="batchApprove">
102
+                    批量通过
103
+                </view>
104
+            </view>
105
+        </view>
106
+        <!-- </view> -->
107
+    </home-container>
108
+</template>
109
+
110
+<script>
111
+import HomeContainer from "@/components/HomeContainer.vue";
112
+import HTabs from "@/components/h-tabs/h-tabs.vue";
113
+import { listCheckApprovalCcDetails, listCheckPendingTasks, listCheckFinishedTasks, updateCheckApprovalCcDetails } from "@/api/myToDoList/myToDoList.js"
114
+import { approvePassBatch, approveRejectBatch } from "@/api/approve/approve.js";
115
+import { showMessageTabRedDot } from "@/utils/common.js"
116
+export default {
117
+    components: { HomeContainer, HTabs },
118
+    data() {
119
+        return {
120
+            keyword: '',
121
+            filteredList: [],
122
+            selectedItems: [],
123
+            currentTab: 'todo',
124
+            list: [],
125
+            // 分页参数
126
+            pageNum: 1,
127
+            pageSize: 10,
128
+            total: 0,
129
+            loading: false,
130
+            hasMore: true,
131
+            // scroll-view下拉刷新状态控制
132
+            refresherTriggered: false,
133
+            // 待办任务未读数量
134
+            todoUnreadCount: 0,
135
+            msgUnreadCount: 0
136
+        }
137
+    },
138
+    computed: {
139
+        tabList() {
140
+            return [
141
+                {
142
+                    type: 'msg',
143
+                    title: '消息',
144
+                    unreadCount: this.msgUnreadCount
145
+                },
146
+                {
147
+                    type: 'todo',
148
+                    title: '待办',
149
+                    unreadCount: this.todoUnreadCount
150
+                },
151
+                {
152
+                    type: 'done',
153
+                    title: '已办'
154
+                }
155
+            ];
156
+        }
157
+    },
158
+    watch: {
159
+
160
+    },
161
+    methods: {
162
+        async getUnreadCount() {
163
+            try {
164
+                const query = {
165
+                    pageNum: this.pageNum,
166
+                    pageSize: this.pageSize
167
+                };
168
+
169
+                // 添加搜索关键词
170
+                if (this.keyword) {
171
+                    query.keyword = this.keyword;
172
+                }
173
+                let response = await listCheckPendingTasks(query);
174
+                this.todoUnreadCount = response.total;
175
+                let response1 = await listCheckApprovalCcDetails(query);
176
+
177
+                this.msgUnreadCount = response1.rows.filter(item => item.isRead == '0').length;
178
+            } catch (error) {
179
+                console.error('获取待办任务未读数量失败:', error);
180
+            }
181
+        },
182
+        getFormData(row, key) {
183
+            if (row.formData) {
184
+                const formData = JSON.parse(row.formData);
185
+                const value = formData[key] || '';
186
+
187
+                // 如果是时间戳字段且值为数字,进行格式化
188
+                if ((key.includes('Time') || key.includes('Deadline')) && typeof value === 'number' && value > 0) {
189
+                    return this.formatTimestamp(value);
190
+                }
191
+
192
+                return value;
193
+            }
194
+        },
195
+
196
+        // 格式化时间戳为年月日时分秒
197
+        formatTimestamp(timestamp) {
198
+            const date = new Date(timestamp);
199
+            const year = date.getFullYear();
200
+            const month = String(date.getMonth() + 1).padStart(2, '0');
201
+            const day = String(date.getDate()).padStart(2, '0');
202
+
203
+            return `${year}-${month}-${day}`;
204
+        },
205
+        getChecked(item) {
206
+            if (this.getLabel(item.instance && item.instance.businessType) == '科室') {
207
+                return this.getFormData(item, 'checkedDepartmentName');
208
+            }
209
+            if (this.getLabel(item.instance && item.instance.businessType) == '班组') {
210
+                return this.getFormData(item, 'checkedTeamName');
211
+            }
212
+            if (this.getLabel(item.instance && item.instance.businessType) == '人') {
213
+                return this.getFormData(item, 'checkedPersonnelName');
214
+            }
215
+            return '';
216
+        },
217
+        getLabel(key) {
218
+            if (key === 'PERSONAL_CHECK') {
219
+                return '人'
220
+            }
221
+            if (key === 'GROUP_CHECK') {
222
+                return '班组'
223
+            }
224
+            if (key === 'SECTION_CHECK') {
225
+                return '科室'
226
+            }
227
+            return ''
228
+        },
229
+        navigateToDetail(row) {
230
+            let type = 'view'; // 默认查看模式
231
+            if (this.currentTab == 'msg') {
232
+                const { businessType, businessId, instanceId } = row;
233
+                let obj = {
234
+                    instanceId,
235
+                    businessId,
236
+                    businessType,
237
+                    id,
238
+                    nodeCode,
239
+                    type
240
+                }
241
+                updateCheckApprovalCcDetails([row.ccId]
242
+                ).then(res => {
243
+
244
+                })
245
+                uni.navigateTo({
246
+                    url: `/pages/problemRect/index?params=${encodeURIComponent(JSON.stringify(obj))}`
247
+                });
248
+                return;
249
+            }
250
+            const { instance, id, nodeDefinition } = row;
251
+            const { businessId = "", businessType = "", id: instanceId } = instance || {};
252
+            const { nodeCode = "" } = nodeDefinition || {};
253
+            let url = '';
254
+
255
+
256
+            if (this.currentTab === 'todo') {
257
+                type = 'approve'; // 待办时是审批模式
258
+            }
259
+
260
+            let obj = {
261
+                instanceId,
262
+                businessId,
263
+                businessType,
264
+                id,
265
+                nodeCode,
266
+                type // 添加type参数
267
+            }
268
+
269
+            //查获
270
+            if (businessType === 'SEIZURE_REPORT') {
271
+                url = `/pages/seizedReported/index?params=${encodeURIComponent(JSON.stringify(obj))}`;
272
+            }
273
+            // 巡检对应个人 班组  科室
274
+            if (['PERSONAL_CHECK', 'GROUP_CHECK', 'SECTION_CHECK'].includes(businessType) || this.currentTab == 'msg') {
275
+                url = `/pages/problemRect/index?params=${encodeURIComponent(JSON.stringify(obj))}`;
276
+            }
277
+            if (url) {
278
+                uni.navigateTo({
279
+                    url: url
280
+                });
281
+            }
282
+        },
283
+        itemChange(item) {
284
+            item.checked = !item.checked
285
+            if (item.checked) {
286
+                this.selectedItems.push(item);
287
+            } else {
288
+                this.selectedItems = this.selectedItems.filter(selectedItem => selectedItem.id !== item.id);
289
+            }
290
+        },
291
+        handleSearch(val) {
292
+            this.pageNum = 1;
293
+            this.keyword = val || '';
294
+            this.loadData();
295
+        },
296
+        handleClear() {
297
+            this.pageNum = 1;
298
+            this.keyword = '';
299
+            this.loadData();
300
+        },
301
+        handleTabChange(tabValue, tabItem) {
302
+            this.pageNum = 1;
303
+            this.currentTab = tabValue;
304
+            this.loadData();
305
+        },
306
+        handleSelectAll(e) {
307
+            console.log(e, "e")
308
+            this.$nextTick(() => {
309
+                this.filteredList = this.filteredList.map(item => ({
310
+                    ...item,
311
+                    //只有查获可以批量审批
312
+                    ...(item.instance && item.instance.businessType === 'SEIZURE_REPORT' ? { checked: e } : {}),
313
+                }));
314
+                this.selectedItems = this.filteredList.filter(item => item.checked);
315
+            })
316
+        },
317
+        async batchReject() {
318
+            // 批量驳回逻辑
319
+            if (this.selectedItems.length === 0) {
320
+                uni.showToast({
321
+                    title: '请选择要驳回的任务',
322
+                    icon: 'none'
323
+                });
324
+                return;
325
+            }
326
+
327
+            try {
328
+                // 提取选中项的任务ID数组
329
+                const taskIds = this.selectedItems.map(item => item.id);
330
+
331
+                // 调用批量驳回接口
332
+                await approveRejectBatch(taskIds);
333
+
334
+                uni.showToast({
335
+                    title: '批量驳回成功',
336
+                    icon: 'success'
337
+                });
338
+
339
+                // 清空选中项并刷新数据
340
+                this.selectedItems = [];
341
+                this.filteredList = this.filteredList.map(item => ({
342
+                    ...item,
343
+                    checked: false
344
+                }));
345
+                this.loadData();
346
+                // 刷新未读消息数量
347
+                this.getUnreadCount();
348
+            } catch (error) {
349
+                console.error('批量驳回失败:', error);
350
+                uni.showToast({
351
+                    title: '批量驳回失败',
352
+                    icon: 'none'
353
+                });
354
+            }
355
+        },
356
+        async batchApprove() {
357
+            // 批量通过逻辑
358
+            if (this.selectedItems.length === 0) {
359
+                uni.showToast({
360
+                    title: '请选择要通过的任务',
361
+                    icon: 'none'
362
+                });
363
+                return;
364
+            }
365
+
366
+            try {
367
+                // 提取选中项的任务ID数组
368
+                const taskIds = this.selectedItems.map(item => item.id);
369
+
370
+                // 调用批量通过接口
371
+                await approvePassBatch(taskIds);
372
+
373
+                uni.showToast({
374
+                    title: '批量通过成功',
375
+                    icon: 'success'
376
+                });
377
+
378
+                // 清空选中项并刷新数据
379
+                this.selectedItems = [];
380
+                this.filteredList = this.filteredList.map(item => ({
381
+                    ...item,
382
+                    checked: false
383
+                }));
384
+                this.loadData();
385
+                // 刷新未读消息数量
386
+                this.getUnreadCount();
387
+            } catch (error) {
388
+                console.error('批量通过失败:', error);
389
+                uni.showToast({
390
+                    title: '批量通过失败',
391
+                    icon: 'none'
392
+                });
393
+            }
394
+        },
395
+        onRefresh() {
396
+            this.pageNum = 1;
397
+            this.refresherTriggered = true;
398
+            this.loadData()
399
+        },
400
+        // 加载数据方法
401
+        async loadData() {
402
+            if (this.loading) return;
403
+
404
+            this.loading = true;
405
+            try {
406
+                const query = {
407
+                    pageNum: this.pageNum,
408
+                    pageSize: this.pageSize
409
+                };
410
+
411
+                // 添加搜索关键词
412
+                if (this.keyword) {
413
+                    query.keyword = this.keyword;
414
+                }
415
+
416
+                let response;
417
+                // 根据当前tab调用不同的API
418
+                if (this.currentTab === 'msg') {
419
+                    response = await listCheckApprovalCcDetails(query);
420
+                } else if (this.currentTab === 'todo') {
421
+                    response = await listCheckPendingTasks(query);
422
+                } else if (this.currentTab === 'done') {
423
+                    response = await listCheckFinishedTasks(query);
424
+                }
425
+
426
+                // 处理响应数据
427
+                const data = response.rows || response.list || [];
428
+
429
+                if (this.pageNum === 1) {
430
+                    this.list = data;
431
+                } else {
432
+                    this.list = [...this.list, ...data];
433
+                }
434
+
435
+                this.total = response.total || 0;
436
+                this.hasMore = this.list.length < this.total;
437
+
438
+                // 更新过滤列表
439
+                this.filteredList = [...this.list];
440
+
441
+            } catch (error) {
442
+                console.error('加载数据失败:', error);
443
+                uni.showToast({
444
+                    title: '加载失败',
445
+                    icon: 'none'
446
+                });
447
+            } finally {
448
+                this.loading = false;
449
+                // 重置下拉刷新状态
450
+                this.refresherTriggered = false;
451
+                uni.stopPullDownRefresh();
452
+            }
453
+        },
454
+        // 加载更多数据
455
+        loadMore() {
456
+            if (this.loading || !this.hasMore) return;
457
+            this.pageNum++;
458
+            this.loadData();
459
+        }
460
+    },
461
+    mounted() {
462
+        this.loadData();
463
+    },
464
+    onShow() {
465
+        // 页面再次展示时刷新数据
466
+        this.pageNum = 1;
467
+        this.selectedItems = [];
468
+        this.loadData();
469
+        this.getUnreadCount();
470
+        showMessageTabRedDot();
471
+    }
472
+}
473
+</script>
474
+
475
+<style lang="scss" scoped>
476
+// .mytodo-container {
477
+//     min-height: 100vh;
478
+//     background: none !important;
479
+//     overflow: hidden;
480
+
481
+
482
+.mytodo-list {
483
+    height: calc(100vh - 207rpx);
484
+    // overflow: hidden; /* 防止与scroll-view的滚动冲突 */
485
+    margin-bottom: 100rpx;
486
+
487
+    .checkbox-group {
488
+        display: flex;
489
+        flex-direction: column;
490
+    }
491
+
492
+    .inspection-item {
493
+        display: flex;
494
+        flex-flow: column;
495
+        justify-content: space-between;
496
+        padding: 40rpx;
497
+        background: #FFFFFF;
498
+        box-shadow: 0 8rpx 20rpx 0 rgba(0, 0, 0, 0.08);
499
+        border-radius: 16rpx;
500
+        border: 2rpx solid #F0F8FF;
501
+        margin-top: 32rpx;
502
+    }
503
+
504
+    .inspection-item-title {
505
+        font-size: 32rpx;
506
+        color: #333333;
507
+        line-height: 38rpx;
508
+    }
509
+
510
+    .inspection-item-desc {
511
+        margin: 20rpx 0 0;
512
+        font-size: 28rpx;
513
+        color: #666666;
514
+    }
515
+
516
+    /* 加载状态样式 */
517
+    .load-more,
518
+    .no-more,
519
+    .no-data {
520
+        display: flex;
521
+        justify-content: center;
522
+        align-items: center;
523
+        padding: 40rpx;
524
+        font-size: 28rpx;
525
+        color: #999;
526
+    }
527
+
528
+    /* 未读红点样式 */
529
+    .list-title {
530
+        position: relative;
531
+    }
532
+
533
+    .unread-dot {
534
+        position: absolute;
535
+        top: -4rpx;
536
+        right: -20rpx;
537
+        width: 16rpx;
538
+        height: 16rpx;
539
+        background-color: #FF4D4F;
540
+        border-radius: 50%;
541
+    }
542
+
543
+    /* list-row和list-value样式 */
544
+    .list-row {
545
+
546
+
547
+
548
+        .list-value-clamp-1 {
549
+
550
+            word-break: break-all;
551
+            /* 文字截断样式 - 超过一行显示省略号 */
552
+            display: -webkit-box;
553
+            -webkit-line-clamp: 1;
554
+            -webkit-box-orient: vertical;
555
+            overflow: hidden;
556
+            text-overflow: ellipsis;
557
+        }
558
+
559
+        /* 三行文字截断样式 */
560
+        .list-value-truncate {
561
+            flex: 1;
562
+            color: #999999;
563
+            word-break: break-all;
564
+            /* 三行文字截断 - 超过三行显示省略号 */
565
+            display: -webkit-box;
566
+            -webkit-line-clamp: 2;
567
+            -webkit-box-orient: vertical;
568
+            overflow: hidden;
569
+            text-overflow: ellipsis;
570
+            max-height: 120rpx;
571
+            /* 3行 * 40rpx行高 */
572
+        }
573
+    }
574
+
575
+
576
+}
577
+
578
+// }
579
+
580
+.fixed-actions {
581
+    position: fixed;
582
+    bottom: 87rpx;
583
+    left: 0;
584
+    right: 0;
585
+    display: flex;
586
+    justify-content: space-between;
587
+    align-items: center;
588
+    padding: 20rpx 32rpx;
589
+    background: #fff;
590
+    box-shadow: 0 -4rpx 10rpx rgba(0, 0, 0, 0.1);
591
+    z-index: 100;
592
+
593
+    .select-all {
594
+        flex: 1;
595
+    }
596
+
597
+    .batch-btn-container {
598
+        display: flex;
599
+
600
+        .batch-btn {
601
+            width: 240rpx;
602
+            margin-top: 0;
603
+            margin-left: 20rpx;
604
+        }
605
+    }
606
+
607
+
608
+}
609
+
610
+/* 卡片标签样式 */
611
+.card-tag {
612
+    position: absolute;
613
+    bottom: 0rpx;
614
+    right: 0rpx;
615
+    padding: 8rpx 16rpx;
616
+    border-radius: 8rpx 0 8rpx 0;
617
+    font-size: 24rpx;
618
+    font-weight: 500;
619
+    color: #FFFFFF;
620
+    z-index: 10;
621
+    white-space: nowrap;
622
+    pointer-events: none;
623
+}
624
+
625
+.seizure-tag {
626
+    background-color: #1890FF;
627
+    /* 蓝色背景 */
628
+}
629
+
630
+.rectification-tag {
631
+    background-color: #FA8C16;
632
+    /* 橙色背景 */
633
+}
634
+</style>

+ 551 - 0
src/pages/personCount/index.vue

@@ -0,0 +1,551 @@
1
+<template>
2
+	<view class="person_count">
3
+		<view class="piece">
4
+			<view class="user">
5
+				<view class="img">王</view>
6
+				<view class="info">
7
+					<view class="name">王小虎</view>
8
+					<view class="position">安检员·T3航站楼</view>
9
+				</view>
10
+			</view>
11
+			<view class="cont">
12
+				<view class="item" v-for="(item, index) in seizureData" :key="index">
13
+					<view class="itm_title">{{ item.title }}</view>
14
+					<view class="num">{{ item.count }}</view>
15
+					<view class="item_text">
16
+						<img src="" alt="" />
17
+						<view class="text">{{ item.percentage }}%</view>
18
+						<view class="compare">vs上月</view>
19
+					</view>
20
+				</view>
21
+			</view>
22
+		</view>
23
+		<view class="piece">
24
+			<view class="title">查获情况分析</view>
25
+			<view class="top">
26
+				<view>查获总数排名</view>
27
+				<view>第8名/126人</view>
28
+			</view>
29
+			<view class="percentage"><u-line-progress :percentage="126/8" :showText="false"></u-line-progress></view>
30
+			<view class="top_cont">
31
+				<view id="top_Echart" class="top_Echart"></view>
32
+			</view>
33
+			<view class="top_cont">
34
+				<view id="brokenLine" class="top_Echart"></view>
35
+			</view>
36
+		</view>
37
+		<view class="piece">
38
+			<view class="title">巡检与整改情况</view>
39
+			<view class="top">
40
+				<view>本月巡检记录</view>
41
+				<view>5次</view>
42
+			</view>
43
+			<view class="cont_item" v-for="(item, index) in inspectionList" :key="index">
44
+				<view class="left">
45
+					<h4>{{ item.title }}</h4>
46
+					<view class="time">检查时间:{{item.time}}</view>
47
+					<view :style="item.status == '合格' ? 'color: green' : item.status == '待整改' ? 'color: red' : 'color: #ffaa00'" v-if="item.problem">问题:{{item.time}}</view>
48
+				</view>
49
+				<view class="right" :style="item.status == '合格' ? 'color: green' : item.status == '待整改' ? 'color: red' : 'color: #ffaa00'">{{ item.status }}</view>
50
+			</view>
51
+			<view class="top_cont">
52
+				<view id="typeScatter" class="top_Echart"></view>
53
+			</view>
54
+		</view>
55
+		<view class="piece">
56
+			<view class="title">测试与培训情况</view>
57
+			<view class="top">
58
+				<view>综合成绩排名</view>
59
+				<view>第12名/126人</view>
60
+			</view>
61
+			<view class="percentage"><u-line-progress :percentage="126/12" :showText="false"></u-line-progress></view>
62
+			<view class="top_cont">
63
+				<view id="knowledge_area" class="top_Echart"></view>
64
+			</view>
65
+			<view>
66
+				<view class="title">最近三次测试成绩</view>
67
+				<view class="top">
68
+					<view>安检设备操作规范</view>
69
+					<view>96分</view>
70
+				</view>
71
+				<view class="percentage"><u-line-progress :percentage="96" :showText="false"></u-line-progress></view>
72
+				<view class="top">
73
+					<view>违禁物品识别</view>
74
+					<view>88分</view>
75
+				</view>
76
+				<view class="percentage"><u-line-progress activeColor="#ffaa00" :percentage="96" :showText="false"></u-line-progress></view>
77
+				<view class="top">
78
+					<view>应急处置流程</view>
79
+					<view>92分</view>
80
+				</view>
81
+				<view class="percentage"><u-line-progress activeColor="#155DFF" :percentage="96" :showText="false"></u-line-progress></view>
82
+			</view>
83
+		</view>
84
+		<view class="piece">
85
+			<view class="title">个人发展建议</view>
86
+			<view class="suggest" v-for="(item, index) in suggestList" :key="index">
87
+				<view class="serial_num" :style="index == 0 ? 'background: blue' : (index == 1 ? 'background: red' :  'background: yellow')"></view>
88
+				<view class="info">
89
+					<view class="name">{{ item.suggestTitle }}</view>
90
+					<view class="position">{{ item.suggestCont }}</view>
91
+				</view>
92
+			</view>
93
+		</view>
94
+	</view>
95
+</template>
96
+
97
+<script>
98
+	import * as echarts from 'echarts';
99
+  export default {
100
+    data() {
101
+      return {
102
+        seizureData: [{
103
+        	count: '1029',
104
+        	title: '查获总数',
105
+        	percentage: '23',
106
+        	status: true
107
+        }, {
108
+        	count: '2',
109
+        	title: '重大违禁品数',
110
+        	percentage: '2',
111
+        	status: false
112
+        }, {
113
+        	count: '2',
114
+        	title: '巡检问题',
115
+        	percentage: '33.3',
116
+        	status: false
117
+        }, {
118
+        	count: '92',
119
+        	title: '测试成绩',
120
+        	percentage: '4.5',
121
+        	status: false
122
+        }],
123
+				suggestList: [
124
+					{
125
+						url: '',
126
+						suggestTitle: '保持优势',
127
+						suggestCont: '您在金属物品识别和爆炸物检测方面表现出色,继续保持!建议您将经验分享给新同事,共同提升团队整体水平。'
128
+					},
129
+					{
130
+						url: '',
131
+						suggestTitle: '重点提升',
132
+						suggestCont: '您在液态物品检测和电子设备检查环节还有提升空间。建议参加下周三的“新型液态违禁品识别”专项培训,并在日常工作中加强对电子设备复杂结构的检查。'
133
+					},
134
+					{
135
+						url: '',
136
+						suggestTitle: '即将开始的培训',
137
+						suggestCont: '7月5日将开展“锂电池安全检查规范”培训,建议您报名参加,进一步提升相关领域知识水平。'
138
+					},
139
+				],
140
+				inspectionList: [
141
+					{
142
+						title: '日常操作规范检查',
143
+						status: '合格',
144
+						time: '2025-06-12',
145
+						problem: ''
146
+					},
147
+					{
148
+						title: '设备使用规范检查',
149
+						status: '待整改',
150
+						time: '2025-06-15',
151
+						problem: '金属探测器操作不规范'
152
+					},
153
+					{
154
+						title: '安全意识抽查',
155
+						status: '已整改',
156
+						time: '2025-06-19',
157
+						problem: '未及时记录特殊旅客信息'
158
+					},
159
+				],
160
+				echart: null,
161
+				chartOption: {
162
+					title: {
163
+						text: '查获物品分类',
164
+						textStyle: {
165
+							fontSize: 14
166
+						},
167
+						padding: [0, 14]
168
+					},
169
+					legend: {
170
+						top: '8%',
171
+						left: 'center'
172
+					},
173
+					series: [
174
+						{
175
+							name: '查获占比',
176
+							type: 'pie',
177
+							radius: ['40%', '70%'],
178
+							center:['50%','60%'],
179
+							avoidLabelOverlap: false,
180
+							label: {
181
+								show: false,
182
+								position: 'center'
183
+							},
184
+							data: [
185
+								{ value: 1048, name: '金属物品' },
186
+								{ value: 735, name: '液态物品' },
187
+								{ value: 580, name: '锂电池' },
188
+								{ value: 484, name: '其他违禁品' },
189
+								{ value: 300, name: '管制刀具' }
190
+							]
191
+						}
192
+					],
193
+				},
194
+				brokenLine: null,
195
+				brokenLineOption: {
196
+					title: {
197
+						text: '月度查获趋势',
198
+						textStyle: {
199
+							fontSize: 14
200
+						},
201
+						padding: [20, 14, 0, 14]
202
+					},
203
+					grid: {
204
+						left: '3%',
205
+						right: '4%',
206
+						bottom: '3%',
207
+						containLabel: true
208
+					},
209
+					xAxis: [
210
+						{
211
+							type: 'category',
212
+							boundaryGap: false,
213
+							data: ['1月', '2月', '3月', '4月', '5月', '6月']
214
+						}
215
+					],
216
+					yAxis: [
217
+						{
218
+							type: 'value'
219
+						}
220
+					],
221
+					series: [
222
+						{
223
+							name: '趋势',
224
+							type: 'line',
225
+							stack: 'Total',
226
+							areaStyle: {},
227
+							data: [100, 101, 98, 96, 99, 95]
228
+						}
229
+					]
230
+				},
231
+				typeScatter: null,
232
+				typeScatterOption: {
233
+					title: {
234
+						text: '问题类型分布',
235
+						textStyle: {
236
+							fontSize: 14
237
+						},
238
+						padding: [20, 14, 0, 14]
239
+					},
240
+					grid: {
241
+						left: '5%',
242
+						right: '5%',
243
+						bottom: '4%',
244
+						containLabel: true
245
+					},
246
+					xAxis: {
247
+						type: 'category',
248
+						data: ['操作规范', '设备使用', '安全意识', '记录规范', '应急处置'],
249
+						axisLabel: {
250
+							interval: 0, // 控制标签的显示间隔,0表示全部显示
251
+							margin: 10,
252
+						}
253
+					},
254
+					yAxis: {
255
+						type: 'value'
256
+					},
257
+					series: [
258
+						{
259
+							data: [
260
+								{
261
+									value: 2,
262
+									itemStyle: {
263
+										color: '#FF5253'
264
+									}
265
+								},
266
+								{
267
+									value: 5,
268
+									itemStyle: {
269
+										color: '#FF5253'
270
+									}
271
+								},
272
+								{
273
+									value: 3,
274
+									itemStyle: {
275
+										color: '#FF5253'
276
+									}
277
+								},
278
+								{
279
+									value: 1,
280
+									itemStyle: {
281
+										color: '#FF5253'
282
+									}
283
+								},
284
+								{
285
+									value: 0,
286
+									itemStyle: {
287
+										color: '#FF5253'
288
+									}
289
+								},
290
+							],
291
+							type: 'bar'
292
+						}
293
+					]
294
+				},
295
+				knowledgeArea: null,
296
+				knowledgeAreaOption: {
297
+					title: {
298
+						text: '知识领域得分',
299
+						textStyle: {
300
+							fontSize: 14
301
+						},
302
+						padding: [20, 14, 0, 14]
303
+					},
304
+					legend: {
305
+						data: ['您的得分', '全站平均'],
306
+						bottom: '5%',
307
+						left: 'center'
308
+					},
309
+					radar: {
310
+						indicator: [
311
+							{ name: '安检设备操作', max: 100 },
312
+							{ name: '安全法规知识', max: 100 },
313
+							{ name: '旅客服务规范', max: 100 },
314
+							{ name: '应急处置流程', max: 100 },
315
+							{ name: '违禁物品识别', max: 100 },
316
+						],
317
+						center: ['50%', '50%'],
318
+						radius: '50%',
319
+						name: {
320
+							// 修改指标名称的文本样式
321
+							textStyle: {
322
+								fontSize: 12, // 设置文字大小
323
+								fontWeight: 'bold' // 设置文字粗细
324
+							}
325
+						}
326
+					},
327
+					series: [
328
+						{
329
+							name: 'Budget vs spending',
330
+							type: 'radar',
331
+							data: [
332
+								{
333
+									value: [80, 70, 75, 80, 80, 80],
334
+									name: '您的得分',
335
+									label: {
336
+										show: true,
337
+									},
338
+									areaStyle: {
339
+										color: '#a9bdff',
340
+									},
341
+									itemStyle: {
342
+										color: 'blue',
343
+									},
344
+								},
345
+								{
346
+									value: [8, 50, 25, 70, 60, 80],
347
+									name: '全站平均',
348
+									label: {
349
+										show: true,
350
+									},
351
+									areaStyle: {
352
+										color: '#e6e6e6',
353
+									},
354
+									itemStyle: {
355
+										color: '#ccc',
356
+									},
357
+								}
358
+							]
359
+						}
360
+					]
361
+				}
362
+      }
363
+    },
364
+    mounted() {
365
+			this.initEcharts();
366
+    },
367
+    methods: {
368
+			// 初始化图表
369
+      initEcharts() {
370
+				this.echart = echarts.init(document.getElementById('top_Echart'));
371
+				this.echart.setOption(this.chartOption);
372
+				this.brokenLine = echarts.init(document.getElementById('brokenLine'));
373
+				this.brokenLine.setOption(this.brokenLineOption);
374
+				this.typeScatter = echarts.init(document.getElementById('typeScatter'));
375
+				this.typeScatter.setOption(this.typeScatterOption);
376
+				this.knowledgeArea = echarts.init(document.getElementById('knowledge_area'));
377
+				this.knowledgeArea.setOption(this.knowledgeAreaOption);
378
+			}
379
+		}
380
+  }
381
+</script>
382
+
383
+<style scoped lang="scss">
384
+	.person_count {
385
+		width: 100%;
386
+		height: 100vh;
387
+		.piece {
388
+			width: 96%;
389
+			margin-left: 2%;
390
+			background: #fff;
391
+			border-radius: 0.2rem;
392
+			margin-top: 2%;
393
+			padding-bottom: 0.5rem;
394
+			.suggest {
395
+				width: 100%;
396
+				display: flex;
397
+				padding: 0.5rem;
398
+				&:last-child {
399
+					padding-bottom: 2rem;
400
+				}
401
+				.serial_num {
402
+					width: 0.5rem;
403
+					border-radius: 5px;
404
+				}
405
+				.info {
406
+					width: 90%;
407
+					margin-left: 0.5rem;
408
+					.name {
409
+						font-size: 14px;
410
+						font-weight: 700;
411
+					}
412
+					.position {
413
+						font-size: 12px;
414
+						color: #9c9c9c;
415
+					}
416
+				}
417
+			}
418
+			.user {
419
+				width: 100%;
420
+				height: 2.5rem;
421
+				display: flex;
422
+				padding: 0.5rem;
423
+				.img {
424
+					width: 2rem;
425
+					height: 2rem;
426
+					background: #E7EEFF;
427
+					border-radius: 50%;
428
+					color: blue;
429
+					text-align: center;
430
+					line-height: 2rem;
431
+				}
432
+				.info {
433
+					margin-left: 0.5rem;
434
+					.name {
435
+						font-size: 14px;
436
+						font-weight: 700;
437
+					}
438
+					.position {
439
+						font-size: 12px;
440
+						color: #9c9c9c;
441
+					}
442
+				}
443
+			}
444
+			.top {
445
+				width: 100%;
446
+				display: flex;
447
+				justify-content: space-between;
448
+				padding: 0 1rem;
449
+				font-size: 13px;
450
+			}
451
+			.cont_item {
452
+				width: 96%;
453
+				background: #EFF6FF;
454
+				display: flex;
455
+				justify-content: space-between;
456
+				padding: 0.7rem;
457
+				margin-top: 0.5rem;
458
+				margin-left: 2%;
459
+				line-height: 1.4rem;
460
+				.left {
461
+					.time {
462
+						color: #9c9c9c;
463
+						font-size: 13px;
464
+					}
465
+				}
466
+				&:nth-child(4) {
467
+					background: #FFF2F2;
468
+				}
469
+				&:nth-child(5) {
470
+					background: #FEFBE8;
471
+				}
472
+			}
473
+			.percentage {
474
+				height: 2rem;
475
+				padding: 0 1rem;
476
+				display: flex;
477
+				align-items: center;
478
+			}
479
+			.top_cont {
480
+				width: 100%;
481
+				display: flex;
482
+				justify-content: center;
483
+				.top_Echart {
484
+					width: 100%;
485
+					height: 30vh;
486
+				}
487
+			}
488
+			.title {
489
+				width: 100%;
490
+				height: 2rem;
491
+				line-height: 2rem;
492
+				font-size: 14px;
493
+				font-weight: 700;
494
+				& {
495
+					padding-left: 1rem;
496
+				}
497
+			}
498
+			.cont {
499
+				width: 100%;
500
+				display: flex;
501
+				flex-wrap: wrap;
502
+				.item {
503
+					width: 46%;
504
+					margin: 2%;
505
+					background: #E7EEFF;
506
+					border-radius: 5%;
507
+					padding: 0.3rem;
508
+					line-height: 1.3rem;
509
+					.itm_title {
510
+						font-size: 12px;
511
+						color: blue;
512
+					}
513
+					&:nth-child(2) {
514
+						background: #FEF2F2;
515
+						.itm_title {
516
+							color: #ffaa00;
517
+						}
518
+					}
519
+					&:nth-child(3) {
520
+						background: #FEF2F2;
521
+						.itm_title {
522
+							color: #ca0000;
523
+						}
524
+					}
525
+					&:nth-child(4) {
526
+						background: #FBF5FF;
527
+						.itm_title {
528
+							color: #aa00ff;
529
+						}
530
+					}
531
+					.num {
532
+						font-size: 14px;
533
+						font-weight: 700;
534
+					}
535
+					.item_text {
536
+						display: flex;
537
+						.text {
538
+							font-size: 13px;
539
+							color: #00ff00;
540
+						}
541
+						.compare {
542
+							font-size: 12px;
543
+							color: #949494;
544
+							margin-left: 3px;
545
+						}
546
+					}
547
+				}
548
+			}
549
+		}
550
+	}
551
+</style>

+ 142 - 0
src/pages/personal-center/index.vue

@@ -0,0 +1,142 @@
1
+<template>
2
+  <home-container>
3
+    <div class="title">
4
+      <user-info name="刘阳" sub-title="欢迎使用安检,移动业务更轻松~"></user-info>
5
+    </div>
6
+
7
+    <div class="user-detail">
8
+      <div class="avatar">
9
+        <img alt="" src="@/static/images/profile.jpg">
10
+      </div>
11
+      <div class="item">
12
+        <div class="name">刘阳</div>
13
+        <div class="level">安检员:<span class="address">T3航站楼</span></div>
14
+        <div class="more">查看或者编辑个人资料</div>
15
+      </div>
16
+      <div class="right">
17
+        <img alt="" src="@/static/images/right.png">
18
+      </div>
19
+    </div>
20
+
21
+    <div class="common-functions">
22
+      <head-title title="常用功能"></head-title>
23
+
24
+      <div class="list">
25
+        <div class="list-item">
26
+          <div class="icon"><img alt="" src="@/static/images/tongji.png"></div>
27
+          个人统计
28
+        </div>
29
+        <div class="list-item">
30
+          <div class="icon"><img alt="" src="@/static/images/tiaokuan.png"></div>
31
+          隐私条款
32
+        </div>
33
+      </div>
34
+    </div>
35
+  </home-container>
36
+</template>
37
+<script>
38
+import HomeContainer from "@/components/HomeContainer.vue";
39
+import UserInfo from "@/components/UserInfo.vue";
40
+import HeadTitle from "@/components/HeadTitle.vue";
41
+
42
+export default {
43
+  components: {HeadTitle, UserInfo, HomeContainer},
44
+  data() {
45
+    return {}
46
+  },
47
+}
48
+</script>
49
+
50
+<style lang="scss" scoped>
51
+img {
52
+  display: inline-flex;
53
+  width: 100%;
54
+  height: 100%;
55
+}
56
+
57
+.title {
58
+  padding: 40rpx 20rpx 20rpx;
59
+}
60
+
61
+.user-detail {
62
+  display: flex;
63
+  align-items: center;
64
+  padding: 40rpx 48rpx;
65
+  background: #FFFFFF;
66
+  box-shadow: 0 8rpx 32rpx 0 rgba(0, 0, 0, 0.06);
67
+  border-radius: 32rpx;
68
+  margin-bottom: 32rpx;
69
+
70
+  .avatar {
71
+    margin-right: 48rpx;
72
+
73
+    img {
74
+      width: 152rpx;
75
+      height: 152rpx;
76
+      border-radius: 28rpx;
77
+    }
78
+  }
79
+
80
+  .item {
81
+    display: flex;
82
+    flex-flow: column;
83
+    justify-content: space-between;
84
+  }
85
+
86
+  .name {
87
+    font-weight: bold;
88
+    font-size: 36rpx;
89
+    color: #3D3D3D;
90
+    line-height: 40rpx;
91
+  }
92
+
93
+  .level {
94
+    font-weight: 400;
95
+    font-size: 28rpx;
96
+    color: #333333;
97
+    line-height: 40rpx;
98
+    margin: 12rpx 0;
99
+
100
+    .address {
101
+      color: #999999;
102
+    }
103
+  }
104
+
105
+  .more {
106
+    font-weight: 400;
107
+    font-size: 28rpx;
108
+    color: #666666;
109
+    line-height: 40rpx;
110
+  }
111
+
112
+  .right {
113
+    width: 24rpx;
114
+    height: 24rpx;
115
+    margin-left: auto;
116
+  }
117
+}
118
+
119
+.common-functions {
120
+  padding: 40rpx 48rpx;
121
+  background: #FFFFFF;
122
+  box-shadow: 0 8rpx 32rpx 0 rgba(0, 0, 0, 0.06);
123
+  border-radius: 32rpx;
124
+  font-weight: 400;
125
+  font-size: 20rpx;
126
+  color: #3D3D3D;
127
+
128
+  .list {
129
+    display: flex;
130
+  }
131
+
132
+  .list-item {
133
+    margin: 32rpx 108rpx 0 0;
134
+
135
+    .icon {
136
+      width: 64rpx;
137
+      height: 64rpx;
138
+      margin-bottom: 12rpx;
139
+    }
140
+  }
141
+}
142
+</style>

+ 183 - 0
src/pages/problemRect/components/RejectModal.vue

@@ -0,0 +1,183 @@
1
+<template>
2
+  <view class="reject-modal">
3
+    <!-- 插槽内容,点击后唤起弹窗 -->
4
+    <view class="slot-content" @click="openModal">
5
+      <slot></slot>
6
+    </view>
7
+    
8
+    <!-- 驳回理由弹窗 -->
9
+    <u-popup :show="show" mode="center" :round="8" @close="closeModal">
10
+      <view class="modal-content">
11
+        <!-- 标题 -->
12
+        <view class="modal-header">
13
+          <text class="modal-title">驳回理由</text>
14
+          <u-icon name="close" color="#666666" size="20" @click="closeModal" />
15
+        </view>
16
+        
17
+        <!-- 驳回理由输入框 -->
18
+        <view class="reject-reason-input">
19
+          <view class="input-label">请填写驳回理由:</view>
20
+          <uni-easyinput 
21
+            type="textarea"
22
+            v-model="rejectReason" 
23
+            placeholder="请输入驳回理由..."
24
+            maxlength="500"
25
+            :auto-height="true"
26
+          />
27
+          <view class="word-count">{{ rejectReason.length }}/500</view>
28
+        </view>
29
+        
30
+        <!-- 按钮区域 -->
31
+        <view class="modal-footer">
32
+          <view class="btn btn-cancel" @click="closeModal">取消</view>
33
+          <view class="btn btn-confirm" @click="handleConfirm">确定</view>
34
+        </view>
35
+      </view>
36
+    </u-popup>
37
+  </view>
38
+</template>
39
+
40
+<script>
41
+export default {
42
+  name: 'RejectModal',
43
+  props: {
44
+    // 是否显示弹窗(外部控制)
45
+    value: {
46
+      type: Boolean,
47
+      default: false
48
+    }
49
+  },
50
+  data() {
51
+    return {
52
+      show: false,
53
+      rejectReason: ''
54
+    }
55
+  },
56
+  watch: {
57
+    value: {
58
+      immediate: true,
59
+      handler(newVal) {
60
+        this.show = newVal
61
+      }
62
+    },
63
+    show(newVal) {
64
+      this.$emit('input', newVal)
65
+    }
66
+  },
67
+  methods: {
68
+    // 打开弹窗
69
+    openModal() {
70
+      this.show = true
71
+      this.rejectReason = ''
72
+    },
73
+    
74
+    // 关闭弹窗
75
+    closeModal() {
76
+      this.show = false
77
+      this.rejectReason = ''
78
+    },
79
+    
80
+    // 确定按钮处理
81
+    handleConfirm() {
82
+      if (!this.rejectReason.trim()) {
83
+        uni.showToast({
84
+          title: '请输入驳回理由',
85
+          icon: 'none'
86
+        })
87
+        return
88
+      }
89
+      
90
+      // 向外传递驳回理由
91
+      this.$emit('confirm', this.rejectReason.trim())
92
+      this.closeModal()
93
+    }
94
+  }
95
+}
96
+</script>
97
+
98
+<style lang="scss" scoped>
99
+.reject-modal {
100
+  width: 48%;
101
+  // margin-right: 20rpx;
102
+  .slot-content {
103
+    // display: inline-block;
104
+  }
105
+  
106
+  .modal-content {
107
+    width: 600rpx;
108
+    background-color: #fff;
109
+    border-radius: 16rpx;
110
+    padding: 40rpx 32rpx 32rpx;
111
+    
112
+    .modal-header {
113
+      display: flex;
114
+      justify-content: space-between;
115
+      align-items: center;
116
+      margin-bottom: 32rpx;
117
+      
118
+      .modal-title {
119
+        font-size: 32rpx;
120
+        font-weight: 600;
121
+        color: #333;
122
+      }
123
+    }
124
+    
125
+    .reject-reason-input {
126
+      margin-bottom: 40rpx;
127
+      
128
+      .input-label {
129
+        font-size: 28rpx;
130
+        color: #666;
131
+        margin-bottom: 16rpx;
132
+      }
133
+      
134
+      .word-count {
135
+        text-align: right;
136
+        font-size: 24rpx;
137
+        color: #999;
138
+        margin-top: 8rpx;
139
+      }
140
+    }
141
+    
142
+    .modal-footer {
143
+      display: flex;
144
+      justify-content: flex-end;
145
+      gap: 20rpx;
146
+      
147
+      .btn {
148
+        padding: 20rpx 40rpx;
149
+        border-radius: 8rpx;
150
+        font-size: 28rpx;
151
+        font-weight: 500;
152
+        cursor: pointer;
153
+        
154
+        &.btn-cancel {
155
+          background-color: #f5f5f5;
156
+          color: #666;
157
+          border: 2rpx solid #e5e5e5;
158
+          
159
+          &:active {
160
+            background-color: #e8e8e8;
161
+          }
162
+        }
163
+        
164
+        &.btn-confirm {
165
+          background-color: #409EFF;
166
+          color: #fff;
167
+          border: 2rpx solid #409EFF;
168
+          
169
+          &:active {
170
+            background-color: #337ecc;
171
+          }
172
+          
173
+          &:disabled {
174
+            background-color: #c0c4cc;
175
+            border-color: #c0c4cc;
176
+            cursor: not-allowed;
177
+          }
178
+        }
179
+      }
180
+    }
181
+  }
182
+}
183
+</style>

+ 843 - 0
src/pages/problemRect/index.vue

@@ -0,0 +1,843 @@
1
+<template>
2
+    <home-container>
3
+        <view class="report-container">
4
+            <!-- 表单区域 -->
5
+            <uni-forms ref="form" :rules="rules" :modelValue="formData" label-position="top" err-show-type="modal">
6
+                <!-- 1. 基本信息分组 (默认展开) -->
7
+                <view class="card">
8
+                    <uni-collapse :accordion="false" :value="['group1']">
9
+                        <h-collapse-item title="基本信息" name="group1" :show-animation="true"
10
+                            :iconUrl="'../../static/images/icon/jiben.png'">
11
+                            <uni-forms-item label="任务编号" name="taskCode">
12
+                                <uni-easyinput v-model="formData.taskCode" placeholder="系统自动生成" disabled />
13
+                            </uni-forms-item>
14
+                            <uni-forms-item label="巡检编号" name="documentCode">
15
+                                <uni-easyinput v-model="formData.documentCode" placeholder="系统自动生成" disabled />
16
+                            </uni-forms-item>
17
+
18
+                            <uni-forms-item label="检查人" name="checkerName">
19
+                                <uni-easyinput v-model="formData.checkerName" placeholder="请输入检查人姓名" disabled />
20
+                            </uni-forms-item>
21
+
22
+                            <uni-forms-item :label="getCheckLabel" name="checkedDepartmentId">
23
+                                <uni-data-picker v-if="getCheckLabel == '被检查科'" :localdata="departments"
24
+                                    :popup-title="`请选择${getCheckLabel}`" v-model="formData.checkedDepartmentId"
25
+                                    :readonly="true" />
26
+                                <uni-data-picker v-if="getCheckLabel == '被检查班组'" :localdata="teams"
27
+                                    :popup-title="`请选择${getCheckLabel}`" v-model="formData.checkedTeamId"
28
+                                    :readonly="true" />
29
+                                <fuzzy-select v-if="getCheckLabel == '被检查人'" v-model="formData.checkedPersonnelId"
30
+                                    :options="userOptions" placeholder="请输入被检查人姓名搜索" data-value="userId"
31
+                                    data-text="nickName" disabled />
32
+                            </uni-forms-item>
33
+
34
+                            <uni-forms-item label="检查时间" name="checkTime">
35
+                                <uni-datetime-picker type="datetime" :start="startDate" :end="endDate"
36
+                                    v-model="formData.checkTime" disabled />
37
+                            </uni-forms-item>
38
+
39
+                            <uni-forms-item label="检查位置" name="checkLocation">
40
+                                <uni-easyinput v-model="formData.checkLocation" placeholder="请选择检查位置" disabled />
41
+                            </uni-forms-item>
42
+                        </h-collapse-item>
43
+                    </uni-collapse>
44
+                </view>
45
+
46
+                <!-- 2. 待整改问题分组 -->
47
+                <view class="card">
48
+                    <uni-collapse :accordion="false" :value="['group2']">
49
+                        <h-collapse-item title="待整改问题" name="group2" :show-animation="true"
50
+                            :iconUrl="'../../static/images/icon/wenti.png'">
51
+                            <view style="padding: 0 15px 15px 15px;">
52
+                                <!-- 问题列表遍历 -->
53
+                                <view v-for="(problem, index) in formData.checkProjectItemList" :key="problem.id"
54
+                                    class="problem-item">
55
+                                    <view class="problem-header">
56
+                                        <text class="problem-title">{{
57
+                                            `${problem.categoryNameOne}/${problem.categoryNameTwo}/${problem.projectName}`
58
+                                        }}</text>
59
+                                    </view>
60
+                                    <view class="problem-content">
61
+                                        <view class="problem-person">
62
+                                            <view class="problem-label">问题人<text style="color: red;margin-left: 10rpx;">请确定问题人</text></view>
63
+
64
+                                            <fuzzy-select v-model="problem.checkUserList.userId" :options="userOptions"
65
+                                                :placeholder="'请输入问题' + (index + 1) + '的责任人'" data-value="userId"
66
+                                                data-text="nickName" @change="handleUserSelect"
67
+                                                :disabled="formDisabled" />
68
+                                        </view>
69
+                                        <view class="problem-description">
70
+                                            <text class="problem-label">问题描述</text>
71
+                                            <uni-easyinput type="textarea" v-model="problem.problemDescription"
72
+                                                :disabled="true" />
73
+                                        </view>
74
+                                    </view>
75
+                                </view>
76
+
77
+                                <uni-file-picker v-if="formData.checkRecordBaseAttachmentList.length > 0"
78
+                                    v-model="formData.checkRecordBaseAttachmentList" limit="8" title="最多上传8张"
79
+                                    :image-styles="imageStyles" fileMediatype="image" mode="grid"
80
+                                    @select="onCheckRecordBaseAttachmentListSelect" :disabled="true" :del-icon="false"
81
+                                    :readonly="true" />
82
+                            </view>
83
+                        </h-collapse-item>
84
+                    </uni-collapse>
85
+                </view>
86
+
87
+                <!-- 3. 整改要求分组 -->
88
+                <view class="card">
89
+                    <uni-collapse :accordion="false" :value="['group3']">
90
+                        <h-collapse-item title="整改要求" name="group3" :show-animation="true"
91
+                            :iconUrl="'../../static/images/icon/yaoqiu.png'">
92
+                            <uni-forms-item label="责任人" name="responsibleUserName" required>
93
+                                <uni-easyinput v-model="formData.responsibleUserName" placeholder="请输入责任人姓名" disabled />
94
+                            </uni-forms-item>
95
+
96
+                            <uni-forms-item label="整改期限" name="rectificationDeadline" required>
97
+                                <uni-datetime-picker type="datetime" :start="startDate" :end="endDate"
98
+                                    v-model="formData.rectificationDeadline" disabled />
99
+                            </uni-forms-item>
100
+
101
+
102
+                            <!-- 是否流转至班组 -->
103
+                            <uni-forms-item
104
+                                v-if="nodeCode === 'SECTION_LEADER_APPROVE' && businessType === 'SECTION_CHECK'"
105
+                                label="是否流转至班组" name="isSelectTeam">
106
+                                <radio-group @change="handleIsSelectTeamChange" class="radio-group">
107
+                                    <label class="radio-item">
108
+                                        <radio :value="'0'" :checked="formData.isSelectTeam == 0" color="#409EFF" />
109
+                                        <text>否</text>
110
+                                    </label>
111
+                                    <label class="radio-item">
112
+                                        <radio :value="'1'" :checked="formData.isSelectTeam == 1" color="#409EFF" />
113
+                                        <text>是</text>
114
+                                    </label>
115
+                                </radio-group>
116
+                            </uni-forms-item>
117
+
118
+                            <!-- 整改班组 -->
119
+                            <uni-forms-item
120
+                                v-if="nodeCode === 'SECTION_LEADER_APPROVE' && businessType === 'SECTION_CHECK' && formData.isSelectTeam == 1"
121
+                                label="整改班组" name="selectTeamId">
122
+                                <uni-data-picker :localdata="teams" popup-title="请选择整改班组"
123
+                                    v-model="formData.selectTeamId" @change="handlecheckedTeamIdChange" />
124
+                            </uni-forms-item>
125
+
126
+                            <!-- 班组长 -->
127
+                            <uni-forms-item
128
+                                v-if="nodeCode === 'SECTION_LEADER_APPROVE' && businessType === 'SECTION_CHECK' && formData.isSelectTeam == 1"
129
+                                label="班组长" name="selectTeamLeaderName">
130
+                                <uni-easyinput v-model="formData.selectTeamLeaderName" placeholder="班组长" disabled />
131
+                            </uni-forms-item>
132
+
133
+                            <uni-forms-item label="整改要求" name="rectificationSuggestions">
134
+                                <uni-easyinput type="textarea" v-model="formData.rectificationSuggestions"
135
+                                    placeholder="请输入整改要求" disabled />
136
+                            </uni-forms-item>
137
+
138
+                        </h-collapse-item>
139
+                    </uni-collapse>
140
+                </view>
141
+
142
+                <!-- 4. 整改信息分组 -->
143
+                <view class="card">
144
+                    <uni-collapse :accordion="false" :value="['group4']">
145
+                        <h-collapse-item title="整改信息" name="group4" :show-animation="true"
146
+                            :iconUrl="'../../static/images/icon/zhenggai.png'">
147
+                            <uni-forms-item label="整改详情" name="rectificationDetails"
148
+                                :required="rectificationDetailsRequired">
149
+                                <uni-easyinput type="textarea" v-model="formData.rectificationDetails"
150
+                                    placeholder="请输入整改详情" :disabled="formDisabled" />
151
+                            </uni-forms-item>
152
+
153
+                            <view style="padding: 0 15px 15px 15px;">
154
+                                <uni-file-picker v-model="formData.baseAttachmentList" limit="8" title="最多上传8张整改照片"
155
+                                    :image-styles="imageStyles" fileMediatype="image" mode="grid"
156
+                                    @select="onBaseAttachmentListelect" :disabled="formDisabled"
157
+                                    :del-icon="!formDisabled" :readonly="formDisabled" />
158
+                            </view>
159
+                        </h-collapse-item>
160
+                    </uni-collapse>
161
+                </view>
162
+
163
+                <!-- 5. 审批历史分组 -->
164
+                <view class="card">
165
+                    <uni-collapse :accordion="false" :value="['group5']">
166
+                        <h-collapse-item title="审批历史" name="group5" :show-animation="true"
167
+                            :iconUrl="'../../static/images/icon/lishi.png'">
168
+                            <approve-history :history-list="approvalHistory" />
169
+                        </h-collapse-item>
170
+                    </uni-collapse>
171
+                </view>
172
+
173
+                <!-- 提交按钮 -->
174
+                <view class="button-group" v-if="type === 'approve'">
175
+                    <!-- <view v-if="btnPermission.showSubmitBtn" class="custom-btn-normal" @click="submitForm">立刻提交
176
+                    </view> -->
177
+                    <RejectModal v-if="btnPermission.showApproveRejectBtn" @confirm="handleRejectConfirm">
178
+                        <view class="custom-btn-white">
179
+                            驳回/退回
180
+                        </view>
181
+                    </RejectModal>
182
+
183
+                    <view v-if="btnPermission.showApprovePassBtn" class="custom-btn-normal" @click="submitForm">{{
184
+                        nodeCode == 'START' ? '提交' : '通过/提交' }}
185
+                    </view>
186
+                </view>
187
+            </uni-forms>
188
+        </view>
189
+    </home-container>
190
+</template>
191
+
192
+<script>
193
+import HomeContainer from "@/components/HomeContainer.vue";
194
+import { treeSelectByType } from "@/api/system/common"
195
+import { uploadFile } from '@/utils/common'
196
+import useDictMixin from '@/utils/dict'
197
+import RejectModal from './components/RejectModal.vue'
198
+import { addChecklistRecord } from '@/api/check/checklist.js'
199
+import config from '@/config'
200
+import { getToken } from '@/utils/auth'
201
+import { checkedLevelEnums } from "@/utils/enums.js"
202
+import { getDeptList } from "@/api/system/dept/dept.js"
203
+import { selectDeptLeaderByUserId } from '@/api/approve/approve.js'
204
+import { formatTime } from '@/utils/formatUtils'
205
+import { listAllUser } from "@/api/system/user.js"
206
+import { getProblemRectDetail, approvalAgree, approvalReject } from '@/api/problemRect/problemRect.js'
207
+import { getApprovelHistory } from '@/api/approve/approve.js'
208
+import { getDeptManager, getDeptDetail } from "@/api/system/dept/dept.js"
209
+import { buildTeamOptions, buildDepartmentOptions } from "@/utils/common.js"
210
+export default {
211
+    components: { HomeContainer, RejectModal },
212
+    mixins: [useDictMixin],
213
+    computed: {
214
+        currentUser() {
215
+            return this.$store.state.user;
216
+        },
217
+        userInfo() {
218
+            return (this.$store.state.user && this.$store.state.user.userInfo) ? this.$store.state.user.userInfo : {}
219
+        },
220
+        userInfoRoles() {
221
+            return this.$store.state.user && this.$store.state.user.roles
222
+        },
223
+        // 按钮权限判断
224
+        btnPermission() {
225
+            const roles = this.userInfoRoles || [];
226
+            return {
227
+                // 班组长或安全检查员显示"立刻提交"按钮
228
+                showApprovePassBtn: ['SECTION_LEADER_APPROVE', 'GROUP_LEADER_RECTIFY', 'INITIATOR_FINAL_REVIEW', 'RESPONSIBLE_PERSON_REVIEW'].includes(this.nodeCode) || (this.businessType == 'PERSONAL_CHECK' && ['INITIATOR_REVIEW', 'PROBLEM_USER_RECTIFY'].includes(this.nodeCode)) || (this.businessType == 'GROUP_CHECK' && ['SECTION_REVIEW'].includes(this.nodeCode)) || this.nodeCode == 'START',
229
+                showApproveRejectBtn: ['GROUP_LEADER_RECTIFY', 'INITIATOR_FINAL_REVIEW', 'RESPONSIBLE_PERSON_REVIEW'].includes(this.nodeCode) || (this.businessType == 'PERSONAL_CHECK' && ['INITIATOR_REVIEW', 'SECTION_LEADER_APPROVE'].includes(this.nodeCode)) || (this.businessType == 'GROUP_CHECK' && ['SECTION_REVIEW'].includes(this.nodeCode)),
230
+            };
231
+        },
232
+        formDisabled() {
233
+            return this.type === 'view';
234
+        },
235
+        getCheckLabel() {
236
+            if (this.formData.checkedLevel == checkedLevelEnums.TEAM_LEVEL) {
237
+                return '被检查班组'
238
+            }
239
+            if (this.formData.checkedLevel == checkedLevelEnums.PERSONNEL_LEVEL) {
240
+                // this.getDeptLeader()
241
+                return '被检查人'
242
+            }
243
+            if (this.formData.checkedLevel == checkedLevelEnums.DEPARTMENT_LEVEL) {
244
+                return '被检查科'
245
+            }
246
+        },
247
+        // 动态验证规则
248
+        rules() {
249
+            return {
250
+                checkerName: {
251
+                    rules: [{ required: true, errorMessage: '请输入检查人姓名' }]
252
+                },
253
+                checkTime: {
254
+                    rules: [{ required: true, errorMessage: '请选择检查时间' }]
255
+                },
256
+                checkLocation: {
257
+                    rules: [{ required: true, errorMessage: '请选择检查位置' }]
258
+                },
259
+                rectificationDetails: {
260
+                    rules: this.rectificationDetailsRequired ? [{ required: true, errorMessage: '请输入整改详情' }] : []
261
+                },
262
+                isSelectTeam: {
263
+                    rules: [{ required: true, errorMessage: '请选择是否流转至班组' }]
264
+                },
265
+                selectTeamId: {
266
+                    rules: [{ required: true, errorMessage: '请选择整改班组' }]
267
+                }
268
+            };
269
+        },
270
+        rectificationDetailsRequired() {
271
+            //科长并且是责任人节点,并且是否流转至班组长为是,整改详情不必填,为否,整改详情必填;或者是班组长节点,也必填
272
+            let res1 = this.formData.checkedLevel == 'DEPARTMENT_LEVEL' && ((this.nodeCode == 'SECTION_LEADER_APPROVE' && this.formData.isSelectTeam !== 1) || this.nodeCode == 'GROUP_LEADER_RECTIFY');
273
+            // 班组长并且是责任人节点,必填
274
+            let res2 = this.formData.checkedLevel == 'TEAM_LEVEL' && this.nodeCode == 'GROUP_LEADER_RECTIFY';
275
+            //个人
276
+            let res3 = this.formData.checkedLevel == 'PERSONNEL_LEVEL' && this.nodeCode == 'PROBLEM_USER_RECTIFY';
277
+            return res1 || res2 || res3
278
+        },
279
+    },
280
+    data() {
281
+        return {
282
+            userOptions: [],
283
+            teams: [], // 班组选项数据
284
+            departments: [], // 部门选项数据
285
+            // 表单数据
286
+            formData: {
287
+                // 基本信息
288
+                taskCode: "",
289
+                checkerName: '',
290
+
291
+                checkLocation: '',
292
+                // 待整改问题
293
+                baseAttachmentList: [],
294
+                checkRecordBaseAttachmentList: [],
295
+                // 整改要求
296
+                responsibleUserName: '',
297
+                rectificationDeadline: '',
298
+                rectificationSuggestions: '',
299
+                isSelectTeam: 0, // 是否流转至班组 0-否 1-是
300
+                selectTeamId: '', // 整改班组ID
301
+                selectTeamName: '', // 整改班组名称
302
+                selectTeamLeaderId: '', // 班组长ID
303
+                selectTeamLeaderName: '', // 班组长姓名
304
+
305
+                // 整改信息
306
+                rectificationDetails: '',
307
+                baseAttachmentList: []
308
+            },
309
+            // 审批历史数据
310
+            approvalHistory: [],
311
+            // 数据选项
312
+            location_options: [],
313
+            // 其他数据
314
+            startDate: '2020-01-01',
315
+            endDate: '2030-12-31',
316
+            imageStyles: {
317
+                width: 80,
318
+                height: 80,
319
+                border: {
320
+                    color: '#eee',
321
+                    width: '1px',
322
+                    style: 'solid'
323
+                }
324
+            },
325
+            dataOptionMap: { text: 'label', value: 'value' },
326
+            businessId: '',
327
+            instanceId: '',
328
+            id: '',
329
+            nodeCode: '',
330
+            type: '',
331
+            businessType: '',
332
+        }
333
+    },
334
+    onLoad(options) {
335
+        let params = JSON.parse(decodeURIComponent(options.params));
336
+        this.businessId = params.businessId;
337
+        this.instanceId = params.instanceId;
338
+        this.id = params.id;
339
+        this.nodeCode = params.nodeCode;
340
+        this.type = params.type;
341
+        this.businessType = params.businessType;
342
+        this.initPageData();
343
+    },
344
+    mounted() {
345
+        this.loadDictData();
346
+        console.log(this.$store.state.user)
347
+    },
348
+    methods: {
349
+        handlecheckedDepartmentIdChange(e) {
350
+
351
+        },
352
+        async loadDictData() {
353
+            const user = await listAllUser();
354
+            this.userOptions = user.data.map(item => ({
355
+                ...item,
356
+                nickName: `${item.nickName}(${item.userName})`,
357
+            })) || [];
358
+        },
359
+
360
+
361
+        async initPageData() {
362
+            try {
363
+                uni.showLoading({ title: '加载中...', mask: true });
364
+
365
+                // 如果有ID,获取问题整改详细信息
366
+                if (this.businessId) {
367
+                    const detailRes = await getProblemRectDetail(this.businessId);
368
+                    if (detailRes.code === 200 && detailRes.data) {
369
+                        this.fillFormData(detailRes.data);
370
+                    }
371
+
372
+                    // 获取审批历史
373
+                    const historyRes = await getApprovelHistory(this.instanceId);
374
+
375
+                    if (historyRes.code === 200 && historyRes.rows) {
376
+                        this.approvalHistory = historyRes.rows;
377
+                    }
378
+                }
379
+
380
+                // 获取位置列表
381
+                const positionRes = await treeSelectByType("POSITION", 3);
382
+                this.location_options = this.convertTree(positionRes.data || []);
383
+
384
+                // 获取班组列表
385
+                const deptTree = await getDeptList();
386
+                this.teams = buildTeamOptions(deptTree.data || []);
387
+                this.departments = buildDepartmentOptions(deptTree.data || []);
388
+                uni.hideLoading();
389
+            } catch (err) {
390
+                uni.hideLoading();
391
+                uni.showToast({ title: '加载失败', icon: 'none' });
392
+                console.error('初始化数据失败:', err);
393
+            }
394
+        },
395
+
396
+        // 填充表单数据
397
+        async fillFormData(data) {
398
+            console.log(data,"data")
399
+            // debugger
400
+            let checedProjectArr = [];
401
+            if (data.checkProjectItemList && data.checkProjectItemList?.length > 0) {
402
+                if (this.businessType == 'PERSONAL_CHECK' && this.nodeCode == 'SECTION_LEADER_APPROVE') {
403
+                    // let res = await selectDeptLeaderByUserId({
404
+                    //     userId: data.checkedPersonnelId,
405
+                    //     deptType: 'TEAMS'
406
+                    // })
407
+                    // let dept = {}
408
+                    // if (res.code == 200) {
409
+                    //     dept = res.data || {};
410
+                    // }
411
+                    // let res1 = await getDeptDetail(dept.deptId)
412
+                    // if (res1.code == 200) {
413
+                    //     console.log(res1, "res1")
414
+                    //     debugger
415
+                    // }
416
+                    data.checkProjectItemList = data.checkProjectItemList.map(item => ({
417
+                        ...item,
418
+                        checkUserList: [{ userName: data.checkedPersonnelName, userId: data.checkedPersonnelId, }]
419
+                    }))
420
+                    console.log(data.checkProjectItemList, "data.checkProjectItemList")
421
+                    data.checkProjectItemList.forEach(element => {
422
+
423
+                        if (element.checkUserList && element.checkUserList?.length > 0) {
424
+                            element.checkUserList.forEach(user => {
425
+                                checedProjectArr.push({
426
+                                    ...element,
427
+                                    checkUserList: user
428
+                                })
429
+                            });
430
+                        }
431
+                    });
432
+                } else {
433
+                    data.checkProjectItemList.forEach(element => {
434
+                        if (element.checkUserList && element.checkUserList?.length > 0) {
435
+                            element.checkUserList.forEach(user => {
436
+                                checedProjectArr.push({
437
+                                    ...element,
438
+                                    checkUserList: user
439
+                                })
440
+                            });
441
+                        } else {
442
+                            checedProjectArr.push({
443
+                                ...element,
444
+                                checkUserList: { userId: "" }
445
+                            })
446
+                        }
447
+                    });
448
+                }
449
+
450
+            }
451
+            console.log(checedProjectArr, "checedProjectArr")
452
+        //    debugger
453
+
454
+            this.formData = {
455
+                ...this.formData,
456
+                ...data,
457
+                checkLocation: `${data.terminlName}/${data.regionalName}/${data.channelName}`,
458
+                baseAttachmentList: data.baseAttachmentList?.length ? data.baseAttachmentList.map(img => ({
459
+                    ...img,
460
+                    url: img.attachmentUrl || img.url,
461
+                    name: img.attachmentName || img.name,
462
+                    extname: img.extname || (img.name ? img.name.split('.').pop() : '')
463
+                })) : [],
464
+                baseAttachmentList: (data.baseAttachmentList && data.baseAttachmentList?.length > 0) ? data.baseAttachmentList.map(img => ({
465
+                    ...img,
466
+                    url: img.attachmentUrl || img.url,
467
+                    name: img.attachmentName || img.name,
468
+                    extname: img.extname || (img.name ? img.name.split('.').pop() : '')
469
+                })) : [],
470
+                checkProjectItemList: checedProjectArr,
471
+                approvalHistory: (data.approvalHistory && data.approvalHistory?.length > 0) ? data.approvalHistory.map(item => ({
472
+                    name: item.name || '未知用户',
473
+                    date: item.date || new Date().toISOString().split('T')[0],
474
+                    content: item.content || '无审批内容'
475
+                })) : [],
476
+            }
477
+        },
478
+
479
+        convertTree(list = []) {
480
+            return list.map(node => ({
481
+                text: node.label,
482
+                value: node.code,
483
+                children: node.children ? this.convertTree(node.children) : null
484
+            }));
485
+        },
486
+
487
+        // 选择待整改问题图片
488
+        async onBaseAttachmentListelect(event) {
489
+            const file = await uploadFile(event);
490
+            this.formData.baseAttachmentList.push({
491
+                url: file.url,
492
+                name: file.newFileName,
493
+                attachmentName: file.newFileName,
494
+                attachmentUrl: file.url,
495
+                extname: file.newFileName.split('.').pop()
496
+            });
497
+        },
498
+
499
+        // 选择不合格图片
500
+        async onCheckRecordBaseAttachmentListSelect(event) {
501
+            const file = await uploadFile(event);
502
+            this.formData.checkRecordBaseAttachmentList.push({
503
+                url: file.url,
504
+                name: file.newFileName,
505
+                attachmentName: file.newFileName,
506
+                attachmentUrl: file.url,
507
+                extname: file.newFileName.split('.').pop()
508
+            });
509
+        },
510
+
511
+
512
+        formatDateTime(date) {
513
+            const y = date.getFullYear();
514
+            const m = String(date.getMonth() + 1).padStart(2, '0');
515
+            const d = String(date.getDate()).padStart(2, '0');
516
+            const h = String(date.getHours()).padStart(2, '0');
517
+            const mm = String(date.getMinutes()).padStart(2, '0');
518
+            const s = String(date.getSeconds()).padStart(2, '0');
519
+            return `${y}-${m}-${d} ${h}:${mm}:${s}`;
520
+        },
521
+        handleRejectConfirm(reason) {
522
+            this.formData.comment = reason
523
+            this.rejectForm()
524
+        },
525
+        rejectForm() {
526
+            // this.$refs.form.validate().then(res => {
527
+            uni.showLoading({ title: '提交中...', mask: true });
528
+
529
+            // 还原checkProjectItemList结构到后端原始格式
530
+            const restoredCheckProjectItemList = this.restoreOriginalCheckProjectItemList();
531
+
532
+            const payload = {
533
+                ...this.formData,
534
+                checkProjectItemList: restoredCheckProjectItemList,
535
+                taskId: this.id,
536
+                instanceId: this.instanceId,
537
+            };
538
+
539
+            approvalReject(payload)
540
+                .then(() => {
541
+                    uni.showToast({ title: '提交成功', icon: 'success' });
542
+                    setTimeout(() => uni.navigateBack(), 1500);
543
+                })
544
+                .catch((error) => {
545
+                    uni.showToast({ title: '操作失败,请稍后重试!', icon: 'none' });
546
+                    console.error('提交失败:', error);
547
+                });
548
+
549
+            // }).catch(err => {
550
+            //     console.log('表单验证失败:', err);
551
+            // });
552
+        },
553
+
554
+        // 提交表单
555
+        submitForm() {
556
+            this.$refs.form.validate().then(res => {
557
+                uni.showLoading({ title: '提交中...', mask: true });
558
+
559
+                // 还原checkProjectItemList结构到后端原始格式
560
+                const restoredCheckProjectItemList = this.restoreOriginalCheckProjectItemList();
561
+
562
+                const payload = {
563
+                    ...this.formData,
564
+                    checkProjectItemList: restoredCheckProjectItemList,
565
+                    taskId: this.id,
566
+                    instanceId: this.instanceId,
567
+                    comment: '审批通过'
568
+                };
569
+
570
+                approvalAgree(payload)
571
+                    .then(() => {
572
+                        uni.showToast({ title: '提交成功', icon: 'success' });
573
+                        setTimeout(() => uni.navigateBack(), 1500);
574
+                    })
575
+                    .catch((error) => {
576
+                        uni.showToast({ title: '操作失败,请稍后重试!', icon: 'none' });
577
+                        console.error('提交失败:', error);
578
+                    });
579
+
580
+            }).catch(err => {
581
+                console.log('表单验证失败:', err);
582
+            });
583
+        },
584
+
585
+        // 处理是否流转至班组选择
586
+        handleIsSelectTeamChange(e) {
587
+            const selectedValue = parseInt(e.detail.value);
588
+            this.$set(this.formData, 'isSelectTeam', selectedValue);
589
+
590
+            // 如果选择"否",清空班组相关字段
591
+            if (selectedValue === 0) {
592
+                this.$set(this.formData, 'selectTeamId', '');
593
+                this.$set(this.formData, 'selectTeamName', '');
594
+                this.$set(this.formData, 'selectTeamLeaderId', '');
595
+                this.$set(this.formData, 'selectTeamLeaderName', '');
596
+            }
597
+        },
598
+
599
+        // 处理整改班组选择
600
+        handlecheckedTeamIdChange(e) {
601
+
602
+            const { text, value } = e.detail.value[0];
603
+            this.$set(this.formData, 'selectTeamName', text);
604
+            this.$set(this.formData, 'selectTeamId', value);
605
+
606
+            // 获取班组长信息
607
+            getDeptManager(value).then(res => {
608
+                console.log(res?.data?.userId, "班组长信息");
609
+                this.$set(this.formData, 'selectTeamLeaderId', res?.data?.userId || '');
610
+                this.$set(this.formData, 'selectTeamLeaderName', res?.data?.nickName || '');
611
+            }).catch(err => {
612
+                console.error('获取班组长信息失败:', err);
613
+                this.$set(this.formData, 'selectTeamLeaderId', '');
614
+                this.$set(this.formData, 'selectTeamLeaderName', '');
615
+            });
616
+        },
617
+
618
+        // 还原checkProjectItemList到后端原始格式
619
+        restoreOriginalCheckProjectItemList() {
620
+            const groupedItems = {};
621
+
622
+            // 按照原始问题项分组
623
+            this.formData.checkProjectItemList?.forEach(item => {
624
+                const key = `${item.categoryCodeOne}-${item.categoryCodeTwo}-${item.problemDescription}`;
625
+                if (!groupedItems[key]) {
626
+                    groupedItems[key] = {
627
+                        ...item,
628
+                        checkUserList: []
629
+                    };
630
+                }
631
+
632
+                // 将用户对象添加到checkUserList数组中
633
+                if (item.checkUserList && typeof item.checkUserList === 'object') {
634
+                    groupedItems[key].checkUserList.push(item.checkUserList);
635
+                }
636
+            });
637
+
638
+            return Object.values(groupedItems);
639
+        }
640
+    }
641
+}
642
+</script>
643
+
644
+<style lang="scss" scoped>
645
+.report-container {
646
+    min-height: 100vh;
647
+    padding-top: 35px;
648
+
649
+    .card {
650
+        border-radius: 8px;
651
+        margin: 15px 0;
652
+        overflow: hidden;
653
+        box-shadow: rgba(0, 0, 0, 0.08) 0px 0px 3px 1px;
654
+    }
655
+}
656
+
657
+/* 折叠面板标题 */
658
+.collapse-title {
659
+    display: flex;
660
+    flex-direction: column;
661
+    padding: 12px 15px;
662
+
663
+    .collapse-summary {
664
+        font-size: 12px;
665
+        color: #999;
666
+        margin-top: 4px;
667
+    }
668
+}
669
+
670
+/* 表单项样式 */
671
+::v-deep .uni-forms-item {
672
+    margin-bottom: 0;
673
+    padding: 0 15px;
674
+
675
+    .uni-forms-item__label {
676
+        padding: 12px 0 8px;
677
+        font-size: 14px;
678
+        color: #666;
679
+        width: auto !important;
680
+    }
681
+
682
+    .uni-forms-item__content {
683
+        padding: 0 0 12px;
684
+        border-bottom: 1px solid #f0f0f0;
685
+    }
686
+
687
+    &:last-child .uni-forms-item__content {
688
+        border-bottom: none;
689
+    }
690
+}
691
+
692
+/* 审批历史样式 */
693
+.approval-history {
694
+    padding: 15px;
695
+
696
+    .approval-item {
697
+        display: flex;
698
+        // margin-bottom: 20px;
699
+
700
+        &:last-child {
701
+            margin-bottom: 0;
702
+        }
703
+
704
+        .timeline {
705
+            display: flex;
706
+            flex-direction: column;
707
+            align-items: center;
708
+            margin-right: 12px;
709
+
710
+            .circle {
711
+                width: 12px;
712
+                height: 12px;
713
+                border-radius: 50%;
714
+                background-color: #409EFF;
715
+                border: 2px solid #fff;
716
+                box-shadow: 0 0 0 2px #409EFF;
717
+            }
718
+
719
+            .line {
720
+                width: 2px;
721
+                flex: 1;
722
+                background-color: #409EFF;
723
+                margin-top: 4px;
724
+            }
725
+        }
726
+
727
+        .content {
728
+            flex: 1;
729
+            position: relative;
730
+            bottom: 8rpx;
731
+
732
+            .header {
733
+                display: flex;
734
+                justify-content: space-between;
735
+                align-items: center;
736
+                margin-bottom: 8px;
737
+
738
+                .name {
739
+                    font-size: 14px;
740
+                    font-weight: 500;
741
+                    color: #333;
742
+                }
743
+
744
+                .date {
745
+                    font-size: 12px;
746
+                    color: #999;
747
+                }
748
+            }
749
+
750
+            .approval-content {
751
+                font-size: 13px;
752
+                color: #666;
753
+                border: 1px solid #DDDDDD;
754
+                line-height: 1.5;
755
+                background-color: #f8f9fa;
756
+                padding: 10px;
757
+                border-radius: 6px;
758
+
759
+            }
760
+        }
761
+    }
762
+}
763
+
764
+.submit-btn {
765
+    margin-top: 20px;
766
+    width: calc(100% - 30px);
767
+    margin-left: 15px;
768
+}
769
+
770
+/* 底部弹窗样式调整 */
771
+::v-deep .uni-popup__wrapper {
772
+    border-radius: 16px 16px 0 0;
773
+
774
+    .uni-popup__wrapper-box {
775
+        max-height: 70vh;
776
+        overflow-y: auto;
777
+    }
778
+
779
+    .uni-data-pickerview {
780
+        padding-bottom: 20px;
781
+    }
782
+}
783
+
784
+/* 按钮组样式 - 左右布局 */
785
+.button-group {
786
+    display: flex;
787
+    justify-content: space-between;
788
+    align-items: center;
789
+    padding: 0 15px;
790
+    margin-top: 20px;
791
+    gap: 15px;
792
+}
793
+
794
+.button-group .custom-btn-white,
795
+.button-group .custom-btn-normal {
796
+    flex: 1;
797
+    margin-top: 0;
798
+    width: auto;
799
+}
800
+
801
+/* 问题列表样式 */
802
+.problem-item {
803
+    margin-bottom: 20px;
804
+    padding-bottom: 15px;
805
+    border-bottom: 1px dashed #e0e0e0;
806
+}
807
+
808
+.problem-item:last-child {
809
+    border-bottom: none;
810
+    margin-bottom: 15px;
811
+}
812
+
813
+.problem-header {
814
+    margin-bottom: 12px;
815
+}
816
+
817
+.problem-title {
818
+    font-size: 16px;
819
+    font-weight: 600;
820
+    color: #626FF0;
821
+}
822
+
823
+.problem-content {}
824
+
825
+.problem-person,
826
+.problem-description {
827
+    margin-bottom: 12px;
828
+}
829
+
830
+.problem-label {
831
+    display: block;
832
+    font-size: 14px;
833
+    color: #606266;
834
+    margin-bottom: 6px;
835
+    font-weight: 500;
836
+}
837
+
838
+.problem-description ::v-deep .uni-textarea__textarea {
839
+    background-color: #f5f7fa !important;
840
+    color: #909399 !important;
841
+    cursor: not-allowed !important;
842
+}
843
+</style>

+ 0 - 0
src/pages/problemRect/问题记录及整改单


+ 199 - 0
src/pages/questionStatistics/index.vue

@@ -0,0 +1,199 @@
1
+<template>
2
+    <home-container>
3
+        <div class="question-statistics-container">
4
+
5
+
6
+
7
+            <!-- 加载状态 -->
8
+            <div v-if="loading" class="loading-container">
9
+                <text>加载中...</text>
10
+            </div>
11
+
12
+            <!-- 统计表格区域 - 遍历分组后的数据 -->
13
+            <div v-else class="statistics-section">
14
+                <div v-for="(group, index) in groupedData" :key="index" class="statistic-table-wrapper">
15
+                    <!-- 每个统计表格的标题 - 使用deptName -->
16
+                    <h2 class="table-title">{{ group.title }}</h2>
17
+
18
+                    <!-- statistic-table组件 -->
19
+                    <statistic-table :columns="tableColumns" :data="group.data" >
20
+                        <template #column-progress="{ row, index }">
21
+                            <view class="progress-container">
22
+                                <u-line-progress :percentage="Math.round(row.progress * 100)" :showText="false"
23
+                                    :activeColor="getProgressColor(row.progress * 100)" height="20rpx"
24
+                                    width="200rpx"></u-line-progress>
25
+                                <text class="progress-text">{{ Math.round(row.progress * 100) }}%</text>
26
+                            </view>
27
+                        </template>
28
+                    </statistic-table>
29
+                </div>
30
+
31
+                <!-- 无数据提示 -->
32
+                <div v-if="groupedData.length === 0" class="no-data">
33
+                    <text>暂无统计数据</text>
34
+                </div>
35
+            </div>
36
+        </div>
37
+    </home-container>
38
+</template>
39
+
40
+<script>
41
+import HomeContainer from "@/components/HomeContainer.vue";
42
+import StatisticTable from "@/components/statistic-table/statistic-table.vue";
43
+import { getDailyAllUsersRanking } from "@/api/questionStatistics/questionStatistics";
44
+
45
+export default {
46
+    name: 'QuestionStatistics',
47
+    components: {
48
+        HomeContainer,
49
+        StatisticTable
50
+    },
51
+    data() {
52
+        return {
53
+            loading: false,
54
+            // 固定的表格列配置
55
+            tableColumns: [
56
+                { props: 'userName', title: '姓名' },
57
+                { props: 'completedTasks', title: '答题进度' },
58
+                { props: 'avgScore', title: '平均分' },
59
+                { props: 'progress', title: '完成率', slot: true }
60
+            ],
61
+            // 分组后的数据
62
+            groupedData: [],
63
+            // 原始接口数据
64
+            apiData: []
65
+        };
66
+    },
67
+    mounted() {
68
+        this.getDailyAllUsersRanking();
69
+    },
70
+    methods: {
71
+        async getDailyAllUsersRanking() {
72
+            const res = await getDailyAllUsersRanking({
73
+                pageNum: 1,
74
+                pageSize: 100
75
+            });
76
+            console.log('getDailyAllUsersRanking', res);
77
+            if (res.code === 200) {
78
+                this.apiData = res.data.rows || [];
79
+
80
+                this.groupDataByDept();
81
+            } else {
82
+                uni.showToast({
83
+                    title: '获取数据失败',
84
+                    icon: 'none'
85
+                });
86
+            }
87
+        },
88
+        // 按照deptName分组数据
89
+        groupDataByDept() {
90
+            if (!this.apiData || this.apiData.length === 0) {
91
+                this.groupedData = [];
92
+                return;
93
+            }
94
+
95
+            // 使用Map来分组数据
96
+            const deptMap = new Map();
97
+
98
+            this.apiData.forEach(item => {
99
+                const deptName = item.deptName || '未知部门';
100
+
101
+                if (!deptMap.has(deptName)) {
102
+                    deptMap.set(deptName, []);
103
+                }
104
+
105
+                // 格式化数据,确保包含所有需要的字段
106
+                const formattedItem = {
107
+                    userName: item.userName || '未知用户',
108
+                    completedTasks: item.completedTasks,
109
+                    avgScore: item.avgScore || 0,
110
+                    progress: item.completedTasks == 0 ? 0 : item.completedTasks / item.totalTasks
111
+                };
112
+
113
+                deptMap.get(deptName).push(formattedItem);
114
+            });
115
+
116
+            // 转换为数组格式
117
+            this.groupedData = Array.from(deptMap.entries()).map(([deptName, data]) => ({
118
+                title: deptName,
119
+                data: data
120
+            }));
121
+
122
+            console.log('分组后的数据:', this.groupedData);
123
+        },
124
+        // 根据完成比例返回对应的颜色
125
+        getProgressColor(percentage) {
126
+            if (percentage >= 100) {
127
+                return '#2B7BFF'; // 大于等于100%时使用蓝色
128
+            } else if (percentage > 0 && percentage < 100) {
129
+                return '#03AF43'; // 0-100%之间使用绿色
130
+            } else {
131
+                return '#E40808'; // 0%时使用红色
132
+            }
133
+        }
134
+    }
135
+};
136
+</script>
137
+
138
+<style lang="scss" scoped>
139
+.question-statistics-container {
140
+    padding: 32rpx;
141
+}
142
+
143
+
144
+
145
+.loading-container {
146
+    display: flex;
147
+    justify-content: center;
148
+    align-items: center;
149
+    height: 200rpx;
150
+    font-size: 32rpx;
151
+    color: #666;
152
+}
153
+
154
+.statistics-section {
155
+    display: flex;
156
+    flex-direction: column;
157
+    gap: 48rpx;
158
+}
159
+
160
+.statistic-table-wrapper {
161
+    background: #fff;
162
+    border-radius: 16rpx;
163
+    padding: 32rpx;
164
+    box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.08);
165
+
166
+    .table-title {
167
+        font-size: 36rpx;
168
+        font-weight: 600;
169
+        color: #333;
170
+        margin-bottom: 24rpx;
171
+        padding-bottom: 16rpx;
172
+        border-bottom: 2rpx solid #f0f0f0;
173
+    }
174
+}
175
+
176
+.no-data {
177
+    display: flex;
178
+    justify-content: center;
179
+    align-items: center;
180
+    height: 200rpx;
181
+    font-size: 32rpx;
182
+    color: #999;
183
+    background: #f8f8f8;
184
+    border-radius: 16rpx;
185
+}
186
+
187
+.progress-container {
188
+    display: flex;
189
+    align-items: center;
190
+    gap: 16rpx;
191
+
192
+    .progress-text {
193
+        font-size: 24rpx;
194
+        color: #4873E3;
195
+        font-weight: 500;
196
+        min-width: 60rpx;
197
+    }
198
+}
199
+</style>

+ 189 - 0
src/pages/register.vue

@@ -0,0 +1,189 @@
1
+<template>
2
+  <view class="normal-login-container">
3
+    <view class="logo-content align-center justify-center flex">
4
+      <image style="width: 100rpx;height: 100rpx;" :src="globalConfig.appInfo.logo" mode="widthFix">
5
+      </image>
6
+      <text class="title">若依移动端注册</text>
7
+    </view>
8
+    <view class="login-form-content">
9
+      <view class="input-item flex align-center">
10
+        <view class="iconfont icon-user icon"></view>
11
+        <input v-model="registerForm.username" class="input" type="text" placeholder="请输入账号" maxlength="30" />
12
+      </view>
13
+      <view class="input-item flex align-center">
14
+        <view class="iconfont icon-password icon"></view>
15
+        <input v-model="registerForm.password" type="password" class="input" placeholder="请输入密码" maxlength="20" />
16
+      </view>
17
+      <view class="input-item flex align-center">
18
+        <view class="iconfont icon-password icon"></view>
19
+        <input v-model="registerForm.confirmPassword" type="password" class="input" placeholder="请输入重复密码" maxlength="20" />
20
+      </view>
21
+      <view class="input-item flex align-center" style="width: 60%;margin: 0px;" v-if="captchaEnabled">
22
+        <view class="iconfont icon-code icon"></view>
23
+        <input v-model="registerForm.code" type="number" class="input" placeholder="请输入验证码" maxlength="4" />
24
+        <view class="login-code"> 
25
+          <image :src="codeUrl" @click="getCode" class="login-code-img"></image>
26
+        </view>
27
+      </view>
28
+      <view class="action-btn">
29
+        <button @click="handleRegister()" class="register-btn cu-btn block bg-blue lg round">注册</button>
30
+      </view>
31
+    </view>
32
+    <view class="xieyi text-center">
33
+      <text @click="handleUserLogin" class="text-blue">使用已有账号登录</text>
34
+    </view>
35
+  </view>
36
+</template>
37
+
38
+<script>
39
+  import { getCodeImg, register } from '@/api/login'
40
+
41
+  export default {
42
+    data() {
43
+      return {
44
+        codeUrl: "",
45
+        captchaEnabled: true,
46
+        globalConfig: getApp().globalData.config,
47
+        registerForm: {
48
+          username: "",
49
+          password: "",
50
+          confirmPassword: "",
51
+          code: "",
52
+          uuid: ""
53
+        }
54
+      }
55
+    },
56
+    created() {
57
+      this.getCode()
58
+    },
59
+    methods: {
60
+      // 用户登录
61
+      handleUserLogin() {
62
+        this.$tab.navigateTo(`/pages/login`)
63
+      },
64
+      // 获取图形验证码
65
+      getCode() {
66
+        getCodeImg().then(res => {
67
+          this.captchaEnabled = res.captchaEnabled === undefined ? true : res.captchaEnabled
68
+          if (this.captchaEnabled) {
69
+            this.codeUrl = 'data:image/gif;base64,' + res.img
70
+            this.registerForm.uuid = res.uuid
71
+          }
72
+        })
73
+      },
74
+      // 注册方法
75
+      async handleRegister() {
76
+        if (this.registerForm.username === "") {
77
+          this.$modal.msgError("请输入您的账号")
78
+        } else if (this.registerForm.password === "") {
79
+          this.$modal.msgError("请输入您的密码")
80
+        } else if (this.registerForm.confirmPassword === "") {
81
+          this.$modal.msgError("请再次输入您的密码")
82
+        } else if (this.registerForm.password !== this.registerForm.confirmPassword) {
83
+          this.$modal.msgError("两次输入的密码不一致")
84
+        } else if (this.registerForm.code === "" && this.captchaEnabled) {
85
+          this.$modal.msgError("请输入验证码")
86
+        } else {
87
+          this.$modal.loading("注册中,请耐心等待...")
88
+          this.register()
89
+        }
90
+      },
91
+      // 用户注册
92
+      async register() {
93
+        register(this.registerForm).then(res => {
94
+          this.$modal.closeLoading()
95
+          uni.showModal({
96
+          	title: "系统提示",
97
+          	content: "恭喜你,您的账号 " + this.registerForm.username + " 注册成功!",
98
+          	success: function (res) {
99
+          		if (res.confirm) {
100
+                uni.redirectTo({ url: `/pages/login` });
101
+          		}
102
+          	}
103
+          })
104
+        }).catch(() => {
105
+          if (this.captchaEnabled) {
106
+            this.getCode()
107
+          }
108
+        })
109
+      }
110
+    }
111
+  }
112
+</script>
113
+
114
+<style lang="scss" scoped>
115
+  page {
116
+    background-color: #ffffff;
117
+  }
118
+
119
+  .normal-login-container {
120
+    width: 100%;
121
+
122
+    .logo-content {
123
+      width: 100%;
124
+      font-size: 21px;
125
+      text-align: center;
126
+      padding-top: 15%;
127
+
128
+      image {
129
+        border-radius: 4px;
130
+      }
131
+
132
+      .title {
133
+        margin-left: 10px;
134
+      }
135
+    }
136
+
137
+    .login-form-content {
138
+      text-align: center;
139
+      margin: 20px auto;
140
+      margin-top: 15%;
141
+      width: 80%;
142
+
143
+      .input-item {
144
+        margin: 20px auto;
145
+        background-color: #f5f6f7;
146
+        height: 45px;
147
+        border-radius: 20px;
148
+
149
+        .icon {
150
+          font-size: 38rpx;
151
+          margin-left: 10px;
152
+          color: #999;
153
+        }
154
+
155
+        .input {
156
+          width: 100%;
157
+          font-size: 14px;
158
+          line-height: 20px;
159
+          text-align: left;
160
+          padding-left: 15px;
161
+        }
162
+
163
+      }
164
+
165
+      .register-btn {
166
+        margin-top: 40px;
167
+        height: 45px;
168
+      }
169
+
170
+      .xieyi {
171
+        color: #333;
172
+        margin-top: 20px;
173
+      }
174
+      
175
+      .login-code {
176
+        height: 38px;
177
+        float: right;
178
+      
179
+        .login-code-img {
180
+          height: 38px;
181
+          position: absolute;
182
+          margin-left: 10px;
183
+          width: 200rpx;
184
+        }
185
+      }
186
+    }
187
+  }
188
+
189
+</style>

+ 164 - 0
src/pages/seizeStatistics/components/switch-tab.vue

@@ -0,0 +1,164 @@
1
+<template>
2
+  <view class="capsule-tab-container">
3
+    <view v-for="(item, index) in tabList" :key="index" class="capsule-tab-item" :class="{
4
+      'capsule-tab-active': activeIndex == item.value,
5
+      'capsule-tab-first': index == 0,
6
+      'capsule-tab-last': index == tabList.length - 1
7
+    }" @click="handleTabClick(index, item)">
8
+      <text class="capsule-tab-text">{{ item.label || item.text || item.name }}</text>
9
+    </view>
10
+  </view>
11
+</template>
12
+
13
+<script>
14
+export default {
15
+  name: 'SwitchTab',
16
+  props: {
17
+    // tab数据数组
18
+    tabList: {
19
+      type: Array,
20
+      default: () => [],
21
+      required: true
22
+    },
23
+    // 默认激活的索引
24
+    defaultActive: {
25
+      type: Number,
26
+      default: 0
27
+    }
28
+  },
29
+  data() {
30
+    return {
31
+      activeIndex: this.defaultActive
32
+    }
33
+  },
34
+  watch: {
35
+    defaultActive: {
36
+      handler(newVal) {
37
+
38
+        this.activeIndex = newVal
39
+      },
40
+      immediate: true
41
+    }
42
+  },
43
+  methods: {
44
+    // 处理tab点击事件
45
+    handleTabClick(index, item) {
46
+      if (this.activeIndex === item.value) {
47
+        return
48
+      }
49
+
50
+      this.activeIndex = item.value;
51
+      
52
+      // 向外传递被点击的对象
53
+      this.$emit('tabChange', {
54
+        index,
55
+        item,
56
+        value: item.value || item.id || index
57
+      })
58
+    },
59
+
60
+    // 外部调用的方法:切换tab
61
+    switchTab(index) {
62
+      if (index >= 0 && index < this.tabList.length && index !== this.activeIndex) {
63
+        this.activeIndex = index
64
+        const item = this.tabList[index]
65
+        this.$emit('tabChange', {
66
+          index,
67
+          item,
68
+          value: item.value || item.id || index
69
+        })
70
+      }
71
+    },
72
+
73
+    // 外部调用的方法:获取当前激活的tab
74
+    getCurrentTab() {
75
+      return {
76
+        index: this.activeIndex,
77
+        item: this.tabList[this.activeIndex],
78
+        value: this.tabList[this.activeIndex]?.value || this.tabList[this.activeIndex]?.id || this.activeIndex
79
+      }
80
+    }
81
+  }
82
+}
83
+</script>
84
+
85
+<style lang="scss" scoped>
86
+.capsule-tab-container {
87
+  display: inline-flex;
88
+  background: #f5f5f5;
89
+  border-radius: 32rpx;
90
+  padding: 2rpx;
91
+  box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
92
+}
93
+
94
+.capsule-tab-item {
95
+  position: relative;
96
+  padding: 6rpx 12rpx;
97
+  border-radius: 30rpx;
98
+  cursor: pointer;
99
+  transition: all 0.3s ease;
100
+  user-select: none;
101
+
102
+  // 第一个和最后一个的特殊圆角处理
103
+  //   &.capsule-tab-first {
104
+  //     border-top-right-radius: 0;
105
+  //     border-bottom-right-radius: 0;
106
+  //   }
107
+
108
+  //   &.capsule-tab-last {
109
+  //     border-top-left-radius: 0;
110
+  //     border-bottom-left-radius: 0;
111
+  //   }
112
+
113
+  // 非激活状态
114
+  &:not(.capsule-tab-active) {
115
+    background: transparent;
116
+
117
+    .capsule-tab-text {
118
+      color: #666;
119
+      font-weight: normal;
120
+    }
121
+
122
+
123
+  }
124
+
125
+  // 激活状态
126
+  &.capsule-tab-active {
127
+    background: #5896FC;
128
+    box-shadow: 0 2rpx 8rpx rgba(24, 144, 255, 0.3);
129
+
130
+    .capsule-tab-text {
131
+      color: #ffffff;
132
+      font-weight: bold;
133
+    }
134
+  }
135
+}
136
+
137
+.capsule-tab-text {
138
+  font-size: 24rpx;
139
+  line-height: 1;
140
+  transition: all 0.3s ease;
141
+}
142
+
143
+// 响应式适配
144
+@media (max-width: 768px) {
145
+  .capsule-tab-container {
146
+    width: auto;
147
+    justify-content: center;
148
+  }
149
+
150
+  .capsule-tab-item {
151
+    flex: none;
152
+    text-align: center;
153
+    padding: 8rpx 18rpx;
154
+
155
+    // &.capsule-tab-first {
156
+    //   border-radius: 30rpx 0 0 30rpx;
157
+    // }
158
+
159
+    // &.capsule-tab-last {
160
+    //   border-radius: 0 30rpx 30rpx 0;
161
+    // }
162
+  }
163
+}
164
+</style>

Plik diff jest za duży
+ 1492 - 0
src/pages/seizeStatistics/index.vue


Plik diff jest za duży
+ 1238 - 0
src/pages/seizedReported/index.vue


Plik diff jest za duży
+ 1531 - 0
src/pages/seizedReportedVoice/index.vue


+ 732 - 0
src/pages/seizureRecord/detail.vue

@@ -0,0 +1,732 @@
1
+<template>
2
+  <home-container>
3
+    <view class="report-container">
4
+      <!-- 表单区域 -->
5
+      <uni-forms ref="form" :rules="rules" :modelValue="formData" label-position="top" err-show-type="modal">
6
+        <!-- <scroll-view class="form-scroll" scroll-y> -->
7
+        <!-- 安检员信息分组 (默认折叠) -->
8
+        <uni-card :is-shadow="true" :shadow="shadow">
9
+          <uni-collapse :accordion="false">
10
+            <uni-collapse-item title="安检员信息" name="group1" :open="false" :show-animation="true">
11
+              <template v-slot:title>
12
+
13
+                <view class="header-section collapse-title">
14
+                  <uni-row>
15
+                    <uni-col :span="8">
16
+                      <view>
17
+                        <image v-if="currentUser.avatar" :src="currentUser.avatar" class="cu-avatar xl round"></image>
18
+                      </view>
19
+                    </uni-col>
20
+                    <uni-col :span="16" class="flex padding justify-between">
21
+                      <uni-row>
22
+                        <uni-col>
23
+                          <text>{{ formData.inspectUserName }} </text>
24
+                          <!-- <text class="collapse-summary">{{ formData.securityLocationText }} </text>
25
+                          <text class="collapse-summary">{{ formData.positionText }} </text> -->
26
+                        </uni-col>
27
+                        <uni-col>
28
+                          <view v-if="formData.teamName"><text>查获班组:</text><text class="collapse-summary">{{
29
+                            formData.teamName }}</text></view>
30
+                          <view v-if="formData.reportTeamText"><text>上报班组:</text><text class="collapse-summary">{{
31
+                            formData.reportTeamText }}</text></view>
32
+                        </uni-col>
33
+                      </uni-row>
34
+
35
+                    </uni-col>
36
+                  </uni-row>
37
+                </view>
38
+              </template>
39
+
40
+              <uni-forms-item label="安检员" name="inspectUserName">
41
+                <text class="form-value">{{ formData.inspectUserName }}</text>
42
+              </uni-forms-item>
43
+
44
+              <uni-forms-item label="查获时间" name="seizureTime">
45
+
46
+                <!-- <uni-datetime-picker type="datetime" :readonly="true" :start="startDate" :end="endDate"
47
+                  v-model="formData.seizureTime" /> -->
48
+                <uni-easyinput v-model="formData.seizureTime" :disabled="true" class="custom-location" />
49
+
50
+              </uni-forms-item>
51
+
52
+              <uni-forms-item label="安检位置" name="securityLocation">
53
+                <!-- <uni-data-picker :localdata="position_options" popup-title="请选择安检位置" v-model="formData.securityLocation"
54
+                  :readonly="true" /> -->
55
+                <uni-easyinput v-model="formData.securityLocation" :disabled="true" class="custom-location" />
56
+              </uni-forms-item>
57
+
58
+              <uni-forms-item label="安检岗位" name="checkMethod">
59
+                <!-- <uni-data-picker :localdata="item_check_method_options" :map="dataOptionMap" popup-title="请选择安检岗位"
60
+                  v-model="formData.checkMethod" :readonly="true" @change="onItemCheckMethodChange" /> -->
61
+
62
+                <uni-easyinput v-model="formData.checkMethodText" :disabled="true" class="custom-location" />
63
+
64
+              </uni-forms-item>
65
+
66
+              <uni-forms-item label="查获班组" name="team">
67
+                <!-- <uni-data-picker :localdata="teams" :readonly="true" popup-title="请选择查获班组" v-model="formData.team"
68
+                  @change="onTeamChange" /> -->
69
+                <uni-easyinput v-model="formData.team" :disabled="true" class="custom-location" />
70
+
71
+              </uni-forms-item>
72
+
73
+              <uni-forms-item label="上报班组" name="reportTeam">
74
+                <!-- <uni-data-picker :localdata="teams" :readonly="true" popup-title="请选择上报班组" v-model="formData.reportTeam"
75
+                  @change="onReportTeamChange" /> -->
76
+                <uni-easyinput v-model="formData.reportTeam" :disabled="true" class="custom-location" />
77
+
78
+              </uni-forms-item>
79
+            </uni-collapse-item>
80
+          </uni-collapse>
81
+        </uni-card>
82
+
83
+        <!-- 违禁品信息分组 -->
84
+        <uni-card :is-shadow="true" :shadow="shadow">
85
+          <uni-collapse :accordion="false" :value="['group2']">
86
+            <uni-collapse-item title="违禁品信息" name="group2" :show-animation="true">
87
+              <template v-slot:title>
88
+                <view class="collapse-title">
89
+                  <text>违禁品信息</text>
90
+                  <text class="collapse-summary" v-if="formData.forbiddenName">{{ formData.forbiddenName }} ·
91
+                    {{ formData.quantity }}{{ formData.unitText }}</text>
92
+
93
+                </view>
94
+              </template>
95
+
96
+              <uni-forms-item label="违禁品类别/类型" name="forbiddenCategory">
97
+                <!-- <uni-data-picker :localdata="item_category_options" :map="dataTreeOptionMap" popup-title="请选择违禁品类别"
98
+                  v-model="formData.forbiddenCategory" @change="onCategoryChange" /> -->
99
+                <uni-easyinput v-model="formData.forbiddenCategory" :disabled="true" class="custom-location" />
100
+
101
+              </uni-forms-item>
102
+
103
+              <uni-forms-item label="违禁品名称" name="forbiddenName">
104
+                <!-- <uni-combox :candidates="forbiddenNames" v-model="formData.forbiddenName"></uni-combox> -->
105
+                <uni-easyinput v-model="formData.forbiddenName" :disabled="true" class="custom-location" />
106
+
107
+              </uni-forms-item>
108
+
109
+              <uni-forms-item label="数量" name="quantity">
110
+                <view class="number-input">
111
+                  <uni-easyinput v-model="formData.quantity" type="number" placeholder="1" class="input-number"
112
+                    :disabled="true" />
113
+                  <!-- <uni-easyinput v-model="formData.unit" :disabled="true" class="custom-location" /> -->
114
+
115
+                  <!-- <uni-data-picker :localdata="item_unit_options" :map="dataOptionMap" popup-title="请选择单位"
116
+                    v-model="formData.unit" @change="onUnitChange" name="unit" /> -->
117
+                </view>
118
+              </uni-forms-item>
119
+            </uni-collapse-item>
120
+          </uni-collapse>
121
+        </uni-card>
122
+
123
+        <!-- 查获部位分组 -->
124
+        <uni-card :is-shadow="true" :shadow="shadow">
125
+          <uni-collapse :accordion="false" :value="['group3']">
126
+            <uni-collapse-item title="查获部位" name="group3" :show-animation="true">
127
+              <template v-slot:title>
128
+                <view class="collapse-title">
129
+                  <text>查获部位</text>
130
+
131
+                  <text class="collapse-summary" v-if="formData.partCategoryText">{{ formData.partCategoryText }} ·
132
+                    {{ formData.customLocation || formData.locationText }}</text>
133
+                </view>
134
+              </template>
135
+
136
+              <uni-forms-item label="部位类别/类型" name="partCategory">
137
+
138
+                <!-- <uni-data-picker :localdata="check_point_options" :map="dataTreeOptionMap" popup-title="请选择部位类别"
139
+                  v-model="formData.partCategory" @change="onPartCategoryChange" /> -->
140
+                <uni-easyinput v-model="formData.partCategory" :disabled="true" class="custom-location" />
141
+
142
+              </uni-forms-item>
143
+
144
+
145
+              <uni-forms-item label="具体位置" name="location">
146
+                <view class="location-input">
147
+                  <!-- <uni-easyinput v-model="formData.customLocation" :disabled="true" class="custom-location" /> -->
148
+                  <uni-easyinput v-model="formData.customLocation" :disabled="true" class="custom-location" />
149
+
150
+                </view>
151
+              </uni-forms-item>
152
+
153
+              <uni-forms-item label="处理方式" name="handlingMethod">
154
+                <!-- <uni-data-picker popup-title="请选择处理方式" :map="dataOptionMap" :localdata="item_handling_method_options"
155
+                  v-model="formData.handlingMethod" @change="onMethodChange" /> -->
156
+                <uni-easyinput v-model="formData.handlingMethod" :disabled="true" class="custom-location" />
157
+
158
+              </uni-forms-item>
159
+
160
+              <uni-forms-item label="是否隐匿夹带" name="isConcealed">
161
+                <uni-easyinput v-model="formData.isConcealed" :disabled="true" class="custom-location" />
162
+
163
+              </uni-forms-item>
164
+            </uni-collapse-item>
165
+          </uni-collapse>
166
+        </uni-card>
167
+
168
+        <!-- 违禁品照片分组 -->
169
+        <uni-card :is-shadow="true" :shadow="shadow">
170
+          <uni-collapse :accordion="false" :value="['group4']">
171
+            <uni-collapse-item title="违禁品照片" name="group4" :show-animation="true">
172
+              <uni-file-picker v-if="ready" v-model="formData.images" limit="8" title="最多上传8张" :image-styles="imageStyles"
173
+                fileMediatype="image" mode="grid" :readonly="true"/>
174
+            </uni-collapse-item>
175
+          </uni-collapse>
176
+        </uni-card>
177
+
178
+        <!-- 旅客信息分组 -->
179
+        <!-- <uni-card :is-shadow="true" :shadow="shadow">
180
+          <uni-collapse :accordion="false" :value="['group5']">
181
+            <uni-collapse-item title="旅客信息" name="group5" :show-animation="true">
182
+              <template v-slot:title>
183
+                <view class="collapse-title">
184
+                  <text>旅客信息</text>
185
+                  <text class="collapse-summary" v-if="formData.passengerName">{{ formData.passengerName }} ·
186
+                    {{ formData.flightNumber || '无航班号' }}</text>
187
+                </view>
188
+              </template>
189
+
190
+              <uni-forms-item label="姓名" name="passengerName">
191
+
192
+                <uni-easyinput v-model="formData.passengerName" placeholder="请输入旅客姓名" :disabled="true" />
193
+              </uni-forms-item>
194
+
195
+              <uni-forms-item label="证件号" name="passengerId">
196
+                <uni-easyinput v-model="formData.passengerId" placeholder="请输入证件号 (可选)" :disabled="true" />
197
+              </uni-forms-item>
198
+
199
+              <uni-forms-item label="航班号" name="flightNumber">
200
+                <uni-easyinput v-model="formData.flightNumber" placeholder="例如:CA1234 (可选)" :disabled="true" />
201
+              </uni-forms-item>
202
+            </uni-collapse-item>
203
+          </uni-collapse>
204
+        </uni-card> -->
205
+
206
+        <!-- 提交按钮 -->
207
+        <!-- <button type="primary" class="submit-btn" @click="submitForm">提交上报</button> -->
208
+        <!-- </scroll-view> -->
209
+      </uni-forms>
210
+
211
+    </view>
212
+  </home-container>
213
+
214
+</template>
215
+
216
+<script>
217
+import HomeContainer from "@/components/HomeContainer.vue";
218
+
219
+import { treeSelectByType } from "@/api/system/common"
220
+import useDictMixin from '@/utils/dict'
221
+import { addSeizureRecord } from '@/api/seizure/seizureRecord.js'
222
+import { getInfo } from '@/api/seizure/seizureRecord.js'
223
+
224
+
225
+
226
+
227
+
228
+export default {
229
+  components: { HomeContainer },
230
+
231
+  mixins: [useDictMixin],
232
+
233
+
234
+  computed: {
235
+    currentUser() {
236
+      return this.$store.state.user;
237
+    }
238
+  },
239
+  data() {
240
+    return {
241
+       ready: false,
242
+      // 阴影效果配置
243
+      shadow: '0 2px 6px rgba(0, 0, 0, 0.1)',
244
+      // 表单数据
245
+      formData: {
246
+        // 安检员信息
247
+        inspectUserName: this.$store.state.user.name,
248
+        inspectUserId: this.$store.state.user.id,
249
+        seizureTime: this.formatDateTime(new Date()),
250
+        securityLocation: '',
251
+        securityLocationText: '',
252
+        checkMethod: '',    // 检查岗位
253
+        checkMethodText: '',
254
+        teamId: 1,
255
+        team: 'morning-a',
256
+        teamName: '早班A组',
257
+        reportTeam: '',
258
+        reportTeamText: '',
259
+
260
+        // 违禁品信息
261
+        forbiddenCategory: '',
262
+        forbiddenCategoryText: '',
263
+        forbiddenType: '',
264
+        forbiddenTypeText: '',
265
+        forbiddenName: '',
266
+        quantity: '1',
267
+        unit: '',
268
+        unitText: '',
269
+
270
+        // 查获部位
271
+        partCategory: '',
272
+        partCategoryText: '',
273
+        partType: '',
274
+        partTypeText: '',
275
+        location: '',
276
+        locationText: '',
277
+        customLocation: '',
278
+        handlingMethod: '',
279
+        handlingMethodDesc: '',
280
+        isConcealed: 'no',
281
+
282
+        // 旅客信息
283
+        passengerName: '',
284
+        passengerId: '',
285
+        flightNumber: '',
286
+
287
+        // 照片
288
+        images: [],
289
+
290
+        //暂时不要
291
+        channelCode: '无',
292
+        attendanceId: 111111,
293
+        attendanceTeamId: 111111,
294
+        attendanceTeamName: '无',
295
+        attendanceDepartmentId: 111111,
296
+        attendanceDepartmentName: '无',
297
+        attendanceStationId: 111111,
298
+        attendanceStationName: '无',
299
+        regionalCode: 111111,
300
+        regionalName: '无',
301
+        terminlCode: 111111,
302
+        terminlName: '无',
303
+        channelName: '无',
304
+        inspectTeamId: 111111,
305
+        inspectTeamName: '无',
306
+        inspectDepartmentId: 111111,
307
+        inspectDepartmentName: '无',
308
+        checkMethodDesc: '无',
309
+
310
+      },
311
+
312
+
313
+      // 验证规则
314
+      rules: {
315
+
316
+      },
317
+
318
+      // 数据选项
319
+      item_check_method_options: [],   // 检查岗位
320
+      position_options: [],  // 位置通道
321
+      teams: [
322
+        { text: '早班A组', value: 'morning-a' },
323
+        { text: '早班B组', value: 'morning-b' },
324
+        { text: '中班A组', value: 'noon-a' },
325
+        { text: '中班B组', value: 'noon-b' },
326
+        { text: '晚班A组', value: 'night-a' },
327
+        { text: '晚班B组', value: 'night-b' }
328
+      ],
329
+      forbiddenNames: ['打火机', '汽油'],
330
+      item_category_options: [], // 物品分类
331
+      typeData: [],
332
+      item_unit_options: [],
333
+      check_point_options: [],
334
+      partTypeData: [],
335
+      locations: [
336
+        { text: '上衣口袋', value: 'jacket-pocket' },
337
+        { text: '裤子口袋', value: 'pants-pocket' },
338
+        { text: '行李箱夹层', value: 'luggage-interlayer' },
339
+        { text: '背包侧袋', value: 'bag-side' }
340
+      ],
341
+      item_handling_method_options: [], // 处理方式
342
+      concealOptions: [
343
+        { name: '是', value: 'yes' },
344
+        { name: '否', value: 'no' }
345
+      ],
346
+
347
+      // 其他数据
348
+      startDate: '2020-01-01',
349
+      endDate: '2030-12-31',
350
+      imageStyles: {
351
+        width: 80,
352
+        height: 80,
353
+        border: {
354
+          color: '#eee',
355
+          width: '1px',
356
+          style: 'solid'
357
+        }
358
+      },
359
+      dataOptionMap: { text: 'label', value: 'value' }, // 级联字段映射关系
360
+      dataTreeOptionMap: { text: 'label', value: 'code' } // 级联字段映射关系树结构
361
+    }
362
+  },
363
+  onLoad(options) {
364
+    // 从 URL 参数取 id
365
+    const id = options.id
366
+    if (id) {
367
+      this.loadDetail(id)
368
+    }
369
+
370
+
371
+  },
372
+  methods: {
373
+    async loadDetail(id) {
374
+      uni.showLoading({ title: '加载中...' })
375
+      try {
376
+        console.log("id", id)
377
+        const res = await getInfo(id)
378
+        console.log("res", res)
379
+        if (res.code === 200) {
380
+          this.formData = res.data
381
+          this.formData.securityLocation = res.data.regionalName
382
+          this.formData.securityLocationText = res.data.regionalName
383
+          this.formData.team = res.data.inspectStationName+" / "+res.data.inspectDepartmentName+" / "+res.data.inspectTeamName
384
+          this.formData.teamName = res.data.inspectStationName+" / "+res.data.inspectDepartmentName+" / "+res.data.inspectTeamName,
385
+          this.formData.reportTeam = res.data.attendanceTeamName
386
+          this.formData.reportTeamText = res.data.attendanceTeamName
387
+          this.formData.passengerId = res.data.passengerCard
388
+          this.formData.flightNumber = res.data.passengerFlight
389
+          this.formData.checkMethodText = res.data.checkMethodDesc
390
+
391
+
392
+
393
+
394
+
395
+
396
+
397
+
398
+          this.formData.forbiddenCategory = res.data.itemSeizureItemsList[0].categoryNameOne,
399
+            this.formData.forbiddenCategoryText = res.data.itemSeizureItemsList[0].categoryNameOne,
400
+            this.formData.forbiddenType = res.data.itemSeizureItemsList[0].forbiddenTypeText,
401
+            this.formData.forbiddenTypeText = res.data.itemSeizureItemsList[0].forbiddenTypeText,
402
+            this.formData.quantity = res.data.itemSeizureItemsList[0].quantity,
403
+            this.formData.unit = res.data.itemSeizureItemsList[0].unitDesc,
404
+            this.formData.unitDesc = res.data.itemSeizureItemsList[0].unitDesc,
405
+
406
+            this.formData.forbiddenName = res.data.itemSeizureItemsList[0].itemName,
407
+
408
+            this.formData.partCategory = res.data.itemSeizureItemsList[0].checkPositionNameOne,
409
+            this.formData.partCategoryText = res.data.itemSeizureItemsList[0].checkPositionNameOne,
410
+            this.formData.customLocation = res.data.itemSeizureItemsList[0].checkPositionSpecific,
411
+
412
+            this.formData.isActiveConcealment = res.data.itemSeizureItemsList[0].isActiveConcealment,
413
+
414
+            this.formData.handlingMethod = res.data.itemSeizureItemsList[0].handlingMethodDesc,
415
+            this.formData.handlingMethodDesc = res.data.itemSeizureItemsList[0].handlingMethodDesc,
416
+            console.log("")
417
+            if(res.data.itemSeizureItemsList[0].baseAttachmentList){
418
+this.formData.images = res.data.itemSeizureItemsList[0].baseAttachmentList.map(item => ({
419
+              url: item.attachmentUrl,
420
+              name: item.attachmentUrl.split('/').pop(),
421
+              extname: '.' + item.attachmentUrl.split('.').pop()
422
+            }));
423
+            }
424
+
425
+            
426
+
427
+            // 数据装完后才让组件渲染
428
+    this.$nextTick(() => (this.ready = true));
429
+
430
+          if (res.data.itemSeizureItemsList[0].isActiveConcealment == 1) {
431
+            this.formData.isConcealed = '是'
432
+          } else {
433
+            this.formData.isConcealed = '否'
434
+          }
435
+          console.log("this.formData", this.formData)
436
+          // 初始化下拉选项
437
+          // this.initOptions()
438
+        } else {
439
+          uni.showToast({ title: '详情加载失败', icon: 'none' })
440
+        }
441
+      } catch (e) {
442
+        console.log("eee", e)
443
+        uni.showToast({ title: '加载失败', icon: 'none' })
444
+      } finally {
445
+        uni.hideLoading()
446
+      }
447
+    },
448
+
449
+
450
+
451
+    formatDateTime(date) {
452
+      const y = date.getFullYear();
453
+      const m = String(date.getMonth() + 1).padStart(2, '0');
454
+      const d = String(date.getDate()).padStart(2, '0');
455
+      const h = String(date.getHours()).padStart(2, '0');
456
+      const mm = String(date.getMinutes()).padStart(2, '0');
457
+      const s = String(date.getSeconds()).padStart(2, '0');
458
+      return `${y}-${m}-${d} ${h}:${mm}:${s}`;
459
+    },
460
+
461
+    // 安检位置变化
462
+    onLocationChange(e) {
463
+      this.formData.securityLocation = e.detail.value[1]?.value || '';
464
+      this.formData.securityLocationText = e.detail.value[1]?.label || e.detail.value[0]?.label || '';
465
+    },
466
+
467
+    // 通用取值:只拿最后一级
468
+    lastValue(e) {
469
+      const val = e.detail.value;
470
+      if (Array.isArray(val)) return val[val.length - 1] || '';
471
+      if (val && typeof val === 'object') return val.value || '';
472
+      return String(val || '');
473
+    },
474
+
475
+    // 安检岗位
476
+    onItemCheckMethodChange(e) {
477
+      this.formData.checkMethod = e.detail.value[0]?.value || '';
478
+      this.formData.checkMethodText = e.detail.value[0]?.text || e.detail.value[0]?.text || '';
479
+    },
480
+
481
+    // 查获班组变化
482
+    onTeamChange(e) {
483
+      this.formData.team = e.detail.value;
484
+      this.formData.teamName = e.detail.value[1]?.label || e.detail.value[0]?.label || '';
485
+    },
486
+
487
+    // 上报班组变化
488
+    onReportTeamChange(e) {
489
+      this.formData.reportTeam = e.detail.value;
490
+      this.formData.reportTeamText = e.detail.value[1]?.label || e.detail.value[0]?.label || '';
491
+    },
492
+
493
+    // 违禁品类别变化
494
+    onCategoryChange(e) {
495
+      this.formData.forbiddenCategory = e.detail.value[0].value;
496
+      this.formData.forbiddenCategoryText = e.detail.value[0].label;
497
+      this.formData.forbiddenType = '';
498
+      this.formData.forbiddenTypeText = '';
499
+
500
+      // 根据类别加载类型数据  需要调用接口
501
+
502
+    },
503
+
504
+    // 违禁品类型变化
505
+    onTypeChange(e) {
506
+      this.formData.forbiddenType = e.detail.value[0].value;
507
+      this.formData.forbiddenTypeText = e.detail.value[0].text;
508
+      this.formData.forbiddenName = this.formData.forbiddenName + ' - ' + e.detail.value[0].text;
509
+    },
510
+
511
+    // 单位变化
512
+    onUnitChange(e) {
513
+
514
+      this.formData.unit = e.detail.value;
515
+      this.formData.unitText =
516
+        this.item_unit_options.find(item => item.value === e.detail.value)?.label || '件';
517
+    },
518
+
519
+    // 部位类别变化
520
+    onPartCategoryChange(e) {
521
+      this.formData.partCategory = e.detail.value[0].value;
522
+      this.formData.partCategoryText = e.detail.value[0].text;
523
+      this.formData.partType = '';
524
+      this.formData.partTypeText = '';
525
+      this.formData.customLocation = '';
526
+
527
+      // 根据部位类别加载类型数据
528
+      if (this.formData.partCategory === 'body') {
529
+        this.partTypeData = [
530
+          { text: '口袋', value: 'pocket' },
531
+          { text: '腰带', value: 'belt' },
532
+          { text: '鞋底', value: 'shoe' }
533
+        ];
534
+      } else if (this.formData.partCategory === 'luggage') {
535
+        this.partTypeData = [
536
+          { text: '夹层', value: 'interlayer' },
537
+          { text: '侧袋', value: 'sidepocket' },
538
+          { text: '主仓', value: 'main' }
539
+        ];
540
+      } else {
541
+        this.partTypeData = [
542
+          { text: '外层', value: 'outer' },
543
+          { text: '内层', value: 'inner' },
544
+          { text: '特殊部位', value: 'special' }
545
+        ];
546
+      }
547
+    },
548
+
549
+    // 部位类型变化
550
+    onPartTypeChange(e) {
551
+      this.formData.partType = e.detail.value[0].value;
552
+      this.formData.partTypeText = e.detail.value[0].text;
553
+    },
554
+
555
+    // 具体位置选择
556
+    onLocationSelectChange(e) {
557
+      this.formData.location = e.detail.value;
558
+      this.formData.locationText = this.locations.find(item => item.value === e.detail.value)?.text || '';
559
+      this.formData.customLocation = this.formData.locationText;
560
+    },
561
+
562
+    // 处理方式变化
563
+    onMethodChange(e) {
564
+      this.formData.handlingMethod = e.detail.value;
565
+      this.formData.handlingMethodDesc = this.item_handling_method_options.find(item => item.value === e.detail.value)?.label || '';
566
+    },
567
+
568
+    // 是否有意隐匿选择
569
+    bindConcealChange(e) {
570
+      this.formData.isConcealed = e.detail.value;
571
+    },
572
+
573
+    // 提交表单
574
+
575
+  }
576
+}
577
+</script>
578
+
579
+<style lang="scss" scoped>
580
+.report-container {
581
+  // background-color: #f5f5f5;
582
+  min-height: 100vh;
583
+  padding-top: 180rpx;
584
+}
585
+
586
+
587
+.form-scroll {
588
+  height: calc(100vh - 50px);
589
+  padding: 1px;
590
+  box-sizing: border-box;
591
+}
592
+
593
+/* 卡片样式 */
594
+::v-deep .uni-card {
595
+  margin-bottom: 15px;
596
+  border-radius: 8px;
597
+  overflow: hidden;
598
+
599
+  .uni-card__content {
600
+    padding: 0;
601
+  }
602
+}
603
+
604
+/* 折叠面板标题 */
605
+.collapse-title {
606
+  display: flex;
607
+  flex-direction: column;
608
+  padding: 12px 15px;
609
+
610
+  .collapse-summary {
611
+    font-size: 12px;
612
+    color: #999;
613
+    margin-top: 4px;
614
+  }
615
+}
616
+
617
+/* 表单项样式 */
618
+::v-deep .uni-forms-item {
619
+  margin-bottom: 0;
620
+  padding: 0 15px;
621
+
622
+  .uni-forms-item__label {
623
+    padding: 12px 0 8px;
624
+    font-size: 14px;
625
+    color: #666;
626
+    width: auto !important;
627
+  }
628
+
629
+  .uni-forms-item__content {
630
+    padding: 0 0 12px;
631
+    border-bottom: 1px solid #f0f0f0;
632
+  }
633
+
634
+  &:last-child .uni-forms-item__content {
635
+    border-bottom: none;
636
+  }
637
+}
638
+
639
+.picker-box {
640
+  display: flex;
641
+  align-items: center;
642
+  justify-content: space-between;
643
+  height: 44px;
644
+
645
+  .picker-value {
646
+    font-size: 15px;
647
+    color: #333;
648
+
649
+    &:empty::after {
650
+      content: attr(placeholder);
651
+      color: #999;
652
+    }
653
+  }
654
+
655
+  &.disabled {
656
+    opacity: 0.5;
657
+  }
658
+}
659
+
660
+.form-value {
661
+  font-size: 15px;
662
+  color: #333;
663
+  height: 44px;
664
+  line-height: 44px;
665
+}
666
+
667
+.number-input {
668
+  display: flex;
669
+  align-items: center;
670
+  height: 44px;
671
+
672
+  .input-number {
673
+    flex: 1;
674
+  }
675
+
676
+  .unit-picker {
677
+    width: 80px;
678
+    margin-left: 10px;
679
+  }
680
+}
681
+
682
+.location-input {
683
+  display: flex;
684
+  height: 44px;
685
+
686
+  .picker-box {
687
+    width: 120px;
688
+  }
689
+
690
+  .custom-location {
691
+    flex: 1;
692
+    margin-left: 0px;
693
+  }
694
+}
695
+
696
+.radio-group {
697
+  display: flex;
698
+  padding: 8px 0;
699
+
700
+  .radio-item {
701
+    display: flex;
702
+    align-items: center;
703
+    margin-right: 30px;
704
+
705
+    text {
706
+      font-size: 15px;
707
+      color: #333;
708
+      margin-left: 5px;
709
+    }
710
+  }
711
+}
712
+
713
+.submit-btn {
714
+  margin-top: 20px;
715
+  width: calc(100% - 30px);
716
+  margin-left: 15px;
717
+}
718
+
719
+/* 底部弹窗样式调整 */
720
+::v-deep .uni-popup__wrapper {
721
+  border-radius: 16px 16px 0 0;
722
+
723
+  .uni-popup__wrapper-box {
724
+    max-height: 70vh;
725
+    overflow-y: auto;
726
+  }
727
+
728
+  .uni-data-pickerview {
729
+    padding-bottom: 20px;
730
+  }
731
+}
732
+</style>

+ 528 - 0
src/pages/seizureRecord/index.vue

@@ -0,0 +1,528 @@
1
+<template>
2
+  <view class="container">
3
+    <uni-card :is-shadow="true" :shadow="shadow">
4
+      <template v-slot:title>
5
+
6
+        <view class="header-section collapse-title">
7
+          <uni-row>
8
+            <uni-col :span="8">
9
+              <view>
10
+                <image v-if="currentUser.avatar" :src="currentUser.avatar" class="cu-avatar xl round"></image>
11
+              </view>
12
+            </uni-col>
13
+            <uni-col :span="16" class="flex padding justify-between">
14
+              <uni-row>
15
+                <uni-col>
16
+                  <text>{{ formData.checkerName }} </text></br>
17
+                  <!-- <text class="collapse-summary" v-if="formData.positionText">{{ formData.positionText }} : </text>
18
+                  <text class="collapse-summary">{{ formData.securityLocationText }} </text> -->
19
+                </uni-col>
20
+
21
+              </uni-row>
22
+
23
+            </uni-col>
24
+          </uni-row>
25
+        </view>
26
+      </template>
27
+    </uni-card>
28
+    <!-- 顶部tab切换 -->
29
+    <view class="tabs">
30
+      <view v-for="(tab, index) in tabList" :key="index" :class="['tab-item', currentTab === index ? 'active' : '']"
31
+        @click="switchTab(index)">
32
+        {{ tab.name }}
33
+        <view v-if="currentTab === index" class="tab-line"></view>
34
+      </view>
35
+    </view>
36
+
37
+    <!-- 时间筛选 -->
38
+    <uni-card :is-shadow="true" :shadow="shadow">
39
+
40
+      <view class="filter">
41
+        <view v-for="(item, index) in timeFilters" :key="index"
42
+          :class="['filter-item', activeTimeFilter === index ? 'active' : '']" @click="changeTimeFilter(index)">
43
+          {{ item }}
44
+        </view>
45
+      </view>
46
+      <view class="filter filter-column" style="margin-top: 20rpx;" v-if="currentTab === 1">
47
+        <text class="label">人员查询</text>
48
+        <input v-model="keyword" class="search-input" type="text" placeholder="请输入人员姓名" @input="doFilter" />
49
+      </view>
50
+
51
+
52
+      <!-- 大类 -->
53
+      <view class="form-item">
54
+        <text class="label">查获类型筛选</text>
55
+        <picker :value="this.activeTypeFilter" :range="typeFilters" @change="changeTypeFilter($event.detail.value)">
56
+          <view class="picker-box">{{ typeFilters[this.activeTypeFilter] || '请选择' }}</view>
57
+        </picker>
58
+      </view>
59
+      <!-- 查获类型筛选 -->
60
+      <!-- <view class="filter" style="margin-top: 20rpx">
61
+        <view v-for="(type, index) in typeFilters" :key="index"
62
+          :class="['filter-item', activeTypeFilter === index ? 'active' : '']" @click="changeTypeFilter(index)">
63
+          {{ type }}
64
+        </view>
65
+      </view> -->
66
+    </uni-card>
67
+
68
+
69
+    <uni-card :is-shadow="true" :shadow="shadow">
70
+
71
+      <!-- 数据统计 -->
72
+      <view class="stats">
73
+        <view class="stat-item">
74
+          <text class="num">{{ stats.total }}</text>
75
+          <text class="label">查获总数</text>
76
+        </view>
77
+        <view class="stat-item">
78
+          <text class="num">{{ stats.forbidden }}</text>
79
+          <text class="label">移交公安数量</text>
80
+        </view>
81
+        <view class="stat-item">
82
+          <text class="num">{{ stats.limited }}</text>
83
+          <text class="label">故意隐匿数</text>
84
+        </view>
85
+      </view>
86
+    </uni-card>
87
+
88
+    <!-- 记录列表 -->
89
+
90
+    <view class="record-list">
91
+      <view v-for="(item, index) in recordList" :key="index" class="record-item" @tap="viewDetail(item)">
92
+        <view class="item-content">
93
+          <text class="name">{{ item.categoryNameTwo }}</text>
94
+          <text class="location">{{ item.location }} {{ item.name }}</text>
95
+          <text class="time">{{ item.time }}</text>
96
+        </view>
97
+        <view class="item-footer">
98
+          <!-- <text class="user">{{ formData.checkerName }}</text> -->
99
+          <!-- <text class="type" :class="'type-' + item.typeKey">{{ item.type }}</text> -->
100
+        </view>
101
+        <view class="arrow">></view>
102
+      </view>
103
+      <view v-if="recordList.length === 0" class="empty">暂无数据</view>
104
+    </view>
105
+  </view>
106
+</template>
107
+
108
+<script>
109
+import { selectByConditions } from '@/api/seizureRecord/seizureRecord.js'
110
+import { selectGroupCount } from '@/api/seizureRecord/seizureRecord.js'
111
+import { treeSelectByType } from "@/api/system/common"
112
+
113
+
114
+export default {
115
+  data() {
116
+    return {
117
+      shadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
118
+      activeTypeFilter: 0,
119
+      typeFilters: ['全部', '违禁品', '限带品', '其他'],
120
+      typeOptions: [],
121
+      currentTab: 0,
122
+      tabList: [{ name: '我的查获记录' }, { name: '本班组查获记录' }],
123
+      activeTimeFilter: 0,
124
+      timeFilters: ['今日', '本周', '本月'],
125
+      recordList: [],
126
+      stats: { total: 0, forbidden: 0, limited: 0 },
127
+      allRows: [], // 原始数据缓存
128
+      keyword: '',   // 人员姓名关键字
129
+      dataType: 'ALL',
130
+
131
+
132
+      formData: {
133
+        // 安检员信息
134
+        checkerId: this.$store.state.user.id,
135
+        checkerName: this.$store.state.user.name,
136
+        positionText: '安检员',
137
+        securityLocationText: 'T3航站楼',
138
+
139
+      },
140
+    }
141
+  },
142
+  onLoad() {
143
+    this.loadData()
144
+  },
145
+  computed: {
146
+    currentUser() {
147
+      return this.$store.state.user;
148
+    },
149
+
150
+  },
151
+  methods: {
152
+    // 扁平化 children 成 [{ label, code }]
153
+    getChildrenOptions(tree) {
154
+      return tree.flatMap(node => (node.children || []).map(c => ({
155
+        label: c.label,
156
+        code: c.code
157
+      })));
158
+    },
159
+    /* 切换 tab / 时间 / 类型 都重新拉数据+过滤 */
160
+    changeTypeFilter(idx) {
161
+      const selectedCode = this.typeOptions[idx - 1].code;
162
+      console.log("selectedCode===", selectedCode)
163
+      this.dataType = selectedCode
164
+      this.loadData();
165
+
166
+      this.activeTypeFilter = idx
167
+      // this.doFilter()
168
+    },
169
+    switchTab(idx) {
170
+      this.currentTab = idx
171
+      this.loadData()
172
+    },
173
+    changeTimeFilter(index) {
174
+      this.activeTimeFilter = index
175
+      this.loadData()
176
+    },
177
+
178
+    /* 调接口拿到原始数据 */
179
+    async loadData() {
180
+      uni.showLoading({ title: '加载中' })
181
+      try {
182
+        // 真实项目换成自己的封装方法
183
+
184
+        const res = await selectByConditions({
185
+          timeType: this.activeTimeFilter + 1,
186
+          recordType: this.currentTab + 1,
187
+          inspectUserId: this.$store.state.user.id,
188
+          seizureType: this.dataType,
189
+          pageNum: 1,
190
+          pageSize: 500,
191
+          // tab: this.currentTab,
192
+          // time: this.activeTimeFilter
193
+        })
194
+
195
+        if (res.code === 200) {
196
+          this.allRows = res.rows || []
197
+          this.doFilter()
198
+        }
199
+
200
+
201
+        const [itemCategoryRes] = await Promise.all([
202
+          treeSelectByType("ITEM_CATEGORY", 2)
203
+        ]);
204
+
205
+
206
+        // 先拉类型树
207
+        const [{ data: typeTree }] = await Promise.all([
208
+          treeSelectByType("ITEM_CATEGORY", 2)
209
+        ]);
210
+        this.typeOptions = this.getChildrenOptions(typeTree);   // [{label,code}]
211
+        this.typeFilters = ['全部', ...this.typeOptions.map(o => o.label)];
212
+        const count = await selectGroupCount({
213
+          timeType: this.activeTimeFilter + 1,
214
+          recordType: this.currentTab + 1,
215
+          inspectUserId: this.$store.state.user.id,
216
+          seizureType: this.dataType
217
+
218
+          // tab: this.currentTab,
219
+          // time: this.activeTimeFilter
220
+        })
221
+        this.stats.total = count.data.total;
222
+        this.stats.forbidden = count.data.policeTotal
223
+        this.stats.limited = count.data.concealTotal
224
+        console.log("count", count)
225
+
226
+
227
+
228
+        console.log("typeFilters", this.typeFilters)
229
+
230
+      } catch (e) {
231
+        console.log("e===", e)
232
+        uni.showToast({ title: '加载失败', icon: 'none' })
233
+      } finally {
234
+        uni.hideLoading()
235
+      }
236
+    },
237
+
238
+    doFilter() {
239
+      let list = this.allRows;
240
+      // 第 0 个永远是“全部”
241
+      if (this.activeTypeFilter > 0) {
242
+        const selectedCode = this.typeOptions[this.activeTypeFilter - 1].code;
243
+        console.log("list", list);
244
+        console.log("selectedCode", selectedCode);
245
+
246
+        list = list.filter(v => v.categoryCodeTwo === selectedCode);
247
+      }
248
+      /* 新增:人员姓名搜索(仅本班组) */
249
+      if (this.currentTab === 1 && this.keyword.trim()) {
250
+        list = list.filter(v =>
251
+          (v.inspectUserName || '').includes(this.keyword.trim())
252
+        );
253
+      }
254
+
255
+      this.recordList = list.map(v => ({
256
+        id: v.id,
257
+        name: v.inspectUserName || '-',
258
+        type: v.checkMethodDesc,
259
+        location: v.regionalName || '-',
260
+        time: v.seizureTime || '-',
261
+        categoryNameOne: v.categoryNameOne,
262
+        categoryNameTwo: v.categoryNameTwo
263
+      }));
264
+
265
+      this.stats.total = this.recordList.length;
266
+      this.stats.forbidden = list.filter(v => v.handlingMethodDesc === '移交机场公安').length;
267
+      this.stats.limited = list.filter(v => v.isActiveConcealment === 1).length;
268
+    },
269
+
270
+    viewDetail(item) {
271
+      uni.navigateTo({ url: '/pages/seizureRecord/detail?id=' + item.id });
272
+    },
273
+
274
+    /* 模拟接口:直接把题目给的返回值包一层 Promise */
275
+    mockApi() {
276
+      return new Promise(resolve => {
277
+        setTimeout(() => {
278
+          resolve({
279
+            code: 200,
280
+            rows: [
281
+              {
282
+                id: 5,
283
+                remark: '打火机',
284
+                checkMethodDesc: '违禁品',
285
+                inspectUserName: '张三',
286
+                channelName: 'A区安检口',
287
+                seizureTime: '2025-07-15 18:42:09'
288
+              },
289
+              {
290
+                id: 6,
291
+                remark: '矿泉水',
292
+                checkMethodDesc: '限带品',
293
+                inspectUserName: '李四',
294
+                channelName: 'B区安检口',
295
+                seizureTime: '2025-07-15 19:00:00'
296
+              }
297
+            ]
298
+          })
299
+        }, 300)
300
+      })
301
+    }
302
+  }
303
+}
304
+</script>
305
+
306
+
307
+<style>
308
+.container {
309
+  padding: 20rpx;
310
+}
311
+
312
+.tabs {
313
+  display: flex;
314
+  height: 80rpx;
315
+  margin-bottom: 20rpx;
316
+}
317
+
318
+.tab-item {
319
+  flex: 1;
320
+  display: flex;
321
+  flex-direction: column;
322
+  align-items: center;
323
+  justify-content: center;
324
+  font-size: 30rpx;
325
+  color: #666;
326
+  position: relative;
327
+}
328
+
329
+.tab-item.active {
330
+  color: #2979FF;
331
+  font-weight: bold;
332
+}
333
+
334
+.tab-line {
335
+  width: 60rpx;
336
+  height: 6rpx;
337
+  background: #2979FF;
338
+  border-radius: 3rpx;
339
+  position: absolute;
340
+  bottom: 0;
341
+}
342
+
343
+.filter {
344
+  display: flex;
345
+  margin-bottom: 20rpx;
346
+}
347
+
348
+.filter-item {
349
+  padding: 10rpx 20rpx;
350
+  margin-right: 20rpx;
351
+  border-radius: 30rpx;
352
+  background: #f5f5f5;
353
+  color: #666;
354
+}
355
+
356
+.filter-item.active {
357
+  background: #2979FF;
358
+  color: #fff;
359
+}
360
+
361
+.stats {
362
+  display: flex;
363
+  justify-content: space-around;
364
+  margin-bottom: 20rpx;
365
+  padding: 20rpx;
366
+  background: #fff;
367
+  border-radius: 10rpx;
368
+}
369
+
370
+.stat-item {
371
+  display: flex;
372
+  flex-direction: column;
373
+  align-items: center;
374
+}
375
+
376
+.num {
377
+  font-size: 36rpx;
378
+  font-weight: bold;
379
+  color: #2979FF;
380
+}
381
+
382
+.label {
383
+  font-size: 24rpx;
384
+  color: #999;
385
+}
386
+
387
+.item-header {
388
+  display: flex;
389
+  justify-content: space-between;
390
+  margin-bottom: 10rpx;
391
+}
392
+
393
+.name {
394
+  font-size: 32rpx;
395
+  font-weight: bold;
396
+}
397
+
398
+.type {
399
+  padding: 0 10rpx;
400
+  background: #ff4d4f;
401
+  color: #fff;
402
+  border-radius: 4rpx;
403
+  font-size: 24rpx;
404
+}
405
+
406
+.item-footer button {
407
+  width: 160rpx;
408
+  height: 60rpx;
409
+  line-height: 60rpx;
410
+  font-size: 26rpx;
411
+  background: #2979FF;
412
+  color: #fff;
413
+}
414
+
415
+.empty {
416
+  padding: 60rpx 0;
417
+  text-align: center;
418
+  color: #999;
419
+}
420
+
421
+.type-1 {
422
+  background: #ff4d4f;
423
+}
424
+
425
+.type-2 {
426
+  background: #faad14;
427
+}
428
+
429
+.type-3 {
430
+  background: #52c41a;
431
+}
432
+
433
+.record-list {
434
+  padding: 30rpx;
435
+  display: flex;
436
+  flex-direction: column;
437
+}
438
+
439
+.record-item {
440
+  display: flex;
441
+  align-items: center;
442
+  background: #fff;
443
+  border-radius: 10rpx;
444
+  padding: 20rpx;
445
+  margin-bottom: 20rpx;
446
+}
447
+
448
+.item-content {
449
+  flex: 1;
450
+  display: flex;
451
+  flex-direction: column;
452
+}
453
+
454
+
455
+
456
+.location {
457
+  font-size: 28rpx;
458
+  color: #666;
459
+}
460
+
461
+.time {
462
+  font-size: 28rpx;
463
+  color: #999;
464
+}
465
+
466
+.item-footer {
467
+  display: flex;
468
+  align-items: center;
469
+}
470
+
471
+.user {
472
+  font-size: 28rpx;
473
+  color: #666;
474
+}
475
+
476
+.type {
477
+  padding: 0 10rpx;
478
+  background: #ff4d4f;
479
+  color: #fff;
480
+  border-radius: 4rpx;
481
+  font-size: 24rpx;
482
+  margin-left: 10rpx;
483
+}
484
+
485
+.arrow {
486
+  width: 30rpx;
487
+  height: 30rpx;
488
+  /* background: url('path/to/arrow.png') no-repeat center center; */
489
+  background-size: contain;
490
+}
491
+
492
+.empty {
493
+  padding: 60rpx 0;
494
+  text-align: center;
495
+  color: #999;
496
+}
497
+
498
+.picker-box {
499
+  padding: 20rpx;
500
+  border: 1rpx solid #ddd;
501
+  border-radius: 8rpx;
502
+  font-size: 30rpx;
503
+}
504
+
505
+.label {
506
+  font-size: 30rpx;
507
+  color: #333;
508
+  margin-bottom: 10rpx;
509
+  display: block;
510
+}
511
+
512
+.filter-column {
513
+  display: flex;
514
+  flex-direction: column;
515
+}
516
+
517
+.search-input {
518
+  width: 100%;
519
+  height: 80rpx;
520
+  /* 明显加高 */
521
+  line-height: 96rpx;
522
+  padding: 0 24rpx;
523
+  font-size: 30rpx;
524
+  border: 1rpx solid #ddd;
525
+  border-radius: 12rpx;
526
+  box-sizing: border-box;
527
+}
528
+</style>

+ 41 - 0
src/pages/train/index.vue

@@ -0,0 +1,41 @@
1
+<template>
2
+  <view class="container" style="height: 100vh; position: relative;">
3
+    <!-- 状态栏占位 -->
4
+    <view :style="{ height: `${statusBarHeight}px` }" v-if="platform !== 'ios'"></view>
5
+    
6
+    <!-- WebView(动态高度) -->
7
+    <web-view 
8
+      :src="url55" 
9
+      :style="{
10
+        height: `calc(100vh - ${statusBarHeight}px - 50px)`,
11
+        width: '100%'
12
+      }"
13
+    ></web-view>
14
+    
15
+    <!-- 自定义 TabBar(固定底部) -->
16
+    <custom-tabbar/>
17
+  </view>
18
+</template>
19
+
20
+<script>
21
+import CustomTabbar from '@/components/custom-tabbar.vue';
22
+export default {
23
+  components: { CustomTabbar },
24
+  data() {
25
+    return {
26
+      platform: '',
27
+      statusBarHeight: 0,
28
+      url55: '/'
29
+    };
30
+  },
31
+  onLoad(option) {
32
+    const systemInfo = uni.getSystemInfoSync();
33
+    this.platform = systemInfo.platform;
34
+    this.statusBarHeight = systemInfo.statusBarHeight;
35
+    
36
+    // // 处理 URL
37
+    // let url = decodeURIComponent(option.url);
38
+    // this.url55 = `${url}&type=app`;
39
+  }
40
+};
41
+</script>

+ 225 - 0
src/pages/voiceSubmissionDraft/index.vue

@@ -0,0 +1,225 @@
1
+<template>
2
+    <home-container :customStyle="{ background: 'none' }" :customHomeStyle="{ overflow: 'hidden' }">
3
+        <view class="workdocu-list">
4
+            <scroll-view scroll-y="true" @refresherrefresh="onRefresh" refresher-enabled
5
+                :refresher-triggered="refresherTriggered" @scrolltolower="loadMore" :style="{ height: '100%' }"
6
+                enable-back-to-top="true">
7
+                <list-card v-for="item in list" :key="item.id" @click="navigateToDetail(item)">
8
+                    <template #title>
9
+                        <view class="list-title">
10
+                            {{ item.categoryName }}
11
+                        </view>
12
+                    </template>
13
+                    <template #default>
14
+                        <view class="list-info">
15
+                            <view class="list-meta">
16
+                                <view class="meta-item">
17
+                                    <text class="meta-label">查获位置:</text>
18
+                                    <text class="meta-value">{{ item.fullPositionPath || '未知位置' }}</text>
19
+                                </view>
20
+                                <view class="meta-item">
21
+                                    <text class="meta-label">查获时间:</text>
22
+                                    <text class="meta-value">{{ formatDate(item.createTime) || '未知时间' }}</text>
23
+                                </view>
24
+                            </view>
25
+                        </view>
26
+                    </template>
27
+                </list-card>
28
+
29
+                <!-- 加载状态提示 -->
30
+                <view v-if="loading" class="load-more">
31
+                    <text>加载中...</text>
32
+                </view>
33
+                <view v-else-if="!hasMore && list.length > 0" class="no-more">
34
+                    <text>没有更多数据了</text>
35
+                </view>
36
+                <view v-else-if="list.length === 0" class="no-data">
37
+                    <view class="empty-icon">📄</view>
38
+                    <text>暂无草稿箱数据</text>
39
+                    <text class="empty-hint">暂无可用的草稿箱数据,请稍后再试</text>
40
+                </view>
41
+            </scroll-view>
42
+        </view>
43
+    </home-container>
44
+</template>
45
+
46
+<script>
47
+import HomeContainer from "@/components/HomeContainer.vue";
48
+import ListCard from "@/components/list-card/list-card.vue";
49
+import { listVoiceSubmissionDraft } from "@/api/voiceSubmissionDraft/voiceSubmissionDraft.js";
50
+export default {
51
+    components: {
52
+        HomeContainer,
53
+        ListCard
54
+    },
55
+    data() {
56
+        return {
57
+            list: [],
58
+            // 分页参数
59
+            pageNum: 1,
60
+            pageSize: 10,
61
+            total: 0,
62
+            loading: false,
63
+            hasMore: true,
64
+            // scroll-view下拉刷新状态控制
65
+            refresherTriggered: false
66
+        }
67
+    },
68
+    methods: {
69
+        // 格式化日期
70
+        formatDate(timeString) {
71
+            if (!timeString) return '';
72
+            const date = new Date(timeString);
73
+            const year = date.getFullYear();
74
+            const month = String(date.getMonth() + 1).padStart(2, '0');
75
+            const day = String(date.getDate()).padStart(2, '0');
76
+            return `${year}-${month}-${day}`;
77
+        },
78
+
79
+        // 跳转到详情页
80
+        navigateToDetail(item) {
81
+            if (!item || !item.id) return;
82
+            let url = `/pages/seizedReportedVoice/index?params=${encodeURIComponent(JSON.stringify({
83
+                voiceId: item.id,
84
+                type:'add'
85
+            }))}`;
86
+            uni.navigateTo({
87
+                url: url
88
+            });
89
+        },
90
+
91
+        onRefresh() {
92
+            this.pageNum = 1;
93
+            this.refresherTriggered = true;
94
+            this.loadData();
95
+        },
96
+
97
+        // 加载数据方法
98
+        async loadData() {
99
+            if (this.loading) return;
100
+
101
+            this.loading = true;
102
+            try {
103
+                // 调用语音提交草稿列表API
104
+                const response = await listVoiceSubmissionDraft({
105
+                    pageNum: this.pageNum,
106
+                    pageSize: this.pageSize
107
+                });
108
+
109
+                // 处理响应数据
110
+                const data = response.rows || response.list || [];
111
+
112
+                if (this.pageNum === 1) {
113
+                    this.list = data;
114
+                } else {
115
+                    this.list = [...this.list, ...data];
116
+                }
117
+
118
+                this.total = response.total || 0;
119
+                this.hasMore = this.list.length < this.total;
120
+            } catch (error) {
121
+                console.error('加载语音提交草稿数据失败:', error);
122
+                uni.showToast({
123
+                    title: '加载失败',
124
+                    icon: 'none'
125
+                });
126
+            } finally {
127
+                this.loading = false;
128
+                // 重置下拉刷新状态
129
+                this.refresherTriggered = false;
130
+                uni.stopPullDownRefresh();
131
+            }
132
+        },
133
+
134
+        // 加载更多数据
135
+        loadMore() {
136
+            if (this.loading || !this.hasMore) return;
137
+            this.pageNum++;
138
+            this.loadData();
139
+        }
140
+    },
141
+    mounted() {
142
+        this.loadData();
143
+    },
144
+    onShow() {
145
+        // 页面再次展示时刷新数据
146
+        this.pageNum = 1;
147
+        this.loadData();
148
+    }
149
+}
150
+</script>
151
+
152
+<style lang="scss" scoped>
153
+.workdocu-list {
154
+    height: calc(100vh - 177rpx);
155
+    margin-bottom: 20rpx;
156
+
157
+    // 标题样式
158
+    .list-title {
159
+        font-size: 32rpx;
160
+        font-weight: bold;
161
+        color: #333333;
162
+    }
163
+
164
+    // 信息样式
165
+    .list-info {
166
+        .list-meta {
167
+            display: flex;
168
+            flex-direction: column;
169
+            gap: 8px;
170
+
171
+            font-size: 16px;
172
+            color: #666666;
173
+
174
+            .meta-item {
175
+                display: flex;
176
+                align-items: center;
177
+
178
+                .meta-label {
179
+                    color: #999;
180
+                    min-width: 80px;
181
+                    margin-right: 8px;
182
+                }
183
+
184
+                .meta-value {
185
+                    color: #333;
186
+                    flex: 1;
187
+                }
188
+            }
189
+        }
190
+    }
191
+
192
+    /* 加载状态样式 */
193
+    .load-more,
194
+    .no-more {
195
+        display: flex;
196
+        justify-content: center;
197
+        align-items: center;
198
+        padding: 40rpx;
199
+        font-size: 28rpx;
200
+        color: #999;
201
+    }
202
+
203
+    /* 空状态样式优化 */
204
+    .no-data {
205
+        display: flex;
206
+        flex-direction: column;
207
+        justify-content: center;
208
+        align-items: center;
209
+        padding: 120rpx 40rpx;
210
+        font-size: 28rpx;
211
+        color: #999;
212
+
213
+        .empty-icon {
214
+            font-size: 120rpx;
215
+            margin-bottom: 30rpx;
216
+        }
217
+
218
+        .empty-hint {
219
+            font-size: 26rpx;
220
+            color: #bbb;
221
+            margin-top: 20rpx;
222
+        }
223
+    }
224
+}
225
+</style>

+ 223 - 0
src/pages/work/index.vue

@@ -0,0 +1,223 @@
1
+<template>
2
+  <home-container :customStyle="{ background: 'none', backgroundColor: '#f5f5f5' }">
3
+    <div class="work-container">
4
+      <!-- 顶部状态栏 -->
5
+      <view class="status-bar">
6
+        <text class="welcome">欢迎使用考勤系统</text>
7
+        <text class="date">{{ currentDate }}</text>
8
+      </view>
9
+
10
+      <!-- 快捷打卡入口 -->
11
+      <view class="quick-checkin" @click="goToAttendance">
12
+        <view class="checkin-left">
13
+          <uni-icons type="calendar-filled" size="30" color="#409EFF"></uni-icons>
14
+          <text class="checkin-text">快速打卡</text>
15
+        </view>
16
+        <uni-icons type="arrowright" size="20" color="#999"></uni-icons>
17
+      </view>
18
+
19
+
20
+
21
+      <view class="grid-body">
22
+        <!-- 宫格组件 -->
23
+        <uni-section title="系统功能" type="line" class="uni-section-custom"></uni-section>
24
+        <uni-grid :column="3" :showBorder="false">
25
+          <uni-grid-item v-for="(item, index) in items" :key="index">
26
+            <view class="grid-item-box" @click="handleGridClick(item.appUrl)">
27
+              <!-- <uni-icons v-if="item.iconType" :type="item.iconType" :color="item.color" size="30"></uni-icons>
28
+              <u-icon v-else :name="item.uIconType" :color="item.uIconColor" size="35"></u-icon> -->
29
+              <img alt="" :src="item.workbenchIcon">
30
+              <text class="text">{{ item.appName }}</text>
31
+            </view>
32
+          </uni-grid-item>
33
+        </uni-grid>
34
+      </view>
35
+    </div>
36
+  </home-container>
37
+</template>
38
+<script>
39
+import HomeContainer from "@/components/HomeContainer.vue";
40
+import { checkRolePermission } from "@/utils/common.js";
41
+import { getUserProfile, getAppListByRoleId } from "@/api/system/user";
42
+
43
+export default {
44
+  components: { HomeContainer },
45
+  data() {
46
+    return {
47
+      currentDate: this.formatDate(new Date()),
48
+      appList: []
49
+    }
50
+  },
51
+  computed: {
52
+    role() {
53
+      return this.$store?.state?.user?.roles[0]
54
+    },
55
+    items() {
56
+      return this.appList
57
+    }
58
+  },
59
+  onShow() {
60
+    this.updateCurrentDate();
61
+  },
62
+  onLoad() {
63
+    this.getUser()
64
+  },
65
+  methods: {
66
+    async getUser() {
67
+      const data = await getUserProfile();
68
+      await this.loadAppList(data.data);
69
+    },
70
+    async loadAppList(userInfo) {
71
+      const roleId = userInfo.roles && userInfo.roles.length ? userInfo.roles[0].roleId : null;
72
+
73
+      try {
74
+        if (roleId) {
75
+          const res = await getAppListByRoleId(roleId);
76
+          if (res.code === 200) {
77
+            //全部应用
78
+            const sysAppList = res.sysAppList;
79
+            const checkedAppIdList = res.checkedAppIdList;
80
+            this.appList = sysAppList.filter(app => checkedAppIdList.includes(app.appId));
81
+          }
82
+        }
83
+      } catch (error) {
84
+        console.error('获取应用列表失败:', error);
85
+        this.appList = [];
86
+      }
87
+    },
88
+    formatDate(date) {
89
+      const year = date.getFullYear();
90
+      const month = (date.getMonth() + 1).toString().padStart(2, '0');
91
+      const day = date.getDate().toString().padStart(2, '0');
92
+      const week = ['日', '一', '二', '三', '四', '五', '六'][date.getDay()];
93
+      return `${year}年${month}月${day}日 星期${week}`;
94
+    },
95
+    updateCurrentDate() {
96
+      this.currentDate = this.formatDate(new Date());
97
+    },
98
+    handleGridClick(url) {
99
+      console.log('点击了宫格:', url, this.role);
100
+      uni.navigateTo({
101
+        url: url
102
+      });
103
+      // if (item.path) {
104
+      //   // if (checkRolePermission(this.role, item.path)) {
105
+
106
+      //   // } else {
107
+      //   //   this.$modal.showToast('此角色不需要查看该功能,无需操作~');
108
+      //   // }
109
+      // } else {
110
+      //   this.$modal.showToast('功能开发中,敬请期待');
111
+      // }
112
+    },
113
+    goToAttendance() {
114
+      uni.navigateTo({
115
+        url: '/pages/attendance/index'
116
+      });
117
+    },
118
+    changeGrid(e) {
119
+      const index = e.detail.index;
120
+      const item = this.items[index];
121
+      this.handleGridClick(item);
122
+    },
123
+  }
124
+}
125
+</script>
126
+
127
+<style lang="scss" scoped>
128
+.work-container {
129
+  padding: 0;
130
+  background-color: #f5f5f5;
131
+}
132
+
133
+.status-bar {
134
+  display: flex;
135
+  justify-content: space-between;
136
+  align-items: center;
137
+  padding: 20rpx;
138
+  margin-bottom: 20rpx;
139
+
140
+  .welcome {
141
+    font-size: 32rpx;
142
+    font-weight: bold;
143
+    color: #333;
144
+  }
145
+
146
+  .date {
147
+    font-size: 26rpx;
148
+    color: #666;
149
+  }
150
+}
151
+
152
+.quick-checkin {
153
+  display: flex;
154
+  justify-content: space-between;
155
+  align-items: center;
156
+  background-color: #fff;
157
+  padding: 30rpx;
158
+  border-radius: 16rpx;
159
+  margin-bottom: 30rpx;
160
+  box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
161
+
162
+  .checkin-left {
163
+    display: flex;
164
+    align-items: center;
165
+
166
+    .checkin-text {
167
+      font-size: 32rpx;
168
+      margin-left: 20rpx;
169
+      color: #409EFF;
170
+    }
171
+  }
172
+}
173
+
174
+.grid-body {
175
+  background-color: #fff;
176
+  border-radius: 16rpx;
177
+  padding: 20rpx;
178
+  box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
179
+
180
+  .uni-section-custom {
181
+
182
+    ::v-deep .uni-section-header {
183
+      padding: 12rpx 10rpx !important;
184
+    }
185
+  }
186
+
187
+
188
+}
189
+
190
+.grid-item-box {
191
+  flex: 1;
192
+  display: flex;
193
+  flex-direction: column;
194
+  align-items: center;
195
+  justify-content: center;
196
+  padding: 30rpx 0;
197
+
198
+  img {
199
+    display: inline-block;
200
+    width: 120rpx;
201
+    height: 120rpx;
202
+  }
203
+
204
+  &:active {
205
+    background-color: #f5f5f5;
206
+    border-radius: 12rpx;
207
+  }
208
+}
209
+
210
+.text {
211
+  text-align: center;
212
+  font-size: 26rpx;
213
+  margin-top: 15rpx;
214
+  color: #333;
215
+}
216
+
217
+.footer-tips {
218
+  text-align: center;
219
+  margin-top: 40rpx;
220
+  font-size: 24rpx;
221
+  color: #999;
222
+}
223
+</style>

+ 205 - 0
src/pages/workDocu/index.vue

@@ -0,0 +1,205 @@
1
+<template>
2
+    <home-container :customStyle="{ background: 'none' }" :customHomeStyle="{ overflow: 'hidden' }">
3
+        <view class="workdocu-list">
4
+            <scroll-view scroll-y="true" @refresherrefresh="onRefresh" refresher-enabled
5
+                :refresher-triggered="refresherTriggered" @scrolltolower="loadMore" :style="{ height: '100%' }"
6
+                enable-back-to-top="true">
7
+                <list-card v-for="item in list" :key="item.id" @click="navigateToDetail(item)">
8
+                    <template #title>
9
+                        <view class="list-title">
10
+                            {{ item.documentTitle }}
11
+                        </view>
12
+                    </template>
13
+                    <template #default>
14
+                        <view class="list-info">
15
+                            <view class="list-meta">
16
+                                <view class="author">{{ item.createBy || '管理员' }}</view>
17
+                                <view class="date">{{ formatDate(item.createTime) }}</view>
18
+                            </view>
19
+                        </view>
20
+                    </template>
21
+                </list-card>
22
+
23
+                <!-- 加载状态提示 -->
24
+                <view v-if="loading" class="load-more">
25
+                    <text>加载中...</text>
26
+                </view>
27
+                <view v-else-if="!hasMore && list.length > 0" class="no-more">
28
+                    <text>没有更多数据了</text>
29
+                </view>
30
+                <view v-else-if="list.length === 0" class="no-data">
31
+                    <view class="empty-icon">📄</view>
32
+                    <text>暂无工作文档</text>
33
+                    <text class="empty-hint">暂无可用的工作文档,请稍后再试</text>
34
+                </view>
35
+            </scroll-view>
36
+        </view>
37
+    </home-container>
38
+</template>
39
+
40
+<script>
41
+import HomeContainer from "@/components/HomeContainer.vue";
42
+import ListCard from "@/components/list-card/list-card.vue";
43
+import { listWorkDocu } from "@/api/workDocu/workDocu.js";
44
+export default {
45
+    components: {
46
+        HomeContainer,
47
+        ListCard
48
+    },
49
+    data() {
50
+        return {
51
+            list: [],
52
+            // 分页参数
53
+            pageNum: 1,
54
+            pageSize: 10,
55
+            total: 0,
56
+            loading: false,
57
+            hasMore: true,
58
+            // scroll-view下拉刷新状态控制
59
+            refresherTriggered: false
60
+        }
61
+    },
62
+    methods: {
63
+        // 格式化日期
64
+        formatDate(timeString) {
65
+            if (!timeString) return '';
66
+            const date = new Date(timeString);
67
+            const year = date.getFullYear();
68
+            const month = String(date.getMonth() + 1).padStart(2, '0');
69
+            const day = String(date.getDate()).padStart(2, '0');
70
+            return `${year}-${month}-${day}`;
71
+        },
72
+
73
+        // 跳转到详情页
74
+        navigateToDetail(item) {
75
+            if (!item || !item.documentId) return;
76
+
77
+            uni.navigateTo({
78
+                url: '/pages/workDocu/workDocuDetail?id=' + item.documentId
79
+            });
80
+        },
81
+
82
+        onRefresh() {
83
+            this.pageNum = 1;
84
+            this.refresherTriggered = true;
85
+            this.loadData();
86
+        },
87
+
88
+        // 加载数据方法
89
+        async loadData() {
90
+            if (this.loading) return;
91
+
92
+            this.loading = true;
93
+            try {
94
+                // 调用真实的文档列表API
95
+                const response = await listWorkDocu({
96
+                    pageNum: this.pageNum,
97
+                    pageSize: this.pageSize,
98
+                    status: 1
99
+                });
100
+
101
+                // 处理响应数据
102
+                const data = response.rows || response.list || [];
103
+
104
+                if (this.pageNum === 1) {
105
+                    this.list = data;
106
+                } else {
107
+                    this.list = [...this.list, ...data];
108
+                }
109
+
110
+                this.total = response.total || 0;
111
+                this.hasMore = this.list.length < this.total;
112
+            } catch (error) {
113
+                console.error('加载文档数据失败:', error);
114
+                uni.showToast({
115
+                    title: '加载失败',
116
+                    icon: 'none'
117
+                });
118
+            } finally {
119
+                this.loading = false;
120
+                // 重置下拉刷新状态
121
+                this.refresherTriggered = false;
122
+                uni.stopPullDownRefresh();
123
+            }
124
+        },
125
+
126
+        // 加载更多数据
127
+        loadMore() {
128
+            if (this.loading || !this.hasMore) return;
129
+            this.pageNum++;
130
+            this.loadData();
131
+        }
132
+    },
133
+    mounted() {
134
+        this.loadData();
135
+    },
136
+    onShow() {
137
+        // 页面再次展示时刷新数据
138
+        this.pageNum = 1;
139
+        this.loadData();
140
+    }
141
+}
142
+</script>
143
+
144
+<style lang="scss" scoped>
145
+.workdocu-list {
146
+    height: calc(100vh - 177rpx);
147
+    margin-bottom: 20rpx;
148
+
149
+    // 标题样式
150
+    .list-title {
151
+        font-size: 32rpx;
152
+        font-weight: bold;
153
+        color: #333333;
154
+    }
155
+
156
+    // 信息样式
157
+    .list-info {
158
+        .list-meta {
159
+            display: flex;
160
+            justify-content: flex-start; // 靠左展示
161
+            margin-bottom: 11px;
162
+            font-size: 16px;
163
+            color: #666666;
164
+            align-items: center;
165
+
166
+            .author {
167
+                margin-right: 30rpx;
168
+            }
169
+        }
170
+    }
171
+
172
+    /* 加载状态样式 */
173
+    .load-more,
174
+    .no-more {
175
+        display: flex;
176
+        justify-content: center;
177
+        align-items: center;
178
+        padding: 40rpx;
179
+        font-size: 28rpx;
180
+        color: #999;
181
+    }
182
+
183
+    /* 空状态样式优化 */
184
+    .no-data {
185
+        display: flex;
186
+        flex-direction: column;
187
+        justify-content: center;
188
+        align-items: center;
189
+        padding: 120rpx 40rpx;
190
+        font-size: 28rpx;
191
+        color: #999;
192
+
193
+        .empty-icon {
194
+            font-size: 120rpx;
195
+            margin-bottom: 30rpx;
196
+        }
197
+
198
+        .empty-hint {
199
+            font-size: 26rpx;
200
+            color: #bbb;
201
+            margin-top: 20rpx;
202
+        }
203
+    }
204
+}
205
+</style>

+ 509 - 0
src/pages/workDocu/workDocuDetail.vue

@@ -0,0 +1,509 @@
1
+<template>
2
+    <home-container title="文档详情" :customStyle="{backgroundPosition:'0px -65px'}">
3
+        <view class="doc-detail-container">
4
+            <!-- 文档信息部分 -->
5
+            <view class="doc-info">
6
+                <text class="doc-title">{{ documentInfo.documentTitle || '文档标题' }}</text>
7
+                <view class="doc-meta">
8
+                    <text class="meta-item">创建人:{{ documentInfo.createBy || '管理员' }}</text>
9
+                    <text class="meta-item">创建时间:{{ formatDate(documentInfo.createTime) }}</text>
10
+                </view>
11
+            </view>
12
+
13
+            <!-- 文件预览容器 -->
14
+            <view class="preview-container" v-if="loading">
15
+                <view class="loading-indicator">
16
+                    <text>正在加载文档...</text>
17
+                </view>
18
+            </view>
19
+
20
+            <view class="preview-container" v-else-if="documentInfo.documentUrl">
21
+                <!-- 只预览PDF格式文件 -->
22
+                <view v-if="isPdfFile" class="pdf-viewer">
23
+                    <!-- App端:blob URL预览方案 -->
24
+                    <view v-if="isApp" class="app-pdf-viewer">
25
+                        <view class="pdf-notice">
26
+                            <text class="pdf-tip">PDF文件预览</text>
27
+                            <text class="pdf-tip">点击下方按钮下载并预览PDF文件</text>
28
+                        </view>
29
+                        <button class="open-pdf-btn" @tap="downloadAndGenerateBlobUrl" :disabled="isDownloading">
30
+                            {{ isDownloading ? '正在下载...' : '下载并预览PDF' }}
31
+                        </button>
32
+                        <view v-if="pdfLoadError" class="error-notice">
33
+                            <text>PDF加载失败,请重试</text>
34
+                        </view>
35
+                        
36
+                        <!-- blob URL预览区域 -->
37
+                        <view v-if="localPdfUrl && !pdfLoading" class="blob-preview-area">
38
+                            <web-view 
39
+                                :src="localPdfUrl" 
40
+                                :key="webViewKey"
41
+                                @message="handleWebViewMessage"
42
+                                @load="handleWebViewLoad"
43
+                                @error="handleWebViewError">
44
+                            </web-view>
45
+                        </view>
46
+                        
47
+                        <!-- 加载遮罩层 -->
48
+                        <view v-if="pdfLoading" class="pdf-loading-mask">
49
+                            <view class="loading-content">
50
+                                <text class="loading-text">PDF加载中...</text>
51
+                                <button class="reload-btn" @tap="downloadAndGenerateBlobUrl">重新加载</button>
52
+                            </view>
53
+                        </view>
54
+                    </view>
55
+                    
56
+                    <!-- H5端:web-view预览方案 -->
57
+                    <view v-else class="web-pdf-viewer">
58
+                        <web-view 
59
+                            :src="getPdfViewUrl()" 
60
+                            :key="webViewKey"
61
+                            @message="handleWebViewMessage"
62
+                            @load="handleWebViewLoad"
63
+                            @error="handleWebViewError">
64
+                        </web-view>
65
+                        <!-- 加载遮罩层 -->
66
+                        <view v-if="pdfLoading" class="pdf-loading-mask">
67
+                            <view class="loading-content">
68
+                                <text class="loading-text">PDF加载中...</text>
69
+                                <button v-if="isApp" class="reload-btn" @tap="reloadWebView">重新加载</button>
70
+                            </view>
71
+                        </view>
72
+                    </view>
73
+                </view>
74
+                <view v-else class="file-viewer">
75
+                    <!-- 非PDF文件,提示用户 -->
76
+                    <view class="file-notice">
77
+                        <text>仅支持PDF格式文件预览</text>
78
+                        <button class="download-btn" @click="downloadFile">下载文件</button>
79
+                    </view>
80
+                </view>
81
+            </view>
82
+
83
+            <view class="preview-container" v-else>
84
+                <view class="file-notice">
85
+                    <text>暂无文件信息</text>
86
+                </view>
87
+            </view>
88
+        </view>
89
+    </home-container>
90
+</template>
91
+
92
+<script>
93
+import HomeContainer from "@/components/HomeContainer.vue";
94
+import { getWorkDocu } from "@/api/workDocu/workDocu.js";
95
+import config from '@/config';
96
+export default {
97
+    components: {
98
+        HomeContainer
99
+    },
100
+    data() {
101
+        return {
102
+            documentInfo: {
103
+                docTitle: '',
104
+                createUser: '',
105
+                createTime: '',
106
+                documentUrl: '',
107
+                fileType: ''
108
+            },
109
+            loading: true,
110
+            docId: '',
111
+            pdfLoading: true,
112
+            baseUrl: config.baseUrl,
113
+            isApp: false, // 是否为App环境
114
+            webViewKey: 0, // web-view强制刷新key
115
+            pdfLoadError: false, // PDF加载错误状态
116
+            localPdfUrl: '', // 本地PDF文件URL
117
+            isDownloading: false // 是否正在下载
118
+        };
119
+    },
120
+    computed: {
121
+        // 判断是否为PDF文件
122
+        isPdfFile() {
123
+            return this.documentInfo.fileType === 'pdf' ||
124
+                (this.documentInfo.documentUrl && this.documentInfo.documentUrl.toLowerCase().endsWith('.pdf'));
125
+        }
126
+    },
127
+    onLoad(options) {
128
+        // 从路由参数中获取文档ID
129
+        if (options && options.id) {
130
+            this.docId = options.id;
131
+            this.fetchDocumentDetail();
132
+        }
133
+        
134
+     
135
+    },
136
+    methods: {
137
+        
138
+        // 确保URL以斜杠结尾
139
+        ensureTrailingSlash(url) {
140
+            return url.endsWith('/') ? url : url + '/';
141
+        },
142
+        
143
+        formatDate(timeString) {
144
+            if (!timeString) return '';
145
+            const date = new Date(timeString);
146
+            const year = date.getFullYear();
147
+            const month = String(date.getMonth() + 1).padStart(2, '0');
148
+            const day = String(date.getDate()).padStart(2, '0');
149
+            return `${year}-${month}-${day}`;
150
+        },
151
+
152
+        // 获取文档详情
153
+        async fetchDocumentDetail() {
154
+            this.loading = true;
155
+            try {
156
+                // 调用真实的API获取文档详情
157
+                const response = await getWorkDocu(this.docId);
158
+
159
+                // 根据实际API返回的数据结构进行处理
160
+                // 注意字段映射:documentTitle替代docTitle,createBy替代createUser
161
+                this.documentInfo = response.data;
162
+             
163
+                
164
+            } catch (error) {
165
+                console.error('获取文档详情失败:', error);
166
+                uni.showToast({
167
+                    title: '加载文档失败',
168
+                    icon: 'none'
169
+                });
170
+            } finally {
171
+                this.loading = false;
172
+                // 文档加载完成后,设置PDF加载状态
173
+                if (this.isPdfFile && this.documentInfo.documentUrl) {
174
+                    // 延迟一段时间隐藏加载提示
175
+                    setTimeout(() => {
176
+                        this.pdfLoading = false;
177
+                    }, 2000);
178
+                }
179
+            }
180
+        },
181
+        
182
+        // 处理web-view消息
183
+        handleWebViewMessage(e) {
184
+            console.log('web-view message:', e);
185
+            this.pdfLoading = false;
186
+        },
187
+
188
+        // 下载文件
189
+        downloadFile() {
190
+            if (!this.documentInfo.documentUrl) return;
191
+
192
+            uni.showModal({
193
+                title: '提示',
194
+                content: '确定要下载此文件吗?',
195
+                success: (res) => {
196
+                    if (res.confirm) {
197
+                        // 使用uni-app的下载API
198
+                        uni.downloadFile({
199
+                            url: this.documentInfo.documentUrl,
200
+                            success: (downloadResult) => {
201
+                                if (downloadResult.statusCode === 200) {
202
+                                    // 保存文件到本地
203
+                                    uni.saveFile({
204
+                                        tempFilePath: downloadResult.tempFilePath,
205
+                                        success: (saveResult) => {
206
+                                            uni.showToast({
207
+                                                title: '下载成功',
208
+                                                icon: 'success'
209
+                                            });
210
+                                            // 提示用户打开文件
211
+                                            uni.showModal({
212
+                                                title: '下载成功',
213
+                                                content: '文件已下载完成,是否打开?',
214
+                                                success: (openRes) => {
215
+                                                    if (openRes.confirm) {
216
+                                                        // 打开下载的文件
217
+                                                        uni.openDocument({
218
+                                                            filePath: saveResult.savedFilePath,
219
+                                                            showMenu: true,
220
+                                                            success: () => {
221
+                                                                console.log('打开文档成功');
222
+                                                            },
223
+                                                            fail: (error) => {
224
+                                                                console.error('打开文档失败:', error);
225
+                                                                uni.showToast({
226
+                                                                    title: '打开失败,请手动打开',
227
+                                                                    icon: 'none'
228
+                                                                });
229
+                                                            }
230
+                                                        });
231
+                                                    }
232
+                                                }
233
+                                            });
234
+                                        },
235
+                                        fail: (error) => {
236
+                                            console.error('保存文件失败:', error);
237
+                                            uni.showToast({
238
+                                                title: '保存失败',
239
+                                                icon: 'none'
240
+                                            });
241
+                                        }
242
+                                    });
243
+                                }
244
+                            },
245
+                            fail: (error) => {
246
+                                console.error('下载文件失败:', error);
247
+                                uni.showToast({
248
+                                    title: '下载失败',
249
+                                    icon: 'none'
250
+                                });
251
+                            }
252
+                        });
253
+                    }
254
+                }
255
+            });
256
+        },
257
+
258
+        // 下载文件并生成blob URL(App端专用)
259
+        async downloadAndGenerateBlobUrl() {
260
+            if (!this.documentInfo.documentUrl) {
261
+                uni.showToast({
262
+                    title: '文件链接无效',
263
+                    icon: 'none'
264
+                });
265
+                return;
266
+            }
267
+            
268
+            // 如果正在下载,直接返回
269
+            if (this.isDownloading) {
270
+                return;
271
+            }
272
+            
273
+            this.isDownloading = true;
274
+            this.pdfLoading = true;
275
+            
276
+            // 显示加载提示
277
+            uni.showLoading({
278
+                title: '正在下载PDF...',
279
+                mask: true
280
+            });
281
+            
282
+            try {
283
+                // 下载文件到本地
284
+                const downloadResult = await new Promise((resolve, reject) => {
285
+                    uni.downloadFile({
286
+                        url: this.documentInfo.documentUrl,
287
+                        success: resolve,
288
+                        fail: reject
289
+                    });
290
+                });
291
+                
292
+                if (downloadResult.statusCode === 200) {
293
+                    // 读取文件内容并生成blob URL
294
+                    const fileData = await this.readFileAsArrayBuffer(downloadResult.tempFilePath);
295
+                    const blob = new Blob([fileData], { type: 'application/pdf' });
296
+                    const blobUrl = URL.createObjectURL(blob);
297
+                    
298
+                    // 保存blob URL
299
+                    this.localPdfUrl = blobUrl;
300
+                    
301
+                    uni.hideLoading();
302
+                    uni.showToast({
303
+                        title: 'PDF加载成功',
304
+                        icon: 'success'
305
+                    });
306
+                    
307
+                    // 强制刷新web-view
308
+                    this.webViewKey++;
309
+                    this.pdfLoading = false;
310
+                    
311
+                } else {
312
+                    throw new Error(`下载失败,状态码: ${downloadResult.statusCode}`);
313
+                }
314
+            } catch (error) {
315
+                console.error('下载生成blob URL失败:', error);
316
+                uni.hideLoading();
317
+                
318
+                uni.showToast({
319
+                    title: 'PDF加载失败,请重试',
320
+                    icon: 'none'
321
+                });
322
+                
323
+                // 标记加载错误状态
324
+                this.pdfLoadError = true;
325
+                this.pdfLoading = false;
326
+            } finally {
327
+                this.isDownloading = false;
328
+            }
329
+        },
330
+        
331
+        // 读取文件为ArrayBuffer
332
+        readFileAsArrayBuffer(filePath) {
333
+            return new Promise((resolve, reject) => {
334
+                if (typeof plus !== 'undefined') {
335
+                    // App端使用plus.io读取文件
336
+                    plus.io.resolveLocalFileSystemURL(filePath, (entry) => {
337
+                        entry.file((file) => {
338
+                            const fileReader = new plus.io.FileReader();
339
+                            fileReader.onload = (e) => {
340
+                                resolve(e.target.result);
341
+                            };
342
+                            fileReader.onerror = reject;
343
+                            fileReader.readAsArrayBuffer(file);
344
+                        }, reject);
345
+                    }, reject);
346
+                } else {
347
+                    // H5端使用XMLHttpRequest
348
+                    const xhr = new XMLHttpRequest();
349
+                    xhr.open('GET', filePath, true);
350
+                    xhr.responseType = 'arraybuffer';
351
+                    xhr.onload = () => {
352
+                        if (xhr.status === 200) {
353
+                            resolve(xhr.response);
354
+                        } else {
355
+                            reject(new Error(`读取文件失败: ${xhr.status}`));
356
+                        }
357
+                    };
358
+                    xhr.onerror = reject;
359
+                    xhr.send();
360
+                }
361
+            });
362
+        },
363
+        
364
+        // 获取PDF预览URL
365
+        getPdfViewUrl() {
366
+            // 如果已经有本地blob URL,优先使用
367
+            if (this.localPdfUrl) {
368
+                return this.localPdfUrl;
369
+            }
370
+            // 否则使用原始URL
371
+            return this.documentInfo.documentUrl;
372
+        }
373
+    }
374
+};
375
+</script>
376
+
377
+<style lang="scss" scoped>
378
+.doc-detail-container {
379
+    margin-top: 40rpx;
380
+    padding: 30rpx;
381
+    border-radius: 16rpx;
382
+    background-color: #ffffff;
383
+    min-height: calc(100vh - 177rpx);
384
+
385
+    // 文档信息样式
386
+    .doc-info {
387
+        margin-bottom: 40rpx;
388
+        border-bottom: 1rpx solid #eeeeee;
389
+        padding-bottom: 30rpx;
390
+        background-color: #fafafa;
391
+        padding: 30rpx;
392
+        border-radius: 10rpx;
393
+
394
+        .doc-title {
395
+            font-size: 36rpx;
396
+            font-weight: bold;
397
+            color: #333333;
398
+            display: block;
399
+            margin-bottom: 25rpx;
400
+            line-height: 1.6;
401
+            word-break: break-word;
402
+        }
403
+
404
+        .doc-meta {
405
+            display: flex;
406
+            flex-wrap: wrap;
407
+            gap: 40rpx;
408
+
409
+            .meta-item {
410
+                font-size: 26rpx;
411
+                color: #666666;
412
+                display: flex;
413
+                align-items: center;
414
+            }
415
+            
416
+        }
417
+    }
418
+
419
+    // 预览容器样式
420
+    .preview-container {
421
+        background-color: #f8f8f8;
422
+        border-radius: 10rpx;
423
+        overflow: hidden;
424
+        box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.05);
425
+
426
+        .pdf-viewer {
427
+            width: 100%;
428
+            height: calc(100vh - 400rpx);
429
+            background-color: #ffffff;
430
+            position: relative;
431
+        }
432
+        
433
+        // 加载遮罩层
434
+        .loading-overlay {
435
+            position: absolute;
436
+            top: 0;
437
+            left: 0;
438
+            width: 100%;
439
+            height: 100%;
440
+            background-color: rgba(255, 255, 255, 0.8);
441
+            display: flex;
442
+            justify-content: center;
443
+            align-items: center;
444
+            z-index: 10;
445
+        }
446
+        
447
+        .loading-text {
448
+            color: #666666;
449
+            font-size: 32rpx;
450
+            padding: 20rpx;
451
+            background-color: white;
452
+            border-radius: 8rpx;
453
+            box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.1);
454
+        }
455
+
456
+        // 加载状态样式
457
+        .loading-indicator {
458
+            display: flex;
459
+            justify-content: center;
460
+            align-items: center;
461
+            height: 200rpx;
462
+            color: #666666;
463
+            font-size: 28rpx;
464
+        }
465
+
466
+        // 文件提示样式
467
+        .file-notice {
468
+            display: flex;
469
+            flex-direction: column;
470
+            justify-content: center;
471
+            align-items: center;
472
+            height: 200rpx;
473
+            color: #999999;
474
+            font-size: 28rpx;
475
+
476
+            .download-btn {
477
+                margin-top: 30rpx;
478
+                background-color: #007aff;
479
+                color: #ffffff;
480
+            }
481
+        }
482
+        
483
+        .error-notice {
484
+            margin-top: 20rpx;
485
+            padding: 20rpx;
486
+            background-color: #fff2f0;
487
+            border: 1px solid #ffccc7;
488
+            border-radius: 8rpx;
489
+            text-align: center;
490
+        }
491
+        .error-notice text {
492
+            color: #a8071a;
493
+            font-size: 28rpx;
494
+        }
495
+        
496
+        .blob-preview-area {
497
+            margin-top: 30rpx;
498
+            border: 1px solid #e8e8e8;
499
+            border-radius: 8rpx;
500
+            overflow: hidden;
501
+            height: 600rpx;
502
+        }
503
+        .blob-preview-area web-view {
504
+            width: 100%;
505
+            height: 100%;
506
+        }
507
+    }
508
+}
509
+</style>

+ 441 - 0
src/pages/workProfile/components/management-push.vue

@@ -0,0 +1,441 @@
1
+<template>
2
+    <uni-collapse>
3
+        <profile-collapse-item title="管理推动" name="managementPush" :borderColor="'#00C853'">
4
+            <div class="detail-card">
5
+
6
+                <!-- 巡检任务执行趋势图 -->
7
+                <div class="chart-section">
8
+                    <h4 class="chart-title">巡检任务执行趋势统计图</h4>
9
+                    <div ref="taskChart" class="chart"></div>
10
+                </div>
11
+
12
+                <!-- 整改统计 -->
13
+                <div class="chart-section">
14
+                    <h4 class="chart-title">整改统计</h4>
15
+                    <div class="rectification-stats">
16
+                        <h-legend :legend-data="rectificationData" :colorSize="20" :textSize="24" />
17
+                        <h-line :data="rectificationData" :total="rectificationTotal" :itemWidth="300"
18
+                            :lineHeight="'40'" />
19
+                    </div>
20
+                </div>
21
+
22
+                <!-- 抽问抽答完成率趋势图 -->
23
+                <div class="chart-section">
24
+                    <h4 class="chart-title">抽问抽答完成率趋势图</h4>
25
+                    <div ref="qaChart" class="chart"></div>
26
+                </div>
27
+
28
+                <!-- 查获审批时长统计 -->
29
+                <div class="chart-section">
30
+                    <h4 class="chart-title">查获审批时长统计</h4>
31
+                    <statistic-table :columns="approvalColumns" :data="approvalData" />
32
+                </div>
33
+            </div>
34
+        </profile-collapse-item>
35
+    </uni-collapse>
36
+
37
+
38
+
39
+</template>
40
+
41
+<script>
42
+import * as echarts from 'echarts'
43
+import HLegend from '@/components/h-legend/h-legend.vue'
44
+import HLine from '@/components/h-line/h-line.vue'
45
+import StatisticTable from '@/components/statistic-table/statistic-table.vue'
46
+import ProfileCollapseItem from './profile-collapse-item.vue'
47
+
48
+export default {
49
+    name: 'ManagementPush',
50
+    components: {
51
+        HLegend,
52
+        HLine,
53
+        StatisticTable,
54
+        ProfileCollapseItem
55
+    },
56
+    inject: ['getLevelRateData', 'getManagementPushData', 'getManagementRectifyData', 'getDurationChartData'],
57
+    data() {
58
+        return {
59
+            rectificationData: [],
60
+            rectificationTotal: 0,
61
+            approvalColumns: [
62
+                { props: 'empty', title: '指标' },
63
+                { props: 'duration', title: '时长' }
64
+            ],
65
+            approvalData: [],
66
+            charts: {}
67
+        }
68
+    },
69
+    computed: {
70
+        levelRateData() {
71
+            return this.getLevelRateData()
72
+        },
73
+        managementPushData() {
74
+            return this.getManagementPushData()
75
+        },
76
+        managementRectifyData() {
77
+            return this.getManagementRectifyData()
78
+        },
79
+        durationData() {
80
+            return this.getDurationChartData()
81
+        }
82
+    },
83
+    watch: {
84
+        managementRectifyData: {
85
+            handler(newVal) {
86
+                this.handleData(newVal)
87
+            },
88
+            deep: true
89
+        },
90
+        levelRateData: {
91
+            handler() {
92
+                this.updateCharts()
93
+            },
94
+            deep: true
95
+        },
96
+        managementPushData: {
97
+            handler() {
98
+                this.updateTaskChart()
99
+            },
100
+            deep: true
101
+        },
102
+        durationData: {
103
+            handler(newV) {
104
+                this.approvalData = [
105
+                    { empty: '平均审批时长', duration: newV.averageDurationText || '-' },
106
+                    { empty: '最长审批时常', duration: newV.maxDurationText || '-' },
107
+                    { empty: '最短审批时常', duration: newV.minDurationText || '-' }
108
+                ]
109
+            },
110
+            deep: true
111
+        },
112
+    },
113
+    mounted() {
114
+        this.initCharts()
115
+        this.updateCharts()
116
+    },
117
+    beforeDestroy() {
118
+        Object.values(this.charts).forEach(chart => {
119
+            chart && chart.dispose()
120
+        })
121
+    },
122
+    methods: {
123
+        handleData(newVal) {
124
+            this.rectificationData = [
125
+                { name: '按期整改', value: newVal.onTimeCompletedCount, color: '#4873E3' },
126
+                { name: '超期整改', value: newVal.overTimeCompletedCount, color: '#FF6B6B' }
127
+            ]
128
+            this.rectificationTotal = newVal.onTimeCompletedCount + newVal.overTimeCompletedCount
129
+        },
130
+        initCharts() {
131
+            this.initTaskChart()
132
+            this.initQAChart()
133
+        },
134
+
135
+        updateCharts() {
136
+            this.updateTaskChart()
137
+            this.updateQAChart()
138
+        },
139
+
140
+        initTaskChart() {
141
+            const chartDom = this.$refs.taskChart
142
+            if (!chartDom) return
143
+
144
+            this.charts.task = echarts.init(chartDom)
145
+        },
146
+
147
+        updateTaskChart() {
148
+            const chart = this.charts.task
149
+            if (!chart) return
150
+
151
+            // 处理managementPushData数据
152
+            const managementData = this.managementPushData || []
153
+
154
+            // 按日期分组数据
155
+            const dateGroups = {}
156
+            managementData.length && managementData.forEach(item => {
157
+                if (!dateGroups[item.date]) {
158
+                    dateGroups[item.date] = {
159
+                        date: item.date,
160
+                        dailyTask: 0,
161
+                        specialTask: 0
162
+                    }
163
+                }
164
+
165
+                if (item.typeDesc === '日常任务') {
166
+                    dateGroups[item.date].dailyTask = item.total
167
+                } else if (item.typeDesc === '专项任务') {
168
+                    dateGroups[item.date].specialTask = item.total
169
+                }
170
+            })
171
+
172
+            // 转换为数组并按日期排序
173
+            const sortedData = Object.values(dateGroups).sort((a, b) => {
174
+                return new Date(a.date) - new Date(b.date)
175
+            })
176
+
177
+            // 提取x轴数据(格式化日期)
178
+            const xAxisData = sortedData.map(item => {
179
+                const date = new Date(item.date)
180
+                return `${date.getMonth() + 1}月${date.getDate()}日`
181
+            })
182
+
183
+            // 提取两条曲线的数据
184
+            const dailyTaskData = sortedData.map(item => item.dailyTask)
185
+            const specialTaskData = sortedData.map(item => item.specialTask)
186
+
187
+            const option = {
188
+                tooltip: {
189
+                    trigger: 'axis',
190
+                    formatter: function (params) {
191
+                        let result = `${params[0].axisValue}<br/>`
192
+                        params.forEach(param => {
193
+                            result += `${param.seriesName}: ${param.value}<br/>`
194
+                        })
195
+                        return result
196
+                    }
197
+                },
198
+                legend: {
199
+                    data: ['日常任务', '专项任务'],
200
+                    top: 10
201
+                },
202
+                dataZoom: [
203
+                    {
204
+                        type: 'slider', // 滑动条型数据区域缩放组件
205
+                        show: true,
206
+                        xAxisIndex: [0],
207
+                        start: 0,
208
+                        end: 100,
209
+                        height: 20,
210
+                        bottom: 10,
211
+                        backgroundColor: '#f5f5f5',
212
+                        dataBackground: {
213
+                            lineStyle: {
214
+                                color: '#9C27B0',
215
+                                width: 1,
216
+                                opacity: 0.3
217
+                            },
218
+                            areaStyle: {
219
+                                color: 'rgba(156, 39, 176, 0.1)'
220
+                            }
221
+                        },
222
+                        fillerColor: 'rgba(156, 39, 176, 0.2)',
223
+                        borderColor: '#ddd',
224
+                        textStyle: {
225
+                            color: '#666'
226
+                        }
227
+                    },
228
+                    {
229
+                        type: 'inside', // 内置型数据区域缩放组件
230
+                        xAxisIndex: [0],
231
+                        start: 0,
232
+                        end: 100,
233
+                        zoomOnMouseWheel: true, // 支持鼠标滚轮缩放
234
+                        moveOnMouseMove: true, // 支持鼠标拖拽平移
235
+                        moveOnMouseWheel: false
236
+                    }
237
+                ],
238
+                xAxis: {
239
+                    type: 'category',
240
+                    data: xAxisData,
241
+                    axisLabel: {
242
+                        rotate: 45,
243
+
244
+                        fontSize: 10
245
+                    },
246
+                    axisTick: {
247
+                        alignWithLabel: true
248
+                    }
249
+                },
250
+                yAxis: {
251
+                    type: 'value',
252
+                    name: '任务数量'
253
+                },
254
+                series: [
255
+                    {
256
+                        name: '日常任务',
257
+                        data: dailyTaskData,
258
+                        type: 'line',
259
+                        smooth: true,
260
+                        lineStyle: {
261
+                            color: '#9C27B0',
262
+                            width: 3
263
+                        },
264
+                        itemStyle: {
265
+                            color: '#9C27B0'
266
+                        },
267
+                        areaStyle: {
268
+                            color: 'rgba(156, 39, 176, 0.3)'
269
+                        }
270
+                    },
271
+                    {
272
+                        name: '专项任务',
273
+                        data: specialTaskData,
274
+                        type: 'line',
275
+                        smooth: true,
276
+                        lineStyle: {
277
+                            color: '#00BCD4',
278
+                            width: 3
279
+                        },
280
+                        itemStyle: {
281
+                            color: '#00BCD4'
282
+                        },
283
+                        areaStyle: {
284
+                            color: 'rgba(0, 188, 212, 0.3)'
285
+                        }
286
+                    }
287
+                ],
288
+                grid: {
289
+                    left: '5%',
290
+                    right: '5%',
291
+                    top: '15%',
292
+                    bottom: '20%', // 增加底部空间给数据缩放组件
293
+                    containLabel: true
294
+                }
295
+            }
296
+
297
+            chart.setOption(option)
298
+        },
299
+
300
+        initQAChart() {
301
+            const chartDom = this.$refs.qaChart
302
+            if (!chartDom) return
303
+
304
+            this.charts.qa = echarts.init(chartDom)
305
+        },
306
+
307
+        updateQAChart() {
308
+            const chart = this.charts.qa
309
+            if (!chart) return
310
+
311
+            // 使用levelRateData数据
312
+            const levelRateData = this.levelRateData || []
313
+            const dates = levelRateData.map(item => item.date)
314
+            const completedTasks = levelRateData.map(item => item.completedTasks)
315
+
316
+            const option = {
317
+                tooltip: {
318
+                    trigger: 'axis',
319
+                    formatter: function (params) {
320
+                        const data = params[0]
321
+                        return `${data.name}<br/>已完成任务: ${data.value}`
322
+                    }
323
+                },
324
+                xAxis: {
325
+                    type: 'category',
326
+                    data: dates,
327
+                    axisLabel: {
328
+                        rotate: 45,
329
+
330
+                        fontSize: 10
331
+                    },
332
+                    axisTick: {
333
+                        alignWithLabel: true
334
+                    }
335
+                },
336
+                yAxis: {
337
+                    type: 'value',
338
+                    name: '已完成任务'
339
+                },
340
+                dataZoom: [
341
+                    {
342
+                        type: 'slider', // 滑动条型数据区域缩放组件
343
+                        show: true,
344
+                        xAxisIndex: [0],
345
+                        start: 0,
346
+                        end: 100,
347
+                        height: 20,
348
+                        bottom: 10,
349
+                        backgroundColor: '#f5f5f5',
350
+                        dataBackground: {
351
+                            lineStyle: {
352
+                                color: '#00BCD4',
353
+                                width: 1,
354
+                                opacity: 0.3
355
+                            },
356
+                            areaStyle: {
357
+                                color: 'rgba(0, 188, 212, 0.1)'
358
+                            }
359
+                        },
360
+                        fillerColor: 'rgba(0, 188, 212, 0.2)',
361
+                        borderColor: '#ddd',
362
+                        textStyle: {
363
+                            color: '#666'
364
+                        }
365
+                    },
366
+                    {
367
+                        type: 'inside', // 内置型数据区域缩放组件
368
+                        xAxisIndex: [0],
369
+                        start: 0,
370
+                        end: 100,
371
+                        zoomOnMouseWheel: true, // 支持鼠标滚轮缩放
372
+                        moveOnMouseMove: true, // 支持鼠标拖拽平移
373
+                        moveOnMouseWheel: false
374
+                    }
375
+                ],
376
+                series: [{
377
+                    name: '已完成任务',
378
+                    data: completedTasks,
379
+                    type: 'line',
380
+                    smooth: true,
381
+                    lineStyle: {
382
+                        color: '#00BCD4',
383
+                        width: 3
384
+                    },
385
+                    itemStyle: {
386
+                        color: '#00BCD4'
387
+                    },
388
+                    areaStyle: {
389
+                        color: 'rgba(0, 188, 212, 0.3)'
390
+                    }
391
+                }],
392
+                grid: {
393
+                    left: '5%',
394
+                    right: '5%',
395
+                    top: '15%',
396
+                    bottom: '20%', // 增加底部空间给数据缩放组件
397
+                    containLabel: true
398
+                }
399
+            }
400
+
401
+            chart.setOption(option)
402
+        }
403
+    }
404
+}
405
+</script>
406
+
407
+<style lang="scss" scoped>
408
+.detail-card {
409
+    background: #fff;
410
+    border-radius: 16rpx;
411
+    padding: 10rpx 30rpx;
412
+    // margin-bottom: 32rpx;
413
+    box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.1);
414
+
415
+    .detail-title {
416
+        font-size: 32rpx;
417
+        font-weight: bold;
418
+        color: #333;
419
+        margin-bottom: 24rpx;
420
+    }
421
+
422
+    .chart-section {
423
+        .chart-title {
424
+            font-size: 28rpx;
425
+            font-weight: bold;
426
+            color: #333;
427
+            margin-bottom: 24rpx;
428
+        }
429
+
430
+        .chart {
431
+            height: 500rpx;
432
+        }
433
+
434
+        .rectification-stats {
435
+            .h-line {
436
+                margin-top: 24rpx;
437
+            }
438
+        }
439
+    }
440
+}
441
+</style>

+ 334 - 0
src/pages/workProfile/components/organization-support.vue

@@ -0,0 +1,334 @@
1
+<template>
2
+    <uni-collapse>
3
+        <profile-collapse-item title="组织支撑" name="organizationSupport" :borderColor="'#007AFF'">
4
+            <div class="detail-card">
5
+
6
+                <!-- 左右布局的文字信息 -->
7
+                <div class="text-layout">
8
+                    <div class="content-item">
9
+                        <div class="item-label">胜任岗位数统计</div>
10
+                        <!-- <div class="item-value">{{ showData.positionCount || '-' }}</div> -->
11
+                    </div>
12
+                    <div class="content-item">
13
+
14
+                        <div class="item-value">{{ showData.positionText || '-' }}</div>
15
+                    </div>
16
+                    <div class="content-item" style="margin-bottom: 18rpx;">
17
+                        <div class="item-label">人均在岗时长</div>
18
+                        <div class="item-value">{{ showData.averagePersonnel || '-' }}</div>
19
+                    </div>
20
+                </div>
21
+
22
+                <!-- 资质能力统计图 -->
23
+                <div class="chart-section">
24
+                    <h4 class="chart-title">资质能力统计图</h4>
25
+                    <div class="chart-container">
26
+                        <div ref="qualificationChart" class="chart"></div>
27
+                    </div>
28
+                </div>
29
+
30
+                <!-- 培训测试平均分趋势图 -->
31
+                <div class="chart-section">
32
+                    <h4 class="chart-title">培训测试平均分趋势图</h4>
33
+                    <div class="chart-container">
34
+                        <div ref="trainingChart" class="chart"></div>
35
+                    </div>
36
+                </div>
37
+            </div>
38
+        </profile-collapse-item>
39
+    </uni-collapse>
40
+
41
+</template>
42
+
43
+<script>
44
+import * as echarts from 'echarts'
45
+import useDictMixin from "@/utils/dict";
46
+import ProfileCollapseItem from './profile-collapse-item.vue'
47
+export default {
48
+    name: 'OrganizationSupport',
49
+    components: {
50
+        ProfileCollapseItem
51
+    },
52
+
53
+    mixins: [useDictMixin],
54
+    inject: ['overviewData', 'moduleMetricsData', 'getTrainingTestScoreTrendData'],
55
+    data() {
56
+        return {
57
+            charts: {},
58
+            showData: {}
59
+        }
60
+    },
61
+    computed: {
62
+        organization() {
63
+            return this.overviewData.organization || ''
64
+        },
65
+        moduleMetrics() {
66
+            return this.moduleMetricsData() || {}
67
+        },
68
+        testScoreTrend() {
69
+            return this.getTrainingTestScoreTrendData() || []
70
+        },
71
+        channelOpenTrend() {
72
+            return this.getChannelOpenTrendChartData() || []
73
+        },
74
+    },
75
+    mounted() {
76
+        this.initCharts()
77
+
78
+    },
79
+    beforeDestroy() {
80
+        Object.values(this.charts).forEach(chart => {
81
+            chart && chart.dispose()
82
+        })
83
+    },
84
+    watch: {
85
+        moduleMetrics: {
86
+            handler(newVal, oldVal) {
87
+                this.updateQualificationChart()
88
+                const positionCompetencyStats = newVal.system && newVal.system.positionCompetencyStats || []
89
+                let positionText = []
90
+                for (const item of positionCompetencyStats) {
91
+                    positionText.push(`${item.postName} ${item.competentCount}人`)
92
+                }
93
+                this.showData = {
94
+                    averagePersonnel: newVal.attendance.workingHours.averagePersonnel || '-',
95
+                    positionText: positionText.join('、'),
96
+                    positionCount: positionCompetencyStats.reduce((acc, cur) => acc + cur.competentCount, 0) || '-',
97
+                }
98
+            },
99
+            deep: true
100
+        },
101
+        testScoreTrend: {
102
+            handler(newVal, oldVal) {
103
+                this.updateTrainingChart()
104
+            },
105
+            deep: true
106
+        },
107
+
108
+    },
109
+    methods: {
110
+        initCharts() {
111
+            this.initQualificationChart()
112
+            this.initTrainingChart()
113
+        },
114
+
115
+        updateCharts() {
116
+            this.updateQualificationChart()
117
+            this.updateTrainingChart()
118
+        },
119
+
120
+        initQualificationChart() {
121
+            const chartDom = this.$refs.qualificationChart
122
+            if (!chartDom) return
123
+
124
+            this.charts.qualification = echarts.init(chartDom)
125
+        },
126
+
127
+        async updateQualificationChart() {
128
+            const chart = this.charts.qualification
129
+            if (!chart) return
130
+            const dict = await this.useDict('sys_user_qualification_level')
131
+            const qualificationDict = dict['sys_user_qualification_level'] || [];
132
+
133
+            // 获取availablePositions数据,如果没有数据则使用默认数据
134
+            let qualificationLevel = this.moduleMetrics.system && this.moduleMetrics.system.qualificationLevelStats || []
135
+            let qualificationLevelArr = qualificationLevel.filter(item => item.levelName)
136
+
137
+            // 转换数据格式用于环形图
138
+            const chartData = qualificationLevelArr.map((position, index) => ({
139
+                value: position.count, // 使用索引+1作为值,确保每个岗位都有值
140
+                name: qualificationDict.find(item => item.value === position.levelName)?.label || `资质${index + 1}`
141
+            }))
142
+
143
+
144
+
145
+            const option = {
146
+                tooltip: {
147
+                    trigger: 'item',
148
+                    formatter: function(params) {
149
+                        return `${params.name}: ${params.value}`;
150
+                    }
151
+                },
152
+                legend: {
153
+                    orient: 'vertical',
154
+                    right: 5,
155
+                    top: 'center',
156
+                    itemGap: 8,
157
+                    textStyle: {
158
+                        fontSize: 10
159
+                    },
160
+                    itemWidth: 10,
161
+                    itemHeight: 8
162
+                },
163
+                series: [{
164
+                    name: '可用岗位',
165
+                    type: 'pie',
166
+                    radius: ['40%', '70%'],
167
+                    center: ['45%', '50%'],
168
+                    avoidLabelOverlap: false,
169
+                    
170
+                    label: {
171
+                        show: true,
172
+                        formatter: '{b}: {c}'
173
+                    },
174
+
175
+                    emphasis: {
176
+                        label: {
177
+                            show: true,
178
+                            fontSize: 18,
179
+                            fontWeight: 'bold',
180
+                            formatter: '{b}\n{c}'
181
+                        }
182
+                    },
183
+
184
+                    data: chartData.length > 0 ? chartData : [
185
+                        { value: 1, name: '暂无岗位数据' }
186
+                    ]
187
+                }]
188
+            }
189
+
190
+            chart.setOption(option)
191
+        },
192
+
193
+        initTrainingChart() {
194
+            const chartDom = this.$refs.trainingChart
195
+            if (!chartDom) return
196
+
197
+            this.charts.training = echarts.init(chartDom)
198
+        },
199
+
200
+        updateTrainingChart() {
201
+            const chart = this.charts.training
202
+            if (!chart) return
203
+
204
+            // 处理testScoreTrend数据
205
+            const xAxisData = this.testScoreTrend.map(item => {
206
+                // 格式化日期,只显示月份和日期
207
+                const date = new Date(item.date);
208
+                return `${date.getMonth() + 1}月${date.getDate()}日`;
209
+            });
210
+
211
+            const yAxisData = this.testScoreTrend.map(item => item.number);
212
+
213
+            const option = {
214
+                tooltip: {
215
+                    trigger: 'axis',
216
+                    formatter: function (params) {
217
+                        const data = params[0];
218
+                        const originalData = this.testScoreTrend[data.dataIndex];
219
+                        return `${originalData.name}<br/>${originalData.date}: ${originalData.number}分`;
220
+                    }.bind(this)
221
+                },
222
+                xAxis: {
223
+                    type: 'category',
224
+                    data: xAxisData,
225
+                    axisLabel: {
226
+                        rotate: 45, // 日期标签旋转45度,避免重叠
227
+
228
+                    }
229
+                },
230
+                yAxis: {
231
+                    type: 'value',
232
+                    min: function (value) {
233
+                        // 设置y轴最小值,留出一些空间
234
+                        return Math.max(0, Math.floor(value.min * 0.9));
235
+                    },
236
+                    max: function (value) {
237
+                        // 设置y轴最大值,留出一些空间
238
+                        return Math.min(100, Math.ceil(value.max * 1.1));
239
+                    }
240
+                },
241
+                series: [{
242
+                    data: yAxisData,
243
+                    type: 'line',
244
+                    smooth: true,
245
+                    lineStyle: {
246
+                        color: '#4873E3',
247
+                        width: 3
248
+                    },
249
+                    itemStyle: {
250
+                        color: '#4873E3'
251
+                    },
252
+                    areaStyle: {
253
+                        color: 'rgba(72, 115, 227, 0.3)'
254
+                    },
255
+                    // markPoint: {
256
+                    //     data: [
257
+                    //         { type: 'max', name: '最高分' },
258
+                    //         { type: 'min', name: '最低分' }
259
+                    //     ]
260
+                    // }
261
+                }]
262
+            }
263
+
264
+            chart.setOption(option)
265
+        }
266
+    }
267
+}
268
+</script>
269
+
270
+<style lang="scss" scoped>
271
+.content-item {
272
+  display: flex;
273
+  justify-content: space-between;
274
+  align-items: center;
275
+  margin-bottom: 16rpx;
276
+
277
+  &:last-child {
278
+    margin-bottom: 0;
279
+  }
280
+}
281
+
282
+.item-label {
283
+  font-size: 28rpx;
284
+  color: #333333;
285
+  font-weight: 500;
286
+  min-width: 120rpx;
287
+}
288
+
289
+.item-value {
290
+  font-size: 28rpx;
291
+  color: #999999;
292
+  font-weight: 500;
293
+}
294
+.detail-card {
295
+
296
+    padding: 10rpx 30rpx;
297
+
298
+
299
+    .detail-title {
300
+        font-size: 32rpx;
301
+        font-weight: bold;
302
+        color: #333;
303
+        margin-bottom: 24rpx;
304
+    }
305
+
306
+    .text-layout {
307
+       
308
+    }
309
+
310
+    .chart-section {
311
+        width: 100%;
312
+
313
+        .chart-title {
314
+            font-size: 28rpx;
315
+            font-weight: bold;
316
+            color: #333;
317
+            margin-bottom: 24rpx;
318
+        }
319
+
320
+        .chart-container {
321
+            display: flex;
322
+            justify-content: center;
323
+            align-items: center;
324
+            height: 400rpx;
325
+            width: 100%;
326
+
327
+            .chart {
328
+                width: 100%;
329
+                height: 100%;
330
+            }
331
+        }
332
+    }
333
+}
334
+</style>

+ 150 - 0
src/pages/workProfile/components/profile-collapse-item.vue

@@ -0,0 +1,150 @@
1
+<template>
2
+    <view class="profile-collapse-item" :style="{ borderTopColor: borderColor }">
3
+        <uni-collapse-item :name="name" :show-animation="showAnimation" :disabled="disabled"  :open="open" ref="collapseItem"
4
+            @change="handleCollapseChange">
5
+            <template #title>
6
+                <view class="profile-collapse-item-title">
7
+                    <view class="title-left-bar" :style="{ backgroundColor: borderColor }"></view>
8
+                    <view v-if="iconUrl" class="title-icon"
9
+                        :style="{ backgroundImage: iconUrl ? 'url(' + iconUrl + ')' : '' }"></view>
10
+                    <text class="title-text">{{ title }}</text>
11
+                </view>
12
+            </template>
13
+            <view ref="contentSlot">
14
+                <slot></slot>
15
+            </view>
16
+        </uni-collapse-item>
17
+    </view>
18
+</template>
19
+
20
+<script>
21
+export default {
22
+    name: 'profileCollapseItem',
23
+    props: {
24
+        // 折叠面板名称
25
+        name: {
26
+            type: String,
27
+            default: ''
28
+        },
29
+        // 标题文本
30
+        title: {
31
+            type: String,
32
+            default: ''
33
+        },
34
+        // 是否显示动画
35
+        showAnimation: {
36
+            type: Boolean,
37
+            default: true
38
+        },
39
+        // 是否禁用
40
+        disabled: {
41
+            type: Boolean,
42
+            default: false
43
+        },
44
+        // 是否默认展开
45
+        open: {
46
+            type: Boolean,
47
+            default: true
48
+        },
49
+        // 图标URL地址(可选)
50
+        iconUrl: {
51
+            type: String,
52
+            default: ''
53
+        },
54
+        // 边框和左侧竖条的颜色
55
+        borderColor: {
56
+            type: String,
57
+            default: '#4873E3'
58
+        }
59
+    },
60
+    mounted() {
61
+
62
+    },
63
+    methods: {
64
+        handleCollapseChange(e) {
65
+            // 当折叠面板状态变化时,如果是展开状态,重新计算高度
66
+            if (e.detail.show) {
67
+                this.$nextTick(() => {
68
+                    this.updateCollapseHeight();
69
+                });
70
+            }
71
+        },
72
+
73
+        updateCollapseHeight() {
74
+            // 简化高度计算逻辑,避免干扰uni-collapse-item的默认行为
75
+            if (this.$refs.collapseItem && this.$refs.collapseItem.resize) {
76
+                // 使用uni-collapse-item提供的resize方法
77
+                this.$nextTick(() => {
78
+                    this.$refs.collapseItem.resize();
79
+                });
80
+            }
81
+        }
82
+    },
83
+    beforeDestroy() {
84
+        if (this.contentObserver) {
85
+            this.contentObserver.disconnect();
86
+        }
87
+    }
88
+}
89
+</script>
90
+
91
+<style lang="scss" scoped>
92
+.profile-collapse-item {
93
+    border-top: 6rpx solid;
94
+    box-shadow: 0px 4px 10px 0px rgba(0, 0, 0, 0.08);
95
+    border-radius: 32rpx;
96
+    margin-bottom: 24rpx;
97
+    overflow: hidden;
98
+}
99
+
100
+::v-deep .uni-collapse-item__title {
101
+    height: 96rpx !important;
102
+    padding-left: 28rpx;
103
+}
104
+
105
+// 确保内容区域在展开时高度自适应
106
+::v-deep .uni-collapse-item__wrap {
107
+    // 保持原有行为,不设置固定高度
108
+}
109
+
110
+::v-deep .uni-collapse-item__content {
111
+    // 移除任何可能影响动画的高度限制
112
+    // 让uni-collapse-item自己管理高度
113
+    width: 100% !important;
114
+    padding: 0 !important;
115
+}
116
+
117
+::v-deep .uni-collapse-item__wrap-content {
118
+    width: 100% !important;
119
+    padding: 0 !important;
120
+}
121
+
122
+.profile-collapse-item-title {
123
+    display: flex;
124
+    align-items: center;
125
+    position: relative;
126
+
127
+    .title-left-bar {
128
+       
129
+        width: 8rpx;
130
+        height: 32rpx;
131
+        border-radius: 4rpx;
132
+        margin-right: 16rpx;
133
+    }
134
+
135
+    .title-icon {
136
+        width: 40rpx;
137
+        height: 40rpx;
138
+        margin-right: 8px;
139
+        background-size: contain;
140
+        background-repeat: no-repeat;
141
+        background-position: center;
142
+    }
143
+
144
+    .title-text {
145
+        font-size: 30rpx;
146
+        color: #333;
147
+        font-weight: 500;
148
+    }
149
+}
150
+</style>

+ 622 - 0
src/pages/workProfile/components/work-output.vue

@@ -0,0 +1,622 @@
1
+<template>
2
+    <uni-collapse>
3
+        <profile-collapse-item title="工作产出" name="workOutput" :borderColor="'#FF9500'">
4
+            <div class="detail-card">
5
+
6
+                <!-- 通道开放趋势图 -->
7
+                <div class="chart-section">
8
+                    <h4 class="chart-title">通道开放趋势图</h4>
9
+                    <div ref="channelChart" class="chart"></div>
10
+                </div>
11
+
12
+                <!-- 巡检问题统计图 -->
13
+                <div class="chart-section">
14
+                    <h4 class="chart-title">巡检问题统计图</h4>
15
+                    <div ref="inspectionChart" class="chart"></div>
16
+                </div>
17
+
18
+                <!-- 查获上报统计 -->
19
+                <div class="chart-section">
20
+                    <h4 class="chart-title">查获上报统计</h4>
21
+
22
+                    <!-- 三个统计项横向展示 -->
23
+                    <div class="seizure-stats">
24
+                        <div class="stat-item">
25
+                            <div class="stat-label">查获总数</div>
26
+                            <div class="stat-value">{{ (itemData.effectiveSeizureCount &&
27
+                                itemData.effectiveSeizureCount.totalCount)
28
+                                || 0 }}</div>
29
+                        </div>
30
+                        <div class="stat-item">
31
+                            <div class="stat-label">移交公安</div>
32
+                            <div class="stat-value">{{ (itemData.referToPoliceCount &&
33
+                                itemData.referToPoliceCount.totalCount) || 0
34
+                                }}</div>
35
+                        </div>
36
+                        <div class="stat-item">
37
+                            <div class="stat-label">隐匿夹带</div>
38
+                            <div class="stat-value">{{ (itemData.willfulConcealmentCount &&
39
+                                itemData.willfulConcealmentCount.totalCount) || 0 }}</div>
40
+                        </div>
41
+                    </div>
42
+
43
+                    <!-- 查获趋势图 -->
44
+                    <h4 class="chart-title">查获趋势图</h4>
45
+                    <div class="chart-container">
46
+                        <div ref="seizureTrendChart" class="chart"></div>
47
+                    </div>
48
+                </div>
49
+            </div>
50
+        </profile-collapse-item>
51
+    </uni-collapse>
52
+
53
+
54
+</template>
55
+
56
+<script>
57
+import * as echarts from 'echarts'
58
+import ProfileCollapseItem from './profile-collapse-item.vue'
59
+
60
+export default {
61
+    name: 'WorkOutput',
62
+    components: {
63
+        ProfileCollapseItem
64
+    },
65
+    inject: ['getPortraitData', 'moduleMetricsData', 'getInspectionProblemChartData', 'getChannelOpenTrendChartData', 'getSeizureTrendChartData'],
66
+    data() {
67
+        return {
68
+            charts: {},
69
+            seizureStats: {
70
+                total: 0,
71
+                transferPolice: 0,
72
+                hiddenCarry: 0
73
+            }
74
+        }
75
+    },
76
+    computed: {
77
+        portraitData() {
78
+            return this.getPortraitData()
79
+        },
80
+        moduleMetrics() {
81
+            return this.moduleMetricsData()
82
+        },
83
+        itemData() {
84
+            return this.moduleMetrics && this.moduleMetrics.item || {}
85
+        },
86
+        inspectionProblemData() {
87
+            return this.getInspectionProblemChartData()
88
+        },
89
+        channelOpenTrendData() {
90
+            return this.getChannelOpenTrendChartData()
91
+        },
92
+        seizureTrendData() {
93
+            return this.getSeizureTrendChartData()
94
+        }
95
+    },
96
+    watch: {
97
+        inspectionProblemData: {
98
+            handler(newVal, oldVal) {
99
+                this.updateInspectionChart()
100
+            },
101
+            deep: true
102
+        },
103
+        channelOpenTrendData: {
104
+            handler(newVal, oldVal) {
105
+                this.updateChannelChart()
106
+            },
107
+            deep: true
108
+        },
109
+        seizureTrendData: {
110
+            handler(newVal, oldVal) {
111
+                this.updateSeizureTrendChart()
112
+            },
113
+            deep: true
114
+        }
115
+    },
116
+    mounted() {
117
+        this.initCharts()
118
+
119
+    },
120
+    beforeDestroy() {
121
+        Object.values(this.charts).forEach(chart => {
122
+            chart && chart.dispose()
123
+        })
124
+    },
125
+    methods: {
126
+        initCharts() {
127
+            this.initChannelChart()
128
+            this.initInspectionChart()
129
+            this.initSeizureTrendChart()
130
+        },
131
+
132
+        updateCharts() {
133
+            this.updateChannelChart()
134
+            this.updateInspectionChart()
135
+            this.updateSeizureTrendChart()
136
+        },
137
+
138
+        initChannelChart() {
139
+            const chartDom = this.$refs.channelChart
140
+            if (!chartDom) return
141
+
142
+            this.charts.channel = echarts.init(chartDom)
143
+        },
144
+
145
+        updateChannelChart() {
146
+            const chart = this.charts.channel
147
+            if (!chart) return
148
+
149
+            // 使用channelOpenTrendData数据
150
+            const channelData = this.channelOpenTrendData.trend || []
151
+            const avg = this.channelOpenTrendData.avg || 0
152
+            // 提取日期、通道数和开放时长
153
+            const dates = channelData.map(item => item.date)
154
+            const openCounts = channelData.map(item => item.openCount || 0)
155
+            const openDurations = channelData.map(item => item.openDuration || 0)
156
+
157
+            const option = {
158
+                tooltip: {
159
+                    trigger: 'axis',
160
+                    formatter: function (params) {
161
+                        let result = `日期: ${params[0].name}<br/>`
162
+                        params.forEach(param => {
163
+                            let value = param.value
164
+                            if (param.seriesName === '开放时长') {
165
+                                value = `${param.value}小时`
166
+                            } else if (param.seriesName === '平均线') {
167
+                                value = `平均值: ${param.value}`
168
+                            }
169
+                            result += `${param.marker} ${param.seriesName}: ${value}<br/>`
170
+                        })
171
+                        return result
172
+                    }
173
+                },
174
+                legend: {
175
+                    data: ['通道开放数', '开放时长', '平均线'],
176
+
177
+                    left: 'center',
178
+                    textStyle: {
179
+                        fontSize: 12
180
+                    }
181
+                },
182
+                xAxis: {
183
+                    type: 'category',
184
+                    data: dates,
185
+                    axisLabel: {
186
+                        rotate: 45, // 日期标签旋转45度,避免重叠
187
+                        fontSize: 10
188
+                    }
189
+                },
190
+                yAxis: [
191
+                    {
192
+                        type: 'value',
193
+                        name: '通道数',
194
+                        position: 'left',
195
+                        min: 0,
196
+                        interval: function() {
197
+                            // 动态计算间隔,确保刻度不会太密
198
+                            const maxCount = Math.max(...openCounts, 5);
199
+                            return maxCount > 10 ? 5 : 3;
200
+                        }(),
201
+                        axisLine: {
202
+                            lineStyle: {
203
+                                color: '#4CAF50'
204
+                            }
205
+                        },
206
+                        axisLabel: {
207
+                            formatter: '{value}'
208
+                        }
209
+                    },
210
+                    {
211
+                        type: 'value',
212
+                        name: '时长(小时)',
213
+                        position: 'right',
214
+                        min: 0,
215
+                        interval: function () {
216
+                            // 动态计算间隔,确保刻度不会太密
217
+                            const maxDuration = Math.max(...openDurations, 10);
218
+                            return maxDuration > 20 ? 10 : 5;
219
+                        }(),
220
+                        axisLine: {
221
+                            lineStyle: {
222
+                                color: '#2196F3'
223
+                            }
224
+                        },
225
+                        axisLabel: {
226
+                            formatter: '{value}'
227
+                        }
228
+                    }
229
+                ],
230
+                grid: {
231
+                    left: '5%',
232
+                    right: '5%',
233
+                    top: '25%', // 增加顶部间距,降低y轴高度
234
+                    bottom: '18%', // 为缩放控件留出空间
235
+                    containLabel: true
236
+                },
237
+                dataZoom: [
238
+                    {
239
+                        type: 'inside', // 内置型数据区域缩放
240
+                        start: 0, // 初始缩放范围
241
+                        end: 100
242
+                    },
243
+                    {
244
+                        type: 'slider', // 滑动条型数据区域缩放
245
+                        show: true,
246
+                        start: 0,
247
+                        end: 100,
248
+                        bottom: '5%',
249
+                        height: 20
250
+                    }
251
+                ],
252
+                series: [
253
+                    {
254
+                        name: '通道开放数',
255
+                        data: openCounts,
256
+                        type: 'line',
257
+                        smooth: true,
258
+                        yAxisIndex: 0,
259
+                        lineStyle: {
260
+                            color: '#4CAF50',
261
+                            width: 2
262
+                        },
263
+                        itemStyle: {
264
+                            color: '#4CAF50'
265
+                        },
266
+                        areaStyle: {
267
+                            color: {
268
+                                type: 'linear',
269
+                                x: 0,
270
+                                y: 0,
271
+                                x2: 0,
272
+                                y2: 1,
273
+                                colorStops: [{
274
+                                    offset: 0,
275
+                                    color: 'rgba(76, 175, 80, 0.3)'
276
+                                }, {
277
+                                    offset: 1,
278
+                                    color: 'rgba(76, 175, 80, 0.1)'
279
+                                }]
280
+                            }
281
+                        }
282
+                    },
283
+                    {
284
+                        name: '开放时长',
285
+                        data: openDurations,
286
+                        type: 'line',
287
+                        smooth: true,
288
+                        yAxisIndex: 1,
289
+                        lineStyle: {
290
+                            color: '#2196F3',
291
+                            width: 2
292
+                        },
293
+                        itemStyle: {
294
+                            color: '#2196F3'
295
+                        }
296
+                    },
297
+                    {
298
+                        name: '平均线',
299
+                        data: openCounts.map(() => avg), // 为每个数据点创建相同的平均值
300
+                        type: 'line',
301
+                        yAxisIndex: 0,
302
+                        lineStyle: {
303
+                            color: '#FF9800',
304
+                            width: 2,
305
+                            type: 'dashed' // 虚线表示平均线
306
+                        },
307
+                        itemStyle: {
308
+                            color: '#FF9800'
309
+                        },
310
+                        symbol: 'none', // 不显示数据点
311
+                        label: {
312
+                            show: true,
313
+                            position: 'end',
314
+                            formatter: `平均值: {c}`
315
+                        }
316
+                    }
317
+                ]
318
+            }
319
+
320
+            chart.setOption(option)
321
+        },
322
+
323
+        initInspectionChart() {
324
+            const chartDom = this.$refs.inspectionChart
325
+            if (!chartDom) return
326
+
327
+            this.charts.inspection = echarts.init(chartDom)
328
+        },
329
+
330
+        updateInspectionChart() {
331
+            const chart = this.charts.inspection
332
+            if (!chart) return
333
+
334
+            // 使用inspectionProblemData数据
335
+            const inspectionData = this.inspectionProblemData || []
336
+
337
+            // 计算最大值,用于雷达图刻度
338
+            const maxTotal = inspectionData.length > 0 ? Math.max(...inspectionData.map(item => item.total || 0)) : 1
339
+
340
+            // 构建雷达图维度指示器
341
+            const indicator = inspectionData.map((item) => {
342
+                return {
343
+                    name: item.name || '未知维度',
344
+                    max: maxTotal > 0 ? maxTotal : 1
345
+                }
346
+            })
347
+
348
+            // 构建雷达图数据系列
349
+            const seriesData = [{
350
+                value: inspectionData.map((item) => {
351
+                    return item.total || 0
352
+                }),
353
+                name: '巡检问题分布',
354
+                lineStyle: {
355
+                    color: '#4873E3',
356
+                    width: 2
357
+                },
358
+                areaStyle: {
359
+                    color: 'rgba(72, 115, 227, 0.3)'
360
+                },
361
+                itemStyle: {
362
+                    color: '#4873E3'
363
+                }
364
+            }]
365
+
366
+            const option = {
367
+                tooltip: {
368
+                    trigger: 'item',
369
+                    formatter: function (params) {
370
+                        // 雷达图的params.value是数组,需要与indicator对应
371
+                        // 使用闭包访问外部的indicator变量
372
+                        const values = params.value || []
373
+                        let result = ``
374
+
375
+                        indicator.forEach((item, index) => {
376
+                            if (values[index] !== undefined) {
377
+                                result += `${item.name}: ${values[index]}<br/>`
378
+                            }
379
+                        })
380
+
381
+                        return result
382
+                    }
383
+                },
384
+                radar: {
385
+                    indicator: indicator,
386
+                    shape: 'polygon',
387
+                    splitNumber: 4,
388
+                    radius: '55%',
389
+                    axisName: {
390
+                        color: '#666',
391
+                        fontSize: 12
392
+                    },
393
+                    splitLine: {
394
+                        lineStyle: {
395
+                            color: ['rgba(238, 238, 238, 0.8)', 'rgba(238, 238, 238, 0.6)',
396
+                                'rgba(238, 238, 238, 0.4)', 'rgba(238, 238, 238, 0.2)']
397
+                        }
398
+                    },
399
+                    splitArea: {
400
+                        show: true,
401
+                        areaStyle: {
402
+                            color: ['rgba(250, 250, 250, 0.8)', 'rgba(250, 250, 250, 0.6)',
403
+                                'rgba(250, 250, 250, 0.4)', 'rgba(250, 250, 250, 0.2)']
404
+                        }
405
+                    }
406
+                },
407
+                series: [{
408
+                    type: 'radar',
409
+                    data: seriesData,
410
+                    symbolSize: 6,
411
+                    symbol: 'circle',
412
+                    // label: {
413
+                    //     show: true,
414
+                    //     formatter: function(params) {
415
+                    //         return params.value
416
+                    //     }
417
+                    // }
418
+                }]
419
+            }
420
+
421
+            chart.setOption(option)
422
+        },
423
+
424
+        initSeizureTrendChart() {
425
+            const chartDom = this.$refs.seizureTrendChart
426
+            if (!chartDom) return
427
+
428
+            this.charts.seizureTrend = echarts.init(chartDom)
429
+        },
430
+
431
+        updateSeizureTrendChart() {
432
+            const chart = this.charts.seizureTrend
433
+            if (!chart) return
434
+
435
+            // 使用seizureTrendData数据
436
+            const seizureData = this.seizureTrendData || []
437
+
438
+            // 提取日期和查获数量
439
+            const dates = seizureData.map(item => item.date)
440
+            const totals = seizureData.map(item => item.total || 0)
441
+
442
+            const option = {
443
+                tooltip: {
444
+                    trigger: 'axis',
445
+                    formatter: function (params) {
446
+                        const data = params[0]
447
+                        return `日期: ${data.name}<br/>查获数量: ${data.value}`
448
+                    }
449
+                },
450
+                xAxis: {
451
+                    type: 'category',
452
+                    data: dates,
453
+                    axisLine: {
454
+                        lineStyle: {
455
+                            color: '#999'
456
+                        }
457
+                    },
458
+                    axisLabel: {
459
+                        color: '#666',
460
+                        rotate: 45, // 日期标签旋转45度,避免重叠
461
+                        fontSize: 10,
462
+
463
+                    }
464
+                },
465
+                yAxis: {
466
+                    type: 'value',
467
+                    name: '查获数量',
468
+                    min: 0,
469
+                    max: function () {
470
+                        // 计算最大值并向上取整到最近的整数,确保五等分
471
+                        const maxTotal = Math.max(...totals, 5);
472
+                        return Math.ceil(maxTotal / 5) * 5;
473
+                    }(),
474
+                    interval: function () {
475
+                        // 固定为五等分
476
+                        const maxTotal = Math.max(...totals, 5);
477
+                        return Math.ceil(maxTotal / 5);
478
+                    }(),
479
+                    axisLine: {
480
+                        lineStyle: {
481
+                            color: '#999'
482
+                        }
483
+                    },
484
+                    axisLabel: {
485
+                        color: '#666'
486
+                    },
487
+                    splitLine: {
488
+                        lineStyle: {
489
+                            color: '#f0f0f0'
490
+                        }
491
+                    }
492
+                },
493
+                grid: {
494
+                    left: '3%',
495
+                    right: '4%',
496
+                    bottom: '15%', // 为缩放控件留出空间
497
+                    containLabel: true
498
+                },
499
+                dataZoom: [
500
+                    {
501
+                        type: 'inside', // 内置型数据区域缩放
502
+                        start: 0, // 初始缩放范围
503
+                        end: 100
504
+                    },
505
+                    {
506
+                        type: 'slider', // 滑动条型数据区域缩放
507
+                        show: true,
508
+                        start: 0,
509
+                        end: 100,
510
+                        bottom: '5%',
511
+                        height: 20
512
+                    }
513
+                ],
514
+                series: [{
515
+                    name: '查获数量',
516
+                    type: 'line',
517
+                    smooth: true,
518
+                    data: totals,
519
+                    lineStyle: {
520
+                        width: 3,
521
+                        color: '#FF6B6B'
522
+                    },
523
+                    itemStyle: {
524
+                        color: '#FF6B6B'
525
+                    },
526
+                    areaStyle: {
527
+                        color: {
528
+                            type: 'linear',
529
+                            x: 0,
530
+                            y: 0,
531
+                            x2: 0,
532
+                            y2: 1,
533
+                            colorStops: [{
534
+                                offset: 0,
535
+                                color: 'rgba(255, 107, 107, 0.3)'
536
+                            }, {
537
+                                offset: 1,
538
+                                color: 'rgba(255, 107, 107, 0.1)'
539
+                            }]
540
+                        }
541
+                    }
542
+                }]
543
+            }
544
+
545
+            chart.setOption(option)
546
+        }
547
+    }
548
+}
549
+</script>
550
+
551
+<style lang="scss" scoped>
552
+.detail-card {
553
+    background: #fff;
554
+    border-radius: 16rpx;
555
+    padding: 10rpx 30rpx;
556
+    // margin-bottom: 32rpx;
557
+    box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.1);
558
+
559
+    .detail-title {
560
+        font-size: 32rpx;
561
+        font-weight: bold;
562
+        color: #333;
563
+        margin-bottom: 24rpx;
564
+    }
565
+
566
+    .chart-section {
567
+        .chart-title {
568
+            font-size: 28rpx;
569
+            font-weight: bold;
570
+            color: #333;
571
+            margin: 24rpx 0;
572
+        }
573
+
574
+        .chart-container {
575
+            display: flex;
576
+            align-items: center;
577
+
578
+            .chart {
579
+                flex: 1;
580
+                height: 400rpx;
581
+            }
582
+        }
583
+
584
+        .chart {
585
+            height: 550rpx;
586
+            min-height: 550rpx;
587
+        }
588
+
589
+        /* 查获统计样式 */
590
+        .seizure-stats {
591
+            display: flex;
592
+            justify-content: space-between;
593
+            margin-bottom: 32rpx;
594
+            background: #f8f9fa;
595
+            border-radius: 12rpx;
596
+            padding: 24rpx;
597
+
598
+            .stat-item {
599
+                flex: 1;
600
+                text-align: center;
601
+                padding: 0 16rpx;
602
+
603
+                &:not(:last-child) {
604
+                    border-right: 1rpx solid #e0e0e0;
605
+                }
606
+
607
+                .stat-label {
608
+                    font-size: 24rpx;
609
+                    color: #666;
610
+                    margin-bottom: 8rpx;
611
+                }
612
+
613
+                .stat-value {
614
+                    font-size: 36rpx;
615
+                    font-weight: bold;
616
+                    color: #4873E3;
617
+                }
618
+            }
619
+        }
620
+    }
621
+}
622
+</style>

+ 498 - 0
src/pages/workProfile/index.vue

@@ -0,0 +1,498 @@
1
+<template>
2
+    <home-container :customStyle="{ background: 'none', backgroundColor: 'white' }">
3
+
4
+        <view class="workProfileContainer">
5
+            <!-- 下拉选择 -->
6
+            <div class="filter-section">
7
+                <uni-data-select :class="{ 'filter-select': true, 'kezhang-style': isKeZhang }" v-model="selectedLevel"
8
+                    :localdata="levelOptions" placeholder="请选择级别" :clear="false" @change="handleLevelChange"
9
+                    :disabled="isKeZhang" />
10
+            </div>
11
+
12
+            <!-- 总体概览 -->
13
+            <div class="overview-section">
14
+                <h2 class="section-title">总体概览</h2>
15
+                <div class="overview-cards">
16
+                    <!-- 组织支撑 -->
17
+                    <div class="overview-card" style="border-top-color: #007AFF;">
18
+                        <div class="card-title" style="--card-color: #007AFF;">组织支撑</div>
19
+                        <div class="stat-value">
20
+                            {{ overviewData.avgWorkingHours || '-' }}H
21
+                        </div>
22
+                        <div class="stat-item">
23
+                            人均在岗时长
24
+                        </div>
25
+                    </div>
26
+
27
+                    <!-- 工作产出 -->
28
+                    <div class="overview-card" style="border-top-color: #FF9500;">
29
+                        <div class="card-title" style="--card-color: #FF9500;">工作产出</div>
30
+                        <div class="stat-value">
31
+                            {{ overviewData.workOutput || '-' }}
32
+                        </div>
33
+                        <div class="stat-item">
34
+                            巡检总问题数
35
+                        </div>
36
+                    </div>
37
+
38
+                    <!-- 管理推动 -->
39
+                    <div class="overview-card" style="border-top-color: #00C853;">
40
+                        <div class="card-title" style="--card-color: #00C853;">管理推动</div>
41
+                        <div class="stat-value">
42
+                            {{ overviewData.managementPromotion || '-' }}
43
+                        </div>
44
+                        <div class="stat-item">
45
+                            超期整改数
46
+                        </div>
47
+                    </div>
48
+                </div>
49
+            </div>
50
+
51
+            <!-- 详细内容 -->
52
+            <div class="detail-section">
53
+                <h2 class="section-title">详细内容</h2>
54
+                <!-- <uni-collapse> -->
55
+                <!-- 组织支撑 -->
56
+                <organization-support />
57
+
58
+                <!-- 工作产出 -->
59
+                <work-output />
60
+
61
+                <!-- 管理推动 -->
62
+                <management-push />
63
+                <!-- </uni-collapse> -->
64
+            </div>
65
+        </view>
66
+    </home-container>
67
+</template>
68
+
69
+<script>
70
+import HomeContainer from '@/components/HomeContainer.vue'
71
+import OrganizationSupport from './components/organization-support.vue'
72
+import WorkOutput from './components/work-output.vue'
73
+import ManagementPush from './components/management-push.vue'
74
+import { getDeptList } from '@/api/system/dept/dept'
75
+import { getPortrait, getModuleMetrics } from '@/api/eikonStatistics/eikonStatistics'
76
+import {
77
+    getStationLevelRate,
78
+    getDepartmentLevelRate,
79
+    getWorkingPortrait,
80
+    getTrainingTestScoreTrend,
81
+    getManagementRecord,
82
+    getManagementRectify,
83
+    getInspectionProblemChart,
84
+    getChannelOpenTrendChart,
85
+    getDurationChart,
86
+    getSeizureTrendChart
87
+} from '@/api/workProfile/workProfile'
88
+import { mapState } from 'vuex'
89
+export default {
90
+    name: 'WorkProfile',
91
+    components: {
92
+        HomeContainer,
93
+        OrganizationSupport,
94
+        WorkOutput,
95
+        ManagementPush
96
+    },
97
+    data() {
98
+        return {
99
+            selectedLevel: '',
100
+            selectedLevelId: '',
101
+            selectedLevelType: '',
102
+            portraitData: {},
103
+            moduleMetricsData: {},
104
+            levelOptions: [],
105
+            testScoreTrendData: [],
106
+            overviewData: {
107
+                organization: "",
108
+                work: "",
109
+                management: ""
110
+            },
111
+
112
+            levelRateData: [],
113
+            managementPushData: {},
114
+            managementRectifyData: {},
115
+            inspectionProblemData: {},
116
+            channelOpenTrendData: {},
117
+            durationData: [],
118
+            seizureTrendData: []
119
+        }
120
+    },
121
+    async mounted() {
122
+        await this.getDept()
123
+        this.loadData()
124
+
125
+    },
126
+    provide() {
127
+        return {
128
+            getPortraitData: () => this.portraitData,
129
+            overviewData: () => this.overviewData,
130
+            moduleMetricsData: () => this.moduleMetricsData,
131
+            getLevelRateData: () => this.levelRateData,
132
+            getTrainingTestScoreTrendData: () => this.testScoreTrendData,
133
+            getManagementPushData: () => this.managementPushData,
134
+            getManagementRectifyData: () => this.managementRectifyData,
135
+            getInspectionProblemChartData: () => this.inspectionProblemData,
136
+            getChannelOpenTrendChartData: () => this.channelOpenTrendData,
137
+            getDurationChartData: () => this.durationData,
138
+            getSeizureTrendChartData: () => this.seizureTrendData,
139
+
140
+        }
141
+    },
142
+    computed: {
143
+        ...mapState({
144
+            userInfo: state => state.user.userInfo,
145
+            userRoles: state => state.user.roles || [],
146
+        }),
147
+        isSpecialUser() {
148
+            return this.userRoles.includes('test') || this.userRoles.includes('zhijianke')
149
+        },
150
+        isKeZhang() {
151
+            return this.userRoles.includes('kezhang')
152
+        }
153
+    },
154
+
155
+    methods: {
156
+        handleLevelChange(value) {
157
+            let targetObj = this.levelOptions.find(item => item.value == value)
158
+            this.selectedLevelId = targetObj.value
159
+            this.selectedLevelType = targetObj.deptType == 'STATION' ? 'station' : 'department'
160
+            this.loadData()
161
+        },
162
+        loadData() {
163
+            this.getOverviewData()
164
+
165
+            this.getLevelRateData()
166
+            this.getTrainingTestScoreTrendData()
167
+            //获取组织支撑的数据
168
+            this.getOrganizationSupport()
169
+            //获取管理推动的数据
170
+            this.getManagementPush()
171
+            //获取工作产出
172
+            this.getWorkOutputData()
173
+        },
174
+        //获取培训测试平均分趋势图
175
+        getTrainingTestScoreTrendData() {
176
+            let params = {
177
+                deptId: this.selectedLevelId || ''
178
+            }
179
+            getTrainingTestScoreTrend(params).then(res => {
180
+                this.testScoreTrendData = res.data || {};
181
+            })
182
+        },
183
+        //获取抽问抽答完成率
184
+        getLevelRateData() {
185
+            let params = {}
186
+            let api = this.selectedLevelType == 'station' ? getStationLevelRate : getDepartmentLevelRate
187
+            if (this.selectedLevelType == 'station') {
188
+                params.siteId = this.selectedLevelId
189
+            } else if (this.selectedLevelType == 'department') {
190
+                params.deptId = this.selectedLevelId
191
+            }
192
+            api(params).then(res => {
193
+                this.levelRateData = res.data || {};
194
+            })
195
+        },
196
+        getDept() {
197
+            return getDeptList().then(res => {
198
+                const { data } = res
199
+                let targetObj = data.find(item => item.id == '100' || item.label == '安检站');
200
+                let arr = [
201
+                    targetObj,
202
+                    ...targetObj.children
203
+                ]
204
+                this.levelOptions = arr.map(item => ({
205
+                    ...item,
206
+                    value: item.id,
207
+                    text: item.label
208
+                }))
209
+                //如果是站长质检科的就赋值安检站
210
+                if (this.isSpecialUser) {
211
+                    this.selectedLevel = arr[0].id
212
+                    this.handleLevelChange(arr[0].id)
213
+                }
214
+                if (this.isKeZhang) {
215
+
216
+                    this.selectedLevel = this.userInfo.departmentId
217
+                    this.handleLevelChange(this.userInfo.departmentId)
218
+                }
219
+
220
+            })
221
+        },
222
+        //获取总览
223
+        getOverviewData() {
224
+            let params = { deptId: this.selectedLevelId || '' }
225
+            getWorkingPortrait(params).then(res => {
226
+                this.overviewData = res.data || {};
227
+            })
228
+        },
229
+        //获取工作产出
230
+        getWorkOutputData() {
231
+            let specialParams = {}
232
+            let params = {
233
+                deptId: this.selectedLevelId || ''
234
+            }
235
+            if (this.selectedLevelType == 'department') {
236
+                specialParams.deptId = this.selectedLevelId || ''
237
+            }
238
+            getInspectionProblemChart(params).then(res => {
239
+                this.inspectionProblemData = res.data || {};
240
+            })
241
+            getChannelOpenTrendChart(specialParams).then(res => {
242
+                this.channelOpenTrendData = res || [];
243
+            })
244
+            getSeizureTrendChart(this.selectedLevelType == 'department'?params:{}).then(res => {
245
+                this.seizureTrendData = res.data || [];
246
+            })
247
+        },
248
+        //获取管理推动
249
+        getManagementPush() {
250
+            let params = {};
251
+            let otherparams = {
252
+                deptId: this.selectedLevelId || ''
253
+            }
254
+            if (this.selectedLevelType == 'station') {
255
+                params.checkedSiteId = this.selectedLevelId
256
+            } else if (this.selectedLevelType == 'department') {
257
+                params.checkedDepartmentId = this.selectedLevelId
258
+            }
259
+            getPortrait(params).then(res => {
260
+                this.portraitData = res.data || {};
261
+            })
262
+            getManagementRecord(otherparams).then(res => {
263
+                this.managementPushData = res.data || {};
264
+            })
265
+            getManagementRectify(otherparams).then(res => {
266
+                this.managementRectifyData = res.data || {};
267
+            })
268
+            getDurationChart(otherparams).then(res => {
269
+                this.durationData = res.data || [];
270
+            })
271
+        },
272
+        getOrganizationSupport() {
273
+            let params = {
274
+                deptId: this.selectedLevelId || '',
275
+                userTypeStr: this.selectedLevelType || '',
276
+
277
+            }
278
+            getModuleMetrics(params).then(res => {
279
+                this.moduleMetricsData = res.data && res.data.moduleResults || {};
280
+            })
281
+        }
282
+
283
+    }
284
+}
285
+</script>
286
+
287
+<style lang="scss" scoped>
288
+.workProfileContainer{
289
+    padding: 20rpx;
290
+}
291
+.page-header {
292
+    margin-bottom: 32rpx;
293
+
294
+    .page-title {
295
+        font-size: 48rpx;
296
+        font-weight: bold;
297
+        color: #333;
298
+        margin: 0;
299
+    }
300
+}
301
+
302
+.filter-section {
303
+    background-color: #fff;
304
+    position: fixed;
305
+    top: 78rpx;
306
+    left: 0;
307
+    right: 0;
308
+    z-index: 999;
309
+    width: 95%;
310
+    padding: 20rpx 20rpx 20rpx 30rpx;
311
+    
312
+
313
+
314
+
315
+    .filter-select {
316
+        width: 205rpx !important;
317
+
318
+        ::v-deep .uni-stat-box {
319
+            background: transparent !important;
320
+            color: black !important;
321
+
322
+            .border-default {
323
+                border: none !important;
324
+            }
325
+
326
+            .uni-select__input-text {
327
+                font-weight: bold;
328
+                font-size:38rpx;
329
+            }
330
+
331
+            .uni-select--disabled {
332
+                background-color: #fff !important;
333
+            }
334
+
335
+        }
336
+    }
337
+
338
+    .kezhang-style {
339
+        ::v-deep .uni-stat-box {
340
+            .uniui-bottom {
341
+                display: none !important;
342
+            }
343
+        }
344
+    }
345
+
346
+
347
+
348
+}
349
+
350
+.overview-section {
351
+    margin-top: 80rpx;
352
+    margin-bottom: 20rpx;
353
+
354
+
355
+
356
+    .overview-cards {
357
+        display: flex;
358
+        gap: 32rpx;
359
+        flex-direction: row;
360
+
361
+        .overview-card {
362
+            flex: 1;
363
+            background: #fff;
364
+            padding: 28rpx;
365
+            border-top: 6rpx solid;
366
+            box-shadow: 0px 4px 10px 0px rgba(0, 0, 0, 0.08);
367
+            border-radius: 32rpx;
368
+
369
+            overflow: hidden;
370
+
371
+            .card-title {
372
+                position: relative;
373
+                font-size: 28rpx;
374
+                font-weight: bold;
375
+                color: #333;
376
+                margin-bottom: 24rpx;
377
+                padding-left: 20rpx;
378
+
379
+                &::before {
380
+                    content: '';
381
+                    position: absolute;
382
+                    left: 0;
383
+                    top: 50%;
384
+                    transform: translateY(-50%);
385
+                    width: 8rpx;
386
+                    height: 28rpx;
387
+                    background-color: var(--card-color);
388
+                    border-radius: 4rpx;
389
+                }
390
+            }
391
+
392
+            .stat-item {
393
+                margin-bottom: 16rpx;
394
+                font-size: 24rpx;
395
+                color: #666;
396
+
397
+                &:first-child {
398
+                    font-size: 32rpx;
399
+                    font-weight: bold;
400
+                    color: #4873E3;
401
+                }
402
+            }
403
+
404
+            .stat-value {
405
+                font-size: 28rpx;
406
+                font-weight: bold;
407
+                color: #333;
408
+                margin-bottom: 16rpx;
409
+            }
410
+        }
411
+    }
412
+}
413
+
414
+.section-title {
415
+    font-size: 36rpx;
416
+    font-weight: bold;
417
+    color: #333;
418
+    padding-left: 10rpx;
419
+    margin-bottom: 20rpx;
420
+}
421
+
422
+.detail-section {
423
+
424
+
425
+    .detail-card {
426
+        background: #fff;
427
+        border-radius: 16rpx;
428
+        padding: 32rpx;
429
+        margin-bottom: 32rpx;
430
+        box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.1);
431
+
432
+        .detail-title {
433
+            font-size: 32rpx;
434
+            font-weight: bold;
435
+            color: #333;
436
+            margin-bottom: 24rpx;
437
+        }
438
+
439
+        .text-layout {
440
+
441
+
442
+            .text-item {
443
+                display: flex;
444
+                justify-content: space-between;
445
+
446
+                .text-label {
447
+                    font-size: 28rpx;
448
+                    color: #333;
449
+
450
+                    margin-bottom: 8rpx;
451
+                }
452
+
453
+                .text-value {
454
+                    font-size: 28rpx;
455
+
456
+                    color: #666;
457
+                }
458
+            }
459
+        }
460
+
461
+        .chart-section {
462
+
463
+
464
+            .chart-title {
465
+                font-size: 28rpx;
466
+                font-weight: bold;
467
+                color: #333;
468
+                margin-bottom: 24rpx;
469
+            }
470
+
471
+            .chart-container {
472
+                display: flex;
473
+                align-items: center;
474
+
475
+                .chart {
476
+                    flex: 1;
477
+                    height: 400rpx;
478
+                }
479
+
480
+                .legend-container {
481
+                    width: 200rpx;
482
+                    margin-left: 32rpx;
483
+                }
484
+            }
485
+
486
+            .chart {
487
+                height: 400rpx;
488
+            }
489
+
490
+            .rectification-stats {
491
+                .h-line {
492
+                    margin-top: 24rpx;
493
+                }
494
+            }
495
+        }
496
+    }
497
+}
498
+</style>