feat(dashboard): 渠道分布修复(provider 维度) + analytics 9 维度全量真实数据

修复 channelDist SQL 报错(pay_trade 无 channel 列):
- 改为 provider 维度(PayProviderEnum), pay_trade 加 provider 冗余列
- channelDist → providerDist 全面重命名(类/方法/URL/widget 文件)
- 新增 providerEnum.ts + 10 语种 payment/provider/common.json

analytics 9 维度全量真实数据驱动:
- useAnalyticsData 重写为异步加载, Promise.allSettled 并发 8 个 API
- 9 个组件加 loading prop + <a-skeleton>
- overview 卡片基于后端 prev* 字段计算环比
- 修复 trade-overview/trade-trend widget 的 API 签名兼容(对象参数)

i18n 10 语种同步:
- dashboard/workspace.json: channelDist → providerDist
- 新增 payment/provider/common.json × 10 (PayProviderEnum 7 项)
This commit is contained in:
DaxPay Dev
2026-07-19 11:07:56 +08:00
parent 638cd9fe1b
commit 61982b1795
40 changed files with 772 additions and 344 deletions

View File

@@ -0,0 +1,168 @@
import type { Result } from '#/types/web';
import { defHttp } from '#/api/request';
/**
* 工作台/分析页交易聚合统计 API
*
* 对接后端 /admin/dashboard/trade/* 聚合接口, 供 workspace widget 与 analytics 9 维度图表使用。
*
* ## 参数约定
* - **天数模式**: `days` 参数(近 N 天含今天)
* - **区间模式**: `start` + `end` 参数(yyyy-MM-dd, 均包含), 同时传时优先区间模式
*
* 金额字段单位为分, 前端按需转元展示。
*/
export const DashboardTradeApi = {
/**
* 交易概览(今日/昨日快捷 或 自定义区间含上期对比)
* @param date 'today' | 'yesterday' 快捷模式, 仅在未传 start/end 时生效
* @param start yyyy-MM-dd(包含), 与 end 同时传时进入区间模式
* @param end yyyy-MM-dd(包含)
*/
overview(params: {
date?: 'today' | 'yesterday';
end?: string;
start?: string;
}): Promise<Result<TradeOverviewResult>> {
return defHttp.get({ url: '/admin/dashboard/trade/overview', params });
},
/**
* 交易趋势(每日成交金额 + 笔数)
*/
trend(params: { days?: number; end?: string; start?: string }): Promise<Result<TradeTrendItemResult[]>> {
return defHttp.get({ url: '/admin/dashboard/trade/trend', params });
},
/**
* 退款趋势(每日退款金额 + 笔数)
*/
refundTrend(params: { days?: number; end?: string; start?: string }): Promise<Result<RefundTrendItemResult[]>> {
return defHttp.get({ url: '/admin/dashboard/trade/refund-trend', params });
},
/**
* 支付渠道分布(各渠道成交金额 + 笔数)
*/
providerDist(params: { days?: number; end?: string; start?: string }): Promise<Result<ProviderDistItemResult[]>> {
return defHttp.get({ url: '/admin/dashboard/trade/provider-dist', params });
},
/**
* 支付渠道成功率(各渠道 success_count / total_count * 100)
*/
providerSuccess(params: { days?: number; end?: string; start?: string }): Promise<Result<ProviderSuccessItemResult[]>> {
return defHttp.get({ url: '/admin/dashboard/trade/provider-success', params });
},
/**
* 24 小时时段分布(各小时成交金额 + 笔数, 已补齐 0-23)
*/
hourlyDist(params: { days?: number; end?: string; start?: string }): Promise<Result<HourlyDistItemResult[]>> {
return defHttp.get({ url: '/admin/dashboard/trade/hourly-dist', params });
},
/**
* 金额区间分桶(5 档: 0-50/50-200/200-1000/1000-5000/5000+, 已补齐)
*/
amountRange(params: { days?: number; end?: string; start?: string }): Promise<Result<AmountRangeItemResult[]>> {
return defHttp.get({ url: '/admin/dashboard/trade/amount-range', params });
},
/**
* 商户交易额排名
* @param limit 返回前 N 名(默认 10, 上限 50)
*/
merchantRank(params: { days?: number; end?: string; limit?: number; start?: string }): Promise<Result<MerchantRankItemResult[]>> {
return defHttp.get({ url: '/admin/dashboard/trade/merchant-rank', params });
},
};
/** 交易概览结果(金额单位: 分) */
export interface TradeOverviewResult {
// ===== 本期 =====
/** 成交金额(分) */
successAmount?: number;
/** 成交笔数 */
successCount?: number;
/** 退款金额(分) */
refundAmount?: number;
/** 退款笔数 */
refundCount?: number;
/** 总下单笔数(含成功/失败/关闭, 用于成功率分母, 按 create_time 口径) */
totalOrders?: number;
// ===== 上期(用于环比, undefined 表示上期无数据) =====
prevSuccessAmount?: number;
prevSuccessCount?: number;
prevRefundAmount?: number;
prevRefundCount?: number;
prevTotalOrders?: number;
}
/** 交易趋势单项(日期 + 金额 + 笔数) */
export interface TradeTrendItemResult {
/** 日期(yyyy-MM-dd) */
date?: string;
/** 成交金额(分) */
amount?: number;
/** 成交笔数 */
count?: number;
}
/** 退款趋势单项 */
export interface RefundTrendItemResult {
date?: string;
amount?: number;
count?: number;
}
/** 支付渠道分布单项 */
export interface ProviderDistItemResult {
/** 支付渠道编码(如 wechat/alipay/union_pay) */
provider?: string;
/** 成交金额(分) */
amount?: number;
/** 成交笔数 */
count?: number;
}
/** 支付渠道成功率单项 */
export interface ProviderSuccessItemResult {
/** 支付渠道编码 */
provider?: string;
/** 成功率(0-100) */
rate?: number;
}
/** 24 小时时段分布单项 */
export interface HourlyDistItemResult {
/** 小时(0-23) */
hour?: number;
/** 成交金额(分) */
amount?: number;
/** 成交笔数 */
count?: number;
}
/** 金额区间分桶单项 */
export interface AmountRangeItemResult {
/** 区间标签(如 '0-50', '5000+') */
bucket?: string;
/** 笔数 */
count?: number;
}
/** 商户交易额排名单项 */
export interface MerchantRankItemResult {
/** 商户号 */
mchNo?: string;
/** 商户名称 */
merchantName?: string;
/** 成交金额(分) */
amount?: number;
/** 成交笔数 */
orders?: number;
/** 占比百分比(0-100) */
proportion?: number;
}

View File

@@ -1,2 +1,3 @@
export * from './channelEnum';
export * from './productEnum';
export * from './providerEnum';

View File

@@ -0,0 +1,51 @@
/**
* 支付渠道枚举
*
* 对齐后端 PayProviderEnum, 表达付款方使用的钱包或支付渠道(C 端视角), 非支付通道。
* 字典: pay_provider
*/
export enum ProviderEnum {
/** 聚合支付(收银台/一码多付等) */
AGGREGATE_PAY = 'aggregate_pay',
/** 微信(C 端钱包渠道) */
WECHAT = 'wechat',
/** 支付宝 */
ALIPAY = 'alipay',
/** 银联 */
UNION_PAY = 'union_pay',
/** Visa 卡组织 */
VISA = 'visa',
/** 万事达卡组织 */
MASTERCARD = 'mastercard',
/** 抖音支付 */
DOUYIN = 'douyin',
}
/**
* 支付渠道国际化Key映射
*
* 用于支付渠道分布报表等"按用户付款方式统计"的场景,
* 与支付通道 channelEnum(系统对接通道) 区分。
*/
export const providerI18nMap: Record<string, string> = {
[ProviderEnum.AGGREGATE_PAY]: 'payment.provider.common.aggregatePay',
[ProviderEnum.WECHAT]: 'payment.provider.common.wechat',
[ProviderEnum.ALIPAY]: 'payment.provider.common.alipay',
[ProviderEnum.UNION_PAY]: 'payment.provider.common.unionPay',
[ProviderEnum.VISA]: 'payment.provider.common.visa',
[ProviderEnum.MASTERCARD]: 'payment.provider.common.mastercard',
[ProviderEnum.DOUYIN]: 'payment.provider.common.douyin',
};
/**
* 支付渠道默认名称映射(降级用, 正常走 i18n)
*/
export const providerNameMap: Record<string, string> = {
[ProviderEnum.AGGREGATE_PAY]: '聚合支付',
[ProviderEnum.WECHAT]: '微信支付',
[ProviderEnum.ALIPAY]: '支付宝',
[ProviderEnum.UNION_PAY]: '银联',
[ProviderEnum.VISA]: 'Visa',
[ProviderEnum.MASTERCARD]: '万事达',
[ProviderEnum.DOUYIN]: '抖音支付',
};

