feat(distribution): 新增分销看板

This commit is contained in:
yml2213
2026-10-30 15:51:56 +08:00
parent 090ab02a67
commit d37cbad323
8 changed files with 427 additions and 2 deletions
+81
View File
@@ -0,0 +1,81 @@
# Phase 2 验收报告(分销看板)
> 日期:2026-10-30
> 项目:解忧商行(hfb_jysh)
> 范围:后台分销看板
> 结论:**本切片通过,只读统计页已接真实接口,typecheck/build 通过**
---
## 1. 页面与路由
| 页面 | 路由 | 文件 |
| --- | --- | --- |
| 分销看板 | `/console-jy8k3m/distribution/dashboard` | `frontend/src/features/admin/AdminDistributionDashboardPage.tsx` |
权限:`distribution:site_view`。
## 2. 接口
| 能力 | 接口 | 权限码 |
| --- | --- | --- |
| 站点经营分析 | `GET /api/admin/distribution/sites/:id/analytics` | `distribution:site_view` |
后端返回:
- 今日/累计收号
- 在售商品数
- 今日订单数
- 今日 GMV、今日利润、本月利润
- 站点可用余额
- 今日新增用户、累计注册用户、成交客户数
- 最近 7 天趋势:日期、收号、订单、GMV、利润
结果在 Redis 缓存 60 秒,前端刷新会重新请求。
## 3. 页面能力
- 站点选择器复用 `DistributionSiteSelect`,站点 ID 写入 URL。
- 10 个 KPI 卡片:今日收号、在售商品、今日订单、今日 GMV、今日利润、本月利润、可用余额、今日新增、成交客户、累计用户。
- 金额全部按整数分展示,统一使用 `formatMoney(cents)`。
- 近 7 天趋势:
- 日期、收号、订单、GMV、利润
- 利润横向条形图,按 7 天最大利润归一化
- 窄屏横向滚动,不压缩表格
- 快捷入口:分销订单、分销用户、站点钱包。
- 无权限时停止请求并提示;站点为空时显示统一空态。
## 4. 共用改造
- `frontend/src/features/admin/types.ts`:新增分销分析、趋势点类型。
- `frontend/src/features/admin/api.ts`:新增 `fetchAdminDistributionAnalytics`。
- `frontend/src/app/AppRouter.tsx`:新增 `distribution/dashboard`。
- `frontend/src/layouts/AdminLayout.tsx`:分销菜单新增「分销看板」并置于分组首位。
- `frontend/src/styles/admin-distribution.css`:新增看板网格、趋势列表与利润条样式。
## 5. 验证结果
```text
cd frontend && npx tsc --noEmit 通过
cd frontend && npm run build 通过(入口 525.59 kB,gzip 168.56 kB)
GET /console-jy8k3m/distribution/dashboard 200
GET /api/admin/distribution/sites/1/analytics 200
```
开发库无分销数据,接口返回全 0,并正常返回最近 7 天日期趋势(2026-10-24 至 2026-10-30)。未调用任何写接口。
## 6. 已知限制
1. 趋势窗口固定为最近 7 天,后端暂不支持自定义区间。
2. 统计缓存 60 秒,刚发生的订单/收号可能短暂延迟。
3. 今日口径按服务器时区自然日计算。
4. 不提供导出;需要导出时可另接报表能力。
5. 开发库无真实站点数据,仅验证了零值响应与页面空态,未做真实经营数据视觉回归。
## 7. 下一步建议
1. 分销账号同步入口。
2. 主站支付配置管理页(如主后台仍未迁移)。
3. 真实站点数据下回归看板口径与趋势。
4. 订单结算比例全面 bps 化与真实订单端到端回归。
+1 -1
View File
@@ -395,7 +395,7 @@ hfb_jysh/
### Phase 4:管理后台核心(4–5 周)
> 状态:**进行中(2026-10-30)**。已完成仪表盘、订单、退款、提现、商品、用户、申诉、财务看板、资金出款、运营开支、公告管理、审计日志、系统配置、分销站点、站点钱包、分销订单、结算批次、分销用户、站点公告、分销商品、支付通道、规则版本、二维码池、站点短信与邀请返利;剩余分销看板、账号同步、主站支付配置管理等细分长尾和真实订单端到端回归。
> 状态:**进行中(2026-10-30)**。已完成仪表盘、订单、退款、提现、商品、用户、申诉、财务看板、资金出款、运营开支、公告管理、审计日志、系统配置、分销站点、分销看板、站点钱包、分销订单、结算批次、分销用户、站点公告、分销商品、支付通道、规则版本、二维码池、站点短信与邀请返利;剩余分销账号同步、主站支付配置管理等细分长尾和真实订单端到端回归。
- 后台登录、工作台、订单列表、订单详情。
- 商品列表、商品详情、审核。
+9
View File
@@ -185,6 +185,11 @@ const AdminDistributionWalletPage = lazy(() =>
default: module.AdminDistributionWalletPage,
}))
)
const AdminDistributionDashboardPage = lazy(() =>
import('@/features/admin/AdminDistributionDashboardPage').then(module => ({
default: module.AdminDistributionDashboardPage,
}))
)
const AdminDistributionSMSPage = lazy(() =>
import('@/features/admin/AdminDistributionSMSPage').then(module => ({
default: module.AdminDistributionSMSPage,
@@ -299,6 +304,10 @@ export function AppRouter() {
<Route path="audit-logs" element={<AdminAuditLogsPage />} />
<Route path="system-configs" element={<AdminSystemConfigsPage />} />
<Route path="distribution/sites" element={<AdminDistributionSitesPage />} />
<Route
path="distribution/dashboard"
element={<AdminDistributionDashboardPage />}
/>
<Route path="distribution/orders" element={<AdminDistributionOrdersPage />} />
<Route
path="distribution/settlements"
@@ -0,0 +1,252 @@
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Alert, Button, Card, Space, Tag, Typography } from 'antd'
import {
BarChartOutlined,
ReloadOutlined,
ShoppingCartOutlined,
TeamOutlined,
WalletOutlined,
} from '@ant-design/icons'
import { useNavigate } from 'react-router-dom'
import { fetchAdminDistributionAnalytics } from '@/features/admin/api'
import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader'
import {
DistributionSiteSelect,
useDistributionSiteSelection,
} from '@/features/admin/components/DistributionSiteSelect'
import { useAdminSessionStore } from '@/stores/adminSession'
import { getAdminBasePath } from '@/shared/api/adminClient'
import { formatMoney } from '@/shared/utils/format'
function errorText(error: unknown) {
const response = (error as { response?: { data?: { message?: string } } })?.response
if (response?.data?.message) return response.data.message
return error instanceof Error ? error.message : '请求失败,请稍后重试'
}
function count(value?: number | null) {
return (value ?? 0).toLocaleString('zh-CN')
}
export function AdminDistributionDashboardPage() {
const navigate = useNavigate()
const permissions = useAdminSessionStore(state => state.admin?.permissions)
const canView = Boolean(
permissions?.includes('*') || permissions?.includes('distribution:site_view')
)
const selection = useDistributionSiteSelection()
const siteId = selection.siteId
const analyticsQuery = useQuery({
queryKey: ['admin-distribution-analytics', siteId],
queryFn: () => fetchAdminDistributionAnalytics(siteId as number),
enabled: canView && Boolean(siteId),
})
const data = analyticsQuery.data
const trendDays = data?.trend_days ?? []
const maxProfit = useMemo(
() => Math.max(1, ...trendDays.map(point => point.profit_cent)),
[trendDays]
)
const metrics = [
{
key: 'today_received',
label: '今日收号',
value: count(data?.today_received_count),
hint: `累计收货 ${count(data?.total_received_count)} 件`,
},
{
key: 'active',
label: '在售商品',
value: count(data?.active_listing_count),
hint: '已上架且审核通过',
},
{
key: 'today_orders',
label: '今日订单',
value: count(data?.today_order_count),
hint: '按订单创建时间统计',
},
{
key: 'today_gmv',
label: '今日 GMV',
value: formatMoney(data?.today_gmv_cent),
hint: '按支付快照实付金额',
},
{
key: 'today_profit',
label: '今日利润',
value: formatMoney(data?.today_profit_cent),
hint: '站点参与方利润',
accent: true,
},
{
key: 'month_profit',
label: '本月利润',
value: formatMoney(data?.month_profit_cent),
hint: '自然月累计',
},
{
key: 'balance',
label: '可用余额',
value: formatMoney(data?.available_balance_cent),
hint: '可申请提现',
},
{
key: 'today_users',
label: '今日新增',
value: count(data?.today_user_count),
hint: `累计注册 ${count(data?.total_user_count)} 人`,
},
{
key: 'order_users',
label: '成交客户',
value: count(data?.total_order_user_count),
hint: '已产生有效订单',
},
{
key: 'total_users',
label: '累计用户',
value: count(data?.total_user_count),
hint: '本站注册用户',
},
]
const adminBase = getAdminBasePath()
const go = (path: string) => navigate(`${adminBase}${path}`)
return (
<div className="admin-page">
<AdminPageHeader
eyebrow="JIEYOU OPS · 分销"
title="分销看板"
description="按站点查看收号、订单、GMV、利润与近 7 天趋势。"
meta={
<Space size={8} wrap>
<Tag color="geekblue">近 7 天趋势</Tag>
{selection.site ? <Tag>{selection.site.site_name}</Tag> : null}
{analyticsQuery.isFetching ? <Tag color="processing">正在刷新</Tag> : null}
</Space>
}
extra={
<Space>
<Button icon={<ShoppingCartOutlined />} onClick={() => go('/distribution/orders')}>
分销订单
</Button>
<Button icon={<TeamOutlined />} onClick={() => go('/distribution/users')}>
分销用户
</Button>
<Button icon={<WalletOutlined />} onClick={() => go('/distribution/wallet')}>
站点钱包
</Button>
<Button
icon={<ReloadOutlined />}
loading={analyticsQuery.isFetching}
disabled={!canView}
onClick={() => {
void analyticsQuery.refetch()
void selection.refetch()
}}
>
刷新
</Button>
</Space>
}
/>
{!canView ? (
<Alert
type="warning"
showIcon
className="admin-table-alert"
message="当前账号没有分销站点查看权限"
description="需要 distribution:site_view 权限才能读取分销看板。"
/>
) : (
<DistributionSiteSelect selection={selection} />
)}
{analyticsQuery.isError ? (
<Alert
type="error"
showIcon
className="admin-table-alert"
message="分销看板没有加载出来"
description={errorText(analyticsQuery.error)}
action={
<Button size="small" onClick={() => void analyticsQuery.refetch()}>
重试
</Button>
}
/>
) : null}
<div className="admin-wallet-grid admin-dashboard-grid">
{metrics.map(metric => (
<Card
key={metric.key}
className={`admin-wallet-card${metric.accent ? ' admin-wallet-card--accent' : ''}`}
bordered={false}
loading={analyticsQuery.isPending}
>
<span className="admin-wallet-card__label">{metric.label}</span>
<strong className="admin-wallet-card__value">{metric.value}</strong>
<span className="admin-wallet-card__hint">{metric.hint}</span>
</Card>
))}
</div>
<Card
className="admin-panel-card admin-trend-card"
title={
<Space>
<BarChartOutlined />
<span>近 7 天经营趋势</span>
</Space>
}
extra={
<Typography.Text type="secondary">
收号 / 订单 / GMV / 站点利润
</Typography.Text>
}
>
{trendDays.length === 0 ? (
<Typography.Text type="secondary">
{analyticsQuery.isPending ? '加载中…' : '暂无趋势数据'}
</Typography.Text>
) : (
<div className="admin-trend-scroll">
<div className="admin-trend-row admin-trend-row--head">
<span>日期</span>
<span>收号</span>
<span>订单</span>
<span>GMV</span>
<span>利润</span>
<span>利润趋势</span>
</div>
{trendDays.map(point => (
<div className="admin-trend-row" key={point.date}>
<span className="admin-mono">{point.date.slice(5)}</span>
<span>{count(point.received_count)}</span>
<span>{count(point.order_count)}</span>
<span>{formatMoney(point.gmv_cent)}</span>
<strong>{formatMoney(point.profit_cent)}</strong>
<span className="admin-trend-bar">
<i
style={{
width: `${Math.max(2, Math.round((point.profit_cent / maxProfit) * 100))}%`,
}}
/>
</span>
</div>
))}
</div>
)}
</Card>
</div>
)
}
+8
View File
@@ -13,6 +13,7 @@ import type {
AdminDispute,
AdminDisputePage,
AdminDisputeQuery,
AdminDistributionAnalytics,
AdminDistributionInvitationConfig,
AdminDistributionOrder,
AdminDistributionOrderPage,
@@ -511,6 +512,13 @@ export async function fetchAdminDistributionWallet(id: number | string) {
return data.data
}
export async function fetchAdminDistributionAnalytics(id: number | string) {
const { data } = await adminClient.get<ApiResponse<AdminDistributionAnalytics>>(
`/admin/distribution/sites/${id}/analytics`
)
return data.data
}
export async function applyAdminDistributionWithdrawal(
id: number | string,
payload: ApplyAdminDistributionWithdrawalPayload
+24
View File
@@ -1256,3 +1256,27 @@ export interface AdminPaymentConfigOption {
environment: string
is_default: boolean
}
/** 分销站点看板:金额单位全部为分,趋势固定为最近 7 天。 */
export interface AdminDistributionTrendPoint {
date: string
received_count: number
order_count: number
gmv_cent: number
profit_cent: number
}
export interface AdminDistributionAnalytics {
today_received_count: number
total_received_count: number
active_listing_count: number
today_order_count: number
today_gmv_cent: number
today_profit_cent: number
month_profit_cent: number
available_balance_cent: number
today_user_count: number
total_user_count: number
total_order_user_count: number
trend_days: AdminDistributionTrendPoint[]
}
+2
View File
@@ -98,6 +98,7 @@ const allNavGroups: NavGroup[] = [
label: '分销',
icon: <ShareAltOutlined />,
children: [
{ key: '/distribution/dashboard', label: '分销看板', permission: 'distribution:site_view' },
{ key: '/distribution/sites', label: '站点管理', permission: 'distribution:site_view' },
{ key: '/distribution/orders', label: '分销订单', permission: 'distribution:order_view' },
{
@@ -153,6 +154,7 @@ const sectionMeta: Record<string, { title: string; description: string }> = {
'/finance/operating-expenses': { title: '运营开支', description: '登记与核对日常运营支出。' },
'/withdrawals': { title: '提现审核', description: '审核提现申请与打款确认。' },
'/distribution/sites': { title: '分销站点', description: '管理分站、规则、结算与站点钱包。' },
'/distribution/dashboard': { title: '分销看板', description: '查看站点收号、订单、利润与近 7 天趋势。' },
'/distribution/orders': { title: '分销订单', description: '核对分销订单快照与冷却期释放状态。' },
'/distribution/settlements': { title: '结算批次', description: '生成人工批次并核对、释放站内份额。' },
'/distribution/users': { title: '分销用户', description: '查看站点注册客户、下单客户与消费贡献。' },
+50 -1
View File
@@ -167,7 +167,56 @@
margin-bottom: 14px;
}
/* 二维码池 */
/* 分销看板 */
.admin-dashboard-grid {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
.admin-trend-card {
margin-top: 16px;
}
.admin-trend-scroll {
overflow-x: auto;
}
.admin-trend-row {
display: grid;
grid-template-columns: 80px 70px 70px 130px 130px minmax(160px, 1fr);
align-items: center;
gap: 12px;
min-width: 760px;
padding: 10px 0;
border-bottom: 1px dashed var(--line);
font-size: 13px;
}
.admin-trend-row:last-child {
border-bottom: none;
}
.admin-trend-row--head {
color: var(--ink-muted);
font-size: 11px;
letter-spacing: 0.04em;
}
.admin-trend-bar {
display: block;
height: 8px;
overflow: hidden;
border-radius: 999px;
background: #ece7dc;
}
.admin-trend-bar i {
display: block;
height: 100%;
border-radius: inherit;
background: var(--brand-seal);
}
/* 分销用户 */
.admin-qr-thumb {
border: 1px solid var(--line);
border-radius: 3px;