index.vue 20 KB

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