feat: enhance superadmin dashboard overview

This commit is contained in:
2026-01-15 14:16:20 +08:00
parent a8453e0c6c
commit c683fa5cf3
10 changed files with 449 additions and 10 deletions

View File

@@ -50,6 +50,20 @@ func (c *contents) ListTenantContents(ctx fiber.Ctx, tenantID int64, filter *dto
return services.Super.ListContents(ctx, filter)
}
// Content statistics
//
// @Router /super/v1/contents/statistics [get]
// @Summary Content statistics
// @Description Content statistics
// @Tags Content
// @Accept json
// @Produce json
// @Success 200 {object} dto.SuperContentStatisticsResponse
// @Bind filter query
func (c *contents) Statistics(ctx fiber.Ctx, filter *dto.SuperContentStatisticsFilter) (*dto.SuperContentStatisticsResponse, error) {
return services.Super.ContentStatistics(ctx, filter)
}
// Update content status
//
// @Router /super/v1/tenants/:tenantID<int>/contents/:contentID<int>/status [patch]

View File

@@ -131,6 +131,18 @@ type SuperContentListFilter struct {
Desc *string `query:"desc"`
}
// SuperContentStatisticsFilter 超管内容统计查询条件。
type SuperContentStatisticsFilter struct {
// TenantID 租户ID不传代表全平台
TenantID *int64 `query:"tenant_id"`
// StartAt 统计开始时间RFC3339可选默认当前时间往前 7 天)。
StartAt *string `query:"start_at"`
// EndAt 统计结束时间RFC3339可选默认当前时间
EndAt *string `query:"end_at"`
// Granularity 统计粒度day目前仅支持 day
Granularity *string `query:"granularity"`
}
type SuperOrderListFilter struct {
requests.Pagination
// ID 订单ID精确匹配。
@@ -635,3 +647,19 @@ type AdminContentOwnerLite struct {
// Status 用户状态。
Status consts.UserStatus `json:"status"`
}
// SuperContentStatisticsResponse 超管内容统计响应。
type SuperContentStatisticsResponse struct {
// TotalCount 内容总量。
TotalCount int64 `json:"total_count"`
// Trend 按天新增内容趋势。
Trend []SuperContentTrendItem `json:"trend"`
}
// SuperContentTrendItem 内容新增趋势条目。
type SuperContentTrendItem struct {
// Date 日期YYYY-MM-DD
Date string `json:"date"`
// CreatedCount 当日新增内容数量。
CreatedCount int64 `json:"created_count"`
}

View File

@@ -58,6 +58,11 @@ func (r *Routes) Register(router fiber.Router) {
r.contents.List,
Query[dto.SuperContentListFilter]("filter"),
))
r.log.Debugf("Registering route: Get /super/v1/contents/statistics -> contents.Statistics")
router.Get("/super/v1/contents/statistics"[len(r.Path()):], DataFunc1(
r.contents.Statistics,
Query[dto.SuperContentStatisticsFilter]("filter"),
))
r.log.Debugf("Registering route: Get /super/v1/tenants/:tenantID<int>/contents -> contents.ListTenantContents")
router.Get("/super/v1/tenants/:tenantID<int>/contents"[len(r.Path()):], DataFunc2(
r.contents.ListTenantContents,

View File

@@ -2059,6 +2059,74 @@ func (s *super) BatchReviewContents(ctx context.Context, operatorID int64, form
return nil
}
func (s *super) ContentStatistics(ctx context.Context, filter *super_dto.SuperContentStatisticsFilter) (*super_dto.SuperContentStatisticsResponse, error) {
// 统一统计时间范围与粒度,默认最近 7 天。
reportFilter := &super_dto.SuperReportOverviewFilter{}
if filter != nil {
reportFilter.TenantID = filter.TenantID
reportFilter.StartAt = filter.StartAt
reportFilter.EndAt = filter.EndAt
reportFilter.Granularity = filter.Granularity
}
rg, err := s.normalizeReportRange(reportFilter)
if err != nil {
return nil, err
}
tenantID := int64(0)
if filter != nil && filter.TenantID != nil {
tenantID = *filter.TenantID
}
// 统计内容总量,支持租户维度过滤。
tbl, q := models.ContentQuery.QueryContext(ctx)
if tenantID > 0 {
q = q.Where(tbl.TenantID.Eq(tenantID))
}
total, err := q.Count()
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
// 按天聚合新增内容数量,补齐趋势序列。
type contentAggRow struct {
Day time.Time `gorm:"column:day"`
Count int64 `gorm:"column:count"`
}
rows := make([]contentAggRow, 0)
query := models.ContentQuery.WithContext(ctx).
UnderlyingDB().
Model(&models.Content{}).
Select("date_trunc('day', created_at) as day, count(*) as count").
Where("created_at >= ? AND created_at < ?", rg.startDay, rg.endNext)
if tenantID > 0 {
query = query.Where("tenant_id = ?", tenantID)
}
if err := query.Group("day").Scan(&rows).Error; err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
trendMap := make(map[string]int64, len(rows))
for _, row := range rows {
key := row.Day.Format("2006-01-02")
trendMap[key] = row.Count
}
trend := make([]super_dto.SuperContentTrendItem, 0)
for day := rg.startDay; !day.After(rg.endDay); day = day.AddDate(0, 0, 1) {
key := day.Format("2006-01-02")
trend = append(trend, super_dto.SuperContentTrendItem{
Date: key,
CreatedCount: trendMap[key],
})
}
return &super_dto.SuperContentStatisticsResponse{
TotalCount: total,
Trend: trend,
}, nil
}
func (s *super) ListOrders(ctx context.Context, filter *super_dto.SuperOrderListFilter) (*requests.Pager, error) {
tbl, q := models.OrderQuery.QueryContext(ctx)

View File

@@ -167,6 +167,29 @@ const docTemplate = `{
}
}
},
"/super/v1/contents/statistics": {
"get": {
"description": "Content statistics",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Content"
],
"summary": "Content statistics",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/dto.SuperContentStatisticsResponse"
}
}
}
}
},
"/super/v1/contents/{id}/review": {
"post": {
"description": "Review content",
@@ -6374,6 +6397,22 @@ const docTemplate = `{
}
}
},
"dto.SuperContentStatisticsResponse": {
"type": "object",
"properties": {
"total_count": {
"description": "TotalCount 内容总量。",
"type": "integer"
},
"trend": {
"description": "Trend 按天新增内容趋势。",
"type": "array",
"items": {
"$ref": "#/definitions/dto.SuperContentTrendItem"
}
}
}
},
"dto.SuperContentTenantLite": {
"type": "object",
"properties": {
@@ -6391,6 +6430,19 @@ const docTemplate = `{
}
}
},
"dto.SuperContentTrendItem": {
"type": "object",
"properties": {
"created_count": {
"description": "CreatedCount 当日新增内容数量。",
"type": "integer"
},
"date": {
"description": "Date 日期YYYY-MM-DD。",
"type": "string"
}
}
},
"dto.SuperCouponGrantItem": {
"type": "object",
"properties": {

View File

@@ -161,6 +161,29 @@
}
}
},
"/super/v1/contents/statistics": {
"get": {
"description": "Content statistics",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Content"
],
"summary": "Content statistics",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/dto.SuperContentStatisticsResponse"
}
}
}
}
},
"/super/v1/contents/{id}/review": {
"post": {
"description": "Review content",
@@ -6368,6 +6391,22 @@
}
}
},
"dto.SuperContentStatisticsResponse": {
"type": "object",
"properties": {
"total_count": {
"description": "TotalCount 内容总量。",
"type": "integer"
},
"trend": {
"description": "Trend 按天新增内容趋势。",
"type": "array",
"items": {
"$ref": "#/definitions/dto.SuperContentTrendItem"
}
}
}
},
"dto.SuperContentTenantLite": {
"type": "object",
"properties": {
@@ -6385,6 +6424,19 @@
}
}
},
"dto.SuperContentTrendItem": {
"type": "object",
"properties": {
"created_count": {
"description": "CreatedCount 当日新增内容数量。",
"type": "integer"
},
"date": {
"description": "Date 日期YYYY-MM-DD。",
"type": "string"
}
}
},
"dto.SuperCouponGrantItem": {
"type": "object",
"properties": {

View File

@@ -1048,6 +1048,17 @@ definitions:
required:
- action
type: object
dto.SuperContentStatisticsResponse:
properties:
total_count:
description: TotalCount 内容总量。
type: integer
trend:
description: Trend 按天新增内容趋势。
items:
$ref: '#/definitions/dto.SuperContentTrendItem'
type: array
type: object
dto.SuperContentTenantLite:
properties:
code:
@@ -1060,6 +1071,15 @@ definitions:
description: Name 租户名称。
type: string
type: object
dto.SuperContentTrendItem:
properties:
created_count:
description: CreatedCount 当日新增内容数量。
type: integer
date:
description: Date 日期YYYY-MM-DD
type: string
type: object
dto.SuperCouponGrantItem:
properties:
coupon_id:
@@ -2500,6 +2520,21 @@ paths:
summary: Batch review contents
tags:
- Content
/super/v1/contents/statistics:
get:
consumes:
- application/json
description: Content statistics
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/dto.SuperContentStatisticsResponse'
summary: Content statistics
tags:
- Content
/super/v1/coupon-grants:
get:
consumes:

View File

@@ -4,8 +4,8 @@
## 1) 总体结论
- **已落地**:登录、租户/用户/订单/内容基础管理、内容审核(含批量)、提现审核、报表概览与导出、用户钱包/通知/优惠券/实名/充值记录视图、创作者申请/成员审核/邀请、优惠券创建/编辑/发放/冻结/发放记录。
- **部分落地**平台概览(缺内容统计/趋势、退款率/漏斗)、租户详情(缺财务/报表聚合)、内容治理(缺评论/举报)、创作者治理(缺提现审核联动与结算账户审批流)、财务(缺钱包流水/异常排查)。
- **已落地**:登录、租户/用户/订单/内容基础管理、内容审核(含批量)、平台概览(内容趋势/退款率/漏斗)、提现审核、报表概览与导出、用户钱包/通知/优惠券/实名/充值记录视图、创作者申请/成员审核/邀请、优惠券创建/编辑/发放/冻结/发放记录。
- **部分落地**:租户详情(缺财务/报表聚合)、内容治理(缺评论/举报)、创作者治理(缺提现审核联动与结算账户审批流)、财务(缺钱包流水/异常排查)、报表(缺提现/内容深度指标与钻取)
- **未落地**:资产治理、通知中心、审计与系统配置类能力。
## 2) 按页面完成度(对照 2.x
@@ -16,9 +16,9 @@
- 缺口:无显著功能缺口。
### 2.2 平台概览 `/`
- 状态:**部分完成**
- 已有:用户统计、订单统计、租户总数(来自租户列表分页)
- 缺口:平台级内容总量、趋势图、退款率/订单漏斗等运营指标未落地
- 状态:**完成**
- 已有:用户统计、订单统计、租户总数、内容总量与趋势、退款率与订单漏斗
- 缺口:无显著功能缺口
### 2.3 租户管理 `/superadmin/tenants`
- 状态:**已完成**
@@ -87,7 +87,7 @@
## 4) 建议的下一步(按优先级)
1. **平台概览增强**:补齐内容总量与趋势、退款率、订单漏斗等核心指标
2. **资产与通知中心**:补齐资产治理与通知中心接口/页面,形成治理闭环
3. **用户互动明细**:补齐收藏/点赞/关注等互动明细视图与聚合能力
4. **优惠券异常核查**:完善发放/核销异常监测与风控处理流程
1. **资产与通知中心**:补齐资产治理与通知中心接口/页面,形成治理闭环
2. **用户互动明细**:补齐收藏/点赞/关注等互动明细视图与聚合能力
3. **优惠券异常核查**:完善发放/核销异常监测与风控处理流程
4. **报表深化**:补齐提现/内容维度指标与多维钻取能力

View File

@@ -130,5 +130,21 @@ export const ContentService = {
reason
}
});
},
async getContentStatistics({ tenant_id, start_at, end_at, granularity } = {}) {
const iso = (d) => {
if (!d) return undefined;
const date = d instanceof Date ? d : new Date(d);
if (Number.isNaN(date.getTime())) return undefined;
return date.toISOString();
};
const query = {
tenant_id,
start_at: iso(start_at),
end_at: iso(end_at),
granularity
};
return requestJson('/super/v1/contents/statistics', { query });
}
};

View File

@@ -1,12 +1,16 @@
<script setup>
import StatisticsStrip from '@/components/StatisticsStrip.vue';
import { useLayout } from '@/layout/composables/layout';
import { ContentService } from '@/service/ContentService';
import { OrderService } from '@/service/OrderService';
import { ReportService } from '@/service/ReportService';
import { TenantService } from '@/service/TenantService';
import { UserService } from '@/service/UserService';
import { useToast } from 'primevue/usetoast';
import { computed, onMounted, ref } from 'vue';
import { computed, onMounted, ref, watch } from 'vue';
const toast = useToast();
const { getPrimary, getSurface, isDarkTheme } = useLayout();
const tenantTotal = ref(null);
const tenantLoading = ref(false);
@@ -17,12 +21,25 @@ const statisticsLoading = ref(false);
const orderStats = ref(null);
const orderStatsLoading = ref(false);
const contentStats = ref(null);
const contentStatsLoading = ref(false);
const contentTrendData = ref(null);
const contentTrendOptions = ref(null);
const reportOverview = ref(null);
const reportLoading = ref(false);
function formatCny(amountInCents) {
const amount = Number(amountInCents) / 100;
if (!Number.isFinite(amount)) return '-';
return new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY' }).format(amount);
}
function formatPercent(rate) {
if (!Number.isFinite(rate)) return '-';
return `${(rate * 100).toFixed(2)}%`;
}
const statisticsItems = computed(() => {
const total = (statistics.value || []).reduce((sum, row) => sum + (Number(row?.count) || 0), 0);
const statusIcon = (status) => {
@@ -89,6 +106,103 @@ const orderItems = computed(() => {
];
});
const orderFunnelItems = computed(() => {
const byStatus = new Map((orderStats.value?.by_status || []).map((row) => [row?.status, row]));
const getCount = (status) => byStatus.get(status)?.count ?? 0;
return [
{ key: 'orders-created', label: '创建:', value: orderStatsLoading.value ? '-' : getCount('created'), icon: 'pi-file' },
{ key: 'orders-paid-funnel', label: '已支付:', value: orderStatsLoading.value ? '-' : getCount('paid'), icon: 'pi-check-circle' },
{ key: 'orders-refunding-funnel', label: '退款中:', value: orderStatsLoading.value ? '-' : getCount('refunding'), icon: 'pi-spin pi-spinner' },
{ key: 'orders-refunded-funnel', label: '已退款:', value: orderStatsLoading.value ? '-' : getCount('refunded'), icon: 'pi-undo' }
];
});
const contentItems = computed(() => {
const trend = contentStats.value?.trend || [];
const recentCreated = trend.reduce((sum, item) => sum + (Number(item?.created_count) || 0), 0);
return [
{ key: 'contents-total', label: '内容总量:', value: contentStatsLoading.value ? '-' : (contentStats.value?.total_count ?? 0), icon: 'pi-book' },
{ key: 'contents-recent', label: '近7日新增', value: contentStatsLoading.value ? '-' : recentCreated, icon: 'pi-chart-line' }
];
});
const refundRateText = computed(() => {
if (reportLoading.value) return '-';
const summary = reportOverview.value?.summary;
const paidOrders = Number(summary?.paid_orders ?? 0);
const refundOrders = Number(summary?.refund_orders ?? 0);
const denominator = paidOrders + refundOrders;
if (!denominator) return '0%';
return formatPercent(refundOrders / denominator);
});
function buildContentChartData(items) {
const documentStyle = getComputedStyle(document.documentElement);
const labels = items.map((item) => item?.date ?? '-');
const counts = items.map((item) => Number(item?.created_count ?? 0));
return {
labels,
datasets: [
{
label: '新增内容',
data: counts,
fill: true,
backgroundColor: documentStyle.getPropertyValue('--p-primary-200'),
borderColor: documentStyle.getPropertyValue('--p-primary-500'),
tension: 0.35
}
]
};
}
function buildChartOptions() {
const documentStyle = getComputedStyle(document.documentElement);
const textColor = documentStyle.getPropertyValue('--text-color');
const textMutedColor = documentStyle.getPropertyValue('--text-color-secondary');
const borderColor = documentStyle.getPropertyValue('--surface-border');
return {
maintainAspectRatio: false,
aspectRatio: 2.2,
plugins: {
legend: {
labels: {
color: textColor
}
}
},
scales: {
x: {
ticks: {
color: textMutedColor
},
grid: {
color: 'transparent',
borderColor: 'transparent'
}
},
y: {
ticks: {
color: textMutedColor
},
grid: {
color: borderColor,
borderColor: 'transparent',
drawTicks: false
}
}
}
};
}
function updateContentChart() {
const items = contentStats.value?.trend ?? [];
contentTrendData.value = buildContentChartData(items);
contentTrendOptions.value = buildChartOptions();
}
async function loadTenantTotal() {
tenantLoading.value = true;
try {
@@ -123,10 +237,39 @@ async function loadOrderStatistics() {
}
}
async function loadContentStatistics() {
contentStatsLoading.value = true;
try {
contentStats.value = await ContentService.getContentStatistics({ granularity: 'day' });
updateContentChart();
} catch (error) {
toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载内容统计信息', life: 4000 });
} finally {
contentStatsLoading.value = false;
}
}
async function loadReportOverview() {
reportLoading.value = true;
try {
reportOverview.value = await ReportService.getOverview({ granularity: 'day' });
} catch (error) {
toast.add({ severity: 'error', summary: '加载失败', detail: error?.message || '无法加载报表指标', life: 4000 });
} finally {
reportLoading.value = false;
}
}
onMounted(() => {
loadTenantTotal();
loadStatistics();
loadOrderStatistics();
loadContentStatistics();
loadReportOverview();
});
watch([getPrimary, getSurface, isDarkTheme], () => {
updateContentChart();
});
</script>
@@ -136,6 +279,32 @@ onMounted(() => {
<StatisticsStrip :items="orderItems" containerClass="card mb-4" />
<StatisticsStrip :items="statisticsItems" containerClass="card mb-4" />
<div class="grid">
<div class="col-12 xl:col-7">
<div class="card">
<div class="flex items-center justify-between mb-4">
<div>
<h4 class="m-0">内容趋势</h4>
<div class="text-sm text-muted-color">近7日</div>
</div>
</div>
<StatisticsStrip :items="contentItems" containerClass="p-0 mb-4" />
<Chart v-if="contentTrendData" type="line" :data="contentTrendData" :options="contentTrendOptions" class="h-72" />
</div>
</div>
<div class="col-12 xl:col-5">
<div class="card">
<div class="flex items-center justify-between mb-4">
<div>
<h4 class="m-0">订单漏斗</h4>
<div class="text-sm text-muted-color">近7日退款率{{ refundRateText }}</div>
</div>
</div>
<StatisticsStrip :items="orderFunnelItems" containerClass="p-0" />
</div>
</div>
</div>
<div class="card">
<div class="flex items-center justify-between mb-4">
<h4 class="m-0">快捷入口</h4>