diff --git a/src/api/auth.ts b/src/api/auth.ts index 62a3f14..fd44531 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -1,10 +1,10 @@ import request from '../utils/request'; -export const loginApi = (data: any) => { +export const loginApi = (data: { account: string; password: string; captcha: string; captchaId: string }): Promise<{ token: string; user: { id: string; nickname: string; avatar: string; roleList?: string[] } }> => { return request.post('/auth/login', data); }; -export const getCaptchaApi = () => { +export const getCaptchaApi = (): Promise<{ captcha: string; captchaId: string }> => { return request.get('/auth/captcha'); }; diff --git a/src/api/oss.ts b/src/api/oss.ts index 40ac670..31696b8 100644 --- a/src/api/oss.ts +++ b/src/api/oss.ts @@ -8,6 +8,6 @@ export const uploadFileApi = (formData: FormData) => { }); }; -export const getFileListApi = (data: any) => request.post('/oss/getFileList', data); +export const getFileListApi = (data: { current?: number; pageSize?: number; keyword?: string }) => request.post('/oss/getFileList', data); export const deleteFileApi = (data: { ids: (string | number)[] }) => request.post('/oss/delete', data); export const getFileDetailApi = (id: string | number) => request.get('/oss/getFile', { params: { id } }); diff --git a/src/api/radio.ts b/src/api/radio.ts index 31dca4c..1c4a9a3 100644 --- a/src/api/radio.ts +++ b/src/api/radio.ts @@ -1,30 +1,74 @@ import request from '../utils/request'; +import type { + PageResult, + RadioCategory, + RadioChannel, + RadioProgram, + RadioUserItem, + RadioUserListParams, + ProgramListParams, +} from '../types/radio'; // --- Category API --- -export const getCategoryPageApi = (data: any) => request.post('/radio/category/page', data); -export const getCategoryListApi = (data: any = {}) => request.post('/radio/category/list', data); -export const getCategoryDetailApi = (id: string | number) => request.get('/radio/category/detail', { params: { id } }); -export const saveCategoryApi = (data: any) => request.post('/radio/category/save', data); -export const updateCategoryApi = (data: any) => request.post('/radio/category/update', data); -export const deleteCategoryApi = (data: { id: string | number }) => request.post('/radio/category/delete', data); +export const getCategoryPageApi = (data: { current?: number; pageSize?: number; name?: string; status?: number }): Promise> => + request.post('/radio/category/page', data); +export const saveCategoryApi = (data: Partial | Record): Promise => + request.post('/radio/category/save', data); +export const updateCategoryApi = (data: Partial | Record): Promise => + request.post('/radio/category/update', data); +export const deleteCategoryApi = (data: { id: string | number }): Promise => + request.post('/radio/category/delete', data); +export const getAllCategoryListApi = (): Promise<{ list: RadioCategory[] } | RadioCategory[]> => + request.get('/radio/category/list'); // --- Channel API --- -export const getChannelListApi = (data: any = {}) => request.post('/radio/channel/list', data); -export const getChannelDetailApi = (id: string | number) => request.get('/radio/channel/detail', { params: { id } }); -export const saveChannelApi = (data: any) => request.post('/radio/channel/save', data); -export const updateChannelApi = (data: any) => request.post('/radio/channel/update', data); -export const deleteChannelApi = (data: { id: string | number }) => request.post('/radio/channel/delete', data); +export const getChannelListApi = (data: { pageSize?: number; categoryId?: string; current?: number; name?: string }): Promise> => + request.post('/radio/channel/list', data); +export const saveChannelApi = (data: Partial): Promise => + request.post('/radio/channel/save', data); +export const updateChannelApi = (data: Partial): Promise => + request.post('/radio/channel/update', data); +export const deleteChannelApi = (data: { id: string | number }): Promise => + request.post('/radio/channel/delete', data); // --- Program API --- -export const getProgramListApi = (data: any = {}) => request.post('/radio/program/list', data); -export const getProgramDetailApi = (id: string | number) => request.get('/radio/program/detail', { params: { id } }); -export const saveProgramApi = (data: any) => request.post('/radio/program/save', data); -export const updateProgramApi = (data: any) => request.post('/radio/program/update', data); -export const deleteProgramApi = (data: { ids: (string | number)[] }) => request.post('/radio/program/delete', data); - -export const getAllCategoryListApi = () => request.get('/radio/category/list'); -export const getCategoryTreeApi = () => request.get('/radio/category/tree'); +export const getProgramListApi = (data: ProgramListParams): Promise> => + request.post('/radio/program/list', data); +export const saveProgramApi = (data: Partial): Promise => + request.post('/radio/program/save', data); +export const updateProgramApi = (data: Partial): Promise => + request.post('/radio/program/update', data); +export const deleteProgramApi = (data: { ids: (string | number)[] }): Promise => + request.post('/radio/program/delete', data); +export const generateTtsApi = (id: string | number): Promise => + request.get('/radio/program/generate-tts', { params: { id } }); // --- VIP API --- -export const getVipConfigDetailApi = () => request.post('/vip/config/detail'); -export const updateVipConfigApi = (data: any) => request.post('/vip/config/update', data); +export const getVipConfigDetailApi = (): Promise> => request.post('/vip/config/detail'); +export const updateVipConfigApi = (data: Record): Promise => request.post('/vip/config/update', data); + +// --- Analytics API --- +export const getListeningTrendApi = (params: Record): Promise => + request.get('/radio/analytics/listening-trend', { params }); +export const getSubscriptionTrendApi = (params: Record): Promise => + request.get('/radio/analytics/subscription-trend', { params }); +export const getRenewalTrendApi = (params: Record): Promise => + request.get('/radio/analytics/renewal-trend', { params }); +export const getSubscriberStatsApi = (params: Record): Promise => + request.get('/radio/analytics/subscriber-stats', { params }); +export const getContentQualityApi = (params: Record): Promise => + request.get('/radio/analytics/content-quality', { params }); +export const getUserStickinessApi = (params: Record): Promise => + request.get('/radio/analytics/user-stickiness', { params }); +export const getBusinessConversionApi = (params: Record): Promise => + request.get('/radio/analytics/business-conversion', { params }); +export const getPreferenceApi = (): Promise => + request.get('/radio/analytics/preference'); +export const getChannelListAnalyticsApi = (params: Record): Promise => + request.get('/radio/channel/list', { params }); +export const getVipStatsApi = (params: Record = {}): Promise => + request.get('/radio/analytics/vip-stats', { params }); + +// --- User API --- +export const getRadioUserListApi = (params: RadioUserListParams): Promise> => + request.get('/radio/user/list', { params }); diff --git a/src/components/studio/AnalogMeter.tsx b/src/components/studio/AnalogMeter.tsx new file mode 100644 index 0000000..cd8a535 --- /dev/null +++ b/src/components/studio/AnalogMeter.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import { motion } from 'framer-motion'; +import type { LucideIcon } from 'lucide-react'; + +interface AnalogMeterProps { + label: string; + value: string | number; + subLabel?: string; + icon: LucideIcon; + percentage: number; // 0 to 100 + color?: 'amber' | 'green' | 'red'; +} + +export const AnalogMeter: React.FC = ({ + label, + value, + subLabel, + icon: Icon, + percentage, + color = 'amber' +}) => { + const glowClass = color === 'amber' ? 'glow-amber' : color === 'green' ? 'glow-green' : 'glow-red'; + const barColor = color === 'amber' ? 'bg-studio-glow-amber' : color === 'green' ? 'bg-studio-glow-green' : 'bg-studio-glow-red'; + const shadowColor = color === 'amber' ? 'shadow-[0_0_10px_var(--studio-glow-amber)]' : color === 'green' ? 'shadow-[0_0_10px_var(--studio-glow-green)]' : 'shadow-[0_0_10px_var(--studio-glow-red)]'; + + return ( +
+ {/* Label and Icon */} +
+
+ {label} +

