index.vue 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822
  1. <template>
  2. <div v-loading="loading" class="oa-dashboard">
  3. <ContentWrap>
  4. <div class="page-header">
  5. <div class="header-title">
  6. <Icon icon="ep:data-analysis" class="title-icon" />
  7. <span>院区运营驾驶舱</span>
  8. </div>
  9. <div class="header-extra">
  10. <span class="update-time">数据更新时间:{{ updateTime }}</span>
  11. <el-button type="primary" :icon="Refresh" @click="loadAll">刷新数据</el-button>
  12. </div>
  13. </div>
  14. <!-- 动态指标加载失败提示 -->
  15. <el-alert
  16. v-if="!dynamicsReady"
  17. type="warning"
  18. :closable="false"
  19. show-icon
  20. class="api-tip"
  21. title="今日新收、今日退院、请假人数、当天请假、轮候待住、预订在途、退住趋势及护理等级分布加载失败,请稍后刷新重试;床位与在院人数不受影响。"
  22. />
  23. <!-- 实时动态 KPI -->
  24. <el-row :gutter="12" class="kpi-row">
  25. <el-col v-for="card in kpiCards" :key="card.label" :xs="12" :sm="8" :md="6" :lg="6" :xl="3">
  26. <div class="kpi-card" :class="card.theme">
  27. <div class="kpi-label">
  28. <Icon :icon="card.icon" />
  29. <span>{{ card.label }}</span>
  30. </div>
  31. <div class="kpi-value">
  32. {{ card.value }}
  33. <span class="kpi-unit">{{ card.unit }}</span>
  34. </div>
  35. </div>
  36. </el-col>
  37. </el-row>
  38. <!-- 运营周期汇总 -->
  39. <div class="period-card">
  40. <div class="period-head">
  41. <span class="period-title">
  42. <Icon icon="ep:calendar" />
  43. 运营周期汇总<template v-if="weekly.institutionName"
  44. >({{ weekly.institutionName }})</template
  45. >
  46. </span>
  47. <span class="period-tip">按所选院区合计口径统计</span>
  48. </div>
  49. <div v-if="!hasWeekly" class="period-empty">
  50. 暂无数据:多院区运营周期汇总需后端在聚合接口中补充,当前仅登录机构查看本院时展示
  51. </div>
  52. <el-row v-else :gutter="10">
  53. <el-col :xs="8" :sm="8" :md="4" :lg="4">
  54. <div class="period-item">
  55. <div class="period-label">本周期初人数</div>
  56. <div class="period-num">{{ weekly.periodBeginCount ?? '-' }}</div>
  57. </div>
  58. </el-col>
  59. <el-col :xs="8" :sm="8" :md="4" :lg="4">
  60. <div class="period-item">
  61. <div class="period-label">本周期末人数</div>
  62. <div class="period-num">{{ weekly.periodEndCount ?? '-' }}</div>
  63. </div>
  64. </el-col>
  65. <el-col :xs="8" :sm="8" :md="5" :lg="5">
  66. <div class="period-item">
  67. <div class="period-label">本周入住 / 退住 / 净增</div>
  68. <div class="period-num triple">
  69. <em class="up">{{ weekly.weeklyCheckInCount ?? 0 }}</em>
  70. <span class="sep">/</span>
  71. <em class="down">{{ weekly.weeklyCheckOutCount ?? 0 }}</em>
  72. <span class="sep">/</span>
  73. <em :class="netClass(weekly.weeklyNetGrowthCount)">
  74. {{ formatNet(weekly.weeklyNetGrowthCount) }}
  75. </em>
  76. </div>
  77. </div>
  78. </el-col>
  79. <el-col :xs="12" :sm="8" :md="6" :lg="6">
  80. <div class="period-item">
  81. <div class="period-label">本月入住 / 退住 / 净增</div>
  82. <div class="period-num triple">
  83. <em class="up">{{ weekly.monthlyCheckInCount ?? 0 }}</em>
  84. <span class="sep">/</span>
  85. <em class="down">{{ weekly.monthlyCheckOutCount ?? 0 }}</em>
  86. <span class="sep">/</span>
  87. <em :class="netClass(weekly.monthlyNetGrowthCount)">
  88. {{ formatNet(weekly.monthlyNetGrowthCount) }}
  89. </em>
  90. </div>
  91. </div>
  92. </el-col>
  93. <el-col :xs="12" :sm="8" :md="5" :lg="5">
  94. <div class="period-item">
  95. <div class="period-label">期末空余床位数</div>
  96. <div class="period-num">
  97. {{ weekly.periodEndEmptyBedCount ?? '-' }}
  98. <span class="kpi-unit">张</span>
  99. </div>
  100. </div>
  101. </el-col>
  102. </el-row>
  103. </div>
  104. </ContentWrap>
  105. <el-row :gutter="12" class="chart-row">
  106. <el-col :xs="24" :lg="15">
  107. <ContentWrap>
  108. <div class="panel-title">各院区床位与入住率</div>
  109. <Echart :options="campusChartOption" height="360px" />
  110. </ContentWrap>
  111. </el-col>
  112. <el-col :xs="24" :lg="9">
  113. <ContentWrap>
  114. <div class="panel-title">{{ campusOptions.length }} 家院区床位结构</div>
  115. <div class="donut-wrap">
  116. <Echart :options="bedStructureOption" height="360px" />
  117. <div class="donut-center">
  118. <div class="donut-total">{{ bedSummary.total || '-' }}</div>
  119. <div class="donut-label">总床位(张)</div>
  120. </div>
  121. </div>
  122. </ContentWrap>
  123. </el-col>
  124. </el-row>
  125. <el-row :gutter="12" class="chart-row">
  126. <el-col :span="24">
  127. <ContentWrap>
  128. <div class="panel-title">
  129. 近 12 个月已完成退住趋势
  130. <span v-if="dynamicsReady" class="title-tip">合计 {{ checkoutTotal }} 人</span>
  131. </div>
  132. <Echart v-if="dynamicsReady" :options="checkoutTrendOption" height="300px" />
  133. <el-empty
  134. v-else
  135. description="暂无数据:动态指标加载失败"
  136. :image-size="90"
  137. class="chart-empty"
  138. />
  139. </ContentWrap>
  140. </el-col>
  141. </el-row>
  142. <el-row :gutter="12" class="chart-row">
  143. <el-col :xs="24" :lg="12">
  144. <ContentWrap>
  145. <div class="panel-title">
  146. 护理等级分布 TOP10
  147. <span class="title-tip">各院区护理等级名称按同名合并统计</span>
  148. </div>
  149. <Echart v-if="careLevels.length" :options="careLevelOption" height="380px" />
  150. <el-empty
  151. v-else
  152. :description="dynamicsReady ? '所选院区暂无护理等级数据' : '暂无数据:动态指标加载失败'"
  153. :image-size="90"
  154. class="chart-empty"
  155. />
  156. </ContentWrap>
  157. </el-col>
  158. <el-col :xs="24" :lg="12">
  159. <ContentWrap>
  160. <div class="panel-title">院区入住率区间分布</div>
  161. <Echart :options="occupancyTierOption" height="380px" />
  162. </ContentWrap>
  163. </el-col>
  164. </el-row>
  165. </div>
  166. </template>
  167. <script lang="ts" setup>
  168. import { Refresh } from '@element-plus/icons-vue'
  169. import Echart from '@/components/Echart/src/Echart.vue'
  170. import {
  171. fetchCampusBedStats,
  172. fetchInHospitalCount,
  173. fetchOverviewDynamics,
  174. getCampusOptions,
  175. type CampusBedStat,
  176. type NameCountItem,
  177. type OverviewDynamics
  178. } from '../campus-data'
  179. defineOptions({ name: 'OaOverviewDashboard' })
  180. const message = useMessage()
  181. const loading = ref(false)
  182. const updateTime = ref('')
  183. /** 在院人数(档案在院状态) */
  184. const inHospitalCount = ref<number | null>(null)
  185. /** 后端聚合动态指标(新收/退院/请假/轮候/预订/退住趋势/护理等级/周期汇总),加载失败为 null */
  186. const dynamics = ref<OverviewDynamics | null>(null)
  187. const campusStats = ref<CampusBedStat[]>([])
  188. const campusOptions = getCampusOptions()
  189. const dynamicsReady = computed(() => dynamics.value !== null)
  190. const hasWeekly = computed(() => dynamics.value?.weekly != null)
  191. const weekly = computed<Record<string, any>>(() => dynamics.value?.weekly ?? {})
  192. const careLevels = computed<NameCountItem[]>(() => dynamics.value?.nursingLevels ?? [])
  193. const checkoutTrend = computed(() => dynamics.value?.checkoutTrend ?? [])
  194. const fmtCount = (val: number | null | undefined) => (val === null || val === undefined ? '-' : val)
  195. const kpiCards = computed(() => [
  196. {
  197. label: '总床位数',
  198. value: bedSummary.value.total || '-',
  199. unit: '张',
  200. icon: 'ep:house',
  201. theme: 'blue'
  202. },
  203. {
  204. label: '在院人数',
  205. value: fmtCount(inHospitalCount.value),
  206. unit: '人',
  207. icon: 'ep:user',
  208. theme: 'green'
  209. },
  210. {
  211. label: '今日新收',
  212. value: fmtCount(dynamics.value?.todayCheckInCount),
  213. unit: '人',
  214. icon: 'ep:plus',
  215. theme: 'cyan'
  216. },
  217. {
  218. label: '今日退院',
  219. value: fmtCount(dynamics.value?.todayCheckOutCount),
  220. unit: '人',
  221. icon: 'ep:minus',
  222. theme: 'orange'
  223. },
  224. {
  225. label: '请假人数',
  226. value: fmtCount(dynamics.value?.leaveOutCount),
  227. unit: '人',
  228. icon: 'ep:clock',
  229. theme: 'purple'
  230. },
  231. {
  232. label: '当天请假人数',
  233. value: fmtCount(dynamics.value?.todayLeaveCount),
  234. unit: '人',
  235. icon: 'ep:timer',
  236. theme: 'grey'
  237. },
  238. {
  239. label: '轮候待住',
  240. value: fmtCount(dynamics.value?.waitingCount),
  241. unit: '人',
  242. icon: 'ep:bell',
  243. theme: 'red'
  244. },
  245. {
  246. label: '预订在途',
  247. value: fmtCount(dynamics.value?.reservationCount),
  248. unit: '人',
  249. icon: 'ep:document',
  250. theme: 'pink'
  251. }
  252. ])
  253. const formatNet = (val: number | undefined | null) => {
  254. if (val === undefined || val === null) return '-'
  255. return val > 0 ? `+${val}` : `${val}`
  256. }
  257. const netClass = (val: number | undefined | null) => {
  258. if (!val) return 'zero'
  259. return val > 0 ? 'up' : 'down'
  260. }
  261. /** 床位结构汇总 */
  262. const bedSummary = computed(() =>
  263. campusStats.value.reduce(
  264. (sum, item) => {
  265. sum.used += item.usedBed
  266. sum.unused += item.unusedBed
  267. sum.temp += item.tempBed
  268. sum.total += item.totalBed
  269. return sum
  270. },
  271. { used: 0, unused: 0, temp: 0, total: 0 }
  272. )
  273. )
  274. /** 院区床位 + 入住率组合图,按入住率降序 */
  275. const campusChartOption = computed<any>(() => {
  276. const sorted = [...campusStats.value].sort((a, b) => {
  277. const ra = a.occupancyRate ?? -1
  278. const rb = b.occupancyRate ?? -1
  279. return rb - ra
  280. })
  281. return {
  282. tooltip: {
  283. trigger: 'axis',
  284. axisPointer: { type: 'shadow' },
  285. formatter: (params: any[]) => {
  286. const campus = sorted[params[0].dataIndex]
  287. const lines = params.map(
  288. (p) => `${p.marker}${p.seriesName}:${p.value}${p.seriesName === '入住率' ? '%' : ' 张'}`
  289. )
  290. const extra = campus
  291. ? `<br/>空闲床位:${campus.unusedBed} 张<br/>楼栋数:${campus.buildCount} 栋`
  292. : ''
  293. return `${params[0].axisValue}${extra}<br/>${lines.join('<br/>')}`
  294. }
  295. },
  296. legend: { data: ['总床位', '在住床位', '入住率'], top: 0 },
  297. grid: { left: 50, right: 50, top: 40, bottom: 90 },
  298. xAxis: {
  299. type: 'category',
  300. data: sorted.map((item) => item.campusName),
  301. axisLabel: { interval: 0, rotate: 35 }
  302. },
  303. yAxis: [
  304. { type: 'value', name: '床位数(张)' },
  305. { type: 'value', name: '入住率', min: 0, max: 100, axisLabel: { formatter: '{value}%' } }
  306. ],
  307. series: [
  308. {
  309. name: '总床位',
  310. type: 'bar',
  311. barMaxWidth: 18,
  312. itemStyle: { color: '#8cc5ff', borderRadius: [3, 3, 0, 0] },
  313. data: sorted.map((item) => item.totalBed)
  314. },
  315. {
  316. name: '在住床位',
  317. type: 'bar',
  318. barMaxWidth: 18,
  319. itemStyle: { color: '#409eff', borderRadius: [3, 3, 0, 0] },
  320. data: sorted.map((item) => item.usedBed)
  321. },
  322. {
  323. name: '入住率',
  324. type: 'line',
  325. yAxisIndex: 1,
  326. smooth: true,
  327. symbol: 'circle',
  328. symbolSize: 7,
  329. lineStyle: { color: '#fa8c16', width: 2 },
  330. itemStyle: { color: '#fa8c16' },
  331. label: { show: true, formatter: '{c}%', fontSize: 10, color: '#fa8c16' },
  332. data: sorted.map((item) => item.occupancyRate ?? 0)
  333. }
  334. ]
  335. }
  336. })
  337. /** 床位结构环形图 */
  338. const bedStructureOption = computed<any>(() => ({
  339. tooltip: { trigger: 'item', formatter: '{b}:{c} 张({d}%)' },
  340. legend: { bottom: 0 },
  341. series: [
  342. {
  343. name: '床位结构',
  344. type: 'pie',
  345. radius: ['45%', '70%'],
  346. center: ['50%', '46%'],
  347. avoidLabelOverlap: true,
  348. itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
  349. label: {
  350. formatter: '{b}\n{c} 张'
  351. },
  352. data: [
  353. { value: bedSummary.value.used, name: '在住床位', itemStyle: { color: '#67c23a' } },
  354. { value: bedSummary.value.unused, name: '空闲床位', itemStyle: { color: '#c0c4cc' } },
  355. { value: bedSummary.value.temp, name: '临时占用', itemStyle: { color: '#e6a23c' } }
  356. ]
  357. }
  358. ]
  359. }))
  360. const checkoutTotal = computed(() => checkoutTrend.value.reduce((sum, item) => sum + item.count, 0))
  361. /** 近 12 个月退住趋势(柱+月均标线) */
  362. const checkoutTrendOption = computed<any>(() => {
  363. const data = checkoutTrend.value
  364. return {
  365. tooltip: {
  366. trigger: 'axis',
  367. axisPointer: { type: 'shadow' },
  368. formatter: (params: any[]) => {
  369. const item = data[params[0].dataIndex]
  370. return `${item.month}<br/>${params[0].marker}已完成退住:${params[0].value} 人`
  371. }
  372. },
  373. grid: { left: 45, right: 25, top: 40, bottom: 30 },
  374. xAxis: {
  375. type: 'category',
  376. data: data.map((item) => item.label),
  377. axisLabel: {
  378. color: '#606266',
  379. formatter: (val: string, index: number) =>
  380. index === 0 || data[index].month.endsWith('-01') ? data[index].month : val
  381. }
  382. },
  383. yAxis: { type: 'value', name: '人数', minInterval: 1 },
  384. series: [
  385. {
  386. name: '已完成退住',
  387. type: 'bar',
  388. barMaxWidth: 26,
  389. label: { show: true, position: 'top', formatter: '{c}' },
  390. itemStyle: {
  391. borderRadius: [4, 4, 0, 0],
  392. color: {
  393. type: 'linear',
  394. x: 0,
  395. y: 0,
  396. x2: 0,
  397. y2: 1,
  398. colorStops: [
  399. { offset: 0, color: '#ffb066' },
  400. { offset: 1, color: '#f5851d' }
  401. ]
  402. }
  403. },
  404. markLine: {
  405. symbol: 'none',
  406. lineStyle: { color: '#f56c6c', type: 'dashed' },
  407. label: {
  408. formatter: '月均 {c} 人',
  409. color: '#f56c6c',
  410. position: 'insideEndTop'
  411. },
  412. data: [{ type: 'average', name: '月均' }]
  413. },
  414. data: data.map((item) => item.count)
  415. }
  416. ]
  417. }
  418. })
  419. /** 护理等级 TOP10 横向条形图 */
  420. const careLevelOption = computed<any>(() => {
  421. const top = careLevels.value.slice(0, 10)
  422. return {
  423. tooltip: {
  424. trigger: 'axis',
  425. axisPointer: { type: 'shadow' },
  426. formatter: (params: any[]) => `${params[0].name}:${params[0].value} 人`
  427. },
  428. grid: { left: 10, right: 40, top: 15, bottom: 10, containLabel: true },
  429. xAxis: { type: 'value', name: '人数' },
  430. yAxis: {
  431. type: 'category',
  432. inverse: true,
  433. data: top.map((item) => item.name),
  434. axisLabel: { width: 120, overflow: 'truncate' }
  435. },
  436. series: [
  437. {
  438. name: '人数',
  439. type: 'bar',
  440. barMaxWidth: 18,
  441. label: { show: true, position: 'right', formatter: '{c} 人' },
  442. itemStyle: {
  443. borderRadius: [0, 4, 4, 0],
  444. color: {
  445. type: 'linear',
  446. x: 0,
  447. y: 0,
  448. x2: 1,
  449. y2: 0,
  450. colorStops: [
  451. { offset: 0, color: '#79bbff' },
  452. { offset: 1, color: '#409eff' }
  453. ]
  454. }
  455. },
  456. data: top.map((item) => item.count)
  457. }
  458. ]
  459. }
  460. })
  461. /** 入住率区间分布 */
  462. const occupancyTierOption = computed<any>(() => {
  463. const tiers = [
  464. { key: 'tight', label: '入住紧张(≥90%)', color: '#f56c6c', campuses: [] as string[] },
  465. { key: 'good', label: '运行良好(70%-90%)', color: '#67c23a', campuses: [] as string[] },
  466. { key: 'normal', label: '入住一般(50%-70%)', color: '#409eff', campuses: [] as string[] },
  467. { key: 'low', label: '空置预警(>0且<50%)', color: '#e6a23c', campuses: [] as string[] },
  468. { key: 'none', label: '未配置房源', color: '#c0c4cc', campuses: [] as string[] }
  469. ]
  470. campusStats.value.forEach((item) => {
  471. const rate = item.occupancyRate
  472. if (rate === null) tiers[4].campuses.push(item.campusName)
  473. else if (rate >= 90) tiers[0].campuses.push(`${item.campusName}(${rate}%)`)
  474. else if (rate >= 70) tiers[1].campuses.push(`${item.campusName}(${rate}%)`)
  475. else if (rate >= 50) tiers[2].campuses.push(`${item.campusName}(${rate}%)`)
  476. else tiers[3].campuses.push(`${item.campusName}(${rate}%)`)
  477. })
  478. return {
  479. tooltip: {
  480. trigger: 'axis',
  481. axisPointer: { type: 'shadow' },
  482. formatter: (params: any[]) => {
  483. const tier = tiers[params[0].dataIndex]
  484. const names = tier.campuses.length ? tier.campuses.join('、') : '无'
  485. return `${tier.label}<br/>院区数量:${tier.campuses.length} 个<br/>${names}`
  486. }
  487. },
  488. grid: { left: 45, right: 30, top: 30, bottom: 70 },
  489. xAxis: {
  490. type: 'category',
  491. data: tiers.map((item) => item.label),
  492. axisLabel: { interval: 0, rotate: 18, fontSize: 11 }
  493. },
  494. yAxis: { type: 'value', name: '院区数量(个)', minInterval: 1 },
  495. series: [
  496. {
  497. name: '院区数量',
  498. type: 'bar',
  499. barMaxWidth: 46,
  500. label: { show: true, position: 'top', formatter: '{c} 个' },
  501. data: tiers.map((item) => ({
  502. value: item.campuses.length,
  503. itemStyle: { color: item.color, borderRadius: [4, 4, 0, 0] }
  504. }))
  505. }
  506. ]
  507. }
  508. })
  509. const formatTime = (date: Date) => {
  510. const pad = (n: number) => String(n).padStart(2, '0')
  511. return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(
  512. date.getHours()
  513. )}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
  514. }
  515. const loadAll = async () => {
  516. loading.value = true
  517. try {
  518. const tenantIds = campusOptions.map((item) => item.value)
  519. const [bedStats, inHospital, dyn] = await Promise.all([
  520. fetchCampusBedStats(tenantIds),
  521. fetchInHospitalCount(tenantIds),
  522. fetchOverviewDynamics(tenantIds)
  523. ])
  524. campusStats.value = bedStats
  525. inHospitalCount.value = inHospital
  526. dynamics.value = dyn
  527. if (!dyn) {
  528. message.warning('今日动态、退住趋势及护理等级数据加载失败,床位与在院人数为实时统计数据')
  529. }
  530. updateTime.value = formatTime(new Date())
  531. } catch (e) {
  532. message.error('运营数据加载失败,请稍后重试')
  533. } finally {
  534. loading.value = false
  535. }
  536. }
  537. onMounted(() => {
  538. loadAll()
  539. })
  540. </script>
  541. <style lang="scss" scoped>
  542. .oa-dashboard {
  543. .page-header {
  544. display: flex;
  545. align-items: center;
  546. justify-content: space-between;
  547. margin-bottom: 16px;
  548. .header-title {
  549. display: flex;
  550. align-items: center;
  551. font-size: 18px;
  552. font-weight: 600;
  553. color: #303133;
  554. .title-icon {
  555. margin-right: 8px;
  556. font-size: 22px;
  557. color: #409eff;
  558. }
  559. }
  560. .header-extra {
  561. display: flex;
  562. align-items: center;
  563. .update-time {
  564. margin-right: 14px;
  565. font-size: 13px;
  566. color: #909399;
  567. }
  568. }
  569. }
  570. .api-tip {
  571. margin-bottom: 14px;
  572. }
  573. .kpi-row {
  574. margin-bottom: 14px;
  575. .kpi-card {
  576. padding: 16px 18px;
  577. border-radius: 8px;
  578. color: #fff;
  579. margin-bottom: 10px;
  580. .kpi-label {
  581. display: flex;
  582. align-items: center;
  583. font-size: 13px;
  584. opacity: 0.95;
  585. .el-icon {
  586. margin-right: 6px;
  587. font-size: 16px;
  588. }
  589. }
  590. .kpi-value {
  591. margin-top: 8px;
  592. font-size: 30px;
  593. font-weight: 700;
  594. line-height: 1.1;
  595. .kpi-unit {
  596. margin-left: 4px;
  597. font-size: 13px;
  598. font-weight: 400;
  599. opacity: 0.85;
  600. }
  601. }
  602. &.blue {
  603. background: linear-gradient(135deg, #5b9bff 0%, #2f7cf6 100%);
  604. }
  605. &.green {
  606. background: linear-gradient(135deg, #62d391 0%, #36a866 100%);
  607. }
  608. &.cyan {
  609. background: linear-gradient(135deg, #4fd0e0 0%, #1fa9bf 100%);
  610. }
  611. &.orange {
  612. background: linear-gradient(135deg, #ffb066 0%, #f5851d 100%);
  613. }
  614. &.purple {
  615. background: linear-gradient(135deg, #b794f6 0%, #8b5cf6 100%);
  616. }
  617. &.grey {
  618. background: linear-gradient(135deg, #a8abb2 0%, #7a7f8a 100%);
  619. }
  620. &.red {
  621. background: linear-gradient(135deg, #ff8f8f 0%, #f56c6c 100%);
  622. }
  623. &.pink {
  624. background: linear-gradient(135deg, #ff9ebd 0%, #ec5f86 100%);
  625. }
  626. }
  627. }
  628. .period-card {
  629. padding: 14px 16px;
  630. background: #f5f8ff;
  631. border: 1px solid #e1ecff;
  632. border-radius: 8px;
  633. .period-empty {
  634. padding: 26px 0;
  635. text-align: center;
  636. font-size: 13px;
  637. color: #a8abb2;
  638. }
  639. .period-head {
  640. display: flex;
  641. align-items: center;
  642. justify-content: space-between;
  643. margin-bottom: 12px;
  644. .period-title {
  645. display: flex;
  646. align-items: center;
  647. font-size: 15px;
  648. font-weight: 600;
  649. color: #303133;
  650. .el-icon {
  651. margin-right: 6px;
  652. color: #409eff;
  653. }
  654. }
  655. .period-tip {
  656. font-size: 12px;
  657. color: #a8abb2;
  658. }
  659. }
  660. .period-item {
  661. padding: 10px 12px;
  662. background: #fff;
  663. border-radius: 6px;
  664. height: 100%;
  665. box-sizing: border-box;
  666. .period-label {
  667. font-size: 12px;
  668. color: #909399;
  669. margin-bottom: 6px;
  670. }
  671. .period-num {
  672. font-size: 22px;
  673. font-weight: 700;
  674. color: #303133;
  675. .kpi-unit {
  676. font-size: 12px;
  677. font-weight: 400;
  678. color: #909399;
  679. }
  680. &.triple {
  681. font-size: 19px;
  682. .sep {
  683. margin: 0 5px;
  684. color: #dcdfe6;
  685. font-weight: 400;
  686. }
  687. em {
  688. font-style: normal;
  689. &.up {
  690. color: #67c23a;
  691. }
  692. &.down {
  693. color: #f56c6c;
  694. }
  695. &.zero {
  696. color: #909399;
  697. }
  698. }
  699. }
  700. }
  701. }
  702. }
  703. .chart-row {
  704. margin-top: 12px;
  705. }
  706. .chart-empty {
  707. display: flex;
  708. align-items: center;
  709. justify-content: center;
  710. height: 380px;
  711. }
  712. .donut-wrap {
  713. position: relative;
  714. }
  715. .donut-center {
  716. position: absolute;
  717. top: 46%;
  718. left: 50%;
  719. transform: translate(-50%, -50%);
  720. text-align: center;
  721. pointer-events: none;
  722. }
  723. .donut-total {
  724. font-size: 26px;
  725. font-weight: 700;
  726. color: #303133;
  727. line-height: 1.2;
  728. }
  729. .donut-label {
  730. margin-top: 2px;
  731. font-size: 12px;
  732. color: #909399;
  733. }
  734. .panel-title {
  735. position: relative;
  736. padding-left: 10px;
  737. margin-bottom: 10px;
  738. font-size: 15px;
  739. font-weight: 600;
  740. color: #303133;
  741. &::before {
  742. content: '';
  743. position: absolute;
  744. left: 0;
  745. top: 2px;
  746. width: 4px;
  747. height: 16px;
  748. background: #409eff;
  749. border-radius: 2px;
  750. }
  751. .title-tip {
  752. margin-left: 10px;
  753. font-size: 12px;
  754. font-weight: 400;
  755. color: #a8abb2;
  756. }
  757. }
  758. }
  759. </style>