8 Commits

15 changed files with 1070 additions and 265 deletions
+121
View File
@@ -0,0 +1,121 @@
"""
仪表盘 API — 题图上传 / 查询
权限:
- POST /cover — 仅 zhipianren / zebianbiandao 返回 403
- GET /cover — 三角色均可读
"""
import os
import uuid
from datetime import datetime
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, status
from sqlmodel import Session, select
from app.core.deps import get_current_user, require_role
from app.db.session import get_session
from app.models.user import User, UserRole
from app.models.cover_settings import CoverSettings
router = APIRouter(prefix="/api/dashboard", tags=["仪表盘"])
# 题图文件存放目录(相对于 backend/)
STATIC_COVERS_DIR = Path(__file__).parent.parent.parent / "static" / "covers"
STATIC_COVERS_URL = "/static/covers"
# 确保目录存在
STATIC_COVERS_DIR.mkdir(parents=True, exist_ok=True)
ALLOWED_TYPES = {"image/png", "image/jpeg", "image/webp"}
MAX_SIZE = 5 * 1024 * 1024 # 5MB
def require_upload_role():
return require_role(UserRole.zhipianren, UserRole.zebian)
@router.get("/cover")
def get_current_cover(
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user),
):
"""查询当前题图设置(三角色均可读)。"""
row = session.exec(
select(CoverSettings).where(CoverSettings.key == "dashboard_cover")
).first()
if not row:
return {"cover_path": None, "episode_number": None, "episode_title": None}
return {
"cover_path": row.cover_path,
"episode_number": row.episode_number,
"episode_title": row.episode_title,
}
@router.post("/cover")
def upload_cover(
file: UploadFile = File(...),
episode_number: int = Form(...),
episode_title: str = Form(...),
session: Session = Depends(get_session),
current_user: User = Depends(require_upload_role()),
):
"""上传题图(仅制片人/责编)。
文件存 static/covers/{uuid}.{ext},更新 cover_settings 表。
"""
# 校验文件类型
if file.content_type not in ALLOWED_TYPES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"仅支持 PNG/JPG/WEBP,当前:{file.content_type}",
)
# 校验文件大小
contents = file.file.read()
if len(contents) > MAX_SIZE:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="文件大小不能超过 5MB",
)
file.file.seek(0)
# 生成文件名
ext = file.filename.split(".")[-1] if "." in file.filename else "jpg"
safe_ext = ext.lower() if ext.lower() in {"png", "jpg", "jpeg", "webp"} else "jpg"
filename = f"{uuid.uuid4().hex}.{safe_ext}"
filepath = STATIC_COVERS_DIR / filename
# 写文件
with open(filepath, "wb") as f:
f.write(contents)
cover_path = f"{STATIC_COVERS_URL}/{filename}"
now = datetime.now()
row = session.exec(
select(CoverSettings).where(CoverSettings.key == "dashboard_cover")
).first()
if row:
row.cover_path = cover_path
row.episode_number = episode_number
row.episode_title = episode_title
row.updated_at = now
else:
row = CoverSettings(
key="dashboard_cover",
cover_path=cover_path,
episode_number=episode_number,
episode_title=episode_title,
updated_at=now,
)
session.add(row)
session.commit()
return {"success": True, "cover_path": cover_path}
+70
View File
@@ -0,0 +1,70 @@
"""
排期 API — 查询未来排播计划
降级逻辑:
- schedules 表有数据 → 从 schedules 取未来 limit 条 JOIN episodes
- schedules 表无数据 → 从 episodes 表取最近 limit 期待播期次(air_date 升序)
"""
from fastapi import APIRouter, Depends
from sqlmodel import Session, select
from app.core.deps import get_current_user
from app.db.session import get_session
from app.models.user import User
from app.models.episode import Episode
from app.models.schedule import Schedule
router = APIRouter(prefix="/api/schedules", tags=["排期"])
@router.get("/upcoming")
def list_upcoming_schedules(
limit: int = 6,
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user),
):
"""获取未来排播计划(三角色均可读)。
优先从 schedules 表取,schedules 无数据时降级取 episodes 最近待播期次。
"""
# 先查 schedules
statement = (
select(Schedule, Episode)
.join(Episode, Schedule.episode_id == Episode.id, isouter=True)
.where(Schedule.planned_air_date >= Episode.air_date)
.order_by(Schedule.planned_air_date.asc())
.limit(limit)
)
results = session.exec(statement).all()
# 降级:从 episodes 直接取(当 schedules 为空或不足时)
if not results or len(results) < limit:
fallback_stmt = (
select(Episode)
.order_by(Episode.air_date.asc())
.limit(limit)
)
episodes = session.exec(fallback_stmt).all()
return [
{
"episode_number": ep.episode_number,
"program_name": ep.program_name,
"planned_air_date": str(ep.air_date),
"editor_name_snapshot": ep.editor_name_snapshot,
}
for ep in episodes
]
# schedules 有数据
output = []
for schedule, episode in results:
if episode is None:
continue
output.append({
"episode_number": episode.episode_number,
"program_name": episode.program_name,
"planned_air_date": str(schedule.planned_air_date),
"editor_name_snapshot": episode.editor_name_snapshot,
})
return output
+12
View File
@@ -6,11 +6,16 @@ from fastapi import FastAPI
from starlette.middleware.cors import CORSMiddleware from starlette.middleware.cors import CORSMiddleware
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
from fastapi.staticfiles import StaticFiles
from pathlib import Path
from app.api.auth import router as auth_router from app.api.auth import router as auth_router
from app.api.episodes import router as episodes_router from app.api.episodes import router as episodes_router
from app.api.imports import router as imports_router from app.api.imports import router as imports_router
from app.api.users import router as users_router from app.api.users import router as users_router
from app.api.yearly_targets import router as yearly_targets_router from app.api.yearly_targets import router as yearly_targets_router
from app.api.dashboard import router as dashboard_router
from app.api.schedules import router as schedules_router
from app.core.config import settings from app.core.config import settings
app = FastAPI(title="军事科技工作台", version="0.1.0") app = FastAPI(title="军事科技工作台", version="0.1.0")
@@ -42,3 +47,10 @@ app.include_router(episodes_router)
app.include_router(imports_router) app.include_router(imports_router)
app.include_router(yearly_targets_router) app.include_router(yearly_targets_router)
app.include_router(users_router) app.include_router(users_router)
app.include_router(dashboard_router)
app.include_router(schedules_router)
# 挂载静态文件目录(题图海报)
_static_dir = Path(__file__).parent.parent / "static"
if _static_dir.exists():
app.mount("/static", StaticFiles(directory=str(_static_dir)), name="static")
+28
View File
@@ -0,0 +1,28 @@
"""
题图设置模型 — SQLModel
只存当前展示的一张题图,不动 episodes 表。
"""
from datetime import datetime
from sqlalchemy import Column
from sqlalchemy import DateTime as SADateTime
from sqlalchemy import Integer as SAInteger
from sqlalchemy import Text
from sqlalchemy.sql import func as sa_func
from sqlmodel import Field, SQLModel
class CoverSettings(SQLModel, table=True):
__tablename__ = "cover_settings"
id: int | None = Field(default=None, primary_key=True)
key: str = Field(default="dashboard_cover", max_length=50)
cover_path: str | None = Field(default=None) # 当前题图文件路径
episode_number: int | None = Field(default=None) # 关联期次号
episode_title: str | None = Field(default=None) # 关联期次节目名
updated_at: datetime | None = Field(
default=None,
sa_column=Column(SADateTime(timezone=True), nullable=False, server_default=sa_func.now()),
)
+33
View File
@@ -0,0 +1,33 @@
"""
排期模型 — SQLModel
"""
from datetime import date, datetime
from sqlalchemy import Column
from sqlalchemy import DateTime as SADateTime
from sqlalchemy import Integer as SAInteger
from sqlalchemy import Date as SADate
from sqlalchemy import Text
from sqlalchemy.sql import func as sa_func
from sqlmodel import Field, SQLModel
class Schedule(SQLModel, table=True):
__tablename__ = "schedules"
id: int | None = Field(default=None, primary_key=True)
episode_id: int | None = Field(default=None, foreign_key="episodes.id")
planned_air_date: date
status: str = Field(default="planned", max_length=20)
editor_id: int | None = Field(default=None, foreign_key="users.id")
notes: str | None = Field(default=None)
created_at: datetime | None = Field(
default=None,
sa_column=Column(SADateTime(timezone=True), nullable=False, server_default=sa_func.now()),
)
updated_at: datetime | None = Field(
default=None,
sa_column=Column(SADateTime(timezone=True), nullable=False, server_default=sa_func.now()),
)
+23
View File
@@ -0,0 +1,23 @@
-- ============================================================
-- 002_add_cover_settings.sql — 题图设置表
-- 执行方式: psql -U postgres -d milsci_dev -f 002_add_cover_settings.sql
-- ============================================================
-- 题图设置表(只存当前展示的一张题图,不动 episodes 表)
CREATE TABLE IF NOT EXISTS cover_settings (
id SERIAL PRIMARY KEY,
key VARCHAR(50) NOT NULL UNIQUE DEFAULT 'dashboard_cover',
cover_path TEXT, -- 当前题图文件路径(相对路径,如 /static/covers/xxx.jpg
episode_number INTEGER, -- 关联期次号(可空)
episode_title TEXT, -- 关联期次节目名(可空)
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 初始化默认值行(允许为空,表示使用默认渐变底图)
INSERT INTO cover_settings (key, cover_path, episode_number, episode_title)
VALUES ('dashboard_cover', NULL, NULL, NULL)
ON CONFLICT (key) DO NOTHING;
-- ============================================================
-- 迁移完成
-- ============================================================
Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

+3 -1
View File
@@ -29,4 +29,6 @@
padding: 24px; padding: 24px;
background: var(--color-bg-cream); background: var(--color-bg-cream);
min-height: calc(100vh - 64px); min-height: calc(100vh - 64px);
} max-width: 1400px;
margin: 0 auto;
}
+25 -1
View File
@@ -35,4 +35,28 @@
.side-nav-menu .ant-menu-item-selected { .side-nav-menu .ant-menu-item-selected {
background: var(--color-accent-green) !important; background: var(--color-accent-green) !important;
color: var(--color-primary-green) !important; color: var(--color-primary-green) !important;
} }
/* disabled 项整体灰化 + 不可点击 */
.side-nav-menu .ant-menu-item-disabled {
opacity: 0.55;
cursor: not-allowed !important;
}
/* 即将上线小标签 */
.menu-soon-tag {
font-size: 10px;
color: #999;
background: #f0f0f0;
padding: 1px 5px;
border-radius: 4px;
margin-left: 6px;
vertical-align: middle;
white-space: nowrap;
}
/* disabled 项内标签不受 opacity 影响 */
.side-nav-menu .ant-menu-item-disabled .menu-soon-tag {
opacity: 1;
color: #888;
}
@@ -7,6 +7,7 @@ import {
SyncOutlined, SyncOutlined,
UserOutlined, UserOutlined,
TeamOutlined, TeamOutlined,
SoundOutlined,
} from '@ant-design/icons' } from '@ant-design/icons'
import useAuthStore from '../../stores/authStore' import useAuthStore from '../../stores/authStore'
@@ -23,6 +24,28 @@ function SideNav() {
{ key: '/doco', icon: <SyncOutlined />, label: '文稿对齐' }, { key: '/doco', icon: <SyncOutlined />, label: '文稿对齐' },
{ key: '/editor-home', icon: <UserOutlined />, label: '个人首页' }, { key: '/editor-home', icon: <UserOutlined />, label: '个人首页' },
{ key: '/users', icon: <TeamOutlined />, label: '用户管理' }, { key: '/users', icon: <TeamOutlined />, label: '用户管理' },
{
key: 'tts-placeholder',
icon: <SoundOutlined />,
label: (
<span className="menu-item-with-tag">
蓝皓配音 TTS 2.0
<span className="menu-soon-tag">即将上线</span>
</span>
),
disabled: true,
},
{
key: 'collab-placeholder',
icon: <TeamOutlined />,
label: (
<span className="menu-item-with-tag">
内部协作Mattermost
<span className="menu-soon-tag">即将上线</span>
</span>
),
disabled: true,
},
] ]
// 按角色过滤菜单项 // 按角色过滤菜单项
@@ -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
+262 -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: 64px;
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,40 @@
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;
justify-content: flex-end;
height: 180px;
} }
.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; white-space: nowrap;
max-width: 40px; margin-bottom: 2px;
}
.bar-program {
font-size: 10px;
color: #6b6b6b;
text-align: center;
max-width: 64px;
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 +223,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;
} }
+277 -146
View File
@@ -1,15 +1,20 @@
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,
LineChartOutlined,
AimOutlined,
} 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,191 +25,317 @@ 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) {
return title.length > 12 ? title.slice(0, 12) + '...' : 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() { 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个,不够用真实数据补位 // 显示最近有份额数据的期次(最多12个
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, 12)
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 数据(扩为 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 ( 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"> 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>
)} )}
<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,
},
}, },
}, },
}) })