AddTrend.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563
  1. <template>
  2. <div class="">
  3. <!-- 新增趋势 -->
  4. <div class="px-14 py-9">
  5. <div class="flex items-center justify-between mb-2 mt-3 ">
  6. <div class="flex items-center">
  7. <el-select v-model="industryCompare" class="!w-[120px]" clearable @change="handleCompareChange">
  8. <el-option label="版本对比" value="version" />
  9. <el-option label="渠道对比" value="channel" />
  10. <el-option label="时段对比" value="time" />
  11. </el-select>
  12. <!-- 版本对比和渠道对比使用popover -->
  13. <el-popover v-if="industryCompare !== 'time' && industryCompare" placement="bottom" trigger="click"
  14. width="400">
  15. <template #reference>
  16. <el-button class="ml-2">{{ t('addUser.average') }}</el-button>
  17. </template>
  18. <template #default>
  19. <div class="p-3">
  20. <div class="mb-3">
  21. <label class="text-sm font-medium mb-2 block">{{ getCompareTitle() }}</label>
  22. <el-input v-model="searchKeyword" :placeholder="`请搜索${getCompareTypeText()}`"
  23. clearable @input="filterCompareOptions" size="small" />
  24. </div>
  25. <div class="max-h-60 overflow-y-auto">
  26. <el-checkbox-group v-model="selectedCompareItems"
  27. @change="handleCompareItemsChange">
  28. <div v-for="item in filteredCompareOptions" :key="item" class="mb-2">
  29. <el-checkbox :label="item" size="small">{{ item }}</el-checkbox>
  30. </div>
  31. </el-checkbox-group>
  32. </div>
  33. </div>
  34. </template>
  35. </el-popover>
  36. <!-- 时段对比使用日期选择 -->
  37. <el-popover v-if="industryCompare === 'time'" placement="bottom" trigger="click" width="300"
  38. :visible="timeCompareVisible" :hide-after="0" :persistent="true">
  39. <template #reference>
  40. <el-button class="ml-2"
  41. @click="timeCompareVisible = !timeCompareVisible">{{ t('addUser.average') }}</el-button>
  42. </template>
  43. <template #default>
  44. <div class="p-3">
  45. <div class="mb-3">
  46. <label class="text-sm font-medium mb-2 block">选择对比时段</label>
  47. <el-date-picker v-model="timeCompareRange" type="date" format="YYYY-MM-DD"
  48. value-format="YYYY-MM-DD" :disabled-date="disableAfterToday"
  49. @change="handleTimeCompareChange" style="width: 100%" :clearable="false" />
  50. </div>
  51. </div>
  52. <!-- <div class="flex justify-end">
  53. <el-button size="small" @click="clearTimeCompare">取消</el-button>
  54. <el-button size="small" type="primary" @click="confirmTimeCompare">确定</el-button>
  55. </div> -->
  56. </template>
  57. </el-popover>
  58. <el-button link class="ml-2" @click="clearCompare(), getData('clearAll')">清除</el-button>
  59. </div>
  60. <div class="flex items-center">
  61. <el-radio-group v-model="formData.timeUnit" @change="getData('clearAll'),getBottomDetail()">
  62. <el-radio-button label="hour" :disabled="isHourDisabled">小时</el-radio-button>
  63. <el-radio-button label="day" :disabled="isDayDisabled">天</el-radio-button>
  64. <el-radio-button label="week" :disabled="isWeekDisabled">周</el-radio-button>
  65. <el-radio-button label="month" :disabled="isMonthDisabled">月</el-radio-button>
  66. </el-radio-group>
  67. </div>
  68. </div>
  69. <div class="relative ">
  70. <div ref="lineChartRef" style="width: 100%; height: 320px"></div>
  71. </div>
  72. </div>
  73. <!-- 明细表格 -->
  74. <div class="mt-3">
  75. <div class="flex items-center justify-between mb-2">
  76. <div class="text-base font-medium cursor-pointer select-none ml-3 items-center flex text-[#167AF0]"
  77. @click="showDetail = !showDetail">
  78. {{ showDetail ? '收起明细数据' : '展开明细数据' }} <el-icon class="ml-2">
  79. <ArrowDown v-if="showDetail" />
  80. <ArrowUp v-else />
  81. </el-icon>
  82. </div>
  83. <div>
  84. <el-button type="primary" text>导出</el-button>
  85. </div>
  86. </div>
  87. <el-table v-if="showDetail" :data="tableData" border :cell-style="tableStyle.cellStyle"
  88. :header-cell-style="tableStyle.headerCellStyle">
  89. <el-table-column prop="date" label="日期" min-width="140" />
  90. <el-table-column label="新增用户(占比)" align="right" min-width="220">
  91. <template #default="scope">
  92. <div class="flex items-center justify-center w-full">
  93. <span>{{ scope.row.newUser }}</span>
  94. <span class="text-gray-500 text-xs ml-2">({{ (scope.row.newUserRate * 100).toFixed(2)
  95. }}%)</span>
  96. </div>
  97. </template>
  98. </el-table-column>
  99. </el-table>
  100. <div v-if="showDetail" class="flex justify-end mt-3">
  101. <el-pagination v-model:current-page="pagination.current" v-model:page-size="pagination.size"
  102. @change="getBottomDetail" background layout="total, prev, pager, next, sizes"
  103. :total="pagination.total" :page-sizes="[5, 10, 20]" />
  104. </div>
  105. </div>
  106. </div>
  107. </template>
  108. <script setup lang="ts">
  109. import { ref, onMounted, watch, computed, defineAsyncComponent } from 'vue';
  110. import * as echarts from 'echarts';
  111. import { useI18n } from 'vue-i18n';
  112. import dayjs from 'dayjs';
  113. import { getDaysBetweenDates } from '/@/utils/formatTime';
  114. import { getAppVersion, getAppChannel } from '/@/api/common/common';
  115. import { getTrend, getTrendDetail } from '/@/api/count/addUser';
  116. const { t } = useI18n();
  117. const tableStyle = {
  118. cellStyle: { textAlign: 'center' },
  119. headerCellStyle: {
  120. textAlign: 'center',
  121. background: 'var(--el-table-row-hover-bg-color)',
  122. color: 'var(--el-text-color-primary)',
  123. },
  124. rowStyle: { textAlign: 'center' },
  125. };
  126. const industryCompare = ref('');
  127. const selectedCompareItems = ref<string[]>([]);
  128. const searchKeyword = ref('');
  129. const compareOptions = ref<string[]>([]);
  130. const filteredCompareOptions = ref<string[]>([]);
  131. const timeCompareRange = ref<string>('');
  132. const timeCompareVisible = ref(false);
  133. const masterData = ref(<any>{
  134. //图表主数据
  135. dates: [],
  136. items: [],
  137. });
  138. const emit = defineEmits(['query']);
  139. const props = defineProps({
  140. formData: {
  141. type: Object,
  142. default: () => ({}),
  143. },
  144. });
  145. interface FormDataType {
  146. timeUnit: string;
  147. version: string[];
  148. channel: string[];
  149. fromDate: string;
  150. toDate: string;
  151. }
  152. const formData = ref<FormDataType>({
  153. timeUnit: 'day',
  154. version: [],
  155. channel: [],
  156. fromDate: props.formData?.time[0],
  157. toDate: props.formData?.time[1],
  158. });
  159. const colorSchemes = [
  160. { color: '#409EFF' }, // 蓝色
  161. { color: '#67C23A' }, // 绿色
  162. { color: '#E6A23C' }, // 黄色
  163. { color: '#F56C6C' }, // 红色
  164. ];
  165. const tableData = ref([]); //表格数据
  166. const pagination = ref({
  167. current: 1, //当前页数
  168. total: 0, // 数据总数
  169. size: 5, // 每页显示条数
  170. });
  171. const previousCompareItems = ref<string[]>([]);
  172. const chartTimes = ref<string[]>([]);
  173. const getData = async (type: string) => {
  174. //上方图表
  175. if (type === 'clearAll') {
  176. lineChartData.value = { dates: [], items: [] };
  177. industryCompare.value = '';
  178. }
  179. // Guard: 小时维度跨度>=7天时不发起查询,不做自动纠正
  180. if (formData.value.timeUnit === 'hour') {
  181. const span = getDaysBetweenDates(formData.value.fromDate, formData.value.toDate);
  182. if (span >= 7) {
  183. return;
  184. }
  185. }
  186. const res = await getTrend({ ...formData.value });
  187. const data = res?.data || [];
  188. if (!industryCompare.value) {
  189. masterData.value = data;
  190. lineChartData.value.items = data.items.map((item: any, index: number) => {
  191. const randomColorIndex = Math.floor(Math.random() * colorSchemes.length);
  192. return {
  193. name: getName(item, index, data),
  194. type: 'line',
  195. smooth: true,
  196. data: item.data,
  197. itemStyle: {
  198. color: colorSchemes[randomColorIndex].color,
  199. },
  200. lineStyle: {
  201. color: colorSchemes[randomColorIndex].color,
  202. },
  203. };
  204. });
  205. chartTimes.value = [];
  206. } else {
  207. lineChartData.value.items.push(
  208. data.items.map((item: any, index: number) => {
  209. console.log(item);
  210. const randomColorIndex = Math.floor(Math.random() * colorSchemes.length);
  211. return {
  212. name: getName(item, index, data),
  213. type: 'line',
  214. smooth: true,
  215. data: item.data,
  216. itemStyle: {
  217. color: colorSchemes[randomColorIndex].color,
  218. },
  219. lineStyle: {
  220. color: colorSchemes[randomColorIndex].color,
  221. },
  222. };
  223. })[0]
  224. );
  225. if (industryCompare.value == 'time') {
  226. chartTimes.value.push(data.dates);
  227. lineChartData.value.items[0].name = props.formData?.time[0] + ' ~ ' + props.formData?.time[1];
  228. } else {
  229. chartTimes.value = [];
  230. }
  231. }
  232. lineChartData.value.dates = masterData.value.dates;
  233. initLineChart();
  234. };
  235. const getBottomDetail = async () => {
  236. // 下方明细表
  237. const resDetail = await getTrendDetail({ ...formData.value, ...pagination.value });
  238. const dataDetail = resDetail?.data || [];
  239. pagination.value.current = dataDetail.current; //当前页码
  240. pagination.value.total = dataDetail.total; //总条数
  241. pagination.value.size = dataDetail.size; //每页条数
  242. tableData.value = dataDetail.records;
  243. };
  244. const getName = (item: any, index: number, data: any) => {
  245. if (industryCompare.value === 'time') {
  246. //日期
  247. return formData.value.fromDate + ' ~ ' + formData.value.toDate;
  248. } else if (industryCompare.value === 'version') {
  249. //版本
  250. return item.version === 'All' ? item.name : item.version + ' ' + item.name;
  251. } else if (industryCompare.value === 'channel') {
  252. //渠道
  253. return formData.value.channel[0];
  254. } else {
  255. return item.name;
  256. }
  257. };
  258. function formatNumber(value: number | string): string {
  259. const num = typeof value === 'number' ? value : Number(value || 0);
  260. return num.toLocaleString('zh-CN');
  261. }
  262. const formatterTips = (params: any) => {
  263. if (!params || !params.length) return '';
  264. const date = params[0]?.axisValue || '';
  265. const rows = params
  266. .map((p: any, index: number) => {
  267. const name = p.seriesName || '';
  268. const val = formatNumber(p.data);
  269. if (industryCompare.value === 'time' && index != 0) {
  270. return `<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;min-width:180px;">
  271. <span style="display:flex;align-items:center;gap:6px;">
  272. <span style="width:8px;height:8px;border-radius:50%;background:${p.color}"></span>
  273. <span>${chartTimes.value[0][p.dataIndex]}</span>
  274. </span>
  275. <span style="font-variant-numeric: tabular-nums;">${val}</span>
  276. </div>`;
  277. } else {
  278. return `
  279. <div style="margin-bottom:6px;color:#93c5fd;">${industryCompare.value === 'time' ? '' : date}</div>
  280. <div style="display:flex;align-items:center;justify-content:space-between;gap:12px;min-width:180px;">
  281. <span style="display:flex;align-items:center;gap:6px;">
  282. <span style="width:8px;height:8px;border-radius:50%;background:${p.color}"></span>
  283. <span>${industryCompare.value === 'time' ? p.axisValue : name} </span>
  284. </span>
  285. <span style="font-variant-numeric: tabular-nums;">${val}</span>
  286. </div>`;
  287. }
  288. })
  289. .join('');
  290. return `<div style="font-size:12px;">
  291. ${rows}
  292. </div>`;
  293. };
  294. // 根据日期范围禁用不合适的粒度
  295. const rangeDays = computed(() => {
  296. const start = props.formData?.time?.[0];
  297. const end = props.formData?.time?.[1];
  298. if (!start || !end) return 0;
  299. return getDaysBetweenDates(start, end);
  300. });
  301. const isHourDisabled = computed(() => rangeDays.value >= 7);
  302. const isWeekDisabled = computed(() => rangeDays.value < 7);
  303. const isMonthDisabled = computed(() => rangeDays.value < 28);
  304. const isDayDisabled = computed(() => false);
  305. function ensureValidTimeUnit() {
  306. // 不进行自动纠正,保持用户选择
  307. }
  308. function disableAfterToday(date: Date) {
  309. const today = new Date();
  310. today.setHours(0, 0, 0, 0);
  311. return date.getTime() > today.getTime();
  312. }
  313. // 弹窗相关函数
  314. function handleCompareChange(value: string) {
  315. selectedCompareItems.value = [];
  316. initCompareOptions();
  317. timeCompareVisible.value = value === 'time';
  318. }
  319. function clearCompare() {
  320. selectedCompareItems.value = [];
  321. timeCompareRange.value = '';
  322. formData.value = {
  323. ...formData.value,
  324. channel: props.formData?.channel ? [props.formData?.channel] : [],
  325. version: props.formData?.version ? [props.formData?.version] : [],
  326. fromDate: props.formData?.time[0],
  327. toDate: props.formData?.time[1],
  328. };
  329. }
  330. function getCompareTitle(): string {
  331. const typeMap = {
  332. version: '选择版本',
  333. channel: '选择渠道',
  334. time: '选择时段',
  335. };
  336. return typeMap[industryCompare.value as keyof typeof typeMap] || '选择对比项';
  337. }
  338. function getCompareTypeText(): string {
  339. const typeMap = {
  340. version: '版本',
  341. channel: '渠道',
  342. time: '时段',
  343. };
  344. return typeMap[industryCompare.value as keyof typeof typeMap] || '';
  345. }
  346. function filterCompareOptions() {
  347. if (!searchKeyword.value) {
  348. filteredCompareOptions.value = compareOptions.value;
  349. } else {
  350. filteredCompareOptions.value = compareOptions.value.filter((item) => item.toLowerCase().includes(searchKeyword.value.toLowerCase()));
  351. }
  352. }
  353. function handleCompareItemsChange(items: string[]) {
  354. selectedCompareItems.value = items;
  355. // 对比当前值和之前的值
  356. const currentItems = new Set(items);
  357. const previousItems = new Set(previousCompareItems.value);
  358. // 找出新增的项
  359. const addedItems = items.filter((item) => !previousItems.has(item));
  360. // 找出减少的项(在之前有但现在没有的项)
  361. const removedItemIndices = previousCompareItems.value
  362. .map((item, index) => ({ item, index }))
  363. .filter(({ item }) => !currentItems.has(item))
  364. .map(({ index }) => index);
  365. // 更新 previousCompareItems 为当前值,用于下次对比
  366. previousCompareItems.value = items;
  367. // 先处理主数据项的name
  368. if (industryCompare.value === 'version') {
  369. lineChartData.value.items[0].name = '全部版本';
  370. } else if (industryCompare.value === 'channel') {
  371. lineChartData.value.items[0].name = '全部渠道';
  372. }
  373. // 清理被取消的对比项
  374. if (removedItemIndices.length > 0) {
  375. // 从后往前删除,避免索引变化影响
  376. removedItemIndices
  377. .sort((a, b) => b - a)
  378. .forEach(index => {
  379. lineChartData.value.items.splice(index + 1, 1);
  380. });
  381. }
  382. // 更新表单数据
  383. const isVersion = industryCompare.value === 'version';
  384. if (isVersion) {
  385. formData.value.version = addedItems;
  386. formData.value.channel = props.formData?.channel ? [props.formData?.channel] : [];
  387. } else {
  388. formData.value.channel = addedItems;
  389. formData.value.version = props.formData?.version ? [props.formData?.version] : [];
  390. }
  391. // 根据是否有新增项来决定是否重新获取数据
  392. if (addedItems.length > 0) {
  393. getData('');
  394. } else if (removedItemIndices.length > 0) {
  395. // 如果只是移除了项,则重新初始化图表
  396. initLineChart();
  397. }
  398. }
  399. // 初始化对比选项
  400. async function initCompareOptions() {
  401. if (industryCompare.value === 'version') {
  402. const res = await getAppVersion();
  403. const list: Array<string> = res?.data || [];
  404. compareOptions.value = list;
  405. } else if (industryCompare.value === 'channel') {
  406. const res = await getAppChannel();
  407. const list: Array<string> = res?.data || [];
  408. compareOptions.value = list;
  409. }
  410. filteredCompareOptions.value = compareOptions.value;
  411. }
  412. // 时段对比相关函数
  413. function handleTimeCompareChange(value: string) {
  414. timeCompareRange.value = value;
  415. formData.value.toDate = timeCompareRange.value;
  416. formData.value.fromDate = dayjs(timeCompareRange.value).subtract(getDaysBetweenDates(props.formData?.time[0], props.formData?.time[1]), 'day').format('YYYY-MM-DD');
  417. formData.value.channel = props.formData?.channel ? [props.formData?.channel] : [];
  418. formData.value.version = props.formData?.version ? [props.formData?.version] : [];
  419. getData('');
  420. timeCompareVisible.value = false;
  421. timeCompareRange.value = '';
  422. }
  423. const lineChartRef = ref<HTMLDivElement | null>(null);
  424. let chartInstance: echarts.ECharts | null = null;
  425. const lineChartData = ref<any>({
  426. //图表数据//显示数据
  427. dates: [],
  428. items: [],
  429. });
  430. function initLineChart(): void {
  431. if (!lineChartRef.value) return;
  432. if (chartInstance) chartInstance.dispose();
  433. chartInstance = echarts.init(lineChartRef.value);
  434. const option: echarts.EChartsOption = {
  435. tooltip: {
  436. trigger: 'axis',
  437. confine: true,
  438. axisPointer: { type: 'line' },
  439. borderWidth: 0,
  440. backgroundColor: 'rgba(17,24,39,0.9)',
  441. textStyle: { color: '#fff' },
  442. formatter: (params: any) => {
  443. return formatterTips(params);
  444. },
  445. },
  446. legend: {
  447. data: lineChartData.value.items.map((item: any) => item.name),
  448. top: 'bottom',
  449. type: 'scroll', // 支持图例滚动
  450. },
  451. grid: {
  452. left: 40,
  453. right: 20,
  454. top: 20, // 为图例留出空间
  455. bottom: 60,
  456. },
  457. xAxis: {
  458. type: 'category',
  459. data: lineChartData.value.dates,
  460. axisLine: { lineStyle: { color: '#e5e7eb' } },
  461. axisLabel: { color: '#6b7280' },
  462. axisTick: { alignWithLabel: true },
  463. },
  464. yAxis: {
  465. type: 'value',
  466. axisLine: { show: false },
  467. splitLine: { lineStyle: { color: '#f3f4f6' } },
  468. axisLabel: { color: '#6b7280' },
  469. },
  470. series: lineChartData.value.items,
  471. };
  472. chartInstance.setOption(option);
  473. }
  474. // 展开/收起明细
  475. const showDetail = ref(true);
  476. onMounted(() => {
  477. getData('');
  478. getBottomDetail();
  479. initCompareOptions();
  480. });
  481. watch(props.formData, () => {
  482. formData.value = {
  483. ...formData.value,
  484. channel: props.formData?.channel ? [props.formData?.channel] : [],
  485. version: props.formData?.version ? [props.formData?.version] : [],
  486. fromDate: props.formData?.time[0],
  487. toDate: props.formData?.time[1],
  488. };
  489. formData.value.timeUnit = 'day';
  490. ensureValidTimeUnit();
  491. getData('');
  492. });
  493. // 监听外部日期与当前粒度,统一在此处做7天规则的静默纠正
  494. watch(
  495. () => [props.formData?.time?.[0], props.formData?.time?.[1], formData.value.timeUnit],
  496. ([start, end, unit]) => {
  497. if (!start || !end) return;
  498. // 对齐内部查询参数
  499. formData.value.fromDate = start as string;
  500. formData.value.toDate = end as string;
  501. const span = getDaysBetweenDates(start as string, end as string);
  502. // 不自动纠正,仅在合法范围内时刷新
  503. if (unit === 'hour' && span >= 7) {
  504. getData('');
  505. }
  506. },
  507. { immediate: true }
  508. );
  509. </script>
  510. <style lang="scss" scoped>
  511. .highlight {
  512. color: #2196f3;
  513. }
  514. </style>