feat(ai): 新增 AI 绘图功能

- 添加 AI 绘图相关的 API 接口和路由
- 实现 AI 绘图页面,支持不同平台的绘图功能
- 添加绘图作品列表和重新生成功能
- 优化绘图页面样式和布局
This commit is contained in:
gjd
2025-06-13 15:27:25 +08:00
parent 4596cd9fa5
commit 33b7a11a4e
24 changed files with 3035 additions and 23 deletions

View File

@@ -5,6 +5,7 @@ import { ref, unref } from 'vue';
import { Page } from '@vben/common-ui';
import List from './list/index.vue';
import Mode from './mode/index.vue';
defineOptions({ name: 'Index' });

View File

@@ -0,0 +1,116 @@
<script lang="ts" setup>
import type { Nullable } from '@vben/types';
import { inject, reactive, ref } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { Image, Slider } from 'ant-design-vue';
import { formatPast } from '#/utils/formatTime';
defineOptions({ name: 'Index' });
const currentSong = inject('currentSong', {});
const audioRef = ref<Nullable<HTMLElement>>(null);
// 音频相关属性https://www.runoob.com/tags/ref-av-dom.html
const audioProps = reactive<any>({
autoplay: true,
paused: false,
currentTime: '00:00',
duration: '00:00',
muted: false,
volume: 50,
});
function toggleStatus(type: string) {
audioProps[type] = !audioProps[type];
if (type === 'paused' && audioRef.value) {
if (audioProps[type]) {
audioRef.value.pause();
} else {
audioRef.value.play();
}
}
}
// 更新播放位置
function audioTimeUpdate(args: any) {
audioProps.currentTime = formatPast(new Date(args.timeStamp), 'mm:ss');
}
</script>
<template>
<div
class="b-solid b-1 b-l-none flex h-[72px] items-center justify-between px-2"
style="background-color: #fffffd; border-color: #dcdfe6"
>
<!-- 歌曲信息 -->
<div class="flex gap-[10px]">
<Image
src="https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png"
:width="45"
/>
<div>
<div>{{ currentSong.name }}</div>
<div class="text-[12px] text-gray-400">{{ currentSong.singer }}</div>
</div>
</div>
<!-- 音频controls -->
<div class="flex items-center gap-[12px]">
<IconifyIcon
icon="majesticons:back-circle"
:size="20"
class="cursor-pointer text-gray-300"
/>
<IconifyIcon
:icon="
audioProps.paused
? 'mdi:arrow-right-drop-circle'
: 'solar:pause-circle-bold'
"
:size="30"
class="cursor-pointer"
@click="toggleStatus('paused')"
/>
<IconifyIcon
icon="majesticons:next-circle"
:size="20"
class="cursor-pointer text-gray-300"
/>
<div class="flex items-center gap-[16px]">
<span>{{ audioProps.currentTime }}</span>
<Slider
v-model:value="audioProps.duration"
color="#409eff"
class="w-[160px!important]"
/>
<span>{{ audioProps.duration }}</span>
</div>
<!-- 音频 -->
<audio
v-bind="audioProps"
ref="audioRef"
controls
v-show="!audioProps"
@timeupdate="audioTimeUpdate"
>
<!-- <source :src="audioUrl" /> -->
</audio>
</div>
<div class="flex items-center gap-[16px]">
<IconifyIcon
:icon="audioProps.muted ? 'tabler:volume-off' : 'tabler:volume'"
:size="20"
class="cursor-pointer"
@click="toggleStatus('muted')"
/>
<Slider
v-model:value="audioProps.volume"
color="#409eff"
class="w-[160px!important]"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,113 @@
<script setup lang="ts">
import type { Recordable } from '@vben/types';
import { provide, ref } from 'vue';
import { Col, Empty, Row, TabPane, Tabs } from 'ant-design-vue';
import audioBar from './audioBar/index.vue';
import songCard from './songCard/index.vue';
import songInfo from './songInfo/index.vue';
defineOptions({ name: 'Index' });
const currentType = ref('mine');
// loading 状态
const loading = ref(false);
// 当前音乐
const currentSong = ref({});
const mySongList = ref<Recordable<any>[]>([]);
const squareSongList = ref<Recordable<any>[]>([]);
/*
*@Description: 调接口生成音乐列表
*@MethodAuthor: xiaohong
*@Date: 2024-06-27 17:06:44
*/
function generateMusic(formData: Recordable<any>) {
loading.value = true;
setTimeout(() => {
mySongList.value = Array.from({ length: 20 }, (_, index) => {
return {
id: index,
audioUrl: '',
videoUrl: '',
title: `我走后${index}`,
imageUrl:
'https://www.carsmp3.com/data/attachment/forum/201909/19/091020q5kgre20fidreqyt.jpg',
desc: 'Metal, symphony, film soundtrack, grand, majesticMetal, dtrack, grand, majestic',
date: '2024年04月30日 14:02:57',
lyric: `<div class="_words_17xen_66"><div>大江东去,浪淘尽,千古风流人物。
</div><div>故垒西边,人道是,三国周郎赤壁。
</div><div>乱石穿空,惊涛拍岸,卷起千堆雪。
</div><div>江山如画,一时多少豪杰。
</div><div>
</div><div>遥想公瑾当年,小乔初嫁了,雄姿英发。
</div><div>羽扇纶巾,谈笑间,樯橹灰飞烟灭。
</div><div>故国神游,多情应笑我,早生华发。
</div><div>人生如梦,一尊还酹江月。</div></div>`,
};
});
loading.value = false;
}, 3000);
}
/*
*@Description: 设置当前播放的音乐
*@MethodAuthor: xiaohong
*@Date: 2024-07-19 11:22:33
*/
function setCurrentSong(music: Recordable<any>) {
currentSong.value = music;
}
defineExpose({
generateMusic,
});
provide('currentSong', currentSong);
</script>
<template>
<div class="flex flex-col">
<div class="flex flex-auto overflow-hidden">
<Tabs
v-model:active-key="currentType"
class="flex-auto px-[20px]"
tab-position="bottom"
>
<!-- 我的创作 -->
<TabPane key="mine" tab="我的创作" v-loading="loading">
<Row v-if="mySongList.length > 0" :gutter="12">
<Col v-for="song in mySongList" :key="song.id" :span="24">
<songCard :song-info="song" @play="setCurrentSong(song)" />
</Col>
</Row>
<Empty v-else description="暂无音乐" />
</TabPane>
<!-- 试听广场 -->
<TabPane key="square" tab="试听广场" v-loading="loading">
<Row v-if="squareSongList.length > 0" :gutter="12">
<Col v-for="song in squareSongList" :key="song.id" :span="24">
<songCard :song-info="song" @play="setCurrentSong(song)" />
</Col>
</Row>
<Empty v-else description="暂无音乐" />
</TabPane>
</Tabs>
<!-- songInfo -->
<songInfo class="flex-none" />
</div>
<audioBar class="flex-none" />
</div>
</template>
<style lang="scss" scoped>
:deep(.ant-tabs) {
.ant-tabs__content {
padding: 0 7px;
overflow: auto;
}
}
</style>

