343 lines
13 KiB
React
343 lines
13 KiB
React
import { useState, useEffect } from 'react'
|
||
import { Row, Col, Card, Avatar, Tooltip, Button, Spin } from 'antd'
|
||
import {
|
||
EyeOutlined,
|
||
FireOutlined,
|
||
CalendarOutlined,
|
||
BarChartOutlined,
|
||
PictureOutlined,
|
||
UploadOutlined,
|
||
LineChartOutlined,
|
||
AimOutlined,
|
||
} from '@ant-design/icons'
|
||
import useAuthStore from '../../stores/authStore'
|
||
import { listEpisodes } from '../../services/episodeService'
|
||
import { listTargets } from '../../services/yearlyTargetService'
|
||
import { getCurrentCover, getUpcomingSchedules } from '../../services/dashboardService'
|
||
import ChangeCoverModal from './ChangeCoverModal'
|
||
import './Dashboard.css'
|
||
|
||
/**
|
||
* 收视份额颜色判定(极易写反,禁止凭直觉):
|
||
* audience_share > stretch_target → 🔴 红(优秀)
|
||
* base_target ≤ audience_share ≤ stretch_target → 🔵 蓝(达标)
|
||
* audience_share < base_target → 🟢 绿(待提升)
|
||
* 判色取该期 air_date 所属年份的 yearly_targets 行(不是当前年)。
|
||
*/
|
||
function getShareColor(share, targets, airYear) {
|
||
const n = Number(share)
|
||
const target = (targets || []).find(t => Number(t.year) === Number(airYear))
|
||
if (!target) return '#999'
|
||
if (isNaN(n)) return '#999'
|
||
const stretch = Number(target.stretch_target)
|
||
const base = Number(target.base_target)
|
||
if (n > stretch) return '#c0584f' // 红=超过摸高目标(优秀)
|
||
if (n >= base) return '#5b8db8' // 蓝=未达摸高目标(达标)
|
||
return '#7aa874' // 绿=未达基础目标(待提升)
|
||
}
|
||
|
||
function getColorLabel(share, targets, airYear) {
|
||
const n = Number(share)
|
||
const target = (targets || []).find(t => Number(t.year) === Number(airYear))
|
||
if (!target) return '未知'
|
||
const stretch = Number(target.stretch_target)
|
||
const base = Number(target.base_target)
|
||
if (n > stretch) return '优秀'
|
||
if (n >= base) return '达标'
|
||
return '待提升'
|
||
}
|
||
|
||
function getBarHeight(share) {
|
||
return Math.round((Number(share) / 1.0) * 160)
|
||
}
|
||
|
||
function getShortTitle(title) {
|
||
if (!title) return '?'
|
||
return title.length > 9 ? title.slice(0, 9) + '...' : title
|
||
}
|
||
|
||
/**
|
||
* 计算当年完成率(纯前端,量纲已确认为 0~1 小数直接相除)
|
||
* - "当年"= 当前自然年(new Date().getFullYear())
|
||
* - avg(audience_share) 当年已录期 ÷ base/stretch_target
|
||
* - 无数据时始终返回 { base: '—', stretch: '—' }
|
||
* - 完成率可 >100% 正常显示
|
||
*/
|
||
function calcCompletionRate(episodes, targets) {
|
||
const yearInt = new Date().getFullYear()
|
||
const yearEpisodes = episodes.filter(e => {
|
||
const airYear = new Date(e.air_date).getFullYear()
|
||
return airYear === yearInt && e.audience_share != null
|
||
})
|
||
const target = (targets || []).find(t => Number(t.year) === yearInt)
|
||
|
||
if (!yearEpisodes.length || !target) return { base: '—', stretch: '—' }
|
||
|
||
const avgShare = yearEpisodes.reduce((sum, e) => sum + Number(e.audience_share), 0) / yearEpisodes.length
|
||
const baseRate = avgShare / Number(target.base_target)
|
||
const stretchRate = avgShare / Number(target.stretch_target)
|
||
return {
|
||
base: (baseRate * 100).toFixed(1) + '%',
|
||
stretch: (stretchRate * 100).toFixed(1) + '%',
|
||
}
|
||
}
|
||
|
||
function Dashboard() {
|
||
const { user } = useAuthStore()
|
||
const [episodes, setEpisodes] = useState([])
|
||
const [targets, setTargets] = useState([])
|
||
const [cover, setCover] = useState({ cover_path: null, episode_number: null, episode_title: null })
|
||
const [schedules, setSchedules] = useState([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [coverModalOpen, setCoverModalOpen] = useState(false)
|
||
|
||
const showChangeCoverBtn = user?.role === 'zhipianren' || user?.role === 'zebian'
|
||
|
||
useEffect(() => {
|
||
Promise.all([
|
||
listEpisodes(50),
|
||
listTargets(),
|
||
getCurrentCover(),
|
||
getUpcomingSchedules(6),
|
||
]).then(([epData, tgtData, coverData, schedData]) => {
|
||
const sorted = (epData || []).sort((a, b) => new Date(b.air_date) - new Date(a.air_date))
|
||
setEpisodes(sorted)
|
||
setTargets(tgtData || [])
|
||
setCover(coverData || { cover_path: null, episode_number: null, episode_title: null })
|
||
setSchedules(Array.isArray(schedData) ? schedData : [])
|
||
setLoading(false)
|
||
}).catch(() => {
|
||
setLoading(false)
|
||
})
|
||
}, [])
|
||
|
||
// 显示最近有份额数据的期次(最多12个)
|
||
const displayEpisodes = episodes
|
||
.filter(e => e.audience_share != null)
|
||
.slice(0, 12)
|
||
|
||
const bestEpisode = [...displayEpisodes].sort((a, b) => Number(b.audience_share) - Number(a.audience_share))[0]
|
||
|
||
const handleCoverUploaded = (newCover) => {
|
||
setCover(newCover)
|
||
setCoverModalOpen(false)
|
||
}
|
||
|
||
// KPI 数据(扩为 5 项:原有 3 项 + 年度完成率 2 项)
|
||
const completion = calcCompletionRate(episodes, targets)
|
||
const kpiData = [
|
||
{
|
||
icon: <EyeOutlined style={{ color: '#6b8e6b', fontSize: 14 }} />,
|
||
bg: '#e8f5e9',
|
||
label: '近12期已录',
|
||
value: displayEpisodes.length || '--',
|
||
},
|
||
{
|
||
icon: <FireOutlined style={{ color: '#ff9800', fontSize: 14 }} />,
|
||
bg: '#fff3e0',
|
||
label: '最佳份额',
|
||
value: bestEpisode ? (Number(bestEpisode.audience_share) * 100).toFixed(2) + '%' : '--',
|
||
},
|
||
{
|
||
icon: <CalendarOutlined style={{ color: '#2196f3', fontSize: 14 }} />,
|
||
bg: '#e3f2fd',
|
||
label: '年度目标',
|
||
value: targets.length || '--',
|
||
},
|
||
{
|
||
icon: <LineChartOutlined style={{ color: '#7b5e9e', fontSize: 14 }} />,
|
||
bg: '#f3e5f5',
|
||
label: '基础目标完成率',
|
||
value: completion.base,
|
||
},
|
||
{
|
||
icon: <AimOutlined style={{ color: '#c0584f', fontSize: 14 }} />,
|
||
bg: '#fce4ec',
|
||
label: '摸高目标完成率',
|
||
value: completion.stretch,
|
||
},
|
||
]
|
||
|
||
return (
|
||
<div className="dashboard">
|
||
{/* ===== Banner ===== */}
|
||
<div className="dashboard-banner">
|
||
{/* 题图底图 或 默认渐变 */}
|
||
{cover.cover_path ? (
|
||
<img
|
||
src={cover.cover_path}
|
||
alt="题图"
|
||
className="banner-bg"
|
||
/>
|
||
) : (
|
||
<div className="banner-default-bg" />
|
||
)}
|
||
{/* 暗化渐变层 */}
|
||
<div className="banner-gradient" />
|
||
{/* 右上角 Logo */}
|
||
<img
|
||
src="/src/assets/junshikeji_logo.png"
|
||
alt="栏目logo"
|
||
className="banner-logo"
|
||
onError={e => { e.target.style.display = 'none' }}
|
||
/>
|
||
{/* 左侧文字 */}
|
||
<div className="banner-text">
|
||
<p className="banner-eyebrow">本月收视最佳·题图</p>
|
||
<h2 className="banner-title">
|
||
{cover.episode_number
|
||
? `第 ${cover.episode_number} 期`
|
||
: bestEpisode
|
||
? `第 ${bestEpisode.episode_number} 期`
|
||
: '暂无收视数据'}
|
||
{cover.episode_title || (bestEpisode ? ` · ${bestEpisode.program_name}` : '')}
|
||
</h2>
|
||
<p className="banner-subtitle">
|
||
{bestEpisode
|
||
? `收视份额 ${Number(bestEpisode.audience_share).toFixed(4)} · 当月最高`
|
||
: '暂无收视数据'}
|
||
</p>
|
||
<p className="banner-hint">建议:可选用近期收视表现好的节目海报</p>
|
||
</div>
|
||
{/* 右下角换图按钮 */}
|
||
{showChangeCoverBtn && (
|
||
<Button
|
||
className="change-cover-btn"
|
||
icon={<PictureOutlined />}
|
||
onClick={() => setCoverModalOpen(true)}
|
||
>
|
||
更换题图
|
||
</Button>
|
||
)}
|
||
</div>
|
||
|
||
{/* ===== KPI 细条 ===== */}
|
||
<div className="kpi-strip">
|
||
{kpiData.map((item, i) => (
|
||
<div key={i} className="kpi-chip">
|
||
<div className="kpi-icon" style={{ background: item.bg }}>
|
||
{item.icon}
|
||
</div>
|
||
<div>
|
||
<p className="kpi-label">{item.label}</p>
|
||
<p className="kpi-value">{item.value}</p>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* ===== 近9期收视柱图(全宽) ===== */}
|
||
<div className="bar-chart-card">
|
||
<div className="bar-chart-header">
|
||
<h3 className="bar-chart-title">近 12 期收视份额</h3>
|
||
<span className="bar-chart-hint">悬浮看编导/期次/收视率·颜色只对收视份额</span>
|
||
</div>
|
||
{loading ? (
|
||
<div className="chart-loading"><Spin size="small" /> 加载中...</div>
|
||
) : (
|
||
<>
|
||
<div className="bars-wrapper">
|
||
{displayEpisodes.map((ep) => {
|
||
const airYear = new Date(ep.air_date).getFullYear()
|
||
const color = ep.audience_share != null
|
||
? getShareColor(ep.audience_share, targets, airYear)
|
||
: '#ccc'
|
||
const colorLabel = ep.audience_share != null
|
||
? getColorLabel(ep.audience_share, targets, airYear)
|
||
: '无数据'
|
||
const tooltipContent = `
|
||
<strong>第 ${ep.episode_number} 期 · ${ep.program_name}</strong><br/>
|
||
编导:${ep.editor_name_snapshot || '未知'}<br/>
|
||
收视份额:${ep.audience_share != null ? Number(ep.audience_share).toFixed(4) : '无数据'}(${colorLabel})<br/>
|
||
收视率:${ep.audience_rating != null ? Number(ep.audience_rating).toFixed(4) : '无数据'}
|
||
`
|
||
return (
|
||
<div key={ep.id} className="bar-item">
|
||
<div className="bar-number" style={{ color }}>
|
||
{ep.audience_share != null ? Number(ep.audience_share).toFixed(2) : '--'}
|
||
</div>
|
||
<Tooltip title={<span dangerouslySetInnerHTML={{ __html: tooltipContent }} />}>
|
||
<div
|
||
className="bar"
|
||
style={{
|
||
height: ep.audience_share != null ? getBarHeight(ep.audience_share) : 20,
|
||
backgroundColor: color,
|
||
}}
|
||
/>
|
||
</Tooltip>
|
||
<div className="bar-program" title={ep.program_name}>
|
||
{getShortTitle(ep.program_name)}
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
<div className="chart-legend">
|
||
<span className="legend-item">
|
||
<span className="dot" style={{ background: '#7aa874' }}></span>绿=未达基础目标(待提升)
|
||
</span>
|
||
<span className="legend-item">
|
||
<span className="dot" style={{ background: '#5b8db8' }}></span>蓝=未达摸高目标(达标)
|
||
</span>
|
||
<span className="legend-item">
|
||
<span className="dot" style={{ background: '#c0584f' }}></span>红=超过摸高目标(优秀)
|
||
</span>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* ===== 下方两栏 ===== */}
|
||
<div className="bottom-row">
|
||
{/* 左:雷达占位 */}
|
||
<div className="side-card">
|
||
<h3 className="side-card-title">军事科技热点雷达</h3>
|
||
<div className="radar-placeholder">
|
||
<span className="radar-placeholder-label">Phase 4c·占位</span>
|
||
<div className="radar-sample-items">
|
||
<div className="radar-sample-item">1. 神舟飞船对接空间站</div>
|
||
<div className="radar-sample-item">2. 新型无人机集群技术</div>
|
||
</div>
|
||
<BarChartOutlined style={{ fontSize: 28, color: '#ccc', marginBottom: 4 }} />
|
||
<p>真实抓取 Phase 4c 实现</p>
|
||
<small>编导个人首页核心功能</small>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 右:排播计划 */}
|
||
<div className="side-card">
|
||
<h3 className="side-card-title">未来排播计划</h3>
|
||
{loading ? (
|
||
<div style={{ textAlign: 'center', color: '#999', padding: '24px 0' }}><Spin size="small" /></div>
|
||
) : schedules.length > 0 ? (
|
||
<ul className="schedule-list">
|
||
{schedules.map((item, i) => (
|
||
<li key={i} className="schedule-item">
|
||
<span className="schedule-ep">第{item.episode_number}期</span>
|
||
<span className="schedule-name" title={item.program_name}>{item.program_name}</span>
|
||
<span className="schedule-date">{item.planned_air_date}</span>
|
||
<span className="schedule-editor">{item.editor_name_snapshot || ''}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
) : (
|
||
<div style={{ textAlign: 'center', color: '#999', padding: '24px 0' }}>
|
||
<CalendarOutlined style={{ fontSize: 28, color: '#ccc', marginBottom: 8 }} />
|
||
<p style={{ margin: 0, fontSize: 12 }}>暂无排播数据</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 换图 Modal */}
|
||
<ChangeCoverModal
|
||
open={coverModalOpen}
|
||
onClose={() => setCoverModalOpen(false)}
|
||
onUploaded={handleCoverUploaded}
|
||
episodes={episodes.slice(0, 20)}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default Dashboard |