feat: 新增数据管理页面与短剧详情页,完善接口类型与菜单配置

1.  新增数据管理页面,支持查看数据概览、短剧/剧集指标和同步记录
2.  新增短剧详情页,支持查看短剧信息、管理剧集和上传视频
3.  新增菜单配置项,添加数据管理入口
4.  扩展API类型定义,新增剧集、上传任务、数据统计等类型
5.  修复timestamp类型为数字类型,新增短剧额外字段
6.  为页面布局添加统一的page-stack样式类
7.  新增短剧详情路由,优化短剧列表页跳转逻辑
This commit is contained in:
2026-07-01 16:35:57 +08:00
parent 56f8c477c2
commit a691db573c
6 changed files with 371 additions and 6 deletions

View File

@@ -1,7 +1,9 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import {
Button,
Card,
Descriptions,
Form,
Grid,
Input,
@@ -10,12 +12,13 @@ import {
Message,
Modal,
Select,
Space,
Switch,
Table,
Tag,
} from '@arco-design/web-react';
import { IconPlus } from '@arco-design/web-react/icon';
import type { Drama } from '../api/types';
import { IconArrowLeft, IconPlus, IconRefresh } from '@arco-design/web-react/icon';
import type { ApiResponse, Drama, Episode, UploadTask } from '../api/types';
import { api } from '../api/client';
import { usePageData } from '../hooks/usePageData';
@@ -30,7 +33,33 @@ const STATUS_OPTIONS: { value: string; label: string }[] = [
{ value: 'offline', label: '已下线' },
];
const REVIEW_STATUS_LABEL: Record<string, string> = {
not_submitted: '未提交',
pending: '待审核',
reviewing: '审核中',
approved: '已通过',
rejected: '已拒绝',
};
const PUBLISH_STATUS_LABEL: Record<string, string> = {
draft: '草稿',
scheduled: '定时发布',
published: '已发布',
offline: '已下线',
};
function DramasPage() {
const { id } = useParams();
if (id) {
return <DramaDetailPage dramaId={Number(id)} />;
}
return <DramaListPage />;
}
function DramaListPage() {
const navigate = useNavigate();
const { data, loading, reload } = usePageData<Drama>('/api/admin/v1/dramas');
const [visible, setVisible] = useState(false);
const [creating, setCreating] = useState(false);
@@ -52,9 +81,13 @@ function DramasPage() {
<Table rowKey="id" loading={loading} data={data.list} pagination={{ total: data.pagination.total, pageSize: data.pagination.pageSize }} columns={[
{ title: 'ID', dataIndex: 'id', width: 80 },
{ title: '标题', dataIndex: 'title' },
{ title: '地区', dataIndex: 'region', width: 120 },
{ title: '类型', dataIndex: 'type', width: 140 },
{ title: '状态', dataIndex: 'status', width: 120, render: (status) => <Tag color={status === 'published' ? 'green' : 'gray'}>{status}</Tag> },
{ title: '推荐', dataIndex: 'isRecommended', width: 140, render: (value) => value ? <Tag color="arcoblue"></Tag> : <Tag></Tag> },
{ title: '总集数', dataIndex: 'totalEpisodes', width: 100 },
{ title: '已发布', dataIndex: 'publishedEpisodes', width: 100 },
{ title: '状态', dataIndex: 'status', width: 120, render: (status) => <Tag color={status === 'published' ? 'green' : 'gray'}>{PUBLISH_STATUS_LABEL[String(status)] || String(status)}</Tag> },
{ title: '推荐', dataIndex: 'isRecommended', width: 100, render: (value) => value ? <Tag color="arcoblue"></Tag> : <Tag></Tag> },
{ title: '操作', width: 120, render: (_, record) => <Button type="text" onClick={() => navigate(`/dramas/${record.id}`)}></Button> },
]} />
<Modal title="创建短剧" visible={visible} onCancel={() => setVisible(false)} footer={null} style={{ width: 640 }}>
<Form
@@ -129,4 +162,144 @@ function DramasPage() {
);
}
function DramaDetailPage({ dramaId }: { dramaId: number }) {
const navigate = useNavigate();
const [drama, setDrama] = useState<Drama | null>(null);
const [episodes, setEpisodes] = useState<Episode[]>([]);
const [loading, setLoading] = useState(false);
const [episodeCount, setEpisodeCount] = useState(1);
const [uploadingEpisodeId, setUploadingEpisodeId] = useState<number | null>(null);
const loadDetail = async () => {
setLoading(true);
try {
const [dramaResponse, episodeResponse] = await Promise.all([
api.get<ApiResponse<Drama>>(`/api/admin/v1/dramas/${dramaId}`),
api.get<ApiResponse<{ list: Episode[] }>>(`/api/admin/v1/dramas/${dramaId}/episodes?pageSize=200`),
]);
setDrama(dramaResponse.data.data);
setEpisodeCount(dramaResponse.data.data.totalEpisodes || 1);
setEpisodes(episodeResponse.data.data.list);
} finally {
setLoading(false);
}
};
useEffect(() => {
void loadDetail();
}, [dramaId]);
const configureEpisodes = async () => {
await api.post(`/api/admin/v1/dramas/${dramaId}/episodes/batch`, { episodeCount });
Message.success('剧集数量已更新');
await loadDetail();
};
const updateDramaStatus = async (status: string) => {
await api.patch(`/api/admin/v1/dramas/${dramaId}`, { status });
Message.success('短剧状态已更新');
await loadDetail();
};
const uploadEpisodeVideo = async (episode: Episode, file: File) => {
setUploadingEpisodeId(episode.id);
try {
const task = await api.post<ApiResponse<UploadTask>>('/api/admin/v1/media/upload-tasks', {
episodeId: episode.id,
fileName: file.name,
contentType: file.type || 'video/mp4',
fileSize: file.size,
});
await api.patch(`/api/admin/v1/media/upload-tasks/${task.data.data.jobId}/complete`, {});
await api.post('/api/admin/v1/media/review-status/sync', {});
Message.success('视频上传并审核通过');
await loadDetail();
} finally {
setUploadingEpisodeId(null);
}
};
const publishEpisode = async (episode: Episode) => {
await api.patch(`/api/admin/v1/episodes/${episode.id}`, {
publishStatus: 'published',
status: 'published',
});
Message.success('剧集已发布');
await loadDetail();
};
return (
<div className="page-stack">
<Card
className="section-card"
loading={loading && !drama}
title={<Space><Button icon={<IconArrowLeft />} onClick={() => navigate('/dramas')} /></Space>}
extra={<Space>
<Button icon={<IconRefresh />} onClick={loadDetail}></Button>
<Button onClick={() => updateDramaStatus('draft')}>稿</Button>
<Button type="primary" onClick={() => updateDramaStatus('published')}></Button>
</Space>}
>
{drama && (
<Descriptions
column={3}
data={[
{ label: '短剧ID', value: drama.id },
{ label: '名称', value: drama.title },
{ label: '地区', value: drama.region || '-' },
{ label: '类型', value: drama.type },
{ label: '总集数', value: drama.totalEpisodes || 0 },
{ label: '已发布集数', value: drama.publishedEpisodes || 0 },
{ label: '状态', value: PUBLISH_STATUS_LABEL[drama.status] || drama.status },
{ label: '封面', value: drama.coverUrl },
{ label: '简介', value: drama.description || '-' },
]}
/>
)}
</Card>
<Card className="section-card" title="剧集配置" extra={<Space>
<InputNumber min={1} max={500} value={episodeCount} onChange={(value) => setEpisodeCount(Number(value) || 1)} />
<Button type="primary" onClick={configureEpisodes}></Button>
</Space>}>
<Table rowKey="id" loading={loading} data={episodes} pagination={false} columns={[
{ title: '集数', dataIndex: 'episodeNo', width: 80 },
{ title: '标题', dataIndex: 'title' },
{ title: 'BytePlus VID', dataIndex: 'byteplusVid', width: 180, render: (value) => value || '-' },
{ title: '尺寸', width: 120, render: (_, record) => record.width && record.height ? `${record.width}x${record.height}` : '-' },
{ title: '时长', dataIndex: 'durationSeconds', width: 100, render: (value) => value ? `${value}s` : '-' },
{ title: '审核状态', dataIndex: 'reviewStatus', width: 120, render: (status) => <Tag color={status === 'approved' ? 'green' : 'orange'}>{REVIEW_STATUS_LABEL[String(status)] || String(status)}</Tag> },
{ title: '发布状态', dataIndex: 'publishStatus', width: 120, render: (status) => <Tag color={status === 'published' ? 'green' : 'gray'}>{PUBLISH_STATUS_LABEL[String(status)] || String(status)}</Tag> },
{
title: '操作',
width: 280,
render: (_, record) => (
<Space>
<Button loading={uploadingEpisodeId === record.id}>
<label>
<input
type="file"
accept="video/*"
style={{ display: 'none' }}
onChange={(event) => {
const file = event.target.files?.[0];
if (file) {
void uploadEpisodeVideo(record, file);
}
event.target.value = '';
}}
/>
</label>
</Button>
<Button type="text" disabled={record.reviewStatus !== 'approved'} onClick={() => publishEpisode(record)}></Button>
</Space>
),
},
]} />
</Card>
</div>
);
}
export default DramasPage;