Parcourir la source

feat(warning): 新增综合预警工作台相关页面和接口

- 新增综合预警工作台汇总数据接口函数
- 新增员工综合预警列表接口函数实现
- 新增红线指标预警接口函数实现
- 新增员工维度评分明细接口函数实现
- 实现员工维度评分明细页面,包含时间筛选、组织架构选择和维度选择
- 员工维度评分明细页面支持分页、搜索和重置功能
- 实现红线指标预警页面,含时间筛选、组织架构和预警等级选择功能
- 红线指标预警页面展示预警明细列表和统计汇总信息
- 两个页面均实现组织架构树状选择及搜索功能
- 增加对应样式,提升界面一致性和用户体验
huoyi il y a 8 heures
Parent
commit
3149440ec3

+ 21 - 0
src/api/warningManage/index.js

@@ -0,0 +1,21 @@
1
+import request from '@/utils/request'
2
+
3
+// 综合预警工作台 - 汇总数据
4
+export function getWarningPageData(data) {
5
+    return request({ url: '/ledger/warning/ledger', method: 'post', data })
6
+}
7
+
8
+// 综合预警工作台 - 员工综合预警列表
9
+export function getEmployeeWarningPageData(data) {
10
+    return request({ url: '/ledger/warning/ledgerDetail', method: 'post', data })
11
+}
12
+
13
+// 红线指标预警
14
+export function getRedLineWarningPageData(data) {
15
+    return request({ url: '/ledger/warning/ledgerReduceDetail', method: 'post', data })
16
+}
17
+
18
+// 员工维度评分明细
19
+export function getEmployeeDimensionDetails(data) {
20
+    return request({ url: '/ledger/warning/employeeDimensionDetail', method: 'post', data })
21
+}

+ 18 - 0
src/pages.json

@@ -380,6 +380,24 @@
380 380
       "style": {
381 381
         "navigationBarTitleText": "查获统计"
382 382
       }
383
+    },
384
+    {
385
+      "path": "pages/warningPage/index",
386
+      "style": {
387
+        "navigationBarTitleText": "综合预警工作台"
388
+      }
389
+    },
390
+    {
391
+      "path": "pages/employeeDimensionDetails/index",
392
+      "style": {
393
+        "navigationBarTitleText": "员工维度评分明细"
394
+      }
395
+    },
396
+    {
397
+      "path": "pages/redLineWarning/index",
398
+      "style": {
399
+        "navigationBarTitleText": "红线指标预警"
400
+      }
383 401
     }
384 402
   ],
