feat: 收视分析页面(指标卡 + 走势图 + 路由导航)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user