View File

@@ -0,0 +1,50 @@
<script lang="ts" setup>
import { inject } from 'vue';
import { IconifyIcon } from '@vben/icons';
import { Image } from 'ant-design-vue';
defineOptions({ name: 'Index' });
defineProps({
songInfo: {
type: Object,
default: () => ({}),
},
});
const emits = defineEmits(['play']);
const currentSong = inject('currentSong', {});
function playSong() {
emits('play');
}
</script>
<template>
<div class="rounded-1 mb-[12px] flex p-[12px]">
<div class="relative" @click="playSong">
<Image :src="songInfo.imageUrl" class="w-80px flex-none" />
<div
class="bg-op-40 absolute left-0 top-0 flex h-full w-full cursor-pointer items-center justify-center bg-black"
>
<IconifyIcon
:icon="
currentSong.id === songInfo.id
? 'solar:pause-circle-bold'
: 'mdi:arrow-right-drop-circle'
"
:size="30"
/>
</div>
</div>
<div class="ml-[8px]">
<div>{{ songInfo.title }}</div>
<div class="mt-[8px] line-clamp-2 text-[12px]">
{{ songInfo.desc }}
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,25 @@
<script lang="ts" setup>
import { inject } from 'vue';
import { Button, Card, Image } from 'ant-design-vue';
defineOptions({ name: 'Index' });
const currentSong = inject('currentSong', {});
</script>
<template>
<Card class="line-height-24px mb-[0!important] w-[300px]">
<Image :src="currentSong.imageUrl" style="width: 100%; height: 100%" />
<div class="">{{ currentSong.title }}</div>
<div class="line-clamp-1 text-[12px]">
{{ currentSong.desc }}
</div>
<div class="text-[12px]">
{{ currentSong.date }}
</div>
<Button size="small" shape="round" class="my-[6px]">信息复用</Button>
<div class="text-[12px]" v-html="currentSong.lyric"></div>
</Card>
</template>