920 lines
26 KiB
Vue
920 lines
26 KiB
Vue
<template>
|
||
<view class="feature-page">
|
||
<view class="page-header" :style="{ paddingTop: statusBarHeight + 'px' }">
|
||
<view class="nav-bar">
|
||
<view class="nav-left" @click="goBack">
|
||
<text class="back-icon">←</text>
|
||
</view>
|
||
<text class="nav-title">接待客户数统计</text>
|
||
</view>
|
||
</view>
|
||
|
||
<scroll-view class="content-scroll" scroll-y="true" :style="{ top: contentTop, bottom: contentBottom }">
|
||
<!-- 搜索区域 -->
|
||
<view class="search-toolbar">
|
||
<view class="toolbar-picker-wrapper" @click="toggleTimeRangeDropdown">
|
||
<view class="toolbar-picker-view">
|
||
<text class="toolbar-picker-text">{{ timeRangeOptions[timeRangeIndex] }}</text>
|
||
<uni-icons :type="showTimeRangeDropdown ? 'top' : 'bottom'" size="14" color="#999"></uni-icons>
|
||
</view>
|
||
<!-- 自定义下拉菜单 -->
|
||
<view class="time-range-dropdown" v-if="showTimeRangeDropdown">
|
||
<view
|
||
class="dropdown-item"
|
||
v-for="(option, index) in timeRangeOptions"
|
||
:key="index"
|
||
:class="{ 'dropdown-item-active': timeRangeIndex === index }"
|
||
@click.stop="selectTimeRange(index)">
|
||
<text>{{ option }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
<view class="toolbar-actions">
|
||
<view class="toolbar-btn toolbar-btn--refresh" @click="onRefresh">
|
||
<uni-icons type="refresh" size="18" color="#2A68FF"></uni-icons>
|
||
<text>刷新</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 遮罩层,点击关闭下拉菜单 -->
|
||
<view class="dropdown-mask" v-if="showTimeRangeDropdown" @click="closeTimeRangeDropdown"></view>
|
||
|
||
<!-- 统计卡片 -->
|
||
<view class="stats-card">
|
||
<view class="stat-item">
|
||
<text class="stat-value">{{ totalCustomers }}</text>
|
||
<text class="stat-label">累计接待客户</text>
|
||
</view>
|
||
<view class="stat-item">
|
||
<text class="stat-value">{{ todayCustomers }}</text>
|
||
<text class="stat-label">今日接待客户</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 图表区域 -->
|
||
<view class="chart-section">
|
||
<text class="section-title">客户数趋势图</text>
|
||
<view class="chart-container">
|
||
<canvas
|
||
canvas-id="trendChart"
|
||
id="trendChart"
|
||
class="chart-canvas"
|
||
:style="{ width: chartWidth + 'px', height: chartHeight + 'px' }"
|
||
></canvas>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 数据列表 -->
|
||
<view class="data-list">
|
||
<text class="section-title">详细数据</text>
|
||
<view class="list-item" v-for="(item, index) in dataList" :key="index">
|
||
<view class="item-left">
|
||
<text class="item-date">{{ item.date }}</text>
|
||
<text class="item-desc">{{ item.description }}</text>
|
||
</view>
|
||
<view class="item-right">
|
||
<text class="item-count">{{ item.count }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</scroll-view>
|
||
</view>
|
||
</template>
|
||
|
||
<script>
|
||
import { post } from '@/common/request.js'
|
||
import { getApiUrl } from '@/common/config.js'
|
||
|
||
// 获取系统信息的辅助函数
|
||
function getSystemInfo() {
|
||
// #ifdef MP-WEIXIN
|
||
// 微信小程序:优先使用新的 API
|
||
if (typeof wx !== 'undefined' && wx.getWindowInfo) {
|
||
try {
|
||
const windowInfo = wx.getWindowInfo();
|
||
return {
|
||
statusBarHeight: windowInfo.statusBarHeight || 20,
|
||
windowWidth: windowInfo.windowWidth,
|
||
windowHeight: windowInfo.windowHeight
|
||
};
|
||
} catch (e) {
|
||
console.warn('[CustomerCount] Failed to use wx.getWindowInfo, fallback to getSystemInfoSync:', e);
|
||
}
|
||
}
|
||
// #endif
|
||
|
||
// 回退到旧的 API 或使用 uni-app 的 API
|
||
try {
|
||
const systemInfo = uni.getSystemInfoSync();
|
||
return {
|
||
statusBarHeight: systemInfo.statusBarHeight || 20,
|
||
windowWidth: systemInfo.windowWidth,
|
||
windowHeight: systemInfo.windowHeight
|
||
};
|
||
} catch (e) {
|
||
console.error('[CustomerCount] Failed to get system info:', e);
|
||
return {
|
||
statusBarHeight: 20,
|
||
windowWidth: 375,
|
||
windowHeight: 667
|
||
};
|
||
}
|
||
}
|
||
|
||
export default {
|
||
data() {
|
||
// 动态计算导航栏高度
|
||
const systemInfo = getSystemInfo();
|
||
const statusBarHeight = systemInfo.statusBarHeight;
|
||
const navbarHeight = 44; // navbar高度44px
|
||
// 转换为rpx:1px = 2rpx(在375px设计稿下)
|
||
const statusBarHeightRpx = statusBarHeight * 2;
|
||
const navbarHeightRpx = navbarHeight * 2;
|
||
const totalNavbarHeight = statusBarHeightRpx + navbarHeightRpx;
|
||
|
||
return {
|
||
statusBarHeight: statusBarHeight, // 状态栏高度(px)
|
||
contentTop: totalNavbarHeight + 'rpx', // 内容区域距离顶部的距离
|
||
contentBottom: '0rpx', // 内容区域距离底部的距离(默认值,会在 onReady 中更新)
|
||
timeRangeOptions: ['1个月', '3个月', '6个月'],
|
||
timeRangeIndex: 0, // 默认选择1个月
|
||
showTimeRangeDropdown: false, // 控制下拉菜单显示
|
||
totalCustomers: '0',
|
||
todayCustomers: '0',
|
||
dataList: [],
|
||
loading: false,
|
||
chartWidth: 0,
|
||
chartHeight: 300,
|
||
chartData: []
|
||
}
|
||
},
|
||
onLoad() {
|
||
// 页面加载时获取数据
|
||
this.loadData();
|
||
// 初始化图表尺寸
|
||
this.initChartSize();
|
||
},
|
||
onReady() {
|
||
// 页面渲染完成后,查询实际导航栏高度并更新位置
|
||
this.$nextTick(() => {
|
||
// 查询导航栏实际高度
|
||
const query = uni.createSelectorQuery().in(this);
|
||
query.select('.nav-bar').boundingClientRect((data) => {
|
||
if (data && data.height) {
|
||
// 使用实际高度
|
||
const actualHeight = data.height; // 实际导航栏高度(px)
|
||
// 转换为rpx:1px = 2rpx(在375px设计稿下)
|
||
const actualHeightRpx = actualHeight * 2;
|
||
const statusBarHeightRpx = this.statusBarHeight * 2;
|
||
const totalHeight = statusBarHeightRpx + actualHeightRpx;
|
||
// 稍微减小一点,确保紧贴
|
||
const adjustedHeight = Math.max(totalHeight - 4, 0);
|
||
this.$set(this, 'contentTop', adjustedHeight + 'rpx');
|
||
console.log('[CustomerCount] 使用实际导航栏高度:', actualHeight, 'px =', actualHeightRpx, 'rpx, 总高度:', totalHeight, 'rpx, 调整后:', adjustedHeight, 'rpx');
|
||
} else {
|
||
// 如果查询失败,使用默认计算
|
||
const systemInfo = getSystemInfo();
|
||
const statusBarHeight = systemInfo.statusBarHeight;
|
||
const navbarHeight = 44;
|
||
const statusBarHeightRpx = statusBarHeight * 2;
|
||
const navbarHeightRpx = navbarHeight * 2;
|
||
const totalNavbarHeight = statusBarHeightRpx + navbarHeightRpx;
|
||
const adjustedHeight = Math.max(totalNavbarHeight - 4, 0);
|
||
this.$set(this, 'contentTop', adjustedHeight + 'rpx');
|
||
}
|
||
}).exec();
|
||
|
||
// 查询底部 tabbar 实际高度(如果有)
|
||
const queryTabbar = uni.createSelectorQuery().in(this);
|
||
queryTabbar.select('.tabbar').boundingClientRect((tabbarData) => {
|
||
if (tabbarData && tabbarData.height) {
|
||
// 使用实际高度
|
||
const tabbarHeight = tabbarData.height; // tabbar 实际高度(px)
|
||
const tabbarHeightRpx = tabbarHeight * 2;
|
||
// 稍微减小一点,确保紧贴
|
||
const adjustedBottom = Math.max(tabbarHeightRpx - 4, 0);
|
||
this.$set(this, 'contentBottom', adjustedBottom + 'rpx');
|
||
console.log('[CustomerCount] 使用实际tabbar高度:', tabbarHeight, 'px =', tabbarHeightRpx, 'rpx, 调整后:', adjustedBottom, 'rpx');
|
||
} else {
|
||
// 如果查询失败,使用默认值(无tabbar)
|
||
this.$set(this, 'contentBottom', '0rpx');
|
||
}
|
||
}).exec();
|
||
|
||
// 延迟再次更新,确保DOM已渲染
|
||
setTimeout(() => {
|
||
// 再次查询并更新导航栏
|
||
const query2 = uni.createSelectorQuery().in(this);
|
||
query2.select('.nav-bar').boundingClientRect((data) => {
|
||
if (data && data.height) {
|
||
const actualHeight = data.height;
|
||
const actualHeightRpx = actualHeight * 2;
|
||
const statusBarHeightRpx = this.statusBarHeight * 2;
|
||
const totalHeight = statusBarHeightRpx + actualHeightRpx;
|
||
const adjustedHeight = Math.max(totalHeight - 4, 0);
|
||
this.$set(this, 'contentTop', adjustedHeight + 'rpx');
|
||
console.log('[CustomerCount] 延迟更新导航栏高度:', actualHeight, 'px =', actualHeightRpx, 'rpx, 总高度:', totalHeight, 'rpx, 调整后:', adjustedHeight, 'rpx');
|
||
}
|
||
}).exec();
|
||
|
||
// 再次查询 tabbar 高度
|
||
const queryTabbar2 = uni.createSelectorQuery().in(this);
|
||
queryTabbar2.select('.tabbar').boundingClientRect((tabbarData) => {
|
||
if (tabbarData && tabbarData.height) {
|
||
const tabbarHeight = tabbarData.height;
|
||
const tabbarHeightRpx = tabbarHeight * 2;
|
||
const adjustedBottom = Math.max(tabbarHeightRpx - 4, 0);
|
||
this.$set(this, 'contentBottom', adjustedBottom + 'rpx');
|
||
console.log('[CustomerCount] 延迟更新tabbar高度:', tabbarHeight, 'px =', tabbarHeightRpx, 'rpx, 调整后:', adjustedBottom, 'rpx');
|
||
}
|
||
}).exec();
|
||
}, 200);
|
||
});
|
||
},
|
||
methods: {
|
||
goBack() {
|
||
uni.navigateBack()
|
||
},
|
||
toggleTimeRangeDropdown() {
|
||
this.showTimeRangeDropdown = !this.showTimeRangeDropdown;
|
||
},
|
||
closeTimeRangeDropdown() {
|
||
this.showTimeRangeDropdown = false;
|
||
},
|
||
selectTimeRange(index) {
|
||
this.timeRangeIndex = index;
|
||
this.showTimeRangeDropdown = false;
|
||
// 根据选择的时间范围重新加载数据
|
||
this.loadData();
|
||
},
|
||
onRefresh() {
|
||
// 刷新数据
|
||
this.loadData();
|
||
},
|
||
/**
|
||
* 获取当前登录用户信息
|
||
*/
|
||
getCurrentUserInfo() {
|
||
try {
|
||
const loginResponse = uni.getStorageSync('backend-login-response') || {};
|
||
return {
|
||
salesId: loginResponse.userId ? String(loginResponse.userId) : null,
|
||
salesPhone: loginResponse.phone || null
|
||
};
|
||
} catch (e) {
|
||
console.error('获取用户信息失败:', e);
|
||
return { salesId: null, salesPhone: null };
|
||
}
|
||
},
|
||
/**
|
||
* 格式化客户数
|
||
*/
|
||
formatCustomerCount(count) {
|
||
if (!count || count === 0) return '0';
|
||
return String(count);
|
||
},
|
||
/**
|
||
* 格式化日期
|
||
*/
|
||
formatDate(dateStr) {
|
||
if (!dateStr) return '';
|
||
try {
|
||
const date = new Date(dateStr);
|
||
const year = date.getFullYear();
|
||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||
const day = String(date.getDate()).padStart(2, '0');
|
||
return `${year}-${month}-${day}`;
|
||
} catch (e) {
|
||
return dateStr;
|
||
}
|
||
},
|
||
/**
|
||
* 加载统计数据
|
||
*/
|
||
async loadData() {
|
||
// 防止重复请求
|
||
if (this.loading) {
|
||
return;
|
||
}
|
||
|
||
this.loading = true;
|
||
|
||
try {
|
||
// 获取当前用户信息
|
||
const userInfo = this.getCurrentUserInfo();
|
||
const { salesId, salesPhone } = userInfo;
|
||
|
||
// 验证:至少需要提供一个查询条件
|
||
if (!salesId && !salesPhone) {
|
||
uni.showToast({
|
||
title: '无法获取用户信息',
|
||
icon: 'none',
|
||
duration: 2000
|
||
});
|
||
this.loading = false;
|
||
return;
|
||
}
|
||
|
||
// 根据 timeRangeIndex 确定 dateRangeType
|
||
// 0 -> 1个月(1), 1 -> 3个月(2), 2 -> 6个月(3)
|
||
const dateRangeType = this.timeRangeIndex + 1;
|
||
|
||
// 构建查询参数(后端使用 @RequestParam,参数需要作为 URL 查询参数传递)
|
||
const queryParams = {
|
||
dateRangeType: dateRangeType
|
||
};
|
||
|
||
// 添加销售人员ID或电话号码(至少提供一个)
|
||
if (salesId) {
|
||
queryParams.salesId = salesId;
|
||
}
|
||
if (salesPhone) {
|
||
queryParams.salesPhone = salesPhone;
|
||
}
|
||
|
||
// 将参数转换为 URL 查询字符串
|
||
const queryString = Object.keys(queryParams)
|
||
.filter(key => queryParams[key] !== null && queryParams[key] !== undefined && queryParams[key] !== '')
|
||
.map(key => `${encodeURIComponent(key)}=${encodeURIComponent(queryParams[key])}`)
|
||
.join('&');
|
||
|
||
// 构建完整URL(使用接待客户数统计接口)
|
||
// 注意:如果后端接口不同,需要修改此处的URL
|
||
const baseUrl = getApiUrl('/api/customer-statistics/sales');
|
||
const url = queryString ? `${baseUrl}?${queryString}` : baseUrl;
|
||
|
||
console.log('[CustomerCount] 请求参数:', queryParams);
|
||
console.log('[CustomerCount] 请求URL:', url);
|
||
|
||
// 显示加载提示
|
||
uni.showLoading({
|
||
title: '加载中...',
|
||
mask: true
|
||
});
|
||
|
||
// 发送POST请求(参数已在URL的query string中,data为空)
|
||
const res = await post(url, {});
|
||
|
||
uni.hideLoading();
|
||
|
||
// 处理响应
|
||
if (res.statusCode === 200 && res.data) {
|
||
const result = res.data;
|
||
|
||
// 检查返回结果(支持两种格式:success 或 code)
|
||
// code: 0 表示成功,code: 200 也表示成功
|
||
const isSuccess = result.success === true || result.code === 0 || result.code === 200;
|
||
|
||
if (!isSuccess) {
|
||
const errorMsg = result.msg || result.message || '查询失败';
|
||
uni.showToast({
|
||
title: errorMsg,
|
||
icon: 'none',
|
||
duration: 2000
|
||
});
|
||
this.loading = false;
|
||
return;
|
||
}
|
||
|
||
// 获取统计数据列表
|
||
const statisticsList = result.data || [];
|
||
|
||
console.log('[CustomerCount] 返回数据:', statisticsList);
|
||
console.log('[CustomerCount] 返回结果:', result);
|
||
|
||
// 处理统计数据
|
||
this.processStatisticsData(statisticsList);
|
||
|
||
// 刷新成功后显示提示
|
||
uni.showToast({
|
||
title: '刷新成功',
|
||
icon: 'success',
|
||
duration: 1500
|
||
});
|
||
|
||
} else {
|
||
throw new Error('请求失败,状态码: ' + res.statusCode);
|
||
}
|
||
|
||
} catch (error) {
|
||
uni.hideLoading();
|
||
console.error('[CustomerCount] 加载数据失败:', error);
|
||
uni.showToast({
|
||
title: error.message || '加载失败,请重试',
|
||
icon: 'none',
|
||
duration: 2000
|
||
});
|
||
} finally {
|
||
this.loading = false;
|
||
}
|
||
},
|
||
/**
|
||
* 处理统计数据
|
||
*/
|
||
processStatisticsData(statisticsList) {
|
||
if (!statisticsList || statisticsList.length === 0) {
|
||
// 没有数据时重置显示
|
||
this.totalCustomers = '0';
|
||
this.todayCustomers = '0';
|
||
this.dataList = [];
|
||
return;
|
||
}
|
||
|
||
// 注意:salesTotalCustomerCount 是销售人员在所有门店的总客户数(所有记录的值都相同)
|
||
// 所以应该使用第一条记录的 salesTotalCustomerCount 作为总客户数,而不是累加
|
||
const firstItem = statisticsList[0];
|
||
const totalCustomerCount = firstItem.salesTotalCustomerCount || firstItem.salesCustomerCount || 0;
|
||
|
||
// 获取今日客户数(如果有今日数据)
|
||
const today = new Date();
|
||
const todayStr = this.formatDate(today.toISOString());
|
||
const todayItem = statisticsList.find(item => {
|
||
const itemDate = this.formatDate(item.statisticsDate);
|
||
return itemDate === todayStr;
|
||
});
|
||
const todayCustomerCount = todayItem ? (todayItem.salesCustomerCount || 0) : 0;
|
||
|
||
// 构建数据列表(展示每个门店的统计数据)
|
||
const formattedList = statisticsList.map(item => {
|
||
// 使用销售人员接待客户数作为该条记录的客户数
|
||
const customerCount = item.salesCustomerCount || 0;
|
||
|
||
// 格式化日期
|
||
const date = this.formatDate(item.statisticsDate);
|
||
|
||
// 构建描述信息(显示门店名称和日期)
|
||
let description = '';
|
||
if (item.dealershipName) {
|
||
description = `${item.dealershipName}`;
|
||
} else if (item.salesName) {
|
||
description = `${item.salesName}的接待`;
|
||
} else {
|
||
description = '客户接待统计';
|
||
}
|
||
|
||
return {
|
||
date: date,
|
||
description: description,
|
||
count: `${this.formatCustomerCount(customerCount)} 人`,
|
||
// 保存原始数据,方便后续使用
|
||
rawData: item
|
||
};
|
||
});
|
||
|
||
// 按日期倒序排列(最新的在前)
|
||
formattedList.sort((a, b) => {
|
||
if (a.date > b.date) return -1;
|
||
if (a.date < b.date) return 1;
|
||
return 0;
|
||
});
|
||
|
||
// 更新页面数据
|
||
this.totalCustomers = this.formatCustomerCount(totalCustomerCount);
|
||
this.todayCustomers = this.formatCustomerCount(todayCustomerCount);
|
||
this.dataList = formattedList;
|
||
|
||
console.log('[CustomerCount] 数据处理完成:', {
|
||
totalCustomers: this.totalCustomers,
|
||
todayCustomers: this.todayCustomers,
|
||
dataListCount: this.dataList.length,
|
||
statisticsListLength: statisticsList.length
|
||
});
|
||
|
||
// 准备图表数据并绘制
|
||
this.prepareChartData(statisticsList);
|
||
},
|
||
/**
|
||
* 初始化图表尺寸
|
||
*/
|
||
initChartSize() {
|
||
// 获取屏幕宽度
|
||
const systemInfo = uni.getSystemInfoSync();
|
||
const screenWidth = systemInfo.windowWidth || 375;
|
||
// 图表宽度 = 屏幕宽度 - 左右边距(60rpx = 30px) - 卡片内边距(60rpx = 30px)
|
||
this.chartWidth = screenWidth - 60;
|
||
},
|
||
/**
|
||
* 准备图表数据
|
||
*/
|
||
prepareChartData(statisticsList) {
|
||
if (!statisticsList || statisticsList.length === 0) {
|
||
this.chartData = [];
|
||
return;
|
||
}
|
||
|
||
// 按日期正序排列(从旧到新,用于图表显示)
|
||
const sortedList = [...statisticsList].sort((a, b) => {
|
||
const dateA = new Date(a.statisticsDate);
|
||
const dateB = new Date(b.statisticsDate);
|
||
return dateA - dateB;
|
||
});
|
||
|
||
// 提取日期和客户数数据
|
||
this.chartData = sortedList.map(item => ({
|
||
date: item.statisticsDate,
|
||
count: item.salesCustomerCount || 0
|
||
}));
|
||
|
||
// 延迟绘制,确保canvas已渲染
|
||
this.$nextTick(() => {
|
||
setTimeout(() => {
|
||
this.drawChart();
|
||
}, 100);
|
||
});
|
||
},
|
||
/**
|
||
* 绘制趋势图
|
||
*/
|
||
drawChart() {
|
||
if (!this.chartData || this.chartData.length === 0) {
|
||
return;
|
||
}
|
||
|
||
const ctx = uni.createCanvasContext('trendChart', this);
|
||
const width = this.chartWidth;
|
||
const height = this.chartHeight;
|
||
|
||
// 图表边距
|
||
const padding = {
|
||
top: 40,
|
||
right: 20,
|
||
bottom: 50,
|
||
left: 50
|
||
};
|
||
|
||
// 绘图区域
|
||
const chartWidth = width - padding.left - padding.right;
|
||
const chartHeight = height - padding.top - padding.bottom;
|
||
|
||
// 清空画布
|
||
ctx.clearRect(0, 0, width, height);
|
||
|
||
// 计算数据范围
|
||
const counts = this.chartData.map(item => item.count);
|
||
const maxCount = Math.max(...counts, 1);
|
||
const minCount = Math.min(...counts, 0);
|
||
const range = maxCount - minCount || 1;
|
||
|
||
// 计算Y轴刻度(向上取整到合适的值)
|
||
const yMax = Math.ceil(maxCount / 10) * 10;
|
||
const yStep = yMax / 5;
|
||
|
||
// 绘制Y轴刻度和标签
|
||
ctx.setFillStyle('#666666');
|
||
ctx.setFontSize(10);
|
||
for (let i = 0; i <= 5; i++) {
|
||
const y = padding.top + chartHeight - (chartHeight / 5) * i;
|
||
const value = Math.round(yStep * i);
|
||
ctx.fillText(value.toString(), 5, y + 4);
|
||
|
||
// 绘制网格线
|
||
ctx.setStrokeStyle('#e0e0e0');
|
||
ctx.setLineWidth(1);
|
||
ctx.beginPath();
|
||
ctx.moveTo(padding.left, y);
|
||
ctx.lineTo(padding.left + chartWidth, y);
|
||
ctx.stroke();
|
||
}
|
||
|
||
// 绘制X轴日期标签
|
||
const dataCount = this.chartData.length;
|
||
const xStep = chartWidth / Math.max(dataCount - 1, 1);
|
||
|
||
this.chartData.forEach((item, index) => {
|
||
const x = padding.left + xStep * index;
|
||
const date = new Date(item.date);
|
||
const month = date.getMonth() + 1;
|
||
const day = date.getDate();
|
||
const label = `${month}/${day}`;
|
||
|
||
ctx.setFillStyle('#666666');
|
||
ctx.setFontSize(10);
|
||
ctx.fillText(label, x - 15, height - padding.bottom + 20);
|
||
});
|
||
|
||
// 绘制折线
|
||
ctx.setStrokeStyle('#007aff');
|
||
ctx.setLineWidth(2);
|
||
ctx.beginPath();
|
||
|
||
this.chartData.forEach((item, index) => {
|
||
const x = padding.left + xStep * index;
|
||
const y = padding.top + chartHeight - ((item.count / yMax) * chartHeight);
|
||
|
||
if (index === 0) {
|
||
ctx.moveTo(x, y);
|
||
} else {
|
||
ctx.lineTo(x, y);
|
||
}
|
||
});
|
||
|
||
ctx.stroke();
|
||
|
||
// 绘制数据点
|
||
ctx.setFillStyle('#007aff');
|
||
this.chartData.forEach((item, index) => {
|
||
const x = padding.left + xStep * index;
|
||
const y = padding.top + chartHeight - ((item.count / yMax) * chartHeight);
|
||
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, 4, 0, 2 * Math.PI);
|
||
ctx.fill();
|
||
});
|
||
|
||
// 绘制坐标轴
|
||
ctx.setStrokeStyle('#333333');
|
||
ctx.setLineWidth(1);
|
||
|
||
// Y轴
|
||
ctx.beginPath();
|
||
ctx.moveTo(padding.left, padding.top);
|
||
ctx.lineTo(padding.left, padding.top + chartHeight);
|
||
ctx.stroke();
|
||
|
||
// X轴
|
||
ctx.beginPath();
|
||
ctx.moveTo(padding.left, padding.top + chartHeight);
|
||
ctx.lineTo(padding.left + chartWidth, padding.top + chartHeight);
|
||
ctx.stroke();
|
||
|
||
ctx.draw();
|
||
}
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.feature-page {
|
||
min-height: 100vh;
|
||
background: #f8f9fa;
|
||
}
|
||
|
||
.page-header {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
z-index: 999;
|
||
background: #ffffff;
|
||
border-bottom: 1rpx solid #e9ecef;
|
||
}
|
||
|
||
.nav-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
height: 88rpx;
|
||
padding: 0 30rpx;
|
||
}
|
||
|
||
.nav-left {
|
||
width: 60rpx;
|
||
height: 60rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
margin-right: 20rpx;
|
||
}
|
||
|
||
.back-icon {
|
||
font-size: 36rpx;
|
||
color: #333333;
|
||
}
|
||
|
||
.nav-title {
|
||
flex: 1;
|
||
text-align: center;
|
||
font-size: 32rpx;
|
||
font-weight: bold;
|
||
color: #333333;
|
||
}
|
||
|
||
.content-scroll {
|
||
position: absolute;
|
||
left: 0;
|
||
right: 0;
|
||
top: 0; /* 先从顶部开始,通过内联样式动态设置 */
|
||
bottom: 0; /* 先从底部开始,通过内联样式动态设置 */
|
||
/* top和bottom值通过内联样式动态设置,覆盖上面的默认值 */
|
||
padding: 0 !important;
|
||
margin: 0 !important;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.search-toolbar {
|
||
display: flex;
|
||
flex-wrap: nowrap;
|
||
align-items: center;
|
||
gap: 8rpx;
|
||
padding: 12rpx 30rpx;
|
||
margin: 20rpx 30rpx;
|
||
background-color: #F8F8FA;
|
||
border-radius: 8rpx;
|
||
border: 1rpx solid #EFEFF2;
|
||
}
|
||
|
||
.toolbar-picker-wrapper {
|
||
position: relative;
|
||
flex: 1;
|
||
min-width: 120rpx;
|
||
max-width: 200rpx;
|
||
flex-shrink: 1;
|
||
z-index: 100;
|
||
}
|
||
|
||
.toolbar-picker-view {
|
||
height: 56rpx;
|
||
line-height: 56rpx;
|
||
background-color: #F5F6FA;
|
||
border-radius: 8rpx;
|
||
padding: 0 12rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.toolbar-picker-text {
|
||
font-size: 24rpx;
|
||
color: #333;
|
||
flex: 1;
|
||
}
|
||
|
||
.time-range-dropdown {
|
||
position: absolute;
|
||
top: calc(100% + 8rpx);
|
||
left: 0;
|
||
right: 0;
|
||
background-color: #FFFFFF;
|
||
border-radius: 8rpx;
|
||
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.12);
|
||
z-index: 101;
|
||
overflow: hidden;
|
||
border: 1rpx solid #EFEFF2;
|
||
}
|
||
|
||
.dropdown-item {
|
||
padding: 20rpx 24rpx;
|
||
border-bottom: 1rpx solid #F3F4F6;
|
||
transition: background-color 0.2s ease;
|
||
}
|
||
|
||
.dropdown-item:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.dropdown-item:active {
|
||
background-color: #F9FAFB;
|
||
}
|
||
|
||
.dropdown-item text {
|
||
font-size: 24rpx;
|
||
color: #333;
|
||
}
|
||
|
||
.dropdown-item-active {
|
||
background-color: #F0F7FF;
|
||
}
|
||
|
||
.dropdown-item-active text {
|
||
color: #2A68FF;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.dropdown-mask {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background-color: transparent;
|
||
z-index: 99;
|
||
}
|
||
|
||
.toolbar-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
margin-left: auto;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.toolbar-btn {
|
||
padding: 0 12rpx;
|
||
height: 56rpx;
|
||
min-width: 56rpx;
|
||
color: #2A68FF;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 4rpx;
|
||
font-size: 24rpx;
|
||
font-weight: 400;
|
||
}
|
||
|
||
.toolbar-btn text {
|
||
color: #2A68FF;
|
||
}
|
||
|
||
.stats-card {
|
||
display: flex;
|
||
margin: 20rpx 30rpx;
|
||
background: #ffffff;
|
||
border-radius: 16rpx;
|
||
padding: 40rpx 30rpx;
|
||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
|
||
}
|
||
|
||
.stat-item {
|
||
flex: 1;
|
||
text-align: center;
|
||
}
|
||
|
||
.stat-item:not(:last-child) {
|
||
border-right: 1rpx solid #e9ecef;
|
||
}
|
||
|
||
.stat-value {
|
||
display: block;
|
||
font-size: 48rpx;
|
||
font-weight: bold;
|
||
color: #007aff;
|
||
margin-bottom: 10rpx;
|
||
}
|
||
|
||
.stat-label {
|
||
display: block;
|
||
font-size: 24rpx;
|
||
color: #666666;
|
||
}
|
||
|
||
.chart-section, .data-list {
|
||
margin: 20rpx 30rpx;
|
||
background: #ffffff;
|
||
border-radius: 16rpx;
|
||
overflow: hidden;
|
||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
|
||
}
|
||
|
||
.section-title {
|
||
display: block;
|
||
font-size: 32rpx;
|
||
font-weight: bold;
|
||
color: #333333;
|
||
padding: 30rpx;
|
||
border-bottom: 1rpx solid #e9ecef;
|
||
}
|
||
|
||
.chart-container {
|
||
padding: 30rpx;
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
}
|
||
|
||
.chart-canvas {
|
||
display: block;
|
||
}
|
||
|
||
.list-item {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 30rpx;
|
||
border-bottom: 1rpx solid #f0f0f0;
|
||
}
|
||
|
||
.list-item:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.item-left {
|
||
flex: 1;
|
||
}
|
||
|
||
.item-date {
|
||
display: block;
|
||
font-size: 28rpx;
|
||
font-weight: bold;
|
||
color: #333333;
|
||
margin-bottom: 8rpx;
|
||
}
|
||
|
||
.item-desc {
|
||
display: block;
|
||
font-size: 24rpx;
|
||
color: #666666;
|
||
}
|
||
|
||
.item-right {
|
||
text-align: right;
|
||
}
|
||
|
||
.item-count {
|
||
display: block;
|
||
font-size: 28rpx;
|
||
font-weight: bold;
|
||
color: #007aff;
|
||
}
|
||
</style>
|
||
|