song detail page is ready

This commit is contained in:
RockYang 2024-07-26 19:12:44 +08:00
parent f6f8748521
commit 2129f7a8b7
22 changed files with 718 additions and 82 deletions

View File

@ -152,7 +152,7 @@ func (h *SunoHandler) List(c *gin.Context) {
// 统计总数
var total int64
session.Debug().Model(&model.SunoJob{}).Count(&total)
session.Model(&model.SunoJob{}).Count(&total)
if page > 0 && pageSize > 0 {
offset := (page - 1) * pageSize
@ -164,7 +164,19 @@ func (h *SunoHandler) List(c *gin.Context) {
resp.ERROR(c, err.Error())
return
}
// 初始化续写关系
songIds := make([]string, 0)
for _, v := range list {
if v.RefTaskId != "" {
songIds = append(songIds, v.RefSongId)
}
}
var tasks []model.SunoJob
h.DB.Where("song_id IN ?", songIds).Find(&tasks)
songMap := make(map[string]model.SunoJob)
for _, t := range tasks {
songMap[t.SongId] = t
}
// 转换为 VO
items := make([]vo.SunoJob, 0)
for _, v := range list {
@ -173,6 +185,15 @@ func (h *SunoHandler) List(c *gin.Context) {
if err != nil {
continue
}
item.CreatedAt = v.CreatedAt.Unix()
if s, ok := songMap[v.RefSongId]; ok {
item.RefSong = map[string]interface{}{
"id": s.Id,
"title": s.Title,
"cover": s.CoverURL,
"audio": s.AudioURL,
}
}
items = append(items, item)
}
@ -191,8 +212,7 @@ func (h *SunoHandler) Remove(c *gin.Context) {
// 删除任务
h.DB.Delete(&job)
// 删除文件
_ = h.uploader.GetUploadHandler().Delete(job.ThumbImgURL)
_ = h.uploader.GetUploadHandler().Delete(job.CoverImgURL)
_ = h.uploader.GetUploadHandler().Delete(job.CoverURL)
_ = h.uploader.GetUploadHandler().Delete(job.AudioURL)
}
@ -208,3 +228,71 @@ func (h *SunoHandler) Publish(c *gin.Context) {
resp.SUCCESS(c)
}
func (h *SunoHandler) Update(c *gin.Context) {
var data struct {
Id int `json:"id"`
Title string `json:"title"`
Cover string `json:"cover"`
}
if err := c.ShouldBindJSON(&data); err != nil {
resp.ERROR(c, types.InvalidArgs)
return
}
if data.Id == 0 || data.Title == "" || data.Cover == "" {
resp.ERROR(c, types.InvalidArgs)
return
}
userId := h.GetLoginUserId(c)
var item model.SunoJob
if err := h.DB.Where("id", data.Id).Where("user_id", userId).First(&item).Error; err != nil {
resp.ERROR(c, err.Error())
return
}
item.Title = data.Title
item.CoverURL = data.Cover
if err := h.DB.Updates(&item).Error; err != nil {
resp.ERROR(c, err.Error())
return
}
resp.SUCCESS(c)
}
// Detail 歌曲详情
func (h *SunoHandler) Detail(c *gin.Context) {
id := h.GetInt(c, "id", 0)
if id <= 0 {
resp.ERROR(c, types.InvalidArgs)
return
}
var item model.SunoJob
if err := h.DB.Where("id", id).First(&item).Error; err != nil {
resp.ERROR(c, err.Error())
return
}
// 读取用户信息
var user model.User
if err := h.DB.Where("id", item.UserId).First(&user).Error; err != nil {
resp.ERROR(c, err.Error())
return
}
var itemVo vo.SunoJob
if err := utils.CopyObject(item, &itemVo); err != nil {
resp.ERROR(c, err.Error())
return
}
itemVo.CreatedAt = item.CreatedAt.Unix()
itemVo.User = map[string]interface{}{
"nickname": user.Nickname,
"avatar": user.Avatar,
}
resp.SUCCESS(c, itemVo)
}

View File

@ -492,6 +492,8 @@ func main() {
group.GET("list", h.List)
group.GET("remove", h.Remove)
group.GET("publish", h.Publish)
group.POST("update", h.Update)
group.GET("detail", h.Detail)
}),
fx.Invoke(func(s *core.AppServer, db *gorm.DB) {
go func() {

View File

@ -86,7 +86,7 @@ func (s *Service) Run() {
r, err := s.Create(task)
if err != nil {
logger.Errorf("create task with error: %v", err)
s.db.UpdateColumns(map[string]interface{}{
s.db.Model(&model.SunoJob{Id: task.Id}).UpdateColumns(map[string]interface{}{
"err_msg": err.Error(),
"progress": 101,
})
@ -122,7 +122,7 @@ func (s *Service) Create(task types.SunoTask) (RespVo, error) {
}
reqBody := map[string]interface{}{
"task_id": task.TaskId,
"task_id": task.RefTaskId,
"continue_clip_id": task.RefSongId,
"continue_at": task.ExtendSecs,
"make_instrumental": task.Instrumental,
@ -153,6 +153,10 @@ func (s *Service) Create(task types.SunoTask) (RespVo, error) {
if err != nil {
return RespVo{}, fmt.Errorf("解析API数据失败%v, %s", err, string(body))
}
if res.Code != "success" {
return RespVo{}, fmt.Errorf("API 返回失败:%s", res.Message)
}
res.Channel = apiKey.ApiURL
return res, nil
}
@ -189,15 +193,8 @@ func (s *Service) DownloadImages() {
for _, v := range items {
// 下载图片和音频
logger.Infof("try download thumb image: %s", v.ThumbImgURL)
thumbURL, err := s.uploadManager.GetUploadHandler().PutUrlFile(v.ThumbImgURL, true)
if err != nil {
logger.Errorf("download image with error: %v", err)
continue
}
logger.Infof("try download cover image: %s", v.CoverImgURL)
coverURL, err := s.uploadManager.GetUploadHandler().PutUrlFile(v.CoverImgURL, true)
logger.Infof("try download cover image: %s", v.CoverURL)
coverURL, err := s.uploadManager.GetUploadHandler().PutUrlFile(v.CoverURL, true)
if err != nil {
logger.Errorf("download image with error: %v", err)
continue
@ -209,8 +206,7 @@ func (s *Service) DownloadImages() {
logger.Errorf("download audio with error: %v", err)
continue
}
v.ThumbImgURL = thumbURL
v.CoverImgURL = coverURL
v.CoverURL = coverURL
v.AudioURL = audioURL
v.Progress = 100
s.db.Updates(&v)
@ -260,8 +256,7 @@ func (s *Service) SyncTaskProgress() {
job.Tags = v.Metadata.Tags
job.ModelName = v.ModelName
job.RawData = utils.JsonEncode(v)
job.ThumbImgURL = v.ImageUrl
job.CoverImgURL = v.ImageLargeUrl
job.CoverURL = v.ImageLargeUrl
job.AudioURL = v.AudioUrl
if err = tx.Create(&job).Error; err != nil {

View File

@ -16,8 +16,7 @@ type SunoJob struct {
SongId string // 续写的歌曲id
RefSongId string
Prompt string // 提示词
ThumbImgURL string // 缩略图 URL
CoverImgURL string // 封面图 URL
CoverURL string // 封面图 URL
AudioURL string // 音频 URL
ModelName string // 模型名称
Progress int // 任务进度
@ -26,6 +25,7 @@ type SunoJob struct {
ErrMsg string // 错误信息
RawData string // 原始数据 json
Power int // 消耗算力
PlayTimes int // 播放次数
CreatedAt time.Time
}

View File

@ -1,7 +1,5 @@
package vo
import "time"
type SunoJob struct {
Id uint `json:"id"`
UserId int `json:"user_id"`
@ -9,24 +7,26 @@ type SunoJob struct {
Title string `json:"title"`
Type string `json:"type"`
TaskId string `json:"task_id"`
RefTaskId string `json:"ref_task_id"` // 续写的任务id
Tags string `json:"tags"` // 歌曲风格和标签
Instrumental bool `json:"instrumental"` // 是否生成纯音乐
ExtendSecs int `json:"extend_secs"` // 续写秒数
SongId string `json:"song_id"` // 续写的歌曲id
RefSongId string `json:"ref_song_id"` // 续写的歌曲id
Prompt string `json:"prompt"` // 提示词
ThumbImgURL string `json:"thumb_img_url"` // 缩略图 URL
CoverImgURL string `json:"cover_img_url"` // 封面图 URL
AudioURL string `json:"audio_url"` // 音频 URL
ModelName string `json:"model_name"` // 模型名称
Progress int `json:"progress"` // 任务进度
Duration int `json:"duration"` // 银屏时长,秒
Publish bool `json:"publish"` // 是否发布
ErrMsg string `json:"err_msg"` // 错误信息
RawData map[string]interface{} `json:"raw_data"` // 原始数据 json
Power int `json:"power"` // 消耗算力
CreatedAt time.Time
RefTaskId string `json:"ref_task_id"` // 续写的任务id
Tags string `json:"tags"` // 歌曲风格和标签
Instrumental bool `json:"instrumental"` // 是否生成纯音乐
ExtendSecs int `json:"extend_secs"` // 续写秒数
SongId string `json:"song_id"` // 续写的歌曲id
RefSongId string `json:"ref_song_id"` // 续写的歌曲id
Prompt string `json:"prompt"` // 提示词
CoverURL string `json:"cover_url"` // 封面图 URL
AudioURL string `json:"audio_url"` // 音频 URL
ModelName string `json:"model_name"` // 模型名称
Progress int `json:"progress"` // 任务进度
Duration int `json:"duration"` // 银屏时长,秒
Publish bool `json:"publish"` // 是否发布
ErrMsg string `json:"err_msg"` // 错误信息
RawData map[string]interface{} `json:"raw_data"` // 原始数据 json
Power int `json:"power"` // 消耗算力
RefSong map[string]interface{} `json:"ref_song,omitempty"`
User map[string]interface{} `json:"user,omitempty"` //关联用户信息
PlayTimes int `json:"play_times"` // 播放次数
CreatedAt int64 `json:"created_at"`
}
func (SunoJob) TableName() string {

View File

@ -84,6 +84,8 @@ func CopyObject(src interface{}, dst interface{}) error {
case reflect.Bool:
value.SetBool(v.Bool())
break
default:
value.Set(v)
}
}

View File

@ -0,0 +1,88 @@
.page-song {
display: flex;
justify-content: center;
background-color: #0E0808;
.inner {
text-align left
color rgb(250 247 245)
padding 20px
max-width 600px
width 100%
font-family "Neue Montreal,ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji"
.title {
font-size 40px
font-weight: 500
line-height 1rem
white-space nowrap
text-overflow ellipsis
}
.row {
padding 8px 0
}
.author {
display flex
align-items center
.nickname {
margin 0 10px
}
.btn {
margin-right 10px
background-color #363030
border none
border-radius 5px
padding 5px 10px
cursor pointer
&:hover {
background-color #5F5958
}
}
}
.date {
color #999999
display flex
align-items center
.version {
background-color #1C1616
border 1px solid #8f8f8f
font-weight normal
font-size 14px
padding 1px 3px
border-radius 5px
margin-left 10px
}
}
.prompt {
width 100%
height 100%
background-color transparent
white-space pre-wrap
overflow-y hidden
resize none
position relative
outline 2px solid transparent
outline-offset 2px
border none
font-size 100%
line-height 2rem
}
}
.music-player {
width 100%
position: fixed;
bottom: 0;
left: 50px;
padding 20px 0
}
}

View File

@ -38,6 +38,9 @@
.text {
margin-right 10px
}
.el-icon {
top 2px
}
}
.item {
margin-bottom: 20px
@ -66,6 +69,55 @@
opacity: 0.9;
}
}
.song {
display flex
padding 10px
background-color #252020
border-radius 10px
margin-bottom 10px
font-size 14px
position relative
.el-image {
width 50px
height 50px
border-radius 10px
}
.title {
display flex
margin-left 10px
align-items center
}
.el-button--info {
position absolute
right 20px
top 20px
}
}
.extend-secs {
padding 10px 0
font-size 14px
input {
width 50px
text-align center
padding 8px 10px
font-size 14px
background none
border 1px solid #8f8f8f
margin 0 10px
border-radius 10px
outline: none;
transition: border-color 0.5s ease, box-shadow 0.5s ease;
&:focus {
border-color: #0F7A71;
box-shadow: 0 0 5px #0F7A71;
}
}
}
}
.tag-select {
@ -190,6 +242,10 @@
padding 1px 3px
border-radius 5px
margin-left 10px
.iconfont {
font-size 12px
}
}
}
@ -267,11 +323,22 @@
text-overflow: ellipsis; /* */
}
}
.right {
.center {
display flex
width 100%
justify-content center
.failed {
display flex
align-items center
color #E4696B
font-size 14px
}
}
.right {
display flex
width 100px
justify-content center
align-items center
}
}
}

View File

@ -1,8 +1,8 @@
@font-face {
font-family: "iconfont"; /* Project id 4125778 */
src: url('iconfont.woff2?t=1721356513025') format('woff2'),
url('iconfont.woff?t=1721356513025') format('woff'),
url('iconfont.ttf?t=1721356513025') format('truetype');
src: url('iconfont.woff2?t=1721896403264') format('woff2'),
url('iconfont.woff?t=1721896403264') format('woff'),
url('iconfont.ttf?t=1721896403264') format('truetype');
}
.iconfont {
@ -13,6 +13,10 @@
-moz-osx-font-smoothing: grayscale;
}
.icon-link:before {
content: "\e6b4";
}
.icon-app:before {
content: "\e64f";
}

File diff suppressed because one or more lines are too long

View File

@ -5,6 +5,13 @@
"css_prefix_text": "icon-",
"description": "",
"glyphs": [
{
"icon_id": "880330",
"name": "link",
"font_class": "link",
"unicode": "e6b4",
"unicode_decimal": 59060
},
{
"icon_id": "1503777",
"name": "应用",

Binary file not shown.

View File

@ -177,7 +177,7 @@ const reGenerate = (prompt) => {
padding-left 10px;
.chat-icon {
margin-left 20px;
margin-right 20px;
img {
width: 36px;
@ -368,7 +368,6 @@ const reGenerate = (prompt) => {
.content-wrapper {
display flex
flex-flow row-reverse
.content {
min-height 20px;
word-break break-word;

View File

@ -33,7 +33,7 @@
</div>
<audio ref="audio" @timeupdate="updateProgress" @ended="nextSong"></audio>
<el-button class="close" type="info" :icon="Close" circle size="small" @click="emits('close')" />
<el-button v-if="showClose" class="close" type="info" :icon="Close" circle size="small" @click="emits('close')" />
</div>
</div>
</template>
@ -61,12 +61,15 @@ const props = defineProps({
required: true,
default: () => []
},
showClose: {
type: Boolean,
default: false
}
});
// eslint-disable-next-line no-undef
const emits = defineEmits(['close']);
watch(() => props.songs, (newVal) => {
console.log(newVal)
loadSong(newVal[songIndex.value]);
});
@ -78,7 +81,7 @@ const loadSong = (song) => {
}
title.value = song.title
tags.value = song.tags
cover.value = song.thumb_img_url
cover.value = song.cover_url
audio.value.src = song.audio_url;
audio.value.load();
audio.value.onloadedmetadata = () => {
@ -97,6 +100,7 @@ const togglePlay = () => {
const play = () => {
audio.value.play();
isPlaying.value = true;
}
const prevSong = () => {
@ -177,6 +181,7 @@ onMounted(() => {
.title {
font-weight 700
font-size 16px
color #ffffff
}
.style {

View File

@ -0,0 +1,106 @@
<template>
<div class="black-dialog">
<el-dialog
v-model="showDialog"
style="--el-dialog-bg-color:#414141;
--el-text-color-primary:#f1f1f1;
--el-border-color:#414141;
--el-color-primary:#21aa93;
--el-color-primary-dark-2:#41555d;
--el-color-white: #e1e1e1;
--el-color-primary-light-3:#549688;
--el-fill-color-blank:#616161;
--el-color-primary-light-7:#717171;
--el-color-primary-light-9:#717171;
--el-text-color-regular:#e1e1e1"
:title="title"
:width="width"
:before-close="cancel"
>
<div class="dialog-body">
<slot></slot>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="cancel">{{cancelText}}</el-button>
<el-button type="primary" @click="$emit('confirm')">{{confirmText}}</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup>
import {ref, watch} from "vue";
const props = defineProps({
show : Boolean,
title: {
type: String,
default: 'Tips',
},
width: {
type: Number,
default: 500,
},
confirmText: {
type: String,
default: '确定',
},
cancelText: {
type: String,
default: '取消',
},
});
const emits = defineEmits(['confirm','cancal']);
const showDialog = ref(props.show)
watch(() => props.show, (newValue) => {
showDialog.value = newValue
})
const cancel = () => {
showDialog.value = false
emits('cancal')
}
</script>
<style lang="stylus">
.black-dialog {
.dialog-body {
.form {
.form-item {
display flex
flex-flow column
font-family: "Neue Montreal";
padding 10px 0
.label {
margin-bottom 0.6rem
margin-inline-end 0.75rem
color #ffffff
font-size 1rem
font-weight 500
}
.input {
display flex
padding 10px
text-align left
font-size 1rem
background none
border-radius 0.375rem
border 1px solid #8f8f8f
outline: none;
transition: border-color 0.5s ease, box-shadow 0.5s ease;
&:focus {
border-color: #0F7A71;
box-shadow: 0 0 5px #0F7A71;
}
}
}
}
}
}
</style>

View File

@ -6,20 +6,19 @@
</template>
<script>
export default {
name: 'BlackSwitch',
props: {
value : Boolean,
size: {
type: String,
default: 'default',
}
},
data() {
return {
model: this.value
}
<script setup>
import {ref, watch} from "vue";
const props = defineProps({
value : Boolean,
size: {
type: String,
default: 'default',
}
}
});
const model = ref(props.value)
watch(() => props.value, (newValue) => {
model.value = newValue
})
</script>

View File

@ -93,6 +93,12 @@ const routes = [
path: '/external',
component: () => import('@/views/ExternalPage.vue'),
},
{
name: 'song',
path: '/song/:id',
meta: {title: 'Suno音乐播放'},
component: () => import('@/views/Song.vue'),
},
]
},
{

View File

@ -654,7 +654,8 @@ const connect = function (chat_id, role_id) {
id: randString(32),
icon: _role['icon'],
prompt:prePrompt,
content: ""
content: "",
orgContent: "",
});
} else if (data.type === 'end') { //
//
@ -699,7 +700,7 @@ const connect = function (chat_id, role_id) {
};
}
} catch (e) {
console.error(e)
console.warn(e)
}
});

95
web/src/views/Song.vue Normal file
View File

@ -0,0 +1,95 @@
<template>
<div class="page-song" :style="{ height: winHeight + 'px' }">
<div class="inner">
<h2 class="title">{{song.title}}</h2>
<div class="row tags" v-if="song.tags">
<span>{{song.tags}}</span>
</div>
<div class="row author">
<span>
<el-avatar :size="32" :src="song.user?.avatar" />
</span>
<span class="nickname">{{song.user?.nickname}}</span>
<button class="btn btn-icon" @click="play">
<i class="iconfont icon-play"></i> {{song.play_times}}
</button>
<el-tooltip effect="light" content="复制歌曲链接" placement="top">
<button class="btn btn-icon copy-link" :data-clipboard-text="getShareURL(song)" >
<i class="iconfont icon-share1"></i>
</button>
</el-tooltip>
</div>
<div class="row date">
<span>{{dateFormat(song.created_at)}}</span>
<span class="version">{{song.raw_data?.major_model_version}}</span>
</div>
<div class="row">
<textarea class="prompt" maxlength="2000" rows="18" readonly>{{song.prompt}}</textarea>
</div>
</div>
<div class="music-player" v-if="playList.length > 0">
<music-player :songs="playList" ref="playerRef"/>
</div>
</div>
</template>
<script setup>
import {nextTick, onMounted, onUnmounted, ref} from "vue"
import {useRouter} from "vue-router";
import {httpGet} from "@/utils/http";
import {showMessageError} from "@/utils/dialog";
import {dateFormat} from "@/utils/libs";
import Clipboard from "clipboard";
import {ElMessage} from "element-plus";
import MusicPlayer from "@/components/MusicPlayer.vue";
const router = useRouter()
const id = router.currentRoute.value.params.id
const song = ref({title:""})
const playList = ref([])
const playerRef = ref(null)
httpGet("/api/suno/detail",{id:id}).then(res => {
song.value = res.data
playList.value = [song.value]
document.title = song.value?.title+ " | By "+song.value?.user.nickname+" | Suno音乐"
}).catch(e => {
showMessageError("获取歌曲详情失败:"+e.message)
})
const clipboard = ref(null)
onMounted(() => {
clipboard.value = new Clipboard('.copy-link');
clipboard.value.on('success', () => {
ElMessage.success("复制歌曲链接成功!");
})
clipboard.value.on('error', () => {
ElMessage.error('复制失败!');
})
})
onUnmounted(() => {
clipboard.value.destroy()
})
//
const play = () => {
playerRef.value.play()
}
const winHeight = ref(window.innerHeight-60)
const getShareURL = (item) => {
return `${location.protocol}//${location.host}/song/${item.id}`
}
</script>
<style lang="stylus" scoped>
@import "@/assets/css/song.styl"
</style>

View File

@ -97,11 +97,36 @@
</div>
</div>
<div class="ref-song" v-if="refSong">
<div class="label">
<span class="text">续写</span>
<el-popover placement="right"
:width="200"
trigger="hover" content="输入额外的歌词,根据您之前的歌词来扩展歌曲。">
<template #reference>
<el-icon>
<InfoFilled/>
</el-icon>
</template>
</el-popover>
</div>
<div class="item">
<div class="song">
<el-image :src="refSong.cover_url" fit="cover" />
<span class="title">{{refSong.title}}</span>
<el-button type="info" @click="removeRefSong" size="small" :icon="Delete" circle />
</div>
<div class="extend-secs">
<input v-model="refSong.extend_secs" type="text"/> 秒开始续写
</div>
</div>
</div>
<div class="item">
<button class="create-btn" @click="create">
<img src="/images/create-new.svg" alt=""/>
<span>生成音乐</span>
<span>{{btnText}}</span>
</button>
</div>
</div>
@ -112,7 +137,7 @@
<div class="item" v-if="item.progress === 100">
<div class="left">
<div class="container">
<el-image :src="item.thumb_img_url" fit="cover" />
<el-image :src="item.cover_url" fit="cover" />
<div class="duration">{{formatTime(item.duration)}}</div>
<button class="play" @click="play(item)">
<img src="/images/play.svg" alt=""/>
@ -121,15 +146,19 @@
</div>
<div class="center">
<div class="title">
<a href="/song/xxxxx">{{item.title}}</a>
<a :href="'/song/'+item.id" target="_blank">{{item.title}}</a>
<span class="model">{{item.major_model_version}}</span>
<span class="model" v-if="item.ref_song">
<i class="iconfont icon-link"></i>
{{item.ref_song.title}}
</span>
</div>
<div class="tags">{{item.tags}}</div>
</div>
<div class="right">
<div class="tools">
<el-tooltip effect="light" content="以当前歌曲为素材继续创作" placement="top">
<button class="btn">续写</button>
<button class="btn" @click="extend(item)">续写</button>
</el-tooltip>
<button class="btn btn-publish">
@ -146,13 +175,13 @@
</el-tooltip>
<el-tooltip effect="light" content="复制歌曲链接" placement="top">
<button class="btn btn-icon">
<button class="btn btn-icon copy-link" :data-clipboard-text="getShareURL(item)" >
<i class="iconfont icon-share1"></i>
</button>
</el-tooltip>
<el-tooltip effect="light" content="编辑" placement="top">
<button class="btn btn-icon">
<button class="btn btn-icon" @click="update(item)">
<i class="iconfont icon-edit"></i>
</button>
</el-tooltip>
@ -172,8 +201,16 @@
<span v-else>{{item.prompt}}</span>
</div>
</div>
<div class="center">
<div class="failed" v-if="item.progress === 101">
{{item.err_msg}}
</div>
<generating v-else />
</div>
<div class="right">
<generating />
<el-button type="info" @click="removeJob(item)" circle>
<i class="iconfont icon-remove"></i>
</el-button>
</div>
</div>
</div>
@ -197,15 +234,37 @@
</div>
<div class="music-player" v-if="showPlayer">
<music-player :songs="playList" ref="playerRef" @close="showPlayer = false" />
<music-player :songs="playList" ref="playerRef" :show-close="true" @close="showPlayer = false" />
</div>
</div>
<black-dialog v-model:show="showDialog" title="修改歌曲" @cancal="showDialog = false" @confirm="updateSong" :width="500">
<form class="form">
<div class="form-item">
<div class="label">歌曲名称</div>
<input class="input" v-model="editData.title" type="text" />
</div>
<div class="form-item">
<div class="label">封面图片</div>
<el-upload
class="avatar-uploader"
:auto-upload="true"
:show-file-list="false"
:http-request="uploadCover"
accept=".png,.jpg,.jpeg,.bmp"
>
<el-avatar :src="editData.cover" shape="square" :size="100"/>
</el-upload>
</div>
</form>
</black-dialog>
</div>
</template>
<script setup>
import {nextTick, onMounted, ref} from "vue"
import {InfoFilled} from "@element-plus/icons-vue";
import {nextTick, onMounted, onUnmounted, ref, watch} from "vue"
import {Delete, InfoFilled} from "@element-plus/icons-vue";
import BlackSelect from "@/components/ui/BlackSelect.vue";
import BlackSwitch from "@/components/ui/BlackSwitch.vue";
import BlackInput from "@/components/ui/BlackInput.vue";
@ -217,6 +276,9 @@ import Generating from "@/components/ui/Generating.vue";
import {checkSession} from "@/action/session";
import {ElMessage, ElMessageBox} from "element-plus";
import {formatTime} from "@/utils/libs";
import Clipboard from "clipboard";
import BlackDialog from "@/components/ui/BlackDialog.vue";
import Compressor from "compressorjs";
const winHeight = ref(window.innerHeight - 50)
const custom = ref(false)
@ -248,7 +310,10 @@ const data = ref({
lyrics: "",
prompt: "",
title: "",
instrumental:false
instrumental: false,
ref_task_id: "",
extend_secs: 0,
ref_song_id: "",
})
const loading = ref(true)
const noData = ref(false)
@ -256,6 +321,10 @@ const playList = ref([])
const playerRef = ref(null)
const showPlayer = ref(false)
const list = ref([])
const btnText = ref("开始创作")
const refSong = ref(null)
const showDialog = ref(false)
const editData = ref({title:"",cover:"",id:0})
const socket = ref(null)
const userId = ref(0)
@ -312,7 +381,17 @@ const connect = () => {
});
}
const clipboard = ref(null)
onMounted(() => {
clipboard.value = new Clipboard('.copy-link');
clipboard.value.on('success', () => {
ElMessage.success("复制歌曲链接成功!");
})
clipboard.value.on('error', () => {
ElMessage.error('复制失败!');
})
checkSession().then(user => {
userId.value = user.id
fetchData(1)
@ -320,11 +399,18 @@ onMounted(() => {
})
})
onUnmounted(() => {
clipboard.value.destroy()
})
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
const fetchData = (page) => {
httpGet("/api/suno/list",{page:page, page_size:pageSize.value}).then(res => {
const fetchData = (_page) => {
if (_page) {
page.value = _page
}
httpGet("/api/suno/list",{page:page.value, page_size:pageSize.value}).then(res => {
total.value = res.data.total
const items = []
for (let v of res.data.items) {
@ -341,8 +427,28 @@ const fetchData = (page) => {
})
}
//
const create = () => {
data.value.type = custom.value ? 2 : 1
data.value.ref_task_id = refSong.value ? refSong.value.task_id : ""
data.value.ref_song_id = refSong.value ? refSong.value.song_id : ""
data.value.extend_secs = refSong.value ? refSong.value.extend_secs : 0
if (custom.value) {
if (data.value.lyrics === "") {
return showMessageError("请输入歌词")
}
if (data.value.title === "") {
return showMessageError("请输入歌曲标题")
}
} else {
if (data.value.prompt === "") {
return showMessageError("请输入歌曲描述")
}
}
if (refSong.value && data.value.extend_secs > refSong.value.duration) {
return showMessageError("续写开始时间不能超过原歌曲长度")
}
httpPost("/api/suno/create", data.value).then(() => {
fetchData(1)
showMessageOK("创建任务成功")
@ -351,6 +457,47 @@ const create = () => {
})
}
//
const extend = (item) => {
refSong.value = item
refSong.value.extend_secs = item.duration
data.value.title = item.title
custom.value = true
btnText.value = "续写歌曲"
}
//
const update = (item) => {
showDialog.value = true
editData.value.title = item.title
editData.value.cover = item.cover_url
editData.value.id = item.id
}
const updateSong = () => {
if (editData.value.title === "" || editData.value.cover === "") {
return showMessageError("歌曲标题和封面不能为空")
}
httpPost("/api/suno/update", editData.value).then(() => {
showMessageOK("更新歌曲成功")
showDialog.value = false
fetchData()
}).catch(e => {
showMessageError("更新歌曲失败:"+e.message)
})
}
watch(() => custom.value, (newValue) => {
if (!newValue) {
removeRefSong()
}
})
const removeRefSong = () => {
refSong.value = null
btnText.value = "开始创作"
}
const play = (item) => {
playList.value = [item]
showPlayer.value = true
@ -392,6 +539,31 @@ const publishJob = (item) => {
})
}
const getShareURL = (item) => {
return `${location.protocol}//${location.host}/song/${item.id}`
}
const uploadCover = (file) => {
//
new Compressor(file.file, {
quality: 0.6,
success(result) {
const formData = new FormData();
formData.append('file', result, result.name);
//
httpPost('/api/upload', formData).then((res) => {
editData.value.cover = res.data.url
ElMessage.success({message: "上传成功", duration: 500})
}).catch((e) => {
ElMessage.error('图片上传失败:' + e.message)
})
},
error(err) {
console.log(err.message);
},
});
}
</script>
<style lang="stylus" scoped>