Pārlūkot izejas kodu

feat(warningManage): 新增员工维度评分明细页面

- 添加员工维度评分明细API接口封装
- 实现时间范围快速选择及自定义日期控件
- 支持组织架构/员工及维度筛选功能
- 展示分页表格,包含员工基本信息及维度分数
- 实现分页切换与页面大小调整功能
- 加载并转换组织架构树结构数据用于筛选
- 根据路由参数动态设置筛选条件与数据加载
- 增加页面布局和样式,提升用户体验
huoyi 17 stundas atpakaļ
vecāks
revīzija
c8387cd19e

+ 6 - 0
src/api/warningManage/employeeDimensionDetails.js

@@ -0,0 +1,6 @@
1
+import request from '@/utils/request'
2
+
3
+// 员工维度评分明细
4
+export function getEmployeeDimensionPageData(data) {
5
+    return request({ url: '/ledger/warning/employeeDimensionDetail', method: 'post', data })
6
+}

+ 344 - 0
src/views/warningManage/employeeDimensionDetails/index.vue

@@ -0,0 +1,344 @@
1
+<template>
2
+    <div class="page-container">
3
+        <h1 class="page-title">员工维度评分明细</h1>
4
+
5
+        <div class="filter-bar">
6
+            <div class="filter-left">
7
+                <button class="time-btn" :class="{ active: activeRange === 'week' }"
8
+                    @click="setActiveRange('week')">近一周</button>
9
+                <button class="time-btn" :class="{ active: activeRange === 'month' }"
10
+                    @click="setActiveRange('month')">近一月</button>
11
+                <button class="time-btn" :class="{ active: activeRange === 'quarter' }"
12
+                    @click="setActiveRange('quarter')">近三月</button>
13
+                <button class="time-btn" :class="{ active: activeRange === 'year' }"
14
+                    @click="setActiveRange('year')">近一年</button>
15
+                <el-date-picker v-model="startDate" type="date" placeholder="开始日期" style="width: 150px;" />
16
+                <span style="margin: 0 8px;">至</span>
17
+                <el-date-picker v-model="endDate" type="date" placeholder="结束日期" style="width: 150px;" />
18
+                <el-tree-select v-model="selectedOrg" :data="cascadeOptions"
19
+                    :props="{ label: 'label', value: 'value', children: 'children' }" node-key="value"
20
+                    placeholder="组织架构/员工" clearable filterable check-strictly style="width: 200px;" />
21
+                <el-select v-model="selectedDimension" placeholder="维度" clearable style="width: 150px;">
22
+                    <el-option v-for="item in dimensionOptions" :key="item.value" :label="item.label"
23
+                        :value="item.value" />
24
+                </el-select>
25
+            </div>
26
+            <div class="filter-right">
27
+                <el-button type="primary" @click="handleSearch">搜索</el-button>
28
+                <el-button @click="handleReset">重置</el-button>
29
+            </div>
30
+        </div>
31
+
32
+        <div class="table-section">
33
+            <div class="section-title">
34
+                <span class="title-text">员工维度评分明细</span>
35
+                <span class="title-badge">评分依据: 员工配分表</span>
36
+            </div>
37
+            <el-table :data="tableData" style="width: 100%;" border stripe>
38
+                <el-table-column prop="userId" label="员工ID" />
39
+                <el-table-column prop="nickName" label="姓名" />
40
+                <el-table-column prop="deptName" label="部门" />
41
+                <el-table-column prop="teamName" label="班组" />
42
+                <el-table-column prop="groupName" label="小组" />
43
+                <el-table-column prop="dimName" label="维度名称" />
44
+                <el-table-column prop="dimScore" label="维度分值" />
45
+            </el-table>
46
+            <el-pagination v-show="total > 0" :total="total" v-model:current-page="queryParams.pageNum"
47
+                v-model:page-size="queryParams.pageSize" :page-sizes="[10, 20, 50, 100]"
48
+                layout="total, sizes, prev, pager, next, jumper" @size-change="handleSizeChange"
49
+                @current-change="handlePageChange" style="margin-top: 16px; justify-content: flex-end;" />
50
+        </div>
51
+    </div>
52
+</template>
53
+
54
+<script setup>
55
+import { ref, reactive, onMounted, watch } from 'vue'
56
+import { useRoute } from 'vue-router'
57
+import { getDeptUserTree } from '@/api/item/items'
58
+import { getEmployeeDimensionPageData } from '@/api/warningManage/employeeDimensionDetails'
59
+
60
+const route = useRoute()
61
+
62
+const activeRange = ref('month')
63
+const startDate = ref(null)
64
+const endDate = ref(null)
65
+const selectedOrg = ref('')
66
+const selectedDimension = ref('')
67
+const cascadeOptions = ref([])
68
+
69
+const dimensionOptions = ref([
70
+    { label: '安全响应能力', value: '安全响应能力' },
71
+    { label: '服务响应能力', value: '服务响应能力' }
72
+])
73
+
74
+const tableData = ref([
75
+    { userId: 397, nickName: '胡婷婷', deptName: '旅检一部', teamName: '安平班组', groupName: '陈行小组', dimName: '安全响应能力', dimScore: 80.5 },
76
+    { userId: 437, nickName: '李璐', deptName: '旅检一部', teamName: '安平班组', groupName: '陈行小组', dimName: '服务响应能力', dimScore: 79.2 },
77
+    { userId: 564, nickName: '徐倩', deptName: '旅检一部', teamName: '安平班组', groupName: '陈行小组', dimName: '安全响应能力', dimScore: 81.3 },
78
+    { userId: 587, nickName: '余杭洋', deptName: '旅检一部', teamName: '安平班组', groupName: '陈行小组', dimName: '服务响应能力', dimScore: 78.6 },
79
+    { userId: 550, nickName: '肖垚', deptName: '旅检一部', teamName: '安平班组', groupName: '陈行小组', dimName: '安全响应能力', dimScore: 80.1 },
80
+    { userId: 107, nickName: '刘珊', deptName: '旅检三部', teamName: '安平班组', groupName: '陈行小组', dimName: '服务响应能力', dimScore: 79.8 },
81
+    { userId: 191, nickName: '王佐朝', deptName: '旅检三部', teamName: '安平班组', groupName: '陈行小组', dimName: '安全响应能力', dimScore: 82.0 },
82
+    { userId: 250, nickName: '张元媛', deptName: '旅检三部', teamName: '安平班组', groupName: '陈行小组', dimName: '服务响应能力', dimScore: 78.9 },
83
+    { userId: 293, nickName: '陈凯琳', deptName: '旅检三部', teamName: '安平班组', groupName: '陈行小组', dimName: '安全响应能力', dimScore: 80.7 },
84
+    { userId: 603, nickName: '张霞', deptName: '旅检一部', teamName: '安平班组', groupName: '陈行小组', dimName: '服务响应能力', dimScore: 79.5 }
85
+])
86
+const total = ref(972)
87
+const queryParams = reactive({
88
+    pageNum: 1,
89
+    pageSize: 10
90
+})
91
+
92
+const transformCascadeData = (nodes) => {
93
+    if (!nodes) return []
94
+    return nodes.map(node => {
95
+        const deptType = node.deptType || node.nodeType
96
+        const label = deptType === 'user'
97
+            ? (node.nickName || node.label)
98
+            : (node.deptName || node.name || node.label)
99
+
100
+        let value;
101
+        if (deptType === 'STATION') value = `station_${node.id}`;
102
+        else if (deptType === 'BRIGADE') value = `dept_${node.id}`
103
+        else if (deptType === 'MANAGER') value = `team_${node.id}`
104
+        else if (deptType === 'TEAMS') value = `group_${node.id}`
105
+        else value = `user_${node.id}`
106
+
107
+        const children = node.children && node.children.length > 0 ? transformCascadeData(node.children) : undefined
108
+
109
+        return { label, value, deptType, children }
110
+    })
111
+}
112
+
113
+const getSelectedInfo = (selectedValue) => {
114
+    if (!selectedValue) return null
115
+    const findNodeByValue = (nodes, value) => {
116
+        for (const node of nodes) {
117
+            if (node.value === value) return node
118
+            if (node.children) {
119
+                const found = findNodeByValue(node.children, value)
120
+                if (found) return found
121
+            }
122
+        }
123
+        return null
124
+    }
125
+    return findNodeByValue(cascadeOptions.value, selectedValue)
126
+}
127
+
128
+const formatDate = (d) => {
129
+    if (!d) return ''
130
+    const date = new Date(d)
131
+    const y = date.getFullYear()
132
+    const m = String(date.getMonth() + 1).padStart(2, '0')
133
+    const day = String(date.getDate()).padStart(2, '0')
134
+    return `${y}-${m}-${day}`
135
+}
136
+
137
+const getDateRangeFromActive = () => {
138
+    const now = new Date()
139
+    let start = new Date(now)
140
+    if (activeRange.value === 'week') start.setDate(now.getDate() - 7)
141
+    else if (activeRange.value === 'month') start.setMonth(now.getMonth() - 1)
142
+    else if (activeRange.value === 'quarter') start.setMonth(now.getMonth() - 3)
143
+    else start.setFullYear(now.getFullYear() - 1)
144
+    return { startDate: formatDate(start), endDate: formatDate(now) }
145
+}
146
+
147
+const setActiveRange = (range) => {
148
+    activeRange.value = range
149
+    if (range !== 'custom') {
150
+        startDate.value = null
151
+        endDate.value = null
152
+    }
153
+}
154
+
155
+const fetchDimensionData = async () => {
156
+    // TODO: 接入真实接口
157
+    // let params = {
158
+    //     pageNum: queryParams.pageNum,
159
+    //     pageSize: queryParams.pageSize
160
+    // }
161
+    // if (startDate.value && endDate.value) {
162
+    //     params.startDate = formatDate(startDate.value)
163
+    //     params.endDate = formatDate(endDate.value)
164
+    // } else {
165
+    //     const range = getDateRangeFromActive()
166
+    //     params.startDate = range.startDate
167
+    //     params.endDate = range.endDate
168
+    // }
169
+    // const selectedInfo = getSelectedInfo(selectedOrg.value)
170
+    // if (selectedInfo) {
171
+    //     const rawId = Number(selectedInfo.value.split('_')[1])
172
+    //     if (selectedInfo.deptType === 'BRIGADE') params.deptId = rawId
173
+    //     else if (selectedInfo.deptType === 'MANAGER') params.teamId = rawId
174
+    //     else if (selectedInfo.deptType === 'TEAMS') params.groupId = rawId
175
+    //     else if (selectedInfo.deptType === 'user') params.userId = rawId
176
+    // }
177
+    // if (selectedDimension.value) {
178
+    //     params.dimName = selectedDimension.value
179
+    // }
180
+    // try {
181
+    //     const res = await getEmployeeDimensionPageData(params)
182
+    //     if (res.data) {
183
+    //         tableData.value = res.data.rows || res.data || []
184
+    //         total.value = res.total || res.data?.total || 0
185
+    //     }
186
+    // } catch (error) {
187
+    //     console.error('获取员工维度评分明细失败:', error)
188
+    // }
189
+}
190
+
191
+const handleSearch = () => {
192
+    queryParams.pageNum = 1
193
+    fetchDimensionData()
194
+}
195
+
196
+const handleReset = () => {
197
+    startDate.value = null
198
+    endDate.value = null
199
+    activeRange.value = 'month'
200
+    selectedOrg.value = ''
201
+    selectedDimension.value = ''
202
+    queryParams.pageNum = 1
203
+    fetchDimensionData()
204
+}
205
+
206
+const handlePageChange = (newPage) => {
207
+    queryParams.pageNum = newPage
208
+    fetchDimensionData()
209
+}
210
+
211
+const handleSizeChange = (newSize) => {
212
+    queryParams.pageSize = newSize
213
+    queryParams.pageNum = 1
214
+    fetchDimensionData()
215
+}
216
+
217
+onMounted(async () => {
218
+    try {
219
+        const res = await getDeptUserTree()
220
+        if (res.data) {
221
+            cascadeOptions.value = transformCascadeData(res.data)
222
+        }
223
+    } catch (error) {
224
+        console.error('获取组织架构数据失败:', error)
225
+    }
226
+    fetchDimensionData()
227
+})
228
+
229
+watch(() => route.query, (query) => {
230
+    const { id, org, startDate: sd, endDate: ed, activeRange: ar, dimension } = query
231
+    if (id) {
232
+        selectedOrg.value = `user_${id}`
233
+    } else if (org) {
234
+        selectedOrg.value = org
235
+    } else {
236
+        selectedOrg.value = ''
237
+    }
238
+    if (sd && ed) {
239
+        startDate.value = sd
240
+        endDate.value = ed
241
+        activeRange.value = 'custom'
242
+    } else {
243
+        startDate.value = null
244
+        endDate.value = null
245
+        activeRange.value = ar || 'month'
246
+    }
247
+    if (dimension) {
248
+        selectedDimension.value = dimension
249
+    }
250
+    queryParams.pageNum = 1
251
+    fetchDimensionData()
252
+}, { immediate: true })
253
+</script>
254
+
255
+<style scoped>
256
+.page-container {
257
+    padding: 20px;
258
+    background: #f5f7fa;
259
+    min-height: calc(100vh - 90px);
260
+}
261
+
262
+.page-title {
263
+    font-size: 1.6rem;
264
+    font-weight: 700;
265
+    color: #1e3c72;
266
+    margin: 0 0 16px 0;
267
+}
268
+
269
+.filter-bar {
270
+    background: #fff;
271
+    border-radius: 12px;
272
+    padding: 12px 20px;
273
+    margin-bottom: 20px;
274
+    display: flex;
275
+    align-items: center;
276
+    justify-content: space-between;
277
+    gap: 12px;
278
+    flex-wrap: wrap;
279
+    box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
280
+}
281
+
282
+.filter-left {
283
+    display: flex;
284
+    align-items: center;
285
+    gap: 8px;
286
+    flex-wrap: wrap;
287
+}
288
+
289
+.filter-right {
290
+    display: flex;
291
+    align-items: center;
292
+    gap: 8px;
293
+}
294
+
295
+.time-btn {
296
+    background: #f8fafc;
297
+    border: 1px solid #e2e8f0;
298
+    padding: 6px 18px;
299
+    border-radius: 6px;
300
+    font-size: 0.85rem;
301
+    font-weight: 500;
302
+    cursor: pointer;
303
+    transition: all 0.2s;
304
+    color: #1e293b;
305
+}
306
+
307
+.time-btn.active {
308
+    background: #2563eb;
309
+    border-color: #2563eb;
310
+    color: #fff;
311
+}
312
+
313
+.time-btn:hover:not(.active) {
314
+    background: #e2e8f0;
315
+}
316
+
317
+.table-section {
318
+    background: #fff;
319
+    border-radius: 12px;
320
+    padding: 20px;
321
+    box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
322
+}
323
+
324
+.section-title {
325
+    display: flex;
326
+    align-items: center;
327
+    gap: 12px;
328
+    margin-bottom: 16px;
329
+}
330
+
331
+.title-text {
332
+    font-size: 1.1rem;
333
+    font-weight: 700;
334
+    color: #dc2626;
335
+}
336
+
337
+.title-badge {
338
+    font-size: 0.75rem;
339
+    background: #eef2ff;
340
+    color: #475569;
341
+    padding: 3px 12px;
342
+    border-radius: 20px;
343
+}
344
+</style>