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

@@ -0,0 +1,175 @@
<script setup lang="ts">
import type { PropType } from 'vue';
import type { AiImageApi } from '#/api/ai/image';
import { onMounted, ref, toRefs, watch } from 'vue';
import { confirm } from '@vben/common-ui';
import { Button, Card, Image, message } from 'ant-design-vue';
import { AiImageStatusEnum } from '#/utils/constants';
// 消息
const props = defineProps({
detail: {
type: Object as PropType<AiImageApi.ImageVO>,
default: () => ({}),
},
});
const emits = defineEmits(['onBtnClick', 'onMjBtnClick']);
const cardImageRef = ref<any>(); // 卡片 image ref
/** 处理点击事件 */
const handleButtonClick = async (type: string, detail: AiImageApi.ImageVO) => {
emits('onBtnClick', type, detail);
};
/** 处理 Midjourney 按钮点击事件 */
const handleMidjourneyBtnClick = async (
button: AiImageApi.ImageMidjourneyButtonsVO,
) => {
// 确认窗体
await confirm(`确认操作 "${button.label} ${button.emoji}" ?`);
emits('onMjBtnClick', button, props.detail);
};
// emits
/** 监听详情 */
const { detail } = toRefs(props);
watch(detail, async (newVal) => {
await handleLoading(newVal.status);
});
const loading = ref();
/** 处理加载状态 */
const handleLoading = async (status: number) => {
// 情况一:如果是生成中,则设置加载中的 loading
if (status === AiImageStatusEnum.IN_PROGRESS) {
loading.value = message.loading({
content: `生成中...`,
});
// 情况二:如果已经生成结束,则移除 loading
} else {
if (loading.value) setTimeout(loading.value, 100);
}
};
/** 初始化 */
onMounted(async () => {
await handleLoading(props.detail.status);
});
</script>
<template>
<Card body-class="" class="image-card">
<div class="image-operation">
<div>
<Button v-if="detail?.status === AiImageStatusEnum.IN_PROGRESS">
生成中
</Button>
<Button v-else-if="detail?.status === AiImageStatusEnum.SUCCESS">
已完成
</Button>
<Button danger v-else-if="detail?.status === AiImageStatusEnum.FAIL">
异常
</Button>
</div>
<!-- 操作区 -->
<div>
<Button
class="btn"
type="text"
@click="handleButtonClick('download', detail)"
>
<span class="icon-[ant-design--download-outlined]"></span>
</Button>
<Button
class="btn"
type="text"
@click="handleButtonClick('regeneration', detail)"
>
<span class="icon-[ant-design--redo-outlined]"></span>
</Button>
<Button
class="btn"
type="text"
@click="handleButtonClick('delete', detail)"
>
<span class="icon-[ant-design--delete-outlined]"></span>
</Button>
<Button
class="btn"
type="text"
@click="handleButtonClick('more', detail)"
>
<span class="icon-[ant-design--more-outlined]"></span>
</Button>
</div>
</div>
<div class="image-wrapper" ref="cardImageRef">
<Image class="image" :src="detail?.picUrl" />
<div v-if="detail?.status === AiImageStatusEnum.FAIL">
{{ detail?.errorMessage }}
</div>
</div>
<!-- Midjourney 专属操作 -->
<div class="image-mj-btns">
<Button
size="small"
v-for="(button, index) in detail?.buttons"
:key="index"
class="ml-0 mr-[10px] mt-[5px] min-w-[40px]"
@click="handleMidjourneyBtnClick(button)"
>
{{ button.label }}{{ button.emoji }}
</Button>
</div>
</Card>
</template>
<style scoped lang="scss">
.image-card {
position: relative;
display: flex;
flex-direction: column;
width: 320px;
height: auto;
border-radius: 10px;
.image-operation {
display: flex;
flex-direction: row;
justify-content: space-between;
.btn {
//border: 1px solid red;
padding: 10px;
margin: 0;
}
}
.image-wrapper {
flex: 1;
height: 280px;
margin-top: 20px;
overflow: hidden;
.image {
width: 100%;
border-radius: 10px;
}
}
.image-mj-btns {
display: flex;
flex-flow: row wrap;
justify-content: flex-start;
width: 100%;
margin-top: 5px;
}
}
</style>

View File

