迭代22+23: 策略tab静态化 + 计算字段(数字/判定型)+ 网格超市信号字段

- R-027 静态化:策略恒为内置两项(不可增删改名,保留显隐/排序);设置页移除「策略分组」;
  字段配置入口迁到策略 tab 内「列设置」旁(FieldConfigDialog + schema-update 单策略写入)
- R-026 计算字段:自写公式引擎(中文变量、四则/括号/round·abs·min·max、缺值短路→—)+
  变量目录(行情/合约/持仓/自定义字段)+ strategy-positions 逐行现算(只读内存缓存,不落库、列只读)
- R-028 判定型:比较运算 + and/or + inferResultKind + 结果类型一致性校验 + 判定列 ✓/— 渲染;
  网格超市落地 可下空单=涨停价>基准值+网格大小、可下多单=跌停价<基准值-网格大小
- 变量选择改标签平铺(老师反馈);公式手册 docs/99-其他材料/计算字段公式说明.md
- 约束同步:产品约束-002/003/004/009/013/014、技术约束-014/015/022/023/024、UI约束-002/003/005/008
- 回归:新增 test-r026/test-r027,更新 r011/r013/r017,17 个脚本全绿;typecheck/build 通过
This commit is contained in:
2026-09-10 14:05:06 +08:00
parent 3dfa39b8bf
commit ec02a791ee
40 changed files with 3369 additions and 659 deletions
+6 -6
View File
@@ -12,12 +12,12 @@
*
* 端点(POST /odl/api/<method>body = { args: {...} }):
* positions → 全量持仓
* strategies → 策略列表(含 visible
* strategies/update → 更新策略列表(整表
* strategies/add → 新增策略 { name }
* strategies/remove删除策略 { strategyId },联动清份额
* strategies/move排序 { strategyId, dir }
* strategy-positions → 某策略持仓 { strategyId }
* strategies → 策略列表(R-027:恒为内置两项 + 各自 configSchema
* strategies/schema-update → 写某策略字段定义 { strategyId, configSchema }R-027 唯一写路径
* formula/variables → 公式变量目录 { strategyId? }R-026
* formula/trial 公式试算 { strategyId, formula }R-026
* strategy-positions 某策略持仓 { strategyId }(含计算字段 computed
* R-027 退役:strategies/update、strategies/add、strategies/remove、strategies/move
* strategy-holdings/history → 某策略已清仓历史持仓 { strategyId, range }R-016
* unallocated → 未分配持仓
* summary → 分仓摘要
+94 -18
View File
@@ -2,18 +2,74 @@
* 服务端 API:策略 / 份额 / tabs 域(从 api.js 拆分,2026-08-31
*
* 端点:
* strategies / strategies/update / strategies/add / strategies/remove
* strategies(读;R-027:恒为内置两项)
* strategies/schema-updateR-027:写某策略字段定义,唯一写路径)
* strategy-positions / unallocated / summary
* strategy-holdings/historyR-016 迭代 14:已清仓历史持仓,范围筛选)
* add-shares / move-all-shares / remove-shares
* tabs / tabs/update
* formula/variablesR-026:公式变量目录)/ formula/trialR-026:试算)
* holdings/values-update / strategy-columns / strategy-columns/update
*
* R-027 退役:strategies/update、strategies/add、strategies/remove(策略静态化,不可增删改名)
*/
import {
getStrategies, updateStrategies, addStrategy, removeStrategy,
getStrategies, getStrategyFields, updateStrategyFields, assertBuiltinStrategy,
getTabs, updateTabs,
getStrategyColumns, updateStrategyColumns,
} from '../settings.js';
import { validateFormula, compileFormula, evaluateFormula, inferResultKind } from '../formula/evaluator.js';
import { allowedVarNames, buildContext, catalogFor, BUILTIN_VAR_NAMES } from '../formula/variables.js';
import { computeRows } from '../formula/FormulaService.js';
/** 字段类型白名单(R-026 含计算类型) */
const FIELD_TYPE_WHITELIST = new Set(['text', 'number', 'boolean', 'enum', 'formula']);
/** 字段校验失败(前端 Toast + 公式框描红) */
function fieldError(message) {
return Object.assign(new Error(message), { code: 'field-validation' });
}
/**
* 字段定义校验(R-027/R-026):结构 + 唯一性 + 名称冲突 + 公式(语法/变量/仅非公式字段)
* @param {Array} fieldDefs 客户端提交的 configSchema
*/
function validateConfigSchema(fieldDefs) {
const fields = Array.isArray(fieldDefs) ? fieldDefs : [];
const keys = new Set();
const labels = new Set();
fields.forEach((f, i) => {
const label = String(f?.label ?? '').trim();
const key = String(f?.key ?? '').trim();
if (!label) throw fieldError(`${i + 1} 个字段:展示名不能为空`);
if (!key) throw fieldError(`字段『${label}』:标识(key)不能为空`);
if (!FIELD_TYPE_WHITELIST.has(f?.type)) throw fieldError(`字段『${label}』:类型无效`);
if (keys.has(key)) throw fieldError(`字段标识重复: ${key}`);
if (labels.has(label)) throw fieldError(`字段展示名重复: ${label}`);
if (BUILTIN_VAR_NAMES.has(label)) throw fieldError(`变量名『${label}』与内置变量冲突,请修改字段展示名`);
if (f.type === 'enum' && !(Array.isArray(f.enum) && f.enum.some((x) => String(x).trim()))) {
throw fieldError(`字段『${label}』:枚举类型需填写选项`);
}
keys.add(key);
labels.add(label);
});
// 公式校验:变量 = 内置名 ∪ 本策略非 formula 字段展示名(零循环依赖)
const vars = allowedVarNames(fields);
for (const f of fields) {
if (f?.type !== 'formula') continue;
const res = validateFormula(f.formula, vars);
if (!res.ok) throw fieldError(`字段『${f.label}』公式错误:${res.error.message}`);
// R-028:结果类型声明必须与公式形态一致(判定 = 顶层比较/and/or;数字 = 其余)
const declared = f.resultKind === 'boolean' ? 'boolean' : 'number';
const inferred = inferResultKind(compileFormula(f.formula).ast);
if (declared !== inferred) {
throw fieldError(declared === 'boolean'
? `字段『${f.label}』:结果类型选了「判定」,但公式没有比较运算(如 涨停价 > 基准值 + 1)`
: `字段『${f.label}』:公式结果是判定,请把「结果类型」改为「判定」`);
}
}
}
/** R-016 范围档 → 回溯毫秒(自然日近似,展示用途足够;技术约束-019)。
* range='today' 不走回溯毫秒——二轮补充(2026-09-07 老师定):本地自然日 00:00 起清仓的持仓
@@ -36,9 +92,9 @@ function localDayStartMs() {
/** 策略/份额/tabs 端点方法表 */
export const STRATEGY_METHODS = new Set([
'strategies',
'strategies/update',
'strategies/add',
'strategies/remove',
'strategies/schema-update', // R-027:写某策略字段定义(唯一写路径)
'formula/variables', // R-026:公式变量目录
'formula/trial', // R-026:公式试算(取一行真实持仓)
'strategy-positions',
'unallocated',
'summary',
@@ -59,27 +115,47 @@ export const STRATEGY_METHODS = new Set([
* @param {object} args
* @param {object} runtime { manager, settings, storage }
*/
export async function handleStrategy(method, args, { manager, settings, storage }) {
export async function handleStrategy(method, args, { manager, settings, storage, marketHub }) {
switch (method) {
case 'strategies':
return getStrategies(settings);
case 'strategies/update':
await updateStrategies(settings, args.strategies);
return getStrategies(settings);
case 'strategies/add':
return await addStrategy(settings, args.name);
case 'strategies/remove':
// 删除策略 + 联动清空该策略下所有份额(份额回未分配
await removeStrategy(settings, args.strategyId);
await manager.clearStrategyShares(args.strategyId);
return getStrategies(settings);
case 'strategies/schema-update':
// R-027:单策略字段定义写入(结构/唯一性/公式校验 → strategyFields
assertBuiltinStrategy(args.strategyId);
validateConfigSchema(args.configSchema);
return await updateStrategyFields(settings, args.strategyId, args.configSchema);
case 'formula/variables':
// R-026:公式变量目录(内置分组 + 本策略非 formula 字段
if (args.strategyId != null) assertBuiltinStrategy(args.strategyId);
return catalogFor(getStrategyFields(settings, args.strategyId));
case 'formula/trial': {
// R-026:试算(用该策略一行真实持仓数据求值;无持仓返回 reason)
assertBuiltinStrategy(args.strategyId);
const fieldDefs = getStrategyFields(settings, args.strategyId);
const check = validateFormula(args.formula, allowedVarNames(fieldDefs));
if (!check.ok) throw fieldError(check.error.message);
const rows = await manager.getStrategyPositions(args.strategyId);
const row = rows.find((r) => (r.shares ?? 0) > 0) ?? rows[0] ?? null;
if (!row) return { code: null, name: null, value: null, reason: 'no-holding' };
const ctx = buildContext({
row,
quote: marketHub?.quotes?.get(row.code) ?? null,
instrument: marketHub?.instruments?.get(row.code) ?? null,
fieldDefs,
});
const value = evaluateFormula(args.formula, ctx);
return { code: row.code, name: row.name ?? null, value, reason: value === null ? 'no-data' : null };
}
case 'tabs':
return getTabs(settings);
case 'tabs/update':
await updateTabs(settings, args.tabs);
return getTabs(settings);
case 'strategy-positions':
return await manager.getStrategyPositions(args.strategyId);
case 'strategy-positions': {
// R-026:含计算字段的策略,逐行现算(只读内存缓存;无 formula 字段零开销)
const rows = await manager.getStrategyPositions(args.strategyId);
return computeRows({ rows, fieldDefs: getStrategyFields(settings, args.strategyId), hub: marketHub });
}
case 'unallocated':
return await manager.getUnallocatedPositions();
case 'summary':
+110 -35
View File
@@ -1,7 +1,10 @@
/**
* 策略持仓表列设置弹层(迭代 11 列显隐扩展,2026-09-02
* 管理可配置列(基础数据列 + 自定义字段列):显隐勾选 + ↑↓ 排序 + 立即持久化
* 固定列(代码/名称/操作/展开箭头)不在列表内,恒显示
* 策略持仓表列设置弹层(迭代 11 列显隐扩展 + 迭代 20 拖拽排序2026-09-10
* 管理可配置列(基础数据列 + 自定义字段列):显隐勾选 + 原生 HTML5 拖拽排序(≡ 手柄)+ ↑↓ 兜底微调
* 所有变更(勾选 / 拖拽 / ↑↓)立即持久化,无「保存」按钮;固定列(代码/名称/操作/展开箭头)不在列表内,恒显示
* 拖拽模式复用 Tab 设置(R-011 SettingsSection 已验证):draggable 行 + 间隙高亮线 + drop 重排
* 落点判定(2026-09-10 老师反馈修改):拖动时根据鼠标在目标行内的 Y 坐标(上半/下半)计算插入位置,
* 在目标行上缘/下缘(两行之间的间隙)显示高亮线,明确指示插入该列之前/之后,而非整行高亮
*/
import React, { useState } from 'react';
import { useRpc } from './connection.jsx';
@@ -20,41 +23,85 @@ export function ColumnSettingsPopover({ columns, strategyId, onClose, onSaved })
const [list, setList] = useState(() => (Array.isArray(columns) ? columns.map((c) => ({ ...c })) : []));
const [saving, setSaving] = useState(false);
const [dragKey, setDragKey] = useState(null);
// 落点:idx = 悬停目标行下标;half = 'top'(插入该行上缘=之前)/ 'bottom'(下缘=之后)
const [overPos, setOverPos] = useState(null);
const toggle = (key) => setList((ls) => ls.map((c) => (c.key === key ? { ...c, visible: !c.visible } : c)));
/** ↑↓ 排序(同 Tab 设置简化版:用箭头替代 DnD,零依赖稳妥) */
const move = (index, dir) => {
setList((ls) => {
const next = ls.slice();
const to = index + dir;
if (to < 0 || to >= next.length) return ls;
const [item] = next.splice(index, 1);
next.splice(to, 0, item);
return next;
});
};
const save = async () => {
/** 立即持久化整份列配置(含不可见列,保位置);失败回滚到服务端最新配置 */
const persist = async (nextList, msg) => {
setSaving(true);
try {
// 提交整列配置(含不可见列,保位置)
const payload = list.map((c) => ({ key: c.key, visible: c.visible }));
const payload = nextList.map((c) => ({ key: c.key, visible: c.visible }));
const res = await call('one-divine-lot/strategy-columns/update', { args: { strategyId, columns: payload } });
if (res && res.ok) {
toast.success('列设置已保存');
toast.success(msg);
onSaved?.();
onClose?.();
} else {
toast.error((res && res.error && res.error.message) || '保存失败');
setList(Array.isArray(columns) ? columns.map((c) => ({ ...c })) : []);
}
} catch (e) {
toast.error(e.message);
setList(Array.isArray(columns) ? columns.map((c) => ({ ...c })) : []);
} finally {
setSaving(false);
}
};
/** 勾选显隐:立即持久化 */
const toggle = (key) => {
const next = list.map((c) => (c.key === key ? { ...c, visible: !c.visible } : c));
const target = next.find((c) => c.key === key);
setList(next);
persist(next, (target && target.visible ? '已显示「' : '已隐藏「') + (target ? target.label : '') + '」');
};
/** ↑↓ 兜底微调:立即持久化 */
const move = (index, dir) => {
const next = list.slice();
const to = index + dir;
if (to < 0 || to >= next.length) return;
const [item] = next.splice(index, 1);
next.splice(to, 0, item);
setList(next);
persist(next, '「' + item.label + '」已移动');
};
/** 悬停:按鼠标在该行内的上下半区决定插入线画在上缘(之前)还是下缘(之后) */
const handleOver = (e, index) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const rect = e.currentTarget.getBoundingClientRect();
const half = e.clientY - rect.top < rect.height / 2 ? 'top' : 'bottom';
setOverPos({ idx: index, half });
};
/** 落点重排 + 立即持久化:drop 事件内直接按坐标判定,避免 state 闭包滞后 */
const handleDrop = (e, index) => {
e.preventDefault();
if (!list || !dragKey) { setDragKey(null); setOverPos(null); return; }
const rect = e.currentTarget.getBoundingClientRect();
const before = e.clientY - rect.top < rect.height / 2;
let to = before ? index : index + 1; // 目标行之前 / 之后(原始下标)
const from = list.findIndex((c) => c.key === dragKey);
if (from < 0 || to < 0 || to > list.length) { setDragKey(null); setOverPos(null); return; }
// 无操作(原地):插入位置等于原位置或原位置的后一格(等同原槽位),跳过写库
if (to === from || to === from + 1) { setDragKey(null); setOverPos(null); return; }
const next = list.slice();
const [moved] = next.splice(from, 1);
const insertAt = to > from ? to - 1 : to; // 移除后目标下标左移
next.splice(insertAt, 0, moved);
setDragKey(null);
setOverPos(null);
setList(next);
persist(next, '「' + moved.label + '」已移动');
};
/** 拖拽结束清理(拖出弹层 / 取消等场景) */
const handleDragEnd = () => {
setDragKey(null);
setOverPos(null);
};
const rowBtn = { padding: '0 5px', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 3, background: 'var(--dsw-alias-bg-layer-1, #fff)', fontSize: 11, lineHeight: '16px' };
const kindTag = (kind) => ({
fontSize: 10, borderRadius: 6, padding: '0 4px', marginRight: 4,
@@ -62,33 +109,61 @@ export function ColumnSettingsPopover({ columns, strategyId, onClose, onSaved })
border: '1px solid var(--dsw-alias-border-l3, #90caf9)',
});
// 间隙高亮线:悬停行上缘(该行之前)或下缘(该行之后);行自身有 3px 垂直 padding,线落在两行之间的间隙
const gapLineStyle = (i) => {
if (!overPos || overPos.idx !== i) return null;
return {
position: 'absolute', left: 6, right: 6, height: 3, borderRadius: 2,
background: 'var(--dsw-alias-state-business-primary, #1565c0)',
...(overPos.half === 'top' ? { top: 0 } : { bottom: 0 }),
};
};
return (
<div style={{
position: 'absolute', right: 0, top: 34, zIndex: 1000,
background: 'var(--dsw-alias-bg-layer-1, #fff)', border: '1px solid var(--dsw-alias-border-l2, #ddd)', borderRadius: 8,
boxShadow: 'var(--dsw-shadow-lv3, 0 4px 12px rgba(0,0,0,0.12))', padding: 12, width: 260,
boxShadow: 'var(--dsw-shadow-lv3, 0 4px 12px rgba(0,0,0,0.12))', padding: 12, width: 286,
}}>
<div style={{ fontWeight: 600, fontSize: 13, marginBottom: 10 }}>列设置</div>
<div style={{ fontSize: 11, color: 'var(--dsw-alias-label-secondary, #888)', marginBottom: 8 }}>勾选显示列 调整顺序代码/名称/操作固定</div>
<div style={{ maxHeight: 280, overflowY: 'auto' }}>
<div style={{ fontSize: 11, color: 'var(--dsw-alias-label-secondary, #888)', marginBottom: 8 }}>
勾选显示列按住 拖到两行之间的落点线代码/名称/操作固定
</div>
<div style={{ maxHeight: 280, overflowY: 'auto', position: 'relative' }}>
{list.map((c, i) => (
<div key={c.key} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '3px 0', fontSize: 13 }}>
<div
key={c.key}
draggable={!saving}
onDragStart={(e) => { setDragKey(c.key); setOverPos(null); e.dataTransfer.effectAllowed = 'move'; }}
onDragOver={(e) => handleOver(e, i)}
onDrop={(e) => handleDrop(e, i)}
onDragEnd={handleDragEnd}
style={{
position: 'relative',
display: 'flex', alignItems: 'center', gap: 6, padding: '3px 0', fontSize: 13, borderRadius: 4,
cursor: saving ? 'default' : 'grab',
}}
>
{overPos && overPos.idx === i && <div style={gapLineStyle(i)} />}
<span title="按住拖动排序" style={{ color: 'var(--dsw-alias-label-tertiary, #999)', cursor: saving ? 'default' : 'grab', userSelect: 'none' }}></span>
<span style={{ whiteSpace: 'nowrap' }}>
<button onClick={() => move(i, -1)} disabled={i === 0} style={{ ...rowBtn, opacity: i === 0 ? 0.3 : 1 }}></button>
<button onClick={() => move(i, 1)} disabled={i === list.length - 1} style={{ ...rowBtn, opacity: i === list.length - 1 ? 0.3 : 1 }}></button>
<button onClick={() => move(i, -1)} disabled={saving || i === 0} style={{ ...rowBtn, opacity: i === 0 ? 0.3 : 1 }}></button>
<button onClick={() => move(i, 1)} disabled={saving || i === list.length - 1} style={{ ...rowBtn, opacity: i === list.length - 1 ? 0.3 : 1 }}></button>
</span>
<input type="checkbox" checked={c.visible !== false} onChange={() => toggle(c.key)} />
<input type="checkbox" checked={c.visible !== false} onChange={() => toggle(c.key)} disabled={saving} />
<span style={kindTag(c.kind)}>{c.kind === 'base' ? '数据' : '字段'}</span>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{c.label}</span>
<span
style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
title={c.kind === 'field' && c.type === 'formula' ? '计算字段(只读)' : undefined}
>
{c.kind === 'field' && c.type === 'formula' ? 'ƒ ' + c.label : c.label}
</span>
</div>
))}
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 6, marginTop: 10 }}>
<button onClick={save} disabled={saving} style={{ padding: '4px 14px', cursor: 'pointer', border: 'none', borderRadius: 4, background: 'var(--dsw-alias-state-business-primary, #1565c0)', color: 'var(--dsw-alias-button-contrast-fill, #fff)', fontSize: 12 }}>
{saving ? '保存中...' : '保存'}
</button>
<button onClick={onClose} style={{ padding: '4px 14px', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 4, background: 'var(--dsw-alias-bg-layer-1, #fff)', fontSize: 12 }}>关闭</button>
<button onClick={onClose} disabled={saving} style={{ padding: '4px 14px', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 4, background: 'var(--dsw-alias-bg-layer-1, #fff)', fontSize: 12 }}>关闭</button>
</div>
</div>
);
}
}
+123
View File
@@ -0,0 +1,123 @@
/**
* 字段配置弹层(迭代 22R-026/R-027
* 锚定弹层(与列设置同源视觉):width 520 / maxHeight 70vh / 内容内部滚动
* 底部 [关闭](次)+ [保存字段](主,saving 时 disabled);保存走子组件 ref.save()
* 有未保存改动(dirty = 子组件 onDirtyChange 上报)时:关闭 / 外点 / Esc → 二次确认放弃
*/
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { StrategyFieldsEditor } from './StrategyFieldsEditor.jsx';
export function FieldConfigDialog({ strategyId, strategyName, configSchema, onSaved, onClose }) {
const [dirty, setDirty] = useState(false);
const [saving, setSaving] = useState(false);
const [confirmDiscard, setConfirmDiscard] = useState(false);
const editorRef = useRef(null);
const panelRef = useRef(null);
const dirtyRef = useRef(dirty);
dirtyRef.current = dirty;
/** 关闭请求:有未保存改动 → 二次确认;否则直接关 */
const requestClose = useCallback(() => {
if (dirtyRef.current) setConfirmDiscard(true);
else onClose();
}, [onClose]);
// Esc / 外点关闭(D-8:未保存改动时二次确认)
useEffect(() => {
const onKey = (e) => { if (e.key === 'Escape') requestClose(); };
const onDown = (e) => {
// 以“锚点容器”(含字段配置/列设置按钮)为界:点按钮自身不算外点,
// 否则按钮 onClick 与放弃确认会互相打架(dirty 时弹层被静默卸载)
const anchor = panelRef.current?.parentElement ?? panelRef.current;
if (anchor && !anchor.contains(e.target)) requestClose();
};
document.addEventListener('keydown', onKey);
document.addEventListener('mousedown', onDown);
return () => {
document.removeEventListener('keydown', onKey);
document.removeEventListener('mousedown', onDown);
};
}, [requestClose]);
/** 保存字段:调子组件 savestrategies/schema-update)→ 成功 onSaved + 关闭 */
const handleSave = async () => {
if (saving) return;
setSaving(true);
let ok = false;
try {
ok = (await editorRef.current?.save?.()) === true;
} catch { /* save 内部已提示 */ }
setSaving(false);
if (ok) {
onSaved?.();
onClose();
}
};
return (
<div
ref={panelRef}
style={{
position: 'absolute', right: 0, top: 34, zIndex: 1000,
width: 520, maxWidth: 'calc(100vw - 24px)', maxHeight: '70vh',
display: 'flex', flexDirection: 'column',
background: 'var(--dsw-alias-bg-layer-1, #fff)', border: '1px solid var(--dsw-alias-border-l2, #ddd)', borderRadius: 8,
boxShadow: 'var(--dsw-shadow-lv3, 0 4px 12px rgba(0,0,0,0.12))',
}}
>
<div style={{ flex: 'none', padding: '12px 16px 8px' }}>
<strong style={{ fontSize: 14 }}>字段配置{strategyName || strategyId}</strong>
</div>
<div style={{ overflowY: 'auto', flex: 1, minHeight: 0, padding: '0 16px' }}>
<StrategyFieldsEditor
ref={editorRef}
strategyId={strategyId}
configSchema={configSchema}
saving={saving}
onDirtyChange={setDirty}
/>
</div>
<div style={{ flex: 'none', display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '10px 16px', borderTop: '1px solid var(--dsw-alias-border-l1, #eee)' }}>
<button
onClick={requestClose}
disabled={saving}
style={{ padding: '6px 16px', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 4, background: 'var(--dsw-alias-bg-layer-1, #fff)', fontSize: 13 }}
>
关闭
</button>
<button
onClick={handleSave}
disabled={saving}
style={{ padding: '6px 20px', cursor: 'pointer', border: 'none', borderRadius: 4, background: 'var(--dsw-alias-state-success-primary, #2e7d32)', color: 'var(--dsw-alias-button-contrast-fill, #fff)', fontSize: 13, opacity: saving ? 0.7 : 1 }}
>
{saving ? '保存中…' : '保存字段'}
</button>
</div>
{confirmDiscard && (
<div style={{ position: 'fixed', inset: 0, background: 'var(--dsw-alias-bg-mask-1, rgba(0,0,0,0.4))', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1100 }}>
<div style={{ background: 'var(--dsw-alias-bg-layer-1, #fff)', padding: 24, borderRadius: 8, maxWidth: 420, width: '90%' }}>
<h3 style={{ margin: '0 0 12px', fontSize: 16 }}>放弃未保存的改动</h3>
<div style={{ fontSize: 14, color: 'var(--dsw-alias-label-secondary, #555)', marginBottom: 20 }}>有未保存的字段改动确定放弃</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 10 }}>
<button
onClick={() => setConfirmDiscard(false)}
style={{ padding: '6px 16px', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 4, background: 'var(--dsw-alias-bg-layer-1, #fff)' }}
>
取消
</button>
<button
onClick={() => { setConfirmDiscard(false); onClose(); }}
style={{ padding: '6px 16px', cursor: 'pointer', border: 'none', borderRadius: 4, background: 'var(--dsw-alias-state-error-primary, #c62828)', color: 'var(--dsw-alias-button-contrast-fill, #fff)' }}
>
放弃改动
</button>
</div>
</div>
</div>
)}
</div>
);
}
+55 -260
View File
@@ -1,19 +1,16 @@
/**
* 神之一手设置 section
*
* 结构(R-011 迭代 09):
* - 个子 tabTab 设置 / 策略分组 / QMT 连接配置
* - Tab 设置:统一管理所有会话 tab(内置 + 策略分组混排),拖动排序(落点立即持久化)+ 显示/隐藏开关;
* 结构(迭代 22R-027 后):
* - 个子 tabTab 设置 / QMT 连接配置(「策略分组」已移除,字段配置入口迁到策略 tab 内)
* - Tab 设置:统一管理所有会话 tab(内置 + 策略混排),拖动排序(落点立即持久化)+ 显示/隐藏开关;
* 任何 tab 均不支持重命名/删除(产品约束-009、UI约束-003)
* - 策略分组:策略 CRUD(新增/重命名/删除确认)+ 提示「顺序与显示请在 Tab 设置中调整」
* - QMT 连接配置:R-004 卡片形式(迭代 03)
*/
import { useState, useEffect, useCallback, Fragment } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { useRpc } from './connection.jsx';
import { ToastProvider, useToast } from './Toast.jsx';
import { StrategyFieldsEditor } from './StrategyFieldsEditor.jsx'; // R-013:策略自定义字段编辑器
import { ExpandChevron } from './ExpandChevron.jsx'; // 展开箭头与策略 tab 表格行图标统一
/** 确认弹窗 */
function ConfirmDialog({ title, message, onConfirm, onCancel }) {
@@ -58,13 +55,14 @@ function Switch({ checked, onChange, disabled }) {
);
}
/** Tab 设置子 tabR-011:统一管理所有会话 tab —— 内置 + 策略分组混排,拖动排序 + 显隐开关) */
/** Tab 设置子 tabR-011:统一管理所有会话 tab —— 内置 + 策略混排,拖动排序 + 显隐开关) */
function TabSettings() {
const [tabs, setTabs] = useState(null);
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
const [dragId, setDragId] = useState(null);
const [overId, setOverId] = useState(null);
// 落点:idx = 悬停目标行下标;half = 'top'(该行上缘=插入前)/ 'bottom'(下缘=插入后)——迭代 21 间隙高亮线
const [overPos, setOverPos] = useState(null);
const call = useRpc();
const toast = useToast();
@@ -106,19 +104,34 @@ function TabSettings() {
await persist(next, '「' + t.name + '」已' + (next.find((x) => x.id === t.id).visible ? '显示' : '隐藏'));
};
// 原生 HTML5 拖动:落点重排 + 立即持久化(Q1)
const handleDrop = async (targetId) => {
if (!tabs || !dragId || dragId === targetId) { setDragId(null); setOverId(null); return; }
/** 悬停:按鼠标在该行内的上下半区决定间隙线画上缘(插入前)还是下缘(插入后) */
const handleOver = (e, index) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const rect = e.currentTarget.getBoundingClientRect();
const half = e.clientY - rect.top < rect.height / 2 ? 'top' : 'bottom';
setOverPos({ idx: index, half });
};
/** 原生 HTML5 拖动:落点重排 + 立即持久化(Q1);迭代 21:drop 事件内按坐标判定插入前/后,原地跳过写库 */
const handleDrop = async (e, index) => {
e.preventDefault();
if (!tabs || !dragId) { setDragId(null); setOverPos(null); return; }
const rect = e.currentTarget.getBoundingClientRect();
const before = e.clientY - rect.top < rect.height / 2;
let to = before ? index : index + 1;
const from = tabs.findIndex((x) => x.id === dragId);
const to = tabs.findIndex((x) => x.id === targetId);
if (from < 0 || to < 0) { setDragId(null); setOverId(null); return; }
if (from < 0 || to < 0 || to > tabs.length) { setDragId(null); setOverPos(null); return; }
// 原地(插入位置等于原位置或其下一格 = 原槽位)→ 跳过写库
if (to === from || to === from + 1) { setDragId(null); setOverPos(null); return; }
const next = tabs.slice();
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
const insertAt = to > from ? to - 1 : to;
next.splice(insertAt, 0, moved);
// 重写 order(整表连续)
const ordered = next.map((x, i) => ({ ...x, order: i }));
setDragId(null);
setOverId(null);
setOverPos(null);
await persist(ordered, '「' + moved.name + '」已移动');
};
@@ -128,10 +141,21 @@ function TabSettings() {
border: kind === 'builtin' ? '1px solid var(--dsw-alias-border-l3, #90caf9)' : '1px solid var(--dsw-alias-border-l3, #ce93d8)',
});
// 间隙高亮线(迭代 21,与列设置弹层 R-024 全同):悬停行上缘(插入前)或下缘(插入后);
// 表格行 tr 内不能直接嵌 div,故在每个 td 内各画一段线(position:relative 锚点),视觉连续
const gapLineStyle = (i) => {
if (!overPos || overPos.idx !== i) return null;
return {
position: 'absolute', left: 0, right: 0, height: 3, borderRadius: 2,
background: 'var(--dsw-alias-state-business-primary, #1565c0)',
...(overPos.half === 'top' ? { top: 0 } : { bottom: 0 }),
};
};
return (
<div>
<div style={{ fontSize: 12, color: 'var(--dsw-alias-label-secondary, #888)', marginBottom: 12 }}>
拖动调整所有标签页顺序开关控制显示/隐藏内置与策略标签可任意混排名称不支持在此修改策略名称在策略分组中修改
拖动调整所有标签页顺序开关控制显示/隐藏内置与策略标签可任意混排名称不支持在此修改
</div>
{error && <div style={{ color: 'var(--dsw-alias-state-error-primary, #c62828)', marginBottom: 12 }}>{error}</div>}
{!tabs && !error && <div style={{ color: 'var(--dsw-alias-label-secondary, #666)' }}>加载中...</div>}
@@ -145,25 +169,28 @@ function TabSettings() {
</tr>
</thead>
<tbody>
{tabs.map((t) => (
{tabs.map((t, i) => (
<tr
key={t.id}
draggable={!saving}
onDragStart={(e) => { setDragId(t.id); e.dataTransfer.effectAllowed = 'move'; }}
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; setOverId(t.id); }}
onDragLeave={() => { if (overId === t.id) setOverId(null); }}
onDrop={(e) => { e.preventDefault(); handleDrop(t.id); }}
onDragStart={(e) => { setDragId(t.id); setOverPos(null); e.dataTransfer.effectAllowed = 'move'; }}
onDragOver={(e) => handleOver(e, i)}
onDrop={(e) => handleDrop(e, i)}
onDragEnd={() => { setDragId(null); setOverPos(null); }}
style={{
borderBottom: '1px solid var(--dsw-alias-border-l1, #eee)', cursor: saving ? 'default' : 'grab',
background: overId === t.id ? 'color-mix(in srgb, var(--dsw-alias-state-business-primary, #1565c0) 12%, transparent)' : 'transparent',
}}
>
<td style={{ padding: '8px 6px', color: 'var(--dsw-alias-label-tertiary, #999)', textAlign: 'center' }}></td>
<td style={{ padding: '8px 6px' }}>
<td style={{ padding: '8px 6px', color: 'var(--dsw-alias-label-tertiary, #999)', textAlign: 'center', position: 'relative' }}>
{overPos && overPos.idx === i && <div style={gapLineStyle(i)} />}
</td>
<td style={{ padding: '8px 6px', position: 'relative' }}>
{overPos && overPos.idx === i && <div style={gapLineStyle(i)} />}
<span style={badgeStyle(t.kind)}>{t.kind === 'builtin' ? '内置' : '策略'}</span>
{t.name}
</td>
<td style={{ padding: '8px 6px' }}>
<td style={{ padding: '8px 6px', position: 'relative' }}>
{overPos && overPos.idx === i && <div style={gapLineStyle(i)} />}
<Switch checked={t.visible !== false} onChange={() => toggleVisible(t)} disabled={saving} />
</td>
</tr>
@@ -175,239 +202,9 @@ function TabSettings() {
);
}
/** 策略分组子 tab(原策略 CRUD */
function StrategyGroupSettings() {
const [strategies, setStrategies] = useState(null);
const [error, setError] = useState(null);
const [saving, setSaving] = useState(false);
const [newName, setNewName] = useState('');
const [editingId, setEditingId] = useState(null);
const [editingName, setEditingName] = useState('');
const [confirmDelete, setConfirmDelete] = useState(null);
const [changedMsg, setChangedMsg] = useState(null);
// R-013:当前展开配置字段的策略 id(null=无展开)
const [expandedId, setExpandedId] = useState(null);
const [fieldSaving, setFieldSaving] = useState(false);
const call = useRpc();
const toast = useToast();
const load = useCallback(async () => {
try {
const res = await call('one-divine-lot/strategies', {});
if (res && res.ok) setStrategies(res.value);
else setError((res && res.error && res.error.message) || '加载失败');
} catch (e) {
setError(e.message);
}
}, [call]);
useEffect(() => { load(); }, [load]);
const handleChanged = useCallback((msg) => {
setChangedMsg(msg);
toast.success(msg + '(刷新页面后生效)');
load();
}, [load, toast]);
const handleAdd = async () => {
const name = newName.trim();
if (!name) { toast.error('请输入策略名称'); return; }
setSaving(true);
try {
const res = await call('one-divine-lot/strategies/add', { args: { name } });
if (res && res.ok) {
setNewName('');
handleChanged('策略「' + name + '」已添加');
} else {
toast.error((res && res.error && res.error.message) || '添加失败');
}
} catch (e) {
toast.error(e.message);
} finally {
setSaving(false);
}
};
const handleRename = async (id) => {
const name = editingName.trim();
if (!name) { toast.error('名称不能为空'); return; }
setSaving(true);
try {
const res = await call('one-divine-lot/strategies/update', {
args: { strategies: strategies.map((s) => (s.id === id ? { ...s, name } : s)) },
});
if (res && res.ok) {
setEditingId(null);
handleChanged('策略已重命名');
} else {
toast.error((res && res.error && res.error.message) || '重命名失败');
}
} catch (e) {
toast.error(e.message);
} finally {
setSaving(false);
}
};
/** R-013:保存某策略的自定义字段定义(整表 strategies/update */
const handleSaveConfig = async (id, configSchema) => {
setFieldSaving(true);
try {
const res = await call('one-divine-lot/strategies/update', {
args: { strategies: strategies.map((s) => (s.id === id ? { ...s, configSchema } : s)) },
});
if (res && res.ok) {
handleChanged('策略自定义字段已保存');
} else {
toast.error((res && res.error && res.error.message) || '保存失败');
}
} catch (e) {
toast.error(e.message);
} finally {
setFieldSaving(false);
}
};
const handleDelete = async (id) => {
setSaving(true);
try {
const res = await call('one-divine-lot/strategies/remove', { args: { strategyId: id } });
if (res && res.ok) {
setConfirmDelete(null);
handleChanged('策略已删除,份额已回到未分配');
} else {
toast.error((res && res.error && res.error.message) || '删除失败');
setConfirmDelete(null);
}
} catch (e) {
toast.error(e.message);
setConfirmDelete(null);
} finally {
setSaving(false);
}
};
return (
<div>
{changedMsg && (
<div style={{
padding: '10px 14px', marginBottom: 12, borderRadius: 6,
background: 'color-mix(in srgb, var(--dsw-alias-state-business-primary, #1565c0) 10%, var(--dsw-alias-bg-layer-1, #fff))', color: 'var(--dsw-alias-state-business-primary, #1565c0)', fontSize: 13, border: '1px solid var(--dsw-alias-border-l3, #90caf9)',
}}>
{changedMsg} <strong>刷新页面后生效</strong>
</div>
)}
{error && <div style={{ color: 'var(--dsw-alias-state-error-primary, #c62828)', marginBottom: 12 }}>{error}</div>}
<div style={{ display: 'flex', gap: 8, marginBottom: 16, alignItems: 'center' }}>
<input
type="text"
placeholder="新策略名称(如:长线持有)"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
style={{ flex: 1, padding: '6px 10px', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 4 }}
/>
<button
onClick={handleAdd}
disabled={saving}
style={{ padding: '6px 16px', cursor: 'pointer', border: 'none', borderRadius: 4, background: 'var(--dsw-alias-state-success-primary, #2e7d32)', color: 'var(--dsw-alias-button-contrast-fill, #fff)' }}
>
+ 新增策略
</button>
</div>
<div style={{ fontSize: 12, color: 'var(--dsw-alias-label-secondary, #888)', marginBottom: 12 }}>
顺序与显示请在Tab 设置中调整
</div>
{!strategies && !error && <div style={{ color: 'var(--dsw-alias-label-secondary, #666)' }}>加载中...</div>}
{strategies && (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
<thead>
<tr style={{ textAlign: 'left', borderBottom: '2px solid var(--dsw-alias-border-l2, #ddd)' }}>
<th style={{ padding: '8px 6px' }}>策略</th>
<th style={{ padding: '8px 6px' }}>ID</th>
<th style={{ padding: '8px 6px' }}>操作</th>
</tr>
</thead>
<tbody>
{strategies.map((s) => (
<Fragment key={s.id}>
<tr style={{ borderBottom: '1px solid var(--dsw-alias-border-l1, #eee)' }}>
<td style={{ padding: '8px 6px' }}>
{editingId === s.id ? (
<span>
<input
type="text"
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleRename(s.id)}
style={{ padding: '4px 6px', border: '1px solid #ccc', borderRadius: 3, marginRight: 6 }}
/>
<button onClick={() => handleRename(s.id)} disabled={saving} style={{ padding: '2px 8px', cursor: 'pointer' }}>保存</button>
<button onClick={() => setEditingId(null)} style={{ padding: '2px 8px', cursor: 'pointer', marginLeft: 4 }}>取消</button>
</span>
) : (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<button
onClick={() => { setExpandedId(expandedId === s.id ? null : s.id); }}
style={{ padding: '2px 4px', cursor: 'pointer', border: 'none', background: 'none', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', lineHeight: 0 }}
title={expandedId === s.id ? '收起配置' : '展开配置自定义字段'}
><ExpandChevron expanded={expandedId === s.id} /></button>
{s.name}
</span>
)}
</td>
<td style={{ padding: '8px 6px', color: 'var(--dsw-alias-label-secondary, #666)', fontSize: 12 }}>{s.id}</td>
<td style={{ padding: '8px 6px', whiteSpace: 'nowrap' }}>
<button
onClick={() => { setExpandedId(expandedId === s.id ? null : s.id); setEditingId(null); }}
style={{ padding: '2px 8px', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 3, background: 'var(--dsw-alias-bg-layer-1, #fff)', marginRight: 4 }}
>{expandedId === s.id ? '收起字段' : '配置字段'}</button>
<button
onClick={() => { setEditingId(s.id); setEditingName(s.name); }}
style={{ padding: '2px 8px', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 3, background: 'var(--dsw-alias-bg-layer-1, #fff)', marginRight: 4 }}
>重命名</button>
<button
onClick={() => setConfirmDelete({ id: s.id, name: s.name })}
style={{ padding: '2px 8px', cursor: 'pointer', border: '1px solid var(--dsw-alias-state-error-primary, #c62828)', borderRadius: 3, background: 'var(--dsw-alias-bg-layer-1, #fff)', color: 'var(--dsw-alias-state-error-primary, #c62828)' }}
>删除</button>
</td>
</tr>
{expandedId === s.id && (
<tr>
<td colSpan={3} style={{ padding: '4px 10px 8px', borderBottom: '1px solid var(--dsw-alias-border-l1, #eee)' }}>
<StrategyFieldsEditor
strategy={s}
saving={fieldSaving}
onSave={(fields) => handleSaveConfig(s.id, fields)}
/>
</td>
</tr>
)}
</Fragment>
))}
</tbody>
</table>
)}
{confirmDelete && (
<ConfirmDialog
title={'删除策略「' + confirmDelete.name + '」'}
message="该策略下已分配的份额将回到「未分配」,数据不会丢失。确定删除?"
onConfirm={() => handleDelete(confirmDelete.id)}
onCancel={() => setConfirmDelete(null)}
/>
)}
</div>
);
}
/** 设置 section 主组件:三个子 tab */
/** 设置 section 主组件:两个子 tab */
function SettingsSectionInner(props) {
const [activeTab, setActiveTab] = useState('tabs'); // tabs | strategies | qmt
const [activeTab, setActiveTab] = useState('tabs'); // tabs | qmt
return (
<div style={{ padding: 20, fontFamily: 'system-ui' }}>
@@ -417,7 +214,6 @@ function SettingsSectionInner(props) {
<div style={{ display: 'flex', gap: 4, borderBottom: '1px solid var(--dsw-alias-border-l2, #ddd)', marginBottom: 16 }}>
{[
{ key: 'tabs', label: 'Tab 设置' },
{ key: 'strategies', label: '策略分组' },
{ key: 'qmt', label: 'QMT 连接配置' },
].map((tab) => (
<button
@@ -434,7 +230,6 @@ function SettingsSectionInner(props) {
</div>
{activeTab === 'tabs' && <TabSettings />}
{activeTab === 'strategies' && <StrategyGroupSettings />}
{activeTab === 'qmt' && <QmtConnectionsSettings />}
</div>
);
+290 -40
View File
@@ -1,33 +1,68 @@
/**
* 策略自定义字段编辑器(R-013 迭代 11
* 管理某策略 configSchema:字段列表 + 添加/编辑/删除 + 类型选择(文本/数字/布尔/枚举)
* 保存 = 调父组件 onSave(fields)(父组件整表 strategies/update
* 策略字段编辑器(迭代 22 改造:弹层内容 + 单策略保存,R-026/R-027
* 管理某策略 configSchema:字段列表 + 添加/编辑/删除 + 类型(文本/数字/布尔/枚举/计算
* 计算(formula)分支:公式框(等宽)+ 变量**标签平铺**(按 行情/合约/持仓/自定义字段 分组,
* 点选即插入公式框;数据来自 formula/variables,另加「自定义字段」组 = 本策略非 formula 字段展示名)
* + 试算(formula/trial+ 小数位 0-4
* 保存 = 父弹层调 ref.save() → strategies/schema-update { strategyId, configSchema: fields }
* dirty 经 onDirtyChange 上报(弹层据此做关闭二次确认)
*/
import React, { useState } from 'react';
import React, { useState, useEffect, useMemo, useRef, forwardRef, useImperativeHandle } from 'react';
import { useRpc } from './connection.jsx';
import { useToast } from './Toast.jsx';
const TYPE_LABEL = { text: '文本', number: '数字', boolean: '布尔', enum: '枚举' };
const TYPE_LABEL = { text: '文本', number: '数字', boolean: '布尔', enum: '枚举', formula: '计算' };
const MONO = 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace';
/** 样式常量 */
/** 样式常量(沿用设置页既有风格) */
const inputStyle = { padding: '4px 6px', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 3, marginRight: 6, fontSize: 12, background: 'var(--dsw-alias-bg-layer-1, #fff)' };
const btnStyle = { padding: '3px 10px', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 3, background: 'var(--dsw-alias-bg-layer-1, #fff)', marginRight: 4, fontSize: 12 };
const solidBtn = (bg) => ({ ...btnStyle, border: 'none', background: bg, color: 'var(--dsw-alias-button-contrast-fill, #fff)' });
const dangerBtn = { ...btnStyle, borderColor: 'var(--dsw-alias-state-error-primary, #c62828)', color: 'var(--dsw-alias-state-error-primary, #c62828)' };
/** 小数位夹取 0-4 整数(默认 2) */
function clampDecimals(v) {
const n = Number(v);
if (!Number.isFinite(n)) return 2;
return Math.min(4, Math.max(0, Math.round(n)));
}
/** 计算值/试算值格式化:toFixed(decimals) + 单位(如 6.67 % / 700.00 元) */
function fmtValue(v, decimals, unit) {
const s = Number(v).toFixed(clampDecimals(decimals));
return unit ? s + ' ' + unit : s;
}
/**
* 字段编辑器
* @param {object} props
* @param {object} props.strategy 策略(含 configSchema
* @param {Function} props.onSave 保存回调 (fields) => Promise
* @param {boolean} props.saving 保存中
* @param {string} props.strategyId
* @param {Array} props.configSchema 字段定义(strategies 端点)
* @param {boolean} props.saving 保存中(弹层「保存字段」驱动)
* @param {Function} props.onDirtyChange dirty 变化上报 (bool) => void
*/
export function StrategyFieldsEditor({ strategy, onSave, saving }) {
export const StrategyFieldsEditor = forwardRef(function StrategyFieldsEditor({ strategyId, configSchema, saving, onDirtyChange }, ref) {
const toast = useToast();
const [fields, setFields] = useState(() => (Array.isArray(strategy?.configSchema) ? strategy.configSchema.map((x) => ({ ...x })) : []));
const call = useRpc();
const [fields, setFields] = useState(() => (Array.isArray(configSchema) ? configSchema.map((x) => ({ ...x })) : []));
// 表单草稿:null=关闭;{ index:-1=新增 | 编辑下标, ... }
const [draft, setDraft] = useState(null);
const [saveError, setSaveError] = useState(null);
const [formulaError, setFormulaError] = useState(false);
const [varGroups, setVarGroups] = useState(null);
const [trial, setTrial] = useState(null); // { kind:'ok'|'empty'|'error', text }
const [trialLoading, setTrialLoading] = useState(false);
const formulaRef = useRef(null);
const emptyDraft = () => ({ index: -1, key: '', label: '', type: 'text', unit: '', enumText: '', defText: '', defBool: false, defEnum: '' });
// R-026:变量目录(formula/variables;「自定义字段」组本地合成,见 menuGroups
useEffect(() => {
let alive = true;
call('one-divine-lot/formula/variables', { args: { strategyId } }).then((res) => {
if (alive && res?.ok && res.value) setVarGroups(res.value.groups ?? []);
}).catch(() => { /* 失败静默:变量菜单只剩自定义字段组 */ });
return () => { alive = false; };
}, [call, strategyId]);
const emptyDraft = () => ({ index: -1, key: '', label: '', type: 'text', unit: '', enumText: '', defText: '', defBool: false, defEnum: '', formula: '', resultKind: 'number', decimals: 2 });
/** 由 label 生成 key(转小写 ascii slug;冲突去重) */
const makeKey = (label, usedKeys) => {
@@ -42,7 +77,13 @@ export function StrategyFieldsEditor({ strategy, onSave, saving }) {
return candidate;
};
const startAdd = () => setDraft(emptyDraft());
// dirty:与初始 configSchema 的 JSON 快照比较,经 onDirtyChange 上报
const initialJson = useMemo(() => JSON.stringify(Array.isArray(configSchema) ? configSchema : []), [configSchema]);
const dirty = useMemo(() => JSON.stringify(fields) !== initialJson, [fields, initialJson]);
useEffect(() => { onDirtyChange?.(dirty); }, [dirty, onDirtyChange]);
const startAdd = () => { setDraft(emptyDraft()); setTrial(null); setFormulaError(false); };
const startEdit = (index) => {
const fld = fields[index];
setDraft({
@@ -55,7 +96,11 @@ export function StrategyFieldsEditor({ strategy, onSave, saving }) {
defText: fld.type === 'boolean' ? '' : (fld.def != null ? String(fld.def) : ''),
defBool: fld.type === 'boolean' ? !!fld.def : false,
defEnum: fld.type === 'enum' ? (fld.def ?? '') : '',
formula: fld.formula ?? '',
resultKind: fld.resultKind === 'boolean' ? 'boolean' : 'number', // R-028
decimals: typeof fld.decimals === 'number' ? fld.decimals : 2,
});
setTrial(null);
};
const removeField = (index) => {
@@ -67,45 +112,146 @@ export function StrategyFieldsEditor({ strategy, onSave, saving }) {
if (!draft) return;
const label = draft.label.trim();
if (!label) { toast.error('字段名称不能为空'); return; }
if (!['text', 'number', 'boolean', 'enum'].includes(draft.type)) { toast.error('字段类型无效'); return; }
if (!['text', 'number', 'boolean', 'enum', 'formula'].includes(draft.type)) { toast.error('字段类型无效'); return; }
let enumOptions = [];
if (draft.type === 'enum') {
enumOptions = draft.enumText.split(/[,]/).map((s) => s.trim()).filter(Boolean);
if (enumOptions.length === 0) { toast.error('枚举类型需填写选项(逗号分隔)'); return; }
}
if (draft.type === 'formula' && !String(draft.formula ?? '').trim()) {
toast.error('公式不能为空');
setFormulaError(true);
return;
}
const usedKeys = fields.filter((_, i) => i !== draft.index).map((x) => x.key);
const key = draft.key.trim() || makeKey(label, usedKeys);
if (usedKeys.includes(key)) { toast.error('字段标识已存在: ' + key); return; }
let defVal;
if (draft.type === 'text') defVal = draft.defText;
else if (draft.type === 'number') defVal = draft.defText.trim() === '' ? null : Number(draft.defText);
else if (draft.type === 'boolean') defVal = draft.defBool;
else defVal = draft.defEnum || null;
const field = { key, label, type: draft.type, unit: String(draft.unit ?? '').trim() };
if (draft.type === 'enum') field.enum = enumOptions;
if (defVal !== null && defVal !== undefined && defVal !== '') field.def = defVal;
let field;
if (draft.type === 'formula') {
field = {
key, label, type: 'formula', unit: String(draft.unit ?? '').trim(),
formula: String(draft.formula ?? '').trim(),
resultKind: draft.resultKind === 'boolean' ? 'boolean' : 'number', // R-028
decimals: clampDecimals(draft.decimals),
};
} else {
let defVal;
if (draft.type === 'text') defVal = draft.defText;
else if (draft.type === 'number') defVal = draft.defText.trim() === '' ? null : Number(draft.defText);
else if (draft.type === 'boolean') defVal = draft.defBool;
else defVal = draft.defEnum || null;
field = { key, label, type: draft.type, unit: String(draft.unit ?? '').trim() };
if (draft.type === 'enum') field.enum = enumOptions;
if (defVal !== null && defVal !== undefined && defVal !== '') field.def = defVal;
}
if (draft.index === -1) setFields([...fields, field]);
else setFields(fields.map((f2, i) => (i === draft.index ? field : f2)));
setDraft(null);
setFormulaError(false);
};
const saveAll = async () => {
if (draft) { toast.error('请先完成正在编辑的字段'); return; }
await onSave(fields);
/** 插入变量到公式框光标处(无光标信息 → 追加末尾并补空格) */
const insertVar = (name) => {
setFormulaError(false);
const cur = String(draft?.formula ?? '');
const el = formulaRef.current;
if (el && typeof el.selectionStart === 'number') {
let before = cur.slice(0, el.selectionStart);
let after = cur.slice(el.selectionEnd ?? el.selectionStart);
if (before && !/\s$/.test(before)) before += ' ';
if (after && !/^\s/.test(after)) after = ' ' + after;
const next = before + name + after;
setDraft({ ...draft, formula: next });
const pos = (before + name).length;
requestAnimationFrame(() => { el.focus(); el.setSelectionRange(pos, pos); });
} else {
setDraft({ ...draft, formula: cur ? cur + ' ' + name : name });
}
};
/** 试算:formula/trial(服务端取该策略真实持仓 + 缓存行情求值) */
const runTrial = async () => {
if (trialLoading || !draft) return;
const formula = String(draft.formula ?? '').trim();
if (!formula) { toast.error('公式不能为空'); return; }
const decimals = clampDecimals(draft.decimals);
const unit = String(draft.unit ?? '').trim();
setTrialLoading(true);
setTrial(null);
try {
const res = await call('one-divine-lot/formula/trial', { args: { strategyId, formula, decimals, unit } });
if (res?.ok) {
const v = res.value || {};
if (typeof v.value === 'boolean') {
// R-028:判定型试算(满足 = ✓ 字段名)
setTrial({ kind: 'ok', text: '试算:' + (v.name ?? '—') + ' ' + (v.code ?? '—') + ' → ' + (v.value ? '✓ 满足' : '— 不满足') });
} else if (v.value != null && Number.isFinite(Number(v.value))) {
setTrial({ kind: 'ok', text: '试算:' + (v.name ?? '—') + ' ' + (v.code ?? '—') + ' → ' + fmtValue(Number(v.value), decimals, unit) });
} else if (v.reason === 'no-holding') {
setTrial({ kind: 'empty', text: '暂无可试算的持仓数据' });
} else {
setTrial({ kind: 'empty', text: '行情数据缺失,暂无法试算' });
}
} else {
const msg = (res?.error?.message) || '试算失败';
toast.error(msg);
setTrial({ kind: 'error', text: msg });
setFormulaError(true);
}
} catch (e) {
toast.error(e.message);
setTrial({ kind: 'error', text: e.message });
} finally {
setTrialLoading(false);
}
};
/** 保存(弹层「保存字段」触发):strategies/schema-update → 成功 true(弹层负责 onSaved + 关闭) */
useImperativeHandle(ref, () => ({
save: async () => {
if (draft) { toast.error('请先完成正在编辑的字段'); return false; }
setSaveError(null);
try {
const res = await call('one-divine-lot/strategies/schema-update', { args: { strategyId, configSchema: fields } });
if (res?.ok) { toast.success('字段已保存'); return true; }
const msg = (res?.error?.message) || '保存失败';
toast.error(msg);
setSaveError(msg);
// 服务端字段校验失败:公式框描红(表单不关闭,可重试)
if (/公式|变量|语法|表达式|『/.test(msg)) setFormulaError(true);
return false;
} catch (e) {
toast.error(e.message);
setSaveError(e.message);
return false;
}
},
}), [fields, draft, strategyId, call, toast]);
/** 变量菜单分组:服务端 行情/合约/持仓 + 「自定义字段」(本策略非 formula 字段展示名) */
const menuGroups = useMemo(() => {
const server = (Array.isArray(varGroups) ? varGroups : [])
.filter((g) => g && g.group && g.group !== '自定义字段')
.map((g) => ({ group: g.group, items: (Array.isArray(g.items) ? g.items : []).map((it) => ({ name: it.name, desc: it.desc })) }));
const custom = fields
.filter((f, i) => f.type !== 'formula' && i !== draft?.index)
.map((f) => ({ name: String(f.label || f.key), desc: '本策略手填字段' }));
return [...server, { group: '自定义字段', items: custom }];
}, [varGroups, fields, draft?.index]);
return (
<div style={{ padding: '10px 12px', background: 'var(--dsw-alias-bg-layer-2, #f5f5f5)', borderRadius: 6, margin: '4px 0 8px', fontSize: 13 }}>
<div style={{ padding: '10px 0 12px', fontSize: 13 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<strong style={{ fontSize: 13 }}>自定义字段{fields.length}</strong>
<span>
<button onClick={startAdd} style={solidBtn('var(--dsw-alias-state-success-primary, #2e7d32)')}>+ 添加字段</button>
<button onClick={saveAll} disabled={saving} style={solidBtn('var(--dsw-alias-state-business-primary, #1565c0)')}>{saving ? '保存中...' : '保存字段'}</button>
</span>
<button onClick={startAdd} disabled={saving} style={solidBtn('var(--dsw-alias-state-success-primary, #2e7d32)')}>+ 添加字段</button>
</div>
{saveError && (
<div style={{ marginBottom: 8, fontSize: 12, color: 'var(--dsw-alias-state-error-primary, #c62828)' }}>{saveError}</div>
)}
{fields.length === 0 && !draft && (
<div style={{ color: 'var(--dsw-alias-label-secondary, #888)', marginBottom: 6 }}>尚未配置字段 点击+ 添加字段为本策略添加自定义字段该策略下每个持仓按此定义填写值</div>
<div style={{ color: 'var(--dsw-alias-label-secondary, #888)', marginBottom: 6 }}>尚未配置字段 点击+ 添加字段为本策略添加该策略下每个持仓按此定义填写值</div>
)}
{fields.length > 0 && (
@@ -115,7 +261,7 @@ export function StrategyFieldsEditor({ strategy, onSave, saving }) {
<th style={{ padding: '4px 6px' }}>展示名</th>
<th style={{ padding: '4px 6px' }}>key</th>
<th style={{ padding: '4px 6px' }}>类型</th>
<th style={{ padding: '4px 6px' }}>选项/默认</th>
<th style={{ padding: '4px 6px' }}>公式/默认</th>
<th style={{ padding: '4px 6px' }}>操作</th>
</tr>
</thead>
@@ -124,9 +270,19 @@ export function StrategyFieldsEditor({ strategy, onSave, saving }) {
<tr key={fld.key + '-' + i} style={{ borderTop: '1px solid var(--dsw-alias-border-l1, #eee)' }}>
<td style={{ padding: '4px 6px' }}>{fld.label || fld.key}</td>
<td style={{ padding: '4px 6px', color: 'var(--dsw-alias-label-secondary, #888)', fontSize: 12 }}>{fld.key}</td>
<td style={{ padding: '4px 6px' }}>{TYPE_LABEL[fld.type] || fld.type}</td>
<td style={{ padding: '4px 6px' }}>
{TYPE_LABEL[fld.type] || fld.type}
{fld.type === 'formula' && fld.resultKind === 'boolean' ? '(判定)' : ''}
</td>
<td style={{ padding: '4px 6px', fontSize: 12, color: 'var(--dsw-alias-label-secondary, #666)' }}>
{fld.type === 'enum' ? (Array.isArray(fld.enum) ? fld.enum.join(' / ') : '')
{fld.type === 'formula' ? (
<span
title={fld.formula || ''}
style={{ display: 'inline-block', maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontFamily: MONO, verticalAlign: 'bottom', color: 'var(--dsw-alias-label-secondary, #666)' }}
>
{fld.formula || '—'}
</span>
) : fld.type === 'enum' ? (Array.isArray(fld.enum) ? fld.enum.join(' / ') : '')
: fld.type === 'boolean' ? (fld.def ? '默认开' : '默认关')
: ((fld.def != null ? '默认 ' + fld.def + (fld.unit ? ' ' + fld.unit : '') : '') + (fld.unit && fld.def == null ? '单位 ' + fld.unit : ''))}
</td>
@@ -145,14 +301,108 @@ export function StrategyFieldsEditor({ strategy, onSave, saving }) {
<div style={{ marginBottom: 6 }}>
<input style={inputStyle} placeholder="字段展示名(如:网格间距)" value={draft.label} onChange={(e) => setDraft({ ...draft, label: e.target.value })} />
<input style={{ ...inputStyle, width: 120 }} placeholder="key(留空自动生成)" value={draft.key} onChange={(e) => setDraft({ ...draft, key: e.target.value })} />
<select style={inputStyle} value={draft.type} onChange={(e) => setDraft({ ...draft, type: e.target.value })}>
<select
style={inputStyle}
value={draft.type}
onChange={(e) => { setDraft({ ...draft, type: e.target.value }); setTrial(null); setFormulaError(false); }}
>
<option value="text">文本</option>
<option value="number">数字</option>
<option value="boolean">布尔</option>
<option value="enum">枚举</option>
<option value="formula">计算</option>
</select>
<input style={{ ...inputStyle, width: 60 }} placeholder="单位(可选)" title="展示在值后的单位(如 % / 元 / 手),可为空" value={draft.unit} onChange={(e) => setDraft({ ...draft, unit: e.target.value })} />
<input style={{ ...inputStyle, width: 60 }} placeholder="单位(可选)" title="展示在值后的单位(如 % / 元 / 手),可为空" value={draft.unit} onChange={(e) => { setDraft({ ...draft, unit: e.target.value }); setTrial(null); }} />
{draft.type === 'formula' && (
<label style={{ fontSize: 12, color: 'var(--dsw-alias-label-secondary, #666)' }}>
结果
<select
style={{ ...inputStyle, width: 76, marginLeft: 4 }}
title="数字 = 算式结果(可设小数位);判定 = 比较结果(满足显示 ✓,不满足显示 —)"
value={draft.resultKind}
onChange={(e) => { setTrial(null); setFormulaError(false); setDraft({ ...draft, resultKind: e.target.value }); }}
>
<option value="number">数字</option>
<option value="boolean">判定</option>
</select>
</label>
)}
</div>
{draft.type === 'formula' && (
<div style={{ marginBottom: 6 }}>
<textarea
ref={formulaRef}
rows={2}
placeholder={draft.resultKind === 'boolean'
? '判定公式,如:涨停价 > 基准值 + 网格大小'
: '计算公式,如:(现价 ÷ 成本价 - 1) × 100'}
value={draft.formula}
onChange={(e) => { setFormulaError(false); setTrial(null); setDraft({ ...draft, formula: e.target.value }); }}
style={{
width: '100%', boxSizing: 'border-box', fontFamily: MONO, fontSize: 12, padding: '4px 6px', borderRadius: 3,
border: formulaError ? '1px solid var(--dsw-alias-state-error-primary, #c62828)' : '1px solid var(--dsw-alias-border-l2, #ccc)',
background: 'var(--dsw-alias-bg-layer-1, #fff)', resize: 'vertical',
}}
/>
<div style={{ fontSize: 12, color: 'var(--dsw-alias-label-tertiary, #999)', marginTop: 4 }}>
运算+ - × ÷ 括号 判定&gt; &lt; &gt;= &lt;= == != 组合and / or
</div>
<div style={{ marginTop: 6 }}>
{menuGroups.map((g) => (
<div key={g.group} style={{ display: 'flex', alignItems: 'flex-start', gap: 4, marginTop: 3, flexWrap: 'wrap' }}>
<span style={{ flex: 'none', fontSize: 11, color: 'var(--dsw-alias-label-tertiary, #999)', padding: '4px 2px 2px 0' }}>{g.group}</span>
{g.items.length === 0 ? (
<span style={{ fontSize: 12, color: 'var(--dsw-alias-label-tertiary, #999)', padding: '3px 0 2px' }}>本策略暂无手填字段</span>
) : g.items.map((it) => (
<button
key={it.name}
onClick={() => insertVar(it.name)}
title={(it.desc ? it.desc + '' : '') + '点击插入到公式'}
style={{
padding: '2px 9px', fontSize: 12, lineHeight: '18px', borderRadius: 10, cursor: 'pointer',
border: '1px solid var(--dsw-alias-border-l2, #ccc)',
background: 'var(--dsw-alias-bg-layer-2, #f5f5f5)',
color: 'var(--dsw-alias-label-primary, #333)',
}}
>
{it.name}
</button>
))}
</div>
))}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 6, flexWrap: 'wrap' }}>
<button onClick={runTrial} disabled={trialLoading} style={{ ...btnStyle, marginRight: 0, opacity: trialLoading ? 0.6 : 1 }}>
{trialLoading ? '试算中…' : '试算'}
</button>
{draft.resultKind !== 'boolean' && (
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--dsw-alias-label-secondary, #666)' }}>
小数位
<input
type="number"
min={0}
max={4}
step={1}
value={draft.decimals}
onChange={(e) => { setTrial(null); setDraft({ ...draft, decimals: e.target.value }); }}
style={{ width: 56, padding: '2px 4px', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 3, background: 'var(--dsw-alias-bg-layer-1, #fff)', fontSize: 12 }}
/>
0-4
</label>
)}
</div>
{trial && (
<div style={{ marginTop: 4, fontSize: 12, color: trial.kind === 'ok' ? 'var(--dsw-alias-label-secondary, #666)' : 'var(--dsw-alias-state-error-primary, #c62828)' }}>
{trial.text}
</div>
)}
{formulaError && saveError && (
<div style={{ marginTop: 4, fontSize: 12, color: 'var(--dsw-alias-state-error-primary, #c62828)' }}>{saveError}</div>
)}
</div>
)}
{draft.type === 'enum' && (
<div style={{ marginBottom: 6 }}>
<input style={{ ...inputStyle, width: 220 }} placeholder="枚举选项(逗号分隔,如:低,中,高)" value={draft.enumText} onChange={(e) => setDraft({ ...draft, enumText: e.target.value })} />
@@ -178,11 +428,11 @@ export function StrategyFieldsEditor({ strategy, onSave, saving }) {
</label>
)}
<div>
<button onClick={saveDraft} style={solidBtn('var(--dsw-alias-state-success-primary, #2e7d32)')}>确定</button>
<button onClick={() => setDraft(null)} style={btnStyle}>取消</button>
<button onClick={saveDraft} disabled={saving} style={solidBtn('var(--dsw-alias-state-success-primary, #2e7d32)')}>确定</button>
<button onClick={() => setDraft(null)} disabled={saving} style={btnStyle}>取消</button>
</div>
</div>
)}
</div>
);
}
});
+69 -4
View File
@@ -26,6 +26,7 @@ import { ToastProvider, useToast } from './Toast.jsx';
import { useMarket } from '../market/MarketDataProvider.jsx';
import { PriceCell } from './PriceCell.jsx';
import { ColumnSettingsPopover } from './ColumnSettingsPopover.jsx'; // 迭代11:列显隐设置
import { FieldConfigDialog } from './FieldConfigDialog.jsx'; // 迭代22:字段配置弹层(含计算字段)
import { FieldCellEditor } from './FieldCellEditor.jsx'; // 迭代11:自定义字段单元格点击编辑
import { ExpandChevron } from './ExpandChevron.jsx'; // 行展开图标共享组件
@@ -59,6 +60,19 @@ function fmtPct(pct) {
return sign + pct.toFixed(2) + '%';
}
/** 小数位夹取 0-4 整数(计算字段默认 2) */
function clampDec(v) {
const n = Number(v);
if (!Number.isFinite(n)) return 2;
return Math.min(4, Math.max(0, Math.round(n)));
}
/** 计算字段值格式化:toFixed(decimals) + 单位(如 6.67 % / 700.00 元;迭代22 */
function fmtComputed(v, decimals, unit) {
const s = Number(v).toFixed(clampDec(decimals));
return unit ? s + ' ' + unit : s;
}
/** 添加持仓区域响应式样式(内联注入,含媒体查询) */
const ADD_FORM_CSS = `
.odl-add-form {
@@ -184,9 +198,11 @@ function StrategyTabInner({ strategyId, strategyName }) {
const [sortDir, setSortDir] = useState('desc'); // 'asc' | 'desc'
// R-013:本策略自定义字段定义(configSchema;从 strategies 端点取)
const [strategySchema, setStrategySchema] = useState(null);
// 迭代11列显隐:列配置(归一化有序数组 [{key,label,kind,visible}]+ 列设置弹层开关
// 迭代11列显隐:列配置(归一化有序数组 [{key,label,kind,visible,type?}]+ 列设置弹层开关
const [columns, setColumns] = useState(null);
const [columnOpen, setColumnOpen] = useState(false);
// 迭代22:字段配置弹层开关(R-026/R-027
const [fieldConfigOpen, setFieldConfigOpen] = useState(false);
// 迭代11:正在单元格内编辑的自定义字段({code, key} | null
const [editingCell, setEditingCell] = useState(null);
// R-016 迭代 14:历史持仓展示(title 旁开关 + 清仓范围筛选;会话级,不持久化,Q4)
@@ -427,6 +443,8 @@ function StrategyTabInner({ strategyId, strategyName }) {
if (col.kind === 'field') {
const fieldDef0 = (strategySchema ?? []).find((f0) => f0.key === col.key);
if (!fieldDef0) return null;
// 迭代22:计算字段列历史行无 computed → 显示 —
if (fieldDef0.type === 'formula') return <span style={{ color: 'var(--dsw-alias-label-tertiary, #999)' }}></span>;
const has0 = p.values && Object.prototype.hasOwnProperty.call(p.values, col.key);
const v0 = has0 ? p.values[col.key] : fieldDef0.def;
let shown0;
@@ -442,6 +460,26 @@ function StrategyTabInner({ strategyId, strategyName }) {
// 自定义字段列:p.values[key] ?? def;点击单元格进入内联编辑(老师选 A)
const fieldDef = (strategySchema ?? []).find((f2) => f2.key === col.key);
if (!fieldDef) return null; // configSchema 已删该字段(列配置滞后,读时已跟随,双保险)
// 迭代22:计算字段列只读——取值 row.computed[key],不挂 FieldCellEditor、不绑点击编辑
if (fieldDef.type === 'formula') {
const raw = p.computed ? p.computed[col.key] : undefined;
// R-028 判定型:满足 → 「✓ 字段名」(success 绿);不满足 / 缺值 → —(tertiary
// 注意顺序:布尔必须先判(false 的 Number() 为 0,否则会被当成数字格式化)
if (typeof raw === 'boolean') {
return raw
? <span title={'= ' + (fieldDef.formula ?? '')} style={{ color: 'var(--dsw-alias-state-success-primary, #2e7d32)' }}>{'✓ ' + (fieldDef.label || col.label)}</span>
: <span title={'= ' + (fieldDef.formula ?? '')} style={{ color: 'var(--dsw-alias-label-tertiary, #999)' }}></span>;
}
const hasV = raw != null && Number.isFinite(Number(raw));
return (
<span
title={'= ' + (fieldDef.formula ?? '')}
style={!hasV ? { color: 'var(--dsw-alias-label-tertiary, #999)' } : undefined}
>
{hasV ? fmtComputed(Number(raw), fieldDef.decimals, fieldDef.unit) : '—'}
</span>
);
}
const isEditing = editingCell && editingCell.code === p.code && editingCell.key === col.key;
if (isEditing) {
return (
@@ -503,7 +541,7 @@ function StrategyTabInner({ strategyId, strategyName }) {
}
};
/** 表头列 label(排序箭头仅涨幅支持) */
/** 表头列 label(排序箭头仅涨幅支持;迭代22:计算字段列 ƒ 前缀 + 悬停公式原文 */
const headerCell = (col) => {
if (col.key === 'pctChange') {
return (
@@ -512,7 +550,19 @@ function StrategyTabInner({ strategyId, strategyName }) {
</th>
);
}
return <th style={{ padding: '8px 6px', lineHeight: '24px', whiteSpace: 'nowrap' }}>{col.label}</th>;
const fld0 = (strategySchema ?? []).find((f) => f.key === col.key);
const isFormulaCol = col.kind === 'field' && fld0 && fld0.type === 'formula';
return (
<th
style={{ padding: '8px 6px', lineHeight: '24px', whiteSpace: 'nowrap' }}
title={isFormulaCol ? '= ' + (fld0.formula ?? '') : undefined}
>
{isFormulaCol && (
<span title="计算字段(只读)" style={{ color: 'var(--dsw-alias-label-tertiary, #999)', marginRight: 2 }}>ƒ</span>
)}
{col.label}
</th>
);
};
const pctArrow = sortKey === 'pctChange' ? (sortDir === 'desc' ? ' ▼' : ' ▲') : '';
@@ -561,7 +611,13 @@ function StrategyTabInner({ strategyId, strategyName }) {
)}
<div style={{ position: 'relative' }}>
<button
onClick={() => setColumnOpen(!columnOpen)}
onClick={() => { setColumnOpen(false); setFieldConfigOpen(true); }}
style={{ padding: '6px 12px', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 4, background: 'var(--dsw-alias-bg-layer-1, #fff)', fontSize: 13, marginLeft: 8 }}
>
字段配置
</button>
<button
onClick={() => { setFieldConfigOpen(false); setColumnOpen(!columnOpen); }}
style={{ padding: '6px 12px', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2, #ccc)', borderRadius: 4, background: 'var(--dsw-alias-bg-layer-1, #fff)', fontSize: 13, marginLeft: 8 }}
>
列设置
@@ -574,6 +630,15 @@ function StrategyTabInner({ strategyId, strategyName }) {
onSaved={load}
/>
)}
{fieldConfigOpen && (
<FieldConfigDialog
strategyId={strategyId}
strategyName={name}
configSchema={Array.isArray(strategySchema) ? strategySchema : []}
onSaved={load}
onClose={() => setFieldConfigOpen(false)}
/>
)}
</div>
</div>
</div>
+54
View File
@@ -0,0 +1,54 @@
/**
* 计算字段现算服务(R-026 / 迭代 22 阶段二)
*
* 职责:给定某策略的持仓行 + 字段定义,为每行算出全部 formula 字段的值(computed)。
* 数据口径(技术方案 §2.4):
* - 行情/合约**只读内存缓存**QuoteHub.quotes / instruments),miss → 相关变量 null → 该格 `—`;
* 不做读穿透(strategy-positions 是高频读路径,不引入网络往返)
* - 无 formula 字段 → 直接返回原数组(零开销)
* - 值不落库(策略定义存 settings,值每次现算)
*/
import { compileFormula, evaluateAst } from './evaluator.js';
import { buildContext } from './variables.js';
/**
* 为持仓行附加 computed
* @param {object} opts
* @param {Array} opts.rows strategy-positions 行
* @param {Array} opts.fieldDefs 本策略字段定义
* @param {object|null} [opts.hub] QuoteHub(读 quotes / instruments 内存缓存)
* @param {(code:string)=>object|null} [opts.quoteOf] 自定义行情取数(测试注入)
* @param {(code:string)=>object|null} [opts.instrumentOf] 自定义合约取数(测试注入)
* @returns {Array} rows(含 formula 字段时每行附 computed: { [key]: number|null }
*/
export function computeRows({ rows, fieldDefs, hub = null, quoteOf, instrumentOf } = {}) {
const list = Array.isArray(rows) ? rows : [];
const formulaFields = (Array.isArray(fieldDefs) ? fieldDefs : [])
.filter((f) => f && f.type === 'formula' && String(f.formula ?? '').trim());
if (list.length === 0 || formulaFields.length === 0) return list;
const compiled = formulaFields
.map((f) => ({ key: f.key, ast: compileFormula(f.formula).ast }))
.filter((c) => c.ast);
if (compiled.length === 0) return list;
const getQuote = quoteOf ?? ((code) => hub?.quotes?.get(code) ?? null);
const getInstrument = instrumentOf ?? ((code) => hub?.instruments?.get(code) ?? null);
return list.map((row) => {
const code = row?.code;
const quote = code ? getQuote(code) : null;
const instrument = code ? getInstrument(code) : null;
const ctx = buildContext({ row, quote, instrument, fieldDefs });
const computed = {};
for (const c of compiled) {
let v = null;
try { v = evaluateAst(c.ast, ctx); } catch { v = null; }
// R-028:判定型(布尔)直通;数字型要求有限数;其余(缺值/异常)→ null
computed[c.key] = (typeof v === 'boolean')
? v
: ((typeof v === 'number' && Number.isFinite(v)) ? v : null);
}
return { ...row, computed };
});
}
+352
View File
@@ -0,0 +1,352 @@
/**
* 公式引擎(R-026 / 迭代 22 阶段二):自写小型表达式解析与求值
*
* 设计约束:
* - 零依赖、不使用 eval/Function(安全边界);
* - 支持中文标识符(中文变量名,如 现价 / 成本价 / 份额);
* - 四则运算 + 括号 + 一元负号 + 函数 round/abs/min/max
* - **判定(R-028**:比较运算 > < >= <= == != 与逻辑 and/or(含 && ||、≥ ≤ ≠ 归一),
* 结果可为布尔(判定型字段)或数字(数字型字段,由 `inferResultKind` 静态判定);
* - 求值语义:引用的任一变量缺值(null/undefined/NaN)→ 整式返回 null(缺值短路);
* 除零、非法运算、结果非有限 → null;绝不抛异常、绝不产出 NaN。
*/
/** 函数白名单:名称 → [最小参数个数, 最大参数个数] */
export const FUNCS = {
round: [1, 2],
abs: [1, 1],
min: [1, Infinity],
max: [1, Infinity],
};
const MAX_LEN = 500; // 表达式长度上限
const MAX_DEPTH = 32; // 递归深度上限
/** 全角 → 半角归一化(中文输入法友好:()+-×÷,*/都可以写) */
function normalizeSrc(src) {
return String(src ?? '')
.replace(/[]/g, '(').replace(/[]/g, ')')
.replace(/[]/g, '+').replace(/[-—]/g, '-')
.replace(/[×*]/g, '*').replace(/[÷/]/g, '/')
.replace(/[]/g, ',')
.replace(/≥/g, '>=').replace(/≤/g, '<=').replace(/≠/g, '!=')
.replace(//g, '>').replace(//g, '<').replace(//g, '=');
}
const IDENT_START = /[\p{L}_]/u;
const IDENT_PART = /[\p{L}\p{Nd}_]/u;
/**
* 词法分析 → token 数组
* @returns {{ok: true, tokens: Array<{type:'num'|'ident'|'op', value:any, pos:number}>} | {ok:false, error:object}}
*/
function tokenize(src) {
const s = normalizeSrc(src);
const tokens = [];
let i = 0;
while (i < s.length) {
const c = s[i];
if (c === ' ' || c === '\t' || c === '\n' || c === '\r') { i++; continue; }
if (c >= '0' && c <= '9' || (c === '.' && s[i + 1] >= '0' && s[i + 1] <= '9')) {
let j = i;
while (j < s.length && (s[j] >= '0' && s[j] <= '9' || s[j] === '.')) j++;
const raw = s.slice(i, j);
const num = Number(raw);
if (!Number.isFinite(num)) return { ok: false, error: { code: 'syntax', message: `数字格式错误: ${raw}`, position: i } };
tokens.push({ type: 'num', value: num, pos: i });
i = j;
continue;
}
if (IDENT_START.test(c)) {
let j = i + 1;
while (j < s.length && IDENT_PART.test(s[j])) j++;
tokens.push({ type: 'ident', value: s.slice(i, j), pos: i });
i = j;
continue;
}
const two = s.slice(i, i + 2);
if (['>=', '<=', '==', '!=', '&&', '||'].includes(two)) {
tokens.push({ type: 'op', value: two, pos: i });
i += 2;
continue;
}
if ('+-*/()<>=!,'.includes(c)) {
tokens.push({ type: 'op', value: c, pos: i });
i++;
continue;
}
return { ok: false, error: { code: 'syntax', message: `无法识别的字符: ${c}`, position: i } };
}
return { ok: true, tokens };
}
/** 递归下降解析 → AST */
function parse(tokens) {
let p = 0;
let depth = 0;
const peek = () => tokens[p];
const isOp = (v) => { const t = peek(); return t && t.type === 'op' && t.value === v; };
const err = (message, position) => { throw { code: 'syntax', message, position }; };
const CMP_OPS = new Set(['>', '<', '>=', '<=', '==', '!=']);
const isKeyword = (t, kw) => !!(t && t.type === 'ident' && String(t.value).toLowerCase() === kw);
/** 最低优先级:or → and → 比较 → 加减 → 乘除 → 一元 → 主 */
function expression() {
let node = andExpr();
while (isKeyword(peek(), 'or') || isOp('||')) {
p++;
node = { kind: 'binary', op: 'or', left: node, right: andExpr() };
}
return node;
}
function andExpr() {
let node = comparison();
while (isKeyword(peek(), 'and') || isOp('&&')) {
p++;
node = { kind: 'binary', op: 'and', left: node, right: comparison() };
}
return node;
}
function comparison() {
let node = additive();
while (peek() && peek().type === 'op' && CMP_OPS.has(peek().value)) {
const op = tokens[p++].value;
node = { kind: 'binary', op, left: node, right: additive() };
}
return node;
}
function additive() {
let node = term();
while (peek() && peek().type === 'op' && (peek().value === '+' || peek().value === '-')) {
const op = tokens[p++].value;
node = { kind: 'binary', op, left: node, right: term() };
}
return node;
}
function term() {
let node = unary();
while (peek() && peek().type === 'op' && (peek().value === '*' || peek().value === '/')) {
const op = tokens[p++].value;
node = { kind: 'binary', op, left: node, right: unary() };
}
return node;
}
function unary() {
if (isOp('-') || isOp('+')) {
const op = tokens[p++].value;
const node = unary();
return op === '-' ? { kind: 'neg', value: node } : node;
}
return primary();
}
function primary() {
if (depth++ > MAX_DEPTH) err('公式过于复杂(嵌套过深)', 0);
try {
const t = peek();
if (!t) err('公式不完整', p > 0 ? tokens[p - 1].pos + 1 : 0);
if (t.type === 'num') { p++; return { kind: 'num', value: t.value }; }
if (t.type === 'ident') {
p++;
if (isOp('(')) {
p++;
const args = [];
if (!isOp(')')) {
args.push(expression());
while (isOp(',')) { p++; args.push(expression()); }
}
if (!isOp(')')) err('缺少右括号', t.pos);
p++;
return { kind: 'call', name: t.value, args, pos: t.pos };
}
return { kind: 'var', name: t.value, pos: t.pos };
}
if (t.type === 'op' && t.value === '(') {
p++;
const node = expression();
if (!isOp(')')) err('缺少右括号', t.pos);
p++;
return node;
}
err(`意外的符号: ${t.value}`, t.pos);
} finally { depth--; }
}
const ast = expression();
if (p < tokens.length) {
const t = tokens[p];
err(`多余的内容: ${t.value}`, t.pos);
}
return ast;
}
/**
* 编译公式(词法 + 语法),不校验变量名
* @returns {{ok:true, ast:object} | {ok:false, error:{code:string,message:string,position:number}}}
*/
export function compileFormula(expr) {
const src = String(expr ?? '').trim();
if (!src) return { ok: false, error: { code: 'empty', message: '公式不能为空', position: 0 } };
if (src.length > MAX_LEN) return { ok: false, error: { code: 'syntax', message: '公式过长(上限 ' + MAX_LEN + ' 字符)', position: MAX_LEN } };
const tk = tokenize(src);
if (!tk.ok) return tk;
try {
return { ok: true, ast: parse(tk.tokens) };
} catch (e) {
if (e && e.code === 'syntax') return { ok: false, error: { code: 'syntax', message: e.message, position: e.position ?? 0 } };
return { ok: false, error: { code: 'syntax', message: '公式解析失败', position: 0 } };
}
}
/**
* 校验公式:语法 + 变量存在性 + 函数名与参数个数
* @param {string} expr 公式串
* @param {Iterable<string>} allowedVars 允许的变量名(内置名 ∪ 本策略非公式字段展示名)
* @returns {{ok:true, vars:string[]} | {ok:false, error:{code:string,message:string,position:number}}}
*/
export function validateFormula(expr, allowedVars) {
const compiled = compileFormula(expr);
if (!compiled.ok) return compiled;
const allowed = new Set(allowedVars ?? []);
const used = new Set();
let failure = null;
const walk = (node) => {
if (failure || !node) return;
if (node.kind === 'var') {
if (!allowed.has(node.name)) {
failure = { code: 'unknown-var', message: `未知变量『${node.name}』——该字段已删除或名称有误`, position: node.pos ?? 0 };
return;
}
used.add(node.name);
return;
}
if (node.kind === 'call') {
const arity = FUNCS[node.name];
if (!arity) {
failure = { code: 'unknown-func', message: `未知函数『${node.name}』(可用:round/abs/min/max`, position: node.pos ?? 0 };
return;
}
if (node.args.length < arity[0] || node.args.length > arity[1]) {
failure = { code: 'arity', message: `函数『${node.name}』参数个数不对`, position: node.pos ?? 0 };
return;
}
node.args.forEach(walk);
return;
}
if (node.kind === 'binary') { walk(node.left); walk(node.right); return; }
if (node.kind === 'neg') { walk(node.value); }
};
walk(compiled.ast);
if (failure) return { ok: false, error: failure };
return { ok: true, vars: [...used] };
}
/** 数值判定:有限数才有效,否则 null(中间步骤不做归一,保精度) */
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
/**
* 最终结果浮点归一:消除 11.2-10.5=0.7000000000000002 ×1000 之类的噪声
* (仅最终值处理;|v| ≥ 1e12 不动,避免精度损失)
*/
function normalizeResult(v) {
if (typeof v === 'boolean') return v; // 判定型结果直通(R-028
if (typeof v !== 'number' || !Number.isFinite(v)) return null;
if (Math.abs(v) >= 1e12) return v;
const r = Math.round(v * 1e10) / 1e10;
return Number.isFinite(r) ? r : null;
}
/**
* 求值(缺值短路:任一操作数为 null → 结果 null)
* @param {object} ast compileFormula 的 ast
* @param {Object<string, number|null>} ctx 变量上下文
* @returns {number|null}
*/
function evalNode(ast, ctx = {}) {
if (!ast) return null;
switch (ast.kind) {
case 'num':
return num(ast.value);
case 'var': {
const v = ctx[ast.name];
if (v === null || v === undefined) return null;
const n = typeof v === 'number' ? v : Number(v);
return Number.isFinite(n) ? n : null;
}
case 'neg': {
const v = evalNode(ast.value, ctx);
return v === null ? null : num(-v);
}
case 'binary': {
const { op } = ast;
// 逻辑(R-028):缺值短路(任一操作数 null → null)
if (op === 'and' || op === 'or') {
const l = evalNode(ast.left, ctx);
if (l === null) return null;
const r = evalNode(ast.right, ctx);
if (r === null) return null;
return op === 'and' ? (!!l && !!r) : (!!l || !!r);
}
const l = evalNode(ast.left, ctx);
if (l === null) return null;
const r = evalNode(ast.right, ctx);
if (r === null) return null;
// 比较(R-028:判定)
if (op === '>') return l > r;
if (op === '<') return l < r;
if (op === '>=') return l >= r;
if (op === '<=') return l <= r;
if (op === '==') return l === r;
if (op === '!=') return l !== r;
if (op === '+') return num(l + r);
if (op === '-') return num(l - r);
if (op === '*') return num(l * r);
if (op === '/') return r === 0 ? null : num(l / r);
return null;
}
case 'call': {
const args = ast.args.map((a) => evalNode(a, ctx));
if (args.some((a) => a === null)) return null;
if (ast.name === 'round') {
const digits = args.length > 1 ? Math.trunc(args[1]) : 0;
const f = Math.pow(10, Math.max(0, Math.min(10, digits)));
return num(Math.round(args[0] * f) / f);
}
if (ast.name === 'abs') return num(Math.abs(args[0]));
if (ast.name === 'min') return num(Math.min(...args));
if (ast.name === 'max') return num(Math.max(...args));
return null;
}
default:
return null;
}
}
/**
* 结果类型静态推断(R-028):顶层为比较/逻辑运算 → 'boolean'(判定型),否则 'number'
* @param {object} ast compileFormula 的 ast
* @returns {'boolean'|'number'}
*/
export function inferResultKind(ast) {
if (!ast) return 'number';
if (ast.kind === 'neg') return inferResultKind(ast.value);
if (ast.kind === 'binary') {
const { op } = ast;
if (op === 'and' || op === 'or') return 'boolean';
if (['>', '<', '>=', '<=', '==', '!='].includes(op)) return 'boolean';
}
return 'number';
}
/** 对外求值入口:递归求值 + 最终结果浮点归一 */
export function evaluateAst(ast, ctx = {}) {
return normalizeResult(evalNode(ast, ctx));
}
/** 便捷:编译 + 求值(单次用;多次求值请先 compileFormula 复用 ast */
export function evaluateFormula(expr, ctx) {
const compiled = compileFormula(expr);
if (!compiled.ok) return null;
return evaluateAst(compiled.ast, ctx);
}
+115
View File
@@ -0,0 +1,115 @@
/**
* 公式变量目录(R-026 / 迭代 22 阶段二):服务端缓存数据集 → 中文变量名 → 取数
*
* 首批三组数据集(老师拍板 Q1):
* - 行情盘口 QuoteHub.quotes5s 内存快照):现价/昨收/今开/最高/最低/成交量/成交额
* - 合约信息 QuoteHub.instruments(按交易日缓存):涨停价/跌停价
* - 持仓账本(strategy-positions 行):份额/成本价/最后成交价
* - 自定义字段(row.values):同为「本行上下文」,随字段定义动态展开
* 计算字段(formula)不进目录 → 零循环依赖。
*/
/** 内置变量声明(分组 → 数据源 → 变量名) */
export const VARIABLE_GROUPS = [
{
group: '行情', source: 'quote',
items: [
{ name: '现价', field: 'lastPrice', desc: '最新成交价(盘口快照)' },
{ name: '昨收', field: 'lastClose', desc: '昨日收盘价' },
{ name: '今开', field: 'open', desc: '今日开盘价' },
{ name: '最高', field: 'high', desc: '今日最高价' },
{ name: '最低', field: 'low', desc: '今日最低价' },
{ name: '成交量', field: 'volume', desc: '今日成交量(股)' },
{ name: '成交额', field: 'amount', desc: '今日成交额(元)' },
],
},
{
group: '合约', source: 'instrument',
items: [
{ name: '涨停价', field: 'upStopPrice', desc: '当日涨停价(合约信息)' },
{ name: '跌停价', field: 'downStopPrice', desc: '当日跌停价(合约信息)' },
],
},
{
group: '持仓', source: 'row',
items: [
{ name: '份额', field: 'shares', desc: '本策略下该标的分配份额' },
{ name: '成本价', field: 'avgPrice', desc: '持仓成本价(QMT' },
{ name: '最后成交价', field: 'lastTradePrice', desc: '该持仓最后一笔实际成交价' },
],
},
];
/** 内置变量名集合(保存校验用:字段展示名不得与之冲突) */
export const BUILTIN_VAR_NAMES = new Set(
VARIABLE_GROUPS.flatMap((g) => g.items.map((it) => it.name)),
);
/** 非 formula 字段(可作为变量引用) */
export function valueFieldDefs(fieldDefs) {
return (Array.isArray(fieldDefs) ? fieldDefs : []).filter((f) => f && f.type && f.type !== 'formula');
}
/**
* 变量目录(前端选择器数据):内置分组 + 「自定义字段」分组(本策略非 formula 字段展示名)
* @param {Array} fieldDefs 本策略字段定义
*/
export function catalogFor(fieldDefs) {
const groups = VARIABLE_GROUPS.map((g) => ({
group: g.group,
items: g.items.map((it) => ({ name: it.name, desc: it.desc })),
}));
const custom = valueFieldDefs(fieldDefs).map((f) => ({
name: String(f.label || f.key),
desc: [f.unit ? '单位 ' + f.unit : '', f.type === 'number' ? '数字字段' : f.type].filter(Boolean).join(' · '),
}));
if (custom.length > 0) groups.push({ group: '自定义字段', items: custom });
return { builtin: [...BUILTIN_VAR_NAMES], groups };
}
/** 变量名合法集合(内置名 ∪ 非 formula 字段展示名) */
export function allowedVarNames(fieldDefs) {
const names = new Set(BUILTIN_VAR_NAMES);
for (const f of valueFieldDefs(fieldDefs)) names.add(String(f.label || f.key));
return names;
}
/** 宽松取数:空值/空串 → null;布尔 → 0/1;数字串 → Number;其余 → null */
function toNum(v) {
if (v === null || v === undefined || v === '') return null;
if (typeof v === 'boolean') return v ? 1 : 0;
const n = typeof v === 'number' ? v : Number(String(v).trim());
return Number.isFinite(n) ? n : null;
}
/**
* 构造某一行持仓的变量上下文
* @param {object} opts
* @param {object} opts.row strategy-positions 行(含 shares/avgPrice/lastTradePrice/values
* @param {object|null} opts.quote 行情快照
* @param {object|null} opts.instrument 合约信息(涨停/跌停)
* @param {Array} opts.fieldDefs 本策略字段定义
* @returns {Object<string, number|null>}
*/
export function buildContext({ row, quote, instrument, fieldDefs } = {}) {
const ctx = {};
for (const g of VARIABLE_GROUPS) {
let source = null;
if (g.source === 'quote') source = quote ?? null;
else if (g.source === 'instrument') source = instrument ?? null;
else source = row ?? null;
for (const it of g.items) {
let v = source ? source[it.field] : null;
// 昨收兜底:盘口快照缺 lastClose 时取合约信息 preClose
if (it.name === '昨收' && (v === null || v === undefined)) v = instrument?.preClose ?? null;
ctx[it.name] = toNum(v);
}
}
for (const f of valueFieldDefs(fieldDefs)) {
const label = String(f.label || f.key);
const has = row && row.values && Object.prototype.hasOwnProperty.call(row.values, f.key);
const raw = has ? row.values[f.key] : (f.def !== undefined ? f.def : null);
ctx[label] = toNum(raw);
}
return ctx;
}
+156 -148
View File
@@ -15,6 +15,12 @@
* - addStrategy 联动追加 tab(末尾,Q2);removeStrategy 联动删除对应 tabD3
* - 策略行 name 由 getTabs join strategies(改名自动跟随,Q4
* - removeStrategy 的份额清理由上层(api.js 编排 storage)完成
*
* R-027(迭代 22 阶段一)策略 tab 静态化:
* - 策略集合与名称固定为内置两项(BUILTIN_STRATEGIES):不可新增/删除/重命名
* - tab 列表由 内置 tab + 内置策略 tab 常量派生;存量 tabs 仅保留 visible/order 偏好
* - 字段定义真相源迁到 strategyFieldsstrategies[] 收窄为身份表(仅兼容读取旧 configSchema
* - 退役 addStrategy/removeStrategy/renameStrategy/updateStrategies/append|removeStrategyTab
*/
import z from '@deepseek-ai/schemastery';
@@ -26,15 +32,31 @@ export const BUILTIN_TABS = [
{ id: 'tab-watchlist', refKey: 'watchlist', name: '关注列表' },
];
/** 默认 tab 列表(首次注册时的初始值;策略行由 DEFAULT_STRATEGIES 派生 */
export const DEFAULT_TABS = BUILTIN_TABS.map((t, i) => ({
id: t.id, kind: 'builtin', refKey: t.refKey, name: t.name, visible: true, order: i,
}));
/** 内置策略(R-027 静态化:集合与名称固定,不可增删改名;字段定义另存 strategyFields */
export const BUILTIN_STRATEGIES = [
{ id: 'grid-supermarket', name: '网格超市' },
{ id: 'manual-t', name: '手动做T' },
];
/** 默认策略(首次注册时的初始值 */
export const DEFAULT_STRATEGIES = [
{ id: 'grid-supermarket', name: '网格超市', configSchema: [] },
{ id: 'manual-t', name: '手动做T', configSchema: [] },
/** 内置策略 id 判定(字段定义写入 / 校验用 */
export function isBuiltinStrategy(strategyId) {
return BUILTIN_STRATEGIES.some((s) => s.id === strategyId);
}
/** 未知策略 id 直接抛错(写路径守卫) */
export function assertBuiltinStrategy(strategyId) {
if (!isBuiltinStrategy(strategyId)) {
throw Object.assign(new Error('未知策略: ' + strategyId), { code: 'strategy-not-found' });
}
}
/** 默认 tab 列表(首次注册时的初始值;R-027:内置 tab + 内置策略 tab */
export const DEFAULT_TABS = [
...BUILTIN_TABS.map((t, i) => ({ id: t.id, kind: 'builtin', refKey: t.refKey, name: t.name, visible: true, order: i })),
...BUILTIN_STRATEGIES.map((s, i) => ({
id: 'tab-strategy-' + s.id, kind: 'strategy', refId: s.id, name: s.name,
visible: true, order: BUILTIN_TABS.length + i,
})),
];
/**
@@ -56,6 +78,28 @@ export const COLUMN_META = [
{ key: 'high', label: '最高', fixed: false, defaultVisible: false },
];
/**
* 策略自定义字段定义 schemaR-013 四类型 + R-026 计算类型)
* - type='formula' 时使用 formula(中文变量表达式串)+ decimals(显示小数位),不使用 def
*/
const fieldSchema = z.object({
key: z.string().required(), // 稳健标识(英文 slug,与字段值 JSON 的 key 对齐)
label: z.string(), // UI 展示名(可中文;计算字段中并作公式变量名)
type: z.union([
z.const('text'),
z.const('number'),
z.const('boolean'),
z.const('enum'),
z.const('formula'), // R-026:计算字段(值由公式实时算出,不落库)
]).required(),
enum: z.array(z.string()).default([]), // 仅 type=enum:预设选项
def: z.any(), // 默认值(text=string / number=number / boolean=boolean / enum=枚举项)
unit: z.string().default(''), // 单位(可为空;如 % / 元 / 手 —— 展示时拼在值后)
formula: z.string().default(''), // 仅 type=formula:表达式串(中文变量)
resultKind: z.union([z.const('number'), z.const('boolean')]).default('number'), // R-028:数字 / 判定
decimals: z.number().default(2), // 仅 type=formula 且 resultKind=number:显示小数位(0-4
});
/** 策略设置 schemaschemastery 定义,settings.register 要求 schema 对象) */
/** @type {import('@deepseek-ai/schemastery').Schema<any>} 策略设置 schema(显式注解:含 z.dict,规避 dts 推断 cosmokit Dict 引用) */
export const strategySchema = z.object({
@@ -65,20 +109,10 @@ export const strategySchema = z.object({
strategies: z.array(z.object({
id: z.string().required(),
name: z.string().required(),
configSchema: z.array(z.object({
key: z.string().required(), // 稳健标识(英文 slug,与字段值 JSON 的 key 对齐)
label: z.string(), // UI 展示名(可中文
type: z.union([
z.const('text'),
z.const('number'),
z.const('boolean'),
z.const('enum'),
]).required(), // 字段类型
enum: z.array(z.string()).default([]), // 仅 type=enum:预设选项
def: z.any(), // 默认值(text=string / number=number / boolean=boolean / enum=枚举项)
unit: z.string().default(''), // 单位(可为空;如 % / 元 / 手 —— 展示时拼在值后)
})).default([]),
})).default(DEFAULT_STRATEGIES),
configSchema: z.array(fieldSchema).default([]),
})).default([]),
// R-027:字段定义真相源(写路径唯一入口;strategies[].configSchema 仅兼容读取旧数据
strategyFields: z.dict(z.array(fieldSchema).default([])).default({}),
// 统一 tab 列表(R-011:唯一顺序与显隐来源;内置 + 策略混排)
// union 兼容存量:旧格式为布尔对象(迭代 02),新格式为有序数组;normalizeTabs 读取时归一化
tabs: z.union([
@@ -237,126 +271,115 @@ export function registerSettings(ctx, config) {
return scope;
}
/** 读取 settings 快照(容错:测试用桩 scope 无 get 时返回空对象) */
function scopeValue(scope) {
return (scope && typeof scope.get === 'function') ? (scope.get() ?? {}) : {};
}
/** 字段类型白名单(含 R-026 计算类型) */
const FIELD_TYPES = new Set(['text', 'number', 'boolean', 'enum', 'formula']);
/**
* 读取策略定义表R-011 收窄 + R-013 扩展:{id, name, configSchema}
* 归一化:旧存量数据缺 configSchemaR-013 前)→ 补空数组(Q4 兼容)。
* 字段定义归一化R-013 + R-026):类型白名单、枚举数组、公式串、小数位夹取 0-4
* @param {Array} fields configSchema 原始数组
* @returns {Array} 归一化后的字段定义数组
*/
export function normalizeFields(fields) {
return (Array.isArray(fields) ? fields : []).map((f) => {
const type = FIELD_TYPES.has(f?.type) ? f.type : 'text';
const out = {
key: String(f?.key ?? ''),
label: String(f?.label ?? ''),
type,
enum: Array.isArray(f?.enum) ? f.enum.map((x) => String(x)) : [],
unit: String(f?.unit ?? ''),
};
if (type === 'formula') {
out.formula = String(f?.formula ?? '').trim();
out.resultKind = f?.resultKind === 'boolean' ? 'boolean' : 'number'; // R-028
const n = Number(f?.decimals);
out.decimals = Number.isFinite(n) ? Math.min(4, Math.max(0, Math.round(n))) : 2;
} else if (f?.def !== undefined) {
out.def = f.def;
}
return out;
});
}
/**
* 读取策略表(R-027 静态化:恒为内置两项;configSchema 取 strategyFields,缺失时兼容读取旧 strategies[]
* @param {ReturnType<typeof registerSettings>} scope
* @returns {Array<{id: string, name: string, configSchema: Array}>}
*/
export function getStrategies(scope) {
const value = scope?.get() ?? {};
const list = Array.isArray(value.strategies) ? value.strategies : DEFAULT_STRATEGIES;
return list.map((s) => ({ ...s, configSchema: Array.isArray(s.configSchema) ? s.configSchema : [] }));
}
/**
* 更新策略(整表更新;R-013configSchema 随整表携带)
* 写入归一化:项缺 configSchema → 补 [](防客户端旧格式整表提交丢定义)。
* @param {ReturnType<typeof registerSettings>} scope
* @param {Array} strategies 完整策略列表
*/
export async function updateStrategies(scope, strategies) {
const next = (Array.isArray(strategies) ? strategies : []).map((s) => ({
id: s.id,
name: s.name,
configSchema: Array.isArray(s.configSchema) ? s.configSchema : [],
const value = scopeValue(scope);
const legacy = new Map((Array.isArray(value.strategies) ? value.strategies : []).map((s) => [s.id, s.configSchema]));
const store = (value.strategyFields && typeof value.strategyFields === 'object') ? value.strategyFields : {};
return BUILTIN_STRATEGIES.map((s) => ({
...s,
configSchema: normalizeFields(
Array.isArray(store[s.id]) ? store[s.id] : (Array.isArray(legacy.get(s.id)) ? legacy.get(s.id) : []),
),
}));
await scope.update({ strategies: next });
}
/** 读取某策略字段定义(未知名 → []) */
export function getStrategyFields(scope, strategyId) {
return getStrategies(scope).find((s) => s.id === strategyId)?.configSchema ?? [];
}
/**
* 新增策略(O3
* 写入某策略字段定义(R-027:单策略写入,唯一写路径;不触碰 strategies[] 与份额数据
* @param {ReturnType<typeof registerSettings>} scope
* @param {string} name 策略名称
* @returns {Promise<object>} 新策略对象
* @param {string} strategyId 内置策略 id
* @param {Array} configSchema 完整字段定义数组
* @returns {Promise<object>} 更新后的策略对象
*/
export async function addStrategy(scope, name) {
const list = getStrategies(scope);
const id = generateStrategyId(name, list.map((s) => s.id));
const strategy = {
id,
name: String(name ?? '').trim(),
configSchema: [], // R-013:新策略默认无自定义字段(定义在设置页配置)
};
await updateStrategies(scope, [...list, strategy]);
// R-011:联动在 tabs 列表末尾追加策略 tab 条目(Q2 追加末尾)
await appendStrategyTab(scope, strategy);
return strategy;
}
/**
* 重命名策略(O3;分配数据挂 id 不挂名称,改名不影响)
* @param {ReturnType<typeof registerSettings>} scope
* @param {string} id 策略 id
* @param {string} name 新名称
* @returns {Promise<boolean>} 是否成功
*/
export async function renameStrategy(scope, id, name) {
const list = getStrategies(scope);
const target = list.find((s) => s.id === id);
if (!target) throw Object.assign(new Error(`策略不存在: ${id}`), { code: 'strategy-not-found' });
const next = list.map((s) => (s.id === id ? { ...s, name: String(name ?? '').trim() || s.name } : s));
await updateStrategies(scope, next);
return true;
export async function updateStrategyFields(scope, strategyId, configSchema) {
assertBuiltinStrategy(strategyId);
const cur = scopeValue(scope);
const store = (cur.strategyFields && typeof cur.strategyFields === 'object') ? cur.strategyFields : {};
await scope.update({ ...cur, strategyFields: { ...store, [strategyId]: normalizeFields(configSchema) } });
return getStrategies(scope).find((s) => s.id === strategyId);
}
/**
* 删除策略(O3;份额清理由上层编排 storage)
* @param {ReturnType<typeof registerSettings>} scope
* @param {string} id 策略 id
* @returns {Promise<boolean>} 是否成功
*/
export async function removeStrategy(scope, id) {
const list = getStrategies(scope);
const target = list.find((s) => s.id === id);
if (!target) throw Object.assign(new Error(`策略不存在: ${id}`), { code: 'strategy-not-found' });
await updateStrategies(scope, list.filter((s) => s.id !== id));
// R-011:联动删除 tabs 列表中对应策略条目(D3)
await removeStrategyTab(scope, id);
return true;
}
/**
* 归一化 tabs 为有序数组(R-011 迁移:旧布尔对象 → 数组;幂等,不重复迁移)
* 旧格式:tabs = { allPositions, tradeRecords, watchlist } + strategies 自带 visible/order
* 新格式:tabs = [{ id, kind, refKey|refId, name, visible, order }]
* 旧隐藏策略迁移后 visible=trueQ3,产品约束-009)。
* 归一化 tabs(R-027 静态化):序列由常量派生(内置 tab + 内置策略 tab),
* 存量 tabs 仅保留 visible/order 偏好;未知条目(自建策略 tab / 已退役 tab)丢弃,缺失条目补齐。
* 旧布尔对象格式(迭代 02)继续兼容:仅取内置显隐。
* @param {ReturnType<typeof registerSettings>} scope
* @returns {Array} 归一化后的 tabs 数组(按 order 排序)
*/
export function normalizeTabs(scope) {
const value = scope?.get() ?? {};
const raw = value.tabs;
// 已归一化(数组)
if (Array.isArray(raw)) return raw.slice().sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
const raw = scopeValue(scope).tabs;
// 旧布尔对象或缺失 → 生成内置条目(保留原显隐值)
const legacy = (typeof raw === 'object' && raw !== null) ? raw : {};
const tabs = BUILTIN_TABS.map((t, i) => ({
id: t.id, kind: 'builtin', refKey: t.refKey, name: t.name,
visible: legacy[t.refKey] !== false, order: i,
}));
// 旧布尔对象(迭代 02):映射到内置 tab 显隐
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
return DEFAULT_TABS.map((t) => (t.kind === 'builtin' && t.refKey in raw
? { ...t, visible: raw[t.refKey] !== false }
: t));
}
if (!Array.isArray(raw)) return DEFAULT_TABS.map((t) => ({ ...t }));
// 策略条目:按旧 order 接续追加;旧隐藏策略迁移后显示(Q3)
const strategies = Array.isArray(value.strategies) ? value.strategies : DEFAULT_STRATEGIES;
const strategyRows = strategies.map((s, i) => ({
id: 'tab-strategy-' + s.id, kind: 'strategy', refId: s.id, name: s.name,
visible: true, order: tabs.length + i,
}));
return [...tabs, ...strategyRows];
const byId = new Map(raw.map((t) => [t.id, t]));
return DEFAULT_TABS
.map((t) => {
const hit = byId.get(t.id);
return hit
? { ...t, visible: hit.visible !== false, order: typeof hit.order === 'number' ? hit.order : t.order }
: { ...t };
})
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
}
/**
* 读取统一 tab 列表(R-011):归一化 + 策略行 name joinQ4 自动跟随改名
* 读取统一 tab 列表(R-011/R-027):归一化结果(策略行 name 恒为内置名,无改名场景
* @param {ReturnType<typeof registerSettings>} scope
* @returns {Array} tabs 数组(按 order 排序,策略行 name 已 join
* @returns {Array} tabs 数组(按 order 排序)
*/
export function getTabs(scope) {
const tabs = normalizeTabs(scope);
const nameMap = new Map(getStrategies(scope).map((s) => [s.id, s.name]));
return tabs.map((t) => (t.kind === 'strategy'
? { ...t, name: nameMap.get(t.refId) ?? t.name ?? t.refId }
: t));
return normalizeTabs(scope);
}
/**
@@ -367,35 +390,21 @@ export function getTabs(scope) {
*/
export async function updateTabs(scope, tabs) {
const current = scope?.get() ?? {};
const normalized = (Array.isArray(tabs) ? tabs : []).map((t, i) => ({
id: t.id,
kind: t.kind === 'strategy' ? 'strategy' : 'builtin',
refKey: t.refKey,
refId: t.refId,
name: t.name,
visible: t.visible !== false,
order: typeof t.order === 'number' ? t.order : i,
}));
const known = new Set(DEFAULT_TABS.map((t) => t.id));
const normalized = (Array.isArray(tabs) ? tabs : [])
.filter((t) => known.has(t?.id)) // R-027:只接受内置条目(策略条目恒存在,不可增删)
.map((t, i) => ({
id: t.id,
kind: t.kind === 'strategy' ? 'strategy' : 'builtin',
refKey: t.refKey,
refId: t.refId,
name: t.name,
visible: t.visible !== false,
order: typeof t.order === 'number' ? t.order : i,
}));
await scope.update({ ...current, tabs: normalized });
}
/** 追加策略 tab 条目到末尾(addStrategy 联动;Q2 追加末尾) */
export async function appendStrategyTab(scope, strategy) {
const tabs = getTabs(scope);
const next = [...tabs, {
id: 'tab-strategy-' + strategy.id, kind: 'strategy', refId: strategy.id,
name: strategy.name, visible: true,
order: tabs.reduce((max, t) => Math.max(max, t.order ?? 0), -1) + 1,
}];
await updateTabs(scope, next);
}
/** 删除策略 tab 条目(removeStrategy 联动;D3 */
export async function removeStrategyTab(scope, strategyId) {
const tabs = getTabs(scope);
await updateTabs(scope, tabs.filter((t) => !(t.kind === 'strategy' && t.refId === strategyId)));
}
/**
* 读取通用 tab 显隐配置(R-011 迁移期兼容:返回布尔对象,供旧调用点使用)
* @deprecated 由 getTabs 取代
@@ -437,15 +446,14 @@ export async function updateTabConfig(scope, tabs) {
* @returns {Array<{key: string, label: string, fixed: boolean, visible: boolean, kind: 'base'|'field'}>}
*/
export function normalizeStrategyColumns(scope, strategyId) {
const value = scope?.get() ?? {};
const strategies = Array.isArray(value.strategies) ? value.strategies : [];
const strategy = strategies.find((s) => s.id === strategyId);
const configSchema = Array.isArray(strategy?.configSchema) ? strategy.configSchema : [];
const value = scopeValue(scope);
// R-027:字段定义真相源 = strategyFields(缺失时兼容读取旧 strategies[].configSchema
const configSchema = getStrategyFields(scope, strategyId);
const raw = (value.strategyColumns && value.strategyColumns[strategyId]) || [];
// 1. 默认全列(基础列默认序 + 字段列 configSchema 序;显隐缺省跟随 defaultVisibleR-015 行情列默认隐藏)
const baseCols = COLUMN_META.map((c) => ({ key: c.key, label: c.label, fixed: false, kind: 'base', defaultVisible: c.defaultVisible !== false }));
const fieldCols = configSchema.map((f) => ({ key: f.key, label: f.label || f.key, fixed: false, kind: 'field' }));
const fieldCols = configSchema.map((f) => ({ key: f.key, label: f.label || f.key, fixed: false, kind: 'field', type: f.type }));
const defaults = [...baseCols, ...fieldCols];
// 2. 应用覆盖:visible + 顺序(未覆盖的列保持默认,追加在默认序后)
@@ -485,7 +493,7 @@ export function getStrategyColumns(scope, strategyId) {
* @param {Array} columns 完整列数组 [{key, visible}](含不可见列——保留位置,visible 控制显示)
*/
export async function updateStrategyColumns(scope, strategyId, columns) {
const current = scope?.get() ?? {};
const current = scopeValue(scope);
const store = current.strategyColumns && typeof current.strategyColumns === 'object' ? current.strategyColumns : {};
const next = { ...store, [strategyId]: (Array.isArray(columns) ? columns : []).map((c) => ({ key: c.key, visible: c.visible !== false })) };
await scope.update({ ...current, strategyColumns: next });