完成移动端Suno页面功能

This commit is contained in:
RockYang
2025-08-06 17:58:39 +08:00
parent bb6e90d50a
commit ec00f156f0
5 changed files with 791 additions and 349 deletions

View File

@@ -112,6 +112,10 @@ type RespVo struct {
Message string `json:"message"`
Data string `json:"data"`
Channel string `json:"channel,omitempty"`
Error struct {
Message string `json:"message"`
Type string `json:"type"`
} `json:"error,omitempty"`
}
func (s *Service) Create(task types.SunoTask) (RespVo, error) {
@@ -154,13 +158,14 @@ func (s *Service) Create(task types.SunoTask) (RespVo, error) {
}
body, _ := io.ReadAll(r.Body)
logger.Debugf("API response: %s", string(body))
err = json.Unmarshal(body, &res)
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)
return RespVo{}, fmt.Errorf("API 返回失败:%s", res.Error.Message)
}
// update the last_use_at for api key
apiKey.LastUsedAt = time.Now().Unix()

View File

@@ -56,3 +56,24 @@ export function showLoading(message = '正在处理...') {
export function closeLoading() {
closeToast()
}
// 自定义 Toast 消息系统
export function showToastMessage(message, type = 'info', duration = 3000) {
const toast = document.createElement('div')
toast.className = `fixed top-20 left-1/2 transform -translate-x-1/2 z-50 px-4 py-2 rounded-lg text-white font-medium ${
type === 'error' ? 'bg-red-500' : type === 'success' ? 'bg-green-500' : 'bg-blue-500'
} animate-fade-in`
toast.textContent = message
document.body.appendChild(toast)
if (duration > 0) {
setTimeout(() => {
toast.classList.add('animate-fade-out')
setTimeout(() => {
if (document.body.contains(toast)) {
document.body.removeChild(toast)
}
}, 300)
}, duration)
}
}

View File

@@ -0,0 +1,116 @@
<template>
<div class="custom-select">
<label v-if="label" class="block text-gray-700 font-medium mb-2">{{ label }}</label>
<button
@click="showPicker = true"
class="w-full flex items-center justify-between px-4 py-3 bg-gray-50 rounded-lg border border-gray-200 hover:border-blue-300 transition-colors"
>
<span class="text-gray-900">{{ selectedLabel || placeholder || '请选择' }}</span>
<i class="iconfont icon-down text-gray-400"></i>
</button>
<!-- 选择器弹窗 -->
<div
v-if="showPicker"
class="fixed inset-0 z-50 flex items-end justify-center bg-black bg-opacity-50"
@click="showPicker = false"
>
<div @click.stop class="bg-white rounded-t-2xl w-full max-w-md animate-slide-up">
<div class="flex items-center justify-between p-4 border-b">
<h3 class="text-lg font-semibold text-gray-900">{{ title || '请选择' }}</h3>
<button @click="showPicker = false" class="p-2 hover:bg-gray-100 rounded-full">
<i class="iconfont icon-close text-gray-500"></i>
</button>
</div>
<div class="max-h-80 overflow-y-auto">
<CustomSelectOption
v-for="option in options"
:key="option.value"
:option="option"
:selected="modelValue === option.value"
@select="onSelect"
>
<template #default="slotProps">
<slot name="option" v-bind="slotProps">
<div>
<span class="text-gray-900 font-medium">{{ slotProps.option.label }}</span>
<p v-if="slotProps.option.desc" class="text-sm text-gray-500 mt-1">
{{ slotProps.option.desc }}
</p>
</div>
<div v-if="slotProps.selected" class="text-blue-600">
<i class="iconfont icon-success"></i>
</div>
</slot>
</template>
</CustomSelectOption>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import CustomSelectOption from './CustomSelectOption.vue'
// Props
const props = defineProps({
modelValue: {
type: [String, Number],
default: '',
},
options: {
type: Array,
default: () => [],
},
label: {
type: String,
default: '',
},
placeholder: {
type: String,
default: '请选择',
},
title: {
type: String,
default: '请选择',
},
})
// Emits
const emit = defineEmits(['update:modelValue', 'change'])
// Data
const showPicker = ref(false)
// Computed
const selectedLabel = computed(() => {
const selected = props.options.find((option) => option.value === props.modelValue)
return selected ? selected.label : ''
})
// Methods
const onSelect = (option) => {
emit('update:modelValue', option.value)
emit('change', option)
showPicker.value = false
}
</script>
<style scoped>
@keyframes slide-up {
from {
opacity: 0;
transform: translateY(100%);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-slide-up {
animation: slide-up 0.3s ease-out;
}
</style>

View File

@@ -0,0 +1,50 @@
<template>
<!--
CustomSelectOption 组件
Props:
- option: 选项对象必需包含 label/desc/value 等属性
- selected: 是否为当前选中项
Emits:
- select(option): 选中该项时触发
Slots:
- 默认插槽default用于自定义 option 内容slotProps: { option, selected }
示例
<template #option="{ option, selected }">
<div>{{ option.label }}</div>
<div v-if="selected"></div>
</template>
-->
<div
class="flex items-center justify-between p-4 hover:bg-gray-50 cursor-pointer transition-colors border-b last:border-b-0"
@click="$emit('select', option)"
>
<slot :option="option" :selected="selected">
<div>
<span class="text-gray-900 font-medium">{{ option.label }}</span>
<p v-if="option.desc" class="text-sm text-gray-500 mt-1">{{ option.desc }}</p>
</div>
<div v-if="selected" class="text-blue-600">
<i class="iconfont icon-success"></i>
</div>
</slot>
</div>
</template>
<script setup>
import { defineProps, defineEmits } from 'vue'
const props = defineProps({
option: {
type: Object,
required: true,
},
selected: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['select'])
</script>
<style scoped></style>

File diff suppressed because it is too large Load Diff