feat: 课程创建

This commit is contained in:
mozzie 2023-03-07 17:44:18 +08:00
parent d649a14244
commit 220b46091d
22 changed files with 1345 additions and 726 deletions

View File

@ -0,0 +1,9 @@
export interface IgetVodRequest {
offset: number;
limit: number;
}
export interface IGetVodeResponse {
MediaInfoSet: any[];
TotalCount: number;
}

View File

@ -1,3 +1,9 @@
import axios from "axios";
import R from "./request";
import P from "./process";
import { IgetVodRequest } from "./dto";
export const getVod = () => axios.post("/api/vod", { offset: 0, limit: 5000 });
/**
* vod媒资
*/
export const getVod = (p: IgetVodRequest) =>
R.post("/api/vod", { ...p }).then((d: any) => P.getVod(d.data));

View File

@ -0,0 +1,23 @@
import { IGetVodeResponse } from "./dto";
/**
*
*/
export default {
/**
* vod媒资
*/
getVod: (p: IGetVodeResponse) => {
const { TotalCount: total, MediaInfoSet } = p;
const mediaList = MediaInfoSet.map((item) => {
return {
FileId: item.FileId,
AdaptStream:
item.AdaptiveDynamicStreamingInfo.AdaptiveDynamicStreamingSet[0],
BasicInfo: item.BasicInfo,
MetaData: item.MetaData,
};
});
return { total, mediaList };
},
};

View File

