Explorar el Código

Merge branch 'new-demand'

# Conflicts:
#	src/pages/employeeProfile/index.vue
huoyi hace 1 mes
padre
commit
43cbbd2810

+ 4 - 0
src/api/portraitManagement/portraitManagement.js

@@ -105,4 +105,8 @@ export function getEmployeePortrait(params) {
105 105
 }
106 106
 export function searchPortraitUsers(keyword) {
107 107
     return request({ url: '/score/portrait/searchUsers', method: 'get', params: { keyword } })
108
+}
109
+// 获取职业资格等级获取时间
110
+export function getQualificationLevelTime(params) {
111
+    return request({ url: '/ledger/qualificationLevel/user', method: 'get', params })
108 112
 }

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

@@ -0,0 +1,26 @@
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/employeeDimensionScores', method: 'post', data })
21
+}
22
+
23
+//
24
+export function getDimensionAll(params) {
25
+  return request({ url: `/score/dimension/all`, method: 'get', params: params })
26
+}

+ 24 - 0
src/pages.json

@@ -364,6 +364,12 @@
364 364
       }
365 365
     },
366 366
     {
367
+      "path": "pages/warningManage/index",
368
+      "style": {
369
+        "navigationBarTitleText": "预警管理"
370
+      }
371
+    },
372
+    {
367 373
       "path": "pages/statisticalAnalysis/index",
368 374
       "style": {
369 375
         "navigationBarTitleText": "统计分析"
@@ -380,6 +386,24 @@
380 386
       "style": {
381 387
         "navigationBarTitleText": "查获统计"
382 388
       }
389
+    },
390
+    {
391
+      "path": "pages/warningPage/index",
392
+      "style": {
393
+        "navigationBarTitleText": "综合预警工作台"
394
+      }
395
+    },
396
+    {
397
+      "path": "pages/employeeDimensionDetails/index",
398
+      "style": {
399
+        "navigationBarTitleText": "员工维度评分明细"
400
+      }
401
+    },
402
+    {
403
+      "path": "pages/redLineWarning/index",
404
+      "style": {
405
+        "navigationBarTitleText": "红线指标预警"
406
+      }
383 407
     }
384 408
   ],
