fix(signals): 数字右值条件不再整页崩溃 — 修复"扩展 official.signals 暂时不可用"

用户反馈信号库/模拟盘页显示"扩展 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 <shy3130@qq.com>
This commit is contained in:
shy3130
2026-10-29 11:09:06 +08:00
parent e6fc60078f
commit d7a5edd730
4 changed files with 111 additions and 5 deletions
@@ -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 (
<div className="flex items-center gap-1 flex-1 min-w-0">
@@ -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(
<QueryClientProvider client={qc}>
<MemoryRouter initialEntries={['/signals']}>{ui}</MemoryRouter>
</QueryClientProvider>,
)
}
it('信号库页: 数字右值条件不再触发错误边界, 正常渲染', async () => {
render(
<ExtensionBoundary extensionId="official.signals">
<Signals />
</ExtensionBoundary>,
)
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(
<ConditionEditor
conditions={[{ left: 'change_pct', op: '>', 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')
})
+1 -1
View File
@@ -170,7 +170,7 @@ function SignalsBody({ highlight }: { highlight: string }) {
<span className="font-mono text-foreground/80">{fieldWithDays(c.left, c.leftDays, fields)}</span>
<span className="font-mono text-muted">{c.op}</span>
<span className="font-mono text-foreground/80">
{c.right.startsWith('field:')
{typeof c.right === 'string' && c.right.startsWith('field:')
? fieldWithDays(c.right.slice(6), c.rightDays, fields)
: c.right}
</span>
+1 -1
View File
@@ -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 为字段时有意义)
}