feat: 完善短剧管理后台功能,新增编辑、详情展示优化

1.  重构接口类型定义,新增创建/更新短剧和剧集的载荷类型
2.  新增表单数据转换工具,规范化表单提交参数
3.  优化短剧列表展示,新增封面图和更清晰的集数统计
4.  新增短剧和剧集的编辑弹窗功能
5.  修复测试文件导入路径和README标题
6.  优化角色管理页面的表单提示样式
This commit is contained in:
2026-07-02 17:59:31 +08:00
parent a4a1891a7e
commit 96eaa810f3
10 changed files with 542 additions and 193 deletions

View File

@@ -1,4 +1,4 @@
# CTH TikTok 短剧管理后台 # TikTok 短剧管理后台
基于 Vite + TypeScript + React + Arco Design 的后台管理系统,默认中文界面。 基于 Vite + TypeScript + React + Arco Design 的后台管理系统,默认中文界面。

View File

@@ -1,11 +1,21 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom"; 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 { IconArrowLeft, IconRefresh } from "@arco-design/web-react/icon";
import { dramaApi, mediaApi } from "../../api"; import { dramaApi, mediaApi } from "../../api";
import { toDramaPayload, toEpisodePayload } from "../formPayload";
import type { Drama, DramaDetailPageProps, Episode } from "../interface"; import type { Drama, DramaDetailPageProps, Episode } from "../interface";
import { EpisodeTable } from "./EpisodeTable"; 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> = { const PUBLISH_STATUS_LABEL: Record<string, string> = {
draft: "草稿", draft: "草稿",
scheduled: "定时发布", scheduled: "定时发布",
@@ -20,6 +30,8 @@ export function DramaDetail({ dramaId }: DramaDetailPageProps) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [episodeCount, setEpisodeCount] = useState(1); const [episodeCount, setEpisodeCount] = useState(1);
const [uploadingEpisodeId, setUploadingEpisodeId] = useState<number | null>(null); const [uploadingEpisodeId, setUploadingEpisodeId] = useState<number | null>(null);
const [editVisible, setEditVisible] = useState(false);
const [updatingDrama, setUpdatingDrama] = useState(false);
const loadDetail = async () => { const loadDetail = async () => {
setLoading(true); setLoading(true);
@@ -49,6 +61,18 @@ export function DramaDetail({ dramaId }: DramaDetailPageProps) {
await loadDetail(); 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) => { const uploadEpisodeVideo = async (episode: Episode, file: File) => {
setUploadingEpisodeId(episode.id); setUploadingEpisodeId(episode.id);
try { try {
@@ -76,6 +100,12 @@ export function DramaDetail({ dramaId }: DramaDetailPageProps) {
await loadDetail(); await loadDetail();
}; };
const updateEpisode = async (episode: Episode, values: Partial<Episode>) => {
await dramaApi.updateDramaEpisode(dramaId, episode.id, toEpisodePayload(values));
Message.success("剧集信息已更新");
await loadDetail();
};
return ( return (
<div className="page-stack"> <div className="page-stack">
<Card <Card
@@ -92,6 +122,9 @@ export function DramaDetail({ dramaId }: DramaDetailPageProps) {
<Button icon={<IconRefresh />} onClick={loadDetail}> <Button icon={<IconRefresh />} onClick={loadDetail}>
</Button> </Button>
<Button disabled={!drama} onClick={() => setEditVisible(true)}>
</Button>
<Button onClick={() => updateDramaStatus("draft")}>稿</Button> <Button onClick={() => updateDramaStatus("draft")}>稿</Button>
<Button type="primary" onClick={() => updateDramaStatus("published")}> <Button type="primary" onClick={() => updateDramaStatus("published")}>
@@ -135,8 +168,104 @@ export function DramaDetail({ dramaId }: DramaDetailPageProps) {
uploadingEpisodeId={uploadingEpisodeId} uploadingEpisodeId={uploadingEpisodeId}
onUploadVideo={uploadEpisodeVideo} onUploadVideo={uploadEpisodeVideo}
onPublishEpisode={publishEpisode} onPublishEpisode={publishEpisode}
onUpdateEpisode={updateEpisode}
/> />
</Card> </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> </div>
); );
} }

View File

@@ -4,6 +4,7 @@ import { Button, Card, Form, Grid, Input, InputNumber, InputTag, Message, Modal,
import { IconPlus } from "@arco-design/web-react/icon"; import { IconPlus } from "@arco-design/web-react/icon";
import { dramaApi } from "../../api"; import { dramaApi } from "../../api";
import { usePageData } from "../../hooks/usePageData"; import { usePageData } from "../../hooks/usePageData";
import { toDramaPayload } from "../formPayload";
import type { Drama, StatusOption } from "../interface"; import type { Drama, StatusOption } from "../interface";
const { Row, Col } = Grid; const { Row, Col } = Grid;
@@ -31,7 +32,7 @@ export function DramaList() {
const createDrama = async (values: Partial<Drama>) => { const createDrama = async (values: Partial<Drama>) => {
setCreating(true); setCreating(true);
try { try {
await dramaApi.createDrama(values); await dramaApi.createDrama(toDramaPayload(values));
Message.success("短剧创建成功"); Message.success("短剧创建成功");
setVisible(false); setVisible(false);
await reload(); await reload();
@@ -41,124 +42,128 @@ export function DramaList() {
}; };
return ( return (
<Card <div className="">
className="section-card" <nav className="flex justify-end border border border-[#e5e5e5] pb-4">
title="短剧管理"
extra={
<Button type="primary" icon={<IconPlus />} onClick={() => setVisible(true)}> <Button type="primary" icon={<IconPlus />} onClick={() => setVisible(true)}>
</Button> </Button>
} </nav>
> <Card className="section-card" title="短剧管理">
<Table <Table
rowKey="id" rowKey="id"
loading={loading} loading={loading}
data={data.list} data={data.list}
pagination={{ pagination={{
current: data.pagination.page, current: data.pagination.page,
total: data.pagination.total, total: data.pagination.total,
pageSize: data.pagination.pageSize, pageSize: data.pagination.pageSize,
onChange: (page, pageSize) => changePage(page, pageSize), onChange: (page, pageSize) => changePage(page, pageSize),
}} }}
columns={[ columns={[
{ title: "ID", dataIndex: "id", width: 80 }, { title: "ID", dataIndex: "id", width: 80 },
{ title: "标题", dataIndex: "title" }, { title: "封面图", dataIndex: "coverUrl", width: 120, render: (value) => (value ? <img src={value} alt="封面图" style={{ width: 100, height: 100, objectFit: "cover" }} /> : null) },
{ title: "地区", dataIndex: "region", width: 120 }, { title: "标题", dataIndex: "title" },
{ title: "类型", dataIndex: "type", width: 140 }, { title: "地区", dataIndex: "region", width: 120 },
{ title: "总集数", dataIndex: "totalEpisodes", width: 100 }, { title: "类型", dataIndex: "type", width: 140 },
{ title: "已发布", dataIndex: "publishedEpisodes", width: 100 }, { title: "已发布 / 总集数", dataIndex: "totalEpisodes", width: 180, render: (_, record) => `${record.publishedEpisodes}(已发布)/共${record.totalEpisodes}` },
{ {
title: "状态", title: "状态",
dataIndex: "status", dataIndex: "status",
width: 120, width: 120,
render: (status) => <Tag color={status === "published" ? "green" : "gray"}>{PUBLISH_STATUS_LABEL[String(status)] || String(status)}</Tag>, render: (status) => <Tag color={status === "published" ? "green" : "gray"}>{PUBLISH_STATUS_LABEL[String(status)] || String(status)}</Tag>,
}, },
{ {
title: "推荐", title: "推荐",
dataIndex: "isRecommended", dataIndex: "isRecommended",
width: 100, width: 100,
render: (value) => (value ? <Tag color="arcoblue"></Tag> : <Tag></Tag>), render: (value) => (value ? <Tag color="arcoblue"></Tag> : <Tag></Tag>),
}, },
{ {
title: "操作", title: "操作",
width: 120, width: 120,
render: (_, record) => ( render: (_, record) => (
<Button type="text" onClick={() => navigate(`/dramas/${record.id}`)}> <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> </Button>
), </div>
}, </Form>
]} </Modal>
/> </Card>
<Modal title="创建短剧" visible={visible} onCancel={() => setVisible(false)} footer={null} style={{ width: 640 }}> </div>
<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>
); );
} }

View File

@@ -1,5 +1,8 @@
import { Button, Space, Table, Tag } from "@arco-design/web-react"; import { useState } from "react";
import type { EpisodeTableProps } from "../interface"; 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> = { const REVIEW_STATUS_LABEL: Record<string, string> = {
not_submitted: "未提交", not_submitted: "未提交",
@@ -16,74 +19,186 @@ const PUBLISH_STATUS_LABEL: Record<string, string> = {
offline: "已下线", 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 ( return (
<Table <>
rowKey="id" <Table
loading={loading} rowKey="id"
data={episodes} loading={loading}
pagination={false} data={episodes}
columns={[ pagination={false}
{ title: "集数", dataIndex: "episodeNo", width: 80 }, columns={[
{ title: "标题", dataIndex: "title" }, { title: "集数", dataIndex: "episodeNo", width: 80 },
{ { title: "标题", dataIndex: "title" },
title: "BytePlus VID", {
dataIndex: "byteplusVid", title: "BytePlus VID",
width: 180, dataIndex: "byteplusVid",
render: (value) => value || "-", width: 180,
}, render: (value) => value || "-",
{ },
title: "尺寸", {
width: 120, title: "尺寸",
render: (_, record) => (record.width && record.height ? `${record.width}x${record.height}` : "-"), width: 120,
}, render: (_, record) => (record.width && record.height ? `${record.width}x${record.height}` : "-"),
{ },
title: "时长", {
dataIndex: "durationSeconds", title: "时长",
width: 100, dataIndex: "durationSeconds",
render: (value) => (value ? `${value}s` : "-"), width: 100,
}, render: (value) => (value ? `${value}s` : "-"),
{ },
title: "审核状态", {
dataIndex: "reviewStatus", title: "审核状态",
width: 120, dataIndex: "reviewStatus",
render: (status) => <Tag color={status === "approved" ? "green" : "orange"}>{REVIEW_STATUS_LABEL[String(status)] || String(status)}</Tag>, width: 120,
}, render: (status) => <Tag color={status === "approved" ? "green" : "orange"}>{REVIEW_STATUS_LABEL[String(status)] || String(status)}</Tag>,
{ },
title: "发布状态", {
dataIndex: "publishStatus", title: "发布状态",
width: 120, dataIndex: "publishStatus",
render: (status) => <Tag color={status === "published" ? "green" : "gray"}>{PUBLISH_STATUS_LABEL[String(status)] || String(status)}</Tag>, width: 120,
}, render: (status) => <Tag color={status === "published" ? "green" : "gray"}>{PUBLISH_STATUS_LABEL[String(status)] || String(status)}</Tag>,
{ },
title: "操作", {
width: 280, title: "操作",
render: (_, record) => ( width: 340,
<Space> render: (_, record) => (
<Button loading={uploadingEpisodeId === record.id}> <Space>
<label> <Button loading={uploadingEpisodeId === record.id}>
<label>
<input
type="file" <input
accept="video/*" type="file"
style={{ display: "none" }} accept="video/*"
onChange={(event) => { style={{ display: "none" }}
const file = event.target.files?.[0]; onChange={(event) => {
if (file) { const file = event.target.files?.[0];
void onUploadVideo(record, file); if (file) {
} void onUploadVideo(record, file);
event.target.value = ""; }
}} event.target.value = "";
/> }}
</label> />
</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>
<Button type="text" disabled={record.reviewStatus !== "approved"} onClick={() => onPublishEpisode(record)}> </div>
</Form>
</Button> </Modal>
</Space> )}
), </>
},
]}
/>
); );
} }

82
src/Drama/formPayload.ts Normal file
View 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),
};
}

View File

@@ -17,4 +17,5 @@ export type EpisodeTableProps = {
uploadingEpisodeId: number | null; uploadingEpisodeId: number | null;
onUploadVideo: (episode: Episode, file: File) => void | Promise<void>; onUploadVideo: (episode: Episode, file: File) => void | Promise<void>;
onPublishEpisode: (episode: Episode) => void | Promise<void>; onPublishEpisode: (episode: Episode) => void | Promise<void>;
onUpdateEpisode: (episode: Episode, values: Partial<Episode>) => void | Promise<void>;
}; };

View File

@@ -119,7 +119,7 @@ function RolesPage() {
</Form.Item> </Form.Item>
<div className="form-label"></div> <div className="form-label"></div>
<PermissionTreePicker permissions={permissions.data.list} value={createPermissionIds} onChange={setCreatePermissionIds} /> <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"> <div className="modal-actions">
<Button onClick={() => setCreateVisible(false)}></Button> <Button onClick={() => setCreateVisible(false)}></Button>
<Button type="primary" htmlType="submit" loading={creatingRole}> <Button type="primary" htmlType="submit" loading={creatingRole}>

View File

@@ -1,11 +1,11 @@
import { fetchPage, request } from "../plugins/axios"; 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) { export function listDramas(query: PageQuery) {
return fetchPage<Drama>('/api/admin/v1/dramas', query); 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); await request.post('/api/admin/v1/dramas', payload);
} }
@@ -35,14 +35,22 @@ export async function configureDramaEpisodes(
export async function updateDrama( export async function updateDrama(
dramaId: number, dramaId: number,
payload: Partial<Pick<Drama, 'status'>>, payload: UpdateDramaPayload,
) { ) {
await request.patch(`/api/admin/v1/dramas/${dramaId}`, payload); await request.patch(`/api/admin/v1/dramas/${dramaId}`, payload);
} }
export async function updateEpisode( export async function updateEpisode(
episodeId: number, episodeId: number,
payload: Partial<Pick<Episode, 'publishStatus'>> & { status?: string }, payload: UpdateEpisodePayload,
) { ) {
await request.patch(`/api/admin/v1/episodes/${episodeId}`, payload); 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);
}

View File

@@ -58,6 +58,7 @@ export type Episode = {
episodeNo: number; episodeNo: number;
seq: number; seq: number;
title: string; title: string;
description?: string;
coverUrl: string; coverUrl: string;
videoUrl?: string; videoUrl?: string;
byteplusVid?: string; byteplusVid?: string;
@@ -73,6 +74,14 @@ export type Episode = {
createdAt: string; 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 = { export type UploadTask = {
id: number; id: number;
jobId: string; jobId: string;

View File

@@ -1,5 +1,5 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { resolvePageData } from '../src/hooks/pageData.js'; import { resolvePageData } from '../src/hooks/pageData.ts';
const response = { const response = {
list: [{ id: 21 }], list: [{ id: 21 }],