index.vue 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838
  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-popover trigger="click" placement="bottom" :width="480" ref="treePopoverRef">
  30. <template #reference>
  31. <el-input v-model="selectedOrgLabel" placeholder="组织架构/员工" clearable style="width: 480px;"
  32. @clear="handleTreeClear" />
  33. </template>
  34. <el-tree
  35. :data="cascadeOptions"
  36. :props="treeProps"
  37. node-key="value"
  38. highlight-current
  39. :current-node-key="selectedOrg"
  40. @node-click="handleTreeNodeClick"
  41. />
  42. </el-popover>
  43. </div>
  44. <div class="filter-group">
  45. <el-select v-model="selectedAlertLevel" placeholder="预警等级" clearable style="width: 250px;">
  46. <el-option label="全部" value=""></el-option>
  47. <el-option v-for="item in alert_level" :key="item.value" :label="item.label" :value="item.value" />
  48. </el-select>
  49. </div>
  50. </div>
  51. <div class="cards-container">
  52. <div class="cards-grid" style="overflow-x: auto; white-space: nowrap;">
  53. <div class="card" v-for="(item, index) in summaryCards" :key="index"
  54. style="display: inline-block; width: 200px; flex-shrink: 0;">
  55. <div class="card-header">
  56. <i :class="item.icon"></i>
  57. <h3>{{ item.title }}</h3>
  58. <span class="card-badge">{{ item.badge }}</span>
  59. </div>
  60. <div class="value-large" :style="{ color: item.color }">{{ item.value }}</div>
  61. </div>
  62. </div>
  63. </div>
  64. <div class="employee-section">
  65. <div class="section-title">
  66. <i class="fas fa-users" style="color:#dc2626;"></i> 员工综合评估预警看板
  67. <span
  68. style="font-size:0.7rem; background:#eef2ff; padding:4px 12px; border-radius:30px; margin-left:12px;">
  69. 评分依据:员工配分表
  70. </span>
  71. </div>
  72. <div class="employee-card">
  73. <div style="overflow-x: auto;">
  74. <table class="data-table" style="width:100%;">
  75. <thead>
  76. <tr>
  77. <th>员工ID</th>
  78. <th>姓名</th>
  79. <th>所属部门</th>
  80. <th>综合评估得分</th>
  81. <th>预警等级</th>
  82. <th>核心风险/优秀事迹</th>
  83. <th>状态标签</th>
  84. </tr>
  85. </thead>
  86. <tbody>
  87. <tr v-for="emp in filteredEmployees" :key="emp.id" :class="getRowClass(emp.overallScore)">
  88. <td>{{ emp.userId }}</td>
  89. <td><strong>{{ emp.nickName }}</strong></td>
  90. <td>{{ emp.deptName }}</td>
  91. <td>
  92. <span v-if="emp.overallScore < 75" class="score-danger">{{ emp.overallScore }}
  93. 分</span>
  94. <span v-else-if="emp.overallScore >= 90" class="score-excellent">{{ emp.overallScore
  95. }}
  96. 分</span>
  97. <span v-else style="font-weight:600;">{{ emp.overallScore }} 分</span>
  98. </td>
  99. <td>
  100. <span v-if="emp.overallScore < 75" class="status-badge"
  101. style="animation: subtlePulse 1s infinite;"><i
  102. class="fas fa-exclamation-triangle"></i> 红色预警</span>
  103. <span v-else-if="emp.overallScore >= 90" class="status-excellent"
  104. style="background:#d1fae5; color:#065f46;"><i class="fas fa-star"></i>
  105. 优秀标杆</span>
  106. <span v-else class="status-warning">正常范围</span>
  107. </td>
  108. <td style="font-size:0.75rem;">{{ emp.coreRisksOrOutstandingAchievements }}</td>
  109. <td>
  110. <span v-if="emp.overallScore < 75" style="color:#b91c1c;"><i
  111. class="fas fa-bell"></i>
  112. {{ getAlertLabel(emp.statusLabel) }}</span>
  113. <span v-else-if="emp.overallScore >= 90" style="color:#15803d;"><i
  114. class="fas fa-crown"></i> {{ getAlertLabel(emp.statusLabel) }}</span>
  115. <span v-else>{{ getAlertLabel(emp.statusLabel) }}</span>
  116. </td>
  117. </tr>
  118. </tbody>
  119. </table>
  120. </div>
  121. <div class="warning-summary">
  122. <div><i class="fas fa-circle" style="color:#ef4444;"></i> <strong>红色预警</strong> (综合评估 &lt; 75分) 共计
  123. {{ redAlertCount }} 人 → 立即约谈/培训</div>
  124. <div><i class="fas fa-circle" style="color:#22c55e;"></i> <strong>优秀标杆</strong> (综合评估 ≥ 90分) 共计 {{
  125. excellentCount }} 人 → 表彰激励</div>
  126. <div><i class="fas fa-chart-simple"></i> 全员平均综合得分: <strong>{{ avgScore }}</strong> 分</div>
  127. </div>
  128. </div>
  129. </div>
  130. <footer>
  131. <i class="far fa-bell"></i> 员工部门:旅检一/二/三部 | 阈值:<75红色预警 | ≥90优秀
  132. </footer>
  133. </div>
  134. </template>
  135. <script setup>
  136. import { ref, computed, onMounted, watch } from 'vue'
  137. import { getDeptUserTree } from '@/api/item/items'
  138. import { getWarningPageData, getEmployeeWarningPageData } from '@/api/warningPage/warningPage'
  139. import { useDict } from '@/utils/dict'
  140. import { useRoute } from 'vue-router'
  141. const route = useRoute()
  142. const { alert_level } = useDict('alert_level')
  143. const dateRangeInput = ref(null)
  144. const activeRange = ref('month')
  145. const startDate = ref(null)
  146. const endDate = ref(null)
  147. const selectedAlertLevel = ref('')
  148. const selectedOrg = ref('')
  149. const selectedOrgLabel = ref('')
  150. const treePopoverRef = ref(null)
  151. const treeProps = { label: 'label', children: 'children' }
  152. const cascadeOptions = ref([])
  153. const summaryCards = ref([
  154. { icon: 'fas fa-clipboard-list', title: '部门监察问题', badge: '部门级', value: '13项', color: '#b45309' },
  155. { icon: 'fas fa-microchip', title: '实时质控拦截', badge: '部门级', value: '347次', color: '#2563eb' },
  156. { icon: 'fas fa-bug', title: '不安全事件', badge: '一级预警', value: '18起', color: '#dc2626' },
  157. { icon: 'fas fa-shield-virus', title: '安保测试记录', badge: '部门级', value: '4项', color: '#e67e22' },
  158. { icon: 'fas fa-comment-dots', title: '旅客服务投诉', badge: '服务响应', value: '11件', color: '#e67e22' },
  159. { icon: 'fas fa-clipboard-check', title: '服务巡查', badge: '部门级', value: '5项', color: '#333' },
  160. { icon: 'fas fa-graduation-cap', title: '培训及考试成绩', badge: '平均分数', value: '92.4分', color: '#333' },
  161. { icon: 'fas fa-plane-departure', title: '航站楼', badge: '吞吐量', value: '2.8万', color: '#059669' },
  162. { icon: 'fas fa-gift', title: '小额奖励', badge: '奖励次数', value: '156次', color: '#7c3aed' }
  163. ])
  164. const newNames = [
  165. "刘孟", "王苡衡", "周海涵", "何欣怡", "王崎",
  166. "黄政", "刘韵儿", "汪安霖", "张诗敏", "张维", "陈凯琳"
  167. ]
  168. const deptList = ["旅检一部", "旅检二部", "旅检三部"]
  169. const scores = [68, 92, 55, 96, 72, 88, 48, 94, 81, 63, 91]
  170. const coreRisksOrOutstandingAchievementsList = [
  171. "违规操作2次+投诉1起,考试成绩62分",
  172. "优秀服务案例,安保测试满分,无违规",
  173. "安保测试未通过,不安全事件责任人",
  174. "典型服务案例主导者,考试成绩98",
  175. "质控拦截违规2次,考试成绩74分",
  176. "考试成绩89分,服务巡查良好",
  177. "严重不规范操作,安保测试未过",
  178. "质控拦截贡献突出,考试成绩96",
  179. "表现良好,无安全事故,考试86分",
  180. "服务巡查扣分,投诉关联2件",
  181. "临近预警线,服务巡查扣分1次"
  182. ]
  183. const employeesData = ref([])
  184. for (let i = 0; i < newNames.length; i++) {
  185. employeesData.value.push({
  186. id: String(10021 + i),
  187. name: newNames[i],
  188. dept: deptList[i % deptList.length],
  189. overallScore: scores[i % scores.length],
  190. coreRisksOrOutstandingAchievements: coreRisksOrOutstandingAchievementsList[i % coreRisksOrOutstandingAchievementsList.length]
  191. })
  192. }
  193. // 将组织架构数据转换为树形数据
  194. const transformCascadeData = (nodes) => {
  195. if (!nodes) return []
  196. return nodes.map(node => {
  197. const label = node.nickName || node.deptName || node.name || node.label
  198. const deptType = node.deptType || node.nodeType
  199. // console.log(node,"node")
  200. // let value
  201. // if (deptType === 'BRIGADE') {
  202. // value = String(node.deptId)
  203. // } else if (deptType === 'MANAGER') {
  204. // value = String(node.teamId)
  205. // } else if (deptType === 'TEAMS') {
  206. // value = String(node.groupId)
  207. // } else {
  208. // value = String(node.userId ?? node.id ?? node.deptId ?? '')
  209. // }
  210. const children = node.children && node.children.length > 0 ? transformCascadeData(node.children) : undefined
  211. return {
  212. label,
  213. value:node.id,
  214. deptType,
  215. children
  216. }
  217. })
  218. }
  219. // 获取选中的部门或用户信息
  220. const getSelectedInfo = (selectedValue) => {
  221. if (!selectedValue) return null
  222. const findNodeByValue = (nodes, value) => {
  223. for (const node of nodes) {
  224. if (node.value === value) {
  225. return node
  226. }
  227. if (node.children) {
  228. const found = findNodeByValue(node.children, value)
  229. if (found) return found
  230. }
  231. }
  232. return null
  233. }
  234. return findNodeByValue(cascadeOptions.value, selectedValue)
  235. }
  236. const handleTreeNodeClick = (data) => {
  237. selectedOrg.value = data.value
  238. selectedOrgLabel.value = data.label
  239. treePopoverRef.value?.hide()
  240. fetchWarningData()
  241. }
  242. const handleTreeClear = () => {
  243. selectedOrg.value = ''
  244. selectedOrgLabel.value = ''
  245. fetchWarningData()
  246. }
  247. const findNodeLabelByValue = (nodes, value) => {
  248. if (!nodes) return ''
  249. for (const node of nodes) {
  250. if (node.value === value) return node.label
  251. if (node.children) {
  252. const found = findNodeLabelByValue(node.children, value)
  253. if (found) return found
  254. }
  255. }
  256. return ''
  257. }
  258. const filteredEmployees = computed(() => {
  259. let result = employeesData.value.ledgerWarningDetailItemList
  260. // 预警等级筛选
  261. if (selectedAlertLevel.value) {
  262. // 根据字典值判断筛选条件(红色预警 <75,优秀标杆 >=90,正常范围 75-89)
  263. // 可以根据字典的 value 或 label 来判断
  264. const alertItem = alert_level.value.find(item => item.value === selectedAlertLevel.value)
  265. if (alertItem) {
  266. const label = alertItem.label
  267. if (label.includes('红色') || label.includes('预警')) {
  268. result = result.filter(emp => emp.overallScore < 75)
  269. } else if (label.includes('优秀') || label.includes('标杆')) {
  270. result = result.filter(emp => emp.overallScore >= 90)
  271. } else if (label.includes('正常')) {
  272. result = result.filter(emp => emp.overallScore >= 75 && emp.overallScore < 90)
  273. }
  274. }
  275. }
  276. return result
  277. })
  278. const redAlertCount = computed(() => {
  279. return employeesData.value?.redAlertNum || 0
  280. })
  281. const excellentCount = computed(() => {
  282. return employeesData.value?.excellentBenchmarkNum || 0
  283. })
  284. const avgScore = computed(() => {
  285. return employeesData.value?.averageComprehensiveScore || 0
  286. })
  287. const getRowClass = (score) => {
  288. if (score < 75) return "employee-warning-row"
  289. if (score >= 90) return "employee-excellent-row"
  290. return ""
  291. }
  292. const getAlertLabel = (value) => {
  293. if (!value) return ''
  294. const item = alert_level.value.find(d => d.value === value)
  295. return item ? item.label : value
  296. }
  297. const setActiveRange = (range) => {
  298. activeRange.value = range
  299. // 清除自定义日期
  300. if (range !== 'custom') {
  301. startDate.value = null
  302. endDate.value = null
  303. }
  304. }
  305. const formatDate = (d) => {
  306. if (!d) return ''
  307. const date = new Date(d)
  308. const y = date.getFullYear()
  309. const m = String(date.getMonth() + 1).padStart(2, '0')
  310. const day = String(date.getDate()).padStart(2, '0')
  311. return `${y}-${m}-${day}`
  312. }
  313. const getDateRangeFromActive = () => {
  314. const now = new Date()
  315. let start = new Date(now)
  316. if (activeRange.value === 'week') {
  317. start.setDate(now.getDate() - 7)
  318. } else if (activeRange.value === 'month') {
  319. start.setMonth(now.getMonth() - 1)
  320. } else if (activeRange.value === 'quarter') {
  321. start.setMonth(now.getMonth() - 3)
  322. } else {
  323. start.setFullYear(now.getFullYear() - 1)
  324. }
  325. return { startDate: formatDate(start), endDate: formatDate(now) }
  326. }
  327. const warningDataMap = {
  328. ledgerSupervisionProblem: 0,
  329. ledgerRealtimeInterception: 1,
  330. ledgerUnsafeEvent: 2,
  331. ledgerSecurityTest: 3,
  332. ledgerComplaint: 4,
  333. ledgerServicePatrol: 5,
  334. ledgerExamScore: 6,
  335. ledgerTerminalBonus: 7,
  336. ledgerRewardApproval: 8
  337. }
  338. const fetchWarningData = async () => {
  339. let params = {}
  340. if (startDate.value && endDate.value) {
  341. params.startDate = formatDate(startDate.value)
  342. params.endDate = formatDate(endDate.value)
  343. } else {
  344. const range = getDateRangeFromActive()
  345. params.startDate = range.startDate
  346. params.endDate = range.endDate
  347. }
  348. const selectedInfo = getSelectedInfo(selectedOrg.value)
  349. if (selectedInfo) {
  350. if (selectedInfo.deptType === 'BRIGADE') params.deptId = selectedInfo.value
  351. else if (selectedInfo.deptType === 'MANAGER') params.teamId = selectedInfo.value
  352. else if (selectedInfo.deptType === 'TEAMS') params.groupId = selectedInfo.value
  353. else if (selectedInfo.deptType === 'user') params.userId = selectedInfo.value
  354. }
  355. try {
  356. const [r1, r2] = await Promise.all([
  357. getWarningPageData(params),
  358. getEmployeeWarningPageData(params)
  359. ])
  360. if (r1.data) {
  361. const d = r1.data
  362. summaryCards.value.forEach((card, idx) => {
  363. for (const [key, i] of Object.entries(warningDataMap)) {
  364. if (i === idx) {
  365. card.value = d[key] !== undefined ? String(d[key]) : card.value
  366. break
  367. }
  368. }
  369. })
  370. }
  371. if (r2.data) {
  372. employeesData.value = r2.data
  373. }
  374. } catch (error) {
  375. console.error('获取预警数据失败:', error)
  376. }
  377. }
  378. onMounted(async () => {
  379. try {
  380. const res = await getDeptUserTree()
  381. if (res.data) {
  382. cascadeOptions.value = transformCascadeData(res.data)
  383. }
  384. } catch (error) {
  385. console.error('获取组织架构数据失败:', error)
  386. }
  387. fetchWarningData()
  388. })
  389. watch(startDate, () => {
  390. fetchWarningData()
  391. })
  392. watch(endDate, () => {
  393. fetchWarningData()
  394. })
  395. watch(activeRange, () => {
  396. fetchWarningData()
  397. })
  398. watch(cascadeOptions, (val) => {
  399. if (val.length && selectedOrg.value) {
  400. selectedOrgLabel.value = findNodeLabelByValue(val, selectedOrg.value)
  401. }
  402. })
  403. // 监听路由参数变化,回显到级联选择器
  404. watch(() => route.query, (query) => {
  405. const { id } = query
  406. if (id) {
  407. selectedOrg.value = id
  408. selectedOrgLabel.value = findNodeLabelByValue(cascadeOptions.value, id)
  409. } else {
  410. selectedOrg.value = ''
  411. selectedOrgLabel.value = ''
  412. }
  413. fetchWarningData()
  414. }, { immediate: true })
  415. </script>
  416. <style scoped>
  417. @keyframes subtlePulse {
  418. 0% {
  419. opacity: 0.7;
  420. }
  421. 50% {
  422. opacity: 1;
  423. background: #ffb3b3;
  424. }
  425. 100% {
  426. opacity: 0.7;
  427. }
  428. }
  429. .dashboard {
  430. max-width: 100%;
  431. padding: 10px;
  432. }
  433. .header {
  434. margin-bottom: 28px;
  435. display: flex;
  436. justify-content: space-between;
  437. align-items: flex-end;
  438. flex-wrap: wrap;
  439. gap: 16px;
  440. }
  441. .title-section h1 {
  442. font-size: 1.7rem;
  443. font-weight: 700;
  444. background: linear-gradient(135deg, #1e3c72, #2a5298);
  445. -webkit-background-clip: text;
  446. background-clip: text;
  447. color: transparent;
  448. letter-spacing: -0.3px;
  449. }
  450. .title-section p {
  451. color: #475569;
  452. margin-top: 6px;
  453. font-size: 0.85rem;
  454. }
  455. .badge-group {
  456. display: flex;
  457. gap: 12px;
  458. }
  459. .alert-badge {
  460. background: #fff1f0;
  461. border-left: 5px solid #ef4444;
  462. padding: 6px 16px;
  463. border-radius: 40px;
  464. font-weight: 600;
  465. font-size: 0.85rem;
  466. }
  467. .alert-badge i {
  468. color: #ef4444;
  469. margin-right: 6px;
  470. }
  471. .filter-bar {
  472. background: white;
  473. border-radius: 60px;
  474. padding: 8px 20px;
  475. margin-bottom: 28px;
  476. display: flex;
  477. flex-wrap: wrap;
  478. align-items: center;
  479. justify-content: space-between;
  480. gap: 16px;
  481. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.02), 0 1px 2px rgba(0, 0, 0, 0.03);
  482. border: 1px solid #eef2f6;
  483. }
  484. .time-range {
  485. display: flex;
  486. gap: 8px;
  487. align-items: center;
  488. flex-wrap: wrap;
  489. }
  490. .time-btn {
  491. background: #f8fafc;
  492. border: 1px solid #e2e8f0;
  493. padding: 6px 18px;
  494. border-radius: 40px;
  495. font-size: 0.8rem;
  496. font-weight: 500;
  497. cursor: pointer;
  498. transition: all 0.2s;
  499. color: #1e293b;
  500. }
  501. .time-btn.active {
  502. background: #2563eb;
  503. border-color: #2563eb;
  504. color: white;
  505. }
  506. .time-btn:hover {
  507. background: #e2e8f0;
  508. }
  509. .custom-date {
  510. display: flex;
  511. align-items: center;
  512. gap: 8px;
  513. }
  514. .clear-btn {
  515. background: transparent;
  516. border: 1px solid #e2e8f0;
  517. padding: 4px 8px;
  518. border-radius: 40px;
  519. cursor: pointer;
  520. color: #64748b;
  521. }
  522. .clear-btn:hover {
  523. background: #f1f5f9;
  524. }
  525. .custom-date :deep(.el-date-picker) {
  526. background: transparent;
  527. }
  528. .custom-date :deep(.el-input__wrapper) {
  529. background: transparent;
  530. }
  531. .filter-group {
  532. display: flex;
  533. gap: 12px;
  534. align-items: center;
  535. }
  536. .search-wrapper {
  537. display: flex;
  538. align-items: center;
  539. gap: 8px;
  540. background: #f8fafc;
  541. padding: 4px 12px;
  542. border-radius: 40px;
  543. border: 1px solid #e2e8f0;
  544. }
  545. .search-btn {
  546. background: #2563eb;
  547. border: none;
  548. color: white;
  549. padding: 4px 14px;
  550. border-radius: 30px;
  551. font-size: 0.75rem;
  552. font-weight: 500;
  553. cursor: pointer;
  554. transition: 0.2s;
  555. }
  556. .search-btn:hover {
  557. background: #1d4ed8;
  558. }
  559. .cards-container {
  560. width: 100%;
  561. margin-bottom: 36px;
  562. overflow-x: auto;
  563. }
  564. .cards-grid {
  565. display: flex;
  566. gap: 16px;
  567. flex-wrap: nowrap;
  568. min-width: max-content;
  569. }
  570. .card {
  571. background: #ffffff;
  572. border-radius: 20px;
  573. box-shadow: 0 4px 12px rgba(0, 0, 0, 0.03), 0 1px 2px rgba(0, 0, 0, 0.05);
  574. transition: all 0.2s;
  575. border: 1px solid #eef2f6;
  576. padding: 1rem 0.5rem;
  577. display: flex;
  578. flex-direction: column;
  579. text-align: center;
  580. min-width: 280px;
  581. flex-shrink: 0;
  582. }
  583. .card:hover {
  584. transform: translateY(-3px);
  585. box-shadow: 0 12px 20px -10px rgba(0, 0, 0, 0.1);
  586. border-color: #cbd5e1;
  587. }
  588. .card-header {
  589. display: flex;
  590. flex-direction: column;
  591. align-items: center;
  592. gap: 6px;
  593. border-bottom: none;
  594. padding-bottom: 0;
  595. margin-bottom: 12px;
  596. }
  597. .card-header i {
  598. font-size: 1.5rem;
  599. color: #2563eb;
  600. }
  601. .card-header h3 {
  602. font-size: 0.85rem;
  603. font-weight: 700;
  604. margin: 0;
  605. white-space: nowrap;
  606. }
  607. .card-badge {
  608. font-size: 0.6rem;
  609. background: #f1f5f9;
  610. padding: 2px 8px;
  611. border-radius: 30px;
  612. margin-top: 4px;
  613. display: inline-block;
  614. }
  615. .value-large {
  616. font-size: 1.8rem;
  617. font-weight: 800;
  618. line-height: 1.2;
  619. margin: 8px 0 4px 0;
  620. }
  621. .employee-section {
  622. margin-top: 20px;
  623. width: 100%;
  624. }
  625. .section-title {
  626. font-size: 1.2rem;
  627. font-weight: 700;
  628. margin-bottom: 1rem;
  629. display: flex;
  630. align-items: center;
  631. gap: 8px;
  632. border-left: 5px solid #ef4444;
  633. padding-left: 14px;
  634. }
  635. .employee-card {
  636. background: #ffffff;
  637. border-radius: 24px;
  638. box-shadow: 0 4px 12px rgba(0, 0, 0, 0.03), 0 1px 2px rgba(0, 0, 0, 0.05);
  639. border: 1px solid #eef2f6;
  640. padding: 1rem 1.5rem;
  641. width: 100%;
  642. }
  643. .data-table {
  644. width: 100%;
  645. border-collapse: collapse;
  646. font-size: 0.8rem;
  647. }
  648. .data-table th {
  649. text-align: left;
  650. padding: 10px 6px 8px 0;
  651. font-weight: 600;
  652. color: #334155;
  653. border-bottom: 2px solid #e2e8f0;
  654. }
  655. .data-table td {
  656. padding: 8px 6px 8px 0;
  657. border-bottom: 1px solid #f1f5f9;
  658. vertical-align: middle;
  659. }
  660. .employee-warning-row {
  661. background-color: #fff5f5;
  662. border-left: 4px solid #ef4444;
  663. }
  664. .employee-excellent-row {
  665. background-color: #f0fdf4;
  666. border-left: 4px solid #22c55e;
  667. }
  668. .score-danger {
  669. font-weight: 800;
  670. color: #dc2626;
  671. background: #fee2e2;
  672. padding: 2px 8px;
  673. border-radius: 30px;
  674. display: inline-block;
  675. font-size: 0.8rem;
  676. }
  677. .score-excellent {
  678. font-weight: 800;
  679. color: #15803d;
  680. background: #dcfce7;
  681. padding: 2px 8px;
  682. border-radius: 30px;
  683. display: inline-block;
  684. font-size: 0.8rem;
  685. }
  686. .warning-summary {
  687. background: #fff9f0;
  688. border-radius: 18px;
  689. padding: 12px 20px;
  690. margin-top: 18px;
  691. display: flex;
  692. gap: 28px;
  693. flex-wrap: wrap;
  694. font-weight: 500;
  695. font-size: 0.85rem;
  696. }
  697. footer {
  698. text-align: center;
  699. margin-top: 32px;
  700. font-size: 0.7rem;
  701. color: #7e8b9c;
  702. border-top: 1px solid #e2edf7;
  703. padding-top: 18px;
  704. }
  705. @media (max-width: 1200px) {
  706. .cards-grid {
  707. grid-template-columns: repeat(4, 1fr);
  708. gap: 14px;
  709. }
  710. }
  711. @media (max-width: 800px) {
  712. .cards-grid {
  713. grid-template-columns: repeat(2, 1fr);
  714. }
  715. body {
  716. padding: 20px;
  717. }
  718. .filter-bar {
  719. border-radius: 24px;
  720. flex-direction: column;
  721. align-items: stretch;
  722. }
  723. .org-search {
  724. justify-content: space-between;
  725. }
  726. }
  727. @media (max-width: 550px) {
  728. .cards-grid {
  729. grid-template-columns: 1fr;
  730. }
  731. }
  732. </style>