From d7a5edd73011f8e5f5af27df484097cc753c3e6b Mon Sep 17 00:00:00 2001 From: shy3130 Date: Thu, 29 Oct 2026 11:09:06 +0800 Subject: [PATCH] =?UTF-8?q?fix(signals):=20=E6=95=B0=E5=AD=97=E5=8F=B3?= =?UTF-8?q?=E5=80=BC=E6=9D=A1=E4=BB=B6=E4=B8=8D=E5=86=8D=E6=95=B4=E9=A1=B5?= =?UTF-8?q?=E5=B4=A9=E6=BA=83=20=E2=80=94=20=E4=BF=AE=E5=A4=8D"=E6=89=A9?= =?UTF-8?q?=E5=B1=95=20official.signals=20=E6=9A=82=E6=97=B6=E4=B8=8D?= =?UTF-8?q?=E5=8F=AF=E7=94=A8"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户反馈信号库/模拟盘页显示"扩展 official.signals 暂时不可用" — 错误边界 兜底, 真实错误是渲染期 TypeError。根因: 残月策略的两个迁移信号 (canyue_long_enter/exit) 条件右值为 number (0.6/0.2, 迁移时 Python float 直接进 JSON), 后端 isinstance(int,float) 一直合法, 但前端两处 right.startsWith('field:') 假设恒为字符串, 数字右值直接抛 "startsWith is not a function", 整页渲染崩。崩点代码 v0.3.6 即有, 系数据侧新出现数字右值触发, 非本版回归。 修复: 信号库列表渲染与条件编辑器 (ConditionEditor, 多处共享) 均加 typeof 守卫, 数字常量正常显示与回填; CustomSignalCondition.right 类型 放宽为 string | number 诚实反映运行时, 编译期逼出并修复编辑器一处 隐藏收窄问题。 验证: Signals.numeric-right.test.tsx 2 条回归 (真实数据形态 + 错误边界 断言, 修复前 2 failed 复现线上错误 → 修复后 passed); 全量 vitest 337 passed; pnpm build 通过。 Signed-off-by: shy3130 --- .../components/signals/ConditionEditor.tsx | 9 +- .../signals/Signals.numeric-right.test.tsx | 103 ++++++++++++++++++ frontend/src/custom/signals/extension.tsx | 2 +- frontend/src/lib/api.ts | 2 +- 4 files changed, 111 insertions(+), 5 deletions(-) create mode 100644 frontend/src/custom/signals/Signals.numeric-right.test.tsx diff --git a/frontend/src/components/signals/ConditionEditor.tsx b/frontend/src/components/signals/ConditionEditor.tsx index 89cf5a94..4754944e 100644 --- a/frontend/src/components/signals/ConditionEditor.tsx +++ b/frontend/src/components/signals/ConditionEditor.tsx @@ -297,9 +297,12 @@ function RightValueInput({ cond, fields, groups, maxDays, hideDays, onChangeRigh onChangeRight: (v: string) => void onChangeDays: (v: number) => void }) { - const isField = cond.right.startsWith('field:') - const fieldValue = isField ? cond.right.slice(6) : '' - const numValue = isField ? '' : cond.right + // 右值可为字段引用或数字常量; 迁移的历史信号 (如残月) 常量是 number — 后端 + // isinstance(int,float) 一直合法, 前端判断必须先排除非字符串再 startsWith + const right = cond.right + const isField = typeof right === 'string' && right.startsWith('field:') + const fieldValue = typeof right === 'string' ? right.slice(6) : '' + const numValue = typeof right === 'string' ? right : String(right) return (
diff --git a/frontend/src/custom/signals/Signals.numeric-right.test.tsx b/frontend/src/custom/signals/Signals.numeric-right.test.tsx new file mode 100644 index 00000000..4534afdf --- /dev/null +++ b/frontend/src/custom/signals/Signals.numeric-right.test.tsx @@ -0,0 +1,103 @@ +// @vitest-environment jsdom +// 回归: "扩展 official.signals 暂时不可用" — 条件右值为数字 (如迁移的残月信号 +// right=0.6, 后端 isinstance(int,float) 一直合法) 时, 前端 c.right.startsWith +// 直接 TypeError, 整页渲染崩、错误边界兜底。用真实接口数据结构 + ExtensionBoundary +// 复刻页面挂载路径, 断言边界不再触发、条件正常显示。 +import { createRoot, type Root } from 'react-dom/client' +import { MemoryRouter } from 'react-router-dom' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { Signals } from '@/custom/signals/extension' +import { ConditionEditor } from '@/components/signals/ConditionEditor' +import { ExtensionBoundary } from '@/extensions/ExtensionBoundary' + +const fixtures = vi.hoisted(() => ({ + options: { + fields: [ + { key: 'close', label: '前复权收盘价' }, + { key: 'volume', label: '成交量' }, + { key: 'ma5', label: 'MA5' }, + { key: 'ma20', label: 'MA20' }, + ], + groups: [{ label: '价格', fields: ['close', 'ma5', 'ma20'] }], + maxDays: 60, + operators: ['>', '>=', '<', '<=', '==', '!='], + stringFields: ['symbol'], + stringOperators: ['contains', '==', '!='], + }, +})) + +vi.mock('@/lib/api', () => ({ + api: { + customSignalsList: async () => ({ + signals: [ + { + id: 'canyue_long_enter', name: '残月入场', kind: 'entry', timeframe: 'daily', + conditions: [{ left: 'change_pct', op: '>', right: 0.6, leftDays: 0, rightDays: 0 }], // 数字右值 (真实迁移数据形态) + column: 'signal_canyue_long_enter', + }, + { + id: 'ma_golden_5_20', name: '均线金叉', kind: 'both', timeframe: 'daily', enabled: true, + conditions: [ + { left: 'ma5', op: '>', right: 'field:ma20', leftDays: 0, rightDays: 0 }, + { left: 'ma5', op: '>', right: 'field:ma5', leftDays: 0, rightDays: 1 }, + ], + column: 'signal_ma_golden_5_20', + }, + ], + }), + customSignalsOptions: async () => fixtures.options, + strategyAiStatus: async () => ({ configured: false }), + customSignalDelete: async () => ({ ok: true }), + }, +})) + +let root: Root | null = null +let container: HTMLElement | null = null + +beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + root?.unmount() + container?.remove() +}) + +function render(ui: React.ReactNode) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + root!.render( + + {ui} + , + ) +} + +it('信号库页: 数字右值条件不再触发错误边界, 正常渲染', async () => { + render( + + + , + ) + await new Promise(r => setTimeout(r, 120)) + expect(container!.textContent).not.toContain('暂时不可用') + expect(container!.textContent).toContain('信号库') + // 数字右值条件正常显示 (不再因 .startsWith 崩溃) + expect(container!.textContent).toContain('0.6') +}) + +it('条件编辑器: 数字右值不抛错, 常量输入正常挂载', async () => { + render( + ', right: 0.6, leftDays: 0, rightDays: 0 }]} + onChange={() => {}} + options={fixtures.options as any} + />, + ) + await new Promise(r => setTimeout(r, 50)) + const input = container!.querySelector('input[type="number"]') as HTMLInputElement + expect(input).not.toBeNull() + expect(input.value).toBe('0.6') +}) diff --git a/frontend/src/custom/signals/extension.tsx b/frontend/src/custom/signals/extension.tsx index 84c38f9b..91f38cb9 100644 --- a/frontend/src/custom/signals/extension.tsx +++ b/frontend/src/custom/signals/extension.tsx @@ -170,7 +170,7 @@ function SignalsBody({ highlight }: { highlight: string }) { {fieldWithDays(c.left, c.leftDays, fields)} {c.op} - {c.right.startsWith('field:') + {typeof c.right === 'string' && c.right.startsWith('field:') ? fieldWithDays(c.right.slice(6), c.rightDays, fields) : c.right} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 08c4df61..d901d89a 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -946,7 +946,7 @@ export interface AiIterateResult { export interface CustomSignalCondition { left: string // 字段名 op: string // > >= < <= == != - right: string // "field:xxx" 或数字字符串 + right: string | number // "field:xxx" 或数字常量 (迁移的历史信号常量为 number, 后端两者均合法) leftDays?: number // 左字段取几日前 (0=当日, 默认) rightDays?: number // 右字段取几日前 (仅 right 为字段时有意义) }