fix(首页图表): 订单统计按周期对齐 ECharts,并补商品支付排行

修正日环比零除口径,用户新增按日分组日期格式统一为 m-d。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
吴昊天 2026-10-26 12:04:39 +08:00
parent 2444d60cc4
commit b3f71d28e3
4 changed files with 336 additions and 68 deletions

View File

@ -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 调用方,适配新订单中台字段:

View File

@ -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();
}

View File

@ -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],
],
],
],
];
}
/**

View File

@ -19,7 +19,7 @@
></echarts-from>
</el-card>
</el-col>
<el-col :xl="8" :lg="12" :md="24" :sm="24" :xs="24">
<el-col :xl="8" :lg="12" :md="24" :sm="24" :xs="24" class="ivu-mb">
<el-card :bordered="false" shadow="never" class="dashboard-console-visit">
<div class="card-title">
<el-avatar
@ -31,6 +31,29 @@
<echarts-from ref="visitChart" :infoList="infoList" :echartsTitle="circle"></echarts-from>
</el-card>
</el-col>
<el-col :span="24" class="ivu-mb">
<el-card :bordered="false" shadow="never" class="dashboard-console-visit">
<div class="card-title">
<el-avatar
icon="TrendCharts"
style="color: var(--prev-color-primary); background-color: #e6f7ff"
></el-avatar>
<h4 class="ivu-pl-8">商品支付排行</h4>
</div>
<div class="trees-coadd">
<div class="scollhide">
<div v-if="lists.length" class="rank-list">
<div v-for="(item, index) in lists" :key="item.product_id || index" class="rank-item">
<span class="rank-index" :class="{ top: index < 3 }">{{ index + 1 }}</span>
<span class="names" :title="item.store_name">{{ item.store_name }}</span>
<span class="rank-val">¥{{ formatVal(item.val) }}</span>
</div>
</div>
<el-empty v-else description="暂无排行数据" :image-size="64" />
</div>
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
@ -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;