@@ -0,0 +1,236 @@
<script setup lang="ts">
import type { AiImageApi } from '#/api/ai/image';
import type { ImageModelVO } from '#/utils/constants';
import { ref, toRefs, watch } from 'vue';
import { Image } from 'ant-design-vue';
import { getImageMy } from '#/api/ai/image';
import {
AiPlatformEnum,
Dall3StyleList,
StableDiffusionClipGuidancePresets,
StableDiffusionSamplers,
StableDiffusionStylePresets,
} from '#/utils/constants';
import { formatTime } from '#/utils/formatTime';
// 图片详细信息
const props = defineProps({
id: {
type: Number,
required: true,
},
});
const detail = ref<AiImageApi.ImageVO>({} as AiImageApi.ImageVO);
/** 获取图片详情 */
const getImageDetail = async (id: number) => {
detail.value = await getImageMy(id);
};
const { id } = toRefs(props);
watch(
id,
async (newVal) => {
if (newVal) {
await getImageDetail(newVal);
}
},
{ immediate: true },
);
</script>
<template>
<div class="item">
<div class="body">
<Image class="image" :src="detail?.picUrl" />
</div>
</div>
<!-- 时间 -->
<div class="item">
<div class="tip">时间</div>
<div class="body">
<div>
提交时间{{ formatTime(detail.createTime, 'yyyy-MM-dd HH:mm:ss') }}
</div>
<div>
生成时间{{ formatTime(detail.finishTime, 'yyyy-MM-dd HH:mm:ss') }}
</div>
</div>
</div>
<!-- 模型 -->
<div class="item">
<div class="tip">模型</div>
<div class="body">
{{ detail.model }}({{ detail.height }}x{{ detail.width }})
</div>
</div>
<!-- 提示词 -->
<div class="item">
<div class="tip">提示词</div>
<div class="body">
{{ detail.prompt }}
</div>
</div>
<!-- 地址 -->
<div class="item">
<div class="tip">图片地址</div>
<div class="body">
{{ detail.picUrl }}
</div>
</div>
<!-- StableDiffusion 专属区域 -->
<div
class="item"
v-if="
detail.platform === AiPlatformEnum.STABLE_DIFFUSION &&
detail?.options?.sampler
"
>
<div class="tip">采样方法</div>
<div class="body">
{{
StableDiffusionSamplers.find(
(item: ImageModelVO) => item.key === detail?.options?.sampler,
)?.name
}}
</div>
</div>
<div
class="item"
v-if="
detail.platform === AiPlatformEnum.STABLE_DIFFUSION &&
detail?.options?.clipGuidancePreset
"
>
<div class="tip">CLIP</div>
<div class="body">
{{
StableDiffusionClipGuidancePresets.find(
(item: ImageModelVO) =>
item.key === detail?.options?.clipGuidancePreset,
)?.name
}}
</div>
</div>
<div
class="item"
v-if="
detail.platform === AiPlatformEnum.STABLE_DIFFUSION &&
detail?.options?.stylePreset
"
>
<div class="tip">风格</div>
<div class="body">
{{
StableDiffusionStylePresets.find(
(item: ImageModelVO) => item.key === detail?.options?.stylePreset,
)?.name
}}
</div>
</div>
<div
class="item"
v-if="
detail.platform === AiPlatformEnum.STABLE_DIFFUSION &&
detail?.options?.steps
"
>
<div class="tip">迭代步数</div>
<div class="body">
{{ detail?.options?.steps }}
</div>
</div>
<div
class="item"
v-if="
detail.platform === AiPlatformEnum.STABLE_DIFFUSION &&
detail?.options?.scale
"
>
<div class="tip">引导系数</div>
<div class="body">
{{ detail?.options?.scale }}
</div>
</div>
<div
class="item"
v-if="
detail.platform === AiPlatformEnum.STABLE_DIFFUSION &&
detail?.options?.seed
"
>
<div class="tip">随机因子</div>
<div class="body">
{{ detail?.options?.seed }}
</div>
</div>
<!-- Dall3 专属区域 -->
<div
class="item"
v-if="detail.platform === AiPlatformEnum.OPENAI && detail?.options?.style"
>
<div class="tip">风格选择</div>
<div class="body">
{{
Dall3StyleList.find(
(item: ImageModelVO) => item.key === detail?.options?.style,
)?.name
}}
</div>
</div>
<!-- Midjourney 专属区域 -->
<div
class="item"
v-if="
detail.platform === AiPlatformEnum.MIDJOURNEY && detail?.options?.version
"
>
<div class="tip">模型版本</div>
<div class="body">
{{ detail?.options?.version }}
</div>
</div>
<div
class="item"
v-if="
detail.platform === AiPlatformEnum.MIDJOURNEY &&
detail?.options?.referImageUrl
"
>
<div class="tip">参考图</div>
<div class="body">
<Image :src="detail.options.referImageUrl" />
</div>
</div>
</template>
<style scoped lang="scss">
.item {
width: 100%;
margin-bottom: 20px;
overflow: hidden;
word-wrap: break-word;
.header {
display: flex;
flex-direction: row;
justify-content: space-between;
}
.tip {
font-size: 16px;
font-weight: bold;
}
.body {
margin-top: 10px;
color: #616161;
.taskImage {
border-radius: 10px;
}
}
}
</style>

View File

@@ -0,0 +1,250 @@
<script setup lang="ts">
import type { AiImageApi } from '#/api/ai/image';
import { onMounted, onUnmounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { confirm, useVbenDrawer } from '@vben/common-ui';
import { useDebounceFn } from '@vueuse/core';
import { Button, Card, message, Pagination } from 'ant-design-vue';
import {
deleteImageMy,
getImageListMyByIds,
getImagePageMy,
midjourneyAction,
} from '#/api/ai/image';
import { AiImageStatusEnum } from '#/utils/constants';
import { download } from '#/utils/download';
import ImageCard from './ImageCard.vue';
import ImageDetail from './ImageDetail.vue';
// 暴露组件方法
const emits = defineEmits(['onRegeneration']);
const router = useRouter(); // 路由
const [Drawer, drawerApi] = useVbenDrawer({
title: '图片详情',
footer: false,
});
// 图片分页相关的参数
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
});
const pageTotal = ref<number>(0); // page size
const imageList = ref<AiImageApi.ImageVO[]>([]); // image 列表
const imageListRef = ref<any>(); // ref
// 图片轮询相关的参数(正在生成中的)
const inProgressImageMap = ref<{}>({}); // 监听的 image 映射一般是生成中需要轮询key 为 image 编号value 为 image
const inProgressTimer = ref<any>(); // 生成中的 image 定时器,轮询生成进展
const showImageDetailId = ref<number>(0); // 图片详情的图片编号
/** 处理查看绘图作品 */
const handleViewPublic = () => {
router.push({
name: 'AiImageSquare',
});
};
/** 查看图片的详情 */
const handleDetailOpen = async () => {
drawerApi.open();
};
/** 获得 image 图片列表 */
const getImageList = async () => {
const loading = message.loading({
content: `加载中...`,
});
try {
// 1. 加载图片列表
const { list, total } = await getImagePageMy(queryParams);
imageList.value = list;
pageTotal.value = total;
// 2. 计算需要轮询的图片
const newWatImages: any = {};
imageList.value.forEach((item: any) => {
if (item.status === AiImageStatusEnum.IN_PROGRESS) {
newWatImages[item.id] = item;
}
});
inProgressImageMap.value = newWatImages;
} finally {
// 关闭正在“加载中”的 Loading
loading();
}
};
const debounceGetImageList = useDebounceFn(getImageList, 80);
/** 轮询生成中的 image 列表 */
const refreshWatchImages = async () => {
const imageIds = Object.keys(inProgressImageMap.value).map(Number);
if (imageIds.length === 0) {
return;
}
const list = (await getImageListMyByIds(imageIds)) as AiImageApi.ImageVO[];
const newWatchImages: any = {};
list.forEach((image) => {
if (image.status === AiImageStatusEnum.IN_PROGRESS) {
newWatchImages[image.id] = image;
} else {
const index = imageList.value.findIndex(
(oldImage) => image.id === oldImage.id,
);
if (index !== -1) {
// 更新 imageList
imageList.value[index] = image;
}
}
});
inProgressImageMap.value = newWatchImages;
};
/** 图片的点击事件 */
const handleImageButtonClick = async (
type: string,
imageDetail: AiImageApi.ImageVO,
) => {
// 详情
if (type === 'more') {
showImageDetailId.value = imageDetail.id;
await handleDetailOpen();
return;
}
// 删除
if (type === 'delete') {
await confirm(`是否删除照片?`);
await deleteImageMy(imageDetail.id);
await getImageList();
message.success('删除成功!');
return;
}
// 下载
if (type === 'download') {
await download.image({ url: imageDetail.picUrl });
return;
}
// 重新生成
if (type === 'regeneration') {
await emits('onRegeneration', imageDetail);
}
};
/** 处理 Midjourney 按钮点击事件 */
const handleImageMidjourneyButtonClick = async (
button: AiImageApi.ImageMidjourneyButtonsVO,
imageDetail: AiImageApi.ImageVO,
) => {
// 1. 构建 params 参数
const data = {
id: imageDetail.id,
customId: button.customId,
} as AiImageApi.ImageMidjourneyActionVO;
// 2. 发送 action
await midjourneyAction(data);
// 3. 刷新列表
await getImageList();
};
defineExpose({ getImageList }); /** 组件挂在的时候 */
onMounted(async () => {
// 获取 image 列表
await getImageList();
// 自动刷新 image 列表
inProgressTimer.value = setInterval(async () => {
await refreshWatchImages();
}, 1000 * 3);
});
/** 组件取消挂在的时候 */
onUnmounted(async () => {
if (inProgressTimer.value) {
clearInterval(inProgressTimer.value);
}
});
</script>
<template>
<Drawer class="w-[600px]">
<ImageDetail :id="showImageDetailId" />
</Drawer>
<Card
class="dr-task"
:body-style="{
margin: 0,
padding: 0,
height: '100%',
position: 'relative',
}"
>
<template #title>
绘画任务
<!-- TODO @fan看看怎么优化下这个样子哈 -->
<Button @click="handleViewPublic">绘画作品</Button>
</template>
<div class="task-image-list" ref="imageListRef">
<ImageCard
v-for="image in imageList"
:key="image.id"
:detail="image"
@on-btn-click="handleImageButtonClick"
@on-mj-btn-click="handleImageMidjourneyButtonClick"
/>
</div>
<div class="task-image-pagination">
<Pagination
:total="pageTotal"
:show-total="(total: number) => `${total}`"
show-quick-jumper
show-size-changer
v-model:current="queryParams.pageNo"
v-model:page-size="queryParams.pageSize"
@change="debounceGetImageList"
@show-size-change="debounceGetImageList"
/>
</div>
</Card>
</template>
<style lang="scss">
.dr-task {
width: 100%;
height: 100%;
}
.task-image-list {
position: relative;
box-sizing: border-box; /* 确保内边距不会增加高度 */
display: flex;
flex-flow: row wrap;
align-content: flex-start;
height: 100%;
padding: 20px 20px 140px;
overflow: auto;
> div {
margin-right: 20px;
margin-bottom: 20px;
}
> div:last-of-type {
//margin-bottom: 100px;
}
}
.task-image-pagination {
position: absolute;
bottom: 60px;
z-index: 999;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
width: 100%;
height: 50px;
line-height: 90px;
background-color: #fff;
}
</style>

