index.vue 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233
  1. <script setup lang="ts">
  2. import chroma from 'chroma-js'
  3. import * as echarts from 'echarts'
  4. import { NNumberAnimation } from 'naive-ui'
  5. import { onMounted, watch, ref, computed, onUnmounted, nextTick } from 'vue'
  6. import { ContentWrapper } from '@/components'
  7. import { usePersonalization } from '@/composables'
  8. import { usePreferencesStore } from '@/stores'
  9. import twc from '@/utils/tailwindColor'
  10. import type { ECharts } from 'echarts'
  11. defineOptions({
  12. name: 'Dashboard',
  13. })
  14. const { isDark, color } = usePersonalization()
  15. const preferencesStore = usePreferencesStore()
  16. const cardList = ref(generateCardData())
  17. const revenueChart = ref<HTMLDivElement | null>(null)
  18. let revenueChartInstance: ECharts | null = null
  19. let revenueChartResizeHandler: (() => void) | null = null
  20. const revenueBarChart = ref<HTMLDivElement | null>(null)
  21. let revenueBarChartInstance: ECharts | null = null
  22. let revenueBarChartResizeHandler: (() => void) | null = null
  23. const revenueBarChart2 = ref<HTMLDivElement | null>(null)
  24. let revenueBarChart2Instance: ECharts | null = null
  25. let revenueBarChart2ResizeHandler: (() => void) | null = null
  26. const monthlyRadarChart = ref<HTMLDivElement | null>(null)
  27. let monthlyRadarChartInstance: ECharts | null = null
  28. let monthlyRadarChartResizeHandler: (() => void) | null = null
  29. const highestRevenueChart = ref<HTMLDivElement | null>(null)
  30. let highestRevenueChartInstance: ECharts | null = null
  31. let highestRevenueChartResizeHandler: (() => void) | null = null
  32. let collapseResizeTimeout: ReturnType<typeof setTimeout> | null = null
  33. const CHART_CONFIG = {
  34. MONTHS: Array.from({ length: 12 }, (_, i) => `${i + 1}月`),
  35. }
  36. const getBusinessLinesConfig = () => [
  37. {
  38. name: '主要收入',
  39. color: color.value,
  40. dataRange: { min: 30000, max: 85000 },
  41. },
  42. {
  43. name: '核心业务',
  44. color: twc.cyan[500],
  45. dataRange: { min: 25000, max: 75000 },
  46. },
  47. {
  48. name: '辅助收入',
  49. color: twc.lime[500],
  50. dataRange: { min: 15000, max: 50000 },
  51. },
  52. {
  53. name: '订阅收入',
  54. color: twc.orange[500],
  55. dataRange: { min: 10000, max: 35000 },
  56. },
  57. {
  58. name: '广告收入',
  59. color: twc.pink[500],
  60. dataRange: { min: 8000, max: 25000 },
  61. },
  62. ]
  63. const barChartSelectedLegend = ref(getBusinessLinesConfig()[0].name)
  64. const revenueChartSelected = ref<Record<string, boolean>>(
  65. Object.fromEntries(getBusinessLinesConfig().map((line) => [line.name, true])),
  66. )
  67. const highestChartSelected = ref<'max' | 'min'>('max')
  68. function generateCardData() {
  69. const now = new Date()
  70. const currentMonth = now.getMonth() + 1
  71. const baseUserCount = 10000 + Math.floor(Math.random() * 5000)
  72. const todayVisits = Math.floor(
  73. 5000 + Math.random() * 8000 + Math.sin((now.getHours() / 24) * Math.PI) * 2000,
  74. )
  75. const monthlySales = Math.floor(1500000 + Math.random() * 1000000 + (currentMonth / 12) * 500000)
  76. const pendingOrders = Math.floor(150 + Math.random() * 200)
  77. return [
  78. {
  79. title: '用户总数',
  80. value: baseUserCount,
  81. percentage: parseFloat((3.2 + Math.random() * 4).toFixed(2)),
  82. iconClass: 'iconify ph--users-bold text-indigo-50 dark:text-indigo-150',
  83. iconBgClass:
  84. 'text-indigo-500/5 bg-indigo-400 ring-4 ring-indigo-200 dark:bg-indigo-650 dark:ring-indigo-500/30 transition-all',
  85. precision: 0,
  86. description: `${currentMonth}月新增 ${Math.floor(100 + Math.random() * 200)} 人`,
  87. },
  88. {
  89. title: '今日访问',
  90. value: todayVisits,
  91. percentage: parseFloat((-2 + Math.random() * 20).toFixed(2)),
  92. iconClass: 'iconify ph--eye-bold text-blue-50 dark:text-blue-150',
  93. iconBgClass:
  94. 'text-blue-500/5 bg-blue-400 ring-4 ring-blue-200 dark:bg-blue-650 dark:ring-blue-500/30 transition-all',
  95. precision: 0,
  96. description: '较昨日变化',
  97. },
  98. {
  99. title: `${currentMonth}月销售额`,
  100. value: monthlySales,
  101. percentage: parseFloat((5 + Math.random() * 10).toFixed(2)),
  102. iconClass: 'iconify ph--currency-dollar-bold text-emerald-50 dark:text-emerald-150',
  103. iconBgClass:
  104. 'text-emerald-500/5 bg-emerald-400 ring-4 ring-emerald-200 dark:bg-emerald-650 dark:ring-emerald-500/30 transition-all',
  105. precision: 2,
  106. description: '本月累计收入',
  107. },
  108. {
  109. title: '待处理订单',
  110. value: pendingOrders,
  111. percentage: parseFloat((-8 + Math.random() * 6).toFixed(2)),
  112. iconClass: 'iconify ph--shopping-cart-bold text-orange-50 dark:text-orange-150',
  113. iconBgClass:
  114. 'text-orange-500/5 bg-orange-400 ring-4 ring-orange-200 dark:bg-orange-650 dark:ring-orange-500/30 transition-all',
  115. precision: 0,
  116. description: '需要及时处理',
  117. },
  118. ]
  119. }
  120. const generateRandomData = (
  121. baseMin: number,
  122. baseMax: number,
  123. trend: 'up' | 'down' | 'stable' = 'stable',
  124. ) => {
  125. const data = []
  126. let currentBase = (baseMin + baseMax) / 2
  127. for (let i = 0; i < 12; i++) {
  128. if (trend === 'up') {
  129. currentBase += (baseMax - baseMin) * 0.03
  130. } else if (trend === 'down') {
  131. currentBase -= (baseMax - baseMin) * 0.02
  132. }
  133. const baseVariation = (baseMax - baseMin) * 0.6 * (Math.random() - 0.5)
  134. const seasonalFactor = Math.sin((i * Math.PI) / 6) * (baseMax - baseMin) * 0.15
  135. const randomSpike = Math.random() > 0.8 ? (baseMax - baseMin) * 0.2 * (Math.random() - 0.5) : 0
  136. const variation = baseVariation + seasonalFactor + randomSpike
  137. const value = Math.max(baseMin * 0.3, Math.min(90000, currentBase + variation))
  138. data.push(Math.round(value))
  139. }
  140. return data
  141. }
  142. const businessLinesWithData = computed(() =>
  143. getBusinessLinesConfig().map((line) => ({
  144. ...line,
  145. data: generateRandomData(line.dataRange.min, line.dataRange.max, 'up'),
  146. })),
  147. )
  148. const createTooltipConfig = (formatter?: any) => ({
  149. trigger: 'axis',
  150. backgroundColor: isDark.value ? twc.neutral[750] : '#fff',
  151. borderWidth: 0,
  152. padding: 8,
  153. extraCssText: 'box-shadow: none;',
  154. textStyle: {
  155. color: isDark.value ? twc.neutral[400] : twc.neutral[600],
  156. fontSize: 12,
  157. },
  158. axisPointer: {
  159. type: 'none',
  160. },
  161. ...(formatter && { formatter }),
  162. })
  163. const chartDataManager = {
  164. getMonths: () => CHART_CONFIG.MONTHS,
  165. getAllLines: () => businessLinesWithData.value,
  166. getLineByName: (name: string) => {
  167. return businessLinesWithData.value.find((line) => line.name === name)
  168. },
  169. getAllNames: () => {
  170. return businessLinesWithData.value.map((line) => line.name)
  171. },
  172. getAllColors: () => {
  173. return businessLinesWithData.value.map((line) => line.color)
  174. },
  175. getHighestRevenueLine: () => {
  176. return businessLinesWithData.value.reduce((prev, current) => {
  177. const prevTotal = prev.data.reduce((sum, value) => sum + value, 0)
  178. const currentTotal = current.data.reduce((sum, value) => sum + value, 0)
  179. return prevTotal > currentTotal ? prev : current
  180. })
  181. },
  182. getLowestRevenueLine: () => {
  183. return businessLinesWithData.value.reduce((prev, current) => {
  184. const prevTotal = prev.data.reduce((sum, value) => sum + value, 0)
  185. const currentTotal = current.data.reduce((sum, value) => sum + value, 0)
  186. return prevTotal < currentTotal ? prev : current
  187. })
  188. },
  189. getCurrentMonthData: (month: number) => {
  190. return businessLinesWithData.value.map((line) => ({
  191. name: line.name,
  192. value: line.data[month],
  193. color: line.color,
  194. }))
  195. },
  196. }
  197. function initRevenueChart() {
  198. if (!revenueChart.value) return
  199. const chart = echarts.init(revenueChart.value)
  200. const option = {
  201. title: [
  202. {
  203. text: '收入概览',
  204. left: 0,
  205. top: 0,
  206. textStyle: {
  207. fontSize: 18,
  208. color: isDark.value ? twc.neutral[400] : twc.neutral[600],
  209. fontWeight: 'normal',
  210. },
  211. },
  212. {
  213. text: '总收入趋势与增长分析',
  214. left: 0,
  215. top: 28,
  216. textStyle: {
  217. fontSize: 14,
  218. color: isDark.value ? twc.neutral[500] : twc.neutral[450],
  219. fontWeight: 'normal',
  220. },
  221. },
  222. ],
  223. tooltip: createTooltipConfig((params: any) => {
  224. const date = params[0].axisValue
  225. let result = `<div>${date}数据</div>`
  226. params.forEach((item: any) => {
  227. const value = item.value.toLocaleString()
  228. result += `
  229. <div style="display: flex; align-items: center; margin-top: 4px;">
  230. <span style="display:inline-block; margin-right:4px; width:10px; height:10px; border-radius:50%; background-color:${item.color};"></span>
  231. <span style="margin-right: 10px">${item.seriesName}</span>
  232. <span>${value}</span>
  233. </div>
  234. `
  235. })
  236. return result
  237. }),
  238. legend: {
  239. top: 6,
  240. right: 22,
  241. itemGap: 16,
  242. icon: 'circle',
  243. itemWidth: 12,
  244. itemHeight: 12,
  245. textStyle: {
  246. color: isDark.value ? twc.neutral[400] : twc.neutral[600],
  247. fontSize: 14,
  248. },
  249. data: chartDataManager.getAllNames(),
  250. selected: revenueChartSelected.value,
  251. },
  252. grid: {
  253. left: 20,
  254. right: 20,
  255. top: 72,
  256. bottom: 0,
  257. containLabel: true,
  258. },
  259. xAxis: {
  260. type: 'category',
  261. boundaryGap: false,
  262. data: chartDataManager.getMonths(),
  263. axisLine: { show: false },
  264. axisTick: { show: false },
  265. axisLabel: {
  266. color: isDark.value ? twc.neutral[400] : twc.neutral[600],
  267. fontSize: 12,
  268. },
  269. splitLine: { show: false },
  270. },
  271. yAxis: {
  272. type: 'value',
  273. min: 0,
  274. max: 100000,
  275. interval: 10000,
  276. axisLine: { show: false },
  277. axisTick: { show: false },
  278. axisLabel: {
  279. color: isDark.value ? twc.neutral[400] : twc.neutral[600],
  280. fontSize: 11,
  281. formatter(value: number) {
  282. if (value === 0) return '0'
  283. return `${(value / 1000).toFixed(0)},000`
  284. },
  285. },
  286. splitLine: {
  287. show: true,
  288. lineStyle: {
  289. color: isDark.value ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.08)',
  290. width: 1,
  291. },
  292. },
  293. },
  294. series: businessLinesWithData.value.map((line, idx) => ({
  295. name: line.name,
  296. type: 'line',
  297. data: line.data,
  298. symbol: 'circle',
  299. showSymbol: false,
  300. smooth: true,
  301. lineStyle: {
  302. color: line.color,
  303. width: idx < 2 ? 2 : 1,
  304. type: idx < 2 ? 'solid' : 'dashed',
  305. },
  306. itemStyle: {
  307. color: line.color,
  308. },
  309. emphasis: {
  310. focus: 'series',
  311. symbolSize: 6,
  312. itemStyle: {
  313. color: line.color,
  314. borderColor: line.color,
  315. borderWidth: 2,
  316. },
  317. },
  318. areaStyle: {
  319. color: {
  320. type: 'linear',
  321. x: 0,
  322. y: 0,
  323. x2: 0,
  324. y2: 1,
  325. colorStops: [
  326. { offset: 0, color: chroma(line.color).alpha(0.12).hex() },
  327. { offset: 1, color: chroma(line.color).alpha(0.02).hex() },
  328. ],
  329. },
  330. },
  331. })),
  332. animationDuration: 1000,
  333. animationEasing: 'cubicOut' as const,
  334. animationDelay(idx: number) {
  335. return idx * 100
  336. },
  337. }
  338. chart.setOption(option)
  339. chart.on('legendselectchanged', (params: any) => {
  340. if (params?.selected) {
  341. revenueChartSelected.value = params.selected
  342. }
  343. })
  344. revenueChartInstance = chart
  345. revenueChartResizeHandler = () => chart.resize()
  346. window.addEventListener('resize', revenueChartResizeHandler, { passive: true })
  347. }
  348. function initRevenueBarChart() {
  349. if (!revenueBarChart.value) return
  350. const chart = echarts.init(revenueBarChart.value)
  351. const option = {
  352. color: chartDataManager.getAllColors(),
  353. grid: { left: 5, right: 5, top: 60, bottom: 0, containLabel: false },
  354. xAxis: {
  355. type: 'category',
  356. boundaryGap: false,
  357. data: chartDataManager.getMonths(),
  358. show: false,
  359. },
  360. yAxis: {
  361. type: 'value',
  362. min: 0,
  363. show: false,
  364. },
  365. legend: {
  366. top: 6,
  367. itemWidth: 10,
  368. itemHeight: 10,
  369. itemGap: 16,
  370. inactiveColor: isDark.value ? twc.neutral[400] : twc.neutral[350],
  371. inactiveBorderColor: isDark.value ? twc.neutral[400] : twc.neutral[350],
  372. textStyle: {
  373. color: isDark.value ? twc.neutral[400] : twc.neutral[600],
  374. fontSize: 13,
  375. },
  376. selectedMode: 'single',
  377. selected: Object.fromEntries(
  378. businessLinesWithData.value.map((line) => [
  379. line.name,
  380. line.name === barChartSelectedLegend.value,
  381. ]),
  382. ),
  383. data: businessLinesWithData.value.map((line) => ({
  384. name: line.name,
  385. icon: 'circle',
  386. itemStyle: {
  387. borderColor: line.color,
  388. },
  389. })),
  390. },
  391. series: businessLinesWithData.value.map((line) => ({
  392. name: line.name,
  393. type: 'line',
  394. stack: 'Total',
  395. data: line.data,
  396. symbol: 'circle',
  397. symbolSize: 6,
  398. showSymbol: true,
  399. lineStyle: {
  400. width: 2,
  401. },
  402. itemStyle: {
  403. borderColor: isDark.value ? twc.neutral[800] : twc.neutral[50],
  404. borderWidth: 2,
  405. },
  406. emphasis: {
  407. focus: 'series',
  408. symbolSize: 6,
  409. itemStyle: {
  410. color: line.color,
  411. borderColor: line.color,
  412. borderWidth: 2,
  413. },
  414. },
  415. areaStyle: {
  416. color: {
  417. type: 'linear',
  418. x: 0,
  419. y: 0,
  420. x2: 0,
  421. y2: 1,
  422. colorStops: [
  423. { offset: 0, color: chroma(line.color).alpha(0.12).hex() },
  424. { offset: 1, color: chroma(line.color).alpha(0.02).hex() },
  425. ],
  426. },
  427. },
  428. })),
  429. tooltip: createTooltipConfig((params: any) => {
  430. const color = params[0].color
  431. const date = params[0].axisValue
  432. const value = params[0].value.toLocaleString()
  433. let result = `<div>${date}数据</div>`
  434. params.forEach((item: any) => {
  435. result += `
  436. <div style="display: flex; align-items: center; margin-top: 4px;">
  437. <span style="display:inline-block; margin-right:4px; width:10px; height:10px; border-radius:50%; background-color:${color};"></span>
  438. <span style="margin-right: 10px">${item.seriesName}</span>
  439. <span>${value}</span>
  440. </div>
  441. `
  442. })
  443. return result
  444. }),
  445. animationDuration: 1000,
  446. animationEasing: 'cubicOut' as const,
  447. animationDelay(idx: number) {
  448. return idx * 100
  449. },
  450. }
  451. chart.setOption(option)
  452. revenueBarChartInstance = chart
  453. revenueBarChartResizeHandler = () => chart.resize()
  454. window.addEventListener('resize', revenueBarChartResizeHandler, { passive: true })
  455. chart.on('legendselectchanged', (params: any) => {
  456. barChartSelectedLegend.value = params.name
  457. if (revenueBarChart2Instance) {
  458. const selectedLine = chartDataManager.getLineByName(params.name)
  459. if (!selectedLine) return
  460. revenueBarChart2Instance.setOption({
  461. tooltip: {
  462. trigger: 'axis',
  463. axisPointer: {
  464. type: 'none',
  465. },
  466. backgroundColor: isDark.value ? twc.neutral[750] : '#fff',
  467. borderWidth: 0,
  468. padding: 8,
  469. extraCssText: 'box-shadow: none;',
  470. textStyle: {
  471. color: isDark.value ? twc.neutral[400] : twc.neutral[600],
  472. fontSize: 12,
  473. },
  474. formatter(params: any) {
  475. const date = params[0].axisValue
  476. const seriesName = params[0].seriesName
  477. const value = params[0].value.toLocaleString()
  478. let result = `<div>${date}数据</div>`
  479. params.forEach(() => {
  480. result += `
  481. <div style="display: flex; align-items: center; margin-top: 4px;">
  482. <span style="display:inline-block; margin-right:4px; width:10px; height:10px; border-radius:50%; background-color:${selectedLine.color};"></span>
  483. <span style="margin-right: 10px">${seriesName}</span>
  484. <span>${value}</span>
  485. </div>
  486. `
  487. })
  488. return result
  489. },
  490. },
  491. series: [
  492. {
  493. name: params.name,
  494. type: 'bar',
  495. barWidth: '60%',
  496. data: selectedLine.data,
  497. itemStyle: {
  498. color: chroma(selectedLine.color).alpha(0.15).hex(),
  499. borderWidth: 0,
  500. borderRadius: [3, 3, 0, 0],
  501. },
  502. emphasis: {
  503. itemStyle: {
  504. color: chroma(selectedLine.color).alpha(0.3).hex(),
  505. borderWidth: 0,
  506. },
  507. },
  508. },
  509. ],
  510. })
  511. }
  512. })
  513. }
  514. function initRevenueBarChart2() {
  515. if (!revenueBarChart2.value) return
  516. const chart = echarts.init(revenueBarChart2.value)
  517. const selectedLine = chartDataManager.getLineByName(barChartSelectedLegend.value)
  518. if (!selectedLine) return
  519. const option = {
  520. color: chartDataManager.getAllColors(),
  521. grid: { left: 0, right: 0, top: 0, bottom: 0, containLabel: false },
  522. tooltip: {
  523. trigger: 'axis',
  524. axisPointer: {
  525. type: 'none',
  526. },
  527. backgroundColor: isDark.value ? twc.neutral[750] : '#fff',
  528. borderWidth: 0,
  529. padding: 8,
  530. extraCssText: 'box-shadow: none;',
  531. textStyle: {
  532. color: isDark.value ? twc.neutral[400] : twc.neutral[600],
  533. fontSize: 12,
  534. },
  535. formatter(params: any) {
  536. const date = params[0].axisValue
  537. const value = params[0].value.toLocaleString()
  538. const seriesName = params[0].seriesName
  539. let result = `<div>${date}数据</div>`
  540. params.forEach(() => {
  541. result += `
  542. <div style="display: flex; align-items: center; margin-top: 4px;">
  543. <span style="display:inline-block; margin-right:4px; width:10px; height:10px; border-radius:50%; background-color:${selectedLine.color};"></span>
  544. <span style="margin-right: 10px">${seriesName}</span>
  545. <span>${value}</span>
  546. </div>
  547. `
  548. })
  549. return result
  550. },
  551. },
  552. xAxis: {
  553. type: 'category',
  554. data: chartDataManager.getMonths(),
  555. show: false,
  556. },
  557. yAxis: {
  558. type: 'value',
  559. show: false,
  560. },
  561. series: [
  562. {
  563. name: barChartSelectedLegend.value,
  564. type: 'bar',
  565. barWidth: '60%',
  566. data: selectedLine.data,
  567. itemStyle: {
  568. color: chroma(selectedLine.color).alpha(0.15).hex(),
  569. borderWidth: 0,
  570. borderRadius: [3, 3, 0, 0],
  571. },
  572. emphasis: {
  573. itemStyle: {
  574. color: chroma(selectedLine.color).alpha(0.3).hex(),
  575. borderWidth: 0,
  576. },
  577. },
  578. },
  579. ],
  580. animationDuration: 1000,
  581. animationEasing: 'cubicOut' as const,
  582. animationDelay(idx: number) {
  583. return idx * 50
  584. },
  585. }
  586. chart.setOption(option)
  587. revenueBarChart2Instance = chart
  588. revenueBarChart2ResizeHandler = () => chart.resize()
  589. window.addEventListener('resize', revenueBarChart2ResizeHandler, { passive: true })
  590. }
  591. function initMonthlyRadarChart() {
  592. if (!monthlyRadarChart.value) return
  593. const now = new Date()
  594. const currentMonth = now.getMonth()
  595. const currentMonthData = chartDataManager.getCurrentMonthData(currentMonth)
  596. const chart = echarts.init(monthlyRadarChart.value)
  597. const option = {
  598. title: [
  599. {
  600. text: '当月各业务收入',
  601. left: 0,
  602. top: 0,
  603. textStyle: {
  604. fontSize: 15,
  605. color: isDark.value ? twc.neutral[400] : twc.neutral[600],
  606. fontWeight: 'normal',
  607. },
  608. },
  609. {
  610. text: `${currentMonth + 1}月数据对比`,
  611. left: 0,
  612. top: 28,
  613. textStyle: {
  614. fontSize: 14,
  615. color: isDark.value ? twc.neutral[500] : twc.neutral[450],
  616. fontWeight: 'normal',
  617. },
  618. },
  619. ],
  620. tooltip: {
  621. trigger: 'axis',
  622. axisPointer: {
  623. type: 'none',
  624. },
  625. backgroundColor: isDark.value ? twc.neutral[750] : '#fff',
  626. borderWidth: 0,
  627. padding: 8,
  628. extraCssText: 'box-shadow: none;',
  629. textStyle: {
  630. color: isDark.value ? twc.neutral[400] : twc.neutral[600],
  631. fontSize: 12,
  632. },
  633. formatter(params: any) {
  634. const color = params[0].color.slice(0, -2)
  635. const date = params[0].axisValue
  636. const value = params[0].data.value
  637. let result = `<div>${date}</div>`
  638. params.forEach(() => {
  639. result += `
  640. <div style="display: flex; align-items: center; margin-top: 4px;">
  641. <span style="display:inline-block; margin-right:8px; width:10px; height:10px; border-radius:50%; background-color:${color};"></span>
  642. <span>${value}</span>
  643. </div>
  644. `
  645. })
  646. return result
  647. },
  648. },
  649. grid: {
  650. left: 10,
  651. right: 5,
  652. top: 100,
  653. bottom: -10,
  654. containLabel: true,
  655. },
  656. xAxis: {
  657. type: 'category',
  658. show: false,
  659. data: chartDataManager.getAllNames(),
  660. },
  661. yAxis: {
  662. type: 'value',
  663. axisLine: {
  664. show: false,
  665. },
  666. axisTick: {
  667. show: false,
  668. },
  669. splitLine: {
  670. lineStyle: {
  671. color: isDark.value ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.05)',
  672. type: 'dashed',
  673. },
  674. },
  675. axisLabel: {
  676. color: isDark.value ? twc.neutral[400] : twc.neutral[600],
  677. fontSize: 12,
  678. formatter(value: number) {
  679. if (value >= 1000) {
  680. return `${(value / 1000).toFixed(0)}k`
  681. }
  682. return value
  683. },
  684. },
  685. },
  686. series: [
  687. {
  688. type: 'bar',
  689. barWidth: '50%',
  690. data: currentMonthData.map((item) => ({
  691. value: item.value,
  692. itemStyle: {
  693. color: chroma(item.color).alpha(0.35).hex(),
  694. borderRadius: [3, 3, 0, 0],
  695. },
  696. })),
  697. label: {
  698. show: true,
  699. position: 'top',
  700. distance: 10,
  701. color: isDark.value ? twc.neutral[400] : twc.neutral[600],
  702. fontSize: 12,
  703. formatter(params: any) {
  704. const index = params.dataIndex
  705. return `${chartDataManager.getAllNames()[index]}`
  706. },
  707. },
  708. },
  709. ],
  710. animationDuration: 1000,
  711. animationEasing: 'cubicOut' as const,
  712. }
  713. chart.setOption(option)
  714. monthlyRadarChartInstance = chart
  715. monthlyRadarChartResizeHandler = () => chart.resize()
  716. window.addEventListener('resize', monthlyRadarChartResizeHandler, { passive: true })
  717. }
  718. function initHighestRevenueChart() {
  719. if (!highestRevenueChart.value) return
  720. const highestLine = chartDataManager.getHighestRevenueLine()
  721. const lowestLine = chartDataManager.getLowestRevenueLine()
  722. const chartData = [
  723. {
  724. legendName: '最高',
  725. legendValue: 'max',
  726. businessName: highestLine.name,
  727. color: highestLine.color,
  728. data: highestLine.data,
  729. },
  730. {
  731. legendName: '最低',
  732. legendValue: 'min',
  733. businessName: lowestLine.name,
  734. color: lowestLine.color,
  735. data: lowestLine.data,
  736. },
  737. ]
  738. const chart = echarts.init(highestRevenueChart.value)
  739. const legendSelected: Record<string, boolean> = {
  740. max: highestChartSelected.value === 'max',
  741. min: highestChartSelected.value === 'min',
  742. }
  743. const option = {
  744. title: [
  745. {
  746. text: '年度最高业务收入',
  747. left: 0,
  748. top: 0,
  749. textStyle: {
  750. fontSize: 15,
  751. color: isDark.value ? twc.neutral[400] : twc.neutral[600],
  752. fontWeight: 'normal',
  753. },
  754. },
  755. {
  756. text: `{a|${legendSelected.max ? chartData[0].businessName : chartData[1].businessName}}`,
  757. left: 0,
  758. top: 24,
  759. textStyle: {
  760. rich: {
  761. a: {
  762. fontSize: 14,
  763. color: legendSelected.max ? chartData[0].color : chartData[1].color,
  764. backgroundColor: chroma(legendSelected.max ? chartData[0].color : chartData[1].color)
  765. .alpha(0.1)
  766. .hex(),
  767. padding: [4, 6],
  768. borderRadius: 2,
  769. lineHeight: 22,
  770. },
  771. },
  772. },
  773. },
  774. ],
  775. color: [chartData[0].color, chartData[1].color],
  776. grid: { left: -30, right: -30, top: 70, bottom: 0, containLabel: false },
  777. xAxis: {
  778. type: 'category',
  779. data: chartDataManager.getMonths(),
  780. show: false,
  781. },
  782. yAxis: {
  783. axisLine: { show: false },
  784. axisTick: { show: false },
  785. splitLine: {
  786. lineStyle: {
  787. color: isDark.value ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.05)',
  788. type: 'dashed',
  789. },
  790. },
  791. },
  792. legend: {
  793. top: 6,
  794. right: 22,
  795. orient: 'horizontal',
  796. itemGap: 16,
  797. icon: 'circle',
  798. itemWidth: 12,
  799. itemHeight: 12,
  800. inactiveBorderWidth: 0,
  801. inactiveColor: isDark.value ? twc.neutral[400] : twc.neutral[350],
  802. inactiveBorderColor: isDark.value ? twc.neutral[400] : twc.neutral[350],
  803. textStyle: {
  804. color: isDark.value ? twc.neutral[400] : twc.neutral[600],
  805. fontSize: 14,
  806. },
  807. data: chartData.map((item) => ({
  808. name: item.legendName,
  809. itemStyle: {
  810. borderColor: item.color,
  811. borderWidth: 0,
  812. },
  813. })),
  814. selectedMode: 'single',
  815. selected: Object.fromEntries(
  816. chartData.map((item) => [item.legendName, legendSelected[item.legendValue] ?? false]),
  817. ),
  818. },
  819. tooltip: {
  820. trigger: 'axis',
  821. axisPointer: { type: 'none' },
  822. backgroundColor: isDark.value ? twc.neutral[750] : '#fff',
  823. borderWidth: 0,
  824. padding: 8,
  825. extraCssText: 'box-shadow: none;',
  826. textStyle: {
  827. color: isDark.value ? twc.neutral[400] : twc.neutral[600],
  828. fontSize: 12,
  829. },
  830. formatter(params: any) {
  831. const color = params[0].color
  832. const date = params[0].axisValue
  833. const value = params[0].value.toLocaleString()
  834. const chartItem = chartData.find((item) => item.legendName === params[0].seriesName)
  835. const realName = chartItem ? chartItem.businessName : params[0].seriesName
  836. let result = `<div>${date}数据</div>`
  837. result += `
  838. <div style="display: flex; align-items: center; margin-top: 4px;">
  839. <span style="display:inline-block; margin-right:4px; width:10px; height:10px; border-radius:50%; background-color:${color};"></span>
  840. <span style="margin-right: 10px">${realName}</span>
  841. <span>${value}</span>
  842. </div>
  843. `
  844. return result
  845. },
  846. },
  847. series: chartData.map((item) => ({
  848. name: item.legendName,
  849. type: 'line',
  850. step: 'middle',
  851. data: item.data,
  852. left: 0,
  853. symbol: 'circle',
  854. symbolSize: 6,
  855. showSymbol: true,
  856. lineStyle: {
  857. width: 2,
  858. color: item.color,
  859. },
  860. itemStyle: {
  861. color: item.color,
  862. borderColor: isDark.value ? twc.neutral[800] : twc.neutral[50],
  863. borderWidth: 2,
  864. },
  865. areaStyle: {
  866. color: {
  867. type: 'linear',
  868. x: 0,
  869. y: 0,
  870. x2: 0,
  871. y2: 1,
  872. colorStops: [
  873. { offset: 0, color: chroma(item.color).alpha(0.12).hex() },
  874. { offset: 1, color: chroma(item.color).alpha(0.02).hex() },
  875. ],
  876. },
  877. },
  878. })),
  879. animationDuration: 1000,
  880. animationEasing: 'cubicOut' as const,
  881. animationDelay(idx: number) {
  882. return idx * 100
  883. },
  884. }
  885. chart.setOption(option)
  886. highestRevenueChartInstance = chart
  887. highestRevenueChartResizeHandler = () => chart.resize()
  888. window.addEventListener('resize', highestRevenueChartResizeHandler, { passive: true })
  889. chart.on('legendselectchanged', (params: any) => {
  890. const chartItem = chartData.find((item) => item.legendName === params.name)
  891. if (!chartItem) return
  892. const isHighest = chartItem.legendValue === 'max'
  893. highestChartSelected.value = isHighest ? 'max' : 'min'
  894. chart.setOption({
  895. title: [
  896. {
  897. text: isHighest ? '年度最高收入业务' : '年度最低收入业务',
  898. },
  899. {
  900. text: `{a|${chartItem.businessName}}`,
  901. textStyle: {
  902. rich: {
  903. a: {
  904. color: chartItem.color,
  905. backgroundColor: chroma(chartItem.color).alpha(0.1).hex(),
  906. },
  907. },
  908. },
  909. },
  910. ],
  911. })
  912. })
  913. }
  914. onMounted(() => {
  915. initRevenueChart()
  916. initRevenueBarChart()
  917. initRevenueBarChart2()
  918. initMonthlyRadarChart()
  919. initHighestRevenueChart()
  920. })
  921. onUnmounted(() => {
  922. if (revenueChartInstance) {
  923. if (revenueChartResizeHandler) {
  924. window.removeEventListener('resize', revenueChartResizeHandler)
  925. revenueChartResizeHandler = null
  926. }
  927. revenueChartInstance.dispose()
  928. revenueChartInstance = null
  929. }
  930. if (revenueBarChartInstance) {
  931. if (revenueBarChartResizeHandler) {
  932. window.removeEventListener('resize', revenueBarChartResizeHandler)
  933. revenueBarChartResizeHandler = null
  934. }
  935. revenueBarChartInstance.dispose()
  936. revenueBarChartInstance = null
  937. }
  938. if (revenueBarChart2Instance) {
  939. if (revenueBarChart2ResizeHandler) {
  940. window.removeEventListener('resize', revenueBarChart2ResizeHandler)
  941. revenueBarChart2ResizeHandler = null
  942. }
  943. revenueBarChart2Instance.dispose()
  944. revenueBarChart2Instance = null
  945. }
  946. if (monthlyRadarChartInstance) {
  947. if (monthlyRadarChartResizeHandler) {
  948. window.removeEventListener('resize', monthlyRadarChartResizeHandler)
  949. monthlyRadarChartResizeHandler = null
  950. }
  951. monthlyRadarChartInstance.dispose()
  952. monthlyRadarChartInstance = null
  953. }
  954. if (highestRevenueChartInstance) {
  955. if (highestRevenueChartResizeHandler) {
  956. window.removeEventListener('resize', highestRevenueChartResizeHandler)
  957. highestRevenueChartResizeHandler = null
  958. }
  959. highestRevenueChartInstance.dispose()
  960. highestRevenueChartInstance = null
  961. }
  962. if (collapseResizeTimeout !== null) {
  963. clearTimeout(collapseResizeTimeout)
  964. collapseResizeTimeout = null
  965. }
  966. })
  967. function resizeAllCharts() {
  968. if (revenueChartInstance) revenueChartInstance.resize()
  969. if (revenueBarChartInstance) revenueBarChartInstance.resize()
  970. if (revenueBarChart2Instance) revenueBarChart2Instance.resize()
  971. if (monthlyRadarChartInstance) monthlyRadarChartInstance.resize()
  972. if (highestRevenueChartInstance) highestRevenueChartInstance.resize()
  973. }
  974. watch(
  975. [
  976. () => preferencesStore.preferences.sidebarMenu.collapsed,
  977. () => preferencesStore.preferences.navigationMode,
  978. ],
  979. () => {
  980. if (collapseResizeTimeout !== null) {
  981. clearTimeout(collapseResizeTimeout)
  982. collapseResizeTimeout = null
  983. }
  984. nextTick(() => {
  985. collapseResizeTimeout = setTimeout(() => {
  986. resizeAllCharts()
  987. }, 350)
  988. })
  989. },
  990. )
  991. watch([isDark, color], () => {
  992. if (revenueChartInstance) {
  993. if (revenueChartResizeHandler) {
  994. window.removeEventListener('resize', revenueChartResizeHandler)
  995. revenueChartResizeHandler = null
  996. }
  997. revenueChartInstance.dispose()
  998. revenueChartInstance = null
  999. }
  1000. if (revenueBarChartInstance) {
  1001. if (revenueBarChartResizeHandler) {
  1002. window.removeEventListener('resize', revenueBarChartResizeHandler)
  1003. revenueBarChartResizeHandler = null
  1004. }
  1005. revenueBarChartInstance.dispose()
  1006. revenueBarChartInstance = null
  1007. }
  1008. if (revenueBarChart2Instance) {
  1009. if (revenueBarChart2ResizeHandler) {
  1010. window.removeEventListener('resize', revenueBarChart2ResizeHandler)
  1011. revenueBarChart2ResizeHandler = null
  1012. }
  1013. revenueBarChart2Instance.dispose()
  1014. revenueBarChart2Instance = null
  1015. }
  1016. if (monthlyRadarChartInstance) {
  1017. if (monthlyRadarChartResizeHandler) {
  1018. window.removeEventListener('resize', monthlyRadarChartResizeHandler)
  1019. monthlyRadarChartResizeHandler = null
  1020. }
  1021. monthlyRadarChartInstance.dispose()
  1022. monthlyRadarChartInstance = null
  1023. }
  1024. if (highestRevenueChartInstance) {
  1025. if (highestRevenueChartResizeHandler) {
  1026. window.removeEventListener('resize', highestRevenueChartResizeHandler)
  1027. highestRevenueChartResizeHandler = null
  1028. }
  1029. highestRevenueChartInstance.dispose()
  1030. highestRevenueChartInstance = null
  1031. }
  1032. initRevenueChart()
  1033. initRevenueBarChart()
  1034. initRevenueBarChart2()
  1035. initMonthlyRadarChart()
  1036. initHighestRevenueChart()
  1037. })
  1038. </script>
  1039. <template>
  1040. <ContentWrapper content-class="flex flex-col gap-y-4 max-sm:gap-y-2">
  1041. <div class="grid grid-cols-1 gap-4 max-sm:gap-2 md:grid-cols-2 lg:grid-cols-4">
  1042. <div
  1043. v-for="{
  1044. title,
  1045. value,
  1046. precision,
  1047. percentage,
  1048. description,
  1049. iconBgClass,
  1050. iconClass,
  1051. } in cardList"
  1052. :key="title"
  1053. class="flex items-center justify-between gap-x-4 overflow-hidden rounded bg-naive-card p-6 shadow-xs transition-[background-color]"
  1054. >
  1055. <div class="flex-1">
  1056. <span class="text-sm font-medium text-neutral-450">{{ title }}</span>
  1057. <div class="mt-1 mb-1.5 flex gap-x-4 text-2xl text-neutral-700 dark:text-neutral-400">
  1058. <NNumberAnimation
  1059. :to="value"
  1060. show-separator
  1061. :precision="precision"
  1062. />
  1063. </div>
  1064. <div class="flex items-center">
  1065. <div
  1066. class="flex items-center gap-x-0.5 rounded-xs px-1.5 py-0.5 text-xs transition-[background-color,color]"
  1067. :class="
  1068. percentage > 0
  1069. ? 'bg-green-100 text-green-600 dark:bg-green-500/20 dark:text-green-400'
  1070. : 'bg-rose-100 text-rose-600 dark:bg-rose-500/20 dark:text-rose-400'
  1071. "
  1072. >
  1073. <span
  1074. :class="percentage > 0 ? 'iconify ph--arrow-up' : 'iconify ph--arrow-down'"
  1075. ></span>
  1076. <span class="font-medium">{{ Math.abs(percentage) }}%</span>
  1077. </div>
  1078. <span class="ml-2 text-neutral-500 dark:text-neutral-400">{{ description }}</span>
  1079. </div>
  1080. </div>
  1081. <div>
  1082. <div
  1083. class="grid place-items-center rounded-full p-3"
  1084. :class="iconBgClass"
  1085. >
  1086. <span
  1087. class="size-7"
  1088. :class="iconClass"
  1089. />
  1090. </div>
  1091. </div>
  1092. </div>
  1093. </div>
  1094. <div class="grid grid-cols-1 gap-4 overflow-hidden max-sm:gap-2 lg:grid-cols-12">
  1095. <div class="col-span-1 lg:col-span-8">
  1096. <div
  1097. class="rounded bg-naive-card px-5 pt-5 pb-4.5 shadow-xs transition-[background-color]"
  1098. style="height: 400px"
  1099. >
  1100. <div
  1101. ref="revenueChart"
  1102. class="h-full"
  1103. />
  1104. </div>
  1105. </div>
  1106. <div class="col-span-1 lg:col-span-4">
  1107. <div
  1108. class="flex flex-col rounded bg-naive-card px-5 pt-5 pb-4.5 shadow-xs transition-[background-color]"
  1109. style="height: 400px"
  1110. >
  1111. <div
  1112. ref="revenueBarChart"
  1113. class="flex-1"
  1114. />
  1115. <div
  1116. ref="revenueBarChart2"
  1117. style="height: 150px"
  1118. />
  1119. </div>
  1120. </div>
  1121. </div>
  1122. <div class="grid grid-cols-1 gap-4 overflow-hidden max-sm:gap-2 lg:grid-cols-12">
  1123. <div class="col-span-1 lg:col-span-5">
  1124. <div
  1125. class="rounded bg-naive-card px-5 pt-5 pb-3 shadow-xs transition-[background-color]"
  1126. style="height: 340px"
  1127. >
  1128. <div
  1129. ref="monthlyRadarChart"
  1130. class="h-full"
  1131. />
  1132. </div>
  1133. </div>
  1134. <div class="col-span-1 lg:col-span-7">
  1135. <div
  1136. class="rounded bg-naive-card p-5 shadow-xs transition-[background-color]"
  1137. style="height: 340px; position: relative"
  1138. >
  1139. <div
  1140. ref="highestRevenueChart"
  1141. class="h-full"
  1142. />
  1143. </div>
  1144. </div>
  1145. </div>
  1146. </ContentWrapper>
  1147. </template>