feat: 收视分析页面(指标卡 + 走势图 + 路由导航)

This commit is contained in:
simonkoson
2026-07-03 10:21:56 +08:00
parent d537bee9a7
commit 8d880f06cf
4 changed files with 483 additions and 0 deletions
+2
View File
@@ -10,6 +10,7 @@ import KnowledgeBase from './pages/KnowledgeBase/KnowledgeBase'
import Doco from './pages/Doco/Doco'
import UserManage from './pages/UserManage/UserManage'
import EditorDesk from './pages/EditorDesk/EditorDesk'
import Analytics from './pages/Analytics/Analytics'
import AuthGuard from './components/AuthGuard/AuthGuard'
import RoleGuard from './components/AuthGuard/RoleGuard'
@@ -31,6 +32,7 @@ function App() {
<Route element={<AppLayout />}>
<Route index element={<Navigate to="/dashboard" replace />} />
<Route path="dashboard" element={<Dashboard />} />
<Route path="analytics" element={<Analytics />} />
<Route path="tps" element={<TPS />} />
<Route path="knowledge" element={<KnowledgeBase />} />
<Route path="doco" element={<Doco />} />
@@ -8,6 +8,7 @@ import {
UserOutlined,
TeamOutlined,
SoundOutlined,
LineChartOutlined,
} from '@ant-design/icons'
import useAuthStore from '../../stores/authStore'
@@ -18,6 +19,7 @@ function SideNav() {
const allMenuItems = [
{ key: '/dashboard', icon: <DashboardOutlined />, label: '仪表盘' },
{ key: '/analytics', icon: <LineChartOutlined />, label: '收视分析' },
{ key: '/editor-desk', icon: <FileTextOutlined />, label: '责编录入' },
{ key: '/tps', icon: <FileTextOutlined />, label: 'TPS 选题策划' },
{ key: '/knowledge', icon: <BookOutlined />, label: '知识库' },
@@ -53,6 +55,8 @@ function SideNav() {
const visibleItems = allMenuItems.filter(item => {
// /dashboard 三角色都可见
if (item.key === '/dashboard') return true
// /analytics 三角色都可见
if (item.key === '/analytics') return true
// /users 仅 zhipianren
if (item.key === '/users') return String(user?.role) === 'zhipianren'
// /editor-desk 仅 zhipianren + zebian
+159
View File
@@ -0,0 +1,159 @@
/* ===== 收视分析页面 ===== */
.analytics-page {
max-width: 1100px;
margin: 0 auto;
padding: 24px;
min-height: 100vh;
background: linear-gradient(135deg, #fdf6ee 0%, #f0f4f8 100%);
}
/* 头部 */
.analytics-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
}
.analytics-title {
margin: 0;
font-size: 22px;
font-weight: 600;
color: #333;
}
/* 加载与空状态 */
.analytics-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 0;
color: #999;
}
.analytics-loading p {
margin-top: 12px;
font-size: 14px;
}
.analytics-empty {
display: flex;
align-items: center;
justify-content: center;
padding: 80px 0;
}
/* ===== 指标卡行 ===== */
.analytics-kpi-row {
margin-bottom: 24px;
}
.analytics-kpi-card {
background: rgba(255, 255, 255, 0.72);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-radius: 16px;
padding: 20px 24px;
margin-bottom: 16px;
border: 1px solid rgba(255, 255, 255, 0.5);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
transition: transform 0.2s, box-shadow 0.2s;
}
.analytics-kpi-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
}
.analytics-kpi-label {
font-size: 13px;
color: #888;
margin-bottom: 8px;
}
.analytics-kpi-value {
font-size: 26px;
font-weight: 700;
color: #333;
font-variant-numeric: tabular-nums;
}
/* ===== 走势图卡片 ===== */
.analytics-chart-card {
background: rgba(255, 255, 255, 0.72);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-radius: 16px;
padding: 24px;
border: 1px solid rgba(255, 255, 255, 0.5);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
}
.analytics-chart-header {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 8px;
}
.analytics-chart-title {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #333;
}
.analytics-chart-hint {
font-size: 12px;
color: #aaa;
}
/* 图例 */
.analytics-chart-legend {
display: flex;
gap: 20px;
justify-content: center;
margin-top: 12px;
font-size: 12px;
color: #666;
}
.analytics-legend-item {
display: flex;
align-items: center;
gap: 6px;
}
.analytics-legend-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
}
/* ===== 响应式 ===== */
@media (max-width: 768px) {
.analytics-page {
padding: 16px;
}
.analytics-header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.analytics-kpi-card {
padding: 16px;
}
.analytics-kpi-value {
font-size: 22px;
}
.analytics-chart-card {
padding: 16px;
}
}
+318
View File
@@ -0,0 +1,318 @@
import { useState, useEffect, useMemo } from 'react'
import { Select, Spin, Empty, Row, Col } from 'antd'
import ReactECharts from 'echarts-for-react'
import { getShareColor } from '../../utils/ratingColors'
import { getAnalyticsEpisodes, getAvailableYears } from '../../services/analyticsService'
import './Analytics.css'
/**
* 收视分析页面
* - 年份选择器
* - 指标卡行(4 张)
* - 收视走势折线图(ECharts
* - 空状态兜底
*/
function Analytics() {
const [years, setYears] = useState([])
const [selectedYear, setSelectedYear] = useState(null)
const [yearlyTarget, setYearlyTarget] = useState(null)
const [episodes, setEpisodes] = useState([])
const [loading, setLoading] = useState(true)
// 获取可用年份列表
useEffect(() => {
getAvailableYears()
.then((data) => {
setYears(data || [])
if (data && data.length > 0) {
setSelectedYear(data[0]) // 默认选最近年份
} else {
setLoading(false)
}
})
.catch(() => {
setLoading(false)
})
}, [])
// 年份切换时获取数据
useEffect(() => {
if (selectedYear == null) return
setLoading(true)
getAnalyticsEpisodes(selectedYear)
.then((data) => {
setEpisodes(data.episodes || [])
setYearlyTarget(data.yearly_target)
setLoading(false)
})
.catch(() => {
setEpisodes([])
setYearlyTarget(null)
setLoading(false)
})
}, [selectedYear])
// ===== 指标卡计算 =====
const kpiMetrics = useMemo(() => {
const withShare = episodes.filter((ep) => ep.audience_share != null)
const count = withShare.length
if (count === 0) {
return {
count: 0,
avgShare: null,
avgShareColor: '#999',
baseRate: '—',
stretchRate: '—',
}
}
const avgShare =
withShare.reduce((sum, ep) => sum + Number(ep.audience_share), 0) / count
// 平均份额的颜色判定(用选定年份的 target)
let avgShareColor = '#999'
if (yearlyTarget) {
const base = Number(yearlyTarget.base_target)
const stretch = Number(yearlyTarget.stretch_target)
if (avgShare > stretch) avgShareColor = '#c0584f' // 红=优秀
else if (avgShare >= base) avgShareColor = '#5b8db8' // 蓝=达标
else avgShareColor = '#7aa874' // 绿=待提升
}
let baseRate = '—'
let stretchRate = '—'
if (yearlyTarget) {
const base = Number(yearlyTarget.base_target)
const stretch = Number(yearlyTarget.stretch_target)
baseRate = ((avgShare / base) * 100).toFixed(1) + '%'
stretchRate = ((avgShare / stretch) * 100).toFixed(1) + '%'
}
return {
count,
avgShare: (avgShare * 100).toFixed(2) + '%',
avgShareColor,
baseRate,
stretchRate,
}
}, [episodes, yearlyTarget])
// ===== ECharts 配置 =====
const chartOption = useMemo(() => {
if (!episodes.length) return {}
const withShare = episodes.filter((ep) => ep.audience_share != null)
const xData = withShare.map((ep) => `${ep.episode_number}`)
// 构建用于 getShareColor 的 targets 数组格式
const targetsForChart = yearlyTarget
? [{ year: selectedYear, ...yearlyTarget }]
: []
const sharePoints = withShare.map((ep) => ({
value: Number(ep.audience_share),
itemStyle: {
color: getShareColor(ep.audience_share, targetsForChart, selectedYear),
},
}))
const markLineData = []
if (yearlyTarget) {
markLineData.push(
{
name: '基础目标',
yAxis: Number(yearlyTarget.base_target),
lineStyle: { color: '#5b8db8', type: 'dashed', width: 2 },
label: {
formatter: '基础目标 {c}',
position: 'insideEndTop',
color: '#5b8db8',
},
},
{
name: '摸高目标',
yAxis: Number(yearlyTarget.stretch_target),
lineStyle: { color: '#c0584f', type: 'dashed', width: 2 },
label: {
formatter: '摸高目标 {c}',
position: 'insideEndTop',
color: '#c0584f',
},
}
)
}
return {
tooltip: {
trigger: 'axis',
formatter: (params) => {
const idx = params[0].dataIndex
const ep = withShare[idx]
if (!ep) return ''
const share = ep.audience_share != null ? Number(ep.audience_share).toFixed(4) : '无数据'
const rating = ep.audience_rating != null ? Number(ep.audience_rating).toFixed(4) : '无数据'
const editor = ep.editor_name_snapshot || '未知'
return `<strong>第 ${ep.episode_number} 期 · ${ep.program_name}</strong><br/>` +
`编导:${editor}<br/>` +
`收视份额:${share}<br/>` +
`收视率:${rating}`
},
},
grid: {
left: 60,
right: 30,
top: 40,
bottom: 70,
},
xAxis: {
type: 'category',
data: xData,
axisLabel: {
rotate: 30,
fontSize: 11,
},
},
yAxis: {
type: 'value',
name: '收视份额',
axisLabel: {
formatter: (val) => val.toFixed(2),
},
},
dataZoom: [
{ type: 'inside', start: 0, end: 100 },
{
type: 'slider',
bottom: 10,
height: 20,
start: 0,
end: 100,
borderColor: '#ddd',
fillerColor: 'rgba(107,142,107,0.15)',
handleStyle: { color: '#6b8e6b' },
},
],
series: [
{
name: '收视份额',
type: 'line',
data: sharePoints,
smooth: false,
symbol: 'circle',
symbolSize: 8,
lineStyle: { width: 2, color: '#6b8e6b' },
markLine: {
silent: true,
data: markLineData,
},
},
],
}
}, [episodes, yearlyTarget, selectedYear])
const hasData = episodes.length > 0 && episodes.some((ep) => ep.audience_share != null)
return (
<div className="analytics-page">
{/* 页面头部 + 年份选择器 */}
<div className="analytics-header">
<h2 className="analytics-title">收视分析</h2>
<Select
className="analytics-year-select"
value={selectedYear}
onChange={setSelectedYear}
placeholder="选择年份"
loading={years.length === 0}
options={years.map((y) => ({ label: `${y}`, value: y }))}
style={{ width: 120 }}
/>
</div>
{loading ? (
<div className="analytics-loading">
<Spin size="large" />
<p>加载中...</p>
</div>
) : !hasData ? (
<div className="analytics-empty">
<Empty description="暂无收视数据" />
</div>
) : (
<>
{/* 指标卡行 */}
<Row gutter={16} className="analytics-kpi-row">
<Col xs={24} sm={12} md={6}>
<div className="analytics-kpi-card">
<div className="analytics-kpi-label">本年已播期数</div>
<div className="analytics-kpi-value">{kpiMetrics.count}</div>
</div>
</Col>
<Col xs={24} sm={12} md={6}>
<div className="analytics-kpi-card">
<div className="analytics-kpi-label">平均收视份额</div>
<div
className="analytics-kpi-value"
style={{ color: kpiMetrics.avgShareColor }}
>
{kpiMetrics.avgShare || '—'}
</div>
</div>
</Col>
<Col xs={24} sm={12} md={6}>
<div className="analytics-kpi-card">
<div className="analytics-kpi-label">基础目标完成率</div>
<div className="analytics-kpi-value">{kpiMetrics.baseRate}</div>
</div>
</Col>
<Col xs={24} sm={12} md={6}>
<div className="analytics-kpi-card">
<div className="analytics-kpi-label">摸高目标完成率</div>
<div className="analytics-kpi-value">{kpiMetrics.stretchRate}</div>
</div>
</Col>
</Row>
{/* 收视走势折线图 */}
<div className="analytics-chart-card">
<div className="analytics-chart-header">
<h3 className="analytics-chart-title">{selectedYear} 年收视走势</h3>
<span className="analytics-chart-hint">
拖拽底部滑块缩放 · 颜色只对收视份额
</span>
</div>
<ReactECharts
option={chartOption}
style={{ height: 400, width: '100%' }}
notMerge
/>
<div className="analytics-chart-legend">
<span className="analytics-legend-item">
<span
className="analytics-legend-dot"
style={{ background: '#7aa874' }}
/>
绿=未达基础目标待提升
</span>
<span className="analytics-legend-item">
<span
className="analytics-legend-dot"
style={{ background: '#5b8db8' }}
/>
=未达摸高目标达标
</span>
<span className="analytics-legend-item">
<span
className="analytics-legend-dot"
style={{ background: '#c0584f' }}
/>
=超过摸高目标优秀
</span>
</div>
</div>
</>
)}
</div>
)
}
export default Analytics