View File

@@ -0,0 +1,248 @@
<!-- dall3 -->
<script setup lang="ts">
import type { AiImageApi } from '#/api/ai/image';
import type { AiModelModelApi } from '#/api/ai/model/model';
import { ref, watch } from 'vue';
import { confirm } from '@vben/common-ui';
import { Button, InputNumber, Select, Space, Textarea } from 'ant-design-vue';
import { drawImage } from '#/api/ai/image';
import {
AiPlatformEnum,
ImageHotWords,
OtherPlatformEnum,
} from '#/utils/constants';
// 消息弹窗
// 接收父组件传入的模型列表
const props = defineProps({
models: {
type: Array<AiModelModelApi.ModelVO>,
default: () => [] as AiModelModelApi.ModelVO[],
},
});
const emits = defineEmits(['onDrawStart', 'onDrawComplete']);
// 定义属性
const drawIn = ref<boolean>(false); // 生成中
const selectHotWord = ref<string>(''); // 选中的热词
// 表单
const prompt = ref<string>(''); // 提示词
const width = ref<number>(512); // 图片宽度
const height = ref<number>(512); // 图片高度
const otherPlatform = ref<string>(AiPlatformEnum.TONG_YI); // 平台
const platformModels = ref<AiModelModelApi.ModelVO[]>([]); // 模型列表
const modelId = ref<number>(); // 选中的模型
/** 选择热词 */
const handleHotWordClick = async (hotWord: string) => {
// 情况一:取消选中
if (selectHotWord.value === hotWord) {
selectHotWord.value = '';
return;
}
// 情况二:选中
selectHotWord.value = hotWord; // 选中
prompt.value = hotWord; // 替换提示词
};
/** 图片生成 */
const handleGenerateImage = async () => {
// 二次确认
await confirm(`确认生成内容?`);
try {
// 加载中
drawIn.value = true;
// 回调
emits('onDrawStart', otherPlatform.value);
// 发送请求
const form = {
platform: otherPlatform.value,
modelId: modelId.value, // 模型
prompt: prompt.value, // 提示词
width: width.value, // 图片宽度
height: height.value, // 图片高度
options: {},
} as unknown as AiImageApi.ImageDrawReqVO;
await drawImage(form);
} finally {
// 回调
emits('onDrawComplete', otherPlatform.value);
// 加载结束
drawIn.value = false;
}
};
/** 填充值 */
const settingValues = async (detail: AiImageApi.ImageVO) => {
prompt.value = detail.prompt;
width.value = detail.width;
height.value = detail.height;
};
/** 平台切换 */
const handlerPlatformChange = async (platform: any) => {
// 根据选择的平台筛选模型
platformModels.value = props.models.filter(
(item: AiModelModelApi.ModelVO) => item.platform === platform,
);
modelId.value =
platformModels.value.length > 0 && platformModels.value[0]
? platformModels.value[0].id
: undefined;
// 切换平台,默认选择一个模型
};
/** 监听 models 变化 */
watch(
() => props.models,
() => {
handlerPlatformChange(otherPlatform.value);
},
{ immediate: true, deep: true },
);
/** 暴露组件方法 */
defineExpose({ settingValues });
</script>
<template>
<div class="prompt">
<b>画面描述</b>
<p>建议使用形容词 + 动词 + 风格的格式使用隔开</p>
<Textarea
v-model:value="prompt"
:maxlength="1024"
:rows="5"
class="w-100% mt-[15px]"
placeholder="例如:童话里的小屋应该是什么样子?"
show-count
/>
</div>
<div class="hot-words">
<div>
<b>随机热词</b>
</div>
<Space wrap class="word-list">
<Button
shape="round"
class="btn"
:type="selectHotWord === hotWord ? 'primary' : 'default'"
v-for="hotWord in ImageHotWords"
:key="hotWord"
@click="handleHotWordClick(hotWord)"
>
{{ hotWord }}
</Button>
</Space>
</div>
<div class="group-item">
<div>
<b>平台</b>
</div>
<Space wrap class="group-item-body">
<Select
v-model:value="otherPlatform"
placeholder="Select"
size="large"
class="!w-[330px]"
@change="handlerPlatformChange"
>
<Select.Option
v-for="item in OtherPlatformEnum"
:key="item.key"
:value="item.key"
>
{{ item.name }}
</Select.Option>
</Select>
</Space>
</div>
<div class="group-item">
<div>
<b>模型</b>
</div>
<Space wrap class="group-item-body">
<Select
v-model:value="modelId"
placeholder="Select"
size="large"
class="!w-[330px]"
>
<Select.Option
v-for="item in platformModels"
:key="item.id"
:value="item.id"
>
{{ item.name }}
</Select.Option>
</Select>
</Space>
</div>
<div class="group-item">
<div>
<b>图片尺寸</b>
</div>
<Space wrap class="group-item-body">
<InputNumber
v-model:value="width"
class="mt-[10px] w-[170px]"
placeholder="图片宽度"
/>
<InputNumber
v-model:value="height"
class="w-[170px]"
placeholder="图片高度"
/>
</Space>
</div>
<div class="btns">
<Button
type="primary"
size="large"
shape="round"
:loading="drawIn"
:disabled="prompt.length === 0"
@click="handleGenerateImage"
>
{{ drawIn ? '生成中' : '生成内容' }}
</Button>
</div>
</template>
<style scoped lang="scss">
.hot-words {
display: flex;
flex-direction: column;
margin-top: 30px;
.word-list {
display: flex;
flex-flow: row wrap;
justify-content: start;
margin-top: 15px;
.btn {
margin: 0;
}
}
}
// 模型
.group-item {
margin-top: 30px;
.group-item-body {
width: 100%;
margin-top: 15px;
}
}
.btns {
display: flex;
justify-content: center;
margin-top: 50px;
}
</style>

