diff --git a/backend/api/internal/logic/platform/dashboard/dashboard.go b/backend/api/internal/logic/platform/dashboard/dashboard.go index 4e4b881..672706d 100644 --- a/backend/api/internal/logic/platform/dashboard/dashboard.go +++ b/backend/api/internal/logic/platform/dashboard/dashboard.go @@ -2,7 +2,6 @@ package dashboard import ( "git.apinb.com/bsm-sdk/core/infra" - "git.apinb.com/heqiapp/platforms/backend/api/internal/models" "github.com/gin-gonic/gin" ) @@ -13,7 +12,7 @@ func PingHello(ctx *gin.Context) { // DashboardOverview 返回平台总后台的运营概览数据。 func DashboardOverview(ctx *gin.Context) { - overview, err := models.GetDashboardOverview() + overview, err := GetDashboardOverview() if err != nil { infra.Response.Error(ctx, err) return diff --git a/backend/api/internal/logic/platform/dashboard/dashboard_test.go b/backend/api/internal/logic/platform/dashboard/dashboard_test.go index 01c4f10..81b9978 100644 --- a/backend/api/internal/logic/platform/dashboard/dashboard_test.go +++ b/backend/api/internal/logic/platform/dashboard/dashboard_test.go @@ -1,30 +1,29 @@ package dashboard import ( - "regexp" "testing" - - "git.apinb.com/heqiapp/platforms/backend/api/internal/models" - "github.com/DATA-DOG/go-sqlmock" + "time" ) -func TestDashboardOverviewReturnsZeroValuesForAnEmptyDatabase(t *testing.T) { - _, mock := setupDashboardDatabase(t) - for _, query := range []string{ - `SELECT count\(\*\) FROM "gas_basic" WHERE status = \$1`, - `SELECT count\(\*\) FROM "delivery_basic" WHERE status = \$1`, - `SELECT count\(\*\) FROM "staff_account" WHERE work_status = \$1`, - `SELECT count\(\*\) FROM "user_account" WHERE status = \$1`, - } { - mock.ExpectQuery(regexp.MustCompile(query).String()).WithArgs(sqlmock.AnyArg()).WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0)) +func TestNamedStatusesUsesFallbackForUnknownStatus(t *testing.T) { + got := namedStatuses([]statusAggregate{{Status: 16, Count: 3}, {Status: 999, Count: 1}}, map[int]string{16: "已创建"}) + if len(got) != 2 || got[0].Name != "已创建" || got[0].Value != 3 || got[1].Name != "其他" { + t.Fatalf("named statuses = %#v", got) + } +} + +func TestFillRecentDaysIncludesMissingDays(t *testing.T) { + now := time.Date(2026, 7, 29, 12, 0, 0, 0, time.Local) + got := fillRecentDays([]dailyAggregate{{ + Date: time.Date(2026, 7, 28, 0, 0, 0, 0, time.Local), OrderCount: 2, OrderAmount: 3600, + }}, now) + if len(got) != 7 { + t.Fatalf("day count = %d, want 7", len(got)) + } + if got[5].Date != "2026-07-28" || got[5].OrderCount != 2 || got[5].OrderAmount != 3600 { + t.Fatalf("filled metric = %#v", got[5]) + } + if got[0].OrderCount != 0 || got[6].OrderAmount != 0 { + t.Fatalf("missing days were not zero-filled: %#v", got) } - - overview, err := models.GetDashboardOverview() - if err != nil { - t.Fatal(err) - } - if overview != (models.DashboardOverview{}) { - t.Fatalf("empty dashboard = %#v, want zero values", overview) - } - assertDashboardMockExpectations(t, mock) } diff --git a/backend/api/internal/logic/platform/dashboard/statistics.go b/backend/api/internal/logic/platform/dashboard/statistics.go new file mode 100644 index 0000000..98bdda3 --- /dev/null +++ b/backend/api/internal/logic/platform/dashboard/statistics.go @@ -0,0 +1,166 @@ +package dashboard + +import ( + "time" + + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + "git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" +) + +// MetricSlice 是仪表盘图表使用的名称/数值序列。 +type MetricSlice struct { + Name string `json:"name"` + Value int64 `json:"value"` +} + +// DailyMetric 是按自然日聚合的运营指标。 +type DailyMetric struct { + Date string `json:"date"` + OrderCount int64 `json:"order_count"` + OrderAmount int64 `json:"order_amount"` +} + +// DashboardStatistics 是平台运营总览。该接口专用结构只属于 dashboard 逻辑层。 +type DashboardStatistics struct { + GasBasicCount int64 `json:"gas_basic_count"` + DeliveryBasicCount int64 `json:"delivery_basic_count"` + StaffCount int64 `json:"staff_count"` + UserCount int64 `json:"user_count"` + ProductCount int64 `json:"product_count"` + ActiveContractCount int64 `json:"active_contract_count"` + TodayOrderCount int64 `json:"today_order_count"` + TodayOrderAmount int64 `json:"today_order_amount"` + PendingTicketCount int64 `json:"pending_ticket_count"` + PaidAmount int64 `json:"paid_amount"` + OrderStatuses []MetricSlice `json:"order_statuses"` + ProductStatuses []MetricSlice `json:"product_statuses"` + PaymentChannels []MetricSlice `json:"payment_channels"` + RecentOrders []DailyMetric `json:"recent_orders"` +} + +type statusAggregate struct { + Status int + Count int64 +} + +type channelAggregate struct { + Name string + Value int64 +} + +type dailyAggregate struct { + Date time.Time + OrderCount int64 + OrderAmount int64 +} + +var orderStatusNames = map[int]string{ + common.StatusCreated: "已创建", common.StatusOrdered: "已下单", + common.StatusAssigned: "已分配", common.StatusFilling: "充装中", + common.StatusReady: "已就绪", common.StatusDelivering: "配送中", + common.StatusAwaitingConfirmation: "待确认", common.StatusCompleted: "已完成", + common.StatusCancelled: "已取消", common.StatusException: "异常", +} + +var productStatusNames = map[int]string{ + common.StatusPending: "待处理", common.StatusInStock: "在库", + common.StatusInTransit: "运输中", common.StatusInUse: "使用中", + common.StatusRepairing: "维修中", common.StatusScrapped: "已报废", +} + +// GetDashboardOverview 汇总组织、人员、资产、订单、支付及客服维度的数据。 +func GetDashboardOverview() (DashboardStatistics, error) { + var result DashboardStatistics + counts := []struct { + model any + where string + args []any + out *int64 + }{ + {&models.GasBasic{}, "status = ?", []any{common.StatusEnable}, &result.GasBasicCount}, + {&models.DeliveryBasic{}, "status = ?", []any{common.StatusEnable}, &result.DeliveryBasicCount}, + {&models.StaffAccount{}, "status = ? AND work_status = ?", []any{common.StatusEnable, "on_duty"}, &result.StaffCount}, + {&models.UserAccount{}, "status = ?", []any{common.StatusEnable}, &result.UserCount}, + {&models.ProductInfo{}, "status = ?", []any{common.StatusEnable}, &result.ProductCount}, + {&models.GasorderContract{}, "status = ? AND contract_status = ?", []any{common.StatusEnable, common.StatusActive}, &result.ActiveContractCount}, + {&models.GasorderBasic{}, "status = ? AND created_at >= CURRENT_DATE", []any{common.StatusEnable}, &result.TodayOrderCount}, + {&models.CsTicket{}, "status = ? AND ticket_status = ?", []any{common.StatusEnable, common.StatusOpen}, &result.PendingTicketCount}, + } + for _, item := range counts { + if err := impl.DBService.Model(item.model).Where(item.where, item.args...).Count(item.out).Error; err != nil { + return DashboardStatistics{}, err + } + } + if err := impl.DBService.Model(&models.GasorderBasic{}). + Select("COALESCE(SUM(payable_amount), 0)"). + Where("status = ? AND created_at >= CURRENT_DATE", common.StatusEnable). + Scan(&result.TodayOrderAmount).Error; err != nil { + return DashboardStatistics{}, err + } + if err := impl.DBService.Model(&models.WalletPayment{}). + Select("COALESCE(SUM(amount), 0)"). + Where("status = ? AND payment_status = ?", common.StatusEnable, common.StatusPaid). + Scan(&result.PaidAmount).Error; err != nil { + return DashboardStatistics{}, err + } + + var orderStatuses []statusAggregate + if err := impl.DBService.Model(&models.GasorderBasic{}). + Select("order_status AS status, COUNT(*) AS count"). + Where("status = ?", common.StatusEnable).Group("order_status").Scan(&orderStatuses).Error; err != nil { + return DashboardStatistics{}, err + } + result.OrderStatuses = namedStatuses(orderStatuses, orderStatusNames) + + var productStatuses []statusAggregate + if err := impl.DBService.Model(&models.ProductInfo{}). + Select("product_status AS status, COUNT(*) AS count"). + Where("status = ?", common.StatusEnable).Group("product_status").Scan(&productStatuses).Error; err != nil { + return DashboardStatistics{}, err + } + result.ProductStatuses = namedStatuses(productStatuses, productStatusNames) + + if err := impl.DBService.Model(&models.WalletPayment{}). + Select("pay_channel AS name, COALESCE(SUM(amount), 0) AS value"). + Where("status = ? AND payment_status = ?", common.StatusEnable, common.StatusPaid). + Group("pay_channel").Order("value DESC").Scan(&result.PaymentChannels).Error; err != nil { + return DashboardStatistics{}, err + } + + var daily []dailyAggregate + if err := impl.DBService.Model(&models.GasorderBasic{}). + Select("DATE_TRUNC('day', created_at) AS date, COUNT(*) AS order_count, COALESCE(SUM(payable_amount), 0) AS order_amount"). + Where("status = ? AND created_at >= CURRENT_DATE - INTERVAL '6 days'", common.StatusEnable). + Group("DATE_TRUNC('day', created_at)").Order("date").Scan(&daily).Error; err != nil { + return DashboardStatistics{}, err + } + result.RecentOrders = fillRecentDays(daily, time.Now()) + return result, nil +} + +func namedStatuses(rows []statusAggregate, names map[int]string) []MetricSlice { + result := make([]MetricSlice, 0, len(rows)) + for _, row := range rows { + name, ok := names[row.Status] + if !ok { + name = "其他" + } + result = append(result, MetricSlice{Name: name, Value: row.Count}) + } + return result +} + +func fillRecentDays(rows []dailyAggregate, now time.Time) []DailyMetric { + values := make(map[string]dailyAggregate, len(rows)) + for _, row := range rows { + values[row.Date.Format("2006-01-02")] = row + } + result := make([]DailyMetric, 0, 7) + for offset := -6; offset <= 0; offset++ { + date := now.AddDate(0, 0, offset).Format("2006-01-02") + row := values[date] + result = append(result, DailyMetric{Date: date, OrderCount: row.OrderCount, OrderAmount: row.OrderAmount}) + } + return result +} diff --git a/backend/api/internal/logic/platform/menu.go b/backend/api/internal/logic/platform/menu.go index 61d1a7a..7d3f25d 100644 --- a/backend/api/internal/logic/platform/menu.go +++ b/backend/api/internal/logic/platform/menu.go @@ -26,14 +26,9 @@ var PlatformMenus = [][]Menu{ {Identity: "dashboard_reports", ParentIdentity: "dashboard", GroupCode: "dashboard", Name: "统计报表", Path: "/dashboard/reports", SortNo: 2, Status: common.StatusEnable}, }, { - {Identity: "gas", GroupCode: "gas", Name: "气站管理", Icon: "icon-storage", Path: "/gas", SortNo: 20, Status: common.StatusEnable}, - {Identity: "gas_basic", ParentIdentity: "gas", GroupCode: "gas", Name: "气站", Path: "/gas/gas-basic", SortNo: 1, Status: common.StatusEnable}, - {Identity: "gas_account", ParentIdentity: "gas", GroupCode: "gas", Name: "气站账户", Path: "/gas/gas-account", SortNo: 2, Status: common.StatusEnable}, - }, - { - {Identity: "delivery", GroupCode: "delivery", Name: "配送站管理", Icon: "icon-send", Path: "/delivery", SortNo: 30, Status: common.StatusEnable}, - {Identity: "delivery_basic", ParentIdentity: "delivery", GroupCode: "delivery", Name: "配送站", Path: "/delivery/delivery-basic", SortNo: 1, Status: common.StatusEnable}, - {Identity: "delivery_account", ParentIdentity: "delivery", GroupCode: "delivery", Name: "配送站账户", Path: "/delivery/delivery-account", SortNo: 2, Status: common.StatusEnable}, + {Identity: "organization", GroupCode: "organization", Name: "机构管理", Icon: "icon-storage", Path: "/organization", SortNo: 20, Status: common.StatusEnable}, + {Identity: "gas_basic", ParentIdentity: "organization", GroupCode: "organization", Name: "气站管理", Path: "/gas/gas-basic", SortNo: 1, Status: common.StatusEnable}, + {Identity: "delivery_basic", ParentIdentity: "organization", GroupCode: "organization", Name: "配送点管理", Path: "/delivery/delivery-basic", SortNo: 2, Status: common.StatusEnable}, }, { {Identity: "staff", GroupCode: "staff", Name: "工作人员管理", Icon: "icon-user-group", Path: "/staff", SortNo: 40, Status: common.StatusEnable}, diff --git a/backend/api/internal/logic/platform/platform/access.go b/backend/api/internal/logic/platform/platform/access.go index 4508a94..a6aa7ee 100644 --- a/backend/api/internal/logic/platform/platform/access.go +++ b/backend/api/internal/logic/platform/platform/access.go @@ -32,6 +32,10 @@ func platformRouteMenuIdentity(resource string) string { switch { case resource == "dashboard": return "dashboard_overview" + case resource == "gas_account": + return "gas_basic" + case resource == "delivery_account": + return "delivery_basic" case strings.HasPrefix(resource, "gasorder_contract"): return "gasorder_contract" case resource == "gasorder_track" || resource == "gasorder_track_point": diff --git a/backend/api/internal/logic/platform/platform/access_test.go b/backend/api/internal/logic/platform/platform/access_test.go index b47f437..ebee9f3 100644 --- a/backend/api/internal/logic/platform/platform/access_test.go +++ b/backend/api/internal/logic/platform/platform/access_test.go @@ -11,8 +11,11 @@ func TestSecondLevelMenuPermissionDoesNotGrantSibling(t *testing.T) { if !platformMenuAllowsPath(menus, "/heqi/platform/v1/delivery_basic") { t.Fatal("selected second-level menu did not grant its resource") } - if platformMenuAllowsPath(menus, "/heqi/platform/v1/delivery_account") { - t.Fatal("selected second-level menu granted a sibling resource") + if !platformMenuAllowsPath(menus, "/heqi/platform/v1/delivery_account") { + t.Fatal("delivery management did not grant its embedded account management") + } + if platformMenuAllowsPath(menus, "/heqi/platform/v1/gas_account") { + t.Fatal("delivery management granted gas account management") } if platformMenuAllowsPath(menus, "/heqi/platform/v1/gasorder_basic") { t.Fatal("delivery permission leaked into gasorder") diff --git a/backend/api/internal/models/query.go b/backend/api/internal/models/query.go index 861eca7..4064e0d 100644 --- a/backend/api/internal/models/query.go +++ b/backend/api/internal/models/query.go @@ -2,32 +2,6 @@ package models import "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" -// DashboardOverview 是平台总后台的跨组织运营概览指标。 -type DashboardOverview struct { - GasBasicCount int64 `json:"gas_basic_count"` // 启用可燃气体站数量 - DeliveryBasicCount int64 `json:"delivery_basic_count"` // 启用配送点数量 - StaffCount int64 `json:"staff_count"` // 在岗服务人员数量 - UserCount int64 `json:"user_count"` // 启用业主客户数量 -} - -// GetDashboardOverview 通过独立查询返回首页概览指标。 -func GetDashboardOverview() (DashboardOverview, error) { - var overview DashboardOverview - if err := impl.DBService.Model(&GasBasic{}).Where("status = ?", 1).Count(&overview.GasBasicCount).Error; err != nil { - return DashboardOverview{}, err - } - if err := impl.DBService.Model(&DeliveryBasic{}).Where("status = ?", 1).Count(&overview.DeliveryBasicCount).Error; err != nil { - return DashboardOverview{}, err - } - if err := impl.DBService.Model(&StaffAccount{}).Where("work_status = ?", "on_duty").Count(&overview.StaffCount).Error; err != nil { - return DashboardOverview{}, err - } - if err := impl.DBService.Model(&UserAccount{}).Where("status = ?", 1).Count(&overview.UserCount).Error; err != nil { - return DashboardOverview{}, err - } - return overview, nil -} - // ListPlatformAccount 返回平台账号分页列表,敏感字段由接口展示层脱敏。 func ListPlatformAccount(page, size int) ([]PlatformAccount, int64, error) { var list []PlatformAccount diff --git a/frontend/platform_admin/src/api/platform.ts b/frontend/platform_admin/src/api/platform.ts index fee8d50..b7cce48 100644 --- a/frontend/platform_admin/src/api/platform.ts +++ b/frontend/platform_admin/src/api/platform.ts @@ -2,11 +2,36 @@ import { request } from './http'; import { resourceApi } from './resource'; /** 平台角色、菜单、账号和工作台接口。 */ +export interface DashboardMetric { + name: string; + value: number; +} +export interface DashboardDailyMetric { + date: string; + order_count: number; + order_amount: number; +} +export interface DashboardOverview { + gas_basic_count: number; + delivery_basic_count: number; + staff_count: number; + user_count: number; + product_count: number; + active_contract_count: number; + today_order_count: number; + today_order_amount: number; + pending_ticket_count: number; + paid_amount: number; + order_statuses: DashboardMetric[]; + product_statuses: DashboardMetric[]; + payment_channels: DashboardMetric[]; + recent_orders: DashboardDailyMetric[]; +} export type PlatformRole = { identity: string; role_code: string; name: string; location_scope: string; is_system: boolean; status: number }; export type PlatformMenu = { identity: string; parent_identity?: string; group_code: string; name: string; icon: string; path: string; sort_no: number }; export const platformApi = { - overview: () => request>('/dashboard/overview'), + overview: () => request('/dashboard/overview'), listAccount: () => request<{ total: number; list: Record[] }>('/platform_account'), listRole: () => resourceApi.list('/platform_role'), createRole: (data: Record) => resourceApi.create('/platform_role', data), diff --git a/frontend/platform_admin/src/api/resources.ts b/frontend/platform_admin/src/api/resources.ts index 71fc270..421ca36 100644 --- a/frontend/platform_admin/src/api/resources.ts +++ b/frontend/platform_admin/src/api/resources.ts @@ -48,6 +48,11 @@ export type ResourceUiDefinition = { canEdit: boolean; canChangeStatus: boolean; canArchive: boolean; + accountManagement?: { + resource: `/${string}`; + relationKey: string; + title: string; + }; }; const fieldLabels: Record = { @@ -276,9 +281,9 @@ function define( const reason = [f('reason', { required: true })]; export const resources: ResourceUiDefinition[] = [ - define('gas_basic', '气站', 'writable', [f('code', { required: true }), f('name', { required: true }), f('credit_code'), f('principal'), f('address'), f('longitude'), f('latitude')]), + { ...define('gas_basic', '气站管理', 'writable', [f('code', { required: true }), f('name', { required: true }), f('credit_code'), f('principal'), f('address'), f('longitude'), f('latitude')]), accountManagement: { resource: '/gas_account', relationKey: 'gas_basic_identity', title: '气站账户' } }, define('gas_account', '气站账户', 'writable', [f('username', { required: true }), f('password', { required: true }), f('display_name'), f('role_code'), relation('gas_basic_identity', '/gas_basic', true)]), - define('delivery_basic', '配送站', 'writable', [f('delivery_code', { required: true }), f('name', { required: true }), relation('gas_basic_identity', '/gas_basic'), f('principal'), f('address')]), + { ...define('delivery_basic', '配送点管理', 'writable', [f('delivery_code', { required: true }), f('name', { required: true }), relation('gas_basic_identity', '/gas_basic'), f('principal'), f('address')]), accountManagement: { resource: '/delivery_account', relationKey: 'delivery_basic_identity', title: '配送点账户' } }, define('delivery_account', '配送站账户', 'writable', [f('username', { required: true }), f('password', { required: true }), f('display_name'), f('role_code'), relation('delivery_basic_identity', '/delivery_basic', true)]), define('staff_account', '工作人员', 'writable', [f('username', { required: true }), f('password', { required: true }), f('name', { required: true }), f('phone'), f('avatar'), f('role_code'), relation('gas_basic_identity', '/gas_basic'), relation('delivery_basic_identity', '/delivery_basic'), f('work_status')]), define('staff_credential', '工作人员资质', 'writable', [relation('staff_account_identity', '/staff_account', true), f('credential_type', { required: true }), f('credential_no'), f('expired_at')]), diff --git a/frontend/platform_admin/src/router/routes/modules/platform.ts b/frontend/platform_admin/src/router/routes/modules/platform.ts index 72cb529..c552d0b 100644 --- a/frontend/platform_admin/src/router/routes/modules/platform.ts +++ b/frontend/platform_admin/src/router/routes/modules/platform.ts @@ -59,13 +59,11 @@ const routes: AppRouteRecordRaw[] = [ { path: 'reports', name: 'dashboard-reports', component: () => import('@/views/dashboard/ReportPage.vue'), meta: { title: '统计报表', requiresAuth: true, menuCode: 'dashboard_reports' } }, ], }, - group('gas', 'gas', '气站管理', 'icon-storage', 10, [ - child('gas', 'gas-basic', 'basic', '气站', '/gas_basic', 'gas_basic'), - child('gas', 'gas-account', 'account', '气站账户', '/gas_account', 'gas_account'), - ]), - group('delivery', 'delivery', '配送站管理', 'icon-send', 20, [ - child('delivery', 'delivery-basic', 'basic', '配送站', '/delivery_basic', 'delivery_basic'), - child('delivery', 'delivery-account', 'account', '配送站账户', '/delivery_account', 'delivery_account'), + group('organization', 'organization', '机构管理', 'icon-storage', 10, [ + child('organization', 'gas-basic', 'gas-basic', '气站管理', '/gas_basic', 'gas_basic'), + child('organization', 'delivery-basic', 'delivery-basic', '配送点管理', '/delivery_basic', 'delivery_basic'), + child('organization', 'gas-account', 'gas-account', '气站账户', '/gas_account', 'gas_basic', true, 'gas-basic'), + child('organization', 'delivery-account', 'delivery-account', '配送点账户', '/delivery_account', 'delivery_basic', true, 'delivery-basic'), ]), group('staff', 'staff', '工作人员管理', 'icon-user-group', 30, [ child('staff', 'staff-account', 'account', '工作人员', '/staff_account', 'staff_account'), diff --git a/frontend/platform_admin/src/views/dashboard/DashboardPage.vue b/frontend/platform_admin/src/views/dashboard/DashboardPage.vue index bb9bd12..963beb4 100644 --- a/frontend/platform_admin/src/views/dashboard/DashboardPage.vue +++ b/frontend/platform_admin/src/views/dashboard/DashboardPage.vue @@ -1,3 +1,125 @@ - - - + + + + + diff --git a/frontend/platform_admin/src/views/dashboard/ReportPage.vue b/frontend/platform_admin/src/views/dashboard/ReportPage.vue index 831eac7..8964f3c 100644 --- a/frontend/platform_admin/src/views/dashboard/ReportPage.vue +++ b/frontend/platform_admin/src/views/dashboard/ReportPage.vue @@ -16,17 +16,28 @@