Merge pull request #4575 from Astrenix/feat/batch-image-guide-i18n

feat(frontend): 批量生图页面完整迁移至 vue-i18n
This commit is contained in:
Wesley Liddick
2026-07-20 15:32:21 +08:00
committed by GitHub
5 changed files with 609 additions and 287 deletions
+212
View File
@@ -0,0 +1,212 @@
export default {
batchImage: {
columns: {
taskName: 'Task name',
model: 'Model',
apiKey: 'API key',
result: 'Results',
cost: 'Cost',
downloadStatus: 'Download status',
},
status: {
queued: 'Queued',
running: 'Generating',
processingResults: 'Processing results',
settling: 'Settling',
completed: 'Completed',
failed: 'Failed',
cancelled: 'Cancelled',
outputDeleted: 'Results deleted',
partialSuccess: 'Partially succeeded',
allFailed: 'All failed',
},
itemStatus: {
pending: 'Queued',
succeeded: 'Succeeded',
failed: 'Failed',
cancelled: 'Cancelled',
recovered: 'Recovered by retry',
},
filters: {
searchTaskName: 'Search task name',
allApiKeys: 'All API keys',
allStatuses: 'All statuses',
allDownloadStates: 'All download states',
downloaded: 'Downloaded',
notDownloaded: 'Not downloaded',
},
actions: {
usageGuide: 'Usage guide',
createJob: 'Create batch job',
downloadSelected: 'Download selected',
deleteRecords: 'Delete records',
retryFailedItems: 'Retry failed items',
cancelJob: 'Cancel job',
downloadZip: 'Download ZIP',
viewDetail: 'View details',
download: 'Download',
moreActions: 'More actions',
copyInstruction: 'Copy instructions',
submitJob: 'Submit job',
},
list: {
selectedJobs: 'Selected {count} job | Selected {count} jobs',
expandChildren: 'Expand {n} subtask | Expand {n} subtasks',
collapseChildren: 'Collapse subtasks',
childCount: '{n} subtask | {n} subtasks',
childBadge: 'Subtask',
keyNotRecorded: 'Not recorded',
totalCount: 'of {n}',
notDownloaded: 'Not downloaded',
empty: 'No batch jobs yet',
emptyHint: 'Use the button in the top-right corner to create a batch job.',
},
pagination: {
pageNumber: 'Page {page}',
pageItems: '{count} on this page',
},
promptPopover: {
title: 'Full prompt',
copied: 'Prompt copied',
},
detail: {
title: 'Job details',
aggregatedResult: 'Combined results',
result: 'Results',
cost: 'Cost',
downloadStatus: 'Download status',
items: 'Items',
preview: 'Preview',
previewZoom: 'Zoom compressed preview {id}',
previewReload: 'Reload compressed preview',
previewLoad: 'Load compressed preview',
previewUnavailable: 'Preview unavailable',
noImage: 'No image',
loadingItems: 'Loading items...',
noItems: 'No items yet',
noItemsHint: 'Queued or generating jobs show submitted prompts first; image statuses update once results are processed.',
mainTask: 'Main job: {name}',
childTask: 'Subtask: {name}',
holdCost: 'Hold {amount}',
},
itemResult: {
recoveredByRetry: 'Previous failure recovered by a retry subtask',
readyPreview: 'Image generated. Click to preview.',
readyDownload: 'Image generated and ready to download.',
noUsableImage: 'No usable image was generated.',
cancelled: 'Job cancelled.',
waiting: 'Waiting for results.',
emptyImageOutput: 'The upstream returned a result, but this item has no image content. This usually means a single Gemini/Vertex generation failed or was blocked by safety policies.',
providerItemFailed: 'The upstream result for this item has no usable image.',
},
imagePreview: {
title: 'Image preview',
notice: 'This is a compressed thumbnail cached locally in your browser, so quality is reduced. Download the ZIP to view the original image.',
},
create: {
title: 'Create batch job',
taskName: 'Task name',
taskNamePlaceholder: 'Defaults to the current time if left empty',
loadingKeys: 'Loading API keys...',
selectKeyPlaceholder: 'Select a Gemini API key',
noKeysHint: 'No Gemini API key is available for batch image generation. Create one and bind it to a Gemini group with batch image generation enabled first.',
model: 'Model',
imageSize: 'Image size',
imageSizeHint: 'Batch jobs are currently submitted at a fixed 1K image size.',
outputFormat: 'Output format',
estimatedOutput: 'Estimated output',
estimatedOutputValue: '{images} images / {prompts} prompts',
promptAdded: '{count} added',
promptPlaceholder: 'Paste a prompt, then add it to the list below',
customIdPlaceholder: 'Custom ID (optional)',
outputCountPerPrompt: 'Images per prompt',
outputCountOption: '{n} image | {n} images',
referenceImage: 'Reference images',
removeReferenceImage: 'Remove reference image',
limitsHint: 'Up to {maxPerItem} images per prompt and {maxPerJob} per job. The current model allows up to {refLimit} reference images per prompt; reference images consume input tokens once per generated image.',
referenceCount: '{n} reference image | {n} reference images',
noPrompts: 'No prompts added yet.',
cancelNotice: 'Cancelling requests an upstream cancellation. Images already indexed as successful will still be billed, and the remaining hold will be released.',
submittingNotice: 'Creating the upstream batch job. This usually takes a few seconds; please do not submit again.',
modelNoReferenceImages: 'The current model does not support reference images.',
refLimitReached: 'The current model allows up to {limit} reference images per prompt.',
refLimitExceededIgnored: 'The current model allows up to {limit} reference images per prompt. Extra files were ignored.',
refFormatUnsupported: 'Reference images must be PNG, JPEG, or WebP.',
refFileTooLarge: '{name} exceeds 10MB and was ignored.',
},
guide: {
title: 'Batch Image Generation Guide',
uiTitle: 'How to use this page',
step1: '1. Select a Gemini API key with batch image generation enabled. The model list shows the models available to that key’s group.',
step2: '2. The task name can be left empty; the current time is used automatically on submit. Prompts are added to the list one by one, and each prompt can carry reference images and a repeat count.',
step3: '3. After submitting, the job is queued first and the item list shows the submitted prompts. Image previews are not loaded by default; click the preview button on an item to load a single image.',
step4: '4. Once completed you can download the ZIP. If some items failed, the More menu lets you retry only the failed items. Billing is still based on the number of successfully generated images; reference images are not billed separately.',
skillTitle: 'Skill instructions for Codex',
skillDesc: 'Tells Codex how to organize prompts, submit jobs, and download results on behalf of the user.',
},
messages: {
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.',
retryTaskNameSuffix: 'Retry failed items',
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',
errorCodeRef: 'code: {code}',
requestIdRef: 'request ID: {id}',
httpStatusRef: 'HTTP status: {status}',
},
},
}
+2
View File
@@ -1,6 +1,7 @@
import landing from './landing'
import common from './common'
import dashboard from './dashboard'
import batchImage from './batchImage'
import admin from './admin'
import misc from './misc'
@@ -8,6 +9,7 @@ export default {
...landing,
...common,
...dashboard,
...batchImage,
admin,
...misc,
}
+212
View File
@@ -0,0 +1,212 @@
export default {
batchImage: {
columns: {
taskName: '任务名称',
model: '模型',
apiKey: '提交密钥',
result: '结果',
cost: '费用',
downloadStatus: '下载状态',
},
status: {
queued: '排队中',
running: '生成中',
processingResults: '整理结果',
settling: '结算中',
completed: '已完成',
failed: '失败',
cancelled: '已取消',
outputDeleted: '结果已删除',
partialSuccess: '部分成功',
allFailed: '全部失败',
},
itemStatus: {
pending: '排队中',
succeeded: '成功',
failed: '失败',
cancelled: '已取消',
recovered: '已补成功',
},
filters: {
searchTaskName: '搜索任务名称',
allApiKeys: '全部 API Key',
allStatuses: '全部状态',
allDownloadStates: '全部下载状态',
downloaded: '已下载',
notDownloaded: '未下载',
},
actions: {
usageGuide: '使用说明',
createJob: '创建批量任务',
downloadSelected: '下载选中',
deleteRecords: '删除记录',
retryFailedItems: '重试失败项',
cancelJob: '取消任务',
downloadZip: '下载 ZIP',
viewDetail: '查看详情',
download: '下载',
moreActions: '更多操作',
copyInstruction: '复制说明',
submitJob: '提交任务',
},
list: {
selectedJobs: '已选择 {count} 个任务',
expandChildren: '展开 {n} 个子任务',
collapseChildren: '收起子任务',
childCount: '{n} 子任务',
childBadge: '子任务',
keyNotRecorded: '未记录',
totalCount: '共 {n}',
notDownloaded: '未下载',
empty: '暂无批量任务',
emptyHint: '点击右上角创建批量任务。',
},
pagination: {
pageNumber: '第 {page} 页',
pageItems: '本页 {count} 条',
},
promptPopover: {
title: '完整 Prompt',
copied: 'Prompt 已复制',
},
detail: {
title: '任务详情',
aggregatedResult: '汇总结果',
result: '结果',
cost: '费用',
downloadStatus: '下载状态',
items: '明细',
preview: '预览',
previewZoom: '放大压缩预览 {id}',
previewReload: '重新加载压缩预览',
previewLoad: '加载压缩预览',
previewUnavailable: '不可预览',
noImage: '无图片',
loadingItems: '正在加载明细...',
noItems: '暂无明细',
noItemsHint: '排队或生成中的任务会先显示已提交的 prompt,结果整理完成后会更新图片状态。',
mainTask: '主任务:{name}',
childTask: '子任务:{name}',
holdCost: '冻结 {amount}',
},
itemResult: {
recoveredByRetry: '旧失败已由重试子任务补成功',
readyPreview: '图片已生成,可点击预览',
readyDownload: '图片已生成,可下载',
noUsableImage: '未生成可用图片',
cancelled: '任务已取消',
waiting: '等待生成结果',
emptyImageOutput: '上游返回了结果,但这条没有图片内容。通常是 Gemini/Vertex 单条生成失败或被安全策略拦截。',
providerItemFailed: '上游返回的这条结果没有可用图片。',
},
imagePreview: {
title: '图片预览',
notice: '当前显示的是浏览器本地缓存的压缩缩略图,清晰度会有影响;需要查看原图请下载 ZIP。',
},
create: {
title: '创建批量任务',
taskName: '任务名称',
taskNamePlaceholder: '不填写则默认使用当前时间',
loadingKeys: '加载 API Key 中...',
selectKeyPlaceholder: '请选择 Gemini API Key',
noKeysHint: '当前没有可用于批量生图的 Gemini API Key。请先创建并绑定已开启批量生图的 Gemini 分组。',
model: '模型',
imageSize: '图片尺寸',
imageSizeHint: '当前批量任务固定按 1K 图片提交。',
outputFormat: '输出格式',
estimatedOutput: '预计生成',
estimatedOutputValue: '{images} 张 / {prompts} 条',
promptAdded: '已添加 {count} 条',
promptPlaceholder: '粘贴 prompt,添加后进入下方列表',
customIdPlaceholder: 'Custom ID 可选',
outputCountPerPrompt: '每条生成张数',
outputCountOption: '{n} 张',
referenceImage: '参考图',
removeReferenceImage: '移除参考图',
limitsHint: '每条最多 {maxPerItem} 张,整组最多 {maxPerJob} 张;当前模型每条最多 {refLimit} 张参考图,参考图按生成张数重复消耗输入 token。',
referenceCount: '{n} 参考图',
noPrompts: '还没有添加 prompt。',
cancelNotice: '取消任务会请求上游取消;已被系统索引为成功的图片仍会按成功项结算扣费,其余冻结金额会释放。',
submittingNotice: '正在创建上游批量任务,通常需要几秒,请不要重复提交。',
modelNoReferenceImages: '当前模型不支持参考图。',
refLimitReached: '当前模型每条最多 {limit} 张参考图。',
refLimitExceededIgnored: '当前模型每条最多 {limit} 张参考图,已忽略超出的文件。',
refFormatUnsupported: '参考图仅支持 PNG、JPEG 或 WebP。',
refFileTooLarge: '{name} 超过 10MB,已忽略。',
},
guide: {
title: '批量生图使用说明',
uiTitle: '当前界面如何使用',
step1: '1. 选择已开启批量生图的 Gemini API Key,模型列表会按该 Key 所属分组可用模型展示。',
step2: '2. 任务名称可以留空,提交时会自动使用当前时间;Prompt 需要一条条添加到列表里,每条 Prompt 可附参考图,也可以设置重复生成张数。',
step3: '3. 提交后任务会先排队,明细会展示已提交的 Prompt;图片预览默认不加载,点击明细里的预览按钮才会加载单张图。',
step4: '4. 完成后可以下载 ZIP;部分失败时,更多菜单里可以只重试失败项。当前结算仍按成功输出图张数计算,不单独对参考图加价。',
skillTitle: '给 Codex 的 Skill 说明',
skillDesc: '用于告诉 Codex 如何代替用户整理 prompt、提交任务和下载结果。',
},
messages: {
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 后重新创建任务。',
retryTaskNameSuffix: '重试失败项',
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: '错误信息',
errorCodeRef: '错误码:{code}',
requestIdRef: '请求 ID:{id}',
httpStatusRef: 'HTTP 状态:{status}',
},
},
}
+2
View File
@@ -1,6 +1,7 @@
import landing from './landing'
import common from './common'
import dashboard from './dashboard'
import batchImage from './batchImage'
import admin from './admin'
import misc from './misc'
@@ -8,6 +9,7 @@ export default {
...landing,
...common,
...dashboard,
...batchImage,
admin,
...misc,
}
+181 -287
View File
@@ -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) {