View File

@@ -0,0 +1,389 @@
<!-- dall3 -->
<script setup lang="ts">
import type { AiImageApi } from '#/api/ai/image';
import type { AiModelModelApi } from '#/api/ai/model/model';
import type { ImageModelVO, ImageSizeVO } from '#/utils/constants';
import { ref } from 'vue';
import { confirm } from '@vben/common-ui';
import { Button, Image, message, Space, Textarea } from 'ant-design-vue';
import { drawImage } from '#/api/ai/image';
import {
AiPlatformEnum,
Dall3Models,
Dall3SizeList,
Dall3StyleList,
ImageHotWords,
} from '#/utils/constants';
// 接收父组件传入的模型列表
const props = defineProps({
models: {
type: Array<AiModelModelApi.ModelVO>,
default: () => [] as AiModelModelApi.ModelVO[],
},
});
const emits = defineEmits(['onDrawStart', 'onDrawComplete']);
// 定义属性
const prompt = ref<string>(''); // 提示词
const drawIn = ref<boolean>(false); // 生成中
const selectHotWord = ref<string>(''); // 选中的热词
const selectModel = ref<string>('dall-e-3'); // 模型
const selectSize = ref<string>('1024x1024'); // 选中 size
const style = ref<string>('vivid'); // style 样式
/** 选择热词 */
const handleHotWordClick = async (hotWord: string) => {
// 情况一:取消选中
if (selectHotWord.value === hotWord) {
selectHotWord.value = '';
return;
}
// 情况二:选中
selectHotWord.value = hotWord;
prompt.value = hotWord;
};
/** 选择 model 模型 */
const handleModelClick = async (model: ImageModelVO) => {
selectModel.value = model.key;
// 可以在这里添加模型特定的处理逻辑
// 例如,如果未来需要根据不同模型设置不同参数
if (model.key === 'dall-e-3') {
// DALL-E-3 模型特定的处理
style.value = 'vivid'; // 默认设置vivid风格
} else if (model.key === 'dall-e-2') {
// DALL-E-2 模型特定的处理
style.value = 'natural'; // 如果有其他DALL-E-2适合的默认风格
}
// 更新其他相关参数
// 例如可以默认选择最适合当前模型的尺寸
const recommendedSize = Dall3SizeList.find(
(size) =>
(model.key === 'dall-e-3' && size.key === '1024x1024') ||
(model.key === 'dall-e-2' && size.key === '512x512'),
);
if (recommendedSize) {
selectSize.value = recommendedSize.key;
}
};
/** 选择 style 样式 */
const handleStyleClick = async (imageStyle: ImageModelVO) => {
style.value = imageStyle.key;
};
/** 选择 size 大小 */
const handleSizeClick = async (imageSize: ImageSizeVO) => {
selectSize.value = imageSize.key;
};
/** 图片生产 */
const handleGenerateImage = async () => {
// 从 models 中查找匹配的模型
const matchedModel = props.models.find(
(item) =>
item.model === selectModel.value &&
item.platform === AiPlatformEnum.OPENAI,
);
if (!matchedModel) {
message.error('该模型不可用,请选择其它模型');
return;
}
// 二次确认
await confirm(`确认生成内容?`);
try {
// 加载中
drawIn.value = true;
// 回调
emits('onDrawStart', AiPlatformEnum.OPENAI);
const imageSize = Dall3SizeList.find(
(item) => item.key === selectSize.value,
) as ImageSizeVO;
const form = {
platform: AiPlatformEnum.OPENAI,
prompt: prompt.value, // 提示词
modelId: matchedModel.id, // 使用匹配到的模型
style: style.value, // 图像生成的风格
width: imageSize.width, // size 不能为空
height: imageSize.height, // size 不能为空
options: {
style: style.value, // 图像生成的风格
},
} as AiImageApi.ImageDrawReqVO;
// 发送请求
await drawImage(form);
} finally {
// 回调
emits('onDrawComplete', AiPlatformEnum.OPENAI);
// 加载结束
drawIn.value = false;
}
};
/** 填充值 */
const settingValues = async (detail: AiImageApi.ImageVO) => {
prompt.value = detail.prompt;
selectModel.value = detail.model;
style.value = detail.options?.style;
const imageSize = Dall3SizeList.find(
(item) => item.key === `${detail.width}x${detail.height}`,
) as ImageSizeVO;
await handleSizeClick(imageSize);
};
/** 暴露组件方法 */
defineExpose({ settingValues });
</script>
<template>
<div class="prompt">
<b>画面描述</b>
<p>建议使用"形容词 + 动词 + 风格"的格式使用""隔开</p>
<Textarea
v-model:value="prompt"
:maxlength="1024"
:rows="5"
class="w-100% mt-[15px]"
placeholder="例如:童话里的小屋应该是什么样子?"
show-count
/>
</div>
<div class="hot-words">
<div>
<b>随机热词</b>
</div>
<Space wrap class="word-list">
<Button
shape="round"
class="btn"
:type="selectHotWord === hotWord ? 'primary' : 'default'"
v-for="hotWord in ImageHotWords"
:key="hotWord"
@click="handleHotWordClick(hotWord)"
>
{{ hotWord }}
</Button>
</Space>
</div>
<div class="model">
<div>
<b>模型选择</b>
</div>
<Space wrap class="model-list">
<div
:class="
selectModel === model.key ? 'modal-item selectModel' : 'modal-item'
"
v-for="model in Dall3Models"
:key="model.key"
>
<Image
:preview="false"
:src="model.image"
fit="contain"
@click="handleModelClick(model)"
/>
<div class="model-font">{{ model.name }}</div>
</div>
</Space>
</div>
<div class="image-style">
<div>
<b>风格选择</b>
</div>
<Space wrap class="image-style-list">
<div
:class="
style === imageStyle.key
? 'image-style-item selectImageStyle'
: 'image-style-item'
"
v-for="imageStyle in Dall3StyleList"
:key="imageStyle.key"
>
<Image
:preview="false"
:src="imageStyle.image"
fit="contain"
@click="handleStyleClick(imageStyle)"
/>
<div class="style-font">{{ imageStyle.name }}</div>
</div>
</Space>
</div>
<div class="image-size">
<div>
<b>画面比例</b>
</div>
<Space wrap class="size-list">
<div
class="size-item"
v-for="imageSize in Dall3SizeList"
:key="imageSize.key"
@click="handleSizeClick(imageSize)"
>
<div
:class="
selectSize === imageSize.key
? 'size-wrapper selectImageSize'
: 'size-wrapper'
"
>
<div :style="imageSize.style"></div>
</div>
<div class="size-font">{{ imageSize.name }}</div>
</div>
</Space>
</div>
<div class="btns">
<Button
type="primary"
size="large"
shape="round"
:loading="drawIn"
:disabled="prompt.length === 0"
@click="handleGenerateImage"
>
{{ drawIn ? '生成中' : '生成内容' }}
</Button>
</div>
</template>
<style scoped lang="scss">
// 热词
.hot-words {
display: flex;
flex-direction: column;
margin-top: 30px;
.word-list {
display: flex;
flex-flow: row wrap;
justify-content: start;
margin-top: 15px;
.btn {
margin: 0;
}
}
}
// 模型
.model {
margin-top: 30px;
.model-list {
margin-top: 15px;
.modal-item {
display: flex;
flex-direction: column;
align-items: center;
width: 110px;
//outline: 1px solid blue;
overflow: hidden;
cursor: pointer;
border: 3px solid transparent;
.model-font {
font-size: 14px;
font-weight: bold;
color: #3e3e3e;
}
}
.selectModel {
border: 3px solid #1293ff;
border-radius: 5px;
}
}
}
// 样式 style
.image-style {
margin-top: 30px;
.image-style-list {
margin-top: 15px;
.image-style-item {
display: flex;
flex-direction: column;
align-items: center;
width: 110px;
//outline: 1px solid blue;
overflow: hidden;
cursor: pointer;
border: 3px solid transparent;
.style-font {
font-size: 14px;
font-weight: bold;
color: #3e3e3e;
}
}
.selectImageStyle {
border: 3px solid #1293ff;
border-radius: 5px;
}
}
}
// 尺寸
.image-size {
width: 100%;
margin-top: 30px;
.size-list {
display: flex;
flex-direction: row;
justify-content: space-between;
width: 100%;
margin-top: 20px;
.size-item {
display: flex;
flex-direction: column;
align-items: center;
cursor: pointer;
.size-wrapper {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 50px;
height: 50px;
padding: 4px;
background-color: #fff;
border: 1px solid #fff;
border-radius: 7px;
}
.size-font {
font-size: 14px;
font-weight: bold;
color: #3e3e3e;
}
}
}
.selectImageSize {
border: 1px solid #1293ff !important;
}
}
.btns {
display: flex;
justify-content: center;
margin-top: 50px;
}
</style>