385 403
   "tabBar": {

+ 569 - 0
src/pages/employeeDimensionDetails/index.vue

@@ -0,0 +1,569 @@
1
+<template>
2
+    <view class="dimension-page">
3
+        <!-- 页面标题 -->
4
+        <view class="page-header">
5
+            <view class="header-title">员工维度评分明细</view>
6
+        </view>
7
+
8
+        <!-- 筛选栏 -->
9
+        <view class="filter-bar">
10
+            <scroll-view scroll-x class="time-scroll">
11
+                <view class="time-tags">
12
+                    <view v-for="(tag, index) in timeTags" :key="index"
13
+                        :class="['time-tag', { active: selectedTimeTag === index }]" @click="onTimeTagClick(index)">
14
+                        {{ tag }}
15
+                    </view>
16
+                </view>
17
+            </scroll-view>
18
+            <view class="date-range-picker">
19
+                <picker mode="date" :value="beginTime" @change="onBeginTimeChange" class="date-picker-half">
20
+                    <view class="date-input" :class="{ filled: beginTime }">
21
+                        {{ beginTime || '开始日期' }}
22
+                    </view>
23
+                </picker>
24
+                <text class="date-separator">至</text>
25
+                <picker mode="date" :value="endTime" @change="onEndTimeChange" class="date-picker-half">
26
+                    <view class="date-input" :class="{ filled: endTime }">
27
+                        {{ endTime || '结束日期' }}
28
+                    </view>
29
+                </picker>
30
+            </view>
31
+
32
+            <view class="filter-row">
33
+                <view class="filter-select" @click="showOrgPicker = true">
34
+                    <text class="filter-select-text">{{ selectedOrgName || '组织架构/员工' }}</text>
35
+                    <u-icon name="arrow-down" size="14" color="#999"></u-icon>
36
+                </view>
37
+                <view class="filter-select" @click="showDimPicker = true">
38
+                    <text class="filter-select-text">{{ selectedDimName || '维度' }}</text>
39
+                    <u-icon name="arrow-down" size="14" color="#999"></u-icon>
40
+                </view>
41
+            </view>
42
+
43
+            <view class="filter-actions">
44
+                <view class="btn-search" @click="handleSearch">
45
+                    <u-icon name="search" size="16" color="#fff"></u-icon>
46
+                    <text>搜索</text>
47
+                </view>
48
+                <view class="btn-reset" @click="handleReset">重置</view>
49
+            </view>
50
+        </view>
51
+
52
+        <!-- 数据表格 -->
53
+        <view class="section-area">
54
+            <view class="section-header">
55
+                <text class="section-title">员工维度评分明细</text>
56
+                <text class="section-badge">评分依据:员工配分表</text>
57
+            </view>
58
+
59
+            <view class="table-wrapper">
60
+                <statistic-table :columns="tableColumns" :data="tableData" />
61
+            </view>
62
+
63
+            <view class="pagination-wrapper" v-if="total > 0">
64
+                <uni-pagination :current="pageNum" :total="total" :page-size="pageSize" :show-page-size="true"
65
+                    :page-size-range="[10, 20, 50]" @change="onPageChange" @pageSizeChange="onPageSizeChange" />
66
+            </view>
67
+        </view>
68
+
69
+        <!-- 组织架构选择弹窗 -->
70
+        <u-popup :show="showOrgPicker" mode="bottom" :round="16" :mask-close-able="true"
71
+            @close="showOrgPicker = false">
72
+            <view class="picker-popup">
73
+                <view class="picker-header">
74
+                    <text class="picker-title">选择组织架构/员工</text>
75
+                    <u-icon name="close" size="20" @click="showOrgPicker = false"></u-icon>
76
+                </view>
77
+                <view class="search-box">
78
+                    <u-input v-model="orgSearchKeyword" placeholder="搜索" @input="onOrgSearch"></u-input>
79
+                </view>
80
+                <scroll-view v-if="!orgSearchKeyword.trim()" scroll-y class="tree-list">
81
+                    <employee-tree-node v-for="(node, index) in deptTreeData" :key="node.id" :node="node" :expanded-ids="expandedDeptIds"
82
+                        :selected-id="selectedOrgId" @toggle="toggleDeptExpand" @select="onOrgSelect" />
83
+                </scroll-view>
84
+                <scroll-view v-else scroll-y class="org-list">
85
+                    <view class="org-item" v-for="item in filteredOrgList" :key="item.userId"
86
+                        @click="onOrgSelect(item)">
87
+                        <text class="org-item-name">{{ item.nickName }}</text>
88
+                        <u-icon v-if="item.userId === selectedOrgId" name="checkmark" color="#34D399"
89
+                            size="18"></u-icon>
90
+                    </view>
91
+                </scroll-view>
92
+            </view>
93
+        </u-popup>
94
+
95
+        <!-- 维度选择弹窗 -->
96
+        <u-popup :show="showDimPicker" mode="bottom" :round="16" :mask-close-able="true"
97
+            @close="showDimPicker = false">
98
+            <view class="dim-popup">
99
+                <view class="picker-header">
100
+                    <text class="picker-title">选择维度</text>
101
+                    <u-icon name="close" size="20" @click="showDimPicker = false"></u-icon>
102
+                </view>
103
+                <view class="dim-list">
104
+                    <view class="dim-item" :class="{ active: selectedDimension === '' }" @click="selectDimension('')">
105
+                        <text>全部</text>
106
+                    </view>
107
+                    <view class="dim-item" v-for="item in dimensionOptions" :key="item.value"
108
+                        :class="{ active: selectedDimension === item.value }" @click="selectDimension(item.value)">
109
+                        <text>{{ item.label }}</text>
110
+                    </view>
111
+                </view>
112
+            </view>
113
+        </u-popup>
114
+    </view>
115
+</template>
116
+
117
+<script>
118
+import StatisticTable from '@/components/statistic-table/statistic-table.vue'
119
+import EmployeeTreeNode from '@/pages/components/EmployeeTreeNode.vue'
120
+import { getEmployeeDimensionDetails } from '@/api/warningManage/index'
121
+import { getDeptUserTree } from '@/api/system/user'
122
+
123
+export default {
124
+    name: 'EmployeeDimensionDetails',
125
+    components: {
126
+        StatisticTable,
127
+        EmployeeTreeNode
128
+    },
129
+    data() {
130
+        return {
131
+            selectedTimeTag: 1,
132
+            timeTags: ['近一周', '近一月', '近三月', '近一年'],
133
+            beginTime: '',
134
+            endTime: '',
135
+            selectedOrgId: null,
136
+            selectedOrgName: '',
137
+            selectedDimension: '',
138
+            selectedDimName: '全部',
139
+            showOrgPicker: false,
140
+            showDimPicker: false,
141
+            orgSearchKeyword: '',
142
+            deptTreeData: [],
143
+            expandedDeptIds: [],
144
+            orgList: [],
145
+            dimensionOptions: [
146
+                { label: '安全响应能力', value: '安全响应能力' },
147
+                { label: '服务响应能力', value: '服务响应能力' }
148
+            ],
149
+            tableColumns: [
150
+                { props: 'userId', title: '员工ID' },
151
+                { props: 'nickName', title: '姓名' },
152
+                { props: 'deptName', title: '部门' },
153
+                { props: 'teamName', title: '班组' },
154
+                { props: 'groupName', title: '小组' },
155
+                { props: 'dimName', title: '维度名称' },
156
+                { props: 'dimScore', title: '维度分值' }
157
+            ],
158
+            tableData: [],
159
+            allTableData: [],
160
+            pageNum: 1,
161
+            pageSize: 10,
162
+            total: 0
163
+        }
164
+    },
165
+    computed: {
166
+        filteredOrgList() {
167
+            const keyword = this.orgSearchKeyword.trim().toLowerCase()
168
+            if (!keyword) return this.orgList
169
+            return this.orgList.filter(item =>
170
+                (item.nickName || '').toLowerCase().includes(keyword)
171
+            )
172
+        }
173
+    },
174
+    mounted() {
175
+        this.loadDeptTree()
176
+        this.fetchData()
177
+    },
178
+    methods: {
179
+        formatDate(date) {
180
+            const y = date.getFullYear()
181
+            const m = String(date.getMonth() + 1).padStart(2, '0')
182
+            const d = String(date.getDate()).padStart(2, '0')
183
+            return `${y}-${m}-${d}`
184
+        },
185
+        getDateRange() {
186
+            const now = new Date()
187
+            let start = new Date(now)
188
+            switch (this.selectedTimeTag) {
189
+                case 0: start.setDate(now.getDate() - 7); break
190
+                case 1: start.setMonth(now.getMonth() - 1); break
191
+                case 2: start.setMonth(now.getMonth() - 3); break
192
+                case 3: start.setFullYear(now.getFullYear() - 1); break
193
+                default: break
194
+            }
195
+            return { startDate: this.formatDate(start), endDate: this.formatDate(now) }
196
+        },
197
+        onTimeTagClick(index) {
198
+            this.selectedTimeTag = index
199
+        },
200
+        onBeginTimeChange(e) {
201
+            this.beginTime = e.detail.value
202
+        },
203
+        onEndTimeChange(e) {
204
+            this.endTime = e.detail.value
205
+        },
206
+        flattenDeptTree(tree) {
207
+            const result = []
208
+            const traverse = (nodes) => {
209
+                nodes.forEach(node => {
210
+                    const hasChildren = node.children && node.children.length > 0
211
+                    if (!hasChildren && node.nodeType !== 'dept') {
212
+                        result.push({
213
+                            nodeType: node.nodeType || '',
214
+                            userId: node.userId || node.id,
215
+                            nickName: node.nickName || node.label || node.userName || ''
216
+                        })
217
+                    }
218
+                    if (hasChildren) {
219
+                        traverse(node.children)
220
+                    }
221
+                })
222
+            }
223
+            traverse(tree)
224
+            return result
225
+        },
226
+        expandAllDepts(nodes) {
227
+            this.expandedDeptIds = []
228
+            const traverse = (list) => {
229
+                list.forEach(node => {
230
+                    const hasChildren = node.children && node.children.length > 0
231
+                    if (hasChildren || node.nodeType === 'dept') {
232
+                        this.expandedDeptIds.push(node.id)
233
+                        if (hasChildren) {
234
+                            traverse(node.children)
235
+                        }
236
+                    }
237
+                })
238
+            }
239
+            traverse(nodes)
240
+        },
241
+        loadDeptTree() {
242
+            getDeptUserTree({}).then(res => {
243
+                if (res.code === 200) {
244
+                    this.deptTreeData = res.data || []
245
+                    this.orgList = this.flattenDeptTree(this.deptTreeData).filter(u => u.nodeType === 'user')
246
+                    this.expandAllDepts(this.deptTreeData)
247
+                }
248
+            }).catch(() => { })
249
+        },
250
+        toggleDeptExpand(id) {
251
+            const index = this.expandedDeptIds.indexOf(id)
252
+            if (index > -1) {
253
+                this.expandedDeptIds.splice(index, 1)
254
+            } else {
255
+                this.expandedDeptIds.push(id)
256
+            }
257
+        },
258
+        onOrgSelect(item) {
259
+            this.selectedOrgId = item.userId
260
+            this.selectedOrgName = item.nickName
261
+            this.showOrgPicker = false
262
+        },
263
+        onOrgSearch() { },
264
+        selectDimension(value) {
265
+            this.selectedDimension = value
266
+            const item = this.dimensionOptions.find(d => d.value === value)
267
+            this.selectedDimName = value === '' ? '全部' : (item ? item.label : '')
268
+            this.showDimPicker = false
269
+        },
270
+        handleSearch() {
271
+            this.pageNum = 1
272
+            this.fetchData()
273
+        },
274
+        handleReset() {
275
+            this.selectedTimeTag = 1
276
+            this.beginTime = ''
277
+            this.endTime = ''
278
+            this.selectedOrgId = null
279
+            this.selectedOrgName = ''
280
+            this.selectedDimension = ''
281
+            this.selectedDimName = '全部'
282
+            this.pageNum = 1
283
+            this.fetchData()
284
+        },
285
+        onPageChange(e) {
286
+            this.pageNum = e.current
287
+            this.fetchData()
288
+        },
289
+        onPageSizeChange(e) {
290
+            this.pageSize = e.pageSize
291
+            this.pageNum = 1
292
+            this.fetchData()
293
+        },
294
+        async fetchData() {
295
+            let params = this.getQueryParams()
296
+            params.pageNum = this.pageNum
297
+            params.pageSize = this.pageSize
298
+            try {
299
+                const res = await getEmployeeDimensionDetails(params)
300
+                if (res.code === 200 && res.data) {
301
+                    const data = res.data
302
+                    this.allTableData = data.list || data || []
303
+                    this.tableData = this.allTableData
304
+                    this.total = data.total || this.allTableData.length
305
+                }
306
+            } catch (e) { }
307
+        },
308
+        getQueryParams() {
309
+            let params = {}
310
+            if (this.beginTime && this.endTime) {
311
+                params.startDate = this.beginTime
312
+                params.endDate = this.endTime
313
+            } else {
314
+                const range = this.getDateRange()
315
+                params.startDate = range.startDate
316
+                params.endDate = range.endDate
317
+            }
318
+            if (this.selectedOrgId) {
319
+                params.userId = this.selectedOrgId
320
+            }
321
+            if (this.selectedDimension) {
322
+                params.dimension = this.selectedDimension
323
+            }
324
+            return params
325
+        }
326
+    }
327
+}
328
+</script>
329
+
330
+<style lang="scss" scoped>
331
+.dimension-page {
332
+    min-height: 100vh;
333
+    background: #f5f7fa;
334
+    padding-bottom: 40rpx;
335
+}
336
+
337
+.page-header {
338
+    background: #fff;
339
+    padding: 24rpx 32rpx;
340
+    border-bottom: 1rpx solid #eee;
341
+}
342
+
343
+.header-title {
344
+    font-size: 36rpx;
345
+    font-weight: bold;
346
+    color: #1e3c72;
347
+}
348
+
349
+.filter-bar {
350
+    background: #fff;
351
+    margin: 20rpx;
352
+    border-radius: 16rpx;
353
+    padding: 20rpx;
354
+    box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
355
+}
356
+
357
+.time-scroll {
358
+    width: 100%;
359
+    margin-bottom: 16rpx;
360
+}
361
+
362
+.time-tags {
363
+    display: flex;
364
+    gap: 0;
365
+    width: 100%;
366
+}
367
+
368
+.time-tag {
369
+    flex: 1;
370
+    text-align: center;
371
+    padding: 12rpx 0;
372
+    background: #f8fafc;
373
+    border: 1rpx solid #e2e8f0;
374
+    font-size: 24rpx;
375
+    color: #333;
376
+
377
+    &.active {
378
+        background: #2563eb;
379
+        border-color: #2563eb;
380
+        color: #fff;
381
+    }
382
+}
383
+
384
+.date-range-picker {
385
+    display: flex;
386
+    align-items: center;
387
+    width: 100%;
388
+    margin-top: 16rpx;
389
+    margin-bottom: 16rpx;
390
+}
391
+
392
+.date-picker-half {
393
+    flex: 1;
394
+}
395
+
396
+.date-input {
397
+    width: 100%;
398
+    padding: 12rpx 16rpx;
399
+    border-radius: 8rpx;
400
+    background: #f5f5f5;
401
+    font-size: 24rpx;
402
+    color: #999;
403
+    text-align: center;
404
+    box-sizing: border-box;
405
+
406
+    &.filled {
407
+        color: #333;
408
+    }
409
+}
410
+
411
+.date-separator {
412
+    font-size: 24rpx;
413
+    color: #999;
414
+    flex-shrink: 0;
415
+    padding: 0 12rpx;
416
+}
417
+
418
+.filter-row {
419
+    display: flex;
420
+    gap: 12rpx;
421
+    margin-bottom: 16rpx;
422
+}
423
+
424
+.filter-select {
425
+    flex: 1;
426
+    display: flex;
427
+    align-items: center;
428
+    justify-content: space-between;
429
+    padding: 16rpx 20rpx;
430
+    background: #f5f5f5;
431
+    border-radius: 8rpx;
432
+    border: 1rpx solid #e0e0e0;
433
+}
434
+
435
+.filter-select-text {
436
+    font-size: 26rpx;
437
+    color: #333;
438
+}
439
+
440
+.filter-actions {
441
+    display: flex;
442
+    gap: 16rpx;
443
+}
444
+
445
+.btn-search {
446
+    flex: 1;
447
+    display: flex;
448
+    align-items: center;
449
+    justify-content: center;
450
+    gap: 8rpx;
451
+    padding: 16rpx;
452
+    background: #2563eb;
453
+    border-radius: 8rpx;
454
+    color: #fff;
455
+    font-size: 28rpx;
456
+}
457
+
458
+.btn-reset {
459
+    padding: 16rpx 32rpx;
460
+    background: #f5f5f5;
461
+    border-radius: 8rpx;
462
+    color: #666;
463
+    font-size: 28rpx;
464
+    text-align: center;
465
+}
466
+
467
+.section-area {
468
+    margin: 0 20rpx;
469
+}
470
+
471
+.section-header {
472
+    display: flex;
473
+    align-items: center;
474
+    gap: 16rpx;
475
+    margin-bottom: 16rpx;
476
+}
477
+
478
+.section-title {
479
+    font-size: 32rpx;
480
+    font-weight: bold;
481
+    color: #dc2626;
482
+}
483
+
484
+.section-badge {
485
+    font-size: 22rpx;
486
+    background: #eef2ff;
487
+    padding: 6rpx 20rpx;
488
+    border-radius: 30rpx;
489
+    color: #666;
490
+}
491
+
492
+.table-wrapper {
493
+    background: #fff;
494
+    border-radius: 16rpx;
495
+    overflow-x: auto;
496
+    box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
497
+}
498
+
499
+.pagination-wrapper {
500
+    display: flex;
501
+    justify-content: center;
502
+    padding: 20rpx 0;
503
+}
504
+
505
+.picker-popup,
506
+.dim-popup {
507
+    background: #fff;
508
+    border-radius: 16rpx 16rpx 0 0;
509
+    max-height: 70vh;
510
+    display: flex;
511
+    flex-direction: column;
512
+}
513
+
514
+.picker-header {
515
+    display: flex;
516
+    justify-content: space-between;
517
+    align-items: center;
518
+    padding: 24rpx 32rpx;
519
+    border-bottom: 1rpx solid #eee;
520
+}
521
+
522
+.picker-title {
523
+    font-size: 32rpx;
524
+    font-weight: 600;
525
+    color: #333;
526
+}
527
+
528
+.search-box {
529
+    padding: 16rpx 32rpx;
530
+}
531
+
532
+.tree-list,
533
+.org-list {
534
+    flex: 1;
535
+    padding: 0 32rpx;
536
+    height: 0;
537
+    overflow: hidden;
538
+}
539
+
540
+.org-item {
541
+    display: flex;
542
+    align-items: center;
543
+    justify-content: space-between;
544
+    padding: 24rpx 0;
545
+    border-bottom: 1rpx solid #f0f0f0;
546
+}
547
+
548
+.org-item-name {
549
+    font-size: 28rpx;
550
+    color: #333;
551
+}
552
+
553
+.dim-list {
554
+    padding: 16rpx 32rpx;
555
+}
556
+
557
+.dim-item {
558
+    padding: 24rpx 0;
559
+    border-bottom: 1rpx solid #f0f0f0;
560
+    font-size: 28rpx;
561
+    color: #333;
562
+    text-align: center;
563
+
564
+    &.active {
565
+        color: #2563eb;
566
+        font-weight: 500;
567
+    }
568
+}
569
+</style>

+ 674 - 0
src/pages/redLineWarning/index.vue

@@ -0,0 +1,674 @@
1
+<template>
2
+    <view class="redline-page">
3
+        <view class="page-header">
4
+            <view class="header-title">红线指标预警</view>
5
+            <view class="header-subtitle">权重扣分 ≥ 3分自动进入红色预警区</view>
6
+            <view class="badge-group">
7
+                <view class="alert-badge">红色预警</view>
8
+                <view class="alert-badge orange">动态月度数据</view>
9
+            </view>
10
+        </view>
11
+
12
+        <view class="filter-bar">
13
+            <scroll-view scroll-x class="time-scroll">
14
+                <view class="time-tags">
15
+                    <view v-for="(tag, index) in timeTags" :key="index"
16
+                        :class="['time-tag', { active: selectedTimeTag === index }]" @click="onTimeTagClick(index)">
17
+                        {{ tag }}
18
+                    </view>
19
+                </view>
20
+            </scroll-view>
21
+            <view class="date-range-picker">
22
+                <picker mode="date" :value="beginTime" @change="onBeginTimeChange" class="date-picker-half">
23
+                    <view class="date-input" :class="{ filled: beginTime }">
24
+                        {{ beginTime || '开始日期' }}
25
+                    </view>
26
+                </picker>
27
+                <text class="date-separator">至</text>
28
+                <picker mode="date" :value="endTime" @change="onEndTimeChange" class="date-picker-half">
29
+                    <view class="date-input" :class="{ filled: endTime }">
30
+                        {{ endTime || '结束日期' }}
31
+                    </view>
32
+                </picker>
33
+            </view>
34
+
35
+            <view class="filter-row">
36
+                <view class="filter-select" @click="showOrgPicker = true">
37
+                    <text class="filter-select-text">{{ selectedOrgName || '组织架构/员工' }}</text>
38
+                    <u-icon name="arrow-down" size="14" color="#999"></u-icon>
39
+                </view>
40
+                <view class="filter-select" @click="showLevelPicker = true">
41
+                    <text class="filter-select-text">{{ selectedLevelName || '预警等级' }}</text>
42
+                    <u-icon name="arrow-down" size="14" color="#999"></u-icon>
43
+                </view>
44
+            </view>
45
+
46
+            <view class="filter-actions">
47
+                <view class="btn-search" @click="handleSearch">
48
+                    <u-icon name="search" size="16" color="#fff"></u-icon>
49
+                    <text>搜索</text>
50
+                </view>
51
+                <view class="btn-reset" @click="handleReset">重置</view>
52
+            </view>
53
+        </view>
54
+
55
+        <!-- 员工列表 -->
56
+        <view class="section-area">
57
+            <SectionTitle title="红线指标预警明细">
58
+                <view class="section-badge">权重扣分 ≥ 3分</view>
59
+            </SectionTitle>
60
+
61
+            <view class="table-wrapper">
62
+                <statistic-table :columns="tableColumns" :data="tableData">
63
+                    <template #column-totalScore="{ row }">
64
+                        <text v-if="row.totalScore >= 3" class="score-danger">{{ row.totalScore }}</text>
65
+                        <text v-else>{{ row.totalScore }}</text>
66
+                    </template>
67
+                </statistic-table>
68
+            </view>
69
+
70
+            <view class="pagination-wrapper" v-if="total > 0">
71
+                <uni-pagination :current="pageNum" :total="total" :page-size="pageSize" :show-page-size="true"
72
+                    :page-size-range="[10, 20, 50]" @change="onPageChange" @pageSizeChange="onPageSizeChange" />
73
+            </view>
74
+
75
+            <view class="warning-summary">
76
+                <view class="summary-item">
77
+                    <view class="dot red"></view>
78
+                    <text><strong>红色预警</strong> 共计 {{ redAlertCount }} 人</text>
79
+                </view>
80
+                <view class="summary-item">
81
+                    <view class="dot green"></view>
82
+                    <text><strong>优秀标杆</strong> 共计 {{ excellentCount }} 人</text>
83
+                </view>
84
+                <view class="summary-item">
85
+                    <text>全员平均得分: <strong>{{ avgScore }}</strong> 分</text>
86
+                </view>
87
+            </view>
88
+        </view>
89
+
90
+        <!-- 组织架构选择弹窗 -->
91
+        <u-popup :show="showOrgPicker" mode="bottom" :round="16" :mask-close-able="true"
92
+            @close="showOrgPicker = false">
93
+            <view class="picker-popup">
94
+                <view class="picker-header">
95
+                    <text class="picker-title">选择组织架构/员工</text>
96
+                    <u-icon name="close" size="20" @click="showOrgPicker = false"></u-icon>
97
+                </view>
98
+                <view class="search-box">
99
+                    <u-input v-model="orgSearchKeyword" placeholder="搜索" @input="onOrgSearch"></u-input>
100
+                </view>
101
+                <scroll-view v-if="!orgSearchKeyword.trim()" scroll-y class="tree-list">
102
+                    <employee-tree-node v-for="node in deptTreeData" :key="node.id" :node="node"
103
+                        :expanded-ids="expandedDeptIds" :selected-id="selectedOrgId" @toggle="toggleDeptExpand"
104
+                        @select="onOrgSelect" />
105
+                </scroll-view>
106
+                <scroll-view v-else scroll-y class="org-list">
107
+                    <view class="org-item" v-for="item in filteredOrgList" :key="item.userId"
108
+                        @click="onOrgSelect(item)">
109
+                        <text class="org-item-name">{{ item.nickName }}</text>
110
+                        <u-icon v-if="item.userId === selectedOrgId" name="checkmark" color="#34D399"
111
+                            size="18"></u-icon>
112
+                    </view>
113
+                </scroll-view>
114
+            </view>
115
+        </u-popup>
116
+
117
+        <!-- 预警等级选择弹窗 -->
118
+        <u-popup :show="showLevelPicker" mode="bottom" :round="16" :mask-close-able="true"
119
+            @close="showLevelPicker = false">
120
+            <view class="level-popup">
121
+                <view class="picker-header">
122
+                    <text class="picker-title">选择预警等级</text>
123
+                    <u-icon name="close" size="20" @click="showLevelPicker = false"></u-icon>
124
+                </view>
125
+                <view class="level-list">
126
+                    <view class="level-item" :class="{ active: selectedAlertLevel === '' }" @click="selectLevel('')">
127
+                        <text>全部</text>
128
+                    </view>
129
+                    <view class="level-item" v-for="item in alertLevelList" :key="item.value"
130
+                        :class="{ active: selectedAlertLevel === item.value }" @click="selectLevel(item.value)">
131
+                        <text>{{ item.label }}</text>
132
+                    </view>
133
+                </view>
134
+            </view>
135
+        </u-popup>
136
+    </view>
137
+</template>
138
+
139
+<script>
140
+import SectionTitle from '@/components/SectionTitle.vue'
141
+import StatisticTable from '@/components/statistic-table/statistic-table.vue'
142
+import EmployeeTreeNode from '@/pages/components/EmployeeTreeNode.vue'
143
+import { getRedLineWarningPageData } from '@/api/warningManage/index'
144
+import { getDeptUserTree } from '@/api/system/user'
145
+
146
+export default {
147
+    name: 'RedLineWarning',
148
+    components: {
149
+        SectionTitle,
150
+        StatisticTable,
151
+        EmployeeTreeNode
152
+    },
153
+    data() {
154
+        return {
155
+            selectedTimeTag: 1,
156
+            timeTags: ['近一周', '近一月', '近三月', '近一年'],
157
+            beginTime: '',
158
+            endTime: '',
159
+            selectedOrgId: null,
160
+            selectedOrgName: '',
161
+            selectedAlertLevel: '',
162
+            selectedLevelName: '全部',
163
+            showOrgPicker: false,
164
+            showLevelPicker: false,
165
+            orgSearchKeyword: '',
166
+            deptTreeData: [],
167
+            expandedDeptIds: [],
168
+            orgList: [],
169
+            alertLevelList: [
170
+                { label: '红色预警', value: '1' },
171
+                { label: '正常范围', value: '3' }
172
+            ],
173
+            tableColumns: [
174
+                { props: 'nickName', title: '姓名' },
175
+                { props: 'deptName', title: '部门' },
176
+                { props: 'dimensionName', title: '维度' },
177
+                { props: 'level2Name', title: '二级指标' },
178
+                { props: 'totalScore', title: '扣分合计', slot: true },
179
+                { props: 'occurrenceCount', title: '发生次数' },
180
+                { props: 'warningLevel', title: '预警等级' },
181
+                { props: 'coreRisks', title: '核心风险' }
182
+            ],
183
+            tableData: [],
184
+            allTableData: [],
185
+            pageNum: 1,
186
+            pageSize: 10,
187
+            total: 0,
188
+            redAlertCount: 0,
189
+            excellentCount: 0,
190
+            avgScore: 0
191
+        }
192
+    },
193
+    computed: {
194
+        filteredOrgList() {
195
+            const keyword = this.orgSearchKeyword.trim().toLowerCase()
196
+            if (!keyword) return this.orgList
197
+            return this.orgList.filter(item =>
198
+                (item.nickName || '').toLowerCase().includes(keyword)
199
+            )
200
+        }
201
+    },
202
+    onLoad(options) {
203
+        if (options.id) {
204
+            this.selectedOrgId = Number(options.id)
205
+        }
206
+        if (options.startDate) {
207
+            this.beginTime = options.startDate
208
+            this.selectedTimeTag = 4
209
+        }
210
+        if (options.endDate) {
211
+            this.endTime = options.endDate
212
+        }
213
+        if (options.activeRange) {
214
+            this.selectedTimeTag = options.activeRange
215
+        }
216
+        if (options.alertLevel) {
217
+            this.selectedAlertLevel = options.alertLevel
218
+            const item = this.alertLevelList.find(l => l.value === options.alertLevel)
219
+            if (item) this.selectedLevelName = item.label
220
+        }
221
+    },
222
+    mounted() {
223
+        this.loadDeptTree()
224
+        this.fetchData()
225
+    },
226
+    methods: {
227
+        formatDate(date) {
228
+            const y = date.getFullYear()
229
+            const m = String(date.getMonth() + 1).padStart(2, '0')
230
+            const d = String(date.getDate()).padStart(2, '0')
231
+            return `${y}-${m}-${d}`
232
+        },
233
+        getDateRange() {
234
+            const now = new Date()
235
+            let start = new Date(now)
236
+            switch (this.selectedTimeTag) {
237
+                case 0: start.setDate(now.getDate() - 7); break
238
+                case 1: start.setMonth(now.getMonth() - 1); break
239
+                case 2: start.setMonth(now.getMonth() - 3); break
240
+                case 3: start.setFullYear(now.getFullYear() - 1); break
241
+                default: break
242
+            }
243
+            return { startDate: this.formatDate(start), endDate: this.formatDate(now) }
244
+        },
245
+        onTimeTagClick(index) {
246
+            this.selectedTimeTag = index
247
+        },
248
+        onBeginTimeChange(e) {
249
+            this.beginTime = e.detail.value
250
+        },
251
+        onEndTimeChange(e) {
252
+            this.endTime = e.detail.value
253
+        },
254
+        flattenDeptTree(tree) {
255
+            const result = []
256
+            const traverse = (nodes) => {
257
+                nodes.forEach(node => {
258
+                    const hasChildren = node.children && node.children.length > 0
259
+                    if (!hasChildren && node.nodeType !== 'dept') {
260
+                        result.push({
261
+                            nodeType: node.nodeType || '',
262
+                            userId: node.userId || node.id,
263
+                            nickName: node.nickName || node.label || node.userName || ''
264
+                        })
265
+                    }
266
+                    if (hasChildren) {
267
+                        traverse(node.children)
268
+                    }
269
+                })
270
+            }
271
+            traverse(tree)
272
+            return result
273
+        },
274
+        expandAllDepts(nodes) {
275
+            this.expandedDeptIds = []
276
+            const traverse = (list) => {
277
+                list.forEach(node => {
278
+                    const hasChildren = node.children && node.children.length > 0
279
+                    if (hasChildren || node.nodeType === 'dept') {
280
+                        this.expandedDeptIds.push(node.id)
281
+                        if (hasChildren) {
282
+                            traverse(node.children)
283
+                        }
284
+                    }
285
+                })
286
+            }
287
+            traverse(nodes)
288
+        },
289
+        loadDeptTree() {
290
+            getDeptUserTree({}).then(res => {
291
+                if (res.code === 200) {
292
+                    this.deptTreeData = res.data || []
293
+                    this.orgList = this.flattenDeptTree(this.deptTreeData).filter(u => u.nodeType === 'user')
294
+                    this.expandAllDepts(this.deptTreeData)
295
+                }
296
+            }).catch(() => { })
297
+        },
298
+        toggleDeptExpand(id) {
299
+            const index = this.expandedDeptIds.indexOf(id)
300
+            if (index > -1) {
301
+                this.expandedDeptIds.splice(index, 1)
302
+            } else {
303
+                this.expandedDeptIds.push(id)
304
+            }
305
+        },
306
+        onOrgSelect(item) {
307
+            this.selectedOrgId = item.userId
308
+            this.selectedOrgName = item.nickName
309
+            this.showOrgPicker = false
310
+        },
311
+        onOrgSearch() { },
312
+        selectLevel(value) {
313
+            this.selectedAlertLevel = value
314
+            const item = this.alertLevelList.find(l => l.value === value)
315
+            this.selectedLevelName = value === '' ? '全部' : (item ? item.label : '')
316
+            this.showLevelPicker = false
317
+        },
318
+        handleSearch() {
319
+            this.pageNum = 1
320
+            this.fetchData()
321
+        },
322
+        handleReset() {
323
+            this.selectedTimeTag = 1
324
+            this.beginTime = ''
325
+            this.endTime = ''
326
+            this.selectedOrgId = null
327
+            this.selectedOrgName = ''
328
+            this.selectedAlertLevel = ''
329
+            this.selectedLevelName = '全部'
330
+            this.pageNum = 1
331
+            this.fetchData()
332
+        },
333
+        onPageChange(e) {
334
+            this.pageNum = e.current
335
+            this.fetchData()
336
+        },
337
+        onPageSizeChange(e) {
338
+            this.pageSize = e.pageSize
339
+            this.pageNum = 1
340
+            this.fetchData()
341
+        },
342
+        async fetchData() {
343
+            let params = this.getQueryParams()
344
+            params.pageNum = this.pageNum
345
+            params.pageSize = this.pageSize
346
+            try {
347
+                const res = await getRedLineWarningPageData(params)
348
+                if (res.code === 200 && res.data) {
349
+                    const data = res.data
350
+                    this.allTableData = data || []
351
+                    this.tableData = this.allTableData
352
+                    this.total = data.total || this.allTableData.length
353
+                    this.redAlertCount = data.redAlertNum || 0
354
+                    this.excellentCount = data.excellentBenchmarkNum || 0
355
+                    this.avgScore = data.averageComprehensiveScore || 0
356
+                }
357
+            } catch (e) { }
358
+        },
359
+        getQueryParams() {
360
+            let params = {}
361
+            if (this.beginTime && this.endTime) {
362
+                params.startDate = this.beginTime
363
+                params.endDate = this.endTime
364
+            } else {
365
+                const range = this.getDateRange()
366
+                params.startDate = range.startDate
367
+                params.endDate = range.endDate
368
+            }
369
+            if (this.selectedOrgId) {
370
+                params.userId = this.selectedOrgId
371
+            }
372
+            if (this.selectedAlertLevel) {
373
+                params.warningLevel = this.selectedAlertLevel
374
+            }
375
+            return params
376
+        }
377
+    }
378
+}
379
+</script>
380
+
381
+<style lang="scss" scoped>
382
+.redline-page {
383
+    min-height: 100vh;
384
+    background: #f5f7fa;
385
+    padding-bottom: 40rpx;
386
+}
387
+
388
+.page-header {
389
+    background: #fff;
390
+    padding: 24rpx 32rpx;
391
+    border-bottom: 1rpx solid #eee;
392
+}
393
+
394
+.header-title {
395
+    font-size: 36rpx;
396
+    font-weight: bold;
397
+    color: #dc2626;
398
+}
399
+
400
+.header-subtitle {
401
+    font-size: 24rpx;
402
+    color: #666;
403
+    margin-top: 8rpx;
404
+}
405
+
406
+.badge-group {
407
+    display: flex;
408
+    gap: 16rpx;
409
+    margin-top: 16rpx;
410
+}
411
+
412
+.alert-badge {
413
+    padding: 8rpx 20rpx;
414
+    border-radius: 40rpx;
415
+    font-size: 24rpx;
416
+    font-weight: 500;
417
+    background: #fff1f0;
418
+    border-left: 5rpx solid #ef4444;
419
+    color: #ef4444;
420
+
421
+    &.orange {
422
+        border-left-color: #f97316;
423
+        color: #f97316;
424
+    }
425
+}
426
+
427
+.filter-bar {
428
+    background: #fff;
429
+    margin: 20rpx;
430
+    border-radius: 16rpx;
431
+    padding: 20rpx;
432
+    box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
433
+}
434
+
435
+.time-scroll {
436
+    width: 100%;
437
+    margin-bottom: 16rpx;
438
+}
439
+
440
+.time-tags {
441
+    display: flex;
442
+    gap: 0;
443
+    width: 100%;
444
+}
445
+
446
+.time-tag {
447
+    flex: 1;
448
+    text-align: center;
449
+    padding: 12rpx 0;
450
+    background: #f8fafc;
451
+    border: 1rpx solid #e2e8f0;
452
+    font-size: 24rpx;
453
+    color: #333;
454
+
455
+    &.active {
456
+        background: #dc2626;
457
+        border-color: #dc2626;
458
+        color: #fff;
459
+    }
460
+}
461
+
462
+.date-range-picker {
463
+    display: flex;
464
+    align-items: center;
465
+    width: 100%;
466
+    margin-top: 16rpx;
467
+    margin-bottom: 16rpx;
468
+}
469
+
470
+.date-picker-half {
471
+    flex: 1;
472
+}
473
+
474
+.date-input {
475
+    width: 100%;
476
+    padding: 12rpx 16rpx;
477
+    border-radius: 8rpx;
478
+    background: #f5f5f5;
479
+    font-size: 24rpx;
480
+    color: #999;
481
+    text-align: center;
482
+    box-sizing: border-box;
483
+
484
+    &.filled {
485
+        color: #333;
486
+    }
487
+}
488
+
489
+.date-separator {
490
+    font-size: 24rpx;
491
+    color: #999;
492
+    flex-shrink: 0;
493
+    padding: 0 12rpx;
494
+}
495
+
496
+.filter-row {
497
+    display: flex;
498
+    gap: 12rpx;
499
+    margin-bottom: 16rpx;
500
+}
501
+
502
+.filter-select {
503
+    flex: 1;
504
+    display: flex;
505
+    align-items: center;
506
+    justify-content: space-between;
507
+    padding: 16rpx 20rpx;
508
+    background: #f5f5f5;
509
+    border-radius: 8rpx;
510
+    border: 1rpx solid #e0e0e0;
511
+}
512
+
513
+.filter-select-text {
514
+    font-size: 26rpx;
515
+    color: #333;
516
+}
517
+
518
+.filter-actions {
519
+    display: flex;
520
+    gap: 16rpx;
521
+}
522
+
523
+.btn-search {
524
+    flex: 1;
525
+    display: flex;
526
+    align-items: center;
527
+    justify-content: center;
528
+    gap: 8rpx;
529
+    padding: 16rpx;
530
+    background: #dc2626;
531
+    border-radius: 8rpx;
532
+    color: #fff;
533
+    font-size: 28rpx;
534
+}
535
+
536
+.btn-reset {
537
+    padding: 16rpx 32rpx;
538
+    background: #f5f5f5;
539
+    border-radius: 8rpx;
540
+    color: #666;
541
+    font-size: 28rpx;
542
+    text-align: center;
543
+}
544
+
545
+.section-area {
546
+    margin: 0 20rpx;
547
+}
548
+
549
+.section-badge {
550
+    font-size: 22rpx;
551
+    background: #fef2f2;
552
+    padding: 6rpx 20rpx;
553
+    border-radius: 30rpx;
554
+    color: #dc2626;
555
+}
556
+
557
+.table-wrapper {
558
+    background: #fff;
559
+    border-radius: 16rpx;
560
+    overflow-x: auto;
561
+    box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
562
+}
563
+
564
+.pagination-wrapper {
565
+    display: flex;
566
+    justify-content: center;
567
+    padding: 20rpx 0;
568
+}
569
+
570
+.warning-summary {
571
+    background: #fff9f0;
572
+    border-radius: 16rpx;
573
+    padding: 20rpx;
574
+    margin-top: 20rpx;
575
+}
576
+
577
+.summary-item {
578
+    display: flex;
579
+    align-items: center;
580
+    gap: 12rpx;
581
+    padding: 8rpx 0;
582
+    font-size: 26rpx;
583
+    color: #333;
584
+}
585
+
586
+.dot {
587
+    width: 16rpx;
588
+    height: 16rpx;
589
+    border-radius: 50%;
590
+    flex-shrink: 0;
591
+
592
+    &.red {
593
+        background: #ef4444;
594
+    }
595
+
596
+    &.green {
597
+        background: #22c55e;
598
+    }
599
+}
600
+
601
+.score-danger {
602
+    font-weight: bold;
603
+    color: #dc2626;
604
+    background: #fee2e2;
605
+    padding: 4rpx 12rpx;
606
+    border-radius: 30rpx;
607
+    font-size: 24rpx;
608
+}
609
+
610
+.picker-popup,
611
+.level-popup {
612
+    background: #fff;
613
+    border-radius: 16rpx 16rpx 0 0;
614
+    max-height: 70vh;
615
+    display: flex;
616
+    flex-direction: column;
617
+}
618
+
619
+.picker-header {
620
+    display: flex;
621
+    justify-content: space-between;
622
+    align-items: center;
623
+    padding: 24rpx 32rpx;
624
+    border-bottom: 1rpx solid #eee;
625
+}
626
+
627
+.picker-title {
628
+    font-size: 32rpx;
629
+    font-weight: 600;
630
+    color: #333;
631
+}
632
+
633
+.search-box {
634
+    padding: 16rpx 32rpx;
635
+}
636
+
637
+.tree-list,
638
+.org-list {
639
+    flex: 1;
640
+    padding: 0 32rpx;
641
+    height: 0;
642
+    overflow: hidden;
643
+}
644
+
645
+.org-item {
646
+    display: flex;
647
+    align-items: center;
648
+    justify-content: space-between;
649
+    padding: 24rpx 0;
650
+    border-bottom: 1rpx solid #f0f0f0;
651
+}
652
+
653
+.org-item-name {
654
+    font-size: 28rpx;
655
+    color: #333;
656
+}
657
+
658
+.level-list {
659
+    padding: 16rpx 32rpx;
660
+}
661
+
662
+.level-item {
663
+    padding: 24rpx 0;
664
+    border-bottom: 1rpx solid #f0f0f0;
665
+    font-size: 28rpx;
666
+    color: #333;
667
+    text-align: center;
668
+
669
+    &.active {
670
+        color: #dc2626;
671
+        font-weight: 500;
672
+    }
673
+}
674
+</style>

+ 769 - 0
src/pages/warningPage/index.vue

@@ -0,0 +1,769 @@
1
+<template>
2
+    <view class="warning-page">
3
+        <view class="page-header">
4
+            <view class="header-title">综合预警工作台</view>
5
+            <view class="header-subtitle">员工综合评估(<75分红色预警 | ≥90分优秀)</view>
6
+            <view class="badge-group">
7
+                <view class="alert-badge">实时预警</view>
8
+                <view class="alert-badge orange">动态月度数据</view>
9
+            </view>
10
+        </view>
11
+
12
+        <view class="filter-bar">
13
+            <scroll-view scroll-x class="time-scroll">
14
+                <view class="time-tags">
15
+                    <view v-for="(tag, index) in timeTags" :key="index"
16
+                        :class="['time-tag', { active: selectedTimeTag === index }]" @click="onTimeTagClick(index)">
17
+                        {{ tag }}
18
+                    </view>
19
+                </view>
20
+            </scroll-view>
21
+            <view class="date-range-picker">
22
+                <picker mode="date" :value="beginTime" @change="onBeginTimeChange" class="date-picker-half">
23
+                    <view class="date-input" :class="{ filled: beginTime }">
24
+                        {{ beginTime || '开始日期' }}
25
+                    </view>
26
+                </picker>
27
+                <text class="date-separator">至</text>
28
+                <picker mode="date" :value="endTime" @change="onEndTimeChange" class="date-picker-half">
29
+                    <view class="date-input" :class="{ filled: endTime }">
30
+                        {{ endTime || '结束日期' }}
31
+                    </view>
32
+                </picker>
33
+            </view>
34
+
35
+            <view class="filter-row">
36
+                <view class="filter-select" @click="showOrgPicker = true">
37
+                    <text class="filter-select-text">{{ selectedOrgName || '组织架构/员工' }}</text>
38
+                    <u-icon name="arrow-down" size="14" color="#999"></u-icon>
39
+                </view>
40
+                <view class="filter-select" @click="showLevelPicker = true">
41
+                    <text class="filter-select-text">{{ selectedLevelName || '预警等级' }}</text>
42
+                    <u-icon name="arrow-down" size="14" color="#999"></u-icon>
43
+                </view>
44
+            </view>
45
+
46
+            <view class="filter-actions">
47
+                <view class="btn-search" @click="handleSearch">
48
+                    <u-icon name="search" size="16" color="#fff"></u-icon>
49
+                    <text>搜索</text>
50
+                </view>
51
+                <view class="btn-reset" @click="handleReset">重置</view>
52
+            </view>
53
+        </view>
54
+
55
+        <!-- 9个汇总卡片 -->
56
+        <view class="cards-grid">
57
+            <view class="summary-card" v-for="(card, index) in summaryCards" :key="index">
58
+                <view class="card-title">{{ card.title }}</view>
59
+                <view class="card-badge">{{ card.badge }}</view>
60
+                <view class="card-value" :style="{ color: card.color }">{{ card.value }}</view>
61
+            </view>
62
+        </view>
63
+
64
+        <view class="section-area">
65
+            <SectionTitle title="员工综合预警">
66
+                <view class="section-badge">评分依据:员工配分表</view>
67
+            </SectionTitle>
68
+
69
+            <view class="table-wrapper">
70
+                <statistic-table :columns="employeeColumns" :data="employeeList">
71
+                    <template #column-overallScore="{ row }">
72
+                        <text v-if="row.overallScore < 75" class="score-danger">{{ row.overallScore }}分</text>
73
+                        <text v-else-if="row.overallScore >= 90" class="score-excellent">{{ row.overallScore }}分</text>
74
+                        <text v-else>{{ row.overallScore }}分</text>
75
+                    </template>
76
+                    <template #column-detail="{ row }">
77
+                        <text class="detail-link" @click="goToDetail(row)">详情</text>
78
+                    </template>
79
+                </statistic-table>
80
+            </view>
81
+
82
+            <view class="pagination-wrapper" v-if="total > 0">
83
+                <uni-pagination :current="pageNum" :total="total" :page-size="pageSize" simple @change="onPageChange" />
84
+            </view>
85
+
86
+            <view class="warning-summary">
87
+                <view class="summary-item">
88
+                    <view class="dot red"></view>
89
+                    <text><strong>红色预警</strong> (<75分) 共计 {{ redAlertCount }} 人</text>
90
+                </view>
91
+                <view class="summary-item">
92
+                    <view class="dot green"></view>
93
+                    <text><strong>优秀标杆</strong> (≥90分) 共计 {{ excellentCount }} 人</text>
94
+                </view>
95
+                <view class="summary-item">
96
+                    <text>全员平均综合得分: <strong>{{ avgScore }}</strong> 分</text>
97
+                </view>
98
+            </view>
99
+        </view>
100
+
101
+        <!-- 组织架构选择弹窗 -->
102
+        <u-popup :show="showOrgPicker" mode="bottom" :round="16" :mask-close-able="true"
103
+            @close="showOrgPicker = false">
104
+            <view class="picker-popup">
105
+                <view class="picker-header">
106
+                    <text class="picker-title">选择组织架构/员工</text>
107
+                    <u-icon name="close" size="20" @click="showOrgPicker = false"></u-icon>
108
+                </view>
109
+                <view class="search-box">
110
+                    <u-input v-model="orgSearchKeyword" placeholder="搜索" @input="onOrgSearch"></u-input>
111
+                </view>
112
+                <scroll-view v-if="!orgSearchKeyword.trim()" scroll-y class="tree-list">
113
+                    <employee-tree-node v-for="node in deptTreeData" :key="node.id" :node="node"
114
+                        :expanded-ids="expandedDeptIds" :selected-id="selectedOrgId" @toggle="toggleDeptExpand"
115
+                        @select="onOrgSelect" />
116
+                </scroll-view>
117
+                <scroll-view v-else scroll-y class="org-list">
118
+                    <view class="org-item" v-for="item in filteredOrgList" :key="item.userId"
119
+                        @click="onOrgSelect(item)">
120
+                        <text class="org-item-name">{{ item.nickName }}</text>
121
+                        <u-icon v-if="item.userId === selectedOrgId" name="checkmark" color="#34D399"
122
+                            size="18"></u-icon>
123
+                    </view>
124
+                </scroll-view>
125
+            </view>
126
+        </u-popup>
127
+
128
+        <!-- 预警等级选择弹窗 -->
129
+        <u-popup :show="showLevelPicker" mode="bottom" :round="16" :mask-close-able="true"
130
+            @close="showLevelPicker = false">
131
+            <view class="level-popup">
132
+                <view class="picker-header">
133
+                    <text class="picker-title">选择预警等级</text>
134
+                    <u-icon name="close" size="20" @click="showLevelPicker = false"></u-icon>
135
+                </view>
136
+                <view class="level-list">
137
+                    <view class="level-item" :class="{ active: selectedAlertLevel === '' }" @click="selectLevel('')">
138
+                        <text>全部</text>
139
+                    </view>
140
+                    <view class="level-item" v-for="item in alertLevelList" :key="item.value"
141
+                        :class="{ active: selectedAlertLevel === item.value }" @click="selectLevel(item.value)">
142
+                        <text>{{ item.label }}</text>
143
+                    </view>
144
+                </view>
145
+            </view>
146
+        </u-popup>
147
+    </view>
148
+</template>
149
+
150
+<script>
151
+import SectionTitle from '@/components/SectionTitle.vue'
152
+import StatisticTable from '@/components/statistic-table/statistic-table.vue'
153
+import EmployeeTreeNode from '@/pages/components/EmployeeTreeNode.vue'
154
+import { getWarningPageData, getEmployeeWarningPageData } from '@/api/warningManage/index'
155
+import { getDeptUserTree } from '@/api/system/user'
156
+
157
+export default {
158
+    name: 'WarningPage',
159
+    components: {
160
+        SectionTitle,
161
+        StatisticTable,
162
+        EmployeeTreeNode
163
+    },
164
+    data() {
165
+        return {
166
+            selectedTimeTag: 1,
167
+            timeTags: ['近一周', '近一月', '近三月', '近一年'],
168
+            beginTime: '',
169
+            endTime: '',
170
+            selectedOrgId: null,
171
+            selectedOrgName: '',
172
+            selectedAlertLevel: '',
173
+            selectedLevelName: '全部',
174
+            showOrgPicker: false,
175
+            showLevelPicker: false,
176
+            orgSearchKeyword: '',
177
+            deptTreeData: [],
178
+            expandedDeptIds: [],
179
+            orgList: [],
180
+            alertLevelList: [
181
+                { label: '红色预警', value: '1' },
182
+                { label: '正常范围', value: '3' }
183
+            ],
184
+            summaryCards: [
185
+                { title: '部门监察问题', badge: '部门级', value: '0', color: '#b45309' },
186
+                { title: '实时质控拦截', badge: '部门级', value: '0', color: '#2563eb' },
187
+                { title: '不安全事件', badge: '一级预警', value: '0', color: '#dc2626' },
188
+                { title: '安保测试记录', badge: '部门级', value: '0', color: '#e67e22' },
189
+                { title: '旅客服务投诉', badge: '服务响应', value: '0', color: '#e67e22' },
190
+                { title: '服务巡查', badge: '部门级', value: '0', color: '#333' },
191
+                { title: '培训及考试成绩', badge: '平均分数', value: '0', color: '#333' },
192
+                { title: '航站楼', badge: '吞吐量', value: '0', color: '#059669' },
193
+                { title: '小额奖励', badge: '奖励次数', value: '0', color: '#7c3aed' }
194
+            ],
195
+            employeeColumns: [
196
+                { props: 'userId', title: '员工ID' },
197
+                { props: 'nickName', title: '姓名' },
198
+                { props: 'deptName', title: '所属部门' },
199
+                { props: 'overallScore', title: '综合评估得分', slot: true },
200
+                { props: 'warningLevel', title: '预警等级' },
201
+                { props: 'coreRisksOrOutstandingAchievements', title: '核心风险/优秀事迹' },
202
+                { props: 'statusLabel', title: '状态标签' },
203
+                { props: 'detail', title: '详情', slot: true }
204
+            ],
205
+            employeeList: [],
206
+            allEmployeeList: [],
207
+            pageNum: 1,
208
+            pageSize: 10,
209
+            total: 0,
210
+            redAlertCount: 0,
211
+            excellentCount: 0,
212
+            avgScore: 0,
213
+            warningDataMap: {
214
+                ledgerSupervisionProblem: 0,
215
+                ledgerRealtimeInterception: 1,
216
+                ledgerUnsafeEvent: 2,
217
+                ledgerSecurityTest: 3,
218
+                ledgerComplaint: 4,
219
+                ledgerServicePatrol: 5,
220
+                ledgerExamScore: 6,
221
+                ledgerTerminalBonus: 7,
222
+                ledgerRewardApproval: 8
223
+            }
224
+        }
225
+    },
226
+    computed: {
227
+        filteredOrgList() {
228
+            const keyword = this.orgSearchKeyword.trim().toLowerCase()
229
+            if (!keyword) return this.orgList
230
+            return this.orgList.filter(item =>
231
+                (item.nickName || '').toLowerCase().includes(keyword)
232
+            )
233
+        }
234
+    },
235
+    onLoad(options) {
236
+        if (options.id) {
237
+            this.selectedOrgId = Number(options.id)
238
+        }
239
+    },
240
+    mounted() {
241
+        this.loadDeptTree()
242
+        this.fetchData()
243
+    },
244
+    methods: {
245
+        formatDate(date) {
246
+            const y = date.getFullYear()
247
+            const m = String(date.getMonth() + 1).padStart(2, '0')
248
+            const d = String(date.getDate()).padStart(2, '0')
249
+            return `${y}-${m}-${d}`
250
+        },
251
+        getDateRange() {
252
+            const now = new Date()
253
+            let start = new Date(now)
254
+            switch (this.selectedTimeTag) {
255
+                case 0: start.setDate(now.getDate() - 7); break
256
+                case 1: start.setMonth(now.getMonth() - 1); break
257
+                case 2: start.setMonth(now.getMonth() - 3); break
258
+                case 3: start.setFullYear(now.getFullYear() - 1); break
259
+                default: break
260
+            }
261
+            return { startDate: this.formatDate(start), endDate: this.formatDate(now) }
262
+        },
263
+        onTimeTagClick(index) {
264
+            this.selectedTimeTag = index
265
+        },
266
+        onBeginTimeChange(e) {
267
+            this.beginTime = e.detail.value
268
+        },
269
+        onEndTimeChange(e) {
270
+            this.endTime = e.detail.value
271
+        },
272
+        flattenDeptTree(tree) {
273
+            const result = []
274
+            const traverse = (nodes) => {
275
+                nodes.forEach(node => {
276
+                    const hasChildren = node.children && node.children.length > 0
277
+                    if (!hasChildren && node.nodeType !== 'dept') {
278
+                        result.push({
279
+                            nodeType: node.nodeType || '',
280
+                            userId: node.userId || node.id,
281
+                            nickName: node.nickName || node.label || node.userName || ''
282
+                        })
283
+                    }
284
+                    if (hasChildren) {
285
+                        traverse(node.children)
286
+                    }
287
+                })
288
+            }
289
+            traverse(tree)
290
+            return result
291
+        },
292
+        expandAllDepts(nodes) {
293
+            this.expandedDeptIds = []
294
+            const traverse = (list) => {
295
+                list.forEach(node => {
296
+                    const hasChildren = node.children && node.children.length > 0
297
+                    if (hasChildren || node.nodeType === 'dept') {
298
+                        this.expandedDeptIds.push(node.id)
299
+                        if (hasChildren) {
300
+                            traverse(node.children)
301
+                        }
302
+                    }
303
+                })
304
+            }
305
+            traverse(nodes)
306
+        },
307
+        loadDeptTree() {
308
+            getDeptUserTree({}).then(res => {
309
+                if (res.code === 200) {
310
+                    this.deptTreeData = res.data || []
311
+                    this.orgList = this.flattenDeptTree(this.deptTreeData).filter(u => u.nodeType === 'user')
312
+                    this.expandAllDepts(this.deptTreeData)
313
+                }
314
+            }).catch(() => { })
315
+        },
316
+        toggleDeptExpand(id) {
317
+            const index = this.expandedDeptIds.indexOf(id)
318
+            if (index > -1) {
319
+                this.expandedDeptIds.splice(index, 1)
320
+            } else {
321
+                this.expandedDeptIds.push(id)
322
+            }
323
+        },
324
+        onOrgSelect(item) {
325
+            this.selectedOrgId = item.userId
326
+            this.selectedOrgName = item.nickName
327
+            this.showOrgPicker = false
328
+        },
329
+        onOrgSearch() { },
330
+        selectLevel(value) {
331
+            this.selectedAlertLevel = value
332
+            const item = this.alertLevelList.find(l => l.value === value)
333
+            this.selectedLevelName = value === '' ? '全部' : (item ? item.label : '')
334
+            this.showLevelPicker = false
335
+        },
336
+        handleSearch() {
337
+            this.pageNum = 1
338
+            this.fetchData()
339
+        },
340
+        handleReset() {
341
+            this.selectedTimeTag = 1
342
+            this.beginTime = ''
343
+            this.endTime = ''
344
+            this.selectedOrgId = null
345
+            this.selectedOrgName = ''
346
+            this.selectedAlertLevel = ''
347
+            this.selectedLevelName = '全部'
348
+            this.pageNum = 1
349
+            this.fetchData()
350
+        },
351
+        onPageChange(e) {
352
+            this.pageNum = e.current
353
+            this.updatePagedData()
354
+        },
355
+        async fetchData() {
356
+            await Promise.all([this.fetchSummaryData(), this.fetchEmployeeData()])
357
+        },
358
+        async fetchSummaryData() {
359
+            let params = this.getQueryParams()
360
+            try {
361
+                const res = await getWarningPageData(params)
362
+                if (res.code === 200 && res.data) {
363
+                    const d = res.data
364
+                    this.summaryCards.forEach((card, idx) => {
365
+                        for (const [key, i] of Object.entries(this.warningDataMap)) {
366
+                            if (i === idx) {
367
+                                card.value = d[key] !== undefined ? String(d[key]) : '0'
368
+                                break
369
+                            }
370
+                        }
371
+                    })
372
+                }
373
+            } catch (e) { }
374
+        },
375
+        async fetchEmployeeData() {
376
+            let params = this.getQueryParams()
377
+            try {
378
+                const res = await getEmployeeWarningPageData(params)
379
+                if (res.code === 200 && res.data) {
380
+                    const data = res.data
381
+                    this.allEmployeeList = data.ledgerWarningDetailItemList || []
382
+                    this.total = this.allEmployeeList.length
383
+                   
384
+                    this.redAlertCount = data.redAlertNum || 0
385
+                    this.excellentCount = data.excellentBenchmarkNum || 0
386
+                    this.avgScore = data.averageComprehensiveScore || 0
387
+                    this.updatePagedData()
388
+                }
389
+            } catch (e) { }
390
+        },
391
+        updatePagedData() {
392
+            const start = (this.pageNum - 1) * this.pageSize
393
+            const end = start + this.pageSize
394
+            this.employeeList = this.allEmployeeList.slice(start, end)
395
+        },
396
+        getQueryParams() {
397
+            let params = {}
398
+            if (this.beginTime && this.endTime) {
399
+                params.startDate = this.beginTime
400
+                params.endDate = this.endTime
401
+            } else {
402
+                const range = this.getDateRange()
403
+                params.startDate = range.startDate
404
+                params.endDate = range.endDate
405
+            }
406
+            if (this.selectedOrgId) {
407
+                params.userId = this.selectedOrgId
408
+            }
409
+            if (this.selectedAlertLevel) {
410
+                params.warningLevel = this.selectedAlertLevel
411
+            }
412
+            return params
413
+        },
414
+        goToDetail(row) {
415
+            uni.navigateTo({
416
+                url: '/pages/redLineWarning/index?id=' + row.userId
417
+            })
418
+        }
419
+    }
420
+}
421
+</script>
422
+
423
+<style lang="scss" scoped>
424
+.warning-page {
425
+    min-height: 100vh;
426
+    background: #f5f7fa;
427
+    padding-bottom: 40rpx;
428
+}
429
+
430
+.page-header {
431
+    background: #fff;
432
+    padding: 24rpx 32rpx;
433
+    border-bottom: 1rpx solid #eee;
434
+}
435
+
436
+.header-title {
437
+    font-size: 36rpx;
438
+    font-weight: bold;
439
+    color: #1e3c72;
440
+}
441
+
442
+.header-subtitle {
443
+    font-size: 24rpx;
444
+    color: #666;
445
+    margin-top: 8rpx;
446
+}
447
+
448
+.badge-group {
449
+    display: flex;
450
+    gap: 16rpx;
451
+    margin-top: 16rpx;
452
+}
453
+
454
+.alert-badge {
455
+    padding: 8rpx 20rpx;
456
+    border-radius: 40rpx;
457
+    font-size: 24rpx;
458
+    font-weight: 500;
459
+    background: #fff1f0;
460
+    border-left: 5rpx solid #ef4444;
461
+    color: #ef4444;
462
+
463
+    &.orange {
464
+        border-left-color: #f97316;
465
+        color: #f97316;
466
+    }
467
+}
468
+
469
+.filter-bar {
470
+    background: #fff;
471
+    margin: 20rpx;
472
+    border-radius: 16rpx;
473
+    padding: 20rpx;
474
+    box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
475
+}
476
+
477
+.time-scroll {
478
+    width: 100%;
479
+    margin-bottom: 16rpx;
480
+}
481
+
482
+.time-tags {
483
+    display: flex;
484
+    gap: 0;
485
+    width: 100%;
486
+}
487
+
488
+.time-tag {
489
+    flex: 1;
490
+    text-align: center;
491
+    padding: 12rpx 0;
492
+    background: #f8fafc;
493
+    border: 1rpx solid #e2e8f0;
494
+    font-size: 24rpx;
495
+    color: #333;
496
+
497
+    &.active {
498
+        background: #2563eb;
499
+        border-color: #2563eb;
500
+        color: #fff;
501
+    }
502
+}
503
+
504
+.date-range-picker {
505
+    display: flex;
506
+    align-items: center;
507
+    width: 100%;
508
+    margin-top: 16rpx;
509
+    margin-bottom: 16rpx;
510
+}
511
+
512
+.date-picker-half {
513
+    flex: 1;
514
+}
515
+
516
+.date-input {
517
+    width: 100%;
518
+    padding: 12rpx 16rpx;
519
+    border-radius: 8rpx;
520
+    background: #f5f5f5;
521
+    font-size: 24rpx;
522
+    color: #999;
523
+    text-align: center;
524
+    box-sizing: border-box;
525
+
526
+    &.filled {
527
+        color: #333;
528
+    }
529
+}
530
+
531
+.date-separator {
532
+    font-size: 24rpx;
533
+    color: #999;
534
+    flex-shrink: 0;
535
+    padding: 0 12rpx;
536
+}
537
+
538
+.filter-row {
539
+    display: flex;
540
+    gap: 12rpx;
541
+    margin-bottom: 16rpx;
542
+}
543
+
544
+.filter-select {
545
+    flex: 1;
546
+    display: flex;
547
+    align-items: center;
548
+    justify-content: space-between;
549
+    padding: 16rpx 20rpx;
550
+    background: #f5f5f5;
551
+    border-radius: 8rpx;
552
+    border: 1rpx solid #e0e0e0;
553
+}
554
+
555
+.filter-select-text {
556
+    font-size: 26rpx;
557
+    color: #333;
558
+}
559
+
560
+.filter-actions {
561
+    display: flex;
562
+    gap: 16rpx;
563
+}
564
+
565
+.btn-search {
566
+    flex: 1;
567
+    display: flex;
568
+    align-items: center;
569
+    justify-content: center;
570
+    gap: 8rpx;
571
+    padding: 16rpx;
572
+    background: #2563eb;
573
+    border-radius: 8rpx;
574
+    color: #fff;
575
+    font-size: 28rpx;
576
+}
577
+
578
+.btn-reset {
579
+    padding: 16rpx 32rpx;
580
+    background: #f5f5f5;
581
+    border-radius: 8rpx;
582
+    color: #666;
583
+    font-size: 28rpx;
584
+    text-align: center;
585
+}
586
+
587
+.cards-grid {
588
+    display: grid;
589
+    grid-template-columns: 1fr 1fr;
590
+    gap: 16rpx;
591
+    padding: 0 20rpx;
592
+    margin-bottom: 20rpx;
593
+}
594
+
595
+.summary-card {
596
+    background: #fff;
597
+    border-radius: 16rpx;
598
+    padding: 24rpx 20rpx;
599
+    text-align: center;
600
+    box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
601
+    border: 1rpx solid #eef2f6;
602
+}
603
+
604
+.card-title {
605
+    font-size: 26rpx;
606
+    font-weight: bold;
607
+    color: #333;
608
+    margin-bottom: 8rpx;
609
+}
610
+
611
+.card-badge {
612
+    display: inline-block;
613
+    font-size: 20rpx;
614
+    background: #f1f5f9;
615
+    padding: 4rpx 16rpx;
616
+    border-radius: 30rpx;
617
+    color: #666;
618
+    margin-bottom: 12rpx;
619
+}
620
+
621
+.card-value {
622
+    font-size: 48rpx;
623
+    font-weight: 800;
624
+}
625
+
626
+.section-area {
627
+    margin: 0 20rpx;
628
+}
629
+
630
+.section-badge {
631
+    font-size: 22rpx;
632
+    background: #eef2ff;
633
+    padding: 6rpx 20rpx;
634
+    border-radius: 30rpx;
635
+    color: #666;
636
+}
637
+
638
+.table-wrapper {
639
+    background: #fff;
640
+    border-radius: 16rpx;
641
+    overflow-x: auto;
642
+    box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
643
+}
644
+
645
+.pagination-wrapper {
646
+    display: flex;
647
+    justify-content: center;
648
+    padding: 20rpx 0;
649
+}
650
+
651
+.warning-summary {
652
+    background: #fff9f0;
653
+    border-radius: 16rpx;
654
+    padding: 20rpx;
655
+    margin-top: 20rpx;
656
+}
657
+
658
+.summary-item {
659
+    display: flex;
660
+    align-items: center;
661
+    gap: 12rpx;
662
+    padding: 8rpx 0;
663
+    font-size: 26rpx;
664
+    color: #333;
665
+}
666
+
667
+.dot {
668
+    width: 16rpx;
669
+    height: 16rpx;
670
+    border-radius: 50%;
671
+    flex-shrink: 0;
672
+
673
+    &.red {
674
+        background: #ef4444;
675
+    }
676
+
677
+    &.green {
678
+        background: #22c55e;
679
+    }
680
+}
681
+
682
+.score-danger {
683
+    font-weight: bold;
684
+    color: #dc2626;
685
+    background: #fee2e2;
686
+    padding: 4rpx 12rpx;
687
+    border-radius: 30rpx;
688
+    font-size: 24rpx;
689
+}
690
+
691
+.score-excellent {
692
+    font-weight: bold;
693
+    color: #15803d;
694
+    background: #dcfce7;
695
+    padding: 4rpx 12rpx;
696
+    border-radius: 30rpx;
697
+    font-size: 24rpx;
698
+}
699
+
700
+.detail-link {
701
+    color: #2563eb;
702
+    font-size: 24rpx;
703
+}
704
+
705
+.picker-popup,
706
+.level-popup {
707
+    background: #fff;
708
+    border-radius: 16rpx 16rpx 0 0;
709
+    max-height: 70vh;
710
+    display: flex;
711
+    flex-direction: column;
712
+}
713
+
714
+.picker-header {
715
+    display: flex;
716
+    justify-content: space-between;
717
+    align-items: center;
718
+    padding: 24rpx 32rpx;
719
+    border-bottom: 1rpx solid #eee;
720
+}
721
+
722
+.picker-title {
723
+    font-size: 32rpx;
724
+    font-weight: 600;
725
+    color: #333;
726
+}
727
+
728
+.search-box {
729
+    padding: 16rpx 32rpx;
730
+}
731
+
732
+.tree-list,
733
+.org-list {
734
+    flex: 1;
735
+    padding: 0 32rpx;
736
+    height: 0;
737
+    overflow: hidden;
738
+}
739
+
740
+.org-item {
741
+    display: flex;
742
+    align-items: center;
743
+    justify-content: space-between;
744
+    padding: 24rpx 0;
745
+    border-bottom: 1rpx solid #f0f0f0;
746
+}
747
+
748
+.org-item-name {
749
+    font-size: 28rpx;
750
+    color: #333;
751
+}
752
+
753
+.level-list {
754
+    padding: 16rpx 32rpx;
755
+}
756
+
757
+.level-item {
758
+    padding: 24rpx 0;
759
+    border-bottom: 1rpx solid #f0f0f0;
760
+    font-size: 28rpx;
761
+    color: #333;
762
+    text-align: center;
763
+
764
+    &.active {
765
+        color: #2563eb;
766
+        font-weight: 500;
767
+    }
768
+}
769
+</style>