+ {value} +

+ {subLabel && {subLabel}} +
+
+ +
+
+ + {/* Analog/LED Meter */} +
+
+ Min + Peak +
+ +
+ {/* LED Segments */} + {Array.from({ length: 24 }).map((_, i) => { + const isActive = (i / 23) * 100 <= percentage; + const isCritical = i > 18; + const segmentColor = isCritical ? 'bg-studio-glow-red' : isActive ? barColor : 'bg-studio-edge'; + const segmentShadow = (isActive && !isCritical) ? shadowColor : (isActive && isCritical) ? 'shadow-[0_0_10px_var(--studio-glow-red)]' : ''; + + return ( + + ); + })} +
+
+ + {/* Metric Detail Overlay (Subtle Gradient) */} +
+
+ ); +}; diff --git a/src/components/studio/OscilloscopeChart.tsx b/src/components/studio/OscilloscopeChart.tsx new file mode 100644 index 0000000..667a0d9 --- /dev/null +++ b/src/components/studio/OscilloscopeChart.tsx @@ -0,0 +1,108 @@ +import React from 'react'; +import { + AreaChart, + Area, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, +} from 'recharts'; + +interface OscilloscopeChartProps { + data: any[]; + dataKey: string; + xKey: string; + color?: string; + title: string; + unit?: string; +} + +export const OscilloscopeChart: React.FC = ({ + data, + dataKey, + xKey, + color = '#FFB347', + title, + unit = '' +}) => { + return ( +
+
+
+ Waveform Analyzer +

{title}

+
+
+
+ Scan Frequency + 60Hz +
+
+
+
+ +
+ {/* Oscilloscope Grid Overlay (Visual Only) */} +
+ + + + + + + + + + + val.toString().split('-').slice(1).join('/')} + /> + + { + if (active && payload && payload.length) { + return ( +
+

{label}

+
+ Signal + + {payload[0].value} {unit} + +
+
+ ); + } + return null; + }} + /> + +
+
+ + {/* Glow effect on the bottom */} +
+
+
+ ); +}; diff --git a/src/components/studio/StudioHeader.tsx b/src/components/studio/StudioHeader.tsx new file mode 100644 index 0000000..a1a929a --- /dev/null +++ b/src/components/studio/StudioHeader.tsx @@ -0,0 +1,83 @@ +import React, { useState, useEffect } from 'react'; +import { Radio, Zap } from 'lucide-react'; +import { motion } from 'framer-motion'; + +export const StudioHeader: React.FC = () => { + const [time, setTime] = useState(new Date()); + + useEffect(() => { + const timer = setInterval(() => setTime(new Date()), 1000); + return () => clearInterval(timer); + }, []); + + const formatTime = (date: Date) => { + return date.toLocaleTimeString('en-US', { + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); + }; + + return ( +
+ {/* Brand & Status */} +
+
+
+ +
+ +
+ +
+

+ Morning Radio +

+
+ Broadcast Studio 01 +
+
+
+
+ + {/* Center Display: Time & On Air */} +
+
+ Local Time + + {formatTime(time)} + +
+ +
+ +
+ Status +
+ On Air +
+
+
+ + {/* Metrics Mini-Readout */} +
+
+ Signal Strength +
+ {[1, 2, 3, 4, 5].map((i) => ( +
+ ))} +
+
+
+ +
+
+
+ ); +}; diff --git a/src/components/studio/TrendLineChart.tsx b/src/components/studio/TrendLineChart.tsx new file mode 100644 index 0000000..8ccb392 --- /dev/null +++ b/src/components/studio/TrendLineChart.tsx @@ -0,0 +1,98 @@ +import React from 'react'; +import { + AreaChart, + Area, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, +} from 'recharts'; + +interface TrendLineChartProps { + data: any[]; + dataKey: string; + xKey: string; + color?: string; + title: string; + unit?: string; +} + +export const TrendLineChart: React.FC = ({ + data, + dataKey, + xKey, + color = '#D28F4F', + title, + unit = '' +}) => { + return ( +
+ {/* Header section */} +
+
+

{title}

+ 数据趋势分析 +
+
+ + 实时 +
+
+ +
+ + + + + + + + + + val ? val.toString().split('-').slice(1).join('-') : ''} + minTickGap={20} + /> + + { + if (active && payload && payload.length) { + return ( +
+

{label}

+
+ 数值 + + {payload[0].value} {unit} + +
+
+ ); + } + return null; + }} + /> + +
+
+
+
+ ); +}; diff --git a/src/index.css b/src/index.css index fc53e1f..f0717ff 100644 --- a/src/index.css +++ b/src/index.css @@ -79,32 +79,80 @@ --glass-bg: rgba(255, 253, 235, 0.4); --glass-border: rgba(255, 255, 255, 0.6); --glass-shadow: 0 20px 50px rgba(74, 58, 44, 0.1); + + /* Studio Analog Tokens */ + --studio-panel: #2C2C2C; + --studio-edge: #3D3D3D; + --studio-inset: #1A1A1A; + --studio-glow-amber: #FFB347; + --studio-glow-green: #00FF41; + --studio-glow-red: #FF3131; + --studio-text-mono: 'JetBrains Mono', 'Courier New', monospace; } .dark { - --background: #1A1A1A; - --foreground: #E8E1D9; - --card: rgba(30, 30, 30, 0.8); - --card-foreground: #E8E1D9; - --popover: rgba(30, 30, 30, 0.9); - --popover-foreground: #E8E1D9; + --background: #121212; + --foreground: #D4D4D4; + --card: #1E1E1E; + --card-foreground: #D4D4D4; + --popover: #1E1E1E; + --popover-foreground: #D4D4D4; --primary: #D28F4F; --primary-foreground: #FFFFFF; --secondary: #6A7F6A; --secondary-foreground: #FFFFFF; - --muted: #2D2D2D; - --muted-foreground: #8C7E6C; + --muted: #262626; + --muted-foreground: #737373; --accent: rgba(210, 143, 79, 0.2); --accent-foreground: #D28F4F; - --destructive: #A64452; - --border: rgba(255, 255, 255, 0.1); - --input: rgba(255, 255, 255, 0.1); + --destructive: #EF4444; + --border: #262626; + --input: #262626; --ring: #D28F4F; - --glass-bg: rgba(30, 30, 30, 0.6); + --glass-bg: rgba(30, 30, 30, 0.8); --glass-border: rgba(255, 255, 255, 0.05); + + /* Studio Analog Tokens Dark */ + --studio-panel: #1A1A1A; + --studio-edge: #2A2A2A; + --studio-inset: #0A0A0A; } +/* Radio Rack Style */ +.radio-rack { + background: var(--studio-panel); + border: 1px solid var(--studio-edge); + box-shadow: + inset 0 1px 0 rgba(255,255,255,0.05), + 0 10px 30px rgba(0,0,0,0.5); + position: relative; +} + +.radio-rack::after { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(180deg, rgba(255,255,255,0.03) 0%, transparent 100%); + pointer-events: none; +} + +/* Inset Panel for Readouts */ +.studio-inset { + background: var(--studio-inset); + border-radius: 4px; + box-shadow: inset 0 2px 10px rgba(0,0,0,0.8); + border: 1px solid rgba(255,255,255,0.05); +} + +/* Vacuum Tube Glow */ +.glow-amber { text-shadow: 0 0 10px var(--studio-glow-amber), 0 0 20px var(--studio-glow-amber); color: var(--studio-glow-amber); } +.glow-green { text-shadow: 0 0 10px var(--studio-glow-green), 0 0 20px var(--studio-glow-green); color: var(--studio-glow-green); } +.glow-red { text-shadow: 0 0 10px var(--studio-glow-red), 0 0 20px var(--studio-glow-red); color: var(--studio-glow-red); } + .glass-card { background: var(--glass-bg); backdrop-filter: blur(20px); diff --git a/src/layouts/AdminLayout.tsx b/src/layouts/AdminLayout.tsx index a0fcb8f..70eb7ce 100644 --- a/src/layouts/AdminLayout.tsx +++ b/src/layouts/AdminLayout.tsx @@ -50,6 +50,7 @@ export default function AdminLayout() { { name: '频道管理', path: '/radio/channel', icon: Mic2 }, { name: '节目管理', path: '/radio/program', icon: Disc3 }, { name: 'VIP配置', path: '/radio/vip', icon: Crown }, + { name: '用户管理', path: '/radio/user', icon: UserIcon }, { name: '文件管理', path: '/system/oss', icon: FolderOpen }, ]; @@ -162,7 +163,7 @@ export default function AdminLayout() { {/* Page Content */}
-
+
diff --git a/src/pages/Dashboard/index.tsx b/src/pages/Dashboard/index.tsx index 0e56ff3..3763206 100644 --- a/src/pages/Dashboard/index.tsx +++ b/src/pages/Dashboard/index.tsx @@ -1,423 +1,704 @@ +import { useState, useEffect } from 'react'; import { - ListMusic, Mic2, Disc3, - TrendingUp, Users, Activity, - ArrowUpRight, - ArrowDownRight, - Play, - Zap, + BarChart3, + PieChart, + TrendingUp, + ArrowRight, + Headphones, + ShoppingCart, + CreditCard, + RefreshCw, + UserCheck, + UserX, + Filter, Crown, - Star, - Heart, - Flame + Coins } from 'lucide-react'; -import { Card, CardContent, CardHeader, CardTitle } from '../../components/ui/card'; -import { Button } from '../../components/ui/button'; import { motion } from 'framer-motion'; import { - AreaChart, - Area, - XAxis, - YAxis, - CartesianGrid, - Tooltip, - ResponsiveContainer, - BarChart, - Bar, - Cell + 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'; -const chartData = [ - { name: '06:00', morning: 400, evening: 240, active: 300 }, - { name: '09:00', morning: 700, evening: 300, active: 550 }, - { name: '12:00', morning: 600, evening: 450, active: 480 }, - { name: '15:00', morning: 800, evening: 500, active: 720 }, - { name: '18:00', morning: 500, evening: 900, active: 680 }, - { name: '21:00', morning: 300, evening: 1200, active: 850 }, - { name: '00:00', morning: 200, evening: 800, active: 400 }, -]; -const categoryData = [ - { name: '流行', value: 400, color: '#D28F4F' }, - { name: '爵士', value: 300, color: '#A64452' }, - { name: '民谣', value: 200, color: '#E29A66' }, - { name: '古典', value: 278, color: '#8C7E6C' }, - { name: '电音', value: 189, color: '#4A3A2C' }, -]; +// ─── Color Palette ─── +const COLORS = { + listen: '#D28F4F', // primary (Orange) + sub: '#6A7F6A', // secondary (Green) + renew: '#C86354', // distinct warm red + muted: '#8C7E6C', + chart: ['#D28F4F', '#6A7F6A', '#C86354', '#E8B878', '#8BAE8B'], +}; 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([]); + + // 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(); + + 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: '12', - icon: ListMusic, - change: '+2', - trend: 'up', - color: 'from-[#D28F4F] to-[#E29A66]', - iconColor: 'text-[#D28F4F]' + 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: '45', + value: statsData.channelCount, icon: Mic2, - change: '+5', - trend: 'up', - color: 'from-orange-400 to-rose-400', - iconColor: 'text-orange-500' + subLabel: '在线广播节点', + color: COLORS.listen, }, { - name: '声波单元', - value: '3,284', + name: '节目总数', + value: statsData.programCount, icon: Disc3, - change: '+124', - trend: 'up', - color: 'from-[#A64452] to-[#D28F4F]', - iconColor: 'text-[#A64452]' + subLabel: '内容资产储备', + color: COLORS.renew, }, { - name: '订阅用户', - value: '12.4K', - icon: Users, - change: '-24', - trend: 'down', - color: 'from-[#8C7E6C] to-[#4A3A2C]', - iconColor: 'text-[#4A3A2C]' + 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: '#D28F4F', }, ]; + // ─── 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 ( -
- {/* Header Section */} - -
-
- - System Control Panel - -
- {[1, 2, 3].map(i => ( -
- -
+
+
+ + {/* ═══ Header ═══ */} +
+
+

电台数据概览

+

实时监控频道运营、内容表现与商业转化

+
+ + {/* ═══ Filters ═══ */} +
+
+ 日期: + setStartDate(e.target.value)} + className="bg-transparent text-[13px] font-medium text-foreground outline-none border-none custom-date-input" + /> + - + setEndDate(e.target.value)} + className="bg-transparent text-[13px] font-medium text-foreground outline-none border-none custom-date-input" + /> +
+ +
+ 频道: +
-

- 晨间灵感,
- 全声汇 管理系统 -

-

- - 当前有 1,248 位听众在不同时区的频道中共振,让声音温暖每一个瞬间。 -

-
-
-
-
- -
-
-
- Network Status - 高带宽低延迟运行中 -
+
+ { 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="重置过滤" + > + + + + { 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="刷新数据" + > + +
- +
- {/* Stats Grid */} -
- {stats.map((stat, index) => ( + {/* ═══ Stats Overview ═══ */} +
+ {stats.map((stat, idx) => ( - -
- -
-
- -
-
- - {stat.trend === 'up' ? : } - {stat.change} - -
+
+

{stat.name}

+
+
- -
-

{stat.name}

-

{stat.value}

-
- -
-
- -
-
- +
+

+ {loading ? '—' : stat.value.toLocaleString()} +

+
+ {stat.subLabel} + {stat.trend && ( + + {stat.trend} + + )} +
))} -
+
- {/* Main Content Sections */} -
- {/* Large Chart Card */} + {/* ═══ Main Trend Chart + Sidebar ═══ */} +
+ {/* Multiplex Trend Chart */} - -
- -
-
-
- -
- 声波极化趋势 -
-

实时捕捉全网音频流的活跃度与用户共鸣度

+
+ {/* Chart header */} +
+
+

核心运营趋势

+

+ 收听与订阅多维度对比分析 +

-
-
-
-
- 晨间频率 -
-

1.2M

-
-
-
-
- 傍晚余辉 -
-

0.8M

-
-
- - -
- - - - - - - - - - - - - - - - { - if (active && payload && payload.length) { - return ( -
-

{label} WAVE REPORT

-
-
-
-
- 晨间 -
- {payload[0].value} -
-
-
-
- 傍晚 -
- {payload[1].value} -
+
+ + {/* Chart area */} +
+ + + + + + + + + + + + + + + + + + + + + active && payload?.length ? ( +
+

{fmtDate(label as string)} 统计

+ {payload.map(p => ( +
+
+ + {p.name}
+ {p.value}
- ); - } - return null; - }} - /> - - - - -
- - + ))} +
+ ) : null + } + /> + + + + + + + +
+
- {/* Categories & Trending */} + {/* ─── Conversion Funnel Side Card ─── */} - - -
- - - 热门类目共振 - - +
+
+

商业转化漏斗

+ +
+ + {/* LTV highlight */} + {funnelData && ( +
+

人均生命周期价值 (LTV)

+

+ ¥{(funnelData.ltv / 100).toFixed(2)} +

- - - {categoryData.map((item, i) => ( -
-
-
- 0{i + 1} - {item.name} + )} + + {/* Funnel bars */} +
+ {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 ( +
+
+
+ + {step.label} +
+
+ {step.value.toLocaleString()} + {convRate && ( + + {convRate}% + + )} +
- {item.value} Units +
+ +
+ {i < funnelSteps.length - 1 && ( +
+ +
+ )}
-
+ ); + })} +
+
+ +
+ + {/* ═══ Subscriber Active Trend (mini chart) + Content Quality ═══ */} +
+ {/* Mini subscriber trend */} +
+
+
+ +

有效订阅用户趋势

+
+ + 当前 {statsData.activeSubscribers} 人 + +
+
+ + + + + + + + + + + + + active && payload?.length ? ( +
+

{fmtDate(label as string)}

+

{payload[0].value} 人

+
+ ) : null + } + /> + +
+
+
+
+ + {/* Content Quality */} +
+
+
+ +

内容完播率 TOP5

+
+
+
+ {qualityData.slice(0, 5).map((item, i) => { + const pct = (item.avgCompletion * 100); + return ( +
+
+ {item.title} +
+ {item.playCount} 播放 + = 60 ? COLORS.sub : COLORS.muted }}> + {pct.toFixed(1)}% + +
+
+
-
- + animate={{ width: `${pct}%` }} + className="h-full rounded-full" + style={{ backgroundColor: pct >= 60 ? COLORS.sub : COLORS.listen }} + transition={{ duration: 1, delay: i * 0.08 }} + />
- ))} - - + ); + })} +
+
+
- -
- - -
-
- + {/* ═══ Bottom: Preference + Retention ═══ */} +
+ {/* Revenue by Category */} +
+
+
+ +