View File

@@ -0,0 +1,371 @@
<!-- dall3 -->
<script setup lang="ts">
import type { AiImageApi } from '#/api/ai/image';
import type { AiModelModelApi } from '#/api/ai/model/model';
import type { ImageModelVO, ImageSizeVO } from '#/utils/constants';
import { ref } from 'vue';
import { confirm } from '@vben/common-ui';
import {
Button,
Image,
message,
Select,
Space,
Textarea,
} from 'ant-design-vue';
import { midjourneyImagine } from '#/api/ai/image';
import { ImageUpload } from '#/components/upload';
import {
AiPlatformEnum,
ImageHotWords,
MidjourneyModels,
MidjourneySizeList,
MidjourneyVersions,
NijiVersionList,
} from '#/utils/constants';
// 消息弹窗
// 接收父组件传入的模型列表
const props = defineProps({
models: {
type: Array<AiModelModelApi.ModelVO>,
default: () => [] as AiModelModelApi.ModelVO[],
},
});
const emits = defineEmits(['onDrawStart', 'onDrawComplete']);
// 定义属性
const drawIn = ref<boolean>(false); // 生成中
const selectHotWord = ref<string>(''); // 选中的热词
// 表单
const prompt = ref<string>(''); // 提示词
const referImageUrl = ref<any>(); // 参考图
const selectModel = ref<string>('midjourney'); // 选中的模型
const selectSize = ref<string>('1:1'); // 选中 size
const selectVersion = ref<any>('6.0'); // 选中的 version
const versionList = ref<any>(MidjourneyVersions); // version 列表
/** 选择热词 */
const handleHotWordClick = async (hotWord: string) => {
// 情况一:取消选中
if (selectHotWord.value === hotWord) {
selectHotWord.value = '';
return;
}
// 情况二:选中
selectHotWord.value = hotWord; // 选中
prompt.value = hotWord; // 设置提示次
};
/** 点击 size 尺寸 */
const handleSizeClick = async (imageSize: ImageSizeVO) => {
selectSize.value = imageSize.key;
};
/** 点击 model 模型 */
const handleModelClick = async (model: ImageModelVO) => {
selectModel.value = model.key;
versionList.value =
model.key === 'niji' ? NijiVersionList : MidjourneyVersions;
selectVersion.value = versionList.value[0].value;
};
/** 图片生成 */
const handleGenerateImage = async () => {
// 从 models 中查找匹配的模型
const matchedModel = props.models.find(
(item) =>
item.model === selectModel.value &&
item.platform === AiPlatformEnum.MIDJOURNEY,
);
if (!matchedModel) {
message.error('该模型不可用,请选择其它模型');
return;
}
// 二次确认
await confirm(`确认生成内容?`);
try {
// 加载中
drawIn.value = true;
// 回调
emits('onDrawStart', AiPlatformEnum.MIDJOURNEY);
// 发送请求
const imageSize = MidjourneySizeList.find(
(item) => selectSize.value === item.key,
) as ImageSizeVO;
const req = {
prompt: prompt.value,
modelId: matchedModel.id,
width: imageSize.width,
height: imageSize.height,
version: selectVersion.value,
referImageUrl: referImageUrl.value,
} as AiImageApi.ImageMidjourneyImagineReqVO;
await midjourneyImagine(req);
} finally {
// 回调
emits('onDrawComplete', AiPlatformEnum.MIDJOURNEY);
// 加载结束
drawIn.value = false;
}
};
/** 填充值 */
const settingValues = async (detail: AiImageApi.ImageVO) => {
// 提示词
prompt.value = detail.prompt;
// image size
const imageSize = MidjourneySizeList.find(
(item) => item.key === `${detail.width}:${detail.height}`,
) as ImageSizeVO;
selectSize.value = imageSize.key;
// 选中模型
const model = MidjourneyModels.find(
(item) => item.key === detail.options?.model,
) as ImageModelVO;
await handleModelClick(model);
// 版本
selectVersion.value = versionList.value.find(
(item: any) => item.value === detail.options?.version,
).value;
// image
referImageUrl.value = detail.options.referImageUrl;
};
/** 暴露组件方法 */
defineExpose({ settingValues });
</script>
<template>
<div class="prompt">
<b>画面描述</b>
<p>建议使用形容词+动词+风格的格式使用隔开.</p>
<Textarea
v-model:value="prompt"
:maxlength="1024"
:rows="5"
class="w-100% mt-[15px]"
placeholder="例如:童话里的小屋应该是什么样子?"
show-count
/>
</div>
<div class="hot-words">
<div>
<b>随机热词</b>
</div>
<Space wrap class="word-list">
<Button
shape="round"
class="btn"
:type="selectHotWord === hotWord ? 'primary' : 'default'"
v-for="hotWord in ImageHotWords"
:key="hotWord"
@click="handleHotWordClick(hotWord)"
>
{{ hotWord }}
</Button>
</Space>
</div>
<div class="image-size">
<div>
<b>尺寸</b>
</div>
<Space wrap class="size-list">
<div
class="size-item"
v-for="imageSize in MidjourneySizeList"
:key="imageSize.key"
@click="handleSizeClick(imageSize)"
>
<div
:class="
selectSize === imageSize.key
? 'size-wrapper selectImageSize'
: 'size-wrapper'
"
>
<div :style="imageSize.style"></div>
</div>
<div class="size-font">{{ imageSize.key }}</div>
</div>
</Space>
</div>
<div class="model">
<div>
<b>模型</b>
</div>
<Space wrap class="model-list">
<div
:class="
selectModel === model.key ? 'modal-item selectModel' : 'modal-item'
"
v-for="model in MidjourneyModels"
:key="model.key"
>
<Image
:preview="false"
:src="model.image"
fit="contain"
@click="handleModelClick(model)"
/>
<div class="model-font">{{ model.name }}</div>
</div>
</Space>
</div>
<div class="version">
<div>
<b>版本</b>
</div>
<Space wrap class="version-list">
<Select
v-model:value="selectVersion"
class="version-select !w-[330px]"
clearable
placeholder="请选择版本"
>
<Select.Option
v-for="item in versionList"
:key="item.value"
:value="item.value"
>
{{ item.label }}
</Select.Option>
</Select>
</Space>
</div>
<div class="model">
<div>
<b>参考图</b>
</div>
<Space wrap class="model-list">
<ImageUpload v-model:value="referImageUrl" :show-description="false" />
</Space>
</div>
<div class="btns">
<Button
type="primary"
size="large"
shape="round"
:disabled="prompt.length === 0"
@click="handleGenerateImage"
>
{{ drawIn ? '生成中' : '生成内容' }}
</Button>
</div>
</template>
<style scoped lang="scss">
// 热词
.hot-words {
display: flex;
flex-direction: column;
margin-top: 30px;
.word-list {
display: flex;
flex-flow: row wrap;
justify-content: start;
margin-top: 15px;
.btn {
margin: 0;
}
}
}
// version
.version {
margin-top: 20px;
.version-list {
width: 100%;
margin-top: 20px;
}
}
// 模型
.model {
margin-top: 30px;
.model-list {
margin-top: 15px;
.modal-item {
display: flex;
flex-direction: column;
align-items: center;
width: 150px;
//outline: 1px solid blue;
overflow: hidden;
cursor: pointer;
border: 3px solid transparent;
.model-font {
font-size: 14px;
font-weight: bold;
color: #3e3e3e;
}
}
.selectModel {
border: 3px solid #1293ff;
border-radius: 5px;
}
}
}
// 尺寸
.image-size {
width: 100%;
margin-top: 30px;
.size-list {
display: flex;
flex-direction: row;
justify-content: space-between;
width: 100%;
margin-top: 20px;
.size-item {
display: flex;
flex-direction: column;
align-items: center;
cursor: pointer;
.size-wrapper {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 50px;
height: 50px;
padding: 4px;
background-color: #fff;
border: 1px solid #fff;
border-radius: 7px;
}
.size-font {
font-size: 14px;
font-weight: bold;
color: #3e3e3e;
}
}
}
.selectImageSize {
border: 1px solid #1293ff !important;
}
}
.btns {
display: flex;
justify-content: center;
margin-top: 50px;
}
</style>

