From 8d880f06cf4d589a2e563d8e3699010f50427e98 Mon Sep 17 00:00:00 2001 From: simonkoson <28867558@qq.com> Date: Fri, 3 Jul 2026 10:21:56 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=B6=E8=A7=86=E5=88=86=E6=9E=90?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=EF=BC=88=E6=8C=87=E6=A0=87=E5=8D=A1=20+=20?= =?UTF-8?q?=E8=B5=B0=E5=8A=BF=E5=9B=BE=20+=20=E8=B7=AF=E7=94=B1=E5=AF=BC?= =?UTF-8?q?=E8=88=AA=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.jsx | 2 + frontend/src/components/Layout/SideNav.jsx | 4 + frontend/src/pages/Analytics/Analytics.css | 159 +++++++++++ frontend/src/pages/Analytics/Analytics.jsx | 318 +++++++++++++++++++++ 4 files changed, 483 insertions(+) create mode 100644 frontend/src/pages/Analytics/Analytics.css create mode 100644 frontend/src/pages/Analytics/Analytics.jsx 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 ( +
+ {/* 页面头部 + 年份选择器 */} +
+

收视分析

+