89fd333702
Add client-logging SDK, expanded event taxonomy, API observability, admin domain events UI, and move SENTRY_DSN to HQ system settings with @sentry/node bootstrap after config preload. Co-authored-by: Cursor <cursoragent@cursor.com>
224 lines
9.1 KiB
TypeScript
224 lines
9.1 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import type { PartnerWeeklyReportResponse } from '@dukang/shared-types';
|
|
import PageHeader from '@dukang/shared-ui/PageHeader';
|
|
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
|
import { fetchPartnerWeeklyReport } from '../lib/weeklyReport';
|
|
import { usePartnerPageView } from '../lib/usePageView';
|
|
|
|
function fmtMoney(n: number) {
|
|
return n.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
|
|
}
|
|
|
|
function fmtGrowth(n: number) {
|
|
const sign = n > 0 ? '+' : '';
|
|
return `${sign}${n}%`;
|
|
}
|
|
|
|
export default function WeeklyReportPage() {
|
|
usePartnerPageView('partner_weekly_report_view');
|
|
const navigate = useNavigate();
|
|
const [selectedStart, setSelectedStart] = useState<string | undefined>();
|
|
const [data, setData] = useState<PartnerWeeklyReportResponse | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState('');
|
|
|
|
const loadWeeklyReport = useCallback(() => {
|
|
setLoading(true);
|
|
setError('');
|
|
return fetchPartnerWeeklyReport(selectedStart)
|
|
.then(setData)
|
|
.catch((e) => {
|
|
setData(null);
|
|
setError(e instanceof Error ? e.message : '加载失败');
|
|
})
|
|
.finally(() => setLoading(false));
|
|
}, [selectedStart]);
|
|
|
|
useEffect(() => {
|
|
void loadWeeklyReport();
|
|
}, [loadWeeklyReport]);
|
|
|
|
const maxDailyGmv = useMemo(() => {
|
|
if (!data?.dailyGmv.length) return 1;
|
|
return Math.max(1, ...data.dailyGmv.map((item) => item.amount));
|
|
}, [data]);
|
|
|
|
const topStores = data?.storeRanking.slice(0, 3) ?? [];
|
|
const summary = data?.summary;
|
|
const growthPositive = (summary?.gmvGrowthPercent ?? 0) >= 0;
|
|
|
|
return (
|
|
<PullToRefresh onRefresh={loadWeeklyReport} className="page-no-tab">
|
|
<PageHeader title="数据周报" onBack={() => navigate('/')} />
|
|
|
|
<main className="partner-weekly-page">
|
|
<section className="partner-weekly-title-section">
|
|
<h2>经营数据周报</h2>
|
|
{data && (
|
|
<div className="partner-weekly-tabs">
|
|
{data.availablePeriods.map((period) => (
|
|
<button
|
|
key={period.startDate}
|
|
type="button"
|
|
className={`partner-weekly-tab${
|
|
period.startDate === data.period.startDate ? ' active' : ''
|
|
}`}
|
|
onClick={() => setSelectedStart(period.startDate)}
|
|
>
|
|
{period.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
{loading && <p className="label-md text-muted partner-weekly-state">加载中…</p>}
|
|
{!loading && error && (
|
|
<p className="label-md text-error partner-weekly-state">{error}</p>
|
|
)}
|
|
|
|
{!loading && !error && data && summary && (
|
|
<>
|
|
<section className="partner-weekly-hero">
|
|
<div className="partner-weekly-hero-top">
|
|
<div>
|
|
<p className="partner-weekly-hero-label">累计成交额 (GMV)</p>
|
|
<p className="partner-weekly-hero-gmv">¥{fmtMoney(summary.gmv)}</p>
|
|
</div>
|
|
<div
|
|
className={`partner-weekly-growth-badge${
|
|
growthPositive ? ' partner-weekly-growth-badge--up' : ' partner-weekly-growth-badge--down'
|
|
}`}
|
|
>
|
|
<span className="material-symbols-outlined">
|
|
{growthPositive ? 'trending_up' : 'trending_down'}
|
|
</span>
|
|
<span>{fmtGrowth(summary.gmvGrowthPercent)}</span>
|
|
</div>
|
|
</div>
|
|
<div className="partner-weekly-hero-grid">
|
|
<div>
|
|
<p className="label-md text-muted">活跃门店</p>
|
|
<p className="partner-weekly-stat-value">
|
|
{summary.activeStoreCount}
|
|
<span className="partner-weekly-stat-sub">/ {summary.totalStoreCount}</span>
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<p className="label-md text-muted">订单总量</p>
|
|
<p className="partner-weekly-stat-value">{summary.orderCount}</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="partner-weekly-card">
|
|
<div className="partner-weekly-card-header">
|
|
<div className="partner-weekly-card-title">
|
|
<span className="material-symbols-outlined partner-weekly-icon-amber">storefront</span>
|
|
<h3 className="headline-md">本周新签约门店</h3>
|
|
</div>
|
|
<p className="partner-weekly-target">
|
|
{summary.newStoreCount}
|
|
<span className="partner-weekly-target-sub">/ {summary.newStoreTarget}</span>
|
|
</p>
|
|
</div>
|
|
<div className="partner-weekly-progress-track">
|
|
<div
|
|
className="partner-weekly-progress-bar"
|
|
style={{ width: `${summary.newStoreProgressPercent}%` }}
|
|
/>
|
|
</div>
|
|
<p className="label-md text-muted partner-weekly-progress-hint">
|
|
已完成本周目标的 {summary.newStoreProgressPercent}%。
|
|
{summary.newStoreCount < summary.newStoreTarget
|
|
? `还差 ${summary.newStoreTarget - summary.newStoreCount} 家!`
|
|
: '目标已达成!'}
|
|
</p>
|
|
</section>
|
|
|
|
<section className="partner-weekly-card">
|
|
<div className="partner-weekly-card-header">
|
|
<h3 className="headline-md">每日成交趋势</h3>
|
|
<span className="label-md text-muted">过去 7 天</span>
|
|
</div>
|
|
<div className="partner-weekly-chart">
|
|
<div className="partner-weekly-chart-bars">
|
|
{data.dailyGmv.map((item) => {
|
|
const height = Math.max(8, Math.round((item.amount / maxDailyGmv) * 100));
|
|
const isMax = item.amount === maxDailyGmv && item.amount > 0;
|
|
return (
|
|
<div
|
|
key={item.date}
|
|
className={`partner-weekly-chart-bar${isMax ? ' partner-weekly-chart-bar--peak' : ''}`}
|
|
style={{ height: `${height}%` }}
|
|
title={`${item.weekdayLabel} ¥${fmtMoney(item.amount)}`}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
<div className="partner-weekly-chart-labels">
|
|
{data.dailyGmv.map((item) => (
|
|
<span key={item.date}>{item.weekdayLabel}</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="partner-weekly-rank-section">
|
|
<div className="partner-weekly-card-header">
|
|
<h3 className="headline-md">门店业绩排行</h3>
|
|
<button
|
|
type="button"
|
|
className="partner-weekly-link"
|
|
onClick={() => navigate('/stores')}
|
|
>
|
|
查看全部
|
|
<span className="material-symbols-outlined">arrow_forward</span>
|
|
</button>
|
|
</div>
|
|
{topStores.length === 0 ? (
|
|
<p className="label-md text-muted partner-weekly-empty">本周暂无核销排行数据</p>
|
|
) : (
|
|
<div className="partner-weekly-rank-list">
|
|
{topStores.map((store) => (
|
|
<div
|
|
key={store.storeId}
|
|
className={`partner-weekly-rank-row${
|
|
store.rank === 1 ? ' partner-weekly-rank-row--first' : ''
|
|
}`}
|
|
>
|
|
<div className="partner-weekly-rank-badge">
|
|
{store.rank === 1 ? (
|
|
<span className="material-symbols-outlined">workspace_premium</span>
|
|
) : (
|
|
<span>{store.rank}</span>
|
|
)}
|
|
</div>
|
|
<div className="partner-weekly-rank-info">
|
|
<p className="partner-weekly-rank-name">{store.name}</p>
|
|
{store.subtitle && (
|
|
<p className="label-md text-muted">{store.subtitle}</p>
|
|
)}
|
|
</div>
|
|
<p className="partner-weekly-rank-amount">¥{fmtMoney(store.redeemAmount)}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
<section className="partner-weekly-insight">
|
|
<span className="material-symbols-outlined">tips_and_updates</span>
|
|
<div>
|
|
<p className="headline-md">合伙人经营策略</p>
|
|
<p className="body-md text-muted">{data.insight}</p>
|
|
</div>
|
|
</section>
|
|
</>
|
|
)}
|
|
</main>
|
|
</PullToRefresh>
|
|
);
|
|
}
|