feat: 完善短剧管理后台功能,新增编辑、详情展示优化
1. 重构接口类型定义,新增创建/更新短剧和剧集的载荷类型 2. 新增表单数据转换工具,规范化表单提交参数 3. 优化短剧列表展示,新增封面图和更清晰的集数统计 4. 新增短剧和剧集的编辑弹窗功能 5. 修复测试文件导入路径和README标题 6. 优化角色管理页面的表单提示样式
This commit is contained in:
@@ -1,11 +1,21 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button, Card, Descriptions, InputNumber, Message, Space } from "@arco-design/web-react";
|
||||
import { Button, Card, Descriptions, Form, Grid, Input, InputNumber, InputTag, Message, Modal, Select, Space, Switch } from "@arco-design/web-react";
|
||||
import { IconArrowLeft, IconRefresh } from "@arco-design/web-react/icon";
|
||||
import { dramaApi, mediaApi } from "../../api";
|
||||
import { toDramaPayload, toEpisodePayload } from "../formPayload";
|
||||
import type { Drama, DramaDetailPageProps, Episode } from "../interface";
|
||||
import { EpisodeTable } from "./EpisodeTable";
|
||||
|
||||
const { Row, Col } = Grid;
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: "draft", label: "草稿" },
|
||||
{ value: "scheduled", label: "定时发布" },
|
||||
{ value: "published", label: "已发布" },
|
||||
{ value: "offline", label: "已下线" },
|
||||
];
|
||||
|
||||
const PUBLISH_STATUS_LABEL: Record<string, string> = {
|
||||
draft: "草稿",
|
||||
scheduled: "定时发布",
|
||||
@@ -20,6 +30,8 @@ export function DramaDetail({ dramaId }: DramaDetailPageProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [episodeCount, setEpisodeCount] = useState(1);
|
||||
const [uploadingEpisodeId, setUploadingEpisodeId] = useState<number | null>(null);
|
||||
const [editVisible, setEditVisible] = useState(false);
|
||||
const [updatingDrama, setUpdatingDrama] = useState(false);
|
||||
|
||||
const loadDetail = async () => {
|
||||
setLoading(true);
|
||||
@@ -49,6 +61,18 @@ export function DramaDetail({ dramaId }: DramaDetailPageProps) {
|
||||
await loadDetail();
|
||||
};
|
||||
|
||||
const updateDrama = async (values: Partial<Drama>) => {
|
||||
setUpdatingDrama(true);
|
||||
try {
|
||||
await dramaApi.updateDrama(dramaId, toDramaPayload(values));
|
||||
Message.success("短剧信息已更新");
|
||||
setEditVisible(false);
|
||||
await loadDetail();
|
||||
} finally {
|
||||
setUpdatingDrama(false);
|
||||
}
|
||||
};
|
||||
|
||||
const uploadEpisodeVideo = async (episode: Episode, file: File) => {
|
||||
setUploadingEpisodeId(episode.id);
|
||||
try {
|
||||
@@ -76,6 +100,12 @@ export function DramaDetail({ dramaId }: DramaDetailPageProps) {
|
||||
await loadDetail();
|
||||
};
|
||||
|
||||
const updateEpisode = async (episode: Episode, values: Partial<Episode>) => {
|
||||
await dramaApi.updateDramaEpisode(dramaId, episode.id, toEpisodePayload(values));
|
||||
Message.success("剧集信息已更新");
|
||||
await loadDetail();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<Card
|
||||
@@ -92,6 +122,9 @@ export function DramaDetail({ dramaId }: DramaDetailPageProps) {
|
||||
<Button icon={<IconRefresh />} onClick={loadDetail}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button disabled={!drama} onClick={() => setEditVisible(true)}>
|
||||
编辑短剧
|
||||
</Button>
|
||||
<Button onClick={() => updateDramaStatus("draft")}>设为草稿</Button>
|
||||
<Button type="primary" onClick={() => updateDramaStatus("published")}>
|
||||
上架短剧
|
||||
@@ -135,8 +168,104 @@ export function DramaDetail({ dramaId }: DramaDetailPageProps) {
|
||||
uploadingEpisodeId={uploadingEpisodeId}
|
||||
onUploadVideo={uploadEpisodeVideo}
|
||||
onPublishEpisode={publishEpisode}
|
||||
onUpdateEpisode={updateEpisode}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{drama && (
|
||||
<Modal title="编辑短剧" visible={editVisible} onCancel={() => setEditVisible(false)} footer={null} style={{ width: 720 }}>
|
||||
<Form
|
||||
key={drama.id}
|
||||
layout="vertical"
|
||||
onSubmit={updateDrama}
|
||||
initialValues={{
|
||||
title: drama.title,
|
||||
type: drama.type,
|
||||
status: drama.status,
|
||||
coverUrl: drama.coverUrl,
|
||||
description: drama.description,
|
||||
tags: drama.tags,
|
||||
region: drama.region,
|
||||
language: drama.language,
|
||||
year: drama.year,
|
||||
sortOrder: drama.sortOrder,
|
||||
isRecommended: drama.isRecommended,
|
||||
totalEpisodes: drama.totalEpisodes || 1,
|
||||
}}
|
||||
>
|
||||
<Form.Item label="标题" field="title" rules={[{ required: true }]}>
|
||||
<Input placeholder="请输入短剧标题" />
|
||||
</Form.Item>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="类型" field="type" rules={[{ required: true }]}>
|
||||
<Input placeholder="请输入类型" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="状态" field="status">
|
||||
<Select>
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<Select.Option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item label="封面地址" field="coverUrl" rules={[{ required: true }]}>
|
||||
<Input placeholder="请输入封面图片 URL" />
|
||||
</Form.Item>
|
||||
<Form.Item label="简介" field="description">
|
||||
<Input.TextArea placeholder="请输入短剧简介" autoSize={{ minRows: 2, maxRows: 4 }} />
|
||||
</Form.Item>
|
||||
<Form.Item label="标签" field="tags">
|
||||
<InputTag placeholder="输入后回车添加标签" allowClear />
|
||||
</Form.Item>
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Form.Item label="地区" field="region">
|
||||
<Input placeholder="如 中国" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item label="语言" field="language">
|
||||
<Input placeholder="如 zh-CN" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item label="年份" field="year">
|
||||
<InputNumber min={1900} style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Form.Item label="排序权重" field="sortOrder">
|
||||
<InputNumber style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item label="剧集总数" field="totalEpisodes" rules={[{ required: true }]}>
|
||||
<InputNumber min={1} max={500} style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item label="推荐" field="isRecommended" triggerPropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<div className="modal-actions">
|
||||
<Button onClick={() => setEditVisible(false)}>取消</Button>
|
||||
<Button type="primary" htmlType="submit" loading={updatingDrama}>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Button, Card, Form, Grid, Input, InputNumber, InputTag, Message, Modal,
|
||||
import { IconPlus } from "@arco-design/web-react/icon";
|
||||
import { dramaApi } from "../../api";
|
||||
import { usePageData } from "../../hooks/usePageData";
|
||||
import { toDramaPayload } from "../formPayload";
|
||||
import type { Drama, StatusOption } from "../interface";
|
||||
|
||||
const { Row, Col } = Grid;
|
||||
@@ -31,7 +32,7 @@ export function DramaList() {
|
||||
const createDrama = async (values: Partial<Drama>) => {
|
||||
setCreating(true);
|
||||
try {
|
||||
await dramaApi.createDrama(values);
|
||||
await dramaApi.createDrama(toDramaPayload(values));
|
||||
Message.success("短剧创建成功");
|
||||
setVisible(false);
|
||||
await reload();
|
||||
@@ -41,124 +42,128 @@ export function DramaList() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="section-card"
|
||||
title="短剧管理"
|
||||
extra={
|
||||
<div className="">
|
||||
<nav className="flex justify-end border border border-[#e5e5e5] pb-4">
|
||||
<Button type="primary" icon={<IconPlus />} onClick={() => setVisible(true)}>
|
||||
新增短剧
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
data={data.list}
|
||||
pagination={{
|
||||
current: data.pagination.page,
|
||||
total: data.pagination.total,
|
||||
pageSize: data.pagination.pageSize,
|
||||
onChange: (page, pageSize) => changePage(page, pageSize),
|
||||
}}
|
||||
columns={[
|
||||
{ title: "ID", dataIndex: "id", width: 80 },
|
||||
{ title: "标题", dataIndex: "title" },
|
||||
{ title: "地区", dataIndex: "region", width: 120 },
|
||||
{ title: "类型", dataIndex: "type", width: 140 },
|
||||
{ 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}`)}>
|
||||
详情
|
||||
</nav>
|
||||
<Card className="section-card" title="短剧管理">
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
data={data.list}
|
||||
pagination={{
|
||||
current: data.pagination.page,
|
||||
total: data.pagination.total,
|
||||
pageSize: data.pagination.pageSize,
|
||||
onChange: (page, pageSize) => changePage(page, pageSize),
|
||||
}}
|
||||
columns={[
|
||||
{ title: "ID", dataIndex: "id", width: 80 },
|
||||
{ title: "封面图", dataIndex: "coverUrl", width: 120, render: (value) => (value ? <img src={value} alt="封面图" style={{ width: 100, height: 100, objectFit: "cover" }} /> : null) },
|
||||
{ title: "标题", dataIndex: "title" },
|
||||
{ title: "地区", dataIndex: "region", width: 120 },
|
||||
{ title: "类型", dataIndex: "type", width: 140 },
|
||||
{ title: "已发布 / 总集数", dataIndex: "totalEpisodes", width: 180, render: (_, record) => `${record.publishedEpisodes}(已发布)/共${record.totalEpisodes}集` },
|
||||
{
|
||||
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 layout="vertical" onSubmit={createDrama} initialValues={{ status: "draft", isRecommended: false, sortOrder: 0, totalEpisodes: 1 }}>
|
||||
<Form.Item label="标题" field="title" rules={[{ required: true }]}>
|
||||
<Input placeholder="请输入短剧标题" />
|
||||
</Form.Item>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="类型" field="type" rules={[{ required: true }]}>
|
||||
<Input placeholder="请输入类型,如复仇、爱情、动作等" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="状态" field="status">
|
||||
<Select placeholder="请选择状态">
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<Select.Option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item label="封面地址" field="coverUrl" rules={[{ required: true }]}>
|
||||
<Input placeholder="请输入封面图片 URL" />
|
||||
</Form.Item>
|
||||
<Form.Item label="简介" field="description">
|
||||
<Input.TextArea placeholder="请输入短剧简介" autoSize={{ minRows: 2, maxRows: 4 }} />
|
||||
</Form.Item>
|
||||
<Form.Item label="标签" field="tags">
|
||||
<InputTag placeholder="输入后回车添加标签" allowClear />
|
||||
</Form.Item>
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Form.Item label="地区" field="region">
|
||||
<Input placeholder="如 中国" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item label="语言" field="language">
|
||||
<Input placeholder="如 zh-CN" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item label="年份" field="year">
|
||||
<InputNumber placeholder="如 2026" min={1900} style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Form.Item label="排序权重" field="sortOrder">
|
||||
<InputNumber placeholder="数值越大越靠前" style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item label="剧集总数" field="totalEpisodes" rules={[{ required: true }]}>
|
||||
<InputNumber min={1} max={500} placeholder="创建剧集占位" style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item label="推荐" field="isRecommended" triggerPropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<div className="modal-actions">
|
||||
<Button onClick={() => setVisible(false)}>取消</Button>
|
||||
<Button type="primary" htmlType="submit" loading={creating}>
|
||||
创建
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Modal title="创建短剧" visible={visible} onCancel={() => setVisible(false)} footer={null} style={{ width: 640 }}>
|
||||
<Form layout="vertical" onSubmit={createDrama} initialValues={{ status: "draft", isRecommended: false, sortOrder: 0 }}>
|
||||
<Form.Item label="标题" field="title" rules={[{ required: true }]}>
|
||||
<Input placeholder="请输入短剧标题" />
|
||||
</Form.Item>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="类型" field="type" rules={[{ required: true }]}>
|
||||
<Input placeholder="请输入类型,如复仇、爱情、动作等" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="状态" field="status">
|
||||
<Select placeholder="请选择状态">
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<Select.Option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item label="封面地址" field="coverUrl" rules={[{ required: true }]}>
|
||||
<Input placeholder="请输入封面图片 URL" />
|
||||
</Form.Item>
|
||||
<Form.Item label="简介" field="description">
|
||||
<Input.TextArea placeholder="请输入短剧简介" autoSize={{ minRows: 2, maxRows: 4 }} />
|
||||
</Form.Item>
|
||||
<Form.Item label="标签" field="tags">
|
||||
<InputTag placeholder="输入后回车添加标签" allowClear />
|
||||
</Form.Item>
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Form.Item label="地区" field="region">
|
||||
<Input placeholder="如 中国" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item label="语言" field="language">
|
||||
<Input placeholder="如 zh-CN" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item label="年份" field="year">
|
||||
<InputNumber placeholder="如 2026" min={1900} style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="排序权重" field="sortOrder">
|
||||
<InputNumber placeholder="数值越大越靠前" style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="推荐" field="isRecommended" triggerPropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<div className="modal-actions">
|
||||
<Button onClick={() => setVisible(false)}>取消</Button>
|
||||
<Button type="primary" htmlType="submit" loading={creating}>
|
||||
创建
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Card>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Button, Space, Table, Tag } from "@arco-design/web-react";
|
||||
import type { EpisodeTableProps } from "../interface";
|
||||
import { useState } from "react";
|
||||
import { Button, Form, Grid, Input, InputNumber, Modal, Select, Space, Table, Tag } from "@arco-design/web-react";
|
||||
import type { Episode, EpisodeTableProps } from "../interface";
|
||||
|
||||
const { Row, Col } = Grid;
|
||||
|
||||
const REVIEW_STATUS_LABEL: Record<string, string> = {
|
||||
not_submitted: "未提交",
|
||||
@@ -16,74 +19,186 @@ const PUBLISH_STATUS_LABEL: Record<string, string> = {
|
||||
offline: "已下线",
|
||||
};
|
||||
|
||||
export function EpisodeTable({ episodes, loading, uploadingEpisodeId, onUploadVideo, onPublishEpisode }: EpisodeTableProps) {
|
||||
const PUBLISH_STATUS_OPTIONS = [
|
||||
{ value: "draft", label: "草稿" },
|
||||
{ value: "scheduled", label: "定时发布" },
|
||||
{ value: "published", label: "已发布" },
|
||||
{ value: "offline", label: "已下线" },
|
||||
];
|
||||
|
||||
export function EpisodeTable({ episodes, loading, uploadingEpisodeId, onUploadVideo, onPublishEpisode, onUpdateEpisode }: EpisodeTableProps) {
|
||||
const [editingEpisode, setEditingEpisode] = useState<Episode | null>(null);
|
||||
const [updating, setUpdating] = useState(false);
|
||||
|
||||
const submitEpisode = async (values: Partial<Episode>) => {
|
||||
if (!editingEpisode) {
|
||||
return;
|
||||
}
|
||||
setUpdating(true);
|
||||
try {
|
||||
await onUpdateEpisode(editingEpisode, values);
|
||||
setEditingEpisode(null);
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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 onUploadVideo(record, file);
|
||||
}
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<>
|
||||
<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: 340,
|
||||
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 onUploadVideo(record, file);
|
||||
}
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</Button>
|
||||
<Button type="text" onClick={() => setEditingEpisode(record)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Button type="text" disabled={record.reviewStatus !== "approved"} onClick={() => onPublishEpisode(record)}>
|
||||
发布
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{editingEpisode && (
|
||||
<Modal title={`编辑第 ${editingEpisode.episodeNo} 集`} visible={Boolean(editingEpisode)} onCancel={() => setEditingEpisode(null)} footer={null} style={{ width: 680 }}>
|
||||
<Form
|
||||
key={editingEpisode.id}
|
||||
layout="vertical"
|
||||
onSubmit={submitEpisode}
|
||||
initialValues={{
|
||||
title: editingEpisode.title,
|
||||
description: editingEpisode.description,
|
||||
coverUrl: editingEpisode.coverUrl,
|
||||
durationSeconds: editingEpisode.durationSeconds,
|
||||
width: editingEpisode.width,
|
||||
height: editingEpisode.height,
|
||||
freeType: editingEpisode.freeType,
|
||||
price: editingEpisode.price,
|
||||
publishStatus: editingEpisode.publishStatus,
|
||||
nextReleaseAt: editingEpisode.nextReleaseAt,
|
||||
}}
|
||||
>
|
||||
<Form.Item label="标题" field="title" rules={[{ required: true }]}>
|
||||
<Input placeholder="请输入剧集标题" />
|
||||
</Form.Item>
|
||||
<Form.Item label="简介" field="description">
|
||||
<Input.TextArea placeholder="请输入剧集简介" autoSize={{ minRows: 2, maxRows: 4 }} />
|
||||
</Form.Item>
|
||||
<Form.Item label="封面地址" field="coverUrl">
|
||||
<Input placeholder="请输入封面图片 URL" />
|
||||
</Form.Item>
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Form.Item label="时长(秒)" field="durationSeconds" rules={[{ required: true }]}>
|
||||
<InputNumber min={1} style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item label="宽度" field="width">
|
||||
<InputNumber min={1} style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item label="高度" field="height">
|
||||
<InputNumber min={1} style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Form.Item label="付费类型" field="freeType">
|
||||
<Select>
|
||||
<Select.Option value="free">免费</Select.Option>
|
||||
<Select.Option value="paid">付费</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item label="价格" field="price">
|
||||
<InputNumber min={0} style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item label="发布状态" field="publishStatus">
|
||||
<Select>
|
||||
{PUBLISH_STATUS_OPTIONS.map((option) => (
|
||||
<Select.Option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item label="下次释放时间" field="nextReleaseAt">
|
||||
<Input placeholder="如 2026-07-02T10:00:00.000Z" />
|
||||
</Form.Item>
|
||||
<div className="modal-actions">
|
||||
<Button onClick={() => setEditingEpisode(null)}>取消</Button>
|
||||
<Button type="primary" htmlType="submit" loading={updating}>
|
||||
保存
|
||||
</Button>
|
||||
<Button type="text" disabled={record.reviewStatus !== "approved"} onClick={() => onPublishEpisode(record)}>
|
||||
发布
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
82
src/Drama/formPayload.ts
Normal file
82
src/Drama/formPayload.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import type { Drama, Episode } from "../api";
|
||||
|
||||
type DramaFormValues = Partial<
|
||||
Pick<
|
||||
Drama,
|
||||
| "title"
|
||||
| "type"
|
||||
| "status"
|
||||
| "coverUrl"
|
||||
| "description"
|
||||
| "tags"
|
||||
| "region"
|
||||
| "language"
|
||||
| "year"
|
||||
| "sortOrder"
|
||||
| "isRecommended"
|
||||
| "totalEpisodes"
|
||||
>
|
||||
>;
|
||||
|
||||
type EpisodeFormValues = Partial<
|
||||
Pick<
|
||||
Episode,
|
||||
| "title"
|
||||
| "description"
|
||||
| "coverUrl"
|
||||
| "durationSeconds"
|
||||
| "width"
|
||||
| "height"
|
||||
| "freeType"
|
||||
| "price"
|
||||
| "publishStatus"
|
||||
| "nextReleaseAt"
|
||||
>
|
||||
>;
|
||||
|
||||
const normalizeOptionalString = (value?: string) => {
|
||||
const nextValue = value?.trim();
|
||||
return nextValue ? nextValue : undefined;
|
||||
};
|
||||
|
||||
const normalizePositiveInteger = (value?: number) => {
|
||||
const nextValue = Number(value);
|
||||
return Number.isInteger(nextValue) && nextValue > 0 ? nextValue : undefined;
|
||||
};
|
||||
|
||||
const normalizeNonNegativeInteger = (value?: number) => {
|
||||
const nextValue = Number(value);
|
||||
return Number.isInteger(nextValue) && nextValue >= 0 ? nextValue : undefined;
|
||||
};
|
||||
|
||||
export function toDramaPayload(values: DramaFormValues): DramaFormValues {
|
||||
return {
|
||||
title: normalizeOptionalString(values.title),
|
||||
type: normalizeOptionalString(values.type),
|
||||
status: values.status,
|
||||
coverUrl: normalizeOptionalString(values.coverUrl),
|
||||
description: normalizeOptionalString(values.description),
|
||||
tags: values.tags?.filter(Boolean),
|
||||
region: normalizeOptionalString(values.region),
|
||||
language: normalizeOptionalString(values.language),
|
||||
year: normalizePositiveInteger(values.year),
|
||||
sortOrder: normalizeNonNegativeInteger(values.sortOrder),
|
||||
isRecommended: Boolean(values.isRecommended),
|
||||
totalEpisodes: normalizePositiveInteger(values.totalEpisodes),
|
||||
};
|
||||
}
|
||||
|
||||
export function toEpisodePayload(values: EpisodeFormValues): EpisodeFormValues {
|
||||
return {
|
||||
title: normalizeOptionalString(values.title),
|
||||
description: normalizeOptionalString(values.description),
|
||||
coverUrl: normalizeOptionalString(values.coverUrl),
|
||||
durationSeconds: normalizePositiveInteger(values.durationSeconds),
|
||||
width: normalizePositiveInteger(values.width),
|
||||
height: normalizePositiveInteger(values.height),
|
||||
freeType: values.freeType,
|
||||
price: normalizeNonNegativeInteger(values.price),
|
||||
publishStatus: values.publishStatus,
|
||||
nextReleaseAt: normalizeOptionalString(values.nextReleaseAt),
|
||||
};
|
||||
}
|
||||
@@ -17,4 +17,5 @@ export type EpisodeTableProps = {
|
||||
uploadingEpisodeId: number | null;
|
||||
onUploadVideo: (episode: Episode, file: File) => void | Promise<void>;
|
||||
onPublishEpisode: (episode: Episode) => void | Promise<void>;
|
||||
onUpdateEpisode: (episode: Episode, values: Partial<Episode>) => void | Promise<void>;
|
||||
};
|
||||
|
||||
@@ -119,7 +119,7 @@ function RolesPage() {
|
||||
</Form.Item>
|
||||
<div className="form-label">权限设置</div>
|
||||
<PermissionTreePicker permissions={permissions.data.list} value={createPermissionIds} onChange={setCreatePermissionIds} />
|
||||
<div className="form-help mt-5">为角色分配系统权限,决定拥有此角色的用户可以进行的操作</div>
|
||||
<div className="form-help">为角色分配系统权限,决定拥有此角色的用户可以进行的操作</div>
|
||||
<div className="modal-actions">
|
||||
<Button onClick={() => setCreateVisible(false)}>取消</Button>
|
||||
<Button type="primary" htmlType="submit" loading={creatingRole}>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { fetchPage, request } from "../plugins/axios";
|
||||
import type { ApiResponse, Drama, Episode, PageQuery } from "./interface";
|
||||
import type { ApiResponse, CreateDramaPayload, Drama, Episode, PageQuery, UpdateDramaPayload, UpdateEpisodePayload } from "./interface";
|
||||
|
||||
export function listDramas(query: PageQuery) {
|
||||
return fetchPage<Drama>('/api/admin/v1/dramas', query);
|
||||
}
|
||||
|
||||
export async function createDrama(payload: Partial<Drama>) {
|
||||
export async function createDrama(payload: CreateDramaPayload) {
|
||||
await request.post('/api/admin/v1/dramas', payload);
|
||||
}
|
||||
|
||||
@@ -35,14 +35,22 @@ export async function configureDramaEpisodes(
|
||||
|
||||
export async function updateDrama(
|
||||
dramaId: number,
|
||||
payload: Partial<Pick<Drama, 'status'>>,
|
||||
payload: UpdateDramaPayload,
|
||||
) {
|
||||
await request.patch(`/api/admin/v1/dramas/${dramaId}`, payload);
|
||||
}
|
||||
|
||||
export async function updateEpisode(
|
||||
episodeId: number,
|
||||
payload: Partial<Pick<Episode, 'publishStatus'>> & { status?: string },
|
||||
payload: UpdateEpisodePayload,
|
||||
) {
|
||||
await request.patch(`/api/admin/v1/episodes/${episodeId}`, payload);
|
||||
}
|
||||
|
||||
export async function updateDramaEpisode(
|
||||
dramaId: number,
|
||||
episodeId: number,
|
||||
payload: UpdateEpisodePayload,
|
||||
) {
|
||||
await request.patch(`/api/admin/v1/dramas/${dramaId}/episodes/${episodeId}`, payload);
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ export type Episode = {
|
||||
episodeNo: number;
|
||||
seq: number;
|
||||
title: string;
|
||||
description?: string;
|
||||
coverUrl: string;
|
||||
videoUrl?: string;
|
||||
byteplusVid?: string;
|
||||
@@ -73,6 +74,14 @@ export type Episode = {
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type CreateDramaPayload = Partial<Omit<Drama, "id" | "publishedEpisodes" | "createdAt">>;
|
||||
|
||||
export type UpdateDramaPayload = Partial<Omit<Drama, "id" | "publishedEpisodes" | "createdAt">>;
|
||||
|
||||
export type UpdateEpisodePayload = Partial<Omit<Episode, "id" | "dramaId" | "albumId" | "episodeNo" | "seq" | "createdAt">> & {
|
||||
status?: string;
|
||||
};
|
||||
|
||||
export type UploadTask = {
|
||||
id: number;
|
||||
jobId: string;
|
||||
|
||||
Reference in New Issue
Block a user