385 409
   "tabBar": {

+ 24 - 4
src/pages/components/EmployeeTreeNode.vue

@@ -4,14 +4,18 @@
4 4
             :class="['node-row', { 'is-dept': isDept, 'is-user': isUser }]"
5 5
             @click="handleClick"
6 6
         >
7
-            <view class="toggle-icon" v-if="isDept">
7
+            <view class="toggle-icon" v-if="isDept && !selectable">
8 8
                 <u-icon :name="expanded ? 'arrow-down' : 'arrow-right'" size="16" color="#999" />
9 9
             </view>
10
+            <view class="toggle-icon" v-else-if="isDept && selectable">
11
+                <text class="user-dot" style="color: #A78BFA">●</text>
12
+            </view>
10 13
             <view class="toggle-icon" v-else>
11 14
                 <text class="user-dot">●</text>
12 15
             </view>
13 16
             <text class="node-label">{{ nodeLabel }}</text>
14
-            <u-icon v-if="isUser && nodeId === selectedId" name="checkmark" color="#34D399" size="18" />
17
+            <u-icon v-if="selectable && nodeId === selectedId" name="checkmark" color="#34D399" size="18" />
18
+            <u-icon v-else-if="!selectable && isUser && nodeId === selectedId" name="checkmark" color="#34D399" size="18" />
15 19
         </view>
16 20
         <view v-if="isDept && expanded" class="node-children">
17 21
             <template v-for="child in nodeChildren">
@@ -20,6 +24,7 @@
20 24
                     :node="child"
21 25
                     :expanded-ids="expandedIds"
22 26
                     :selected-id="selectedId"
27
+                    :selectable="selectable"
23 28
                     @toggle="$emit('toggle', $event)"
24 29
                     @select="$emit('select', $event)"
25 30
                 />
@@ -43,6 +48,10 @@ export default {
43 48
         selectedId: {
44 49
             type: [String, Number],
45 50
             default: null
51
+        },
52
+        selectable: {
53
+            type: Boolean,
54
+            default: false
46 55
         }
47 56
     },
48 57
     computed: {
@@ -51,7 +60,7 @@ export default {
51 60
             return hasChildren || this.node.nodeType === 'dept'
52 61
         },
53 62
         isUser() {
54
-            return !this.isDept
63
+            return !this.isDept || this.selectable
55 64
         },
56 65
         nodeId() {
57 66
             return this.node.userId || this.node.id
@@ -73,10 +82,21 @@ export default {
73 82
         handleClick() {
74 83
             if (this.isDept) {
75 84
                 this.$emit('toggle', this.node.id)
85
+                if (this.selectable) {
86
+                    this.$emit('select', {
87
+                        userId: this.node.userId || this.node.id,
88
+                        nodeId: this.node.id,
89
+                        nickName: this.nodeLabel,
90
+                        nodeType: this.node.nodeType || 'dept',
91
+                        deptType: this.node.deptType || ''
92
+                    })
93
+                }
76 94
             } else {
77 95
                 this.$emit('select', {
78 96
                     userId: this.nodeId,
79
-                    nickName: this.nodeLabel
97
+                    nickName: this.nodeLabel,
98
+                    nodeType: this.node.nodeType || 'user',
99
+                    deptType: this.node.deptType || ''
80 100
                 })
81 101
             }
82 102
         }

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

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

+ 52 - 11
src/pages/employeeProfile/index.vue

@@ -33,11 +33,13 @@
33 33
             </view>
34 34
 
35 35
             <view class="search-selector">
36
-                <view class="search-select-trigger" :class="{ 'search-select-trigger-disabled': isSecurityCheck || routeUserId }"
36
+                <view class="search-select-trigger"
37
+                    :class="{ 'search-select-trigger-disabled': isSecurityCheck || routeUserId }"
37 38
                     @click="!isSecurityCheck && !routeUserId && (showEmployeePicker = true)">
38 39
                     <u-icon name="list" color="#A78BFA" size="16"></u-icon>
39 40
                     <text class="search-name-text">{{ selectedEmployeeName || '请选择员工' }}</text>
40
-                    <u-icon v-if="!isSecurityCheck && !routeUserId" name="arrow-down" color="#A78BFA" size="14"></u-icon>
41
+                    <u-icon v-if="!isSecurityCheck && !routeUserId" name="arrow-down" color="#A78BFA"
42
+                        size="14"></u-icon>
41 43
                 </view>
42 44
             </view>
43 45
 
@@ -99,7 +101,7 @@
99 101
                                 <view class="info-label">标签:</view>
100 102
                                 <view class="info-value" v-if="portrait.userTags">
101 103
                                     <text class="tag" v-for="tag in portrait.userTags.split(',')" :key="tag">{{ tag
102
-                                        }}</text>
104
+                                    }}</text>
103 105
                                 </view>
104 106
                             </view>
105 107
                         </view>
@@ -146,6 +148,15 @@
146 148
                     </view>
147 149
                 </SectionTitle>
148 150
 
151
+                <SectionTitle v-if="activeTab === 'profile'" title="职业资格证书获取时间">
152
+                    <view class="cert-info">
153
+                        <view class="cert-item" v-for="level in displayQualificationLevels" :key="level.field">
154
+                            <text class="cert-name">{{ level.label }}:{{ (qualificationData && qualificationData[level.field]) || '-' }}</text>
155
+                        </view>
156
+                        <view v-if="!displayQualificationLevels.length" class="no-data">暂无数据</view>
157
+                    </view>
158
+                </SectionTitle>
159
+
149 160
                 <SectionTitle v-if="activeTab === 'profile' && displayQualificationLevels.length > 0" title="职业资格证书获取时间">
150 161
                     <view class="cert-info">
151 162
                         <view class="cert-item" v-for="level in displayQualificationLevels" :key="level.field">
@@ -161,7 +172,7 @@
161 172
                         <div class="legend-item legend-excellent"><span></span>优秀线(高于90分)</div>
162 173
                         <div class="legend-item legend-current"><span></span>当前分值</div>
163 174
                     </div>
164
-                    <div class="chart-container" ref="radarChart" ></div>
175
+                    <div class="chart-container" ref="radarChart"></div>
165 176
                 </SectionTitle>
166 177
 
167 178
                 <SectionTitle v-if="activeTab === 'profile'" title="补充信息">
@@ -226,7 +237,7 @@
226 237
                         <view class="warning-score-item">
227 238
                             <text class="warning-score-label">综合得分</text>
228 239
                             <text class="warning-score-value" :class="scoreLevelClass">{{ portrait.totalScore || 0
229
-                            }}</text>
240
+                                }}</text>
230 241
                         </view>
231 242
                     </view>
232 243
                     <view class="warning-detail" v-if="scoreDetails.length">
@@ -280,8 +291,8 @@
280 291
         </u-popup>
281 292
 
282 293
         <!-- 雷达图tooltip -->
283
-        <u-popup :show="showRadarTooltipPopup" mode="center" :round="12"
284
-            :mask-close-able="true" @close="closeRadarPopup"> 
294
+        <u-popup :show="showRadarTooltipPopup" mode="center" :round="12" :mask-close-able="true"
295
+            @close="closeRadarPopup">
285 296
 