品类营收结构

+
+ 全量统计 +
+
+ {preferenceData.slice(0, 5).map((item, idx) => ( +
+
+ {idx + 1} +
+
+
+ {item.categoryName} + + ¥{(item.revenue / 100).toLocaleString()} + +
+
+
+ +
+ + {(item.share * 100).toFixed(1)}% + +
+
-

VIP 专属生态
特权升级方案

-

- 通过精细化的会员等级与多维度的定价策略,构建高粘性的听众社群,深度发掘商业潜能。 -

- -
- - -
+ ))} +
+
- {/* Recent Activity Section */} - - - -
- -
- 实时播报流 - -

系统内所有频道的最新动态与播控反馈

+ {/* User Retention (real data from getUserStickinessApi) */} +
+
+
+ +

用户留存分析

- - - -
- {[1, 2, 3, 4].map((i) => ( -
-
-
- -
- -
-
-

城市森林:晨间漫步 0{i}

-
- - - 爵士之声频道 - -
- 12:34 PM -
-
-
-
-
-
- - +1,248 -
- New Listeners -
- -
-
- ))} -
- - - +
+ +
+ + + + + + + + + + + + + {retentionData.slice(0, 7).map((row) => ( + + + + {(row.retention || []).map((rate: number, idx: number) => { + const pct = (rate * 100); + const bgOpacity = Math.max(0.06, rate * 0.5); + return ( + + ); + })} + {/* Fill empty if less than 4 retention values */} + {(row.retention || []).length < 4 && + Array.from({ length: 4 - (row.retention || []).length }).map((_, idx) => ( + + ))} + + ))} + {retentionData.length === 0 && ( + + + + )} + +
日期新增次日3日7日30日
{fmtDate(row.date)}{row.newUsers} +
20 ? COLORS.sub : COLORS.muted, + }} + > + {pct.toFixed(0)}% +
+
+
+ — +
+
暂无留存数据
+
+
+
); } diff --git a/src/pages/Login/index.tsx b/src/pages/Login/index.tsx index 67dab61..bcae370 100644 --- a/src/pages/Login/index.tsx +++ b/src/pages/Login/index.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; -import { useAuthStore } from '../../store/authStore'; -import { loginApi, getCaptchaApi } from '../../api/auth'; +import { useAuthStore } from '@/store/authStore.ts'; +import { loginApi, getCaptchaApi } from '@/api/auth.ts'; import { toast } from 'sonner'; import { Button } from '../../components/ui/button'; import { Input } from '../../components/ui/input'; @@ -25,7 +25,7 @@ export default function Login() { const fetchCaptcha = async () => { try { - const res: any = await getCaptchaApi(); + const res = await getCaptchaApi(); const b64 = res.captcha; if (b64 && !b64.startsWith('data:')) { setCaptchaImage(`data:image/png;base64,${b64}`); @@ -52,7 +52,7 @@ export default function Login() { try { setLoading(true); - const res: any = await loginApi({ + const res = await loginApi({ account, password, captcha, @@ -62,7 +62,8 @@ export default function Login() { setToken(res.token); setUserInfo(res.user); navigate('/'); - } catch (error: any) { + } catch (error: Error | unknown) { + console.error(error); fetchCaptcha(); setCaptcha(''); } finally { diff --git a/src/pages/Radio/Category/index.tsx b/src/pages/Radio/Category/index.tsx index 56023d5..ba7edc4 100644 --- a/src/pages/Radio/Category/index.tsx +++ b/src/pages/Radio/Category/index.tsx @@ -4,14 +4,15 @@ import { saveCategoryApi, updateCategoryApi, deleteCategoryApi -} from '../../../api/radio'; -import { DeleteConfirm } from '../../../components/DeleteConfirm'; -import { Button } from '../../../components/ui/button'; -import { Input } from '../../../components/ui/input'; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../../components/ui/table'; -import { Dialog, DialogContent, DialogTitle } from '../../../components/ui/dialog'; -import { Card, CardContent, CardHeader, CardTitle } from '../../../components/ui/card'; -import { Label } from '../../../components/ui/label'; +} from '@/api/radio.ts'; +import type {RadioCategory, CategoryFormData} from '@/types/radio.ts'; +import { DeleteConfirm } from '@/components/DeleteConfirm.tsx'; +import { Button } from '@/components/ui/button.tsx'; +import { Input } from '@/components/ui/input.tsx'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table.tsx'; +import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog.tsx'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card.tsx'; +import { Label } from '@/components/ui/label.tsx'; import { Plus, Edit, @@ -25,7 +26,7 @@ import { toast } from 'sonner'; import { motion } from 'framer-motion'; export default function Category() { - const [data, setData] = useState([]); + const [data, setData] = useState([]); const [total, setTotal] = useState(0); const [loading, setLoading] = useState(false); @@ -36,7 +37,7 @@ export default function Category() { const [open, setOpen] = useState(false); const [isEdit, setIsEdit] = useState(false); - const [formData, setFormData] = useState({ + const [formData, setFormData] = useState({ id: '', name: '', description: '', @@ -45,12 +46,12 @@ export default function Category() { }); const [deleteOpen, setDeleteOpen] = useState(false); - const [deleteId, setDeleteId] = useState(null); + const [deleteId, setDeleteId] = useState(null); const fetchData = async () => { setLoading(true); try { - const res: any = await getCategoryPageApi({ + const res = await getCategoryPageApi({ current: page, pageSize: pageSize, name: debouncedSearch, @@ -83,10 +84,10 @@ export default function Category() { setOpen(true); }; - const handleOpenEdit = (record: any) => { + const handleOpenEdit = (record: RadioCategory) => { setIsEdit(true); setFormData({ - id: String(record.ID || record.id || ""), + id: record.id, name: record.name, description: record.description, sort: record.sort, @@ -95,7 +96,7 @@ export default function Category() { setOpen(true); }; - const handleDeleteClick = (id: any) => { + const handleDeleteClick = (id: string) => { setDeleteId(id); setDeleteOpen(true); }; @@ -197,9 +198,9 @@ export default function Category() { ) : data.length === 0 ? ( 暂无分类架构 ) : ( - data.map((item: any, index: number) => ( + data.map((item, index) => ( @@ -212,7 +213,7 @@ export default function Category() {

{item.name}

-

ID: {item.ID || item.id}

+

ID: {item.id}

@@ -248,7 +249,7 @@ export default function Category() {
-
+
{/* Sidebar Categories */} -
- - - - - 频道分类 - - - -
- - {categories.map((category: any) => { - const catId = String(category.ID || category.id || ""); - const isSelected = selectedCategoryId === catId; - return ( - - ); - })} -
-
-
-
+ + + + + 分类筛选 + + + +
+ + {categories.map((category) => { + const catId = String(category.id); + const isSelected = selectedCategoryId === catId; + return ( + + ); + })} +
+
+
{/* Main Table Content */} - +
- {selectedCategoryId ? categories.find(c => String(c.ID || c.id) === selectedCategoryId)?.name : '电台频道全集'} + {selectedCategoryId ? categories.find(c => c.id === selectedCategoryId)?.name : '电台频道全集'}
@@ -319,9 +324,9 @@ export default function Channel() { ) : data.length === 0 ? ( 未发现相关频道 ) : ( - data.map((item: any) => ( + data.map((item) => ( @@ -332,7 +337,7 @@ export default function Channel() {

{item.name}

-

#{categories.find(c => String(c.ID || c.id) === String(item.categoryId))?.name || '未分类'}

+

#{categories.find(c => c.id === String(item.categoryId))?.name || '未分类'}

@@ -392,7 +397,7 @@ export default function Channel() {
@@ -556,7 +561,7 @@ export default function Channel() { value={formData.sort ?? ""} onChange={(e) => { const val = e.target.value; - setFormData({ ...formData, sort: val === "" ? "" : parseInt(val) }); + setFormData({ ...formData, sort: val === "" ? 0 : parseInt(val) }); }} className="h-14 md:h-16 rounded-3xl border-none bg-white shadow-sm font-black text-center text-[#4A3A2C] focus:ring-4 ring-[#D28F4F]/10 transition-all text-xl" /> diff --git a/src/pages/Radio/Program/index.tsx b/src/pages/Radio/Program/index.tsx index a48a778..40adc89 100644 --- a/src/pages/Radio/Program/index.tsx +++ b/src/pages/Radio/Program/index.tsx @@ -1,19 +1,21 @@ -import { useState, useEffect, useRef } from 'react'; +import {useState, useEffect, useRef} from 'react'; import { getProgramListApi, saveProgramApi, updateProgramApi, deleteProgramApi, - getChannelListApi -} from '../../../api/radio'; -import { FileUploader } from '../../../components/FileUploader'; -import { DeleteConfirm } from '../../../components/DeleteConfirm'; -import { Button } from '../../../components/ui/button'; -import { Input } from '../../../components/ui/input'; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../../components/ui/table'; -import { Dialog, DialogContent, DialogTitle } from '../../../components/ui/dialog'; -import { Card, CardContent, CardHeader, CardTitle } from '../../../components/ui/card'; -import { Label } from '../../../components/ui/label'; + getChannelListApi, + generateTtsApi, getAllCategoryListApi +} from '@/api/radio.ts'; +import type {RadioProgram, RadioChannel, RadioCategory, ProgramFormData} from '@/types/radio.ts'; +import {FileUploader} from '@/components/FileUploader.tsx'; +import {DeleteConfirm} from '@/components/DeleteConfirm.tsx'; +import {Button} from '@/components/ui/button.tsx'; +import {Input} from '@/components/ui/input.tsx'; +import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from '@/components/ui/table.tsx'; +import {Dialog, DialogContent, DialogTitle} from '@/components/ui/dialog.tsx'; +import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card.tsx'; +import {Label} from '@/components/ui/label.tsx'; import { Plus, Edit, @@ -27,11 +29,15 @@ import { Disc3, ArrowRight, Headphones, - FileAudio, - Smile + Smile, + Wand2, + RefreshCw, + ChevronDown, + ChevronRight, + FolderOpen } from 'lucide-react'; -import { toast } from 'sonner'; -import { motion, AnimatePresence } from 'framer-motion'; +import {toast} from 'sonner'; +import {motion} from 'framer-motion'; const EMOJI_LIST = [ '🎵', '🎶', '📻', '🎙️', '🎧', '🌙', '☀️', '🌊', '🌲', '🌌', @@ -42,11 +48,13 @@ const EMOJI_LIST = [ ]; export default function Program() { - const [data, setData] = useState([]); + const [data, setData] = useState([]); const [total, setTotal] = useState(0); const [loading, setLoading] = useState(false); - const [channels, setChannels] = useState([]); + const [channels, setChannels] = useState([]); + const [categories, setCategories] = useState([]); const [selectedChannelId, setSelectedChannelId] = useState(""); + const [expandedCats, setExpandedCats] = useState>(new Set()); const [page, setPage] = useState(1); const [pageSize] = useState(10); @@ -55,7 +63,7 @@ export default function Program() { const [open, setOpen] = useState(false); const [isEdit, setIsEdit] = useState(false); - const [formData, setFormData] = useState({ + const [formData, setFormData] = useState({ id: undefined, channelId: '', title: '', @@ -69,30 +77,49 @@ export default function Program() { }); const [deleteOpen, setDeleteOpen] = useState(false); - const [deleteIds, setDeleteIds] = useState([]); + const [deleteIds, setDeleteIds] = useState([]); - const [playingId, setPlayingId] = useState(null); + const [playingId, setPlayingId] = useState(null); const audioRef = useRef(null); const fetchChannels = async () => { try { - const res: any = await getChannelListApi({ pageSize: 100 }); - setChannels(res.list || res || []); + const res = await getChannelListApi({pageSize: 100}); + setChannels(res.list || []); } catch (e) { console.error(e); } } + const fetchCategories = async () => { + try { + const res = await getAllCategoryListApi(); + const list: RadioCategory[] = Array.isArray(res) ? res : ('list' in res ? res.list : []); + setCategories(list); + setExpandedCats(new Set(list.map((c) => String(c.id)))); + } catch (e) { + console.error(e); + } + } + + const toggleCat = (catId: string) => { + setExpandedCats(prev => { + const next = new Set(prev); + next.has(catId) ? next.delete(catId) : next.add(catId); + return next; + }); + }; + const fetchData = async () => { setLoading(true); try { - const res: any = await getProgramListApi({ + const res = await getProgramListApi({ current: page, pageSize: pageSize, title: debouncedSearch, channelId: selectedChannelId || undefined }); - setData(res.list || res || []); + setData(res.list || []); setTotal(res.total || 0); } catch (e) { console.error(e); @@ -103,6 +130,7 @@ export default function Program() { useEffect(() => { fetchChannels(); + fetchCategories(); }, []); useEffect(() => { @@ -134,10 +162,10 @@ export default function Program() { setOpen(true); }; - const handleOpenEdit = (record: any) => { + const handleOpenEdit = (record: RadioProgram) => { setIsEdit(true); setFormData({ - id: String(record.ID || record.id || ""), + id: record.id, channelId: record.channelId, title: record.title, description: record.description, @@ -151,15 +179,15 @@ export default function Program() { setOpen(true); }; - const handleDeleteClick = (id: any) => { - setDeleteIds([String(id)]); + const handleDeleteClick = (id: string) => { + setDeleteIds([id]); setDeleteOpen(true); }; const confirmDelete = async () => { if (deleteIds.length === 0) return; try { - await deleteProgramApi({ ids: deleteIds }); + await deleteProgramApi({ids: deleteIds}); toast.success('删除成功'); fetchData(); } catch (e) { @@ -170,6 +198,16 @@ export default function Program() { } }; + const handleGenerateTts = async (id: string) => { + try { + await generateTtsApi(id); + toast.success('语音生成任务已提交'); + fetchData(); + } catch (e) { + console.error(e); + } + }; + const handleSubmit = async () => { if (!formData.title) return toast.error('请填写节目标题'); if (!formData.channelId) return toast.error('请选择所属频道'); @@ -189,18 +227,18 @@ export default function Program() { } }; - const togglePlay = (record: any) => { + const togglePlay = (record: RadioProgram) => { const audioUrl = record.audio?.url || record.audioId; if (!audioUrl) return toast.error('无可用音频文件'); - if (playingId === (record.ID || record.id)) { + if (playingId === record.id) { audioRef.current?.pause(); setPlayingId(null); } else { if (audioRef.current) { audioRef.current.src = audioUrl; audioRef.current.play(); - setPlayingId(record.ID || record.id); + setPlayingId(record.id); } } }; @@ -213,22 +251,24 @@ export default function Program() { return ( -