diff --git a/backend/app/dao/order/OrderCenterDao.php b/backend/app/dao/order/OrderCenterDao.php index d767cc088..dd0357d95 100644 --- a/backend/app/dao/order/OrderCenterDao.php +++ b/backend/app/dao/order/OrderCenterDao.php @@ -388,27 +388,33 @@ class OrderCenterDao extends BaseDao /** * 订单图表分组统计 - * 按周期对已支付订单进行分组,统计每天的订单量与销售额 - * @param int $startTime 开始时间戳 - * @param int $endTime 结束时间戳 - * @param string $cycle 统计周期:thirtyday=按天、month=按月、其余=按年 + * 按周期对已支付订单分组,统计订单量与销售额 + * @param string|int $dateBefore 开始日期(Y-m-d 或时间戳) + * @param string|int $dateAfter 结束日期(Y-m-d 或时间戳) + * @param string $timeType 分组粒度:30=月日、week=星期、month=日、year=月 * @return array 返回分组统计列表(day/count/price) */ - public function orderCharts(int $startTime, int $endTime, string $cycle = 'thirtyday'): array + public function orderCharts($dateBefore, $dateAfter, string $timeType = '30'): array { - // 根据统计周期决定分组粒度 - switch ($cycle) { - case 'thirtyday': - $format = '%Y-%m-%d'; + switch ($timeType) { + case 'week': + $format = '%w'; break; case 'month': - $format = '%Y-%m'; + $format = '%d'; break; + case 'year': + $format = '%m'; + break; + case '30': default: - $format = '%Y'; + $format = '%m-%d'; break; } + $startTime = is_numeric($dateBefore) ? (int)$dateBefore : strtotime((string)$dateBefore); + $endTime = is_numeric($dateAfter) ? (int)$dateAfter : strtotime((string)$dateAfter); + return $this->getModel() ->where('is_del', 0) ->where('pay_status', 1) @@ -420,6 +426,25 @@ class OrderCenterDao extends BaseDao ->toArray(); } + /** + * 统计区间内已支付订单总量与销售额 + * @param string|int $dateBefore 开始日期 + * @param string|int $dateAfter 结束日期 + * @return array{count:int,price:float} + */ + public function preTotalFind($dateBefore, $dateAfter): array + { + $startTime = is_numeric($dateBefore) ? (int)$dateBefore : strtotime((string)$dateBefore); + $endTime = is_numeric($dateAfter) ? (int)$dateAfter : strtotime((string)$dateAfter); + $res = $this->getModel() + ->where('is_del', 0) + ->where('pay_status', 1) + ->whereBetween('pay_time', [$startTime, $endTime]) + ->fieldRaw('COUNT(*) as count, COALESCE(SUM(pay_price), 0) as price') + ->find(); + return $res ? $res->toArray() : ['count' => 0, 'price' => 0]; + } + /** * 获取订单列表(导出用) * 兼容旧 getOrderList 调用方,适配新订单中台字段: diff --git a/backend/app/dao/user/UserDao.php b/backend/app/dao/user/UserDao.php index e75be2143..07cda6d48 100644 --- a/backend/app/dao/user/UserDao.php +++ b/backend/app/dao/user/UserDao.php @@ -256,8 +256,8 @@ class UserDao extends BaseDao { return $this->getModel() ->whereBetweenTime('add_time', $starday, $yesterday) - ->field("FROM_UNIXTIME(add_time,'%m-%e') as day,count(*) as count") - ->group("FROM_UNIXTIME(add_time, '%Y%m%e')") + ->field("FROM_UNIXTIME(add_time,'%m-%d') as day,count(*) as count") + ->group("FROM_UNIXTIME(add_time, '%Y-%m-%d')") ->order('add_time asc')->select()->toArray(); } diff --git a/backend/app/services/order/OrderCenterServices.php b/backend/app/services/order/OrderCenterServices.php index ee705ad7e..9a3817be9 100644 --- a/backend/app/services/order/OrderCenterServices.php +++ b/backend/app/services/order/OrderCenterServices.php @@ -2374,16 +2374,16 @@ class OrderCenterServices extends BaseService implements OrderCenterSyncInterfac ], ]; - // 计算日环比增长率 + // 计算日环比增长率(昨日为 0 时:今日有值记 100%,否则 0) foreach ($info as &$item) { - $nowValue = $item['today']; - $lastValue = $item['yesterday']; - if ($lastValue == 0 && $nowValue == 0) { + $nowValue = (float)$item['today']; + $lastValue = (float)$item['yesterday']; + if ($lastValue == 0.0 && $nowValue == 0.0) { $item['today_ratio'] = 0; - } elseif ($lastValue == 0) { - $item['today_ratio'] = round($nowValue, 2); - } elseif ($nowValue == 0) { - $item['today_ratio'] = -round($lastValue, 2); + } elseif ($lastValue == 0.0) { + $item['today_ratio'] = 100; + } elseif ($nowValue == 0.0) { + $item['today_ratio'] = -100; } else { $item['today_ratio'] = (float)bcmul(bcdiv((string)($nowValue - $lastValue), (string)$lastValue, 4), '100', 2); } @@ -2396,38 +2396,227 @@ class OrderCenterServices extends BaseService implements OrderCenterSyncInterfac /** * 订单图表 * 根据周期统计已支付订单的订单量与销售额,用于后台首页图表展示 - * @param string $cycle 统计周期:thirtyday=近30天、month=近12月、year=近1年 - * @return array 返回图表数据(legend/yAxis/xAxis/series) + * @param string $cycle thirtyday=近30天;week/month/year=上期与本期对比 + * @return array 返回图表数据(legend/yAxis/xAxis/series 为 ECharts 结构) */ public function orderCharts(string $cycle): array { - // 根据周期计算起始日期 - $starday = $cycle == 'thirtyday' ? date('Y-m-d', strtotime('-30 day')) : ($cycle == 'month' ? date('Y-m-d', strtotime('-12 month')) : date('Y-m-d', strtotime('-1 year'))); - $yesterday = date('Y-m-d', strtotime('+1 day')); - $startTime = strtotime($starday); - $endTime = strtotime($yesterday); - - $orderList = $this->dao->orderCharts($startTime, $endTime, $cycle); - - $chartdata = []; - $data = []; - $chartdata['legend'] = ['订单量', '销售额'];//分类 - $chartdata['yAxis']['maxnum'] = 0;//最大值数量 - $chartdata['xAxis'] = [$starday];//X轴值 - $chartdata['series'] = [0, 0];//分类1值 - if (!empty($orderList)) { - foreach ($orderList as $v) { - $data['day'][] = $v['day']; - $data['count'][] = $v['count']; - $data['price'][] = (float)$v['price']; - if ($chartdata['yAxis']['maxnum'] < $v['count']) - $chartdata['yAxis']['maxnum'] = $v['count']; - } - $chartdata['xAxis'] = $data['day'];//X轴值 - $chartdata['series'][0] = $data['count'];//分类1值 - $chartdata['series'][1] = $data['price'];//分类2值 + switch ($cycle) { + case 'week': + return $this->buildCompareOrderCharts( + ['周日', '周一', '周二', '周三', '周四', '周五', '周六'], + ['上周金额', '本周金额', '上周订单数', '本周订单数'], + date('Y-m-d', strtotime('-1 week Monday')), + date('Y-m-d 23:59:59', strtotime('-1 week Sunday')), + date('Y-m-d', (time() - ((date('w') == 0 ? 7 : date('w')) - 1) * 24 * 3600)), + date('Y-m-d 23:59:59'), + 'week', + static fn($k) => (string)(int)$k + ); + case 'month': + $days = []; + for ($i = 1; $i <= 31; $i++) { + $key = str_pad((string)$i, 2, '0', STR_PAD_LEFT); + $days[$key] = (string)$i; + } + return $this->buildCompareOrderCharts( + $days, + ['上月金额', '本月金额', '上月订单数', '本月订单数'], + date('Y-m-01', strtotime('-1 month')), + date('Y-m-d 23:59:59', strtotime(date('Y-m-01')) - 1), + date('Y-m-01'), + date('Y-m-d 23:59:59'), + 'month' + ); + case 'year': + return $this->buildCompareOrderCharts( + [ + '01' => '一月', '02' => '二月', '03' => '三月', '04' => '四月', + '05' => '五月', '06' => '六月', '07' => '七月', '08' => '八月', + '09' => '九月', '10' => '十月', '11' => '十一月', '12' => '十二月', + ], + ['去年金额', '今年金额', '去年订单数', '今年订单数'], + date('Y-01-01', strtotime('-1 year')), + date('Y-12-31 23:59:59', strtotime('-1 year')), + date('Y-01-01'), + date('Y-12-31 23:59:59'), + 'year' + ); + case 'thirtyday': + default: + return $this->buildThirtydayOrderCharts(); } - return $chartdata; + } + + /** + * 近 30 天订单金额/订单数图表(补齐缺失日期) + */ + protected function buildThirtydayOrderCharts(): array + { + $dateBefore = date('Y-m-d', strtotime('-29 day')); + $dateAfter = date('Y-m-d 23:59:59'); + $preBefore = date('Y-m-d', strtotime('-59 day')); + $preAfter = date('Y-m-d 23:59:59', strtotime('-30 day')); + + $datalist = []; + for ($i = -29; $i <= 0; $i++) { + $label = date('m-d', strtotime($i . ' day')); + $datalist[$label] = $label; + } + + $orderMap = []; + foreach ($this->dao->orderCharts($dateBefore, $dateAfter, '30') as $row) { + $orderMap[$row['day']] = $row; + } + + $counts = []; + $prices = []; + $xAxis = []; + $maxNum = 0; + $maxPrice = 0; + foreach ($datalist as $day) { + $count = (int)($orderMap[$day]['count'] ?? 0); + $price = round((float)($orderMap[$day]['price'] ?? 0), 2); + $xAxis[] = $day; + $counts[] = $count; + $prices[] = $price; + $maxNum = max($maxNum, $count); + $maxPrice = max($maxPrice, $price); + } + + $legend = ['订单金额', '订单数']; + $chartdata = [ + 'legend' => $legend, + 'xAxis' => $xAxis, + 'yAxis' => ['maxnum' => $maxNum, 'maxprice' => $maxPrice], + 'series' => [ + ['name' => $legend[0], 'type' => 'bar', 'itemStyle' => $this->orderChartBarStyle('#69cdff', '#1495eb'), 'data' => $prices], + ['name' => $legend[1], 'type' => 'line', 'itemStyle' => $this->orderChartBarStyle('#6fdeab', '#2cc981'), 'data' => $counts, 'yAxisIndex' => 1], + ], + ]; + return array_merge($chartdata, $this->buildOrderCycleSummary($preBefore, $preAfter, $dateBefore, $dateAfter)); + } + + /** + * 周/月/年:上期 vs 本期对比图表 + * @param array $axisLabels 轴标签(索引或关联) + * @param array $legend 图例四项 + * @param string $preBefore 上期开始 + * @param string $preAfter 上期结束 + * @param string $nowBefore 本期开始 + * @param string $nowAfter 本期结束 + * @param string $timeType DAO 分组类型 + * @param callable|null $keyNormalizer 将查询 day 规范为轴 key + */ + protected function buildCompareOrderCharts( + array $axisLabels, + array $legend, + string $preBefore, + string $preAfter, + string $nowBefore, + string $nowAfter, + string $timeType, + ?callable $keyNormalizer = null + ): array { + $preMap = []; + foreach ($this->dao->orderCharts($preBefore, $preAfter, $timeType) as $row) { + $key = $keyNormalizer ? $keyNormalizer($row['day']) : (string)$row['day']; + $preMap[$key] = $row; + } + $nowMap = []; + foreach ($this->dao->orderCharts($nowBefore, $nowAfter, $timeType) as $row) { + $key = $keyNormalizer ? $keyNormalizer($row['day']) : (string)$row['day']; + $nowMap[$key] = $row; + } + + $xAxis = []; + $preCount = []; + $prePrice = []; + $nowCount = []; + $nowPrice = []; + $maxNum = 0; + $maxPrice = 0; + foreach ($axisLabels as $key => $label) { + $axisKey = is_int($key) ? (string)$key : (string)$key; + $labelText = is_array($label) ? ($label[0] ?? $axisKey) : (string)$label; + $pc = (int)($preMap[$axisKey]['count'] ?? 0); + $pp = round((float)($preMap[$axisKey]['price'] ?? 0), 2); + $nc = (int)($nowMap[$axisKey]['count'] ?? 0); + $np = round((float)($nowMap[$axisKey]['price'] ?? 0), 2); + $xAxis[] = $labelText; + $preCount[] = $pc; + $prePrice[] = $pp; + $nowCount[] = $nc; + $nowPrice[] = $np; + $maxNum = max($maxNum, $pc, $nc); + $maxPrice = max($maxPrice, $pp, $np); + } + + $chartdata = [ + 'legend' => $legend, + 'xAxis' => $xAxis, + 'yAxis' => ['maxnum' => $maxNum, 'maxprice' => $maxPrice], + 'series' => [ + ['name' => $legend[0], 'type' => 'bar', 'itemStyle' => $this->orderChartBarStyle('#69cdff', '#1495eb'), 'data' => $prePrice], + ['name' => $legend[1], 'type' => 'bar', 'itemStyle' => $this->orderChartBarStyle('#6fdeab', '#2cc981'), 'data' => $nowPrice], + ['name' => $legend[2], 'type' => 'line', 'itemStyle' => $this->orderChartBarStyle('#69cdff', '#1495eb'), 'data' => $preCount, 'yAxisIndex' => 1], + ['name' => $legend[3], 'type' => 'line', 'itemStyle' => $this->orderChartBarStyle('#6fdeab', '#2cc981'), 'data' => $nowCount, 'yAxisIndex' => 1], + ], + ]; + return array_merge($chartdata, $this->buildOrderCycleSummary($preBefore, $preAfter, $nowBefore, $nowAfter)); + } + + /** + * 组装上期/本期汇总(环比) + */ + protected function buildOrderCycleSummary(string $preBefore, string $preAfter, string $nowBefore, string $nowAfter): array + { + $preTotal = $this->dao->preTotalFind($preBefore, $preAfter); + $total = $this->dao->preTotalFind($nowBefore, $nowAfter); + $preCount = (int)($preTotal['count'] ?? 0); + $prePrice = (float)($preTotal['price'] ?? 0); + $nowCount = (int)($total['count'] ?? 0); + $nowPrice = (float)($total['price'] ?? 0); + $chaCount = $preCount - $nowCount; + $chaPrice = round($prePrice, 2) - round($nowPrice, 2); + $baseCount = $preCount == 0 ? 1 : $preCount; + $basePrice = $prePrice == 0 ? 1 : $prePrice; + return [ + 'pre_cycle' => [ + 'count' => ['data' => $preCount], + 'price' => ['data' => $prePrice], + ], + 'cycle' => [ + 'count' => [ + 'data' => $nowCount, + 'percent' => round((abs($chaCount) / $baseCount) * 100, 2), + 'is_plus' => $chaCount > 0 ? -1 : ($chaCount == 0 ? 0 : 1), + ], + 'price' => [ + 'data' => $nowPrice, + 'percent' => round((abs($chaPrice) / $basePrice) * 100, 2), + 'is_plus' => $chaPrice > 0 ? -1 : ($chaPrice == 0 ? 0 : 1), + ], + ], + ]; + } + + /** + * ECharts 柱/线渐变样式 + */ + protected function orderChartBarStyle(string $startColor, string $endColor): array + { + return [ + 'normal' => [ + 'color' => [ + 'x' => 0, 'y' => 0, 'x2' => 0, 'y2' => 1, + 'colorStops' => [ + ['offset' => 0, 'color' => $startColor], + ['offset' => 1, 'color' => $endColor], + ], + ], + ], + ]; } /** diff --git a/frontend/admin/src/pages/index/components/userChart.vue b/frontend/admin/src/pages/index/components/userChart.vue index 9a6146af3..7e46c7d71 100644 --- a/frontend/admin/src/pages/index/components/userChart.vue +++ b/frontend/admin/src/pages/index/components/userChart.vue @@ -19,7 +19,7 @@ > - +
+ + +
+ +

商品支付排行

+
+
+
+
+
+ {{ index + 1 }} + {{ item.store_name }} + ¥{{ formatVal(item.val) }} +
+
+ +
+
+
+
@@ -50,21 +73,21 @@ const line = 'line'; const circle = 'circle'; const infoList = ref({}); const series = ref([]); -const xData = ref([]); -const y1Data = ref([]); -const y2Data = ref([]); const lists = ref([]); -const bing_data = ref([]); -const bing_xdata = ref([]); + +function formatVal(val) { + const num = Number(val || 0); + return Number.isFinite(num) ? num.toFixed(2) : '0.00'; +} // 统计 function getStatistics() { userApi() .then(async (res) => { - infoList.value = res.data; + infoList.value = res.data || {}; series.value = [ { - data: res.data.series, + data: infoList.value.series || [], name: '人数(人)', type: 'line', tooltip: true, @@ -77,8 +100,6 @@ function getStatistics() { }, }, ]; - bing_data.value = res.bing_data; - bing_xdata.value = res.bing_xdata; }) .catch((res) => { ElMessage.error(res.msg); @@ -87,8 +108,7 @@ function getStatistics() { function getRank() { rankApi() .then(async (res) => { - let data = res.data; - lists.value = data.list; + lists.value = res.data?.list || []; }) .catch((res) => { ElMessage.error(res.msg); @@ -120,21 +140,55 @@ onMounted(() => { height: 100%; .scollhide { width: 100%; - height: 100%; + max-height: 320px; overflow-x: hidden; - overflow-y: scroll; + overflow-y: auto; } } .scollhide::-webkit-scrollbar { - display: none; + width: 4px; +} +.rank-list { + padding: 8px 0 4px; +} +.rank-item { + display: flex; + align-items: center; + padding: 10px 4px; + border-bottom: 1px solid var(--prev-border-color-lighter, #f0f2f5); + &:last-child { + border-bottom: none; + } +} +.rank-index { + flex: none; + width: 22px; + height: 22px; + margin-right: 10px; + border-radius: 50%; + background: var(--prev-bg-color-page, #f5f7fa); + color: var(--prev-color-text-secondary, #909399); + font-size: 12px; + line-height: 22px; + text-align: center; + &.top { + background: var(--prev-color-primary); + color: #fff; + } } .names { - display: inline-block; - text-overflow: ellipsis; + flex: 1; + min-width: 0; overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; - width: 84%; - margin-bottom: -7px; + color: var(--prev-color-text-regular); +} +.rank-val { + flex: none; + margin-left: 12px; + color: var(--prev-color-text-primary); + font-weight: 500; } .card-title { display: flex;