710 lines
40 KiB
TypeScript
710 lines
40 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import {
|
|
Mic2,
|
|
Disc3,
|
|
Users,
|
|
Activity,
|
|
BarChart3,
|
|
PieChart,
|
|
TrendingUp,
|
|
ArrowRight,
|
|
Headphones,
|
|
ShoppingCart,
|
|
CreditCard,
|
|
RefreshCw,
|
|
UserCheck,
|
|
UserX,
|
|
Filter,
|
|
Crown,
|
|
Coins
|
|
} from 'lucide-react';
|
|
import { motion } from 'framer-motion';
|
|
import { AnimatedCounter } from '@/components/AnimatedCounter.tsx';
|
|
import {
|
|
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend
|
|
} from 'recharts';
|
|
import {
|
|
getListeningTrendApi,
|
|
getSubscriptionTrendApi,
|
|
getRenewalTrendApi,
|
|
getSubscriberStatsApi,
|
|
getContentQualityApi,
|
|
getBusinessConversionApi,
|
|
getPreferenceApi,
|
|
getUserStickinessApi,
|
|
getChannelListApi,
|
|
getProgramListApi,
|
|
getVipStatsApi
|
|
} from '@/api/radio.ts';
|
|
import type { RadioChannel } from '@/types/radio.ts';
|
|
|
|
|
|
// ─── Color Palette ───
|
|
const COLORS = {
|
|
listen: '#0f172a', // Slate-900 (primary)
|
|
sub: '#3b82f6', // Blue-500
|
|
renew: '#10b981', // Emerald-500
|
|
muted: '#9ca3af', // Gray-400
|
|
chart: ['#0f172a', '#3b82f6', '#10b981', '#f59e0b', '#6366f1'],
|
|
};
|
|
|
|
export default function Dashboard() {
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
// Filter states
|
|
const [startDate, setStartDate] = useState('');
|
|
const [endDate, setEndDate] = useState('');
|
|
const [channelId, setChannelId] = useState('');
|
|
const [channelOptions, setChannelOptions] = useState<RadioChannel[]>([]);
|
|
|
|
// Core stats
|
|
const [statsData, setStatsData] = useState<{
|
|
activeSubscribers: number;
|
|
totalSubscribers: number;
|
|
expiredSubscribers: number;
|
|
channelCount: number;
|
|
programCount: number;
|
|
}>({
|
|
activeSubscribers: 0,
|
|
totalSubscribers: 0,
|
|
expiredSubscribers: 0,
|
|
channelCount: 0,
|
|
programCount: 0,
|
|
});
|
|
|
|
const [vipStats, setVipStats] = useState<{
|
|
activeVipUsers: number;
|
|
vipRevenue: number;
|
|
newVipOrders: number;
|
|
}>({
|
|
activeVipUsers: 0,
|
|
vipRevenue: 0,
|
|
newVipOrders: 0,
|
|
});
|
|
|
|
// Unified Trend Data
|
|
const [trendData, setTrendData] = useState<{ date: string; listen: number; sub: number; renew: number }[]>([]);
|
|
|
|
// Quality / Preference / Funnel / Retention
|
|
const [qualityData, setQualityData] = useState<{ programId: string; title: string; playCount: number; avgCompletion: number }[]>([]);
|
|
const [preferenceData, setPreferenceData] = useState<{ categoryId: string; categoryName: string; revenue: number; share: number }[]>([]);
|
|
const [funnelData, setFunnelData] = useState<{ listenUsers: number; orderUsers: number; payUsers: number; ltv: number } | null>(null);
|
|
const [retentionData, setRetentionData] = useState<{ date: string; newUsers: number; retention: number[] }[]>([]);
|
|
const [subscriberTrend, setSubscriberTrend] = useState<{ date: string; count: number }[]>([]);
|
|
|
|
// Initial load: Fetch channels for filter
|
|
useEffect(() => {
|
|
getChannelListApi({ pageSize: 100 })
|
|
.then(res => {
|
|
if (res && res.list) setChannelOptions(res.list);
|
|
})
|
|
.catch(console.error);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fetchDashboardData();
|
|
fetchTrendData();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [startDate, endDate, channelId]);
|
|
|
|
const fetchDashboardData = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const commonParams = {
|
|
...(startDate ? { startDate } : {}),
|
|
...(endDate ? { endDate } : {}),
|
|
...(channelId ? { channelId } : {}),
|
|
};
|
|
const dateOnlyParams = {
|
|
...(startDate ? { startDate } : {}),
|
|
...(endDate ? { endDate } : {}),
|
|
};
|
|
const channelOnlyParams = {
|
|
...(channelId ? { channelId } : {}),
|
|
};
|
|
|
|
const [subStats, conversion, preference, channels, programs, quality, stickiness, vipData] =
|
|
await Promise.all([
|
|
getSubscriberStatsApi(commonParams),
|
|
getBusinessConversionApi(commonParams),
|
|
getPreferenceApi(), // NO PARAMS
|
|
getChannelListApi({ pageSize: 1 }),
|
|
getProgramListApi({ pageSize: 1 }),
|
|
getContentQualityApi(channelOnlyParams),
|
|
getUserStickinessApi(dateOnlyParams),
|
|
getVipStatsApi(dateOnlyParams)
|
|
]);
|
|
|
|
setStatsData({
|
|
activeSubscribers: subStats.activeSubscribers || 0,
|
|
totalSubscribers: subStats.totalSubscribers || 0,
|
|
expiredSubscribers: subStats.expiredSubscribers || 0,
|
|
channelCount: channels.total || 0,
|
|
programCount: programs.total || 0,
|
|
});
|
|
setSubscriberTrend(subStats.activeTrend || []);
|
|
setFunnelData(conversion);
|
|
setPreferenceData(preference.list || []);
|
|
setQualityData(quality || []);
|
|
setRetentionData(stickiness || []);
|
|
setVipStats({
|
|
activeVipUsers: vipData.activeVipUsers || 0,
|
|
vipRevenue: vipData.vipRevenue || 0,
|
|
newVipOrders: vipData.newVipOrders || 0,
|
|
});
|
|
} catch (error) {
|
|
console.error('Fetch dashboard error:', error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const fetchTrendData = async () => {
|
|
try {
|
|
const commonParams = {
|
|
...(startDate ? { startDate } : {}),
|
|
...(endDate ? { endDate } : {}),
|
|
...(channelId ? { channelId } : {}),
|
|
};
|
|
|
|
const [listenRes, subRes, renewRes] = await Promise.all([
|
|
getListeningTrendApi(commonParams),
|
|
getSubscriptionTrendApi(commonParams),
|
|
getRenewalTrendApi(commonParams)
|
|
]);
|
|
|
|
// Merge trend arrays grouping by date
|
|
const mergedMap = new Map<string, { date: string; listen: number; sub: number; renew: number }>();
|
|
|
|
const addData = (arr: { date: string; count: number }[], key: 'listen' | 'sub' | 'renew') => {
|
|
if (!arr) return;
|
|
arr.forEach(item => {
|
|
const existing = mergedMap.get(item.date) || { date: item.date, listen: 0, sub: 0, renew: 0 };
|
|
existing[key] = item.count;
|
|
mergedMap.set(item.date, existing);
|
|
});
|
|
};
|
|
|
|
addData(listenRes.trend, 'listen');
|
|
addData(subRes.trend, 'sub');
|
|
addData(renewRes.trend, 'renew');
|
|
|
|
const finalTrend = Array.from(mergedMap.values()).sort((a, b) => a.date.localeCompare(b.date));
|
|
setTrendData(finalTrend);
|
|
} catch (error) {
|
|
console.error('Fetch trend error:', error);
|
|
}
|
|
};
|
|
|
|
// ─── Funnel steps ───
|
|
const funnelSteps = funnelData
|
|
? [
|
|
{ label: '活跃收听', value: funnelData.listenUsers || 0, icon: Headphones, color: COLORS.chart[0] },
|
|
{ label: '尝试下单', value: funnelData.orderUsers || 0, icon: ShoppingCart, color: COLORS.chart[1] },
|
|
{ label: '支付成功', value: funnelData.payUsers || 0, icon: CreditCard, color: COLORS.chart[2] },
|
|
]
|
|
: [];
|
|
|
|
const funnelMax = funnelSteps.length > 0 ? Math.max(...funnelSteps.map((s) => s.value), 1) : 1;
|
|
|
|
// ─── Stats cards ───
|
|
const stats = [
|
|
{
|
|
name: '有效订阅',
|
|
value: statsData.activeSubscribers,
|
|
icon: UserCheck,
|
|
subLabel: '当前活跃付费用户',
|
|
color: COLORS.sub,
|
|
trend: statsData.totalSubscribers > 0
|
|
? `共 ${statsData.totalSubscribers} 人`
|
|
: undefined,
|
|
},
|
|
{
|
|
name: '已过期订阅',
|
|
value: statsData.expiredSubscribers,
|
|
icon: UserX,
|
|
subLabel: '可触达召回用户',
|
|
color: COLORS.muted,
|
|
},
|
|
{
|
|
name: '活跃频道',
|
|
value: statsData.channelCount,
|
|
icon: Mic2,
|
|
subLabel: '在线广播节点',
|
|
color: COLORS.listen,
|
|
},
|
|
{
|
|
name: '节目总数',
|
|
value: statsData.programCount,
|
|
icon: Disc3,
|
|
subLabel: '内容资产储备',
|
|
color: COLORS.renew,
|
|
},
|
|
{
|
|
name: 'VIP 用户',
|
|
value: vipStats.activeVipUsers,
|
|
icon: Crown,
|
|
subLabel: '当前生效的尊享会员',
|
|
color: '#f59e0b',
|
|
trend: vipStats.newVipOrders > 0 ? `+${vipStats.newVipOrders} 订单` : undefined,
|
|
},
|
|
{
|
|
name: 'VIP 营收',
|
|
value: `¥${(vipStats.vipRevenue / 100).toFixed(2)}`,
|
|
icon: Coins,
|
|
subLabel: '区间内会员特权变现',
|
|
color: '#0f172a',
|
|
},
|
|
];
|
|
|
|
// ─── Formatted date helper ───
|
|
// Handles format "2026-03-07T00:00:00+08:00" -> "03-07"
|
|
const fmtDate = (val: string) => {
|
|
if (!val) return '';
|
|
const justDate = val.split('T')[0];
|
|
return justDate.split('-').slice(1).join('-');
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-8 pb-20 overflow-x-hidden min-h-screen text-gray-900 font-sans relative">
|
|
|
|
{/* ═══ Header ═══ */}
|
|
<header className="relative z-10 flex flex-col xl:flex-row xl:items-end justify-between gap-6 pb-6 border-b border-border">
|
|
<div>
|
|
<h1 className="text-3xl md:text-4xl font-bold tracking-tight">电台数据概览</h1>
|
|
<p className="text-sm text-muted-foreground mt-1">实时监控频道运营、内容表现与商业转化</p>
|
|
</div>
|
|
|
|
{/* ═══ Filters ═══ */}
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<div className="flex items-center bg-card border border-border rounded-xl px-3 py-1.5 shadow-sm focus-within:ring-2 ring-primary/20 transition-all">
|
|
<span className="text-[13px] font-medium text-muted-foreground mr-2 whitespace-nowrap">日期:</span>
|
|
<input
|
|
type="date"
|
|
value={startDate}
|
|
onChange={(e) => setStartDate(e.target.value)}
|
|
className="bg-transparent text-[13px] font-medium text-foreground outline-none border-none custom-date-input"
|
|
/>
|
|
<span className="text-muted-foreground mx-1 text-xs">-</span>
|
|
<input
|
|
type="date"
|
|
value={endDate}
|
|
onChange={(e) => setEndDate(e.target.value)}
|
|
className="bg-transparent text-[13px] font-medium text-foreground outline-none border-none custom-date-input"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center bg-card border border-border rounded-xl px-3 py-1.5 shadow-sm focus-within:ring-2 ring-primary/20 transition-all">
|
|
<span className="text-[13px] font-medium text-muted-foreground mr-2 whitespace-nowrap">频道:</span>
|
|
<select
|
|
value={channelId}
|
|
onChange={(e) => setChannelId(e.target.value)}
|
|
className="bg-transparent text-[13px] font-medium text-foreground outline-none border-none appearance-none pr-6 relative cursor-pointer min-w-[100px]"
|
|
style={{ backgroundImage: `url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e")`, backgroundRepeat: 'no-repeat', backgroundPosition: 'right center', backgroundSize: '14px' }}
|
|
>
|
|
<option value="">全部频道</option>
|
|
{channelOptions.map(ch => (
|
|
<option key={ch.id} value={ch.id}>{ch.name || `Channel ${ch.id}`}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<motion.button
|
|
whileTap={{ scale: 0.95 }}
|
|
onClick={() => { setStartDate(''); setEndDate(''); setChannelId(''); }}
|
|
className="p-1.5 rounded-lg bg-muted border border-border shadow-sm hover:bg-muted/80 transition-colors flex items-center justify-center h-8 w-8"
|
|
title="重置过滤"
|
|
>
|
|
<Filter className="w-4 h-4 text-muted-foreground" />
|
|
</motion.button>
|
|
|
|
<motion.button
|
|
whileHover={{ rotate: 180 }}
|
|
transition={{ duration: 0.4 }}
|
|
onClick={() => { fetchDashboardData(); fetchTrendData(); }}
|
|
className="p-1.5 rounded-lg bg-card border border-border shadow-sm hover:bg-muted transition-colors flex items-center justify-center h-8 w-8"
|
|
title="刷新数据"
|
|
>
|
|
<RefreshCw className="w-4 h-4 text-muted-foreground" />
|
|
</motion.button>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
{/* ═══ Stats Overview ═══ */}
|
|
<section className="relative z-10 grid gap-5 grid-cols-2 md:grid-cols-3 xl:grid-cols-6">
|
|
{stats.map((stat, idx) => (
|
|
<motion.div
|
|
key={stat.name}
|
|
initial={{ opacity: 0, y: 16 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: idx * 0.08 }}
|
|
className="bg-white rounded-[2rem] p-6 border border-gray-100/50 shadow-soft shadow-hover-spring flex flex-col gap-3 group relative overflow-hidden"
|
|
>
|
|
<div className="flex justify-between items-start">
|
|
<p className="text-xs font-semibold text-muted-foreground">{stat.name}</p>
|
|
<div className="p-2 rounded-lg bg-muted/60 border border-border group-hover:scale-110 transition-transform">
|
|
<stat.icon className="w-4 h-4" style={{ color: stat.color }} />
|
|
</div>
|
|
</div>
|
|
<p className="text-2xl md:text-3xl font-bold text-gray-900 leading-none">
|
|
{loading ? '—' : (
|
|
<AnimatedCounter
|
|
value={stat.value}
|
|
format={typeof stat.value === 'string' && stat.value.startsWith('¥') ? 'currency' : 'number'}
|
|
/>
|
|
)}
|
|
</p>
|
|
<div className="flex justify-between items-center">
|
|
<span className="text-[11px] text-muted-foreground">{stat.subLabel}</span>
|
|
{stat.trend && (
|
|
<span className="text-[10px] font-semibold text-muted-foreground bg-muted px-2 py-0.5 rounded-full">
|
|
{stat.trend}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
))}
|
|
</section>
|
|
|
|
{/* ═══ Main Trend Chart + Sidebar ═══ */}
|
|
<section className="relative z-10 grid grid-cols-1 xl:grid-cols-12 gap-6">
|
|
{/* Multiplex Trend Chart */}
|
|
<motion.div
|
|
className="xl:col-span-8"
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
transition={{ duration: 0.5 }}
|
|
>
|
|
<div className="bg-white rounded-[2rem] p-8 border-glass shadow-soft flex flex-col gap-4">
|
|
{/* Chart header */}
|
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
|
<div>
|
|
<h2 className="text-lg font-bold text-foreground">核心运营趋势</h2>
|
|
<p className="text-xs text-muted-foreground mt-0.5">
|
|
收听与订阅多维度对比分析
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Chart area */}
|
|
<div className="w-full h-[320px]">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<AreaChart data={trendData} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
|
|
<defs>
|
|
<linearGradient id="listenGrad" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="5%" stopColor={COLORS.listen} stopOpacity={0.15} />
|
|
<stop offset="95%" stopColor={COLORS.listen} stopOpacity={0} />
|
|
</linearGradient>
|
|
<linearGradient id="subGrad" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="5%" stopColor={COLORS.sub} stopOpacity={0.15} />
|
|
<stop offset="95%" stopColor={COLORS.sub} stopOpacity={0} />
|
|
</linearGradient>
|
|
<linearGradient id="renewGrad" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="5%" stopColor={COLORS.renew} stopOpacity={0.15} />
|
|
<stop offset="95%" stopColor={COLORS.renew} stopOpacity={0} />
|
|
</linearGradient>
|
|
</defs>
|
|
<CartesianGrid strokeDasharray="4 4" vertical={false} stroke="var(--border)" opacity={0.4} />
|
|
<XAxis
|
|
dataKey="date"
|
|
axisLine={false}
|
|
tickLine={false}
|
|
tick={{ fill: 'var(--muted-foreground)', fontSize: 11 }}
|
|
dy={12}
|
|
tickFormatter={fmtDate}
|
|
minTickGap={30}
|
|
/>
|
|
<YAxis hide />
|
|
<Tooltip
|
|
cursor={{ stroke: 'var(--border)', strokeDasharray: '4 4' }}
|
|
content={({ active, payload, label }) =>
|
|
active && payload?.length ? (
|
|
<div className="px-4 py-3 rounded-xl bg-popover border border-border shadow-xl text-xs flex flex-col gap-2 min-w-[140px]">
|
|
<p className="font-semibold text-muted-foreground border-b border-border/50 pb-2">{fmtDate(label as string)} 统计</p>
|
|
{payload.map(p => (
|
|
<div key={p.dataKey} className="flex items-center justify-between">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: p.color }} />
|
|
<span className="text-muted-foreground">{p.name}</span>
|
|
</div>
|
|
<span className="font-bold text-foreground ml-4">{p.value}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : null
|
|
}
|
|
/>
|
|
<Legend iconType="circle" wrapperStyle={{ fontSize: '11px', paddingTop: '10px' }} />
|
|
|
|
<Area type="monotone" dataKey="listen" name="收听量" stroke={COLORS.listen} strokeWidth={2.5} fill="url(#listenGrad)" activeDot={{ r: 5, fill: 'var(--background)', stroke: COLORS.listen, strokeWidth: 2.5 }} />
|
|
<Area type="monotone" dataKey="sub" name="新增订阅" stroke={COLORS.sub} strokeWidth={2.5} fill="url(#subGrad)" activeDot={{ r: 5, fill: 'var(--background)', stroke: COLORS.sub, strokeWidth: 2.5 }} />
|
|
<Area type="monotone" dataKey="renew" name="续费订单" stroke={COLORS.renew} strokeWidth={2.5} fill="url(#renewGrad)" activeDot={{ r: 5, fill: 'var(--background)', stroke: COLORS.renew, strokeWidth: 2.5 }} />
|
|
</AreaChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
|
|
{/* ─── Conversion Funnel Side Card ─── */}
|
|
<motion.div
|
|
className="xl:col-span-4"
|
|
initial={{ opacity: 0, x: 16 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
transition={{ delay: 0.15 }}
|
|
>
|
|
<div className="bg-white rounded-[2rem] p-8 border-glass shadow-soft h-full flex flex-col">
|
|
<div className="flex items-center justify-between mb-6 pb-4 border-b border-border">
|
|
<h3 className="text-sm font-bold text-foreground">商业转化漏斗</h3>
|
|
<TrendingUp className="w-4 h-4 text-muted-foreground" />
|
|
</div>
|
|
|
|
{/* LTV highlight */}
|
|
{funnelData && (
|
|
<div className="mb-6 rounded-2xl bg-gray-50/50 border border-gray-100 p-5 text-center shadow-inner group transition-all duration-300 hover:bg-white hover:shadow-soft">
|
|
<p className="text-xs text-gray-500 mb-2 transition-colors group-hover:text-gray-900">人均生命周期价值 (LTV)</p>
|
|
<p className="text-3xl font-black" style={{ color: COLORS.listen }}>
|
|
<AnimatedCounter value={funnelData.ltv / 100} format="currency" />
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Funnel bars */}
|
|
<div className="space-y-4 flex-1">
|
|
{funnelSteps.map((step, i) => {
|
|
const pct = funnelMax > 0 ? (step.value / funnelMax) * 100 : 0;
|
|
const convRate =
|
|
i > 0 && funnelSteps[i - 1].value > 0
|
|
? ((step.value / funnelSteps[i - 1].value) * 100).toFixed(1)
|
|
: null;
|
|
return (
|
|
<div key={step.label} className="space-y-2">
|
|
<div className="flex justify-between items-center">
|
|
<div className="flex items-center gap-2">
|
|
<step.icon className="w-4 h-4" style={{ color: step.color }} />
|
|
<span className="text-xs font-bold text-foreground">{step.label}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-bold text-gray-900"><AnimatedCounter value={step.value}/></span>
|
|
{convRate && (
|
|
<span className="text-[10px] font-semibold text-gray-500 bg-gray-50 px-2 py-0.5 rounded-full border border-gray-100">
|
|
<AnimatedCounter value={convRate} format="percent" />
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="w-full bg-muted rounded-full h-2.5 overflow-hidden">
|
|
<motion.div
|
|
initial={{ width: 0 }}
|
|
animate={{ width: `${pct}%` }}
|
|
transition={{ duration: 1, delay: 0.2 + i * 0.15 }}
|
|
className="h-full rounded-full"
|
|
style={{ backgroundColor: step.color }}
|
|
/>
|
|
</div>
|
|
{i < funnelSteps.length - 1 && (
|
|
<div className="flex justify-center">
|
|
<ArrowRight className="w-3 h-3 text-border rotate-90" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
</section>
|
|
|
|
{/* ═══ Subscriber Active Trend (mini chart) + Content Quality ═══ */}
|
|
<section className="relative z-10 grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
{/* Mini subscriber trend */}
|
|
<div className="bg-card rounded-2xl p-6 border border-border shadow-sm">
|
|
<div className="flex items-center justify-between mb-4 pb-3 border-b border-border">
|
|
<div className="flex items-center gap-2">
|
|
<Users className="w-4 h-4 text-secondary" />
|
|
<h3 className="text-sm font-bold text-foreground">有效订阅用户趋势</h3>
|
|
</div>
|
|
<span className="text-xs text-muted-foreground">
|
|
当前 <span className="font-bold text-foreground">{statsData.activeSubscribers}</span> 人
|
|
</span>
|
|
</div>
|
|
<div className="w-full h-[200px]">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<AreaChart data={subscriberTrend} margin={{ top: 5, right: 5, left: -25, bottom: 0 }}>
|
|
<defs>
|
|
<linearGradient id="subTrendGrad" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="5%" stopColor={COLORS.sub} stopOpacity={0.2} />
|
|
<stop offset="95%" stopColor={COLORS.sub} stopOpacity={0} />
|
|
</linearGradient>
|
|
</defs>
|
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--border)" opacity={0.3} />
|
|
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fill: 'var(--muted-foreground)', fontSize: 10 }} dy={8} tickFormatter={fmtDate} minTickGap={40} />
|
|
<YAxis hide />
|
|
<Tooltip
|
|
content={({ active, payload, label }) =>
|
|
active && payload?.length ? (
|
|
<div className="px-3 py-2 rounded-lg bg-popover border border-border shadow-md text-xs">
|
|
<p className="text-muted-foreground">{fmtDate(label as string)}</p>
|
|
<p className="font-bold text-foreground" style={{ color: COLORS.sub }}>{payload[0].value} 人</p>
|
|
</div>
|
|
) : null
|
|
}
|
|
/>
|
|
<Area type="monotone" dataKey="count" stroke={COLORS.sub} strokeWidth={2} fill="url(#subTrendGrad)" animationDuration={1000} activeDot={{ r: 4, fill: 'var(--background)', stroke: COLORS.sub, strokeWidth: 2 }} />
|
|
</AreaChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Content Quality */}
|
|
<div className="bg-card rounded-2xl p-6 border border-border shadow-sm">
|
|
<div className="flex items-center justify-between mb-4 pb-3 border-b border-border">
|
|
<div className="flex items-center gap-2">
|
|
<BarChart3 className="w-4 h-4 text-listen" />
|
|
<h3 className="text-sm font-bold text-foreground">内容完播率 TOP5</h3>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-5">
|
|
{qualityData.slice(0, 5).map((item, i) => {
|
|
const pct = (item.avgCompletion * 100);
|
|
return (
|
|
<div key={item.programId} className="space-y-1.5">
|
|
<div className="flex justify-between items-baseline">
|
|
<span className="text-xs font-medium text-foreground truncate pr-4 flex-1">{item.title}</span>
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<span className="text-[11px] text-muted-foreground">{item.playCount} 播放</span>
|
|
<span className="text-xs font-bold" style={{ color: pct >= 60 ? COLORS.sub : COLORS.muted }}>
|
|
{pct.toFixed(1)}%
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="w-full bg-muted rounded-full h-2 overflow-hidden">
|
|
<motion.div
|
|
initial={{ width: 0 }}
|
|
animate={{ width: `${pct}%` }}
|
|
className="h-full rounded-full"
|
|
style={{ backgroundColor: pct >= 60 ? COLORS.sub : COLORS.listen }}
|
|
transition={{ duration: 1, delay: i * 0.08 }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* ═══ Bottom: Preference + Retention ═══ */}
|
|
<section className="relative z-10 grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
{/* Revenue by Category */}
|
|
<div className="bg-card rounded-2xl p-6 border border-border shadow-sm">
|
|
<div className="flex items-center justify-between mb-5 pb-3 border-b border-border">
|
|
<div className="flex items-center gap-2">
|
|
<PieChart className="w-4 h-4 text-primary" />
|
|
<h3 className="text-sm font-bold text-foreground">品类营收结构</h3>
|
|
</div>
|
|
<span className="text-xs text-muted-foreground">全量统计</span>
|
|
</div>
|
|
<div className="space-y-5">
|
|
{preferenceData.slice(0, 5).map((item, idx) => (
|
|
<div key={item.categoryId} className="flex items-center gap-4">
|
|
<div className="w-8 h-8 rounded-lg flex items-center justify-center text-xs font-bold text-white shrink-0"
|
|
style={{ backgroundColor: COLORS.chart[idx % COLORS.chart.length] }}>
|
|
{idx + 1}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex justify-between items-center mb-1">
|
|
<span className="text-sm font-semibold text-foreground truncate">{item.categoryName}</span>
|
|
<span className="text-sm font-bold" style={{ color: COLORS.chart[idx % COLORS.chart.length] }}>
|
|
¥{(item.revenue / 100).toLocaleString()}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex-1 bg-muted rounded-full h-1.5 overflow-hidden">
|
|
<motion.div
|
|
className="h-full rounded-full"
|
|
style={{ backgroundColor: COLORS.chart[idx % COLORS.chart.length] }}
|
|
initial={{ width: 0 }}
|
|
animate={{ width: `${item.share * 100}%` }}
|
|
transition={{ duration: 0.8, delay: 0.2 + idx * 0.08 }}
|
|
/>
|
|
</div>
|
|
<span className="text-[11px] font-bold text-muted-foreground w-12 text-right shrink-0">
|
|
{(item.share * 100).toFixed(1)}%
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* User Retention (real data from getUserStickinessApi) */}
|
|
<div className="bg-card rounded-2xl p-6 border border-border shadow-sm overflow-hidden">
|
|
<div className="flex items-center justify-between mb-5 pb-3 border-b border-border">
|
|
<div className="flex items-center gap-2">
|
|
<Activity className="w-4 h-4 text-secondary" />
|
|
<h3 className="text-sm font-bold text-foreground">用户留存分析</h3>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="w-full overflow-x-auto custom-scrollbar">
|
|
<table className="w-full text-left border-collapse min-w-[420px]">
|
|
<thead>
|
|
<tr>
|
|
<th className="text-[11px] font-bold text-muted-foreground py-2.5 px-2 border-b border-border">日期</th>
|
|
<th className="text-[11px] font-bold text-muted-foreground text-center py-2.5 px-2 border-b border-border">新增</th>
|
|
<th className="text-[11px] font-bold text-muted-foreground text-center py-2.5 px-2 border-b border-border">次日</th>
|
|
<th className="text-[11px] font-bold text-muted-foreground text-center py-2.5 px-2 border-b border-border">3日</th>
|
|
<th className="text-[11px] font-bold text-muted-foreground text-center py-2.5 px-2 border-b border-border">7日</th>
|
|
<th className="text-[11px] font-bold text-muted-foreground text-center py-2.5 px-2 border-b border-border">30日</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-border/40">
|
|
{retentionData.slice(0, 7).map((row) => (
|
|
<tr key={row.date} className="hover:bg-muted/30 transition-colors">
|
|
<td className="py-3 px-2 text-xs font-medium text-muted-foreground whitespace-nowrap">{fmtDate(row.date)}</td>
|
|
<td className="py-3 px-2 text-center text-xs font-bold text-foreground">{row.newUsers}</td>
|
|
{(row.retention || []).map((rate: number, idx: number) => {
|
|
const pct = (rate * 100);
|
|
const bgOpacity = Math.max(0.06, rate * 0.5);
|
|
return (
|
|
<td key={idx} className="py-2 px-1 text-center">
|
|
<div
|
|
className="w-full h-7 rounded-md flex items-center justify-center text-[11px] font-bold transition-colors"
|
|
style={{
|
|
backgroundColor: `rgba(106, 127, 106, ${bgOpacity})`,
|
|
color: pct > 20 ? COLORS.sub : COLORS.muted,
|
|
}}
|
|
>
|
|
{pct.toFixed(0)}%
|
|
</div>
|
|
</td>
|
|
);
|
|
})}
|
|
{/* Fill empty if less than 4 retention values */}
|
|
{(row.retention || []).length < 4 &&
|
|
Array.from({ length: 4 - (row.retention || []).length }).map((_, idx) => (
|
|
<td key={`empty-${idx}`} className="py-2 px-1 text-center">
|
|
<div className="w-full h-7 rounded-md bg-muted/30 flex items-center justify-center text-[11px] text-muted-foreground">
|
|
—
|
|
</div>
|
|
</td>
|
|
))}
|
|
</tr>
|
|
))}
|
|
{retentionData.length === 0 && (
|
|
<tr>
|
|
<td colSpan={6} className="py-8 text-center text-xs text-muted-foreground">暂无留存数据</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|