View File

@@ -0,0 +1,324 @@
<!-- dall3 -->
<script setup lang="ts">
import type { AiImageApi } from '#/api/ai/image';
import type { AiModelModelApi } from '#/api/ai/model/model';
import { ref } from 'vue';
import { alert, confirm } from '@vben/common-ui';
import {
Button,
Input,
InputNumber,
message,
Select,
Space,
Textarea,
} from 'ant-design-vue';
import { drawImage } from '#/api/ai/image';
import {
AiPlatformEnum,
ImageHotEnglishWords,
StableDiffusionClipGuidancePresets,
StableDiffusionSamplers,
StableDiffusionStylePresets,
} from '#/utils/constants';
import { hasChinese } from '#/utils/utils';
// 消息弹窗
// 接收父组件传入的模型列表
const props = defineProps({
models: {
type: Array<AiModelModelApi.ModelVO>,
default: () => [] as AiModelModelApi.ModelVO[],
},
});
const emits = defineEmits(['onDrawStart', 'onDrawComplete']);
// 定义属性
const drawIn = ref<boolean>(false); // 生成中
const selectHotWord = ref<string>(''); // 选中的热词
// 表单
const prompt = ref<string>(''); // 提示词
const width = ref<number>(512); // 图片宽度
const height = ref<number>(512); // 图片高度
const sampler = ref<string>('DDIM'); // 采样方法
const steps = ref<number>(20); // 迭代步数
const seed = ref<number>(42); // 控制生成图像的随机性
const scale = ref<number>(7.5); // 引导系数
const clipGuidancePreset = ref<string>('NONE'); // 文本提示相匹配的图像(clip_guidance_preset) 简称 CLIP
const stylePreset = ref<string>('3d-model'); // 风格
/** 选择热词 */
const handleHotWordClick = async (hotWord: string) => {
// 情况一:取消选中
if (selectHotWord.value === hotWord) {
selectHotWord.value = '';
return;
}
// 情况二:选中
selectHotWord.value = hotWord; // 选中
prompt.value = hotWord; // 替换提示词
};
/** 图片生成 */
const handleGenerateImage = async () => {
// 从 models 中查找匹配的模型
const selectModel = 'stable-diffusion-v1-6';
const matchedModel = props.models.find(
(item) =>
item.model === selectModel &&
item.platform === AiPlatformEnum.STABLE_DIFFUSION,
);
if (!matchedModel) {
message.error('该模型不可用,请选择其它模型');
return;
}
// 二次确认
if (hasChinese(prompt.value)) {
alert('暂不支持中文!');
return;
}
await confirm(`确认生成内容?`);
try {
// 加载中
drawIn.value = true;
// 回调
emits('onDrawStart', AiPlatformEnum.STABLE_DIFFUSION);
// 发送请求
const form = {
modelId: matchedModel.id,
prompt: prompt.value, // 提示词
width: width.value, // 图片宽度
height: height.value, // 图片高度
options: {
seed: seed.value, // 随机种子
steps: steps.value, // 图片生成步数
scale: scale.value, // 引导系数
sampler: sampler.value, // 采样算法
clipGuidancePreset: clipGuidancePreset.value, // 文本提示相匹配的图像 CLIP
stylePreset: stylePreset.value, // 风格
},
} as unknown as AiImageApi.ImageDrawReqVO;
await drawImage(form);
} finally {
// 回调
emits('onDrawComplete', AiPlatformEnum.STABLE_DIFFUSION);
// 加载结束
drawIn.value = false;
}
};
/** 填充值 */
const settingValues = async (detail: AiImageApi.ImageVO) => {
prompt.value = detail.prompt;
width.value = detail.width;
height.value = detail.height;
seed.value = detail.options?.seed;
steps.value = detail.options?.steps;
scale.value = detail.options?.scale;
sampler.value = detail.options?.sampler;
clipGuidancePreset.value = detail.options?.clipGuidancePreset;
stylePreset.value = detail.options?.stylePreset;
};
/** 暴露组件方法 */
defineExpose({ settingValues });
</script>
<template>
<div class="prompt">
<b>画面描述</b>
<p>建议使用形容词 + 动词 + 风格的格式使用隔开</p>
<Textarea
v-model:value="prompt"
:maxlength="1024"
:rows="5"
class="w-100% mt-[15px]"
placeholder="例如:童话里的小屋应该是什么样子?"
show-count
/>
</div>
<div class="hot-words">
<div>
<b>随机热词</b>
</div>
<Space wrap class="word-list">
<Button
shape="round"
class="btn"
:type="selectHotWord === hotWord ? 'primary' : 'default'"
v-for="hotWord in ImageHotEnglishWords"
:key="hotWord"
@click="handleHotWordClick(hotWord)"
>
{{ hotWord }}
</Button>
</Space>
</div>
<div class="group-item">
<div>
<b>采样方法</b>
</div>
<Space wrap class="group-item-body">
<Select
v-model:value="sampler"
placeholder="Select"
size="large"
class="!w-[330px]"
>
<Select.Option
v-for="item in StableDiffusionSamplers"
:key="item.key"
:value="item.key"
>
{{ item.name }}
</Select.Option>
</Select>
</Space>
</div>
<div class="group-item">
<div>
<b>CLIP</b>
</div>
<Space wrap class="group-item-body">
<Select
v-model:value="clipGuidancePreset"
placeholder="Select"
size="large"
class="!w-[330px]"
>
<Select.Option
v-for="item in StableDiffusionClipGuidancePresets"
:key="item.key"
:value="item.key"
>
{{ item.name }}
</Select.Option>
</Select>
</Space>
</div>
<div class="group-item">
<div>
<b>风格</b>
</div>
<Space wrap class="group-item-body">
<Select
v-model:value="stylePreset"
placeholder="Select"
size="large"
class="!w-[330px]"
>
<Select.Option
v-for="item in StableDiffusionStylePresets"
:key="item.key"
:label="item.name"
:value="item.key"
>
{{ item.name }}
</Select.Option>
</Select>
</Space>
</div>
<div class="group-item">
<div>
<b>图片尺寸</b>
</div>
<Space wrap class="group-item-body">
<Input v-model="width" class="w-[170px]" placeholder="图片宽度" />
<Input v-model="height" class="w-[170px]" placeholder="图片高度" />
</Space>
</div>
<div class="group-item">
<div>
<b>迭代步数</b>
</div>
<Space wrap class="group-item-body">
<InputNumber
v-model="steps"
size="large"
class="!w-[330px]"
placeholder="Please input"
/>
</Space>
</div>
<div class="group-item">
<div>
<b>引导系数</b>
</div>
<Space wrap class="group-item-body">
<InputNumber
v-model="scale"
type="number"
size="large"
class="!w-[330px]"
placeholder="Please input"
/>
</Space>
</div>
<div class="group-item">
<div>
<b>随机因子</b>
</div>
<Space wrap class="group-item-body">
<InputNumber
v-model:value="seed"
size="large"
class="!w-[330px]"
placeholder="Please input"
/>
</Space>
</div>
<div class="btns">
<Button
type="primary"
size="large"
shape="round"
:loading="drawIn"
:disabled="prompt.length === 0"
@click="handleGenerateImage"
>
{{ drawIn ? '生成中' : '生成内容' }}
</Button>
</div>
</template>
<style scoped lang="scss">
// 热词
.hot-words {
display: flex;
flex-direction: column;
margin-top: 30px;
.word-list {
display: flex;
flex-flow: row wrap;
justify-content: start;
margin-top: 15px;
.btn {
margin: 0;
}
}
}
// 模型
.group-item {
margin-top: 30px;
.group-item-body {
width: 100%;
margin-top: 15px;
}
}
.btns {
display: flex;
justify-content: center;
margin-top: 50px;
}
</style>

