fix(提示语): 失败提示优先展示后端真实原因,修正 10+ 处误导文案
上架/删除品牌/去重/转换/拆分/撤回/查ASIN/产品风险/跟价/巡店/店铺匹配/视频工作台: - 解析失败不再只报通用文案,逐条展示后端文件级 errorMessage(上架新增「店铺未找到」专属弹窗) - 匹配失败不再一律「请检查店铺名」,按 matchStatus 区分 CONFLICT/PENDING - 去重/转换/拆分全失败弹窗优先列各文件真实原因,取不到才回退猜测文案 - dispatch-guard 新增 fileErrors 选项与 collectDistinctErrors 导出
This commit is contained in:
@@ -166,7 +166,7 @@ import {
|
|||||||
} from '@/shared/api/java-modules'
|
} from '@/shared/api/java-modules'
|
||||||
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
|
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
|
||||||
import { saveUrlWithProgress } from '@/shared/utils/download-progress'
|
import { saveUrlWithProgress } from '@/shared/utils/download-progress'
|
||||||
import { EXCEL_EXTENSIONS, checkSelectedFiles, guardBlocked } from '@/shared/dispatch-guard'
|
import { EXCEL_EXTENSIONS, checkSelectedFiles, collectDistinctErrors, guardBlocked } from '@/shared/dispatch-guard'
|
||||||
import { passGuard } from '@/shared/dispatch-guard-ui'
|
import { passGuard } from '@/shared/dispatch-guard-ui'
|
||||||
import { formatDateTime } from '@/shared/utils/datetime'
|
import { formatDateTime } from '@/shared/utils/datetime'
|
||||||
import { uploadPathsToJava } from '@/shared/utils/upload-to-java'
|
import { uploadPathsToJava } from '@/shared/utils/upload-to-java'
|
||||||
@@ -349,11 +349,15 @@ async function submitConvertRun() {
|
|||||||
convertResultItems.value = result.items || []
|
convertResultItems.value = result.items || []
|
||||||
await loadConvertHistory()
|
await loadConvertHistory()
|
||||||
if (result.total > 0 && result.successCount === 0) {
|
if (result.total > 0 && result.successCount === 0) {
|
||||||
|
// 逐文件原因就在 items[].error 里,优先直接列出,不再让用户对着「常见原因」猜
|
||||||
|
const details = collectDistinctErrors((result.items || []).map((item) => item.error)).slice(0, 5)
|
||||||
await passGuard(
|
await passGuard(
|
||||||
guardBlocked(
|
guardBlocked(
|
||||||
'格式转换未成功',
|
'格式转换未成功',
|
||||||
`本次提交的 ${result.total} 个文件全部转换失败。\n` +
|
`本次提交的 ${result.total} 个文件全部转换失败。\n` +
|
||||||
'常见原因是源文件表头与所选模板不匹配,或文件内没有数据行。\n' +
|
(details.length
|
||||||
|
? `失败原因:\n${details.map((message) => `· ${message}`).join('\n')}\n`
|
||||||
|
: '常见原因是源文件表头与所选模板不匹配,或文件内没有数据行。\n') +
|
||||||
'请在右侧结果列表查看每个文件的失败原因,或换一个模板后重试。',
|
'请在右侧结果列表查看每个文件的失败原因,或换一个模板后重试。',
|
||||||
'convert.all-failed',
|
'convert.all-failed',
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ import { expandBrandFolderRecursive } from '@/shared/api/brand'
|
|||||||
import { deleteDedupeHistory, getDedupeHistory, getDedupeResultDownloadUrl, getDedupeRunProgress, getExcelInfo, runDedupe, type DedupeResultItem, type DedupeRunProgressVo, type DedupeRunVo } from '@/shared/api/java-modules'
|
import { deleteDedupeHistory, getDedupeHistory, getDedupeResultDownloadUrl, getDedupeRunProgress, getExcelInfo, runDedupe, type DedupeResultItem, type DedupeRunProgressVo, type DedupeRunVo } from '@/shared/api/java-modules'
|
||||||
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
|
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
|
||||||
import { saveUrlWithProgress } from '@/shared/utils/download-progress'
|
import { saveUrlWithProgress } from '@/shared/utils/download-progress'
|
||||||
import { EXCEL_EXTENSIONS, checkSelectedFiles, guardBlocked } from '@/shared/dispatch-guard'
|
import { EXCEL_EXTENSIONS, checkSelectedFiles, collectDistinctErrors, guardBlocked } from '@/shared/dispatch-guard'
|
||||||
import { passGuard } from '@/shared/dispatch-guard-ui'
|
import { passGuard } from '@/shared/dispatch-guard-ui'
|
||||||
import { formatDateTime } from '@/shared/utils/datetime'
|
import { formatDateTime } from '@/shared/utils/datetime'
|
||||||
import { uploadPathsToJava } from '@/shared/utils/upload-to-java'
|
import { uploadPathsToJava } from '@/shared/utils/upload-to-java'
|
||||||
@@ -237,7 +237,8 @@ function clearAllCleanColumns() {
|
|||||||
async function loadCleanHeaders(fileKey: string) {
|
async function loadCleanHeaders(fileKey: string) {
|
||||||
const result = await getExcelInfo(fileKey)
|
const result = await getExcelInfo(fileKey)
|
||||||
if (!result.headers?.length) {
|
if (!result.headers?.length) {
|
||||||
ElMessage.error('读取 Excel 表头失败')
|
// 接口本身成功、只是没解析出表头,说明文件内容有问题而非「读取失败」,别让用户反复重选文件
|
||||||
|
ElMessage.error('未读到表头行:请确认文件不是空表、且首行是表头')
|
||||||
cleanAvailableColumns.value = []
|
cleanAvailableColumns.value = []
|
||||||
cleanSelectedColumns.value = []
|
cleanSelectedColumns.value = []
|
||||||
return
|
return
|
||||||
@@ -359,11 +360,15 @@ async function submitCleanRun() {
|
|||||||
await loadCleanHistory()
|
await loadCleanHistory()
|
||||||
// 全部文件都失败时不该报「完成」,让用户看清是哪一批没处理成功
|
// 全部文件都失败时不该报「完成」,让用户看清是哪一批没处理成功
|
||||||
if (result.total > 0 && result.successCount === 0) {
|
if (result.total > 0 && result.successCount === 0) {
|
||||||
|
// 逐文件原因就在 items[].error 里,优先直接列出,不再让用户对着「常见原因」猜
|
||||||
|
const details = collectDistinctErrors((result.items || []).map((item) => item.error)).slice(0, 5)
|
||||||
await passGuard(
|
await passGuard(
|
||||||
guardBlocked(
|
guardBlocked(
|
||||||
'去重未成功',
|
'去重未成功',
|
||||||
`本次提交的 ${result.total} 个文件全部处理失败。\n` +
|
`本次提交的 ${result.total} 个文件全部处理失败。\n` +
|
||||||
'常见原因是表头列名与所选保留列对不上,或文件内没有数据行。\n' +
|
(details.length
|
||||||
|
? `失败原因:\n${details.map((message) => `· ${message}`).join('\n')}\n`
|
||||||
|
: '常见原因是表头列名与所选保留列对不上,或文件内没有数据行。\n') +
|
||||||
'请在右侧结果列表查看每个文件的失败原因后重试。',
|
'请在右侧结果列表查看每个文件的失败原因后重试。',
|
||||||
'dedupe.all-failed',
|
'dedupe.all-failed',
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1105,7 +1105,13 @@ async function submitRun() {
|
|||||||
syncResultState()
|
syncResultState()
|
||||||
|
|
||||||
if (hasBlockedItems) {
|
if (hasBlockedItems) {
|
||||||
ElMessage.warning('部分文件尚未匹配到店铺,已保留状态信息,请稍后重试。')
|
// 具体原因(店铺未录入 / 索引未命中 / 表头错误等)在各文件项里,直接带出来;
|
||||||
|
// 只报「请稍后重试」会把「需去后台添加店铺」误导成「等一等就好」
|
||||||
|
const firstBlocked = normalizedItems.find((item) => !isUsableMatchedItem(item))
|
||||||
|
const detail = firstBlocked ? getDisplayError(firstBlocked) : ''
|
||||||
|
ElMessage.warning(detail
|
||||||
|
? `部分文件不可启动:${detail}`
|
||||||
|
: '部分文件尚未匹配到店铺,已保留状态信息,请稍后重试。')
|
||||||
} else if (hasStaleMatchedItems) {
|
} else if (hasStaleMatchedItems) {
|
||||||
ElMessage.warning('部分文件匹配到的店铺信息已过期,仍可启动任务;后台会自动重新匹配。')
|
ElMessage.warning('部分文件匹配到的店铺信息已过期,仍可启动任务;后台会自动重新匹配。')
|
||||||
} else if (hasRunnableItems) {
|
} else if (hasRunnableItems) {
|
||||||
|
|||||||
@@ -511,6 +511,12 @@ function formatMatchRemark(row: PatrolDeleteShopQueueItem) {
|
|||||||
if (row.matched) {
|
if (row.matched) {
|
||||||
return "已匹配成功,请查看状态确认";
|
return "已匹配成功,请查看状态确认";
|
||||||
}
|
}
|
||||||
|
if (row.matchStatus === "CONFLICT") {
|
||||||
|
return "存在多个同名店铺,请人工确认";
|
||||||
|
}
|
||||||
|
if (row.matchStatus === "PENDING") {
|
||||||
|
return "店铺尚未匹配完成,请稍后查看";
|
||||||
|
}
|
||||||
return "未匹配成功,请检查店铺名";
|
return "未匹配成功,请检查店铺名";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -848,6 +848,8 @@ function formatMatchRemark(row: PriceTrackShopQueueItem) {
|
|||||||
if (msg) return msg
|
if (msg) return msg
|
||||||
if (row.matched && row.matchStatus === 'MATCHED') return '已匹配成功,可启动任务'
|
if (row.matched && row.matchStatus === 'MATCHED') return '已匹配成功,可启动任务'
|
||||||
if (row.matched) return '已关联店铺,请查看状态确认'
|
if (row.matched) return '已关联店铺,请查看状态确认'
|
||||||
|
if (row.matchStatus === 'CONFLICT') return '存在多个同名店铺,请人工确认'
|
||||||
|
if (row.matchStatus === 'PENDING') return '店铺尚未匹配完成,请稍后查看'
|
||||||
return '未匹配成功,请检查店铺名'
|
return '未匹配成功,请检查店铺名'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1056,6 +1056,12 @@ function formatMatchRemark(row: ProductRiskShopQueueItem) {
|
|||||||
if (row.matched) {
|
if (row.matched) {
|
||||||
return '已关联店铺,请查看状态确认'
|
return '已关联店铺,请查看状态确认'
|
||||||
}
|
}
|
||||||
|
if (row.matchStatus === 'CONFLICT') {
|
||||||
|
return '存在多个同名店铺,请人工确认'
|
||||||
|
}
|
||||||
|
if (row.matchStatus === 'PENDING') {
|
||||||
|
return '店铺尚未匹配完成,请稍后查看'
|
||||||
|
}
|
||||||
return '未匹配成功,请检查店铺名'
|
return '未匹配成功,请检查店铺名'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -216,6 +216,8 @@ import {
|
|||||||
checkParseResult,
|
checkParseResult,
|
||||||
checkQueuePayload,
|
checkQueuePayload,
|
||||||
checkSelectedFiles,
|
checkSelectedFiles,
|
||||||
|
collectDistinctErrors,
|
||||||
|
guardBlocked,
|
||||||
} from '@/shared/dispatch-guard'
|
} from '@/shared/dispatch-guard'
|
||||||
import { passGuard } from '@/shared/dispatch-guard-ui'
|
import { passGuard } from '@/shared/dispatch-guard-ui'
|
||||||
import { useZiniaoVersion, type ZiniaoVersion } from '@/shared/utils/ziniao-version'
|
import { useZiniaoVersion, type ZiniaoVersion } from '@/shared/utils/ziniao-version'
|
||||||
@@ -563,10 +565,25 @@ async function submitRun() {
|
|||||||
sync_countries: syncCountries.value.filter((country) => country !== publishCountry.value),
|
sync_countries: syncCountries.value.filter((country) => country !== publishCountry.value),
|
||||||
})
|
})
|
||||||
if (!parsed.taskId) throw new Error('后端未返回有效任务标识')
|
if (!parsed.taskId) throw new Error('后端未返回有效任务标识')
|
||||||
// publish 的 Vo 用 files 承载明细,没有 acceptedRows;这里先卡住 taskId 与总行数
|
const fileErrors = collectDistinctErrors(
|
||||||
|
(parsed.files || []).map((file) => file.errorMessage || file.error),
|
||||||
|
)
|
||||||
|
// 店铺未录入后台是最高频的解析失败原因(2026-09-17 客户因此连试 7 次),
|
||||||
|
// 单独用「店铺未找到」弹窗直给后端原因,避免被通用文案淹没
|
||||||
|
const missingShopErrors = fileErrors.filter((message) => message.includes('未找到店铺'))
|
||||||
|
if (!parsed.totalRows && missingShopErrors.length) {
|
||||||
|
await passGuard(guardBlocked('店铺未找到', missingShopErrors.join('\n'), 'publish.shop-missing'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// publish 的 Vo 用 files 承载明细,没有 acceptedRows;这里先卡住 taskId 与总行数。
|
||||||
|
// 文件级失败(店铺未匹配等)时 Java 不解析 Excel、totalRows 同样为 0,把具体
|
||||||
|
// 原因一并交给 guard 优先展示,避免只报「没有读到任何数据行」误导排查方向。
|
||||||
const guard = checkParseResult(
|
const guard = checkParseResult(
|
||||||
{ taskId: parsed.taskId, totalRows: parsed.totalRows, acceptedRows: parsed.totalRows },
|
{ taskId: parsed.taskId, totalRows: parsed.totalRows, acceptedRows: parsed.totalRows },
|
||||||
{ requiredColumnsHint: '店铺名 / 商品行' },
|
{
|
||||||
|
requiredColumnsHint: '店铺名 / 商品行',
|
||||||
|
fileErrors,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
if (!(await passGuard(guard))) return
|
if (!(await passGuard(guard))) return
|
||||||
|
|
||||||
@@ -604,7 +621,9 @@ async function submitRun() {
|
|||||||
|
|
||||||
await Promise.all([loadDashboard(), loadHistory()])
|
await Promise.all([loadDashboard(), loadHistory()])
|
||||||
if (!batch.pendingFileIds.length) {
|
if (!batch.pendingFileIds.length) {
|
||||||
queueMessage.value = '解析完成,当前没有匹配成功且可上架的文件。'
|
queueMessage.value = fileErrors.length
|
||||||
|
? `解析完成,当前没有匹配成功且可上架的文件:${fileErrors[0]}`
|
||||||
|
: '解析完成,当前没有匹配成功且可上架的文件。'
|
||||||
ElMessage.warning(queueMessage.value)
|
ElMessage.warning(queueMessage.value)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -439,6 +439,12 @@ function formatMatchRemark(row: QueryAsinShopQueueItem) {
|
|||||||
if (row.matched) {
|
if (row.matched) {
|
||||||
return "已匹配成功,请查看状态确认";
|
return "已匹配成功,请查看状态确认";
|
||||||
}
|
}
|
||||||
|
if (row.matchStatus === "CONFLICT") {
|
||||||
|
return "存在多个同名店铺,请人工确认";
|
||||||
|
}
|
||||||
|
if (row.matchStatus === "PENDING") {
|
||||||
|
return "店铺尚未匹配完成,请稍后查看";
|
||||||
|
}
|
||||||
return "未匹配成功,请检查店铺名";
|
return "未匹配成功,请检查店铺名";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -565,7 +565,7 @@ async function deleteTaskRecord(item: ShopMatchHistoryItem) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
function formatMatchStatus(status?: string) { const value = (status || '').trim(); return { MATCHED: '已匹配', PENDING: '待匹配', CONFLICT: '需人工确认', INDEX_STALE: '匹配已过期' }[value] || value || '—' }
|
function formatMatchStatus(status?: string) { const value = (status || '').trim(); return { MATCHED: '已匹配', PENDING: '待匹配', CONFLICT: '需人工确认', INDEX_STALE: '匹配已过期' }[value] || value || '—' }
|
||||||
function formatMatchRemark(row: ShopMatchShopQueueItem) { const message = (row.matchMessage || '').trim(); if (message) return message; if (row.matched && row.matchStatus === 'MATCHED') return '已匹配成功,可启动任务'; if (row.matched) return '已关联店铺,请查看状态确认'; return '未匹配成功,请检查店铺名' }
|
function formatMatchRemark(row: ShopMatchShopQueueItem) { const message = (row.matchMessage || '').trim(); if (message) return message; if (row.matched && row.matchStatus === 'MATCHED') return '已匹配成功,可启动任务'; if (row.matched) return '已关联店铺,请查看状态确认'; if (row.matchStatus === 'CONFLICT') return '存在多个同名店铺,请人工确认'; if (row.matchStatus === 'PENDING') return '店铺尚未匹配完成,请稍后查看'; return '未匹配成功,请检查店铺名' }
|
||||||
async function pushToPythonQueue() { autoQueueEnabled.value = true; await processMatchedQueue() }
|
async function pushToPythonQueue() { autoQueueEnabled.value = true; await processMatchedQueue() }
|
||||||
function nextMatchedQueueItem() { return matchedItems.value.find((item) => item.matched) }
|
function nextMatchedQueueItem() { return matchedItems.value.find((item) => item.matched) }
|
||||||
async function processMatchedQueue() { if (disposed || queueWorkerRunning.value) return; const api = getPywebviewApi(); if (!api?.enqueue_json) { ElMessage.error('当前客户端未提供 enqueue_json'); return } const matched = matchedItems.value.filter((item) => item.matched); if (!matched.length) { ElMessage.warning('请先匹配可用店铺'); return } if (!orderedCountryCodes.value.length) { ElMessage.warning('请至少勾选一个国家'); return } let scheduleValues: string[] | undefined; try { scheduleValues = parseScheduleValues() } catch (error) { ElMessage.error(error instanceof Error ? error.message : '定时配置无效'); return } queueWorkerRunning.value = true; pushing.value = true; queuePayloadText.value = ''; let successCount = 0; let failedCount = 0; let index = 0; try { while (!disposed && autoQueueEnabled.value) { const item = nextMatchedQueueItem(); if (!item) break; index += 1; try { const created = await withTransientRetry(() => createShopMatchTask([buildTaskCreateItem(item)], orderedCountryCodes.value, scheduleValues), (attempt, maxAttempts) => { queuePushResult.value = `服务暂时不可用,正在重试创建任务(${attempt}/${maxAttempts})...` }); const taskId = created.taskId; const initialStatus = scheduleValues?.length ? 'SCHEDULED' : 'RUNNING'; const snapshot: ShopMatchTaskDetailVo = { task: { id: taskId, status: initialStatus, scheduledAt: scheduleValues?.[0], countryCodes: [...orderedCountryCodes.value], currentStageIndex: scheduleValues?.length ? 0 : undefined, activeStageIndex: undefined, scheduleStages: (scheduleValues || []).map((value, stageIndex) => ({ stageIndex, scheduledAt: value, status: stageIndex === 0 ? 'SCHEDULED' : 'PENDING' })) }, items: created.items }; taskSnapshots.value = { ...taskSnapshots.value, [taskId]: snapshot }; taskDetails.value = { ...taskDetails.value, [taskId]: initialStatus }; saveTaskSnapshotsToStorage(); saveTaskDetailsToStorage(); const createdItem = created.items?.[0]; if (!createdItem) { removeMatchedRowsLocally([item]); continue } if (scheduleValues?.length) { successCount += 1; restoreScheduledDispatches(); queuePushResult.value = `任务 ${taskId} 已创建,共 ${scheduleValues.length} 个执行时间点`; removeMatchedRowsLocally([item]); continue } const payload = buildQueuePayload(taskId, item, orderedCountryCodes.value); queuePayloadText.value = JSON.stringify(payload, null, 2); const result = await api.enqueue_json(payload); if (!result?.success) { failedCount += 1; queuePushResult.value = `任务 ${taskId} 启动失败,已自动继续下一条:${result?.error || '未知错误'}`; ElMessage.error(queuePushResult.value); removeMatchedRowsLocally([item]); continue } addPollingTask(taskId); ensurePolling(true); removeMatchedRowsLocally([item]); queuePushResult.value = `任务 ${taskId} 已提交,等待执行完成`; const finalStatus = await waitForTaskTerminal(taskId); if (finalStatus !== 'SUCCESS') { failedCount += 1; queuePushResult.value = `任务 ${taskId} 执行失败,已自动继续下一条`; ElMessage.error(queuePushResult.value); continue } successCount += 1; queuePushResult.value = `任务 ${taskId} 已完成` } catch (error) { if (disposed) break; failedCount += 1; queuePushResult.value = `第 ${index} 条任务处理失败,已自动继续下一条:${error instanceof Error ? error.message : '未知错误'}`; ElMessage.error(queuePushResult.value); removeMatchedRowsLocally([item]); continue } } restoreScheduledDispatches(); if (!scheduleValues?.length) ensurePolling(true); if (!disposed && (successCount > 0 || failedCount > 0)) ElMessage.success(scheduleValues?.length ? `定时任务已创建:成功 ${successCount} 条,失败 ${failedCount} 条` : `店铺启动已完成:成功 ${successCount} 条,失败 ${failedCount} 条`) } catch (error) { if (disposed) return; const message = error instanceof Error ? error.message : '启动失败'; queuePushResult.value = message; ElMessage.error(message) } finally { queueWorkerRunning.value = false; pushing.value = false } }
|
async function processMatchedQueue() { if (disposed || queueWorkerRunning.value) return; const api = getPywebviewApi(); if (!api?.enqueue_json) { ElMessage.error('当前客户端未提供 enqueue_json'); return } const matched = matchedItems.value.filter((item) => item.matched); if (!matched.length) { ElMessage.warning('请先匹配可用店铺'); return } if (!orderedCountryCodes.value.length) { ElMessage.warning('请至少勾选一个国家'); return } let scheduleValues: string[] | undefined; try { scheduleValues = parseScheduleValues() } catch (error) { ElMessage.error(error instanceof Error ? error.message : '定时配置无效'); return } queueWorkerRunning.value = true; pushing.value = true; queuePayloadText.value = ''; let successCount = 0; let failedCount = 0; let index = 0; try { while (!disposed && autoQueueEnabled.value) { const item = nextMatchedQueueItem(); if (!item) break; index += 1; try { const created = await withTransientRetry(() => createShopMatchTask([buildTaskCreateItem(item)], orderedCountryCodes.value, scheduleValues), (attempt, maxAttempts) => { queuePushResult.value = `服务暂时不可用,正在重试创建任务(${attempt}/${maxAttempts})...` }); const taskId = created.taskId; const initialStatus = scheduleValues?.length ? 'SCHEDULED' : 'RUNNING'; const snapshot: ShopMatchTaskDetailVo = { task: { id: taskId, status: initialStatus, scheduledAt: scheduleValues?.[0], countryCodes: [...orderedCountryCodes.value], currentStageIndex: scheduleValues?.length ? 0 : undefined, activeStageIndex: undefined, scheduleStages: (scheduleValues || []).map((value, stageIndex) => ({ stageIndex, scheduledAt: value, status: stageIndex === 0 ? 'SCHEDULED' : 'PENDING' })) }, items: created.items }; taskSnapshots.value = { ...taskSnapshots.value, [taskId]: snapshot }; taskDetails.value = { ...taskDetails.value, [taskId]: initialStatus }; saveTaskSnapshotsToStorage(); saveTaskDetailsToStorage(); const createdItem = created.items?.[0]; if (!createdItem) { removeMatchedRowsLocally([item]); continue } if (scheduleValues?.length) { successCount += 1; restoreScheduledDispatches(); queuePushResult.value = `任务 ${taskId} 已创建,共 ${scheduleValues.length} 个执行时间点`; removeMatchedRowsLocally([item]); continue } const payload = buildQueuePayload(taskId, item, orderedCountryCodes.value); queuePayloadText.value = JSON.stringify(payload, null, 2); const result = await api.enqueue_json(payload); if (!result?.success) { failedCount += 1; queuePushResult.value = `任务 ${taskId} 启动失败,已自动继续下一条:${result?.error || '未知错误'}`; ElMessage.error(queuePushResult.value); removeMatchedRowsLocally([item]); continue } addPollingTask(taskId); ensurePolling(true); removeMatchedRowsLocally([item]); queuePushResult.value = `任务 ${taskId} 已提交,等待执行完成`; const finalStatus = await waitForTaskTerminal(taskId); if (finalStatus !== 'SUCCESS') { failedCount += 1; queuePushResult.value = `任务 ${taskId} 执行失败,已自动继续下一条`; ElMessage.error(queuePushResult.value); continue } successCount += 1; queuePushResult.value = `任务 ${taskId} 已完成` } catch (error) { if (disposed) break; failedCount += 1; queuePushResult.value = `第 ${index} 条任务处理失败,已自动继续下一条:${error instanceof Error ? error.message : '未知错误'}`; ElMessage.error(queuePushResult.value); removeMatchedRowsLocally([item]); continue } } restoreScheduledDispatches(); if (!scheduleValues?.length) ensurePolling(true); if (!disposed && (successCount > 0 || failedCount > 0)) ElMessage.success(scheduleValues?.length ? `定时任务已创建:成功 ${successCount} 条,失败 ${failedCount} 条` : `店铺启动已完成:成功 ${successCount} 条,失败 ${failedCount} 条`) } catch (error) { if (disposed) return; const message = error instanceof Error ? error.message : '启动失败'; queuePushResult.value = message; ElMessage.error(message) } finally { queueWorkerRunning.value = false; pushing.value = false } }
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ import { expandBrandFolderRecursive } from '@/shared/api/brand'
|
|||||||
import { deleteSplitHistory, getExcelInfo, getSplitHistory, getSplitResultDownloadUrl, runSplit, type SplitResultItem } from '@/shared/api/java-modules'
|
import { deleteSplitHistory, getExcelInfo, getSplitHistory, getSplitResultDownloadUrl, runSplit, type SplitResultItem } from '@/shared/api/java-modules'
|
||||||
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
|
import { getPywebviewApi, type UploadedJavaFile } from '@/shared/bridges/pywebview'
|
||||||
import { saveUrlWithProgress } from '@/shared/utils/download-progress'
|
import { saveUrlWithProgress } from '@/shared/utils/download-progress'
|
||||||
import { EXCEL_EXTENSIONS, checkSelectedFiles, guardBlocked } from '@/shared/dispatch-guard'
|
import { EXCEL_EXTENSIONS, checkSelectedFiles, collectDistinctErrors, guardBlocked } from '@/shared/dispatch-guard'
|
||||||
import { passGuard } from '@/shared/dispatch-guard-ui'
|
import { passGuard } from '@/shared/dispatch-guard-ui'
|
||||||
import { formatDateTime } from '@/shared/utils/datetime'
|
import { formatDateTime } from '@/shared/utils/datetime'
|
||||||
import { uploadPathsToJava } from '@/shared/utils/upload-to-java'
|
import { uploadPathsToJava } from '@/shared/utils/upload-to-java'
|
||||||
@@ -327,11 +327,15 @@ async function submitSplitRun() {
|
|||||||
splitResultItems.value = result.items || []
|
splitResultItems.value = result.items || []
|
||||||
await loadSplitHistory()
|
await loadSplitHistory()
|
||||||
if (result.total > 0 && result.successCount === 0) {
|
if (result.total > 0 && result.successCount === 0) {
|
||||||
|
// 逐文件原因就在 items[].error 里,优先直接列出,不再让用户对着「常见原因」猜
|
||||||
|
const details = collectDistinctErrors((result.items || []).map((item) => item.error)).slice(0, 5)
|
||||||
await passGuard(
|
await passGuard(
|
||||||
guardBlocked(
|
guardBlocked(
|
||||||
'拆分未成功',
|
'拆分未成功',
|
||||||
`本次提交的 ${result.total} 个文件全部处理失败。\n` +
|
`本次提交的 ${result.total} 个文件全部处理失败。\n` +
|
||||||
'常见原因是表头列名与所选保留列对不上,或文件内没有数据行。\n' +
|
(details.length
|
||||||
|
? `失败原因:\n${details.map((message) => `· ${message}`).join('\n')}\n`
|
||||||
|
: '常见原因是表头列名与所选保留列对不上,或文件内没有数据行。\n') +
|
||||||
'请在右侧结果列表查看每个文件的失败原因后重试。',
|
'请在右侧结果列表查看每个文件的失败原因后重试。',
|
||||||
'split.all-failed',
|
'split.all-failed',
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -570,6 +570,12 @@ function formatMatchRemark(row: WithdrawShopQueueItem) {
|
|||||||
if (row.matched) {
|
if (row.matched) {
|
||||||
return "已匹配成功,请查看状态确认";
|
return "已匹配成功,请查看状态确认";
|
||||||
}
|
}
|
||||||
|
if (row.matchStatus === "CONFLICT") {
|
||||||
|
return "存在多个同名店铺,请人工确认";
|
||||||
|
}
|
||||||
|
if (row.matchStatus === "PENDING") {
|
||||||
|
return "店铺尚未匹配完成,请稍后查看";
|
||||||
|
}
|
||||||
return "未匹配成功,请检查店铺名";
|
return "未匹配成功,请检查店铺名";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1015,12 +1015,12 @@ async function waitForImageVideoTask(ticket: ImageVideoAsyncTaskVo): Promise<unk
|
|||||||
}
|
}
|
||||||
if (task.status === 'SUCCESS') return task.result
|
if (task.status === 'SUCCESS') return task.result
|
||||||
if (isTerminalImageVideoTask(task)) {
|
if (isTerminalImageVideoTask(task)) {
|
||||||
throw new Error(task.errorMessage || 'Coze task failed')
|
throw new Error(task.errorMessage || 'Coze 任务执行失败')
|
||||||
}
|
}
|
||||||
await sleep(IMAGE_VIDEO_TASK_POLL_DELAY_MS)
|
await sleep(IMAGE_VIDEO_TASK_POLL_DELAY_MS)
|
||||||
task = await getImageVideoAsyncTask(task.taskId)
|
task = await getImageVideoAsyncTask(task.taskId)
|
||||||
}
|
}
|
||||||
throw new Error('Coze task polling timed out')
|
throw new Error('Coze 任务查询超时,请稍后重试')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function rewriteScriptFromSource() {
|
async function rewriteScriptFromSource() {
|
||||||
@@ -1296,7 +1296,8 @@ async function pollAssemblyResult(tab: WorkspaceTab, taskId: number) {
|
|||||||
if (isTerminalImageVideoTask(task)) {
|
if (isTerminalImageVideoTask(task)) {
|
||||||
assembly.polling = false
|
assembly.polling = false
|
||||||
if (task.status === 'FAILED') {
|
if (task.status === 'FAILED') {
|
||||||
ElMessage.error('Coze 工作流执行失败')
|
// 后端 errorMessage 带具体原因(内容违规/超时/额度等),只报「执行失败」用户无从下手
|
||||||
|
ElMessage.error(task.errorMessage ? `Coze 工作流执行失败:${task.errorMessage}` : 'Coze 工作流执行失败')
|
||||||
} else {
|
} else {
|
||||||
ElMessage.success(videoUrl || assembly.videoUrl ? '视频生成完成' : 'Coze 工作流执行完成,未解析到视频地址')
|
ElMessage.success(videoUrl || assembly.videoUrl ? '视频生成完成' : 'Coze 工作流执行完成,未解析到视频地址')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -267,6 +267,13 @@ export interface ParseResultOptions {
|
|||||||
requireRows?: boolean
|
requireRows?: boolean
|
||||||
/** 必要字段名,用于拼「缺什么」的提示,如 'ASIN / 国家' */
|
/** 必要字段名,用于拼「缺什么」的提示,如 'ASIN / 国家' */
|
||||||
requiredColumnsHint?: string
|
requiredColumnsHint?: string
|
||||||
|
/**
|
||||||
|
* 文件级失败原因(后端 files[].errorMessage)。0 有效行时优先展示这些具体
|
||||||
|
* 原因:店铺未录入、表头不匹配等都会让文件级解析提前失败,totalRows 同样是
|
||||||
|
* 0,只报通用「空文件」文案会把用户引向错误的排查方向(2026-09-17 用户因
|
||||||
|
* 「店铺未录入」被误导反复重传同一个 Excel,连试 7 次)。
|
||||||
|
*/
|
||||||
|
fileErrors?: readonly string[]
|
||||||
title?: string
|
title?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,7 +340,21 @@ export function checkParseResult(
|
|||||||
: ''
|
: ''
|
||||||
|
|
||||||
if (requireRows && acceptedRows === 0) {
|
if (requireRows && acceptedRows === 0) {
|
||||||
if (!totalRows) {
|
const fileErrors = collectDistinctErrors(options.fileErrors)
|
||||||
|
if (fileErrors.length) {
|
||||||
|
const shown = fileErrors.slice(0, 8)
|
||||||
|
issues.push({
|
||||||
|
code: 'parse.file-failed',
|
||||||
|
severity: 'block',
|
||||||
|
message:
|
||||||
|
'以下文件解析未通过,没有可执行的数据行:\n' +
|
||||||
|
shown.map((item) => `· ${item}`).join('\n') +
|
||||||
|
(fileErrors.length > shown.length
|
||||||
|
? `\n· 另有 ${fileErrors.length - shown.length} 条不同原因`
|
||||||
|
: '') +
|
||||||
|
'\n请按上述原因处理对应文件后重新上传解析。',
|
||||||
|
})
|
||||||
|
} else if (!totalRows) {
|
||||||
issues.push({
|
issues.push({
|
||||||
code: 'parse.empty-file',
|
code: 'parse.empty-file',
|
||||||
severity: 'block',
|
severity: 'block',
|
||||||
@@ -387,6 +408,20 @@ function normalizeCount(value: unknown): number | null {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 汇总一组错误原因:去空、去重(同一原因多条只留一条),保持出现顺序。 */
|
||||||
|
export function collectDistinctErrors(values: readonly unknown[] | undefined): string[] {
|
||||||
|
if (!values || !values.length) return []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const result: string[] = []
|
||||||
|
for (const value of values) {
|
||||||
|
const trimmed = typeof value === 'string' ? value.trim() : ''
|
||||||
|
if (!trimmed || seen.has(trimmed)) continue
|
||||||
|
seen.add(trimmed)
|
||||||
|
result.push(trimmed)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
export interface QueuePayloadOptions {
|
export interface QueuePayloadOptions {
|
||||||
/** data 下必须存在且非空的字段名 */
|
/** data 下必须存在且非空的字段名 */
|
||||||
requiredDataKeys?: readonly string[]
|
requiredDataKeys?: readonly string[]
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
checkParseResult,
|
checkParseResult,
|
||||||
checkQueuePayload,
|
checkQueuePayload,
|
||||||
checkSelectedFiles,
|
checkSelectedFiles,
|
||||||
|
collectDistinctErrors,
|
||||||
extensionOf,
|
extensionOf,
|
||||||
findUnsafeJsonPaths,
|
findUnsafeJsonPaths,
|
||||||
guardPassed,
|
guardPassed,
|
||||||
@@ -165,6 +166,55 @@ test('test_task_101_dispatch_guard_boundary_parse_result_zero_rows_blocked', ()
|
|||||||
assert.match(emptyFile.message, /没有读到任何数据行/)
|
assert.match(emptyFile.message, /没有读到任何数据行/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('test_task_101_dispatch_guard_parse_result_file_errors_shown_instead_of_empty_file', () => {
|
||||||
|
// 2026-09-17:店铺未录入后台导致文件级失败,用户被「空文件」文案误导反复重传
|
||||||
|
const result = checkParseResult(parseVo({ totalRows: 0, acceptedRows: 0 }), {
|
||||||
|
fileErrors: ['后台店铺管理中未找到店铺:林清斌,请先添加店铺信息'],
|
||||||
|
})
|
||||||
|
assert.equal(result.ok, false)
|
||||||
|
assert.deepEqual(codes(result), ['parse.file-failed'])
|
||||||
|
assert.match(result.message, /林清斌/)
|
||||||
|
assert.ok(!result.message.includes('没有读到任何数据行'), '有具体原因时不展示通用空文件文案')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_101_dispatch_guard_parse_result_file_errors_dedupe_blank_and_limit', () => {
|
||||||
|
const deduped = checkParseResult(parseVo({ totalRows: 0, acceptedRows: 0 }), {
|
||||||
|
fileErrors: ['店铺未匹配', ' ', '店铺未匹配', '', '第二条原因'],
|
||||||
|
})
|
||||||
|
assert.deepEqual(codes(deduped), ['parse.file-failed'])
|
||||||
|
assert.equal(
|
||||||
|
deduped.message.split('\n').filter((line) => line.startsWith('· ')).length,
|
||||||
|
2,
|
||||||
|
'空串与重复原因不应重复展示',
|
||||||
|
)
|
||||||
|
|
||||||
|
const many = checkParseResult(parseVo({ totalRows: 0, acceptedRows: 0 }), {
|
||||||
|
fileErrors: Array.from({ length: 10 }, (_, index) => `原因 ${index}`),
|
||||||
|
})
|
||||||
|
assert.match(many.message, /另有 2 条不同原因/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_101_dispatch_guard_parse_result_blank_file_errors_fallback_to_empty_file', () => {
|
||||||
|
const result = checkParseResult(parseVo({ totalRows: 0, acceptedRows: 0 }), {
|
||||||
|
fileErrors: [' ', ''],
|
||||||
|
})
|
||||||
|
assert.deepEqual(codes(result), ['parse.empty-file'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_101_dispatch_guard_collect_distinct_errors', () => {
|
||||||
|
assert.deepEqual(collectDistinctErrors(['a', ' ', 'a', '', 'b']), ['a', 'b'], '去空去重且保持顺序')
|
||||||
|
assert.deepEqual(collectDistinctErrors([' 店铺未找到 ']), ['店铺未找到'], '首尾空白裁剪')
|
||||||
|
assert.deepEqual(collectDistinctErrors([]), [])
|
||||||
|
assert.deepEqual(collectDistinctErrors(undefined), [])
|
||||||
|
assert.deepEqual(collectDistinctErrors([null, 7, undefined]), [], '非字符串项忽略')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_101_dispatch_guard_parse_result_file_errors_ignored_when_rows_present', () => {
|
||||||
|
const result = checkParseResult(parseVo(), { fileErrors: ['某文件失败'] })
|
||||||
|
assert.equal(result.ok, true)
|
||||||
|
assert.equal(result.needsConfirm, false)
|
||||||
|
})
|
||||||
|
|
||||||
test('test_task_101_dispatch_guard_parse_result_zero_rows_allowed_when_not_required', () => {
|
test('test_task_101_dispatch_guard_parse_result_zero_rows_allowed_when_not_required', () => {
|
||||||
const result = checkParseResult(parseVo({ totalRows: 0, acceptedRows: 0 }), { requireRows: false })
|
const result = checkParseResult(parseVo({ totalRows: 0, acceptedRows: 0 }), { requireRows: false })
|
||||||
assert.equal(result.ok, true)
|
assert.equal(result.ok, true)
|
||||||
|
|||||||
Reference in New Issue
Block a user