@ -0,0 +1,34 @@
import { message } from "antd";
import axios from "axios";
const config = {
baseURL: "",
timeout: 1000 * 15,
headers: {},
};
const instance = axios.create(config);
instance.interceptors.request.use(
(config) => {
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Add a response interceptor
instance.interceptors.response.use(
(response) => {
if (response.data.code === 10000)
message.success(`接口: ${response.config.url}, 请求成功`);
return response?.data;
},
(error) => {
message.error(error);
return Promise.reject(error);
}
);
export default instance;

View File

@ -0,0 +1,5 @@
const Appendix = () => {
return <div></div>;
};
export default Appendix;

View File

@ -0,0 +1,30 @@
.preview-course {
position: relative;
background-size: cover;
background-position: center;
border-radius: 6px;
height: 100%;
.mask {
position: absolute;
left: 50%;
top: 50%;
width: 60%;
transform: translate(-50%, -50%);
p {
color: #fff;
text-align: center;
margin: 0;
&.title {
background: rgba(0, 0, 0, 0.7);
font-size: 22px;
padding: 10px;
line-height: 1.5;
}
&.summary {
background: rgba(0, 0, 0, 0.3);
padding: 6px;
line-height: 1.4;
}
}
}
}

View File

@ -0,0 +1,112 @@
import { InboxOutlined } from "@ant-design/icons";
import {
Card,
Col,
Form,
Input,
message,
Row,
Upload,
UploadProps,
} from "antd";
import { useState } from "react";
import "./index.less";
const { Dragger } = Upload;
const { Meta } = Card;
interface IProps {
onChange: Function;
}
const BasicForm = (props: IProps) => {
const [preview, setPreivew] = useState({
coverUrl: "",
title: "标题",
summary: "摘要",
});
const [form] = Form.useForm();
const onValuesChange = (_: any, all: any) => {
setPreivew((p) => ({ ...p, ...all }));
};
const coverDragger: UploadProps = {
name: "file",
multiple: true,
action: "/api/upload",
onChange(info) {
const { status } = info.file;
if (status !== "uploading") console.log(info.file, info.fileList);
if (status === "done") {
const { code, data } = info.file.response;
if (code === 10000) {
message.success(`${info.file.name} 文件上传成功`);
const { MediaUrl } = data;
setPreivew((p) => ({ ...p, coverUrl: MediaUrl }));
}
} else if (status === "error") {
message.error(`${info.file.name} 文件上传失败`);
}
},
onDrop(e) {
console.log("Dropped files", e.dataTransfer.files);
},
};
return (
<div>
<Row gutter={24}>
<Col span={12}>
<Form form={form} onValuesChange={onValuesChange}>
<Form.Item>
<Dragger {...coverDragger} maxCount={1}>
<p className="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p className="ant-upload-text"></p>
<p className="ant-upload-hint"></p>
</Dragger>
</Form.Item>
<Form.Item name="title" rules={[{ required: true }]}>
<Input type="text" placeholder="标题" />
</Form.Item>
<Form.Item
name="summary"
rules={[{ required: true }]}
style={{ marginBottom: 0 }}
>
<Input.TextArea
placeholder="摘要"
style={{ height: 120, resize: "none" }}
/>
</Form.Item>
</Form>
</Col>
<Col span={12}>
<div
className="preview-course"
style={{
backgroundImage: !preview.coverUrl
? `linear-gradient(
to right,
#e95659,
#e15084,
#c55aaa,
#976bc4,
#5678ce
)`
: `url(${preview.coverUrl})`,
}}
>
<div className="mask">
<p className="title">{preview.title}</p>
<p className="summary">{preview.summary} </p>
</div>
</div>
</Col>
</Row>
</div>
);
};
export default BasicForm;

View File

@ -0,0 +1,5 @@
const MediaBind = () => {
return <div>123</div>;
};
export default MediaBind;

View File

@ -0,0 +1,8 @@
.create-course {
display: flex;
flex-direction: column;
.content {
padding: 20px 0;
flex: 1;
}
}

View File

@ -1,9 +1,67 @@
import { Button, Form, Input, InputNumber, Row, Space } from "antd";
import {
Button,
Form,
Input,
InputNumber,
message,
Row,
Space,
Steps,
} from "antd";
import { useState } from "react";
import Appendix from "./Appendix";
import BasicForm from "./BasicForm";
import "./index.less";
import MediaBind from "./MediaBind";
const CourseCreate = () => {
const [current, setCurrent] = useState(0);
const steps = [
{
title: "基本信息",
content: <BasicForm />,
},
{
title: "媒体资源",
content: <MediaBind />,
},
{
title: "附件",
content: <Appendix />,
},
];
const items = steps.map((item) => ({ key: item.title, title: item.title }));
return (
<div>
<Form>
<div className="create-course">
<Steps current={current} items={items} />
<div className="content">{steps[current].content}</div>
<div style={{ textAlign: "right" }}>
<Button
style={{
margin: "0 8px",
visibility: current > 0 ? "visible" : "hidden",
}}
onClick={() => setCurrent(current - 1)}
>
</Button>
{current < steps.length - 1 && (
<Button type="primary" onClick={() => setCurrent(current + 1)}>
</Button>
)}
{current === steps.length - 1 && (
<Button
type="primary"
onClick={() => message.success("Processing complete!")}
>
</Button>
)}
</div>
{/* <Form>
<Form.Item
wrapperCol={{ span: 24 }}
name="title"
@ -11,13 +69,11 @@ const CourseCreate = () => {
>
<Input placeholder="标题" />
</Form.Item>
<Form.Item>
<InputNumber placeholder="售价" />
</Form.Item>
<Form.Item><InputNumber placeholder="售价" /></Form.Item>
<Form.Item>
<Button type="primary"></Button>
</Form.Item>
</Form>
</Form> */}
</div>
);
};

View File

@ -10,69 +10,159 @@ import {
Segmented,
Tooltip,
Image,
Typography,
Modal,
Tag,
} from "antd";
import { useEffect } from "react";
import dayjs from "dayjs";
import { useState } from "react";
import { getVod } from "../../../api";
import { useMount } from "../../../hooks";
const { Paragraph, Text } = Typography;
const { Search } = Input;
const Library = () => {
const dataSource = [
{
key: "1",
name: "K线篇1--特殊K线的量化描述.mp4",
duration: 845,
const [dataSource, setDataSource] = useState<any>([]);
const [total, setTotal] = useState<number | string>("");
const [loading, setLoading] = useState(false);
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
cover: (
<Image
height={60}
src="https://1500018521.vod2.myqcloud.com/a28b6648vodtranssh1500018521/8a1352da243791580308966554/coverBySnapshot_10_0.jpg"
/>
),
},
{
key: "2",
name: "Vite + React + TS - Google Chrome 2023-02-15 09-55-08.mp4",
duration: 16,
cover: (
<Image
height={60}
src="https://1500018521.vod2.myqcloud.com/a28b6648vodtranssh1500018521/29226db4243791580097740418/coverBySnapshot/coverBySnapshot_10_0.jpg"
/>
),
},
];
const colors = [
"magenta",
"red",
"volcano",
"orange",
"gold",
"green",
"cyan",
].reverse();
const columns = [
{
title: "媒体文件名称",
dataIndex: "name",
key: "name",
render: (_: any, record: any) => {
return (
<div>
<Row>{record.name}</Row>
<Row style={{ paddingTop: "5px" }}>
{record.m3u8SubStreamList.map((item: any, index: number) => (
<Tag color={colors[index]}>{item}</Tag>
))}
</Row>
<Row style={{ paddingTop: "5px" }}>
<Text type="secondary">: {record.duration}s</Text>
</Row>
</div>
);
},
},
{
title: "时长",
dataIndex: "duration",
key: "duration",
title: "FileID",
dataIndex: "key",
key: "key",
},
{
title: "封面图片地址",
title: "m3u8 大小",
dataIndex: "m3u8Size",
key: "m3u8Size",
},
{
title: "状态",
dataIndex: "status",
key: "status",
},
{
title: "创建时间",
dataIndex: "createtime",
key: "createtime",
},
{
title: "m3u8",
dataIndex: "m3u8",
key: "m3u8",
},
{
title: "视频封面图",
dataIndex: "cover",
key: "cover",
},
{
title: "操作",
dataIndex: "operation",
key: "operation",
render: (_: any, record: any) => {
return (
<Space>
<a href={record.mp4} target="_blank">
</a>
</Space>
);
},
},
];
const onSelectChange = (newSelectedRowKeys: React.Key[]) => {
console.log("selectedRowKeys changed: ", newSelectedRowKeys);
setSelectedRowKeys(newSelectedRowKeys);
};
const rowSelection = { selectedRowKeys, onChange: onSelectChange };
const onSearch = (value: string) => console.log(value);
useMount(() => {
getVod().then((res) => {
console.log(res);
const computeMediaList = (mediaList: any) => {
return mediaList.map((m: any) => ({
key: m.FileId,
mp4: m.BasicInfo.MediaUrl,
m3u8: (
<Paragraph style={{ margin: 0 }} copyable={{ text: m.AdaptStream.Url }}>
</Paragraph>
),
m3u8Size: (m.AdaptStream.Size / 1024 / 1024).toFixed(2) + " MB",
m3u8SubStreamList: m.AdaptStream.SubStreamSet.map(
(i: any) => i.Height + "p"
),
name: m.BasicInfo.Name,
duration: m.MetaData.Duration,
cover: (
<Paragraph
style={{ margin: 0 }}
copyable={{ text: m.BasicInfo.CoverUrl }}
>
</Paragraph>
),
status: m.BasicInfo.Status,
createtime: dayjs(m.BasicInfo.CreateTime).format("YYYY-MM-DD HH:mm:ss"),
}));
};
/**
* 5000
*/
const fetchVod = () => {
setLoading(true);
getVod({ offset: 0, limit: 5000 }).then((process: any) => {
const { mediaList, total } = process;
setDataSource(computeMediaList(mediaList));
setTotal(total);
setLoading(false);
});
};
useMount(() => {
fetchVod();
});
return (
<div>
<Row>
<Col span={20}>
<Col span={14}>
<Space>
<Segmented options={["生", "熟"]} />
<Search
@ -82,19 +172,29 @@ const Library = () => {
/>
</Space>
</Col>
<Col span={4} style={{ textAlign: "right" }}>
<Tooltip title="从腾讯云VOD同步全部视频" placement="left">
<Button type="primary" icon={<CloudSyncOutlined />}>
</Button>
</Tooltip>
<Col span={10} style={{ textAlign: "right" }}>
<Space>
<Text type="secondary"> {total} </Text>
<Tooltip title="从腾讯云VOD同步全部视频" placement="left">
<Button
onClick={() => fetchVod()}
type="primary"
icon={<CloudSyncOutlined />}
>
</Button>
</Tooltip>
</Space>
</Col>
</Row>
<Row style={{ marginTop: "16px" }}>
<Col span={24}>
<Card>
<Table dataSource={dataSource} columns={columns} />
</Card>
<Table
rowSelection={rowSelection}
loading={loading}
dataSource={dataSource}
columns={columns}
/>
</Col>
</Row>
</div>

View File

@ -9,7 +9,7 @@ export default defineConfig({
port: 5174,
proxy: {
"/api": {
target: "http://172.20.160.221:7001/api",
target: "http://127.0.0.1:7001/api",
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ""),
},

View File

@ -3,4 +3,5 @@ OSS_SECRET=12345
SECRET_ID=AKID534tZ7OvYzb2KQMwLYaVEl5FBwUtQWbU
SECRET_KEY=q9HD6lQimeLp9IH5h7NRJzUpNjwxmPq5
SECRET_KEY=q9HD6lQimeLp9IH5h7NRJzUpNjwxmPq5
SUBAPPID=1500018521

View File

@ -16,6 +16,7 @@
"@midwayjs/static-file": "^3.0.0",
"@midwayjs/redis": "^3.0.0",
"@midwayjs/typeorm": "^3.0.0",
"@midwayjs/upload": "3.10.14",
"mongoose": "^6.0.7",
"@midwayjs/typegoose": "3.0.0",
"@typegoose/typegoose": "10.1.1",
@ -23,7 +24,8 @@
"mysql2": "3.0.1",
"dotenv": "16.0.3",
"jsonwebtoken": "9.0.0",
"tencentcloud-sdk-nodejs": "4.0.552"
"tencentcloud-sdk-nodejs": "4.0.552",
"vod-node-sdk": "1.1.0"
},
"devDependencies": {
"@midwayjs/cli": "^2.0.0",

View File

@ -0,0 +1,7 @@
/**
*
*/
export enum BizCode {
OK = 10000,
ERROR = 20000,
}

View File

@ -1,4 +1,34 @@
import { MidwayAppInfo, MidwayConfig } from '@midwayjs/core';
import { uploadWhiteList } from '@midwayjs/upload';
import { tmpdir } from 'os';
import { join } from 'path';
// '.jpg',
// '.jpeg',
// '.png',
// '.gif',
// '.bmp',
// '.wbmp',
// '.webp',
// '.tif',
// '.psd',
// '.svg',
// '.js',
// '.jsx',
// '.json',
// '.css',
// '.less',
// '.html',
// '.htm',
// '.xml',
// '.pdf',
// '.zip',
// '.gz',
// '.tgz',
// '.gzip',
// '.mp3',
// '.mp4',
// '.avi',
export default (appInfo: MidwayAppInfo): MidwayConfig => {
return {
@ -6,5 +36,19 @@ export default (appInfo: MidwayAppInfo): MidwayConfig => {
koa: {
port: 7001,
},
upload: {
// mode: UploadMode, 默认为file即上传到服务器临时目录可以配置为 stream
mode: 'file',
// fileSize: string, 最大上传文件大小,默认为 10mb
fileSize: '10mb',
// whitelist: string[],文件扩展名白名单
whitelist: uploadWhiteList,
// tmpdir: string上传的文件临时存储路径
tmpdir: join(tmpdir(), 'midway-upload-files'),
// cleanTimeout: number上传的文件在临时目录中多久之后自动删除默认为 5 分钟
cleanTimeout: 5 * 60 * 1000,
// base64: boolean设置原始body是否是base64格式默认为false一般用于腾讯云的兼容
base64: false,
},
};
};

View File

@ -6,6 +6,7 @@ import * as staticFile from '@midwayjs/static-file';
import * as orm from '@midwayjs/typeorm';
import * as dotenv from 'dotenv';
import * as redis from '@midwayjs/redis';
import * as upload from '@midwayjs/upload';
import { join } from 'path';
import { DefaultErrorFilter } from './filter/default.filter';
import { NotFoundFilter } from './filter/notfound.filter';
@ -21,6 +22,7 @@ dotenv.config();
staticFile,
orm,
redis,
upload,
{
component: info,
enabledEnvironment: ['local'],

View File

@ -1,9 +1,11 @@
import { Inject, Controller, Post, Body } from '@midwayjs/core';
import { Inject, Controller, Post, Body, Files, Fields } from '@midwayjs/core';
import { Context } from '@midwayjs/koa';
import { Validate } from '@midwayjs/validate';
import { UserDTO } from '../dto/user.dto';
import * as tencentcloud from 'tencentcloud-sdk-nodejs';
import { VodSearchDTO } from '../dto/vod.dto';
import { BizCode } from '../biz/code';
import { uploadImagePromise } from '../util/vod';
@Controller('/api')
export class APIController {
@ -39,10 +41,29 @@ export class APIController {
},
};
const client = new VodClient(clientConfig);
const params = { SubAppId: 1500018521, Offset, Limit };
const params = {
SubAppId: +process.env.SUBAPPID,
Categories: ['Video'],
Offset,
Limit,
};
return await client.SearchMedia(params).then(
data => data,
err => this.ctx.logger.error(err)
data => ({ code: BizCode.OK, data }),
err => {
this.ctx.logger.error(err);
throw new Error(err);
}
);
}
@Post('/upload')
async upload(@Files() files, @Fields() fields) {
const tmpPath = files[0].data;
return await uploadImagePromise(tmpPath)
.then(data => ({ code: BizCode.OK, data }))
.catch(err => {
this.ctx.logger.error(err);
throw new Error(err);
});
}
}

View File

@ -0,0 +1,18 @@
import { VodUploadClient, VodUploadRequest } from 'vod-node-sdk';
/**
*
* @param {string} tmpPath midwayjs缓存的地址
*/
export const uploadImagePromise = (tmpPath: string) => {
const { SECRET_ID, SECRET_KEY } = process.env;
const client = new VodUploadClient(SECRET_ID, SECRET_KEY);
const req = new VodUploadRequest();
req.MediaFilePath = tmpPath;
req.SubAppId = +process.env.SUBAPPID;
return new Promise((resolve, reject) => {
client.upload('ap-shanghai', req, (err, data) => {
err ? reject(err) : resolve(data);
});
});
};

View File

@ -16,7 +16,6 @@
"@arco-design/web-react": "2.45.0",
"@ricons/fluent": "0.12.0",
"@ricons/utils": "0.1.6",
"dayjs": "1.11.7",
"dplayer": "1.27.1"
},
"devDependencies": {

View File

@ -48,7 +48,8 @@
"rollup-plugin-babel": "4.4.0",
"@babel/core": "7.21.0",
"rollup-plugin-dts": "5.2.0",
"axios": "1.3.4"
"axios": "1.3.4",
"dayjs": "1.11.7"
},
"dependencies": {
"object-hash": "^3.0.0"

File diff suppressed because it is too large Load Diff