index.vue 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655
  1. <template>
  2. <div class="fission-analysis-container">
  3. <el-card class="analysis-card mt-4" shadow="never">
  4. <div class="date-selector-container">
  5. <!-- Top Navigation Section -->
  6. <div class="date-period-selector">
  7. <el-button
  8. :class="{ active: activePeriod === 'yesterday' }"
  9. @click="changePeriod('yesterday')"
  10. >昨日</el-button
  11. >
  12. <el-button
  13. :class="{ active: activePeriod === '7d' }"
  14. @click="changePeriod('7d')"
  15. >7日</el-button
  16. >
  17. <el-button
  18. :class="{ active: activePeriod === '15d' }"
  19. @click="changePeriod('15d')"
  20. >15日</el-button
  21. >
  22. <el-button
  23. :class="{ active: activePeriod === '30d' }"
  24. @click="changePeriod('30d')"
  25. >30日</el-button
  26. >
  27. <el-date-picker
  28. v-model="dateRange"
  29. type="daterange"
  30. range-separator="至"
  31. start-placeholder="开始日期"
  32. end-placeholder="结束日期"
  33. format="YYYY-MM-DD"
  34. value-format="YYYY-MM-DD"
  35. :clearable="false"
  36. :disabled-date="(date) => false"
  37. @change="handleDateRangeChange"
  38. />
  39. <div class="date-action-buttons">
  40. <el-button type="primary" @click="fetchData"
  41. >搜索</el-button
  42. >
  43. <el-button @click="resetFilters">重置</el-button>
  44. </div>
  45. </div>
  46. </div>
  47. <!-- Metrics Cards -->
  48. <div class="metrics-cards">
  49. <el-row :gutter="20" class="metrics-row">
  50. <el-col v-for="(item, index) in metricsCardsData" :key="index" class="metric-col">
  51. <el-card shadow="never" class="metric-card">
  52. <div class="metric-title">{{ item.title }}</div>
  53. <div class="metric-date">{{ displayDateRange }}</div>
  54. <div class="metric-value-container">
  55. <div class="metric-label">合计</div>
  56. <div class="metric-value">{{ item.value }}</div>
  57. </div>
  58. <div class="metric-comparison">
  59. 环比 {{ item.change }}%
  60. <template v-if="parseFloat(item.change) > 0"><span class="arrow-up">↑</span></template>
  61. <template v-else-if="parseFloat(item.change) < 0"><span class="arrow-down">↓</span></template>
  62. </div>
  63. </el-card>
  64. </el-col>
  65. </el-row>
  66. </div>
  67. <!-- Numbers Cards -->
  68. <div class="number-metrics">
  69. <el-row :gutter="20">
  70. <el-col :span="4" v-for="(item, index) in numberMetricsData" :key="index">
  71. <el-card shadow="hover" class="number-card">
  72. <div class="number-value">{{ item.value }}</div>
  73. <div class="number-label">{{ item.label }}</div>
  74. </el-card>
  75. </el-col>
  76. </el-row>
  77. </div>
  78. <!-- Chart Section -->
  79. <div class="trend-chart-section">
  80. <div class="chart-title">30日分享人数趋势</div>
  81. <div class="chart-container" ref="chartRef"></div>
  82. </div>
  83. </el-card>
  84. </div>
  85. </template>
  86. <script setup>
  87. import { ref, onMounted, computed, watch } from 'vue';
  88. import * as echarts from 'echarts';
  89. import dayjs from 'dayjs';
  90. import request from '@/utils/request';
  91. import { ElMessage } from 'element-plus';
  92. // State
  93. const activePeriod = ref('7d');
  94. const chartRef = ref(null);
  95. let chart = null;
  96. // Track if custom date range is used
  97. const isCustomDateRange = ref(false);
  98. // Initialize dateRange with 7 days range
  99. const dateRange = ref([
  100. dayjs().subtract(7, 'day').format('YYYY-MM-DD'),
  101. dayjs().format('YYYY-MM-DD')
  102. ]);
  103. // API data
  104. const statisticData = ref(null);
  105. // Metrics computed from API response
  106. const metrics = computed(() => {
  107. if (!statisticData.value) {
  108. return {
  109. totalUsers: '',
  110. totalUsersChange: '0',
  111. orders: '',
  112. ordersChange: '0',
  113. scanNum: '',
  114. scanNumChange: '0',
  115. scanRate: '',
  116. scanRateChange: '0',
  117. newUserRate: '',
  118. newUserRateChange: '0'
  119. };
  120. }
  121. const data = statisticData.value.data;
  122. // Helper function to format comparison values consistently
  123. const formatComparison = (value) => {
  124. if (value === null || value === undefined) return '0';
  125. // Remove % if present and return just the number as string
  126. return String(value).replace('%', '');
  127. };
  128. return {
  129. totalUsers: data.upsellHelpNum || '0',
  130. totalUsersChange: formatComparison(data.upsellHelpNumComparison),
  131. orders: data.upsellOrderNum || '0',
  132. ordersChange: formatComparison(data.upsellOrderNumComparison),
  133. scanNum: data.upsellScanNum || '0',
  134. scanNumChange: formatComparison(data.upsellScanNumComparison),
  135. scanRate: data.scanJoinRate ? `${data.scanJoinRate}%` : '0%',
  136. scanRateChange: formatComparison(data.scanJoinRateComparison),
  137. newUserRate: data.newUserRate ? `${data.newUserRate}%` : '0%',
  138. newUserRateChange: formatComparison(data.newUserRateComparison)
  139. };
  140. });
  141. const stats = computed(() => {
  142. if (!statisticData.value) {
  143. return {
  144. participants: '0',
  145. helps: '0',
  146. ordersCount: '0',
  147. totalOrderAmount: '0',
  148. totalAddedPoints: '0',
  149. totalSaleAmount: '0'
  150. };
  151. }
  152. const data = statisticData.value.data;
  153. return {
  154. participants: data.upsellShareNum || '0',
  155. helps: data.upsellHelpTimes || '0',
  156. ordersCount: data.orderBookNum || '0',
  157. totalOrderAmount: data.orderTotalMoney || '0',
  158. totalAddedPoints: data.upsellTotalMoney || '0',
  159. totalSaleAmount: data.upsellFinalTotalMoney || '0'
  160. };
  161. });
  162. // 数字指标卡片数据
  163. const numberMetricsData = computed(() => {
  164. return [
  165. { label: '参与用户数', value: stats.value.participants },
  166. { label: '助力次数', value: stats.value.helps },
  167. { label: '订单总数', value: stats.value.ordersCount },
  168. { label: '订单预估总金额', value: stats.value.totalOrderAmount },
  169. { label: '加价预估总金额', value: stats.value.totalAddedPoints },
  170. { label: '实际加价金额', value: stats.value.totalSaleAmount }
  171. ];
  172. });
  173. // 度量指标卡片数据
  174. const metricsCardsData = computed(() => {
  175. return [
  176. {
  177. title: '助力人数',
  178. value: `${metrics.value.totalUsers}人`,
  179. change: metrics.value.totalUsersChange
  180. },
  181. {
  182. title: '参与活动订单数',
  183. value: `${metrics.value.orders}单`,
  184. change: metrics.value.ordersChange
  185. },
  186. {
  187. title: '活动书单扫描次数',
  188. value: `${metrics.value.scanNum}次`,
  189. change: metrics.value.scanNumChange
  190. },
  191. {
  192. title: '活动书单扫描/参与占比',
  193. value: metrics.value.scanRate,
  194. change: metrics.value.scanRateChange
  195. },
  196. {
  197. title: '助力新用户占比',
  198. value: metrics.value.newUserRate,
  199. change: metrics.value.newUserRateChange
  200. }
  201. ];
  202. });
  203. // Chart data
  204. const chartData = computed(() => {
  205. if (!statisticData.value || !statisticData.value.data.chartList) {
  206. return {
  207. dates: [],
  208. values: []
  209. };
  210. }
  211. const chartList = statisticData.value.data.chartList;
  212. return {
  213. dates: chartList.map((item) => item.date),
  214. values: chartList.map((item) => item.shareNum)
  215. };
  216. });
  217. const currentDate = computed(() => {
  218. return dayjs().format('YYYY-MM-DD');
  219. });
  220. const displayDateRange = computed(() => {
  221. if (Array.isArray(dateRange.value) && dateRange.value.length === 2) {
  222. return `${dateRange.value[0]} - ${dateRange.value[1]}`;
  223. } else {
  224. return currentDate.value;
  225. }
  226. });
  227. // Method to update date range based on period
  228. const updateDateRangeByPeriod = (period) => {
  229. isCustomDateRange.value = false;
  230. switch (period) {
  231. case 'yesterday':
  232. dateRange.value = [
  233. dayjs().subtract(1, 'day').format('YYYY-MM-DD'),
  234. dayjs().subtract(1, 'day').format('YYYY-MM-DD')
  235. ];
  236. break;
  237. case '7d':
  238. dateRange.value = [
  239. dayjs().subtract(7, 'day').format('YYYY-MM-DD'),
  240. dayjs().format('YYYY-MM-DD')
  241. ];
  242. break;
  243. case '15d':
  244. dateRange.value = [
  245. dayjs().subtract(15, 'day').format('YYYY-MM-DD'),
  246. dayjs().format('YYYY-MM-DD')
  247. ];
  248. break;
  249. case '30d':
  250. dateRange.value = [
  251. dayjs().subtract(30, 'day').format('YYYY-MM-DD'),
  252. dayjs().format('YYYY-MM-DD')
  253. ];
  254. break;
  255. default:
  256. dateRange.value = [
  257. dayjs().subtract(7, 'day').format('YYYY-MM-DD'),
  258. dayjs().format('YYYY-MM-DD')
  259. ];
  260. }
  261. };
  262. // Method to handle period change button clicks
  263. const changePeriod = (period) => {
  264. activePeriod.value = period;
  265. updateDateRangeByPeriod(period);
  266. fetchData();
  267. };
  268. // Handle date picker change
  269. const handleDateRangeChange = () => {
  270. isCustomDateRange.value = true;
  271. activePeriod.value = '';
  272. fetchData();
  273. };
  274. const getDateRangeForAPI = () => {
  275. let startDate, endDate;
  276. if (Array.isArray(dateRange.value) && dateRange.value.length === 2) {
  277. startDate = dayjs(dateRange.value[0]).format('YYYY-MM-DD 00:00:00');
  278. endDate = dayjs(dateRange.value[1]).format('YYYY-MM-DD 23:59:59');
  279. } else {
  280. // Fallback if dateRange is not an array
  281. startDate = dayjs().subtract(7, 'day').format('YYYY-MM-DD 00:00:00');
  282. endDate = dayjs().format('YYYY-MM-DD 23:59:59');
  283. }
  284. return { startDate, endDate };
  285. };
  286. const fetchData = async () => {
  287. const { startDate, endDate } = getDateRangeForAPI();
  288. try {
  289. const { data } = await request.get(
  290. '/activity/activityUpsellInfo/statistic',
  291. {
  292. params: {
  293. startDate,
  294. endDate
  295. }
  296. }
  297. );
  298. statisticData.value = data;
  299. updateChart();
  300. } catch (error) {
  301. ElMessage.error('获取数据失败');
  302. console.error('Failed to fetch statistic data:', error);
  303. }
  304. };
  305. const resetFilters = () => {
  306. isCustomDateRange.value = false;
  307. activePeriod.value = '7d';
  308. updateDateRangeByPeriod('7d');
  309. fetchData();
  310. };
  311. const updateChart = () => {
  312. if (!chart || !chartData.value.dates.length) return;
  313. const option = {
  314. tooltip: {
  315. trigger: 'axis',
  316. axisPointer: {
  317. type: 'shadow'
  318. }
  319. },
  320. grid: {
  321. left: '3%',
  322. right: '4%',
  323. bottom: '3%',
  324. containLabel: true
  325. },
  326. xAxis: {
  327. type: 'category',
  328. data: chartData.value.dates,
  329. boundaryGap: false
  330. },
  331. yAxis: {
  332. type: 'value',
  333. min: 0
  334. },
  335. series: [
  336. {
  337. name: '分享人数',
  338. type: 'line',
  339. data: chartData.value.values,
  340. smooth: true,
  341. lineStyle: {
  342. color: '#5470c6',
  343. width: 2
  344. },
  345. symbol: 'circle',
  346. symbolSize: 8,
  347. itemStyle: {
  348. color: '#5470c6'
  349. }
  350. }
  351. ]
  352. };
  353. chart.setOption(option);
  354. };
  355. const initChart = () => {
  356. if (chartRef.value) {
  357. chart = echarts.init(chartRef.value);
  358. // Initialize with empty data, will be updated after API call
  359. const option = {
  360. tooltip: {
  361. trigger: 'axis',
  362. axisPointer: {
  363. type: 'shadow'
  364. }
  365. },
  366. grid: {
  367. left: '3%',
  368. right: '4%',
  369. bottom: '3%',
  370. containLabel: true
  371. },
  372. xAxis: {
  373. type: 'category',
  374. data: [],
  375. boundaryGap: false
  376. },
  377. yAxis: {
  378. type: 'value',
  379. min: 0
  380. },
  381. series: [
  382. {
  383. name: '分享人数',
  384. type: 'line',
  385. data: [],
  386. smooth: true,
  387. lineStyle: {
  388. color: '#5470c6',
  389. width: 2
  390. },
  391. symbol: 'circle',
  392. symbolSize: 8,
  393. itemStyle: {
  394. color: '#5470c6'
  395. }
  396. }
  397. ]
  398. };
  399. chart.setOption(option);
  400. // Handle resize
  401. window.addEventListener('resize', () => {
  402. chart.resize();
  403. });
  404. // Use ResizeObserver to detect and respond to size changes of the chart container
  405. const resizeObserver = new ResizeObserver(() => {
  406. chart.resize();
  407. });
  408. resizeObserver.observe(chartRef.value);
  409. }
  410. };
  411. // Method to manually resize chart - can be called from parent components
  412. const resizeChart = () => {
  413. if (chart) {
  414. chart.resize();
  415. }
  416. };
  417. // Watch for changes in dateRange to update activePeriod
  418. watch(dateRange, (newValue) => {
  419. if (isCustomDateRange.value) {
  420. activePeriod.value = '';
  421. }
  422. });
  423. onMounted(() => {
  424. initChart();
  425. activePeriod.value = '7d';
  426. updateDateRangeByPeriod('7d');
  427. fetchData();
  428. // Force a resize after component is mounted
  429. setTimeout(() => {
  430. resizeChart();
  431. }, 300);
  432. });
  433. // Expose the resizeChart method to parent components
  434. defineExpose({
  435. resizeChart
  436. });
  437. </script>
  438. <style lang="scss" scoped>
  439. .date-selector-container {
  440. display: flex;
  441. align-items: center;
  442. justify-content: space-between;
  443. margin-bottom: 20px;
  444. border-bottom: 1px solid #ebeef5;
  445. padding-bottom: 16px;
  446. }
  447. .arrow-up {
  448. color: #67C23A; /* Element Plus success color (green) */
  449. }
  450. .arrow-down {
  451. color: #F56C6C; /* Element Plus danger color (red) */
  452. }
  453. .date-period-selector {
  454. display: flex;
  455. gap: 10px;
  456. .el-button {
  457. border-radius: 4px;
  458. }
  459. .active {
  460. color: #409eff;
  461. border-color: #409eff;
  462. }
  463. }
  464. .custom-date-range {
  465. display: flex;
  466. align-items: center;
  467. gap: 16px;
  468. }
  469. .time-range-selector {
  470. display: flex;
  471. align-items: center;
  472. gap: 8px;
  473. .time-label {
  474. font-size: 14px;
  475. color: #606266;
  476. }
  477. }
  478. .time-slider {
  479. width: 200px;
  480. }
  481. .date-action-buttons {
  482. display: flex;
  483. gap: 8px;
  484. }
  485. .date-display {
  486. display: flex;
  487. align-items: center;
  488. gap: 4px;
  489. cursor: pointer;
  490. padding: 6px 12px;
  491. border: 1px solid #dcdfe6;
  492. border-radius: 4px;
  493. margin-bottom: 20px;
  494. }
  495. .metrics-cards {
  496. margin-bottom: 24px;
  497. }
  498. .metrics-row {
  499. display: flex;
  500. flex-wrap: nowrap;
  501. }
  502. .metric-col {
  503. flex: 1;
  504. }
  505. .metric-card {
  506. border: 1px solid #ebeef5;
  507. height: 150px;
  508. position: relative;
  509. &:first-child {
  510. border-left: 3px solid #409eff;
  511. }
  512. .metric-title {
  513. font-size: 14px;
  514. color: #303133;
  515. margin-bottom: 4px;
  516. }
  517. .metric-date {
  518. font-size: 12px;
  519. color: #909399;
  520. }
  521. .metric-value-container {
  522. display: flex;
  523. margin-bottom: 4px;
  524. align-items: center;
  525. .metric-label {
  526. font-size: 14px;
  527. color: #606266;
  528. margin-right: 10px;
  529. }
  530. .metric-value {
  531. font-size: 32px;
  532. font-weight: bold;
  533. color: #303133;
  534. }
  535. }
  536. .metric-comparison {
  537. font-size: 12px;
  538. color: #606266;
  539. position: absolute;
  540. bottom: 12px;
  541. }
  542. }
  543. .number-metrics {
  544. margin-bottom: 24px;
  545. }
  546. .number-card {
  547. text-align: center;
  548. padding: 16px;
  549. height: 120px;
  550. .number-value {
  551. font-size: 22px;
  552. font-weight: bold;
  553. color: #f6a623;
  554. margin-bottom: 8px;
  555. }
  556. .number-label {
  557. font-size: 14px;
  558. color: #606266;
  559. }
  560. }
  561. .trend-chart-section {
  562. margin-top: 24px;
  563. width: 100%;
  564. .chart-title {
  565. font-size: 16px;
  566. color: #303133;
  567. margin-bottom: 16px;
  568. }
  569. .chart-container {
  570. height: 350px;
  571. width: 100%;
  572. position: relative;
  573. overflow: hidden;
  574. }
  575. }
  576. .date-popover-content {
  577. padding: 12px;
  578. }
  579. </style>