286 297
             <div class="radar-tooltip-popup">
287 298
                 <div class="tooltip-card">
@@ -331,8 +342,7 @@
331 342
 
332 343
 <script>
333 344
 import * as echarts from 'echarts'
334
-import { getEmployeePortrait, countTagScore } from '@/api/portraitManagement/portraitManagement'
335
-import { getQualificationLevelTime } from '@/api/ledger/index'
345
+import { getEmployeePortrait, countTagScore,getQualificationLevelTime } from '@/api/portraitManagement/portraitManagement'
336 346
 import { listAllUser, getDeptUserTree } from '@/api/system/user'
337 347
 import SectionTitle from '@/components/SectionTitle.vue'
338 348
 import EmployeeTreeNode from '@/pages/components/EmployeeTreeNode.vue'
@@ -398,7 +408,14 @@ export default {
398 408
             // 控制雷达图 tooltip popup 显示
399 409
             showRadarTooltipPopup: false,
400 410
             // 评分明细数据
401
-            allScoreDetails: []
411
+            allScoreDetails: [],
412
+            qualificationLevelMap: [
413
+                { label: '五级', field: 'levelFiveTime' },
414
+                { label: '四级', field: 'levelFourTime' },
415
+                { label: '三级', field: 'levelThreeTime' },
416
+                { label: '二级', field: 'levelTwoTime' },
417
+                { label: '一级', field: 'levelOneTime' },
418
+            ]
402 419
         }
403 420
     },
404 421
     computed: {
@@ -490,7 +507,8 @@ export default {
490 507
         deductTotal() {
491 508
             const s = this.deductList.reduce((acc, d) => acc + Number(d.totalScore), 0)
492 509
             return s.toFixed(2)
493
-        }
510
+        },
511
+
494 512
     },
495 513
     // uni-app 页面生命周期:接收路由参数
