feat: 仪表盘布局重排,新增题图上传能力

This commit is contained in:
simonkoson
2026-05-25 09:47:21 +08:00
parent a6e8cc25ec
commit d774370a4b
5 changed files with 688 additions and 262 deletions
@@ -0,0 +1,151 @@
import { useState, useEffect } from 'react'
import { Modal, Upload, Select, Button, message } from 'antd'
import { UploadOutlined, InboxOutlined } from '@ant-design/icons'
import { uploadCover } from '../../services/dashboardService'
const { Dragger } = Upload
/**
* 更换题图 Modal
*
* 权限:仅 zhipianren / zebian 可用(由父组件控制显示/隐藏)
*
* @param {boolean} open - Modal 是否显示
* @param {Function} onClose - 关闭回调
* @param {Function} onUploaded - 上传成功回调,传入新题图对象
* @param {Array} episodes - 期次列表(用于下拉选择关联期次)
*/
function ChangeCoverModal({ open, onClose, onUploaded, episodes = [] }) {
const [file, setFile] = useState(null)
const [previewUrl, setPreviewUrl] = useState(null)
const [selectedEpisode, setSelectedEpisode] = useState(null)
const [uploading, setUploading] = useState(false)
// 每次打开时重置状态
useEffect(() => {
if (open) {
setFile(null)
setPreviewUrl(null)
setSelectedEpisode(null)
setUploading(false)
}
}, [open])
// 选择文件后生成预览
const handleFileChange = (info) => {
const rawFile = info.file.originFileObj || info.file
if (!rawFile) return
setFile(rawFile)
const url = URL.createObjectURL(rawFile)
setPreviewUrl(url)
}
const handleUpload = async () => {
if (!file) {
message.warning('请先选择图片')
return
}
if (!selectedEpisode) {
message.warning('请选择关联期次')
return
}
setUploading(true)
try {
const ep = episodes.find(e => String(e.episode_number) === String(selectedEpisode))
const result = await uploadCover(file, Number(selectedEpisode), ep?.program_name || `${selectedEpisode}`)
message.success('题图上传成功')
onUploaded({
cover_path: result.cover_path,
episode_number: Number(selectedEpisode),
episode_title: ep?.program_name || `${selectedEpisode}`,
})
} catch (err) {
const errMsg = err?.response?.data?.detail || '上传失败,请重试'
message.error(errMsg)
} finally {
setUploading(false)
}
}
const episodeOptions = episodes.map(ep => ({
value: String(ep.episode_number),
label: `${ep.episode_number} 期 · ${ep.program_name}`,
}))
return (
<Modal
title="更换题图"
open={open}
onCancel={onClose}
footer={null}
width={480}
destroyOnClose
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* 图片上传区域 */}
<div>
<p style={{ fontSize: 13, color: '#6b6b6b', marginBottom: 8 }}>上传海报图片PNG/JPG/WEBP不超过 5MB</p>
<Dragger
accept=".png,.jpg,.jpeg,.webp"
maxCount={1}
beforeUpload={() => false}
onChange={handleFileChange}
showUploadList={false}
style={{ borderRadius: 12 }}
>
{previewUrl ? (
<div style={{ padding: '8px 0' }}>
<img
src={previewUrl}
alt="预览"
style={{ maxWidth: '100%', maxHeight: 160, borderRadius: 8 }}
/>
<p style={{ marginTop: 8, color: '#6b8e6b', fontSize: 13 }}>点击或拖拽更换图片</p>
</div>
) : (
<>
<p className="ant-upload-drag-icon" style={{ marginBottom: 8 }}>
<InboxOutlined style={{ fontSize: 32, color: '#6b8e6b' }} />
</p>
<p style={{ color: '#6b6b6b', fontSize: 13 }}>点击或拖拽图片到此处上传</p>
</>
)}
</Dragger>
</div>
{/* 关联期次下拉 */}
<div>
<p style={{ fontSize: 13, color: '#6b6b6b', marginBottom: 8 }}>关联期次</p>
<Select
placeholder="请选择期次"
style={{ width: '100%' }}
value={selectedEpisode}
onChange={setSelectedEpisode}
options={episodeOptions}
showSearch
filterOption={(input, option) =>
option.label.toLowerCase().includes(input.toLowerCase())
}
/>
</div>
{/* 操作按钮 */}
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<Button onClick={onClose} disabled={uploading}>取消</Button>
<Button
type="primary"
icon={<UploadOutlined />}
loading={uploading}
onClick={handleUpload}
disabled={!file || !selectedEpisode}
>
确认上传
</Button>
</div>
</div>
</Modal>
)
}
export default ChangeCoverModal
+260 -117
View File
@@ -1,149 +1,173 @@
.dashboard { .dashboard {
max-width: 1200px; max-width: 1200px;
padding: 12px;
} }
/* Banner */ /* ===== Banner ===== */
.dashboard-banner { .dashboard-banner {
background: #fff; width: 100%;
border-radius: var(--radius-card); height: 150px;
padding: 24px; border-radius: 14px;
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
}
.banner-text h2 {
margin: 0 0 8px;
font-size: 20px;
color: var(--color-text-primary);
}
.banner-text p {
margin: 0;
color: var(--color-text-secondary);
}
.banner-image-placeholder {
width: 200px;
height: 100px;
background: var(--color-accent-green);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-primary-green);
font-size: 12px;
position: relative;
overflow: hidden; overflow: hidden;
position: relative;
background: #3a4a3a;
margin-bottom: 12px;
} }
.banner-placeholder-gradient { .banner-bg {
position: absolute; position: absolute;
inset: 0; inset: 0;
background: linear-gradient(135deg, #6b8e6b 0%, #a8c89a 50%, #d4e6b5 100%); object-fit: cover;
width: 100%;
height: 100%;
}
.banner-gradient {
position: absolute;
inset: 0;
background: linear-gradient(
to right,
rgba(12, 16, 12, 0.86) 0%,
rgba(12, 16, 12, 0.6) 35%,
rgba(12, 16, 12, 0.08) 70%,
transparent 100%
);
}
.banner-logo {
position: absolute;
top: 16px;
right: 16px;
height: 40px;
width: auto;
opacity: 0.9;
}
.banner-text {
position: absolute;
bottom: 20px;
left: 20px;
z-index: 2;
}
.banner-eyebrow {
font-size: 11px;
color: #cdd8c8;
letter-spacing: 1px;
margin-bottom: 4px;
}
.banner-title {
font-size: 21px;
font-weight: 500;
color: #ffffff;
margin: 0 0 4px;
line-height: 1.2;
}
.banner-subtitle {
font-size: 12px;
color: #d6e0d0;
margin: 0 0 4px;
}
.banner-hint {
font-size: 11px;
color: #bcc8b6;
margin: 0;
} }
.change-cover-btn { .change-cover-btn {
position: absolute; position: absolute;
bottom: 6px; bottom: 16px;
right: 6px; right: 16px;
z-index: 2; z-index: 3;
} }
.placeholder-label { /* 默认渐变底图(无题图时) */
.banner-default-bg {
position: absolute; position: absolute;
top: 50%; inset: 0;
left: 50%; background: linear-gradient(135deg, #3a4a3a 0%, #5a7a5a 50%, #7a9a6a 100%);
transform: translate(-50%, -50%);
z-index: 2;
color: rgba(255,255,255,0.85);
font-size: 13px;
font-weight: 600;
letter-spacing: 2px;
} }
/* KPI Cards */ /* ===== KPI Strip ===== */
.kpi-row { .kpi-strip {
margin-bottom: 24px; display: flex;
gap: 12px;
margin-bottom: 12px;
} }
.kpi-card { .kpi-chip {
border-radius: var(--radius-card); flex: 1;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 16px; gap: 10px;
background: #fff;
border-radius: 12px;
padding: 10px 16px;
height: 48px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
} }
.kpi-icon { .kpi-icon {
width: 56px; width: 28px;
height: 56px; height: 28px;
border-radius: 12px; border-radius: 8px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} flex-shrink: 0;
.kpi-info {
display: flex;
flex-direction: column;
}
.kpi-value {
font-size: 24px;
font-weight: 600;
color: var(--color-text-primary);
} }
.kpi-label { .kpi-label {
font-size: 12px; font-size: 11px;
color: var(--color-text-secondary); color: #6b6b6b;
margin: 0;
white-space: nowrap;
} }
/* Content Cards */ .kpi-value {
.cards-row { font-size: 15px;
margin-bottom: 24px;
}
.content-card {
border-radius: var(--radius-card);
height: 100%;
}
.content-card .ant-card-head-title {
font-weight: 600; font-weight: 600;
color: #1a1a1a;
margin: 0;
} }
.placeholder-text { /* ===== Bar Chart Card ===== */
.bar-chart-card {
background: #fff;
border-radius: 14px;
padding: 16px;
margin-bottom: 12px;
}
.bar-chart-header {
display: flex; display: flex;
flex-direction: column; justify-content: space-between;
align-items: center; align-items: center;
justify-content: center; margin-bottom: 12px;
padding: 32px 16px;
text-align: center;
color: var(--color-text-secondary);
} }
.placeholder-text p { .bar-chart-title {
margin: 8px 0 4px; font-size: 15px;
font-weight: 500; font-weight: 500;
color: #3b4a3b;
margin: 0;
} }
.placeholder-text small { .bar-chart-hint {
color: #999; font-size: 11px;
} color: #888780;
/* Chart Container */
.chart-container {
padding: 8px 0;
} }
.bars-wrapper { .bars-wrapper {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: flex-end; align-items: flex-end;
height: 200px; height: 180px;
padding: 0 8px; padding: 0 4px;
gap: 4px;
} }
.bar-item { .bar-item {
@@ -151,41 +175,38 @@
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
flex: 1; flex: 1;
max-width: 40px; max-width: 42px;
min-width: 20px;
} }
.bar { .bar {
width: 24px; width: 100%;
border-radius: 4px 4px 0 0; max-width: 38px;
border-radius: 7px 7px 0 0;
transition: all 0.3s; transition: all 0.3s;
cursor: pointer; cursor: pointer;
position: relative;
} }
.bar-label-top { .bar-number {
font-size: 9px; font-size: 9px;
color: var(--color-text-secondary); font-weight: 600;
text-align: center; text-align: center;
margin-top: 4px; margin-bottom: 3px;
max-width: 40px; white-space: nowrap;
}
.bar-program {
font-size: 9px;
color: #6b6b6b;
text-align: center;
max-width: 42px;
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
}
.bar-label-bottom {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
margin-top: 4px; margin-top: 4px;
} }
.bar-label-bottom span {
font-size: 9px;
color: var(--color-text-secondary);
}
/* Chart Legend */
.chart-legend { .chart-legend {
display: flex; display: flex;
justify-content: center; justify-content: center;
@@ -200,11 +221,133 @@
align-items: center; align-items: center;
gap: 4px; gap: 4px;
font-size: 11px; font-size: 11px;
color: var(--color-text-secondary); color: #6b6b6b;
} }
.dot { .dot {
width: 8px; width: 8px;
height: 8px; height: 8px;
border-radius: 50%; border-radius: 50%;
}
/* ===== Bottom Two Columns ===== */
.bottom-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.side-card {
background: #fff;
border-radius: 14px;
padding: 16px;
}
.side-card-title {
font-size: 15px;
font-weight: 500;
color: #3b4a3b;
margin: 0 0 12px;
}
/* Radar placeholder card */
.radar-placeholder {
border: 1.5px dashed #c8d9b0;
border-radius: 12px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 120px;
padding: 16px;
position: relative;
}
.radar-placeholder-label {
position: absolute;
top: 8px;
right: 8px;
font-size: 10px;
color: #bcc8b6;
background: #fbf9f1;
padding: 2px 6px;
border-radius: 6px;
}
.radar-placeholder p {
font-size: 13px;
color: #6b6b6b;
margin: 8px 0 4px;
}
.radar-placeholder small {
font-size: 11px;
color: #999;
}
.radar-sample-items {
width: 100%;
margin-bottom: 8px;
}
.radar-sample-item {
font-size: 12px;
color: #4a5a4a;
padding: 4px 8px;
background: #f0f7ec;
border-radius: 6px;
margin-bottom: 4px;
}
/* Schedule list */
.schedule-list {
list-style: none;
padding: 0;
margin: 0;
}
.schedule-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid #f5f5f5;
font-size: 12px;
}
.schedule-item:last-child {
border-bottom: none;
}
.schedule-ep {
color: #1a1a1a;
font-weight: 500;
}
.schedule-name {
color: #4a5a4a;
flex: 1;
margin: 0 8px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.schedule-date {
color: #888780;
font-size: 11px;
}
.schedule-editor {
color: #6b6b6b;
font-size: 11px;
}
/* Loading state */
.chart-loading {
display: flex;
align-items: center;
justify-content: center;
height: 180px;
color: #999;
} }
+235 -145
View File
@@ -1,15 +1,18 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Row, Col, Card, Avatar, Tooltip, Button } from 'antd' import { Row, Col, Card, Avatar, Tooltip, Button, Spin } from 'antd'
import { import {
BarChartOutlined,
EyeOutlined, EyeOutlined,
CalendarOutlined,
FireOutlined, FireOutlined,
CalendarOutlined,
BarChartOutlined,
PictureOutlined, PictureOutlined,
UploadOutlined,
} from '@ant-design/icons' } from '@ant-design/icons'
import useAuthStore from '../../stores/authStore' import useAuthStore from '../../stores/authStore'
import { listEpisodes } from '../../services/episodeService' import { listEpisodes } from '../../services/episodeService'
import { listTargets } from '../../services/yearlyTargetService' import { listTargets } from '../../services/yearlyTargetService'
import { getCurrentCover, getUpcomingSchedules } from '../../services/dashboardService'
import ChangeCoverModal from './ChangeCoverModal'
import './Dashboard.css' import './Dashboard.css'
/** /**
@@ -20,23 +23,34 @@ import './Dashboard.css'
* 判色取该期 air_date 所属年份的 yearly_targets 行(不是当前年)。 * 判色取该期 air_date 所属年份的 yearly_targets 行(不是当前年)。
*/ */
function getShareColor(share, targets, airYear) { function getShareColor(share, targets, airYear) {
// 强制转数值,避免后端返回字符串导致数值比较失败
const n = Number(share) const n = Number(share)
const target = targets.find(t => Number(t.year) === Number(airYear)) const target = (targets || []).find(t => Number(t.year) === Number(airYear))
if (!target) return '#999' if (!target) return '#999'
if (isNaN(n)) return '#999' if (isNaN(n)) return '#999'
const stretch = Number(target.stretch_target) const stretch = Number(target.stretch_target)
const base = Number(target.base_target) const base = Number(target.base_target)
if (n > stretch) return '#c0584f' // 红=超过摸高目标(优秀) if (n > stretch) return '#c0584f' // 红=超过摸高目标(优秀)
if (n >= base) return '#5b8db8' // 蓝=未达摸高目标(达标) if (n >= base) return '#5b8db8' // 蓝=未达摸高目标(达标)
return '#7aa874' // 绿=未达基础目标(待提升) 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) { function getBarHeight(share) {
return Math.round((share / 1.0) * 180) return Math.round((Number(share) / 1.0) * 160)
} }
function getShortTitle(title) { function getShortTitle(title) {
if (!title) return '?'
return title.length > 12 ? title.slice(0, 12) + '...' : title return title.length > 12 ? title.slice(0, 12) + '...' : title
} }
@@ -44,167 +58,243 @@ function Dashboard() {
const { user } = useAuthStore() const { user } = useAuthStore()
const [episodes, setEpisodes] = useState([]) const [episodes, setEpisodes] = useState([])
const [targets, setTargets] = 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 [loading, setLoading] = useState(true)
const [coverModalOpen, setCoverModalOpen] = useState(false)
const showChangeCoverBtn = user?.role === 'zhipianren' || user?.role === 'zebian'
useEffect(() => { useEffect(() => {
Promise.all([ Promise.all([
listEpisodes(9), listEpisodes(50),
listTargets(), listTargets(),
]).then(([epData, tgtData]) => { getCurrentCover(),
// 按 air_date 倒序取前 9 getUpcomingSchedules(6),
const sorted = (epData || []).sort((a, b) => new Date(b.air_date) - new Date(a.air_date)).slice(0, 9) ]).then(([epData, tgtData, coverData, schedData]) => {
const sorted = (epData || []).sort((a, b) => new Date(b.air_date) - new Date(a.air_date))
setEpisodes(sorted) setEpisodes(sorted)
setTargets(tgtData || []) setTargets(tgtData || [])
setCover(coverData || { cover_path: null, episode_number: null, episode_title: null })
setSchedules(Array.isArray(schedData) ? schedData : [])
setLoading(false) setLoading(false)
}).catch(() => { }).catch(() => {
setLoading(false) setLoading(false)
}) })
}, []) }, [])
// 显示最近有份额数据的期次(最多9个,不够用真实数据补位 // 显示最近有份额数据的期次(最多9个)
const hasShare = episodes.filter(e => e.audience_share != null) const displayEpisodes = episodes
const displayEpisodes = hasShare.length >= 5 ? hasShare.slice(0, 9) : episodes.slice(0, Math.max(9, hasShare.length || 5)) .filter(e => e.audience_share != null)
.slice(0, 9)
const bestEpisode = [...displayEpisodes].filter(e => e.audience_share != null).sort((a, b) => b.audience_share - a.audience_share)[0] const bestEpisode = [...displayEpisodes].sort((a, b) => Number(b.audience_share) - Number(a.audience_share))[0]
const showChangeCoverBtn = user?.role === 'zhipianren' || user?.role === 'zebian' const handleCoverUploaded = (newCover) => {
setCover(newCover)
setCoverModalOpen(false)
}
// KPI 数据
const kpiData = [
{
icon: <EyeOutlined style={{ color: '#6b8e6b', fontSize: 14 }} />,
bg: '#e8f5e9',
label: '近9期已录',
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 || '--',
},
]
return ( return (
<div className="dashboard"> <div className="dashboard">
{/* 顶部 Banner */} {/* ===== Banner ===== */}
<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"> <div className="banner-text">
<h2>本月收视最佳</h2> <p className="banner-eyebrow">本月收视最佳·题图</p>
{bestEpisode ? ( <h2 className="banner-title">
<p> {cover.episode_number
{bestEpisode.episode_number} {bestEpisode.program_name} · ? `${cover.episode_number}`
收视份额 {bestEpisode.audience_share} : bestEpisode
</p> ? `${bestEpisode.episode_number}`
) : ( : '暂无收视数据'}
<p>暂无收视数据</p> {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> </div>
{/* 题图渐变占位 */} {/* 右下角换图按钮 */}
<div className="banner-image-placeholder"> {showChangeCoverBtn && (
<div className="banner-placeholder-gradient" /> <Button
{showChangeCoverBtn && ( className="change-cover-btn"
<Button icon={<PictureOutlined />}
size="small" onClick={() => setCoverModalOpen(true)}
icon={<PictureOutlined />} >
disabled 更换题图
className="change-cover-btn" </Button>
> )}
更换题图 </div>
</Button>
{/* ===== 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"> 9 期收视份额</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">
<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-number" style={{ color }}>
{ep.audience_share != null ? Number(ep.audience_share).toFixed(2) : '--'}
</div>
<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>
)} )}
<span className="placeholder-label">敬请期待</span>
</div> </div>
</div> </div>
{/* KPI Cards */} {/* 换图 Modal */}
<Row gutter={16} className="kpi-row"> <ChangeCoverModal
<Col span={8}> open={coverModalOpen}
<Card className="kpi-card"> onClose={() => setCoverModalOpen(false)}
<div className="kpi-icon" style={{ background: '#e8f5e9' }}> onUploaded={handleCoverUploaded}
<EyeOutlined style={{ color: '#6b8e6b', fontSize: 24 }} /> episodes={episodes.slice(0, 20)}
</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> </div>
) )
} }
+38
View File
@@ -0,0 +1,38 @@
import http from './http'
/**
* 获取当前题图设置
* @returns {Promise<{cover_path: string|null, episode_number: number|null, episode_title: string|null}>}
*/
export async function getCurrentCover() {
const response = await http.get('/dashboard/cover')
return response.data
}
/**
* 上传题图(仅制片人/责编可用)
* @param {File} file - 海报图片文件
* @param {number} episodeNumber - 关联期次号
* @param {string} episodeTitle - 关联期次节目名
* @returns {Promise<{success: boolean, cover_path: string}>}
*/
export async function uploadCover(file, episodeNumber, episodeTitle) {
const formData = new FormData()
formData.append('file', file)
formData.append('episode_number', String(episodeNumber))
formData.append('episode_title', episodeTitle)
const response = await http.post('/dashboard/cover', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return response.data
}
/**
* 获取未来排播计划
* @param {number} limit - 返回条数,默认6
* @returns {Promise<Array<{episode_number, program_name, planned_air_date, editor_name_snapshot}>>}
*/
export async function getUpcomingSchedules(limit = 6) {
const response = await http.get('/schedules/upcoming', { params: { limit } })
return response.data
}
+4
View File
@@ -10,6 +10,10 @@ export default defineConfig({
target: 'http://localhost:8000', target: 'http://localhost:8000',
changeOrigin: true, changeOrigin: true,
}, },
'/static': {
target: 'http://localhost:8000',
changeOrigin: true,
},
}, },
}, },
}) })