fix(提示语): 失败提示优先展示后端真实原因,修正 10+ 处误导文案

上架/删除品牌/去重/转换/拆分/撤回/查ASIN/产品风险/跟价/巡店/店铺匹配/视频工作台:
- 解析失败不再只报通用文案,逐条展示后端文件级 errorMessage(上架新增「店铺未找到」专属弹窗)
- 匹配失败不再一律「请检查店铺名」,按 matchStatus 区分 CONFLICT/PENDING
- 去重/转换/拆分全失败弹窗优先列各文件真实原因,取不到才回退猜测文案
- dispatch-guard 新增 fileErrors 选项与 collectDistinctErrors 导出
This commit is contained in:
2026-09-17 23:02:15 +08:00
parent d2d95f0b71
commit 3ce0569c59
14 changed files with 166 additions and 16 deletions
@@ -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 工作流执行完成,未解析到视频地址')
} }
+36 -1
View File
@@ -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[]
+50
View File
@@ -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)