Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d40d46a434 | |||
| 2881b866a6 | |||
| 72ae73764a | |||
| 51a34e9ec6 | |||
| 303995a93f | |||
| d774370a4b | |||
| a6e8cc25ec | |||
| 8299b6bbc9 |
@@ -0,0 +1,121 @@
|
|||||||
|
"""
|
||||||
|
仪表盘 API — 题图上传 / 查询
|
||||||
|
|
||||||
|
权限:
|
||||||
|
- POST /cover — 仅 zhipianren / zebian(biandao 返回 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}
|
||||||
@@ -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
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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()),
|
||||||
|
)
|
||||||
@@ -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()),
|
||||||
|
)
|
||||||
@@ -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 |
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -36,3 +36,27 @@
|
|||||||
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
|
||||||
@@ -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,7 +223,7 @@
|
|||||||
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 {
|
||||||
@@ -208,3 +231,125 @@
|
|||||||
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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
Reference in New Issue
Block a user