View File

@@ -18,7 +18,7 @@
"tradeOverview": "Trade Overview",
"payOrder": "Payment Orders",
"tradeTrend": "Trade Trend",
"channelDist": "Payment Channel",
"providerDist": "Payment Method Distribution",
"noticeList": "Notices"
},
"tradeOverview": {
@@ -29,14 +29,6 @@
"refundAmount": "Refund Amount (CNY)",
"refundCount": "Refund Count"
},
"payOrder": {
"developing": "Payment order management in development",
"status": {
"success": "Success",
"fail": "Failed",
"closed": "Closed"
}
},
"tradeTrend": {
"last7days": "7 Days",
"last30days": "30 Days"

View File

@@ -0,0 +1,9 @@
{
"aggregatePay": "Aggregate Pay",
"wechat": "WeChat Pay",
"alipay": "Alipay",
"unionPay": "UnionPay",
"visa": "Visa",
"mastercard": "Mastercard",
"douyin": "Douyin Pay"
}

View File

@@ -18,7 +18,7 @@
"tradeOverview": "Ikhtisar Perdagangan",
"payOrder": "Perintah Pembayaran",
"tradeTrend": "Tren Perdagangan",
"channelDist": "Saluran Pembayaran",
"providerDist": "Distribusi Metode Pembayaran",
"noticeList": "Pemberitahuan"
},
"tradeOverview": {
@@ -29,14 +29,6 @@
"refundAmount": "Jumlah Pengembalian Dana (CNY)",
"refundCount": "Jumlah Pengembalian Dana"
},
"payOrder": {
"developing": "Manajemen pesanan pembayaran dalam pengembangan",
"status": {
"success": "Kesuksesan",
"fail": "Gagal",
"closed": "Tertutup"
}
},
"tradeTrend": {
"last7days": "7 hari",
"last30days": "30 Hari"

View File

@@ -0,0 +1,9 @@
{
"aggregatePay": "Pembayaran Agregat",
"wechat": "Pembayaran WeChat",
"alipay": "Alipay",
"unionPay": "UnionPay",
"visa": "Visa",
"mastercard": "Mastercard",
"douyin": "Pembayaran Douyin"
}

View File

@@ -18,7 +18,7 @@
"tradeOverview": "貿易概要",
"payOrder": "支払い命令",
"tradeTrend": "貿易動向",
"channelDist": "支払いチャネル",
"providerDist": "決済方法分布",
"noticeList": "お知らせ"
},
"tradeOverview": {
@@ -29,14 +29,6 @@
"refundAmount": "払い戻し金額 (CNY)",
"refundCount": "払い戻し回数"
},
"payOrder": {
"developing": "支払い注文管理は開発中",
"status": {
"success": "成功",
"fail": "失敗した",
"closed": "閉店"
}
},
"tradeTrend": {
"last7days": "7日間",
"last30days": "30日"

View File

@@ -0,0 +1,9 @@
{
"aggregatePay": "統合決済",
"wechat": "WeChat Pay",
"alipay": "Alipay",
"unionPay": "UnionPay",
"visa": "Visa",
"mastercard": "Mastercard",
"douyin": "Douyin Pay"
}

View File

@@ -18,7 +18,7 @@
"tradeOverview": "무역개요",
"payOrder": "지불 주문",
"tradeTrend": "무역동향",
"channelDist": "결제 채널",
"providerDist": "결제 수단 분포",
"noticeList": "공지사항"
},
"tradeOverview": {
@@ -29,14 +29,6 @@
"refundAmount": "환불 금액(CNY)",
"refundCount": "환불 횟수"
},
"payOrder": {
"developing": "결제 주문 관리 개발 중",
"status": {
"success": "성공",
"fail": "실패한",
"closed": "닫은"
}
},
"tradeTrend": {
"last7days": "7일",
"last30days": "30일"

View File

@@ -0,0 +1,9 @@
{
"aggregatePay": "통합 결제",
"wechat": "위챗페이",
"alipay": "알리페이",
"unionPay": "유니온페이",
"visa": "Visa",
"mastercard": "Mastercard",
"douyin": "두인페이"
}

View File

@@ -18,7 +18,7 @@
"tradeOverview": "Tinjauan Perdagangan",
"payOrder": "Pesanan Pembayaran",
"tradeTrend": "Trend Perdagangan",
"channelDist": "Saluran Pembayaran",
"providerDist": "Agihan Kaedah Pembayaran",
"noticeList": "Notis"
},
"tradeOverview": {
@@ -29,14 +29,6 @@
"refundAmount": "Amaun Bayaran Balik (CNY)",
"refundCount": "Kiraan Bayaran Balik"
},
"payOrder": {
"developing": "Pengurusan pesanan pembayaran dalam pembangunan",
"status": {
"success": "Kejayaan",
"fail": "gagal",
"closed": "tertutup"
}
},
"tradeTrend": {
"last7days": "7 Hari",
"last30days": "30 Hari"

View File

@@ -0,0 +1,9 @@
{
"aggregatePay": "Bayaran Agregat",
"wechat": "WeChat Pay",
"alipay": "Alipay",
"unionPay": "UnionPay",
"visa": "Visa",
"mastercard": "Mastercard",
"douyin": "Douyin Pay"
}

View File

@@ -18,7 +18,7 @@
"tradeOverview": "ภาพรวมการค้า",
"payOrder": "คำสั่งการชำระเงิน",
"tradeTrend": "แนวโน้มการค้า",
"channelDist": "ช่องทางการชำระเงิน",
"providerDist": "การกระจายวิธีการชำระเงิน",
"noticeList": "ประกาศ"
},
"tradeOverview": {
@@ -29,14 +29,6 @@
"refundAmount": "จำนวนเงินคืน (CNY)",
"refundCount": "จำนวนการคืนเงิน"
},
"payOrder": {
"developing": "การจัดการคำสั่งการชำระเงินอยู่ระหว่างการพัฒนา",
"status": {
"success": "ความสำเร็จ",
"fail": "ล้มเหลว",
"closed": "ปิด"
}
},
"tradeTrend": {
"last7days": "7 วัน",
"last30days": "30 วัน"

View File

@@ -0,0 +1,9 @@
{
"aggregatePay": "ชำระเงินแบบรวม",
"wechat": "WeChat Pay",
"alipay": "Alipay",
"unionPay": "UnionPay",
"visa": "Visa",
"mastercard": "Mastercard",
"douyin": "Douyin Pay"
}

View File

@@ -18,7 +18,7 @@
"tradeOverview": "Tổng quan thương mại",
"payOrder": "Lệnh thanh toán",
"tradeTrend": "Xu hướng thương mại",
"channelDist": "Kênh thanh toán",
"providerDist": "Phân bố phương thức thanh toán",
"noticeList": "Thông báo"
},
"tradeOverview": {
@@ -29,14 +29,6 @@
"refundAmount": "Số tiền hoàn lại (CNY)",
"refundCount": "Số tiền hoàn lại"
},
"payOrder": {
"developing": "Quản lý lệnh thanh toán đang phát triển",
"status": {
"success": "Thành công",
"fail": "Thất bại",
"closed": "Đã đóng"
}
},
"tradeTrend": {
"last7days": "7 ngày",
"last30days": "30 ngày"

View File

@@ -0,0 +1,9 @@
{
"aggregatePay": "Thanh toán tổng hợp",
"wechat": "WeChat Pay",
"alipay": "Alipay",
"unionPay": "UnionPay",
"visa": "Visa",
"mastercard": "Mastercard",
"douyin": "Douyin Pay"
}

View File

@@ -18,7 +18,7 @@
"tradeOverview": "交易概览",
"payOrder": "支付订单",
"tradeTrend": "交易趋势",
"channelDist": "支付渠道",
"providerDist": "支付渠道分布",
"noticeList": "系统公告"
},
"tradeOverview": {
@@ -29,14 +29,6 @@
"refundAmount": "退款金额(元)",
"refundCount": "退款笔数"
},
"payOrder": {
"developing": "支付订单管理开发中",
"status": {
"success": "成功",
"fail": "失败",
"closed": "已关闭"
}
},
"tradeTrend": {
"last7days": "近 7 天",
"last30days": "近 30 天"

View File

@@ -0,0 +1,9 @@
{
"aggregatePay": "聚合支付",
"wechat": "微信支付",
"alipay": "支付宝",
"unionPay": "银联",
"visa": "Visa",
"mastercard": "万事达",
"douyin": "抖音支付"
}

View File

@@ -18,7 +18,7 @@
"tradeOverview": "交易概覽",
"payOrder": "支付訂單",
"tradeTrend": "交易趨勢",
"channelDist": "支付渠道",
"providerDist": "支付渠道分佈",
"noticeList": "系統公告"
},
"tradeOverview": {
@@ -29,14 +29,6 @@
"refundAmount": "退款金額(元)",
"refundCount": "退款筆數"
},
"payOrder": {
"developing": "支付訂單管理開發中",
"status": {
"success": "成功",
"fail": "失敗",
"closed": "已關閉"
}
},
"tradeTrend": {
"last7days": "近 7 天",
"last30days": "近 30 天"

View File

@@ -0,0 +1,9 @@
{
"aggregatePay": "聚合支付",
"wechat": "微信支付",
"alipay": "支付寶",
"unionPay": "銀聯",
"visa": "Visa",
"mastercard": "萬事達",
"douyin": "抖音支付"
}

View File

@@ -18,7 +18,7 @@
"tradeOverview": "交易概覽",
"payOrder": "支付訂單",
"tradeTrend": "交易趨勢",
"channelDist": "支付渠道",
"providerDist": "支付渠道分佈",
"noticeList": "系統公告"
},
"tradeOverview": {
@@ -29,14 +29,6 @@
"refundAmount": "退款金額(元)",
"refundCount": "退款筆數"
},
"payOrder": {
"developing": "支付訂單管理開發中",
"status": {
"success": "成功",
"fail": "失敗",
"closed": "已關閉"
}
},
"tradeTrend": {
"last7days": "近 7 天",
"last30days": "近 30 天"

View File

@@ -0,0 +1,9 @@
{
"aggregatePay": "聚合支付",
"wechat": "微信支付",
"alipay": "支付寶",
"unionPay": "銀聯",
"visa": "Visa",
"mastercard": "萬事達",
"douyin": "抖音支付"
}

View File

@@ -10,11 +10,14 @@
const props = withDefaults(defineProps<Props>(), {
data: undefined,
loading: false,
});
interface Props {
/** 单笔金额区间分桶(用户消费特征) */
data?: AmountRangeItem[];
/** 加载中(显示骨架屏) */
loading?: boolean;
}
const chartRef = ref<EchartsUIType>();
@@ -51,6 +54,7 @@
<a-card variant="borderless" class="!h-full min-h-[320px] !bg-card">
<template #title>{{ $t('dashboard.analytics.amountRange.title') }}</template>
<EchartsUI ref="chartRef" class="h-[320px]" />
<a-skeleton v-if="loading" active :paragraph="{ rows: 6 }" />
<EchartsUI v-else ref="chartRef" class="h-[320px]" />
</a-card>
</template>

View File

@@ -10,11 +10,14 @@
const props = withDefaults(defineProps<Props>(), {
data: undefined,
loading: false,
});
interface Props {
/** 各支付渠道成功率(横向对比,一眼看出渠道质量差异) */
data?: ChannelSuccessItem[];
/** 加载中(显示骨架屏) */
loading?: boolean;
}
const chartRef = ref<EchartsUIType>();
@@ -57,6 +60,7 @@
<a-card variant="borderless" class="!h-full min-h-[320px] !bg-card">
<template #title>{{ $t('dashboard.analytics.channelSuccess.title') }}</template>
<EchartsUI ref="chartRef" class="h-[320px]" />
<a-skeleton v-if="loading" active :paragraph="{ rows: 6 }" />
<EchartsUI v-else ref="chartRef" class="h-[320px]" />
</a-card>
</template>

View File

@@ -10,11 +10,14 @@
const props = withDefaults(defineProps<Props>(), {
data: undefined,
loading: false,
});
interface Props {
/** 各支付通道交易笔数 */
data?: NameValue[];
/** 加载中(显示骨架屏) */
loading?: boolean;
}
const chartRef = ref<EchartsUIType>();
@@ -51,6 +54,7 @@
<a-card variant="borderless" class="!h-full min-h-[320px] !bg-card">
<template #title>{{ $t('dashboard.analytics.channelVolume.title') }}</template>
<EchartsUI ref="chartRef" class="h-[320px]" />
<a-skeleton v-if="loading" active :paragraph="{ rows: 6 }" />
<EchartsUI v-else ref="chartRef" class="h-[320px]" />
</a-card>
</template>

View File

@@ -8,11 +8,14 @@
const props = withDefaults(defineProps<Props>(), {
data: undefined,
loading: false,
});
interface Props {
/** 24 小时交易分布(长度 24 数组,下标即小时) */
data?: number[];
/** 加载中(显示骨架屏) */
loading?: boolean;
}
const chartRef = ref<EchartsUIType>();
@@ -46,6 +49,7 @@
<a-card variant="borderless" class="!h-full min-h-[320px] !bg-card">
<template #title>{{ $t('dashboard.analytics.hourlyDist.title') }}</template>
<EchartsUI ref="chartRef" class="h-[320px]" />
<a-skeleton v-if="loading" active :paragraph="{ rows: 6 }" />
<EchartsUI v-else ref="chartRef" class="h-[320px]" />
</a-card>
</template>

View File

@@ -9,11 +9,14 @@
const props = withDefaults(defineProps<Props>(), {
data: () => [],
loading: false,
});
interface Props {
/** 商户交易额排名 Top10 */
data?: MerchantRankItem[];
/** 加载中(显示骨架屏) */
loading?: boolean;
}
// 表格列定义(排名 / 商户 / 交易额 / 笔数 / 占比)
@@ -45,6 +48,7 @@
<template>
<a-card variant="borderless" class="!bg-card">
<template #title>{{ $t('dashboard.analytics.merchantRank.title') }}</template>
<a-table :columns="columns" :data-source="dataSource" :pagination="false" row-key="merchantName" size="small" />
<a-skeleton v-if="loading" active :paragraph="{ rows: 5 }" />
<a-table v-else :columns="columns" :data-source="dataSource" :pagination="false" row-key="merchantName" size="small" />
</a-card>
</template>

View File

@@ -7,11 +7,14 @@
const props = withDefaults(defineProps<Props>(), {
data: () => [],
loading: false,
});
interface Props {
/** 6 个指标卡片数据(规模 + 质量) */
data?: OverviewStat[];
/** 加载中(显示骨架屏) */
loading?: boolean;
}
// 数值千分位格式化
@@ -22,7 +25,14 @@
<template>
<a-row :gutter="[16, 16]">
<a-col v-for="item in props.data" :key="item.key" :span="4">
<template v-if="loading">
<a-col v-for="i in 6" :key="i" :span="4">
<a-card variant="borderless" class="!bg-card">
<a-skeleton active :paragraph="{ rows: 2 }" />
</a-card>
</a-col>
</template>
<a-col v-for="item in props.data" v-else :key="item.key" :span="4">
<a-card variant="borderless" class="!bg-card">
<div class="text-foreground/60 text-sm">
{{ $t(`dashboard.analytics.overview.${item.key}`) }}
@@ -34,9 +44,10 @@
</div>
<div class="mt-1 flex items-center text-xs">
<!-- 涨跌色遵循中国惯例涨红跌绿西方为涨绿跌红 -->
<span :class="item.chainRatio >= 0 ? 'text-red-500' : 'text-emerald-500'">
<span v-if="item.chainRatio !== null" :class="item.chainRatio >= 0 ? 'text-red-500' : 'text-emerald-500'">
{{ item.chainRatio >= 0 ? '↑' : '↓' }} {{ Math.abs(item.chainRatio) }}%
</span>
<span v-else class="text-foreground/40"></span>
<span class="text-foreground/40 ml-1">{{ $t('dashboard.analytics.overview.chainRatio') }}</span>
</div>
</a-card>

View File

@@ -10,11 +10,14 @@
const props = withDefaults(defineProps<Props>(), {
data: undefined,
loading: false,
});
interface Props {
/** 各支付方式交易额占比 */
data?: NameValue[];
/** 加载中(显示骨架屏) */
loading?: boolean;
}
const chartRef = ref<EchartsUIType>();
@@ -46,6 +49,7 @@
<a-card variant="borderless" class="!h-full min-h-[320px] !bg-card">
<template #title>{{ $t('dashboard.analytics.payMethod.title') }}</template>
<EchartsUI ref="chartRef" class="h-[320px]" />
<a-skeleton v-if="loading" active :paragraph="{ rows: 6 }" />
<EchartsUI v-else ref="chartRef" class="h-[320px]" />
</a-card>
</template>

View File

@@ -8,11 +8,14 @@
const props = withDefaults(defineProps<Props>(), {
data: undefined,
loading: false,
});
interface Props {
/** 退款趋势(日期 + 退款额) */
data?: { amounts: number[]; dates: string[] };
/** 加载中(显示骨架屏) */
loading?: boolean;
}
const chartRef = ref<EchartsUIType>();
@@ -51,6 +54,7 @@
<a-card variant="borderless" class="!h-full min-h-[320px] !bg-card">
<template #title>{{ $t('dashboard.analytics.refundTrend.title') }}</template>
<EchartsUI ref="chartRef" class="h-[320px]" />
<a-skeleton v-if="loading" active :paragraph="{ rows: 6 }" />
<EchartsUI v-else ref="chartRef" class="h-[320px]" />
</a-card>
</template>

View File

@@ -10,11 +10,14 @@
const props = withDefaults(defineProps<Props>(), {
data: undefined,
loading: false,
});
interface Props {
/** 交易趋势序列(日期 + 多度量) */
data?: TrendSeries;
/** 加载中(显示骨架屏) */
loading?: boolean;
}
// 度量切换:交易额 / 交易笔数 / 客单价
@@ -69,6 +72,7 @@
</a-radio-group>
</template>
<EchartsUI ref="chartRef" class="h-[320px]" />
<a-skeleton v-if="loading" active :paragraph="{ rows: 6 }" />
<EchartsUI v-else ref="chartRef" class="h-[320px]" />
</a-card>
</template>

View File

@@ -6,6 +6,11 @@ import { $t } from '@vben/locales';
import dayjs from 'dayjs';
import { DashboardTradeApi } from '#/api/payment/dashboard/trade-dashboard.api';
import { providerI18nMap, providerNameMap } from '#/enums/payment/providerEnum';
import { emptyAnalyticsData } from '../types';
/** 预设范围 → [start, end]YYYY-MM-DD本月取月初到今天未来无数据 */
function computePreset(key: PresetKey): DateRange {
const today = dayjs();
@@ -25,128 +30,47 @@ function computePreset(key: PresetKey): DateRange {
}
}
/** 按 [start, end] 生成日期标签MM-DD含首尾 */
function genDateLabels(start: string, end: string): string[] {
const labels: string[] = [];
let cur = dayjs(start);
const last = dayjs(end);
while (cur.isBefore(last) || cur.isSame(last, 'day')) {
labels.push(cur.format('MM-DD'));
cur = cur.add(1, 'day');
/** 支付渠道编码 → 展示名: 优先 i18n 映射, 无映射时降级原编码 */
function providerLabel(provider?: string): string {
if (!provider) return '-';
const i18nKey = providerI18nMap[provider];
if (i18nKey) {
const text = $t(i18nKey);
if (text && text !== i18nKey) return text;
}
return labels;
return providerNameMap[provider] ?? provider;
}
/** 基于索引的稳定波动函数(同输入同输出,避免每次切换数据跳变) */
function wave(i: number, seed: number): number {
return Math.sin(i * 0.8 + seed) * 0.5 + Math.cos(i * 1.3 + seed) * 0.3;
/** 金额分(后端) → 元(前端展示), 保留整数 */
function fenToYuan(fen?: number): number {
if (!fen || fen <= 0) return 0;
return Math.round(fen / 100);
}
/** 按日期范围构造全量 mock 数据(后端聚合 API 就绪后替换为真实请求) */
function buildData(start: string, end: string): AnalyticsData {
const dates = genDateLabels(start, end);
const n = dates.length;
// 交易额(元):基线 + 上升趋势 + 周期波动
const amounts = dates.map((_, i) => Math.round(50_000 + i * 600 + wave(i, 1) * 12_000));
// 交易笔数
const orders = dates.map((_, i) => Math.round(800 + i * 8 + wave(i, 2) * 120));
// 客单价 = 交易额 / 笔数
const avgAmounts = amounts.map((a, i) => Math.round(a / (orders[i] || 1)));
// 汇总指标
const totalAmount = amounts.reduce((s, v) => s + v, 0);
const totalOrders = orders.reduce((s, v) => s + v, 0);
const refundAmount = Math.round(totalAmount * 0.038);
const avgAmount = Math.round(totalAmount / totalOrders);
// 环比百分比mock随范围稳定
const ratio = (base: number, seed: number) => Math.round((base + wave(n, seed) * 5) * 10) / 10;
// 退款趋势(元)
const refundTrendAmounts = dates.map((_, i) => Math.round(1800 + wave(i, 3) * 600 + i * 20));
// 24h 时段分布:模拟双高峰(午间 12 点、晚间 20 点),凌晨低谷
const hourlyDist = Array.from({ length: 24 }).map((_, h) => {
const noonPeak = Math.exp(-Math.pow((h - 12) / 4, 2)) * 1000;
const eveningPeak = Math.exp(-Math.pow((h - 20) / 3, 2)) * 800;
const noise = wave(h, 4) * 100;
return Math.max(50, Math.round(noonPeak + eveningPeak + noise + 100));
});
return {
overview: [
{ key: 'totalAmount', value: totalAmount, prefix: '¥', chainRatio: ratio(8.5, 1) },
{ key: 'totalOrders', value: totalOrders, chainRatio: ratio(6.2, 2) },
{ key: 'avgAmount', value: avgAmount, prefix: '¥', chainRatio: ratio(2.1, 3) },
{ key: 'successRate', value: 97.6, suffix: '%', chainRatio: ratio(0.3, 4) },
{ key: 'refundAmount', value: refundAmount, prefix: '¥', chainRatio: ratio(-3.4, 5) },
{ key: 'refundRate', value: 3.8, suffix: '%', chainRatio: ratio(-1.2, 6) },
],
tradeTrend: { amounts, avgAmounts, dates, orders },
payMethod: [
{ name: $t('payment.channel.common.wechat'), value: 38 },
{ name: $t('payment.channel.common.alipay'), value: 28 },
{ name: $t('payment.channel.common.unionPay'), value: 18 },
{ name: $t('payment.channel.common.visa'), value: 8 },
{ name: $t('payment.channel.common.mastercard'), value: 5 },
{ name: $t('payment.channel.common.douyin'), value: 3 },
],
channelSuccess: [
{ name: $t('payment.channel.common.alipay'), rate: 99.2 },
{ name: $t('payment.channel.common.wechat'), rate: 98.7 },
{ name: $t('payment.channel.common.unionPay'), rate: 97.5 },
{ name: $t('payment.channel.common.douyin'), rate: 96.3 },
{ name: $t('payment.channel.common.visa'), rate: 95.8 },
{ name: $t('payment.channel.common.mastercard'), rate: 94.6 },
],
hourlyDist,
amountRange: [
{ count: 3200, range: '0-50' },
{ count: 5800, range: '50-200' },
{ count: 4200, range: '200-1000' },
{ count: 1800, range: '1000-5000' },
{ count: 600, range: '5000+' },
],
channelVolume: [
{ name: $t('payment.channel.common.wechat'), value: 45_000 },
{ name: $t('payment.channel.common.alipay'), value: 32_000 },
{ name: $t('payment.channel.common.unionPay'), value: 12_000 },
{ name: $t('payment.channel.common.visa'), value: 8000 },
{ name: $t('payment.channel.common.mastercard'), value: 6000 },
{ name: $t('payment.channel.common.douyin'), value: 3000 },
],
refundTrend: { amounts: refundTrendAmounts, dates },
merchantRank: [
{ amount: 520_000, merchantName: '云端科技有限公司', orders: 12_300, proportion: 18.5 },
{ amount: 438_000, merchantName: '数聚信息科技', orders: 9800, proportion: 15.6 },
{ amount: 362_000, merchantName: '极客电子商务', orders: 8600, proportion: 12.9 },
{ amount: 298_000, merchantName: '智慧零售集团', orders: 7200, proportion: 10.6 },
{ amount: 245_000, merchantName: '新橙科技', orders: 5900, proportion: 8.7 },
{ amount: 198_000, merchantName: '环球贸易有限公司', orders: 4700, proportion: 7.1 },
{ amount: 165_000, merchantName: '蓝海传媒', orders: 3900, proportion: 5.9 },
{ amount: 132_000, merchantName: '星辰网络', orders: 3100, proportion: 4.7 },
{ amount: 98_000, merchantName: '迅捷物流', orders: 2300, proportion: 3.5 },
{ amount: 76_000, merchantName: '橙意生活', orders: 1800, proportion: 2.7 },
],
};
/**
* 环比百分比计算
* @returns null 表示无法计算(prev 缺数据或为 0)
*/
function chainRatio(curr?: number, prev?: number): null | number {
if (curr === undefined || prev === undefined || prev === 0) return null;
return Math.round(((curr - prev) / prev) * 1000) / 10;
}
/**
* 分析页聚合数据异步加载 composable
*
* 按 dateRange 触发 8 个 API 并发加载(Promise.allSettled), 单个失败时该字段保留空,
* 不影响其他图表。chainRatio(环比) 由前端基于后端返回的 curr/prev 字段计算。
*/
export function useAnalyticsData() {
// 当前模式(预设 key 或 'custom' 自定义)
const activePreset = ref<PresetKey>('last7days');
// 自定义日期范围(仅 custom 模式生效)
const customRange = ref<DateRange | undefined>(undefined);
// 是否自定义模式(控制 RangePicker 启用/禁用)
const isCustom = computed(() => activePreset.value === 'custom');
// 选预设时清空自定义范围(避免残留)
watch(activePreset, (key) => {
if (key !== 'custom') customRange.value = undefined;
});
// 真实数据源custom 走自定义范围未选时兜底近7天否则走预设
const dateRange = computed<DateRange>(() => {
if (activePreset.value === 'custom') {
return customRange.value ?? computePreset('last7days');
@@ -154,15 +78,172 @@ export function useAnalyticsData() {
return computePreset(activePreset.value);
});
// 副标题:动态日期摘要(与右侧选择器联动)
const subtitle = computed(() => {
const [start, end] = dateRange.value;
const days = dayjs(end).diff(dayjs(start), 'day') + 1;
return $t('dashboard.analytics.subtitle', { days, end, start });
});
// 按日期范围派生的全量数据
const data = computed(() => buildData(dateRange.value[0], dateRange.value[1]));
const data = ref<AnalyticsData>(emptyAnalyticsData());
const loading = ref(false);
return { activePreset, customRange, data, isCustom, subtitle };
/** 公共参数(start/end 同时传时优先区间模式) */
function commonParams() {
const [start, end] = dateRange.value;
return { end, start };
}
/** 并发加载所有维度, allSettled 保证单个失败不阻塞其他 */
async function load() {
loading.value = true;
try {
const params = commonParams();
const results = await Promise.allSettled([
DashboardTradeApi.overview(params),
DashboardTradeApi.trend(params),
DashboardTradeApi.refundTrend(params),
DashboardTradeApi.providerDist(params),
DashboardTradeApi.providerSuccess(params),
DashboardTradeApi.hourlyDist(params),
DashboardTradeApi.amountRange(params),
DashboardTradeApi.merchantRank({ ...params, limit: 10 }),
]);
// 失败的维度单独记录到控制台(开发期可见, 生产静默)
const labels = ['overview', 'trend', 'refundTrend', 'providerDist', 'providerSuccess', 'hourlyDist', 'amountRange', 'merchantRank'] as const;
results.forEach((r, i) => {
if (r.status === 'rejected') console.warn(`[analytics] ${labels[i]} 加载失败:`, r.reason);
});
const overviewRes = results[0].status === 'fulfilled' ? results[0].value?.data : undefined;
const trendRes = results[1].status === 'fulfilled' ? results[1].value?.data : undefined;
const refundTrendRes = results[2].status === 'fulfilled' ? results[2].value?.data : undefined;
const providerDistRes = results[3].status === 'fulfilled' ? results[3].value?.data : undefined;
const providerSuccessRes = results[4].status === 'fulfilled' ? results[4].value?.data : undefined;
const hourlyDistRes = results[5].status === 'fulfilled' ? results[5].value?.data : undefined;
const amountRangeRes = results[6].status === 'fulfilled' ? results[6].value?.data : undefined;
const merchantRankRes = results[7].status === 'fulfilled' ? results[7].value?.data : undefined;
data.value = buildAnalyticsData({
amountRangeRes,
hourlyDistRes,
merchantRankRes,
overviewRes,
providerDistRes,
providerSuccessRes,
refundTrendRes,
trendRes,
});
} finally {
loading.value = false;
}
}
// dateRange 变化即触发加载
watch(dateRange, load, { immediate: true });
return { activePreset, customRange, data, isCustom, loading, subtitle };
}
/** 将各 API 响应组装为 AnalyticsData */
function buildAnalyticsData(input: {
amountRangeRes?: { bucket?: string; count?: number }[];
hourlyDistRes?: { amount?: number; count?: number; hour?: number }[];
merchantRankRes?: { amount?: number; merchantName?: string; orders?: number; proportion?: number }[];
overviewRes?: {
prevRefundAmount?: number;
prevRefundCount?: number;
prevSuccessAmount?: number;
prevSuccessCount?: number;
prevTotalOrders?: number;
refundAmount?: number;
refundCount?: number;
successAmount?: number;
successCount?: number;
totalOrders?: number;
};
providerDistRes?: { amount?: number; count?: number; provider?: string }[];
providerSuccessRes?: { provider?: string; rate?: number }[];
refundTrendRes?: { amount?: number; count?: number; date?: string }[];
trendRes?: { amount?: number; count?: number; date?: string }[];
}): AnalyticsData {
const { overviewRes, trendRes, refundTrendRes, providerDistRes, providerSuccessRes, hourlyDistRes, amountRangeRes, merchantRankRes } = input;
// ===== overview 6 卡片(含环比) =====
const ov = overviewRes ?? {};
const successAmountYuan = fenToYuan(ov.successAmount);
const refundAmountYuan = fenToYuan(ov.refundAmount);
const successCount = ov.successCount ?? 0;
const totalOrders = ov.totalOrders ?? 0;
const avgAmount = successCount > 0 ? Math.round((successAmountYuan / successCount) * 100) / 100 : 0;
// 成功率口径: success_count / total_orders * 100(分子分母时间基准不同, 见后端 Mapper 注释)
const successRate = totalOrders > 0 ? Math.round((successCount / totalOrders) * 1000) / 10 : 0;
const refundRate = successAmountYuan > 0 ? Math.round((refundAmountYuan / successAmountYuan) * 1000) / 10 : 0;
const overview = [
{ key: 'totalAmount', value: successAmountYuan, prefix: '¥', chainRatio: chainRatio(ov.successAmount, ov.prevSuccessAmount) },
{ key: 'totalOrders', value: successCount, chainRatio: chainRatio(ov.successCount, ov.prevSuccessCount) },
{ key: 'avgAmount', value: avgAmount, prefix: '¥', chainRatio: chainRatio(avgAmount, ov.prevSuccessAmount && ov.prevSuccessCount ? Math.round((ov.prevSuccessAmount / 100 / ov.prevSuccessCount) * 100) / 100 : undefined) },
{ key: 'successRate', value: successRate, suffix: '%', chainRatio: chainRatio(ov.successCount && ov.totalOrders ? successRate : undefined, ov.prevSuccessCount && ov.prevTotalOrders ? Math.round((ov.prevSuccessCount / ov.prevTotalOrders) * 1000) / 10 : undefined) },
{ key: 'refundAmount', value: refundAmountYuan, prefix: '¥', chainRatio: chainRatio(ov.refundAmount, ov.prevRefundAmount) },
{ key: 'refundRate', value: refundRate, suffix: '%', chainRatio: null },
];
// ===== tradeTrend(按日期 + 多度量) =====
// dates 保留 yyyy-MM-dd 原值(与 a-tooltip / 前端期望一致), amounts/orders 取后端原值, avgAmounts 派生
const trendList = trendRes ?? [];
const tradeTrend = {
amounts: trendList.map((i) => fenToYuan(i.amount)),
avgAmounts: trendList.map((i) => {
const yuan = fenToYuan(i.amount);
const cnt = i.count ?? 0;
return cnt > 0 ? Math.round((yuan / cnt) * 100) / 100 : 0;
}),
dates: trendList.map((i) => i.date ?? ''),
orders: trendList.map((i) => i.count ?? 0),
};
// ===== refundTrend(按日期) =====
const refundTrend = {
amounts: (refundTrendRes ?? []).map((i) => fenToYuan(i.amount)),
dates: (refundTrendRes ?? []).map((i) => i.date ?? ''),
};
// ===== payMethod(支付方式占比饼图, 用 provider amount) =====
const payMethod = (providerDistRes ?? []).map((i) => ({
name: providerLabel(i.provider),
value: fenToYuan(i.amount),
}));
// ===== channelVolume(渠道交易量柱状图, 用 provider count) =====
const channelVolume = (providerDistRes ?? []).map((i) => ({
name: providerLabel(i.provider),
value: i.count ?? 0,
}));
// ===== channelSuccess(渠道成功率) =====
const channelSuccess = (providerSuccessRes ?? []).map((i) => ({
name: providerLabel(i.provider),
rate: i.rate ?? 0,
}));
// ===== hourlyDist(24 小时时段, 取 count 数组) =====
// 后端返回 0-23 已补齐, 取 count 字段(原前端约定: 柱状图值)
const hourlyDist = (hourlyDistRes ?? []).map((i) => i.count ?? 0);
// ===== amountRange(金额区间分桶) =====
const amountRange = (amountRangeRes ?? []).map((i) => ({
count: i.count ?? 0,
range: i.bucket ?? '',
}));
// ===== merchantRank =====
const merchantRank = (merchantRankRes ?? []).map((i) => ({
amount: fenToYuan(i.amount),
merchantName: i.merchantName ?? '-',
orders: i.orders ?? 0,
proportion: i.proportion ?? 0,
}));
return { amountRange, channelSuccess, channelVolume, hourlyDist, merchantRank, overview, payMethod, refundTrend, tradeTrend };
}

View File

@@ -14,8 +14,8 @@
defineOptions({ name: 'Analytics' });
// 全局时间范围(预设 + 自定义互斥)+ 派生数据mock后端聚合 API 就绪后替换)
const { activePreset, customRange, data, isCustom, subtitle } = useAnalyticsData();
// 全局时间范围(预设 + 自定义互斥)+ 派生数据(异步加载, 8 个维度并发)
const { activePreset, customRange, data, isCustom, loading, subtitle } = useAnalyticsData();
</script>
<template>
@@ -41,40 +41,40 @@
</div>
<!-- 指标卡片行规模(总额/笔数/客单价) + 质量(成功率/退款额/退款率) -->
<AnalysisOverview :data="data.overview" />
<AnalysisOverview :data="data.overview" :loading="loading" />
<!-- 图表网格核心趋势 洞察三连 深度 明细 -->
<a-row :gutter="[16, 16]" align="stretch" class="!mt-4">
<!-- 第二行交易趋势(多度量) + 支付方式占比 -->
<a-col :span="16">
<AnalysisTradeTrend :data="data.tradeTrend" />
<AnalysisTradeTrend :data="data.tradeTrend" :loading="loading" />
</a-col>
<a-col :span="8">
<AnalysisPayMethod :data="data.payMethod" />
<AnalysisPayMethod :data="data.payMethod" :loading="loading" />
</a-col>
<!-- 第三行洞察三连渠道质量/时段/金额区间 -->
<a-col :span="8">
<AnalysisChannelSuccess :data="data.channelSuccess" />
<AnalysisChannelSuccess :data="data.channelSuccess" :loading="loading" />
</a-col>
<a-col :span="8">
<AnalysisHourlyDist :data="data.hourlyDist" />
<AnalysisHourlyDist :data="data.hourlyDist" :loading="loading" />
</a-col>
<a-col :span="8">
<AnalysisAmountRange :data="data.amountRange" />
<AnalysisAmountRange :data="data.amountRange" :loading="loading" />
</a-col>
<!-- 第四行渠道交易量 + 退款趋势 -->
<a-col :span="12">
<AnalysisChannelVolume :data="data.channelVolume" />
<AnalysisChannelVolume :data="data.channelVolume" :loading="loading" />
</a-col>
<a-col :span="12">
<AnalysisRefundTrend :data="data.refundTrend" />
<AnalysisRefundTrend :data="data.refundTrend" :loading="loading" />
</a-col>
<!-- 第五行商户交易额排名明细 -->
<a-col :span="24">
<AnalysisMerchantRank :data="data.merchantRank" />
<AnalysisMerchantRank :data="data.merchantRank" :loading="loading" />
</a-col>
</a-row>
</div>

View File

@@ -17,8 +17,8 @@ export interface OverviewStat {
prefix?: string;
/** 数值后缀(如 % */
suffix?: string;
/** 环比百分比(正数上升、负数下降) */
chainRatio: number;
/** 环比百分比(正数上升、负数下降); null 表示无上期数据, 不展示 */
chainRatio: null | number;
}
/** 折线图序列数据(日期 + 多度量) */
@@ -59,7 +59,7 @@ export interface MerchantRankItem {
proportion: number;
}
/** 分析页聚合数据(由 useAnalyticsData 按 timeRange 派生 */
/** 分析页聚合数据(由 useAnalyticsData 按 timeRange 异步加载组装 */
export interface AnalyticsData {
overview: OverviewStat[];
tradeTrend: TrendSeries;
@@ -71,3 +71,18 @@ export interface AnalyticsData {
refundTrend: { amounts: number[]; dates: string[] };
merchantRank: MerchantRankItem[];
}
/** 空数据(初次加载前的占位, 所有图表显示空状态) */
export function emptyAnalyticsData(): AnalyticsData {
return {
overview: [],
tradeTrend: { amounts: [], avgAmounts: [], dates: [], orders: [] },
payMethod: [],
channelSuccess: [],
hourlyDist: [],
amountRange: [],
channelVolume: [],
refundTrend: { amounts: [], dates: [] },
merchantRank: [],
};
}

View File

@@ -1,8 +1,8 @@
import type { LayoutItem, WidgetMeta } from './types';
import ChannelDistributionWidget from './widgets/channel-distribution.widget.vue';
import NoticeListWidget from './widgets/notice-list.widget.vue';
import PayOrderListWidget from './widgets/pay-order-list.widget.vue';
import ProviderDistributionWidget from './widgets/provider-distribution.widget.vue';
import QuickEntryWidget from './widgets/quick-entry.widget.vue';
import TradeOverviewWidget from './widgets/trade-overview.widget.vue';
import TradeTrendWidget from './widgets/trade-trend.widget.vue';
@@ -69,9 +69,9 @@ export const WIDGET_REGISTRY: WidgetMeta[] = [
defaultOrder: 6,
},
{
id: 'channel-distribution',
component: ChannelDistributionWidget,
titleKey: 'dashboard.workspace.widget.channelDist',
id: 'provider-distribution',
component: ProviderDistributionWidget,
titleKey: 'dashboard.workspace.widget.providerDist',
icon: 'lucide:pie-chart',
defaultSpan: 8,
defaultOrder: 5,

View File

@@ -1,59 +0,0 @@
<script lang="ts" setup>
import type { DashboardData } from '../types';
import { onMounted, ref } from 'vue';
import { $t } from '@vben/locales';
import { EchartsUI, type EchartsUIType, useEcharts } from '@vben/plugins/echarts';
interface Props {
/** 工作台聚合数据(支付方式分布暂无后端 API保留以统一 widget props 契约) */
data?: DashboardData;
}
defineOptions({ name: 'ChannelDistributionWidget' });
// 支付方式分布暂无后端 API保留 data prop 以统一 widget 渲染契约
withDefaults(defineProps<Props>(), {
data: undefined,
});
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
onMounted(() => {
// Mock 数据:各支付方式占比(百分比)
// 后端支付方式分布 API 就绪后替换为真实请求结果
renderEcharts({
legend: { bottom: 0, orient: 'horizontal' },
series: [
{
label: { formatter: '{d}%', show: true },
radius: ['45%', '70%'],
type: 'pie',
data: [
{ name: $t('payment.channel.common.wechat'), value: 38 },
{ name: $t('payment.channel.common.alipay'), value: 28 },
{ name: $t('payment.channel.common.unionPay'), value: 18 },
{ name: $t('payment.channel.common.visa'), value: 8 },
{ name: $t('payment.channel.common.mastercard'), value: 5 },
{ name: $t('payment.channel.common.douyin'), value: 3 },
],
},
],
tooltip: { trigger: 'item' },
});
});
</script>
<template>
<a-card variant="borderless" class="!h-full min-h-[260px] !bg-card">
<template #title>
<div class="flex items-center gap-2">
<span>{{ $t('dashboard.workspace.widget.channelDist') }}</span>
</div>
</template>
<EchartsUI ref="chartRef" class="h-[280px]" />
</a-card>
</template>

View File

@@ -0,0 +1,92 @@
<script lang="ts" setup>
import type { DashboardData } from '../types';
import { nextTick, onMounted, ref } from 'vue';
import { $t } from '@vben/locales';
import { EchartsUI, type EchartsUIType, useEcharts } from '@vben/plugins/echarts';
import { DashboardTradeApi, type ProviderDistItemResult } from '#/api/payment/dashboard/trade-dashboard.api';
import { providerI18nMap } from '#/enums/payment/providerEnum';
interface Props {
/** 工作台聚合数据(支付渠道分布独立拉数据,保留以统一 widget props 契约) */
data?: DashboardData;
}
defineOptions({ name: 'ProviderDistributionWidget' });
// 支付渠道分布独立拉取数据,不消费聚合统计;保留 data prop 以统一 widget 渲染契约
withDefaults(defineProps<Props>(), {
data: undefined,
});
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
const loading = ref(false);
const distData = ref<ProviderDistItemResult[]>([]);
/** 支付渠道编码 → 展示名: 优先 i18n 映射, 无映射时降级原编码 */
function providerLabel(provider?: string): string {
if (!provider) return '-';
const i18nKey = providerI18nMap[provider];
if (i18nKey) {
const text = $t(i18nKey);
if (text && text !== i18nKey) return text;
}
return provider;
}
/** 拉取近 30 天支付渠道分布 */
async function load() {
loading.value = true;
try {
const res = await DashboardTradeApi.providerDist({ days: 30 });
distData.value = res?.data || [];
} finally {
loading.value = false;
await nextTick();
render();
}
}
/** 渲染饼图 */
function render(): void {
const data = distData.value.map((i) => ({
name: providerLabel(i.provider),
// 金额分转元(仅影响 tooltip 展示, 占比按 value 相对计算)
value: Math.round((i.amount ?? 0) / 100),
}));
renderEcharts({
legend: { bottom: 0, orient: 'horizontal' },
series: [
{
label: { formatter: '{d}%', show: true },
radius: ['45%', '70%'],
type: 'pie',
data,
},
],
tooltip: {
trigger: 'item',
valueFormatter: (val: any) => `¥${Number(val).toLocaleString('en-US')}`,
},
});
}
onMounted(load);
</script>
<template>
<a-card variant="borderless" class="!h-full min-h-[260px] !bg-card">
<template #title>
<div class="flex items-center gap-2">
<span>{{ $t('dashboard.workspace.widget.providerDist') }}</span>
</div>
</template>
<a-skeleton v-if="loading && distData.length === 0" active :paragraph="{ rows: 5 }" />
<EchartsUI v-else ref="chartRef" class="h-[280px]" />
</a-card>
</template>

View File

@@ -1,19 +1,21 @@
<script lang="ts" setup>
import type { DashboardData } from '../types';
import { computed, ref } from 'vue';
import { computed, onMounted, ref, watch } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { $t } from '@vben/locales';
import { DashboardTradeApi, type TradeOverviewResult } from '#/api/payment/dashboard/trade-dashboard.api';
interface Props {
/** 工作台聚合数据(交易概览暂无后端 API,保留以统一 widget props 契约) */
/** 工作台聚合数据(交易概览独立拉数据,保留以统一 widget props 契约) */
data?: DashboardData;
}
defineOptions({ name: 'TradeOverviewWidget' });
// 交易概览暂无后端 API保留 data prop 以统一 widget 渲染契约
// 交易概览独立拉取数据,不消费聚合统计;保留 data prop 以统一 widget 渲染契约
withDefaults(defineProps<Props>(), {
data: undefined,
});
@@ -21,25 +23,43 @@
// 今日 / 昨日 tab 切换
const activeTab = ref<'today' | 'yesterday'>('today');
// Mock 数据:后端交易聚合 API 就绪后替换为真实数据
// 成交金额(元)/ 成交笔数 / 退款金额(元)/ 退款笔数
const mockData = {
today: {
successAmount: '128,560.50',
refundAmount: '1,280.00',
refundCount: 8,
successCount: 326,
},
yesterday: {
successAmount: '112,380.20',
refundAmount: '980.50',
refundCount: 6,
successCount: 289,
},
} as const;
const loading = ref(false);
const overview = ref<TradeOverviewResult>({});
// 当前展示数据(随 tab 切换)
const overview = computed(() => mockData[activeTab.value]);
/** 拉取指定日期的概览统计 */
async function load() {
loading.value = true;
try {
const res = await DashboardTradeApi.overview({ date: activeTab.value });
overview.value = res?.data || {};
} finally {
loading.value = false;
}
}
// tab 切换时重新拉取
watch(activeTab, load);
onMounted(load);
/** 金额分转元(千分位 + 2 位小数), null/undefined 显示 '-' */
function formatAmount(fen?: number): string {
if (fen === null || fen === undefined) return '-';
return (fen / 100).toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
/** 数值千分位格式化 */
function formatCount(count?: number): string {
if (count === null || count === undefined) return '-';
return count.toLocaleString('en-US');
}
const successAmount = computed(() => formatAmount(overview.value.successAmount));
const successCount = computed(() => formatCount(overview.value.successCount));
const refundAmount = computed(() => formatAmount(overview.value.refundAmount));
const refundCount = computed(() => formatCount(overview.value.refundCount));
</script>
<template>
@@ -57,12 +77,13 @@
</a-radio-group>
</template>
<a-skeleton v-if="loading" active :paragraph="{ rows: 3 }" />
<!-- 主指标 + 副指标垂直分布主指标贴顶副指标贴底等高无空白 -->
<div class="flex h-full flex-col justify-between">
<div v-else class="flex h-full flex-col justify-between">
<div>
<p class="text-foreground/60 text-sm">{{ $t('dashboard.workspace.tradeOverview.successAmount') }}</p>
<p class="text-primary mt-1 text-[40px] font-semibold leading-none tabular-nums">
{{ overview.successAmount }}
{{ successAmount }}
</p>
</div>
@@ -70,15 +91,15 @@
<div class="mt-8 flex justify-between">
<div>
<p class="text-foreground/50 text-xs">{{ $t('dashboard.workspace.tradeOverview.successCount') }}</p>
<span class="text-foreground mt-1 block text-lg font-semibold tabular-nums">{{ overview.successCount }}</span>
<span class="text-foreground mt-1 block text-lg font-semibold tabular-nums">{{ successCount }}</span>
</div>
<div>
<p class="text-foreground/50 text-xs">{{ $t('dashboard.workspace.tradeOverview.refundAmount') }}</p>
<span class="text-foreground mt-1 block text-lg font-semibold tabular-nums">{{ overview.refundAmount }}</span>
<span class="text-foreground mt-1 block text-lg font-semibold tabular-nums">{{ refundAmount }}</span>
</div>
<div>
<p class="text-foreground/50 text-xs">{{ $t('dashboard.workspace.tradeOverview.refundCount') }}</p>
<span class="text-foreground mt-1 block text-lg font-semibold tabular-nums">{{ overview.refundCount }}</span>
<span class="text-foreground mt-1 block text-lg font-semibold tabular-nums">{{ refundCount }}</span>
</div>
</div>
</div>

View File

@@ -1,19 +1,21 @@
<script lang="ts" setup>
import type { DashboardData } from '../types';
import { onMounted, ref, watch } from 'vue';
import { nextTick, onMounted, ref, watch } from 'vue';
import { $t } from '@vben/locales';
import { EchartsUI, type EchartsUIType, useEcharts } from '@vben/plugins/echarts';
import { DashboardTradeApi, type TradeTrendItemResult } from '#/api/payment/dashboard/trade-dashboard.api';
interface Props {
/** 工作台聚合数据(交易趋势暂无后端 API,保留以统一 widget props 契约) */
/** 工作台聚合数据(交易趋势独立拉数据,保留以统一 widget props 契约) */
data?: DashboardData;
}
defineOptions({ name: 'TradeTrendWidget' });
// 交易趋势暂无后端 API保留 data prop 以统一 widget 渲染契约
// 交易趋势独立拉取数据,不消费聚合统计;保留 data prop 以统一 widget 渲染契约
withDefaults(defineProps<Props>(), {
data: undefined,
});
@@ -25,38 +27,40 @@
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
// Mock 数据:近 7 天 / 近 30 天交易额(元)
// 后端交易聚合 API 就绪后替换为真实请求结果
const mockSeries: Record<TrendRange, number[]> = {
'7days': [8200, 9320, 9010, 9340, 12_900, 13_300, 13_200],
'30days': [
5200, 6100, 5800, 6700, 7200, 6900, 7800, 8200, 7600, 8400, 9100, 8800, 9600, 10_200, 9800, 10_800, 11_500,
11_200, 12_300, 12_800, 11_900, 13_200, 12_900, 13_800, 14_500, 14_200, 15_300, 15_800, 14_900, 16_200,
],
};
const loading = ref(false);
const trendData = ref<TradeTrendItemResult[]>([]);
// 近 7 天 x 轴标签(周一~周日)
const weekLabels: string[] = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
// 近 30 天 x 轴标签MM-DD从今天倒推
const monthLabels: string[] = Array.from({ length: 30 }).map((_, i) => {
const d = new Date();
d.setDate(d.getDate() - (29 - i));
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
return `${mm}-${dd}`;
});
/** 拉取指定天数的交易趋势 */
async function load() {
loading.value = true;
try {
const days = activeRange.value === '7days' ? 7 : 30;
const res = await DashboardTradeApi.trend({ days });
trendData.value = res?.data || [];
} finally {
loading.value = false;
// 首次从骨架屏切到 EchartsUI 需等 DOM 挂载后再渲染
await nextTick();
render();
}
}
/** 按当前时间跨度渲染折线图 */
/** 按当前数据渲染折线图 */
function render(): void {
const isWeek = activeRange.value === '7days';
// 日期截取 MM-DD 作为 x 轴; 金额分转元
const labels = trendData.value.map((i) => i.date?.slice(5) || '');
const amounts = trendData.value.map((i) => Math.round((i.amount ?? 0) / 100));
renderEcharts({
grid: { bottom: '8%', containLabel: true, left: '3%', right: '4%', top: '10%' },
tooltip: { trigger: 'axis' },
tooltip: {
trigger: 'axis',
valueFormatter: (val: any) => `¥${Number(val).toLocaleString('en-US')}`,
},
xAxis: {
axisLabel: { fontSize: 11 },
boundaryGap: false,
type: 'category',
data: isWeek ? weekLabels : monthLabels,
data: labels,
},
yAxis: {
axisLabel: { fontSize: 11 },
@@ -65,7 +69,7 @@
series: [
{
areaStyle: { opacity: 0.15 },
data: mockSeries[activeRange.value],
data: amounts,
smooth: true,
type: 'line',
},
@@ -73,10 +77,10 @@
});
}
// tab 切换时重渲染
watch(activeRange, render);
// tab 切换时重新拉取
watch(activeRange, load);
onMounted(render);
onMounted(load);
</script>
<template>
@@ -93,6 +97,7 @@
</a-radio-group>
</template>
<EchartsUI ref="chartRef" class="h-[280px]" />
<a-skeleton v-if="loading && trendData.length === 0" active :paragraph="{ rows: 5 }" />
<EchartsUI v-else ref="chartRef" class="h-[280px]" />
</a-card>
</template>