mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-31 08:25:35 +00:00
feat(frontend): migrate batch image page to vue-i18n
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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
d4b9797ff7
commit
7bbe170405
@@ -8,7 +8,7 @@
|
||||
<div class="min-w-0">
|
||||
<SearchInput
|
||||
v-model="filters.taskName"
|
||||
placeholder="搜索任务名称"
|
||||
:placeholder="t('batchImage.filters.searchTaskName')"
|
||||
class="w-full"
|
||||
@search="applyFilters"
|
||||
/>
|
||||
@@ -19,18 +19,18 @@
|
||||
</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="'刷新'" @click="refreshPage">
|
||||
<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>
|
||||
@@ -39,9 +39,17 @@
|
||||
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"
|
||||
>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-300">
|
||||
已选择 <span class="font-medium text-gray-900 dark:text-white">{{ selectedJobIds.size }}</span> 个任务
|
||||
</span>
|
||||
<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"
|
||||
@@ -50,7 +58,7 @@
|
||||
@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"
|
||||
@@ -59,7 +67,7 @@
|
||||
@click="deleteSelectedJobs"
|
||||
>
|
||||
<Icon :name="bulkDeleting ? 'refresh' : 'trash'" size="sm" class="mr-1.5" :class="bulkDeleting ? 'animate-spin' : ''" />
|
||||
删除记录
|
||||
{{ t('batchImage.actions.deleteRecords') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -100,7 +108,7 @@
|
||||
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) ? '收起子任务' : `展开 ${row.child_count} 个子任务`"
|
||||
: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" />
|
||||
@@ -113,10 +121,10 @@
|
||||
>
|
||||
<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">
|
||||
{{ row.child_count }} 子任务
|
||||
{{ 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">
|
||||
@@ -134,7 +142,7 @@
|
||||
|
||||
<template #cell-api_key_name="{ value }">
|
||||
<span class="block truncate text-center text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ value || '未记录' }}
|
||||
{{ value || t('batchImage.list.keyNotRecorded') }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -151,7 +159,7 @@
|
||||
<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">共 {{ displayJob(row).item_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>
|
||||
|
||||
@@ -163,7 +171,7 @@
|
||||
|
||||
<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) : '未下载' }}
|
||||
{{ row.downloaded_at ? formatDate(row.downloaded_at) : t('batchImage.list.notDownloaded') }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -172,18 +180,18 @@
|
||||
<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="查看详情"
|
||||
:title="t('batchImage.actions.viewDetail')"
|
||||
@click="selectJob(row.id)"
|
||||
>
|
||||
<Icon name="eye" size="sm" />
|
||||
<span class="text-xs">查看</span>
|
||||
<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="下载 ZIP"
|
||||
:title="t('batchImage.actions.downloadZip')"
|
||||
@click="downloadJob(row)"
|
||||
>
|
||||
<Icon
|
||||
@@ -191,18 +199,18 @@
|
||||
size="sm"
|
||||
:class="isDownloadingJob(row.id) ? 'animate-spin' : ''"
|
||||
/>
|
||||
<span class="text-xs">下载</span>
|
||||
<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="更多操作"
|
||||
:title="t('batchImage.actions.moreActions')"
|
||||
@click.stop="toggleMoreMenu(row, $event)"
|
||||
>
|
||||
<Icon name="more" size="sm" />
|
||||
<span class="text-xs">更多</span>
|
||||
<span class="text-xs">{{ t('common.more') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -211,9 +219,9 @@
|
||||
<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">暂无批量任务</p>
|
||||
<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>
|
||||
@@ -226,14 +234,18 @@
|
||||
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">
|
||||
<span>
|
||||
第 <span class="font-medium">{{ pagination.page }}</span> 页
|
||||
</span>
|
||||
<span>
|
||||
本页 <span class="font-medium">{{ visibleBatchJobs.length }}</span> 条
|
||||
</span>
|
||||
<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>每页</span>
|
||||
<span>{{ t('pagination.perPage') }}</span>
|
||||
<Select
|
||||
v-model="pagination.page_size"
|
||||
:options="batchPageSizeOptions"
|
||||
@@ -250,7 +262,7 @@
|
||||
@click="handlePageChange(pagination.page - 1)"
|
||||
>
|
||||
<Icon name="chevronLeft" size="sm" class="mr-1" />
|
||||
上一页
|
||||
{{ t('pagination.previous') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -258,7 +270,7 @@
|
||||
:disabled="!pagination.has_more || loadingJobs"
|
||||
@click="handlePageChange(pagination.page + 1)"
|
||||
>
|
||||
下一页
|
||||
{{ t('pagination.next') }}
|
||||
<Icon name="chevronRight" size="sm" class="ml-1" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -283,7 +295,7 @@
|
||||
@click="retryFailedJob(job)"
|
||||
>
|
||||
<Icon name="refresh" size="sm" :class="retryingBatchId === job.id ? 'animate-spin' : ''" />
|
||||
重试失败项
|
||||
{{ t('batchImage.actions.retryFailedItems') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="canDeleteRecord(job)"
|
||||
@@ -293,7 +305,7 @@
|
||||
@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>
|
||||
@@ -309,13 +321,13 @@
|
||||
@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">完整 Prompt</span>
|
||||
<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">
|
||||
@@ -324,12 +336,12 @@
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<BaseDialog :show="!!currentJob" title="任务详情" width="extra-wide" @close="closeDetail">
|
||||
<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">状态</p>
|
||||
<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) }}
|
||||
@@ -337,7 +349,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0 text-center">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ hasChildJobs(currentJob.id) ? '汇总结果' : '结果' }}</p>
|
||||
<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>
|
||||
@@ -345,23 +357,23 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="min-w-0 text-center">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">费用</p>
|
||||
<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">下载状态</p>
|
||||
<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) : '未下载' }}
|
||||
{{ 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">明细</h3>
|
||||
<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>
|
||||
|
||||
@@ -378,9 +390,9 @@
|
||||
<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">状态</th>
|
||||
<th class="px-3 py-3 text-center text-sm font-medium text-gray-500 dark:text-gray-400">预览</th>
|
||||
<th class="px-3 py-3 text-center text-sm font-medium text-gray-500 dark:text-gray-400">结果</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">
|
||||
@@ -426,7 +438,7 @@
|
||||
v-if="itemPreviewUrls[itemPreviewKey(item)] && !previewErrorIds.has(itemPreviewKey(item))"
|
||||
type="button"
|
||||
class="block h-full w-full overflow-hidden"
|
||||
:title="`放大压缩预览 ${item.custom_id}`"
|
||||
:title="t('batchImage.detail.previewZoom', { id: item.custom_id })"
|
||||
@click="openImagePreview(item)"
|
||||
>
|
||||
<img
|
||||
@@ -441,12 +453,12 @@
|
||||
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)) ? '重新加载压缩预览' : '加载压缩预览'"
|
||||
: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 ? '不可预览' : '无图片'">
|
||||
<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>
|
||||
@@ -467,10 +479,10 @@
|
||||
<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 ? '正在加载明细...' : '暂无明细' }}
|
||||
{{ 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">
|
||||
排队或生成中的任务会先显示已提交的 prompt,结果整理完成后会更新图片状态。
|
||||
{{ t('batchImage.detail.noItemsHint') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -479,7 +491,7 @@
|
||||
<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)"
|
||||
@@ -489,7 +501,7 @@
|
||||
@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"
|
||||
@@ -503,16 +515,16 @@
|
||||
class="mr-2"
|
||||
:class="currentJob && isDownloadingJob(currentJob.id) ? 'animate-spin' : ''"
|
||||
/>
|
||||
下载 ZIP
|
||||
{{ t('batchImage.actions.downloadZip') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
|
||||
<BaseDialog :show="!!previewImageItem" :title="previewImageItem?.custom_id || '图片预览'" width="extra-wide" :z-index="60" @close="closeImagePreview">
|
||||
<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">
|
||||
当前显示的是浏览器本地缓存的压缩缩略图,清晰度会有影响;需要查看原图请下载 ZIP。
|
||||
{{ 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
|
||||
@@ -525,35 +537,35 @@
|
||||
</div>
|
||||
</BaseDialog>
|
||||
|
||||
<BaseDialog :show="showCreateModal" title="创建批量任务" width="wide" @close="closeCreateModal">
|
||||
<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">任务名称</label>
|
||||
<label class="input-label">{{ t('batchImage.create.taskName') }}</label>
|
||||
<input
|
||||
v-model="form.taskName"
|
||||
type="text"
|
||||
maxlength="255"
|
||||
class="input"
|
||||
placeholder="不填写则默认使用当前时间"
|
||||
: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 ? '加载 API Key 中...' : '请选择 Gemini API Key' }}</option>
|
||||
<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">
|
||||
当前没有可用于批量生图的 Gemini API Key。请先创建并绑定已开启批量生图的 Gemini 分组。
|
||||
{{ t('batchImage.create.noKeysHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label">模型</label>
|
||||
<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>
|
||||
@@ -570,15 +582,15 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label">图片尺寸</label>
|
||||
<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">当前批量任务固定按 1K 图片提交。</p>
|
||||
<p class="input-hint">{{ t('batchImage.create.imageSizeHint') }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label">输出格式</label>
|
||||
<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>
|
||||
@@ -587,9 +599,9 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label">预计生成</label>
|
||||
<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">
|
||||
{{ estimatedOutputCount }} 张 / {{ promptRows.length }} 条
|
||||
{{ t('batchImage.create.estimatedOutputValue', { images: estimatedOutputCount, prompts: promptRows.length }) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -597,14 +609,14 @@
|
||||
<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">已添加 {{ promptRows.length }} 条</span>
|
||||
<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="粘贴 prompt,添加后进入下方列表"
|
||||
: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
|
||||
@@ -612,16 +624,16 @@
|
||||
type="text"
|
||||
maxlength="255"
|
||||
class="input h-9 text-sm"
|
||||
placeholder="Custom ID 可选"
|
||||
:placeholder="t('batchImage.create.customIdPlaceholder')"
|
||||
/>
|
||||
<select
|
||||
v-model.number="outputCountDraft"
|
||||
class="batch-output-count-select input h-9 text-sm"
|
||||
title="每条生成张数"
|
||||
aria-label="每条生成张数"
|
||||
:title="t('batchImage.create.outputCountPerPrompt')"
|
||||
:aria-label="t('batchImage.create.outputCountPerPrompt')"
|
||||
>
|
||||
<option v-for="count in outputCountOptions" :key="count" :value="count">
|
||||
{{ count }} 张
|
||||
{{ t('batchImage.create.outputCountOption', { n: count }, count) }}
|
||||
</option>
|
||||
</select>
|
||||
<label
|
||||
@@ -629,7 +641,7 @@
|
||||
: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"
|
||||
@@ -641,7 +653,7 @@
|
||||
</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">
|
||||
@@ -651,13 +663,13 @@
|
||||
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="移除参考图" @click="removeReferenceImageDraft(refIndex)">
|
||||
<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">
|
||||
每条最多 {{ BATCH_IMAGE_MAX_OUTPUTS_PER_ITEM }} 张,整组最多 {{ BATCH_IMAGE_MAX_OUTPUTS_PER_JOB }} 张;当前模型每条最多 {{ selectedModelReferenceLimit }} 张参考图,参考图按生成张数重复消耗输入 token。
|
||||
{{ 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">
|
||||
@@ -672,52 +684,52 @@
|
||||
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">
|
||||
{{ row.reference_images.length }} 参考图
|
||||
{{ 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="删除" @click="removePromptRow(index)">
|
||||
<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">
|
||||
还没有添加 prompt。
|
||||
{{ 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">取消</button>
|
||||
<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 ? '提交中...' : '提交任务' }}
|
||||
{{ submitting ? t('common.submitting') : t('batchImage.actions.submitJob') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
|
||||
<BaseDialog :show="showGuideModal" title="批量生图使用说明" width="wide" @close="showGuideModal = false">
|
||||
<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">当前界面如何使用</h3>
|
||||
<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>1. 选择已开启批量生图的 Gemini API Key,模型列表会按该 Key 所属分组可用模型展示。</p>
|
||||
<p>2. 任务名称可以留空,提交时会自动使用当前时间;Prompt 需要一条条添加到列表里,每条 Prompt 可附参考图,也可以设置重复生成张数。</p>
|
||||
<p>3. 提交后任务会先排队,明细会展示已提交的 Prompt;图片预览默认不加载,点击明细里的预览按钮才会加载单张图。</p>
|
||||
<p>4. 完成后可以下载 ZIP;部分失败时,更多菜单里可以只重试失败项。当前结算仍按成功输出图张数计算,不单独对参考图加价。</p>
|
||||
<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">给 Codex 的 Skill 说明</h3>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">用于告诉 Codex 如何代替用户整理 prompt、提交任务和下载结果。</p>
|
||||
<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"
|
||||
@@ -728,10 +740,10 @@
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" class="btn btn-secondary" @click="showGuideModal = false">关闭</button>
|
||||
<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>
|
||||
@@ -824,37 +836,37 @@ const batchPageSizeOptions: SelectOption[] = [20, 50, 100].map(size => ({ value:
|
||||
|
||||
const appStore = useAppStore()
|
||||
const { copyToClipboard } = useClipboard()
|
||||
const { locale } = useI18n()
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
const columns: Column[] = [
|
||||
const columns = computed<Column[]>(() => [
|
||||
{ key: 'select', label: '', sortable: false, class: 'w-12 text-center' },
|
||||
{ key: 'id', label: '任务名称', sortable: false, class: 'w-[240px] max-w-[240px]' },
|
||||
{ key: 'model', label: '模型', sortable: false, class: 'w-[180px] max-w-[180px] text-center' },
|
||||
{ key: 'api_key_name', label: '提交密钥', sortable: false, class: 'w-40 max-w-40 text-center' },
|
||||
{ key: 'status', label: '状态', sortable: false, class: 'w-28 text-center' },
|
||||
{ key: 'counts', label: '结果', sortable: false, class: 'w-32 text-center' },
|
||||
{ key: 'cost', label: '费用', sortable: false, class: 'w-36 text-center' },
|
||||
{ key: 'downloaded', label: '下载状态', sortable: false, class: 'w-40 text-center' },
|
||||
{ key: 'actions', label: '操作', sortable: false, class: 'w-40 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: SelectOption[] = [
|
||||
{ value: '', label: '全部状态' },
|
||||
{ value: 'queued', label: '排队中' },
|
||||
{ value: 'running', label: '生成中' },
|
||||
{ value: 'processing_results', label: '整理结果' },
|
||||
{ value: 'settling', label: '结算中' },
|
||||
{ value: 'completed', label: '已完成' },
|
||||
{ value: 'failed', label: '失败' },
|
||||
{ value: 'cancelled', label: '已取消' },
|
||||
{ value: 'output_deleted', label: '结果已删除' },
|
||||
]
|
||||
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: SelectOption[] = [
|
||||
{ value: '', label: '全部下载状态' },
|
||||
{ value: 'true', label: '已下载' },
|
||||
{ value: 'false', label: '未下载' },
|
||||
]
|
||||
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,
|
||||
@@ -944,7 +956,7 @@ const filteredApiKeys = computed(() => {
|
||||
})
|
||||
|
||||
const apiKeyFilterOptions = computed<SelectOption[]>(() => [
|
||||
{ value: '', label: '全部 API Key' },
|
||||
{ value: '', label: t('batchImage.filters.allApiKeys') },
|
||||
...geminiApiKeys.value.map(key => ({
|
||||
value: String(key.id),
|
||||
label: key.name || `API Key #${key.id}`,
|
||||
@@ -1179,26 +1191,26 @@ async function handleReferenceImageFiles(event: Event) {
|
||||
if (files.length === 0) return
|
||||
const limit = selectedModelReferenceLimit.value
|
||||
if (limit <= 0) {
|
||||
appStore.showError('当前模型不支持参考图。')
|
||||
appStore.showError(t('batchImage.create.modelNoReferenceImages'))
|
||||
return
|
||||
}
|
||||
const slots = Math.max(0, limit - referenceImageDrafts.value.length)
|
||||
if (slots <= 0) {
|
||||
appStore.showError(`当前模型每条最多 ${limit} 张参考图。`)
|
||||
appStore.showError(t('batchImage.create.refLimitReached', { limit }))
|
||||
return
|
||||
}
|
||||
const accepted = files.slice(0, slots)
|
||||
if (accepted.length < files.length) {
|
||||
appStore.showError(`当前模型每条最多 ${limit} 张参考图,已忽略超出的文件。`)
|
||||
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('参考图仅支持 PNG、JPEG 或 WebP。')
|
||||
appStore.showError(t('batchImage.create.refFormatUnsupported'))
|
||||
continue
|
||||
}
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
appStore.showError(`${file.name} 超过 10MB,已忽略。`)
|
||||
appStore.showError(t('batchImage.create.refFileTooLarge', { name: file.name }))
|
||||
continue
|
||||
}
|
||||
const data = await readFileAsBase64(file)
|
||||
@@ -1479,7 +1491,7 @@ function openPromptPopover(target: HTMLElement, value: string) {
|
||||
|
||||
function copyPromptPopover() {
|
||||
if (!promptPopover.text) return
|
||||
void copyToClipboard(promptPopover.text, 'Prompt 已复制')
|
||||
void copyToClipboard(promptPopover.text, t('batchImage.promptPopover.copied'))
|
||||
}
|
||||
|
||||
async function loadBatchJobs() {
|
||||
@@ -1803,7 +1815,7 @@ async function retryFailedJob(job: BatchImageJobRow | BatchImageJob) {
|
||||
key.key,
|
||||
{
|
||||
model: job.model,
|
||||
task_name: `${job.task_name || defaultTaskName()} 重试失败项`,
|
||||
task_name: `${job.task_name || defaultTaskName()} ${t('batchImage.messages.retryTaskNameSuffix')}`,
|
||||
parent_batch_id: rootBatchIdForRetry(job),
|
||||
provider: job.provider,
|
||||
image_size: '1K',
|
||||
@@ -2212,8 +2224,8 @@ function detailJobsForBatch(batchId: string): BatchImageJobRow[] {
|
||||
|
||||
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 `主任务:${name}`
|
||||
return `子任务:${name}`
|
||||
if (job.id === rootBatchId) return t('batchImage.detail.mainTask', { name })
|
||||
return t('batchImage.detail.childTask', { name })
|
||||
}
|
||||
|
||||
async function loadItemPreview(item: BatchImageItem) {
|
||||
@@ -2289,21 +2301,22 @@ function copyInstruction() {
|
||||
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 '部分成功'
|
||||
return '全部失败'
|
||||
if (jobOrStatus.success_count > 0) return t('batchImage.status.partialSuccess')
|
||||
return t('batchImage.status.allFailed')
|
||||
}
|
||||
const labels: Record<string, string> = {
|
||||
queued: '排队中',
|
||||
running: '生成中',
|
||||
indexing: '整理结果',
|
||||
processing_results: '整理结果',
|
||||
settling: '结算中',
|
||||
completed: '已完成',
|
||||
failed: '失败',
|
||||
cancelled: '已取消',
|
||||
output_deleted: '结果已删除',
|
||||
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',
|
||||
}
|
||||
return labels[status] || status
|
||||
const key = statusKeys[status]
|
||||
return key ? t(`batchImage.status.${key}`) : status
|
||||
}
|
||||
|
||||
function statusBadgeClass(jobOrStatus: BatchImageStatus | Pick<BatchImageJob, 'status' | 'success_count' | 'fail_count'>) {
|
||||
@@ -2319,18 +2332,19 @@ function statusBadgeClass(jobOrStatus: BatchImageStatus | Pick<BatchImageJob, 's
|
||||
}
|
||||
|
||||
function itemStatusLabel(status: string) {
|
||||
const labels: Record<string, string> = {
|
||||
pending: '排队中',
|
||||
succeeded: '成功',
|
||||
success: '成功',
|
||||
failed: '失败',
|
||||
cancelled: '已取消',
|
||||
const statusKeys: Record<string, string> = {
|
||||
pending: 'pending',
|
||||
succeeded: 'succeeded',
|
||||
success: 'succeeded',
|
||||
failed: 'failed',
|
||||
cancelled: 'cancelled',
|
||||
}
|
||||
return labels[status] || status
|
||||
const key = statusKeys[status]
|
||||
return key ? t(`batchImage.itemStatus.${key}`) : status
|
||||
}
|
||||
|
||||
function itemDisplayStatusLabel(item: BatchImageDetailItem) {
|
||||
if (isRecoveredOriginalFailure(item)) return '已补成功'
|
||||
if (isRecoveredOriginalFailure(item)) return t('batchImage.itemStatus.recovered')
|
||||
return itemStatusLabel(item.status)
|
||||
}
|
||||
|
||||
@@ -2346,14 +2360,14 @@ function itemDisplayStatusBadgeClass(item: BatchImageDetailItem) {
|
||||
}
|
||||
|
||||
function itemResultLabel(item: BatchImageDetailItem) {
|
||||
if (isRecoveredOriginalFailure(item)) return '旧失败已由重试子任务补成功'
|
||||
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)] ? '图片已生成,可点击预览' : '图片已生成,可下载'
|
||||
return itemPreviewUrls[itemPreviewKey(item)] ? t('batchImage.itemResult.readyPreview') : t('batchImage.itemResult.readyDownload')
|
||||
}
|
||||
if (item.status === 'failed') return '未生成可用图片'
|
||||
if (item.status === 'cancelled') return '任务已取消'
|
||||
return '等待生成结果'
|
||||
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) {
|
||||
@@ -2365,8 +2379,8 @@ function itemResultClass(item: BatchImageDetailItem) {
|
||||
|
||||
function friendlyItemError(error: BatchImageItem['error']) {
|
||||
if (!error) return '-'
|
||||
if (error.code === 'EMPTY_IMAGE_OUTPUT') return '上游返回了结果,但这条没有图片内容。通常是 Gemini/Vertex 单条生成失败或被安全策略拦截。'
|
||||
if (error.code === 'PROVIDER_ITEM_FAILED') 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 || '-'
|
||||
}
|
||||
|
||||
@@ -2382,7 +2396,7 @@ function terminalZeroCost(job: Pick<BatchImageJob, 'status' | 'actual_cost'>) {
|
||||
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 `冻结 ${formatMoney(job.hold_amount)}`
|
||||
return t('batchImage.detail.holdCost', { amount: formatMoney(job.hold_amount) })
|
||||
}
|
||||
|
||||
type BatchImageTextKey =
|
||||
@@ -2450,127 +2464,7 @@ function isZhLocale() {
|
||||
}
|
||||
|
||||
function batchImageText(key: BatchImageTextKey) {
|
||||
const zh: Record<BatchImageTextKey, string> = {
|
||||
loadKeysFailed: '加载 API Key 失败',
|
||||
loadModelsFailed: '加载可用模型失败',
|
||||
loadJobsFailed: '加载批量任务失败',
|
||||
selectApiKey: '请选择可用的 Gemini API Key',
|
||||
noModelsForKey: '当前密钥没有可用的批量生图模型',
|
||||
selectModel: '请选择模型',
|
||||
promptRequired: '请至少填写一条 prompt',
|
||||
submitted: '批量任务已提交',
|
||||
submitFailed: '提交失败',
|
||||
refreshFailed: '刷新失败',
|
||||
cancelConfirm: '取消会请求上游取消;已被系统索引为成功的图片仍会按成功项结算扣费,其余冻结金额会释放。确定取消吗?',
|
||||
cancelled: '已请求取消任务',
|
||||
cancelFailed: '取消失败',
|
||||
batchDownloadStarted: '已开始下载选中的任务',
|
||||
downloadFailed: '下载失败',
|
||||
retrySubmitted: '已提交失败项重试任务',
|
||||
retryFailed: '重试失败项失败',
|
||||
retryMissingPrompts: '这个任务没有保存失败项 prompt,无法自动重试。请复制原 prompt 后重新创建任务。',
|
||||
deleteConfirm: '删除后这个任务会从你的列表隐藏,但账务记录仍会保留。确定删除吗?',
|
||||
deleteSelectedConfirm: '删除后选中的任务会从你的列表隐藏,但账务记录仍会保留。确定删除吗?',
|
||||
deleted: '任务记录已删除',
|
||||
deleteFailed: '删除任务记录失败',
|
||||
loadItemsFailed: '加载明细失败',
|
||||
loadPreviewFailed: '加载图片预览失败',
|
||||
copiedInstruction: '已复制批量生图说明',
|
||||
loadingModels: '加载可用模型中...',
|
||||
noModels: '无可用模型',
|
||||
noModelsHint: '当前密钥所属分组没有配置可用于批量生图的模型。',
|
||||
noCompatibleAccount: '当前密钥所属分组没有可用的批量生图上游账号。请联系管理员检查:该分组是否绑定了可调度的 Gemini API Key 或 Vertex 服务账号,以及账号是否支持所选模型。',
|
||||
unsupportedProvider: '这个任务使用的批量生图通道当前不可用。请联系管理员检查批量生图通道配置。',
|
||||
providerSubmitFailed: '上游批量生图任务提交失败。请联系管理员检查上游账号状态、模型权限或服务状态。',
|
||||
vertexGcsBucketMissing: 'Vertex 批量生图缺少托管 GCS 存储桶配置。请联系管理员配置 BATCH_IMAGE_VERTEX_MANAGED_GCS_BUCKET 后再提交。',
|
||||
queueFailed: '任务队列暂时不可用,批量任务没有成功入队。请联系管理员检查队列服务。',
|
||||
billingHoldFailed: '费用冻结失败,批量任务没有成功提交。请联系管理员检查余额冻结或计费服务。',
|
||||
groupDisabled: '当前密钥所属分组没有开启批量生图。你可以换一个已开启批量生图的密钥,或联系管理员开启。',
|
||||
pricingMissing: '所选模型还没有配置批量生图价格。请联系管理员补充价格配置。',
|
||||
insufficientBalance: '余额不足,无法冻结本次批量生图费用。',
|
||||
invalidModel: '请选择一个可用于当前密钥的批量生图模型。',
|
||||
invalidItems: 'Prompt 列表格式不正确,请检查是否为空、是否超过数量限制,或图片尺寸是否仍为 1K。',
|
||||
duplicateCustomId: 'Prompt 列表里的 custom_id 不能重复。',
|
||||
promptTooLong: '单条 prompt 过长,请缩短后重试。',
|
||||
invalidReferenceImage: '参考图格式不正确,请使用 10MB 以内的 PNG、JPEG 或 WebP。',
|
||||
tooManyReferenceImages: '参考图数量超过限制:Flash Image 每条最多 3 张,Pro Image 每条最多 14 张,整组最多 1000 张。',
|
||||
referenceImagesTooLarge: '参考图总量过大。inline 参考图整组最多 128MB;大量参考图请改用 gs:// file_uri 或拆分任务。',
|
||||
tooManyOutputImages: '预计生成张数超过限制:每条最多 4 张,整组最多 200 张。',
|
||||
idempotencyConflict: '这次提交和之前的请求标识冲突,请刷新页面后重新提交。',
|
||||
notReady: '任务还没有完成,完成后才能下载。',
|
||||
outputDeleted: '这个任务的结果文件已经被清理,无法下载。',
|
||||
resultMissing: '结果文件不可用,可能是上游结果文件已清理、存储权限异常,或管理员迁移过存储配置。请联系管理员检查结果文件。',
|
||||
itemFailed: '这条明细没有成功图片,无法预览。',
|
||||
itemImageIndexOutOfRange: '这条明细没有可预览的图片。',
|
||||
downloadLimited: '当前下载请求太多,请稍后再试。',
|
||||
downloadTooLarge: '这个 ZIP 太大,已超过单次下载限制。请减少单次下载数量,或联系管理员调整批量下载上限。',
|
||||
deleteNotReady: '任务结束后才能删除记录。正在生成或结算中的任务请先等待完成。',
|
||||
disabled: '批量生图功能当前未开启。',
|
||||
authRequired: '当前 API Key 不可用或已失效,请重新选择密钥。',
|
||||
adminReference: '请把错误码和请求 ID 发给管理员排查。',
|
||||
errorReference: '错误信息',
|
||||
}
|
||||
const en: Record<BatchImageTextKey, string> = {
|
||||
loadKeysFailed: 'Failed to load API keys.',
|
||||
loadModelsFailed: 'Failed to load available models.',
|
||||
loadJobsFailed: 'Failed to load batch jobs.',
|
||||
selectApiKey: 'Select an available Gemini API key.',
|
||||
noModelsForKey: 'This key has no available batch image models.',
|
||||
selectModel: 'Select a model.',
|
||||
promptRequired: 'Enter at least one prompt.',
|
||||
submitted: 'Batch job submitted.',
|
||||
submitFailed: 'Failed to submit the batch job.',
|
||||
refreshFailed: 'Failed to refresh the job.',
|
||||
cancelConfirm: 'Cancellation will be sent upstream. Images already indexed as successful will still be billed, and the remaining hold will be released. Continue?',
|
||||
cancelled: 'Cancellation requested.',
|
||||
cancelFailed: 'Failed to cancel the job.',
|
||||
batchDownloadStarted: 'Downloads for the selected jobs have started.',
|
||||
downloadFailed: 'Failed to download the result.',
|
||||
retrySubmitted: 'Retry job submitted for failed items.',
|
||||
retryFailed: 'Failed to retry failed items.',
|
||||
retryMissingPrompts: 'This job does not have saved prompts for failed items, so it cannot be retried automatically. Recreate it with the original prompt.',
|
||||
deleteConfirm: 'This hides the job from your list while keeping billing records. Delete it?',
|
||||
deleteSelectedConfirm: 'This hides the selected jobs from your list while keeping billing records. Delete them?',
|
||||
deleted: 'Job record deleted.',
|
||||
deleteFailed: 'Failed to delete the job record.',
|
||||
loadItemsFailed: 'Failed to load item details.',
|
||||
loadPreviewFailed: 'Failed to load the image preview.',
|
||||
copiedInstruction: 'Batch image instructions copied.',
|
||||
loadingModels: 'Loading available models...',
|
||||
noModels: 'No available models',
|
||||
noModelsHint: 'This key’s group has no models configured for batch image generation.',
|
||||
noCompatibleAccount: 'No usable upstream batch image account is available for this key’s group. Contact an administrator to check the group’s schedulable Gemini API key or Vertex service account and model support.',
|
||||
unsupportedProvider: 'The batch image provider for this job is not available. Contact an administrator to check the batch image provider configuration.',
|
||||
providerSubmitFailed: 'The upstream batch image job failed to submit. Contact an administrator to check the upstream account, model permission, or provider status.',
|
||||
vertexGcsBucketMissing: 'Vertex batch image generation is missing the managed GCS bucket configuration. Contact an administrator to configure BATCH_IMAGE_VERTEX_MANAGED_GCS_BUCKET before submitting again.',
|
||||
queueFailed: 'The task queue is temporarily unavailable, so the batch job was not queued. Contact an administrator to check the queue service.',
|
||||
billingHoldFailed: 'The cost hold failed, so the batch job was not submitted. Contact an administrator to check billing or balance hold service.',
|
||||
groupDisabled: 'Batch image generation is not enabled for this key’s group. Choose another enabled key or contact an administrator.',
|
||||
pricingMissing: 'The selected model does not have batch image pricing configured. Contact an administrator to add pricing first.',
|
||||
insufficientBalance: 'Insufficient balance to hold the estimated batch image cost.',
|
||||
invalidModel: 'Select a batch image model available for the current key.',
|
||||
invalidItems: 'The prompt list is invalid. Check that it is not empty, within the item limit, and still using 1K image size.',
|
||||
duplicateCustomId: 'Custom IDs in the prompt list must be unique.',
|
||||
promptTooLong: 'One prompt is too long. Shorten it and try again.',
|
||||
invalidReferenceImage: 'A reference image is invalid. Use PNG, JPEG, or WebP under 10 MB.',
|
||||
tooManyReferenceImages: 'Too many reference images. Flash Image allows up to 3 per item, Pro Image allows up to 14, and each job allows up to 1000 total.',
|
||||
referenceImagesTooLarge: 'Reference images are too large. Inline reference images are limited to 128 MB per job; use gs:// file_uri or split the job for large batches.',
|
||||
tooManyOutputImages: 'Too many expected output images. Each prompt can request up to 4 images, and each job can generate up to 200 images.',
|
||||
idempotencyConflict: 'This submission conflicts with a previous request ID. Refresh the page and submit again.',
|
||||
notReady: 'The job is not complete yet. Download will be available after completion.',
|
||||
outputDeleted: 'The result files for this job have already been cleaned up.',
|
||||
resultMissing: 'The result file is unavailable. It may have been cleaned up, storage permissions may be broken, or storage settings may have changed. Contact an administrator to check the result file.',
|
||||
itemFailed: 'This item has no successful image to preview.',
|
||||
itemImageIndexOutOfRange: 'This item has no previewable image.',
|
||||
downloadLimited: 'Too many download requests are active. Please try again later.',
|
||||
downloadTooLarge: 'This ZIP is too large for a single download. Download fewer items at once or ask an administrator to raise the batch download limit.',
|
||||
deleteNotReady: 'Job records can only be deleted after the job finishes.',
|
||||
disabled: 'Batch image generation is currently disabled.',
|
||||
authRequired: 'The current API key is unavailable or expired. Select the key again.',
|
||||
adminReference: 'Send the error code and request ID to an administrator for troubleshooting.',
|
||||
errorReference: 'Error detail',
|
||||
}
|
||||
return (isZhLocale() ? zh : en)[key]
|
||||
return t(`batchImage.messages.${key}`)
|
||||
}
|
||||
|
||||
function batchImageErrorReference(error: any) {
|
||||
@@ -2578,10 +2472,10 @@ function batchImageErrorReference(error: any) {
|
||||
const code = String(error?.code || '').trim()
|
||||
const requestId = String(error?.requestId || '').trim()
|
||||
const status = String(error?.status || '').trim()
|
||||
if (code) parts.push(isZhLocale() ? `错误码:${code}` : `code: ${code}`)
|
||||
if (requestId) parts.push(isZhLocale() ? `请求 ID:${requestId}` : `request ID: ${requestId}`)
|
||||
if (!code && status) parts.push(isZhLocale() ? `HTTP 状态:${status}` : `HTTP status: ${status}`)
|
||||
return parts.length ? `(${parts.join(',')})` : ''
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user