feat: 图片广场优化
This commit is contained in:
@@ -14,12 +14,23 @@ export interface TaskStatusResponse {
|
|||||||
publishStatus: string;
|
publishStatus: string;
|
||||||
categories: string[];
|
categories: string[];
|
||||||
creationTime: string;
|
creationTime: string;
|
||||||
|
errorInfo?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TaskListRequest {
|
export interface TaskListRequest {
|
||||||
PageIndex: number;
|
SkipCount: number;
|
||||||
PageSize: number;
|
MaxResultCount: number;
|
||||||
TaskStatus?: 'Processing' | 'Success' | 'Fail';
|
TaskStatus?: 'Processing' | 'Success' | 'Fail';
|
||||||
|
Prompt?: string;
|
||||||
|
PublishStatus?: 'Unpublished' | 'Published';
|
||||||
|
StartTime?: string;
|
||||||
|
EndTime?: string;
|
||||||
|
OrderByColumn?: string;
|
||||||
|
IsAsc?: string;
|
||||||
|
IsAscending?: boolean;
|
||||||
|
Sorting?: string;
|
||||||
|
Categories?: string;
|
||||||
|
UserName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TaskItem {
|
export interface TaskItem {
|
||||||
@@ -31,6 +42,10 @@ export interface TaskItem {
|
|||||||
publishStatus: string;
|
publishStatus: string;
|
||||||
categories: string[];
|
categories: string[];
|
||||||
creationTime: string;
|
creationTime: string;
|
||||||
|
errorInfo?: string;
|
||||||
|
isAnonymous?: boolean;
|
||||||
|
userName?: string | null;
|
||||||
|
userId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TaskListResponse {
|
export interface TaskListResponse {
|
||||||
@@ -41,6 +56,7 @@ export interface TaskListResponse {
|
|||||||
export interface PublishImageRequest {
|
export interface PublishImageRequest {
|
||||||
taskId: string;
|
taskId: string;
|
||||||
categories: string[];
|
categories: string[];
|
||||||
|
isAnonymous?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ImageModel {
|
export interface ImageModel {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { UploadFile, UploadUserFile } from 'element-plus';
|
|||||||
import type { ImageModel, TaskStatusResponse } from '@/api/aiImage/types';
|
import type { ImageModel, TaskStatusResponse } from '@/api/aiImage/types';
|
||||||
import {
|
import {
|
||||||
CircleCloseFilled,
|
CircleCloseFilled,
|
||||||
|
CopyDocument,
|
||||||
Delete,
|
Delete,
|
||||||
Download,
|
Download,
|
||||||
MagicStick,
|
MagicStick,
|
||||||
@@ -11,6 +12,7 @@ import {
|
|||||||
Refresh,
|
Refresh,
|
||||||
ZoomIn,
|
ZoomIn,
|
||||||
} from '@element-plus/icons-vue';
|
} from '@element-plus/icons-vue';
|
||||||
|
import { useClipboard } from '@vueuse/core';
|
||||||
import { ElMessage } from 'element-plus';
|
import { ElMessage } from 'element-plus';
|
||||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||||
import { getSelectableTokenInfo } from '@/api';
|
import { getSelectableTokenInfo } from '@/api';
|
||||||
@@ -25,6 +27,8 @@ const props = defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits(['task-created']);
|
const emit = defineEmits(['task-created']);
|
||||||
|
|
||||||
|
const { copy } = useClipboard();
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const tokenOptions = ref<any[]>([]);
|
const tokenOptions = ref<any[]>([]);
|
||||||
const selectedTokenId = ref('');
|
const selectedTokenId = ref('');
|
||||||
@@ -292,6 +296,11 @@ async function downloadImage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function copyError(text: string) {
|
||||||
|
await copy(text);
|
||||||
|
ElMessage.success('错误信息已复制');
|
||||||
|
}
|
||||||
|
|
||||||
// Exposed methods for external control
|
// Exposed methods for external control
|
||||||
function setPrompt(text: string) {
|
function setPrompt(text: string) {
|
||||||
prompt.value = text;
|
prompt.value = text;
|
||||||
@@ -351,7 +360,7 @@ onUnmounted(() => {
|
|||||||
配置
|
配置
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<el-form label-position="top" class="space-y-2" label-width="auto">
|
<el-form label-position="top" class="space-y-2">
|
||||||
<!-- Token -->
|
<!-- Token -->
|
||||||
<el-form-item label="API密钥 (可选)">
|
<el-form-item label="API密钥 (可选)">
|
||||||
<el-select
|
<el-select
|
||||||
@@ -385,18 +394,18 @@ onUnmounted(() => {
|
|||||||
:label="model.modelName"
|
:label="model.modelName"
|
||||||
:value="model.modelId"
|
:value="model.modelId"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col py-1">
|
<div class="flex flex-col py-1 max-w-[350px]">
|
||||||
<span class="font-medium">{{ model.modelName }}</span>
|
<span class="font-medium truncate">{{ model.modelName }}</span>
|
||||||
<span class="text-xs text-gray-400 truncate">{{ model.modelDescribe }}</span>
|
<span class="text-xs text-gray-400 truncate" :title="model.modelDescribe">{{ model.modelDescribe }}</span>
|
||||||
</div>
|
</div>
|
||||||
</el-option>
|
</el-option>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<!-- Prompt -->
|
<!-- Prompt -->
|
||||||
<el-form-item label="提示词" required class="prompt-form-item ">
|
<el-form-item label="提示词" required>
|
||||||
<template #label>
|
<template #label>
|
||||||
<div class="flex justify-between items-center w-full ">
|
<div class="flex justify-between items-center w-full">
|
||||||
<span>提示词</span>
|
<span>提示词</span>
|
||||||
<el-button link type="primary" size="small" @click="clearPrompt">
|
<el-button link type="primary" size="small" @click="clearPrompt">
|
||||||
<el-icon class="mr-1">
|
<el-icon class="mr-1">
|
||||||
@@ -408,7 +417,7 @@ onUnmounted(() => {
|
|||||||
<el-input
|
<el-input
|
||||||
v-model="prompt"
|
v-model="prompt"
|
||||||
type="textarea"
|
type="textarea"
|
||||||
:autosize="{ minRows: 8, maxRows: 8 }"
|
:autosize="{ minRows: 8, maxRows: 15 }"
|
||||||
placeholder="描述你想要生成的画面,例如:一只在太空中飞行的赛博朋克风格的猫..."
|
placeholder="描述你想要生成的画面,例如:一只在太空中飞行的赛博朋克风格的猫..."
|
||||||
maxlength="2000"
|
maxlength="2000"
|
||||||
show-word-limit
|
show-word-limit
|
||||||
@@ -486,6 +495,8 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<el-image-viewer
|
<el-image-viewer
|
||||||
v-if="showViewer"
|
v-if="showViewer"
|
||||||
|
|
||||||
|
fit="contain"
|
||||||
:url-list="[currentTask.storeUrl]"
|
:url-list="[currentTask.storeUrl]"
|
||||||
@close="showViewer = false"
|
@close="showViewer = false"
|
||||||
/>
|
/>
|
||||||
@@ -503,17 +514,27 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Fail State -->
|
<!-- Fail State -->
|
||||||
<div v-else-if="currentTask.taskStatus === 'Fail'" class="text-center text-red-500">
|
<div v-else-if="currentTask.taskStatus === 'Fail'" class="text-center text-red-500 w-full max-w-lg">
|
||||||
<el-icon class="text-6xl mb-4">
|
<el-icon class="text-6xl mb-4">
|
||||||
<CircleCloseFilled />
|
<CircleCloseFilled />
|
||||||
</el-icon>
|
</el-icon>
|
||||||
<p class="text-lg font-medium">
|
<p class="text-lg font-medium mb-2">
|
||||||
生成失败
|
生成失败
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm opacity-80 mt-1">
|
<div class="bg-red-50 p-4 rounded-lg border border-red-100 text-sm text-left relative group/error">
|
||||||
{{ currentTask.errorInfo || '请检查提示词或稍后重试' }}
|
<div class="max-h-32 overflow-y-auto custom-scrollbar break-words pr-6">
|
||||||
</p>
|
{{ currentTask.errorInfo || '请检查提示词或稍后重试' }}
|
||||||
<el-button class="mt-4" icon="Refresh" @click="handleGenerate">
|
</div>
|
||||||
|
<el-button
|
||||||
|
class="absolute top-2 right-2 opacity-0 group-hover/error:opacity-100 transition-opacity"
|
||||||
|
size="small"
|
||||||
|
circle
|
||||||
|
:icon="CopyDocument"
|
||||||
|
title="复制错误信息"
|
||||||
|
@click="copyError(currentTask.errorInfo || '')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<el-button class="mt-6" icon="Refresh" @click="handleGenerate">
|
||||||
重试
|
重试
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -594,8 +615,4 @@ onUnmounted(() => {
|
|||||||
:deep(.hide-upload-btn .el-upload--picture-card) {
|
:deep(.hide-upload-btn .el-upload--picture-card) {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
/* 隐藏默认的标签 */
|
|
||||||
:deep(.prompt-form-item .el-form-item__label){
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -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">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue';
|
import type { TaskItem, TaskListRequest } from '@/api/aiImage/types';
|
||||||
import { getImagePlaza } from '@/api/aiImage';
|
import { CircleCloseFilled, CollectionTag, CopyDocument, Download, Filter, Loading, MagicStick, Picture, Refresh, Search, User, WarningFilled } from '@element-plus/icons-vue';
|
||||||
import type { TaskItem } from '@/api/aiImage/types';
|
import { useClipboard } from '@vueuse/core';
|
||||||
import TaskCard from './TaskCard.vue';
|
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { ElMessage } from 'element-plus';
|
import { ElMessage } from 'element-plus';
|
||||||
import { useClipboard } from '@vueuse/core';
|
import { computed, reactive, ref, watch } from 'vue';
|
||||||
import { Picture, Loading, MagicStick, CopyDocument, Download } from '@element-plus/icons-vue';
|
import { getImagePlaza } from '@/api/aiImage';
|
||||||
|
import TaskCard from './TaskCard.vue';
|
||||||
|
|
||||||
const emit = defineEmits(['use-prompt', 'use-reference']);
|
const emit = defineEmits(['use-prompt', 'use-reference']);
|
||||||
|
|
||||||
@@ -134,33 +18,54 @@ const noMore = ref(false);
|
|||||||
const dialogVisible = ref(false);
|
const dialogVisible = ref(false);
|
||||||
const currentTask = ref<TaskItem | null>(null);
|
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 { copy } = useClipboard();
|
||||||
|
|
||||||
const disabled = computed(() => loading.value || noMore.value);
|
const disabled = computed(() => loading.value || noMore.value);
|
||||||
|
|
||||||
const loadMore = async () => {
|
async function loadMore() {
|
||||||
if (loading.value || noMore.value) return;
|
if (loading.value || noMore.value)
|
||||||
|
return;
|
||||||
|
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const res = await getImagePlaza({
|
const params: TaskListRequest = {
|
||||||
PageIndex: pageIndex.value,
|
SkipCount: (pageIndex.value - 1) * pageSize.value,
|
||||||
PageSize: pageSize.value,
|
MaxResultCount: pageSize.value,
|
||||||
TaskStatus: 'Success'
|
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
|
// Handle potential wrapper
|
||||||
const data = (res as any).data || res;
|
const data = (res as any).data || res;
|
||||||
const items = data.items || [];
|
const items = data.items || [];
|
||||||
const total = data.total || 0;
|
const total = data.total || 0;
|
||||||
|
|
||||||
if (items.length < pageSize.value) {
|
if (items.length < pageSize.value) {
|
||||||
noMore.value = true;
|
noMore.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pageIndex.value === 1) {
|
if (pageIndex.value === 1) {
|
||||||
taskList.value = items;
|
taskList.value = items;
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
// Avoid duplicates
|
// Avoid duplicates
|
||||||
const newItems = items.filter((item: TaskItem) => !taskList.value.some(t => t.id === item.id));
|
const newItems = items.filter((item: TaskItem) => !taskList.value.some(t => t.id === item.id));
|
||||||
taskList.value.push(...newItems);
|
taskList.value.push(...newItems);
|
||||||
@@ -170,39 +75,67 @@ const loadMore = async () => {
|
|||||||
if (taskList.value.length >= total) {
|
if (taskList.value.length >= total) {
|
||||||
noMore.value = true;
|
noMore.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (items.length > 0) {
|
if (items.length > 0) {
|
||||||
pageIndex.value++;
|
pageIndex.value++;
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
noMore.value = true;
|
noMore.value = true;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
}
|
||||||
|
catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
noMore.value = true;
|
noMore.value = true;
|
||||||
} finally {
|
}
|
||||||
|
finally {
|
||||||
loading.value = false;
|
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;
|
currentTask.value = task;
|
||||||
dialogVisible.value = true;
|
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 {
|
try {
|
||||||
return format(new Date(time), 'yyyy-MM-dd HH:mm');
|
return format(new Date(time), 'yyyy-MM-dd HH:mm');
|
||||||
} catch (e) {
|
}
|
||||||
|
catch (e) {
|
||||||
return time;
|
return time;
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const copyPrompt = async (text: string) => {
|
async function copyPrompt(text: string) {
|
||||||
await copy(text);
|
await copy(text);
|
||||||
ElMessage.success('提示词已复制');
|
ElMessage.success('已复制到剪贴板');
|
||||||
};
|
}
|
||||||
|
|
||||||
const downloadImage = async (url: string) => {
|
async function downloadImage(url: string) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
@@ -214,22 +147,290 @@ const downloadImage = async (url: string) => {
|
|||||||
link.click();
|
link.click();
|
||||||
document.body.removeChild(link);
|
document.body.removeChild(link);
|
||||||
window.URL.revokeObjectURL(blobUrl);
|
window.URL.revokeObjectURL(blobUrl);
|
||||||
} catch (e) {
|
}
|
||||||
|
catch (e) {
|
||||||
console.error('Download failed', e);
|
console.error('Download failed', e);
|
||||||
window.open(url, '_blank');
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<template>
|
||||||
.custom-scrollbar::-webkit-scrollbar {
|
<div class="h-full flex bg-gray-50">
|
||||||
width: 6px;
|
<!-- Left Sidebar - Filters -->
|
||||||
}
|
<div class="w-72 bg-white border-r border-gray-200 flex flex-col shadow-sm">
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
<div class="p-6 border-b border-gray-100">
|
||||||
background-color: #e5e7eb;
|
<h2 class="text-lg font-bold text-gray-800 flex items-center gap-2">
|
||||||
border-radius: 3px;
|
<el-icon><Filter /></el-icon>
|
||||||
}
|
筛选条件
|
||||||
.custom-scrollbar::-webkit-scrollbar-track {
|
</h2>
|
||||||
background-color: transparent;
|
</div>
|
||||||
}
|
|
||||||
</style>
|
<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>
|
||||||
|
|||||||
@@ -1,49 +1,405 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { InputInstance } from 'element-plus';
|
||||||
|
import type { TaskItem, TaskListRequest } from '@/api/aiImage/types';
|
||||||
|
import { Check, CircleCloseFilled, CopyDocument, Download, Filter, Loading, MagicStick, Picture, Refresh, Search, Share, WarningFilled } from '@element-plus/icons-vue';
|
||||||
|
import { useClipboard } from '@vueuse/core';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { ElMessage } from 'element-plus';
|
||||||
|
import { computed, nextTick, 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);
|
||||||
|
|
||||||
|
// 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>
|
<template>
|
||||||
<div class="h-full flex flex-col bg-gray-50">
|
<div class="h-full flex bg-gray-50">
|
||||||
<div
|
<!-- Left Sidebar - Filters -->
|
||||||
class="flex-1 overflow-y-auto p-4 custom-scrollbar"
|
<div class="w-72 bg-white border-r border-gray-200 flex flex-col shadow-sm">
|
||||||
v-infinite-scroll="loadMore"
|
<div class="p-6 border-b border-gray-100">
|
||||||
:infinite-scroll-disabled="disabled"
|
<h2 class="text-lg font-bold text-gray-800 flex items-center gap-2">
|
||||||
:infinite-scroll-distance="50"
|
<el-icon><Filter /></el-icon>
|
||||||
>
|
筛选条件
|
||||||
<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">
|
</h2>
|
||||||
<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)"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Empty State -->
|
<div class=" overflow-y-auto p-6 custom-scrollbar">
|
||||||
<div v-else-if="!loading && taskList.length === 0" class="h-full flex flex-col items-center justify-center text-gray-400">
|
<el-form :model="searchForm" label-position="top" class="space-y-4">
|
||||||
<el-icon class="text-6xl mb-4"><Picture /></el-icon>
|
<el-form-item label="提示词">
|
||||||
<p>您还没有生成过图片</p>
|
<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>
|
||||||
|
|
||||||
<!-- Loading State -->
|
<div class="p-6 border-t border-gray-100 space-y-2">
|
||||||
<div v-if="loading" class="py-6 flex justify-center">
|
<el-button type="primary" class="w-full" @click="handleSearch">
|
||||||
<div class="flex items-center gap-2 text-gray-500">
|
<el-icon class="mr-1">
|
||||||
<el-icon class="is-loading"><Loading /></el-icon>
|
<Search />
|
||||||
<span>加载中...</span>
|
</el-icon>
|
||||||
</div>
|
搜索
|
||||||
</div>
|
</el-button>
|
||||||
|
<el-button class="w-full" @click="handleReset">
|
||||||
<!-- No More State -->
|
<el-icon class="mr-1">
|
||||||
<div v-if="noMore && taskList.length > 0" class="py-6 text-center text-gray-400 text-sm">
|
<Refresh />
|
||||||
- 到底了,没有更多图片了 -
|
</el-icon>
|
||||||
|
重置
|
||||||
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</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"
|
||||||
|
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 -->
|
<!-- Dialog for details -->
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="dialogVisible"
|
v-model="dialogVisible"
|
||||||
title="图片详情"
|
title="图片详情"
|
||||||
width="900px"
|
width="900px"
|
||||||
append-to-body
|
append-to-body
|
||||||
class="image-detail-dialog"
|
class="image-detail-dialog"
|
||||||
align-center
|
align-center
|
||||||
@@ -51,28 +407,51 @@
|
|||||||
<div v-if="currentTask" class="flex flex-col md:flex-row gap-6 h-[600px]">
|
<div v-if="currentTask" class="flex flex-col md:flex-row gap-6 h-[600px]">
|
||||||
<!-- Left Image -->
|
<!-- Left Image -->
|
||||||
<div class="flex-1 bg-black/5 rounded-lg flex items-center justify-center overflow-hidden relative group">
|
<div class="flex-1 bg-black/5 rounded-lg flex items-center justify-center overflow-hidden relative group">
|
||||||
<el-image
|
<el-image
|
||||||
v-if="currentTask.storeUrl"
|
v-if="currentTask.storeUrl"
|
||||||
:src="currentTask.storeUrl"
|
:src="currentTask.storeUrl"
|
||||||
fit="contain"
|
fit="contain"
|
||||||
class="w-full h-full"
|
class="w-full h-full"
|
||||||
:preview-src-list="[currentTask.storeUrl]"
|
:preview-src-list="[currentTask.storeUrl]"
|
||||||
/>
|
/>
|
||||||
<div v-else class="flex flex-col items-center text-gray-400 p-4 text-center">
|
<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'">生成中...</span>
|
<span v-if="currentTask.taskStatus === 'Processing'" class="flex flex-col items-center">
|
||||||
<div v-else-if="currentTask.taskStatus === 'Fail'" class="text-red-500 flex flex-col items-center">
|
<el-icon class="text-4xl mb-3 is-loading text-blue-500"><Loading /></el-icon>
|
||||||
<el-icon class="text-4xl mb-2"><CircleCloseFilled /></el-icon>
|
<span class="text-blue-500 font-medium">生成中...</span>
|
||||||
<span class="font-bold mb-1">生成失败</span>
|
</span>
|
||||||
<span class="text-sm opacity-80">{{ currentTask.errorInfo || '未知错误' }}</span>
|
<div v-else-if="currentTask.taskStatus === 'Fail'" class="flex flex-col items-center w-full max-w-md px-4">
|
||||||
</div>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Download Button Overlay -->
|
<!-- Download Button Overlay -->
|
||||||
<div v-if="currentTask.storeUrl" class="absolute bottom-4 right-4 opacity-0 group-hover:opacity-100 transition-opacity z-10">
|
<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)" />
|
<el-button circle type="primary" :icon="Download" @click="downloadImage(currentTask.storeUrl)" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right Info -->
|
<!-- Right Info -->
|
||||||
<div class="w-full md:w-[300px] flex flex-col gap-4 overflow-hidden">
|
<div class="w-full md:w-[300px] flex flex-col gap-4 overflow-hidden">
|
||||||
<div class="flex-1 flex flex-col min-h-0">
|
<div class="flex-1 flex flex-col min-h-0">
|
||||||
@@ -81,17 +460,17 @@
|
|||||||
</h3>
|
</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">
|
<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 }}
|
{{ currentTask.prompt }}
|
||||||
<el-button
|
<el-button
|
||||||
class="absolute top-2 right-2 opacity-0 group-hover/prompt:opacity-100 transition-opacity shadow-sm z-10"
|
class="absolute top-2 right-2 opacity-0 group-hover/prompt:opacity-100 transition-opacity shadow-sm z-10"
|
||||||
size="small"
|
size="small"
|
||||||
circle
|
circle
|
||||||
:icon="CopyDocument"
|
:icon="CopyDocument"
|
||||||
@click="copyPrompt(currentTask.prompt)"
|
|
||||||
title="复制提示词"
|
title="复制提示词"
|
||||||
|
@click="copyPrompt(currentTask.prompt)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-auto space-y-3 pt-4 border-t border-gray-100 shrink-0">
|
<div class="mt-auto space-y-3 pt-4 border-t border-gray-100 shrink-0">
|
||||||
<div class="flex justify-between text-sm">
|
<div class="flex justify-between text-sm">
|
||||||
<span class="text-gray-500">创建时间</span>
|
<span class="text-gray-500">创建时间</span>
|
||||||
@@ -100,45 +479,53 @@
|
|||||||
<div class="flex justify-between text-sm items-center">
|
<div class="flex justify-between text-sm items-center">
|
||||||
<span class="text-gray-500">状态</span>
|
<span class="text-gray-500">状态</span>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<el-tag v-if="currentTask.taskStatus === 'Success'" size="small" type="success">成功</el-tag>
|
<el-tag v-if="currentTask.taskStatus === 'Success'" size="small" type="success">
|
||||||
<el-tag v-else-if="currentTask.taskStatus === 'Processing'" size="small" type="primary">进行中</el-tag>
|
成功
|
||||||
<el-tag v-else size="small" type="danger">失败</el-tag>
|
</el-tag>
|
||||||
|
<el-tag v-else-if="currentTask.taskStatus === 'Processing'" size="small" type="primary">
|
||||||
<el-tag v-if="currentTask.publishStatus === 'Published'" size="small" type="warning" effect="dark">已发布</el-tag>
|
进行中
|
||||||
|
</el-tag>
|
||||||
|
<el-tag v-else size="small" type="danger">
|
||||||
|
失败
|
||||||
|
</el-tag>
|
||||||
|
|
||||||
|
<el-tag v-if="currentTask.publishStatus === 'Published'" size="small" type="warning" effect="dark">
|
||||||
|
已发布
|
||||||
|
</el-tag>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="currentTask.taskStatus === 'Success'" class="pt-2 space-y-2">
|
<div v-if="currentTask.taskStatus === 'Success'" class="pt-2 space-y-2">
|
||||||
<div class="grid grid-cols-2 gap-2">
|
<div class="grid grid-cols-2 gap-2">
|
||||||
<el-button
|
<el-button
|
||||||
type="primary"
|
type="primary"
|
||||||
plain
|
plain
|
||||||
:icon="MagicStick"
|
:icon="MagicStick"
|
||||||
@click="$emit('use-prompt', currentTask.prompt)"
|
@click="$emit('use-prompt', currentTask.prompt)"
|
||||||
>
|
>
|
||||||
使用提示词
|
使用提示词
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
type="primary"
|
type="primary"
|
||||||
plain
|
plain
|
||||||
:icon="Picture"
|
:icon="Picture"
|
||||||
@click="$emit('use-reference', currentTask.storeUrl)"
|
@click="$emit('use-reference', currentTask.storeUrl)"
|
||||||
>
|
>
|
||||||
做参考图
|
做参考图
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="currentTask.publishStatus === 'Unpublished'"
|
v-if="currentTask.publishStatus === 'Unpublished'"
|
||||||
type="success"
|
type="success"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
:icon="Share"
|
:icon="Share"
|
||||||
@click="openPublishDialog(currentTask)"
|
@click="openPublishDialog(currentTask)"
|
||||||
>
|
>
|
||||||
发布到广场
|
发布到广场
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button v-else type="info" disabled class="w-full">
|
<el-button v-else type="info" disabled class="w-full">
|
||||||
已发布
|
已发布
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -171,7 +558,7 @@
|
|||||||
v-model="inputValue"
|
v-model="inputValue"
|
||||||
class="w-24"
|
class="w-24"
|
||||||
size="small"
|
size="small"
|
||||||
@keyup.enter="handleInputConfirm"
|
@keydown.enter.prevent="handleInputConfirm"
|
||||||
@blur="handleInputConfirm"
|
@blur="handleInputConfirm"
|
||||||
/>
|
/>
|
||||||
<el-button v-else class="button-new-tag" size="small" @click="showInput">
|
<el-button v-else class="button-new-tag" size="small" @click="showInput">
|
||||||
@@ -179,196 +566,29 @@
|
|||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-checkbox v-model="isAnonymousPublish" label="匿名发布" />
|
||||||
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<span class="dialog-footer">
|
<span class="dialog-footer">
|
||||||
<el-button @click="publishDialogVisible = false">取消</el-button>
|
<el-button @click="publishDialogVisible = false">取消</el-button>
|
||||||
<el-button type="primary" @click="confirmPublish" :loading="publishing">
|
<el-button type="primary" :loading="publishing" @click="confirmPublish">
|
||||||
发布
|
发布
|
||||||
</el-button>
|
</el-button>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- Global Image Viewer -->
|
||||||
|
<el-image-viewer
|
||||||
|
v-if="showViewer && previewUrl"
|
||||||
|
:url-list="[previewUrl]"
|
||||||
|
@close="closeViewer"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, nextTick } from 'vue';
|
|
||||||
import { getMyTasks, publishImage } from '@/api/aiImage';
|
|
||||||
import type { TaskItem } from '@/api/aiImage/types';
|
|
||||||
import TaskCard from './TaskCard.vue';
|
|
||||||
import { format } from 'date-fns';
|
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
|
||||||
import type { InputInstance } from 'element-plus';
|
|
||||||
import { useClipboard } from '@vueuse/core';
|
|
||||||
import { Picture, Loading, MagicStick, CopyDocument, Share, CircleCloseFilled, Download } from '@element-plus/icons-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);
|
|
||||||
|
|
||||||
// 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 { copy } = useClipboard();
|
|
||||||
|
|
||||||
const disabled = computed(() => loading.value || noMore.value);
|
|
||||||
|
|
||||||
const loadMore = async () => {
|
|
||||||
if (loading.value || noMore.value) return;
|
|
||||||
|
|
||||||
loading.value = true;
|
|
||||||
try {
|
|
||||||
const res = await getMyTasks({
|
|
||||||
PageIndex: pageIndex.value,
|
|
||||||
PageSize: pageSize.value
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCardClick = (task: TaskItem) => {
|
|
||||||
currentTask.value = task;
|
|
||||||
dialogVisible.value = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTime = (time: string) => {
|
|
||||||
try {
|
|
||||||
return format(new Date(time), 'yyyy-MM-dd HH:mm');
|
|
||||||
} catch (e) {
|
|
||||||
return time;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const copyPrompt = async (text: string) => {
|
|
||||||
await copy(text);
|
|
||||||
ElMessage.success('提示词已复制');
|
|
||||||
};
|
|
||||||
|
|
||||||
const openPublishDialog = (task: TaskItem) => {
|
|
||||||
taskToPublish.value = task;
|
|
||||||
publishTags.value = [];
|
|
||||||
inputValue.value = '';
|
|
||||||
inputVisible.value = false;
|
|
||||||
publishDialogVisible.value = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCloseTag = (tag: string) => {
|
|
||||||
publishTags.value.splice(publishTags.value.indexOf(tag), 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const showInput = () => {
|
|
||||||
inputVisible.value = true;
|
|
||||||
nextTick(() => {
|
|
||||||
InputRef.value!.input!.focus();
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleInputConfirm = () => {
|
|
||||||
if (inputValue.value) {
|
|
||||||
if (!publishTags.value.includes(inputValue.value)) {
|
|
||||||
publishTags.value.push(inputValue.value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
inputVisible.value = false;
|
|
||||||
inputValue.value = '';
|
|
||||||
};
|
|
||||||
|
|
||||||
const confirmPublish = async () => {
|
|
||||||
if (!taskToPublish.value) return;
|
|
||||||
|
|
||||||
publishing.value = true;
|
|
||||||
try {
|
|
||||||
await publishImage({
|
|
||||||
taskId: taskToPublish.value.id,
|
|
||||||
categories: publishTags.value
|
|
||||||
});
|
|
||||||
|
|
||||||
ElMessage.success('发布成功');
|
|
||||||
taskToPublish.value.publishStatus = 'Published';
|
|
||||||
publishDialogVisible.value = false;
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
ElMessage.error('发布失败');
|
|
||||||
} finally {
|
|
||||||
publishing.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const downloadImage = async (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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.custom-scrollbar::-webkit-scrollbar {
|
.custom-scrollbar::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
|
|||||||
@@ -50,6 +50,12 @@
|
|||||||
查看详情
|
查看详情
|
||||||
</el-button>
|
</el-button>
|
||||||
<div class="flex gap-2" v-if="task.taskStatus === 'Success'">
|
<div class="flex gap-2" v-if="task.taskStatus === 'Success'">
|
||||||
|
<el-tooltip content="放大查看" placement="top" :show-after="500">
|
||||||
|
<el-button circle size="small" :icon="ZoomIn" @click.stop="handlePreview" />
|
||||||
|
</el-tooltip>
|
||||||
|
<el-tooltip content="下载图片" placement="top" :show-after="500">
|
||||||
|
<el-button circle size="small" :icon="Download" @click.stop="handleDownload" />
|
||||||
|
</el-tooltip>
|
||||||
<el-tooltip content="使用提示词" placement="top" :show-after="500">
|
<el-tooltip content="使用提示词" placement="top" :show-after="500">
|
||||||
<el-button circle size="small" :icon="MagicStick" @click.stop="$emit('use-prompt', task.prompt)" />
|
<el-button circle size="small" :icon="MagicStick" @click.stop="$emit('use-prompt', task.prompt)" />
|
||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
@@ -73,8 +79,33 @@
|
|||||||
<p class="text-sm text-gray-700 line-clamp-2 mb-2 h-10 leading-5" :title="task.prompt">
|
<p class="text-sm text-gray-700 line-clamp-2 mb-2 h-10 leading-5" :title="task.prompt">
|
||||||
{{ task.prompt }}
|
{{ task.prompt }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<!-- Tags -->
|
||||||
|
<div v-if="task.categories && task.categories.length > 0" class="flex flex-wrap gap-1 mb-2">
|
||||||
|
<el-tag
|
||||||
|
v-for="tag in task.categories.slice(0, 3)"
|
||||||
|
:key="tag"
|
||||||
|
size="small"
|
||||||
|
type="info"
|
||||||
|
effect="plain"
|
||||||
|
>
|
||||||
|
{{ tag }}
|
||||||
|
</el-tag>
|
||||||
|
<el-tag v-if="task.categories.length > 3" size="small" type="info" effect="plain">
|
||||||
|
+{{ task.categories.length - 3 }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mt-auto flex justify-between items-center text-xs text-gray-400">
|
<div class="mt-auto flex justify-between items-center text-xs text-gray-400">
|
||||||
<span>{{ formatTime(task.creationTime) }}</span>
|
<div class="flex flex-col gap-1">
|
||||||
|
<span>{{ formatTime(task.creationTime) }}</span>
|
||||||
|
<span v-if="!task.isAnonymous && task.userName" class="text-blue-500 flex items-center gap-1">
|
||||||
|
<el-icon><User /></el-icon> {{ task.userName }}
|
||||||
|
</span>
|
||||||
|
<span v-else-if="task.isAnonymous" class="text-gray-400 flex items-center gap-1">
|
||||||
|
<el-icon><User /></el-icon> 匿名用户
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<span v-if="task.taskStatus === 'Success'" class="text-green-500 flex items-center gap-1">
|
<span v-if="task.taskStatus === 'Success'" class="text-green-500 flex items-center gap-1">
|
||||||
<el-icon><Check /></el-icon> 完成
|
<el-icon><Check /></el-icon> 完成
|
||||||
</span>
|
</span>
|
||||||
@@ -84,8 +115,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { defineProps, defineEmits } from 'vue';
|
import { defineProps, defineEmits, ref } from 'vue';
|
||||||
import { Picture, Loading, CircleCloseFilled, Check, MagicStick, Picture as PictureIcon, Share } from '@element-plus/icons-vue';
|
import { Picture, Loading, CircleCloseFilled, Check, MagicStick, Picture as PictureIcon, Share, ZoomIn, Download, User } from '@element-plus/icons-vue';
|
||||||
import type { TaskItem } from '@/api/aiImage/types';
|
import type { TaskItem } from '@/api/aiImage/types';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
@@ -94,7 +125,7 @@ const props = defineProps<{
|
|||||||
showPublishStatus?: boolean;
|
showPublishStatus?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits(['click', 'use-prompt', 'use-reference', 'publish']);
|
const emit = defineEmits(['click', 'use-prompt', 'use-reference', 'publish', 'preview']);
|
||||||
|
|
||||||
const formatTime = (time: string) => {
|
const formatTime = (time: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -103,6 +134,32 @@ const formatTime = (time: string) => {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handlePreview = () => {
|
||||||
|
if (props.task.storeUrl) {
|
||||||
|
emit('preview', props.task.storeUrl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = async () => {
|
||||||
|
if (!props.task.storeUrl) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(props.task.storeUrl);
|
||||||
|
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(props.task.storeUrl, '_blank');
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
Reference in New Issue
Block a user