diff --git a/frontend/src/pages/Dashboard/ChangeCoverModal.jsx b/frontend/src/pages/Dashboard/ChangeCoverModal.jsx
new file mode 100644
index 0000000..87e2401
--- /dev/null
+++ b/frontend/src/pages/Dashboard/ChangeCoverModal.jsx
@@ -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 (
+
+
+ {/* 图片上传区域 */}
+
+
上传海报图片(PNG/JPG/WEBP,不超过 5MB)
+
false}
+ onChange={handleFileChange}
+ showUploadList={false}
+ style={{ borderRadius: 12 }}
+ >
+ {previewUrl ? (
+
+

+
点击或拖拽更换图片
+
+ ) : (
+ <>
+
+
+
+ 点击或拖拽图片到此处上传
+ >
+ )}
+
+
+
+ {/* 关联期次下拉 */}
+
+
关联期次
+
+
+ {/* 操作按钮 */}
+
+
+ }
+ loading={uploading}
+ onClick={handleUpload}
+ disabled={!file || !selectedEpisode}
+ >
+ 确认上传
+
+
+
+
+ )
+}
+
+export default ChangeCoverModal
\ No newline at end of file
diff --git a/frontend/src/pages/Dashboard/Dashboard.css b/frontend/src/pages/Dashboard/Dashboard.css
index 96b8d59..b5f611e 100644
--- a/frontend/src/pages/Dashboard/Dashboard.css
+++ b/frontend/src/pages/Dashboard/Dashboard.css
@@ -1,149 +1,173 @@
.dashboard {
max-width: 1200px;
+ padding: 12px;
}
-/* Banner */
+/* ===== Banner ===== */
.dashboard-banner {
- background: #fff;
- border-radius: var(--radius-card);
- padding: 24px;
- 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;
+ width: 100%;
+ height: 150px;
+ border-radius: 14px;
overflow: hidden;
+ position: relative;
+ background: #3a4a3a;
+ margin-bottom: 12px;
}
-.banner-placeholder-gradient {
+.banner-bg {
position: absolute;
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 {
position: absolute;
- bottom: 6px;
- right: 6px;
- z-index: 2;
+ bottom: 16px;
+ right: 16px;
+ z-index: 3;
}
-.placeholder-label {
+/* 默认渐变底图(无题图时) */
+.banner-default-bg {
position: absolute;
- top: 50%;
- left: 50%;
- transform: translate(-50%, -50%);
- z-index: 2;
- color: rgba(255,255,255,0.85);
- font-size: 13px;
- font-weight: 600;
- letter-spacing: 2px;
+ inset: 0;
+ background: linear-gradient(135deg, #3a4a3a 0%, #5a7a5a 50%, #7a9a6a 100%);
}
-/* KPI Cards */
-.kpi-row {
- margin-bottom: 24px;
+/* ===== KPI Strip ===== */
+.kpi-strip {
+ display: flex;
+ gap: 12px;
+ margin-bottom: 12px;
}
-.kpi-card {
- border-radius: var(--radius-card);
+.kpi-chip {
+ flex: 1;
display: flex;
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 {
- width: 56px;
- height: 56px;
- border-radius: 12px;
+ width: 28px;
+ height: 28px;
+ border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
-}
-
-.kpi-info {
- display: flex;
- flex-direction: column;
-}
-
-.kpi-value {
- font-size: 24px;
- font-weight: 600;
- color: var(--color-text-primary);
+ flex-shrink: 0;
}
.kpi-label {
- font-size: 12px;
- color: var(--color-text-secondary);
+ font-size: 11px;
+ color: #6b6b6b;
+ margin: 0;
+ white-space: nowrap;
}
-/* Content Cards */
-.cards-row {
- margin-bottom: 24px;
-}
-
-.content-card {
- border-radius: var(--radius-card);
- height: 100%;
-}
-
-.content-card .ant-card-head-title {
+.kpi-value {
+ font-size: 15px;
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;
- flex-direction: column;
+ justify-content: space-between;
align-items: center;
- justify-content: center;
- padding: 32px 16px;
- text-align: center;
- color: var(--color-text-secondary);
+ margin-bottom: 12px;
}
-.placeholder-text p {
- margin: 8px 0 4px;
+.bar-chart-title {
+ font-size: 15px;
font-weight: 500;
+ color: #3b4a3b;
+ margin: 0;
}
-.placeholder-text small {
- color: #999;
-}
-
-/* Chart Container */
-.chart-container {
- padding: 8px 0;
+.bar-chart-hint {
+ font-size: 11px;
+ color: #888780;
}
.bars-wrapper {
display: flex;
justify-content: space-between;
align-items: flex-end;
- height: 200px;
- padding: 0 8px;
+ height: 180px;
+ padding: 0 4px;
+ gap: 4px;
}
.bar-item {
@@ -151,41 +175,38 @@
flex-direction: column;
align-items: center;
flex: 1;
- max-width: 40px;
+ max-width: 42px;
+ min-width: 20px;
}
.bar {
- width: 24px;
- border-radius: 4px 4px 0 0;
+ width: 100%;
+ max-width: 38px;
+ border-radius: 7px 7px 0 0;
transition: all 0.3s;
cursor: pointer;
+ position: relative;
}
-.bar-label-top {
+.bar-number {
font-size: 9px;
- color: var(--color-text-secondary);
+ font-weight: 600;
text-align: center;
- margin-top: 4px;
- max-width: 40px;
+ margin-bottom: 3px;
+ white-space: nowrap;
+}
+
+.bar-program {
+ font-size: 9px;
+ color: #6b6b6b;
+ text-align: center;
+ max-width: 42px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
-}
-
-.bar-label-bottom {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 2px;
margin-top: 4px;
}
-.bar-label-bottom span {
- font-size: 9px;
- color: var(--color-text-secondary);
-}
-
-/* Chart Legend */
.chart-legend {
display: flex;
justify-content: center;
@@ -200,11 +221,133 @@
align-items: center;
gap: 4px;
font-size: 11px;
- color: var(--color-text-secondary);
+ color: #6b6b6b;
}
.dot {
width: 8px;
height: 8px;
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;
}
\ No newline at end of file
diff --git a/frontend/src/pages/Dashboard/Dashboard.jsx b/frontend/src/pages/Dashboard/Dashboard.jsx
index f76b381..6d6df95 100644
--- a/frontend/src/pages/Dashboard/Dashboard.jsx
+++ b/frontend/src/pages/Dashboard/Dashboard.jsx
@@ -1,15 +1,18 @@
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 {
- BarChartOutlined,
EyeOutlined,
- CalendarOutlined,
FireOutlined,
+ CalendarOutlined,
+ BarChartOutlined,
PictureOutlined,
+ UploadOutlined,
} 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'
/**
@@ -20,23 +23,34 @@ import './Dashboard.css'
* 判色取该期 air_date 所属年份的 yearly_targets 行(不是当前年)。
*/
function getShareColor(share, targets, airYear) {
- // 强制转数值,避免后端返回字符串导致数值比较失败
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 (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' // 绿=未达基础目标(待提升)
+ 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((share / 1.0) * 180)
+ return Math.round((Number(share) / 1.0) * 160)
}
function getShortTitle(title) {
+ if (!title) return '?'
return title.length > 12 ? title.slice(0, 12) + '...' : title
}
@@ -44,167 +58,243 @@ 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(9),
+ listEpisodes(50),
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)
+ 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)
})
}, [])
- // 显示最近有份额数据的期次(最多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))
+ // 显示最近有份额数据的期次(最多9个)
+ const displayEpisodes = episodes
+ .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: ,
+ bg: '#e8f5e9',
+ label: '近9期已录',
+ value: displayEpisodes.length || '--',
+ },
+ {
+ icon: ,
+ bg: '#fff3e0',
+ label: '最佳份额',
+ value: bestEpisode ? (Number(bestEpisode.audience_share) * 100).toFixed(2) + '%' : '--',
+ },
+ {
+ icon: ,
+ bg: '#e3f2fd',
+ label: '年度目标',
+ value: targets.length || '--',
+ },
+ ]
return (
- {/* 顶部 Banner */}
+ {/* ===== Banner ===== */}
+ {/* 题图底图 或 默认渐变 */}
+ {cover.cover_path ? (
+

+ ) : (
+
+ )}
+ {/* 暗化渐变层 */}
+
+ {/* 右上角 Logo */}
+

{ e.target.style.display = 'none' }}
+ />
+ {/* 左侧文字 */}
-
本月收视最佳
- {bestEpisode ? (
-
- 第 {bestEpisode.episode_number} 期 {bestEpisode.program_name} ·
- 收视份额 {bestEpisode.audience_share}
-
- ) : (
-
暂无收视数据
- )}
+
本月收视最佳·题图
+
+ {cover.episode_number
+ ? `第 ${cover.episode_number} 期`
+ : bestEpisode
+ ? `第 ${bestEpisode.episode_number} 期`
+ : '暂无收视数据'}
+ {cover.episode_title || (bestEpisode ? ` · ${bestEpisode.program_name}` : '')}
+
+
+ {bestEpisode
+ ? `收视份额 ${Number(bestEpisode.audience_share).toFixed(4)} · 当月最高`
+ : '暂无收视数据'}
+
+
建议:可选用近期收视表现好的节目海报
- {/* 题图渐变占位 */}
-
-
- {showChangeCoverBtn && (
-
}
- disabled
- className="change-cover-btn"
- >
- 更换题图
-
+ {/* 右下角换图按钮 */}
+ {showChangeCoverBtn && (
+
}
+ onClick={() => setCoverModalOpen(true)}
+ >
+ 更换题图
+
+ )}
+
+
+ {/* ===== KPI 细条 ===== */}
+
+ {kpiData.map((item, i) => (
+
+
+ {item.icon}
+
+
+
{item.label}
+
{item.value}
+
+
+ ))}
+
+
+ {/* ===== 近9期收视柱图(全宽) ===== */}
+
+
+
近 9 期收视份额
+ 悬浮看编导/期次/收视率·颜色只对收视份额
+
+ {loading ? (
+
加载中...
+ ) : (
+ <>
+
+ {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 = `
+
第 ${ep.episode_number} 期 · ${ep.program_name}
+ 编导:${ep.editor_name_snapshot || '未知'}
+ 收视份额:${ep.audience_share != null ? Number(ep.audience_share).toFixed(4) : '无数据'}(${colorLabel})
+ 收视率:${ep.audience_rating != null ? Number(ep.audience_rating).toFixed(4) : '无数据'}
+ `
+ return (
+
+
}>
+
+
+
+ {ep.audience_share != null ? Number(ep.audience_share).toFixed(2) : '--'}
+
+
+ {getShortTitle(ep.program_name)}
+
+
+ )
+ })}
+
+
+
+ 绿=未达基础目标(待提升)
+
+
+ 蓝=未达摸高目标(达标)
+
+
+ 红=超过摸高目标(优秀)
+
+
+ >
+ )}
+
+
+ {/* ===== 下方两栏 ===== */}
+
+ {/* 左:雷达占位 */}
+
+
军事科技热点雷达
+
+
Phase 4c·占位
+
+
1. 神舟飞船对接空间站
+
2. 新型无人机集群技术
+
+
+
真实抓取 Phase 4c 实现
+
编导个人首页核心功能
+
+
+
+ {/* 右:排播计划 */}
+
+
未来排播计划
+ {loading ? (
+
+ ) : schedules.length > 0 ? (
+
+ {schedules.map((item, i) => (
+ -
+ 第{item.episode_number}期
+ {item.program_name}
+ {item.planned_air_date}
+ {item.editor_name_snapshot || ''}
+
+ ))}
+
+ ) : (
+
)}
-
敬请期待
- {/* KPI Cards */}
-
-
-
-
-
-
-
- {displayEpisodes.filter(e => e.audience_share).length || '--'}
- 近 9 期已录
-
-
-
-
-
-
-
-
-
-
- {bestEpisode ? (bestEpisode.audience_share * 100).toFixed(1) + '%' : '--'}
-
- 最佳收视份额
-
-
-
-
-
-
-
-
-
- {targets.length || '--'}
- 年度目标条数
-
-
-
-
-
- {/* 三卡片区域 */}
-
- {/* 热点雷达 */}
-
-
-
-
-
热点雷达 · Phase 4c
-
编导个人首页核心功能,Phase 4c 实施
-
-
-
-
- {/* 近 9 期收视柱图 */}
-
-
-
-
- {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 (
-
-
-
-
-
{getShortTitle(ep.program_name)}
-
-
- {(ep.editor_name_snapshot || '?')[0]}
-
-
{ep.editor_name_snapshot || '未知'}
-
-
- )
- })}
-
-
- 绿=未达基础目标(待提升)
- 蓝=未达摸高目标(达标)
- 红=超过摸高目标(优秀)
-
-
-
-
-
- {/* 排播计划 */}
-
-
-
-
-
排播计划 · Phase 4b
-
甘特图排期使用 frappe-gantt 实现,Phase 4b 开发
-
-
-
-
+ {/* 换图 Modal */}
+
setCoverModalOpen(false)}
+ onUploaded={handleCoverUploaded}
+ episodes={episodes.slice(0, 20)}
+ />
)
}
diff --git a/frontend/src/services/dashboardService.js b/frontend/src/services/dashboardService.js
new file mode 100644
index 0000000..c1daeb5
--- /dev/null
+++ b/frontend/src/services/dashboardService.js
@@ -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
>}
+ */
+export async function getUpcomingSchedules(limit = 6) {
+ const response = await http.get('/schedules/upcoming', { params: { limit } })
+ return response.data
+}
\ No newline at end of file
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
index 733a420..2fb5fba 100644
--- a/frontend/vite.config.js
+++ b/frontend/vite.config.js
@@ -10,6 +10,10 @@ export default defineConfig({
target: 'http://localhost:8000',
changeOrigin: true,
},
+ '/static': {
+ target: 'http://localhost:8000',
+ changeOrigin: true,
+ },
},
},
})
\ No newline at end of file