mirror of
https://github.com/yangjian102621/geekai.git
synced 2025-09-17 16:56:38 +08:00
song detail page is ready
This commit is contained in:
parent
f6f8748521
commit
2129f7a8b7
@ -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)
|
||||
}
|
||||
|
@ -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() {
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -84,6 +84,8 @@ func CopyObject(src interface{}, dst interface{}) error {
|
||||
case reflect.Bool:
|
||||
value.SetBool(v.Bool())
|
||||
break
|
||||
default:
|
||||
value.Set(v)
|
||||
}
|
||||
}
|
||||
|
||||
|
88
web/src/assets/css/song.styl
Normal file
88
web/src/assets/css/song.styl
Normal 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
|
||||
}
|
||||
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
@ -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.
Binary file not shown.
Binary file not shown.
@ -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;
|
||||
|
@ -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 {
|
||||
|
106
web/src/components/ui/BlackDialog.vue
Normal file
106
web/src/components/ui/BlackDialog.vue
Normal 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>
|
@ -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>
|
||||
|
@ -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'),
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
|
@ -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
95
web/src/views/Song.vue
Normal 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>
|
@ -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>
|
||||
|
Loading…
Reference in New Issue
Block a user