View File

@@ -1,28 +1,171 @@
<script lang="ts" setup>
import type { AiImageApi } from '#/api/ai/image';
import type { AiModelModelApi } from '#/api/ai/model/model';
import { nextTick, onMounted, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { Button } from 'ant-design-vue';
import { Segmented } from 'ant-design-vue';
import { getModelSimpleList } from '#/api/ai/model/model';
import { AiModelTypeEnum, AiPlatformEnum } from '#/utils/constants';
import Common from './components/common/index.vue';
import Dall3 from './components/dall3/index.vue';
import ImageList from './components/ImageList.vue';
import Midjourney from './components/midjourney/index.vue';
import StableDiffusion from './components/stableDiffusion/index.vue';
const imageListRef = ref<any>(); // image 列表 ref
const dall3Ref = ref<any>(); // dall3(openai) ref
const midjourneyRef = ref<any>(); // midjourney ref
const stableDiffusionRef = ref<any>(); // stable diffusion ref
const commonRef = ref<any>(); // stable diffusion ref
// 定义属性
const selectPlatform = ref('common'); // 选中的平台
const platformOptions = [
{
label: '通用',
value: 'common',
},
{
label: 'DALL3 绘画',
value: AiPlatformEnum.OPENAI,
},
{
label: 'MJ 绘画',
value: AiPlatformEnum.MIDJOURNEY,
},
{
label: 'SD 绘图',
value: AiPlatformEnum.STABLE_DIFFUSION,
},
];
const models = ref<AiModelModelApi.ModelVO[]>([]); // 模型列表
/** 绘画 start */
const handleDrawStart = async () => {};
/** 绘画 complete */
const handleDrawComplete = async () => {
await imageListRef.value.getImageList();
};
/** 重新生成:将画图详情填充到对应平台 */
const handleRegeneration = async (image: AiImageApi.ImageVO) => {
// 切换平台
selectPlatform.value = image.platform;
// 根据不同平台填充 image
await nextTick();
switch (image.platform) {
case AiPlatformEnum.MIDJOURNEY: {
midjourneyRef.value.settingValues(image);
break;
}
case AiPlatformEnum.OPENAI: {
dall3Ref.value.settingValues(image);
break;
}
case AiPlatformEnum.STABLE_DIFFUSION: {
stableDiffusionRef.value.settingValues(image);
break;
}
// No default
}
// TODO @fan貌似 other 重新设置不行?
};
/** 组件挂载的时候 */
onMounted(async () => {
// 获取模型列表
models.value = await getModelSimpleList(AiModelTypeEnum.IMAGE);
});
</script>
<template>
<Page>
<Button
danger
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
>
该功能支持 Vue3 + element-plus 版本
</Button>
<br />
<Button
type="link"
target="_blank"
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/ai/image/index/index.vue"
>
可参考
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/ai/image/index/index.vue
代码pull request 贡献给我们
</Button>
<Page auto-content-height>
<div class="ai-image">
<div class="left">
<div class="segmented flex justify-center">
<Segmented
v-model:value="selectPlatform"
:options="platformOptions"
/>
</div>
<div class="modal-switch-container">
<Common
v-if="selectPlatform === 'common'"
ref="commonRef"
:models="models"
@on-draw-complete="handleDrawComplete"
/>
<Dall3
v-if="selectPlatform === AiPlatformEnum.OPENAI"
ref="dall3Ref"
:models="models"
@on-draw-start="handleDrawStart"
@on-draw-complete="handleDrawComplete"
/>
<Midjourney
v-if="selectPlatform === AiPlatformEnum.MIDJOURNEY"
ref="midjourneyRef"
:models="models"
/>
<StableDiffusion
v-if="selectPlatform === AiPlatformEnum.STABLE_DIFFUSION"
ref="stableDiffusionRef"
:models="models"
@on-draw-complete="handleDrawComplete"
/>
</div>
</div>
<div class="main">
<ImageList ref="imageListRef" @on-regeneration="handleRegeneration" />
</div>
</div>
</Page>
</template>
<style scoped lang="scss">
.ai-image {
position: absolute;
inset: 0;
display: flex;
flex-direction: row;
width: 100%;
height: 100%;
.left {
display: flex;
flex-direction: column;
width: 390px;
padding: 20px;
.segmented .ant-segmented {
background-color: #ececec;
}
.modal-switch-container {
height: 100%;
margin-top: 30px;
overflow-y: auto;
}
}
.main {
flex: 1;
background-color: #fff;
}
.right {
width: 350px;
background-color: #f7f8fa;
}
}
</style>

View File

@@ -0,0 +1,116 @@
<script setup lang="ts">
import type { AiImageApi } from '#/api/ai/image';
import { onMounted, reactive, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { useDebounceFn } from '@vueuse/core';
import { Input, Pagination } from 'ant-design-vue';
import { getImagePageMy } from '#/api/ai/image';
// TODO @fan加个 loading 加载中的状态
const loading = ref(true); // 列表的加载中
const list = ref<AiImageApi.ImageVO[]>([]); // 列表的数据
const total = ref(0); // 列表的总页数
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
publicStatus: true,
prompt: undefined,
});
/** 查询列表 */
const getList = async () => {
loading.value = true;
try {
const data = await getImagePageMy(queryParams);
list.value = data.list;
total.value = data.total;
} finally {
loading.value = false;
}
};
const debounceGetList = useDebounceFn(getList, 80);
/** 搜索按钮操作 */
const handleQuery = () => {
queryParams.pageNo = 1;
getList();
};
/** 初始化 */
onMounted(async () => {
await getList();
});
</script>
<template>
<Page auto-content-height>
<div class="square-container">
<!-- TODO @fanstyle 建议换成 unocss -->
<!-- TODO @fanSearch 可以换成 Icon 组件么 -->
<Input.Search
v-model="queryParams.prompt"
style="width: 100%; margin-bottom: 20px"
size="large"
placeholder="请输入要搜索的内容"
@keyup.enter="handleQuery"
/>
<div class="gallery">
<!-- TODO @fan这个图片的风格要不和 ImageCard.vue 界面一致只有卡片没有操作因为看着更有相框的感觉~~~ -->
<div v-for="item in list" :key="item.id" class="gallery-item">
<img :src="item.picUrl" class="img" />
</div>
</div>
<!-- TODO @fan缺少翻页 -->
<!-- 分页 -->
<Pagination
:total="total"
:show-total="(total: number) => `${total}`"
show-quick-jumper
show-size-changer
v-model:current="queryParams.pageNo"
v-model:page-size="queryParams.pageSize"
@change="debounceGetList"
@show-size-change="debounceGetList"
/>
</div>
</Page>
</template>
<style scoped lang="scss">
.square-container {
padding: 20px;
background-color: #fff;
.gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 10px;
//max-width: 1000px;
background-color: #fff;
box-shadow: 0 0 10px rgb(0 0 0 / 10%);
}
.gallery-item {
position: relative;
overflow: hidden;
cursor: pointer;
background: #f0f0f0;
transition: transform 0.3s;
}
.gallery-item img {
display: block;
width: 100%;
height: auto;
transition: transform 0.3s;
}
.gallery-item:hover img {
transform: scale(1.1);
}
.gallery-item:hover {
transform: scale(1.05);
}
}
</style>