mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-31 16:26:30 +00:00
BatchImageGuideView carried ~140 user-visible strings outside vue-i18n: hardcoded Chinese template strings (buttons, placeholders, tooltips, table headers, dialog titles, empty states) plus an inline bilingual batchImageText dictionary switched by isZhLocale() in script. Move all of it into a new batchImage locale namespace (180 leaf keys per language, zh/en verified key-aligned) registered in both locale indexes, reusing 15 existing common.*/pagination.* keys. Column and filter-option definitions become computed so language switches take effect immediately; countable strings use vue-i18n plurals and the three rich-text counters keep their inner bold spans via <i18n-t> slots. The Codex agent-instruction prompt template intentionally stays as a plain string: it is an AI-facing prompt document containing literal JSON braces that conflict with vue-i18n message syntax. Verified: eslint, vue-tsc --noEmit, vite build, and the full vitest suite (175 files / 1219 tests) all pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2694 lines
109 KiB
Vue
2694 lines
109 KiB
Vue
<template>
|
||
<AppLayout>
|
||
<TablePageLayout>
|
||
<template #filters>
|
||
<div class="flex flex-col gap-3">
|
||
<div class="flex flex-col gap-3 2xl:flex-row 2xl:items-center 2xl:justify-between">
|
||
<div class="grid w-full grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-[260px_160px_144px_152px] 2xl:w-auto">
|
||
<div class="min-w-0">
|
||
<SearchInput
|
||
v-model="filters.taskName"
|
||
:placeholder="t('batchImage.filters.searchTaskName')"
|
||
class="w-full"
|
||
@search="applyFilters"
|
||
/>
|
||
</div>
|
||
<Select v-model="filters.apiKeyId" :options="apiKeyFilterOptions" class="w-full" @change="applyFilters" />
|
||
<Select v-model="filters.status" :options="statusFilterOptions" class="w-full" @change="applyFilters" />
|
||
<Select v-model="filters.downloaded" :options="downloadFilterOptions" class="w-full" @change="applyFilters" />
|
||
</div>
|
||
<div class="flex flex-wrap items-center justify-start gap-2 sm:justify-end 2xl:flex-shrink-0">
|
||
<button type="button" class="btn btn-secondary" :disabled="loadingJobs" @click="resetFilters">
|
||
{{ t('common.reset') }}
|
||
</button>
|
||
<button type="button" class="btn btn-secondary" :disabled="loadingKeys || loadingJobs" :title="t('common.refresh')" @click="refreshPage">
|
||
<Icon name="refresh" size="md" :class="loadingKeys || loadingJobs ? 'animate-spin' : ''" />
|
||
</button>
|
||
<button type="button" class="btn btn-secondary" @click="showGuideModal = true">
|
||
<Icon name="book" size="md" class="mr-2" />
|
||
{{ t('batchImage.actions.usageGuide') }}
|
||
</button>
|
||
<button type="button" class="btn btn-primary" @click="openCreateModal">
|
||
<Icon name="plus" size="md" class="mr-2" />
|
||
{{ t('batchImage.actions.createJob') }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
v-if="selectedJobIds.size"
|
||
class="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-gray-200 bg-white px-3 py-2 shadow-sm dark:border-dark-700 dark:bg-dark-800"
|
||
>
|
||
<i18n-t
|
||
keypath="batchImage.list.selectedJobs"
|
||
tag="span"
|
||
scope="global"
|
||
:plural="selectedJobIds.size"
|
||
class="text-sm text-gray-600 dark:text-gray-300"
|
||
>
|
||
<template #count>
|
||
<span class="font-medium text-gray-900 dark:text-white">{{ selectedJobIds.size }}</span>
|
||
</template>
|
||
</i18n-t>
|
||
<div class="flex flex-wrap items-center gap-2">
|
||
<button
|
||
type="button"
|
||
class="btn btn-secondary btn-sm"
|
||
:disabled="bulkDownloading || selectedDownloadableRows.length === 0"
|
||
@click="downloadSelectedJobs"
|
||
>
|
||
<Icon :name="bulkDownloading ? 'refresh' : 'download'" size="sm" class="mr-1.5" :class="bulkDownloading ? 'animate-spin' : ''" />
|
||
{{ t('batchImage.actions.downloadSelected') }}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="btn btn-secondary btn-sm text-red-600 hover:text-red-700 dark:text-red-400"
|
||
:disabled="bulkDeleting"
|
||
@click="deleteSelectedJobs"
|
||
>
|
||
<Icon :name="bulkDeleting ? 'refresh' : 'trash'" size="sm" class="mr-1.5" :class="bulkDeleting ? 'animate-spin' : ''" />
|
||
{{ t('batchImage.actions.deleteRecords') }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<template #table>
|
||
<DataTable
|
||
:columns="columns"
|
||
:data="visibleBatchJobs"
|
||
:loading="loadingKeys || loadingJobs"
|
||
:expandable-actions="false"
|
||
row-key="id"
|
||
>
|
||
<template #header-select>
|
||
<input
|
||
type="checkbox"
|
||
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||
:checked="allVisibleSelected"
|
||
:indeterminate="someVisibleSelected"
|
||
@change="toggleAllVisible(($event.target as HTMLInputElement).checked)"
|
||
/>
|
||
</template>
|
||
|
||
<template #cell-select="{ row }">
|
||
<input
|
||
type="checkbox"
|
||
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||
:checked="selectedJobIds.has(row.id)"
|
||
@change="toggleJobSelection(row.id, ($event.target as HTMLInputElement).checked)"
|
||
@click.stop
|
||
/>
|
||
</template>
|
||
|
||
<template #cell-id="{ row }">
|
||
<div class="flex w-[220px] items-start gap-1" :class="row.is_child ? 'pl-6' : ''">
|
||
<button
|
||
v-if="row.child_count > 0 && !row.is_child"
|
||
type="button"
|
||
class="mt-1 flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-md text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/30 dark:text-gray-400 dark:hover:bg-dark-700 dark:hover:text-white"
|
||
:title="expandedParentIds.has(row.id) ? t('batchImage.list.collapseChildren') : t('batchImage.list.expandChildren', { n: row.child_count }, row.child_count)"
|
||
@click.stop="toggleChildRows(row.id)"
|
||
>
|
||
<Icon :name="expandedParentIds.has(row.id) ? 'chevronDown' : 'chevronRight'" size="xs" />
|
||
</button>
|
||
<span v-else class="w-6 flex-shrink-0" />
|
||
<button type="button" class="min-w-0 flex-1 rounded-lg py-1 text-left transition-colors hover:bg-gray-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/30 dark:hover:bg-dark-700" @click="selectJob(row.id)">
|
||
<span
|
||
class="flex min-w-0 items-center gap-2 text-sm font-medium"
|
||
:class="row.task_name ? 'text-gray-900 dark:text-white' : 'text-gray-500 dark:text-gray-400'"
|
||
>
|
||
<span class="min-w-0 truncate">{{ row.task_name || defaultTaskName(row.created_at) }}</span>
|
||
<span v-if="row.child_count > 0 && !row.is_child" class="flex-shrink-0 rounded-full bg-gray-100 px-2 py-0.5 text-xs font-normal text-gray-600 dark:bg-dark-700 dark:text-gray-300">
|
||
{{ t('batchImage.list.childCount', { n: row.child_count }, row.child_count) }}
|
||
</span>
|
||
<span v-if="row.is_child" class="flex-shrink-0 rounded-full bg-amber-50 px-2 py-0.5 text-xs font-normal text-amber-700 dark:bg-amber-900/20 dark:text-amber-300">
|
||
{{ t('batchImage.list.childBadge') }}
|
||
</span>
|
||
</span>
|
||
<span class="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-gray-500 dark:text-gray-400">
|
||
<span>{{ formatDate(row.created_at) }}</span>
|
||
</span>
|
||
</button>
|
||
</div>
|
||
</template>
|
||
|
||
<template #cell-model="{ row }">
|
||
<div class="mx-auto max-w-[180px] text-center">
|
||
<p class="truncate text-sm text-gray-700 dark:text-gray-300" :title="row.model">{{ row.model }}</p>
|
||
</div>
|
||
</template>
|
||
|
||
<template #cell-api_key_name="{ value }">
|
||
<span class="block truncate text-center text-sm text-gray-700 dark:text-gray-300">
|
||
{{ value || t('batchImage.list.keyNotRecorded') }}
|
||
</span>
|
||
</template>
|
||
|
||
<template #cell-status="{ row }">
|
||
<div class="flex justify-center">
|
||
<span :class="statusBadgeClass(displayJob(row))" class="badge">
|
||
{{ statusLabel(displayJob(row)) }}
|
||
</span>
|
||
</div>
|
||
</template>
|
||
|
||
<template #cell-counts="{ row }">
|
||
<div class="flex items-center justify-center gap-2 text-sm tabular-nums">
|
||
<span class="text-emerald-600 dark:text-emerald-300">{{ displayJob(row).success_count }}</span>
|
||
<span class="text-gray-300 dark:text-dark-500">/</span>
|
||
<span :class="displayJob(row).fail_count > 0 ? 'text-red-600 dark:text-red-300' : 'text-gray-400 dark:text-gray-500'">{{ displayJob(row).fail_count }}</span>
|
||
<span class="text-xs text-gray-400 dark:text-gray-500">{{ t('batchImage.list.totalCount', { n: displayJob(row).item_count }) }}</span>
|
||
</div>
|
||
</template>
|
||
|
||
<template #cell-cost="{ row }">
|
||
<span class="block text-center text-sm text-gray-700 dark:text-gray-300">
|
||
{{ costLabel(displayJob(row)) }}
|
||
</span>
|
||
</template>
|
||
|
||
<template #cell-downloaded="{ row }">
|
||
<span class="block text-center text-sm" :class="row.downloaded_at ? 'text-emerald-700 dark:text-emerald-300' : 'text-gray-500 dark:text-gray-400'">
|
||
{{ row.downloaded_at ? formatDate(row.downloaded_at) : t('batchImage.list.notDownloaded') }}
|
||
</span>
|
||
</template>
|
||
|
||
<template #cell-actions="{ row }">
|
||
<div class="flex items-center justify-center gap-1">
|
||
<button
|
||
type="button"
|
||
class="batch-row-action flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/30 dark:hover:bg-dark-700 dark:hover:text-primary-400"
|
||
:title="t('batchImage.actions.viewDetail')"
|
||
@click="selectJob(row.id)"
|
||
>
|
||
<Icon name="eye" size="sm" />
|
||
<span class="text-xs">{{ t('common.view') }}</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="batch-row-action flex flex-col items-center gap-0.5 rounded-lg p-1.5 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/30"
|
||
:class="canDownload(row) ? 'text-gray-500 hover:bg-green-50 hover:text-green-600 dark:hover:bg-green-900/20 dark:hover:text-green-400' : 'text-gray-300 dark:text-dark-500'"
|
||
:disabled="!canDownload(row) || downloading"
|
||
:title="t('batchImage.actions.downloadZip')"
|
||
@click="downloadJob(row)"
|
||
>
|
||
<Icon
|
||
:name="isDownloadingJob(row.id) ? 'refresh' : 'download'"
|
||
size="sm"
|
||
:class="isDownloadingJob(row.id) ? 'animate-spin' : ''"
|
||
/>
|
||
<span class="text-xs">{{ t('batchImage.actions.download') }}</span>
|
||
</button>
|
||
<div v-if="canRetry(row) || canDeleteRecord(row)">
|
||
<button
|
||
type="button"
|
||
class="batch-row-action flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/30 dark:hover:bg-dark-700 dark:hover:text-white"
|
||
:class="{ 'bg-gray-100 text-gray-900 dark:bg-dark-700 dark:text-white': openMoreJobId === row.id }"
|
||
:title="t('batchImage.actions.moreActions')"
|
||
@click.stop="toggleMoreMenu(row, $event)"
|
||
>
|
||
<Icon name="more" size="sm" />
|
||
<span class="text-xs">{{ t('common.more') }}</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<template #empty>
|
||
<div class="flex min-h-[260px] flex-col items-center justify-center py-6 md:min-h-[300px]">
|
||
<Icon name="sparkles" size="xl" class="mb-4 h-12 w-12 text-gray-400 dark:text-dark-500" />
|
||
<p class="text-lg font-medium text-gray-900 dark:text-gray-100">{{ t('batchImage.list.empty') }}</p>
|
||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||
{{ t('batchImage.list.emptyHint') }}
|
||
</p>
|
||
</div>
|
||
</template>
|
||
</DataTable>
|
||
</template>
|
||
|
||
<template #pagination>
|
||
<div
|
||
v-if="visibleBatchJobs.length > 0 || pagination.page > 1"
|
||
class="flex flex-col gap-3 border-t border-gray-200 bg-white px-4 py-3 dark:border-dark-700 dark:bg-dark-800 sm:flex-row sm:items-center sm:justify-between sm:px-6"
|
||
>
|
||
<div class="flex flex-wrap items-center gap-3 text-sm text-gray-700 dark:text-gray-300">
|
||
<i18n-t keypath="batchImage.pagination.pageNumber" tag="span" scope="global">
|
||
<template #page>
|
||
<span class="font-medium">{{ pagination.page }}</span>
|
||
</template>
|
||
</i18n-t>
|
||
<i18n-t keypath="batchImage.pagination.pageItems" tag="span" scope="global">
|
||
<template #count>
|
||
<span class="font-medium">{{ visibleBatchJobs.length }}</span>
|
||
</template>
|
||
</i18n-t>
|
||
<div class="flex items-center gap-2">
|
||
<span>{{ t('pagination.perPage') }}</span>
|
||
<Select
|
||
v-model="pagination.page_size"
|
||
:options="batchPageSizeOptions"
|
||
class="w-24"
|
||
@change="handlePageSizeChange"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center justify-end gap-2">
|
||
<button
|
||
type="button"
|
||
class="btn btn-secondary btn-sm"
|
||
:disabled="pagination.page <= 1 || loadingJobs"
|
||
@click="handlePageChange(pagination.page - 1)"
|
||
>
|
||
<Icon name="chevronLeft" size="sm" class="mr-1" />
|
||
{{ t('pagination.previous') }}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="btn btn-secondary btn-sm"
|
||
:disabled="!pagination.has_more || loadingJobs"
|
||
@click="handlePageChange(pagination.page + 1)"
|
||
>
|
||
{{ t('pagination.next') }}
|
||
<Icon name="chevronRight" size="sm" class="ml-1" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</TablePageLayout>
|
||
|
||
<Teleport to="body">
|
||
<div
|
||
v-if="openMoreJobId"
|
||
class="fixed z-[9999] w-44 overflow-hidden rounded-xl bg-white py-1 text-sm shadow-lg ring-1 ring-black/5 dark:bg-dark-800 dark:ring-white/10"
|
||
:style="moreMenuStyle"
|
||
@click.stop
|
||
>
|
||
<template v-for="job in batchJobs" :key="job.id">
|
||
<template v-if="job.id === openMoreJobId">
|
||
<button
|
||
v-if="canRetry(job)"
|
||
type="button"
|
||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-gray-700 transition-colors hover:bg-amber-50 hover:text-amber-700 disabled:opacity-60 dark:text-gray-200 dark:hover:bg-amber-900/20 dark:hover:text-amber-300"
|
||
:disabled="retryingBatchId === job.id"
|
||
@click="retryFailedJob(job)"
|
||
>
|
||
<Icon name="refresh" size="sm" :class="retryingBatchId === job.id ? 'animate-spin' : ''" />
|
||
{{ t('batchImage.actions.retryFailedItems') }}
|
||
</button>
|
||
<button
|
||
v-if="canDeleteRecord(job)"
|
||
type="button"
|
||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-red-600 transition-colors hover:bg-red-50 disabled:opacity-60 dark:text-red-400 dark:hover:bg-red-900/20"
|
||
:disabled="deletingBatchId === job.id"
|
||
@click="deleteJob(job)"
|
||
>
|
||
<Icon :name="deletingBatchId === job.id ? 'refresh' : 'trash'" size="sm" :class="deletingBatchId === job.id ? 'animate-spin' : ''" />
|
||
{{ t('batchImage.actions.deleteRecords') }}
|
||
</button>
|
||
</template>
|
||
</template>
|
||
</div>
|
||
</Teleport>
|
||
|
||
<Teleport to="body">
|
||
<div
|
||
v-if="promptPopover.visible"
|
||
class="batch-prompt-popover fixed z-[9999] rounded-lg border border-gray-200 bg-white p-3 text-sm text-gray-800 shadow-xl ring-1 ring-black/5 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-100 dark:ring-white/10"
|
||
:style="promptPopover.style"
|
||
@mouseenter="cancelPromptPopoverClose"
|
||
@mouseleave="schedulePromptPopoverClose"
|
||
>
|
||
<div class="mb-2 flex items-center justify-between gap-3">
|
||
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('batchImage.promptPopover.title') }}</span>
|
||
<button
|
||
type="button"
|
||
class="rounded-md px-2 py-1 text-xs font-medium text-primary-600 transition-colors hover:bg-primary-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/30 dark:text-primary-300 dark:hover:bg-primary-900/20"
|
||
@click="copyPromptPopover"
|
||
>
|
||
{{ t('common.copy') }}
|
||
</button>
|
||
</div>
|
||
<p class="max-h-48 overflow-y-auto whitespace-pre-wrap break-words leading-6 selection:bg-primary-100 selection:text-primary-900 dark:selection:bg-primary-900/60 dark:selection:text-primary-100">
|
||
{{ promptPopover.text }}
|
||
</p>
|
||
</div>
|
||
</Teleport>
|
||
|
||
<BaseDialog :show="!!currentJob" :title="t('batchImage.detail.title')" width="extra-wide" @close="closeDetail">
|
||
<div v-if="currentJob" class="space-y-4">
|
||
<div class="rounded-lg border border-gray-200 bg-gray-50/70 px-4 py-3 dark:border-dark-700 dark:bg-dark-900/40">
|
||
<div class="grid gap-x-6 gap-y-3 sm:grid-cols-2 lg:grid-cols-4">
|
||
<div class="min-w-0 text-center">
|
||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('common.status') }}</p>
|
||
<div class="mt-1 flex justify-center">
|
||
<span :class="statusBadgeClass(currentDisplayJob || currentJob)" class="badge whitespace-nowrap">
|
||
{{ statusLabel(currentDisplayJob || currentJob) }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div class="min-w-0 text-center">
|
||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ hasChildJobs(currentJob.id) ? t('batchImage.detail.aggregatedResult') : t('batchImage.detail.result') }}</p>
|
||
<p class="mt-1 flex items-center justify-center gap-2 font-medium tabular-nums">
|
||
<span class="text-emerald-600 dark:text-emerald-300">{{ (currentDisplayJob || currentJob).success_count }}</span>
|
||
<span class="text-gray-300 dark:text-dark-500">/</span>
|
||
<span :class="(currentDisplayJob || currentJob).fail_count > 0 ? 'text-red-600 dark:text-red-300' : 'text-gray-400 dark:text-gray-500'">{{ (currentDisplayJob || currentJob).fail_count }}</span>
|
||
</p>
|
||
</div>
|
||
<div class="min-w-0 text-center">
|
||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('batchImage.detail.cost') }}</p>
|
||
<p class="mt-1 truncate font-medium text-gray-900 dark:text-white">{{ costLabel(currentDisplayJob || currentJob) }}</p>
|
||
</div>
|
||
<div class="min-w-0 text-center">
|
||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('batchImage.detail.downloadStatus') }}</p>
|
||
<p class="mt-1 truncate font-medium text-gray-900 dark:text-white">
|
||
{{ currentJob.downloaded_at ? formatDate(currentJob.downloaded_at) : t('batchImage.list.notDownloaded') }}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">{{ t('batchImage.detail.items') }}</h3>
|
||
<button type="button" class="btn btn-secondary btn-sm" :disabled="refreshing || loadingItems" @click="refreshDetail">
|
||
<Icon name="refresh" size="sm" class="mr-1.5" :class="refreshing || loadingItems ? 'animate-spin' : ''" />
|
||
{{ t('common.refresh') }}
|
||
</button>
|
||
</div>
|
||
|
||
<div v-if="items.length" class="overflow-x-auto rounded-lg border border-gray-200 bg-white dark:border-dark-700 dark:bg-dark-900">
|
||
<table class="w-full min-w-[860px] table-fixed divide-y divide-gray-200 text-sm dark:divide-dark-700">
|
||
<colgroup>
|
||
<col class="w-[18%]" />
|
||
<col class="w-[34%]" />
|
||
<col class="w-[12%]" />
|
||
<col class="w-[10%]" />
|
||
<col class="w-[26%]" />
|
||
</colgroup>
|
||
<thead class="bg-gray-50 dark:bg-dark-800/80">
|
||
<tr>
|
||
<th class="px-3 py-3 text-center text-sm font-medium text-gray-500 dark:text-gray-400">Custom ID</th>
|
||
<th class="px-3 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-400">Prompt</th>
|
||
<th class="px-3 py-3 text-center text-sm font-medium text-gray-500 dark:text-gray-400">{{ t('common.status') }}</th>
|
||
<th class="px-3 py-3 text-center text-sm font-medium text-gray-500 dark:text-gray-400">{{ t('batchImage.detail.preview') }}</th>
|
||
<th class="px-3 py-3 text-center text-sm font-medium text-gray-500 dark:text-gray-400">{{ t('batchImage.detail.result') }}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody class="divide-y divide-gray-100 dark:divide-dark-700">
|
||
<tr
|
||
v-for="item in items"
|
||
:key="itemPreviewKey(item)"
|
||
class="align-middle"
|
||
:class="detailItemRowClass(item)"
|
||
>
|
||
<td class="px-3 py-2.5 text-center">
|
||
<span
|
||
class="block min-w-0 truncate font-mono text-sm"
|
||
:class="isRecoveredOriginalFailure(item) ? 'text-gray-400 dark:text-gray-500' : 'text-gray-900 dark:text-white'"
|
||
:title="item.custom_id"
|
||
>
|
||
{{ item.custom_id }}
|
||
</span>
|
||
</td>
|
||
<td class="px-3 py-2.5 text-left" :class="isRecoveredOriginalFailure(item) ? 'text-gray-400 dark:text-gray-500' : 'text-gray-700 dark:text-gray-300'">
|
||
<div
|
||
class="batch-prompt-trigger cursor-default truncate rounded px-1 text-sm leading-6 focus:outline-none"
|
||
tabindex="0"
|
||
@pointerenter="schedulePromptPopoverOpen($event, item.prompt_preview || '-')"
|
||
@pointerleave="schedulePromptPopoverClose"
|
||
@mouseenter="schedulePromptPopoverOpen($event, item.prompt_preview || '-')"
|
||
@mouseleave="schedulePromptPopoverClose"
|
||
@click="showPromptPopover($event, item.prompt_preview || '-')"
|
||
@focus="showPromptPopover($event, item.prompt_preview || '-')"
|
||
@focusin="showPromptPopover($event, item.prompt_preview || '-')"
|
||
@blur="schedulePromptPopoverClose"
|
||
>
|
||
{{ item.prompt_preview || '-' }}
|
||
</div>
|
||
</td>
|
||
<td class="px-3 py-2.5 text-center">
|
||
<span :class="itemDisplayStatusBadgeClass(item)" class="badge max-w-full truncate whitespace-nowrap" :title="itemDisplayStatusLabel(item)">
|
||
{{ itemDisplayStatusLabel(item) }}
|
||
</span>
|
||
</td>
|
||
<td class="px-3 py-2.5 text-center">
|
||
<div class="mx-auto h-12 w-12 overflow-hidden rounded-md border border-gray-200 bg-gray-50 dark:border-dark-700 dark:bg-dark-800">
|
||
<button
|
||
v-if="itemPreviewUrls[itemPreviewKey(item)] && !previewErrorIds.has(itemPreviewKey(item))"
|
||
type="button"
|
||
class="block h-full w-full overflow-hidden"
|
||
:title="t('batchImage.detail.previewZoom', { id: item.custom_id })"
|
||
@click="openImagePreview(item)"
|
||
>
|
||
<img
|
||
:src="itemPreviewUrls[itemPreviewKey(item)]"
|
||
class="h-full w-full object-cover"
|
||
alt=""
|
||
@error="handlePreviewError(itemPreviewKey(item))"
|
||
/>
|
||
</button>
|
||
<button
|
||
v-else-if="canLoadItemPreview(item)"
|
||
type="button"
|
||
class="flex h-full w-full items-center justify-center text-gray-500 transition-colors hover:bg-gray-100 hover:text-primary-600 disabled:cursor-wait disabled:opacity-70 dark:text-gray-400 dark:hover:bg-dark-700"
|
||
:disabled="previewLoadingIds.has(itemPreviewKey(item))"
|
||
:title="previewErrorIds.has(itemPreviewKey(item)) ? t('batchImage.detail.previewReload') : t('batchImage.detail.previewLoad')"
|
||
@click="loadItemPreview(item)"
|
||
>
|
||
<Icon :name="previewLoadingIds.has(itemPreviewKey(item)) ? 'refresh' : 'eye'" size="sm" :class="previewLoadingIds.has(itemPreviewKey(item)) ? 'animate-spin' : ''" />
|
||
</button>
|
||
<div v-else class="flex h-full w-full items-center justify-center text-gray-400" :title="item.image_count > 0 ? t('batchImage.detail.previewUnavailable') : t('batchImage.detail.noImage')">
|
||
<Icon name="document" size="sm" />
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td class="px-3 py-2.5 text-center">
|
||
<span
|
||
class="inline-flex max-w-full items-center justify-center truncate rounded-md px-2.5 py-1 text-xs font-medium leading-5 ring-1 ring-inset"
|
||
:class="itemResultClass(item)"
|
||
:title="itemResultLabel(item)"
|
||
>
|
||
{{ itemResultLabel(item) }}
|
||
</span>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div v-else class="rounded-lg border border-dashed border-gray-200 py-10 text-center dark:border-dark-700">
|
||
<Icon name="refresh" size="lg" class="mx-auto mb-3 text-gray-400" :class="loadingItems ? 'animate-spin' : ''" />
|
||
<p class="text-sm font-medium text-gray-700 dark:text-gray-200">
|
||
{{ loadingItems ? t('batchImage.detail.loadingItems') : t('batchImage.detail.noItems') }}
|
||
</p>
|
||
<p v-if="!loadingItems" class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||
{{ t('batchImage.detail.noItemsHint') }}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<template #footer>
|
||
<div class="flex justify-end gap-3">
|
||
<button type="button" class="btn btn-secondary" :disabled="!currentJob || !canCancel(currentJob) || cancelling" @click="cancelSelected">
|
||
<Icon v-if="cancelling" name="refresh" size="sm" class="mr-2 animate-spin" />
|
||
{{ t('batchImage.actions.cancelJob') }}
|
||
</button>
|
||
<button
|
||
v-if="currentJob && currentDisplayJob && canRetry(currentDisplayJob)"
|
||
type="button"
|
||
class="btn btn-secondary inline-flex min-w-[116px] items-center justify-center"
|
||
:disabled="retryingBatchId === currentJob.id"
|
||
@click="retrySelected"
|
||
>
|
||
<Icon name="refresh" size="sm" class="mr-2" :class="currentJob && retryingBatchId === currentJob.id ? 'animate-spin' : ''" />
|
||
{{ t('batchImage.actions.retryFailedItems') }}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="btn btn-primary inline-flex min-w-[112px] items-center justify-center"
|
||
:disabled="!currentJob || !canDownload(currentJob) || downloading"
|
||
@click="downloadSelected"
|
||
>
|
||
<Icon
|
||
:name="currentJob && isDownloadingJob(currentJob.id) ? 'refresh' : 'download'"
|
||
size="sm"
|
||
class="mr-2"
|
||
:class="currentJob && isDownloadingJob(currentJob.id) ? 'animate-spin' : ''"
|
||
/>
|
||
{{ t('batchImage.actions.downloadZip') }}
|
||
</button>
|
||
</div>
|
||
</template>
|
||
</BaseDialog>
|
||
|
||
<BaseDialog :show="!!previewImageItem" :title="previewImageItem?.custom_id || t('batchImage.imagePreview.title')" width="extra-wide" :z-index="60" @close="closeImagePreview">
|
||
<div class="space-y-3">
|
||
<div class="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-100">
|
||
{{ t('batchImage.imagePreview.notice') }}
|
||
</div>
|
||
<div class="flex min-h-[420px] items-center justify-center rounded-lg bg-gray-50 p-4 dark:bg-dark-900">
|
||
<img
|
||
v-if="previewImageUrl"
|
||
:src="previewImageUrl"
|
||
class="max-h-[70vh] max-w-full rounded-md object-contain"
|
||
:alt="previewImageItem?.custom_id || ''"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</BaseDialog>
|
||
|
||
<BaseDialog :show="showCreateModal" :title="t('batchImage.create.title')" width="wide" @close="closeCreateModal">
|
||
<form class="space-y-5" @submit.prevent="submitJob">
|
||
<div class="grid gap-4 md:grid-cols-2">
|
||
<div class="md:col-span-2">
|
||
<label class="input-label">{{ t('batchImage.create.taskName') }}</label>
|
||
<input
|
||
v-model="form.taskName"
|
||
type="text"
|
||
maxlength="255"
|
||
class="input"
|
||
:placeholder="t('batchImage.create.taskNamePlaceholder')"
|
||
/>
|
||
</div>
|
||
|
||
<div class="md:col-span-2">
|
||
<label class="input-label">API Key</label>
|
||
<select v-model.number="form.apiKeyId" class="input" :disabled="loadingKeys">
|
||
<option :value="0">{{ loadingKeys ? t('batchImage.create.loadingKeys') : t('batchImage.create.selectKeyPlaceholder') }}</option>
|
||
<option v-for="key in geminiApiKeys" :key="key.id" :value="key.id">
|
||
{{ key.name }} · {{ key.group?.name || 'Gemini' }}
|
||
</option>
|
||
</select>
|
||
<p v-if="!loadingKeys && geminiApiKeys.length === 0" class="input-hint text-amber-600 dark:text-amber-400">
|
||
{{ t('batchImage.create.noKeysHint') }}
|
||
</p>
|
||
</div>
|
||
|
||
<div>
|
||
<label class="input-label">{{ t('batchImage.create.model') }}</label>
|
||
<select v-model="form.model" class="input" :disabled="loadingModels || availableBatchImageModels.length === 0">
|
||
<option v-if="loadingModels" value="">{{ batchImageText('loadingModels') }}</option>
|
||
<option v-else-if="availableBatchImageModels.length === 0" value="">{{ batchImageText('noModels') }}</option>
|
||
<option v-for="model in availableBatchImageModels" :key="model.value" :value="model.value">
|
||
{{ model.label }}
|
||
</option>
|
||
</select>
|
||
<p v-if="modelLoadError" class="input-hint text-amber-600 dark:text-amber-400">
|
||
{{ modelLoadError }}
|
||
</p>
|
||
<p v-else-if="selectedApiKey && !loadingModels && availableBatchImageModels.length === 0" class="input-hint text-amber-600 dark:text-amber-400">
|
||
{{ batchImageText('noModelsHint') }}
|
||
</p>
|
||
</div>
|
||
|
||
<div>
|
||
<label class="input-label">{{ t('batchImage.create.imageSize') }}</label>
|
||
<div class="input flex items-center bg-gray-50 text-gray-600 dark:bg-dark-900 dark:text-gray-300">
|
||
1K
|
||
</div>
|
||
<p class="input-hint">{{ t('batchImage.create.imageSizeHint') }}</p>
|
||
</div>
|
||
|
||
<div>
|
||
<label class="input-label">{{ t('batchImage.create.outputFormat') }}</label>
|
||
<select v-model="form.responseMimeType" class="input">
|
||
<option value="image/png">PNG</option>
|
||
<option value="image/jpeg">JPEG</option>
|
||
<option value="image/webp">WebP</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label class="input-label">{{ t('batchImage.create.estimatedOutput') }}</label>
|
||
<div class="input flex items-center bg-gray-50 text-gray-600 dark:bg-dark-900 dark:text-gray-300">
|
||
{{ t('batchImage.create.estimatedOutputValue', { images: estimatedOutputCount, prompts: promptRows.length }) }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="space-y-3">
|
||
<div class="flex items-center justify-between gap-3">
|
||
<label class="input-label mb-0">Prompt</label>
|
||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('batchImage.create.promptAdded', { count: promptRows.length }) }}</span>
|
||
</div>
|
||
<div class="rounded-lg border border-gray-200 p-3 dark:border-dark-700">
|
||
<textarea
|
||
v-model="promptDraft"
|
||
rows="3"
|
||
class="h-[76px] w-full resize-y rounded-md border border-gray-300 px-3 py-2 text-sm leading-5 outline-none focus:border-primary-500 focus:ring-2 focus:ring-primary-100 dark:border-dark-600 dark:bg-dark-900 dark:text-gray-100 dark:focus:border-primary-500 dark:focus:ring-primary-900/40"
|
||
:placeholder="t('batchImage.create.promptPlaceholder')"
|
||
/>
|
||
<div class="mt-2 grid gap-2 md:grid-cols-[minmax(0,1fr)_112px_132px_112px] md:items-center">
|
||
<input
|
||
v-model="customIdDraft"
|
||
type="text"
|
||
maxlength="255"
|
||
class="input h-9 text-sm"
|
||
:placeholder="t('batchImage.create.customIdPlaceholder')"
|
||
/>
|
||
<select
|
||
v-model.number="outputCountDraft"
|
||
class="batch-output-count-select input h-9 text-sm"
|
||
:title="t('batchImage.create.outputCountPerPrompt')"
|
||
:aria-label="t('batchImage.create.outputCountPerPrompt')"
|
||
>
|
||
<option v-for="count in outputCountOptions" :key="count" :value="count">
|
||
{{ t('batchImage.create.outputCountOption', { n: count }, count) }}
|
||
</option>
|
||
</select>
|
||
<label
|
||
class="btn btn-secondary h-9 cursor-pointer justify-center text-sm"
|
||
:class="referenceImageDrafts.length >= selectedModelReferenceLimit ? 'pointer-events-none opacity-60' : ''"
|
||
>
|
||
<Icon name="upload" size="sm" class="mr-1.5" />
|
||
{{ t('batchImage.create.referenceImage') }}
|
||
<input
|
||
type="file"
|
||
accept="image/png,image/jpeg,image/webp"
|
||
multiple
|
||
class="hidden"
|
||
:disabled="referenceImageDrafts.length >= selectedModelReferenceLimit"
|
||
@change="handleReferenceImageFiles"
|
||
/>
|
||
</label>
|
||
<button type="button" class="btn btn-secondary h-9 justify-center whitespace-nowrap px-4 text-sm" :disabled="!promptDraft.trim()" @click="addPromptRow">
|
||
<Icon name="plus" size="sm" class="mr-1.5" />
|
||
{{ t('common.add') }}
|
||
</button>
|
||
</div>
|
||
<div v-if="referenceImageDrafts.length" class="mt-3 flex flex-wrap gap-2">
|
||
<span
|
||
v-for="(ref, refIndex) in referenceImageDrafts"
|
||
:key="`${ref.name}-${refIndex}`"
|
||
class="inline-flex max-w-full items-center gap-1 rounded-md border border-gray-200 bg-gray-50 px-2 py-1 text-xs text-gray-700 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-200"
|
||
>
|
||
<span class="max-w-[180px] truncate">{{ ref.name }}</span>
|
||
<button type="button" class="text-gray-400 hover:text-red-600" :title="t('batchImage.create.removeReferenceImage')" @click="removeReferenceImageDraft(refIndex)">
|
||
<Icon name="x" size="xs" />
|
||
</button>
|
||
</span>
|
||
</div>
|
||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||
{{ t('batchImage.create.limitsHint', { maxPerItem: BATCH_IMAGE_MAX_OUTPUTS_PER_ITEM, maxPerJob: BATCH_IMAGE_MAX_OUTPUTS_PER_JOB, refLimit: selectedModelReferenceLimit }) }}
|
||
</p>
|
||
</div>
|
||
<div v-if="promptRows.length" class="overflow-hidden rounded-lg border border-gray-200 dark:border-dark-700">
|
||
<div
|
||
v-for="(row, index) in promptRows"
|
||
:key="row.localId"
|
||
class="flex items-center gap-3 border-b border-gray-100 px-3 py-2 last:border-b-0 dark:border-dark-700"
|
||
>
|
||
<span class="w-20 flex-shrink-0 font-mono text-xs text-gray-500 dark:text-gray-400">{{ row.custom_id }}</span>
|
||
<p class="min-w-0 flex-1 truncate text-sm text-gray-800 dark:text-gray-100">{{ row.prompt }}</p>
|
||
<span v-if="row.output_count > 1" class="flex-shrink-0 text-xs text-gray-500 dark:text-gray-400">
|
||
x{{ row.output_count }}
|
||
</span>
|
||
<span v-if="row.reference_images.length" class="flex-shrink-0 text-xs text-gray-500 dark:text-gray-400">
|
||
{{ t('batchImage.create.referenceCount', { n: row.reference_images.length }, row.reference_images.length) }}
|
||
</span>
|
||
<button type="button" class="btn-ghost btn-icon flex-shrink-0 text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20" :title="t('common.delete')" @click="removePromptRow(index)">
|
||
<Icon name="trash" size="sm" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div v-else class="rounded-lg border border-dashed border-gray-200 px-3 py-6 text-center text-sm text-gray-500 dark:border-dark-700 dark:text-gray-400">
|
||
{{ t('batchImage.create.noPrompts') }}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm leading-6 text-amber-900 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-100">
|
||
{{ t('batchImage.create.cancelNotice') }}
|
||
</div>
|
||
<div v-if="submitting" class="rounded-lg border border-sky-200 bg-sky-50 p-3 text-sm leading-6 text-sky-800 dark:border-sky-800 dark:bg-sky-950/30 dark:text-sky-100">
|
||
{{ t('batchImage.create.submittingNotice') }}
|
||
</div>
|
||
</form>
|
||
|
||
<template #footer>
|
||
<div class="flex justify-end gap-3">
|
||
<button type="button" class="btn btn-secondary" :disabled="submitting" @click="closeCreateModal">{{ t('common.cancel') }}</button>
|
||
<button type="button" class="btn btn-primary inline-flex min-w-[120px] justify-center" :disabled="submitting || loadingModels || (parsedItems.length === 0 && !promptDraft.trim()) || !selectedApiKey || !form.model" @click="submitJob">
|
||
<Icon v-if="submitting" name="refresh" size="sm" class="mr-2 animate-spin" />
|
||
{{ submitting ? t('common.submitting') : t('batchImage.actions.submitJob') }}
|
||
</button>
|
||
</div>
|
||
</template>
|
||
</BaseDialog>
|
||
|
||
<BaseDialog :show="showGuideModal" :title="t('batchImage.guide.title')" width="wide" @close="showGuideModal = false">
|
||
<div class="space-y-5">
|
||
<section class="space-y-3">
|
||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">{{ t('batchImage.guide.uiTitle') }}</h3>
|
||
<div class="rounded-lg border border-gray-200 bg-gray-50 p-3 text-sm leading-6 text-gray-700 dark:border-dark-700 dark:bg-dark-900/50 dark:text-gray-200">
|
||
<p>{{ t('batchImage.guide.step1') }}</p>
|
||
<p>{{ t('batchImage.guide.step2') }}</p>
|
||
<p>{{ t('batchImage.guide.step3') }}</p>
|
||
<p>{{ t('batchImage.guide.step4') }}</p>
|
||
</div>
|
||
</section>
|
||
<section class="space-y-3">
|
||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">{{ t('batchImage.guide.skillTitle') }}</h3>
|
||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('batchImage.guide.skillDesc') }}</p>
|
||
</div>
|
||
<textarea
|
||
:value="agentInstruction"
|
||
readonly
|
||
class="min-h-[420px] w-full resize-y rounded-md border border-gray-200 bg-gray-50 p-4 font-mono text-sm leading-6 text-gray-800 outline-none focus:border-primary-400 focus:ring-2 focus:ring-primary-100 dark:border-dark-600 dark:bg-dark-900 dark:text-gray-100 dark:focus:border-primary-500 dark:focus:ring-primary-900/40"
|
||
/>
|
||
</section>
|
||
</div>
|
||
<template #footer>
|
||
<div class="flex justify-end gap-3">
|
||
<button type="button" class="btn btn-secondary" @click="showGuideModal = false">{{ t('common.close') }}</button>
|
||
<button type="button" class="btn btn-primary" @click="copyInstruction">
|
||
<Icon name="copy" size="sm" class="mr-2" />
|
||
{{ t('batchImage.actions.copyInstruction') }}
|
||
</button>
|
||
</div>
|
||
</template>
|
||
</BaseDialog>
|
||
</AppLayout>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||
import { useI18n } from 'vue-i18n'
|
||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
|
||
import DataTable from '@/components/common/DataTable.vue'
|
||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||
import Select, { type SelectOption } from '@/components/common/Select.vue'
|
||
import SearchInput from '@/components/common/SearchInput.vue'
|
||
import Icon from '@/components/icons/Icon.vue'
|
||
import { useClipboard } from '@/composables/useClipboard'
|
||
import { getPersistedPageSize, setPersistedPageSize } from '@/composables/usePersistedPageSize'
|
||
import { useAppStore } from '@/stores/app'
|
||
import { keysAPI } from '@/api'
|
||
import {
|
||
cancelBatchImageJob,
|
||
deleteBatchImageJobRecord,
|
||
downloadBatchImageZip,
|
||
getBatchImageItemContent,
|
||
getBatchImageJob,
|
||
listBatchImageJobs,
|
||
listBatchImageItems,
|
||
listBatchImageModels,
|
||
saveBlob,
|
||
submitBatchImageJob,
|
||
type BatchImageItem,
|
||
type BatchImageJob,
|
||
type BatchImageJobsListOptions,
|
||
type BatchImageReferenceImage,
|
||
type BatchImageStatus,
|
||
type BatchImageSubmitItem,
|
||
} from '@/api/batchImage'
|
||
import type { ApiKey } from '@/types'
|
||
import type { Column } from '@/components/common/types'
|
||
|
||
type BatchImageJobRow = Pick<BatchImageJob, 'id' | 'task_name' | 'parent_batch_id' | 'status' | 'model' | 'provider' | 'item_count' | 'success_count' | 'fail_count' | 'estimated_cost' | 'hold_amount' | 'actual_cost' | 'created_at' | 'downloaded_at'> & {
|
||
api_key_id: number
|
||
api_key_name: string
|
||
child_count: number
|
||
is_child?: boolean
|
||
}
|
||
|
||
type BatchImageDetailItem = BatchImageItem & {
|
||
batch_id: string
|
||
source_task_name: string
|
||
}
|
||
|
||
type PromptRow = {
|
||
localId: string
|
||
custom_id: string
|
||
prompt: string
|
||
output_count: number
|
||
reference_images: BatchImageReferenceImage[]
|
||
}
|
||
|
||
type ReferenceImageDraft = BatchImageReferenceImage & {
|
||
name: string
|
||
size: number
|
||
}
|
||
|
||
type PreviewCacheRecord = {
|
||
key: string
|
||
blob: Blob
|
||
size: number
|
||
createdAt: number
|
||
lastAccessedAt: number
|
||
}
|
||
|
||
type PreviewImageSource = ImageBitmap | HTMLImageElement
|
||
|
||
const TERMINAL_STATUSES = new Set(['completed', 'failed', 'cancelled', 'output_deleted'])
|
||
const PREVIEW_CACHE_DB_NAME = 'sub2api-batch-image-preview-cache'
|
||
const PREVIEW_CACHE_STORE_NAME = 'thumbnails'
|
||
const PREVIEW_THUMBNAIL_MAX_EDGE = 360
|
||
const PREVIEW_THUMBNAIL_QUALITY = 0.72
|
||
const PREVIEW_CACHE_MAX_AGE_MS = 3 * 24 * 60 * 60 * 1000
|
||
const PREVIEW_CACHE_MAX_ENTRIES = 120
|
||
const PREVIEW_CACHE_MAX_BYTES = 48 * 1024 * 1024
|
||
const BATCH_IMAGE_MAX_OUTPUTS_PER_ITEM = 4
|
||
const BATCH_IMAGE_MAX_OUTPUTS_PER_JOB = 200
|
||
const outputCountOptions = Array.from({ length: BATCH_IMAGE_MAX_OUTPUTS_PER_ITEM }, (_, index) => index + 1)
|
||
const batchPageSizeOptions: SelectOption[] = [20, 50, 100].map(size => ({ value: size, label: String(size) }))
|
||
|
||
const appStore = useAppStore()
|
||
const { copyToClipboard } = useClipboard()
|
||
const { t, locale } = useI18n()
|
||
|
||
const columns = computed<Column[]>(() => [
|
||
{ key: 'select', label: '', sortable: false, class: 'w-12 text-center' },
|
||
{ key: 'id', label: t('batchImage.columns.taskName'), sortable: false, class: 'w-[240px] max-w-[240px]' },
|
||
{ key: 'model', label: t('batchImage.columns.model'), sortable: false, class: 'w-[180px] max-w-[180px] text-center' },
|
||
{ key: 'api_key_name', label: t('batchImage.columns.apiKey'), sortable: false, class: 'w-40 max-w-40 text-center' },
|
||
{ key: 'status', label: t('common.status'), sortable: false, class: 'w-28 text-center' },
|
||
{ key: 'counts', label: t('batchImage.columns.result'), sortable: false, class: 'w-32 text-center' },
|
||
{ key: 'cost', label: t('batchImage.columns.cost'), sortable: false, class: 'w-36 text-center' },
|
||
{ key: 'downloaded', label: t('batchImage.columns.downloadStatus'), sortable: false, class: 'w-40 text-center' },
|
||
{ key: 'actions', label: t('common.actions'), sortable: false, class: 'w-40 text-center' },
|
||
])
|
||
|
||
const statusFilterOptions = computed<SelectOption[]>(() => [
|
||
{ value: '', label: t('batchImage.filters.allStatuses') },
|
||
{ value: 'queued', label: t('batchImage.status.queued') },
|
||
{ value: 'running', label: t('batchImage.status.running') },
|
||
{ value: 'processing_results', label: t('batchImage.status.processingResults') },
|
||
{ value: 'settling', label: t('batchImage.status.settling') },
|
||
{ value: 'completed', label: t('batchImage.status.completed') },
|
||
{ value: 'failed', label: t('batchImage.status.failed') },
|
||
{ value: 'cancelled', label: t('batchImage.status.cancelled') },
|
||
{ value: 'output_deleted', label: t('batchImage.status.outputDeleted') },
|
||
])
|
||
|
||
const downloadFilterOptions = computed<SelectOption[]>(() => [
|
||
{ value: '', label: t('batchImage.filters.allDownloadStates') },
|
||
{ value: 'true', label: t('batchImage.filters.downloaded') },
|
||
{ value: 'false', label: t('batchImage.filters.notDownloaded') },
|
||
])
|
||
|
||
const form = reactive({
|
||
apiKeyId: 0,
|
||
taskName: '',
|
||
model: '',
|
||
responseMimeType: 'image/png',
|
||
})
|
||
|
||
const filters = reactive({
|
||
taskName: '',
|
||
apiKeyId: '',
|
||
status: '',
|
||
downloaded: '',
|
||
})
|
||
|
||
const pagination = reactive({
|
||
page: 1,
|
||
page_size: Math.min(getPersistedPageSize(20), 100),
|
||
has_more: false,
|
||
})
|
||
|
||
const apiKeys = ref<ApiKey[]>([])
|
||
const loadingKeys = ref(false)
|
||
const loadingJobs = ref(false)
|
||
const submitting = ref(false)
|
||
const refreshing = ref(false)
|
||
const cancelling = ref(false)
|
||
const downloading = ref(false)
|
||
const downloadingBatchId = ref('')
|
||
const retryingBatchId = ref('')
|
||
const bulkDownloading = ref(false)
|
||
const bulkDeleting = ref(false)
|
||
const deletingBatchId = ref('')
|
||
const loadingItems = ref(false)
|
||
const loadingModels = ref(false)
|
||
const showCreateModal = ref(false)
|
||
const showGuideModal = ref(false)
|
||
const currentJob = ref<BatchImageJob | null>(null)
|
||
const selectedBatchId = ref('')
|
||
const selectedBatchApiKeyId = ref(0)
|
||
const items = ref<BatchImageDetailItem[]>([])
|
||
const batchJobs = ref<BatchImageJobRow[]>([])
|
||
const selectedJobIds = ref(new Set<string>())
|
||
const expandedParentIds = ref(new Set<string>())
|
||
const promptRows = ref<PromptRow[]>([])
|
||
const promptDraft = ref('')
|
||
const customIdDraft = ref('')
|
||
const outputCountDraft = ref(1)
|
||
const referenceImageDrafts = ref<ReferenceImageDraft[]>([])
|
||
const itemPreviewUrls = reactive<Record<string, string>>({})
|
||
const previewLoadingIds = ref(new Set<string>())
|
||
const previewErrorIds = ref(new Set<string>())
|
||
const previewImageItem = ref<BatchImageItem | null>(null)
|
||
const availableBatchImageModels = ref<Array<{ value: string; label: string }>>([])
|
||
const modelLoadError = ref('')
|
||
const openMoreJobId = ref('')
|
||
const moreMenuStyle = ref<Record<string, string>>({})
|
||
const promptPopover = reactive({
|
||
visible: false,
|
||
text: '',
|
||
style: {} as Record<string, string>,
|
||
})
|
||
let modelRequestSeq = 0
|
||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||
let previewCacheDBPromise: Promise<IDBDatabase | null> | null = null
|
||
let previewCacheCleanupTimer: ReturnType<typeof setInterval> | null = null
|
||
let promptPopoverCloseTimer: ReturnType<typeof setTimeout> | null = null
|
||
let promptPopoverOpenTimer: ReturnType<typeof setTimeout> | null = null
|
||
let activePromptPopoverTarget: HTMLElement | null = null
|
||
|
||
const geminiApiKeys = computed(() =>
|
||
apiKeys.value.filter((key) =>
|
||
key.status === 'active' &&
|
||
key.group?.platform === 'gemini' &&
|
||
key.group?.allow_batch_image_generation === true,
|
||
),
|
||
)
|
||
|
||
const selectedApiKey = computed(() =>
|
||
geminiApiKeys.value.find((key) => key.id === Number(form.apiKeyId)) || null,
|
||
)
|
||
|
||
const filteredApiKeys = computed(() => {
|
||
const selectedFilterID = Number(filters.apiKeyId || 0)
|
||
if (!selectedFilterID) return geminiApiKeys.value
|
||
return geminiApiKeys.value.filter(key => key.id === selectedFilterID)
|
||
})
|
||
|
||
const apiKeyFilterOptions = computed<SelectOption[]>(() => [
|
||
{ value: '', label: t('batchImage.filters.allApiKeys') },
|
||
...geminiApiKeys.value.map(key => ({
|
||
value: String(key.id),
|
||
label: key.name || `API Key #${key.id}`,
|
||
})),
|
||
])
|
||
|
||
const selectedRows = computed(() =>
|
||
batchJobs.value.filter(job => selectedJobIds.value.has(job.id)),
|
||
)
|
||
|
||
const childrenByParent = computed(() => {
|
||
const groups = new Map<string, BatchImageJobRow[]>()
|
||
for (const job of batchJobs.value) {
|
||
if (!job.parent_batch_id) continue
|
||
const rows = groups.get(job.parent_batch_id) || []
|
||
rows.push(job)
|
||
groups.set(job.parent_batch_id, rows)
|
||
}
|
||
for (const rows of groups.values()) {
|
||
rows.sort((a, b) => a.created_at - b.created_at)
|
||
}
|
||
return groups
|
||
})
|
||
|
||
const visibleBatchJobs = computed(() => {
|
||
const rows: BatchImageJobRow[] = []
|
||
for (const job of batchJobs.value.filter(item => !item.parent_batch_id)) {
|
||
rows.push(job)
|
||
if (expandedParentIds.value.has(job.id)) {
|
||
rows.push(...(childrenByParent.value.get(job.id) || []).map(child => ({ ...child, is_child: true })))
|
||
}
|
||
}
|
||
return rows
|
||
})
|
||
|
||
const selectedDownloadableRows = computed(() =>
|
||
selectedRows.value.filter(job => canDownload(job)),
|
||
)
|
||
|
||
const allVisibleSelected = computed(() =>
|
||
visibleBatchJobs.value.length > 0 && visibleBatchJobs.value.every(job => selectedJobIds.value.has(job.id)),
|
||
)
|
||
|
||
const someVisibleSelected = computed(() =>
|
||
visibleBatchJobs.value.some(job => selectedJobIds.value.has(job.id)) && !allVisibleSelected.value,
|
||
)
|
||
|
||
const previewImageUrl = computed(() => {
|
||
const item = previewImageItem.value
|
||
if (!item) return ''
|
||
return itemPreviewUrls[itemPreviewKey(item)] || ''
|
||
})
|
||
|
||
const recoveredOriginalCustomIds = computed(() => {
|
||
const rootBatchId = detailRootBatchId()
|
||
if (!rootBatchId) return new Set<string>()
|
||
const ids = new Set<string>()
|
||
for (const item of items.value) {
|
||
if (!isChildDetailItem(item) || !isSuccessfulImageItem(item)) continue
|
||
const sourceCustomID = retrySourceCustomID(item.custom_id)
|
||
if (sourceCustomID) ids.add(sourceCustomID)
|
||
}
|
||
return ids
|
||
})
|
||
|
||
const currentDisplayJob = computed(() => {
|
||
if (!currentJob.value) return null
|
||
return displayJob(currentJob.value)
|
||
})
|
||
|
||
const endpointBase = computed(() => {
|
||
const configured = appStore.apiBaseUrl?.trim()
|
||
if (configured) return configured.replace(/\/+$/, '')
|
||
if (typeof window !== 'undefined') return window.location.origin.replace(/\/+$/, '')
|
||
return '<你的 Sub2API API 端点>'
|
||
})
|
||
|
||
const selectedModelReferenceLimit = computed(() => referenceImageLimitForModel(form.model))
|
||
|
||
const estimatedOutputCount = computed(() =>
|
||
promptRows.value.reduce((sum, row) => sum + normalizeOutputCount(row.output_count), 0),
|
||
)
|
||
|
||
const parsedItems = computed<BatchImageSubmitItem[]>(() => {
|
||
const used = new Set<string>()
|
||
return promptRows.value
|
||
.map((row, index) => {
|
||
const customID = uniqueCustomID(row.custom_id || `img_${String(index + 1).padStart(3, '0')}`, used, index)
|
||
const item: BatchImageSubmitItem = { custom_id: customID, prompt: row.prompt.trim() }
|
||
const outputCount = normalizeOutputCount(row.output_count)
|
||
if (outputCount > 1) {
|
||
item.output_count = outputCount
|
||
}
|
||
if (row.reference_images.length) {
|
||
item.reference_images = row.reference_images
|
||
}
|
||
return item
|
||
})
|
||
.filter(item => item.prompt)
|
||
})
|
||
|
||
function referenceImageLimitForModel(model: string) {
|
||
const normalized = String(model || '').toLowerCase()
|
||
if (normalized.includes('pro-image')) return 14
|
||
if (normalized.includes('flash-image')) return 3
|
||
return 0
|
||
}
|
||
|
||
const agentInstruction = computed(() => `---
|
||
name: sub2api-batch-image
|
||
description: 当用户希望用 Gemini/Vertex 批量生成图片、批量跑提示词、下载批量生图结果、重试失败图片时使用。
|
||
---
|
||
|
||
你是 Codex 中的批量生图执行 Agent。用户不需要手动填写页面表单;你应从当前聊天、用户给的文件、目录或上下文中整理任务名称、prompt 列表和输出目录,只有缺少关键决策时才向用户提问。
|
||
|
||
默认端点:
|
||
${endpointBase.value}
|
||
|
||
你需要自己完成:
|
||
1. 从用户聊天或附件中提取 prompt。每条 prompt 保留完整文本,按顺序生成稳定 custom_id,例如 img_001、img_002。
|
||
2. 从用户要求或上下文推断任务名称;没有明确名称时用当前时间生成任务名。
|
||
3. 从用户要求或上下文推断输出目录;如果用户没有说保存到哪里,才询问用户。
|
||
4. 提交前必须先计算 expected_output_count = 所有 item 的 output_count 之和。单个批量任务硬性最多 200 张输出图;超过 200 张必须拆成多组任务,不能提交一个超大任务,也不能把参考图附件上限当成生成张数上限。
|
||
5. 如果用户提供参考图,把参考图按用途绑定到具体 item。参考图只是输入附件,不是输出图数量。模型单条限制必须按模型执行:Gemini 2.5 Flash Image 每条最多 3 张参考图;Gemini 3 Pro Image 每条最多 14 张参考图。不要把后端附件风控理解成 Pro 单条能力:按 output_count 展开后,所有 item 的参考图附件总数还有内部保护阈值 1000 个,inline base64 参考图解码后总量最多 128MB。这个 1000 只是服务器拒绝异常请求的保护阈值,不是推荐规模;参考图很多或总请求体较大时应主动拆分任务。
|
||
6. 参考图会按 output_count 重复消耗输入 token;大量任务、重复复用同一张参考图或参考图总体积较大时,优先使用 gs:// file_uri 或拆分成多组任务。
|
||
7. 选择 API Key 和模型:先获取当前可用的批量生图 Key/模型;如果用户指定模型且该 Key 支持,则使用用户指定模型;否则使用该 Key 可用模型中的默认/第一个。不要展示或询问内部 provider 名称。
|
||
8. 调用批量生图 API 提交、轮询、下载,不要求用户去页面里手填。
|
||
|
||
API 调用规范:
|
||
- 模型:GET ${joinEndpointPath(endpointBase.value, '/v1/images/batches/models')}
|
||
- 提交:POST ${joinEndpointPath(endpointBase.value, '/v1/images/batches')}
|
||
- 查询:GET ${joinEndpointPath(endpointBase.value, '/v1/images/batches/{id}')}
|
||
- 明细:GET ${joinEndpointPath(endpointBase.value, '/v1/images/batches/{id}/items')}
|
||
- 下载:GET ${joinEndpointPath(endpointBase.value, '/v1/images/batches/{id}/download')}
|
||
- 取消:POST ${joinEndpointPath(endpointBase.value, '/v1/images/batches/{id}/cancel')}
|
||
|
||
提交请求体:
|
||
{
|
||
"model": "<按所选 Key 可用模型填写>",
|
||
"task_name": "<从聊天推断;为空则用当前时间>",
|
||
"image_size": "1K",
|
||
"response_mime_type": "image/png",
|
||
"items": [
|
||
{
|
||
"custom_id": "img_001",
|
||
"prompt": "<第一条完整 prompt>",
|
||
"output_count": 1,
|
||
"reference_images": [
|
||
{
|
||
"id": "face",
|
||
"type": "subject",
|
||
"mime_type": "image/png",
|
||
"data": "<base64,不含 data:image/png;base64, 前缀>"
|
||
}
|
||
]
|
||
}
|
||
]
|
||
}
|
||
|
||
必须遵守:
|
||
- 不要把 API Key 写入仓库、日志、提交记录或最终回复。
|
||
- 不要把参考图 base64 写入最终回复、日志或公开文件。恢复记录中只保存参考图文件名、用途、数量和请求 JSON 文件路径;若请求 JSON 文件包含 base64,应保存在用户指定输出目录且不要提交到仓库。
|
||
- output_count 表示同一 prompt 和参考图重复生成几张,默认 1,每条最多 4;这不是依赖 Gemini 单次请求返回多图,而是系统展开成多个真实任务项。提交前必须确认预计输出图总数不超过 200,超过就拆分成多组任务。绝不能因为参考图附件有更高的内部保护阈值,就提交会生成超过 200 张图的任务。
|
||
- 当前对用户的批量生图计费仍按成功输出图片数量结算,不单独对参考图加价。可以向用户说明:参考图会产生少量上游输入 token 和临时存储成本,且会随 output_count 重复计算;页面显示的冻结/结算金额按输出图片数量计算。
|
||
- 提交成功后,必须立刻在输出目录写入本地恢复记录,例如 batch-image-resume.json。不要在恢复记录里保存 API Key。
|
||
- 恢复记录至少包含:endpoint、task_name、batch_id、model、output_dir、request_file、submitted_at、last_status、status_url、items_url、download_url、prompt_count、expected_output_count,以及可用于失败重试的 custom_id 到 prompt 映射或请求 JSON 文件路径。
|
||
- 每次查询状态后更新恢复记录,写入 last_checked_at、last_status、成功数、失败数、实际扣费和失败摘要。会话中断或暂停后,下次必须能凭该文件继续查询、下载或重试。
|
||
- 不要高频轮询。首次查询等待约 20 到 30 秒;queued 状态每 60 到 120 秒查询一次;如果连续 3 次仍是 queued,就先停止主动查询,告诉用户任务仍在排队,并保留恢复记录,之后可继续其他任务或等待用户稍后让你恢复。
|
||
- running 状态每约 60 秒查询一次,服务器压力大或大批量任务时可以更久;processing_results 等接近完成的状态可每 20 到 45 秒查询一次。
|
||
- 任务完成后报告任务名、任务 id、成功数、失败数、实际扣费和保存路径。
|
||
- 只下载成功图片。部分失败时,先展示失败 custom_id、错误码、错误来源和简要原因。
|
||
- 重试只能重试失败项,不能重复提交已成功项。若历史任务没有保存失败项 prompt,必须告诉用户无法自动重试,并询问用户是否提供原 prompt。
|
||
- 取消任务前必须提醒:已被系统索引为成功的图片仍会按成功项结算扣费,其余冻结金额会释放。
|
||
- 图片预览按需加载;不要为了查看列表自动批量加载图片内容。`)
|
||
|
||
function joinEndpointPath(base: string, path: string): string {
|
||
return `${base.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`
|
||
}
|
||
|
||
function uniqueCustomID(raw: string, used: Set<string>, index: number): string {
|
||
const base = raw.replace(/[^\w.-]+/g, '_').replace(/^_+|_+$/g, '') || `img_${String(index + 1).padStart(3, '0')}`
|
||
let candidate = base
|
||
let suffix = 2
|
||
while (used.has(candidate)) {
|
||
candidate = `${base}_${suffix}`
|
||
suffix += 1
|
||
}
|
||
used.add(candidate)
|
||
return candidate
|
||
}
|
||
|
||
function normalizeOutputCount(value: unknown): number {
|
||
const parsed = Math.floor(Number(value || 1))
|
||
if (!Number.isFinite(parsed)) return 1
|
||
return Math.min(BATCH_IMAGE_MAX_OUTPUTS_PER_ITEM, Math.max(1, parsed))
|
||
}
|
||
|
||
function addPromptRow() {
|
||
const prompt = promptDraft.value.trim()
|
||
if (!prompt) return
|
||
const outputCount = normalizeOutputCount(outputCountDraft.value)
|
||
const used = new Set(promptRows.value.map(row => row.custom_id))
|
||
const customID = uniqueCustomID(customIdDraft.value || `img_${String(promptRows.value.length + 1).padStart(3, '0')}`, used, promptRows.value.length)
|
||
promptRows.value = [
|
||
...promptRows.value,
|
||
{
|
||
localId: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||
custom_id: customID,
|
||
prompt,
|
||
output_count: outputCount,
|
||
reference_images: referenceImageDrafts.value.map(({ name: _name, size: _size, ...ref }) => ref),
|
||
},
|
||
]
|
||
promptDraft.value = ''
|
||
customIdDraft.value = ''
|
||
outputCountDraft.value = 1
|
||
referenceImageDrafts.value = []
|
||
}
|
||
|
||
function removePromptRow(index: number) {
|
||
promptRows.value = promptRows.value.filter((_, currentIndex) => currentIndex !== index)
|
||
}
|
||
|
||
function removeReferenceImageDraft(index: number) {
|
||
referenceImageDrafts.value = referenceImageDrafts.value.filter((_, currentIndex) => currentIndex !== index)
|
||
}
|
||
|
||
async function handleReferenceImageFiles(event: Event) {
|
||
const input = event.target as HTMLInputElement
|
||
const files = Array.from(input.files || [])
|
||
input.value = ''
|
||
if (files.length === 0) return
|
||
const limit = selectedModelReferenceLimit.value
|
||
if (limit <= 0) {
|
||
appStore.showError(t('batchImage.create.modelNoReferenceImages'))
|
||
return
|
||
}
|
||
const slots = Math.max(0, limit - referenceImageDrafts.value.length)
|
||
if (slots <= 0) {
|
||
appStore.showError(t('batchImage.create.refLimitReached', { limit }))
|
||
return
|
||
}
|
||
const accepted = files.slice(0, slots)
|
||
if (accepted.length < files.length) {
|
||
appStore.showError(t('batchImage.create.refLimitExceededIgnored', { limit }))
|
||
}
|
||
const next: ReferenceImageDraft[] = []
|
||
for (const file of accepted) {
|
||
if (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type)) {
|
||
appStore.showError(t('batchImage.create.refFormatUnsupported'))
|
||
continue
|
||
}
|
||
if (file.size > 10 * 1024 * 1024) {
|
||
appStore.showError(t('batchImage.create.refFileTooLarge', { name: file.name }))
|
||
continue
|
||
}
|
||
const data = await readFileAsBase64(file)
|
||
next.push({
|
||
id: file.name,
|
||
type: 'reference',
|
||
mime_type: file.type,
|
||
data,
|
||
name: file.name,
|
||
size: file.size,
|
||
})
|
||
}
|
||
referenceImageDrafts.value = [...referenceImageDrafts.value, ...next]
|
||
}
|
||
|
||
function readFileAsBase64(file: File): Promise<string> {
|
||
return new Promise((resolve, reject) => {
|
||
const reader = new FileReader()
|
||
reader.onerror = () => reject(reader.error || new Error('Failed to read file'))
|
||
reader.onload = () => {
|
||
const result = String(reader.result || '')
|
||
resolve(result.includes(',') ? result.slice(result.indexOf(',') + 1) : result)
|
||
}
|
||
reader.readAsDataURL(file)
|
||
})
|
||
}
|
||
|
||
async function loadApiKeys() {
|
||
loadingKeys.value = true
|
||
try {
|
||
const response = await keysAPI.list(1, 100, { status: 'active', sort_by: 'created_at', sort_order: 'desc' })
|
||
apiKeys.value = response.items || []
|
||
if (!selectedApiKey.value && geminiApiKeys.value.length > 0) {
|
||
form.apiKeyId = geminiApiKeys.value[0].id
|
||
}
|
||
if (filters.apiKeyId && !geminiApiKeys.value.some(key => String(key.id) === filters.apiKeyId)) {
|
||
filters.apiKeyId = ''
|
||
}
|
||
if (!selectedApiKey.value) {
|
||
availableBatchImageModels.value = []
|
||
form.model = ''
|
||
}
|
||
} catch (error: any) {
|
||
appStore.showError(batchImageErrorMessage(error, batchImageText('loadKeysFailed')))
|
||
} finally {
|
||
loadingKeys.value = false
|
||
}
|
||
}
|
||
|
||
async function loadAvailableModels() {
|
||
const key = selectedApiKey.value
|
||
const requestID = ++modelRequestSeq
|
||
modelLoadError.value = ''
|
||
availableBatchImageModels.value = []
|
||
form.model = ''
|
||
if (!key) return
|
||
|
||
loadingModels.value = true
|
||
try {
|
||
const result = await listBatchImageModels(key.key)
|
||
if (requestID !== modelRequestSeq) return
|
||
const seen = new Set<string>()
|
||
availableBatchImageModels.value = (result.data || [])
|
||
.map(model => String(model.id || '').trim())
|
||
.filter((model) => {
|
||
if (!model || seen.has(model)) return false
|
||
seen.add(model)
|
||
return true
|
||
})
|
||
.map(model => ({ value: model, label: model }))
|
||
form.model = availableBatchImageModels.value[0]?.value || ''
|
||
} catch (error: any) {
|
||
if (requestID !== modelRequestSeq) return
|
||
modelLoadError.value = batchImageErrorMessage(error, batchImageText('loadModelsFailed'))
|
||
} finally {
|
||
if (requestID === modelRequestSeq) {
|
||
loadingModels.value = false
|
||
}
|
||
}
|
||
}
|
||
|
||
async function refreshPage() {
|
||
await loadApiKeys()
|
||
await loadBatchJobs()
|
||
}
|
||
|
||
function applyFilters() {
|
||
pagination.page = 1
|
||
selectedJobIds.value = new Set()
|
||
void loadBatchJobs()
|
||
}
|
||
|
||
function resetFilters() {
|
||
filters.taskName = ''
|
||
filters.apiKeyId = ''
|
||
filters.status = ''
|
||
filters.downloaded = ''
|
||
applyFilters()
|
||
}
|
||
|
||
function listOptions(): BatchImageJobsListOptions {
|
||
const options: BatchImageJobsListOptions = {
|
||
limit: pagination.page_size,
|
||
cursor: String((pagination.page - 1) * pagination.page_size),
|
||
}
|
||
if (filters.taskName.trim()) options.taskName = filters.taskName.trim()
|
||
if (filters.status) options.status = filters.status
|
||
if (filters.downloaded) options.downloaded = filters.downloaded
|
||
return options
|
||
}
|
||
|
||
function toJobRow(job: BatchImageJob, key = selectedApiKey.value): BatchImageJobRow {
|
||
return {
|
||
id: job.id,
|
||
task_name: job.task_name || defaultTaskName(job.created_at),
|
||
parent_batch_id: job.parent_batch_id || null,
|
||
status: job.status,
|
||
model: job.model,
|
||
provider: job.provider,
|
||
item_count: job.item_count,
|
||
success_count: job.success_count,
|
||
fail_count: job.fail_count,
|
||
estimated_cost: job.estimated_cost,
|
||
hold_amount: job.hold_amount,
|
||
actual_cost: job.actual_cost,
|
||
created_at: job.created_at,
|
||
downloaded_at: job.downloaded_at,
|
||
api_key_id: key?.id || 0,
|
||
api_key_name: key?.name || '',
|
||
child_count: 0,
|
||
}
|
||
}
|
||
|
||
function applyChildCounts(rows: BatchImageJobRow[]) {
|
||
const counts = new Map<string, number>()
|
||
for (const row of rows) {
|
||
if (!row.parent_batch_id) continue
|
||
counts.set(row.parent_batch_id, (counts.get(row.parent_batch_id) || 0) + 1)
|
||
}
|
||
return rows.map(row => ({ ...row, child_count: counts.get(row.id) || 0 }))
|
||
}
|
||
|
||
function displayJob<T extends Pick<BatchImageJob, 'id' | 'parent_batch_id' | 'status' | 'item_count' | 'success_count' | 'fail_count' | 'estimated_cost' | 'hold_amount' | 'actual_cost'>>(job: T): T {
|
||
if (job.parent_batch_id) return job
|
||
const children = childrenByParent.value.get(job.id) || []
|
||
if (!children.length) return job
|
||
|
||
const childSuccess = children.reduce((sum, child) => sum + child.success_count, 0)
|
||
const childEstimated = children.reduce((sum, child) => sum + child.estimated_cost, 0)
|
||
const childHold = children.reduce((sum, child) => sum + child.hold_amount, 0)
|
||
const childActual = children.reduce((sum, child) => sum + (child.actual_cost || 0), 0)
|
||
const childActualReady = children.every(child => child.actual_cost !== null)
|
||
const successCount = Math.min(job.item_count, job.success_count + childSuccess)
|
||
const failCount = Math.max(0, job.item_count - successCount)
|
||
const actualCost = job.actual_cost === null
|
||
? (childActualReady ? childActual : null)
|
||
: job.actual_cost + childActual
|
||
|
||
return {
|
||
...job,
|
||
success_count: successCount,
|
||
fail_count: failCount,
|
||
status: failCount === 0 && TERMINAL_STATUSES.has(job.status) ? 'completed' : job.status,
|
||
estimated_cost: job.estimated_cost + childEstimated,
|
||
hold_amount: job.hold_amount + childHold,
|
||
actual_cost: actualCost,
|
||
}
|
||
}
|
||
|
||
function hasChildJobs(batchId: string) {
|
||
return (childrenByParent.value.get(batchId) || []).length > 0
|
||
}
|
||
|
||
function toggleChildRows(batchId: string) {
|
||
const next = new Set(expandedParentIds.value)
|
||
if (next.has(batchId)) next.delete(batchId)
|
||
else next.add(batchId)
|
||
expandedParentIds.value = next
|
||
}
|
||
|
||
function closeMoreMenu() {
|
||
openMoreJobId.value = ''
|
||
}
|
||
|
||
function toggleMoreMenu(job: BatchImageJobRow, event: MouseEvent) {
|
||
if (openMoreJobId.value === job.id) {
|
||
closeMoreMenu()
|
||
return
|
||
}
|
||
const trigger = event.currentTarget as HTMLElement | null
|
||
const rect = trigger?.getBoundingClientRect()
|
||
if (!rect) return
|
||
const menuWidth = 176
|
||
const margin = 8
|
||
const left = Math.max(margin, Math.min(rect.right - menuWidth, window.innerWidth - menuWidth - margin))
|
||
const top = Math.min(rect.bottom + margin, window.innerHeight - 96)
|
||
moreMenuStyle.value = {
|
||
left: `${left}px`,
|
||
top: `${Math.max(margin, top)}px`,
|
||
}
|
||
openMoreJobId.value = job.id
|
||
}
|
||
|
||
function cancelPromptPopoverClose() {
|
||
if (!promptPopoverCloseTimer) return
|
||
clearTimeout(promptPopoverCloseTimer)
|
||
promptPopoverCloseTimer = null
|
||
}
|
||
|
||
function cancelPromptPopoverOpen() {
|
||
if (!promptPopoverOpenTimer) return
|
||
clearTimeout(promptPopoverOpenTimer)
|
||
promptPopoverOpenTimer = null
|
||
}
|
||
|
||
function closePromptPopover() {
|
||
cancelPromptPopoverOpen()
|
||
cancelPromptPopoverClose()
|
||
promptPopover.visible = false
|
||
promptPopover.text = ''
|
||
promptPopover.style = {}
|
||
activePromptPopoverTarget = null
|
||
}
|
||
|
||
function schedulePromptPopoverClose() {
|
||
cancelPromptPopoverOpen()
|
||
cancelPromptPopoverClose()
|
||
promptPopoverCloseTimer = setTimeout(() => {
|
||
closePromptPopover()
|
||
}, 180)
|
||
}
|
||
|
||
function schedulePromptPopoverOpen(event: MouseEvent | PointerEvent, text: string) {
|
||
const target = event.currentTarget as HTMLElement | null
|
||
if (!target) return
|
||
const value = String(text || '').trim()
|
||
if (!value || value === '-') return
|
||
activePromptPopoverTarget = target
|
||
cancelPromptPopoverOpen()
|
||
cancelPromptPopoverClose()
|
||
promptPopoverOpenTimer = setTimeout(() => {
|
||
if (activePromptPopoverTarget !== target || !document.body.contains(target)) return
|
||
openPromptPopover(target, value)
|
||
}, 520)
|
||
}
|
||
|
||
function showPromptPopover(event: MouseEvent | FocusEvent, text: string) {
|
||
const value = String(text || '').trim()
|
||
if (!value || value === '-') return
|
||
const target = event.currentTarget as HTMLElement | null
|
||
cancelPromptPopoverClose()
|
||
cancelPromptPopoverOpen()
|
||
if (!target) return
|
||
activePromptPopoverTarget = target
|
||
openPromptPopover(target, value)
|
||
}
|
||
|
||
function openPromptPopover(target: HTMLElement, value: string) {
|
||
const rect = target.getBoundingClientRect()
|
||
if (!rect) return
|
||
const viewportWidth = window.innerWidth || 1280
|
||
const viewportHeight = window.innerHeight || 720
|
||
const width = Math.min(440, Math.max(320, viewportWidth - 32))
|
||
const left = Math.max(16, Math.min(rect.left, viewportWidth - width - 16))
|
||
const estimatedHeight = 178
|
||
const preferredTop = rect.bottom + 8
|
||
const top = preferredTop + estimatedHeight > viewportHeight
|
||
? Math.max(16, rect.top - estimatedHeight - 8)
|
||
: preferredTop
|
||
promptPopover.text = value
|
||
promptPopover.style = {
|
||
left: `${left}px`,
|
||
top: `${top}px`,
|
||
width: `${width}px`,
|
||
}
|
||
promptPopover.visible = true
|
||
}
|
||
|
||
function copyPromptPopover() {
|
||
if (!promptPopover.text) return
|
||
void copyToClipboard(promptPopover.text, t('batchImage.promptPopover.copied'))
|
||
}
|
||
|
||
async function loadBatchJobs() {
|
||
const keys = filteredApiKeys.value
|
||
if (!keys.length) {
|
||
batchJobs.value = []
|
||
pagination.has_more = false
|
||
return
|
||
}
|
||
loadingJobs.value = true
|
||
closeMoreMenu()
|
||
try {
|
||
const options = listOptions()
|
||
const results = await Promise.all(keys.map(async (key) => {
|
||
const result = await listBatchImageJobs(key.key, options)
|
||
return {
|
||
hasMore: Boolean(result.has_more),
|
||
rows: (result.data || []).map(job => toJobRow(job, key)),
|
||
}
|
||
}))
|
||
batchJobs.value = applyChildCounts(results
|
||
.flatMap(result => result.rows)
|
||
.sort((a, b) => b.created_at - a.created_at)
|
||
.slice(0, pagination.page_size))
|
||
pagination.has_more = results.some(result => result.hasMore)
|
||
selectedJobIds.value = new Set([...selectedJobIds.value].filter(id => visibleBatchJobs.value.some(job => job.id === id)))
|
||
} catch (error: any) {
|
||
appStore.showError(batchImageErrorMessage(error, batchImageText('loadJobsFailed')))
|
||
} finally {
|
||
loadingJobs.value = false
|
||
}
|
||
}
|
||
|
||
function upsertJob(job: BatchImageJob) {
|
||
const next = toJobRow(job)
|
||
const index = batchJobs.value.findIndex(item => item.id === job.id)
|
||
if (index >= 0) {
|
||
const rows = [...batchJobs.value]
|
||
rows[index] = { ...next, is_child: rows[index].is_child }
|
||
batchJobs.value = applyChildCounts(rows)
|
||
return
|
||
}
|
||
batchJobs.value = applyChildCounts([next, ...batchJobs.value].slice(0, pagination.page_size))
|
||
}
|
||
|
||
function handlePageChange(page: number) {
|
||
if (page < 1 || page === pagination.page) return
|
||
pagination.page = page
|
||
selectedJobIds.value = new Set()
|
||
void loadBatchJobs()
|
||
}
|
||
|
||
function handlePageSizeChange(value: string | number | boolean | null) {
|
||
if (value === null || typeof value === 'boolean') return
|
||
const nextSize = Math.min(Math.max(Number(value) || 20, 1), 100)
|
||
pagination.page_size = nextSize
|
||
pagination.page = 1
|
||
setPersistedPageSize(nextSize)
|
||
selectedJobIds.value = new Set()
|
||
void loadBatchJobs()
|
||
}
|
||
|
||
function openCreateModal() {
|
||
showCreateModal.value = true
|
||
if (!apiKeys.value.length) {
|
||
void loadApiKeys()
|
||
}
|
||
}
|
||
|
||
function closeCreateModal() {
|
||
if (submitting.value) return
|
||
showCreateModal.value = false
|
||
resetCreateDraft()
|
||
}
|
||
|
||
function resetCreateDraft() {
|
||
form.taskName = ''
|
||
form.responseMimeType = 'image/png'
|
||
promptRows.value = []
|
||
promptDraft.value = ''
|
||
customIdDraft.value = ''
|
||
outputCountDraft.value = 1
|
||
referenceImageDrafts.value = []
|
||
}
|
||
|
||
function closeDetail() {
|
||
closePromptPopover()
|
||
currentJob.value = null
|
||
selectedBatchId.value = ''
|
||
selectedBatchApiKeyId.value = 0
|
||
items.value = []
|
||
clearItemPreviews()
|
||
}
|
||
|
||
function keyForSelectedBatch(): ApiKey | null {
|
||
if (selectedBatchApiKeyId.value) {
|
||
const key = geminiApiKeys.value.find(item => item.id === selectedBatchApiKeyId.value)
|
||
if (key) return key
|
||
}
|
||
return selectedApiKey.value
|
||
}
|
||
|
||
function requireApiKey(): ApiKey | null {
|
||
if (!selectedApiKey.value) {
|
||
appStore.showError(batchImageText('selectApiKey'))
|
||
return null
|
||
}
|
||
return selectedApiKey.value
|
||
}
|
||
|
||
function validateForm(): boolean {
|
||
if (!requireApiKey()) return false
|
||
if (!form.model) {
|
||
appStore.showError(availableBatchImageModels.value.length === 0 ? batchImageText('noModelsForKey') : batchImageText('selectModel'))
|
||
return false
|
||
}
|
||
if (parsedItems.value.length === 0) {
|
||
appStore.showError(batchImageText('promptRequired'))
|
||
return false
|
||
}
|
||
if (estimatedOutputCount.value > BATCH_IMAGE_MAX_OUTPUTS_PER_JOB) {
|
||
appStore.showError(batchImageText('tooManyOutputImages'))
|
||
return false
|
||
}
|
||
const refLimit = selectedModelReferenceLimit.value
|
||
if (promptRows.value.some(row => row.reference_images.length > refLimit)) {
|
||
appStore.showError(batchImageText('tooManyReferenceImages'))
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
async function submitJob() {
|
||
if (submitting.value) return
|
||
if (promptDraft.value.trim()) addPromptRow()
|
||
if (!validateForm()) return
|
||
const key = requireApiKey()
|
||
if (!key) return
|
||
submitting.value = true
|
||
try {
|
||
const job = await submitBatchImageJob(
|
||
key.key,
|
||
{
|
||
model: form.model,
|
||
task_name: form.taskName.trim() || defaultTaskName(),
|
||
image_size: '1K',
|
||
response_mime_type: form.responseMimeType,
|
||
items: parsedItems.value,
|
||
},
|
||
`sub2api-ui-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
|
||
)
|
||
currentJob.value = job
|
||
selectedBatchId.value = job.id
|
||
selectedBatchApiKeyId.value = key.id
|
||
items.value = []
|
||
upsertJob(job)
|
||
showCreateModal.value = false
|
||
resetCreateDraft()
|
||
appStore.showSuccess(batchImageText('submitted'))
|
||
void loadItems()
|
||
startPolling()
|
||
} catch (error: any) {
|
||
appStore.showError(batchImageErrorMessage(error, batchImageText('submitFailed')))
|
||
} finally {
|
||
submitting.value = false
|
||
}
|
||
}
|
||
|
||
async function refreshSelected() {
|
||
if (!selectedBatchId.value) return
|
||
const key = keyForSelectedBatch() || requireApiKey()
|
||
if (!key) return
|
||
refreshing.value = true
|
||
try {
|
||
const job = await getBatchImageJob(key.key, selectedBatchId.value)
|
||
currentJob.value = job
|
||
upsertJob(job)
|
||
if (TERMINAL_STATUSES.has(job.status)) stopPolling()
|
||
} catch (error: any) {
|
||
appStore.showError(batchImageErrorMessage(error, batchImageText('refreshFailed')))
|
||
} finally {
|
||
refreshing.value = false
|
||
}
|
||
}
|
||
|
||
async function refreshDetail() {
|
||
await Promise.all([
|
||
refreshSelected(),
|
||
loadItems(),
|
||
])
|
||
}
|
||
|
||
function selectJob(batchId: string) {
|
||
const row = batchJobs.value.find(job => job.id === batchId)
|
||
if (row?.api_key_id && geminiApiKeys.value.some(key => key.id === row.api_key_id)) {
|
||
form.apiKeyId = row.api_key_id
|
||
selectedBatchApiKeyId.value = row.api_key_id
|
||
} else {
|
||
selectedBatchApiKeyId.value = 0
|
||
}
|
||
selectedBatchId.value = batchId
|
||
currentJob.value = null
|
||
items.value = []
|
||
void refreshSelected()
|
||
void loadItems()
|
||
}
|
||
|
||
function startPolling() {
|
||
stopPolling()
|
||
pollTimer = setInterval(() => {
|
||
if (!currentJob.value || TERMINAL_STATUSES.has(currentJob.value.status)) {
|
||
stopPolling()
|
||
return
|
||
}
|
||
void refreshSelected()
|
||
}, 8000)
|
||
}
|
||
|
||
function stopPolling() {
|
||
if (pollTimer) {
|
||
clearInterval(pollTimer)
|
||
pollTimer = null
|
||
}
|
||
}
|
||
|
||
function canCancel(job: Pick<BatchImageJob, 'status'>) {
|
||
return !TERMINAL_STATUSES.has(job.status)
|
||
}
|
||
|
||
function canDownload(job: Pick<BatchImageJob, 'status' | 'success_count'>) {
|
||
return job.status === 'completed' && job.success_count > 0
|
||
}
|
||
|
||
function canRetry(job: Pick<BatchImageJob, 'status' | 'fail_count'>) {
|
||
const display = 'id' in job ? displayJob(job as BatchImageJob) : job
|
||
return TERMINAL_STATUSES.has(display.status) && display.fail_count > 0
|
||
}
|
||
|
||
function isDownloadingJob(batchId: string) {
|
||
return downloading.value && downloadingBatchId.value === batchId
|
||
}
|
||
|
||
function applyJobApiKey(job: BatchImageJobRow | Pick<BatchImageJob, 'id'>) {
|
||
if ('api_key_id' in job && job.api_key_id && geminiApiKeys.value.some(key => key.id === job.api_key_id)) {
|
||
form.apiKeyId = job.api_key_id
|
||
}
|
||
}
|
||
|
||
function apiKeyForJob(job: BatchImageJobRow | Pick<BatchImageJob, 'id'>): ApiKey | null {
|
||
if ('api_key_id' in job && job.api_key_id) {
|
||
return geminiApiKeys.value.find(key => key.id === job.api_key_id) || null
|
||
}
|
||
return selectedApiKey.value
|
||
}
|
||
|
||
function toggleJobSelection(batchId: string, checked: boolean) {
|
||
const next = new Set(selectedJobIds.value)
|
||
if (checked) next.add(batchId)
|
||
else next.delete(batchId)
|
||
selectedJobIds.value = next
|
||
}
|
||
|
||
function toggleAllVisible(checked: boolean) {
|
||
const next = new Set(selectedJobIds.value)
|
||
for (const job of visibleBatchJobs.value) {
|
||
if (checked) next.add(job.id)
|
||
else next.delete(job.id)
|
||
}
|
||
selectedJobIds.value = next
|
||
}
|
||
|
||
function canDeleteRecord(job: Pick<BatchImageJob, 'status'>) {
|
||
return TERMINAL_STATUSES.has(job.status)
|
||
}
|
||
|
||
async function cancelSelected() {
|
||
if (!currentJob.value) return
|
||
const key = keyForSelectedBatch() || requireApiKey()
|
||
if (!key) return
|
||
if (!window.confirm(batchImageText('cancelConfirm'))) return
|
||
cancelling.value = true
|
||
try {
|
||
const job = await cancelBatchImageJob(key.key, currentJob.value.id)
|
||
currentJob.value = job
|
||
upsertJob(job)
|
||
appStore.showSuccess(batchImageText('cancelled'))
|
||
} catch (error: any) {
|
||
appStore.showError(batchImageErrorMessage(error, batchImageText('cancelFailed')))
|
||
} finally {
|
||
cancelling.value = false
|
||
}
|
||
}
|
||
|
||
async function downloadSelected() {
|
||
if (!currentJob.value) return
|
||
await downloadJob(currentJob.value)
|
||
}
|
||
|
||
async function retrySelected() {
|
||
if (!currentJob.value) return
|
||
await retryFailedJob(currentJob.value)
|
||
}
|
||
|
||
async function retryFailedJob(job: BatchImageJobRow | BatchImageJob) {
|
||
if (!canRetry(job) || retryingBatchId.value) return
|
||
closeMoreMenu()
|
||
const key = apiKeyForJob(job) || keyForSelectedBatch() || requireApiKey()
|
||
if (!key) return
|
||
retryingBatchId.value = job.id
|
||
try {
|
||
const sourceItems = await ensureItemsForRetry(key.key, job.id)
|
||
const failedItems = sourceItems
|
||
.filter(item => item.status === 'failed')
|
||
.map(item => ({ custom_id: retryCustomID(item.custom_id), prompt: String(item.prompt_preview || '').trim() }))
|
||
.filter(item => item.prompt)
|
||
if (failedItems.length === 0) {
|
||
appStore.showError(batchImageText('retryMissingPrompts'))
|
||
return
|
||
}
|
||
const retryJob = await submitBatchImageJob(
|
||
key.key,
|
||
{
|
||
model: job.model,
|
||
task_name: `${job.task_name || defaultTaskName()} ${t('batchImage.messages.retryTaskNameSuffix')}`,
|
||
parent_batch_id: rootBatchIdForRetry(job),
|
||
provider: job.provider,
|
||
image_size: '1K',
|
||
response_mime_type: form.responseMimeType,
|
||
items: failedItems,
|
||
},
|
||
`sub2api-ui-retry-${job.id}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
|
||
)
|
||
currentJob.value = retryJob
|
||
selectedBatchId.value = retryJob.id
|
||
selectedBatchApiKeyId.value = key.id
|
||
items.value = []
|
||
upsertJob(retryJob)
|
||
if (retryJob.parent_batch_id) {
|
||
expandedParentIds.value = new Set([...expandedParentIds.value, retryJob.parent_batch_id])
|
||
}
|
||
appStore.showSuccess(batchImageText('retrySubmitted'))
|
||
void loadItems()
|
||
startPolling()
|
||
} catch (error: any) {
|
||
appStore.showError(batchImageErrorMessage(error, batchImageText('retryFailed')))
|
||
} finally {
|
||
retryingBatchId.value = ''
|
||
}
|
||
}
|
||
|
||
async function ensureItemsForRetry(apiKey: string, batchId: string) {
|
||
if (selectedBatchId.value === batchId && items.value.length > 0) {
|
||
return items.value
|
||
}
|
||
const result = await listBatchImageItems(apiKey, batchId)
|
||
return result.data || []
|
||
}
|
||
|
||
function retryCustomID(customID: string) {
|
||
const base = String(customID || 'item').replace(/[^\w.-]+/g, '_').replace(/^_+|_+$/g, '') || 'item'
|
||
return `${base}_retry_${Date.now().toString(36)}`
|
||
}
|
||
|
||
function rootBatchIdForRetry(job: BatchImageJobRow | BatchImageJob) {
|
||
return job.parent_batch_id || job.id
|
||
}
|
||
|
||
async function downloadJob(job: (BatchImageJobRow | Pick<BatchImageJob, 'id'>)) {
|
||
if (downloading.value) return
|
||
closeMoreMenu()
|
||
applyJobApiKey(job)
|
||
const key = apiKeyForJob(job) || requireApiKey()
|
||
if (!key) return
|
||
downloading.value = true
|
||
downloadingBatchId.value = job.id
|
||
try {
|
||
const blob = await downloadBatchImageZip(key.key, job.id)
|
||
saveBlob(blob, `${job.id}.zip`)
|
||
markJobDownloaded(job.id)
|
||
} catch (error: any) {
|
||
appStore.showError(batchImageErrorMessage(error, batchImageText('downloadFailed')))
|
||
} finally {
|
||
downloading.value = false
|
||
downloadingBatchId.value = ''
|
||
}
|
||
}
|
||
|
||
async function downloadSelectedJobs() {
|
||
if (bulkDownloading.value || selectedDownloadableRows.value.length === 0) return
|
||
bulkDownloading.value = true
|
||
try {
|
||
for (const row of selectedDownloadableRows.value) {
|
||
const key = apiKeyForJob(row)
|
||
if (!key) continue
|
||
downloading.value = true
|
||
downloadingBatchId.value = row.id
|
||
const blob = await downloadBatchImageZip(key.key, row.id)
|
||
saveBlob(blob, `${row.id}.zip`)
|
||
markJobDownloaded(row.id)
|
||
}
|
||
appStore.showSuccess(batchImageText('batchDownloadStarted'))
|
||
} catch (error: any) {
|
||
appStore.showError(batchImageErrorMessage(error, batchImageText('downloadFailed')))
|
||
} finally {
|
||
bulkDownloading.value = false
|
||
downloading.value = false
|
||
downloadingBatchId.value = ''
|
||
}
|
||
}
|
||
|
||
async function deleteJob(job: BatchImageJobRow) {
|
||
if (!canDeleteRecord(job) || deletingBatchId.value) return
|
||
closeMoreMenu()
|
||
const key = apiKeyForJob(job)
|
||
if (!key) return
|
||
if (!window.confirm(batchImageText('deleteConfirm'))) return
|
||
deletingBatchId.value = job.id
|
||
try {
|
||
await deleteBatchImageJobRecord(key.key, job.id)
|
||
removeJobFromList(job.id)
|
||
appStore.showSuccess(batchImageText('deleted'))
|
||
} catch (error: any) {
|
||
appStore.showError(batchImageErrorMessage(error, batchImageText('deleteFailed')))
|
||
} finally {
|
||
deletingBatchId.value = ''
|
||
}
|
||
}
|
||
|
||
async function deleteSelectedJobs() {
|
||
const rows = selectedRows.value.filter(job => canDeleteRecord(job))
|
||
if (bulkDeleting.value || rows.length === 0) return
|
||
if (!window.confirm(batchImageText('deleteSelectedConfirm'))) return
|
||
bulkDeleting.value = true
|
||
try {
|
||
for (const row of rows) {
|
||
const key = apiKeyForJob(row)
|
||
if (!key) continue
|
||
deletingBatchId.value = row.id
|
||
await deleteBatchImageJobRecord(key.key, row.id)
|
||
removeJobFromList(row.id)
|
||
}
|
||
appStore.showSuccess(batchImageText('deleted'))
|
||
} catch (error: any) {
|
||
appStore.showError(batchImageErrorMessage(error, batchImageText('deleteFailed')))
|
||
} finally {
|
||
bulkDeleting.value = false
|
||
deletingBatchId.value = ''
|
||
}
|
||
}
|
||
|
||
function markJobDownloaded(batchId: string) {
|
||
const downloadedAt = Math.floor(Date.now() / 1000)
|
||
batchJobs.value = batchJobs.value.map(job => job.id === batchId ? { ...job, downloaded_at: job.downloaded_at || downloadedAt } : job)
|
||
if (currentJob.value?.id === batchId && !currentJob.value.downloaded_at) {
|
||
currentJob.value = { ...currentJob.value, downloaded_at: downloadedAt }
|
||
}
|
||
}
|
||
|
||
function removeJobFromList(batchId: string) {
|
||
batchJobs.value = batchJobs.value.filter(job => job.id !== batchId)
|
||
toggleJobSelection(batchId, false)
|
||
if (currentJob.value?.id === batchId) closeDetail()
|
||
}
|
||
|
||
function canLoadItemPreview(item: BatchImageItem) {
|
||
return (item.status === 'succeeded' || item.status === 'success') && item.image_count > 0
|
||
}
|
||
|
||
function isSuccessfulImageItem(item: Pick<BatchImageItem, 'status' | 'image_count'>) {
|
||
return (item.status === 'succeeded' || item.status === 'success') && item.image_count > 0
|
||
}
|
||
|
||
function detailRootBatchId() {
|
||
return currentJob.value?.parent_batch_id || selectedBatchId.value || currentJob.value?.id || ''
|
||
}
|
||
|
||
function isChildDetailItem(item: Pick<BatchImageDetailItem, 'batch_id'>) {
|
||
const rootBatchId = detailRootBatchId()
|
||
return Boolean(rootBatchId && item.batch_id && item.batch_id !== rootBatchId)
|
||
}
|
||
|
||
function retrySourceCustomID(customID: string) {
|
||
return String(customID || '').replace(/(?:_retry_[a-z0-9]+)+$/i, '')
|
||
}
|
||
|
||
function isRecoveredOriginalFailure(item: BatchImageDetailItem) {
|
||
const rootBatchId = detailRootBatchId()
|
||
return Boolean(
|
||
rootBatchId
|
||
&& item.batch_id === rootBatchId
|
||
&& item.status === 'failed'
|
||
&& recoveredOriginalCustomIds.value.has(item.custom_id),
|
||
)
|
||
}
|
||
|
||
function detailItemRowClass(item: BatchImageDetailItem) {
|
||
if (isRecoveredOriginalFailure(item)) {
|
||
return 'bg-gray-50/80 text-gray-400 hover:bg-gray-100/80 dark:bg-dark-900/60 dark:text-gray-500 dark:hover:bg-dark-800/70'
|
||
}
|
||
return 'hover:bg-gray-50/70 dark:hover:bg-dark-800/60'
|
||
}
|
||
|
||
function previewCacheSupported() {
|
||
return typeof window !== 'undefined' && 'indexedDB' in window
|
||
}
|
||
|
||
function previewCacheKey(batchId: string, customID: string, imageIndex = 0) {
|
||
return [batchId, customID, imageIndex].map(part => encodeURIComponent(String(part))).join(':')
|
||
}
|
||
|
||
function itemPreviewKey(item: Pick<BatchImageItem, 'batch_id' | 'custom_id'>) {
|
||
return previewCacheKey(item.batch_id || selectedBatchId.value || currentJob.value?.id || '', item.custom_id, 0)
|
||
}
|
||
|
||
function idbRequest<T>(request: IDBRequest<T>): Promise<T> {
|
||
return new Promise((resolve, reject) => {
|
||
request.onsuccess = () => resolve(request.result)
|
||
request.onerror = () => reject(request.error)
|
||
})
|
||
}
|
||
|
||
function openPreviewCacheDB(): Promise<IDBDatabase | null> {
|
||
if (!previewCacheSupported()) return Promise.resolve(null)
|
||
if (previewCacheDBPromise) return previewCacheDBPromise
|
||
|
||
previewCacheDBPromise = new Promise((resolve) => {
|
||
const request = window.indexedDB.open(PREVIEW_CACHE_DB_NAME, 1)
|
||
request.onupgradeneeded = () => {
|
||
const db = request.result
|
||
if (!db.objectStoreNames.contains(PREVIEW_CACHE_STORE_NAME)) {
|
||
const store = db.createObjectStore(PREVIEW_CACHE_STORE_NAME, { keyPath: 'key' })
|
||
store.createIndex('lastAccessedAt', 'lastAccessedAt', { unique: false })
|
||
}
|
||
}
|
||
request.onsuccess = () => resolve(request.result)
|
||
request.onerror = () => resolve(null)
|
||
request.onblocked = () => resolve(null)
|
||
})
|
||
return previewCacheDBPromise
|
||
}
|
||
|
||
async function getCachedPreviewBlob(cacheKey: string): Promise<Blob | null> {
|
||
const db = await openPreviewCacheDB()
|
||
if (!db) return null
|
||
const record = await idbRequest<PreviewCacheRecord | undefined>(
|
||
db.transaction(PREVIEW_CACHE_STORE_NAME, 'readonly').objectStore(PREVIEW_CACHE_STORE_NAME).get(cacheKey),
|
||
).catch(() => undefined)
|
||
if (!record?.blob) return null
|
||
|
||
const now = Date.now()
|
||
if (now - record.createdAt > PREVIEW_CACHE_MAX_AGE_MS) {
|
||
void deleteCachedPreview(cacheKey)
|
||
return null
|
||
}
|
||
void touchCachedPreview(cacheKey, now)
|
||
return record.blob
|
||
}
|
||
|
||
async function hydrateCachedItemPreviews(detailItems: BatchImageDetailItem[]) {
|
||
const previewableItems = detailItems.filter(item => canLoadItemPreview(item))
|
||
if (!previewableItems.length || !previewCacheSupported()) return
|
||
|
||
await Promise.all(previewableItems.map(async (item) => {
|
||
const batchId = item.batch_id || selectedBatchId.value || currentJob.value?.id || ''
|
||
const previewKey = itemPreviewKey(item)
|
||
if (!batchId || itemPreviewUrls[previewKey] || previewErrorIds.value.has(previewKey)) return
|
||
const cached = await getCachedPreviewBlob(previewCacheKey(batchId, item.custom_id, 0)).catch(() => null)
|
||
if (!cached || itemPreviewUrls[previewKey]) return
|
||
itemPreviewUrls[previewKey] = URL.createObjectURL(cached)
|
||
}))
|
||
}
|
||
|
||
async function putCachedPreviewBlob(cacheKey: string, blob: Blob) {
|
||
const db = await openPreviewCacheDB()
|
||
if (!db) return
|
||
const now = Date.now()
|
||
const record: PreviewCacheRecord = {
|
||
key: cacheKey,
|
||
blob,
|
||
size: blob.size,
|
||
createdAt: now,
|
||
lastAccessedAt: now,
|
||
}
|
||
await idbRequest(db.transaction(PREVIEW_CACHE_STORE_NAME, 'readwrite').objectStore(PREVIEW_CACHE_STORE_NAME).put(record)).catch(() => null)
|
||
void cleanupPreviewCache()
|
||
}
|
||
|
||
async function touchCachedPreview(cacheKey: string, lastAccessedAt: number) {
|
||
const db = await openPreviewCacheDB()
|
||
if (!db) return
|
||
const record = await idbRequest<PreviewCacheRecord | undefined>(
|
||
db.transaction(PREVIEW_CACHE_STORE_NAME, 'readonly').objectStore(PREVIEW_CACHE_STORE_NAME).get(cacheKey),
|
||
).catch(() => undefined)
|
||
if (!record) return
|
||
record.lastAccessedAt = lastAccessedAt
|
||
await idbRequest(db.transaction(PREVIEW_CACHE_STORE_NAME, 'readwrite').objectStore(PREVIEW_CACHE_STORE_NAME).put(record)).catch(() => null)
|
||
}
|
||
|
||
async function deleteCachedPreview(cacheKey: string) {
|
||
const db = await openPreviewCacheDB()
|
||
if (!db) return
|
||
await idbRequest(db.transaction(PREVIEW_CACHE_STORE_NAME, 'readwrite').objectStore(PREVIEW_CACHE_STORE_NAME).delete(cacheKey)).catch(() => null)
|
||
}
|
||
|
||
async function cleanupPreviewCache() {
|
||
const db = await openPreviewCacheDB()
|
||
if (!db) return
|
||
const records = await idbRequest<PreviewCacheRecord[]>(
|
||
db.transaction(PREVIEW_CACHE_STORE_NAME, 'readonly').objectStore(PREVIEW_CACHE_STORE_NAME).getAll(),
|
||
).catch(() => [])
|
||
if (!records.length) return
|
||
|
||
const now = Date.now()
|
||
const sorted = [...records].sort((a, b) => a.lastAccessedAt - b.lastAccessedAt)
|
||
const deleteKeys = new Set<string>()
|
||
let totalBytes = 0
|
||
let keptCount = 0
|
||
|
||
for (const record of sorted) {
|
||
if (now - record.createdAt > PREVIEW_CACHE_MAX_AGE_MS) {
|
||
deleteKeys.add(record.key)
|
||
continue
|
||
}
|
||
totalBytes += record.size || record.blob?.size || 0
|
||
keptCount += 1
|
||
}
|
||
|
||
for (const record of sorted) {
|
||
if (deleteKeys.has(record.key)) continue
|
||
if (keptCount <= PREVIEW_CACHE_MAX_ENTRIES && totalBytes <= PREVIEW_CACHE_MAX_BYTES) break
|
||
deleteKeys.add(record.key)
|
||
totalBytes -= record.size || record.blob?.size || 0
|
||
keptCount -= 1
|
||
}
|
||
|
||
if (!deleteKeys.size) return
|
||
const store = db.transaction(PREVIEW_CACHE_STORE_NAME, 'readwrite').objectStore(PREVIEW_CACHE_STORE_NAME)
|
||
for (const key of deleteKeys) {
|
||
store.delete(key)
|
||
}
|
||
}
|
||
|
||
async function createThumbnailBlob(blob: Blob): Promise<Blob> {
|
||
const source = await loadPreviewImageSource(blob)
|
||
const width = source.width
|
||
const height = source.height
|
||
const scale = Math.min(1, PREVIEW_THUMBNAIL_MAX_EDGE / Math.max(width, height))
|
||
const targetWidth = Math.max(1, Math.round(width * scale))
|
||
const targetHeight = Math.max(1, Math.round(height * scale))
|
||
const canvas = document.createElement('canvas')
|
||
canvas.width = targetWidth
|
||
canvas.height = targetHeight
|
||
const ctx = canvas.getContext('2d')
|
||
if (!ctx) throw new Error('canvas unavailable')
|
||
ctx.drawImage(source.image, 0, 0, targetWidth, targetHeight)
|
||
source.close()
|
||
return await new Promise<Blob>((resolve, reject) => {
|
||
canvas.toBlob((thumbnail) => {
|
||
if (thumbnail) resolve(thumbnail)
|
||
else reject(new Error('thumbnail unavailable'))
|
||
}, 'image/webp', PREVIEW_THUMBNAIL_QUALITY)
|
||
})
|
||
}
|
||
|
||
async function loadPreviewImageSource(blob: Blob): Promise<{ image: PreviewImageSource, width: number, height: number, close: () => void }> {
|
||
if ('createImageBitmap' in window) {
|
||
const bitmap = await window.createImageBitmap(blob)
|
||
return {
|
||
image: bitmap,
|
||
width: bitmap.width,
|
||
height: bitmap.height,
|
||
close: () => bitmap.close(),
|
||
}
|
||
}
|
||
|
||
const url = URL.createObjectURL(blob)
|
||
try {
|
||
const image = await new Promise<HTMLImageElement>((resolve, reject) => {
|
||
const img = new Image()
|
||
img.onload = () => resolve(img)
|
||
img.onerror = () => reject(new Error('image unavailable'))
|
||
img.src = url
|
||
})
|
||
return {
|
||
image,
|
||
width: image.naturalWidth || image.width,
|
||
height: image.naturalHeight || image.height,
|
||
close: () => URL.revokeObjectURL(url),
|
||
}
|
||
} catch (error) {
|
||
URL.revokeObjectURL(url)
|
||
throw error
|
||
}
|
||
}
|
||
|
||
async function loadItems() {
|
||
const batchId = selectedBatchId.value || currentJob.value?.id || ''
|
||
if (!batchId) return
|
||
const key = keyForSelectedBatch() || requireApiKey()
|
||
if (!key) return
|
||
loadingItems.value = true
|
||
try {
|
||
clearItemPreviews()
|
||
const jobs = detailJobsForBatch(batchId)
|
||
const results = await Promise.all(jobs.map(async (job) => {
|
||
const result = await listBatchImageItems(key.key, job.id)
|
||
return (result.data || []).map(item => ({
|
||
...item,
|
||
batch_id: job.id,
|
||
source_task_name: detailSourceName(job, batchId),
|
||
}))
|
||
}))
|
||
const detailItems = results.flat()
|
||
items.value = detailItems
|
||
void hydrateCachedItemPreviews(detailItems)
|
||
} catch (error: any) {
|
||
appStore.showError(batchImageErrorMessage(error, batchImageText('loadItemsFailed')))
|
||
} finally {
|
||
loadingItems.value = false
|
||
}
|
||
}
|
||
|
||
function detailJobsForBatch(batchId: string): BatchImageJobRow[] {
|
||
const row = batchJobs.value.find(job => job.id === batchId)
|
||
const base = row || (currentJob.value && currentJob.value.id === batchId ? toJobRow(currentJob.value, keyForSelectedBatch() || selectedApiKey.value) : null)
|
||
if (!base) return []
|
||
if (base.parent_batch_id) return [base]
|
||
return [base, ...(childrenByParent.value.get(base.id) || [])]
|
||
}
|
||
|
||
function detailSourceName(job: Pick<BatchImageJobRow, 'id' | 'task_name' | 'parent_batch_id'>, rootBatchId: string) {
|
||
const name = job.task_name || job.id
|
||
if (job.id === rootBatchId) return t('batchImage.detail.mainTask', { name })
|
||
return t('batchImage.detail.childTask', { name })
|
||
}
|
||
|
||
async function loadItemPreview(item: BatchImageItem) {
|
||
const batchId = item.batch_id || selectedBatchId.value || currentJob.value?.id || ''
|
||
const previewKey = itemPreviewKey(item)
|
||
if (!batchId || !canLoadItemPreview(item) || (itemPreviewUrls[previewKey] && !previewErrorIds.value.has(previewKey))) return
|
||
const key = keyForSelectedBatch() || requireApiKey()
|
||
if (!key) return
|
||
const cacheKey = previewCacheKey(batchId, item.custom_id, 0)
|
||
previewLoadingIds.value = new Set([...previewLoadingIds.value, previewKey])
|
||
try {
|
||
previewErrorIds.value = new Set([...previewErrorIds.value].filter(id => id !== previewKey))
|
||
if (itemPreviewUrls[previewKey]) {
|
||
URL.revokeObjectURL(itemPreviewUrls[previewKey])
|
||
delete itemPreviewUrls[previewKey]
|
||
}
|
||
const cached = await getCachedPreviewBlob(cacheKey)
|
||
if (cached) {
|
||
itemPreviewUrls[previewKey] = URL.createObjectURL(cached)
|
||
return
|
||
}
|
||
const blob = await getBatchImageItemContent(key.key, batchId, item.custom_id, 0)
|
||
const thumbnail = await createThumbnailBlob(blob).catch(() => blob)
|
||
itemPreviewUrls[previewKey] = URL.createObjectURL(thumbnail)
|
||
if (thumbnail !== blob || thumbnail.size <= 1024 * 1024) {
|
||
void putCachedPreviewBlob(cacheKey, thumbnail)
|
||
}
|
||
} catch (error: any) {
|
||
previewErrorIds.value = new Set([...previewErrorIds.value, previewKey])
|
||
appStore.showError(batchImageErrorMessage(error, batchImageText('loadPreviewFailed')))
|
||
} finally {
|
||
const next = new Set(previewLoadingIds.value)
|
||
next.delete(previewKey)
|
||
previewLoadingIds.value = next
|
||
}
|
||
}
|
||
|
||
function openImagePreview(item: BatchImageItem) {
|
||
const previewKey = itemPreviewKey(item)
|
||
if (!itemPreviewUrls[previewKey] || previewErrorIds.value.has(previewKey)) return
|
||
previewImageItem.value = item
|
||
}
|
||
|
||
function closeImagePreview() {
|
||
previewImageItem.value = null
|
||
}
|
||
|
||
function handlePreviewError(customID: string) {
|
||
if (itemPreviewUrls[customID]) {
|
||
URL.revokeObjectURL(itemPreviewUrls[customID])
|
||
delete itemPreviewUrls[customID]
|
||
}
|
||
previewErrorIds.value = new Set([...previewErrorIds.value, customID])
|
||
}
|
||
|
||
function clearItemPreviews() {
|
||
closePromptPopover()
|
||
for (const url of Object.values(itemPreviewUrls)) {
|
||
if (url) URL.revokeObjectURL(url)
|
||
}
|
||
for (const key of Object.keys(itemPreviewUrls)) {
|
||
delete itemPreviewUrls[key]
|
||
}
|
||
previewLoadingIds.value = new Set()
|
||
previewErrorIds.value = new Set()
|
||
previewImageItem.value = null
|
||
}
|
||
|
||
function copyInstruction() {
|
||
void copyToClipboard(agentInstruction.value, batchImageText('copiedInstruction'))
|
||
}
|
||
|
||
function statusLabel(jobOrStatus: BatchImageStatus | Pick<BatchImageJob, 'status' | 'success_count' | 'fail_count'>) {
|
||
const status = typeof jobOrStatus === 'string' ? jobOrStatus : jobOrStatus.status
|
||
if (typeof jobOrStatus !== 'string' && status === 'completed' && jobOrStatus.fail_count > 0) {
|
||
if (jobOrStatus.success_count > 0) return t('batchImage.status.partialSuccess')
|
||
return t('batchImage.status.allFailed')
|
||
}
|
||
const statusKeys: Record<string, string> = {
|
||
queued: 'queued',
|
||
running: 'running',
|
||
indexing: 'processingResults',
|
||
processing_results: 'processingResults',
|
||
settling: 'settling',
|
||
completed: 'completed',
|
||
failed: 'failed',
|
||
cancelled: 'cancelled',
|
||
output_deleted: 'outputDeleted',
|
||
}
|
||
const key = statusKeys[status]
|
||
return key ? t(`batchImage.status.${key}`) : status
|
||
}
|
||
|
||
function statusBadgeClass(jobOrStatus: BatchImageStatus | Pick<BatchImageJob, 'status' | 'success_count' | 'fail_count'>) {
|
||
const status = typeof jobOrStatus === 'string' ? jobOrStatus : jobOrStatus.status
|
||
if (typeof jobOrStatus !== 'string' && status === 'completed' && jobOrStatus.fail_count > 0) {
|
||
if (jobOrStatus.success_count > 0) return 'badge-warning'
|
||
return 'badge-danger'
|
||
}
|
||
if (status === 'completed') return 'badge-success'
|
||
if (status === 'failed' || status === 'cancelled') return 'badge-danger'
|
||
if (status === 'output_deleted') return 'badge-gray'
|
||
return 'badge-primary'
|
||
}
|
||
|
||
function itemStatusLabel(status: string) {
|
||
const statusKeys: Record<string, string> = {
|
||
pending: 'pending',
|
||
succeeded: 'succeeded',
|
||
success: 'succeeded',
|
||
failed: 'failed',
|
||
cancelled: 'cancelled',
|
||
}
|
||
const key = statusKeys[status]
|
||
return key ? t(`batchImage.itemStatus.${key}`) : status
|
||
}
|
||
|
||
function itemDisplayStatusLabel(item: BatchImageDetailItem) {
|
||
if (isRecoveredOriginalFailure(item)) return t('batchImage.itemStatus.recovered')
|
||
return itemStatusLabel(item.status)
|
||
}
|
||
|
||
function itemStatusBadgeClass(status: string) {
|
||
if (status === 'succeeded' || status === 'success') return 'badge-success'
|
||
if (status === 'failed' || status === 'cancelled') return 'badge-danger'
|
||
return 'badge-primary'
|
||
}
|
||
|
||
function itemDisplayStatusBadgeClass(item: BatchImageDetailItem) {
|
||
if (isRecoveredOriginalFailure(item)) return 'badge-gray'
|
||
return itemStatusBadgeClass(item.status)
|
||
}
|
||
|
||
function itemResultLabel(item: BatchImageDetailItem) {
|
||
if (isRecoveredOriginalFailure(item)) return t('batchImage.itemResult.recoveredByRetry')
|
||
if (item.error) return friendlyItemError(item.error)
|
||
if (item.status === 'succeeded' || item.status === 'success') {
|
||
return itemPreviewUrls[itemPreviewKey(item)] ? t('batchImage.itemResult.readyPreview') : t('batchImage.itemResult.readyDownload')
|
||
}
|
||
if (item.status === 'failed') return t('batchImage.itemResult.noUsableImage')
|
||
if (item.status === 'cancelled') return t('batchImage.itemResult.cancelled')
|
||
return t('batchImage.itemResult.waiting')
|
||
}
|
||
|
||
function itemResultClass(item: BatchImageDetailItem) {
|
||
if (isRecoveredOriginalFailure(item)) return 'bg-gray-100 text-gray-500 ring-gray-200 dark:bg-dark-800 dark:text-gray-400 dark:ring-dark-700'
|
||
if (item.error || item.status === 'failed' || item.status === 'cancelled') return 'bg-red-50 text-red-700 ring-red-100 dark:bg-red-950/30 dark:text-red-300 dark:ring-red-900/50'
|
||
if (item.status === 'succeeded' || item.status === 'success') return 'bg-emerald-50 text-emerald-700 ring-emerald-100 dark:bg-emerald-950/30 dark:text-emerald-300 dark:ring-emerald-900/50'
|
||
return 'bg-gray-50 text-gray-500 ring-gray-200 dark:bg-dark-800 dark:text-gray-400 dark:ring-dark-700'
|
||
}
|
||
|
||
function friendlyItemError(error: BatchImageItem['error']) {
|
||
if (!error) return '-'
|
||
if (error.code === 'EMPTY_IMAGE_OUTPUT') return t('batchImage.itemResult.emptyImageOutput')
|
||
if (error.code === 'PROVIDER_ITEM_FAILED') return t('batchImage.itemResult.providerItemFailed')
|
||
return error.message || error.code || '-'
|
||
}
|
||
|
||
function formatMoney(value: number | null | undefined) {
|
||
if (value === null || value === undefined || Number.isNaN(Number(value))) return '$0.00'
|
||
return `$${Number(value).toFixed(2)}`
|
||
}
|
||
|
||
function terminalZeroCost(job: Pick<BatchImageJob, 'status' | 'actual_cost'>) {
|
||
return job.actual_cost === null && (job.status === 'failed' || job.status === 'cancelled')
|
||
}
|
||
|
||
function costLabel(job: Pick<BatchImageJob, 'status' | 'hold_amount' | 'actual_cost'>) {
|
||
if (job.actual_cost !== null) return formatMoney(job.actual_cost)
|
||
if (terminalZeroCost(job)) return formatMoney(0)
|
||
return t('batchImage.detail.holdCost', { amount: formatMoney(job.hold_amount) })
|
||
}
|
||
|
||
type BatchImageTextKey =
|
||
| 'loadKeysFailed'
|
||
| 'loadModelsFailed'
|
||
| 'loadJobsFailed'
|
||
| 'selectApiKey'
|
||
| 'noModelsForKey'
|
||
| 'selectModel'
|
||
| 'promptRequired'
|
||
| 'submitted'
|
||
| 'submitFailed'
|
||
| 'refreshFailed'
|
||
| 'cancelConfirm'
|
||
| 'cancelled'
|
||
| 'cancelFailed'
|
||
| 'batchDownloadStarted'
|
||
| 'downloadFailed'
|
||
| 'retrySubmitted'
|
||
| 'retryFailed'
|
||
| 'retryMissingPrompts'
|
||
| 'deleteConfirm'
|
||
| 'deleteSelectedConfirm'
|
||
| 'deleted'
|
||
| 'deleteFailed'
|
||
| 'loadItemsFailed'
|
||
| 'loadPreviewFailed'
|
||
| 'copiedInstruction'
|
||
| 'loadingModels'
|
||
| 'noModels'
|
||
| 'noModelsHint'
|
||
| 'noCompatibleAccount'
|
||
| 'unsupportedProvider'
|
||
| 'providerSubmitFailed'
|
||
| 'vertexGcsBucketMissing'
|
||
| 'queueFailed'
|
||
| 'billingHoldFailed'
|
||
| 'groupDisabled'
|
||
| 'pricingMissing'
|
||
| 'insufficientBalance'
|
||
| 'invalidModel'
|
||
| 'invalidItems'
|
||
| 'duplicateCustomId'
|
||
| 'promptTooLong'
|
||
| 'invalidReferenceImage'
|
||
| 'tooManyReferenceImages'
|
||
| 'referenceImagesTooLarge'
|
||
| 'tooManyOutputImages'
|
||
| 'idempotencyConflict'
|
||
| 'notReady'
|
||
| 'outputDeleted'
|
||
| 'resultMissing'
|
||
| 'itemFailed'
|
||
| 'itemImageIndexOutOfRange'
|
||
| 'downloadLimited'
|
||
| 'downloadTooLarge'
|
||
| 'deleteNotReady'
|
||
| 'disabled'
|
||
| 'authRequired'
|
||
| 'adminReference'
|
||
| 'errorReference'
|
||
|
||
function isZhLocale() {
|
||
return String(locale.value || '').toLowerCase().startsWith('zh')
|
||
}
|
||
|
||
function batchImageText(key: BatchImageTextKey) {
|
||
return t(`batchImage.messages.${key}`)
|
||
}
|
||
|
||
function batchImageErrorReference(error: any) {
|
||
const parts: string[] = []
|
||
const code = String(error?.code || '').trim()
|
||
const requestId = String(error?.requestId || '').trim()
|
||
const status = String(error?.status || '').trim()
|
||
if (code) parts.push(t('batchImage.messages.errorCodeRef', { code }))
|
||
if (requestId) parts.push(t('batchImage.messages.requestIdRef', { id: requestId }))
|
||
if (!code && status) parts.push(t('batchImage.messages.httpStatusRef', { status }))
|
||
return parts.length ? `(${parts.join(isZhLocale() ? ',' : ', ')})` : ''
|
||
}
|
||
|
||
function batchImageAdminError(base: string, error: any) {
|
||
const reference = batchImageErrorReference(error)
|
||
return `${base}${reference ? ` ${reference}` : ''} ${batchImageText('adminReference')}`
|
||
}
|
||
|
||
function batchImagePlainError(base: string) {
|
||
return base
|
||
}
|
||
|
||
function batchImageErrorMessage(error: any, fallback: string) {
|
||
const code = String(error?.code || '').trim()
|
||
const message = String(error?.message || '').trim()
|
||
if (code === 'API_KEY_REQUIRED' || code === '401') {
|
||
return batchImagePlainError(batchImageText('authRequired'))
|
||
}
|
||
if (code === 'BATCH_IMAGE_NO_ACCOUNT_AVAILABLE' || /no compatible batch image account/i.test(message)) {
|
||
return batchImageAdminError(batchImageText('noCompatibleAccount'), error)
|
||
}
|
||
if (code === 'BATCH_IMAGE_UNSUPPORTED_PROVIDER' || /unsupported batch image provider/i.test(message)) {
|
||
return batchImageAdminError(batchImageText('unsupportedProvider'), error)
|
||
}
|
||
if (code === 'BATCH_IMAGE_VERTEX_GCS_BUCKET_MISSING' || code === 'VERTEX_MANAGED_GCS_BUCKET_MISSING') {
|
||
return batchImageAdminError(batchImageText('vertexGcsBucketMissing'), error)
|
||
}
|
||
if (
|
||
code === 'BATCH_IMAGE_PROVIDER_SUBMIT_FAILED' ||
|
||
code === 'BATCH_IMAGE_PROVIDER_MISSING_API_KEY' ||
|
||
code === 'BATCH_IMAGE_PROVIDER_MISSING_SERVICE_ACCOUNT' ||
|
||
code === 'BATCH_IMAGE_PROVIDER_UNSUPPORTED_ACCOUNT'
|
||
) {
|
||
return batchImageAdminError(batchImageText('providerSubmitFailed'), error)
|
||
}
|
||
if (code === 'BATCH_IMAGE_QUEUE_FAILED' || code === 'BATCH_IMAGE_QUEUE_NOT_CONFIGURED') {
|
||
return batchImageAdminError(batchImageText('queueFailed'), error)
|
||
}
|
||
if (code === 'BATCH_IMAGE_BILLING_HOLD_FAILED') {
|
||
return batchImageAdminError(batchImageText('billingHoldFailed'), error)
|
||
}
|
||
if (code === 'BATCH_IMAGE_GROUP_DISABLED') {
|
||
return batchImagePlainError(batchImageText('groupDisabled'))
|
||
}
|
||
if (code === 'BATCH_IMAGE_SETTLEMENT_PRICING_MISSING') {
|
||
return batchImageAdminError(batchImageText('pricingMissing'), error)
|
||
}
|
||
if (code === 'BATCH_IMAGE_INSUFFICIENT_BALANCE') {
|
||
return batchImagePlainError(batchImageText('insufficientBalance'))
|
||
}
|
||
if (code === 'BATCH_IMAGE_INVALID_MODEL') {
|
||
return batchImageText('invalidModel')
|
||
}
|
||
if (code === 'BATCH_IMAGE_INVALID_ITEMS') {
|
||
return batchImageText('invalidItems')
|
||
}
|
||
if (code === 'BATCH_IMAGE_DUPLICATE_CUSTOM_ID') {
|
||
return batchImageText('duplicateCustomId')
|
||
}
|
||
if (code === 'BATCH_IMAGE_PROMPT_TOO_LONG') {
|
||
return batchImageText('promptTooLong')
|
||
}
|
||
if (code === 'BATCH_IMAGE_INVALID_REFERENCE_IMAGE') {
|
||
return batchImageText('invalidReferenceImage')
|
||
}
|
||
if (code === 'BATCH_IMAGE_TOO_MANY_REFERENCE_IMAGES') {
|
||
return batchImageText('tooManyReferenceImages')
|
||
}
|
||
if (code === 'BATCH_IMAGE_REFERENCE_IMAGES_TOO_LARGE') {
|
||
return batchImageText('referenceImagesTooLarge')
|
||
}
|
||
if (code === 'BATCH_IMAGE_TOO_MANY_OUTPUT_IMAGES') {
|
||
return batchImageText('tooManyOutputImages')
|
||
}
|
||
if (code === 'BATCH_IMAGE_IDEMPOTENCY_CONFLICT') {
|
||
return batchImagePlainError(batchImageText('idempotencyConflict'))
|
||
}
|
||
if (code === 'BATCH_IMAGE_NOT_READY') {
|
||
return batchImageText('notReady')
|
||
}
|
||
if (code === 'BATCH_IMAGE_OUTPUT_DELETED') {
|
||
return batchImageText('outputDeleted')
|
||
}
|
||
if (code === 'BATCH_IMAGE_RESULT_MISSING') {
|
||
return batchImageAdminError(batchImageText('resultMissing'), error)
|
||
}
|
||
if (code === 'BATCH_IMAGE_ITEM_FAILED') {
|
||
return batchImagePlainError(batchImageText('itemFailed'))
|
||
}
|
||
if (code === 'BATCH_IMAGE_ITEM_IMAGE_INDEX_OUT_OF_RANGE') {
|
||
return batchImagePlainError(batchImageText('itemImageIndexOutOfRange'))
|
||
}
|
||
if (code === 'BATCH_IMAGE_DOWNLOAD_LIMITED') {
|
||
return batchImageText('downloadLimited')
|
||
}
|
||
if (code === 'BATCH_IMAGE_DOWNLOAD_TOO_LARGE') {
|
||
return batchImageText('downloadTooLarge')
|
||
}
|
||
if (code === 'BATCH_IMAGE_RECORD_DELETE_NOT_READY') {
|
||
return batchImagePlainError(batchImageText('deleteNotReady'))
|
||
}
|
||
if (code === 'BATCH_IMAGE_DISABLED') {
|
||
return batchImageAdminError(batchImageText('disabled'), error)
|
||
}
|
||
if (code === 'INTERNAL_ERROR' || code === '500') {
|
||
return batchImageAdminError(fallback, error)
|
||
}
|
||
if (isZhLocale()) {
|
||
const detail = message ? `${batchImageText('errorReference')}:${message}` : batchImageText('adminReference')
|
||
return `${fallback}。${detail} ${batchImageErrorReference(error)}`
|
||
}
|
||
return message || fallback
|
||
}
|
||
|
||
function formatDate(timestamp: number) {
|
||
if (!timestamp) return ''
|
||
return new Date(timestamp * 1000).toLocaleString()
|
||
}
|
||
|
||
function defaultTaskName(timestamp?: number) {
|
||
const date = timestamp ? new Date(timestamp * 1000) : new Date()
|
||
return date.toLocaleString()
|
||
}
|
||
|
||
onMounted(() => {
|
||
void appStore.fetchPublicSettings()
|
||
void refreshPage()
|
||
void cleanupPreviewCache()
|
||
previewCacheCleanupTimer = setInterval(() => {
|
||
void cleanupPreviewCache()
|
||
}, 60 * 60 * 1000)
|
||
document.addEventListener('click', closeMoreMenu)
|
||
window.addEventListener('resize', closeMoreMenu)
|
||
window.addEventListener('scroll', closeMoreMenu, true)
|
||
window.addEventListener('resize', closePromptPopover)
|
||
window.addEventListener('scroll', closePromptPopover, true)
|
||
})
|
||
|
||
watch(
|
||
() => form.apiKeyId,
|
||
() => {
|
||
void loadAvailableModels()
|
||
},
|
||
)
|
||
|
||
watch(
|
||
() => form.model,
|
||
() => {
|
||
const limit = selectedModelReferenceLimit.value
|
||
if (limit <= 0) {
|
||
referenceImageDrafts.value = []
|
||
return
|
||
}
|
||
if (referenceImageDrafts.value.length > limit) {
|
||
referenceImageDrafts.value = referenceImageDrafts.value.slice(0, limit)
|
||
}
|
||
},
|
||
)
|
||
|
||
onBeforeUnmount(() => {
|
||
stopPolling()
|
||
if (previewCacheCleanupTimer) {
|
||
clearInterval(previewCacheCleanupTimer)
|
||
previewCacheCleanupTimer = null
|
||
}
|
||
clearItemPreviews()
|
||
document.removeEventListener('click', closeMoreMenu)
|
||
window.removeEventListener('resize', closeMoreMenu)
|
||
window.removeEventListener('scroll', closeMoreMenu, true)
|
||
window.removeEventListener('resize', closePromptPopover)
|
||
window.removeEventListener('scroll', closePromptPopover, true)
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.batch-row-action {
|
||
display: flex !important;
|
||
flex-direction: column !important;
|
||
align-items: center !important;
|
||
justify-content: center !important;
|
||
min-width: 42px;
|
||
line-height: 1;
|
||
outline: none;
|
||
}
|
||
|
||
.batch-row-action:focus {
|
||
outline: none;
|
||
}
|
||
|
||
.batch-row-action :deep(svg) {
|
||
margin-right: 0 !important;
|
||
}
|
||
|
||
.batch-prompt-trigger:focus {
|
||
outline: none;
|
||
box-shadow: none;
|
||
}
|
||
|
||
.batch-prompt-popover {
|
||
user-select: text;
|
||
}
|
||
|
||
.batch-prompt-popover p {
|
||
scrollbar-width: thin;
|
||
}
|
||
|
||
.batch-output-count-select {
|
||
height: 36px;
|
||
min-height: 36px;
|
||
padding-top: 0;
|
||
padding-bottom: 0;
|
||
padding-left: 14px;
|
||
padding-right: 34px;
|
||
line-height: 36px;
|
||
}
|
||
</style>
|