Files
tps-dashboard/frontend/src/pages/Dashboard/Dashboard.jsx
T

212 lines
8.2 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect } from 'react'
import { Row, Col, Card, Avatar, Tooltip, Button } from 'antd'
import {
BarChartOutlined,
EyeOutlined,
CalendarOutlined,
FireOutlined,
PictureOutlined,
} from '@ant-design/icons'
import useAuthStore from '../../stores/authStore'
import { listEpisodes } from '../../services/episodeService'
import { listTargets } from '../../services/yearlyTargetService'
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 getBarHeight(share) {
return Math.round((share / 1.0) * 180)
}
function getShortTitle(title) {
return title.length > 12 ? title.slice(0, 12) + '...' : title
}
function Dashboard() {
const { user } = useAuthStore()
const [episodes, setEpisodes] = useState([])
const [targets, setTargets] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
Promise.all([
listEpisodes(9),
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'
return (
<div className="dashboard">
{/* 顶部 Banner */}
<div className="dashboard-banner">
<div className="banner-text">
<h2>本月收视最佳</h2>
{bestEpisode ? (
<p>
{bestEpisode.episode_number} {bestEpisode.program_name} ·
收视份额 {bestEpisode.audience_share}
</p>
) : (
<p>暂无收视数据</p>
)}
</div>
{/* 题图渐变占位 */}
<div className="banner-image-placeholder">
<div className="banner-placeholder-gradient" />
{showChangeCoverBtn && (
<Button
size="small"
icon={<PictureOutlined />}
disabled
className="change-cover-btn"
>
更换题图
</Button>
)}
<span className="placeholder-label">敬请期待</span>
</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">
<span className="kpi-value">{displayEpisodes.filter(e => e.audience_share).length || '--'}</span>
<span className="kpi-label"> 9 期已录</span>
</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">
<span className="kpi-value">
{bestEpisode ? (bestEpisode.audience_share * 100).toFixed(1) + '%' : '--'}
</span>
<span className="kpi-label">最佳收视份额</span>
</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">
<span className="kpi-value">{targets.length || '--'}</span>
<span className="kpi-label">年度目标条数</span>
</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 }} />
<p>热点雷达 · Phase 4c</p>
<small>编导个人首页核心功能Phase 4c 实施</small>
</div>
</Card>
</Col>
{/* 近 9 期收视柱图 */}
<Col span={8}>
<Card className="content-card" title="近 9 期收视" loading={loading}>
<div className="chart-container">
<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'
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>
</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>
</Card>
</Col>
{/* 排播计划 */}
<Col span={8}>
<Card className="content-card" title="未来排播计划">
<div className="placeholder-text">
<CalendarOutlined style={{ fontSize: 32, color: '#ccc', marginBottom: 8 }} />
<p>排播计划 · Phase 4b</p>
<small>甘特图排期使用 frappe-gantt 实现Phase 4b 开发</small>
</div>
</Card>
</Col>
</Row>
</div>
)
}
export default Dashboard