deepchart后台管理系统
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1007 lines
29 KiB

3 weeks ago
3 weeks ago
  1. <template>
  2. <div class="user-overview-container">
  3. <div class="tab-header">
  4. <div
  5. class="tab-item"
  6. :class="{ active: activeTab === 'overview' }"
  7. @click="activeTab = 'overview'"
  8. >
  9. 数据概览
  10. </div>
  11. <div
  12. class="tab-item"
  13. :class="{ active: activeTab === 'detail' }"
  14. @click="activeTab = 'detail'"
  15. >
  16. 数据明细
  17. </div>
  18. <div class="tab-note">
  19. <span class="red-asterisk">*</span> 默认展示截止到今日的数据
  20. </div>
  21. </div>
  22. <!-- 数据概览 -->
  23. <div v-show="activeTab === 'overview'" class="tab-content overview-content">
  24. <div class="stats-row">
  25. <!-- 用户总数和登录总数 -->
  26. <div class="stat-card purple-gradient big-card">
  27. <div class="card-title">
  28. <el-icon><UserFilled /></el-icon>
  29. </div>
  30. <div class="big-card-content">
  31. <div class="card-value">{{ overviewData.total }}</div>
  32. <div class="card-tag" :class="getGrowthClass(overviewData.total_growth)">
  33. {{ getGrowthText(overviewData.total_growth) }}
  34. </div>
  35. </div>
  36. <div class="card-title">
  37. <el-icon><View /></el-icon>
  38. </div>
  39. <div class="big-card-content">
  40. <div class="card-value">{{ overviewData.total_login }}</div>
  41. <div class="card-tag" :class="getGrowthClass(overviewData.total_login_growth)">
  42. {{ getGrowthText(overviewData.total_login_growth) }}
  43. </div>
  44. </div>
  45. </div>
  46. <div class="right-stats-grid">
  47. <!-- 会员总数 -->
  48. <div class="stat-card orange-gradient small-card">
  49. <div class="small-card-content">
  50. <div class="card-title">
  51. <el-icon><Trophy /></el-icon>
  52. </div>
  53. <div class="card-value-small">{{ overviewData.member }}</div>
  54. <div class="card-tag-wrapper">
  55. <div class="card-tag" :class="getGrowthClass(overviewData.member_growth)">
  56. {{ getGrowthText(overviewData.member_growth) }}
  57. </div>
  58. </div>
  59. </div>
  60. </div>
  61. <!-- 游客总数 -->
  62. <div class="stat-card purple-gradient small-card">
  63. <div class="small-card-content">
  64. <div class="card-title">
  65. <el-icon><User /></el-icon>
  66. </div>
  67. <div class="card-value-small">{{ overviewData.visitor }}</div>
  68. <div class="card-tag-wrapper">
  69. <div class="card-tag" :class="getGrowthClass(overviewData.vistor_growth)">
  70. {{ getGrowthText(overviewData.vistor_growth) }}
  71. </div>
  72. </div>
  73. </div>
  74. </div>
  75. <!-- 非网注册和登录总数 -->
  76. <div class="stat-card blue-gradient small-card full-width">
  77. <div class="small-card-row">
  78. <!-- 非网注册总数 -->
  79. <div class="small-card-item">
  80. <div class="card-title">
  81. <el-icon><User /></el-icon>
  82. </div>
  83. <div class="card-value-small">{{ overviewData.normal_register }}</div>
  84. <div class="card-tag-wrapper">
  85. <div class="card-tag" :class="getGrowthClass(overviewData.normal_reg_growth)">
  86. {{ getGrowthText(overviewData.normal_reg_growth) }}
  87. </div>
  88. </div>
  89. </div>
  90. <!-- 非网登录总数 -->
  91. <div class="small-card-item">
  92. <div class="card-title">
  93. <el-icon><View /></el-icon>
  94. </div>
  95. <div class="card-value-small">{{ overviewData.normal_login }}</div>
  96. <div class="card-tag-wrapper">
  97. <div class="card-tag" :class="getGrowthClass(overviewData.normal_login_growth)">
  98. {{ getGrowthText(overviewData.normal_login_growth) }}
  99. </div>
  100. </div>
  101. </div>
  102. </div>
  103. </div>
  104. </div>
  105. </div>
  106. <!-- 登录用户构成比例 -->
  107. <div class="composition-section">
  108. <div class="section-header">
  109. <el-icon><PieChart /></el-icon>
  110. </div>
  111. <div class="charts-row">
  112. <div class="chart-wrapper">
  113. <div ref="chartMemberRef" class="chart-box"></div>
  114. <div class="legend-custom">
  115. <div class="legend-item"><span class="dot green"></span>会员用户</div>
  116. <div class="legend-item"><span class="dot red"></span>非会员用户</div>
  117. </div>
  118. </div>
  119. <div class="chart-wrapper">
  120. <div ref="chartNewOldRef" class="chart-box"></div>
  121. <div class="legend-custom">
  122. <div class="legend-item"><span class="dot green"></span>会员用户</div>
  123. <div class="legend-item"><span class="dot red"></span>新非网数量</div>
  124. <div class="legend-item"><span class="dot blue"></span>老非网数量</div>
  125. </div>
  126. </div>
  127. </div>
  128. </div>
  129. </div>
  130. <!-- 数据明细 -->
  131. <div v-show="activeTab === 'detail'" class="tab-content detail-content" v-loading="loading" element-loading-text="数据加载中...">
  132. <!-- 搜索栏 -->
  133. <div class="search-bar">
  134. <div class="search-label">时间段查询</div>
  135. <el-date-picker
  136. v-model="dateRange"
  137. type="daterange"
  138. range-separator="至"
  139. start-placeholder="开始时间"
  140. end-placeholder="结束时间"
  141. size="default"
  142. />
  143. <el-button type="primary" class="search-btn" @click="handleSearch">搜索</el-button>
  144. <el-button type="primary" class="reset-btn" @click="handleReset">重置</el-button>
  145. <el-button type="danger" class="export-btn" @click="handleExport">数据导出</el-button>
  146. </div>
  147. <!-- 表格1: 用户构成明细 -->
  148. <div class="detail-section">
  149. <div class="section-title"><el-icon><User /></el-icon> </div>
  150. <el-table :data="tableData1" style="width: 100%" :header-cell-style="headerCellStyle">
  151. <el-table-column prop="type" label="用户类型" width="180" align="center">
  152. <template #default="scope">
  153. <span :class="{
  154. 'text-red': scope.row.type === '用户总数',
  155. 'text-black-bold': ['会员总数', '非会员总数'].includes(scope.row.type),
  156. 'sub-item-text': ['新非网总数', '老非网总数'].includes(scope.row.type)
  157. }">{{ scope.row.type }}</span>
  158. </template>
  159. </el-table-column>
  160. <el-table-column prop="total" label="当前总数" align="center" />
  161. <el-table-column prop="dailyNew" label="较昨日新增" align="center">
  162. <template #default="scope">
  163. <span :class="getValueColorClass(scope.row.dailyNew)">{{ scope.row.dailyNew }}</span>
  164. </template>
  165. </el-table-column>
  166. <el-table-column prop="weeklyNew" label="较上周新增" align="center">
  167. <template #default="scope">
  168. <span :class="getValueColorClass(scope.row.weeklyNew)">{{ scope.row.weeklyNew }}</span>
  169. </template>
  170. </el-table-column>
  171. <el-table-column prop="monthlyNew" label="较上月新增" align="center">
  172. <template #default="scope">
  173. <span :class="getValueColorClass(scope.row.monthlyNew)">{{ scope.row.monthlyNew }}</span>
  174. </template>
  175. </el-table-column>
  176. <el-table-column prop="periodNew" label="时间段新增" align="center">
  177. <template #default="scope">
  178. <span :class="getValueColorClass(scope.row.periodNew)">{{ scope.row.periodNew }}</span>
  179. </template>
  180. </el-table-column>
  181. </el-table>
  182. </div>
  183. <!-- 表格2: 新注册用户来源 -->
  184. <div class="detail-section">
  185. <div class="section-title"><el-icon><User /></el-icon> </div>
  186. <el-table :data="tableData2" style="width: 100%" :header-cell-style="headerCellStyle">
  187. <el-table-column prop="channel" label="来源渠道" align="center" />
  188. <el-table-column prop="dailyNew" label="今日新增" align="center" />
  189. <el-table-column prop="weeklyNew" label="本周新增" align="center" />
  190. <el-table-column prop="monthlyNew" label="本月新增" align="center" />
  191. <el-table-column prop="periodNew" label="时间段新增" align="center" />
  192. <el-table-column prop="percent" label="占比" align="center" />
  193. </el-table>
  194. </div>
  195. <!-- 表格3: 老用户来源 -->
  196. <div class="detail-section">
  197. <div class="section-title"><el-icon><User /></el-icon></div>
  198. <el-table :data="tableData3" style="width: 100%" :header-cell-style="headerCellStyle">
  199. <el-table-column prop="channel" label="来源渠道" align="center" />
  200. <el-table-column prop="dailyNew" label="今日新增" align="center" />
  201. <el-table-column prop="weeklyNew" label="本周新增" align="center" />
  202. <el-table-column prop="monthlyNew" label="本月新增" align="center" />
  203. <el-table-column prop="periodNew" label="时间段新增" align="center" />
  204. <el-table-column prop="percent" label="占比" align="center" />
  205. </el-table>
  206. </div>
  207. <!-- 图表: 用户来源渠道分布 -->
  208. <div class="detail-section chart-section-bg">
  209. <div class="section-title"><el-icon><PieChart /></el-icon> </div>
  210. <div ref="chartBarRef" class="bar-chart-box"></div>
  211. </div>
  212. </div>
  213. <!-- 悬浮刷新时间 -->
  214. <div class="refresh-time" v-if="lastUpdateTime">
  215. 数据刷新时间{{ lastUpdateTime }}
  216. </div>
  217. </div>
  218. </template>
  219. <script setup>
  220. import { ref, onMounted, nextTick, watch, computed } from 'vue';
  221. import { useRoute, useRouter } from 'vue-router';
  222. import * as echarts from 'echarts';
  223. import { getUserOverviewList, getUserFullReportList, exportUserFullReport } from '../../api/platformData';
  224. import { ElMessage } from 'element-plus';
  225. const route = useRoute();
  226. const router = useRouter();
  227. const activeTab = ref(route.query.tab || 'overview');
  228. const dateRange = ref('');
  229. const loading = ref(false);
  230. const lastUpdateTime = ref('');
  231. const hasDateRange = computed(() => {
  232. return dateRange.value && dateRange.value.length === 2;
  233. });
  234. const chartMemberRef = ref(null);
  235. const chartNewOldRef = ref(null);
  236. const chartBarRef = ref(null);
  237. let chartBarInstance = null;
  238. const overviewData = ref({
  239. total: 0,
  240. total_growth: '0%',
  241. total_login: 0,
  242. total_login_growth: '0%',
  243. member: 0,
  244. member_growth: '0%',
  245. visitor: 0,
  246. vistor_growth: '0%',
  247. normal_register: 0,
  248. normal_reg_growth: '0%',
  249. normal_login: 0,
  250. normal_login_growth: '0%',
  251. group_member_normal: {
  252. member_val: 0,
  253. normal_login_val: 0
  254. },
  255. group_triple: {
  256. member_val: 0,
  257. new_normal_login_val: 0,
  258. old_normal_val: 0
  259. }
  260. });
  261. // 表格数据 - 使用 ref 响应式数据
  262. const tableData1 = ref([]);
  263. const tableData2 = ref([]);
  264. const tableData3 = ref([]);
  265. const headerCellStyle = {
  266. background: '#fff0f0',
  267. color: '#333',
  268. fontWeight: 'bold'
  269. };
  270. // 获取增长率的样式类
  271. const getGrowthClass = (growthStr) => {
  272. if (!growthStr) return '';
  273. return growthStr.startsWith('-') ? 'down' : 'up';
  274. };
  275. // 获取增长率的显示文本(添加箭头)
  276. const getGrowthText = (growthStr) => {
  277. if (!growthStr) return '';
  278. const isDown = growthStr.startsWith('-');
  279. const arrow = isDown ? '↓' : '↑';
  280. const prefix = isDown ? '较昨日减少' : '较昨日增加';
  281. // 移除可能存在的负号,因为箭头已经表示了方向
  282. const value = growthStr.replace('-', '');
  283. return `${prefix}${arrow} ${value}`;
  284. };
  285. // 获取表格数值颜色样式
  286. const getValueColorClass = (val) => {
  287. if (!val || val === '-') return '';
  288. return String(val).startsWith('-') ? 'text-red' : 'text-green';
  289. };
  290. // 格式化日期
  291. const formatDate = (date) => {
  292. if (!date) return '';
  293. const d = new Date(date);
  294. const pad = (n) => n < 10 ? '0' + n : n;
  295. return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
  296. };
  297. const isOverviewLoaded = ref(false);
  298. const isDetailLoaded = ref(false);
  299. const chartMemberInstance = ref(null);
  300. const chartNewOldInstance = ref(null);
  301. const fetchData = async () => {
  302. try {
  303. const res = await getUserOverviewList();
  304. console.log("获取用户概览数据响应完成:",res);
  305. // 根据用户反馈,响应拦截器直接返回data部分,不再包含code
  306. if (res && res.list) {
  307. overviewData.value = res.list;
  308. initCharts();
  309. isOverviewLoaded.value = true;
  310. lastUpdateTime.value = formatDate(new Date());
  311. }
  312. } catch (error) {
  313. console.error('获取用户概览数据失败:', error);
  314. }
  315. };
  316. const fetchDetailData = async (forceUpdate = false) => {
  317. // 决定是否显示加载状态:首次加载或强制更新时显示
  318. const shouldShowLoading = !isDetailLoaded.value || forceUpdate;
  319. if (shouldShowLoading) {
  320. loading.value = true;
  321. }
  322. let params = {};
  323. if (dateRange.value && dateRange.value.length === 2) {
  324. params.start_time = formatDate(dateRange.value[0]);
  325. params.end_time = formatDate(dateRange.value[1]);
  326. }
  327. // Check if range search is active
  328. const isRangeSearch = !!(params.start_time && params.end_time);
  329. try {
  330. const res = await getUserFullReportList(params);
  331. console.log("获取数据明细响应:", res);
  332. // 兼容处理:如果拦截器返回了data,则直接使用res;如果没拦截,使用res.data
  333. const data = res.composition ? res : (res.data ? res.data : null);
  334. if (data) {
  335. // Map Table 1
  336. if (data.composition) {
  337. tableData1.value = data.composition.map(item => ({
  338. type: item.label,
  339. total: item.total.toLocaleString(),
  340. dailyNew: isRangeSearch ? '-' : (item.growth_day > 0 ? '+' + item.growth_day : item.growth_day),
  341. weeklyNew: isRangeSearch ? '-' : (item.growth_week > 0 ? '+' + item.growth_week : item.growth_week),
  342. monthlyNew: isRangeSearch ? '-' : (item.growth_month > 0 ? '+' + item.growth_month : item.growth_month),
  343. periodNew: !isRangeSearch ? '-' : item.growth_range
  344. }));
  345. }
  346. // Map Table 2
  347. if (data.new_source) {
  348. tableData2.value = data.new_source.map(item => ({
  349. channel: item.channel,
  350. dailyNew: isRangeSearch ? '-' : item.today_add,
  351. weeklyNew: isRangeSearch ? '-' : (item.week_add > 0 ? '+' + item.week_add : item.week_add),
  352. monthlyNew: isRangeSearch ? '-' : (item.month_add > 0 ? '+' + item.month_add : item.month_add),
  353. periodNew: !isRangeSearch ? '-' : item.range_add,
  354. percent: item.rate
  355. }));
  356. }
  357. // Map Table 3
  358. if (data.old_source) {
  359. tableData3.value = data.old_source.map(item => ({
  360. channel: item.channel,
  361. dailyNew: isRangeSearch ? '-' : item.today_add,
  362. weeklyNew: isRangeSearch ? '-' : (item.week_add > 0 ? '+' + item.week_add : item.week_add),
  363. monthlyNew: isRangeSearch ? '-' : (item.month_add > 0 ? '+' + item.month_add : item.month_add),
  364. periodNew: !isRangeSearch ? '-' : item.range_add,
  365. percent: item.rate
  366. }));
  367. }
  368. // Chart Data
  369. if (data.chart) {
  370. nextTick(() => {
  371. updateBarChart(data.chart, data.new_source || [], data.old_source || []);
  372. });
  373. }
  374. isDetailLoaded.value = true;
  375. lastUpdateTime.value = formatDate(new Date());
  376. }
  377. } catch(e) {
  378. console.error('获取数据明细失败:', e);
  379. } finally {
  380. if (shouldShowLoading) {
  381. loading.value = false;
  382. }
  383. }
  384. };
  385. const handleSearch = () => {
  386. fetchDetailData(true);
  387. };
  388. const handleReset = () => {
  389. dateRange.value = '';
  390. fetchDetailData(true);
  391. };
  392. const handleExport = async () => {
  393. loading.value = true;
  394. let params = {};
  395. if (dateRange.value && dateRange.value.length === 2) {
  396. params.start_time = formatDate(dateRange.value[0]);
  397. params.end_time = formatDate(dateRange.value[1]);
  398. }
  399. try {
  400. const res = await exportUserFullReport(params);
  401. // Blob 处理
  402. const blob = new Blob([res]);
  403. const fileName = '用户数据明细.pdf';
  404. const link = document.createElement('a');
  405. link.href = window.URL.createObjectURL(blob);
  406. link.download = fileName;
  407. link.click();
  408. window.URL.revokeObjectURL(link.href);
  409. ElMessage.success('导出成功');
  410. } catch (e) {
  411. console.error('导出失败:', e);
  412. ElMessage.error('导出失败');
  413. } finally {
  414. loading.value = false;
  415. }
  416. };
  417. const initCharts = () => {
  418. if (activeTab.value === 'overview') {
  419. nextTick(() => {
  420. // Chart 1: 会员/非会员
  421. if (chartMemberRef.value) {
  422. let chart1 = chartMemberInstance.value;
  423. if (!chart1) {
  424. chart1 = echarts.init(chartMemberRef.value);
  425. chartMemberInstance.value = chart1;
  426. }
  427. chart1.setOption({
  428. tooltip: {
  429. trigger: 'item',
  430. formatter: '{b}: {c} ({d}%)'
  431. },
  432. color: ['#ff4d4f', '#52c41a'],
  433. series: [
  434. {
  435. type: 'pie',
  436. radius: ['50%', '70%'],
  437. avoidLabelOverlap: false,
  438. label: { show: false },
  439. data: [
  440. { value: overviewData.value.group_member_normal.normal_login_val, name: '非会员用户' },
  441. { value: overviewData.value.group_member_normal.member_val, name: '会员用户' }
  442. ]
  443. }
  444. ]
  445. });
  446. }
  447. // Chart 2: 三色环形图
  448. if (chartNewOldRef.value) {
  449. let chart2 = chartNewOldInstance.value;
  450. if (!chart2) {
  451. chart2 = echarts.init(chartNewOldRef.value);
  452. chartNewOldInstance.value = chart2;
  453. }
  454. chart2.setOption({
  455. tooltip: {
  456. trigger: 'item',
  457. formatter: '{b}: {c} ({d}%)'
  458. },
  459. color: ['#ff4d4f', '#52c41a', '#409eff'], // 红 绿 蓝
  460. series: [
  461. {
  462. type: 'pie',
  463. radius: ['50%', '70%'],
  464. avoidLabelOverlap: false,
  465. label: { show: false },
  466. data: [
  467. { value: overviewData.value.group_triple.new_normal_login_val, name: '新非网数量' },
  468. { value: overviewData.value.group_triple.member_val, name: '会员用户' },
  469. { value: overviewData.value.group_triple.old_normal_val, name: '老非网数量' }
  470. ]
  471. }
  472. ]
  473. });
  474. }
  475. });
  476. } else if (activeTab.value === 'detail') {
  477. // 这里的初始化主要用于空状态或第一次渲染,数据更新由 updateBarChart 处理
  478. // 如果没有数据,可以不初始化,或者初始化为空
  479. // fetchDetailData 会被调用并初始化图表
  480. }
  481. };
  482. const updateBarChart = (chartData, newSources, oldSources) => {
  483. if (!chartBarRef.value) return;
  484. if (!chartBarInstance) {
  485. chartBarInstance = echarts.init(chartBarRef.value);
  486. }
  487. const xAxisData = chartData.x_axis || [];
  488. const yAxisData = chartData.y_axis || [];
  489. // 分离新老用户数据
  490. const newUserData = [];
  491. const oldUserData = [];
  492. // 提取渠道名称用于匹配
  493. const newSourceChannels = newSources.map(s => s.channel);
  494. xAxisData.forEach((label, index) => {
  495. const val = yAxisData[index] || 0;
  496. if (newSourceChannels.includes(label)) {
  497. newUserData.push(val);
  498. oldUserData.push(0);
  499. } else {
  500. newUserData.push(0);
  501. oldUserData.push(val);
  502. }
  503. });
  504. const option = {
  505. tooltip: {
  506. trigger: 'item',
  507. },
  508. legend: {
  509. data: ['新用户', '老用户'],
  510. top: 'top',
  511. left: 'center'
  512. },
  513. grid: {
  514. left: '3%',
  515. right: '4%',
  516. bottom: '3%',
  517. containLabel: true
  518. },
  519. xAxis: [
  520. {
  521. type: 'category',
  522. data: xAxisData,
  523. axisTick: { alignWithLabel: true }
  524. }
  525. ],
  526. yAxis: [
  527. { type: 'value' }
  528. ],
  529. series: [
  530. {
  531. name: '新用户',
  532. type: 'bar',
  533. barWidth: '20%',
  534. color: '#40a9ff',
  535. data: newUserData,
  536. stack: 'total'
  537. },
  538. {
  539. name: '老用户',
  540. type: 'bar',
  541. barWidth: '20%',
  542. color: '#92CB74',
  543. data: oldUserData,
  544. stack: 'total'
  545. }
  546. ]
  547. };
  548. chartBarInstance.setOption(option);
  549. };
  550. watch(activeTab, (newVal) => {
  551. // 同步 tab 状态到 URL
  552. router.replace({ query: { ...route.query, tab: newVal } });
  553. if (newVal === 'overview') {
  554. fetchData();
  555. } else if (newVal === 'detail') {
  556. fetchDetailData();
  557. }
  558. });
  559. onMounted(() => {
  560. if (activeTab.value === 'overview') {
  561. fetchData();
  562. } else {
  563. fetchDetailData();
  564. }
  565. });
  566. </script>
  567. <style scoped>
  568. .user-overview-container {
  569. background-color: #fee6e6;
  570. }
  571. /* Tabs */
  572. .tab-header {
  573. display: flex;
  574. margin-bottom: 20px;
  575. }
  576. .tab-item {
  577. padding: 6px 16px;
  578. margin-right: 10px;
  579. background-color: #fff;
  580. border: 1px solid #ffcccc;
  581. border-radius: 4px;
  582. cursor: pointer;
  583. color: #ff4d4f;
  584. font-size: 14px;
  585. }
  586. .tab-item.active {
  587. background-color: #ff4d4f;
  588. color: #fff;
  589. }
  590. .tab-note {
  591. margin-left: auto;
  592. display: flex;
  593. align-items: center;
  594. font-size: 14px;
  595. color: #666;
  596. }
  597. .red-asterisk {
  598. color: #ff4d4f;
  599. font-weight: bold;
  600. margin-right: 5px;
  601. }
  602. /* Overview Tab */
  603. .stats-row {
  604. display: flex;
  605. gap: 20px;
  606. margin-bottom: 20px;
  607. }
  608. .big-card {
  609. flex: 1;
  610. height: auto;
  611. border-radius: 12px;
  612. padding: 24px;
  613. display: flex;
  614. flex-direction: column;
  615. justify-content: space-between; /* 空间分布 */
  616. color: #fff;
  617. position: relative; /* 确保绝对定位相对于卡片 */
  618. }
  619. .purple-gradient {
  620. background: linear-gradient(135deg, #a18cd1 0%, #fbc2eb 100%);
  621. }
  622. .orange-gradient {
  623. background: linear-gradient(135deg, #ff9a9e 0%, #fecfef 99%, #fecfef 100%);
  624. background: linear-gradient(to right, #ffafbd, #ffc3a0); /* approximate orange */
  625. background: linear-gradient(90deg, #ff8c6d 0%, #ffcba4 100%);
  626. }
  627. .blue-gradient {
  628. background: linear-gradient(135deg, #9BB7FC 0%, #66a6ff 100%);
  629. }
  630. .right-stats-grid {
  631. flex: 2;
  632. display: grid;
  633. grid-template-columns: 1fr 1fr;
  634. grid-template-rows: auto auto;
  635. gap: 20px;
  636. }
  637. .right-stats-grid .full-width {
  638. grid-column: 1 / -1;
  639. }
  640. .right-stats-grid .small-card-content {
  641. display: flex;
  642. flex-direction: column;
  643. align-items: center;
  644. justify-content: center;
  645. height: 100%;
  646. }
  647. .right-stats-grid .small-card-content .card-title {
  648. margin-bottom: 15px;
  649. text-align: center;
  650. }
  651. .right-stats-grid .small-card-content .card-value-small {
  652. margin-bottom: 10px;
  653. text-align: center;
  654. }
  655. .small-card {
  656. flex: 1;
  657. border-radius: 12px;
  658. padding: 20px;
  659. display: flex;
  660. flex-direction: column;
  661. justify-content: center;
  662. color: #fff;
  663. position: relative; /* 确保绝对定位相对于卡片 */
  664. }
  665. .small-card-row {
  666. display: flex;
  667. flex-direction: row;
  668. justify-content: space-between;
  669. height: 100%;
  670. }
  671. .small-card-item {
  672. flex: 1;
  673. display: flex;
  674. flex-direction: column;
  675. align-items: center;
  676. justify-content: center;
  677. padding: 0 10px;
  678. }
  679. .small-card-item:first-child {
  680. border-right: 1px solid rgba(255, 255, 255, 0.3);
  681. }
  682. .small-card-content {
  683. display: flex;
  684. flex-direction: row;
  685. justify-content: space-between;
  686. align-items: center;
  687. margin-bottom: 15px;
  688. }
  689. .small-card-content:last-child {
  690. margin-bottom: 0;
  691. }
  692. .left-part {
  693. display: flex;
  694. align-items: center;
  695. align-self: flex-start; /* 标题垂直居上 */
  696. }
  697. .right-part {
  698. display: flex;
  699. flex-direction: column;
  700. align-items: center; /* 改为水平居中 */
  701. justify-content: center;
  702. margin-right: 150px; /* 右边容器向左移 */
  703. }
  704. .card-title {
  705. font-size: 34px; /* 字体放大 */
  706. font-weight: bold; /* 加粗 */
  707. display: flex;
  708. align-items: center;
  709. gap: 8px;
  710. margin-bottom: 20px; /* 增加底部间距 */
  711. width: 100%; /* 占满宽度 */
  712. }
  713. .small-card .card-title {
  714. width: auto;
  715. margin-bottom: 10px;
  716. text-align: center;
  717. }
  718. .small-card-item .card-title {
  719. font-size: 24px;
  720. margin-bottom: 15px;
  721. }
  722. .small-card-item .card-value-small {
  723. margin-bottom: 10px;
  724. }
  725. .card-value {
  726. font-size: 64px;
  727. font-weight: bold;
  728. margin: 0;
  729. text-align: center;
  730. }
  731. .big-card-content {
  732. flex: 1;
  733. display: flex;
  734. flex-direction: column;
  735. justify-content: center;
  736. align-items: center;
  737. gap: 10px; /* 数字和百分比之间的间距 */
  738. }
  739. .card-value-small {
  740. font-size: 64px;
  741. font-weight: bold;
  742. margin-left: auto;
  743. }
  744. .small-card .card-value-small {
  745. margin-left: 0;
  746. line-height: 1.2;
  747. }
  748. .top-row {
  749. display: flex;
  750. align-items: center;
  751. justify-content: space-between;
  752. }
  753. .card-tag-wrapper {
  754. display: flex;
  755. justify-content: flex-end;
  756. margin-top: 10px;
  757. }
  758. .small-card .card-tag-wrapper {
  759. margin-top: 8px;
  760. display: flex;
  761. justify-content: center;
  762. }
  763. .small-card-item .card-tag-wrapper {
  764. margin-top: 8px;
  765. }
  766. .card-tag {
  767. background-color: #fff;
  768. padding: 8px 16px; /* 增加高度 */
  769. border-radius: 4px;
  770. font-weight: bold;
  771. display: inline-block;
  772. }
  773. .big-card .card-tag {
  774. font-size: 18px;
  775. }
  776. .card-tag.up {
  777. color: #52c41a;
  778. }
  779. .card-tag.down {
  780. color: #ff4d4f;
  781. }
  782. /* Composition Section */
  783. .composition-section {
  784. background: linear-gradient(135deg, #b8c6db 0%, #f5f7fa 100%);
  785. background-color: #b8c4f9; /* Fallback */
  786. background: linear-gradient(to right, #a18cd1, #c2e9fb);
  787. border-radius: 12px;
  788. padding: 20px;
  789. /* background-image: linear-gradient(120deg, #a1c4fd 0%, #c2e9fb 100%); */
  790. background: #a3b1ff; /* approximate purple-ish */
  791. }
  792. .section-header {
  793. color: #fff;
  794. font-size: 24px;
  795. font-weight: bold;
  796. margin-bottom: 15px;
  797. display: flex;
  798. align-items: center;
  799. gap: 8px;
  800. }
  801. .charts-row {
  802. display: flex;
  803. gap: 20px;
  804. }
  805. .chart-wrapper {
  806. flex: 1;
  807. background: #e6e9f5;
  808. border-radius: 12px;
  809. border: 2px solid #fff;
  810. height: 220px;
  811. display: flex;
  812. align-items: center;
  813. padding: 20px;
  814. }
  815. .chart-box {
  816. flex: 1;
  817. height: 100%;
  818. }
  819. .legend-custom {
  820. display: flex;
  821. flex-direction: column;
  822. gap: 10px;
  823. min-width: 120px;
  824. }
  825. .legend-item {
  826. display: flex;
  827. align-items: center;
  828. font-size: 14px;
  829. color: #333;
  830. font-weight: bold;
  831. }
  832. .dot {
  833. width: 12px;
  834. height: 12px;
  835. border-radius: 50%;
  836. margin-right: 8px;
  837. }
  838. .dot.green { background-color: #52c41a; }
  839. .dot.red { background-color: #ff4d4f; }
  840. .dot.blue { background-color: #409eff; }
  841. /* Detail Tab */
  842. .search-bar {
  843. background: #fff;
  844. padding: 15px;
  845. border-radius: 8px;
  846. display: flex;
  847. align-items: center;
  848. gap: 10px;
  849. margin-bottom: 20px;
  850. border: 1px solid #f0f0f0;
  851. position: sticky;
  852. top: 0;
  853. z-index: 1000;
  854. box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
  855. }
  856. .search-label {
  857. font-weight: bold;
  858. margin-right: 5px;
  859. }
  860. .search-btn, .reset-btn {
  861. width: 80px;
  862. }
  863. .search-btn {
  864. background-color: #409eff;
  865. }
  866. .reset-btn {
  867. background-color: #409eff;
  868. border-color: #409eff;
  869. }
  870. .export-btn {
  871. margin-left: auto;
  872. background-color: #ff7875;
  873. border-color: #ff7875;
  874. }
  875. .detail-section {
  876. background: #fff;
  877. border-radius: 8px;
  878. padding: 15px;
  879. margin-bottom: 20px;
  880. border: 1px solid #f0f0f0;
  881. }
  882. .section-title {
  883. color: #409eff;
  884. font-size: 16px;
  885. font-weight: bold;
  886. margin-bottom: 15px;
  887. display: flex;
  888. align-items: center;
  889. gap: 5px;
  890. }
  891. .text-red { color: #ff4d4f; font-weight: bold; }
  892. .text-green { color: #52c41a; font-weight: bold; }
  893. .text-black-bold { color: #333; font-weight: bold; }
  894. .sub-item-text {
  895. font-size: 12px;
  896. padding-left: 20px;
  897. color: #606266;
  898. display: inline-block;
  899. }
  900. .chart-section-bg {
  901. /* background: #fff; already set by detail-section */
  902. }
  903. .bar-chart-box {
  904. width: 100%;
  905. height: 350px;
  906. }
  907. .refresh-time {
  908. position: fixed;
  909. bottom: 20px;
  910. right: 20px;
  911. background: rgba(0, 0, 0, 0.6);
  912. color: #fff;
  913. padding: 8px 15px;
  914. border-radius: 20px;
  915. font-size: 12px;
  916. z-index: 2000;
  917. box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
  918. }
  919. </style>