fix(dashboard): correct overview data and states

This commit is contained in:
2026-08-05 09:29:34 +08:00
parent 14a2ed7df8
commit d88e18bd09
8 changed files with 77 additions and 42 deletions

View File

@@ -1,8 +1,11 @@
package dashboard package dashboard
import ( import (
"regexp"
"testing" "testing"
"time" "time"
"github.com/DATA-DOG/go-sqlmock"
) )
func TestNamedStatusesUsesFallbackForUnknownStatus(t *testing.T) { func TestNamedStatusesUsesFallbackForUnknownStatus(t *testing.T) {
@@ -12,6 +15,24 @@ func TestNamedStatusesUsesFallbackForUnknownStatus(t *testing.T) {
} }
} }
func TestLoadPaymentChannelsUsesPaymentOrderChannelColumn(t *testing.T) {
_, mock := setupDashboardDatabase(t)
mock.ExpectQuery(regexp.QuoteMeta(
`SELECT channel AS name, COALESCE(SUM(amount), 0) AS value FROM "payment_order" WHERE (status = $1 AND payment_status = $2) AND "payment_order"."deleted_at" IS NULL GROUP BY "channel" ORDER BY value DESC`,
)).WithArgs(1, 23).WillReturnRows(
sqlmock.NewRows([]string{"name", "value"}).AddRow("wechat", int64(1200)),
)
var result []MetricSlice
if err := loadPaymentChannels(&result); err != nil {
t.Fatal(err)
}
if len(result) != 1 || result[0].Name != "wechat" || result[0].Value != 1200 {
t.Fatalf("payment channels = %#v", result)
}
assertDashboardMockExpectations(t, mock)
}
func TestFillRecentDaysIncludesMissingDays(t *testing.T) { func TestFillRecentDaysIncludesMissingDays(t *testing.T) {
now := time.Date(2026, 7, 29, 12, 0, 0, 0, time.Local) now := time.Date(2026, 7, 29, 12, 0, 0, 0, time.Local)
got := fillRecentDays([]dailyAggregate{{ got := fillRecentDays([]dailyAggregate{{

View File

@@ -126,10 +126,7 @@ func GetDashboardOverview() (DashboardStatistics, error) {
} }
result.ProductStatuses = namedStatuses(productStatuses, productStatusNames) result.ProductStatuses = namedStatuses(productStatuses, productStatusNames)
if err := impl.DBService.Model(&models.PaymentOrder{}). if err := loadPaymentChannels(&result.PaymentChannels); err != nil {
Select("pay_channel AS name, COALESCE(SUM(amount), 0) AS value").
Where("status = ? AND payment_status = ?", common.StatusEnable, 23).
Group("pay_channel").Order("value DESC").Scan(&result.PaymentChannels).Error; err != nil {
return DashboardStatistics{}, err return DashboardStatistics{}, err
} }
@@ -144,6 +141,13 @@ func GetDashboardOverview() (DashboardStatistics, error) {
return result, nil return result, nil
} }
func loadPaymentChannels(result *[]MetricSlice) error {
return impl.DBService.Model(&models.PaymentOrder{}).
Select("channel AS name, COALESCE(SUM(amount), 0) AS value").
Where("status = ? AND payment_status = ?", common.StatusEnable, 23).
Group("channel").Order("value DESC").Scan(result).Error
}
func namedStatuses(rows []statusAggregate, names map[int]string) []MetricSlice { func namedStatuses(rows []statusAggregate, names map[int]string) []MetricSlice {
result := make([]MetricSlice, 0, len(rows)) result := make([]MetricSlice, 0, len(rows))
for _, row := range rows { for _, row := range rows {

View File

@@ -1,5 +1,5 @@
<script lang="tsx"> <script lang="tsx">
import { compile, computed, defineComponent, h, ref } from 'vue'; import { compile, computed, defineComponent, h, ref, watch } from 'vue';
import type { RouteMeta } from 'vue-router'; import type { RouteMeta } from 'vue-router';
import { type RouteRecordRaw, useRoute, useRouter } from 'vue-router'; import { type RouteRecordRaw, useRoute, useRouter } from 'vue-router';
import { useAppStore } from '@/store'; import { useAppStore } from '@/store';
@@ -67,21 +67,24 @@ export default defineComponent({
}); });
return result; return result;
}; };
listenerRouteChange((newRoute) => { const syncMenuSelection = (newRoute: typeof route) => {
const { requiresAuth, activeMenu, hideInMenu } = newRoute.meta; const { requiresAuth, activeMenu, hideInMenu } = newRoute.meta;
if (requiresAuth && (!hideInMenu || activeMenu)) { if (requiresAuth && (!hideInMenu || activeMenu)) {
const target = (activeMenu || newRoute.name) as string;
const menuOpenKeys = findMenuOpenKeys( const menuOpenKeys = findMenuOpenKeys(
(activeMenu || newRoute.name) as string, target,
); );
if (!menuOpenKeys.length) return;
const keySet = new Set([...menuOpenKeys, ...openKeys.value]); const keySet = new Set([...menuOpenKeys, ...openKeys.value]);
openKeys.value = [...keySet]; openKeys.value = [...keySet];
selectedKey.value = [ selectedKey.value = [target];
activeMenu || menuOpenKeys[menuOpenKeys.length - 1],
];
} }
}, true); };
listenerRouteChange(syncMenuSelection, true);
watch(menuTree, () => syncMenuSelection(route), { deep: true, flush: 'post' });
const setCollapse = (val: boolean) => { const setCollapse = (val: boolean) => {
if (appStore.device === 'desktop') if (appStore.device === 'desktop')
appStore.updateSettings({ menuCollapse: val }); appStore.updateSettings({ menuCollapse: val });

View File

@@ -4,7 +4,7 @@
{{ errorMessage }} {{ errorMessage }}
<template #action><a-button size="small" @click="loadOverview">重新加载</a-button></template> <template #action><a-button size="small" @click="loadOverview">重新加载</a-button></template>
</a-alert> </a-alert>
<div class="dashboard-meta">当前配送点业务汇总 · 更新时间{{ updatedAt || '尚未加载' }}</div> <div class="dashboard-meta">当前配送点业务汇总 · 更新时间{{ updatedAtLabel }}</div>
<a-grid :cols="{ xs: 1, sm: 2, lg: 4 }" :col-gap="16" :row-gap="16"> <a-grid :cols="{ xs: 1, sm: 2, lg: 4 }" :col-gap="16" :row-gap="16">
<a-grid-item v-for="item in cards" :key="item.key"> <a-grid-item v-for="item in cards" :key="item.key">
<a-card :bordered="false" class="metric-card"> <a-card :bordered="false" class="metric-card">
@@ -17,12 +17,13 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Message } from '@arco-design/web-vue'; import { Message } from '@arco-design/web-vue';
import { onMounted, reactive, ref } from 'vue'; import { computed, onMounted, reactive, ref } from 'vue';
import { request } from '@/api/http'; import { request } from '@/api/http';
type Overview = Record<'staff_count' | 'user_count' | 'contract_count' | 'order_count', number>; type Overview = Record<'staff_count' | 'user_count' | 'contract_count' | 'order_count', number>;
const loading = ref(false); const loading = ref(false);
const errorMessage = ref(''); const errorMessage = ref('');
const updatedAt = ref(''); const updatedAt = ref('');
const updatedAtLabel = computed(() => updatedAt.value || (errorMessage.value ? '加载失败' : '加载中…'));
const overview = reactive<Overview>({ staff_count: 0, user_count: 0, contract_count: 0, order_count: 0 }); const overview = reactive<Overview>({ staff_count: 0, user_count: 0, contract_count: 0, order_count: 0 });
const cards: Array<{ key: keyof Overview; label: string; hint: string }> = [ const cards: Array<{ key: keyof Overview; label: string; hint: string }> = [
{ key: 'staff_count', label: '配送人员', hint: '本站关联配送人员' }, { key: 'user_count', label: '服务用户', hint: '本站有效服务关系' }, { key: 'staff_count', label: '配送人员', hint: '本站关联配送人员' }, { key: 'user_count', label: '服务用户', hint: '本站有效服务关系' },
@@ -44,6 +45,6 @@ onMounted(loadOverview);
<style scoped lang="less"> <style scoped lang="less">
.dashboard-alert { margin-bottom: 16px; } .dashboard-alert { margin-bottom: 16px; }
.dashboard-meta { margin-bottom: 12px; color: var(--color-text-3); font-size: 12px; text-align: right; } .dashboard-meta { margin-bottom: 12px; color: var(--color-text-3); font-size: 12px; text-align: right; }
.metric-card { min-height: 112px; } .metric-card { min-height: 132px; }
.metric-hint { margin-top: 8px; color: var(--color-text-3); font-size: 12px; } .metric-hint { margin-top: 10px; padding-top: 8px; border-top: 1px solid var(--color-border-2); color: var(--color-text-2); font-size: 13px; line-height: 20px; }
</style> </style>

View File

@@ -1,5 +1,5 @@
<script lang="tsx"> <script lang="tsx">
import { compile, computed, defineComponent, h, ref } from 'vue'; import { compile, computed, defineComponent, h, ref, watch } from 'vue';
import type { RouteMeta } from 'vue-router'; import type { RouteMeta } from 'vue-router';
import { type RouteRecordRaw, useRoute, useRouter } from 'vue-router'; import { type RouteRecordRaw, useRoute, useRouter } from 'vue-router';
import { useAppStore } from '@/store'; import { useAppStore } from '@/store';
@@ -67,21 +67,24 @@ export default defineComponent({
}); });
return result; return result;
}; };
listenerRouteChange((newRoute) => { const syncMenuSelection = (newRoute: typeof route) => {
const { requiresAuth, activeMenu, hideInMenu } = newRoute.meta; const { requiresAuth, activeMenu, hideInMenu } = newRoute.meta;
if (requiresAuth && (!hideInMenu || activeMenu)) { if (requiresAuth && (!hideInMenu || activeMenu)) {
const target = (activeMenu || newRoute.name) as string;
const menuOpenKeys = findMenuOpenKeys( const menuOpenKeys = findMenuOpenKeys(
(activeMenu || newRoute.name) as string, target,
); );
if (!menuOpenKeys.length) return;
const keySet = new Set([...menuOpenKeys, ...openKeys.value]); const keySet = new Set([...menuOpenKeys, ...openKeys.value]);
openKeys.value = [...keySet]; openKeys.value = [...keySet];
selectedKey.value = [ selectedKey.value = [target];
activeMenu || menuOpenKeys[menuOpenKeys.length - 1],
];
} }
}, true); };
listenerRouteChange(syncMenuSelection, true);
watch(menuTree, () => syncMenuSelection(route), { deep: true, flush: 'post' });
const setCollapse = (val: boolean) => { const setCollapse = (val: boolean) => {
if (appStore.device === 'desktop') if (appStore.device === 'desktop')
appStore.updateSettings({ menuCollapse: val }); appStore.updateSettings({ menuCollapse: val });

View File

@@ -4,7 +4,7 @@
{{ errorMessage }} {{ errorMessage }}
<template #action><a-button size="small" @click="loadOverview">重新加载</a-button></template> <template #action><a-button size="small" @click="loadOverview">重新加载</a-button></template>
</a-alert> </a-alert>
<div class="dashboard-meta">当前气站业务汇总 · 更新时间{{ updatedAt || '尚未加载' }}</div> <div class="dashboard-meta">当前气站业务汇总 · 更新时间{{ updatedAtLabel }}</div>
<a-grid :cols="{ xs: 1, sm: 2, lg: 5 }" :col-gap="16" :row-gap="16"> <a-grid :cols="{ xs: 1, sm: 2, lg: 5 }" :col-gap="16" :row-gap="16">
<a-grid-item v-for="item in cards" :key="item.key"> <a-grid-item v-for="item in cards" :key="item.key">
<a-card :bordered="false" class="metric-card"> <a-card :bordered="false" class="metric-card">
@@ -18,13 +18,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { Message } from '@arco-design/web-vue'; import { Message } from '@arco-design/web-vue';
import { onMounted, reactive, ref } from 'vue'; import { computed, onMounted, reactive, ref } from 'vue';
import { request } from '@/api/http'; import { request } from '@/api/http';
type Overview = Record<'delivery_count' | 'staff_count' | 'user_count' | 'contract_count' | 'order_count', number>; type Overview = Record<'delivery_count' | 'staff_count' | 'user_count' | 'contract_count' | 'order_count', number>;
const loading = ref(false); const loading = ref(false);
const errorMessage = ref(''); const errorMessage = ref('');
const updatedAt = ref(''); const updatedAt = ref('');
const updatedAtLabel = computed(() => updatedAt.value || (errorMessage.value ? '加载失败' : '加载中…'));
const overview = reactive<Overview>({ delivery_count: 0, staff_count: 0, user_count: 0, contract_count: 0, order_count: 0 }); const overview = reactive<Overview>({ delivery_count: 0, staff_count: 0, user_count: 0, contract_count: 0, order_count: 0 });
const cards: Array<{ key: keyof Overview; label: string; hint: string }> = [ const cards: Array<{ key: keyof Overview; label: string; hint: string }> = [
{ key: 'delivery_count', label: '配送点', hint: '当前气站服务范围' }, { key: 'delivery_count', label: '配送点', hint: '当前气站服务范围' },
@@ -52,6 +53,6 @@ onMounted(loadOverview);
<style scoped lang="less"> <style scoped lang="less">
.dashboard-alert { margin-bottom: 16px; } .dashboard-alert { margin-bottom: 16px; }
.dashboard-meta { margin-bottom: 12px; color: var(--color-text-3); font-size: 12px; text-align: right; } .dashboard-meta { margin-bottom: 12px; color: var(--color-text-3); font-size: 12px; text-align: right; }
.metric-card { min-height: 112px; } .metric-card { min-height: 132px; }
.metric-hint { margin-top: 8px; color: var(--color-text-3); font-size: 12px; } .metric-hint { margin-top: 10px; padding-top: 8px; border-top: 1px solid var(--color-border-2); color: var(--color-text-2); font-size: 13px; line-height: 20px; }
</style> </style>

View File

@@ -1,5 +1,5 @@
<script lang="tsx"> <script lang="tsx">
import { compile, computed, defineComponent, h, ref } from 'vue'; import { compile, computed, defineComponent, h, ref, watch } from 'vue';
import type { RouteMeta } from 'vue-router'; import type { RouteMeta } from 'vue-router';
import { type RouteRecordRaw, useRoute, useRouter } from 'vue-router'; import { type RouteRecordRaw, useRoute, useRouter } from 'vue-router';
import { useAppStore } from '@/store'; import { useAppStore } from '@/store';
@@ -67,21 +67,24 @@ export default defineComponent({
}); });
return result; return result;
}; };
listenerRouteChange((newRoute) => { const syncMenuSelection = (newRoute: typeof route) => {
const { requiresAuth, activeMenu, hideInMenu } = newRoute.meta; const { requiresAuth, activeMenu, hideInMenu } = newRoute.meta;
if (requiresAuth && (!hideInMenu || activeMenu)) { if (requiresAuth && (!hideInMenu || activeMenu)) {
const target = (activeMenu || newRoute.name) as string;
const menuOpenKeys = findMenuOpenKeys( const menuOpenKeys = findMenuOpenKeys(
(activeMenu || newRoute.name) as string, target,
); );
if (!menuOpenKeys.length) return;
const keySet = new Set([...menuOpenKeys, ...openKeys.value]); const keySet = new Set([...menuOpenKeys, ...openKeys.value]);
openKeys.value = [...keySet]; openKeys.value = [...keySet];
selectedKey.value = [ selectedKey.value = [target];
activeMenu || menuOpenKeys[menuOpenKeys.length - 1],
];
} }
}, true); };
listenerRouteChange(syncMenuSelection, true);
watch(menuTree, () => syncMenuSelection(route), { deep: true, flush: 'post' });
const setCollapse = (val: boolean) => { const setCollapse = (val: boolean) => {
if (appStore.device === 'desktop') if (appStore.device === 'desktop')
appStore.updateSettings({ menuCollapse: val }); appStore.updateSettings({ menuCollapse: val });

View File

@@ -5,7 +5,7 @@
{{ errorMessage }} {{ errorMessage }}
<template #action><a-button size="small" @click="loadOverview">重新加载</a-button></template> <template #action><a-button size="small" @click="loadOverview">重新加载</a-button></template>
</a-alert> </a-alert>
<div class="dashboard-meta">统计口径以服务端为准 · 更新时间{{ updatedAt || '尚未加载' }}</div> <div class="dashboard-meta">统计口径以服务端为准 · 更新时间{{ updatedAtLabel }}</div>
<a-grid :cols="{ xs: 1, sm: 2, lg: 4 }" :col-gap="16" :row-gap="16"> <a-grid :cols="{ xs: 1, sm: 2, lg: 4 }" :col-gap="16" :row-gap="16">
<a-grid-item v-for="item in cards" :key="item.key"> <a-grid-item v-for="item in cards" :key="item.key">
<a-card :bordered="false" class="metric-card"> <a-card :bordered="false" class="metric-card">
@@ -65,16 +65,15 @@ const emptyOverview = (): DashboardOverview => ({
const loading = ref(false); const loading = ref(false);
const errorMessage = ref(''); const errorMessage = ref('');
const updatedAt = ref(''); const updatedAt = ref('');
const updatedAtLabel = computed(() => updatedAt.value || (errorMessage.value ? '加载失败' : '加载中…'));
const overview = ref<DashboardOverview>(emptyOverview()); const overview = ref<DashboardOverview>(emptyOverview());
const router = useRouter(); const router = useRouter();
const userStore = useUserStore(); const userStore = useUserStore();
const cards: { key: CountKey; label: string; hint: string; money?: boolean }[] = [ const cards: { key: CountKey; label: string; hint: string; money?: boolean }[] = [
{ key: 'gas_basic_count', label: '启用气站', hint: '当前正常运营' }, { key: 'gas_basic_count', label: '气站总数', hint: '正常运营气站总数' },
{ key: 'delivery_basic_count', label: '启用配送点', hint: '跨组织汇总' }, { key: 'delivery_basic_count', label: '配送点总数', hint: '正常运营配送点总数' },
{ key: 'staff_count', label: '在岗人员', hint: '当前可接单' }, { key: 'user_count', label: '客户总数', hint: '有效客户账户总数' },
{ key: 'user_count', label: '启用客户', hint: '有效业主账户' }, { key: 'product_count', label: '智能气阀', hint: '平台智能气阀总数' },
{ key: 'product_count', label: '智能气阀', hint: '已启用资产' },
{ key: 'active_contract_count', label: '生效合同', hint: '当前履约中' },
{ key: 'today_order_count', label: '今日订单', hint: '自然日新增' }, { key: 'today_order_count', label: '今日订单', hint: '自然日新增' },
{ key: 'today_order_amount', label: '今日应付金额', hint: '订单口径', money: true }, { key: 'today_order_amount', label: '今日应付金额', hint: '订单口径', money: true },
{ key: 'pending_ticket_count', label: '待受理工单', hint: '客服待办' }, { key: 'pending_ticket_count', label: '待受理工单', hint: '客服待办' },
@@ -149,8 +148,8 @@ onMounted(loadOverview);
.dashboard-spin { width: 100%; } .dashboard-spin { width: 100%; }
.dashboard-alert { margin-bottom: 16px; } .dashboard-alert { margin-bottom: 16px; }
.dashboard-meta { margin-bottom: 12px; color: var(--color-text-3); font-size: 12px; text-align: right; } .dashboard-meta { margin-bottom: 12px; color: var(--color-text-3); font-size: 12px; text-align: right; }
.metric-card { min-height: 120px; } .metric-card { min-height: 132px; }
.metric-hint { margin-top: 8px; color: var(--color-text-3); font-size: 12px; } .metric-hint { margin-top: 10px; padding-top: 8px; border-top: 1px solid var(--color-border-2); color: var(--color-text-2); font-size: 13px; line-height: 20px; }
.section-card { margin-top: 16px; } .section-card { margin-top: 16px; }
.quick-action { height: 52px; justify-content: flex-start; padding: 0 18px; } .quick-action { height: 52px; justify-content: flex-start; padding: 0 18px; }
</style> </style>