现象
import:confirm 在草稿的每一行都还带着 issues 时不会报错,而是把空持仓数组落库、返回「导入成功」。
UI 侧同样放行:「确认导入」按钮只在 draft.rows.length === 0 时禁用,不检查行是否仍有 issue。导入一份「每行都缺字段」的 CSV 后按钮仍可点击,用户会拿到一个 0 持仓的组合,且界面上没有任何提示。
根因
apps/electron/src/main/kernelHost.ts:2099-2112
const rows = Array.isArray(draft.rows) ? draft.rows : [];
if (rows.length === 0) { // ← 校验的是过滤前的 rows
throw createCodeError('IMPORT_EMPTY', 'Nothing to import — the draft has no rows.');
}
const holdings: Holding[] = rows
.filter((row) => row.symbol && row.issues.length === 0) // ← 真正落库的是过滤后的 holdings
.map((row) => ({ ... }));
return this.importRepository.create({ name, holdings }); // ← holdings 为空也照常创建
守卫对象(rows)与落库对象(holdings)不是同一个集合:只要 rows.length > 0,守卫就一定放行,而 holdings 可能已经是空数组。ManualPortfolioRepository.create 原样写入 holdings(packages/shared/src/portfolio-import/repository.ts:56-65),因此空组合会被持久化。
UI 侧对应位置:packages/ui/src/components/portfolio/ImportDraftReview.tsx:173(disabled={confirming || draft.rows.length === 0})。
复现
经 import:confirm 传入 UI 可直接产生的草稿:
{
"name": "我的组合",
"draft": {
"id": "draft_test",
"source": "csv",
"importedAt": 1,
"warnings": [],
"rows": [
{ "symbol": "AAPL.US", "confidence": 0.4, "issues": ["Quantity is not a number"] },
{ "symbol": "TSLA.US", "confidence": 0.3, "issues": ["Cost price missing"] }
]
}
}
- 实际:
IMPORT_EMPTY 不触发(rows.length === 2),过滤后 holdings = [],create({ name, holdings: [] }) 成功,IPC 返回 { ok: true, data: <0 持仓组合> }。
- 预期:抛
IMPORT_EMPTY(「无可导入行」),不落库。
修复
apps/electron/src/main/kernelHost.ts:守卫改为作用在落库对象上 —— 先算出 importable = rows.filter(row => row.symbol && row.issues.length === 0),importable.length === 0 即抛 IMPORT_EMPTY。这同时覆盖了原来的 rows.length === 0 分支(空 rows 必然导致空 importable)。
packages/ui/src/components/portfolio/ImportDraftReview.tsx:确认按钮改为 disabled={confirming || importableCount === 0},使前端在提交前就阻止这种情况,而不是让用户拿到一个后端错误。
验证
bun test apps/electron/src/main/kernelHost.test.ts packages/ui/src/components/portfolio/ImportDraftReview.test.tsx
- 修复前:20 pass / 2 fail(新增的两条用例精确失败:
expect(promise).rejects 收到 resolved 的 Promise;按钮 disabled 为 false)
- 修复后:22 pass / 0 fail
bun run typecheck:5 个 workspace 全部 Exited with code 0。
影响范围
仅改动导入确认路径的校验与按钮禁用条件,不触及解析、草稿生成与仓库层,对合法导入(至少一行无 issue)行为完全不变。
现象
import:confirm在草稿的每一行都还带着issues时不会报错,而是把空持仓数组落库、返回「导入成功」。UI 侧同样放行:「确认导入」按钮只在
draft.rows.length === 0时禁用,不检查行是否仍有 issue。导入一份「每行都缺字段」的 CSV 后按钮仍可点击,用户会拿到一个 0 持仓的组合,且界面上没有任何提示。根因
apps/electron/src/main/kernelHost.ts:2099-2112守卫对象(
rows)与落库对象(holdings)不是同一个集合:只要rows.length > 0,守卫就一定放行,而holdings可能已经是空数组。ManualPortfolioRepository.create原样写入holdings(packages/shared/src/portfolio-import/repository.ts:56-65),因此空组合会被持久化。UI 侧对应位置:
packages/ui/src/components/portfolio/ImportDraftReview.tsx:173(disabled={confirming || draft.rows.length === 0})。复现
经
import:confirm传入 UI 可直接产生的草稿:{ "name": "我的组合", "draft": { "id": "draft_test", "source": "csv", "importedAt": 1, "warnings": [], "rows": [ { "symbol": "AAPL.US", "confidence": 0.4, "issues": ["Quantity is not a number"] }, { "symbol": "TSLA.US", "confidence": 0.3, "issues": ["Cost price missing"] } ] } }IMPORT_EMPTY不触发(rows.length === 2),过滤后holdings = [],create({ name, holdings: [] })成功,IPC 返回{ ok: true, data: <0 持仓组合> }。IMPORT_EMPTY(「无可导入行」),不落库。修复
apps/electron/src/main/kernelHost.ts:守卫改为作用在落库对象上 —— 先算出importable = rows.filter(row => row.symbol && row.issues.length === 0),importable.length === 0即抛IMPORT_EMPTY。这同时覆盖了原来的rows.length === 0分支(空 rows 必然导致空 importable)。packages/ui/src/components/portfolio/ImportDraftReview.tsx:确认按钮改为disabled={confirming || importableCount === 0},使前端在提交前就阻止这种情况,而不是让用户拿到一个后端错误。验证
bun test apps/electron/src/main/kernelHost.test.ts packages/ui/src/components/portfolio/ImportDraftReview.test.tsxexpect(promise).rejects收到 resolved 的 Promise;按钮disabled为false)bun run typecheck:5 个 workspace 全部Exited with code 0。影响范围
仅改动导入确认路径的校验与按钮禁用条件,不触及解析、草稿生成与仓库层,对合法导入(至少一行无 issue)行为完全不变。