index.vue 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678
  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
  51. v-for="(item, index) in metricsCardsData"
  52. :key="index"
  53. class="metric-col"
  54. >
  55. <el-card shadow="never" class="metric-card">
  56. <div class="metric-title">{{ item.title }}</div>
  57. <div class="metric-date">{{
  58. displayDateRange
  59. }}</div>
  60. <div class="metric-value-container">
  61. <div class="metric-label">合计</div>
  62. <div class="metric-value">{{ item.value }}</div>
  63. </div>
  64. <div class="metric-comparison">
  65. 环比 {{ item.change }}%
  66. <template v-if="parseFloat(item.change) > 0"
  67. ><span class="arrow-up">↑</span></template
  68. >
  69. <template
  70. v-else-if="parseFloat(item.change) < 0"
  71. ><span class="arrow-down">↓</span></template
  72. >
  73. </div>
  74. </el-card>
  75. </el-col>
  76. </el-row>
  77. </div>
  78. <!-- Numbers Cards -->
  79. <div class="number-metrics">
  80. <el-row :gutter="20">
  81. <el-col
  82. :span="4"
  83. v-for="(item, index) in numberMetricsData"
  84. :key="index"
  85. >
  86. <el-card shadow="hover" class="number-card">
  87. <div class="number-value">{{ item.value }}</div>
  88. <div class="number-label">{{ item.label }}</div>
  89. </el-card>
  90. </el-col>
  91. </el-row>
  92. </div>
  93. <!-- Chart Section -->
  94. <div class="trend-chart-section">
  95. <div class="chart-title">30日分享人数趋势</div>
  96. <div class="chart-container" ref="chartRef"></div>
  97. </div>
  98. </el-card>
  99. </div>
  100. </template>
  101. <script setup>
  102. import { ref, onMounted, computed, watch } from 'vue';
  103. import * as echarts from 'echarts';
  104. import dayjs from 'dayjs';
  105. import request from '@/utils/request';
  106. import { ElMessage } from 'element-plus';
  107. // 状态变量
  108. const activePeriod = ref('');
  109. const chartRef = ref(null);
  110. let chart = null;
  111. // 跟踪是否使用自定义日期范围
  112. const isCustomDateRange = ref(false);
  113. // 使用今日初始化日期范围
  114. const dateRange = ref([
  115. dayjs().format('YYYY-MM-DD'),
  116. dayjs().format('YYYY-MM-DD')
  117. ]);
  118. // API数据
  119. const statisticData = ref(null);
  120. // 从API响应计算的指标
  121. const metrics = computed(() => {
  122. if (!statisticData.value) {
  123. return {
  124. totalUsers: '',
  125. totalUsersChange: '0',
  126. orders: '',
  127. ordersChange: '0',
  128. scanNum: '',
  129. scanNumChange: '0',
  130. scanRate: '',
  131. scanRateChange: '0',
  132. newUserRate: '',
  133. newUserRateChange: '0'
  134. };
  135. }
  136. const data = statisticData.value.data;
  137. // 辅助函数,用于一致地格式化比较值
  138. const formatComparison = (value) => {
  139. if (value === null || value === undefined) return '0';
  140. // 如果存在%则移除,仅返回数字字符串
  141. return String(value).replace('%', '');
  142. };
  143. return {
  144. totalUsers: data.upsellHelpNum || '0',
  145. totalUsersChange: formatComparison(data.upsellHelpNumComparison),
  146. orders: data.upsellOrderNum || '0',
  147. ordersChange: formatComparison(data.upsellOrderNumComparison),
  148. scanNum: data.upsellScanNum || '0',
  149. scanNumChange: formatComparison(data.upsellScanNumComparison),
  150. scanRate: data.scanJoinRate ? `${data.scanJoinRate}%` : '0%',
  151. scanRateChange: formatComparison(data.scanJoinRateComparison),
  152. newUserRate: data.newUserRate ? `${data.newUserRate}%` : '0%',
  153. newUserRateChange: formatComparison(data.newUserRateComparison)
  154. };
  155. });
  156. const stats = computed(() => {
  157. if (!statisticData.value) {
  158. return {
  159. participants: '0',
  160. helps: '0',
  161. ordersCount: '0',
  162. totalOrderAmount: '0',
  163. totalAddedPoints: '0',
  164. totalSaleAmount: '0'
  165. };
  166. }
  167. const data = statisticData.value.data;
  168. return {
  169. participants: data.upsellShareNum || '0',
  170. helps: data.upsellHelpTimes || '0',
  171. ordersCount: data.orderBookNum || '0',
  172. totalOrderAmount: data.orderTotalMoney || '0',
  173. totalAddedPoints: data.upsellTotalMoney || '0',
  174. totalSaleAmount: data.upsellFinalTotalMoney || '0'
  175. };
  176. });
  177. // 数字指标卡片数据
  178. const numberMetricsData = computed(() => {
  179. return [
  180. { label: '参与用户数', value: stats.value.participants },
  181. { label: '助力次数', value: stats.value.helps },
  182. { label: '订单总本数', value: stats.value.ordersCount },
  183. { label: '订单预估总金额', value: stats.value.totalOrderAmount },
  184. { label: '加价预估总金额', value: stats.value.totalAddedPoints },
  185. { label: '实际加价金额', value: stats.value.totalSaleAmount }
  186. ];
  187. });
  188. // 度量指标卡片数据
  189. const metricsCardsData = computed(() => {
  190. return [
  191. {
  192. title: '助力人数',
  193. value: `${metrics.value.totalUsers}人`,
  194. change: metrics.value.totalUsersChange
  195. },
  196. {
  197. title: '参与活动订单数',
  198. value: `${metrics.value.orders}单`,
  199. change: metrics.value.ordersChange
  200. },
  201. {
  202. title: '活动书单扫描次数',
  203. value: `${metrics.value.scanNum}次`,
  204. change: metrics.value.scanNumChange
  205. },
  206. {
  207. title: '活动书单扫描/参与占比',
  208. value: metrics.value.scanRate,
  209. change: metrics.value.scanRateChange
  210. },
  211. {
  212. title: '助力新用户占比',
  213. value: metrics.value.newUserRate,
  214. change: metrics.value.newUserRateChange
  215. }
  216. ];
  217. });
  218. // 图表数据
  219. const chartData = computed(() => {
  220. if (!statisticData.value || !statisticData.value.data.chartList) {
  221. return {
  222. dates: [],
  223. values: []
  224. };
  225. }
  226. const chartList = statisticData.value.data.chartList;
  227. return {
  228. dates: chartList.map((item) => item.date),
  229. values: chartList.map((item) => item.shareNum)
  230. };
  231. });
  232. const currentDate = computed(() => {
  233. return dayjs().format('YYYY-MM-DD');
  234. });
  235. const displayDateRange = computed(() => {
  236. if (Array.isArray(dateRange.value) && dateRange.value.length === 2) {
  237. // If start and end dates are the same, only show one date
  238. if (dateRange.value[0] === dateRange.value[1]) {
  239. return dateRange.value[0];
  240. }
  241. return `${dateRange.value[0]} - ${dateRange.value[1]}`;
  242. } else {
  243. return currentDate.value;
  244. }
  245. });
  246. // 根据时间段更新日期范围的方法
  247. const updateDateRangeByPeriod = (period) => {
  248. isCustomDateRange.value = false;
  249. switch (period) {
  250. case 'yesterday':
  251. dateRange.value = [
  252. dayjs().subtract(1, 'day').format('YYYY-MM-DD'),
  253. dayjs().subtract(1, 'day').format('YYYY-MM-DD')
  254. ];
  255. break;
  256. case '7d':
  257. dateRange.value = [
  258. dayjs().subtract(7, 'day').format('YYYY-MM-DD'),
  259. dayjs().format('YYYY-MM-DD')
  260. ];
  261. break;
  262. case '15d':
  263. dateRange.value = [
  264. dayjs().subtract(15, 'day').format('YYYY-MM-DD'),
  265. dayjs().format('YYYY-MM-DD')
  266. ];
  267. break;
  268. case '30d':
  269. dateRange.value = [
  270. dayjs().subtract(30, 'day').format('YYYY-MM-DD'),
  271. dayjs().format('YYYY-MM-DD')
  272. ];
  273. break;
  274. default:
  275. dateRange.value = [
  276. dayjs().subtract(7, 'day').format('YYYY-MM-DD'),
  277. dayjs().format('YYYY-MM-DD')
  278. ];
  279. }
  280. };
  281. // 处理时间段变更按钮点击的方法
  282. const changePeriod = (period) => {
  283. activePeriod.value = period;
  284. updateDateRangeByPeriod(period);
  285. fetchData();
  286. };
  287. // 处理日期选择器变更
  288. const handleDateRangeChange = () => {
  289. isCustomDateRange.value = true;
  290. activePeriod.value = '';
  291. fetchData();
  292. };
  293. const getDateRangeForAPI = () => {
  294. let startDate, endDate;
  295. if (Array.isArray(dateRange.value) && dateRange.value.length === 2) {
  296. startDate = dayjs(dateRange.value[0]).format('YYYY-MM-DD 00:00:00');
  297. endDate = dayjs(dateRange.value[1]).format('YYYY-MM-DD 23:59:59');
  298. } else {
  299. // 如果dateRange不是数组,使用当天作为默认值
  300. startDate = dayjs().format('YYYY-MM-DD 00:00:00');
  301. endDate = dayjs().format('YYYY-MM-DD 23:59:59');
  302. }
  303. return { startDate, endDate };
  304. };
  305. const fetchData = async () => {
  306. const { startDate, endDate } = getDateRangeForAPI();
  307. try {
  308. const { data } = await request.get(
  309. '/activity/activityUpsellInfo/statistic',
  310. {
  311. params: {
  312. startDate,
  313. endDate
  314. }
  315. }
  316. );
  317. statisticData.value = data;
  318. updateChart();
  319. } catch (error) {
  320. ElMessage.error('获取数据失败');
  321. console.error('Failed to fetch statistic data:', error);
  322. }
  323. };
  324. const resetFilters = () => {
  325. isCustomDateRange.value = false;
  326. activePeriod.value = '';
  327. // 重置为今日
  328. dateRange.value = [
  329. dayjs().format('YYYY-MM-DD'),
  330. dayjs().format('YYYY-MM-DD')
  331. ];
  332. fetchData();
  333. };
  334. const updateChart = () => {
  335. if (!chart || !chartData.value.dates.length) return;
  336. const option = {
  337. tooltip: {
  338. trigger: 'axis',
  339. axisPointer: {
  340. type: 'shadow'
  341. }
  342. },
  343. grid: {
  344. left: '3%',
  345. right: '4%',
  346. bottom: '3%',
  347. containLabel: true
  348. },
  349. xAxis: {
  350. type: 'category',
  351. data: chartData.value.dates,
  352. boundaryGap: false
  353. },
  354. yAxis: {
  355. type: 'value',
  356. min: 0
  357. },
  358. series: [
  359. {
  360. name: '分享人数',
  361. type: 'line',
  362. data: chartData.value.values,
  363. smooth: true,
  364. lineStyle: {
  365. color: '#5470c6',
  366. width: 2
  367. },
  368. symbol: 'circle',
  369. symbolSize: 8,
  370. itemStyle: {
  371. color: '#5470c6'
  372. }
  373. }
  374. ]
  375. };
  376. chart.setOption(option);
  377. };
  378. const initChart = () => {
  379. if (chartRef.value) {
  380. chart = echarts.init(chartRef.value);
  381. // 使用空数据初始化,API调用后更新
  382. const option = {
  383. tooltip: {
  384. trigger: 'axis',
  385. axisPointer: {
  386. type: 'shadow'
  387. }
  388. },
  389. grid: {
  390. left: '3%',
  391. right: '4%',
  392. bottom: '3%',
  393. containLabel: true
  394. },
  395. xAxis: {
  396. type: 'category',
  397. data: [],
  398. boundaryGap: false
  399. },
  400. yAxis: {
  401. type: 'value',
  402. min: 0
  403. },
  404. series: [
  405. {
  406. name: '分享人数',
  407. type: 'line',
  408. data: [],
  409. smooth: true,
  410. lineStyle: {
  411. color: '#5470c6',
  412. width: 2
  413. },
  414. symbol: 'circle',
  415. symbolSize: 8,
  416. itemStyle: {
  417. color: '#5470c6'
  418. }
  419. }
  420. ]
  421. };
  422. chart.setOption(option);
  423. // 处理窗口大小调整
  424. window.addEventListener('resize', () => {
  425. chart.resize();
  426. });
  427. // 使用ResizeObserver检测并响应图表容器大小变化
  428. const resizeObserver = new ResizeObserver(() => {
  429. chart.resize();
  430. });
  431. resizeObserver.observe(chartRef.value);
  432. }
  433. };
  434. // 手动调整图表大小的方法 - 可从父组件调用
  435. const resizeChart = () => {
  436. if (chart) {
  437. chart.resize();
  438. }
  439. };
  440. // 监听dateRange变化以更新activePeriod
  441. watch(dateRange, (newValue) => {
  442. if (isCustomDateRange.value) {
  443. activePeriod.value = '';
  444. }
  445. });
  446. onMounted(() => {
  447. initChart();
  448. activePeriod.value = '';
  449. // 不更新日期范围,保持为今日
  450. fetchData();
  451. // 在组件挂载后强制调整大小
  452. setTimeout(() => {
  453. resizeChart();
  454. }, 300);
  455. });
  456. // 向父组件暴露resizeChart方法
  457. defineExpose({
  458. resizeChart
  459. });
  460. </script>
  461. <style lang="scss" scoped>
  462. .date-selector-container {
  463. display: flex;
  464. align-items: center;
  465. justify-content: space-between;
  466. margin-bottom: 20px;
  467. border-bottom: 1px solid #ebeef5;
  468. padding-bottom: 16px;
  469. }
  470. .arrow-up {
  471. color: #67c23a; /* Element Plus 成功颜色(绿色) */
  472. }
  473. .arrow-down {
  474. color: #f56c6c; /* Element Plus 危险颜色(红色) */
  475. }
  476. .date-period-selector {
  477. display: flex;
  478. gap: 10px;
  479. .el-button {
  480. border-radius: 4px;
  481. }
  482. .active {
  483. color: #409eff;
  484. border-color: #409eff;
  485. }
  486. }
  487. .custom-date-range {
  488. display: flex;
  489. align-items: center;
  490. gap: 16px;
  491. }
  492. .time-range-selector {
  493. display: flex;
  494. align-items: center;
  495. gap: 8px;
  496. .time-label {
  497. font-size: 14px;
  498. color: #606266;
  499. }
  500. }
  501. .time-slider {
  502. width: 200px;
  503. }
  504. .date-action-buttons {
  505. display: flex;
  506. gap: 8px;
  507. }
  508. .date-display {
  509. display: flex;
  510. align-items: center;
  511. gap: 4px;
  512. cursor: pointer;
  513. padding: 6px 12px;
  514. border: 1px solid #dcdfe6;
  515. border-radius: 4px;
  516. margin-bottom: 20px;
  517. }
  518. .metrics-cards {
  519. margin-bottom: 24px;
  520. }
  521. .metrics-row {
  522. display: flex;
  523. flex-wrap: nowrap;
  524. }
  525. .metric-col {
  526. flex: 1;
  527. }
  528. .metric-card {
  529. border: 1px solid #ebeef5;
  530. height: 150px;
  531. position: relative;
  532. &:first-child {
  533. border-left: 3px solid #409eff;
  534. }
  535. .metric-title {
  536. font-size: 14px;
  537. color: #303133;
  538. margin-bottom: 4px;
  539. }
  540. .metric-date {
  541. font-size: 12px;
  542. color: #909399;
  543. }
  544. .metric-value-container {
  545. display: flex;
  546. margin-bottom: 4px;
  547. align-items: center;
  548. .metric-label {
  549. font-size: 14px;
  550. color: #606266;
  551. margin-right: 10px;
  552. }
  553. .metric-value {
  554. font-size: 32px;
  555. font-weight: bold;
  556. color: #303133;
  557. }
  558. }
  559. .metric-comparison {
  560. font-size: 12px;
  561. color: #606266;
  562. position: absolute;
  563. bottom: 12px;
  564. }
  565. }
  566. .number-metrics {
  567. margin-bottom: 24px;
  568. }
  569. .number-card {
  570. text-align: center;
  571. padding: 16px;
  572. height: 120px;
  573. .number-value {
  574. font-size: 22px;
  575. font-weight: bold;
  576. color: #f6a623;
  577. margin-bottom: 8px;
  578. }
  579. .number-label {
  580. font-size: 14px;
  581. color: #606266;
  582. }
  583. }
  584. .trend-chart-section {
  585. margin-top: 24px;
  586. width: 100%;
  587. .chart-title {
  588. font-size: 16px;
  589. color: #303133;
  590. margin-bottom: 16px;
  591. }
  592. .chart-container {
  593. height: 350px;
  594. width: 100%;
  595. position: relative;
  596. overflow: hidden;
  597. }
  598. }
  599. .date-popover-content {
  600. padding: 12px;
  601. }
  602. </style>