index.vue 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702
  1. <template>
  2. <div class="dashboard">
  3. <div class="header">
  4. <div class="title-section">
  5. <h1><i class="fas fa-shield-alt" style="color:#2c5282; margin-right: 8px;"></i>综合预警工作台</h1>
  6. <p>员工综合评估(<75分红色预警 | ≥90分优秀)</p>
  7. </div>
  8. <div class="badge-group">
  9. <div class="alert-badge"><i class="fas fa-exclamation-triangle"></i> 实时预警</div>
  10. <div class="alert-badge" style="border-left-color:#f97316;"><i class="fas fa-chart-line"></i> 动态月度数据
  11. </div>
  12. </div>
  13. </div>
  14. <div class="filter-bar">
  15. <div class="time-range">
  16. <button class="time-btn" :class="{ active: activeRange === 'week' }"
  17. @click="setActiveRange('week')">近一周</button>
  18. <button class="time-btn" :class="{ active: activeRange === 'month' }"
  19. @click="setActiveRange('month')">近一月</button>
  20. <button class="time-btn" :class="{ active: activeRange === 'quarter' }"
  21. @click="setActiveRange('quarter')">近三月</button>
  22. <button class="time-btn" :class="{ active: activeRange === 'year' }"
  23. @click="setActiveRange('year')">近一年</button>
  24. <div class="custom-date">
  25. <el-date-picker v-model="startDate" type="date" placeholder="开始日期" style="width: 150px;" />
  26. <span style="margin: 0 8px;">至</span>
  27. <el-date-picker v-model="endDate" type="date" placeholder="结束日期" style="width: 150px;" />
  28. </div>
  29. <el-tree-select v-model="selectedOrg" :data="cascadeOptions"
  30. :props="{ label: 'label', value: 'value', children: 'children' }" node-key="value"
  31. placeholder="组织架构/员工" clearable filterable check-strictly style="width: 480px;" />
  32. </div>
  33. <div class="filter-group">
  34. <el-select v-model="selectedAlertLevel" placeholder="预警等级" clearable style="width: 250px;">
  35. <el-option label="全部" value=""></el-option>
  36. <el-option v-for="item in alert_level" :key="item.value" :label="item.label" :value="item.value" />
  37. </el-select>
  38. <el-button type="primary" @click="handleSearch">搜索</el-button>
  39. <el-button @click="handleReset">重置</el-button>
  40. </div>
  41. </div>
  42. <div class="employee-section">
  43. <div class="section-title">
  44. <i class="fas fa-users" style="color:#dc2626;"></i> 员工综合预警
  45. <span
  46. style="font-size:0.7rem; background:#eef2ff; padding:4px 12px; border-radius:30px; margin-left:12px;">
  47. 评分依据:员工配分表
  48. </span>
  49. </div>
  50. <div class="employee-card">
  51. <div style="overflow-x: auto;">
  52. <el-table :data="filteredEmployees" :row-class-name="getRowClass" style="width:100%;" border stripe>
  53. <el-table-column prop="userId" label="员工ID" />
  54. <el-table-column prop="nickName" label="姓名">
  55. <template #default="{ row }">
  56. <strong>{{ row.nickName }}</strong>
  57. </template>
  58. </el-table-column>
  59. <el-table-column prop="deptName" label="所属部门" />
  60. <el-table-column prop="eventTime" label="事件时间" sortable width="180" />
  61. <el-table-column prop="dimensionName" label="维度名称" />
  62. <el-table-column prop="level2Name" label="二级指标名称" />
  63. <el-table-column prop="totalScore" label="扣分分值合计" sortable />
  64. <el-table-column prop="occurrenceCount" label="发生次数" sortable />
  65. <el-table-column prop="warningLevel" label="预警等级">
  66. <template #default="{ row }">
  67. <span v-if="row.warningLevel == '1'" style="color:#b91c1c;" class="status-badge">{{
  68. getAlertLabel(row.warningLevel)
  69. }}</span>
  70. <span v-else class="status-excellent"><i class="fas fa-star"></i>
  71. {{ getAlertLabel(row.warningLevel) }}</span>
  72. </template>
  73. </el-table-column>
  74. <el-table-column prop="coreRisks" label="核心风险" />
  75. <el-table-column prop="statusLabel" label="状态标签">
  76. <template #default="{ row }">
  77. <span v-if="row.statusLabel == '1'" style="color:#b91c1c;"><i class="fas fa-bell"></i>
  78. {{ getStatusLabel(row.statusLabel) }}</span>
  79. <span v-else><i class="fas fa-crown"></i> {{ getStatusLabel(row.statusLabel) }}</span>
  80. </template>
  81. </el-table-column>
  82. </el-table>
  83. <el-pagination v-show="total > 0" :total="total" v-model:current-page="queryParams.pageNum"
  84. v-model:page-size="queryParams.pageSize" :page-sizes="[10, 20, 50, 100]"
  85. layout="total, sizes, prev, pager, next, jumper" @size-change="handleSizeChange"
  86. @current-change="handlePageChange" style="margin-top: 16px; justify-content: flex-end;" />
  87. </div>
  88. <div class="warning-summary">
  89. <div><i class="fas fa-circle" style="color:#ef4444;"></i> <strong>红色预警</strong> (综合评估 &lt; 75分) 共计
  90. {{ redAlertCount }} 人 → 立即约谈/培训</div>
  91. <div><i class="fas fa-circle" style="color:#22c55e;"></i> <strong>优秀标杆</strong> (综合评估 ≥ 90分) 共计 {{
  92. excellentCount }} 人 → 表彰激励</div>
  93. <div><i class="fas fa-chart-simple"></i> 全员平均综合得分: <strong>{{ avgScore }}</strong> 分</div>
  94. </div>
  95. </div>
  96. </div>
  97. <footer>
  98. <i class="far fa-bell"></i> 员工部门:旅检一/二/三部 | 阈值:<75红色预警 | ≥90优秀
  99. </footer>
  100. </div>
  101. </template>
  102. <script setup>
  103. import { ref, computed, reactive, onMounted, watch } from 'vue'
  104. import { getDeptUserTree } from '@/api/item/items'
  105. import { getRedLineWarningPageData } from '@/api/warningManage/redLineWarning'
  106. import { useDict } from '@/utils/dict'
  107. import { useRoute } from 'vue-router'
  108. const route = useRoute()
  109. const { alert_level, status_label } = useDict('alert_level', 'status_label')
  110. const dateRangeInput = ref(null)
  111. const activeRange = ref('month')
  112. const startDate = ref(null)
  113. const endDate = ref(null)
  114. const selectedAlertLevel = ref('')
  115. const selectedOrg = ref('')
  116. const cascadeOptions = ref([])
  117. const newNames = [
  118. "刘孟", "王苡衡", "周海涵", "何欣怡", "王崎",
  119. "黄政", "刘韵儿", "汪安霖", "张诗敏", "张维", "陈凯琳"
  120. ]
  121. const deptList = ["旅检一部", "旅检二部", "旅检三部"]
  122. const scores = [68, 92, 55, 96, 72, 88, 48, 94, 81, 63, 91]
  123. const coreRisksOrOutstandingAchievementsList = [
  124. "违规操作2次+投诉1起,考试成绩62分",
  125. "优秀服务案例,安保测试满分,无违规",
  126. "安保测试未通过,不安全事件责任人",
  127. "典型服务案例主导者,考试成绩98",
  128. "质控拦截违规2次,考试成绩74分",
  129. "考试成绩89分,服务巡查良好",
  130. "严重不规范操作,安保测试未过",
  131. "质控拦截贡献突出,考试成绩96",
  132. "表现良好,无安全事故,考试86分",
  133. "服务巡查扣分,投诉关联2件",
  134. "临近预警线,服务巡查扣分1次"
  135. ]
  136. const indicatorNames = ["安全违规次数", "旅客投诉率", "安检差错率", "培训合格率", "操作规范度"]
  137. const deductionScores = [2, 5, 3, 1, 4]
  138. const occurrenceCounts = [1, 2, 3, 1, 2]
  139. const employeesData = ref([])
  140. const queryParams = reactive({
  141. pageNum: 1,
  142. pageSize: 10
  143. })
  144. // 将组织架构数据转换为树形数据
  145. const transformCascadeData = (nodes) => {
  146. if (!nodes) return []
  147. return nodes.map(node => {
  148. const deptType = node.deptType || node.nodeType
  149. const label = deptType === 'user'
  150. ? (node.nickName || node.label)
  151. : (node.deptName || node.name || node.label)
  152. let value;
  153. if (deptType === 'STATION') value = `station_${node.id}`;
  154. else if (deptType === 'BRIGADE') value = `dept_${node.id}`
  155. else if (deptType === 'MANAGER') value = `team_${node.id}`
  156. else if (deptType === 'TEAMS') value = `group_${node.id}`
  157. else value = `user_${node.id}`
  158. const children = node.children && node.children.length > 0 ? transformCascadeData(node.children) : undefined
  159. return {
  160. label,
  161. value,
  162. deptType,
  163. children
  164. }
  165. })
  166. }
  167. // 获取选中的部门或用户信息
  168. const getSelectedInfo = (selectedValue) => {
  169. if (!selectedValue) return null
  170. const findNodeByValue = (nodes, value) => {
  171. for (const node of nodes) {
  172. if (node.value === value) {
  173. return node
  174. }
  175. if (node.children) {
  176. const found = findNodeByValue(node.children, value)
  177. if (found) return found
  178. }
  179. }
  180. return null
  181. }
  182. return findNodeByValue(cascadeOptions.value, selectedValue)
  183. }
  184. const allFilteredEmployees = computed(() => {
  185. let result = employeesData.value || []
  186. // 预警等级筛选
  187. if (selectedAlertLevel.value) {
  188. result = result.filter(emp => emp.warningLevel == selectedAlertLevel.value)
  189. }
  190. return result
  191. })
  192. const filteredEmployees = computed(() => {
  193. const start = (queryParams.pageNum - 1) * queryParams.pageSize
  194. const end = start + queryParams.pageSize
  195. return allFilteredEmployees.value.slice(start, end)
  196. })
  197. const total = computed(() => allFilteredEmployees.value.length)
  198. function handlePageChange(newPage) {
  199. queryParams.pageNum = newPage
  200. }
  201. function handleSizeChange(newSize) {
  202. queryParams.pageSize = newSize
  203. queryParams.pageNum = 1
  204. }
  205. const redAlertCount = computed(() => {
  206. return employeesData.value?.redAlertNum || 0
  207. })
  208. const excellentCount = computed(() => {
  209. return employeesData.value?.excellentBenchmarkNum || 0
  210. })
  211. const avgScore = computed(() => {
  212. return employeesData.value?.averageComprehensiveScore || 0
  213. })
  214. const getRowClass = ({ row }) => {
  215. if (row.overallScore < 75) return "employee-warning-row"
  216. if (row.overallScore >= 90) return "employee-excellent-row"
  217. return ""
  218. }
  219. const getAlertLabel = (value) => {
  220. if (!value) return ''
  221. const item = alert_level.value.find(d => d.value === value)
  222. return item ? item.label : value
  223. }
  224. const getStatusLabel = (value) => {
  225. if (!value) return ''
  226. const item = status_label.value.find(d => d.value === value)
  227. return item ? item.label : value
  228. }
  229. const setActiveRange = (range) => {
  230. activeRange.value = range
  231. // 清除自定义日期
  232. if (range !== 'custom') {
  233. startDate.value = null
  234. endDate.value = null
  235. }
  236. }
  237. const handleSearch = () => {
  238. fetchWarningData()
  239. }
  240. const handleReset = () => {
  241. startDate.value = null
  242. endDate.value = null
  243. activeRange.value = 'month'
  244. selectedAlertLevel.value = ''
  245. selectedOrg.value = ''
  246. queryParams.pageNum = 1
  247. fetchWarningData()
  248. }
  249. const formatDate = (d) => {
  250. if (!d) return ''
  251. const date = new Date(d)
  252. const y = date.getFullYear()
  253. const m = String(date.getMonth() + 1).padStart(2, '0')
  254. const day = String(date.getDate()).padStart(2, '0')
  255. return `${y}-${m}-${day}`
  256. }
  257. const getDateRangeFromActive = () => {
  258. const now = new Date()
  259. let start = new Date(now)
  260. if (activeRange.value === 'week') {
  261. start.setDate(now.getDate() - 7)
  262. } else if (activeRange.value === 'month') {
  263. start.setMonth(now.getMonth() - 1)
  264. } else if (activeRange.value === 'quarter') {
  265. start.setMonth(now.getMonth() - 3)
  266. } else {
  267. start.setFullYear(now.getFullYear() - 1)
  268. }
  269. return { startDate: formatDate(start), endDate: formatDate(now) }
  270. }
  271. const fetchWarningData = async () => {
  272. queryParams.pageNum = 1
  273. let params = {}
  274. if (startDate.value && endDate.value) {
  275. params.startDate = formatDate(startDate.value)
  276. params.endDate = formatDate(endDate.value)
  277. } else {
  278. const range = getDateRangeFromActive()
  279. params.startDate = range.startDate
  280. params.endDate = range.endDate
  281. }
  282. // cascadeOptions 未加载完成时跳过组织筛选,onMounted 加载完后会再调一次
  283. if (cascadeOptions.value.length > 0 && selectedOrg.value) {
  284. const selectedInfo = getSelectedInfo(selectedOrg.value)
  285. if (selectedInfo) {
  286. const rawId = Number(selectedInfo.value.split('_')[1])
  287. if (selectedInfo.deptType === 'BRIGADE') params.deptId = rawId
  288. else if (selectedInfo.deptType === 'MANAGER') params.teamId = rawId
  289. else if (selectedInfo.deptType === 'TEAMS') params.groupId = rawId
  290. else if (selectedInfo.deptType === 'user') params.userId = rawId
  291. }
  292. }
  293. try {
  294. const r1 = await getRedLineWarningPageData(params)
  295. if (r1.data) {
  296. employeesData.value = r1.data
  297. }
  298. } catch (error) {
  299. console.error('获取预警数据失败:', error)
  300. }
  301. }
  302. // 从路由参数回显查询条件
  303. const applyRouteQuery = (query) => {
  304. const { id, startDate: sd, endDate: ed, activeRange: ar, alertLevel, org } = query
  305. if (id) {
  306. selectedOrg.value = `user_${id}`
  307. } else {
  308. selectedOrg.value = ''
  309. }
  310. if (sd && ed) {
  311. startDate.value = sd
  312. endDate.value = ed
  313. activeRange.value = 'custom'
  314. } else {
  315. startDate.value = null
  316. endDate.value = null
  317. activeRange.value = ar || 'month'
  318. }
  319. selectedAlertLevel.value = alertLevel || ''
  320. queryParams.pageNum = 1
  321. }
  322. onMounted(async () => {
  323. // 先回显路由参数
  324. applyRouteQuery(route.query)
  325. // 再加载组织架构树
  326. try {
  327. const res = await getDeptUserTree()
  328. if (res.data) {
  329. cascadeOptions.value = transformCascadeData(res.data)
  330. }
  331. } catch (error) {
  332. console.error('获取组织架构数据失败:', error)
  333. }
  334. // 最后查询(此时 cascadeOptions 已就绪,不会重复请求)
  335. fetchWarningData()
  336. })
  337. // 监听后续路由参数变化
  338. watch(() => route.query, (query) => {
  339. applyRouteQuery(query)
  340. fetchWarningData()
  341. })
  342. </script>
  343. <style scoped>
  344. @keyframes subtlePulse {
  345. 0% {
  346. opacity: 0.7;
  347. }
  348. 50% {
  349. opacity: 1;
  350. background: #ffb3b3;
  351. }
  352. 100% {
  353. opacity: 0.7;
  354. }
  355. }
  356. .dashboard {
  357. max-width: 100%;
  358. padding: 10px;
  359. }
  360. .header {
  361. margin-bottom: 28px;
  362. display: flex;
  363. justify-content: space-between;
  364. align-items: flex-end;
  365. flex-wrap: wrap;
  366. gap: 16px;
  367. }
  368. .title-section h1 {
  369. font-size: 1.7rem;
  370. font-weight: 700;
  371. background: linear-gradient(135deg, #1e3c72, #2a5298);
  372. -webkit-background-clip: text;
  373. background-clip: text;
  374. color: transparent;
  375. letter-spacing: -0.3px;
  376. }
  377. .title-section p {
  378. color: #475569;
  379. margin-top: 6px;
  380. font-size: 0.85rem;
  381. }
  382. .badge-group {
  383. display: flex;
  384. gap: 12px;
  385. }
  386. .alert-badge {
  387. background: #fff1f0;
  388. border-left: 5px solid #ef4444;
  389. padding: 6px 16px;
  390. border-radius: 40px;
  391. font-weight: 600;
  392. font-size: 0.85rem;
  393. }
  394. .alert-badge i {
  395. color: #ef4444;
  396. margin-right: 6px;
  397. }
  398. .filter-bar {
  399. background: white;
  400. border-radius: 60px;
  401. padding: 8px 20px;
  402. margin-bottom: 10px;
  403. display: flex;
  404. flex-wrap: wrap;
  405. align-items: center;
  406. justify-content: space-between;
  407. gap: 16px;
  408. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.02), 0 1px 2px rgba(0, 0, 0, 0.03);
  409. border: 1px solid #eef2f6;
  410. }
  411. .time-range {
  412. display: flex;
  413. gap: 8px;
  414. align-items: center;
  415. flex-wrap: wrap;
  416. }
  417. .time-btn {
  418. background: #f8fafc;
  419. border: 1px solid #e2e8f0;
  420. padding: 6px 18px;
  421. border-radius: 40px;
  422. font-size: 0.8rem;
  423. font-weight: 500;
  424. cursor: pointer;
  425. transition: all 0.2s;
  426. color: #1e293b;
  427. }
  428. .time-btn.active {
  429. background: #2563eb;
  430. border-color: #2563eb;
  431. color: white;
  432. }
  433. .time-btn:hover {
  434. background: #e2e8f0;
  435. }
  436. .custom-date {
  437. display: flex;
  438. align-items: center;
  439. gap: 8px;
  440. }
  441. .clear-btn {
  442. background: transparent;
  443. border: 1px solid #e2e8f0;
  444. padding: 4px 8px;
  445. border-radius: 40px;
  446. cursor: pointer;
  447. color: #64748b;
  448. }
  449. .clear-btn:hover {
  450. background: #f1f5f9;
  451. }
  452. .custom-date :deep(.el-date-picker) {
  453. background: transparent;
  454. }
  455. .custom-date :deep(.el-input__wrapper) {
  456. background: transparent;
  457. }
  458. .filter-group {
  459. display: flex;
  460. gap: 12px;
  461. align-items: center;
  462. }
  463. .search-wrapper {
  464. display: flex;
  465. align-items: center;
  466. gap: 8px;
  467. background: #f8fafc;
  468. padding: 4px 12px;
  469. border-radius: 40px;
  470. border: 1px solid #e2e8f0;
  471. }
  472. .search-btn {
  473. background: #2563eb;
  474. border: none;
  475. color: white;
  476. padding: 4px 14px;
  477. border-radius: 30px;
  478. font-size: 0.75rem;
  479. font-weight: 500;
  480. cursor: pointer;
  481. transition: 0.2s;
  482. }
  483. .search-btn:hover {
  484. background: #1d4ed8;
  485. }
  486. .employee-section {
  487. margin-top: 20px;
  488. width: 100%;
  489. }
  490. .section-title {
  491. font-size: 1.2rem;
  492. font-weight: 700;
  493. margin-bottom: 1rem;
  494. display: flex;
  495. align-items: center;
  496. gap: 8px;
  497. border-left: 5px solid #ef4444;
  498. padding-left: 14px;
  499. }
  500. .employee-card {
  501. background: #ffffff;
  502. border-radius: 24px;
  503. box-shadow: 0 4px 12px rgba(0, 0, 0, 0.03), 0 1px 2px rgba(0, 0, 0, 0.05);
  504. border: 1px solid #eef2f6;
  505. padding: 1rem 1.5rem;
  506. width: 100%;
  507. }
  508. .data-table {
  509. width: 100%;
  510. border-collapse: collapse;
  511. font-size: 0.8rem;
  512. }
  513. .data-table th {
  514. text-align: left;
  515. padding: 10px 6px 8px 0;
  516. font-weight: 600;
  517. color: #334155;
  518. border-bottom: 2px solid #e2e8f0;
  519. }
  520. .data-table td {
  521. padding: 8px 6px 8px 0;
  522. border-bottom: 1px solid #f1f5f9;
  523. vertical-align: middle;
  524. }
  525. .employee-warning-row {
  526. background-color: #fff5f5;
  527. border-left: 4px solid #ef4444;
  528. }
  529. .employee-excellent-row {
  530. background-color: #f0fdf4;
  531. border-left: 4px solid #22c55e;
  532. }
  533. .score-danger {
  534. font-weight: 800;
  535. color: #dc2626;
  536. background: #fee2e2;
  537. padding: 2px 8px;
  538. border-radius: 30px;
  539. display: inline-block;
  540. font-size: 0.8rem;
  541. }
  542. .score-excellent {
  543. font-weight: 800;
  544. color: #15803d;
  545. background: #dcfce7;
  546. padding: 2px 8px;
  547. border-radius: 30px;
  548. display: inline-block;
  549. font-size: 0.8rem;
  550. }
  551. .warning-summary {
  552. background: #fff9f0;
  553. border-radius: 18px;
  554. padding: 12px 20px;
  555. margin-top: 18px;
  556. display: flex;
  557. gap: 28px;
  558. flex-wrap: wrap;
  559. font-weight: 500;
  560. font-size: 0.85rem;
  561. }
  562. footer {
  563. text-align: center;
  564. margin-top: 32px;
  565. font-size: 0.7rem;
  566. color: #7e8b9c;
  567. border-top: 1px solid #e2edf7;
  568. padding-top: 18px;
  569. }
  570. @media (max-width: 1200px) {
  571. .cards-grid {
  572. grid-template-columns: repeat(4, 1fr);
  573. gap: 14px;
  574. }
  575. }
  576. @media (max-width: 800px) {
  577. .cards-grid {
  578. grid-template-columns: repeat(2, 1fr);
  579. }
  580. body {
  581. padding: 20px;
  582. }
  583. .filter-bar {
  584. border-radius: 24px;
  585. flex-direction: column;
  586. align-items: stretch;
  587. }
  588. .org-search {
  589. justify-content: space-between;
  590. }
  591. }
  592. @media (max-width: 550px) {
  593. .cards-grid {
  594. grid-template-columns: 1fr;
  595. }
  596. }
  597. </style>