index.vue 22 KB

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