496 514
     onLoad(options) {
@@ -1388,6 +1406,29 @@ export default {
1388 1406
 }
1389 1407
 
1390 1408
 .cert-info {
1409
+    .cert-item {
1410
+        padding: 16rpx 0;
1411
+        border-bottom: 1rpx solid #f0f0f0;
1412
+
1413
+        &:last-child {
1414
+            border-bottom: none;
1415
+        }
1416
+
1417
+        .cert-name {
1418
+            font-size: 26rpx;
1419
+            color: #333;
1420
+        }
1421
+    }
1422
+
1423
+    .no-data {
1424
+        font-size: 26rpx;
1425
+        color: #999;
1426
+        text-align: center;
1427
+        padding: 24rpx 0;
1428
+    }
1429
+}
1430
+
1431
+.cert-info {
1391 1432
     display: flex;
1392 1433
     flex-direction: column;
1393 1434
     gap: 16rpx;

+ 2 - 1
src/pages/profileManage/index.vue

@@ -19,8 +19,9 @@
19 19
 <script>
20 20
 import HomeContainer from '@/components/HomeContainer.vue'
21 21
 import { getAppList } from '@/api/system/user'
22
+import {profileAppNames} from "@/utils/common.js";
22 23
 
23
-const profileAppNames = ['全站综合信息', '部门画像', '班组画像', '小组画像', '员工画像']
24
+// const profileAppNames = ['全站综合信息', '部门画像', '班组画像', '小组画像', '员工画像', '部门仪表盘']
24 25
 
25 26
 export default {
26 27
   components: { HomeContainer },

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

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

+ 106 - 0
src/pages/warningManage/index.vue

@@ -0,0 +1,106 @@
1
+<template>
2
+  <home-container :customStyle="{ background: 'none', backgroundColor: '#f5f5f5' }">
3
+    <view class="profile-manage-container">
4
+      <view class="grid-body">
5
+        <uni-section title="预警管理" type="line" class="uni-section-custom"></uni-section>
6
+        <uni-grid :column="3" :showBorder="false">
7
+          <uni-grid-item v-for="(item, index) in items" :key="index">
8
+            <view class="grid-item-box" @click="handleGridClick(item.appUrl)">
9
+              <img alt="" :src="item.workbenchIcon">
10
+              <text class="text">{{ item.appName }}</text>
11
+            </view>
12
+          </uni-grid-item>
13
+        </uni-grid>
14
+      </view>
15
+    </view>
16
+  </home-container>
17
+</template>
18
+
19
+<script>
20
+import HomeContainer from '@/components/HomeContainer.vue'
21
+import { getAppList } from '@/api/system/user'
22
+import { warningAppNames } from "@/utils/common.js";
23
+
24
+
25
+
26
+export default {
27
+  components: { HomeContainer },
28
+  data() {
29
+    return {
30
+      appList: []
31
+    }
32
+  },
33
+  computed: {
34
+    items() {
35
+      return this.appList.filter(item => warningAppNames.includes(item.appName))
36
+    }
37
+  },
38
+  onLoad() {
39
+    this.loadAppList()
40
+  },
41
+  methods: {
42
+    async loadAppList() {
43
+      try {
44
+        const res = await getAppList({ homePage: 0 })
45
+        if (res.code === 200) {
46
+          this.appList = res.data || []
47
+        }
48
+      } catch (error) {
49
+        console.error('获取画像应用列表失败:', error)
50
+        this.appList = []
51
+      }
52
+    },
53
+    handleGridClick(url) {
54
+      if (!url) return
55
+      uni.navigateTo({ url })
56
+    }
57
+  }
58
+}
59
+</script>
60
+
61
+<style lang="scss" scoped>
62
+.profile-manage-container {
63
+  padding: 20rpx;
64
+  background-color: #f5f5f5;
65
+}
66
+
67
+.grid-body {
68
+  background-color: #fff;
69
+  border-radius: 16rpx;
70
+  padding: 20rpx;
71
+  box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
72
+
73
+  .uni-section-custom {
74
+    ::v-deep .uni-section-header {
75
+      padding: 12rpx 10rpx !important;
76
+    }
77
+  }
78
+}
79
+
80
+.grid-item-box {
81
+  flex: 1;
82
+  display: flex;
83
+  flex-direction: column;
84
+  align-items: center;
85
+  justify-content: center;
86
+  padding: 30rpx 0;
87
+
88
+  img {
89
+    display: inline-block;
90
+    width: 88rpx;
91
+    height: 88rpx;
92
+  }
93
+
94
+  &:active {
95
+    background-color: #f5f5f5;
96
+    border-radius: 12rpx;
97
+  }
98
+}
99
+
100
+.text {
101
+  text-align: center;
102
+  font-size: 25rpx;
103
+  margin-top: 15rpx;
104
+  color: #333;
105
+}
106
+</style>

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

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

+ 11 - 12
src/pages/work/index.vue

@@ -34,14 +34,15 @@
34 34
       </view>
35 35
     </div>
36 36
 
37
-   
37
+
38 38
   </home-container>
39 39
 </template>
40 40
 <script>
41 41
 import HomeContainer from "@/components/HomeContainer.vue";
42 42
 import { checkRolePermission } from "@/utils/common.js";
43
-import { getUserProfile, getAppListByRoleId,getAppList } from "@/api/system/user";
44
-const profileAppNames = ['全站综合信息', '部门画像', '班组画像', '小组画像', '员工画像']
43
+import { getUserProfile, getAppListByRoleId, getAppList } from "@/api/system/user";
44
+import { profileAppNames, warningAppNames } from "@/utils/common.js";
45
+
45 46
 export default {
46 47
   components: { HomeContainer },
47 48
   data() {
@@ -55,7 +56,7 @@ export default {
55 56
       return this.$store?.state?.user?.roles[0]
56 57
     },
57 58
     items() {
58
-      return this.appList.filter(item => !profileAppNames.includes(item.appName))
59
+      return this.appList.filter(item => !profileAppNames.includes(item.appName) && !warningAppNames.includes(item.appName))
59 60
     }
60 61
   },
61 62
   onShow() {
@@ -74,11 +75,11 @@ export default {
74 75
 
75 76
       try {
76 77
         // if (roleId) {
77
-          const res = await getAppList({homePage:0});
78
-          if (res.code === 200) {
79
-            //全部应用
80
-            this.appList = res.data;
81
-          }
78
+        const res = await getAppList({ homePage: 0 });
79
+        if (res.code === 200) {
80
+          //全部应用
81
+          this.appList = res.data;
82
+        }
82 83
         // }
83 84
       } catch (error) {
84 85
         console.error('获取应用列表失败:', error);
@@ -95,7 +96,7 @@ export default {
95 96
     updateCurrentDate() {
96 97
       this.currentDate = this.formatDate(new Date());
97 98
     },
98
-   
99
+
99 100
     handleGridClick(url) {
100 101
       console.log('点击了宫格:', url, this.role);
101 102
       uni.navigateTo({
@@ -221,6 +222,4 @@ export default {
221 222
   font-size: 24rpx;
222 223
   color: #999;
223 224
 }
224
-
225
-
226 225
 </style>

+ 3 - 0
src/utils/common.js

@@ -526,3 +526,6 @@ export function checkRolePermission(role, path) {
526 526
 
527 527
 
528 528
 
529
+export const profileAppNames = ['全站综合信息', '部门画像', '班组画像', '小组画像', '员工画像', '部门仪表盘'];
530
+
531
+export const warningAppNames = ['综合预警信息', '员工维度明细', '红线指标预警'];