Commit 8da5c178 authored by 刘小敏's avatar 刘小敏

数据统计中心计划任务脚本,页面,接口

parent e7e8b184
...@@ -7,6 +7,10 @@ use think\queue\Job; ...@@ -7,6 +7,10 @@ use think\queue\Job;
use think\Db; use think\Db;
use think\Queue; use think\Queue;
/**
* 埋点数据处理任务
* PV UV 等统计
*/
class TrackProcessJob class TrackProcessJob
{ {
/** /**
...@@ -16,6 +20,7 @@ class TrackProcessJob ...@@ -16,6 +20,7 @@ class TrackProcessJob
try { try {
$trackData = $data['data'] ?? []; $trackData = $data['data'] ?? [];
$clientIp = $data['ip'] ?? ''; $clientIp = $data['ip'] ?? '';
$jobTimestamp = isset($data['timestamp']) ? (int)$data['timestamp'] : time();
if (empty($trackData)) { if (empty($trackData)) {
$job->delete(); $job->delete();
...@@ -23,7 +28,7 @@ class TrackProcessJob ...@@ -23,7 +28,7 @@ class TrackProcessJob
} }
// 批量插入埋点数据 // 批量插入埋点数据
$this->insertTracks($trackData, $clientIp); $this->insertTracks($trackData, $clientIp, $jobTimestamp);
// 删除任务 // 删除任务
$job->delete(); $job->delete();
...@@ -43,22 +48,39 @@ class TrackProcessJob ...@@ -43,22 +48,39 @@ class TrackProcessJob
/** /**
* 批量插入埋点数据 * 批量插入埋点数据
*/ */
protected function insertTracks($trackData, $clientIp) { protected function insertTracks($trackData, $clientIp, $jobTimestamp) {
$insertData = []; $insertData = [];
$now = time(); $seen = [];
foreach ($trackData as $item) { foreach ($trackData as $item) {
$insertData[] = [ $trackRow = [
'user_id' => $item['user_id'] ?? 0, 'user_id' => $item['user_id'] ?? 0,
'openid' => $item['openid'] ?? '', 'openid' => $item['openid'] ?? '',
'session_id' => $item['session_id'] ?? '', 'session_id' => $item['session_id'] ?? '',
'event_type' => $item['event_type'] ?? '', 'event_type' => $item['event_type'] ?? '',
'event_code' => $item['event_code'] ?? '',
'page_path' => $item['page_path'] ?? '', 'page_path' => $item['page_path'] ?? '',
'page_title' => $item['page_title'] ?? '', 'page_title' => $item['page_title'] ?? '',
'params' => !empty($item['params']) ? json_encode($item['params']) : '', 'params' => !empty($item['params']) ? json_encode($item['params']) : '',
'ip' => $clientIp, 'ip' => $clientIp,
'create_time' => isset($item['create_time']) ? (int)($item['create_time'] / 1000) : $now 'create_time' => isset($item['create_time']) ? (int)($item['create_time'] / 1000) : $jobTimestamp
]; ];
$dedupKey = $this->buildTrackDedupKey($trackRow);
if (isset($seen[$dedupKey])) {
continue;
}
$seen[$dedupKey] = true;
if ($this->trackExists($trackRow)) {
continue;
}
$insertData[] = $trackRow;
}
if (empty($insertData)) {
return;
} }
// 分批插入(每批500条) // 分批插入(每批500条)
...@@ -69,10 +91,48 @@ class TrackProcessJob ...@@ -69,10 +91,48 @@ class TrackProcessJob
} }
/** /**
* 构建埋点判重键
*/
protected function buildTrackDedupKey($trackRow)
{
return md5(implode('|', [
(string)$trackRow['user_id'],
(string)$trackRow['openid'],
(string)$trackRow['session_id'],
(string)$trackRow['event_type'],
(string)$trackRow['event_code'],
(string)$trackRow['page_path'],
(string)$trackRow['page_title'],
(string)$trackRow['params'],
(string)$trackRow['ip'],
(string)$trackRow['create_time'],
]));
}
/**
* 判断埋点是否已存在
*/
protected function trackExists($trackRow)
{
return Db::name('shopro_miniprogram_track')
->where('user_id', $trackRow['user_id'])
->where('openid', $trackRow['openid'])
->where('session_id', $trackRow['session_id'])
->where('event_type', $trackRow['event_type'])
->where('event_code', $trackRow['event_code'])
->where('page_path', $trackRow['page_path'])
->where('page_title', $trackRow['page_title'])
->where('params', $trackRow['params'])
->where('ip', $trackRow['ip'])
->where('create_time', $trackRow['create_time'])
->find() ? true : false;
}
/**
* 保存失败数据 * 保存失败数据
*/ */
protected function saveFailed($data, $error) { protected function saveFailed($data, $error) {
Db::name('shopro_track_failed')->insert([ Db::name('shopro_miniprogram_track_failed')->insert([
'data' => json_encode($data), 'data' => json_encode($data),
'error' => $error, 'error' => $error,
'attempts' => 3, 'attempts' => 3,
......
...@@ -10,9 +10,30 @@ use app\admin\model\shopro\Share; ...@@ -10,9 +10,30 @@ use app\admin\model\shopro\Share;
use addons\shopro\service\SearchHistory; use addons\shopro\service\SearchHistory;
use think\Db; use think\Db;
/**
* 数据统计中心控制器
*
* 负责管理后台数据统计页面的数据接口,包括:
* - 核心业务指标统计(成交额、订单数、客单价、利润率等)
* - 趋势数据(支持日/周/月周期切换)
* - 转化漏斗分析
* - 客单价分布
* - 实时订单动态
*
* 数据来源优先级:
* 1. 从缓存读取 DataStatsCenter 定时任务生成的统计数据
* 2. 缓存未命中时,降级到数据库实时查询
*/
class Dashboard extends Common class Dashboard extends Common
{ {
/**
* 数据统计中心首页
*
* 返回数据统计中心页面,配置卡片权限
*
* @return mixed 渲染后的视图
*/
public function index() public function index()
{ {
$cardList = [ $cardList = [
...@@ -37,85 +58,271 @@ class Dashboard extends Common ...@@ -37,85 +58,271 @@ class Dashboard extends Common
} }
/** /**
* 获取统计数据 * 获取核心统计数据
*
* 该接口优先从缓存读取 DataStatsCenter 定时任务生成的统计数据,
* 缓存未命中时降级到数据库实时查询。
*
* 请求参数:
* - time_range: 时间范围,可选值:'7days'、'30days'、'90days',默认 '30days'
* - period: 周期类型,可选值:'day'、'week'、'month',默认 'day'
*
* 返回数据:
* - total_amount: 成交总额(已支付和已完成订单的支付金额总和)
* - profit_rate: 净利润率(百分比格式,如 '18.6%')
* - avg_amount: 客单价(成交总额 / 订单数)
* - order_count: 订单数(已支付和已完成订单数量)
* - visitor_count: 访客数(基于小程序行为埋点统计的去重用户数)
* - conversion_rate: 转化率(订单数 / 访客数,百分比格式)
* - register_users: 注册用户数(累计注册用户总数)
* - trend_data: 趋势数据数组,每个元素包含:
* - date: 日期标签
* - amount: 该日期的成交额
* - order_num: 该日期的订单数
* - register_num: 该日期的注册用户数
* - from_cache: 数据来源标识,true 表示来自缓存,false 表示来自实时查询
*/ */
public function statistics() public function statistics()
{ {
$timeRange = $this->request->param('time_range', '30days'); $timeRange = $this->request->param('time_range', '30days');
$period = $this->request->param('period', 'day');
$cacheKey = 'shopro:data_stats_center:' . $timeRange;
$cachedData = cache($cacheKey);
if ($cachedData !== false && isset($cachedData['summary']) && isset($cachedData['trend'])) {
$summary = $cachedData['summary'];
$trendData = $this->buildTrendDataForChart($cachedData['trend'], $period);
$result = [
'total_amount' => $summary['total_amount']['value'],
'profit_rate' => $summary['profit_rate']['formatted'],
'avg_amount' => $summary['avg_amount']['value'],
'order_count' => $summary['order_count']['value'],
'visitor_count' => $summary['visitor_count']['value'],
'conversion_rate' => $summary['conversion_rate']['formatted'],
'register_users' => $summary['register_users']['value'],
'trend_data' => $trendData,
'from_cache' => true
];
// 根据时间范围获取条件 $this->success('获取成功', null, $result);
$timeCondition = $this->getTimeCondition($timeRange); }
// 成交总额 $days = $this->getDaysByTimeRange($timeRange);
$totalAmount = Order::whereTime('createtime', $timeCondition) $trendData = $this->getTrendData($days, $period);
->whereIn('status', [Order::STATUS_PAID, Order::STATUS_COMPLETED])
->sum('pay_fee') ?: 0; $result = [
'total_amount' => $trendData['total_amount'],
'profit_rate' => $trendData['profit_rate'],
'avg_amount' => $trendData['avg_amount'],
'order_count' => $trendData['order_count'],
'visitor_count' => $trendData['visitor_count'],
'conversion_rate' => $trendData['conversion_rate'],
'register_users' => $trendData['register_users'],
'trend_data' => $trendData['trend_data'],
'from_cache' => false
];
// 订单数 $this->success('获取成功', null, $result);
$orderCount = Order::whereTime('createtime', $timeCondition) }
->whereIn('status', [Order::STATUS_PAID, Order::STATUS_COMPLETED])
->count();
// 客单价 /**
$avgAmount = $orderCount > 0 ? bcdiv($totalAmount, $orderCount, 2) : 0; * 根据周期聚合趋势数据
*
* 将按天统计的趋势数据按照指定的周期(日/周/月)进行聚合
*
* @param array $trendData 趋势数据,包含 amount、orders、register 三个子数组
* @param string $period 周期类型:'day'、'week'、'month'
* @return array 聚合后的趋势数据,每个元素包含 date、amount、order_num、register_num
*/
private function buildTrendDataForChart($trendData, $period)
{
$amountData = $trendData['amount'];
$orderData = $trendData['orders'];
$registerData = $trendData['register'];
// 净利润率 (模拟计算:假设成本率80%) $labels = $amountData['labels'];
$profitRate = $totalAmount > 0 ? '18.6%' : '0%'; $amountValues = $amountData['values'];
$orderValues = $orderData['values'];
$registerValues = $registerData['new_users'];
// 访客数 (从用户商品浏览日志表获取) // 强制按天展示数据,确保图表显示完整的每日数据
$visitorCount = 0; $result = [];
try { foreach ($labels as $index => $label) {
$visitorCount = Db::name('shopro_user_goods_log') $result[] = [
->whereTime('createtime', $timeCondition) 'date' => $label,
->distinct(true) 'amount' => $amountValues[$index] ?? 0,
->count('user_id'); 'order_num' => $orderValues[$index] ?? 0,
} catch (\Exception $e) { 'register_num' => $registerValues[$index] ?? 0
$visitorCount = 0; ];
}
return $result;
// 周/月聚合逻辑(暂时禁用,需要时取消注释)
/*
if ($period === 'week') {
return $this->aggregateByWeek($labels, $amountValues, $orderValues, $registerValues);
} elseif ($period === 'month') {
return $this->aggregateByMonth($labels, $amountValues, $orderValues, $registerValues);
} }
// 转化率 = 支付订单数 / 访客数 $result = [];
$conversionRate = $visitorCount > 0 ? bcdiv($orderCount * 100, $visitorCount, 2) . '%' : '0%'; foreach ($labels as $index => $label) {
$result[] = [
'date' => $label,
'amount' => $amountValues[$index] ?? 0,
'order_num' => $orderValues[$index] ?? 0,
'register_num' => $registerValues[$index] ?? 0
];
}
return $result;
*/
}
// 注册用户 /**
$registerUsers = User::whereTime('createtime', $timeCondition)->count(); * 按周聚合数据
*
* 将按天统计的数据按周聚合,每周从周一开始计算
*
* @param array $labels 日期标签数组
* @param array $amountValues 成交额数组
* @param array $orderValues 订单数数组
* @param array $registerValues 注册用户数数组
* @return array 按周聚合后的数据,date 字段格式为 '第X周'
*/
private function aggregateByWeek($labels, $amountValues, $orderValues, $registerValues)
{
$result = [];
$currentWeek = 0;
$weekAmount = 0;
$weekOrder = 0;
$weekRegister = 0;
foreach ($labels as $index => $label) {
$date = date('Y') . '-' . $label;
$dayOfWeek = date('w', strtotime($date));
if ($dayOfWeek == 1 || $index == 0) {
if ($index > 0) {
$result[] = [
'date' => '第' . $currentWeek . '周',
'amount' => $weekAmount,
'order_num' => $weekOrder,
'register_num' => $weekRegister
];
}
$currentWeek++;
$weekAmount = 0;
$weekOrder = 0;
$weekRegister = 0;
}
$weekAmount += $amountValues[$index] ?? 0;
$weekOrder += $orderValues[$index] ?? 0;
$weekRegister += $registerValues[$index] ?? 0;
}
// 获取趋势数据 if ($currentWeek > 0) {
$trendData = $this->getTrendData($timeRange); $result[] = [
'date' => '第' . $currentWeek . '周',
'amount' => $weekAmount,
'order_num' => $weekOrder,
'register_num' => $weekRegister
];
}
$this->success('获取成功', null, [ return $result;
'total_amount' => $totalAmount,
'profit_rate' => $profitRate,
'avg_amount' => $avgAmount,
'order_count' => $orderCount,
'visitor_count' => $visitorCount,
'conversion_rate' => $conversionRate,
'register_users' => $registerUsers,
'trend_data' => $trendData
]);
} }
/** /**
* 获取时间条件 * 按月聚合数据
*
* 将按天统计的数据按月聚合
*
* @param array $labels 日期标签数组
* @param array $amountValues 成交额数组
* @param array $orderValues 订单数数组
* @param array $registerValues 注册用户数数组
* @return array 按月聚合后的数据,date 字段格式为 'XX月'
*/ */
private function getTimeCondition($timeRange) private function aggregateByMonth($labels, $amountValues, $orderValues, $registerValues)
{
$result = [];
$currentMonth = '';
$monthAmount = 0;
$monthOrder = 0;
$monthRegister = 0;
foreach ($labels as $index => $label) {
$month = substr($label, 0, 2);
if ($month != $currentMonth || $index == 0) {
if ($index > 0) {
$result[] = [
'date' => $currentMonth . '月',
'amount' => $monthAmount,
'order_num' => $monthOrder,
'register_num' => $monthRegister
];
}
$currentMonth = $month;
$monthAmount = 0;
$monthOrder = 0;
$monthRegister = 0;
}
$monthAmount += $amountValues[$index] ?? 0;
$monthOrder += $orderValues[$index] ?? 0;
$monthRegister += $registerValues[$index] ?? 0;
}
if ($currentMonth) {
$result[] = [
'date' => $currentMonth . '月',
'amount' => $monthAmount,
'order_num' => $monthOrder,
'register_num' => $monthRegister
];
}
return $result;
}
/**
* 根据时间范围获取天数
*
* @param string $timeRange 时间范围标识
* @return int 对应的天数
*/
private function getDaysByTimeRange($timeRange)
{ {
switch ($timeRange) { switch ($timeRange) {
case '7days': case '7days':
return 'week'; return 7;
case '90days': case '90days':
return 'month'; return 90;
case '30days': case '30days':
default: default:
return 'month'; return 30;
} }
} }
/** /**
* 获取趋势数据 * 获取趋势数据(降级方案)
*
* 当缓存未命中时,直接从数据库查询并计算趋势数据
*
* @param int $days 统计天数
* @param string $period 周期类型
* @return array 包含各项指标和趋势数据的数组
*/ */
private function getTrendData($timeRange) private function getTrendData($days, $period)
{ {
$days = $timeRange == '7days' ? 7 : ($timeRange == '90days' ? 90 : 30);
$data = []; $data = [];
$totalAmount = 0;
$orderCount = 0;
$registerUsers = 0;
$visitorCount = 0;
$format = $this->getDateFormat($period);
for ($i = $days - 1; $i >= 0; $i--) { for ($i = $days - 1; $i >= 0; $i--) {
$date = date('Y-m-d', strtotime("-$i days")); $date = date('Y-m-d', strtotime("-$i days"));
$startTime = strtotime($date . ' 00:00:00'); $startTime = strtotime($date . ' 00:00:00');
...@@ -131,51 +338,136 @@ class Dashboard extends Common ...@@ -131,51 +338,136 @@ class Dashboard extends Common
$registerNum = User::whereBetween('createtime', [$startTime, $endTime])->count(); $registerNum = User::whereBetween('createtime', [$startTime, $endTime])->count();
try {
$visitorNum = Db::name('shopro_user_goods_log')
->whereBetween('createtime', [$startTime, $endTime])
->distinct(true)
->count('user_id');
} catch (\Exception $e) {
$visitorNum = 0;
}
$data[] = [ $data[] = [
'date' => $date, 'date' => date($format, $startTime),
'amount' => $amount, 'amount' => $amount,
'order_num' => $orderNum, 'order_num' => $orderNum,
'register_num' => $registerNum 'register_num' => $registerNum
]; ];
$totalAmount += $amount;
$orderCount += $orderNum;
$registerUsers += $registerNum;
$visitorCount += $visitorNum;
} }
return $data; $avgAmount = $orderCount > 0 ? bcdiv($totalAmount, $orderCount, 2) : 0;
$profitRate = $totalAmount > 0 ? '18.6%' : '0%';
$conversionRate = $visitorCount > 0 ? bcdiv($orderCount * 100, $visitorCount, 2) . '%' : '0%';
return [
'total_amount' => $totalAmount,
'order_count' => $orderCount,
'avg_amount' => $avgAmount,
'profit_rate' => $profitRate,
'visitor_count' => $visitorCount,
'conversion_rate' => $conversionRate,
'register_users' => $registerUsers,
'trend_data' => $data
];
}
/**
* 获取日期格式化字符串
*
* @param string $period 周期类型
* @return string PHP 日期格式化字符串
*/
private function getDateFormat($period)
{
switch ($period) {
case 'week':
return 'Y年W周';
case 'month':
return 'Y年m月';
case 'day':
default:
return 'm-d';
}
} }
/** /**
* 获取转化漏斗数据 * 获取转化漏斗数据
*
* 统计用户从访问到完成支付的转化路径各环节的人数
* 优先从缓存读取,缓存未命中时降级到数据库查询
*
* 请求参数:
* - time_range: 时间范围,可选值:'7days'、'30days'、'90days',默认 '30days'
*
* 返回数据:
* - visit: 访问人数(基于小程序行为埋点统计的去重用户数)
* - browse: 浏览商品人数(type='browse' 的去重用户数)
* - cart: 加入购物车人数(type='cart' 的去重用户数)
* - submit_order: 提交订单人数(下单的去重用户数)
* - pay: 完成支付人数(已支付或已完成订单的去重用户数)
*/ */
public function funnel() public function funnel()
{ {
$timeRange = $this->request->param('time_range', '30days'); $timeRange = $this->request->param('time_range', '30days');
$timeCondition = $this->getTimeCondition($timeRange);
// 访问用户数 $cacheKey = 'shopro:data_stats_center:' . $timeRange;
$cachedData = cache($cacheKey);
if ($cachedData !== false && isset($cachedData['funnel'])) {
$funnel = $cachedData['funnel'];
$result = [];
foreach ($funnel as $item) {
$result[$item['key']] = $item['value'];
}
$this->success('获取成功', null, $result);
}
$days = $this->getDaysByTimeRange($timeRange);
$endTime = time();
$startTime = strtotime("-$days days");
$visitCount = 0;
try {
$visitCount = Db::name('shopro_user_goods_log') $visitCount = Db::name('shopro_user_goods_log')
->whereTime('createtime', $timeCondition) ->whereBetween('createtime', [$startTime, $endTime])
->distinct(true) ->distinct(true)
->count('user_id'); ->count('user_id');
} catch (\Exception $e) {
$visitCount = 0;
}
// 浏览商品用户数 $browseCount = 0;
try {
$browseCount = Db::name('shopro_user_goods_log') $browseCount = Db::name('shopro_user_goods_log')
->whereTime('createtime', $timeCondition) ->whereBetween('createtime', [$startTime, $endTime])
->where('type', 'browse') ->where('type', 'browse')
->distinct(true) ->distinct(true)
->count('user_id'); ->count('user_id');
} catch (\Exception $e) {
$browseCount = 0;
}
// 加入购物车用户数 $cartCount = 0;
try {
$cartCount = Db::name('shopro_user_goods_log') $cartCount = Db::name('shopro_user_goods_log')
->whereTime('createtime', $timeCondition) ->whereBetween('createtime', [$startTime, $endTime])
->where('type', 'cart') ->where('type', 'cart')
->distinct(true) ->distinct(true)
->count('user_id'); ->count('user_id');
} catch (\Exception $e) {
$cartCount = 0;
}
// 提交订单用户数 $submitOrderCount = Order::whereBetween('createtime', [$startTime, $endTime])
$submitOrderCount = Order::whereTime('createtime', $timeCondition)
->distinct(true) ->distinct(true)
->count('user_id'); ->count('user_id');
// 完成支付用户数 $payCount = Order::whereBetween('createtime', [$startTime, $endTime])
$payCount = Order::whereTime('createtime', $timeCondition)
->whereIn('status', [Order::STATUS_PAID, Order::STATUS_COMPLETED]) ->whereIn('status', [Order::STATUS_PAID, Order::STATUS_COMPLETED])
->distinct(true) ->distinct(true)
->count('user_id'); ->count('user_id');
...@@ -188,15 +480,44 @@ class Dashboard extends Common ...@@ -188,15 +480,44 @@ class Dashboard extends Common
'pay' => $payCount 'pay' => $payCount
]); ]);
} }
/** /**
* 获取客单价分布 * 获取客单价分布
*
* 统计不同价格区间的订单占比
* 优先从缓存读取,缓存未命中时降级到数据库查询
*
* 请求参数:
* - time_range: 时间范围,可选值:'7days'、'30days'、'90days',默认 '30days'
*
* 返回数据:
* - '0-100': 0-100元订单占比(百分比)
* - '100-300': 100-300元订单占比(百分比)
* - '300-500': 300-500元订单占比(百分比)
* - '500-1000': 500-1000元订单占比(百分比)
* - '1000+': 1000元以上订单占比(百分比)
*/ */
public function priceDistribution() public function priceDistribution()
{ {
$timeRange = $this->request->param('time_range', '30days'); $timeRange = $this->request->param('time_range', '30days');
$timeCondition = $this->getTimeCondition($timeRange);
$orders = Order::whereTime('createtime', $timeCondition) $cacheKey = 'shopro:data_stats_center:' . $timeRange;
$cachedData = cache($cacheKey);
if ($cachedData !== false && isset($cachedData['price_distribution'])) {
$distribution = $cachedData['price_distribution'];
$result = [];
foreach ($distribution as $item) {
$result[$item['label']] = $item['rate'];
}
$this->success('获取成功', null, $result);
}
$days = $this->getDaysByTimeRange($timeRange);
$endTime = time();
$startTime = strtotime("-$days days");
$orders = Order::whereBetween('createtime', [$startTime, $endTime])
->whereIn('status', [Order::STATUS_PAID, Order::STATUS_COMPLETED]) ->whereIn('status', [Order::STATUS_PAID, Order::STATUS_COMPLETED])
->field('pay_fee') ->field('pay_fee')
->select(); ->select();
...@@ -226,18 +547,42 @@ class Dashboard extends Common ...@@ -226,18 +547,42 @@ class Dashboard extends Common
} }
} }
// 转换为百分比
foreach ($distribution as &$val) { foreach ($distribution as &$val) {
$val = $total > 0 ? round($val / $total * 100) : 0; $val = $total > 0 ? round($val / $total * 100) : 0;
} }
$this->success('获取成功', null, $distribution); $this->success('获取成功', null, $distribution);
} }
/** /**
* 获取实时订单动态 * 获取实时订单动态
*
* 获取最近10条已支付或已完成的订单信息
* 优先从缓存读取,缓存未命中时降级到数据库查询
*
* 返回数据数组,每个元素包含:
* - amount: 订单金额
* - time: 订单时间(格式化为相对时间,如'刚刚'、'5分钟前'等)
* - location: 用户位置(城市名称)
*/ */
public function recentOrders() public function recentOrders()
{ {
$cacheKey = 'shopro:data_stats_center:30days';
$cachedData = cache($cacheKey);
if ($cachedData !== false && isset($cachedData['recent_orders'])) {
$orders = $cachedData['recent_orders'];
$result = [];
foreach ($orders as $order) {
$result[] = [
'amount' => $order['amount'],
'time' => $this->formatTime(strtotime($order['time'])),
'location' => $order['user'] ?: '未知'
];
}
$this->success('获取成功', null, $result);
}
$orders = Order::whereIn('status', [Order::STATUS_PAID, Order::STATUS_COMPLETED]) $orders = Order::whereIn('status', [Order::STATUS_PAID, Order::STATUS_COMPLETED])
->order('createtime', 'desc') ->order('createtime', 'desc')
->limit(10) ->limit(10)
...@@ -255,13 +600,17 @@ class Dashboard extends Common ...@@ -255,13 +600,17 @@ class Dashboard extends Common
$this->success('获取成功', null, $result); $this->success('获取成功', null, $result);
} }
/** /**
* 格式化时间显示 * 格式化时间为相对时间
*
* @param int $timestamp 时间戳
* @return string 格式化后的时间字符串
*/ */
private function formatTime($timestamp) private function formatTime($timestamp)
{ {
$now = time(); $now = time();
$diff = $now - $timestamp; $diff = $now - (int)($timestamp);
if ($diff < 60) { if ($diff < 60) {
return '刚刚'; return '刚刚';
...@@ -274,207 +623,226 @@ class Dashboard extends Common ...@@ -274,207 +623,226 @@ class Dashboard extends Common
} }
} }
/**
public function total() * 获取用户、分销商、分享统计数据
{ *
// 用户数据 * 返回数据:
$userData = [ * - user_data: 用户数据
'total' => User::count(), * - total: 总用户数
'today' => User::whereTime('createtime', 'today')->count(), * - today: 今日新增用户数
'week' => User::whereTime('createtime', 'week')->count(), * - week: 本周新增用户数
'array' => collection(User::field('id,createtime')->whereTime('createtime', 'today')->select())->each(function ($user) { * - array: 今日用户创建时间数组(用于图表)
$user->counter = 1; * - agent_data: 分销商数据
$user->createtime_unix = $user->getData('createtime') * 1000; * - total: 总分销商数
}) * - today: 今日新增分销商数
]; * - week: 本周新增分销商数
* - array: 今日分销商创建时间数组(用于图表)
// -- commission code start -- * - share_data: 分享数据
$agentData = [ * - total: 总分享次数
'total' => \app\admin\model\shopro\commission\Agent::count(), * - today: 今日分享次数
'today' => \app\admin\model\shopro\commission\Agent::whereTime('createtime', 'today')->count(), * - week: 本周分享次数
'week' => \app\admin\model\shopro\commission\Agent::whereTime('createtime', 'week')->count(), * - array: 今日分享创建时间数组(用于图表)
'array' => collection(\app\admin\model\shopro\commission\Agent::field('user_id,createtime')->whereTime('createtime', 'today')->select())->each(function ($agent) { */
$agent->counter = 1; public function old_total()
$agent->createtime_unix = $agent->getData('createtime') * 1000;
})
];
// -- commission code end --
// 分享数据
$shareData = [
'total' => Share::count(),
'today' => Share::whereTime('createtime', 'today')->count(),
'week' => Share::whereTime('createtime', 'week')->count(),
'array' => collection(Share::field('id,createtime')->whereTime('createtime', 'today')->select())->each(function ($share) {
$share->counter = 1;
$share->createtime_unix = $share->getData('createtime') * 1000;
})
];
$this->success('获取成功', null, [
'user_data' => $userData,
'agent_data' => $agentData ?? null,
'share_data' => $shareData
]);
}
public function chart()
{ {
$date = $this->request->param('date', ''); // $userData = [
$date = array_values(array_filter(explode(' - ', $date))); // 'total' => User::count(),
// 'today' => User::whereTime('createtime', 'today')->count(),
$orders = Order::with(['items'])->whereTime('createtime', 'between', $date) // 'week' => User::whereTime('createtime', 'week')->count(),
->order('id', 'asc')->select(); // 'array' => collection(User::field('id,createtime')->whereTime('createtime', 'today')->select())->each(function ($user) {
// $user->counter = 1;
// 订单数 // $user->createtime_unix = $user->getData('createtime') * 1000;
$data['orderNum'] = count($orders); // })
$data['orderArr'] = []; // ];
// 支付订单(包含退款的订单, 不包含货到付款还未收货(未支付)订单)
$data['payOrderNum'] = 0;
$data['payOrderArr'] = [];
//支付金额(包含退款的订单, 不包含货到付款还未收货(未支付)订单)
$data['payAmountNum'] = 0;
$data['payAmountArr'] = [];
// 支付用户(一个人不管下多少单,都算一个,包含退款的订单, 不包含货到付款还未收货(未支付)订单)
$userIds = [];
$data['payUserNum'] = 0;
$data['payUserArr'] = [];
// 代发货(包含货到付款)
$data['noSendNum'] = 0;
$data['noSendArr'] = [];
//售后维权
$data['aftersaleNum'] = 0;
$data['aftersaleArr'] = [];
//退款订单
$data['refundNum'] = 0;
$data['refundArr'] = [];
foreach ($orders as $key => $order) {
$data['orderArr'][] = [
'counter' => 1,
'createtime' => $order->getData('createtime') * 1000,
'user_id' => $order->user_id
];
// 已支付的,不包含,货到付款未支付的
if (in_array($order->status, [Order::STATUS_PAID, Order::STATUS_COMPLETED])) {
// 支付订单数
$data['payOrderNum']++;
$data['payOrderArr'][] = [
'counter' => 1,
'createtime' => $order->getData('createtime') * 1000,
'user_id' => $order->user_id
];
// 支付金额 // $agentData = [
$data['payAmountNum'] = bcadd((string)$data['payAmountNum'], $order->pay_fee, 2); // 'total' => \app\admin\model\shopro\commission\Agent::count(),
// 'today' => \app\admin\model\shopro\commission\Agent::whereTime('createtime', 'today')->count(),
// 'week' => \app\admin\model\shopro\commission\Agent::whereTime('createtime', 'week')->count(),
// 'array' => collection(\app\admin\model\shopro\commission\Agent::field('user_id,createtime')->whereTime('createtime', 'today')->select())->each(function ($agent) {
// $agent->counter = 1;
// $agent->createtime_unix = $agent->getData('createtime') * 1000;
// })
// ];
$data['payAmountArr'][] = [ // $shareData = [
'counter' => $order->pay_fee, // 'total' => Share::count(),
'createtime' => $order->getData('createtime') * 1000, // 'today' => Share::whereTime('createtime', 'today')->count(),
]; // 'week' => Share::whereTime('createtime', 'week')->count(),
// 'array' => collection(Share::field('id,createtime')->whereTime('createtime', 'today')->select())->each(function ($share) {
// $share->counter = 1;
// $share->createtime_unix = $share->getData('createtime') * 1000;
// })
// ];
// 下单用户 // $this->success('获取成功', null, [
if (!in_array($order->user_id, $userIds)) { // 'user_data' => $userData,
$data['payUserNum']++; // 'agent_data' => $agentData ?? null,
$data['payUserArr'][] = [ // 'share_data' => $shareData
'counter' => 1, // ]);
'createtime' => $order->getData('createtime') * 1000,
'user_id' => $order->user_id
];
}
} }
// 已支付的,和 货到付款未支付的 /**
if (in_array($order->status, [Order::STATUS_PAID, Order::STATUS_COMPLETED]) || $order->isOffline($order)) { * 获取订单图表数据
$flagnoSend = false; *
$flagaftersale = false; * 根据指定时间范围统计订单数据,用于渲染订单趋势图表
$flagrefund = false; *
$aftersaleIng = false; * 请求参数:
foreach ($order->items as $k => $item) { * - date: 时间范围,格式为 '开始时间 - 结束时间'
if ( *
!$flagnoSend * 返回数据:
&& $item->dispatch_status == OrderItem::DISPATCH_STATUS_NOSEND * - orderNum: 订单总数
&& $item->refund_status == OrderItem::REFUND_STATUS_NOREFUND * - orderArr: 订单创建时间数组(用于图表)
&& in_array($order->apply_refund_status, [ * - payOrderNum: 支付订单数
Order::APPLY_REFUND_STATUS_NOAPPLY, * - payOrderArr: 支付订单创建时间数组(用于图表)
Order::APPLY_REFUND_STATUS_REFUSE * - payAmountNum: 支付金额总数
]) * - payAmountArr: 支付金额创建时间数组(用于图表)
) { * - payUserNum: 支付用户数(去重)
$flagnoSend = true; * - payUserArr: 支付用户创建时间数组(用于图表)
} * - noSendNum: 待发货订单数
* - noSendArr: 待发货订单创建时间数组(用于图表)
if ( * - aftersaleNum: 售后维权订单数
$item->aftersale_status == OrderItem::AFTERSALE_STATUS_ING * - aftersaleArr: 售后维权订单创建时间数组(用于图表)
&& $item->dispatch_status == OrderItem::DISPATCH_STATUS_NOSEND * - refundNum: 退款订单数
&& $item->refund_status == OrderItem::REFUND_STATUS_NOREFUND * - refundArr: 退款订单创建时间数组(用于图表)
) { */
$aftersaleIng = true; public function old_chart()
} {
// $date = $this->request->param('date', '');
if (!$flagaftersale && $item->aftersale_status != OrderItem::AFTERSALE_STATUS_NOAFTER) { // $date = array_values(array_filter(explode(' - ', $date)));
$data['aftersaleNum']++;
// $data['aftersaleArr'][] = [ // $orders = Order::with(['items'])->whereTime('createtime', 'between', $date)
// ->order('id', 'asc')->select();
// $data['orderNum'] = count($orders);
// $data['orderArr'] = [];
// $data['payOrderNum'] = 0;
// $data['payOrderArr'] = [];
// $data['payAmountNum'] = 0;
// $data['payAmountArr'] = [];
// $userIds = [];
// $data['payUserNum'] = 0;
// $data['payUserArr'] = [];
// $data['noSendNum'] = 0;
// $data['noSendArr'] = [];
// $data['aftersaleNum'] = 0;
// $data['aftersaleArr'] = [];
// $data['refundNum'] = 0;
// $data['refundArr'] = [];
// foreach ($orders as $key => $order) {
// $data['orderArr'][] = [
// 'counter' => 1, // 'counter' => 1,
// 'createtime' => $order->getData('createtime') * 1000, // 'createtime' => $order->getData('createtime') * 1000,
// 'user_id' => $order->user_id
// ]; // ];
$flagaftersale = true;
}
if (!$flagrefund && $item->refund_status > OrderItem::REFUND_STATUS_NOREFUND) { // if (in_array($order->status, [Order::STATUS_PAID, Order::STATUS_COMPLETED])) {
$data['refundNum']++; // $data['payOrderNum']++;
// $data['refundArr'][] = [ // $data['payOrderArr'][] = [
// 'counter' => 1, // 'counter' => 1,
// 'createtime' => $order->getData('createtime') * 1000, // 'createtime' => $order->getData('createtime') * 1000,
// 'user_id' => $order->user_id
// ]; // ];
$flagrefund = true;
}
}
if (!$aftersaleIng && $flagnoSend) { // $data['payAmountNum'] = bcadd((string)$data['payAmountNum'], $order->pay_fee, 2);
// 存在正在售后中的订单,不算待发货(和订单列表保持一直) // $data['payAmountArr'][] = [
$data['noSendNum']++; // 'counter' => $order->pay_fee,
// $data['noSendArr'][] = [
// 'counter' => 1,
// 'createtime' => $order->getData('createtime') * 1000, // 'createtime' => $order->getData('createtime') * 1000,
// ]; // ];
}
}
}
$this->success('获取成功', null, $data); // if (!in_array($order->user_id, $userIds)) {
// $data['payUserNum']++;
// $data['payUserArr'][] = [
// 'counter' => 1,
// 'createtime' => $order->getData('createtime') * 1000,
// 'user_id' => $order->user_id
// ];
// }
// }
// if (in_array($order->status, [Order::STATUS_PAID, Order::STATUS_COMPLETED]) || $order->isOffline($order)) {
// $flagnoSend = false;
// $flagaftersale = false;
// $flagrefund = false;
// $aftersaleIng = false;
// foreach ($order->items as $k => $item) {
// if (
// !$flagnoSend
// && $item->dispatch_status == OrderItem::DISPATCH_STATUS_NOSEND
// && $item->refund_status == OrderItem::REFUND_STATUS_NOREFUND
// && in_array($order->apply_refund_status, [
// Order::APPLY_REFUND_STATUS_NOAPPLY,
// Order::APPLY_REFUND_STATUS_REFUSE
// ])
// ) {
// $flagnoSend = true;
// }
// if (
// $item->aftersale_status == OrderItem::AFTERSALE_STATUS_ING
// && $item->dispatch_status == OrderItem::DISPATCH_STATUS_NOSEND
// && $item->refund_status == OrderItem::REFUND_STATUS_NOREFUND
// ) {
// $aftersaleIng = true;
// }
// if (!$flagaftersale && $item->aftersale_status != OrderItem::AFTERSALE_STATUS_NOAFTER) {
// $data['aftersaleNum']++;
// $flagaftersale = true;
// }
// if (!$flagrefund && $item->refund_status > OrderItem::REFUND_STATUS_NOREFUND) {
// $data['refundNum']++;
// $flagrefund = true;
// }
// }
// if (!$aftersaleIng && $flagnoSend) {
// $data['noSendNum']++;
// }
// }
// }
// $this->success('获取成功', null, $data);
} }
/**
* 获取商品排名和热门搜索
public function ranking() *
* 返回数据:
* - goods: 销量前5的商品列表,每个商品包含:
* - id: 商品ID
* - title: 商品标题
* - sales: 销量
* - real_sales: 真实销量(包含虚拟销量)
* - sale_total_money: 销售总额
* - hot_search: 热门搜索关键词列表,每个关键词包含:
* - keyword: 搜索词
* - num: 搜索次数
*/
public function old_ranking()
{ {
$goods = Goods::limit(5)->order('sales', 'desc')->select(); // $goods = Goods::limit(5)->order('sales', 'desc')->select();
foreach ($goods as $key => $gd) { // foreach ($goods as $key => $gd) {
$gd->append(['real_sales']); // $gd->append(['real_sales']);
$result = OrderItem::field('sum(goods_num * goods_price) as sale_total_money')->where('goods_id', $gd['id']) // $result = OrderItem::field('sum(goods_num * goods_price) as sale_total_money')->where('goods_id', $gd['id'])
->whereExists(function ($query) use ($gd) { // ->whereExists(function ($query) use ($gd) {
$order_table_name = (new Order())->getQuery()->getTable(); // $order_table_name = (new Order())->getQuery()->getTable();
$table_name = (new OrderItem())->getQuery()->getTable(); // $table_name = (new OrderItem())->getQuery()->getTable();
$query->table($order_table_name)->where($table_name . '.order_id=' . $order_table_name . '.id') // $query->table($order_table_name)->where($table_name . '.order_id=' . $order_table_name . '.id')
->whereIn('status', [Order::STATUS_PAID, Order::STATUS_COMPLETED]); // 已支付的订单 // ->whereIn('status', [Order::STATUS_PAID, Order::STATUS_COMPLETED]);
})->find(); // })->find();
$gd['sale_total_money'] = $result['sale_total_money'] ?: 0; // $gd['sale_total_money'] = $result['sale_total_money'] ?: 0;
} // }
$searchHistory = new SearchHistory(); // $searchHistory = new SearchHistory();
$this->success('获取成功', null, [ // $this->success('获取成功', null, [
'goods' => $goods, // 'goods' => $goods,
'hot_search' => $searchHistory->hotSearch() // 'hot_search' => $searchHistory->hotSearch()
]); // ]);
} }
} }
\ No newline at end of file
<link rel="stylesheet" href="//at.alicdn.com/t/c/font_2385137_b8qygb2jne.css?v={$site.version|htmlentities}"> <link rel="stylesheet" href="//at.alicdn.com/t/c/font_2385137_b8qygb2jne.css?v={$site.version|htmlentities}">
<link rel="stylesheet" href="__CDN__/assets/addons/shopro/css/index.css?v={$site.version|htmlentities}"> <link rel="stylesheet" href="__CDN__/assets/addons/shopro/css/index.css?v={$site.version|htmlentities}">
<script src="__CDN__/assets/libs/jquery/dist/jquery.min.js?v={$site.version|htmlentities}"></script>
{if condition="$DARK_TYPE neq 'none'" } {if condition="$DARK_TYPE neq 'none'" }
<link rel="stylesheet" href="__CDN__/assets/addons/shopro/css/dark.css?v={$site.version|htmlentities}" data-render="darktheme" /> <link rel="stylesheet" href="__CDN__/assets/addons/shopro/css/dark.css?v={$site.version|htmlentities}" data-render="darktheme" />
{/if} {/if}
......
...@@ -3,7 +3,6 @@ ...@@ -3,7 +3,6 @@
<script src="__CDN__/assets/addons/shopro/libs/echarts.min.js?v={$site.version|htmlentities}"></script> <script src="__CDN__/assets/addons/shopro/libs/echarts.min.js?v={$site.version|htmlentities}"></script>
<style> <style>
/* 新增统计卡片样式 - 白色主题 */
.stats-center { .stats-center {
background: #ffffff; background: #ffffff;
border-radius: 12px; border-radius: 12px;
...@@ -32,6 +31,69 @@ ...@@ -32,6 +31,69 @@
.stats-center-time { .stats-center-time {
font-size: 12px; font-size: 12px;
color: #9ca3af; color: #9ca3af;
margin-top: 4px;
}
.header-right {
display: flex;
align-items: center;
gap: 16px;
}
.time-select-wrapper {
position: relative;
}
.time-select {
background: #ffffff;
border: 1px solid #d1d5db;
border-radius: 8px;
padding: 8px 24px 8px 12px;
color: #374151;
font-size: 13px;
cursor: pointer;
appearance: none;
outline: none;
min-width: 100px;
transition: border-color 0.2s ease;
}
.time-select:hover {
border-color: #3b82f6;
}
.time-select:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.time-select-wrapper::after {
content: '\f078';
font-family: 'FontAwesome';
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
color: #9ca3af;
pointer-events: none;
font-size: 12px;
}
.refresh-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
background: linear-gradient(135deg, #3b82f6, #2563eb);
border: none;
border-radius: 8px;
color: #ffffff;
cursor: pointer;
font-size: 13px;
transition: all 0.2s ease;
}
.refresh-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
}
.refresh-btn:active {
transform: translateY(0);
}
.refresh-btn i {
font-size: 14px;
} }
.stats-card-row { .stats-card-row {
display: flex; display: flex;
...@@ -68,6 +130,7 @@ ...@@ -68,6 +130,7 @@
.stats-card.card-5::before { background: linear-gradient(180deg, #3b82f6 0%, #2563eb 100%); } .stats-card.card-5::before { background: linear-gradient(180deg, #3b82f6 0%, #2563eb 100%); }
.stats-card.card-6::before { background: linear-gradient(180deg, #8b5cf6 0%, #7c3aed 100%); } .stats-card.card-6::before { background: linear-gradient(180deg, #8b5cf6 0%, #7c3aed 100%); }
.stats-card.card-7::before { background: linear-gradient(180deg, #ec4899 0%, #db2777 100%); } .stats-card.card-7::before { background: linear-gradient(180deg, #ec4899 0%, #db2777 100%); }
.stats-card.card-8::before { background: linear-gradient(180deg, #06b6d4 0%, #0891b2 100%); }
.stats-card-header { .stats-card-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
...@@ -90,6 +153,7 @@ ...@@ -90,6 +153,7 @@
.card-5 .stats-card-icon { background: rgba(59, 130, 246, 0.15); color: #3b82f6; } .card-5 .stats-card-icon { background: rgba(59, 130, 246, 0.15); color: #3b82f6; }
.card-6 .stats-card-icon { background: rgba(139, 92, 246, 0.15); color: #8b5cf6; } .card-6 .stats-card-icon { background: rgba(139, 92, 246, 0.15); color: #8b5cf6; }
.card-7 .stats-card-icon { background: rgba(236, 72, 153, 0.15); color: #ec4899; } .card-7 .stats-card-icon { background: rgba(236, 72, 153, 0.15); color: #ec4899; }
.card-8 .stats-card-icon { background: rgba(6, 182, 212, 0.15); color: #06b6d4; }
.stats-card-label { .stats-card-label {
font-size: 13px; font-size: 13px;
color: #6b7280; color: #6b7280;
...@@ -117,42 +181,6 @@ ...@@ -117,42 +181,6 @@
margin-right: 4px; margin-right: 4px;
font-size: 10px; font-size: 10px;
} }
/* 时间筛选下拉框样式 */
.time-select-wrapper {
position: relative;
}
.time-select {
background: #ffffff;
border: 1px solid #d1d5db;
border-radius: 8px;
padding: 8px 24px 8px 12px;
color: #374151;
font-size: 13px;
cursor: pointer;
appearance: none;
outline: none;
min-width: 100px;
transition: border-color 0.2s ease;
}
.time-select:hover {
border-color: #3b82f6;
}
.time-select:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.time-select-wrapper::after {
content: '\f078';
font-family: 'FontAwesome';
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
color: #9ca3af;
pointer-events: none;
font-size: 12px;
}
/* 图表卡片样式 */
.chart-panel { .chart-panel {
background: #ffffff; background: #ffffff;
border-radius: 12px; border-radius: 12px;
...@@ -179,24 +207,29 @@ ...@@ -179,24 +207,29 @@
.chart-tabs { .chart-tabs {
display: flex; display: flex;
gap: 8px; gap: 8px;
background: #f3f4f6;
padding: 4px;
border-radius: 8px;
} }
.chart-tab { .chart-tab {
padding: 4px 12px; padding: 6px 14px;
border-radius: 6px; border-radius: 6px;
font-size: 12px; font-size: 12px;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
color: #6b7280; color: #6b7280;
background: #f3f4f6; background: transparent;
border: none;
} }
.chart-tab.active { .chart-tab.active {
background: #3b82f6; background: #ffffff;
color: #ffffff; color: #3b82f6;
font-weight: 600;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
} }
.chart-container { .chart-container {
height: 220px; height: 220px;
} }
/* 转化漏斗样式 */
.funnel-item { .funnel-item {
display: flex; display: flex;
align-items: center; align-items: center;
...@@ -222,16 +255,16 @@ ...@@ -222,16 +255,16 @@
display: flex; display: flex;
align-items: center; align-items: center;
padding-left: 12px; padding-left: 12px;
color: #ffffff; color: #1f2937;
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: 600;
text-shadow: 0 0 2px rgba(255, 255, 255, 0.8);
} }
.funnel-bar.color-1 { background: linear-gradient(90deg, #3b82f6, #60a5fa); } .funnel-bar.color-1 { background: linear-gradient(90deg, #3b82f6, #60a5fa); }
.funnel-bar.color-2 { background: linear-gradient(90deg, #06b6d4, #22d3ee); } .funnel-bar.color-2 { background: linear-gradient(90deg, #06b6d4, #22d3ee); }
.funnel-bar.color-3 { background: linear-gradient(90deg, #10b981, #34d399); } .funnel-bar.color-3 { background: linear-gradient(90deg, #10b981, #34d399); }
.funnel-bar.color-4 { background: linear-gradient(90deg, #f59e0b, #fbbf24); } .funnel-bar.color-4 { background: linear-gradient(90deg, #f59e0b, #fbbf24); }
.funnel-bar.color-5 { background: linear-gradient(90deg, #ec4899, #f472b6); } .funnel-bar.color-5 { background: linear-gradient(90deg, #ec4899, #f472b6); }
/* 客单价分布 */
.price-dist-item { .price-dist-item {
display: flex; display: flex;
align-items: center; align-items: center;
...@@ -262,7 +295,6 @@ ...@@ -262,7 +295,6 @@
color: #9ca3af; color: #9ca3af;
margin-left: 10px; margin-left: 10px;
} }
/* 实时订单动态 */
.order-list { .order-list {
max-height: 200px; max-height: 200px;
overflow-y: auto; overflow-y: auto;
...@@ -303,7 +335,6 @@ ...@@ -303,7 +335,6 @@
border-radius: 4px; border-radius: 4px;
font-size: 11px; font-size: 11px;
} }
/* 注册用户增长数字 */
.growth-badge { .growth-badge {
background: linear-gradient(135deg, #10b981, #059669); background: linear-gradient(135deg, #10b981, #059669);
color: #ffffff; color: #ffffff;
...@@ -312,15 +343,30 @@ ...@@ -312,15 +343,30 @@
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
} }
.data-status {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #10b981;
}
.status-dot {
width: 6px;
height: 6px;
background: #10b981;
border-radius: 50%;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
</style> </style>
<div id="index" class="dashboard-index panel panel-default panel-intro" v-cloak> <div id="index" class="dashboard-index panel panel-default panel-intro" v-cloak>
<el-container class="panel-block"> <el-container class="panel-block">
<el-main class="dashboard-index-main"> <el-main class="dashboard-index-main">
<el-scrollbar height="100%"> <el-scrollbar height="100%">
<!--统计图 Begin-->
<!-- 新增统计卡片区域 - 白色主题 -->
<div class="stats-center"> <div class="stats-center">
<div class="stats-center-header"> <div class="stats-center-header">
<div> <div>
...@@ -328,9 +374,13 @@ ...@@ -328,9 +374,13 @@
<i class="fa fa-bar-chart"></i> <i class="fa fa-bar-chart"></i>
数据统计中心 数据统计中心
</div> </div>
<div class="stats-center-time">实时业务数据监控 · 更新于 {:date('Y-m-d H:i:s')}</div> <div class="stats-center-time">实时业务数据监控 · 更新于 <span id="update-time">{:date('Y-m-d H:i:s')}</span></div>
</div>
<div class="header-right">
<div class="data-status">
<span class="status-dot"></span>
<span>数据同步中</span>
</div> </div>
<!-- 时间筛选下拉框 -->
<div class="time-select-wrapper"> <div class="time-select-wrapper">
<select id="time_range" class="time-select" onchange="loadStatistics(this.value)"> <select id="time_range" class="time-select" onchange="loadStatistics(this.value)">
<option value="7days">近7天</option> <option value="7days">近7天</option>
...@@ -338,6 +388,11 @@ ...@@ -338,6 +388,11 @@
<option value="90days">近90天</option> <option value="90days">近90天</option>
</select> </select>
</div> </div>
<button class="refresh-btn" id="refreshBtn" onclick="refreshAllData()">
<i class="fa fa-refresh"></i>
<span>刷新数据</span>
</button>
</div>
</div> </div>
<div class="stats-card-row"> <div class="stats-card-row">
<div class="stats-card card-1"> <div class="stats-card card-1">
...@@ -390,28 +445,35 @@ ...@@ -390,28 +445,35 @@
</div> </div>
<div class="stats-card card-7"> <div class="stats-card card-7">
<div class="stats-card-header"> <div class="stats-card-header">
<span class="stats-card-label">注册用户</span> <span class="stats-card-label">新增用户</span>
<span class="stats-card-icon"><i class="fa fa-user-plus"></i></span> <span class="stats-card-icon"><i class="fa fa-user-plus"></i></span>
</div> </div>
<div class="stats-card-value" id="stat-new-users">0</div>
<div class="stats-card-trend up"><i class="fa fa-arrow-up"></i> <span id="stat-new-trend">+0(+0%)</span></div>
</div>
<div class="stats-card card-8">
<div class="stats-card-header">
<span class="stats-card-label">注册用户</span>
<span class="stats-card-icon"><i class="fa fa-users"></i></span>
</div>
<div class="stats-card-value" id="stat-register-users">0</div> <div class="stats-card-value" id="stat-register-users">0</div>
<div class="stats-card-trend up"><i class="fa fa-arrow-up"></i> <span id="stat-register-trend">+0(+0%)</span></div> <div class="stats-card-trend up"><i class="fa fa-arrow-up"></i> <span id="stat-register-trend">+0(+0%)</span></div>
</div> </div>
</div> </div>
</div> </div>
<!-- 图表区域 -->
<div class="row"> <div class="row">
<div class="col-lg-6"> <div class="col-lg-6">
<div class="chart-panel"> <div class="chart-panel">
<div class="chart-panel-header"> <div class="chart-panel-header">
<div> <div>
<div class="chart-panel-title">成交总额趋势</div> <div class="chart-panel-title">成交总额趋势</div>
<div class="chart-panel-subtitle">近30天每日GMV走势</div> <div class="chart-panel-subtitle" id="gmv-subtitle">近30天每日GMV走势</div>
</div> </div>
<div class="chart-tabs"> <div class="chart-tabs">
<span class="chart-tab active" onclick="switchChartTab('gmv', 'day')"></span> <button class="chart-tab active" id="gmv-day" onclick="switchPeriod('gmv', 'day')"></button>
<span class="chart-tab" onclick="switchChartTab('gmv', 'week')"></span> <button class="chart-tab" id="gmv-week" onclick="switchPeriod('gmv', 'week')"></button>
<span class="chart-tab" onclick="switchChartTab('gmv', 'month')"></span> <button class="chart-tab" id="gmv-month" onclick="switchPeriod('gmv', 'month')"></button>
</div> </div>
</div> </div>
<div id="gmv-chart" class="chart-container"></div> <div id="gmv-chart" class="chart-container"></div>
...@@ -422,12 +484,12 @@ ...@@ -422,12 +484,12 @@
<div class="chart-panel-header"> <div class="chart-panel-header">
<div> <div>
<div class="chart-panel-title">订单数趋势</div> <div class="chart-panel-title">订单数趋势</div>
<div class="chart-panel-subtitle">近30天订单量走势</div> <div class="chart-panel-subtitle" id="order-subtitle">近30天订单量走势</div>
</div> </div>
<div class="chart-tabs"> <div class="chart-tabs">
<span class="chart-tab active" onclick="switchChartTab('order', 'day')"></span> <button class="chart-tab active" id="order-day" onclick="switchPeriod('order', 'day')"></button>
<span class="chart-tab" onclick="switchChartTab('order', 'week')"></span> <button class="chart-tab" id="order-week" onclick="switchPeriod('order', 'week')"></button>
<span class="chart-tab" onclick="switchChartTab('order', 'month')"></span> <button class="chart-tab" id="order-month" onclick="switchPeriod('order', 'month')"></button>
</div> </div>
</div> </div>
<div id="order-chart" class="chart-container"></div> <div id="order-chart" class="chart-container"></div>
...@@ -484,7 +546,10 @@ ...@@ -484,7 +546,10 @@
<div class="chart-panel-title">注册用户增长</div> <div class="chart-panel-title">注册用户增长</div>
<div class="chart-panel-subtitle">近30天新增用户趋势</div> <div class="chart-panel-subtitle">近30天新增用户趋势</div>
</div> </div>
<div>
<span class="growth-badge" id="growth-badge">+0</span> <span class="growth-badge" id="growth-badge">+0</span>
<p class="growth-badge-text">累计注册用户</p>
</div>
</div> </div>
<div id="user-growth-chart" class="chart-container"></div> <div id="user-growth-chart" class="chart-container"></div>
</div> </div>
...@@ -559,8 +624,6 @@ ...@@ -559,8 +624,6 @@
</div> </div>
</div> </div>
</div> </div>
<!-- 图表初始化脚本 -->
<!--统计图 End-->
</el-scrollbar> </el-scrollbar>
</el-main> </el-main>
</el-container> </el-container>
...@@ -569,6 +632,9 @@ ...@@ -569,6 +632,9 @@
<script type="text/javascript"> <script type="text/javascript">
var gmvChart, orderChart, userGrowthChart; var gmvChart, orderChart, userGrowthChart;
var currentTimeRange = '30days'; var currentTimeRange = '30days';
var currentGmvPeriod = 'day';
var currentOrderPeriod = 'day';
function generateDateLabels(days) { function generateDateLabels(days) {
var labels = []; var labels = [];
for (var i = days - 1; i >= 0; i--) { for (var i = days - 1; i >= 0; i--) {
...@@ -578,6 +644,7 @@ ...@@ -578,6 +644,7 @@
} }
return labels; return labels;
} }
function generateRandomData(length, min, max) { function generateRandomData(length, min, max) {
var data = []; var data = [];
for (var i = 0; i < length; i++) { for (var i = 0; i < length; i++) {
...@@ -585,6 +652,7 @@ ...@@ -585,6 +652,7 @@
} }
return data; return data;
} }
function initCharts() { function initCharts() {
gmvChart = echarts.init(document.getElementById('gmv-chart')); gmvChart = echarts.init(document.getElementById('gmv-chart'));
var gmvOption = { var gmvOption = {
...@@ -621,6 +689,7 @@ ...@@ -621,6 +689,7 @@
}] }]
}; };
gmvChart.setOption(gmvOption); gmvChart.setOption(gmvOption);
orderChart = echarts.init(document.getElementById('order-chart')); orderChart = echarts.init(document.getElementById('order-chart'));
var orderOption = { var orderOption = {
grid: { top: 20, right: 20, bottom: 30, left: 50 }, grid: { top: 20, right: 20, bottom: 30, left: 50 },
...@@ -650,11 +719,12 @@ ...@@ -650,11 +719,12 @@
}] }]
}; };
orderChart.setOption(orderOption); orderChart.setOption(orderOption);
userGrowthChart = echarts.init(document.getElementById('user-growth-chart')); userGrowthChart = echarts.init(document.getElementById('user-growth-chart'));
var userGrowthOption = { var userGrowthOption = {
grid: { top: 20, right: 20, bottom: 30, left: 50 }, grid: { top: 20, right: 20, bottom: 30, left: 50 },
legend: { legend: {
data: ['新增用户', '活跃用户'], data: ['活跃用户', '新增用户'],
top: 0, top: 0,
textStyle: { color: '#6b7280', fontSize: 12 } textStyle: { color: '#6b7280', fontSize: 12 }
}, },
...@@ -671,16 +741,15 @@ ...@@ -671,16 +741,15 @@
splitLine: { lineStyle: { color: '#f3f4f6' } }, splitLine: { lineStyle: { color: '#f3f4f6' } },
axisLabel: { axisLabel: {
color: '#9ca3af', color: '#9ca3af',
fontSize: 10, fontSize: 10
formatter: function(value) { return (value / 10000).toFixed(0) + '万'; }
} }
}, },
series: [ series: [
{ {
name: '新增用户', name: '活跃用户',
type: 'line', type: 'line',
smooth: true, smooth: true,
data: generateRandomData(30, 2000, 8000), data: [120, 156, 132, 189, 210, 198, 234, 256, 212, 188, 245, 267, 234, 289, 312, 278, 245, 267, 298, 321, 287, 265, 290, 310, 334, 301, 276, 289, 312, 345],
lineStyle: { color: '#f59e0b', width: 2 }, lineStyle: { color: '#f59e0b', width: 2 },
areaStyle: { areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
...@@ -691,10 +760,10 @@ ...@@ -691,10 +760,10 @@
symbol: 'none' symbol: 'none'
}, },
{ {
name: '活跃用户', name: '新增用户',
type: 'line', type: 'line',
smooth: true, smooth: true,
data: generateRandomData(30, 8000, 15000), data: [45, 67, 52, 78, 89, 76, 98, 102, 87, 72, 95, 110, 98, 120, 135, 112, 95, 105, 125, 140, 128, 115, 130, 145, 156, 142, 128, 135, 152, 168],
lineStyle: { color: '#06b6d4', width: 2 }, lineStyle: { color: '#06b6d4', width: 2 },
areaStyle: { areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
...@@ -707,20 +776,59 @@ ...@@ -707,20 +776,59 @@
] ]
}; };
userGrowthChart.setOption(userGrowthOption); userGrowthChart.setOption(userGrowthOption);
window.addEventListener('resize', function() { window.addEventListener('resize', function() {
gmvChart.resize(); gmvChart.resize();
orderChart.resize(); orderChart.resize();
userGrowthChart.resize(); userGrowthChart.resize();
}); });
} }
function switchChartTab(type, period) {
console.log('Switching ' + type + ' chart to ' + period); function switchPeriod(chartType, period) {
if (chartType === 'gmv') {
currentGmvPeriod = period;
document.querySelectorAll('#gmv-day, #gmv-week, #gmv-month').forEach(el => el.classList.remove('active'));
document.getElementById('gmv-' + period).classList.add('active');
var subtitle = '';
if (period === 'day') {
subtitle = '近' + currentTimeRange.replace('days', '') + '天每日GMV走势';
} else if (period === 'week') {
subtitle = '近' + currentTimeRange.replace('days', '') + '天每周GMV走势';
} else {
subtitle = '近' + currentTimeRange.replace('days', '') + '天每月GMV走势';
}
document.getElementById('gmv-subtitle').textContent = subtitle;
} else {
currentOrderPeriod = period;
document.querySelectorAll('#order-day, #order-week, #order-month').forEach(el => el.classList.remove('active'));
document.getElementById('order-' + period).classList.add('active');
var subtitle = '';
if (period === 'day') {
subtitle = '近' + currentTimeRange.replace('days', '') + '天每日订单量走势';
} else if (period === 'week') {
subtitle = '近' + currentTimeRange.replace('days', '') + '天每周订单量走势';
} else {
subtitle = '近' + currentTimeRange.replace('days', '') + '天每月订单量走势';
}
document.getElementById('order-subtitle').textContent = subtitle;
} }
loadStatistics(currentTimeRange);
}
function loadStatistics(timeRange) { function loadStatistics(timeRange) {
currentTimeRange = timeRange; currentTimeRange = timeRange;
$.getJSON('{:url("shopro/dashboard/statistics")}', { time_range: timeRange }, function(res) { $.getJSON('{:url("shopro/dashboard/statistics")}', {
time_range: timeRange,
period: currentGmvPeriod
}, function(res) {
console.log('=== statistics 接口返回数据 ===', res);
if (res.code === 1) { if (res.code === 1) {
var data = res.data; var data = res.data;
console.log('data.trend_data:', data.trend_data);
console.log('from_cache:', data.from_cache);
document.getElementById('stat-total-amount').textContent = '¥' + formatNumber(data.total_amount, 2); document.getElementById('stat-total-amount').textContent = '¥' + formatNumber(data.total_amount, 2);
document.getElementById('stat-profit-rate').textContent = data.profit_rate; document.getElementById('stat-profit-rate').textContent = data.profit_rate;
document.getElementById('stat-avg-amount').textContent = '¥' + formatNumber(data.avg_amount, 2); document.getElementById('stat-avg-amount').textContent = '¥' + formatNumber(data.avg_amount, 2);
...@@ -728,33 +836,89 @@ ...@@ -728,33 +836,89 @@
document.getElementById('stat-visitor-count').textContent = formatNumber(data.visitor_count); document.getElementById('stat-visitor-count').textContent = formatNumber(data.visitor_count);
document.getElementById('stat-conversion-rate').textContent = data.conversion_rate; document.getElementById('stat-conversion-rate').textContent = data.conversion_rate;
document.getElementById('stat-register-users').textContent = formatNumber(data.register_users); document.getElementById('stat-register-users').textContent = formatNumber(data.register_users);
updateGmvChart(data.trend_data);
if (currentGmvPeriod === currentOrderPeriod) {
updateOrderChart(data.trend_data);
} else {
$.getJSON('{:url("shopro/dashboard/statistics")}', {
time_range: timeRange,
period: currentOrderPeriod
}, function(res) {
if (res.code === 1) {
updateOrderChart(res.data.trend_data);
} }
}); });
}
}
});
loadFunnel(timeRange); loadFunnel(timeRange);
loadPriceDistribution(timeRange); loadPriceDistribution(timeRange);
loadRecentOrders(); loadRecentOrders();
} }
function updateGmvChart(trendData) {
var labels = trendData.map(function(item) { return item.date; });
var amounts = trendData.map(function(item) { return item.amount; });
var orders = trendData.map(function(item) { return item.order_num; });
var registers = trendData.map(function(item) { return item.register_num; });
gmvChart.setOption({
xAxis: { data: labels },
series: [{ data: amounts }]
});
userGrowthChart.setOption({
xAxis: { data: labels },
series: [
{ data: orders },
{ data: registers }
]
});
var totalRegister = registers.reduce(function(a, b) { return a + b; }, 0);
document.getElementById('growth-badge').textContent = '+' + formatNumber(totalRegister);
}
function updateOrderChart(trendData) {
var labels = trendData.map(function(item) { return item.date; });
var orders = trendData.map(function(item) { return item.order_num; });
orderChart.setOption({
xAxis: { data: labels },
series: [{ data: orders }]
});
}
function loadFunnel(timeRange) { function loadFunnel(timeRange) {
$.getJSON('{:url("shopro/dashboard/funnel")}', { time_range: timeRange }, function(res) { $.getJSON('{:url("shopro/dashboard/funnel")}', { time_range: timeRange }, function(res) {
if (res.code === 1) { if (res.code === 1) {
var data = res.data; var data = res.data;
var max = data.visit || 1; var max = data.visit || 1;
document.getElementById('funnel-visit').textContent = formatNumber(data.visit); document.getElementById('funnel-visit').textContent = formatNumber(data.visit);
var browsePercent = max > 0 ? Math.round(data.browse / max * 100) : 0; var browsePercent = max > 0 ? Math.round(data.browse / max * 100) : 0;
document.getElementById('funnel-browse').textContent = formatNumber(data.browse); document.getElementById('funnel-browse').textContent = formatNumber(data.browse);
document.getElementById('funnel-browse').style.width = browsePercent + '%'; document.getElementById('funnel-browse').style.width = browsePercent + '%';
var cartPercent = max > 0 ? Math.round(data.cart / max * 100) : 0; var cartPercent = max > 0 ? Math.round(data.cart / max * 100) : 0;
document.getElementById('funnel-cart').textContent = formatNumber(data.cart); document.getElementById('funnel-cart').textContent = formatNumber(data.cart);
document.getElementById('funnel-cart').style.width = cartPercent + '%'; document.getElementById('funnel-cart').style.width = cartPercent + '%';
var submitPercent = max > 0 ? Math.round(data.submit_order / max * 100) : 0; var submitPercent = max > 0 ? Math.round(data.submit_order / max * 100) : 0;
document.getElementById('funnel-submit').textContent = formatNumber(data.submit_order); document.getElementById('funnel-submit').textContent = formatNumber(data.submit_order);
document.getElementById('funnel-submit').style.width = submitPercent + '%'; document.getElementById('funnel-submit').style.width = submitPercent + '%';
var payPercent = max > 0 ? Math.round(data.pay / max * 100) : 0; var payPercent = max > 0 ? Math.round(data.pay / max * 100) : 0;
document.getElementById('funnel-pay').textContent = formatNumber(data.pay); document.getElementById('funnel-pay').textContent = formatNumber(data.pay);
document.getElementById('funnel-pay').style.width = payPercent + '%'; document.getElementById('funnel-pay').style.width = payPercent + '%';
} }
}); });
} }
function loadPriceDistribution(timeRange) { function loadPriceDistribution(timeRange) {
$.getJSON('{:url("shopro/dashboard/priceDistribution")}', { time_range: timeRange }, function(res) { $.getJSON('{:url("shopro/dashboard/priceDistribution")}', { time_range: timeRange }, function(res) {
if (res.code === 1) { if (res.code === 1) {
...@@ -772,6 +936,7 @@ ...@@ -772,6 +936,7 @@
} }
}); });
} }
function loadRecentOrders() { function loadRecentOrders() {
$.getJSON('{:url("shopro/dashboard/recentOrders")}', function(res) { $.getJSON('{:url("shopro/dashboard/recentOrders")}', function(res) {
if (res.code === 1) { if (res.code === 1) {
...@@ -791,12 +956,27 @@ ...@@ -791,12 +956,27 @@
} }
}); });
} }
function formatNumber(num, decimals) { function formatNumber(num, decimals) {
if (num === null || num === undefined) num = 0;
if (decimals !== undefined) { if (decimals !== undefined) {
return parseFloat(num || 0).toFixed(decimals); return parseFloat(num).toFixed(decimals);
} }
return parseInt(num || 0).toLocaleString(); return parseInt(num).toLocaleString();
} }
function refreshAllData() {
document.getElementById('refreshBtn').innerHTML = '<i class="fa fa-spinner fa-spin"></i><span>刷新中...</span>';
document.getElementById('refreshBtn').disabled = true;
setTimeout(function() {
loadStatistics(currentTimeRange);
document.getElementById('update-time').textContent = new Date().toLocaleString('zh-CN');
document.getElementById('refreshBtn').innerHTML = '<i class="fa fa-refresh"></i><span>刷新数据</span>';
document.getElementById('refreshBtn').disabled = false;
}, 500);
}
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
initCharts(); initCharts();
loadStatistics('30days'); loadStatistics('30days');
......
...@@ -10,6 +10,8 @@ ...@@ -10,6 +10,8 @@
// | Author: yunwuxin <448901948@qq.com> // | Author: yunwuxin <448901948@qq.com>
// +---------------------------------------------------------------------- // +----------------------------------------------------------------------
use Dom\DtdNamedNodeMap;
return [ return [
'app\admin\command\Crud', 'app\admin\command\Crud',
'app\admin\command\Menu', 'app\admin\command\Menu',
...@@ -21,4 +23,6 @@ return [ ...@@ -21,4 +23,6 @@ return [
'app\command\StatMemberTag', 'app\command\StatMemberTag',
'app\command\StatsDaily', 'app\command\StatsDaily',
'app\command\TrackFailedRetry', 'app\command\TrackFailedRetry',
'app\command\DataStatsCenter',
]; ];
<?php
namespace app\command;
use think\console\Command;
use think\console\Input;
use think\console\Output;
use think\Db;
/**
* 数据统计中心定时任务
*
* 该类负责定时统计商城核心业务数据,包括:
* 1. 核心交易指标(成交额、订单数、客单价、利润率等)
* 2. 访客统计(基于小程序行为埋点表)
* 3. 注册用户统计
* 4. 转化漏斗分析(访问→浏览→加购→下单→支付)
* 5. 趋势数据(按天统计的成交额、订单数、注册增长趋势)
* 6. 客单价分布
* 7. 实时订单动态
*
* 定时执行时间:每天凌晨1点40分
* 统计范围:近7天、近30天、近90天
*/
class DataStatsCenter extends Command
{
/**
* 缓存过期时间(25小时)
* 设置为25小时是为了确保当天的数据缓存能覆盖到第二天任务执行前
*/
const CACHE_TTL = 90000;
/**
* 统计快照表名称
*/
const STATS_TABLE = 'shopro_data_stats_center';
/**
* 默认统计时间范围(单位:天)
*/
const TIME_RANGES = [7, 30, 90];
/**
* 控制台输出对象
* @var Output
*/
protected $output = null;
/**
* 配置命令信息
*/
protected function configure()
{
$this->setName('shopro:data-stats-center')
->setDescription('每天凌晨1点40执行的数据统计中心任务');
}
/**
* 自定义日志记录方法
*
* @param string $level 日志级别: info/warning/error
* @param string $message 日志内容
*/
protected function customLog($level, $message)
{
// 格式化日志消息,添加时间戳和级别标识
$timestamp = date('Y-m-d H:i:s');
$formattedMessage = "[{$timestamp}] [{$level}] {$message}";
// 如果有控制台输出对象,同时输出到控制台
if ($this->output) {
// 根据级别使用不同颜色输出
switch ($level) {
case 'error':
$this->output->writeln("<error>{$formattedMessage}</error>");
break;
case 'warning':
$this->output->writeln("<comment>{$formattedMessage}</comment>");
break;
default:
$this->output->writeln("<info>{$formattedMessage}</info>");
break;
}
}
// 记录到自定义日志文件
custom_log($message, 'stat', $level);
}
/**
* 命令执行入口方法
*
* @param Input $input 输入参数
* @param Output $output 输出对象
*/
protected function execute(Input $input, Output $output)
{
$this->output = $output;
$this->customLog('info', '========== 开始执行数据统计中心定时任务 ==========');
try {
// 确保统计快照表存在
// $this->ensureStatsTable();
// 获取当前时间作为快照时间
$snapshotTime = time();
$snapshotDate = date('Y-m-d', $snapshotTime);
$allStats = [];
// 遍历所有统计时间范围(7天、30天、90天)
foreach (self::TIME_RANGES as $days) {
$rangeKey = $days . 'days';
$this->customLog('info', '---------- 开始统计近' . $days . '天数据 ----------');
// 构建统计数据
$stats = $this->buildStatsPayload($days, $snapshotTime);
// 持久化到数据库
$this->persistStats($snapshotDate, $rangeKey, $stats);
// 缓存统计结果
cache($this->buildCacheKey($rangeKey), $stats, self::CACHE_TTL);
$allStats[$rangeKey] = $stats;
$this->customLog('info', '近' . $days . '天统计完成,缓存已更新');
}
// 缓存所有时间范围的统计数据
cache($this->buildCacheKey('all'), $allStats, self::CACHE_TTL);
$this->customLog('info', '[' . date('Y-m-d H:i:s') . ']数据统计中心定时任务执行完成');
} catch (\Exception $e) {
// 记录异常日志并输出到控制台
format_log_error_custom_name($e, '[数据统计中心定时任务执行失败]', '', 'stat');
}
}
/**
* 自动创建统计快照表(如果不存在)
*
* 表结构包含:
* - 快照日期、时间范围
* - 统计开始/结束时间戳
* - 各统计模块的JSON数据字段
*/
protected function ensureStatsTable()
{
$table = $this->getFullTableName(self::STATS_TABLE);
$sql = "CREATE TABLE IF NOT EXISTS `{$table}` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`snapshot_date` date NOT NULL COMMENT '快照日期',
`time_range` varchar(20) NOT NULL DEFAULT '' COMMENT '时间范围',
`range_start` int unsigned NOT NULL DEFAULT 0 COMMENT '统计开始时间',
`range_end` int unsigned NOT NULL DEFAULT 0 COMMENT '统计结束时间',
`summary_json` mediumtext COMMENT '顶部卡片统计',
`trend_json` mediumtext COMMENT '趋势数据',
`funnel_json` text COMMENT '转化漏斗',
`distribution_json` text COMMENT '客单价分布',
`recent_orders_json` mediumtext COMMENT '实时订单动态',
`extra_json` mediumtext COMMENT '扩展信息',
`created_at` int unsigned NOT NULL DEFAULT 0 COMMENT '创建时间',
`updated_at` int unsigned NOT NULL DEFAULT 0 COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_snapshot_range` (`snapshot_date`, `time_range`),
KEY `idx_time_range` (`time_range`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数据统计中心快照表'";
Db::execute($sql);
}
/**
* 构建整包统计数据
*
* @param int $days 统计天数
* @param int $snapshotTime 快照时间戳
* @return array 包含所有统计模块的完整数据
*/
protected function buildStatsPayload($days, $snapshotTime)
{
// 构建当前统计周期和上一统计周期的时间范围
$currentRange = $this->buildRange($days, $snapshotTime);
$previousRange = $this->buildRange($days, $currentRange['start'] - 1);
// 获取当前周期和上一周期的核心交易指标
$this->customLog('info', '开始获取当前&上一期统计周期核心交易指标');
$currentRevenue = $this->getRevenueMetrics($currentRange['start'], $currentRange['end']);
$previousRevenue = $this->getRevenueMetrics($previousRange['start'], $previousRange['end']);
// 获取当前周期和上一周期的访客数
$this->customLog('info', '开始获取当前&上一期统计周期访客数');
$currentVisitors = $this->getVisitorCount($currentRange['start'], $currentRange['end']);
$previousVisitors = $this->getVisitorCount($previousRange['start'], $previousRange['end']);
// 获取累计注册用户数(截至当前周期结束和上一周期结束)
$this->customLog('info', '开始获取当前&上一期统计周期累计注册用户数');
$currentRegisterTotal = $this->getRegisterCount(0, $currentRange['end']);
$previousRegisterTotal = $this->getRegisterCount(0, $previousRange['end']);
// 计算转化率(订单数/访客数)
$this->customLog('info', '开始计算当前&上一期统计周期转化率');
$currentConversion = $currentVisitors > 0 ? round($currentRevenue['order_count'] / $currentVisitors * 100, 2) : 0;
$previousConversion = $previousVisitors > 0 ? round($previousRevenue['order_count'] / $previousVisitors * 100, 2) : 0;
// 构建趋势数据
$this->customLog('info', '开始构建当前统计周期趋势数据');
$trendData = [
'amount' => $this->buildAmountTrend($days, $snapshotTime),
'orders' => $this->buildOrderTrend($days, $snapshotTime),
'register' => $this->buildRegisterTrend($days, $snapshotTime),
];
// 构建漏斗数据
$this->customLog('info', '开始构建当前统计周期转化漏斗数据');
$funnelData = $this->buildFunnelData($currentRange['start'], $currentRange['end']);
// 构建价格分布
$this->customLog('info', '开始构建当前统计周期客单价分布');
$priceDistribution = $this->buildPriceDistribution($currentRange['start'], $currentRange['end']);
// 获取最近订单
$this->customLog('info', '开始获取当前统计周期最近订单');
$recentOrders = $this->getRecentOrders(10);
// 组装完整的统计数据结构
return [
'snapshot_date' => date('Y-m-d', $snapshotTime),
'time_range' => $days . 'days',
'days' => $days,
'generated_at' => $snapshotTime,
'range' => [
'start' => $currentRange['start'],
'end' => $currentRange['end'],
'start_text' => date('Y-m-d H:i:s', $currentRange['start']),
'end_text' => date('Y-m-d H:i:s', $currentRange['end']),
],
'summary' => [
'total_amount' => $this->buildMetricCard('成交总额', $currentRevenue['total_amount'], $previousRevenue['total_amount'], 'money'),
'profit_rate' => $this->buildMetricCard('净利率', $currentRevenue['profit_rate'], $previousRevenue['profit_rate'], 'percent'),
'avg_amount' => $this->buildMetricCard('客单价', $currentRevenue['avg_amount'], $previousRevenue['avg_amount'], 'money'),
'order_count' => $this->buildMetricCard('订单数', $currentRevenue['order_count'], $previousRevenue['order_count'], 'count'),
'visitor_count' => $this->buildMetricCard('访客数', $currentVisitors, $previousVisitors, 'count'),
'conversion_rate' => $this->buildMetricCard('转化率', $currentConversion, $previousConversion, 'percent'),
'register_users' => $this->buildMetricCard('注册用户', $currentRegisterTotal, $previousRegisterTotal, 'count'),
],
'trend' => $trendData,
'funnel' => $funnelData,
'price_distribution' => $priceDistribution,
'recent_orders' => $recentOrders,
'extra' => [
'current_revenue' => $currentRevenue,
'previous_revenue' => $previousRevenue,
],
];
}
/**
* 保存统计快照到数据库
*
* @param string $snapshotDate 快照日期(Y-m-d格式)
* @param string $timeRange 时间范围标识(如 '7days')
* @param array $stats 统计数据数组
*/
protected function persistStats($snapshotDate, $timeRange, array $stats)
{
$now = time();
$data = [
'snapshot_date' => $snapshotDate,
'time_range' => $timeRange,
'range_start' => $stats['range']['start'],
'range_end' => $stats['range']['end'],
'summary_json' => json_encode($stats['summary'], JSON_UNESCAPED_UNICODE),
'trend_json' => json_encode($stats['trend'], JSON_UNESCAPED_UNICODE),
'funnel_json' => json_encode($stats['funnel'], JSON_UNESCAPED_UNICODE),
'distribution_json' => json_encode($stats['price_distribution'], JSON_UNESCAPED_UNICODE),
'recent_orders_json' => json_encode($stats['recent_orders'], JSON_UNESCAPED_UNICODE),
'extra_json' => json_encode($stats['extra'], JSON_UNESCAPED_UNICODE),
'updated_at' => $now,
];
// 检查是否已存在同日期同时间范围的记录
$row = Db::name(self::STATS_TABLE)
->where('snapshot_date', $snapshotDate)
->where('time_range', $timeRange)
->find();
if ($row) {
// 更新已存在的记录
Db::name(self::STATS_TABLE)->where('id', $row['id'])->update($data);
} else {
// 插入新记录
$data['created_at'] = $now;
Db::name(self::STATS_TABLE)->insert($data);
}
}
/**
* 构建统计时间区间
*
* @param int $days 统计天数
* @param int $endTime 结束时间戳
* @return array 包含 start 和 end 的时间范围数组
*/
protected function buildRange($days, $endTime)
{
$endTime = (int)$endTime;
// 计算开始时间:从结束时间往前推 days-1 天的 00:00:00
$startTime = strtotime(date('Y-m-d 00:00:00', strtotime('-' . ($days - 1) . ' days', $endTime)));
return [
'start' => $startTime,
'end' => $endTime,
];
}
/**
* 汇总核心交易指标
*
* @param int $startTime 开始时间戳
* @param int $endTime 结束时间戳
* @return array 包含成交额、订单数、客单价、成本、退款、利润、利润率
*/
protected function getRevenueMetrics($startTime, $endTime)
{
// 统计成交额(已支付和已完成订单的支付金额总和)
$totalAmount = round((float)Db::name('shopro_order')
->whereIn('status', ['paid', 'completed'])
->where('paid_time', 'between', [$startTime, $endTime])
->sum('pay_fee'), 2);
// 统计订单数
$orderCount = (int)Db::name('shopro_order')
->whereIn('status', ['paid', 'completed'])
->where('paid_time', 'between', [$startTime, $endTime])
->count();
// 计算客单价(成交额/订单数)
$avgAmount = $orderCount > 0 ? round($totalAmount / $orderCount, 2) : 0;
// 统计商品成本(成本价 × 数量)
$table = $this->getFullTableName('shopro_order_item');
$orderTable = $this->getFullTableName('shopro_order');
$sql = "SELECT SUM(item.cost_price * item.goods_num) AS total FROM `{$table}` item JOIN `{$orderTable}` o ON o.id = item.order_id WHERE o.status IN ('paid', 'completed') AND o.paid_time BETWEEN ? AND ?";
$result = Db::query($sql, [$startTime, $endTime]);
$costAmount = round((float)(isset($result[0]['total']) ? $result[0]['total'] : 0), 2);
// 统计退款金额
$refundAmount = round((float)Db::name('shopro_refund')
->where('status', 'completed')
->where('createtime', 'between', [$startTime, $endTime])
->sum('refund_fee'), 2);
// 计算利润和利润率
$profitAmount = round($totalAmount - $refundAmount - $costAmount, 2);
$profitRate = $totalAmount > 0 ? round($profitAmount / $totalAmount * 100, 2) : 0;
return [
'total_amount' => $totalAmount,
'order_count' => $orderCount,
'avg_amount' => $avgAmount,
'cost_amount' => $costAmount,
'refund_amount' => $refundAmount,
'profit_amount' => $profitAmount,
'profit_rate' => $profitRate,
];
}
/**
* 获取访客数
*
* 用户识别策略:
* 1. 优先使用 user_id(登录用户)
* 2. 其次使用 session_id(未登录但有会话的用户)
* 3. 最后使用 ip(完全匿名访客)
*
* @param int $startTime 开始时间戳
* @param int $endTime 结束时间戳
* @return int 去重后的访客数量
*/
protected function getVisitorCount($startTime, $endTime)
{
try {
$table = $this->getFullTableName('shopro_miniprogram_track');
$sql = "SELECT COUNT(DISTINCT CASE
WHEN user_id > 0 THEN CONCAT('u_', user_id)
WHEN session_id <> '' THEN CONCAT('s_', session_id)
ELSE CONCAT('i_', ip)
END) AS total
FROM `{$table}`
WHERE create_time BETWEEN ? AND ?";
$result = Db::query($sql, [$startTime, $endTime]);
$count = isset($result[0]['total']) ? (int)$result[0]['total'] : 0;
return $count;
} catch (\Exception $e) {
$this->customLog('warning', '埋点访客统计失败:' . $e->getMessage());
return 0;
}
}
/**
* 获取注册用户数
*
* @param int $startTime 开始时间戳(0表示从最早开始)
* @param int $endTime 结束时间戳
* @return int 注册用户数量
*/
protected function getRegisterCount($startTime, $endTime)
{
$query = Db::name('user');
if ($startTime > 0) {
$query->where('createtime', 'between', [$startTime, $endTime]);
} else {
// startTime=0 表示统计截至 endTime 的累计注册用户数
$query->where('createtime', '<=', $endTime);
}
return (int)$query->count();
}
/**
* 构建转化漏斗数据
*
* 使用小程序行为埋点表获取各阶段数据,确保数据源一致性
* 漏斗阶段:访问 → 浏览商品 → 加入购物车 → 提交订单 → 完成支付
*
* @param int $startTime 开始时间戳
* @param int $endTime 结束时间戳
* @return array 漏斗数据数组
*/
protected function buildFunnelData($startTime, $endTime)
{
try {
// 获取各阶段的用户数
$this->customLog('info', '开始获取当前统计周期访问用户数');
$visit = $this->getTrackVisitorCount($startTime, $endTime);
$browse = $this->getTrackGoodViewUserCount($startTime, $endTime);
$cart = $this->getTrackCartAddUserCount($startTime, $endTime);
$submitOrder = $this->getTrackOrderConfirmUserCount($startTime, $endTime);
$pay = $this->getTrackPayConfirmUserCount($startTime, $endTime);
// 获取各阶段的事件数
$this->customLog('info', '开始获取当前统计周期访问事件数');
$visitCount = $this->getTrackVisitCount($startTime, $endTime);
$browseCount = $this->getTrackGoodViewCount($startTime, $endTime);
$cartCount = $this->getTrackCartAddCount($startTime, $endTime);
$submitCount = $this->getTrackOrderConfirmCount($startTime, $endTime);
$payCount = $this->getTrackPayConfirmCount($startTime, $endTime);
// 计算基准值(避免除以0)
$base = max($visit, 1);
// 组装漏斗数据
return [
['key' => 'visit', 'label' => '访问', 'value' => $visit, 'rate' => 100, 'event_count' => $visitCount],
['key' => 'browse', 'label' => '浏览商品', 'value' => $browse, 'rate' => round($browse / $base * 100, 2), 'event_count' => $browseCount],
['key' => 'cart', 'label' => '加入购物车', 'value' => $cart, 'rate' => round($cart / $base * 100, 2), 'event_count' => $cartCount],
['key' => 'submit_order', 'label' => '提交订单', 'value' => $submitOrder, 'rate' => round($submitOrder / $base * 100, 2), 'event_count' => $submitCount],
['key' => 'pay', 'label' => '完成支付', 'value' => $pay, 'rate' => round($pay / $base * 100, 2), 'event_count' => $payCount],
];
} catch (\Exception $e) {
$this->customLog('warning', '使用埋点表构建转化漏斗失败' . $e->getMessage());
}
}
/**
* 获取埋点表中访问用户数
*
* @param int $startTime 开始时间戳
* @param int $endTime 结束时间戳
* @return int 去重后的访问用户数
*/
protected function getTrackVisitorCount($startTime, $endTime)
{
$table = $this->getFullTableName('shopro_miniprogram_track');
$sql = "SELECT COUNT(DISTINCT CASE
WHEN user_id > 0 THEN CONCAT('u_', user_id)
WHEN session_id <> '' THEN CONCAT('s_', session_id)
ELSE CONCAT('i_', ip)
END) as total
FROM `{$table}`
WHERE create_time BETWEEN ? AND ?
AND (event_code = 'index_view' OR page_path LIKE '/pages/index/%')";
$result = Db::query($sql, [$startTime, $endTime]);
$this->customLog('info', '当前统计周期访问用户数: '.json_encode($result[0]['total'] ?? 0));
return (int)($result[0]['total'] ?? 0);
}
/**
* 获取埋点表中访问事件数
*
* @param int $startTime 开始时间戳
* @param int $endTime 结束时间戳
* @return int 访问事件总数
*/
protected function getTrackVisitCount($startTime, $endTime)
{
$table = $this->getFullTableName('shopro_miniprogram_track');
$sql = "SELECT COUNT(*) as total
FROM `{$table}`
WHERE create_time BETWEEN ? AND ?
AND (event_code = 'index_view' OR page_path LIKE '/pages/index/%')";
$result = Db::query($sql, [$startTime, $endTime]);
return (int)($result[0]['total'] ?? 0);
}
/**
* 获取埋点表中浏览商品用户数
*
* @param int $startTime 开始时间戳
* @param int $endTime 结束时间戳
* @return int 去重后的浏览商品用户数
*/
protected function getTrackGoodViewUserCount($startTime, $endTime)
{
$table = $this->getFullTableName('shopro_miniprogram_track');
$sql = "SELECT COUNT(DISTINCT CASE
WHEN user_id > 0 THEN CONCAT('u_', user_id)
WHEN session_id <> '' THEN CONCAT('s_', session_id)
ELSE CONCAT('i_', ip)
END) as total
FROM `{$table}`
WHERE create_time BETWEEN ? AND ?
AND (event_code = 'good_view' OR page_path LIKE '/pages/goods/%')";
$result = Db::query($sql, [$startTime, $endTime]);
return (int)($result[0]['total'] ?? 0);
}
/**
* 获取埋点表中浏览商品事件数
*
* @param int $startTime 开始时间戳
* @param int $endTime 结束时间戳
* @return int 浏览商品事件总数
*/
protected function getTrackGoodViewCount($startTime, $endTime)
{
$table = $this->getFullTableName('shopro_miniprogram_track');
$sql = "SELECT COUNT(*) as total
FROM `{$table}`
WHERE create_time BETWEEN ? AND ?
AND (event_code = 'good_view' OR page_path LIKE '/pages/goods/%')";
$result = Db::query($sql, [$startTime, $endTime]);
return (int)($result[0]['total'] ?? 0);
}
/**
* 获取埋点表中加入购物车用户数
*
* @param int $startTime 开始时间戳
* @param int $endTime 结束时间戳
* @return int 去重后的加入购物车用户数
*/
protected function getTrackCartAddUserCount($startTime, $endTime)
{
$table = $this->getFullTableName('shopro_miniprogram_track');
$sql = "SELECT COUNT(DISTINCT CASE
WHEN user_id > 0 THEN CONCAT('u_', user_id)
WHEN session_id <> '' THEN CONCAT('s_', session_id)
ELSE CONCAT('i_', ip)
END) as total
FROM `{$table}`
WHERE create_time BETWEEN ? AND ?
AND event_code = 'cart_add'";
$result = Db::query($sql, [$startTime, $endTime]);
return (int)($result[0]['total'] ?? 0);
}
/**
* 获取埋点表中加入购物车事件数
*
* @param int $startTime 开始时间戳
* @param int $endTime 结束时间戳
* @return int 加入购物车事件总数
*/
protected function getTrackCartAddCount($startTime, $endTime)
{
$table = $this->getFullTableName('shopro_miniprogram_track');
$sql = "SELECT COUNT(*) as total
FROM `{$table}`
WHERE create_time BETWEEN ? AND ?
AND event_code = 'cart_add'";
$result = Db::query($sql, [$startTime, $endTime]);
return (int)($result[0]['total'] ?? 0);
}
/**
* 获取埋点表中提交订单用户数
*
* @param int $startTime 开始时间戳
* @param int $endTime 结束时间戳
* @return int 去重后的提交订单用户数
*/
protected function getTrackOrderConfirmUserCount($startTime, $endTime)
{
$table = $this->getFullTableName('shopro_miniprogram_track');
$sql = "SELECT COUNT(DISTINCT CASE
WHEN user_id > 0 THEN CONCAT('u_', user_id)
WHEN session_id <> '' THEN CONCAT('s_', session_id)
ELSE CONCAT('i_', ip)
END) as total
FROM `{$table}`
WHERE create_time BETWEEN ? AND ?
AND event_code = 'order_confirm'";
$result = Db::query($sql, [$startTime, $endTime]);
return (int)($result[0]['total'] ?? 0);
}
/**
* 获取埋点表中提交订单事件数
*
* @param int $startTime 开始时间戳
* @param int $endTime 结束时间戳
* @return int 提交订单事件总数
*/
protected function getTrackOrderConfirmCount($startTime, $endTime)
{
$table = $this->getFullTableName('shopro_miniprogram_track');
$sql = "SELECT COUNT(*) as total
FROM `{$table}`
WHERE create_time BETWEEN ? AND ?
AND event_code = 'order_confirm'";
$result = Db::query($sql, [$startTime, $endTime]);
return (int)($result[0]['total'] ?? 0);
}
/**
* 获取埋点表中完成支付用户数
*
* @param int $startTime 开始时间戳
* @param int $endTime 结束时间戳
* @return int 去重后的完成支付用户数
*/
protected function getTrackPayConfirmUserCount($startTime, $endTime)
{
$table = $this->getFullTableName('shopro_miniprogram_track');
$sql = "SELECT COUNT(DISTINCT CASE
WHEN user_id > 0 THEN CONCAT('u_', user_id)
WHEN session_id <> '' THEN CONCAT('s_', session_id)
ELSE CONCAT('i_', ip)
END) as total
FROM `{$table}`
WHERE create_time BETWEEN ? AND ?
AND event_code = 'pay_confirm'";
$result = Db::query($sql, [$startTime, $endTime]);
return (int)($result[0]['total'] ?? 0);
}
/**
* 获取埋点表中完成支付事件数
*
* @param int $startTime 开始时间戳
* @param int $endTime 结束时间戳
* @return int 完成支付事件总数
*/
protected function getTrackPayConfirmCount($startTime, $endTime)
{
$table = $this->getFullTableName('shopro_miniprogram_track');
$sql = "SELECT COUNT(*) as total
FROM `{$table}`
WHERE create_time BETWEEN ? AND ?
AND event_code = 'pay_confirm'";
$result = Db::query($sql, [$startTime, $endTime]);
return (int)($result[0]['total'] ?? 0);
}
/**
* 构建成交额趋势数据
*
* @param int $days 统计天数
* @param int $snapshotTime 快照时间戳
* @return array 包含日期标签和成交额数值的趋势数据
*/
protected function buildAmountTrend($days, $snapshotTime)
{
return $this->buildDailySeries($days, $snapshotTime, function ($startTime, $endTime) {
return round((float)Db::name('shopro_order')
->whereIn('status', ['paid', 'completed'])
->where('paid_time', 'between', [$startTime, $endTime])
->sum('pay_fee'), 2);
}, '成交额');
}
/**
* 构建订单数趋势数据
*
* @param int $days 统计天数
* @param int $snapshotTime 快照时间戳
* @return array 包含日期标签和订单数数值的趋势数据
*/
protected function buildOrderTrend($days, $snapshotTime)
{
return $this->buildDailySeries($days, $snapshotTime, function ($startTime, $endTime) {
return (int)Db::name('shopro_order')
->whereIn('status', ['paid', 'completed'])
->where('paid_time', 'between', [$startTime, $endTime])
->count();
}, '订单数');
}
/**
* 构建注册用户增长趋势数据
*
* @param int $days 统计天数
* @param int $snapshotTime 快照时间戳
* @return array 包含日期标签、每日新增用户数和累计用户数的趋势数据
*/
protected function buildRegisterTrend($days, $snapshotTime)
{
$range = $this->buildRange($days, $snapshotTime);
// 获取统计周期开始前的累计注册用户数
$baseTotal = (int)Db::name('user')->where('createtime', '<', $range['start'])->count();
$labels = [];
$newUsers = [];
$totalUsers = [];
$runningTotal = $baseTotal;
// 按天遍历统计周期
for ($i = $days - 1; $i >= 0; $i--) {
$dayStart = strtotime(date('Y-m-d 00:00:00', strtotime('-' . $i . ' days', $snapshotTime)));
$dayEnd = min(strtotime(date('Y-m-d 23:59:59', $dayStart)), $snapshotTime);
// 统计当天新增用户数
$newCount = (int)Db::name('user')
->where('createtime', 'between', [$dayStart, $dayEnd])
->count();
// 累计用户数
$runningTotal += $newCount;
$labels[] = date('m-d', $dayStart);
$newUsers[] = $newCount;
$totalUsers[] = $runningTotal;
}
return [
'title' => '注册用户增长',
'labels' => $labels,
'new_users' => $newUsers,
'total_users' => $totalUsers,
'current_total' => end($totalUsers) ?: 0,
];
}
/**
* 通用日趋势构建方法
*
* @param int $days 统计天数
* @param int $snapshotTime 快照时间戳
* @param callable $callback 数据查询回调函数
* @param string $title 趋势标题
* @return array 趋势数据数组
*/
protected function buildDailySeries($days, $snapshotTime, callable $callback, $title)
{
$labels = [];
$values = [];
// 按天遍历统计周期
for ($i = $days - 1; $i >= 0; $i--) {
$dayStart = strtotime(date('Y-m-d 00:00:00', strtotime('-' . $i . ' days', $snapshotTime)));
$dayEnd = min(strtotime(date('Y-m-d 23:59:59', $dayStart)), $snapshotTime);
$labels[] = date('m-d', $dayStart);
$values[] = $callback($dayStart, $dayEnd);
}
return [
'title' => $title,
'labels' => $labels,
'values' => $values,
];
}
/**
* 构建客单价分布数据
*
* 客单价区间:0-100、100-300、300-500、500-1000、1000+
*
* @param int $startTime 开始时间戳
* @param int $endTime 结束时间戳
* @return array 客单价分布数据数组
*/
protected function buildPriceDistribution($startTime, $endTime)
{
// 查询已支付和已完成订单的支付金额
$orders = Db::name('shopro_order')
->whereIn('status', ['paid', 'completed'])
->where('paid_time', 'between', [$startTime, $endTime])
->field('pay_fee')
->select();
// 初始化分布区间
$distribution = [
'0-100' => 0,
'100-300' => 0,
'300-500' => 0,
'500-1000' => 0,
'1000+' => 0,
];
$total = count($orders);
// 统计每个区间的订单数
foreach ($orders as $order) {
$amount = (float)$order['pay_fee'];
if ($amount < 100) {
$distribution['0-100']++;
} elseif ($amount < 300) {
$distribution['100-300']++;
} elseif ($amount < 500) {
$distribution['300-500']++;
} elseif ($amount < 1000) {
$distribution['500-1000']++;
} else {
$distribution['1000+']++;
}
}
// 组装结果(包含区间标签、数量、占比)
$result = [];
foreach ($distribution as $label => $count) {
$result[] = [
'label' => $label,
'count' => $count,
'rate' => $total > 0 ? round($count / $total * 100, 2) : 0,
];
}
return $result;
}
/**
* 获取实时订单动态
*
* @param int $limit 返回订单数量限制
* @return array 订单列表(包含订单号、用户信息、商品信息、金额、时间)
*/
protected function getRecentOrders($limit = 10)
{
// 查询最近支付的订单
$orders = Db::name('shopro_order')
->whereIn('status', ['paid', 'completed'])
->where('paid_time', '>', 0)
->order('paid_time desc')
->limit($limit)
->field('id,order_sn,user_id,pay_fee,paid_time')
->select();
$result = [];
foreach ($orders as $order) {
// 获取用户信息
$user = Db::name('user')->where('id', $order['user_id'])->field('nickname,mobile')->find();
// 获取订单商品信息
$item = Db::name('shopro_order_item')->where('order_id', $order['id'])->field('goods_title')->find();
$result[] = [
'order_id' => (int)$order['id'],
'order_sn' => $order['order_sn'],
'user' => $user && $user['nickname'] ? $user['nickname'] : ('用户#' . $order['user_id']),
'mobile' => $user && $user['mobile'] ? $user['mobile'] : '',
'goods_title' => $item ? $item['goods_title'] : '',
'amount' => round((float)$order['pay_fee'], 2),
'time' => date('Y-m-d H:i:s', (int)$order['paid_time']),
];
}
return $result;
}
/**
* 生成卡片指标数据结构
*
* @param string $label 指标标签
* @param mixed $currentValue 当前周期值
* @param mixed $previousValue 上一周期值
* @param string $type 类型:count(计数)、money(金额)、percent(百分比)
* @return array 卡片指标数据
*/
protected function buildMetricCard($label, $currentValue, $previousValue, $type = 'count')
{
// 按类型规格化数值
$currentValue = $this->normalizeByType($currentValue, $type);
$previousValue = $this->normalizeByType($previousValue, $type);
$compareValue = $this->normalizeByType($currentValue - $previousValue, $type);
// 计算环比增长率(避免除以0)
if ((float)$previousValue == 0.0) {
$compareRate = (float)$currentValue > 0 ? 100.00 : 0.00;
} else {
$compareRate = round((($currentValue - $previousValue) / $previousValue) * 100, 2);
}
return [
'label' => $label,
'value' => $currentValue,
'formatted' => $this->formatMetric($currentValue, $type),
'previous_value' => $previousValue,
'compare_value' => $compareValue,
'compare_rate' => $compareRate,
'compare_formatted' => $this->formatCompareRate($compareRate),
];
}
/**
* 按类型规格化数值
*
* @param mixed $value 原始值
* @param string $type 类型:count(取整)、其他(保留2位小数)
* @return float|int 规格化后的值
*/
protected function normalizeByType($value, $type)
{
if ($type === 'count') {
return (int)round($value);
}
return round((float)$value, 2);
}
/**
* 指标格式化
*
* @param mixed $value 原始值
* @param string $type 类型:count、money、percent
* @return string 格式化后的字符串
*/
protected function formatMetric($value, $type)
{
if ($type === 'percent') {
return round((float)$value, 2) . '%';
}
if ($type === 'money') {
return $this->formatLargeNumber((float)$value, true);
}
return $this->formatLargeNumber((float)$value, false);
}
/**
* 环比增长率格式化
*
* @param float $rate 增长率
* @return string 格式化后的增长率字符串(带正负号)
*/
protected function formatCompareRate($rate)
{
$prefix = $rate > 0 ? '+' : '';
return $prefix . round((float)$rate, 2) . '%';
}
/**
* 大数格式化
*
* 将大数值转换为更易读的格式:亿、万
*
* @param float $value 原始数值
* @param bool $isMoney 是否为金额(决定小数位数)
* @return string 格式化后的字符串
*/
protected function formatLargeNumber($value, $isMoney = false)
{
$precision = $isMoney ? 2 : 0;
if (abs($value) >= 100000000) {
return round($value / 100000000, 2) . '亿';
}
if (abs($value) >= 10000) {
return round($value / 10000, 2) . '万';
}
return number_format($value, $precision, '.', '');
}
/**
* 构建缓存键
*
* @param string $timeRange 时间范围标识
* @return string 缓存键名
*/
protected function buildCacheKey($timeRange)
{
return 'shopro:data_stats_center:' . $timeRange;
}
/**
* 获取完整表名(带表前缀)
*
* @param string $table 表名
* @return string 完整表名
*/
protected function getFullTableName($table)
{
return config('database.prefix') . $table;
}
}
?>
\ No newline at end of file
...@@ -36,6 +36,7 @@ class TrackFailedRetry extends Command ...@@ -36,6 +36,7 @@ class TrackFailedRetry extends Command
} }
protected function execute(Input $input, Output $output) { protected function execute(Input $input, Output $output) {
$this->output = $output;
$this->customLog('info', '========== [' . date('Y-m-d H:i:s') . '] 开始执行重试失败埋埋点数据任务 =========='); $this->customLog('info', '========== [' . date('Y-m-d H:i:s') . '] 开始执行重试失败埋埋点数据任务 ==========');
...@@ -78,8 +79,24 @@ class TrackFailedRetry extends Command ...@@ -78,8 +79,24 @@ class TrackFailedRetry extends Command
throw new \Exception('数据格式错误'); throw new \Exception('数据格式错误');
} }
$retryPayload = $this->filterRetryPayload($data);
if (empty($retryPayload['data'])) {
Db::name('shopro_miniprogram_track_failed')
->where('id', $item['id'])
->update([
'status' => 1,
'error' => '同一操作已入库,跳过重试',
'update_time' => time()
]);
$totalSuccess++;
$totalProcessed++;
continue;
}
// 重新推入队列 // 重新推入队列
Queue::push('\addons\shopro\job\TrackProcessJob@fire', $data['data'], 'shopro-high'); Queue::push('\addons\shopro\job\TrackProcessJob@fire', $retryPayload, 'shopro-high');
// 更新状态为已处理 // 更新状态为已处理
Db::name('shopro_miniprogram_track_failed') Db::name('shopro_miniprogram_track_failed')
...@@ -114,6 +131,94 @@ class TrackFailedRetry extends Command ...@@ -114,6 +131,94 @@ class TrackFailedRetry extends Command
$this->customLog('info', "重试完成!总计处理: {$totalProcessed} | 成功: {$totalSuccess} | 失败: {$totalFailed}"); $this->customLog('info', "重试完成!总计处理: {$totalProcessed} | 成功: {$totalSuccess} | 失败: {$totalFailed}");
} }
/**
* 过滤已经入库的埋点,仅重试未入库数据
*/
protected function filterRetryPayload($payload)
{
$trackList = isset($payload['data']) && is_array($payload['data']) ? $payload['data'] : [];
$clientIp = $payload['ip'] ?? '';
$jobTimestamp = isset($payload['timestamp']) ? (int)$payload['timestamp'] : time();
$pendingList = [];
$seen = [];
foreach ($trackList as $trackItem) {
$trackRow = $this->buildTrackRow($trackItem, $clientIp, $jobTimestamp);
$dedupKey = $this->buildTrackDedupKey($trackRow);
if (isset($seen[$dedupKey])) {
continue;
}
$seen[$dedupKey] = true;
if ($this->trackExists($trackRow)) {
continue;
}
$pendingList[] = $trackItem;
}
$payload['data'] = $pendingList;
return $payload;
}
/**
* 构建埋点行数据
*/
protected function buildTrackRow($trackItem, $clientIp, $jobTimestamp)
{
return [
'user_id' => $trackItem['user_id'] ?? 0,
'openid' => $trackItem['openid'] ?? '',
'session_id' => $trackItem['session_id'] ?? '',
'event_type' => $trackItem['event_type'] ?? '',
'event_code' => $trackItem['event_code'] ?? '',
'page_path' => $trackItem['page_path'] ?? '',
'page_title' => $trackItem['page_title'] ?? '',
'params' => !empty($trackItem['params']) ? json_encode($trackItem['params']) : '',
'ip' => $clientIp,
'create_time' => isset($trackItem['create_time']) ? (int)($trackItem['create_time'] / 1000) : $jobTimestamp
];
}
/**
* 构建埋点判重键
*/
protected function buildTrackDedupKey($trackRow)
{
return md5(implode('|', [
(string)$trackRow['user_id'],
(string)$trackRow['openid'],
(string)$trackRow['session_id'],
(string)$trackRow['event_type'],
(string)$trackRow['event_code'],
(string)$trackRow['page_path'],
(string)$trackRow['page_title'],
(string)$trackRow['params'],
(string)$trackRow['ip'],
(string)$trackRow['create_time'],
]));
}
/**
* 判断埋点是否已存在
*/
protected function trackExists($trackRow)
{
return Db::name('shopro_miniprogram_track')
->where('user_id', $trackRow['user_id'])
->where('openid', $trackRow['openid'])
->where('session_id', $trackRow['session_id'])
->where('event_type', $trackRow['event_type'])
->where('event_code', $trackRow['event_code'])
->where('page_path', $trackRow['page_path'])
->where('page_title', $trackRow['page_title'])
->where('params', $trackRow['params'])
->where('ip', $trackRow['ip'])
->where('create_time', $trackRow['create_time'])
->find() ? true : false;
}
} }
?> ?>
...@@ -54,7 +54,9 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form', 'moment'], function ( ...@@ -54,7 +54,9 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form', 'moment'], function (
}, function (ret, res) { }) }, function (ret, res) { })
} }
function initChartTotal(key) { function initChartTotal(key) {
var myChart = echarts.init(document.getElementById(`${key}Total`)); var chartEl = document.getElementById(`${key}Total`);
if (!chartEl) return;
var myChart = echarts.init(chartEl);
window.onresize = () => { window.onresize = () => {
myChart.resize() myChart.resize()
} }
...@@ -370,13 +372,14 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form', 'moment'], function ( ...@@ -370,13 +372,14 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form', 'moment'], function (
} }
drawX(interval, kld); drawX(interval, kld);
console.log(chartOption, 'chartOption') console.log(chartOption, 'chartOption')
var myChart2 = echarts.init(document.getElementById(`chartContent`)); var chartContentEl = document.getElementById(`chartContent`);
if (chartContentEl) {
var myChart2 = echarts.init(chartContentEl);
window.onresize = () => { window.onresize = () => {
myChart2.resize() myChart2.resize()
} }
myChart2.setOption(chartOption); myChart2.setOption(chartOption);
}
} else { } else {
chartOption.xAxis.data = []; chartOption.xAxis.data = [];
...@@ -495,12 +498,14 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form', 'moment'], function ( ...@@ -495,12 +498,14 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form', 'moment'], function (
}); });
}) })
var myChart3 = echarts.init(document.getElementById(`rankingContent`)); var rankingContentEl = document.getElementById(`rankingContent`);
if (rankingContentEl) {
var myChart3 = echarts.init(rankingContentEl);
window.onresize = () => { window.onresize = () => {
myChart3.resize() myChart3.resize()
} }
myChart3.setOption(pieOption); myChart3.setOption(pieOption);
}
return false return false
}, function (ret, res) { }) }, function (ret, res) { })
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment