Files
Yi.Framework/Yi.Ai.Vue3/src/pages/chat/image/components/ImagePlaza.vue
2026-01-04 21:15:41 +08:00

659 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import type { TaskItem, TaskListRequest } from '@/api/aiImage/types';
import {
CircleCloseFilled,
CollectionTag,
CopyDocument,
Download,
Filter,
Loading,
MagicStick,
Picture,
Refresh,
Search,
User,
WarningFilled,
ZoomIn,
} from '@element-plus/icons-vue';
import { useClipboard } from '@vueuse/core';
import { format } from 'date-fns';
import { ElMessage } from 'element-plus';
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue';
import { getImagePlaza } from '@/api/aiImage';
import TaskCard from './TaskCard.vue';
const emit = defineEmits(['use-prompt', 'use-reference']);
const taskList = ref<TaskItem[]>([]);
const pageIndex = ref(1);
const pageSize = ref(10);
const loading = ref(false);
const noMore = ref(false);
const dialogVisible = ref(false);
const currentTask = ref<TaskItem | null>(null);
// Mobile detection
const isMobile = ref(false);
function checkMobile() {
isMobile.value = window.innerWidth < 768;
}
onMounted(() => {
checkMobile();
window.addEventListener('resize', checkMobile);
loadMore();
});
onUnmounted(() => {
window.removeEventListener('resize', checkMobile);
});
// Mobile filter drawer
const showMobileFilter = ref(false);
// Viewer State
const showViewer = ref(false);
const previewUrl = ref('');
// Filter State
const searchForm = reactive({
Prompt: '',
Categories: '',
UserName: '',
OrderByColumn: '',
IsAscending: true,
});
const dateRange = ref<[string, string] | null>(null);
const { copy } = useClipboard();
const disabled = computed(() => loading.value || noMore.value);
async function loadMore() {
if (loading.value || noMore.value)
return;
loading.value = true;
try {
const params: TaskListRequest = {
SkipCount: pageIndex.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,
OrderByColumn: searchForm.OrderByColumn || undefined,
IsAscending: searchForm.IsAscending,
};
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 (pageIndex.value === 1) {
taskList.value = items;
}
else {
// Avoid duplicates
const newItems = items.filter((item: TaskItem) => !taskList.value.some(t => t.id === item.id));
taskList.value.push(...newItems);
}
// Check if we should stop loading more
if (items.length === 0 || items.length < pageSize.value || taskList.value.length >= total) {
noMore.value = true;
}
else {
// Increment pageIndex for next load only if there's more data
pageIndex.value++;
}
}
catch (error) {
console.error(error);
noMore.value = true;
}
finally {
loading.value = false;
}
}
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;
}
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) {
return time;
}
}
async function copyPrompt(text: string) {
await copy(text);
ElMessage.success('已复制到剪贴板');
}
async function downloadImage(url: string) {
try {
const response = await fetch(url);
const blob = await response.blob();
const blobUrl = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = blobUrl;
link.download = `image-${Date.now()}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(blobUrl);
}
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>
<template>
<div class="h-full flex flex-col md:flex-row bg-gray-50 overflow-hidden">
<!-- Mobile Filter Button -->
<div class="md:hidden p-4 bg-white border-b border-gray-200 flex items-center justify-between">
<h2 class="text-lg font-bold text-gray-800">
图片广场
</h2>
<el-button type="primary" @click="showMobileFilter = true">
<el-icon class="mr-1">
<Filter />
</el-icon>
筛选
</el-button>
</div>
<!-- Left Sidebar - Filters (Desktop) -->
<div class="hidden md:flex w-72 bg-white border-r border-gray-200 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-item v-if="false" label="排序方式">
<el-select
v-model="searchForm.OrderByColumn"
placeholder="默认排序"
class="w-full"
clearable
>
<el-option label="创建时间" value="CreationTime" />
</el-select>
</el-form-item>
<el-form-item v-if="searchForm.OrderByColumn" label="排序方向">
<el-radio-group v-model="searchForm.IsAscending">
<el-radio :value="true">
升序
</el-radio>
<el-radio :value="false">
降序
</el-radio>
</el-radio-group>
</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 refresh-btn" @click="handleReset">
<el-icon class="mr-1">
<Refresh />
</el-icon>
重置
</el-button>
<el-button class="w-full refresh-btn" type="success" @click="handleSearch">
<el-icon class="mr-1">
<Refresh />
</el-icon>
刷新
</el-button>
</div>
</div>
<!-- Mobile Filter Drawer -->
<el-drawer
v-model="showMobileFilter"
title="筛选条件"
direction="ltr"
size="80%"
>
<el-form :model="searchForm" label-position="top" class="space-y-4">
<el-form-item label="提示词">
<el-input
v-model="searchForm.Prompt"
placeholder="搜索提示词..."
clearable
>
<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
>
<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
>
<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-item label="排序方式">
<el-select
v-model="searchForm.OrderByColumn"
placeholder="默认排序"
class="w-full"
clearable
>
<el-option label="创建时间" value="CreationTime" />
</el-select>
</el-form-item>
<el-form-item v-if="searchForm.OrderByColumn" label="排序方向">
<el-radio-group v-model="searchForm.IsAscending">
<el-radio :value="true">
升序
</el-radio>
<el-radio :value="false">
降序
</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<div class="space-y-2">
<el-button type="primary" class="w-full" @click="handleSearch(); showMobileFilter = false">
<el-icon class="mr-1">
<Search />
</el-icon>
搜索
</el-button>
<el-button class="w-full refresh-btn" @click="handleReset(); showMobileFilter = false">
<el-icon class="mr-1">
<Refresh />
</el-icon>
重置
</el-button>
<el-button class="w-full refresh-btn" type="success" @click="handleSearch(); showMobileFilter = false">
<el-icon class="mr-1">
<Refresh />
</el-icon>
刷新
</el-button>
</div>
</template>
</el-drawer>
<!-- Right Content Area -->
<div class="flex-1 flex flex-col overflow-hidden">
<div
v-infinite-scroll="loadMore"
class="flex-1 overflow-y-auto p-4 md:p-6 custom-scrollbar"
:infinite-scroll-disabled="disabled"
:infinite-scroll-distance="50"
>
<div v-if="taskList.length > 0" class="grid grid-cols-1 sm: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="isMobile ? '95%' : '900px'"
:fullscreen="isMobile"
append-to-body
class="image-detail-dialog"
align-center
>
<div v-if="currentTask" class="flex flex-col md:flex-row gap-4 md:gap-6 h-auto md: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>
<!-- Desktop Button Overlay -->
<div v-if="currentTask.storeUrl" class="hidden md:flex absolute bottom-4 right-4 gap-2 opacity-0 group-hover:opacity-100 transition-opacity z-10">
<el-button circle type="primary" :icon="ZoomIn" @click="handlePreview(currentTask.storeUrl)" />
<el-button circle type="primary" :icon="Download" @click="downloadImage(currentTask.storeUrl)" />
</div>
<!-- Mobile Button Overlay -->
<div v-if="currentTask.storeUrl" class="md:hidden absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-3 z-10">
<div class="flex items-center justify-center gap-2">
<el-button circle type="primary" :icon="ZoomIn" @click="handlePreview(currentTask.storeUrl)" />
<el-button circle type="primary" :icon="Download" @click="downloadImage(currentTask.storeUrl)" />
</div>
</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 v-if="currentTask.categories && currentTask.categories.length > 0" class="space-y-2">
<div class="flex items-center gap-2 text-sm text-gray-500">
<el-icon><CollectionTag /></el-icon>
<span>分类标签</span>
</div>
<div class="flex flex-wrap gap-1">
<el-tag
v-for="tag in currentTask.categories"
:key="tag"
size="small"
type="info"
effect="plain"
>
{{ tag }}
</el-tag>
</div>
</div>
<!-- 用户信息 -->
<div class="flex justify-between text-sm">
<span class="text-gray-500">创建者</span>
<span v-if="!currentTask.isAnonymous && currentTask.userName" class="text-blue-500 flex items-center gap-1">
<el-icon><User /></el-icon> {{ currentTask.userName }}
</span>
<span v-else class="text-gray-400 flex items-center gap-1">
<el-icon><User /></el-icon> 匿名用户
</span>
</div>
<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-1 gap-1 mt-2">
<el-button
type="primary"
:icon="MagicStick"
@click="$emit('use-prompt', currentTask.prompt)"
>
使用提示词
</el-button>
<el-button
v-if="false"
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>
<style lang="scss" scoped>
.refresh-btn {
margin: 20px 0;
}
</style>