feat: 完善短剧管理后台功能,新增编辑、详情展示优化
1. 重构接口类型定义,新增创建/更新短剧和剧集的载荷类型 2. 新增表单数据转换工具,规范化表单提交参数 3. 优化短剧列表展示,新增封面图和更清晰的集数统计 4. 新增短剧和剧集的编辑弹窗功能 5. 修复测试文件导入路径和README标题 6. 优化角色管理页面的表单提示样式
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
# CTH TikTok 短剧管理后台
|
# TikTok 短剧管理后台
|
||||||
|
|
||||||
基于 Vite + TypeScript + React + Arco Design 的后台管理系统,默认中文界面。
|
基于 Vite + TypeScript + React + Arco Design 的后台管理系统,默认中文界面。
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,15 +42,13 @@ 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}
|
||||||
@@ -62,11 +61,11 @@ export function DramaList() {
|
|||||||
}}
|
}}
|
||||||
columns={[
|
columns={[
|
||||||
{ title: "ID", dataIndex: "id", width: 80 },
|
{ 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: "title" },
|
||||||
{ title: "地区", dataIndex: "region", width: 120 },
|
{ title: "地区", dataIndex: "region", width: 120 },
|
||||||
{ title: "类型", dataIndex: "type", width: 140 },
|
{ title: "类型", dataIndex: "type", width: 140 },
|
||||||
{ title: "总集数", dataIndex: "totalEpisodes", width: 100 },
|
{ title: "已发布 / 总集数", dataIndex: "totalEpisodes", width: 180, render: (_, record) => `${record.publishedEpisodes}(已发布)/共${record.totalEpisodes}集` },
|
||||||
{ title: "已发布", dataIndex: "publishedEpisodes", width: 100 },
|
|
||||||
{
|
{
|
||||||
title: "状态",
|
title: "状态",
|
||||||
dataIndex: "status",
|
dataIndex: "status",
|
||||||
@@ -91,7 +90,7 @@ export function DramaList() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<Modal title="创建短剧" visible={visible} onCancel={() => setVisible(false)} footer={null} style={{ width: 640 }}>
|
<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 layout="vertical" onSubmit={createDrama} initialValues={{ status: "draft", isRecommended: false, sortOrder: 0, totalEpisodes: 1 }}>
|
||||||
<Form.Item label="标题" field="title" rules={[{ required: true }]}>
|
<Form.Item label="标题" field="title" rules={[{ required: true }]}>
|
||||||
<Input placeholder="请输入短剧标题" />
|
<Input placeholder="请输入短剧标题" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -140,12 +139,17 @@ export function DramaList() {
|
|||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
<Col span={12}>
|
<Col span={8}>
|
||||||
<Form.Item label="排序权重" field="sortOrder">
|
<Form.Item label="排序权重" field="sortOrder">
|
||||||
<InputNumber placeholder="数值越大越靠前" style={{ width: "100%" }} />
|
<InputNumber placeholder="数值越大越靠前" style={{ width: "100%" }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={12}>
|
<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">
|
<Form.Item label="推荐" field="isRecommended" triggerPropName="checked">
|
||||||
<Switch />
|
<Switch />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -160,5 +164,6 @@ export function DramaList() {
|
|||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
</Card>
|
</Card>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,8 +19,32 @@ 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
|
<Table
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
@@ -57,7 +84,7 @@ export function EpisodeTable({ episodes, loading, uploadingEpisodeId, onUploadVi
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "操作",
|
title: "操作",
|
||||||
width: 280,
|
width: 340,
|
||||||
render: (_, record) => (
|
render: (_, record) => (
|
||||||
<Space>
|
<Space>
|
||||||
<Button loading={uploadingEpisodeId === record.id}>
|
<Button loading={uploadingEpisodeId === record.id}>
|
||||||
@@ -77,6 +104,9 @@ export function EpisodeTable({ episodes, loading, uploadingEpisodeId, onUploadVi
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button type="text" onClick={() => setEditingEpisode(record)}>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
<Button type="text" disabled={record.reviewStatus !== "approved"} onClick={() => onPublishEpisode(record)}>
|
<Button type="text" disabled={record.reviewStatus !== "approved"} onClick={() => onPublishEpisode(record)}>
|
||||||
发布
|
发布
|
||||||
</Button>
|
</Button>
|
||||||
@@ -85,5 +115,90 @@ export function EpisodeTable({ episodes, loading, uploadingEpisodeId, onUploadVi
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
</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;
|
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>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 }],
|
||||||
|
|||||||
Reference in New Issue
Block a user