2026-05-21 19:47:36 +08:00
|
|
|
|
import { useState, useEffect } from 'react'
|
|
|
|
|
|
import { Row, Col, Card, Avatar, Tooltip, Button } from 'antd'
|
2026-05-15 09:46:42 +08:00
|
|
|
|
import {
|
|
|
|
|
|
BarChartOutlined,
|
|
|
|
|
|
EyeOutlined,
|
|
|
|
|
|
CalendarOutlined,
|
|
|
|
|
|
FireOutlined,
|
2026-05-21 19:47:36 +08:00
|
|
|
|
PictureOutlined,
|
2026-05-15 09:46:42 +08:00
|
|
|
|
} from '@ant-design/icons'
|
2026-05-21 19:47:36 +08:00
|
|
|
|
import useAuthStore from '../../stores/authStore'
|
|
|
|
|
|
import { listEpisodes } from '../../services/episodeService'
|
|
|
|
|
|
import { listTargets } from '../../services/yearlyTargetService'
|
2026-05-15 09:46:42 +08:00
|
|
|
|
import './Dashboard.css'
|
|
|
|
|
|
|
2026-05-21 19:47:36 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 收视份额颜色判定(极易写反,禁止凭直觉):
|
|
|
|
|
|
* audience_share > stretch_target → 🔴 红(优秀)
|
|
|
|
|
|
* base_target ≤ audience_share ≤ stretch_target → 🔵 蓝(达标)
|
|
|
|
|
|
* audience_share < base_target → 🟢 绿(待提升)
|
|
|
|
|
|
* 判色取该期 air_date 所属年份的 yearly_targets 行(不是当前年)。
|
|
|
|
|
|
*/
|
|
|
|
|
|
function getShareColor(share, targets, airYear) {
|
2026-05-21 20:45:48 +08:00
|
|
|
|
// 强制转数值,避免后端返回字符串导致数值比较失败
|
|
|
|
|
|
const n = Number(share)
|
|
|
|
|
|
const target = targets.find(t => Number(t.year) === Number(airYear))
|
2026-05-21 19:47:36 +08:00
|
|
|
|
if (!target) return '#999'
|
2026-05-21 20:45:48 +08:00
|
|
|
|
if (isNaN(n)) return '#999'
|
|
|
|
|
|
const stretch = Number(target.stretch_target)
|
|
|
|
|
|
const base = Number(target.base_target)
|
2026-05-22 18:44:14 +08:00
|
|
|
|
if (n > stretch) return '#c0584f' // 红=超过摸高目标(优秀)
|
|
|
|
|
|
if (n >= base) return '#5b8db8' // 蓝=未达摸高目标(达标)
|
|
|
|
|
|
return '#7aa874' // 绿=未达基础目标(待提升)
|
2026-05-15 09:46:42 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getBarHeight(share) {
|
|
|
|
|
|
return Math.round((share / 1.0) * 180)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getShortTitle(title) {
|
|
|
|
|
|
return title.length > 12 ? title.slice(0, 12) + '...' : title
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function Dashboard() {
|
2026-05-21 19:47:36 +08:00
|
|
|
|
const { user } = useAuthStore()
|
|
|
|
|
|
const [episodes, setEpisodes] = useState([])
|
|
|
|
|
|
const [targets, setTargets] = useState([])
|
|
|
|
|
|
const [loading, setLoading] = useState(true)
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
Promise.all([
|
2026-05-21 20:29:50 +08:00
|
|
|
|
listEpisodes(9),
|
2026-05-21 19:47:36 +08:00
|
|
|
|
listTargets(),
|
|
|
|
|
|
]).then(([epData, tgtData]) => {
|
|
|
|
|
|
// 按 air_date 倒序取前 9
|
|
|
|
|
|
const sorted = (epData || []).sort((a, b) => new Date(b.air_date) - new Date(a.air_date)).slice(0, 9)
|
|
|
|
|
|
setEpisodes(sorted)
|
|
|
|
|
|
setTargets(tgtData || [])
|
|
|
|
|
|
setLoading(false)
|
|
|
|
|
|
}).catch(() => {
|
|
|
|
|
|
setLoading(false)
|
|
|
|
|
|
})
|
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
|
|
// 显示最近有份额数据的期次(最多9个,不够用真实数据补位)
|
|
|
|
|
|
const hasShare = episodes.filter(e => e.audience_share != null)
|
|
|
|
|
|
const displayEpisodes = hasShare.length >= 5 ? hasShare.slice(0, 9) : episodes.slice(0, Math.max(9, hasShare.length || 5))
|
|
|
|
|
|
|
|
|
|
|
|
const bestEpisode = [...displayEpisodes].filter(e => e.audience_share != null).sort((a, b) => b.audience_share - a.audience_share)[0]
|
|
|
|
|
|
|
|
|
|
|
|
const showChangeCoverBtn = user?.role === 'zhipianren' || user?.role === 'zebian'
|
|
|
|
|
|
|
2026-05-15 09:46:42 +08:00
|
|
|
|
return (
|
|
|
|
|
|
<div className="dashboard">
|
|
|
|
|
|
{/* 顶部 Banner */}
|
|
|
|
|
|
<div className="dashboard-banner">
|
|
|
|
|
|
<div className="banner-text">
|
|
|
|
|
|
<h2>本月收视最佳</h2>
|
2026-05-21 19:47:36 +08:00
|
|
|
|
{bestEpisode ? (
|
|
|
|
|
|
<p>
|
|
|
|
|
|
第 {bestEpisode.episode_number} 期 {bestEpisode.program_name} ·
|
|
|
|
|
|
收视份额 {bestEpisode.audience_share}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<p>暂无收视数据</p>
|
|
|
|
|
|
)}
|
2026-05-15 09:46:42 +08:00
|
|
|
|
</div>
|
2026-05-21 19:47:36 +08:00
|
|
|
|
{/* 题图渐变占位 */}
|
2026-05-15 09:46:42 +08:00
|
|
|
|
<div className="banner-image-placeholder">
|
2026-05-21 19:47:36 +08:00
|
|
|
|
<div className="banner-placeholder-gradient" />
|
|
|
|
|
|
{showChangeCoverBtn && (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
icon={<PictureOutlined />}
|
|
|
|
|
|
disabled
|
|
|
|
|
|
className="change-cover-btn"
|
|
|
|
|
|
>
|
|
|
|
|
|
更换题图
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<span className="placeholder-label">敬请期待</span>
|
2026-05-15 09:46:42 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* KPI Cards */}
|
|
|
|
|
|
<Row gutter={16} className="kpi-row">
|
|
|
|
|
|
<Col span={8}>
|
|
|
|
|
|
<Card className="kpi-card">
|
|
|
|
|
|
<div className="kpi-icon" style={{ background: '#e8f5e9' }}>
|
|
|
|
|
|
<EyeOutlined style={{ color: '#6b8e6b', fontSize: 24 }} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="kpi-info">
|
2026-05-21 19:47:36 +08:00
|
|
|
|
<span className="kpi-value">{displayEpisodes.filter(e => e.audience_share).length || '--'}</span>
|
|
|
|
|
|
<span className="kpi-label">近 9 期已录</span>
|
2026-05-15 09:46:42 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
</Col>
|
|
|
|
|
|
<Col span={8}>
|
|
|
|
|
|
<Card className="kpi-card">
|
|
|
|
|
|
<div className="kpi-icon" style={{ background: '#fff3e0' }}>
|
|
|
|
|
|
<FireOutlined style={{ color: '#ff9800', fontSize: 24 }} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="kpi-info">
|
2026-05-21 19:47:36 +08:00
|
|
|
|
<span className="kpi-value">
|
|
|
|
|
|
{bestEpisode ? (bestEpisode.audience_share * 100).toFixed(1) + '%' : '--'}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span className="kpi-label">最佳收视份额</span>
|
2026-05-15 09:46:42 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
</Col>
|
|
|
|
|
|
<Col span={8}>
|
|
|
|
|
|
<Card className="kpi-card">
|
|
|
|
|
|
<div className="kpi-icon" style={{ background: '#e3f2fd' }}>
|
|
|
|
|
|
<CalendarOutlined style={{ color: '#2196f3', fontSize: 24 }} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="kpi-info">
|
2026-05-21 19:47:36 +08:00
|
|
|
|
<span className="kpi-value">{targets.length || '--'}</span>
|
|
|
|
|
|
<span className="kpi-label">年度目标条数</span>
|
2026-05-15 09:46:42 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
</Col>
|
|
|
|
|
|
</Row>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 三卡片区域 */}
|
|
|
|
|
|
<Row gutter={16} className="cards-row">
|
|
|
|
|
|
{/* 热点雷达 */}
|
|
|
|
|
|
<Col span={8}>
|
|
|
|
|
|
<Card className="content-card" title="热点雷达">
|
|
|
|
|
|
<div className="placeholder-text">
|
|
|
|
|
|
<BarChartOutlined style={{ fontSize: 32, color: '#ccc', marginBottom: 8 }} />
|
2026-05-21 19:47:36 +08:00
|
|
|
|
<p>热点雷达 · Phase 4c</p>
|
|
|
|
|
|
<small>编导个人首页核心功能,Phase 4c 实施</small>
|
2026-05-15 09:46:42 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
</Col>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 近 9 期收视柱图 */}
|
|
|
|
|
|
<Col span={8}>
|
2026-05-21 19:47:36 +08:00
|
|
|
|
<Card className="content-card" title="近 9 期收视" loading={loading}>
|
2026-05-15 09:46:42 +08:00
|
|
|
|
<div className="chart-container">
|
|
|
|
|
|
<div className="bars-wrapper">
|
2026-05-21 19:47:36 +08:00
|
|
|
|
{displayEpisodes.map((ep) => {
|
|
|
|
|
|
const airYear = new Date(ep.air_date).getFullYear()
|
|
|
|
|
|
const color = ep.audience_share != null
|
|
|
|
|
|
? getShareColor(ep.audience_share, targets, airYear)
|
|
|
|
|
|
: '#ccc'
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div key={ep.id} className="bar-item">
|
|
|
|
|
|
<Tooltip title={`${ep.program_name} · ${ep.audience_share ?? '无数据'}`}>
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="bar"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
height: ep.audience_share != null ? getBarHeight(ep.audience_share) : 20,
|
|
|
|
|
|
backgroundColor: color,
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</Tooltip>
|
|
|
|
|
|
<div className="bar-label-top">{getShortTitle(ep.program_name)}</div>
|
|
|
|
|
|
<div className="bar-label-bottom">
|
|
|
|
|
|
<Avatar size={20} style={{ fontSize: 10 }}>
|
|
|
|
|
|
{(ep.editor_name_snapshot || '?')[0]}
|
|
|
|
|
|
</Avatar>
|
|
|
|
|
|
<span>{ep.editor_name_snapshot || '未知'}</span>
|
|
|
|
|
|
</div>
|
2026-05-15 09:46:42 +08:00
|
|
|
|
</div>
|
2026-05-21 19:47:36 +08:00
|
|
|
|
)
|
|
|
|
|
|
})}
|
2026-05-15 09:46:42 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="chart-legend">
|
2026-05-22 18:44:14 +08:00
|
|
|
|
<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>
|
2026-05-15 09:46:42 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
</Col>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 排播计划 */}
|
|
|
|
|
|
<Col span={8}>
|
2026-05-21 19:47:36 +08:00
|
|
|
|
<Card className="content-card" title="未来排播计划">
|
2026-05-15 09:46:42 +08:00
|
|
|
|
<div className="placeholder-text">
|
|
|
|
|
|
<CalendarOutlined style={{ fontSize: 32, color: '#ccc', marginBottom: 8 }} />
|
2026-05-21 19:47:36 +08:00
|
|
|
|
<p>排播计划 · Phase 4b</p>
|
|
|
|
|
|
<small>甘特图排期使用 frappe-gantt 实现,Phase 4b 开发</small>
|
2026-05-15 09:46:42 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
</Col>
|
|
|
|
|
|
</Row>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default Dashboard
|