mirror of
https://github.com/yangjian102621/geekai.git
synced 2026-04-11 21:54:26 +08:00
完成移动端Suno页面功能
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
116
web/src/views/mobile/components/CustomSelect.vue
Normal file
116
web/src/views/mobile/components/CustomSelect.vue
Normal 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>
|
||||
50
web/src/views/mobile/components/CustomSelectOption.vue
Normal file
50
web/src/views/mobile/components/CustomSelectOption.vue
Normal 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
Reference in New Issue
Block a user