Files
Yi.Framework/Yi.Ai.Vue3/src/pages/chat/image/components/MyImages.vue
2026-01-03 22:07:20 +08:00

750 lines
24 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 { InputInstance } from 'element-plus';
import type { TaskItem, TaskListRequest } from '@/api/aiImage/types';
import { Check, CircleCloseFilled, CollectionTag, CopyDocument, Download, Filter, Loading, MagicStick, Picture, Refresh, Search, Share, 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, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue';
import { getMyTasks, publishImage } 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(20);
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);
});
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: '',
TaskStatus: '' as 'Processing' | 'Success' | 'Fail' | '',
PublishStatus: '' as 'Unpublished' | 'Published' | '',
});
const dateRange = ref<[string, string] | null>(null);
// Publish Dialog State
const publishDialogVisible = ref(false);
const publishing = ref(false);
const publishTags = ref<string[]>([]);
const inputValue = ref('');
const inputVisible = ref(false);
const InputRef = ref<InputInstance>();
const taskToPublish = ref<TaskItem | null>(null);
const isAnonymousPublish = ref(false);
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 - 1) * pageSize.value,
MaxResultCount: pageSize.value,
Prompt: searchForm.Prompt || undefined,
TaskStatus: searchForm.TaskStatus || undefined,
PublishStatus: searchForm.PublishStatus || undefined,
StartTime: dateRange.value ? dateRange.value[0] : undefined,
EndTime: dateRange.value ? dateRange.value[1] : undefined,
};
const res = await getMyTasks(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 {
// Avoid duplicates if any
const newItems = items.filter((item: TaskItem) => !taskList.value.some(t => t.id === item.id));
taskList.value.push(...newItems);
}
// Check if we reached total
if (taskList.value.length >= total) {
noMore.value = true;
}
if (items.length > 0) {
pageIndex.value++;
}
else {
noMore.value = true;
}
}
catch (error) {
console.error(error);
// Stop trying if error occurs to prevent loop
noMore.value = true;
}
finally {
loading.value = false;
}
}
function handleSearch() {
pageIndex.value = 1;
taskList.value = [];
noMore.value = false;
loadMore();
}
function handleReset() {
searchForm.Prompt = '';
searchForm.TaskStatus = '';
searchForm.PublishStatus = '';
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('提示词已复制');
}
function openPublishDialog(task: TaskItem) {
taskToPublish.value = task;
publishTags.value = [];
inputValue.value = '';
inputVisible.value = false;
isAnonymousPublish.value = false;
publishDialogVisible.value = true;
}
function handleCloseTag(tag: string) {
publishTags.value.splice(publishTags.value.indexOf(tag), 1);
}
function showInput() {
inputVisible.value = true;
nextTick(() => {
InputRef.value!.input!.focus();
});
}
function handleInputConfirm() {
if (inputValue.value) {
if (!publishTags.value.includes(inputValue.value)) {
publishTags.value.push(inputValue.value);
}
inputValue.value = '';
// 保持焦点
nextTick(() => {
InputRef.value?.focus();
});
}
}
async function confirmPublish() {
if (!taskToPublish.value)
return;
publishing.value = true;
try {
await publishImage({
taskId: taskToPublish.value.id,
categories: publishTags.value,
isAnonymous: isAnonymousPublish.value,
});
ElMessage.success('发布成功');
taskToPublish.value.publishStatus = 'Published';
publishDialogVisible.value = false;
}
catch (e) {
console.error(e);
ElMessage.error('发布失败');
}
finally {
publishing.value = false;
}
}
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: () => {
handleSearch();
},
});
// Watch for filter changes - debounce for prompt, immediate for others
let searchTimer: ReturnType<typeof setTimeout> | null = null;
watch(() => searchForm.Prompt, () => {
if (searchTimer) {
clearTimeout(searchTimer);
}
searchTimer = setTimeout(() => {
handleSearch();
}, 500);
});
watch([() => searchForm.TaskStatus, () => searchForm.PublishStatus, dateRange], () => {
handleSearch();
});
</script>
<template>
<div class="h-full flex flex-col md:flex-row bg-gray-50">
<!-- 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-select
v-model="searchForm.TaskStatus"
placeholder="全部状态"
class="w-full"
clearable
>
<el-option label="进行中" value="Processing">
<div class="flex items-center gap-2">
<el-icon class="text-blue-500">
<Loading />
</el-icon>
<span>进行中</span>
</div>
</el-option>
<el-option label="成功" value="Success">
<div class="flex items-center gap-2">
<el-icon class="text-green-500">
<Check />
</el-icon>
<span>成功</span>
</div>
</el-option>
<el-option label="失败" value="Fail">
<div class="flex items-center gap-2">
<el-icon class="text-red-500">
<CircleCloseFilled />
</el-icon>
<span>失败</span>
</div>
</el-option>
</el-select>
</el-form-item>
<el-form-item label="发布状态">
<el-select
v-model="searchForm.PublishStatus"
placeholder="全部状态"
class="w-full"
clearable
>
<el-option label="未发布" value="Unpublished" />
<el-option label="已发布" value="Published" />
</el-select>
</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 refresh-btn" @click="handleReset">
<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-select
v-model="searchForm.TaskStatus"
placeholder="全部状态"
class="w-full"
clearable
>
<el-option label="进行中" value="Processing" />
<el-option label="成功" value="Success" />
<el-option label="失败" value="Fail" />
</el-select>
</el-form-item>
<el-form-item label="发布状态">
<el-select
v-model="searchForm.PublishStatus"
placeholder="全部状态"
class="w-full"
clearable
>
<el-option label="未发布" value="Unpublished" />
<el-option label="已发布" value="Published" />
</el-select>
</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>
<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" @click="handleReset(); 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"
show-publish-status
@click="handleCardClick(task)"
@use-prompt="$emit('use-prompt', $event)"
@use-reference="$emit('use-reference', $event)"
@publish="openPublishDialog(task)"
@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="copyErrorInfo(currentTask.errorInfo || '未知错误')"
/>
</div>
</div>
</div>
<!-- Download Button Overlay -->
<div v-if="currentTask.storeUrl" class="absolute bottom-4 right-4 flex 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>
</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 class="text-gray-800">{{ formatTime(currentTask.creationTime) }}</span>
</div>
<div class="flex justify-between text-sm items-center">
<span class="text-gray-500">任务状态</span>
<el-tag v-if="currentTask.taskStatus === 'Success'" size="small" type="success">
成功
</el-tag>
<el-tag v-else-if="currentTask.taskStatus === 'Processing'" size="small" type="primary">
进行中
</el-tag>
<el-tag v-else size="small" type="danger">
失败
</el-tag>
</div>
<!-- 发布状态 -->
<div v-if="currentTask.publishStatus === 'Published'" class="flex justify-between text-sm items-center">
<span class="text-gray-500">发布状态</span>
<div class="flex items-center gap-1">
<el-tag size="small" type="warning" effect="dark">已发布</el-tag>
<span v-if="!currentTask.isAnonymous && currentTask.userName" class="text-blue-500 text-xs flex items-center gap-1">
<el-icon><User /></el-icon> {{ currentTask.userName }}
</span>
<span v-else class="text-gray-400 text-xs flex items-center gap-1">
<el-icon><User /></el-icon> 匿名
</span>
</div>
</div>
<div v-else class="flex justify-between text-sm items-center">
<span class="text-gray-500">发布状态</span>
<el-tag size="small" type="info" effect="plain">未发布</el-tag>
</div>
<div v-if="currentTask.taskStatus === 'Success'" class="pt-2 space-y-2">
<div class="grid grid-cols-1 gap-1">
<el-button
type="primary"
plain
: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>
<el-button
v-if="currentTask.publishStatus === 'Unpublished'"
type="success"
class="w-full"
:icon="Share"
@click="openPublishDialog(currentTask)"
>
发布到广场
</el-button>
<el-button v-else type="info" disabled class="w-full">
已发布
</el-button>
</div>
</div>
</div>
</div>
</el-dialog>
<!-- Publish Dialog -->
<el-dialog
v-model="publishDialogVisible"
title="发布到广场"
width="500px"
append-to-body
align-center
>
<el-form label-position="top">
<el-form-item label="标签 (回车添加)">
<div class="flex gap-2 flex-wrap w-full p-2 border border-gray-200 rounded-md min-h-[40px]">
<el-tag
v-for="tag in publishTags"
:key="tag"
closable
:disable-transitions="false"
@close="handleCloseTag(tag)"
>
{{ tag }}
</el-tag>
<el-input
v-if="inputVisible"
ref="InputRef"
v-model="inputValue"
class="w-24"
size="small"
@keydown.enter.prevent="handleInputConfirm"
@blur="handleInputConfirm"
/>
<el-button v-else class="button-new-tag" size="small" @click="showInput">
+ New Tag
</el-button>
</div>
</el-form-item>
<el-form-item>
<el-checkbox v-model="isAnonymousPublish" label="匿名发布" />
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="publishDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="publishing" @click="confirmPublish">
发布
</el-button>
</span>
</template>
</el-dialog>
<!-- Global Image Viewer -->
<el-image-viewer
v-if="showViewer && previewUrl"
:url-list="[previewUrl]"
@close="closeViewer"
/>
</div>
</template>
<style scoped>
.refresh-btn {
margin: 20px 0;
}
.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>