Compare commits
3 Commits
89d762a8c5
...
c4f1b2818b
Author | SHA1 | Date | |
---|---|---|---|
![]() |
c4f1b2818b | ||
![]() |
ee45ac1e90 | ||
![]() |
5438f75643 |
@ -6,8 +6,9 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"hotgo/internal/model/input/sysin"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// UploadFileReq 上传文件
|
||||
@ -17,6 +18,14 @@ type UploadFileReq struct {
|
||||
|
||||
type UploadFileRes *sysin.AttachmentListModel
|
||||
|
||||
type UploadVideoReq struct {
|
||||
g.Meta `path:"/upload/video" tags:"附件" method:"post" summary:"上传视频"`
|
||||
}
|
||||
|
||||
type UploadVideoRes struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
// CheckMultipartReq 检查文件分片
|
||||
type CheckMultipartReq struct {
|
||||
g.Meta `path:"/upload/checkMultipart" tags:"附件" method:"post" summary:"检查文件分片"`
|
||||
|
@ -7,12 +7,17 @@ package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"hotgo/api/admin/common"
|
||||
"hotgo/internal/library/storager"
|
||||
"hotgo/internal/service"
|
||||
"hotgo/utility/validate"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var Upload = new(cUpload)
|
||||
@ -36,6 +41,73 @@ func (c *cUpload) UploadFile(ctx context.Context, _ *common.UploadFileReq) (res
|
||||
return service.CommonUpload().UploadFile(ctx, uploadType, file)
|
||||
}
|
||||
|
||||
// UploadVideo 上传视频
|
||||
func (c *cUpload) UploadVideo(ctx context.Context, _ *common.UploadVideoReq) (res common.UploadVideoRes, err error) {
|
||||
r := g.RequestFromCtx(ctx)
|
||||
uploadType := r.Header.Get("uploadType")
|
||||
if uploadType != "default" && !validate.InSlice(storager.KindSlice, uploadType) {
|
||||
err = gerror.New("上传类型是无效的")
|
||||
return
|
||||
}
|
||||
|
||||
file := r.GetUploadFile("file")
|
||||
if file == nil {
|
||||
err = gerror.New("没有找到上传的文件")
|
||||
return
|
||||
}
|
||||
|
||||
m3u8UUID := uuid.New().String()
|
||||
|
||||
// 1. 保存上传视频到本地临时目录
|
||||
tmpDir := "./tmp/video"
|
||||
_, _ = file.Save(tmpDir + "/" + m3u8UUID)
|
||||
tmpFile := tmpDir + "/" + m3u8UUID + "/" + file.Filename
|
||||
|
||||
// 2. 用ffmpeg切片为m3u8和ts文件
|
||||
|
||||
hlsDir := "./tmp/hls/" + m3u8UUID
|
||||
_ = os.MkdirAll(hlsDir, 0755)
|
||||
|
||||
defer func() {
|
||||
os.RemoveAll(hlsDir)
|
||||
os.RemoveAll(tmpDir + "/" + m3u8UUID)
|
||||
}()
|
||||
|
||||
m3u8File := m3u8UUID + ".m3u8"
|
||||
m3u8Path := hlsDir + "/" + m3u8File
|
||||
ffmpegCmd := exec.Command("ffmpeg", "-i", tmpFile, "-c:v", "libx264", "-hls_time", "10", "-hls_playlist_type", "vod", m3u8Path)
|
||||
err = ffmpegCmd.Run()
|
||||
if err != nil {
|
||||
err = gerror.Wrap(err, "ffmpeg切片失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 遍历切片目录,上传所有m3u8和ts文件到minio
|
||||
minioDrive := new(storager.MinioDrive)
|
||||
var m3u8MinioPath string
|
||||
files, _ := filepath.Glob(hlsDir + "/*")
|
||||
for _, f := range files {
|
||||
fileInfo, errStat := os.Stat(f)
|
||||
if errStat == nil && !fileInfo.IsDir() {
|
||||
minioPath, err2 := minioDrive.UploadFromPath(ctx, f, filepath.Base(f))
|
||||
if err2 != nil {
|
||||
err = err2
|
||||
return
|
||||
}
|
||||
if filepath.Ext(f) == ".m3u8" {
|
||||
m3u8MinioPath = minioPath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
m3u8MinioPath = storager.LastUrl(ctx, m3u8MinioPath, "minio")
|
||||
|
||||
// 4. 返回m3u8文件的minio路径
|
||||
return common.UploadVideoRes{
|
||||
Path: m3u8MinioPath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CheckMultipart 检查文件分片
|
||||
func (c *cUpload) CheckMultipart(ctx context.Context, req *common.CheckMultipartReq) (res *common.CheckMultipartRes, err error) {
|
||||
data, err := service.CommonUpload().CheckMultipart(ctx, &req.CheckMultipartInp)
|
||||
|
@ -7,14 +7,18 @@ package storager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"mime"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/net/ghttp"
|
||||
"github.com/gogf/gf/v2/os/gfile"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
"github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
"github.com/minio/minio-go/v7/pkg/s3utils"
|
||||
"mime"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// MinioDrive minio对象存储驱动
|
||||
@ -63,6 +67,59 @@ func (d *MinioDrive) Upload(ctx context.Context, file *ghttp.UploadFile) (fullPa
|
||||
return
|
||||
}
|
||||
|
||||
// UploadFromPath 从本地文件上传
|
||||
func (d *MinioDrive) UploadFromPath(ctx context.Context, path string, filename string) (fullPath string, err error) {
|
||||
if config.MinioPath == "" {
|
||||
err = gerror.New("minio存储驱动必须配置存储路径!")
|
||||
return
|
||||
}
|
||||
|
||||
client, err := minio.New(config.MinioEndpoint, &minio.Options{
|
||||
Creds: credentials.NewStaticV4(config.MinioAccessKey, config.MinioSecretKey, ""),
|
||||
Secure: config.MinioUseSSL == 1,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err = s3utils.CheckValidBucketName(config.MinioBucket); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
fullPath = config.MinioPath + gtime.Date() + "/" + filename
|
||||
if err = s3utils.CheckValidObjectName(fullPath); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
fileInfo, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if fileInfo.IsDir() {
|
||||
return "", fmt.Errorf("上传路径是目录: %s", path)
|
||||
}
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
stat, err := file.Stat()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
opts := minio.PutObjectOptions{
|
||||
ContentType: mime.TypeByExtension(filepath.Ext(path)),
|
||||
}
|
||||
if opts.ContentType == "" {
|
||||
opts.ContentType = "application/octet-stream"
|
||||
}
|
||||
|
||||
_, err = client.PutObject(ctx, config.MinioBucket, fullPath, file, stat.Size(), opts)
|
||||
return
|
||||
}
|
||||
|
||||
// CreateMultipart 创建分片事件
|
||||
func (d *MinioDrive) CreateMultipart(ctx context.Context, in *CheckMultipartParams) (res *MultipartProgress, err error) {
|
||||
err = gerror.New("当前驱动暂不支持分片上传!")
|
||||
|
@ -14,6 +14,7 @@
|
||||
@uploadChange="uploadChange"
|
||||
v-model:value="image"
|
||||
v-model:values="images"
|
||||
:maxSize="100"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -52,6 +52,7 @@ export default {
|
||||
'audio/mp4',
|
||||
'video/webm',
|
||||
'video/x-flv',
|
||||
'video/mp4',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
@ -1,25 +1,13 @@
|
||||
<template>
|
||||
<div>
|
||||
<n-modal
|
||||
v-model:show="showModal"
|
||||
:mask-closable="false"
|
||||
:show-icon="false"
|
||||
preset="dialog"
|
||||
transform-origin="center"
|
||||
:title="formValue.id > 0 ? '编辑课程章节 #' + formValue.id : '添加课程章节'"
|
||||
:style="{
|
||||
<n-modal v-model:show="showModal" :mask-closable="false" :show-icon="false" preset="dialog"
|
||||
transform-origin="center" :title="formValue.id > 0 ? '编辑课程章节 #' + formValue.id : '添加课程章节'" :style="{
|
||||
width: dialogWidth,
|
||||
}"
|
||||
>
|
||||
}">
|
||||
<n-scrollbar style="max-height: 87vh" class="pr-5">
|
||||
<n-spin :show="loading" description="请稍候...">
|
||||
<n-form
|
||||
ref="formRef"
|
||||
:model="formValue"
|
||||
:label-placement="settingStore.isMobile ? 'top' : 'left'"
|
||||
:label-width="100"
|
||||
class="py-4"
|
||||
>
|
||||
<n-form ref="formRef" :model="formValue" :label-placement="settingStore.isMobile ? 'top' : 'left'"
|
||||
:label-width="100" class="py-4">
|
||||
<n-grid cols="1 s:1 m:1 l:1 xl:1 2xl:1" responsive="screen">
|
||||
<n-gi span="1">
|
||||
<n-form-item label="课程id" path="lessonId">
|
||||
@ -28,7 +16,16 @@
|
||||
</n-gi>
|
||||
<n-gi span="1">
|
||||
<n-form-item label="视频url" path="videoUrl">
|
||||
<n-input placeholder="请输入视频url" v-model:value="formValue.videoUrl" />
|
||||
<n-upload
|
||||
:action="`${uploadUrl}${urlPrefix}/upload/video`"
|
||||
:max="1"
|
||||
:show-file-list="false"
|
||||
:headers="uploadHeaders"
|
||||
@finish="handleVideoUpload"
|
||||
@before-upload="handleVideoUploadStart"
|
||||
>
|
||||
<n-button :loading="uploadingVideo">上传视频</n-button>
|
||||
</n-upload>
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi span="1">
|
||||
@ -70,79 +67,111 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useDictStore } from '@/store/modules/dict';
|
||||
import { Edit, View } from '@/api/lessonSection';
|
||||
import { State, newState } from './model';
|
||||
import { useProjectSettingStore } from '@/store/modules/projectSetting';
|
||||
import { useMessage } from 'naive-ui';
|
||||
import { adaModalWidth } from '@/utils/hotgo';
|
||||
import { ref, computed, reactive } from 'vue';
|
||||
import { useDictStore } from '@/store/modules/dict';
|
||||
import { Edit, View } from '@/api/lessonSection';
|
||||
import { State, newState } from './model';
|
||||
import { useProjectSettingStore } from '@/store/modules/projectSetting';
|
||||
import { useMessage } from 'naive-ui';
|
||||
import { adaModalWidth } from '@/utils/hotgo';
|
||||
import { useGlobSetting } from '@/hooks/setting';
|
||||
import { useUserStoreWidthOut } from '@/store/modules/user';
|
||||
|
||||
const emit = defineEmits(['reloadTable']);
|
||||
const message = useMessage();
|
||||
const settingStore = useProjectSettingStore();
|
||||
const dict = useDictStore();
|
||||
const loading = ref(false);
|
||||
const showModal = ref(false);
|
||||
const formValue = ref<State>(newState(null));
|
||||
const formRef = ref<any>({});
|
||||
const formBtnLoading = ref(false);
|
||||
const dialogWidth = computed(() => {
|
||||
return adaModalWidth(840);
|
||||
const globSetting = useGlobSetting();
|
||||
const urlPrefix = globSetting.urlPrefix || '';
|
||||
const { uploadUrl } = globSetting;
|
||||
|
||||
const useUserStore = useUserStoreWidthOut();
|
||||
const uploadHeaders = reactive({
|
||||
Authorization: useUserStore.token,
|
||||
uploadType: 'default',
|
||||
});
|
||||
|
||||
// 提交表单
|
||||
function confirmForm(e) {
|
||||
e.preventDefault();
|
||||
formRef.value.validate((errors) => {
|
||||
if (!errors) {
|
||||
formBtnLoading.value = true;
|
||||
Edit(formValue.value)
|
||||
.then((_res) => {
|
||||
message.success('操作成功');
|
||||
closeForm();
|
||||
emit('reloadTable');
|
||||
})
|
||||
.finally(() => {
|
||||
formBtnLoading.value = false;
|
||||
});
|
||||
} else {
|
||||
message.error('请填写完整信息');
|
||||
}
|
||||
});
|
||||
}
|
||||
const emit = defineEmits(['reloadTable']);
|
||||
const message = useMessage();
|
||||
const settingStore = useProjectSettingStore();
|
||||
const dict = useDictStore();
|
||||
const loading = ref(false);
|
||||
const showModal = ref(false);
|
||||
const formValue = ref<State>(newState(null));
|
||||
const formRef = ref<any>({});
|
||||
const formBtnLoading = ref(false);
|
||||
const dialogWidth = computed(() => {
|
||||
return adaModalWidth(840);
|
||||
});
|
||||
const uploadingVideo = ref(false);
|
||||
|
||||
// 关闭表单
|
||||
function closeForm() {
|
||||
showModal.value = false;
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
// 打开模态框
|
||||
function openModal(state: State) {
|
||||
showModal.value = true;
|
||||
|
||||
// 新增
|
||||
if (!state || state.id < 1) {
|
||||
formValue.value = newState(state);
|
||||
|
||||
return;
|
||||
// 提交表单
|
||||
function confirmForm(e) {
|
||||
e.preventDefault();
|
||||
formRef.value.validate((errors) => {
|
||||
if (!errors) {
|
||||
formBtnLoading.value = true;
|
||||
Edit(formValue.value)
|
||||
.then((_res) => {
|
||||
message.success('操作成功');
|
||||
closeForm();
|
||||
emit('reloadTable');
|
||||
})
|
||||
.finally(() => {
|
||||
formBtnLoading.value = false;
|
||||
});
|
||||
} else {
|
||||
message.error('请填写完整信息');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 编辑
|
||||
loading.value = true;
|
||||
View({ id: state.id })
|
||||
.then((res) => {
|
||||
formValue.value = res;
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
// 关闭表单
|
||||
function closeForm() {
|
||||
showModal.value = false;
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
// 打开模态框
|
||||
function openModal(state: State) {
|
||||
showModal.value = true;
|
||||
|
||||
// 新增
|
||||
if (!state || state.id < 1) {
|
||||
formValue.value = newState(state);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
openModal,
|
||||
});
|
||||
// 编辑
|
||||
loading.value = true;
|
||||
View({ id: state.id })
|
||||
.then((res) => {
|
||||
formValue.value = res;
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
// 上传完成回调
|
||||
function handleVideoUpload({ file, event }) {
|
||||
uploadingVideo.value = false;
|
||||
let res = event?.target?.response
|
||||
? JSON.parse(event.target.response)
|
||||
: {};
|
||||
if (res.code === 0 && res.data?.path) {
|
||||
formValue.value.videoUrl = res.data.path;
|
||||
message.success('视频上传成功');
|
||||
} else {
|
||||
message.error(res.message || '视频上传失败');
|
||||
}
|
||||
}
|
||||
|
||||
// 上传开始回调
|
||||
function handleVideoUploadStart() {
|
||||
uploadingVideo.value = true;
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
openModal,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less"></style>
|
Loading…
x
Reference in New Issue
Block a user