diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index fae5052..b5c6ef7 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -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() {
}>
} />
} />
+ } />
} />
} />
} />
diff --git a/frontend/src/components/Layout/SideNav.jsx b/frontend/src/components/Layout/SideNav.jsx
index 0cb6b86..5423a3a 100644
--- a/frontend/src/components/Layout/SideNav.jsx
+++ b/frontend/src/components/Layout/SideNav.jsx
@@ -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: , label: '仪表盘' },
+ { key: '/analytics', icon: , label: '收视分析' },
{ key: '/editor-desk', icon: , label: '责编录入' },
{ key: '/tps', icon: , label: 'TPS 选题策划' },
{ key: '/knowledge', icon: , 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
diff --git a/frontend/src/pages/Analytics/Analytics.css b/frontend/src/pages/Analytics/Analytics.css
new file mode 100644
index 0000000..820b3c6
--- /dev/null
+++ b/frontend/src/pages/Analytics/Analytics.css
@@ -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;
+ }
+}
\ No newline at end of file
diff --git a/frontend/src/pages/Analytics/Analytics.jsx b/frontend/src/pages/Analytics/Analytics.jsx
new file mode 100644
index 0000000..4f785de
--- /dev/null
+++ b/frontend/src/pages/Analytics/Analytics.jsx
@@ -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 `第 ${ep.episode_number} 期 · ${ep.program_name}
` +
+ `编导:${editor}
` +
+ `收视份额:${share}
` +
+ `收视率:${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 (
+
+ {/* 页面头部 + 年份选择器 */}
+
+
收视分析
+
+
+ {loading ? (
+
+ ) : !hasData ? (
+
+
+
+ ) : (
+ <>
+ {/* 指标卡行 */}
+
+
+
+
本年已播期数
+
{kpiMetrics.count}
+
+
+
+
+
平均收视份额
+
+ {kpiMetrics.avgShare || '—'}
+
+
+
+
+
+
基础目标完成率
+
{kpiMetrics.baseRate}
+
+
+
+
+
摸高目标完成率
+
{kpiMetrics.stretchRate}
+
+
+
+
+ {/* 收视走势折线图 */}
+
+
+
{selectedYear} 年收视走势
+
+ 拖拽底部滑块缩放 · 颜色只对收视份额
+
+
+
+
+
+
+ 绿=未达基础目标(待提升)
+
+
+
+ 蓝=未达摸高目标(达标)
+
+
+
+ 红=超过摸高目标(优秀)
+
+
+
+ >
+ )}
+
+ )
+}
+
+export default Analytics
\ No newline at end of file