feat: 图片广场优化
This commit is contained in:
@@ -1,128 +1,12 @@
|
||||
<template>
|
||||
<div class="h-full flex flex-col bg-gray-50">
|
||||
<div
|
||||
class="flex-1 overflow-y-auto p-4 custom-scrollbar"
|
||||
v-infinite-scroll="loadMore"
|
||||
:infinite-scroll-disabled="disabled"
|
||||
:infinite-scroll-distance="50"
|
||||
>
|
||||
<div v-if="taskList.length > 0" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
<TaskCard
|
||||
v-for="task in taskList"
|
||||
:key="task.id"
|
||||
:task="task"
|
||||
@click="handleCardClick(task)"
|
||||
@use-prompt="$emit('use-prompt', $event)"
|
||||
@use-reference="$emit('use-reference', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else-if="!loading && taskList.length === 0" class="h-full flex flex-col items-center justify-center text-gray-400">
|
||||
<el-icon class="text-6xl mb-4"><Picture /></el-icon>
|
||||
<p>暂无图片,快去生成一张吧!</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="py-6 flex justify-center">
|
||||
<div class="flex items-center gap-2 text-gray-500">
|
||||
<el-icon class="is-loading"><Loading /></el-icon>
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No More State -->
|
||||
<div v-if="noMore && taskList.length > 0" class="py-6 text-center text-gray-400 text-sm">
|
||||
- 到底了,没有更多图片了 -
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dialog for details -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
title="图片详情"
|
||||
width="900px"
|
||||
append-to-body
|
||||
class="image-detail-dialog"
|
||||
align-center
|
||||
>
|
||||
<div v-if="currentTask" class="flex flex-col md:flex-row gap-6 h-[600px]">
|
||||
<!-- Left Image -->
|
||||
<div class="flex-1 bg-black/5 rounded-lg flex items-center justify-center overflow-hidden relative group">
|
||||
<el-image
|
||||
:src="currentTask.storeUrl"
|
||||
fit="contain"
|
||||
class="w-full h-full"
|
||||
:preview-src-list="[currentTask.storeUrl]"
|
||||
/>
|
||||
<!-- Download Button Overlay -->
|
||||
<div class="absolute bottom-4 right-4 opacity-0 group-hover:opacity-100 transition-opacity z-10">
|
||||
<el-button circle type="primary" :icon="Download" @click="downloadImage(currentTask.storeUrl)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Info -->
|
||||
<div class="w-full md:w-[300px] flex flex-col gap-4 overflow-hidden">
|
||||
<div class="flex-1 flex flex-col min-h-0">
|
||||
<h3 class="font-bold text-gray-800 mb-2 flex items-center gap-2 shrink-0">
|
||||
<el-icon><MagicStick /></el-icon> 提示词
|
||||
</h3>
|
||||
<div class="bg-gray-50 p-4 rounded-lg border border-gray-100 text-sm text-gray-600 leading-relaxed relative group/prompt overflow-y-auto custom-scrollbar flex-1">
|
||||
{{ currentTask.prompt }}
|
||||
<el-button
|
||||
class="absolute top-2 right-2 opacity-0 group-hover/prompt:opacity-100 transition-opacity shadow-sm z-10"
|
||||
size="small"
|
||||
circle
|
||||
:icon="CopyDocument"
|
||||
@click="copyPrompt(currentTask.prompt)"
|
||||
title="复制提示词"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-auto space-y-3 pt-4 border-t border-gray-100 shrink-0">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-500">创建时间</span>
|
||||
<span class="text-gray-800">{{ formatTime(currentTask.creationTime) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-500">状态</span>
|
||||
<el-tag size="small" type="success">生成成功</el-tag>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2 mt-2">
|
||||
<el-button
|
||||
type="primary"
|
||||
:icon="MagicStick"
|
||||
@click="$emit('use-prompt', currentTask.prompt)"
|
||||
>
|
||||
使用提示词
|
||||
</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
plain
|
||||
:icon="Picture"
|
||||
@click="$emit('use-reference', currentTask.storeUrl)"
|
||||
>
|
||||
做参考图
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { getImagePlaza } from '@/api/aiImage';
|
||||
import type { TaskItem } from '@/api/aiImage/types';
|
||||
import TaskCard from './TaskCard.vue';
|
||||
import type { TaskItem, TaskListRequest } from '@/api/aiImage/types';
|
||||
import { CircleCloseFilled, CollectionTag, CopyDocument, Download, Filter, Loading, MagicStick, Picture, Refresh, Search, User, WarningFilled } from '@element-plus/icons-vue';
|
||||
import { useClipboard } from '@vueuse/core';
|
||||
import { format } from 'date-fns';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { useClipboard } from '@vueuse/core';
|
||||
import { Picture, Loading, MagicStick, CopyDocument, Download } from '@element-plus/icons-vue';
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { getImagePlaza } from '@/api/aiImage';
|
||||
import TaskCard from './TaskCard.vue';
|
||||
|
||||
const emit = defineEmits(['use-prompt', 'use-reference']);
|
||||
|
||||
@@ -134,33 +18,54 @@ const noMore = ref(false);
|
||||
const dialogVisible = ref(false);
|
||||
const currentTask = ref<TaskItem | null>(null);
|
||||
|
||||
// Viewer State
|
||||
const showViewer = ref(false);
|
||||
const previewUrl = ref('');
|
||||
|
||||
// Filter State
|
||||
const searchForm = reactive({
|
||||
Prompt: '',
|
||||
Categories: '',
|
||||
UserName: '',
|
||||
});
|
||||
const dateRange = ref<[string, string] | null>(null);
|
||||
|
||||
const { copy } = useClipboard();
|
||||
|
||||
const disabled = computed(() => loading.value || noMore.value);
|
||||
|
||||
const loadMore = async () => {
|
||||
if (loading.value || noMore.value) return;
|
||||
|
||||
async function loadMore() {
|
||||
if (loading.value || noMore.value)
|
||||
return;
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getImagePlaza({
|
||||
PageIndex: pageIndex.value,
|
||||
PageSize: pageSize.value,
|
||||
TaskStatus: 'Success'
|
||||
});
|
||||
|
||||
const params: TaskListRequest = {
|
||||
SkipCount: (pageIndex.value - 1) * pageSize.value,
|
||||
MaxResultCount: pageSize.value,
|
||||
TaskStatus: 'Success',
|
||||
Prompt: searchForm.Prompt || undefined,
|
||||
Categories: searchForm.Categories || undefined,
|
||||
UserName: searchForm.UserName || undefined,
|
||||
StartTime: dateRange.value ? dateRange.value[0] : undefined,
|
||||
EndTime: dateRange.value ? dateRange.value[1] : undefined,
|
||||
};
|
||||
|
||||
const res = await getImagePlaza(params);
|
||||
|
||||
// Handle potential wrapper
|
||||
const data = (res as any).data || res;
|
||||
const items = data.items || [];
|
||||
const total = data.total || 0;
|
||||
|
||||
|
||||
if (items.length < pageSize.value) {
|
||||
noMore.value = true;
|
||||
}
|
||||
|
||||
|
||||
if (pageIndex.value === 1) {
|
||||
taskList.value = items;
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
// Avoid duplicates
|
||||
const newItems = items.filter((item: TaskItem) => !taskList.value.some(t => t.id === item.id));
|
||||
taskList.value.push(...newItems);
|
||||
@@ -170,39 +75,67 @@ const loadMore = async () => {
|
||||
if (taskList.value.length >= total) {
|
||||
noMore.value = true;
|
||||
}
|
||||
|
||||
|
||||
if (items.length > 0) {
|
||||
pageIndex.value++;
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
noMore.value = true;
|
||||
}
|
||||
} catch (error) {
|
||||
}
|
||||
catch (error) {
|
||||
console.error(error);
|
||||
noMore.value = true;
|
||||
} finally {
|
||||
}
|
||||
finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleCardClick = (task: TaskItem) => {
|
||||
function handleSearch() {
|
||||
pageIndex.value = 1;
|
||||
taskList.value = [];
|
||||
noMore.value = false;
|
||||
loadMore();
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
searchForm.Prompt = '';
|
||||
searchForm.Categories = '';
|
||||
searchForm.UserName = '';
|
||||
dateRange.value = null;
|
||||
handleSearch();
|
||||
}
|
||||
|
||||
function handleCardClick(task: TaskItem) {
|
||||
currentTask.value = task;
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
}
|
||||
|
||||
const formatTime = (time: string) => {
|
||||
function handlePreview(url: string) {
|
||||
previewUrl.value = url;
|
||||
showViewer.value = true;
|
||||
}
|
||||
|
||||
function closeViewer() {
|
||||
showViewer.value = false;
|
||||
}
|
||||
|
||||
function formatTime(time: string) {
|
||||
try {
|
||||
return format(new Date(time), 'yyyy-MM-dd HH:mm');
|
||||
} catch (e) {
|
||||
}
|
||||
catch (e) {
|
||||
return time;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const copyPrompt = async (text: string) => {
|
||||
async function copyPrompt(text: string) {
|
||||
await copy(text);
|
||||
ElMessage.success('提示词已复制');
|
||||
};
|
||||
ElMessage.success('已复制到剪贴板');
|
||||
}
|
||||
|
||||
const downloadImage = async (url: string) => {
|
||||
async function downloadImage(url: string) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const blob = await response.blob();
|
||||
@@ -214,22 +147,290 @@ const downloadImage = async (url: string) => {
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(blobUrl);
|
||||
} catch (e) {
|
||||
}
|
||||
catch (e) {
|
||||
console.error('Download failed', e);
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Expose refresh method
|
||||
defineExpose({
|
||||
refresh: () => {
|
||||
pageIndex.value = 1;
|
||||
taskList.value = [];
|
||||
noMore.value = false;
|
||||
loadMore();
|
||||
},
|
||||
});
|
||||
|
||||
// Watch for filter changes - debounce for text inputs, immediate for others
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
watch(() => searchForm.Prompt, () => {
|
||||
if (searchTimer) {
|
||||
clearTimeout(searchTimer);
|
||||
}
|
||||
searchTimer = setTimeout(() => {
|
||||
handleSearch();
|
||||
}, 500);
|
||||
});
|
||||
|
||||
watch([() => searchForm.Categories, () => searchForm.UserName], () => {
|
||||
if (searchTimer) {
|
||||
clearTimeout(searchTimer);
|
||||
}
|
||||
searchTimer = setTimeout(() => {
|
||||
handleSearch();
|
||||
}, 500);
|
||||
});
|
||||
|
||||
watch(dateRange, () => {
|
||||
handleSearch();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: #e5e7eb;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background-color: transparent;
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<div class="h-full flex bg-gray-50">
|
||||
<!-- Left Sidebar - Filters -->
|
||||
<div class="w-72 bg-white border-r border-gray-200 flex flex-col shadow-sm">
|
||||
<div class="p-6 border-b border-gray-100">
|
||||
<h2 class="text-lg font-bold text-gray-800 flex items-center gap-2">
|
||||
<el-icon><Filter /></el-icon>
|
||||
筛选条件
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class=" overflow-y-auto p-6 custom-scrollbar">
|
||||
<el-form :model="searchForm" label-position="top" class="space-y-4">
|
||||
<el-form-item label="提示词">
|
||||
<el-input
|
||||
v-model="searchForm.Prompt"
|
||||
placeholder="搜索提示词..."
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="分类标签">
|
||||
<el-input
|
||||
v-model="searchForm.Categories"
|
||||
placeholder="搜索分类..."
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><CollectionTag /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="用户名">
|
||||
<el-input
|
||||
v-model="searchForm.UserName"
|
||||
placeholder="搜索用户..."
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><User /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="创建时间">
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
class="w-full"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="p-6 border-t border-gray-100 space-y-2">
|
||||
<el-button type="primary" class="w-full" @click="handleSearch">
|
||||
<el-icon class="mr-1">
|
||||
<Search />
|
||||
</el-icon>
|
||||
搜索
|
||||
</el-button>
|
||||
<el-button class="w-full" @click="handleReset">
|
||||
<el-icon class="mr-1">
|
||||
<Refresh />
|
||||
</el-icon>
|
||||
重置
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Content Area -->
|
||||
<div class="flex-1 flex flex-col overflow-hidden">
|
||||
<div
|
||||
v-infinite-scroll="loadMore"
|
||||
class="flex-1 overflow-y-auto p-6 custom-scrollbar"
|
||||
:infinite-scroll-disabled="disabled"
|
||||
:infinite-scroll-distance="50"
|
||||
>
|
||||
<div v-if="taskList.length > 0" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
<TaskCard
|
||||
v-for="task in taskList"
|
||||
:key="task.id"
|
||||
:task="task"
|
||||
@click="handleCardClick(task)"
|
||||
@use-prompt="$emit('use-prompt', $event)"
|
||||
@use-reference="$emit('use-reference', $event)"
|
||||
@preview="handlePreview"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else-if="!loading && taskList.length === 0" class="h-full flex flex-col items-center justify-center text-gray-400">
|
||||
<el-icon class="text-6xl mb-4">
|
||||
<Picture />
|
||||
</el-icon>
|
||||
<p>暂无图片,快去生成一张吧!</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="py-6 flex justify-center">
|
||||
<div class="flex items-center gap-2 text-gray-500">
|
||||
<el-icon class="is-loading">
|
||||
<Loading />
|
||||
</el-icon>
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No More State -->
|
||||
<div v-if="noMore && taskList.length > 0" class="py-6 text-center text-gray-400 text-sm">
|
||||
- 到底了,没有更多图片了 -
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dialog for details -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
title="图片详情"
|
||||
width="900px"
|
||||
append-to-body
|
||||
class="image-detail-dialog"
|
||||
align-center
|
||||
>
|
||||
<div v-if="currentTask" class="flex flex-col md:flex-row gap-6 h-[600px]">
|
||||
<!-- Left Image -->
|
||||
<div class="flex-1 bg-black/5 rounded-lg flex items-center justify-center overflow-hidden relative group">
|
||||
<el-image
|
||||
v-if="currentTask.storeUrl"
|
||||
:src="currentTask.storeUrl"
|
||||
fit="contain"
|
||||
class="w-full h-full"
|
||||
:preview-src-list="[currentTask.storeUrl]"
|
||||
/>
|
||||
<div v-else class="flex flex-col items-center justify-center text-gray-400 p-4 text-center w-full h-full">
|
||||
<span v-if="currentTask.taskStatus === 'Processing'" class="flex flex-col items-center">
|
||||
<el-icon class="text-4xl mb-3 is-loading text-blue-500"><Loading /></el-icon>
|
||||
<span class="text-blue-500 font-medium">生成中...</span>
|
||||
</span>
|
||||
<div v-else-if="currentTask.taskStatus === 'Fail'" class="flex flex-col items-center w-full max-w-md px-4">
|
||||
<el-icon class="text-5xl mb-4 text-red-500">
|
||||
<CircleCloseFilled />
|
||||
</el-icon>
|
||||
<span class="font-bold mb-4 text-lg text-red-600">生成失败</span>
|
||||
<div class="w-full bg-gradient-to-br from-red-50 to-red-100 border-2 border-red-200 rounded-xl p-4 relative shadow-sm">
|
||||
<div class="flex items-start gap-3">
|
||||
<el-icon class="text-red-500 mt-0.5 flex-shrink-0">
|
||||
<WarningFilled />
|
||||
</el-icon>
|
||||
<div class="flex-1 text-sm text-red-800 max-h-40 overflow-y-auto custom-scrollbar break-words text-left leading-relaxed">
|
||||
{{ currentTask.errorInfo || '未知错误,请稍后重试' }}
|
||||
</div>
|
||||
</div>
|
||||
<el-button
|
||||
class="absolute top-2 right-2"
|
||||
size="small"
|
||||
circle
|
||||
type="danger"
|
||||
:icon="CopyDocument"
|
||||
title="复制错误信息"
|
||||
@click="copyPrompt(currentTask.errorInfo || '未知错误')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Download Button Overlay -->
|
||||
<div v-if="currentTask.storeUrl" class="absolute bottom-4 right-4 opacity-0 group-hover:opacity-100 transition-opacity z-10">
|
||||
<el-button circle type="primary" :icon="Download" @click="downloadImage(currentTask.storeUrl)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Info -->
|
||||
<div class="w-full md:w-[300px] flex flex-col gap-4 overflow-hidden">
|
||||
<div class="flex-1 flex flex-col min-h-0">
|
||||
<h3 class="font-bold text-gray-800 mb-2 flex items-center gap-2 shrink-0">
|
||||
<el-icon><MagicStick /></el-icon> 提示词
|
||||
</h3>
|
||||
<div class="bg-gray-50 p-4 rounded-lg border border-gray-100 text-sm text-gray-600 leading-relaxed relative group/prompt overflow-y-auto custom-scrollbar flex-1">
|
||||
{{ currentTask.prompt }}
|
||||
<el-button
|
||||
class="absolute top-2 right-2 opacity-0 group-hover/prompt:opacity-100 transition-opacity shadow-sm z-10"
|
||||
size="small"
|
||||
circle
|
||||
:icon="CopyDocument"
|
||||
title="复制提示词"
|
||||
@click="copyPrompt(currentTask.prompt)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-auto space-y-3 pt-4 border-t border-gray-100 shrink-0">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-500">创建时间</span>
|
||||
<span class="text-gray-800">{{ formatTime(currentTask.creationTime) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-500">状态</span>
|
||||
<el-tag size="small" type="success">
|
||||
生成成功
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2 mt-2">
|
||||
<el-button
|
||||
type="primary"
|
||||
:icon="MagicStick"
|
||||
@click="$emit('use-prompt', currentTask.prompt)"
|
||||
>
|
||||
使用提示词
|
||||
</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
plain
|
||||
:icon="Picture"
|
||||
@click="$emit('use-reference', currentTask.storeUrl)"
|
||||
>
|
||||
做参考图
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- Global Image Viewer -->
|
||||
<el-image-viewer
|
||||
v-if="showViewer && previewUrl"
|
||||
:url-list="[previewUrl]"
|
||||
@close="closeViewer"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user